Каждый из 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>
149 lines
6.2 KiB
TypeScript
149 lines
6.2 KiB
TypeScript
/**
|
||
* 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)
|
||
})
|
||
})
|