- Auto-assignment: a new order is offered to the best free provider (same ranking as
the dispatcher hint); a decline or timeout passes it to the next one; nobody is
offered the same order twice; after N tries it waits for a dispatcher and the team
is notified once. The worker retries waiting orders every minute. Settings
assign.auto / assign.maxAutoOffers (toggle on the Orders page, checkbox in old admin).
- Orders: list by day with groups (need a provider, offered, assigned, in progress,
done, cancelled), periods and search; order card with progress, provider or ranked
candidates ("offer" / "pick automatically"), details, checklist and photos,
history in plain words, client, money (superadmin), payment, status change,
reschedule (slots or custom time), cancel with fee.
- New order for a client (from the client card or the Orders page): params form,
address, free slots for 14 days, price with the client's discount.
- Providers: list with rating, orders, declines, low ratings, next order; card with
quality, ratings, upcoming orders, schedule and districts, admission documents,
block/unblock with reason, new bot link, money and payouts (superadmin).
- Payout gets paidAt/note/createdById; Provider gets blockedReason/blockedAt/adminNote
and documents (ProviderDoc).
- Tests for auto-assignment.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
653 lines
40 KiB
TypeScript
653 lines
40 KiB
TypeScript
import { afterAll, beforeEach, describe, expect, it } from 'vitest';
|
||
import { encodeStartPayload } from '@barev/core';
|
||
import {
|
||
applyAgencyCode,
|
||
assignmentCandidates,
|
||
availableSlots,
|
||
cancelByClient,
|
||
createOrder,
|
||
dispatcherAssign,
|
||
dueReminders,
|
||
expireOffers,
|
||
giveConsent,
|
||
kanban,
|
||
markPaid,
|
||
offerOrder,
|
||
providerAction,
|
||
quoteForClient,
|
||
respondToOffer,
|
||
orderPayout,
|
||
rescheduleByClient,
|
||
setChecklistItem,
|
||
startClient,
|
||
transitionOrder,
|
||
DomainError,
|
||
createWebRequest,
|
||
linkWebRequest,
|
||
normalizePhone,
|
||
updateWebRequest,
|
||
createLead,
|
||
normalizeContact,
|
||
updateLead,
|
||
updateLeadByAdmin,
|
||
addClientMessage,
|
||
clientRelocation,
|
||
conversation,
|
||
createAppLead,
|
||
createClientRequest,
|
||
getMembership,
|
||
grantSubscription,
|
||
listConversations,
|
||
replyToClient,
|
||
toggleRelocationDoc,
|
||
updateLeadStep,
|
||
addLeadDoc,
|
||
addLeadStep,
|
||
addProviderMessage,
|
||
replyTo,
|
||
saveRelocationItem,
|
||
acceptPartnerInvite,
|
||
cardQrToken,
|
||
checkCard,
|
||
clientPartners,
|
||
createOwnerInvite,
|
||
createStaffInvite,
|
||
partnerCabinet,
|
||
redeemOffer,
|
||
savePartnerOffer,
|
||
verifyCardToken,
|
||
} from '../src/index.ts';
|
||
import { db, fixtures, makeCtx, NOW, slotAt, tgUser } from './helpers.ts';
|
||
|
||
let fx: Awaited<ReturnType<typeof fixtures>>;
|
||
beforeEach(async () => {
|
||
fx = await fixtures();
|
||
});
|
||
afterAll(() => db.$disconnect());
|
||
|
||
async function onboardedClient(ctx: ReturnType<typeof makeCtx>['ctx'], id: number, payload?: string) {
|
||
const start = await startClient(ctx, tgUser(id), payload);
|
||
await giveConsent(ctx, start.clientId);
|
||
return start.clientId;
|
||
}
|
||
|
||
async function newOrder(ctx: ReturnType<typeof makeCtx>['ctx'], clientId: string, when = slotAt(2, 11)) {
|
||
return createOrder(ctx, {
|
||
clientId,
|
||
serviceCode: 'cleaning',
|
||
params: { cleaningType: 'regular', rooms: 2 },
|
||
scheduledAt: when,
|
||
addressText: 'Pushkin 10',
|
||
districtId: fx.kentron.id,
|
||
});
|
||
}
|
||
|
||
const code = (p: Promise<unknown>) => p.then(() => 'OK', (e) => (e instanceof DomainError ? e.code : String(e)));
|
||
|
||
describe('онбординг (ONB-1…6)', () => {
|
||
it('/start с кодом агентства: клиент с источником, язык, подарок после согласия', async () => {
|
||
const { ctx, events } = makeCtx(NOW);
|
||
const start = await startClient(ctx, tgUser(1, 'en'), encodeStartPayload({ locale: 'hy', agency: 'domik' }));
|
||
expect(start).toMatchObject({ isNew: true, needsLocale: false, needsConsent: true, locale: 'hy', agencyName: 'Domik', gift: null });
|
||
const client = await db.client.findUniqueOrThrow({ where: { id: start.clientId }, include: { user: true } });
|
||
expect(client.agencyId).toBe(fx.agency.id);
|
||
expect(client.source).toBe('deeplink');
|
||
expect(client.user.locale).toBe('hy');
|
||
|
||
const gift = await giveConsent(ctx, start.clientId);
|
||
expect(gift?.endsAt.getTime()).toBe(NOW.getTime() + 30 * 24 * 3600_000);
|
||
expect(events).toContainEqual(expect.objectContaining({ type: 'gift.activated' }));
|
||
expect(await db.subscription.count({ where: { clientId: start.clientId, source: 'AGENCY_GIFT' } })).toBe(1);
|
||
});
|
||
|
||
it('подарок — один раз; повторный /start с другим агентством не перезаписывает источник', async () => {
|
||
const { ctx } = makeCtx(NOW);
|
||
const id = await onboardedClient(ctx, 2, encodeStartPayload({ agency: 'DOMIK' }));
|
||
await db.agency.create({ data: { code: 'OTHER', name: 'Other' } });
|
||
const again = await startClient(ctx, tgUser(2), encodeStartPayload({ agency: 'OTHER' }));
|
||
expect(again).toMatchObject({ isNew: false, agencyName: 'Domik', gift: null });
|
||
expect(await db.subscription.count({ where: { clientId: id } })).toBe(1);
|
||
});
|
||
|
||
it('без языка в payload и у нового клиента — спрашиваем язык', async () => {
|
||
const { ctx } = makeCtx(NOW);
|
||
expect((await startClient(ctx, tgUser(3, 'de'))).needsLocale).toBe(true);
|
||
});
|
||
|
||
it('UTM через короткий токен', async () => {
|
||
const { ctx } = makeCtx(NOW);
|
||
await db.startToken.create({ data: { id: 'tok1', locale: 'es', agency: 'DOMIK', utm: { utm_source: 'qr' } } });
|
||
const s = await startClient(ctx, tgUser(4), encodeStartPayload({ token: 'tok1' }));
|
||
expect(s.locale).toBe('es');
|
||
const c = await db.client.findUniqueOrThrow({ where: { id: s.clientId } });
|
||
expect(c.utm).toEqual({ utm_source: 'qr' });
|
||
expect(c.agencyId).toBe(fx.agency.id);
|
||
});
|
||
|
||
it('ручной ввод кода (ONB-6): неверный/неактивный — ошибка, верный — подарок', async () => {
|
||
const { ctx } = makeCtx(NOW);
|
||
const id = await onboardedClient(ctx, 5);
|
||
expect(await code(applyAgencyCode(ctx, id, 'nope'))).toBe('AGENCY_CODE_INVALID');
|
||
expect(await code(applyAgencyCode(ctx, id, 'closed'))).toBe('AGENCY_CODE_INVALID');
|
||
const r = await applyAgencyCode(ctx, id, ' domik ');
|
||
expect(r.gift).not.toBeNull();
|
||
expect((await db.client.findUniqueOrThrow({ where: { id } })).source).toBe('manual_code');
|
||
});
|
||
});
|
||
|
||
describe('заказ (ORD-3…6)', () => {
|
||
it('цена со скидкой Barev+ только у подписчика', async () => {
|
||
const { ctx } = makeCtx(NOW);
|
||
const plus = await onboardedClient(ctx, 10, encodeStartPayload({ agency: 'DOMIK' }));
|
||
const plain = await onboardedClient(ctx, 11);
|
||
const p = { cleaningType: 'regular', rooms: 2 };
|
||
expect((await quoteForClient(ctx, plus, 'cleaning', p)).quote).toMatchObject({ basePrice: 19000, servicePrice: 19000, extras: [], discountPct: 10, discountAmount: 1900, totalPrice: 17100, durationMinutes: 165 });
|
||
expect((await quoteForClient(ctx, plain, 'cleaning', p)).quote.totalPrice).toBe(19000);
|
||
});
|
||
|
||
it('без согласия заказ не создаётся; без адреса — тоже', async () => {
|
||
const { ctx } = makeCtx(NOW);
|
||
const s = await startClient(ctx, tgUser(12));
|
||
expect(await code(newOrder(ctx, s.clientId))).toBe('CONSENT_REQUIRED');
|
||
await giveConsent(ctx, s.clientId);
|
||
expect(
|
||
await code(createOrder(ctx, { clientId: s.clientId, serviceCode: 'cleaning', params: { cleaningType: 'regular', rooms: 1 }, scheduledAt: slotAt(2, 11) })),
|
||
).toBe('ADDRESS_REQUIRED');
|
||
});
|
||
|
||
it('создаёт «Новый» с чек-листом и историей, шлёт событие', async () => {
|
||
const { ctx, events } = makeCtx(NOW);
|
||
const id = await onboardedClient(ctx, 13);
|
||
const order = await newOrder(ctx, id);
|
||
expect(order.status).toBe('NEW');
|
||
expect(await db.orderChecklistItem.count({ where: { orderId: order.id } })).toBe(5);
|
||
expect(await db.orderStatusHistory.count({ where: { orderId: order.id } })).toBe(1);
|
||
expect(events).toContainEqual({ type: 'order.created', orderId: order.id });
|
||
const cols = await kanban(ctx, {});
|
||
expect(cols.new.map((c) => c.id)).toContain(order.id);
|
||
});
|
||
|
||
it('слот не продаётся больше раз, чем есть свободных исполнителей', async () => {
|
||
const { ctx } = makeCtx(NOW);
|
||
const when = slotAt(2, 11);
|
||
// 3 клинера (Maria, Ani, Lusine) → 3 заказа, четвёртый — отказ
|
||
for (let i = 0; i < 3; i++) await newOrder(ctx, await onboardedClient(ctx, 20 + i), when);
|
||
expect(await code(newOrder(ctx, await onboardedClient(ctx, 30), when))).toBe('SLOT_UNAVAILABLE');
|
||
const slots = await availableSlots(ctx, { serviceCode: 'cleaning', params: { cleaningType: 'regular', rooms: 2 }, dateKey: '2026-10-21' });
|
||
expect(slots.some((s) => s.start.getTime() === when.getTime())).toBe(false);
|
||
});
|
||
|
||
it('время вне окна или в прошлом — отказ', async () => {
|
||
const { ctx } = makeCtx(NOW);
|
||
const id = await onboardedClient(ctx, 31);
|
||
expect(await code(newOrder(ctx, id, slotAt(0, 9)))).toBe('SLOT_UNAVAILABLE');
|
||
expect(await code(newOrder(ctx, id, slotAt(2, 19)))).toBe('SLOT_UNAVAILABLE');
|
||
});
|
||
|
||
it('отмена: бесплатно за 24 ч, позже — удержание 30%, после начала — только оператор', async () => {
|
||
const { ctx, clock } = makeCtx(NOW);
|
||
const id = await onboardedClient(ctx, 32);
|
||
const free = await newOrder(ctx, id, slotAt(2, 11));
|
||
expect((await cancelByClient(ctx, { orderId: free.id, clientId: id })).fee).toBe(0);
|
||
|
||
const late = await newOrder(ctx, id, slotAt(1, 9));
|
||
clock.now = new Date(late.scheduledAt.getTime() - 2 * 3600_000);
|
||
expect((await cancelByClient(ctx, { orderId: late.id, clientId: id })).fee).toBe(Math.floor(late.totalPrice * 0.3));
|
||
|
||
clock.now = NOW;
|
||
const started = await newOrder(ctx, id, slotAt(1, 12));
|
||
clock.now = new Date(started.scheduledAt.getTime() + 60_000);
|
||
expect(await code(cancelByClient(ctx, { orderId: started.id, clientId: id }))).toBe('CANCEL_VIA_OPERATOR');
|
||
});
|
||
|
||
it('чужой заказ отменить нельзя', async () => {
|
||
const { ctx } = makeCtx(NOW);
|
||
const a = await onboardedClient(ctx, 33);
|
||
const b = await onboardedClient(ctx, 34);
|
||
const o = await newOrder(ctx, a);
|
||
expect(await code(cancelByClient(ctx, { orderId: o.id, clientId: b }))).toBe('NOT_FOUND');
|
||
});
|
||
});
|
||
|
||
describe('назначение (ADM-3, PRO-5, PRO-7)', () => {
|
||
it('подсказка учитывает язык клиента', async () => {
|
||
const { ctx } = makeCtx(NOW);
|
||
const s = await startClient(ctx, tgUser(40), encodeStartPayload({ locale: 'hy' }));
|
||
await giveConsent(ctx, s.clientId);
|
||
const o = await newOrder(ctx, s.clientId);
|
||
const { ranked } = await assignmentCandidates(ctx, o.id);
|
||
expect(ranked[0]).toMatchObject({ kind: 'provider', name: 'Ani' });
|
||
expect(ranked.map((r) => r.name)).toContain('Chisto');
|
||
});
|
||
|
||
it('предложение → принятие → «Назначен», клиент получает событие', async () => {
|
||
const { ctx, events } = makeCtx(NOW);
|
||
const o = await newOrder(ctx, await onboardedClient(ctx, 41));
|
||
const offer = await offerOrder(ctx, { orderId: o.id, adminId: 'admin', target: { kind: 'provider', id: fx.maria.id } });
|
||
expect((await kanban(ctx, {})).assigned.find((c) => c.id === o.id)?.pending).toEqual({ kind: 'provider', name: 'Maria' });
|
||
expect(await code(respondToOffer(ctx, { offerId: offer.id, providerId: fx.ani.id, accept: true }))).toBe('OFFER_NOT_ACTIVE');
|
||
await respondToOffer(ctx, { offerId: offer.id, providerId: fx.maria.id, accept: true });
|
||
const updated = await db.order.findUniqueOrThrow({ where: { id: o.id } });
|
||
expect(updated).toMatchObject({ status: 'ASSIGNED', providerId: fx.maria.id });
|
||
expect(events).toContainEqual({ type: 'order.status', orderId: o.id, from: 'NEW', to: 'ASSIGNED' });
|
||
});
|
||
|
||
it('отказ и таймаут возвращают заказ в «Новые»', async () => {
|
||
const { ctx, clock } = makeCtx(NOW);
|
||
const o = await newOrder(ctx, await onboardedClient(ctx, 42));
|
||
const f1 = await offerOrder(ctx, { orderId: o.id, adminId: 'admin', target: { kind: 'provider', id: fx.maria.id } });
|
||
await respondToOffer(ctx, { offerId: f1.id, providerId: fx.maria.id, accept: false });
|
||
expect((await kanban(ctx, {})).new.map((c) => c.id)).toContain(o.id);
|
||
|
||
await offerOrder(ctx, { orderId: o.id, adminId: 'admin', target: { kind: 'provider', id: fx.ani.id } });
|
||
clock.now = new Date(NOW.getTime() + 16 * 60_000);
|
||
expect(await expireOffers(ctx)).toBe(1);
|
||
expect((await kanban(ctx, {})).new.map((c) => c.id)).toContain(o.id);
|
||
expect((await db.order.findUniqueOrThrow({ where: { id: o.id } })).status).toBe('NEW');
|
||
});
|
||
|
||
it('компания: «Назначен на компанию» → диспетчер выбирает сотрудника', async () => {
|
||
const { ctx } = makeCtx(NOW);
|
||
const o = await newOrder(ctx, await onboardedClient(ctx, 43));
|
||
await offerOrder(ctx, { orderId: o.id, adminId: 'admin', target: { kind: 'company', id: fx.company.id } });
|
||
expect((await kanban(ctx, {})).assigned.find((c) => c.id === o.id)?.pending).toEqual({ kind: 'company', name: 'Chisto' });
|
||
// чужой сотрудник и не-диспетчер — отказ
|
||
expect(await code(dispatcherAssign(ctx, { orderId: o.id, companyId: fx.company.id, providerId: fx.maria.id, dispatcherUserId: fx.dispatcher.id }))).toBe('NOT_FOUND');
|
||
expect(await code(dispatcherAssign(ctx, { orderId: o.id, companyId: fx.company.id, providerId: fx.lusine.id, dispatcherUserId: 'x' }))).toBe('FORBIDDEN');
|
||
await dispatcherAssign(ctx, { orderId: o.id, companyId: fx.company.id, providerId: fx.lusine.id, dispatcherUserId: fx.dispatcher.id });
|
||
expect(await db.order.findUniqueOrThrow({ where: { id: o.id } })).toMatchObject({ status: 'ASSIGNED', providerId: fx.lusine.id, companyId: fx.company.id });
|
||
const hist = await db.orderStatusHistory.findFirst({ where: { orderId: o.id, toStatus: 'ASSIGNED' } });
|
||
expect(hist?.actorType).toBe('DISPATCHER');
|
||
});
|
||
|
||
it('занятому исполнителю предложить нельзя', async () => {
|
||
const { ctx } = makeCtx(NOW);
|
||
const when = slotAt(2, 11);
|
||
const a = await newOrder(ctx, await onboardedClient(ctx, 44), when);
|
||
const f = await offerOrder(ctx, { orderId: a.id, adminId: 'admin', target: { kind: 'provider', id: fx.maria.id } });
|
||
await respondToOffer(ctx, { offerId: f.id, providerId: fx.maria.id, accept: true });
|
||
const b = await newOrder(ctx, await onboardedClient(ctx, 45), when);
|
||
expect(await code(offerOrder(ctx, { orderId: b.id, adminId: 'admin', target: { kind: 'provider', id: fx.maria.id } }))).toBe('PROVIDER_BUSY');
|
||
});
|
||
});
|
||
|
||
describe('исполнитель ведёт заказ (PRO-3, PRO-4)', () => {
|
||
async function assigned(ctx: ReturnType<typeof makeCtx>['ctx'], tg: number) {
|
||
const o = await newOrder(ctx, await onboardedClient(ctx, tg));
|
||
const f = await offerOrder(ctx, { orderId: o.id, adminId: 'admin', target: { kind: 'provider', id: fx.maria.id } });
|
||
await respondToOffer(ctx, { offerId: f.id, providerId: fx.maria.id, accept: true });
|
||
return o;
|
||
}
|
||
|
||
it('шаги клинера; закрыть без чек-листа и фото нельзя', async () => {
|
||
const { ctx, events } = makeCtx(NOW);
|
||
const o = await assigned(ctx, 50);
|
||
expect(await code(providerAction(ctx, { orderId: o.id, providerId: fx.maria.id, action: 'startCleaning' }))).toBe('TRANSITION_NOT_ALLOWED');
|
||
await providerAction(ctx, { orderId: o.id, providerId: fx.maria.id, action: 'depart' });
|
||
await providerAction(ctx, { orderId: o.id, providerId: fx.maria.id, action: 'arrived' });
|
||
expect(events).toContainEqual({ type: 'order.event', orderId: o.id, code: 'arrived' });
|
||
// чек-лист до начала уборки не отмечается
|
||
expect(await code(setChecklistItem(ctx, { orderId: o.id, providerId: fx.maria.id, code: 'kitchen', checked: true }))).toBe('TRANSITION_NOT_ALLOWED');
|
||
await providerAction(ctx, { orderId: o.id, providerId: fx.maria.id, action: 'startCleaning' });
|
||
|
||
for (const c of ['kitchen', 'bathroom', 'rooms', 'floors']) await setChecklistItem(ctx, { orderId: o.id, providerId: fx.maria.id, code: c, checked: true });
|
||
expect(await code(providerAction(ctx, { orderId: o.id, providerId: fx.maria.id, action: 'finishCleaning' }))).toBe('CHECKLIST_INCOMPLETE');
|
||
expect(await code(setChecklistItem(ctx, { orderId: o.id, providerId: fx.maria.id, code: 'photoReport', checked: true }))).toBe('PHOTO_REQUIRED');
|
||
|
||
const file = await db.orderFile.create({ data: { orderId: o.id, kind: 'PHOTO_REPORT', storageKey: `t/${o.id}.jpg`, mime: 'image/jpeg', size: 1, uploadedBy: 'PROVIDER' } });
|
||
await setChecklistItem(ctx, { orderId: o.id, providerId: fx.maria.id, code: 'photoReport', checked: true, fileId: file.id });
|
||
await providerAction(ctx, { orderId: o.id, providerId: fx.maria.id, action: 'finishCleaning' });
|
||
|
||
const done = await db.order.findUniqueOrThrow({ where: { id: o.id } });
|
||
expect(done.status).toBe('DONE');
|
||
expect(done.completedAt).not.toBeNull();
|
||
const hist = await db.orderStatusHistory.findMany({ where: { orderId: o.id }, orderBy: { createdAt: 'asc' } });
|
||
expect(hist.map((h) => h.event ?? h.toStatus)).toEqual(['NEW', 'offered', 'accepted', 'depart', 'arrived', 'startCleaning', 'finishCleaning']);
|
||
});
|
||
|
||
it('чужой исполнитель не может двигать заказ', async () => {
|
||
const { ctx } = makeCtx(NOW);
|
||
const o = await assigned(ctx, 51);
|
||
expect(await code(providerAction(ctx, { orderId: o.id, providerId: fx.ani.id, action: 'depart' }))).toBe('NOT_FOUND');
|
||
});
|
||
|
||
it('бэкенд отклоняет недопустимый переход напрямую', async () => {
|
||
const { ctx } = makeCtx(NOW);
|
||
const o = await assigned(ctx, 52);
|
||
expect(await code(transitionOrder(ctx, { orderId: o.id, to: 'DONE', actor: 'ADMIN' }))).toBe('TRANSITION_NOT_ALLOWED');
|
||
expect(await code(transitionOrder(ctx, { orderId: o.id, to: 'DRIVER_WAITING', actor: 'ADMIN' }))).toBe('TRANSITION_NOT_ALLOWED');
|
||
});
|
||
|
||
it('перенос: если исполнитель занят в новое время — заказ возвращается в «Новые»', async () => {
|
||
const { ctx, events } = makeCtx(NOW);
|
||
const o = await assigned(ctx, 53);
|
||
// Мария уже занята в 15:00 другим заказом
|
||
const other = await newOrder(ctx, await onboardedClient(ctx, 54), slotAt(2, 15));
|
||
const f = await offerOrder(ctx, { orderId: other.id, adminId: 'admin', target: { kind: 'provider', id: fx.maria.id } });
|
||
await respondToOffer(ctx, { offerId: f.id, providerId: fx.maria.id, accept: true });
|
||
const client = (await db.order.findUniqueOrThrow({ where: { id: o.id } })).clientId;
|
||
await rescheduleByClient(ctx, { orderId: o.id, clientId: client, scheduledAt: slotAt(2, 15) });
|
||
expect(await db.order.findUniqueOrThrow({ where: { id: o.id } })).toMatchObject({ status: 'NEW', providerId: null });
|
||
expect(events).toContainEqual(expect.objectContaining({ type: 'order.unassigned', orderId: o.id }));
|
||
});
|
||
});
|
||
|
||
describe('оплата и напоминания', () => {
|
||
it('оплата вручную: начисление агентству только с оплаченных и в срок закрепления', async () => {
|
||
const { ctx } = makeCtx(NOW);
|
||
const agencyClient = await onboardedClient(ctx, 60, encodeStartPayload({ agency: 'DOMIK' }));
|
||
const o = await newOrder(ctx, agencyClient);
|
||
expect(await db.agencyCommission.count()).toBe(0);
|
||
await markPaid(ctx, { orderId: o.id, by: { type: 'ADMIN', id: 'admin' } });
|
||
const comm = await db.agencyCommission.findUniqueOrThrow({ where: { orderId: o.id } });
|
||
expect(comm).toMatchObject({ pct: 10, amount: Math.floor(o.totalPrice * 0.1), period: '2026-10' });
|
||
expect(await code(markPaid(ctx, { orderId: o.id, by: { type: 'ADMIN', id: 'admin' } }))).toBe('NOT_PAID_STATE');
|
||
|
||
const organic = await onboardedClient(ctx, 61);
|
||
const o2 = await newOrder(ctx, organic, slotAt(2, 13));
|
||
await markPaid(ctx, { orderId: o2.id, by: { type: 'ADMIN', id: 'admin' } });
|
||
expect(await db.agencyCommission.count()).toBe(1);
|
||
});
|
||
|
||
it('напоминания за 24 ч и 1 ч отправляются по одному разу', async () => {
|
||
const { ctx, events, clock } = makeCtx(NOW);
|
||
const o = await newOrder(ctx, await onboardedClient(ctx, 62), slotAt(2, 11));
|
||
clock.now = new Date(o.scheduledAt.getTime() - 23 * 3600_000);
|
||
expect(await dueReminders(ctx)).toBe(1);
|
||
expect(await dueReminders(ctx)).toBe(0);
|
||
clock.now = new Date(o.scheduledAt.getTime() - 50 * 60_000);
|
||
expect(await dueReminders(ctx)).toBe(1);
|
||
expect(events.filter((e) => e.type === 'order.reminder').map((e) => (e as { hours: number }).hours)).toEqual([24, 1]);
|
||
});
|
||
});
|
||
|
||
describe('выплаты клинеру по пресету', () => {
|
||
it('заказ хранит разбивку цены; заработок считается по строкам пресета', async () => {
|
||
const { ctx } = makeCtx(NOW);
|
||
const clientId = await onboardedClient(ctx, 70);
|
||
const order = await createOrder(ctx, {
|
||
clientId,
|
||
serviceCode: 'cleaning',
|
||
params: { cleaningType: 'regular', rooms: 2, bathrooms: 2, extras: { windows: 2 } },
|
||
scheduledAt: slotAt(2, 11),
|
||
addressText: 'Pushkin 10',
|
||
districtId: fx.kentron.id,
|
||
});
|
||
expect(order.priceBreakdown).toMatchObject({ variant: 'regular', base: 15000, rooms: { count: 1, amount: 4000 }, bathrooms: { count: 1, amount: 3000 } });
|
||
const preset = await db.payoutPreset.create({
|
||
data: {
|
||
name: 'Мидл',
|
||
role: 'CLEANER',
|
||
rules: { base: { regular: { type: 'PERCENT', value: 60 } }, room: { type: 'FIXED', value: 2500 }, bathroom: { type: 'FIXED', value: 1500 }, extras: { windows: { type: 'FIXED', value: 1000 } } },
|
||
},
|
||
});
|
||
await db.provider.update({ where: { id: fx.maria.id }, data: { payoutPresetId: preset.id } });
|
||
const maria = await db.provider.findUniqueOrThrow({ where: { id: fx.maria.id }, include: { company: true, payoutPreset: true } });
|
||
const full = await db.order.findUniqueOrThrow({ where: { id: order.id }, include: { service: true } });
|
||
// 60% от 15000 + 2500 за комнату + 1500 за санузел + 2 окна × 1000
|
||
expect(orderPayout(full, maria)).toBe(9000 + 2500 + 1500 + 2000);
|
||
// без пресета — индивидуальная ставка 70% от итога
|
||
expect(orderPayout(full, { ...maria, payoutPreset: null })).toBe(Math.floor(full.totalPrice * 0.7));
|
||
// сотрудник компании — ставка компании
|
||
const lusine = await db.provider.findUniqueOrThrow({ where: { id: fx.lusine.id }, include: { company: true, payoutPreset: true } });
|
||
expect(orderPayout(full, lusine)).toBe(Math.floor(full.totalPrice * 0.65));
|
||
});
|
||
});
|
||
|
||
describe('заявка с сайта (/clean)', () => {
|
||
const input = (when = slotAt(2, 11)) => ({
|
||
serviceCode: 'cleaning',
|
||
params: { cleaningType: 'regular', rooms: 2, bathrooms: 1, extras: { windows: 2 } },
|
||
preferredAt: when,
|
||
districtCode: 'kentron',
|
||
addressText: 'Pushkin 10',
|
||
phone: '+374 (91) 12-34-56',
|
||
name: 'Anna',
|
||
locale: 'en' as const,
|
||
agency: 'DOMIK',
|
||
});
|
||
|
||
it('цена считается на сервере, телефон нормализуется, токен ведёт в бота с языком и агентством', async () => {
|
||
const { ctx } = makeCtx(NOW);
|
||
const r = await createWebRequest(ctx, input());
|
||
expect(r).toMatchObject({ status: 'NEW', phone: '+37491123456', totalPrice: 15000 + 4000 + 2 * 3500, locale: 'en' });
|
||
const token = await db.startToken.findUniqueOrThrow({ where: { id: r.token } });
|
||
expect(token).toMatchObject({ locale: 'en', agency: 'DOMIK' });
|
||
});
|
||
|
||
it('время вне окна, в прошлом или кривой телефон — отказ', async () => {
|
||
const { ctx } = makeCtx(NOW);
|
||
expect(await code(createWebRequest(ctx, input(slotAt(2, 21))))).toBe('SLOT_UNAVAILABLE');
|
||
expect(await code(createWebRequest(ctx, input(slotAt(0, 11))))).toBe('SLOT_UNAVAILABLE');
|
||
expect(await code(createWebRequest(ctx, { ...input(), phone: '12-34' }))).toBe('PHONE_INVALID');
|
||
expect(await code(createWebRequest(ctx, { ...input(), districtCode: 'mars' }))).toBe('ADDRESS_REQUIRED');
|
||
expect(normalizePhone('8 (999) 123-45-67')).toBe('+89991234567');
|
||
});
|
||
|
||
it('открыл бота: согласие с сайта, агентство, заказ на желаемое время; повторный /start ничего не дублирует', async () => {
|
||
const { ctx, events } = makeCtx(NOW);
|
||
const r = await createWebRequest(ctx, input());
|
||
const start = await startClient(ctx, tgUser(77), encodeStartPayload({ token: r.token }));
|
||
expect(start.locale).toBe('en');
|
||
const link = await linkWebRequest(ctx, r.token, start.clientId);
|
||
expect(link).toMatchObject({ kind: 'ordered', requestNumber: r.number, scheduledAt: slotAt(2, 11) });
|
||
const client = await db.client.findUniqueOrThrow({ where: { id: start.clientId }, include: { user: true } });
|
||
expect(client.consentAt).not.toBeNull();
|
||
expect(client.agencyId).toBe(fx.agency.id);
|
||
expect(client.user.phone).toBe('+37491123456');
|
||
const saved = await db.webRequest.findUniqueOrThrow({ where: { id: r.id }, include: { order: true } });
|
||
expect(saved.status).toBe('ORDERED');
|
||
// подарок агентства (Barev+) активировался вместе с согласием — в заказе уже скидка 10% на «Дом»
|
||
expect(link.kind === 'ordered' && link.gift).toBeTruthy();
|
||
expect(saved.order).toMatchObject({ status: 'NEW', basePrice: 26000, totalPrice: 23400, districtId: fx.kentron.id });
|
||
expect(events.filter((e) => e.type === 'order.created')).toHaveLength(1);
|
||
|
||
expect(await linkWebRequest(ctx, r.token, start.clientId)).toMatchObject({ kind: 'ordered', orderNumber: saved.order!.number });
|
||
expect(await db.order.count()).toBe(1);
|
||
// чужой Telegram по той же ссылке заявку не забирает
|
||
const other = await startClient(ctx, tgUser(78), encodeStartPayload({ token: r.token }));
|
||
expect(await linkWebRequest(ctx, r.token, other.clientId)).toEqual({ kind: 'none' });
|
||
});
|
||
|
||
it('нет свободного исполнителя — заявка остаётся оператору; отмену и возврат делает админ', async () => {
|
||
const { ctx } = makeCtx(NOW);
|
||
await db.provider.updateMany({ data: { active: false } });
|
||
const r = await createWebRequest(ctx, input());
|
||
const start = await startClient(ctx, tgUser(79));
|
||
expect(await linkWebRequest(ctx, r.token, start.clientId)).toMatchObject({ kind: 'linked', requestNumber: r.number });
|
||
expect(await db.order.count()).toBe(0);
|
||
expect((await db.webRequest.findUniqueOrThrow({ where: { id: r.id } })).status).toBe('LINKED');
|
||
|
||
await updateWebRequest(ctx, { id: r.id, status: 'CANCELLED', adminNote: 'перезвонить в пятницу' });
|
||
expect(await db.webRequest.findUniqueOrThrow({ where: { id: r.id } })).toMatchObject({ status: 'CANCELLED', adminNote: 'перезвонить в пятницу' });
|
||
await updateWebRequest(ctx, { id: r.id, status: 'REOPEN' });
|
||
expect((await db.webRequest.findUniqueOrThrow({ where: { id: r.id } })).status).toBe('LINKED');
|
||
});
|
||
});
|
||
|
||
describe('квиз на главной (заявка на переезд)', () => {
|
||
it('контакт: телефон или Telegram, мусор — отказ', async () => {
|
||
expect(normalizeContact('+374 91 12-34-56')).toEqual({ contact: '+37491123456', contactType: 'PHONE' });
|
||
expect(normalizeContact('@anna_k')).toEqual({ contact: '@anna_k', contactType: 'TELEGRAM' });
|
||
expect(normalizeContact('https://t.me/anna_k')).toEqual({ contact: '@anna_k', contactType: 'TELEGRAM' });
|
||
expect(normalizeContact('anna')).toBeNull();
|
||
const { ctx } = makeCtx(NOW);
|
||
expect(await code(createLead(ctx, { name: 'Anna', contact: '12', locale: 'ru' }))).toBe('CONTACT_INVALID');
|
||
expect(await code(createLead(ctx, { name: ' ', contact: '@anna_k', locale: 'ru' }))).toBe('NAME_REQUIRED');
|
||
});
|
||
|
||
it('шаг 1 сохраняется сразу, ответы дописываются по ключу, после отправки заявка не меняется', async () => {
|
||
const { ctx } = makeCtx(NOW);
|
||
const lead = await createLead(ctx, { name: 'Anna', contact: '@anna_k', locale: 'en', utm: { utm_source: 'ig' } });
|
||
expect(lead).toMatchObject({ status: 'NEW', submittedAt: null, locale: 'en', consentAt: NOW });
|
||
expect(await code(updateLead(ctx, { id: lead.id, key: 'wrong', moved: false }))).toBe('NOT_FOUND');
|
||
|
||
await updateLead(ctx, { id: lead.id, key: lead.key, moved: false, moveWhen: 'date', moveDate: slotAt(30, 0) });
|
||
await updateLead(ctx, { id: lead.id, key: lead.key, package: 'CUSTOM', options: { airport: 'premium', stay: 'hotel', stayBudgetFrom: 20000, stayBudgetTo: 40000, residence: true } });
|
||
const sent = await updateLead(ctx, { id: lead.id, key: lead.key, submit: true });
|
||
expect(sent).toMatchObject({ moved: false, moveWhen: 'date', moveDate: slotAt(30, 0), package: 'CUSTOM', submittedAt: NOW });
|
||
expect(sent.options).toMatchObject({ airport: 'premium', stayBudgetTo: 40000 });
|
||
|
||
await updateLead(ctx, { id: lead.id, key: lead.key, package: 'CONSULT' });
|
||
expect((await db.lead.findUniqueOrThrow({ where: { id: lead.id } })).package).toBe('CUSTOM');
|
||
expect(await updateLeadByAdmin(ctx, { id: lead.id, status: 'IN_WORK', adminNote: ' позвонить ' })).toMatchObject({ status: 'IN_WORK', adminNote: 'позвонить' });
|
||
});
|
||
|
||
it('«уже живу в Армении» — квиз закончен, заявка отправлена', async () => {
|
||
const { ctx } = makeCtx(NOW);
|
||
const lead = await createLead(ctx, { name: 'Ivan', contact: '+79991234567', locale: 'ru' });
|
||
expect(await updateLead(ctx, { id: lead.id, key: lead.key, moved: true })).toMatchObject({ moved: true, submittedAt: NOW });
|
||
});
|
||
});
|
||
|
||
describe('приложение: переезд, Elite, заявки оператору, консьерж', () => {
|
||
it('заявка из приложения строит план по шагам; оператор двигает шаг — клиенту событие', async () => {
|
||
const { ctx, events } = makeCtx(NOW);
|
||
const clientId = await onboardedClient(ctx, 501);
|
||
const lead = await createAppLead(ctx, clientId, { moveWhen: 'month', package: 'CUSTOM', options: { airport: 'standard', stay: 'none', flat: true, residence: true } });
|
||
expect(lead.steps.map((s) => s.code)).toEqual(['airport', 'flat', 'residence']);
|
||
expect(lead).toMatchObject({ clientId, submittedAt: NOW, moved: false });
|
||
expect(await code(createAppLead(ctx, clientId, { moveWhen: 'month', package: 'CUSTOM', options: { airport: 'none' } }))).toBe('INVALID_PARAMS');
|
||
|
||
await updateLeadStep(ctx, { stepId: lead.steps[0]!.id, status: 'DONE', note: 'Водитель Арам, +374 91 000000' });
|
||
expect(events).toContainEqual({ type: 'lead.step', stepId: lead.steps[0]!.id });
|
||
expect((await db.lead.findUniqueOrThrow({ where: { id: lead.id } })).status).toBe('IN_WORK');
|
||
|
||
const plan = (await clientRelocation(ctx, clientId))!;
|
||
expect(plan.docs.map((d) => d.code)).toEqual(['passport', 'diploma', 'criminalRecord']);
|
||
await toggleRelocationDoc(ctx, clientId, plan.docs[1]!.id, true);
|
||
expect((await clientRelocation(ctx, clientId))!.docs.filter((d) => d.checked).map((d) => d.code)).toEqual(['diploma']);
|
||
const other = await onboardedClient(ctx, 599);
|
||
expect(await code(toggleRelocationDoc(ctx, other, plan.docs[0]!.id, true))).toBe('NOT_FOUND');
|
||
});
|
||
|
||
it('заявка с сайта находится по нику Telegram и получает план «под ключ»', async () => {
|
||
const { ctx } = makeCtx(NOW);
|
||
const site = await createLead(ctx, { name: 'Ann', contact: '@Ann_Moving', locale: 'en' });
|
||
await updateLead(ctx, { id: site.id, key: site.key, moved: false });
|
||
await updateLead(ctx, { id: site.id, key: site.key, package: 'TURNKEY', submit: true });
|
||
const start = await startClient(ctx, { ...tgUser(502), username: 'ann_moving' });
|
||
const plan = await clientRelocation(ctx, start.clientId);
|
||
expect(plan?.id).toBe(site.id);
|
||
expect(plan?.steps).toHaveLength(7);
|
||
});
|
||
|
||
it('Elite: своя скидка в заказе, заявка на подписку не дублируется, оператор включает', async () => {
|
||
const { ctx, events } = makeCtx(NOW);
|
||
await db.category.updateMany({ where: { code: 'HOME' }, data: { eliteDiscountPct: 35 } });
|
||
const clientId = await onboardedClient(ctx, 503);
|
||
const r1 = await createClientRequest(ctx, clientId, { kind: 'SUBSCRIPTION', plan: 'ELITE' });
|
||
const r2 = await createClientRequest(ctx, clientId, { kind: 'SUBSCRIPTION', plan: 'ELITE' });
|
||
expect(r2.id).toBe(r1.id);
|
||
await grantSubscription(ctx, { clientId, plan: 'ELITE', days: 30, requestId: r1.id });
|
||
expect((await db.clientRequest.findUniqueOrThrow({ where: { id: r1.id } })).status).toBe('DONE');
|
||
expect(events.some((e) => e.type === 'subscription.activated')).toBe(true);
|
||
expect((await getMembership(db, clientId, NOW)).level).toBe('ELITE');
|
||
const order = await newOrder(ctx, clientId);
|
||
// уборка 2 комнаты 15 000 + 4 000 → −35%
|
||
expect(order).toMatchObject({ basePrice: 19000, totalPrice: 12350 });
|
||
expect(await code(createClientRequest(ctx, clientId, { kind: 'SERVICE', serviceCode: 'nope' }))).toBe('NOT_FOUND');
|
||
});
|
||
|
||
it('консьерж: сообщения клиента непрочитаны, пока оператор не открыл диалог; ответ уходит в бота', async () => {
|
||
const { ctx, events } = makeCtx(NOW);
|
||
const clientId = await onboardedClient(ctx, 504);
|
||
await addClientMessage(ctx, clientId, 'Как продлить визу?');
|
||
expect((await listConversations(ctx))[0]).toMatchObject({ clientId, unread: 1 });
|
||
await conversation(ctx, { clientId });
|
||
expect((await listConversations(ctx))[0]!.unread).toBe(0);
|
||
const admin = await db.adminUser.create({ data: { email: 'op@test', name: 'Оператор', passwordHash: 'x' } });
|
||
const msg = await replyToClient(ctx, { clientId, adminUserId: admin.id, text: 'Подскажем завтра утром' });
|
||
expect(events).toContainEqual({ type: 'concierge.reply', messageId: msg.id });
|
||
});
|
||
});
|
||
|
||
describe('шаблоны переезда и переписка с исполнителями', () => {
|
||
it('новый шаг в общем списке попадает в «под ключ» новым клиентам; у клиента — свои шаги и документы', async () => {
|
||
const { ctx } = makeCtx(NOW);
|
||
await saveRelocationItem(ctx, { kind: 'STEP', name: { ru: 'Школа для ребёнка' }, active: true, inTurnkey: true });
|
||
await saveRelocationItem(ctx, { kind: 'STEP', name: { ru: 'Скрытый' }, active: false, inTurnkey: true });
|
||
const clientId = await onboardedClient(ctx, 601);
|
||
const lead = await createAppLead(ctx, clientId, { moveWhen: 'later', package: 'TURNKEY' });
|
||
expect(lead.steps).toHaveLength(8);
|
||
expect(lead.steps.some((s) => s.code.startsWith('c-'))).toBe(true);
|
||
|
||
const custom = await addLeadStep(ctx, { leadId: lead.id, title: 'Купить сим-карту' });
|
||
expect(custom).toMatchObject({ title: 'Купить сим-карту', status: 'TODO' });
|
||
expect(await addLeadStep(ctx, { leadId: lead.id, code: 'airport' })).toMatchObject({ id: lead.steps.find((s) => s.code === 'airport')!.id });
|
||
await addLeadDoc(ctx, { leadId: lead.id, title: 'Справка 2-НДФЛ' });
|
||
expect((await clientRelocation(ctx, clientId))!.docs.map((d) => d.title ?? d.code)).toEqual(['passport', 'diploma', 'criminalRecord', 'Справка 2-НДФЛ']);
|
||
});
|
||
|
||
it('исполнитель пишет боту — операторам уведомление; ответ уходит исполнителю', async () => {
|
||
const { ctx, events } = makeCtx(NOW);
|
||
const provider = await db.provider.findFirstOrThrow({ where: { userId: { not: null } } });
|
||
const msg = (await addProviderMessage(ctx, provider.id, 'Клиент не открывает дверь'))!.message;
|
||
expect(events).toContainEqual({ type: 'concierge.message', messageId: msg.id });
|
||
const admin = await db.adminUser.create({ data: { email: 'op2@test', name: 'Оператор', passwordHash: 'x' } });
|
||
const reply = await replyTo(ctx, { providerId: provider.id, adminUserId: admin.id, text: 'Звоним клиенту' });
|
||
expect(reply).toMatchObject({ providerId: provider.id, author: 'OPERATOR' });
|
||
expect((await db.conciergeMessage.findUniqueOrThrow({ where: { id: msg.id } })).readAt).not.toBeNull();
|
||
const unlinked = await db.provider.create({ data: { name: 'Без бота', role: 'CLEANER', languages: ['ru'], inviteCode: 'X-NOBOT' } });
|
||
expect(await code(replyTo(ctx, { providerId: unlinked.id, adminUserId: admin.id, text: 'привет' }))).toBe('PROVIDER_NOT_LINKED');
|
||
});
|
||
});
|
||
|
||
describe('партнёры: QR клубной карты и кабинет в рабочем боте', () => {
|
||
async function setup(ctx: ReturnType<typeof makeCtx>['ctx']) {
|
||
const partner = await db.partner.create({ data: { category: 'cafe', name: 'Lumen', offer: { ru: 'кофе' } } });
|
||
const every = await savePartnerOffer(ctx, { partnerId: partner.id, title: { ru: '−10% на всё' }, minLevel: null, limit: 'NONE', limitCount: 1, active: true });
|
||
const weekly = await savePartnerOffer(ctx, { partnerId: partner.id, title: { ru: 'Кофе в подарок' }, minLevel: 'BAREV_PLUS', limit: 'WEEK', limitCount: 1, active: true });
|
||
const first = await savePartnerOffer(ctx, { partnerId: partner.id, title: { ru: 'Десерт на первый визит' }, minLevel: null, limit: 'ONCE', limitCount: 1, active: true });
|
||
const { url } = await createOwnerInvite(ctx, partner.id);
|
||
const owner = await acceptPartnerInvite(ctx, tgUser(800), url.split('start=')[1]!);
|
||
expect(owner.role).toBe('OWNER');
|
||
return { partner, every, weekly, first };
|
||
}
|
||
|
||
it('QR меняется каждую минуту, старый скриншот через 3 минуты не проходит, подделка не проходит', () => {
|
||
const { token } = cardQrToken('ABCD2345', NOW);
|
||
expect(token.length).toBeLessThanOrEqual(64);
|
||
expect(verifyCardToken(token, NOW)).toBe('ABCD2345');
|
||
expect(verifyCardToken(token, new Date(NOW.getTime() + 90_000))).toBe('ABCD2345');
|
||
expect(verifyCardToken(token, new Date(NOW.getTime() + 4 * 60_000))).toBeNull();
|
||
expect(verifyCardToken(token.replace('ABCD2345', 'ZZZZ2345'), NOW)).toBeNull();
|
||
});
|
||
|
||
it('сотрудник проверяет карту: уровень, что положено, лимиты «раз в неделю» и «первый визит»', async () => {
|
||
const { ctx, clock } = makeCtx(NOW);
|
||
const { partner, weekly, first } = await setup(ctx);
|
||
const { url } = await createStaffInvite(ctx, 800n);
|
||
expect((await acceptPartnerInvite(ctx, tgUser(801), url.split('start=')[1]!)).role).toBe('STAFF');
|
||
expect(await code(acceptPartnerInvite(ctx, tgUser(802), url.split('start=')[1]!))).toBe('INVITE_INVALID');
|
||
expect(await code(createStaffInvite(ctx, 801n))).toBe('FORBIDDEN');
|
||
|
||
const clientId = await onboardedClient(ctx, 810);
|
||
const card = (await db.client.findUniqueOrThrow({ where: { id: clientId } })).cardCode;
|
||
let check = await checkCard(ctx, { telegramId: 801n, token: `https://t.me/bot?start=${cardQrToken(card, NOW).token}` });
|
||
expect(check.level).toBe('BAREV');
|
||
expect(check.offers.find((o) => o.id === weekly.id)).toMatchObject({ eligible: false });
|
||
expect(await code(redeemOffer(ctx, { telegramId: 801n, checkId: check.checkId, offerId: weekly.id }))).toBe('OFFER_NOT_AVAILABLE');
|
||
expect(await redeemOffer(ctx, { telegramId: 801n, checkId: check.checkId, offerId: first.id })).toMatchObject({ remaining: 0 });
|
||
expect(await code(redeemOffer(ctx, { telegramId: 801n, checkId: check.checkId, offerId: first.id }))).toBe('OFFER_LIMIT_REACHED');
|
||
|
||
await grantSubscription(ctx, { clientId, plan: 'BAREV_PLUS', days: 30 });
|
||
check = await checkCard(ctx, { telegramId: 801n, token: cardQrToken(card, NOW).token });
|
||
expect(check.offers.find((o) => o.id === weekly.id)).toMatchObject({ eligible: true, remaining: 1 });
|
||
await redeemOffer(ctx, { telegramId: 801n, checkId: check.checkId, offerId: weekly.id });
|
||
// через неделю — снова можно (19.10 — понедельник, 26.10 — следующий)
|
||
clock.now = new Date(NOW.getTime() + 7 * 24 * 3600_000);
|
||
check = await checkCard(ctx, { telegramId: 801n, token: cardQrToken(card, clock.now).token });
|
||
expect(check.offers.find((o) => o.id === weekly.id)).toMatchObject({ remaining: 1 });
|
||
expect(check.offers.find((o) => o.id === first.id)).toMatchObject({ remaining: 0 });
|
||
|
||
// чужой человек проверить не может; собственник видит сотрудников и проверки
|
||
expect(await code(checkCard(ctx, { telegramId: 999n, token: cardQrToken(card, clock.now).token }))).toBe('FORBIDDEN');
|
||
const cab = await partnerCabinet(ctx, 800n);
|
||
expect(cab).toMatchObject({ role: 'OWNER', visits30: 3 });
|
||
expect(cab.members.map((m) => m.role)).toEqual(['OWNER', 'STAFF']);
|
||
expect((await partnerCabinet(ctx, 801n)).members).toEqual([]);
|
||
expect((await clientPartners(ctx, clientId)).find((p) => p.id === partner.id)!.offers).toHaveLength(3);
|
||
});
|
||
});
|