Каждый из 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>
123 lines
5.1 KiB
TypeScript
123 lines
5.1 KiB
TypeScript
/**
|
||
* Sprint 27 — cross-feature: ОФД Mock + RetailSale + Reports + ABC.
|
||
*
|
||
* Сценарий:
|
||
* 1. Owner включает Provider=Mock через PUT /api/organization/fiscal.
|
||
* 2. Делает приёмку 100 шт product А.
|
||
* 3. Проводит 50 розничных продаж (1 шт/чек, разные продукты по rotation).
|
||
* 4. Каждый чек получает `FiscalNumber=MOCK-…` после post (через Mock-
|
||
* провайдер; деталь идемпотентна — повторный post тот же номер).
|
||
* 5. Sales-отчёт за день показывает 50 транзакций + сумма ≈ ожидаемой.
|
||
* 6. ABC-отчёт классифицирует наш ОДИН товар в класс A (>80% выручки),
|
||
* потому что больше товаров не продаём.
|
||
*/
|
||
import { expect, test } from '@playwright/test'
|
||
import { request } from '../regression/factories/api-client.js'
|
||
import { Endpoints } from '../regression/factories/types.js'
|
||
import { OrgFactory } from '../regression/factories/OrgFactory.js'
|
||
|
||
test.describe('27.4 OFD Mock + RetailSale + Reports', () => {
|
||
test('Provider=Mock, 50 продаж имеют MOCK-FiscalNumber, отчёт и ABC учитывают их', async () => {
|
||
test.setTimeout(180_000)
|
||
|
||
const org = await OrgFactory.for('s27ofd')
|
||
.withProducts(3)
|
||
.withCounterparties(1)
|
||
.withSupplies(1) // 100 шт каждого товара
|
||
.build()
|
||
|
||
const tok = org.session.accessToken
|
||
const headers = { Authorization: `Bearer ${tok}` }
|
||
|
||
// ── 1. Включаем Mock-провайдер.
|
||
await request('/api/organization/fiscal', {
|
||
method: 'PUT', token: tok,
|
||
body: {
|
||
provider: 1, // Mock
|
||
newApiKey: null,
|
||
newApiSecret: null,
|
||
cashboxUniqueNumber: 'MOCK-CASHBOX-001',
|
||
apiBaseUrl: null,
|
||
},
|
||
})
|
||
const fs = await request<{ provider: number; providerName: string }>(
|
||
'/api/organization/fiscal', { token: tok },
|
||
)
|
||
expect(fs.provider).toBe(1)
|
||
|
||
// ── 2. Проводим 50 продаж по одному штуку product[0].
|
||
const product = org.products[0]
|
||
const today = new Date()
|
||
const created: string[] = []
|
||
const N = 50
|
||
|
||
for (let i = 0; i < N; i++) {
|
||
const saleInput = {
|
||
date: today.toISOString(),
|
||
storeId: org.refs.storeId,
|
||
retailPointId: org.refs.retailPointId ?? null,
|
||
customerId: null,
|
||
currencyId: org.refs.currencyId,
|
||
payment: 1, // Cash
|
||
paidCash: 100,
|
||
paidCard: 0,
|
||
notes: `s27 sale #${i}`,
|
||
lines: [
|
||
{
|
||
productId: product.id,
|
||
quantity: 1,
|
||
unitPrice: 100,
|
||
discount: 0,
|
||
vatPercent: 0,
|
||
},
|
||
],
|
||
}
|
||
const createRes = await request<{ id: string; number: string }>(
|
||
'/api/sales/retail', { token: tok, body: saleInput },
|
||
)
|
||
await request(`/api/sales/retail/${createRes.id}/post`, {
|
||
token: tok, body: {},
|
||
})
|
||
created.push(createRes.id)
|
||
}
|
||
expect(created.length).toBe(N)
|
||
|
||
// ── 3. Проверяем FiscalNumber у каждой проданной (берём первые 5 в выборку).
|
||
let mockCount = 0
|
||
for (const id of created.slice(0, 5)) {
|
||
const sale = await request<{ fiscalNumber: string | null; fiscalQrCode: string | null }>(
|
||
`/api/sales/retail/${id}`, { token: tok },
|
||
)
|
||
if (sale.fiscalNumber?.startsWith('MOCK-')) mockCount++
|
||
}
|
||
expect(mockCount, '5/5 первых чеков имеют MOCK-FiscalNumber').toBe(5)
|
||
|
||
// ── 4. Sales-отчёт за день: 50 транзакций или ≥ 50 (если предыдущие
|
||
// прогоны оставили данные). И revenue ≥ 5000 (50 × 100).
|
||
const fromDate = new Date(today)
|
||
fromDate.setHours(0, 0, 0, 0)
|
||
const toDate = new Date(today)
|
||
toDate.setHours(23, 59, 59, 999)
|
||
type SalesRow = { key: string; label: string; revenue: number; transactions: number }
|
||
const report = await request<SalesRow[]>(
|
||
`/api/reports/sales?from=${fromDate.toISOString()}&to=${toDate.toISOString()}&groupBy=period:day`,
|
||
{ token: tok },
|
||
)
|
||
const totalTx = report.reduce((s, r) => s + r.transactions, 0)
|
||
const totalRevenue = report.reduce((s, r) => s + Number(r.revenue), 0)
|
||
expect(totalTx, 'есть ≥50 транзакций').toBeGreaterThanOrEqual(N)
|
||
expect(totalRevenue).toBeGreaterThanOrEqual(N * 100)
|
||
|
||
// ── 5. ABC-отчёт: наш товар = класс A (мы продаём только его).
|
||
type AbcRow = { productId: string; abcClass: string; share: number }
|
||
const abc = await request<AbcRow[]>(
|
||
`/api/reports/abc?from=${fromDate.toISOString()}&to=${toDate.toISOString()}&metric=revenue`,
|
||
{ token: tok },
|
||
)
|
||
const ourRow = abc.find(x => x.productId === product.id)
|
||
expect(ourRow, 'наш товар присутствует в ABC').toBeTruthy()
|
||
expect(ourRow!.abcClass).toBe('A')
|
||
expect(Number(ourRow!.share)).toBeGreaterThan(0.5)
|
||
})
|
||
})
|