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>
57 lines
2.8 KiB
TypeScript
57 lines
2.8 KiB
TypeScript
/**
|
||
* Sprint 16 — flows 06 multi-tenant isolation (3 flows):
|
||
* 6.1 org A видит свои товары, в org B их нет в списке
|
||
* 6.2 org B → GET /api/catalog/products/{id-from-A} → 404
|
||
* 6.3 org B → GET /api/sales/retail/{id-from-A} → 404
|
||
*/
|
||
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'
|
||
|
||
test.describe('flow 06 — multi-tenant isolation @smoke', () => {
|
||
test('6.1 список товаров org B не содержит ID товара org A', async () => {
|
||
const A = await OrgFactory.for('mt61A').withProducts(1).build()
|
||
const B = await OrgFactory.for('mt61B').withProducts(1).build()
|
||
const aProductId = A.products[0]!.id
|
||
const bList = await request<{ items: Array<{ id: string }> }>(
|
||
Endpoints.products + '?pageSize=500', { token: B.session.accessToken },
|
||
)
|
||
// Изоляция по Id — B не должен видеть Id A среди своих.
|
||
expect(bList.items.some(p => p.id === aProductId), 'B не должен видеть продукт A').toBeFalsy()
|
||
// У B должен быть свой продукт.
|
||
expect(bList.items.some(p => p.id === B.products[0]!.id)).toBeTruthy()
|
||
})
|
||
|
||
test('6.2 GET product-by-id org A под токеном org B → 404', async () => {
|
||
const A = await OrgFactory.for('mt62A').withProducts(1).build()
|
||
const B = await OrgFactory.for('mt62B').build()
|
||
const resp = await fetch(
|
||
`${process.env.E2E_ADMIN_URL ?? 'https://test.admin.food-market.kz'}${Endpoints.products}/${A.products[0]!.id}`,
|
||
{ headers: { Authorization: `Bearer ${B.session.accessToken}` } },
|
||
)
|
||
expect(resp.status).toBe(404)
|
||
})
|
||
|
||
test('6.3 GET retail-sale org A под токеном org B → 404', async () => {
|
||
const A = await OrgFactory.for('mt63A').withProducts(1).withSupplies(1).build()
|
||
const B = await OrgFactory.for('mt63B').build()
|
||
// Создаём чек у A
|
||
const draft = await request<{ id: string }>(Endpoints.retailSales, {
|
||
token: A.session.accessToken,
|
||
body: {
|
||
date: new Date().toISOString(),
|
||
storeId: A.refs.storeId, retailPointId: A.refs.retailPointId, currencyId: A.refs.currencyId,
|
||
payment: 0, isReturn: false,
|
||
lines: [{ productId: A.products[0]!.id, quantity: 1, unitPrice: 100, discount: 0, vatPercent: 12 }],
|
||
subtotal: 100, discountTotal: 0, total: 100, paidCash: 100, paidCard: 0,
|
||
},
|
||
})
|
||
const resp = await fetch(
|
||
`${process.env.E2E_ADMIN_URL ?? 'https://test.admin.food-market.kz'}${Endpoints.retailSales}/${draft.id}`,
|
||
{ headers: { Authorization: `Bearer ${B.session.accessToken}` } },
|
||
)
|
||
expect(resp.status).toBe(404)
|
||
})
|
||
})
|