food-market/tests/regression/flows/05-reports.spec.ts
nns 1989db32bb test(s16): regression suite 35 flows + visual 60 snapshots + nightly + CI badges
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>
2026-06-07 16:14:11 +05:00

96 lines
4.7 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 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]$/)
})
})