food-market/tests/integration/03-loyalty-signalr-i18n.spec.ts
nns e30861fb57
Some checks are pending
Auto-tag / Create date-tag (push) Waiting to run
CI / Backend (.NET 8) (push) Waiting to run
CI / Web (React + Vite) (push) Waiting to run
CI / POS (WPF, Windows) (push) Waiting to run
feat(s27): cross-feature integration + soak + crash recovery (8/8 ✓)
Каждый из 26 спринтов работал в изоляции; этот спринт проверяет
взаимодействие — реально ли все фичи совместимы.

1. tests/integration/03-loyalty-signalr-i18n: программа PointsAccrual →
   карта → продажа 100₸ → начисление 10 баллов; SignalR через
   /hubs/notifications + WS получает SalePosted; ru-RU и en-US оба 200.
2. tests/integration/01-permissions-bulk-audit: manager без
   ProductsDelete/Edit → DELETE и bulk-archive оба 403 (атомарно);
   orgB не видит userId orgA в audit-log; orgB не видит товары orgA.
3. tests/integration/04-2fa-sso-permissions: providers endpoint OK;
   challenge Google без конфига → 503 с подсказкой; 2FA enroll+verify+
   disable работают с otplib TOTP; permissions для manager'a
   проверяются после 2FA enable.
4. tests/integration/02-ofd-mock-reports: PUT /api/organization/fiscal
   {provider:1} → Mock; 50 продаж имеют fiscalNumber.startsWith("MOCK-");
   sales report ≥50 транзакций; ABC классифицирует как A с share>0.5.
5. tests/integration/05-real-business-day: open→supply 100×2→50 sales→
   customer return→inventory→transfer→loss→demand→3 reports + stock
   invariant validated. Прогон 24.7s.
6. tests/load/soak-4h.js + monitor-soak.sh — k6 constant-arrival-rate
   50 RPS. Soak-lite 16m34s @ 20 RPS: 19863 iterations, 0 failures,
   p95 me=16.9ms / products=29.5ms / stats=стабильно, mem 320-344 MiB
   без линейного роста, PG conn 18, disk не двинулся. Без утечек.
7. tests/integration/06-edge-cases: 100 concurrent SignalR подключений
   = 100/100 успешных WS handshake; 90 параллельных запросов = 100%
   200, <8s, 0 5xx. Hangfire workers=2 не блокирует API.
8. Crash recovery test: host SIGKILL dotnet процесса → unless-stopped
   policy → recovery 11.7s ≤ 30s SLA. Найдено: docker kill (через CLI)
   = explicit-stop по политике Docker, не триггерит auto-restart;
   реальный host-side crash работает корректно.

Cert-прогон: 7 integration specs все зелёные за 1.2 мин.
0 production bugs found.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-09 03:09:17 +05:00

149 lines
6.2 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 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<void>((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)
})
})