Декларативные end-to-end сценарии в tests/e2e/. YAML описывает шаги, TypeScript-handler — конкретные API/UI/DB-проверки. Отчёт в Markdown. Структура: - runner.ts : entry, парсит YAML, прогоняет steps, пишет report - run.sh : pnpm install + tsx - lib/api.ts : axios + login() (через /connect/token + /api/me) - lib/db.ts : docker exec psql, resetTenantData(), countRows() - lib/report.ts : Markdown-аккумулятор (steps + bugs + ux + gap + perf) - scenarios/full-cycle.yml : 12 шагов - scenarios/full-cycle.steps.ts : handlers (один на шаг) - README.md : как добавить новый сценарий reset_db в preconditions: - TRUNCATE tenant-таблиц CASCADE - AspNet*/users — оставляем только admin@food-market.local - OpenIddict tokens — все valid → revoked - Реестр products + системные справочники + миграции + platform_settings — НЕ трогаем Запуск: tests/e2e/run.sh full-cycle [--api-only] Первый прогон (--api-only, baseline в reports/full-cycle-2026-05-07-baseline.md): - 8 ✓ / 1 ✗ / 3 ◯ из 12. - Critical bug: Cashier видит /api/organization/employees через API (нет [Authorize(Roles="Admin")] на List endpoint). - High: при CreateOrg через SuperAdmin не сидируются tenant-units — пустой каталог измерений у новой org (DevDataSeeder.SeedTenantReferencesAsync должен вызываться, но не вызывается). - Logic gaps: реестр products tenant-scoped и новая org стартует с пустым каталогом; SuperAdmin /organizations не валидирует ФЛК телефона; Cashier не получает Identity-роль "Cashier" при создании через /employees. UI-шаги (Playwright) в этом коммите не покрыты — runner работает в --api-only режиме. UI-extension добавим следующим коммитом, не блокирует получение полезного отчёта.
103 lines
4.4 KiB
TypeScript
103 lines
4.4 KiB
TypeScript
/**
|
||
* Прямой доступ к Postgres для preconditions (TRUNCATE) и data-checks
|
||
* (SELECT). Работает через docker exec на контейнер food-market-postgres,
|
||
* так что не зависит от того, проброшен ли порт наружу.
|
||
*/
|
||
import { execFileSync } from 'node:child_process'
|
||
|
||
export function psql(sql: string): string {
|
||
// Использую pgcli/psql внутри контейнера. -tA = bare output без заголовков.
|
||
return execFileSync(
|
||
'docker',
|
||
['exec', '-i', 'food-market-postgres', 'psql', '-U', 'food_market', '-d', 'food_market', '-tAv', 'ON_ERROR_STOP=1', '-c', sql],
|
||
{ encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'], maxBuffer: 16 * 1024 * 1024 },
|
||
)
|
||
}
|
||
|
||
export function psqlRows(sql: string): string[][] {
|
||
const out = psql(sql).trim()
|
||
if (!out) return []
|
||
return out.split('\n').map((line) => line.split('|'))
|
||
}
|
||
|
||
/**
|
||
* TRUNCATE для preconditions full-cycle сценария.
|
||
*
|
||
* Реестр товаров и системные справочники — не трогаем. Платформенные настройки,
|
||
* миграции и OpenIddict — не трогаем. Identity SuperAdmin (admin@food-market.local)
|
||
* сохраняем; всех остальных AspNetUsers/UserRoles/Employees/Organizations
|
||
* чистим.
|
||
*/
|
||
export function resetTenantData(): void {
|
||
// Список таблиц получаем из информационной схемы — устойчиво к новым доменным
|
||
// сущностям. Исключаем «keep-list» по подстроке (lowercase). Ниже — порядок
|
||
// вырезания: сначала зависимые таблицы, потом ссылочные.
|
||
const KEEP = new Set([
|
||
// Реестр товаров
|
||
'products', 'product_groups', 'units_of_measure', 'product_packagings',
|
||
'product_barcodes', 'product_prices', 'product_images',
|
||
// Справочники
|
||
'countries', 'currencies', 'price_types', 'employee_roles',
|
||
// Инфраструктура
|
||
'__EFMigrationsHistory', 'platform_settings', 'system_settings',
|
||
])
|
||
const KEEP_PREFIXES = ['OpenIddict']
|
||
|
||
const all = psqlRows(`SELECT tablename FROM pg_tables WHERE schemaname='public' ORDER BY tablename`)
|
||
.map((r) => r[0])
|
||
.filter(Boolean)
|
||
|
||
// Identity-табицы (AspNet*, users, roles) обрабатываем отдельно с фильтром
|
||
// SuperAdmin — нельзя просто TRUNCATE.
|
||
const IDENTITY = ['AspNetUserClaims', 'AspNetUserLogins', 'AspNetUserTokens', 'AspNetUserRoles', 'AspNetRoleClaims', 'users', 'roles']
|
||
const isIdentity = (t: string) => IDENTITY.includes(t)
|
||
const tenantTables = all
|
||
.filter((t) => !KEEP.has(t))
|
||
.filter((t) => !KEEP_PREFIXES.some((p) => t.startsWith(p)))
|
||
.filter((t) => !isIdentity(t))
|
||
|
||
if (tenantTables.length > 0) {
|
||
// Bulk TRUNCATE с CASCADE — порядок не важен.
|
||
const list = tenantTables.map((t) => `"${t}"`).join(', ')
|
||
psql(`TRUNCATE TABLE ${list} RESTART IDENTITY CASCADE;`)
|
||
}
|
||
|
||
// Identity-зачистка: оставляем только SuperAdmin'а.
|
||
psql(`
|
||
DELETE FROM "AspNetUserRoles" ur
|
||
USING users u
|
||
WHERE ur."UserId" = u."Id"
|
||
AND lower(u."Email") <> 'admin@food-market.local';
|
||
|
||
DELETE FROM "AspNetUserClaims" uc
|
||
USING users u
|
||
WHERE uc."UserId" = u."Id"
|
||
AND lower(u."Email") <> 'admin@food-market.local';
|
||
|
||
DELETE FROM "AspNetUserLogins" ul
|
||
USING users u
|
||
WHERE ul."UserId" = u."Id"
|
||
AND lower(u."Email") <> 'admin@food-market.local';
|
||
|
||
DELETE FROM "AspNetUserTokens" ut
|
||
USING users u
|
||
WHERE ut."UserId" = u."Id"
|
||
AND lower(u."Email") <> 'admin@food-market.local';
|
||
|
||
DELETE FROM users WHERE lower("Email") <> 'admin@food-market.local';
|
||
|
||
-- SuperAdmin отвязываем от любой удалённой org.
|
||
UPDATE users SET "OrganizationId" = NULL, "IsActive" = true
|
||
WHERE lower("Email") = 'admin@food-market.local';
|
||
|
||
-- OpenIddict tokens деактивируем (refresh-tokens чужих юзеров).
|
||
UPDATE "OpenIddictTokens" SET "Status" = 'revoked' WHERE "Status" = 'valid';
|
||
`)
|
||
}
|
||
|
||
export function countRows(table: string, where = ''): number {
|
||
const sql = `SELECT count(*) FROM "${table}" ${where ? 'WHERE ' + where : ''};`
|
||
const out = psql(sql).trim()
|
||
return Number(out) || 0
|
||
}
|