Monorepo skeleton, Prisma schema, core domain and services with tests
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
commit
45d7512d2b
64 files changed
+9483
No files matched your search
@@ -0,0 +1,327 @@
|
||||
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<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).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<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]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,23 @@
|
||||
import { execSync } from 'node:child_process';
|
||||
import path from 'node:path';
|
||||
import { config } from 'dotenv';
|
||||
import pg from 'pg';
|
||||
|
||||
/** Отдельная БД barev_test рядом с основной; схема накатывается миграциями */
|
||||
export default async function setup() {
|
||||
config({ path: path.resolve(import.meta.dirname, '../../../.env'), quiet: true });
|
||||
const base = new URL(process.env.DATABASE_URL!);
|
||||
const testUrl = new URL(base);
|
||||
testUrl.pathname = '/barev_test';
|
||||
const admin = new pg.Client({ connectionString: base.toString() });
|
||||
await admin.connect();
|
||||
const exists = await admin.query(`SELECT 1 FROM pg_database WHERE datname = 'barev_test'`);
|
||||
if (!exists.rowCount) await admin.query('CREATE DATABASE barev_test');
|
||||
await admin.end();
|
||||
execSync('npx prisma migrate deploy', {
|
||||
cwd: path.resolve(import.meta.dirname, '../../db'),
|
||||
env: { ...process.env, DATABASE_URL: testUrl.toString() },
|
||||
stdio: 'pipe',
|
||||
});
|
||||
process.env.TEST_DATABASE_URL = testUrl.toString();
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
import path from 'node:path';
|
||||
import { config } from 'dotenv';
|
||||
import { CATALOG, DEFAULT_CATEGORY_DISCOUNTS, fromYerevan, toYerevan, YEREVAN_DISTRICTS } from '@barev/core';
|
||||
import { createDb, type Db } from '@barev/db';
|
||||
import type { Ctx, DomainEvent } from '../src/context.ts';
|
||||
|
||||
config({ path: path.resolve(import.meta.dirname, '../../../.env'), quiet: true });
|
||||
|
||||
function testUrl() {
|
||||
const u = new URL(process.env.DATABASE_URL!);
|
||||
u.pathname = '/barev_test';
|
||||
return u.toString();
|
||||
}
|
||||
|
||||
export const db: Db = createDb(testUrl());
|
||||
|
||||
export function makeCtx(now: Date) {
|
||||
const events: DomainEvent[] = [];
|
||||
const clock = { now };
|
||||
const ctx: Ctx = { db, notify: { emit: (e) => void events.push(e) }, now: () => clock.now };
|
||||
return { ctx, events, clock };
|
||||
}
|
||||
|
||||
export async function wipe() {
|
||||
const tables = await db.$queryRaw<{ tablename: string }[]>`
|
||||
SELECT tablename FROM pg_tables WHERE schemaname = 'public' AND tablename <> '_prisma_migrations'`;
|
||||
await db.$executeRawUnsafe(`TRUNCATE ${tables.map((t) => `"public"."${t.tablename}"`).join(', ')} RESTART IDENTITY CASCADE`);
|
||||
}
|
||||
|
||||
/** Понедельник 2026-10-19, 10:00 по Еревану — «сейчас» в тестах */
|
||||
export const NOW = fromYerevan(2026, 10, 19, 10 * 60);
|
||||
/** Слот послезавтра (ср) в hh:00 — вне 24-часового порога */
|
||||
export const slotAt = (dayOffset: number, hour: number) => {
|
||||
const p = toYerevan(new Date(NOW.getTime() + dayOffset * 24 * 3600_000));
|
||||
return fromYerevan(p.year, p.month, p.day, hour * 60);
|
||||
};
|
||||
|
||||
let tgSeq = 1000;
|
||||
|
||||
export async function fixtures() {
|
||||
await wipe();
|
||||
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 } });
|
||||
const c = CATALOG.find((s) => s.code === 'cleaning')!;
|
||||
const cleaning = await db.service.create({
|
||||
data: {
|
||||
code: c.code,
|
||||
categoryId: home.id,
|
||||
flow: c.flow,
|
||||
providerRole: c.providerRole,
|
||||
pricing: c.pricing,
|
||||
paramsSchema: c.paramsSchema,
|
||||
slotConfig: c.slotConfig,
|
||||
lateCancelFeePct: 30,
|
||||
checklist: { create: c.checklist.map((x, i) => ({ code: x.code, requiresPhoto: !!x.requiresPhoto, sortOrder: i })) },
|
||||
},
|
||||
});
|
||||
const agency = await db.agency.create({ data: { code: 'DOMIK', name: 'Domik', commissionPct: 10 } });
|
||||
await db.agency.create({ data: { code: 'CLOSED', name: 'Closed', active: false } });
|
||||
const schedule = [0, 1, 2, 3, 4, 5, 6].map((weekday) => ({ weekday, startMinute: 9 * 60, endMinute: 20 * 60 }));
|
||||
const mkProvider = async (name: string, languages: ('ru' | 'en' | 'hy')[], companyId?: string) => {
|
||||
const user = await db.user.create({ data: { telegramId: BigInt(++tgSeq), firstName: name } });
|
||||
return db.provider.create({
|
||||
data: {
|
||||
name,
|
||||
userId: user.id,
|
||||
role: 'CLEANER',
|
||||
languages,
|
||||
companyId,
|
||||
inviteCode: `INV-${name}`,
|
||||
onShift: true,
|
||||
districts: { create: [{ districtId: kentron.id }] },
|
||||
schedule: { create: schedule },
|
||||
},
|
||||
});
|
||||
};
|
||||
const maria = await mkProvider('Maria', ['ru', 'en']);
|
||||
const ani = await mkProvider('Ani', ['hy']);
|
||||
const company = await db.providerCompany.create({ data: { name: 'Chisto', roles: ['CLEANER'], inviteCode: 'CO-1', rateValue: 65 } });
|
||||
const lusine = await mkProvider('Lusine', ['ru'], company.id);
|
||||
const dispatcher = await db.user.create({ data: { telegramId: BigInt(++tgSeq), firstName: 'Karen' } });
|
||||
await db.companyDispatcher.create({ data: { companyId: company.id, userId: dispatcher.id } });
|
||||
return { kentron, cleaning, agency, maria, ani, company, lusine, dispatcher };
|
||||
}
|
||||
|
||||
export const tgUser = (id: number, lang = 'ru') => ({ id, first_name: `User${id}`, language_code: lang });
|
||||
Reference in new issue
Block a user