Snapshot of uncommitted work: landing, bots, prod infra, partner cabinet, backoffice stage 1
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
45d7512d2b
commit
d452824c62
314 files changed
+44394
-121
No files matched your search
@@ -16,11 +16,46 @@ import {
|
||||
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';
|
||||
|
||||
@@ -106,7 +141,7 @@ describe('заказ (ORD-3…6)', () => {
|
||||
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, 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);
|
||||
});
|
||||
|
||||
@@ -325,3 +360,293 @@ describe('оплата и напоминания', () => {
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -38,7 +38,16 @@ export const slotAt = (dayOffset: number, hour: number) => {
|
||||
let tgSeq = 1000;
|
||||
|
||||
export async function fixtures() {
|
||||
// шаблоны переезда (в базе их создаёт миграция; тесты чистят все таблицы)
|
||||
const steps = ['consult', 'airport', 'stay', 'flat', 'registration', 'socialCard', 'bankCard', 'residence'];
|
||||
const docs = ['passport', 'diploma', 'criminalRecord'];
|
||||
await wipe();
|
||||
await db.relocationItem.createMany({
|
||||
data: [
|
||||
...steps.map((code, i) => ({ kind: 'STEP' as const, code, name: { ru: code }, sortOrder: i, inTurnkey: code !== 'consult' })),
|
||||
...docs.map((code, i) => ({ kind: 'DOC' as const, code, name: { ru: code }, sortOrder: i, inTurnkey: false })),
|
||||
],
|
||||
});
|
||||
for (const [i, code] of YEREVAN_DISTRICTS.entries()) await db.district.create({ data: { code, sortOrder: i } });
|
||||
const kentron = await db.district.findUniqueOrThrow({ where: { code: 'kentron' } });
|
||||
const home = await db.category.create({ data: { code: 'HOME', barevPlusDiscountPct: DEFAULT_CATEGORY_DISCOUNTS.HOME } });
|
||||
|
||||
Reference in new issue
Block a user