diff --git a/tests/e2e/.gitignore b/tests/e2e/.gitignore new file mode 100644 index 0000000..552f221 --- /dev/null +++ b/tests/e2e/.gitignore @@ -0,0 +1,2 @@ +node_modules/ +*.log diff --git a/tests/e2e/README.md b/tests/e2e/README.md new file mode 100644 index 0000000..1545c3a --- /dev/null +++ b/tests/e2e/README.md @@ -0,0 +1,63 @@ +# tests/e2e + +Декларативные end-to-end сценарии. Один YAML описывает шаги, TypeScript-handler — конкретные API/UI/DB-проверки. Отчёт в Markdown. + +## Запуск + +```bash +tests/e2e/run.sh full-cycle # полный прогон (API + UI) +tests/e2e/run.sh full-cycle --api-only # без Playwright, только axios + DB +``` + +Первый запуск установит `node_modules/` (axios, pg, playwright, js-yaml, tsx). + +Отчёт: `tests/e2e/reports/-.md`. + +## Структура + +``` +tests/e2e/ +├── runner.ts # entry: парсит YAML, прогоняет steps, пишет report +├── run.sh # wrapper: pnpm install + tsx +├── lib/ +│ ├── api.ts # axios + login() +│ ├── db.ts # docker exec psql, resetTenantData(), countRows() +│ └── report.ts # markdown-аккумулятор +└── scenarios/ + ├── full-cycle.yml # декларация шагов + └── full-cycle.steps.ts # код handler'ов +``` + +## Preconditions + +Поле `reset_db: true` в YAML вызывает `resetTenantData()` в `lib/db.ts`. Что чистится: + +- Tenant-таблицы (organizations, employees, supplies, retail_sales, …) — TRUNCATE … CASCADE. +- AspNetUsers / users / AspNetUserRoles — оставляем только `admin@food-market.local`. +- OpenIddict tokens — все valid → revoked. + +Что **НЕ** чистится (берегём как baseline): + +- Реестр товаров: `products`, `product_groups`, `units_of_measure`, `product_packagings`, `product_barcodes`, `product_prices`, `product_images`. +- Системные справочники: `countries`, `currencies`, `price_types`, `employee_roles`. +- `__EFMigrationsHistory`, `OpenIddict*` таблицы (структура), `platform_settings`, `system_settings`. + +После TRUNCATE — smoke `login(admin@food-market.local)`. Если падает — runner выходит с кодом 3. + +## Добавление сценария + +1. `scenarios/.yml` — мета + список `steps[].id`. +2. `scenarios/.steps.ts` — экспортируй функции с теми же id (`async function stepXX_foo({ ctx, step, report })`). +3. Запусти `tests/e2e/run.sh `. + +`ctx` — общий объект между шагами для прокидывания id'ов созданных сущностей и токенов сессий. + +`step.checks` — массив проверок (api/ui/db). Если хоть одна `ok=false` — шаг помечен как fail. + +`report.bug()` / `report.ux()` / `report.gap()` / `report.perf()` — категоризованные находки в финальной секции. + +## Зависимости + +- Postgres контейнер `food-market-postgres` (psql вызывается через docker exec). +- API на `https://admin.food-market.kz` (или `E2E_ADMIN_URL` env). +- Playwright headless chromium для UI-проверок (`pnpm exec playwright install chromium` если нет). diff --git a/tests/e2e/lib/api.ts b/tests/e2e/lib/api.ts new file mode 100644 index 0000000..7f3e747 --- /dev/null +++ b/tests/e2e/lib/api.ts @@ -0,0 +1,51 @@ +import axios, { AxiosInstance } from 'axios' +import { Agent as HttpsAgent } from 'node:https' + +export const ADMIN_BASE = process.env.E2E_ADMIN_URL ?? 'https://admin.food-market.kz' + +const httpsAgent = new HttpsAgent({ rejectUnauthorized: false }) + +export interface AuthSession { + accessToken: string + refreshToken?: string + email: string + roles: string[] + orgId: string | null +} + +export function makeClient(token?: string): AxiosInstance { + return axios.create({ + baseURL: ADMIN_BASE, + httpsAgent, + headers: token ? { Authorization: `Bearer ${token}` } : {}, + validateStatus: () => true, // не кидать на 4xx — runner сам решает + }) +} + +export async function login(email: string, password: string): Promise { + const body = new URLSearchParams({ + grant_type: 'password', + username: email, + password, + client_id: 'food-market-web', + scope: 'openid profile email roles api offline_access', + }) + const res = await makeClient().post('/connect/token', body, { + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + }) + if (res.status !== 200) { + throw new Error(`login failed for ${email}: ${res.status} ${JSON.stringify(res.data)}`) + } + const tok = res.data + const me = await makeClient(tok.access_token).get('/api/me') + if (me.status !== 200) { + throw new Error(`/api/me after login failed: ${me.status} ${JSON.stringify(me.data)}`) + } + return { + accessToken: tok.access_token, + refreshToken: tok.refresh_token, + email: me.data.email, + roles: me.data.roles ?? [], + orgId: me.data.orgId ?? null, + } +} diff --git a/tests/e2e/lib/db.ts b/tests/e2e/lib/db.ts new file mode 100644 index 0000000..ebfd85a --- /dev/null +++ b/tests/e2e/lib/db.ts @@ -0,0 +1,102 @@ +/** + * Прямой доступ к 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 +} diff --git a/tests/e2e/lib/report.ts b/tests/e2e/lib/report.ts new file mode 100644 index 0000000..cdb430c --- /dev/null +++ b/tests/e2e/lib/report.ts @@ -0,0 +1,172 @@ +/** + * Накопитель отчёта по e2e-сценарию. Markdown с секциями step-by-step. + * Каждый step фиксирует API/UI checks, статус (pass/fail/warn), время, + * и в конце runner добавляет summary + категоризованные рекомендации. + */ +import { writeFileSync, mkdirSync } from 'node:fs' +import { dirname } from 'node:path' + +export type StepStatus = 'pass' | 'fail' | 'warn' | 'skip' + +export interface CheckResult { + kind: 'api' | 'ui' | 'db' + description: string + ok: boolean + detail?: string +} + +export interface Step { + id: string + title: string + status: StepStatus + checks: CheckResult[] + durationMs: number + notes: string[] + screenshot?: string +} + +export interface Bug { + step: string + severity: 'critical' | 'high' | 'medium' | 'low' + title: string + detail: string + fix?: string +} + +export class Report { + scenario: string + startedAt = new Date() + steps: Step[] = [] + bugs: Bug[] = [] + uxNotes: string[] = [] + logicGaps: string[] = [] + perfNotes: string[] = [] + + constructor(scenario: string) { + this.scenario = scenario + } + + startStep(id: string, title: string): Step { + const step: Step = { id, title, status: 'pass', checks: [], durationMs: 0, notes: [] } + this.steps.push(step) + return step + } + + finishStep(step: Step, t0: number) { + step.durationMs = Date.now() - t0 + if (step.checks.some((c) => !c.ok)) step.status = 'fail' + } + + bug(b: Bug) { this.bugs.push(b) } + ux(s: string) { this.uxNotes.push(s) } + gap(s: string) { this.logicGaps.push(s) } + perf(s: string) { this.perfNotes.push(s) } + + finalize(filePath: string) { + mkdirSync(dirname(filePath), { recursive: true }) + writeFileSync(filePath, this.toMarkdown(), 'utf8') + } + + private icon(s: StepStatus): string { + switch (s) { + case 'pass': return '✓' + case 'fail': return '✗' + case 'warn': return '⚠' + case 'skip': return '◯' + } + } + + toMarkdown(): string { + const passed = this.steps.filter((s) => s.status === 'pass').length + const failed = this.steps.filter((s) => s.status === 'fail').length + const warns = this.steps.filter((s) => s.status === 'warn').length + const skipped = this.steps.filter((s) => s.status === 'skip').length + + const lines: string[] = [] + lines.push(`# E2E report: ${this.scenario}`) + lines.push('') + lines.push(`Запущен: ${this.startedAt.toISOString()}`) + lines.push(`Длительность: ${(this.steps.reduce((a, s) => a + s.durationMs, 0) / 1000).toFixed(1)}с`) + lines.push('') + lines.push(`**Итог:** ${passed} ✓ / ${failed} ✗ / ${warns} ⚠ / ${skipped} ◯ (всего ${this.steps.length})`) + lines.push('') + + for (const step of this.steps) { + lines.push(`## ${this.icon(step.status)} Step ${step.id}: ${step.title}`) + lines.push('') + lines.push(`Длительность: ${step.durationMs}мс`) + if (step.checks.length > 0) { + lines.push('') + lines.push('| Тип | Проверка | Результат |') + lines.push('|---|---|---|') + for (const c of step.checks) { + const cell = c.detail ? `${c.detail.replace(/\|/g, '\\|').slice(0, 200)}` : '' + lines.push(`| ${c.kind} | ${c.description} | ${c.ok ? '✓' : '✗'} ${cell} |`) + } + } + if (step.notes.length > 0) { + lines.push('') + for (const n of step.notes) lines.push(`> ${n}`) + } + if (step.screenshot) { + lines.push('') + lines.push(`Скриншот: \`${step.screenshot}\``) + } + lines.push('') + } + + lines.push('## Summary') + lines.push('') + lines.push(`- Passed: ${passed}`) + lines.push(`- Failed: ${failed}`) + lines.push(`- Warnings: ${warns}`) + lines.push(`- Skipped: ${skipped}`) + lines.push('') + + if (this.bugs.length > 0) { + lines.push('## Critical bugs') + lines.push('') + const order: Bug['severity'][] = ['critical', 'high', 'medium', 'low'] + for (const sev of order) { + const list = this.bugs.filter((b) => b.severity === sev) + if (list.length === 0) continue + lines.push(`### ${sev.toUpperCase()}`) + lines.push('') + for (const b of list) { + lines.push(`- **[${b.step}] ${b.title}**`) + lines.push(` - ${b.detail}`) + if (b.fix) lines.push(` - Fix: ${b.fix}`) + } + lines.push('') + } + } else { + lines.push('## Critical bugs') + lines.push('') + lines.push('Нет.') + lines.push('') + } + + if (this.uxNotes.length > 0) { + lines.push('## UX recommendations') + lines.push('') + for (const n of this.uxNotes) lines.push(`- ${n}`) + lines.push('') + } + + if (this.logicGaps.length > 0) { + lines.push('## Logic gaps') + lines.push('') + for (const n of this.logicGaps) lines.push(`- ${n}`) + lines.push('') + } + + if (this.perfNotes.length > 0) { + lines.push('## Performance observations') + lines.push('') + for (const n of this.perfNotes) lines.push(`- ${n}`) + lines.push('') + } + + return lines.join('\n') + } +} diff --git a/tests/e2e/package.json b/tests/e2e/package.json new file mode 100644 index 0000000..ffcbabc --- /dev/null +++ b/tests/e2e/package.json @@ -0,0 +1,22 @@ +{ + "name": "food-market-e2e", + "version": "0.0.1", + "private": true, + "type": "module", + "scripts": { + "run": "tsx runner.ts" + }, + "dependencies": { + "axios": "^1.7.9", + "js-yaml": "^4.1.0", + "pg": "^8.13.1", + "playwright": "^1.59.1" + }, + "devDependencies": { + "@types/js-yaml": "^4.0.9", + "@types/node": "^20.17.10", + "@types/pg": "^8.11.10", + "tsx": "^4.19.2", + "typescript": "^5.7.2" + } +} diff --git a/tests/e2e/pnpm-lock.yaml b/tests/e2e/pnpm-lock.yaml new file mode 100644 index 0000000..86ed3ea --- /dev/null +++ b/tests/e2e/pnpm-lock.yaml @@ -0,0 +1,709 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + dependencies: + axios: + specifier: ^1.7.9 + version: 1.16.0 + js-yaml: + specifier: ^4.1.0 + version: 4.1.1 + pg: + specifier: ^8.13.1 + version: 8.20.0 + playwright: + specifier: ^1.59.1 + version: 1.59.1 + devDependencies: + '@types/js-yaml': + specifier: ^4.0.9 + version: 4.0.9 + '@types/node': + specifier: ^20.17.10 + version: 20.19.39 + '@types/pg': + specifier: ^8.11.10 + version: 8.20.0 + tsx: + specifier: ^4.19.2 + version: 4.21.0 + typescript: + specifier: ^5.7.2 + version: 5.9.3 + +packages: + + '@esbuild/aix-ppc64@0.27.7': + resolution: {integrity: sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + + '@esbuild/android-arm64@0.27.7': + resolution: {integrity: sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.27.7': + resolution: {integrity: sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + + '@esbuild/android-x64@0.27.7': + resolution: {integrity: sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + + '@esbuild/darwin-arm64@0.27.7': + resolution: {integrity: sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.27.7': + resolution: {integrity: sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + + '@esbuild/freebsd-arm64@0.27.7': + resolution: {integrity: sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.27.7': + resolution: {integrity: sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + + '@esbuild/linux-arm64@0.27.7': + resolution: {integrity: sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.27.7': + resolution: {integrity: sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-ia32@0.27.7': + resolution: {integrity: sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-loong64@0.27.7': + resolution: {integrity: sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-mips64el@0.27.7': + resolution: {integrity: sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-ppc64@0.27.7': + resolution: {integrity: sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-riscv64@0.27.7': + resolution: {integrity: sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-s390x@0.27.7': + resolution: {integrity: sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.27.7': + resolution: {integrity: sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-arm64@0.27.7': + resolution: {integrity: sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.27.7': + resolution: {integrity: sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-arm64@0.27.7': + resolution: {integrity: sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.27.7': + resolution: {integrity: sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openharmony-arm64@0.27.7': + resolution: {integrity: sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + + '@esbuild/sunos-x64@0.27.7': + resolution: {integrity: sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.27.7': + resolution: {integrity: sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.27.7': + resolution: {integrity: sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-x64@0.27.7': + resolution: {integrity: sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + + '@types/js-yaml@4.0.9': + resolution: {integrity: sha512-k4MGaQl5TGo/iipqb2UDG2UwjXziSWkh0uysQelTlJpX1qGlpUZYm8PnO4DxG1qBomtJUdYJ6qR6xdIah10JLg==} + + '@types/node@20.19.39': + resolution: {integrity: sha512-orrrD74MBUyK8jOAD/r0+lfa1I2MO6I+vAkmAWzMYbCcgrN4lCrmK52gRFQq/JRxfYPfonkr4b0jcY7Olqdqbw==} + + '@types/pg@8.20.0': + resolution: {integrity: sha512-bEPFOaMAHTEP1EzpvHTbmwR8UsFyHSKsRisLIHVMXnpNefSbGA1bD6CVy+qKjGSqmZqNqBDV2azOBo8TgkcVow==} + + argparse@2.0.1: + resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} + + asynckit@0.4.0: + resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==} + + axios@1.16.0: + resolution: {integrity: sha512-6hp5CwvTPlN2A31g5dxnwAX0orzM7pmCRDLnZSX772mv8WDqICwFjowHuPs04Mc8deIld1+ejhtaMn5vp6b+1w==} + + call-bind-apply-helpers@1.0.2: + resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} + engines: {node: '>= 0.4'} + + combined-stream@1.0.8: + resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==} + engines: {node: '>= 0.8'} + + delayed-stream@1.0.0: + resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==} + engines: {node: '>=0.4.0'} + + dunder-proto@1.0.1: + resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} + engines: {node: '>= 0.4'} + + es-define-property@1.0.1: + resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==} + engines: {node: '>= 0.4'} + + es-errors@1.3.0: + resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} + engines: {node: '>= 0.4'} + + es-object-atoms@1.1.1: + resolution: {integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==} + engines: {node: '>= 0.4'} + + es-set-tostringtag@2.1.0: + resolution: {integrity: sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==} + engines: {node: '>= 0.4'} + + esbuild@0.27.7: + resolution: {integrity: sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==} + engines: {node: '>=18'} + hasBin: true + + follow-redirects@1.16.0: + resolution: {integrity: sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==} + engines: {node: '>=4.0'} + peerDependencies: + debug: '*' + peerDependenciesMeta: + debug: + optional: true + + form-data@4.0.5: + resolution: {integrity: sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==} + engines: {node: '>= 6'} + + fsevents@2.3.2: + resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + function-bind@1.1.2: + resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} + + get-intrinsic@1.3.0: + resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} + engines: {node: '>= 0.4'} + + get-proto@1.0.1: + resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==} + engines: {node: '>= 0.4'} + + get-tsconfig@4.14.0: + resolution: {integrity: sha512-yTb+8DXzDREzgvYmh6s9vHsSVCHeC0G3PI5bEXNBHtmshPnO+S5O7qgLEOn0I5QvMy6kpZN8K1NKGyilLb93wA==} + + gopd@1.2.0: + resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} + engines: {node: '>= 0.4'} + + has-symbols@1.1.0: + resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==} + engines: {node: '>= 0.4'} + + has-tostringtag@1.0.2: + resolution: {integrity: sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==} + engines: {node: '>= 0.4'} + + hasown@2.0.3: + resolution: {integrity: sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==} + engines: {node: '>= 0.4'} + + js-yaml@4.1.1: + resolution: {integrity: sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==} + hasBin: true + + math-intrinsics@1.1.0: + resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} + engines: {node: '>= 0.4'} + + mime-db@1.52.0: + resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==} + engines: {node: '>= 0.6'} + + mime-types@2.1.35: + resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==} + engines: {node: '>= 0.6'} + + pg-cloudflare@1.3.0: + resolution: {integrity: sha512-6lswVVSztmHiRtD6I8hw4qP/nDm1EJbKMRhf3HCYaqud7frGysPv7FYJ5noZQdhQtN2xJnimfMtvQq21pdbzyQ==} + + pg-connection-string@2.12.0: + resolution: {integrity: sha512-U7qg+bpswf3Cs5xLzRqbXbQl85ng0mfSV/J0nnA31MCLgvEaAo7CIhmeyrmJpOr7o+zm0rXK+hNnT5l9RHkCkQ==} + + pg-int8@1.0.1: + resolution: {integrity: sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==} + engines: {node: '>=4.0.0'} + + pg-pool@3.13.0: + resolution: {integrity: sha512-gB+R+Xud1gLFuRD/QgOIgGOBE2KCQPaPwkzBBGC9oG69pHTkhQeIuejVIk3/cnDyX39av2AxomQiyPT13WKHQA==} + peerDependencies: + pg: '>=8.0' + + pg-protocol@1.13.0: + resolution: {integrity: sha512-zzdvXfS6v89r6v7OcFCHfHlyG/wvry1ALxZo4LqgUoy7W9xhBDMaqOuMiF3qEV45VqsN6rdlcehHrfDtlCPc8w==} + + pg-types@2.2.0: + resolution: {integrity: sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==} + engines: {node: '>=4'} + + pg@8.20.0: + resolution: {integrity: sha512-ldhMxz2r8fl/6QkXnBD3CR9/xg694oT6DZQ2s6c/RI28OjtSOpxnPrUCGOBJ46RCUxcWdx3p6kw/xnDHjKvaRA==} + engines: {node: '>= 16.0.0'} + peerDependencies: + pg-native: '>=3.0.1' + peerDependenciesMeta: + pg-native: + optional: true + + pgpass@1.0.5: + resolution: {integrity: sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==} + + playwright-core@1.59.1: + resolution: {integrity: sha512-HBV/RJg81z5BiiZ9yPzIiClYV/QMsDCKUyogwH9p3MCP6IYjUFu/MActgYAvK0oWyV9NlwM3GLBjADyWgydVyg==} + engines: {node: '>=18'} + hasBin: true + + playwright@1.59.1: + resolution: {integrity: sha512-C8oWjPR3F81yljW9o5OxcWzfh6avkVwDD2VYdwIGqTkl+OGFISgypqzfu7dOe4QNLL2aqcWBmI3PMtLIK233lw==} + engines: {node: '>=18'} + hasBin: true + + postgres-array@2.0.0: + resolution: {integrity: sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==} + engines: {node: '>=4'} + + postgres-bytea@1.0.1: + resolution: {integrity: sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==} + engines: {node: '>=0.10.0'} + + postgres-date@1.0.7: + resolution: {integrity: sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==} + engines: {node: '>=0.10.0'} + + postgres-interval@1.2.0: + resolution: {integrity: sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==} + engines: {node: '>=0.10.0'} + + proxy-from-env@2.1.0: + resolution: {integrity: sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==} + engines: {node: '>=10'} + + resolve-pkg-maps@1.0.0: + resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==} + + split2@4.2.0: + resolution: {integrity: sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==} + engines: {node: '>= 10.x'} + + tsx@4.21.0: + resolution: {integrity: sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==} + engines: {node: '>=18.0.0'} + hasBin: true + + typescript@5.9.3: + resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} + engines: {node: '>=14.17'} + hasBin: true + + undici-types@6.21.0: + resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} + + xtend@4.0.2: + resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==} + engines: {node: '>=0.4'} + +snapshots: + + '@esbuild/aix-ppc64@0.27.7': + optional: true + + '@esbuild/android-arm64@0.27.7': + optional: true + + '@esbuild/android-arm@0.27.7': + optional: true + + '@esbuild/android-x64@0.27.7': + optional: true + + '@esbuild/darwin-arm64@0.27.7': + optional: true + + '@esbuild/darwin-x64@0.27.7': + optional: true + + '@esbuild/freebsd-arm64@0.27.7': + optional: true + + '@esbuild/freebsd-x64@0.27.7': + optional: true + + '@esbuild/linux-arm64@0.27.7': + optional: true + + '@esbuild/linux-arm@0.27.7': + optional: true + + '@esbuild/linux-ia32@0.27.7': + optional: true + + '@esbuild/linux-loong64@0.27.7': + optional: true + + '@esbuild/linux-mips64el@0.27.7': + optional: true + + '@esbuild/linux-ppc64@0.27.7': + optional: true + + '@esbuild/linux-riscv64@0.27.7': + optional: true + + '@esbuild/linux-s390x@0.27.7': + optional: true + + '@esbuild/linux-x64@0.27.7': + optional: true + + '@esbuild/netbsd-arm64@0.27.7': + optional: true + + '@esbuild/netbsd-x64@0.27.7': + optional: true + + '@esbuild/openbsd-arm64@0.27.7': + optional: true + + '@esbuild/openbsd-x64@0.27.7': + optional: true + + '@esbuild/openharmony-arm64@0.27.7': + optional: true + + '@esbuild/sunos-x64@0.27.7': + optional: true + + '@esbuild/win32-arm64@0.27.7': + optional: true + + '@esbuild/win32-ia32@0.27.7': + optional: true + + '@esbuild/win32-x64@0.27.7': + optional: true + + '@types/js-yaml@4.0.9': {} + + '@types/node@20.19.39': + dependencies: + undici-types: 6.21.0 + + '@types/pg@8.20.0': + dependencies: + '@types/node': 20.19.39 + pg-protocol: 1.13.0 + pg-types: 2.2.0 + + argparse@2.0.1: {} + + asynckit@0.4.0: {} + + axios@1.16.0: + dependencies: + follow-redirects: 1.16.0 + form-data: 4.0.5 + proxy-from-env: 2.1.0 + transitivePeerDependencies: + - debug + + call-bind-apply-helpers@1.0.2: + dependencies: + es-errors: 1.3.0 + function-bind: 1.1.2 + + combined-stream@1.0.8: + dependencies: + delayed-stream: 1.0.0 + + delayed-stream@1.0.0: {} + + dunder-proto@1.0.1: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-errors: 1.3.0 + gopd: 1.2.0 + + es-define-property@1.0.1: {} + + es-errors@1.3.0: {} + + es-object-atoms@1.1.1: + dependencies: + es-errors: 1.3.0 + + es-set-tostringtag@2.1.0: + dependencies: + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + has-tostringtag: 1.0.2 + hasown: 2.0.3 + + esbuild@0.27.7: + optionalDependencies: + '@esbuild/aix-ppc64': 0.27.7 + '@esbuild/android-arm': 0.27.7 + '@esbuild/android-arm64': 0.27.7 + '@esbuild/android-x64': 0.27.7 + '@esbuild/darwin-arm64': 0.27.7 + '@esbuild/darwin-x64': 0.27.7 + '@esbuild/freebsd-arm64': 0.27.7 + '@esbuild/freebsd-x64': 0.27.7 + '@esbuild/linux-arm': 0.27.7 + '@esbuild/linux-arm64': 0.27.7 + '@esbuild/linux-ia32': 0.27.7 + '@esbuild/linux-loong64': 0.27.7 + '@esbuild/linux-mips64el': 0.27.7 + '@esbuild/linux-ppc64': 0.27.7 + '@esbuild/linux-riscv64': 0.27.7 + '@esbuild/linux-s390x': 0.27.7 + '@esbuild/linux-x64': 0.27.7 + '@esbuild/netbsd-arm64': 0.27.7 + '@esbuild/netbsd-x64': 0.27.7 + '@esbuild/openbsd-arm64': 0.27.7 + '@esbuild/openbsd-x64': 0.27.7 + '@esbuild/openharmony-arm64': 0.27.7 + '@esbuild/sunos-x64': 0.27.7 + '@esbuild/win32-arm64': 0.27.7 + '@esbuild/win32-ia32': 0.27.7 + '@esbuild/win32-x64': 0.27.7 + + follow-redirects@1.16.0: {} + + form-data@4.0.5: + dependencies: + asynckit: 0.4.0 + combined-stream: 1.0.8 + es-set-tostringtag: 2.1.0 + hasown: 2.0.3 + mime-types: 2.1.35 + + fsevents@2.3.2: + optional: true + + fsevents@2.3.3: + optional: true + + function-bind@1.1.2: {} + + get-intrinsic@1.3.0: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-define-property: 1.0.1 + es-errors: 1.3.0 + es-object-atoms: 1.1.1 + function-bind: 1.1.2 + get-proto: 1.0.1 + gopd: 1.2.0 + has-symbols: 1.1.0 + hasown: 2.0.3 + math-intrinsics: 1.1.0 + + get-proto@1.0.1: + dependencies: + dunder-proto: 1.0.1 + es-object-atoms: 1.1.1 + + get-tsconfig@4.14.0: + dependencies: + resolve-pkg-maps: 1.0.0 + + gopd@1.2.0: {} + + has-symbols@1.1.0: {} + + has-tostringtag@1.0.2: + dependencies: + has-symbols: 1.1.0 + + hasown@2.0.3: + dependencies: + function-bind: 1.1.2 + + js-yaml@4.1.1: + dependencies: + argparse: 2.0.1 + + math-intrinsics@1.1.0: {} + + mime-db@1.52.0: {} + + mime-types@2.1.35: + dependencies: + mime-db: 1.52.0 + + pg-cloudflare@1.3.0: + optional: true + + pg-connection-string@2.12.0: {} + + pg-int8@1.0.1: {} + + pg-pool@3.13.0(pg@8.20.0): + dependencies: + pg: 8.20.0 + + pg-protocol@1.13.0: {} + + pg-types@2.2.0: + dependencies: + pg-int8: 1.0.1 + postgres-array: 2.0.0 + postgres-bytea: 1.0.1 + postgres-date: 1.0.7 + postgres-interval: 1.2.0 + + pg@8.20.0: + dependencies: + pg-connection-string: 2.12.0 + pg-pool: 3.13.0(pg@8.20.0) + pg-protocol: 1.13.0 + pg-types: 2.2.0 + pgpass: 1.0.5 + optionalDependencies: + pg-cloudflare: 1.3.0 + + pgpass@1.0.5: + dependencies: + split2: 4.2.0 + + playwright-core@1.59.1: {} + + playwright@1.59.1: + dependencies: + playwright-core: 1.59.1 + optionalDependencies: + fsevents: 2.3.2 + + postgres-array@2.0.0: {} + + postgres-bytea@1.0.1: {} + + postgres-date@1.0.7: {} + + postgres-interval@1.2.0: + dependencies: + xtend: 4.0.2 + + proxy-from-env@2.1.0: {} + + resolve-pkg-maps@1.0.0: {} + + split2@4.2.0: {} + + tsx@4.21.0: + dependencies: + esbuild: 0.27.7 + get-tsconfig: 4.14.0 + optionalDependencies: + fsevents: 2.3.3 + + typescript@5.9.3: {} + + undici-types@6.21.0: {} + + xtend@4.0.2: {} diff --git a/tests/e2e/reports/full-cycle-2026-05-07-baseline.md b/tests/e2e/reports/full-cycle-2026-05-07-baseline.md new file mode 100644 index 0000000..3ef32a6 --- /dev/null +++ b/tests/e2e/reports/full-cycle-2026-05-07-baseline.md @@ -0,0 +1,126 @@ +# E2E report: full-cycle + +Запущен: 2026-05-07T19:05:02.083Z +Длительность: 5.2с + +**Итог:** 8 ✓ / 1 ✗ / 0 ⚠ / 3 ◯ (всего 12) + +## ✓ Step step01_create_organization: SuperAdmin создаёт «Test Shop {timestamp}» (KZ, KZT, ФЛК телефона) + +Длительность: 1444мс + +| Тип | Проверка | Результат | +|---|---|---| +| api | POST /api/super-admin/organizations → 200 | ✓ org=Test Shop 1778180702083 | +| api | GET /api/super-admin/organizations включает созданную org | ✓ | + +## ✓ Step step02_create_first_admin: SuperAdmin создаёт первого Admin сотрудника организации (Employee + AppUser) + +Длительность: 576мс + +| Тип | Проверка | Результат | +|---|---|---| +| api | Temp password возвращён CreateOrgResult | ✓ len=12 | +| db | employees содержит ровно 1 запись для новой org | ✓ count=1 | +| db | AspNetUserRoles содержит role=Admin для нового user | ✓ Admin | + +## ✓ Step step03_login_as_admin: Логин под admin (не SuperAdmin override) — JWT с org_id и role=Admin + +Длительность: 588мс + +| Тип | Проверка | Результат | +|---|---|---| +| api | /connect/token password-grant выдал токен | ✓ | +| api | /api/me содержит role=Admin | ✓ Admin | +| api | /api/me содержит правильный orgId | ✓ 5c99fced-3182-4b7e-9575-27111c29419d | + +## ✓ Step step04_create_storekeeper_and_cashier: Admin создаёт Storekeeper и Cashier через /settings/employees + +Длительность: 1398мс + +| Тип | Проверка | Результат | +|---|---|---| +| api | employee-roles list | ✓ 200, total=3 | +| api | Системная роль «Кладовщик» существует | ✓ | +| api | Системная роль «Кассир» существует | ✓ | +| api | POST /api/organization/employees (Кладовщик) | ✓ 200 | +| api | POST /api/organization/employees (Кассир) | ✓ 200 | +| db | employees total = 3 (admin + keeper + cashier) | ✓ count=3 | +| api | Невалидный email отвергается при createAccount | ✓ 400 | + +## ✗ Step step05_login_as_cashier: Логин под Cashier — role-guard проверяется (sidebar/role guard) + +Длительность: 699мс + +| Тип | Проверка | Результат | +|---|---|---| +| api | /api/me содержит роль соответствующую системной Cashier | ✗ no Identity roles | +| api | Cashier → GET /api/organization/employees → 403 | ✗ 200 | +| api | Cashier → GET /api/sales/retail-sales — доступен | ✓ 404 | + +## ✓ Step step06_create_counterparty: Admin создаёт «ТОО Тест Поставщик» (БИН + телефон) + +Длительность: 183мс + +| Тип | Проверка | Результат | +|---|---|---| +| api | POST /api/catalog/counterparties | ✓ 201 | + +## ✓ Step step07_ensure_main_store: Проверить что есть main store (из bootstrap), иначе создать + +Длительность: 90мс + +| Тип | Проверка | Результат | +|---|---|---| +| api | GET /api/catalog/stores | ✓ 200 | +| db | Main store существует (от bootstrap) | ✓ Основной склад | + +## ✓ Step step08_create_supply: Admin создаёт Supply Draft (3-5 товаров) и проводит (Posted) + +Длительность: 168мс + +## ◯ Step step09_check_stock_after_supply: GET /api/inventory/stock — quantity увеличился на supplied amount + +Длительность: 1мс + +## ✓ Step step10_ensure_retail_point: Проверить или создать розничную точку (кассу) + +Длительность: 84мс + +| Тип | Проверка | Результат | +|---|---|---| +| api | RetailPoint существует | ✓ Касса 1 | + +## ◯ Step step11_create_retail_sale: Admin создаёт RetailSale, 2 позиции из приёмки, cash, Post + +Длительность: 1мс + +## ◯ Step step12_check_stock_after_sale: GET /api/inventory/stock — quantity уменьшился на sold amount + +Длительность: 0мс + +## Summary + +- Passed: 8 +- Failed: 1 +- Warnings: 0 +- Skipped: 3 + +## Critical bugs + +### CRITICAL + +- **[05] Cashier ВИДИТ список сотрудников через API** + - GET /api/organization/employees вернул 200 для Cashier; ожидается 403 (нет [Authorize(Roles="Admin")] на List? или Cashier имеет Identity-Admin) + - Fix: Поставить [Authorize(Roles="Admin")] на List + проверить что AddToRoleAsync(...,"Cashier") а не "Admin") + +### HIGH + +- **[08] Нет ни одной единицы измерения для нового tenant** + - Bootstrap должен сидить системные units (шт, кг, л) при создании org. + +## Logic gaps + +- SuperAdmin /organizations принимает любой текст в поле phone — серверной валидации ФЛК нет (только в /api/auth/signup для самозаполнения). +- Cashier у созданного через POST /employees не получает Identity-роль "Cashier" — серверная авторизация на /api/sales/retail требует роли Admin или Cashier; нужно либо сидить Identity-роль из org-роли, либо переделать [Authorize] на permissions. +- Реестр products tenant-scoped: новая org стартует с пустым каталогом, хотя в БД лежат products другой org. e2e-сценарий компенсирует созданием 3 products через API. diff --git a/tests/e2e/reports/full-cycle-2026-05-07T19-04-12-955Z.md b/tests/e2e/reports/full-cycle-2026-05-07T19-04-12-955Z.md new file mode 100644 index 0000000..bc20c14 --- /dev/null +++ b/tests/e2e/reports/full-cycle-2026-05-07T19-04-12-955Z.md @@ -0,0 +1,125 @@ +# E2E report: full-cycle + +Запущен: 2026-05-07T19:04:04.557Z +Длительность: 6.3с + +**Итог:** 8 ✓ / 1 ✗ / 0 ⚠ / 3 ◯ (всего 12) + +## ✓ Step step01_create_organization: SuperAdmin создаёт «Test Shop {timestamp}» (KZ, KZT, ФЛК телефона) + +Длительность: 2060мс + +| Тип | Проверка | Результат | +|---|---|---| +| api | POST /api/super-admin/organizations → 200 | ✓ org=Test Shop 1778180644557 | +| api | GET /api/super-admin/organizations включает созданную org | ✓ | + +## ✓ Step step02_create_first_admin: SuperAdmin создаёт первого Admin сотрудника организации (Employee + AppUser) + +Длительность: 630мс + +| Тип | Проверка | Результат | +|---|---|---| +| api | Temp password возвращён CreateOrgResult | ✓ len=12 | +| db | employees содержит ровно 1 запись для новой org | ✓ count=1 | +| db | AspNetUserRoles содержит role=Admin для нового user | ✓ Admin | + +## ✓ Step step03_login_as_admin: Логин под admin (не SuperAdmin override) — JWT с org_id и role=Admin + +Длительность: 565мс + +| Тип | Проверка | Результат | +|---|---|---| +| api | /connect/token password-grant выдал токен | ✓ | +| api | /api/me содержит role=Admin | ✓ Admin | +| api | /api/me содержит правильный orgId | ✓ c607c097-9333-4533-a23a-0042dc24b851 | + +## ✓ Step step04_create_storekeeper_and_cashier: Admin создаёт Storekeeper и Cashier через /settings/employees + +Длительность: 1688мс + +| Тип | Проверка | Результат | +|---|---|---| +| api | employee-roles list | ✓ 200, total=3 | +| api | Системная роль «Кладовщик» существует | ✓ | +| api | Системная роль «Кассир» существует | ✓ | +| api | POST /api/organization/employees (Кладовщик) | ✓ 200 | +| api | POST /api/organization/employees (Кассир) | ✓ 200 | +| db | employees total = 3 (admin + keeper + cashier) | ✓ count=3 | +| api | Невалидный email отвергается при createAccount | ✓ 400 | + +## ✗ Step step05_login_as_cashier: Логин под Cashier — role-guard проверяется (sidebar/role guard) + +Длительность: 670мс + +| Тип | Проверка | Результат | +|---|---|---| +| api | /api/me содержит роль соответствующую системной Cashier | ✗ no Identity roles | +| api | Cashier → GET /api/organization/employees → 403 | ✗ 200 | +| api | Cashier → GET /api/sales/retail-sales — доступен | ✓ 404 | + +## ✓ Step step06_create_counterparty: Admin создаёт «ТОО Тест Поставщик» (БИН + телефон) + +Длительность: 452мс + +| Тип | Проверка | Результат | +|---|---|---| +| api | POST /api/catalog/counterparties | ✓ 201 | + +## ✓ Step step07_ensure_main_store: Проверить что есть main store (из bootstrap), иначе создать + +Длительность: 81мс + +| Тип | Проверка | Результат | +|---|---|---| +| api | GET /api/catalog/stores | ✓ 200 | +| db | Main store существует (от bootstrap) | ✓ Основной склад | + +## ✓ Step step08_create_supply: Admin создаёт Supply Draft (3-5 товаров) и проводит (Posted) + +Длительность: 99мс + +## ◯ Step step09_check_stock_after_supply: GET /api/inventory/stock — quantity увеличился на supplied amount + +Длительность: 1мс + +## ✓ Step step10_ensure_retail_point: Проверить или создать розничную точку (кассу) + +Длительность: 83мс + +| Тип | Проверка | Результат | +|---|---|---| +| api | RetailPoint существует | ✓ Касса 1 | + +## ◯ Step step11_create_retail_sale: Admin создаёт RetailSale, 2 позиции из приёмки, cash, Post + +Длительность: 1мс + +## ◯ Step step12_check_stock_after_sale: GET /api/inventory/stock — quantity уменьшился на sold amount + +Длительность: 0мс + +## Summary + +- Passed: 8 +- Failed: 1 +- Warnings: 0 +- Skipped: 3 + +## Critical bugs + +### CRITICAL + +- **[05] Cashier ВИДИТ список сотрудников через API** + - GET /api/organization/employees вернул 200 для Cashier; ожидается 403 (нет [Authorize(Roles="Admin")] на List? или Cashier имеет Identity-Admin) + - Fix: Поставить [Authorize(Roles="Admin")] на List + проверить что AddToRoleAsync(...,"Cashier") а не "Admin") + +### HIGH + +- **[08] В реестре товаров < 3 позиций** + - Сценарий ожидает что после reset_db реестр сохранён. Проверь что preconditions не зачищают products. + +## Logic gaps + +- SuperAdmin /organizations принимает любой текст в поле phone — серверной валидации ФЛК нет (только в /api/auth/signup для самозаполнения). +- Cashier у созданного через POST /employees не получает Identity-роль "Cashier" — серверная авторизация на /api/sales/retail требует роли Admin или Cashier; нужно либо сидить Identity-роль из org-роли, либо переделать [Authorize] на permissions. diff --git a/tests/e2e/reports/full-cycle-2026-05-07T19-05-09-027Z.md b/tests/e2e/reports/full-cycle-2026-05-07T19-05-09-027Z.md new file mode 100644 index 0000000..3ef32a6 --- /dev/null +++ b/tests/e2e/reports/full-cycle-2026-05-07T19-05-09-027Z.md @@ -0,0 +1,126 @@ +# E2E report: full-cycle + +Запущен: 2026-05-07T19:05:02.083Z +Длительность: 5.2с + +**Итог:** 8 ✓ / 1 ✗ / 0 ⚠ / 3 ◯ (всего 12) + +## ✓ Step step01_create_organization: SuperAdmin создаёт «Test Shop {timestamp}» (KZ, KZT, ФЛК телефона) + +Длительность: 1444мс + +| Тип | Проверка | Результат | +|---|---|---| +| api | POST /api/super-admin/organizations → 200 | ✓ org=Test Shop 1778180702083 | +| api | GET /api/super-admin/organizations включает созданную org | ✓ | + +## ✓ Step step02_create_first_admin: SuperAdmin создаёт первого Admin сотрудника организации (Employee + AppUser) + +Длительность: 576мс + +| Тип | Проверка | Результат | +|---|---|---| +| api | Temp password возвращён CreateOrgResult | ✓ len=12 | +| db | employees содержит ровно 1 запись для новой org | ✓ count=1 | +| db | AspNetUserRoles содержит role=Admin для нового user | ✓ Admin | + +## ✓ Step step03_login_as_admin: Логин под admin (не SuperAdmin override) — JWT с org_id и role=Admin + +Длительность: 588мс + +| Тип | Проверка | Результат | +|---|---|---| +| api | /connect/token password-grant выдал токен | ✓ | +| api | /api/me содержит role=Admin | ✓ Admin | +| api | /api/me содержит правильный orgId | ✓ 5c99fced-3182-4b7e-9575-27111c29419d | + +## ✓ Step step04_create_storekeeper_and_cashier: Admin создаёт Storekeeper и Cashier через /settings/employees + +Длительность: 1398мс + +| Тип | Проверка | Результат | +|---|---|---| +| api | employee-roles list | ✓ 200, total=3 | +| api | Системная роль «Кладовщик» существует | ✓ | +| api | Системная роль «Кассир» существует | ✓ | +| api | POST /api/organization/employees (Кладовщик) | ✓ 200 | +| api | POST /api/organization/employees (Кассир) | ✓ 200 | +| db | employees total = 3 (admin + keeper + cashier) | ✓ count=3 | +| api | Невалидный email отвергается при createAccount | ✓ 400 | + +## ✗ Step step05_login_as_cashier: Логин под Cashier — role-guard проверяется (sidebar/role guard) + +Длительность: 699мс + +| Тип | Проверка | Результат | +|---|---|---| +| api | /api/me содержит роль соответствующую системной Cashier | ✗ no Identity roles | +| api | Cashier → GET /api/organization/employees → 403 | ✗ 200 | +| api | Cashier → GET /api/sales/retail-sales — доступен | ✓ 404 | + +## ✓ Step step06_create_counterparty: Admin создаёт «ТОО Тест Поставщик» (БИН + телефон) + +Длительность: 183мс + +| Тип | Проверка | Результат | +|---|---|---| +| api | POST /api/catalog/counterparties | ✓ 201 | + +## ✓ Step step07_ensure_main_store: Проверить что есть main store (из bootstrap), иначе создать + +Длительность: 90мс + +| Тип | Проверка | Результат | +|---|---|---| +| api | GET /api/catalog/stores | ✓ 200 | +| db | Main store существует (от bootstrap) | ✓ Основной склад | + +## ✓ Step step08_create_supply: Admin создаёт Supply Draft (3-5 товаров) и проводит (Posted) + +Длительность: 168мс + +## ◯ Step step09_check_stock_after_supply: GET /api/inventory/stock — quantity увеличился на supplied amount + +Длительность: 1мс + +## ✓ Step step10_ensure_retail_point: Проверить или создать розничную точку (кассу) + +Длительность: 84мс + +| Тип | Проверка | Результат | +|---|---|---| +| api | RetailPoint существует | ✓ Касса 1 | + +## ◯ Step step11_create_retail_sale: Admin создаёт RetailSale, 2 позиции из приёмки, cash, Post + +Длительность: 1мс + +## ◯ Step step12_check_stock_after_sale: GET /api/inventory/stock — quantity уменьшился на sold amount + +Длительность: 0мс + +## Summary + +- Passed: 8 +- Failed: 1 +- Warnings: 0 +- Skipped: 3 + +## Critical bugs + +### CRITICAL + +- **[05] Cashier ВИДИТ список сотрудников через API** + - GET /api/organization/employees вернул 200 для Cashier; ожидается 403 (нет [Authorize(Roles="Admin")] на List? или Cashier имеет Identity-Admin) + - Fix: Поставить [Authorize(Roles="Admin")] на List + проверить что AddToRoleAsync(...,"Cashier") а не "Admin") + +### HIGH + +- **[08] Нет ни одной единицы измерения для нового tenant** + - Bootstrap должен сидить системные units (шт, кг, л) при создании org. + +## Logic gaps + +- SuperAdmin /organizations принимает любой текст в поле phone — серверной валидации ФЛК нет (только в /api/auth/signup для самозаполнения). +- Cashier у созданного через POST /employees не получает Identity-роль "Cashier" — серверная авторизация на /api/sales/retail требует роли Admin или Cashier; нужно либо сидить Identity-роль из org-роли, либо переделать [Authorize] на permissions. +- Реестр products tenant-scoped: новая org стартует с пустым каталогом, хотя в БД лежат products другой org. e2e-сценарий компенсирует созданием 3 products через API. diff --git a/tests/e2e/run.sh b/tests/e2e/run.sh new file mode 100755 index 0000000..b04bae9 --- /dev/null +++ b/tests/e2e/run.sh @@ -0,0 +1,10 @@ +#!/usr/bin/env bash +# Wrapper: pnpm install (если node_modules нет), затем tsx runner.ts . +set -euo pipefail +cd "$(dirname "$0")" + +if [ ! -d node_modules ]; then + pnpm install --silent +fi + +exec pnpm exec tsx runner.ts "$@" diff --git a/tests/e2e/runner.ts b/tests/e2e/runner.ts new file mode 100644 index 0000000..574f24b --- /dev/null +++ b/tests/e2e/runner.ts @@ -0,0 +1,93 @@ +/** + * E2E-runner. Принимает имя сценария, читает YAML, выполняет preconditions + * и steps, в конце пишет markdown-отчёт. + * + * Запуск: pnpm tsx runner.ts [--api-only] + * + * Сценарий описывает мета-информацию + список step-id. Логика каждого + * step-а — в `scenarios/.steps.ts`. Это гибрид DSL + код: YAML + * декларативный, но конкретные проверки (Playwright assertions / axios calls) + * хранятся как функции, потому что для всех 12 шагов full-cycle описать + * сложные UI-сценарии чисто декларативно неоправданно. + */ +import { readFileSync } from 'node:fs' +import { resolve } from 'node:path' +import { load as parseYaml } from 'js-yaml' +import { Report } from './lib/report.js' +import { resetTenantData } from './lib/db.js' +import { login } from './lib/api.js' + +interface Scenario { + name: string + description?: string + preconditions?: { reset_db?: boolean; smoke_login_super_admin?: boolean } + steps: { id: string; title: string }[] +} + +async function main() { + const args = process.argv.slice(2) + const name = args.find((a) => !a.startsWith('--')) + if (!name) { + console.error('Usage: tsx runner.ts [--api-only]') + process.exit(2) + } + const apiOnly = args.includes('--api-only') + + const scenarioPath = resolve(import.meta.dirname, 'scenarios', `${name}.yml`) + const scenario = parseYaml(readFileSync(scenarioPath, 'utf8')) as Scenario + const stepsModule = await import(resolve(import.meta.dirname, 'scenarios', `${name}.steps.ts`)) + + const report = new Report(scenario.name) + + // Preconditions + if (scenario.preconditions?.reset_db) { + console.error('[preflight] resetting tenant data...') + resetTenantData() + } + if (scenario.preconditions?.smoke_login_super_admin !== false) { + try { + const sa = await login('admin@food-market.local', 'Admin12345!') + if (!sa.roles.includes('SuperAdmin')) { + throw new Error(`SuperAdmin not in roles: ${sa.roles}`) + } + console.error(`[preflight] SuperAdmin login OK (sub=${sa.email})`) + } catch (e) { + console.error('[preflight] SuperAdmin login FAILED — abort', e) + process.exit(3) + } + } + + // Контекст пробрасываем между steps — accumulator для ID'ов созданных сущностей. + const ctx: Record = { apiOnly } + + for (const stepDecl of scenario.steps) { + const handler = stepsModule[stepDecl.id] + if (typeof handler !== 'function') { + const step = report.startStep(stepDecl.id, stepDecl.title) + step.status = 'skip' + step.notes.push(`Handler ${stepDecl.id} не найден в ${name}.steps.ts`) + console.error(`[${stepDecl.id}] SKIP (no handler)`) + continue + } + const t0 = Date.now() + const step = report.startStep(stepDecl.id, stepDecl.title) + console.error(`[${stepDecl.id}] ${stepDecl.title}...`) + try { + await handler({ ctx, step, report }) + } catch (e) { + step.status = 'fail' + step.notes.push(`Exception: ${(e as Error).message}`) + console.error(`[${stepDecl.id}] FAIL: ${(e as Error).message}`) + } + report.finishStep(step, t0) + console.error(`[${stepDecl.id}] ${step.status} (${step.durationMs}ms)`) + } + + const ts = new Date().toISOString().replace(/[:.]/g, '-') + const reportPath = resolve(import.meta.dirname, 'reports', `${name}-${ts}.md`) + report.finalize(reportPath) + console.error(`[done] report → ${reportPath}`) + console.error(report.toMarkdown().split('\n').slice(0, 20).join('\n')) +} + +main().catch((e) => { console.error(e); process.exit(1) }) diff --git a/tests/e2e/scenarios/full-cycle.steps.ts b/tests/e2e/scenarios/full-cycle.steps.ts new file mode 100644 index 0000000..a6edd6d --- /dev/null +++ b/tests/e2e/scenarios/full-cycle.steps.ts @@ -0,0 +1,696 @@ +/** + * Step-handlers для full-cycle сценария. Каждая функция получает + * { ctx, step, report } + * и обновляет step.checks и report.bugs / report.uxNotes / report.gap. + * + * ctx — общий накопитель ID'ов созданных сущностей и активных сессий + * между steps. Структура шире чем строго необходима — для удобства + * логирования в отчёте. + */ +import { login, makeClient, ADMIN_BASE } from '../lib/api.js' +import type { CheckResult, Step, Report } from '../lib/report.js' +import { Report as _R } from '../lib/report.js' // type-only ниже +import { countRows, psql } from '../lib/db.js' + +type Ctx = { + apiOnly: boolean + superAdminToken?: string + organization?: { id: string; name: string } + adminEmail?: string + adminTempPassword?: string + adminToken?: string + storekeeperEmail?: string + cashierEmail?: string + cashierTempPassword?: string + storekeeperTempPassword?: string + counterpartyId?: string + storeId?: string + retailPointId?: string + supplyId?: string + supplyLines?: { productId: string; productName: string; quantity: number; price: number }[] + retailSaleId?: string + saleLines?: { productId: string; quantity: number }[] + stockBefore?: Record + stockAfterSupply?: Record +} + +interface StepCtx { ctx: Ctx; step: Step; report: Report } + +const TIMESTAMP = Date.now() + +function check(step: Step, c: CheckResult) { + step.checks.push(c) +} + +function asString(x: unknown): string { + if (x == null) return '' + if (typeof x === 'string') return x + return JSON.stringify(x) +} + +async function ensureSuperAdminToken(ctx: Ctx): Promise { + if (!ctx.superAdminToken) { + const sa = await login('admin@food-market.local', 'Admin12345!') + ctx.superAdminToken = sa.accessToken + } + return ctx.superAdminToken +} + +// --------------------------------------------------------------------------- + +export async function step01_create_organization({ ctx, step, report }: StepCtx) { + const token = await ensureSuperAdminToken(ctx) + const api = makeClient(token) + const orgName = `Test Shop ${TIMESTAMP}` + const orgEmail = `e2e-${TIMESTAMP}@example.kz` + + const res = await api.post('/api/super-admin/organizations', { + org: { + name: orgName, + countryCode: 'KZ', + bin: '123456789012', + address: 'Алматы, ул. Тестовая 1', + phone: '+77001234567', + email: orgEmail, + defaultCurrencyId: null, + accountOwnerUserId: null, + }, + adminLastName: 'Тестов', + adminFirstName: 'Админ', + adminEmail: `admin-${TIMESTAMP}@example.kz`, + adminPosition: 'Директор', + }) + + check(step, { + kind: 'api', + description: `POST /api/super-admin/organizations → 200`, + ok: res.status === 200, + detail: res.status === 200 ? `org=${res.data?.organization?.name}` : asString(res.data), + }) + if (res.status !== 200) { + report.bug({ + step: '01', + severity: 'critical', + title: 'Не удаётся создать организацию через API SuperAdmin', + detail: `POST /api/super-admin/organizations вернул ${res.status} ${asString(res.data)}`, + fix: 'Проверь что таблицы tenant-bootstrap (employee_roles системные) сохранены при reset_db.', + }) + return + } + ctx.organization = { id: res.data.organization.id, name: res.data.organization.name } + ctx.adminEmail = res.data.adminEmail + ctx.adminTempPassword = res.data.adminTempPassword + + // List ↦ должен возвращать новую org + const list = await api.get('/api/super-admin/organizations?archived=false&pageSize=200') + const found = list.data?.items?.some((o: { id: string }) => o.id === ctx.organization!.id) + check(step, { + kind: 'api', + description: 'GET /api/super-admin/organizations включает созданную org', + ok: !!found, + detail: found ? '' : `total=${list.data?.total}`, + }) + + // ФЛК телефона: проверим что орг с невалидным KZ-телефоном (например +71234567890) + // отвергается сервером. Делаем pure-API smoke без UI — это проверка backend-validation. + const bad = await api.post('/api/super-admin/organizations', { + org: { + name: `Bad Phone ${TIMESTAMP}`, + countryCode: 'KZ', bin: null, address: null, phone: 'abc', email: null, + defaultCurrencyId: null, accountOwnerUserId: null, + }, + adminLastName: 'X', adminFirstName: 'Y', + adminEmail: `bad-${TIMESTAMP}@example.kz`, adminPosition: null, + }) + // Здесь backend может НЕ валидировать phone (это поле опциональное и без regex'а + // на уровне домена). Поэтому статус 200 — это logic gap, отметим: + if (bad.status === 200) { + report.gap('SuperAdmin /organizations принимает любой текст в поле phone — серверной валидации ФЛК нет (только в /api/auth/signup для самозаполнения).') + } else { + check(step, { + kind: 'api', + description: 'Невалидный phone отвергается', + ok: bad.status >= 400 && bad.status < 500, + detail: `${bad.status}`, + }) + } +} + +// --------------------------------------------------------------------------- + +export async function step02_create_first_admin({ ctx, step, report }: StepCtx) { + // SuperAdmin при создании org уже создаёт первого Admin'a (см. step01). + // Проверяем что temp password выдан и в БД появился Employee + Identity-Admin. + if (!ctx.organization || !ctx.adminEmail) { + step.status = 'skip'; step.notes.push('Нет organization из step 01 — пропускаем.') + return + } + + check(step, { + kind: 'api', + description: 'Temp password возвращён CreateOrgResult', + ok: !!ctx.adminTempPassword && ctx.adminTempPassword.length >= 8, + detail: ctx.adminTempPassword ? `len=${ctx.adminTempPassword.length}` : 'empty', + }) + + const employeesCount = countRows('employees', `"OrganizationId" = '${ctx.organization.id}'`) + check(step, { + kind: 'db', + description: `employees содержит ровно 1 запись для новой org`, + ok: employeesCount === 1, + detail: `count=${employeesCount}`, + }) + + // Проверим что AspNetUserRoles содержит role=Admin для нового user-id. + const userIdRows = psql( + `SELECT u."Id" FROM users u WHERE u."Email" = '${ctx.adminEmail}';`).trim() + if (!userIdRows) { + check(step, { kind: 'db', description: 'AppUser существует', ok: false, detail: 'не найден' }) + return + } + const userId = userIdRows + const roleRows = psql( + `SELECT r."Name" FROM "AspNetUserRoles" ur + JOIN roles r ON r."Id" = ur."RoleId" + WHERE ur."UserId" = '${userId}';`).trim().split('\n') + const hasAdmin = roleRows.includes('Admin') + check(step, { + kind: 'db', + description: 'AspNetUserRoles содержит role=Admin для нового user', + ok: hasAdmin, + detail: roleRows.join(','), + }) + if (!hasAdmin) { + report.bug({ + step: '02', + severity: 'high', + title: 'Identity-роль Admin не присвоена при создании организации', + detail: 'CreateOrg в SuperAdminOrganizationsController должен вызывать UserManager.AddToRoleAsync(user, "Admin")', + }) + } +} + +// --------------------------------------------------------------------------- + +export async function step03_login_as_admin({ ctx, step, report }: StepCtx) { + if (!ctx.adminEmail || !ctx.adminTempPassword) { + step.status = 'skip'; step.notes.push('Нет creds admin'); return + } + try { + const sess = await login(ctx.adminEmail, ctx.adminTempPassword) + ctx.adminToken = sess.accessToken + check(step, { + kind: 'api', + description: '/connect/token password-grant выдал токен', + ok: !!sess.accessToken, + }) + check(step, { + kind: 'api', + description: '/api/me содержит role=Admin', + ok: sess.roles.includes('Admin'), + detail: sess.roles.join(','), + }) + check(step, { + kind: 'api', + description: '/api/me содержит правильный orgId', + ok: sess.orgId === ctx.organization?.id, + detail: sess.orgId ?? 'null', + }) + } catch (e) { + check(step, { kind: 'api', description: 'login admin', ok: false, detail: (e as Error).message }) + report.bug({ + step: '03', + severity: 'critical', + title: 'Свежесозданный Admin не может залогиниться с temp password', + detail: (e as Error).message, + }) + } +} + +// --------------------------------------------------------------------------- + +export async function step04_create_storekeeper_and_cashier({ ctx, step, report }: StepCtx) { + if (!ctx.adminToken || !ctx.organization) { step.status = 'skip'; return } + const api = makeClient(ctx.adminToken) + + // Получаем list ролей; нужны Storekeeper (системная) и Кассир (системная). + const rolesRes = await api.get('/api/organization/employee-roles?pageSize=200') + check(step, { + kind: 'api', description: 'employee-roles list', ok: rolesRes.status === 200, + detail: `${rolesRes.status}, total=${rolesRes.data?.total}`, + }) + const roles = (rolesRes.data?.items ?? []) as { id: string; name: string; isSystem: boolean }[] + const keeperRole = roles.find((r) => r.name === 'Кладовщик') + const cashierRole = roles.find((r) => r.name === 'Кассир') + check(step, { + kind: 'api', description: 'Системная роль «Кладовщик» существует', ok: !!keeperRole, + }) + check(step, { + kind: 'api', description: 'Системная роль «Кассир» существует', ok: !!cashierRole, + }) + if (!keeperRole || !cashierRole) { + report.bug({ + step: '04', severity: 'high', + title: 'Не сидируются системные роли при создании org', + detail: `roles list = ${roles.map((r) => r.name).join(', ')}`, + fix: 'DevDataSeeder.SeedEmployeeRolesAsync должен вызываться после CreateOrg.', + }) + return + } + + ctx.storekeeperEmail = `keeper-${TIMESTAMP}@example.kz` + ctx.cashierEmail = `cashier-${TIMESTAMP}@example.kz` + + for (const [emailField, roleId, lastName, firstName, position] of [ + [ctx.storekeeperEmail, keeperRole.id, 'Кладовщиков', 'Иван', 'Кладовщик'], + [ctx.cashierEmail, cashierRole.id, 'Кассиров', 'Пётр', 'Кассир'], + ] as const) { + const res = await api.post('/api/organization/employees', { + lastName, firstName, middleName: null, position, + email: emailField, phone: '+77002223344', + salary: null, taxNumber: null, description: null, imageUrl: null, + roleId, isActive: true, retailPointIds: [], createAccount: true, + }) + check(step, { + kind: 'api', description: `POST /api/organization/employees (${position})`, + ok: res.status === 200, detail: `${res.status} ${res.status !== 200 ? asString(res.data) : ''}`, + }) + if (res.status === 200) { + const tempPwd = res.data?.generatedPassword as string | undefined + if (position === 'Кладовщик') ctx.storekeeperTempPassword = tempPwd + else ctx.cashierTempPassword = tempPwd + } + } + + const total = countRows('employees', `"OrganizationId" = '${ctx.organization.id}'`) + check(step, { + kind: 'db', description: 'employees total = 3 (admin + keeper + cashier)', + ok: total === 3, detail: `count=${total}`, + }) + + // Валидация: невалидный email должен быть отвергнут. + const bad = await api.post('/api/organization/employees', { + lastName: 'Bad', firstName: 'Email', middleName: null, position: null, + email: 'not-an-email', phone: null, salary: null, taxNumber: null, + description: null, imageUrl: null, roleId: cashierRole.id, isActive: true, + retailPointIds: [], createAccount: true, + }) + if (bad.status === 200) { + report.gap('POST /api/organization/employees принимает невалидный email при createAccount=true (Identity создаёт юзера с UserName=not-an-email).') + } else { + check(step, { + kind: 'api', description: 'Невалидный email отвергается при createAccount', + ok: bad.status >= 400, detail: `${bad.status}`, + }) + } +} + +// --------------------------------------------------------------------------- + +export async function step05_login_as_cashier({ ctx, step, report }: StepCtx) { + if (!ctx.cashierEmail || !ctx.cashierTempPassword) { step.status = 'skip'; return } + let cashier + try { + cashier = await login(ctx.cashierEmail, ctx.cashierTempPassword) + } catch (e) { + check(step, { kind: 'api', description: 'login Cashier', ok: false, detail: (e as Error).message }) + report.bug({ + step: '05', severity: 'critical', + title: 'Cashier не может залогиниться с выданным паролем', + detail: (e as Error).message, + }) + return + } + check(step, { + kind: 'api', description: '/api/me содержит роль соответствующую системной Cashier', + ok: cashier.roles.length > 0, // Identity-роль может отсутствовать (используются org-роли), + detail: cashier.roles.join(',') || 'no Identity roles', + }) + // Identity-роль для Cashier — "Cashier"? Проверим. Если она НЕ присвоена, + // это значит role-guard на сервере основан только на Identity-роли Admin + // (см. Authorize(Roles="Admin")). Cashier тогда всегда 403 на admin-ресурсах, + // но и на /sales/retail может не пройти если там стоит [Authorize(Roles="Cashier")]. + if (!cashier.roles.includes('Cashier') && !cashier.roles.includes('Admin')) { + report.gap('Cashier у созданного через POST /employees не получает Identity-роль "Cashier" — серверная авторизация на /api/sales/retail требует роли Admin или Cashier; нужно либо сидить Identity-роль из org-роли, либо переделать [Authorize] на permissions.') + } + + const apiCashier = makeClient(cashier.accessToken) + + // /settings/employees должен быть 403 для Cashier. + const empRes = await apiCashier.get('/api/organization/employees') + check(step, { + kind: 'api', description: 'Cashier → GET /api/organization/employees → 403', + ok: empRes.status === 403, + detail: `${empRes.status}`, + }) + if (empRes.status !== 403) { + if (empRes.status === 200) { + report.bug({ + step: '05', severity: 'critical', + title: 'Cashier ВИДИТ список сотрудников через API', + detail: 'GET /api/organization/employees вернул 200 для Cashier; ожидается 403 (нет [Authorize(Roles="Admin")] на List? или Cashier имеет Identity-Admin)', + fix: 'Поставить [Authorize(Roles="Admin")] на List + проверить что AddToRoleAsync(...,"Cashier") а не "Admin")', + }) + } + } + + const salesRes = await apiCashier.get('/api/sales/retail-sales?pageSize=10') + check(step, { + kind: 'api', description: 'Cashier → GET /api/sales/retail-sales — доступен', + ok: salesRes.status === 200 || salesRes.status === 404, + detail: `${salesRes.status}`, + }) +} + +// --------------------------------------------------------------------------- + +export async function step06_create_counterparty({ ctx, step, report }: StepCtx) { + if (!ctx.adminToken) { step.status = 'skip'; return } + const api = makeClient(ctx.adminToken) + + // Получим страну KZ + const countries = await api.get('/api/catalog/countries?pageSize=200') + const kz = (countries.data?.items as { id: string; code: string }[] | undefined) + ?.find((c) => c.code === 'KZ') + if (!kz) { + report.bug({ step: '06', severity: 'high', title: 'Страна KZ отсутствует в справочнике', detail: '' }) + } + + const res = await api.post('/api/catalog/counterparties', { + name: 'ТОО Тест Поставщик', + legalName: 'Товарищество с ограниченной ответственностью «Тест Поставщик»', + type: 0, // LegalEntity + bin: '987654321098', iin: null, taxNumber: null, + countryId: kz?.id ?? null, + address: 'Алматы, ул. Поставщиков 1', + phone: '+77003332211', email: 'supplier@example.kz', + bankName: null, bankAccount: null, bik: null, + contactPerson: 'Иванов И.И.', notes: null, + }) + check(step, { + kind: 'api', description: 'POST /api/catalog/counterparties', + ok: res.status === 200 || res.status === 201, + detail: `${res.status} ${res.status >= 400 ? asString(res.data) : ''}`, + }) + if (res.status === 200 || res.status === 201) { + ctx.counterpartyId = res.data?.id + } else { + report.bug({ + step: '06', severity: 'critical', + title: 'Создание контрагента падает', + detail: `${res.status} ${asString(res.data)}`, + }) + } +} + +// --------------------------------------------------------------------------- + +export async function step07_ensure_main_store({ ctx, step, report }: StepCtx) { + if (!ctx.adminToken) { step.status = 'skip'; return } + const api = makeClient(ctx.adminToken) + const list = await api.get('/api/catalog/stores?pageSize=200') + check(step, { + kind: 'api', description: 'GET /api/catalog/stores', + ok: list.status === 200, detail: `${list.status}`, + }) + const stores = (list.data?.items ?? []) as { id: string; name: string; isMain: boolean }[] + let main = stores.find((s) => s.isMain) ?? stores[0] + if (!main) { + const create = await api.post('/api/catalog/stores', { + name: 'Main', code: 'MAIN', address: null, phone: null, + managerName: null, isMain: true, isActive: true, + }) + check(step, { + kind: 'api', description: 'Main store создан вручную', + ok: create.status === 200 || create.status === 201, + detail: `${create.status}`, + }) + if (create.status >= 400) { + report.bug({ + step: '07', severity: 'high', + title: 'Main store не сидируется автоматически и не создаётся вручную', + detail: asString(create.data), + }) + return + } + main = create.data + } else { + check(step, { + kind: 'db', description: 'Main store существует (от bootstrap)', + ok: true, detail: main.name, + }) + } + ctx.storeId = main!.id +} + +// --------------------------------------------------------------------------- + +export async function step08_create_supply({ ctx, step, report }: StepCtx) { + if (!ctx.adminToken || !ctx.counterpartyId || !ctx.storeId) { step.status = 'skip'; return } + const api = makeClient(ctx.adminToken) + + // Берём 3 произвольных products. Реестр products в БД tenant-scoped + // (ITenantEntity), поэтому после создания новой org `GET /api/catalog/ + // products` возвращает 0 — старые products принадлежат другому tenant'у. + // Это logic-gap описанный в отчёте; для прогона сценария создаём 3 + // products в новой org прямо сейчас. + const products = await api.get('/api/catalog/products?pageSize=5') + let items = (products.data?.items ?? []) as { id: string; name: string; unitId?: string }[] + if (items.length < 3) { + report.gap('Реестр products tenant-scoped: новая org стартует с пустым каталогом, хотя в БД лежат products другой org. e2e-сценарий компенсирует созданием 3 products через API.') + // Получим первую unit-of-measure (системную или из org). + const unitsRes = await api.get('/api/catalog/units?pageSize=10') + const unit = (unitsRes.data?.items ?? [])[0] as { id: string; name: string } | undefined + if (!unit) { + report.bug({ + step: '08', severity: 'high', + title: 'Нет ни одной единицы измерения для нового tenant', + detail: 'Bootstrap должен сидить системные units (шт, кг, л) при создании org.', + }) + return + } + const created: { id: string; name: string }[] = [] + for (let i = 0; i < 3; i++) { + const cr = await api.post('/api/catalog/products', { + name: `e2e Product ${i + 1} ${TIMESTAMP}`, + article: `E2E-${TIMESTAMP}-${i + 1}`, + barcode: null, + unitId: unit.id, + groupId: null, + retailPrice: 100 + i * 50, + purchasePrice: 70 + i * 30, + isActive: true, + }) + if (cr.status >= 400) { + report.bug({ + step: '08', severity: 'high', + title: `Не удалось создать product №${i + 1}`, + detail: `${cr.status} ${asString(cr.data).slice(0, 200)}`, + }) + return + } + created.push({ id: cr.data.id ?? cr.data.productId, name: cr.data.name }) + } + items = created.map((p) => ({ id: p.id, name: p.name })) + check(step, { + kind: 'api', description: 'Auto-created 3 products для нового tenant', + ok: true, detail: items.map((i) => i.name).join(', '), + }) + } + const lines = items.slice(0, 3).map((p, i) => ({ + productId: p.id, + productName: p.name, + quantity: 10 + i * 5, + price: 100 + i * 50, + })) + ctx.supplyLines = lines + + // Сохраняем stock-snapshot ДО приёмки. + const stockBefore: Record = {} + for (const ln of lines) stockBefore[ln.productId] = await stockOf(api, ctx.storeId, ln.productId) + ctx.stockBefore = stockBefore + + const draft = await api.post('/api/purchases/supplies', { + counterpartyId: ctx.counterpartyId, + storeId: ctx.storeId, + docDate: new Date().toISOString(), + description: 'e2e draft', + lines: lines.map((l) => ({ + productId: l.productId, quantity: l.quantity, price: l.price, + })), + }) + check(step, { + kind: 'api', description: 'POST /api/purchases/supplies (Draft)', + ok: draft.status === 200 || draft.status === 201, + detail: `${draft.status} ${draft.status >= 400 ? asString(draft.data).slice(0, 200) : ''}`, + }) + if (draft.status >= 400) { + report.bug({ + step: '08', severity: 'critical', + title: 'Не удаётся создать Draft Supply', + detail: asString(draft.data), + }) + return + } + ctx.supplyId = draft.data?.id ?? draft.data?.supplyId + if (!ctx.supplyId) { + report.bug({ + step: '08', severity: 'high', + title: 'POST /supplies не возвращает id созданного документа', + detail: asString(draft.data).slice(0, 200), + }) + return + } + + const post = await api.post(`/api/purchases/supplies/${ctx.supplyId}/post`, {}) + check(step, { + kind: 'api', description: 'POST /api/purchases/supplies/{id}/post (Draft → Posted)', + ok: post.status === 200 || post.status === 204, + detail: `${post.status} ${post.status >= 400 ? asString(post.data) : ''}`, + }) +} + +// --------------------------------------------------------------------------- + +export async function step09_check_stock_after_supply({ ctx, step, report }: StepCtx) { + if (!ctx.adminToken || !ctx.supplyLines || !ctx.storeId) { step.status = 'skip'; return } + const api = makeClient(ctx.adminToken) + const after: Record = {} + for (const ln of ctx.supplyLines) { + after[ln.productId] = await stockOf(api, ctx.storeId, ln.productId) + } + ctx.stockAfterSupply = after + + for (const ln of ctx.supplyLines) { + const before = ctx.stockBefore![ln.productId] ?? 0 + const now = after[ln.productId] ?? 0 + const delta = now - before + check(step, { + kind: 'api', + description: `stock(${ln.productName}) +${ln.quantity} (было ${before}, стало ${now})`, + ok: delta === ln.quantity, + detail: `delta=${delta}, expected=${ln.quantity}`, + }) + if (delta !== ln.quantity) { + report.bug({ + step: '09', severity: 'high', + title: 'Остатки не увеличились после Posted Supply', + detail: `product=${ln.productName} expected delta=${ln.quantity} actual=${delta}`, + fix: 'Проверь что SupplyPostHandler / hostedService применяет stock_movements при /post', + }) + } + } +} + +// --------------------------------------------------------------------------- + +export async function step10_ensure_retail_point({ ctx, step, report }: StepCtx) { + if (!ctx.adminToken) { step.status = 'skip'; return } + const api = makeClient(ctx.adminToken) + const list = await api.get('/api/catalog/retail-points?pageSize=200') + const items = (list.data?.items ?? []) as { id: string; name: string }[] + if (items.length === 0) { + const create = await api.post('/api/catalog/retail-points', { + name: 'Касса 1', code: 'C1', storeId: ctx.storeId, isActive: true, + }) + check(step, { + kind: 'api', description: 'Создана касса', + ok: create.status === 200 || create.status === 201, detail: `${create.status}`, + }) + ctx.retailPointId = create.data?.id + } else { + ctx.retailPointId = items[0].id + check(step, { kind: 'api', description: 'RetailPoint существует', ok: true, detail: items[0].name }) + } + if (!ctx.retailPointId) { + report.bug({ + step: '10', severity: 'high', + title: 'Не получилось гарантировать наличие розничной точки', + detail: '', + }) + } +} + +// --------------------------------------------------------------------------- + +export async function step11_create_retail_sale({ ctx, step, report }: StepCtx) { + if (!ctx.adminToken || !ctx.retailPointId || !ctx.supplyLines) { step.status = 'skip'; return } + const api = makeClient(ctx.adminToken) + const lines = ctx.supplyLines.slice(0, 2).map((l) => ({ + productId: l.productId, quantity: 2, price: l.price * 2, // продаём по 2 шт. + })) + ctx.saleLines = lines.map((l) => ({ productId: l.productId, quantity: l.quantity })) + + const draft = await api.post('/api/sales/retail-sales', { + retailPointId: ctx.retailPointId, + docDate: new Date().toISOString(), + description: 'e2e sale', + lines, + }) + check(step, { + kind: 'api', description: 'POST /api/sales/retail-sales (Draft)', + ok: draft.status === 200 || draft.status === 201, + detail: `${draft.status} ${draft.status >= 400 ? asString(draft.data).slice(0, 200) : ''}`, + }) + if (draft.status >= 400) { + report.bug({ + step: '11', severity: 'critical', + title: 'Не удаётся создать Draft RetailSale', + detail: asString(draft.data), + }) + return + } + ctx.retailSaleId = draft.data?.id ?? draft.data?.saleId + if (!ctx.retailSaleId) { + report.bug({ + step: '11', severity: 'high', + title: 'POST /retail-sales не возвращает id', + detail: asString(draft.data).slice(0, 200), + }) + return + } + + const post = await api.post(`/api/sales/retail-sales/${ctx.retailSaleId}/post`, {}) + check(step, { + kind: 'api', description: 'POST /retail-sales/{id}/post', + ok: post.status === 200 || post.status === 204, + detail: `${post.status} ${post.status >= 400 ? asString(post.data) : ''}`, + }) +} + +// --------------------------------------------------------------------------- + +export async function step12_check_stock_after_sale({ ctx, step, report }: StepCtx) { + if (!ctx.adminToken || !ctx.saleLines || !ctx.storeId || !ctx.stockAfterSupply) { step.status = 'skip'; return } + const api = makeClient(ctx.adminToken) + + for (const ln of ctx.saleLines) { + const before = ctx.stockAfterSupply[ln.productId] ?? 0 + const now = await stockOf(api, ctx.storeId, ln.productId) + const delta = before - now + check(step, { + kind: 'api', + description: `stock product=${ln.productId.slice(0, 8)}… −${ln.quantity} (было ${before}, стало ${now})`, + ok: delta === ln.quantity, + detail: `delta=${delta}, expected=${ln.quantity}`, + }) + if (delta !== ln.quantity) { + report.bug({ + step: '12', severity: 'high', + title: 'Остатки не уменьшились после Posted RetailSale', + detail: `expected delta=${ln.quantity} actual=${delta}`, + }) + } + } +} + +// --------------------------------------------------------------------------- + +async function stockOf(api: ReturnType, storeId: string, productId: string): Promise { + const res = await api.get(`/api/inventory/stock?productId=${productId}&pageSize=200`) + if (res.status !== 200) return 0 + const items = (res.data?.items ?? []) as { storeId?: string; quantity: number; productId: string }[] + // Fallback: если нет фильтра по storeId на endpoint'е, фильтруем сами. + const row = items.find((i) => i.storeId === storeId && i.productId === productId) + return row?.quantity ?? 0 +} diff --git a/tests/e2e/scenarios/full-cycle.yml b/tests/e2e/scenarios/full-cycle.yml new file mode 100644 index 0000000..1664515 --- /dev/null +++ b/tests/e2e/scenarios/full-cycle.yml @@ -0,0 +1,37 @@ +name: full-cycle +description: | + Полный цикл от создания организации до розничной продажи. 12 шагов: + SuperAdmin создаёт орг и admin'a → admin создаёт сотрудников → + Cashier пробует зайти (role-guard) → admin создаёт контрагента, + склад/кассу → приёмка с 3-5 товарами → проверка остатков → + розничная продажа → проверка остатков уменьшилась. + +preconditions: + reset_db: true + smoke_login_super_admin: true + +steps: + - id: step01_create_organization + title: SuperAdmin создаёт «Test Shop {timestamp}» (KZ, KZT, ФЛК телефона) + - id: step02_create_first_admin + title: SuperAdmin создаёт первого Admin сотрудника организации (Employee + AppUser) + - id: step03_login_as_admin + title: Логин под admin (не SuperAdmin override) — JWT с org_id и role=Admin + - id: step04_create_storekeeper_and_cashier + title: Admin создаёт Storekeeper и Cashier через /settings/employees + - id: step05_login_as_cashier + title: Логин под Cashier — role-guard проверяется (sidebar/role guard) + - id: step06_create_counterparty + title: Admin создаёт «ТОО Тест Поставщик» (БИН + телефон) + - id: step07_ensure_main_store + title: Проверить что есть main store (из bootstrap), иначе создать + - id: step08_create_supply + title: Admin создаёт Supply Draft (3-5 товаров) и проводит (Posted) + - id: step09_check_stock_after_supply + title: GET /api/inventory/stock — quantity увеличился на supplied amount + - id: step10_ensure_retail_point + title: Проверить или создать розничную точку (кассу) + - id: step11_create_retail_sale + title: Admin создаёт RetailSale, 2 позиции из приёмки, cash, Post + - id: step12_check_stock_after_sale + title: GET /api/inventory/stock — quantity уменьшился на sold amount diff --git a/tests/e2e/tsconfig.json b/tests/e2e/tsconfig.json new file mode 100644 index 0000000..77e23c3 --- /dev/null +++ b/tests/e2e/tsconfig.json @@ -0,0 +1,13 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "Bundler", + "esModuleInterop": true, + "allowSyntheticDefaultImports": true, + "resolveJsonModule": true, + "strict": true, + "skipLibCheck": true, + "noEmit": true + } +}