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, rescheduleByClient, setChecklistItem, startClient, transitionOrder, DomainError, } from '../src/index.ts'; import { db, fixtures, makeCtx, NOW, slotAt, tgUser } from './helpers.ts'; let fx: Awaited>; beforeEach(async () => { fx = await fixtures(); }); afterAll(() => db.$disconnect()); async function onboardedClient(ctx: ReturnType['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['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) => 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).toEqual({ basePrice: 19000, 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['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', '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]); }); });