Files
BarevClub/packages/services/test/products.test.ts
T
zabudko-ruandClaude Opus 5.5 0edc6432b7 Backoffice stage 5: products, broadcasts, activity log, settings
- Products: services with full setup — content (name, description, extras names
  in 5 languages via text overrides), price tiers and extras, booking window,
  late-cancel fee, on/off, checklist; category discounts by membership level;
  membership plan prices; promo codes; relocation steps and documents.
- Text overrides: TextOverride table on top of the i18n files; API, bots and the
  worker reload them every minute, mini apps fetch public.texts at start.
- Promo codes applied to orders (admin new-order form and client API): not
  stacked with membership discount — the larger wins; once per client; validity,
  limit and categories.
- Broadcasts to the client bot by any client filter, per-language texts with
  Russian fallback, test to yourself, ~25 msg/s, per-recipient status, resume after
  worker restart, history with progress and stop.
- Activity log (who/when/what, filters, details) and Settings page with all
  owner settings grouped, plus payment methods.
- Tests for promo codes and text overrides.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-21 03:17:56 +04:00

79 lines
4.7 KiB
TypeScript

import { afterAll, beforeEach, describe, expect, it } from 'vitest';
import { createT } from '@barev/i18n';
import { createOrder, DomainError, giveConsent, grantSubscription, loadTextOverrides, quoteForClient, startClient } 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(async () => {
await db.textOverride.deleteMany({});
await loadTextOverrides(db);
await db.$disconnect();
});
const code = (p: Promise<unknown>) => p.then(() => 'OK', (e) => (e instanceof DomainError ? e.code : String(e)));
const params = { cleaningType: 'regular', rooms: 2 };
async function client(ctx: ReturnType<typeof makeCtx>['ctx'], tg: number) {
const s = await startClient(ctx, tgUser(tg));
await giveConsent(ctx, s.clientId);
return s.clientId;
}
describe('промокоды', () => {
it('скидка по коду; один раз на клиента; не складывается с подпиской — берётся большая', async () => {
const { ctx } = makeCtx(NOW);
await db.promoCode.create({ data: { code: 'AUTUMN20', kind: 'DISCOUNT', discountPct: 20 } });
await db.promoCode.create({ data: { code: 'SMALL5', kind: 'DISCOUNT', discountPct: 5 } });
const c = await client(ctx, 901);
const q = await quoteForClient(ctx, c, 'cleaning', params, 'autumn20');
expect(q.quote).toMatchObject({ basePrice: 19000, discountPct: 20, totalPrice: 15200 });
expect(q.promo?.code).toBe('AUTUMN20');
const o = await createOrder(ctx, { clientId: c, serviceCode: 'cleaning', params, scheduledAt: slotAt(2, 11), addressText: 'Pushkin 10', districtId: fx.kentron.id, promoCode: 'AUTUMN20' });
expect(o.totalPrice).toBe(15200);
expect((await db.promoCode.findUniqueOrThrow({ where: { code: 'AUTUMN20' } })).usedCount).toBe(1);
expect(await code(quoteForClient(ctx, c, 'cleaning', params, 'AUTUMN20'))).toBe('PROMO_USED');
// подписчик со скидкой 10%: код на 5% не нужен — остаётся скидка подписки, код не тратится
const member = await client(ctx, 902);
await grantSubscription(ctx, { clientId: member, plan: 'BAREV_PLUS', days: 30 });
const m = await quoteForClient(ctx, member, 'cleaning', params, 'SMALL5');
expect(m.quote.discountPct).toBe(10);
expect(m.promo).toBeNull();
});
it('выключен, истёк, исчерпан или не для этого направления — понятная ошибка', async () => {
const { ctx } = makeCtx(NOW);
const c = await client(ctx, 903);
await db.promoCode.create({ data: { code: 'OFF', kind: 'DISCOUNT', discountPct: 10, active: false } });
await db.promoCode.create({ data: { code: 'OLD', kind: 'DISCOUNT', discountPct: 10, validUntil: new Date(NOW.getTime() - 1000) } });
await db.promoCode.create({ data: { code: 'FULL', kind: 'DISCOUNT', discountPct: 10, maxUses: 1, usedCount: 1 } });
await db.promoCode.create({ data: { code: 'DOCSONLY', kind: 'DISCOUNT', discountPct: 10, categories: ['DOCS'] } });
expect(await code(quoteForClient(ctx, c, 'cleaning', params, 'NOPE'))).toBe('PROMO_INVALID');
expect(await code(quoteForClient(ctx, c, 'cleaning', params, 'OFF'))).toBe('PROMO_INVALID');
expect(await code(quoteForClient(ctx, c, 'cleaning', params, 'OLD'))).toBe('PROMO_EXPIRED');
expect(await code(quoteForClient(ctx, c, 'cleaning', params, 'FULL'))).toBe('PROMO_EXPIRED');
expect(await code(quoteForClient(ctx, c, 'cleaning', params, 'DOCSONLY'))).toBe('PROMO_NOT_FOR_SERVICE');
});
});
describe('тексты из админки', () => {
it('правка подменяет стандартный текст, свой перевод языка важнее русской правки', async () => {
const before = createT('ru')('services.cleaning.name');
await db.textOverride.create({ data: { key: 'services.cleaning.name', locale: 'ru', value: 'Уборка квартиры' } });
await db.textOverride.create({ data: { key: 'services.cleaning.name', locale: 'hy', value: 'Բնակարանի մաքրում' } });
await loadTextOverrides(db);
expect(createT('ru')('services.cleaning.name')).toBe('Уборка квартиры');
expect(createT('hy')('services.cleaning.name')).toBe('Բնակարանի մաքրում');
// у английского свой перевод — русская правка его не трогает
expect(createT('en')('services.cleaning.name')).not.toBe('Уборка квартиры');
await db.textOverride.deleteMany({});
await loadTextOverrides(db);
expect(createT('ru')('services.cleaning.name')).toBe(before);
});
});