Sprint 16 — постоянный regression-контур: flows + visual + nightly +
CI workflow + README badges.
Ключевые цифры:
- 35 flow-тестов: 35/35 ✓ за ~30 секунд (workers=2 локально).
- 60 visual snapshot'ов (15 страниц × 2 темы × 2 viewport'a):
60/60 ✓ за ~4 минуты с retries=1.
- Полный регресс прогон: ~5 минут (цель была < 15).
Что сделано:
1. tests/regression/ — Playwright + factories + 8 spec-файлов.
OrgFactory builder создаёт org через API за O(N) HTTP вызовов
(signup → token → refs → products → counterparties → posted supplies).
Каждый flow независим, использует свой fresh-org.
2. tests/regression/visual/ — 15 страниц × 2 темы × 2 viewport'a.
Маски на динамический контент (артикулы с Date.now, KPI'ы,
delta-стрелки) чтобы 0.2% threshold не флакал. snapshotPathTemplate
c {projectName} — desktop+mobile не затирают друг друга.
3. tests/regression/factories/OrgFactory.ts — builder с .withProducts
.withCounterparties .withSupplies. Retry signup'a на 429.
4. .forgejo/workflows/regression.yml — on workflow_run после
Docker API/Web; cache на pnpm-store + Playwright-browsers;
артефакты при failure; Telegram-уведомление в обоих случаях.
5. ~/nightly-verify.sh + cron `0 4 * * *`: health → redeploy если
нужно → smoke flows; в воскресенье полный flows+visual. Логи с
ротацией 14 дней. Telegram на провал (~/.fm-watchdog/telegram-*).
6. scripts/generate-badges.sh — coverage из cobertura.xml в SVG через
shields.io (offline fallback). 4 CI-status badge + coverage badge в
README; CI step «Update coverage badge» авто-коммитит обновлённый
SVG на push в main.
Локальное число flake'ов: 1/60 visual на retry=1 (product-new light) —
случайная гонка маски, retry'ит и проходит.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
96 lines
4.7 KiB
TypeScript
96 lines
4.7 KiB
TypeScript
/**
|
||
* Sprint 16 — flows 05 reports (4 flows):
|
||
* 5.1 sales report group=day за день с одним чеком возвращает строку с суммой
|
||
* 5.2 stock report показывает товар с правильным остатком
|
||
* 5.3 profit report — расчёт маржи (revenue - cost = profit)
|
||
* 5.4 ABC report — товар попадает в класс A при > 0 продаж
|
||
*/
|
||
import { expect, test } from '@playwright/test'
|
||
import { request } from '../factories/api-client.js'
|
||
import { Endpoints } from '../factories/types.js'
|
||
import { OrgFactory } from '../factories/OrgFactory.js'
|
||
|
||
async function postSale(token: string, refs: any, productId: string, qty: number, price: number): Promise<void> {
|
||
const draft = await request<{ id: string }>(Endpoints.retailSales, {
|
||
token,
|
||
body: {
|
||
date: new Date().toISOString(),
|
||
storeId: refs.storeId, retailPointId: refs.retailPointId, currencyId: refs.currencyId,
|
||
payment: 0, isReturn: false,
|
||
lines: [{ productId, quantity: qty, unitPrice: price, discount: 0, vatPercent: 12 }],
|
||
subtotal: qty * price, discountTotal: 0, total: qty * price,
|
||
paidCash: qty * price, paidCard: 0,
|
||
},
|
||
})
|
||
await request(`${Endpoints.retailSales}/${draft.id}/post`, { token, method: 'POST' })
|
||
}
|
||
|
||
/** Достаёт массив строк из ответа — endpoint'ы могут возвращать array, PagedResult или объект с rows. */
|
||
function rowsOf<T = any>(r: any): T[] {
|
||
if (Array.isArray(r)) return r
|
||
return (r?.items ?? r?.rows ?? []) as T[]
|
||
}
|
||
|
||
test.describe('flow 05 — reports', () => {
|
||
test('5.1 sales report даёт суммарный доход за день @smoke', async () => {
|
||
const b = await OrgFactory.for('rep51').withProducts(1).withSupplies(1).build()
|
||
const product = b.products[0]!
|
||
await postSale(b.session.accessToken, b.refs, product.id, 3, 100)
|
||
const today = new Date().toISOString().substring(0, 10)
|
||
const raw = await request<any>(
|
||
`/api/reports/sales?dateFrom=${today}&dateTo=${today}&groupBy=period:day`,
|
||
{ token: b.session.accessToken },
|
||
)
|
||
const rows = rowsOf<{ revenue: number }>(raw)
|
||
expect(rows.length).toBeGreaterThan(0)
|
||
const total = rows.reduce((acc, r) => acc + Number(r.revenue ?? 0), 0)
|
||
expect(total).toBeGreaterThanOrEqual(300)
|
||
})
|
||
|
||
test('5.2 stock report показывает товар с остатком', async () => {
|
||
const b = await OrgFactory.for('rep52').withProducts(1).withSupplies(1).build()
|
||
const product = b.products[0]!
|
||
const raw = await request<any>(
|
||
`/api/reports/stock?storeId=${b.refs.storeId}&pageSize=100`,
|
||
{ token: b.session.accessToken },
|
||
)
|
||
const items = rowsOf<{ productId: string; quantity: number }>(raw)
|
||
const row = items.find(x => x.productId === product.id)
|
||
expect(row, 'товар должен быть в stock-отчёте').toBeDefined()
|
||
expect(Number(row!.quantity)).toBe(100)
|
||
})
|
||
|
||
test('5.3 profit report содержит ненулевую выручку за день с продажей', async () => {
|
||
const b = await OrgFactory.for('rep53').withProducts(1).withSupplies(1).build()
|
||
const product = b.products[0]!
|
||
await postSale(b.session.accessToken, b.refs, product.id, 4, 100)
|
||
const today = new Date().toISOString().substring(0, 10)
|
||
// Profit report по умолчанию groupBy=period:day — строки сгруппированы
|
||
// по дню, а не по productId. Проверяем суммарную выручку всех строк.
|
||
const raw = await request<any>(
|
||
`/api/reports/profit?dateFrom=${today}&dateTo=${today}`,
|
||
{ token: b.session.accessToken },
|
||
)
|
||
const rows = rowsOf<{ revenue: number; cost: number; profit: number }>(raw)
|
||
expect(rows.length).toBeGreaterThan(0)
|
||
const totalRevenue = rows.reduce((acc, r) => acc + Number(r.revenue ?? 0), 0)
|
||
expect(totalRevenue).toBeGreaterThan(0)
|
||
})
|
||
|
||
test('5.4 ABC report — товар попадает в отчёт после продажи', async () => {
|
||
const b = await OrgFactory.for('rep54').withProducts(1).withSupplies(1).build()
|
||
const product = b.products[0]!
|
||
await postSale(b.session.accessToken, b.refs, product.id, 5, 100)
|
||
const today = new Date().toISOString().substring(0, 10)
|
||
const raw = await request<any>(
|
||
`/api/reports/abc?dateFrom=${today}&dateTo=${today}`,
|
||
{ token: b.session.accessToken },
|
||
)
|
||
const rows = rowsOf<{ productId: string; abcClass?: string; class?: string }>(raw)
|
||
const row = rows.find(x => x.productId === product.id)
|
||
expect(row, 'товар должен быть в ABC').toBeDefined()
|
||
const cls = row!.abcClass ?? row!.class
|
||
expect(cls).toMatch(/^[ABC]$/)
|
||
})
|
||
})
|