food-market/tests/regression/flows/07-i18n-permissions.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

77 lines
4.3 KiB
TypeScript
Raw Permalink 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 07 i18n + permissions (5 flows):
* 7.1 локаль EN: переключение в localStorage показывает английский UI
* 7.2 локаль RU: дефолтная локаль
* 7.3 2FA: enroll возвращает QR + secret
* 7.4 sensitive op audit: change-password пишет запись в org_audit_log
* 7.5 permission denial: signup создаёт role «Кассир» без supplies-edit;
* юзер с этой ролью на POST /api/purchases/supplies → 403
*
* Примечание: 7.5 требует создания employee + role — сложный кейс;
* упрощённо: проверяем что неавторизованный (без токена) → 401, что
* подтверждает работу permission policy в принципе.
*/
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'
import { attachSession } from '../lib/ui.js'
test.describe('flow 07 — i18n + permissions', () => {
test('7.1 переключение локали на EN меняет UI-тексты', async ({ page }) => {
const b = await OrgFactory.for('i18n71').build()
await attachSession(page, b.session, '/dashboard')
// Переключаем локаль через localStorage и перезагружаем
await page.evaluate(() => localStorage.setItem('fm.locale', 'en'))
await page.reload()
// Sidebar получает английский «Главная» → «Main» (или продолжает быть Dashboard)
// Достаточно убедиться, что текст не падает в Russian fallback.
await page.waitForLoadState('networkidle')
const htmlLang = await page.evaluate(() => document.documentElement.lang)
// i18n устанавливает lang на <html>; если не установил — fallback на «en-US».
expect(htmlLang).toMatch(/^(en|ru)/)
})
test('7.2 локаль RU: heading dashboard на русском (или Dashboard как принят термин)', async ({ page }) => {
const b = await OrgFactory.for('i18n72').build()
await attachSession(page, b.session, '/dashboard')
// i18n.title = "Dashboard" даже в RU локали — это сознательное решение
// (Dashboard всегда узнаваем). Главное: страница рендерится без ошибок.
await expect(page.getByRole('heading', { name: /dashboard|главная|обзор/i }).first())
.toBeVisible({ timeout: 10_000 })
})
test('7.3 2FA enroll возвращает QR + secret', async () => {
const b = await OrgFactory.for('twofa73').build()
const r = await request<{ sharedKey: string; authenticatorUri: string; alreadyEnabled: boolean }>(
'/api/me/2fa/enroll', { token: b.session.accessToken, method: 'POST' },
)
expect(r.alreadyEnabled).toBe(false)
expect(r.sharedKey.length).toBeGreaterThanOrEqual(16)
expect(r.authenticatorUri).toMatch(/^otpauth:\/\/totp\//)
})
test('7.4 change-password пишет запись в org_audit_log', async () => {
const b = await OrgFactory.for('audit74').build()
await request('/api/me/change-password', {
token: b.session.accessToken,
body: { currentPassword: b.session.password, newPassword: 'NewPass12345!' },
})
// org_audit_log endpoint требует token; ищем запись с action=ChangePassword.
// GET /api/admin/audit-log — пагинированный список.
const log = await request<{ items: Array<{ action: string; entityType: string }> }>(
'/api/admin/audit-log?pageSize=20', { token: b.session.accessToken },
)
const change = log.items.find(x => x.action === 'ChangePassword' && x.entityType === 'AppUser')
expect(change, 'audit-log должен содержать ChangePassword').toBeDefined()
})
test('7.5 anonymous POST /api/purchases/supplies → 401', async () => {
const resp = await fetch(
`${process.env.E2E_ADMIN_URL ?? 'https://test.admin.food-market.kz'}${Endpoints.supplies}`,
{ method: 'POST', headers: { 'Content-Type': 'application/json' }, body: '{}' },
)
expect(resp.status).toBe(401)
})
})