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>
70 lines
3.1 KiB
TypeScript
70 lines
3.1 KiB
TypeScript
/**
|
||
* Sprint 16 — flows 02 auth (4 flows):
|
||
* 2.1 signup → token → /api/me payload sane
|
||
* 2.2 login через форму /login → редирект на /dashboard
|
||
* 2.3 refresh_token обновляет access_token
|
||
* 2.4 неправильный пароль → 4xx
|
||
*/
|
||
import { expect, test } from '@playwright/test'
|
||
import { request, baseUrl } from '../factories/api-client.js'
|
||
import { OrgFactory } from '../factories/OrgFactory.js'
|
||
|
||
test.describe('flow 02 — auth', () => {
|
||
test('2.1 signup + token + /api/me возвращают консистентный payload @smoke', async () => {
|
||
const b = await OrgFactory.for('auth21').build()
|
||
const me = await request<{ sub: string; email: string; roles: string[]; orgId: string }>(
|
||
'/api/me', { token: b.session.accessToken },
|
||
)
|
||
expect(me.email).toBe(b.session.email)
|
||
expect(me.orgId).toBe(b.session.orgId)
|
||
expect(me.roles).toContain('Admin')
|
||
})
|
||
|
||
test('2.2 login форма редиректит из /login на внутреннюю страницу', async ({ page }) => {
|
||
const b = await OrgFactory.for('auth22').build()
|
||
await page.goto('/login')
|
||
await page.getByLabel('Email').fill(b.session.email)
|
||
await page.getByLabel('Пароль').fill(b.session.password)
|
||
await page.getByRole('button', { name: /войти/i }).click()
|
||
// После login юзер уходит с /login. Куда конкретно зависит от onboarding-state
|
||
// (на свежей org может быть Onboarding-page по «/», на seeded — /dashboard).
|
||
await page.waitForURL((url) => !url.pathname.includes('/login'), { timeout: 15_000 })
|
||
expect(page.url()).not.toContain('/login')
|
||
})
|
||
|
||
test('2.3 refresh_token успешно меняет на новый access_token', async () => {
|
||
const b = await OrgFactory.for('auth23').build()
|
||
const body = new URLSearchParams({
|
||
grant_type: 'refresh_token',
|
||
refresh_token: b.session.refreshToken,
|
||
client_id: 'food-market-web',
|
||
scope: 'openid profile email roles api offline_access',
|
||
}).toString()
|
||
const r = await request<{ access_token: string; refresh_token: string }>(
|
||
'/connect/token',
|
||
{ body, headers: { 'Content-Type': 'application/x-www-form-urlencoded' } },
|
||
)
|
||
expect(r.access_token).not.toBe(b.session.accessToken)
|
||
expect(r.access_token.length).toBeGreaterThan(100)
|
||
})
|
||
|
||
test('2.4 неправильный пароль → 400 invalid_grant', async () => {
|
||
const b = await OrgFactory.for('auth24').build()
|
||
const body = new URLSearchParams({
|
||
grant_type: 'password',
|
||
username: b.session.email,
|
||
password: 'WrongPass!',
|
||
client_id: 'food-market-web',
|
||
scope: 'openid profile email roles api offline_access',
|
||
}).toString()
|
||
const resp = await fetch(`${baseUrl}/connect/token`, {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||
body,
|
||
})
|
||
expect(resp.status).toBe(400)
|
||
const j = await resp.json() as { error: string }
|
||
expect(j.error).toBe('invalid_grant')
|
||
})
|
||
})
|