Files
BarevClub/packages/core/src/domain.test.ts
T
2026-10-17 19:45:51 +04:00

270 lines
13 KiB
TypeScript

import { describe, expect, it } from 'vitest';
import { rankCandidates, suggestCandidate, type Candidate } from './assignment.ts';
import { cancelPolicy, canRescheduleSelf } from './cancellation.ts';
import { CATALOG } from './catalog.ts';
import { decodeStartPayload, encodeStartPayload, pickUtm } from './deeplink.ts';
import { agencyCommission, decideGift, discountFor, isAgencyAttributionActive, membershipLevel, payoutFor } from './membership.ts';
import { defaultParams, validateParams } from './params.ts';
import { fromPrice, PricingError, quote } from './pricing.ts';
import { checkPromo, combineDiscounts, type PromoLike } from './promo.ts';
import { DEFAULT_SETTINGS, resolveSettings } from './settings.ts';
import { daySlots, type ProviderAvailability } from './slots.ts';
import { fromYerevan, HOUR, toYerevan, yerevanDateKey } from './time.ts';
const cleaning = CATALOG.find((s) => s.code === 'cleaning')!;
describe('pricing (ORD-4)', () => {
it('считает базовую цену по типу и комнатам', () => {
expect(quote(cleaning.pricing, { cleaningType: 'regular', rooms: 1 })).toEqual({
basePrice: 15000,
discountPct: 0,
discountAmount: 0,
totalPrice: 15000,
durationMinutes: 120,
});
expect(quote(cleaning.pricing, { cleaningType: 'general', rooms: 3 }).basePrice).toBe(25000 + 2 * 7000);
});
it('вычитает скидку Barev+ и округляет её вниз', () => {
const q = quote({ kind: 'fixed', price: 15005, minutes: 60 }, {}, 10);
expect(q.discountAmount).toBe(1500);
expect(q.totalPrice).toBe(13505);
expect(q.basePrice - q.discountAmount).toBe(q.totalPrice);
});
it('отклоняет неизвестный вариант, плохие комнаты и скидку вне 0–100', () => {
expect(() => quote(cleaning.pricing, { cleaningType: 'deluxe', rooms: 1 })).toThrow(PricingError);
expect(() => quote(cleaning.pricing, { cleaningType: 'regular', rooms: 0 })).toThrow(PricingError);
expect(() => quote(cleaning.pricing, { cleaningType: 'regular', rooms: 1 }, 101)).toThrow(PricingError);
});
it('«от N ֏» — минимальный тариф', () => {
expect(fromPrice(cleaning.pricing)).toBe(15000);
});
});
describe('params (ORD-2)', () => {
it('дефолты и валидация', () => {
expect(defaultParams(cleaning.paramsSchema)).toEqual({ cleaningType: 'regular', rooms: 2 });
expect(validateParams(cleaning.paramsSchema, { cleaningType: 'general', rooms: '3', junk: 1 })).toEqual({
ok: true,
values: { cleaningType: 'general', rooms: 3 },
});
expect(validateParams(cleaning.paramsSchema, { cleaningType: 'x', rooms: 9 })).toEqual({
ok: false,
errors: [
{ key: 'cleaningType', code: 'invalid' },
{ key: 'rooms', code: 'invalid' },
],
});
expect(validateParams(cleaning.paramsSchema, {})).toMatchObject({ ok: false });
});
});
describe('time (Asia/Yerevan)', () => {
it('UTC+4 без летнего времени', () => {
const d = fromYerevan(2026, 7, 1, 9 * 60);
expect(d.toISOString()).toBe('2026-07-01T05:00:00.000Z');
expect(toYerevan(new Date('2026-01-15T21:30:00Z'))).toMatchObject({ day: 16, hour: 1, minute: 30, weekday: 4 });
expect(yerevanDateKey(new Date('2026-12-31T20:00:00Z'))).toBe('2027-01-01');
});
});
describe('slots (ORD-3)', () => {
// 2026-10-19 — понедельник
const now = fromYerevan(2026, 10, 18, 12 * 60);
const fullDay = [{ weekday: 0, startMinute: 9 * 60, endMinute: 20 * 60 }];
const config = { dayStartMinute: 9 * 60, dayEndMinute: 20 * 60, stepMinutes: 60, leadHours: 3, horizonDays: 14 };
it('нет исполнителей — нет слотов', () => {
expect(daySlots({ dateKey: '2026-10-19', config, durationMinutes: 120, now, providers: [], unassigned: [] })).toEqual([]);
});
it('слоты по окну услуги, длительности и графику', () => {
const p: ProviderAvailability = { id: 'a', schedule: fullDay, busy: [] };
const slots = daySlots({ dateKey: '2026-10-19', config, durationMinutes: 120, now, providers: [p], unassigned: [] });
// 9:00 … 18:00 (последний слот заканчивается в 20:00)
expect(slots).toHaveLength(10);
expect(toYerevan(slots[0]!.start).hour).toBe(9);
expect(toYerevan(slots.at(-1)!.start).hour).toBe(18);
});
it('занятость исполнителя и неназначенные заказы уменьшают мощность', () => {
const busy = [{ start: fromYerevan(2026, 10, 19, 10 * 60), end: fromYerevan(2026, 10, 19, 12 * 60) }];
const a: ProviderAvailability = { id: 'a', schedule: fullDay, busy };
const b: ProviderAvailability = { id: 'b', schedule: fullDay, busy: [] };
const unassigned = [{ start: fromYerevan(2026, 10, 19, 14 * 60), end: fromYerevan(2026, 10, 19, 15 * 60) }];
const slots = daySlots({ dateKey: '2026-10-19', config, durationMinutes: 60, now, providers: [a, b], unassigned });
const cap = (h: number) => slots.find((s) => toYerevan(s.start).hour === h)?.capacity ?? 0;
expect(cap(9)).toBe(2);
expect(cap(10)).toBe(1);
expect(cap(14)).toBe(1);
});
it('учитывает минимальный запас до начала', () => {
const p: ProviderAvailability = { id: 'a', schedule: [{ weekday: 6, startMinute: 0, endMinute: 1440 }], busy: [] };
// сегодня воскресенье 12:00 → первый слот не раньше 15:00
const slots = daySlots({ dateKey: '2026-10-18', config, durationMinutes: 60, now, providers: [p], unassigned: [] });
expect(toYerevan(slots[0]!.start).hour).toBe(15);
});
});
describe('assignment (ADM-3)', () => {
const base: Omit<Candidate, 'id' | 'name'> = {
kind: 'provider',
languages: ['ru'],
districtIds: [],
load: 0,
rating: 4.5,
free: true,
onShift: true,
};
it('язык клиента важнее района, район — важнее загрузки', () => {
const c: Candidate[] = [
{ ...base, id: '1', name: 'A', languages: ['ru'], districtIds: ['kentron'] },
{ ...base, id: '2', name: 'B', languages: ['en'], districtIds: ['kentron'] },
{ ...base, id: '3', name: 'C', languages: ['en'], districtIds: [], load: 0 },
];
const ranked = rankCandidates(c, { locale: 'en', districtId: 'kentron', isOnline: false });
expect(ranked.map((r) => r.id)).toEqual(['2', '3', '1']);
expect(ranked[0]!.reasons).toEqual(expect.arrayContaining(['language', 'district']));
});
it('занятые исключаются, загрузка снижает приоритет', () => {
const c: Candidate[] = [
{ ...base, id: '1', name: 'A', free: false },
{ ...base, id: '2', name: 'B', load: 3 },
{ ...base, id: '3', name: 'C', load: 0 },
];
expect(suggestCandidate(c, { locale: 'ru', districtId: null, isOnline: false })?.id).toBe('3');
expect(rankCandidates(c, { locale: 'ru', districtId: null, isOnline: false })).toHaveLength(2);
});
it('нет свободных — нет подсказки', () => {
expect(suggestCandidate([{ ...base, id: '1', name: 'A', free: false }], { locale: 'ru', districtId: null, isOnline: false })).toBeNull();
});
});
describe('cancellation (ORD-6)', () => {
const now = new Date('2026-10-17T10:00:00Z');
const at = (h: number) => new Date(now.getTime() + h * HOUR);
it('за 24 ч и раньше — бесплатно', () => {
expect(cancelPolicy({ scheduledAt: at(24), now, freeHours: 24, lateFeePct: 30, totalPrice: 20000 })).toEqual({ kind: 'free', fee: 0 });
});
it('позже — удержание 30%', () => {
expect(cancelPolicy({ scheduledAt: at(23), now, freeHours: 24, lateFeePct: 30, totalPrice: 20001 })).toEqual({
kind: 'late',
fee: 6000,
feePct: 30,
});
});
it('после начала — только через оператора', () => {
expect(cancelPolicy({ scheduledAt: at(-1), now, freeHours: 24, lateFeePct: 30, totalPrice: 1 })).toEqual({ kind: 'operator_only' });
});
it('перенос сам — только вне порога', () => {
expect(canRescheduleSelf(at(25), now, 24)).toBe(true);
expect(canRescheduleSelf(at(5), now, 24)).toBe(false);
});
});
describe('deep-link payload (LND-3 / ONB-1)', () => {
it('кодирует и декодирует язык и агентство', () => {
const p = encodeStartPayload({ locale: 'hy', agency: 'domik1' });
expect(p).toBe('l-hy_a-DOMIK1');
expect(decodeStartPayload(p)).toEqual({ locale: 'hy', agency: 'DOMIK1' });
});
it('токен для UTM', () => {
expect(decodeStartPayload(encodeStartPayload({ token: 'k3j9x2', locale: 'en' }))).toEqual({ token: 'k3j9x2', locale: 'en' });
});
it('игнорирует мусор и неизвестный язык', () => {
expect(decodeStartPayload('l-fr_a-<script>_zzz_x-1')).toEqual({});
expect(decodeStartPayload(undefined)).toEqual({});
});
it('укладывается в лимит Telegram', () => {
const p = encodeStartPayload({ token: 'abcdefghij', locale: 'zh', agency: 'A'.repeat(32) });
expect(p.length).toBeLessThanOrEqual(64);
expect(p).toMatch(/^[A-Za-z0-9_-]+$/);
});
it('достаёт UTM из URL', () => {
expect(pickUtm(new URLSearchParams('utm_source=qr&utm_campaign=oct&x=1'))).toEqual({ utm_source: 'qr', utm_campaign: 'oct' });
});
});
describe('Barev+ и агентства (ONB-4, SUB-3, ADM-8)', () => {
const now = new Date('2026-10-17T10:00:00Z');
it('подарочный месяц — один раз', () => {
const ok = decideGift({ now, giftUsedAt: null, agencyActive: true, giftDays: 30, currentEndsAt: null });
expect(ok).toEqual({ ok: true, startsAt: now, endsAt: new Date('2026-11-16T10:00:00Z') });
expect(decideGift({ now, giftUsedAt: now, agencyActive: true, giftDays: 30, currentEndsAt: null })).toEqual({ ok: false, reason: 'ALREADY_USED' });
expect(decideGift({ now, giftUsedAt: null, agencyActive: false, giftDays: 30, currentEndsAt: null })).toEqual({ ok: false, reason: 'AGENCY_INACTIVE' });
});
it('подарок продлевает действующую подписку', () => {
const endsAt = new Date('2026-10-20T00:00:00Z');
const r = decideGift({ now, giftUsedAt: null, agencyActive: true, giftDays: 30, currentEndsAt: endsAt });
expect(r.ok && r.startsAt).toEqual(endsAt);
});
it('уровень членства и скидка', () => {
const subs = [{ startsAt: new Date('2026-10-01'), endsAt: new Date('2026-11-01'), status: 'ACTIVE' as const }];
expect(membershipLevel(subs, now)).toBe('BAREV_PLUS');
expect(membershipLevel(subs, new Date('2026-11-02'))).toBe('BAREV');
expect(membershipLevel([{ ...subs[0]!, status: 'CANCELLED' }], now)).toBe('BAREV');
expect(discountFor('BAREV_PLUS', 10)).toBe(10);
expect(discountFor('BAREV', 10)).toBe(0);
});
it('клиент закреплён за агентством 12 месяцев, вознаграждение 10%', () => {
const attached = new Date('2025-11-01T00:00:00Z');
expect(isAgencyAttributionActive(attached, now, 12)).toBe(true);
expect(isAgencyAttributionActive(attached, new Date('2026-11-01T00:00:01Z'), 12)).toBe(false);
expect(isAgencyAttributionActive(null, now, 12)).toBe(false);
expect(agencyCommission(18999, 10)).toBe(1899);
});
it('выплата исполнителю: фикс или процент (PRO-8)', () => {
expect(payoutFor(20000, { type: 'FIXED', value: 9000 })).toBe(9000);
expect(payoutFor(20000, { type: 'PERCENT', value: 70 })).toBe(14000);
});
});
describe('promo codes', () => {
const now = new Date('2026-10-17T10:00:00Z');
const p: PromoLike = { kind: 'DISCOUNT', active: true, maxUses: 2, usedCount: 0, validFrom: null, validUntil: null };
it('проверяет активность, сроки, лимиты и повторное использование', () => {
expect(checkPromo(p, now, false)).toEqual({ ok: true });
expect(checkPromo({ ...p, active: false }, now, false)).toEqual({ ok: false, reason: 'INACTIVE' });
expect(checkPromo({ ...p, validFrom: new Date('2026-11-01') }, now, false)).toEqual({ ok: false, reason: 'NOT_STARTED' });
expect(checkPromo({ ...p, validUntil: new Date('2026-10-01') }, now, false)).toEqual({ ok: false, reason: 'EXPIRED' });
expect(checkPromo({ ...p, usedCount: 2 }, now, false)).toEqual({ ok: false, reason: 'EXHAUSTED' });
expect(checkPromo(p, now, true)).toEqual({ ok: false, reason: 'ALREADY_USED_BY_CLIENT' });
});
it('скидки не суммируются', () => {
expect(combineDiscounts(10, 15)).toBe(15);
expect(combineDiscounts(10, null)).toBe(10);
});
});
describe('settings', () => {
it('дефолты из PRD и решений владельца', () => {
expect(DEFAULT_SETTINGS['cancel.freeHours']).toBe(24);
expect(DEFAULT_SETTINGS['agency.attributionMonths']).toBe(12);
expect(DEFAULT_SETTINGS['agency.defaultCommissionPct']).toBe(10);
expect(DEFAULT_SETTINGS['files.retentionDays']).toBe(90);
});
it('битые значения заменяются дефолтом, неизвестные ключи игнорируются', () => {
const s = resolveSettings([
{ key: 'cancel.freeHours', value: 48 },
{ key: 'offer.acceptTimeoutMinutes', value: 'abc' },
{ key: 'nope', value: 1 },
]);
expect(s['cancel.freeHours']).toBe(48);
expect(s['offer.acceptTimeoutMinutes']).toBe(15);
});
});