Files
BarevClub/packages/services/test/flows.test.ts
T
2026-10-25 22:55:29 +04:00

653 lines
40 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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', '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, 'Клиент не открывает дверь');
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);
});
});