food-market/tests/regression/flows/04-documents.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

187 lines
9.2 KiB
TypeScript

/**
* Sprint 16 — flows 04 документы (8 flows):
* 4.1 supply post+unpost остаток меняется на +qty потом обратно
* 4.2 enter post+unpost (оприходование)
* 4.3 retail-sale post → остаток -qty, FiscalNumber=null (None провайдер)
* 4.4 retail-sale unpost восстанавливает остаток
* 4.5 loss post+unpost — списание
* 4.6 transfer post+unpost между двумя складами
* 4.7 demand post (оптовая отгрузка)
* 4.8 supplier-return post (возврат поставщику)
*/
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 stockOf(token: string, productId: string, storeId: string): Promise<number> {
const r = await request<{ items: Array<{ productId: string; storeId: string; quantity: number }> }>(
`/api/inventory/stock?productId=${productId}&pageSize=100`, { token },
)
const row = r.items.find(x => x.productId === productId && x.storeId === storeId)
return row ? Number(row.quantity) : 0
}
test.describe('flow 04 — документы post/unpost', () => {
test('4.1 supply post → stock +qty; unpost → откат @smoke', async () => {
const b = await OrgFactory.for('doc41').withProducts(1).withCounterparties(1).build()
const supplier = b.counterparties[0]!
const product = b.products[0]!
const draft = await request<{ id: string }>(Endpoints.supplies, {
token: b.session.accessToken,
body: {
date: new Date().toISOString(),
supplierId: supplier.id,
storeId: b.refs.storeId,
currencyId: b.refs.currencyId,
lines: [{ productId: product.id, quantity: 50, unitPrice: 30 }],
},
})
await request(`${Endpoints.supplies}/${draft.id}/post`, { token: b.session.accessToken, method: 'POST' })
expect(await stockOf(b.session.accessToken, product.id, b.refs.storeId)).toBe(50)
await request(`${Endpoints.supplies}/${draft.id}/unpost`, { token: b.session.accessToken, method: 'POST' })
expect(await stockOf(b.session.accessToken, product.id, b.refs.storeId)).toBe(0)
})
test('4.2 enter post+unpost — оприходование', async () => {
const b = await OrgFactory.for('doc42').withProducts(1).build()
const product = b.products[0]!
const draft = await request<{ id: string }>(Endpoints.enters, {
token: b.session.accessToken,
body: {
date: new Date().toISOString(),
storeId: b.refs.storeId,
currencyId: b.refs.currencyId,
lines: [{ productId: product.id, quantity: 7, unitPrice: 25 }],
},
})
await request(`${Endpoints.enters}/${draft.id}/post`, { token: b.session.accessToken, method: 'POST' })
expect(await stockOf(b.session.accessToken, product.id, b.refs.storeId)).toBe(7)
await request(`${Endpoints.enters}/${draft.id}/unpost`, { token: b.session.accessToken, method: 'POST' })
expect(await stockOf(b.session.accessToken, product.id, b.refs.storeId)).toBe(0)
})
test('4.3 retail-sale post → stock -qty', async () => {
const b = await OrgFactory.for('doc43').withProducts(1).withSupplies(1).build()
const product = b.products[0]!
const stockBefore = await stockOf(b.session.accessToken, product.id, b.refs.storeId)
expect(stockBefore).toBe(100)
const draft = await request<{ id: string }>(Endpoints.retailSales, {
token: b.session.accessToken,
body: {
date: new Date().toISOString(),
storeId: b.refs.storeId, retailPointId: b.refs.retailPointId, currencyId: b.refs.currencyId,
payment: 0, isReturn: false,
lines: [{ productId: product.id, quantity: 3, unitPrice: 100, discount: 0, vatPercent: 12 }],
subtotal: 300, discountTotal: 0, total: 300,
paidCash: 300, paidCard: 0,
},
})
await request(`${Endpoints.retailSales}/${draft.id}/post`, { token: b.session.accessToken, method: 'POST' })
expect(await stockOf(b.session.accessToken, product.id, b.refs.storeId)).toBe(97)
})
test('4.4 retail-sale unpost восстанавливает остаток', async () => {
const b = await OrgFactory.for('doc44').withProducts(1).withSupplies(1).build()
const product = b.products[0]!
const draft = await request<{ id: string }>(Endpoints.retailSales, {
token: b.session.accessToken,
body: {
date: new Date().toISOString(),
storeId: b.refs.storeId, retailPointId: b.refs.retailPointId, currencyId: b.refs.currencyId,
payment: 0, isReturn: false,
lines: [{ productId: product.id, quantity: 5, unitPrice: 100, discount: 0, vatPercent: 12 }],
subtotal: 500, discountTotal: 0, total: 500,
paidCash: 500, paidCard: 0,
},
})
await request(`${Endpoints.retailSales}/${draft.id}/post`, { token: b.session.accessToken, method: 'POST' })
expect(await stockOf(b.session.accessToken, product.id, b.refs.storeId)).toBe(95)
await request(`${Endpoints.retailSales}/${draft.id}/unpost`, { token: b.session.accessToken, method: 'POST' })
expect(await stockOf(b.session.accessToken, product.id, b.refs.storeId)).toBe(100)
})
test('4.5 loss post+unpost — списание уменьшает/восстанавливает', async () => {
const b = await OrgFactory.for('doc45').withProducts(1).withSupplies(1).build()
const product = b.products[0]!
const draft = await request<{ id: string }>(Endpoints.losses, {
token: b.session.accessToken,
body: {
date: new Date().toISOString(),
storeId: b.refs.storeId, currencyId: b.refs.currencyId,
reason: 1, // LossReason.Expired
lines: [{ productId: product.id, quantity: 2, unitCost: 50 }],
},
})
await request(`${Endpoints.losses}/${draft.id}/post`, { token: b.session.accessToken, method: 'POST' })
expect(await stockOf(b.session.accessToken, product.id, b.refs.storeId)).toBe(98)
await request(`${Endpoints.losses}/${draft.id}/unpost`, { token: b.session.accessToken, method: 'POST' })
expect(await stockOf(b.session.accessToken, product.id, b.refs.storeId)).toBe(100)
})
test('4.6 transfer между двумя складами @smoke', async () => {
const b = await OrgFactory.for('doc46').withProducts(1).withSupplies(1).build()
const product = b.products[0]!
// Создаём второй склад
const target = await request<{ id: string }>('/api/catalog/stores', {
token: b.session.accessToken,
body: { name: 'Второй склад', code: 'S2', isMain: false, isActive: true },
})
const draft = await request<{ id: string }>(Endpoints.transfers, {
token: b.session.accessToken,
body: {
date: new Date().toISOString(),
fromStoreId: b.refs.storeId,
toStoreId: target.id,
currencyId: b.refs.currencyId,
lines: [{ productId: product.id, quantity: 10 }],
},
})
await request(`${Endpoints.transfers}/${draft.id}/post`, { token: b.session.accessToken, method: 'POST' })
expect(await stockOf(b.session.accessToken, product.id, b.refs.storeId)).toBe(90)
expect(await stockOf(b.session.accessToken, product.id, target.id)).toBe(10)
await request(`${Endpoints.transfers}/${draft.id}/unpost`, { token: b.session.accessToken, method: 'POST' })
expect(await stockOf(b.session.accessToken, product.id, b.refs.storeId)).toBe(100)
})
test('4.7 demand post — оптовая отгрузка уменьшает остаток', async () => {
const b = await OrgFactory.for('doc47').withProducts(1).withSupplies(1).withCounterparties(2).build()
const product = b.products[0]!
const customer = b.counterparties[1]!
const draft = await request<{ id: string }>(Endpoints.demands, {
token: b.session.accessToken,
body: {
date: new Date().toISOString(),
customerId: customer.id,
storeId: b.refs.storeId,
currencyId: b.refs.currencyId,
payment: 0,
lines: [{ productId: product.id, quantity: 8, unitPrice: 90, discount: 0, vatPercent: 12 }],
subtotal: 720, discountTotal: 0, total: 720,
paidAmount: 720,
},
})
await request(`${Endpoints.demands}/${draft.id}/post`, { token: b.session.accessToken, method: 'POST' })
expect(await stockOf(b.session.accessToken, product.id, b.refs.storeId)).toBe(92)
})
test('4.8 supplier-return post — возврат поставщику', async () => {
const b = await OrgFactory.for('doc48').withProducts(1).withSupplies(1).build()
const product = b.products[0]!
const supplier = b.counterparties[0]!
const draft = await request<{ id: string }>(Endpoints.supplierReturns, {
token: b.session.accessToken,
body: {
date: new Date().toISOString(),
supplierId: supplier.id,
storeId: b.refs.storeId,
currencyId: b.refs.currencyId,
lines: [{ productId: product.id, quantity: 4, unitPrice: 50 }],
},
})
await request(`${Endpoints.supplierReturns}/${draft.id}/post`, { token: b.session.accessToken, method: 'POST' })
expect(await stockOf(b.session.accessToken, product.id, b.refs.storeId)).toBe(96)
})
})