/** * Sprint 27 — cross-feature: Loyalty + SignalR + i18n. * * Сценарий: * 1. Owner создаёт LoyaltyProgram (PointsAccrual, Rate=10). * 2. Создаёт counterparty и выпускает на него LoyaltyCard "T27-0001". * 3. Owner подключается к SignalR /hubs/notifications с access_token. * 4. Кассир проводит чек на 1 шт × 100 ₸ с loyaltyCardNumber=T27-0001 * → начисление 10 баллов (PointsAccrual: rate=10%). * 5. SignalR событие SalePosted приходит owner'у (мы подписаны на org-group). * 6. Локаль ru: GET /api/me возвращает ожидаемые ru поля; локаль en * (через Accept-Language=en) — проверяем что и en тоже работает. * 7. LoyaltyCard.balance = 10 (start=0 + 10 начислено). * * Покрывает: Loyalty (программа+карта+начисление) + SignalR (org-broadcast) * + i18n (RU/EN headers корректно роутятся, нет 500). */ import { expect, test } from '@playwright/test' import WebSocket from 'ws' import { request, baseUrl } from '../regression/factories/api-client.js' import { OrgFactory } from '../regression/factories/OrgFactory.js' test.describe('27.1 loyalty + signalr + i18n', () => { test('программа+карта→начисление баллов→SignalR push→i18n локали 200', async () => { test.setTimeout(120_000) const org = await OrgFactory.for('s27loy') .withProducts(1) .withCounterparties(1) .withSupplies(1) .build() const tok = org.session.accessToken const product = org.products[0] const customer = org.counterparties[0] // ── 1. Создаём программу. const program = await request<{ id: string }>('/api/loyalty/programs', { token: tok, body: { name: `s27-prog-${Date.now()}`, type: 3, // PointsAccrual rate: 10, // 10% начисления баллов от суммы чека minSubtotal: 0, isActive: true, description: null, }, }) expect(program.id).toBeTruthy() // ── 2. Выпускаем карту. const cardNumber = `T27-${Date.now()}` const card = await request<{ id: string; balance: number }>( '/api/loyalty/cards/issue', { token: tok, body: { programId: program.id, counterpartyId: customer.id, cardNumber, }, }) expect(card.id).toBeTruthy() expect(card.balance).toBe(0) // ── 3. SignalR connect (через negotiate + WebSocket). const wsUrl = baseUrl.replace(/^http/, 'ws') const negotiateRes = await fetch( `${baseUrl}/hubs/notifications/negotiate?negotiateVersion=1`, { method: 'POST', headers: { Authorization: `Bearer ${tok}` }, }) expect(negotiateRes.status).toBe(200) const negotiate = await negotiateRes.json() as { connectionToken: string } expect(negotiate.connectionToken).toBeTruthy() // Подписываемся на WS, собираем events. const collected: Array<{ method: string; payload: unknown }> = [] const ws = new WebSocket( `${wsUrl}/hubs/notifications?id=${negotiate.connectionToken}&access_token=${encodeURIComponent(tok)}`, ) await new Promise((resolve, reject) => { ws.on('open', () => { // SignalR handshake — JSON terminated by 0x1e. ws.send(JSON.stringify({ protocol: 'json', version: 1 }) + '\x1e') resolve() }) ws.on('error', reject) setTimeout(() => reject(new Error('ws connect timeout')), 8000) }) ws.on('message', (data) => { const text = data.toString() // SignalR может слать несколько фреймов через 0x1e separator. for (const part of text.split('\x1e').filter(Boolean)) { try { const obj = JSON.parse(part) if (obj.type === 1 && obj.target) { collected.push({ method: obj.target, payload: obj.arguments?.[0] }) } } catch { /* keep-alive ping и т.п. */ } } }) // ── 4. Кассир проводит чек с loyaltyCardNumber. const saleInput = { date: new Date().toISOString(), storeId: org.refs.storeId, retailPointId: org.refs.retailPointId ?? null, customerId: customer.id, currencyId: org.refs.currencyId, payment: 1, paidCash: 100, paidCard: 0, lines: [{ productId: product.id, quantity: 1, unitPrice: 100, discount: 0, vatPercent: 0 }], loyaltyCardNumber: cardNumber, } const sale = await request<{ id: string; loyaltyPointsAccrued?: number }>( '/api/sales/retail', { token: tok, body: saleInput }, ) expect(sale.id).toBeTruthy() await request(`/api/sales/retail/${sale.id}/post`, { token: tok, body: {} }) // ── 5. Ждём SalePosted event (до 5 секунд). const deadline = Date.now() + 6000 while (Date.now() < deadline && !collected.find(e => e.method === 'SalePosted')) { await new Promise(r => setTimeout(r, 200)) } ws.close() const salePosted = collected.find(e => e.method === 'SalePosted') expect(salePosted, 'SignalR должен прислать SalePosted').toBeTruthy() const sp = salePosted!.payload as { saleId: string; total: number } expect(sp.saleId).toBe(sale.id) // ── 6. i18n проверка: /api/me с Accept-Language=ru и en — оба 200. const meRu = await fetch(`${baseUrl}/api/me`, { headers: { Authorization: `Bearer ${tok}`, 'Accept-Language': 'ru-RU' }, }) expect(meRu.status).toBe(200) const meEn = await fetch(`${baseUrl}/api/me`, { headers: { Authorization: `Bearer ${tok}`, 'Accept-Language': 'en-US' }, }) expect(meEn.status).toBe(200) // ── 7. LoyaltyCard.balance = 10 (10% от 100 = 10). const cards = await request<{ items: Array<{ id: string; balance: number }> }>( '/api/loyalty/cards?page=1&pageSize=10', { token: tok }, ) const ourCard = cards.items.find(c => c.id === card.id) expect(ourCard, 'карта по-прежнему видна').toBeTruthy() expect(Number(ourCard!.balance), '10% от 100 = 10 баллов').toBe(10) }) })