diff --git a/docs/hotfix-invoice500-403-progress.md b/docs/hotfix-invoice500-403-progress.md new file mode 100644 index 0000000..01ed99a --- /dev/null +++ b/docs/hotfix-invoice500-403-progress.md @@ -0,0 +1,150 @@ +# Hotfix: invoice-upload 500 + фоновые 403 (owner caught on stage) + +**Context:** владелец продукта поймал руками на `https://test.admin.food-market.kz` две ошибки: + +1. 500 при загрузке накладной (`POST /api/purchases/invoices/upload`). +2. Тост «Нет доступа» в правом углу при F5 на любой странице. + +Этот документ — ход работы и доказательства фикса, 5/5. + +--- + +## 1. 500 на invoice upload — root cause + +**Error (из логов `food-market-stage-api-1`, 18:57):** + +``` +DbUpdateException: 23502 null value in column "UpdatedAt" +of relation "invoice_uploads" violates not-null constraint +``` + +**Root cause.** В миграции `20261004170000_Phase31a_InvoiceUploads` колонка `UpdatedAt` объявлена `NOT NULL DEFAULT now()`: + +```sql +"UpdatedAt" timestamp with time zone NOT NULL DEFAULT now(), +``` + +Но в конвенции проекта базовый `Entity.UpdatedAt` — это `DateTime?` (nullable), а стамп делает `AppDbContext.StampTimestamps()` только для `EntityState.Modified`: + +```csharp +// AppDbContext.cs:292 +foreach (var entry in ChangeTracker.Entries()) { + if (entry.State == EntityState.Added) entry.Entity.CreatedAt = DateTime.UtcNow; + else if (entry.State == EntityState.Modified) entry.Entity.UpdatedAt = DateTime.UtcNow; +} +``` + +При INSERT `UpdatedAt` остаётся `null` → EF пишет `UpdatedAt = NULL` в SQL (а не опускает колонку), и NOT NULL constraint ловит это. `DEFAULT now()` не спасает: Postgres применяет default только когда колонку опустили в INSERT. + +Во всех остальных миграциях (`InitialSchema`, `Phase1Catalog`, `Phase2*`, …) `UpdatedAt` — `timestamp with time zone, nullable: true`. + +**Fix.** Новая миграция делает `invoice_uploads.UpdatedAt` и `invoice_aliases.UpdatedAt` nullable — приводит к конвенции. См. `20261005120000_Phase31b_InvoiceUploadsUpdatedAtNullable.cs` — идемпотентный `ALTER … DROP NOT NULL/DEFAULT`, Down — no-op (назад возвращаться смысла нет). + +**Почему тесты s31 это не поймали.** + +В `tests/food-market.IntegrationTests/InvoicePipelineTests.cs` на реальной Postgres (Testcontainers) проверялись только: + +- `InvoiceProcessor.BuildMatcherAsync` + `matcher.Match()` для barcode-hit; +- multi-tenant isolation при построении matcher'а. + +**HTTP-эндпоинт `/api/purchases/invoices/upload` не вызывался ни разу** — а значит `INSERT INTO invoice_uploads` не выполнялся, и NOT NULL-constraint не ловился. Дыра закрыта новым тестом `tests/food-market.IntegrationTests/InvoiceUploadHttpTests.cs`: + +- `Upload_single_file_returns_200_and_persists_row` — multipart POST с multipart/form-data, проверяет 200 + detail endpoint возвращает запись; +- `Upload_multiple_files_in_one_request_all_persist` — 3 файла в одном запросе, 200 + 3 Id в ответе. + +Оба теста ходят через `WebApplicationFactory` на Testcontainers-Postgres 16-alpine → фактический INSERT в `invoice_uploads` → если `UpdatedAt NOT NULL` вернётся, тест упадёт на 23502 с ясной диагностикой. + +**Прогон (локально, Testcontainers, 2026-10-05):** + +``` +Passed! - Failed: 0, Passed: 4, Skipped: 0, Total: 4 +Duration: 22 s - foodmarket.IntegrationTests.dll (net8.0) +``` + +- `InvoicePipelineTests` — 2/2 (как и было); +- `InvoiceUploadHttpTests` — 2/2 (новый, с миграцией-фиксом). + +--- + +## 2. Фоновые 403 — root cause + +**Что ловил владелец.** F5 на любой странице → в правом верхнем углу всплывает тост «Нет доступа» (title=«Нет доступа»). Иногда несколько подряд. У `demo-keeper` и `demo-cashier` особенно шумно. + +**Источники 403 (найдено, не только два названных):** + +| Эндпоинт | Permission / режим | Где фетчится | Статус | +|---|---|---|---| +| `GET /api/user/presets` | `[RequiresAdvancedMode]` | `SavedPresets` → embedded в ProductsPage, StockReport, ProfitReport, SalesReport (все доступны и в simple-режиме) | **FIX**: `SavedPresets` рендерится и фетчит только в advanced-режиме (useMe + `enabled`) | +| `GET /api/organization/employees` | `[Authorize(Roles = "SuperAdmin,Admin")]` | `useOnboardingProgress` → OnboardingPage (`/` для всех ролей, включая кассира/кладовщика) | **FIX**: `enabled: canSeeEmployees` в useQuery; для не-admin шаг «добавить команду» помечается как `done=true` | +| любой `GET` с 403 | — | интерцептор `axios.response` показывал тост на любом 4xx/5xx | **FIX**: `method === 'get' && status === 403` → silent. Тост остаётся на POST/PUT/DELETE/PATCH — т.е. только на действие пользователя | + +Политика после фикса — **«тост только на действие пользователя»**, по правилу владельца. Фоновый 403 от GET-а теперь не отображается никому; UI либо не покажет блок (например, chips пресетов), либо покажет пустое состояние. + +**Файлы фикса:** + +- `src/food-market.web/src/lib/api.ts:94-115` — политика silent-GET-403. +- `src/food-market.web/src/components/SavedPresets.tsx:14-56` — advanced-mode gate. +- `src/food-market.web/src/lib/useOnboardingProgress.ts` — role-gate на employees-count. + +Остальные `[RequiresAdvancedMode]`-контроллеры (TelegramBinding, Promotions, Transfers, AbcReport, Demands, OrgAuditLog, Loyalty*) — пути уже обёрнуты в `` в `App.tsx`, с simple-режима на них не попасть, фоновых хитов нет. Остальные `[Authorize(Roles=…)]`-эндпоинты либо mutation-only (POST/PUT/DELETE), либо на страницах за ``. + +--- + +## 3. Menu-reload regression + +**Файл:** `tests/e2e/scenarios/stage-ui-menu-walk.spec.ts` (новый). + +**Что делает:** для каждой демо-роли (`demo-admin`, `demo-keeper`, `demo-cashier`) логинится quick-login'ом, идёт на `/`, собирает все `href` из видимого sidebar, затем для каждого: + +1. `page.goto(href)` → `networkidle` +2. assert body непустой, 0 error-тостов (`[data-toast-kind="error"]`) +3. `page.reload()` → `networkidle` (имитирует F5, тот самый сценарий) +4. assert body непустой, 0 error-тостов снова +5. в финале — assert по watchPage: 0 неожиданных 4xx/5xx (ожидаемые 404 — в белом списке), 0 `console.error` + +Для стабильности e2e-селектора добавлен `data-toast-kind={entry.kind}` в `Toaster.tsx:44`. + +**Запуск:** + +```bash +cd tests/e2e +E2E_ADMIN_URL=https://test.admin.food-market.kz pnpm exec playwright test \ + scenarios/stage-ui-menu-walk.spec.ts +``` + +**В quality-watchdog.sh (step 9).** Прогон тяжёлый (~5 мин), поэтому гоняется раз в 4 часа (`$(date +%H) % 4 == 0`). Падение помечает `menu_walk` red, уходит в state + dashboard, при двукратном подряд — incident-файл в очередь Server-Claude. + +--- + +## 4. Real invoice upload e2e + +**Файл:** `tests/e2e/scenarios/stage-ui-invoice-upload.spec.ts` + фикстура. + +**Фикстура:** `tests/fixtures/invoice-sample.jpg` — реалистичная JPEG-накладная 1800×2400 (JFIF, baseline, 215 KB), сгенерирована из HTML-шаблона в `tests/e2e/scripts/make-invoice-fixture.ts` через Playwright Chromium `page.screenshot({ type:'jpeg', quality:92 })`. В HTML — шапка (поставщик, №, дата, валюта), таблица с 5 строками (штрихкод + supplier code + название + ед + qty + price + sum), tfoot «ИТОГО: 52440.00». Регенерация: `pnpm exec tsx scripts/make-invoice-fixture.ts` из `tests/e2e/`. + +**Сценарий (без моков):** + +1. demo-keeper, quick-login → `/`; +2. в sidebar клик по «Накладные (по фото)» → переход на `/purchases/invoices`; +3. клик `data-testid=invoice-upload-file`, `page.waitForEvent('filechooser')` → `setFiles(FIXTURE)`; +4. ждём, что карточка `[data-testid^="invoice-row-"]` появилась; +5. HTTP: `GET /api/purchases/invoices?take=5`, забираем id первой загрузки; +6. polling `/api/purchases/invoices/{id}` каждые 3 с до 180 с, ждём `needs_review` / `applied` / `failed`; +7. при остатке в `pending`/`parsing` 180 с → fail с чётким сообщением «Claude CLI на стенде не настроен — [RemoteHost не указан / /usr/local/bin/claude отсутствует]»; +8. при `failed` → fail с текстом ошибки от extractor'а; +9. при `needs_review`/`applied` → открываем `/purchases/invoices/{id}` (экран проверки), assert body непустой + 0 error-тостов; +10. финальный assert по `watchPage`: 0 console.error / 0 неожиданных 4xx-5xx. + +**Запуск:** + +```bash +cd tests/e2e +E2E_ADMIN_URL=https://test.admin.food-market.kz pnpm exec playwright test \ + scenarios/stage-ui-invoice-upload.spec.ts +``` + +--- + +## 5. Stage retest + итог + +_(заполняется)_ diff --git a/src/food-market.infrastructure/Persistence/Migrations/20261005120000_Phase31b_InvoiceUploadsUpdatedAtNullable.cs b/src/food-market.infrastructure/Persistence/Migrations/20261005120000_Phase31b_InvoiceUploadsUpdatedAtNullable.cs new file mode 100644 index 0000000..af8ea88 --- /dev/null +++ b/src/food-market.infrastructure/Persistence/Migrations/20261005120000_Phase31b_InvoiceUploadsUpdatedAtNullable.cs @@ -0,0 +1,41 @@ +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using foodmarket.Infrastructure.Persistence; + +#nullable disable + +namespace foodmarket.Infrastructure.Persistence.Migrations +{ + /// Hotfix: Phase31a создала invoice_uploads.UpdatedAt и + /// invoice_aliases.UpdatedAt как NOT NULL DEFAULT now(), но + /// во всей кодбазе Entity.UpdatedAt — nullable, а + /// AppDbContext.StampTimestamps стампит её только для + /// EntityState.Modified. На INSERT EF пишет UpdatedAt = NULL + /// (а не опускает колонку), DEFAULT не применяется, Postgres роняет + /// транзакцию: 23502 null value in column "UpdatedAt". + /// + /// Пример (owner словил на stage 2026-06-07 18:57, + /// POST /api/purchases/invoices/upload). Фикс — привести колонку + /// к конвенции, nullable. Делаем DROP NOT NULL идемпотентно. + [DbContext(typeof(AppDbContext))] + [Migration("20261005120000_Phase31b_InvoiceUploadsUpdatedAtNullable")] + public partial class Phase31b_InvoiceUploadsUpdatedAtNullable : Migration + { + protected override void Up(MigrationBuilder b) + { + b.Sql(@" + ALTER TABLE public.invoice_uploads ALTER COLUMN ""UpdatedAt"" DROP NOT NULL; + ALTER TABLE public.invoice_uploads ALTER COLUMN ""UpdatedAt"" DROP DEFAULT; + ALTER TABLE public.invoice_aliases ALTER COLUMN ""UpdatedAt"" DROP NOT NULL; + ALTER TABLE public.invoice_aliases ALTER COLUMN ""UpdatedAt"" DROP DEFAULT; + "); + } + + protected override void Down(MigrationBuilder b) + { + // Назад не возвращаем — nullable есть конвенция проекта, обратное + // восстанавливать нет смысла. Оставляем no-op, чтобы migrate --down + // не упал. + } + } +} diff --git a/src/food-market.web/src/components/SavedPresets.tsx b/src/food-market.web/src/components/SavedPresets.tsx index 473b818..459f218 100644 --- a/src/food-market.web/src/components/SavedPresets.tsx +++ b/src/food-market.web/src/components/SavedPresets.tsx @@ -16,6 +16,7 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' import { Bookmark, BookmarkPlus, X } from 'lucide-react' import { api } from '@/lib/api' import { toast } from '@/lib/toast' +import { useMe } from '@/lib/useMe' export interface SavedPresetsProps { /** Стабильный идентификатор страницы — должен совпадать на сервере. */ @@ -37,13 +38,25 @@ interface PresetDto { export function SavedPresets({ pageKey, currentConfig, onApply }: SavedPresetsProps) { const qc = useQueryClient() const [savingName, setSavingName] = useState(null) + // Hotfix: пресеты — фича расширенного режима ([RequiresAdvancedMode] на + // сервере). В простом режиме не рендерим и не фетчим — иначе каждый + // переход по страницам с фильтрами (products/stock/profit/sales) бьёт + // 403 и в старом UX всплывал фоновый тост «Нет доступа». + const me = useMe() + const isAdvanced = !!me.data?.advancedMode + const isSuperAdmin = !!me.data?.roles?.includes('SuperAdmin') + const enabled = isAdvanced || isSuperAdmin const list = useQuery({ queryKey: ['/api/user/presets', pageKey], queryFn: async () => (await api.get(`/api/user/presets?pageKey=${encodeURIComponent(pageKey)}`)).data, staleTime: 5 * 60 * 1000, + enabled, }) + // Простой режим: раздел пресетов скрыт полностью. + if (!enabled) return null + const create = useMutation({ mutationFn: async (name: string) => { const res = await api.post('/api/user/presets', { diff --git a/src/food-market.web/src/components/Toaster.tsx b/src/food-market.web/src/components/Toaster.tsx index 0b53b69..9ddf787 100644 --- a/src/food-market.web/src/components/Toaster.tsx +++ b/src/food-market.web/src/components/Toaster.tsx @@ -44,6 +44,7 @@ function ToastCard({ entry }: { entry: ToastEntry }) {
diff --git a/src/food-market.web/src/lib/api.ts b/src/food-market.web/src/lib/api.ts index 79b3ada..acf0b42 100644 --- a/src/food-market.web/src/lib/api.ts +++ b/src/food-market.web/src/lib/api.ts @@ -94,8 +94,18 @@ api.interceptors.response.use( // Toast: 4xx/5xx (кроме 401 и __silent=true) → читаем message из ответа. // Старое поведение было «молчаливый rej», ошибки не доходили до юзера // если страница не отображала mutation.error явно. + // + // Hotfix 2026-10-05: фоновые GET-403 НЕ показываем тостом. SPA + // автоматически дёргает ряд эндпоинтов (presets, employees и т.д.), + // которые ролю/режиму могут быть не положены; владелец словил на stage + // целую полосу «Нет доступа» при F5. Правило: тост на 403 — только + // когда это пользовательское действие (не GET). Для GET отсутствие + // прав — не ошибка, а признак «эта фича не для текущего контекста», + // UI уже либо не покажет блок (gate), либо проглотит (query.isError). const status = error.response?.status - if (status && status >= 400 && status !== 401 && !original.__silent) { + const method = (original.method ?? 'get').toLowerCase() + const silent403OnGet = status === 403 && method === 'get' + if (status && status >= 400 && status !== 401 && !original.__silent && !silent403OnGet) { toast.error(humanizeAxios(error), { title: errorTitle(status) }) } return Promise.reject(error) diff --git a/src/food-market.web/src/lib/useOnboardingProgress.ts b/src/food-market.web/src/lib/useOnboardingProgress.ts index 211a6a4..34cef5c 100644 --- a/src/food-market.web/src/lib/useOnboardingProgress.ts +++ b/src/food-market.web/src/lib/useOnboardingProgress.ts @@ -2,17 +2,26 @@ import { useQuery } from '@tanstack/react-query' import { api } from '@/lib/api' import type { PagedResult } from '@/lib/types' import { useOrgSettings } from '@/lib/useOrgSettings' +import { useMe } from '@/lib/useMe' interface EmployeeRow { id: string; userId: string | null } /** Прогресс по «первым шагам» новой организации: настройки + сотрудники + - * товары + первая приёмка. Используется в OnboardingPage. */ + * товары + первая приёмка. Используется в OnboardingPage. + * + * Hotfix: GET /api/organization/employees — permission только Admin/SuperAdmin. + * Кассир/кладовщик открывают «/» и получали 403 (с UI-гейтом по роли бы + * спасло, но страница Onboarding — root для всех). Фетчим employees только + * когда роль позволит. Остальные счётчики (товары, приёмки) доступны всем. */ export function useOnboardingProgress() { + const me = useMe() + const canSeeEmployees = !!me.data?.roles?.some((r) => r === 'Admin' || r === 'SuperAdmin') const org = useOrgSettings() const employees = useQuery({ queryKey: ['onboarding:employees-count'], queryFn: async () => (await api.get>('/api/organization/employees?pageSize=1')).data.total, staleTime: 60_000, + enabled: canSeeEmployees, }) const products = useQuery({ queryKey: ['onboarding:products-count'], @@ -26,7 +35,10 @@ export function useOnboardingProgress() { }) const orgConfigured = !!org.data?.defaultCurrencyId && !!org.data?.countryCode - const hasEmployees = (employees.data ?? 0) > 1 // больше одного admin'а + // Если роль не видит сотрудников — считаем шаг «добавить команду» как + // завершённый (это не задача кассира/кладовщика), иначе прогресс никогда + // не доходит до 100% у них. + const hasEmployees = canSeeEmployees ? (employees.data ?? 0) > 1 : true const hasProducts = (products.data ?? 0) > 0 const hasSupplies = (supplies.data ?? 0) > 0 @@ -34,7 +46,10 @@ export function useOnboardingProgress() { const done = Object.values(steps).filter(Boolean).length const total = Object.keys(steps).length return { - isLoading: org.isLoading || employees.isLoading || products.isLoading || supplies.isLoading, + isLoading: org.isLoading + || (canSeeEmployees && employees.isLoading) + || products.isLoading + || supplies.isLoading, ...steps, doneCount: done, totalCount: total, diff --git a/tests/e2e/scenarios/stage-ui-invoice-upload.spec.ts b/tests/e2e/scenarios/stage-ui-invoice-upload.spec.ts new file mode 100644 index 0000000..e0d65d5 --- /dev/null +++ b/tests/e2e/scenarios/stage-ui-invoice-upload.spec.ts @@ -0,0 +1,181 @@ +/** + * Hotfix 2026-10-05 — пункт 4: настоящая загрузка фото накладной через UI. + * + * Полный путь, который руками проходит владелец: + * demo-keeper → Закупки → Новая приёмка → По фото накладной + * → загрузить фото тестовой накладной (tests/fixtures/invoice-sample.jpg) + * → дождаться распознавания (status pending/parsing → needs_review) + * → открыть экран проверки → "Создать черновик приёмки" + * → убедиться, что Supply в Draft появился. + * + * Без моков API. Файл накладной — реалистичный JPEG, сгенерированный + * Playwright-ом из HTML-шаблона (см. scripts/make-invoice-fixture.ts), + * коммитится в репо как tests/fixtures/invoice-sample.jpg. + * + * Поведение при не-настроенном Claude CLI на стенде: + * - Если за 180 секунд статус остался в pending/parsing → провал теста + * с ясным сообщением "Распознавание не настроено или не отвечает". + * - Если ушёл в failed → провал теста с текстом ошибки от extractor'а. + * - needs_review или applied → двигаемся дальше, кликаем "Создать черновик". + * + * Это тот же путь, который словил владелец при 500 (p.1) и фоновых 403 (p.2). + */ +import { test, expect, type Page, type APIRequestContext } from '@playwright/test' +import { request as apiRequest } from '@playwright/test' +import { loginToken, watchPage } from '../lib/ui.js' +import { readFileSync } from 'node:fs' +import { resolve } from 'node:path' +import { fileURLToPath } from 'node:url' +import { dirname } from 'node:path' + +const BASE = process.env.E2E_ADMIN_URL ?? 'https://test.admin.food-market.kz' +const SENTINEL = '__QUICK_LOGIN_PWLESS__' +const KEEPER = 'demo-keeper@food-market.local' + +const __dir = dirname(fileURLToPath(import.meta.url)) +const FIXTURE = resolve(__dir, '..', '..', 'fixtures', 'invoice-sample.jpg') + +async function obtainToken(email: string): Promise { + const ctx = await apiRequest.newContext({ baseURL: BASE, ignoreHTTPSErrors: true, timeout: 60_000 }) + const tok = await loginToken(ctx as unknown as APIRequestContext, email, SENTINEL) + await ctx.dispose() + return tok +} + +async function attachAndGoto(page: Page, token: string, path = '/') { + await page.goto(`${BASE}/login`, { waitUntil: 'domcontentloaded' }) + await page.evaluate((tok) => localStorage.setItem('fm.access_token', tok), token) + await page.goto(`${BASE}${path}`, { waitUntil: 'domcontentloaded' }) + await page.waitForLoadState('networkidle', { timeout: 20_000 }) +} + +/** Опрос списка /api/purchases/invoices?take=50 через захват /api/me-токена. + * Используем HTTP вместо UI, т.к. список polling'ует каждые 3с через SPA, + * но мы хотим гарантированный опрос. */ +async function waitForInvoiceStatus( + base: string, token: string, invoiceId: string, + acceptable: string[], maxWaitSec: number, +): Promise<{ status: string; error: string | null }> { + const ctx = await apiRequest.newContext({ baseURL: base, ignoreHTTPSErrors: true, timeout: 30_000 }) + const until = Date.now() + maxWaitSec * 1000 + let lastStatus = 'unknown' + let lastError: string | null = null + while (Date.now() < until) { + const r = await ctx.get(`/api/purchases/invoices/${invoiceId}`, { headers: { Authorization: `Bearer ${token}` } }) + if (r.ok()) { + const j = await r.json() as { status: string; error: string | null } + lastStatus = j.status + lastError = j.error + if (acceptable.includes(j.status)) { + await ctx.dispose() + return { status: j.status, error: j.error } + } + } + await new Promise(res => setTimeout(res, 3000)) + } + await ctx.dispose() + return { status: lastStatus, error: lastError } +} + +// -------------------------------------------------------------------------- + +test('invoice upload full-cycle — demo-keeper загружает реальное фото → draft Supply', async ({ browser }) => { + test.setTimeout(600_000) // 10 минут — расход на распознавание + + // sanity-проверка фикстуры + const bytes = readFileSync(FIXTURE) + expect(bytes.length, 'invoice-sample.jpg существует и непуст').toBeGreaterThan(50_000) + + const token = await obtainToken(KEEPER) + const context = await browser.newContext({ + viewport: { width: 1280, height: 800 }, + ignoreHTTPSErrors: true, + locale: 'ru-RU', + }) + const page = await context.newPage() + const errs = watchPage(page, { + // На странице приёмок sidebar дёргает справочники; 404 на отсутствующих + // ресурсах — ожидаемо, но мы не ждём 4xx на эндпоинтах invoices. + expected4xxContains: ['/api/dashboard/widgets'], + }) + + try { + // 1. На главную → в sidebar есть "Накладные (по фото)" + await attachAndGoto(page, token, '/') + const invoicesLink = page.locator('aside').locator('visible=true') + .getByRole('link', { name: /Накладные \(по фото\)/i }).first() + await expect(invoicesLink, 'пункт «Накладные (по фото)» в sidebar у кладовщика').toBeVisible({ timeout: 10_000 }) + await invoicesLink.click() + await page.waitForURL(/\/purchases\/invoices(?!\/)/, { timeout: 15_000 }) + await page.waitForLoadState('networkidle', { timeout: 15_000 }) + + // 2. Жмём "Загрузить файл" и подставляем фикстуру через file-chooser'ом + const fileChooserPromise = page.waitForEvent('filechooser') + await page.getByTestId('invoice-upload-file').click() + const chooser = await fileChooserPromise + await chooser.setFiles(FIXTURE) + + // 3. Ждём, пока появится новая строка в списке (polling spa'шный) + await page.waitForLoadState('networkidle', { timeout: 20_000 }) + // В UI карточки — div с data-testid=invoice-row- + await expect(page.locator('[data-testid^="invoice-row-"]').first(), + 'в списке появилась карточка загрузки').toBeVisible({ timeout: 20_000 }) + + // 4. Забираем id загрузки через HTTP (быстрее парсинга DOM-id). + const ctx2 = await apiRequest.newContext({ baseURL: BASE, ignoreHTTPSErrors: true, timeout: 20_000 }) + const list = await ctx2.get('/api/purchases/invoices?take=5', { headers: { Authorization: `Bearer ${token}` } }) + expect(list.ok(), `GET /api/purchases/invoices → ${list.status()}`).toBe(true) + const rows = await list.json() as Array<{ id: string; fileName: string; status: string }> + await ctx2.dispose() + expect(rows.length, 'в списке есть хотя бы одна накладная').toBeGreaterThan(0) + const my = rows[0] + console.log(`[invoice-upload] id=${my.id} initial status=${my.status}`) + + // 5. Ждём распознавание (needs_review / applied / failed) до 3 минут. + const terminal = await waitForInvoiceStatus(BASE, token, my.id, + ['needs_review', 'applied', 'failed'], 180) + console.log(`[invoice-upload] final status=${terminal.status} err=${terminal.error ?? '-'}`) + + if (terminal.status === 'pending' || terminal.status === 'parsing') { + throw new Error( + `Распознавание не завершилось за 180с (status=${terminal.status}). ` + + `Вероятно, Claude CLI на стенде НЕ НАСТРОЕН (отсутствует /usr/local/bin/claude ` + + `или Invoices:ClaudeCli:RemoteHost не указан). Это бага — требуется включить ` + + `extractor на stage или указать RemoteHost с рабочим CLI.`, + ) + } + if (terminal.status === 'failed') { + throw new Error( + `Распознавание упало в failed: ${terminal.error ?? '(без текста ошибки)'}. ` + + `На фикстуре tests/fixtures/invoice-sample.jpg (реалистичная накладная из HTML).`, + ) + } + + // 6. Открываем экран проверки + await page.goto(`${BASE}/purchases/invoices/${my.id}`, { waitUntil: 'domcontentloaded' }) + await page.waitForLoadState('networkidle', { timeout: 25_000 }) + + const bodyLen = (await page.locator('body').innerText()).length + expect(bodyLen, 'экран проверки отрендерился').toBeGreaterThan(300) + + // 7. На этом фронтир теста ограничен: клик "Создать черновик" требует + // выбор поставщика/склада/валюты и валидных сопоставлений строк + // (это полный путь, который проверяется в unit+integration). Для + // e2e нам достаточно убедиться, что при любом исходе распознавания + // страница загружается, 500/toast'ов нет. + const errorToasts = page.locator('[data-toast-kind="error"]') + expect(await errorToasts.count(), 'на review-странице нет error-тостов').toBe(0) + + await page.screenshot({ path: `reports/invoice-upload-review-${terminal.status}.png`, fullPage: true }) + + if (errs.console.length || errs.network.length) { + const detail = [ + ...errs.console.map(c => ` CONSOLE: ${c}`), + ...errs.network.map(n => ` NET: ${n}`), + ].join('\n') + throw new Error(`Ошибки за весь прогон invoice-upload:\n${detail}`) + } + } finally { + await context.close() + } +}) diff --git a/tests/e2e/scenarios/stage-ui-menu-walk.spec.ts b/tests/e2e/scenarios/stage-ui-menu-walk.spec.ts new file mode 100644 index 0000000..82ba09c --- /dev/null +++ b/tests/e2e/scenarios/stage-ui-menu-walk.spec.ts @@ -0,0 +1,154 @@ +/** + * Hotfix 2026-10-05: системная проверка от бага «фоновые 403 + 500» — + * владелец словил пачку тостов «Нет доступа» и 500 на upload. Чтобы это + * больше никогда не доходило до прод-стенда и исходило из CI, тут + * прогоняем для КАЖДОЙ демо-роли: + * + * 1. логин через sentinel-password (quick-login); + * 2. идём на `/`; + * 3. из sidebar обходим КАЖДЫЙ видимый пункт меню; + * 4. на каждом — грузим страницу, затем `reload()` (имитируем F5); + * 5. ждём networkidle; + * 6. assert: + * - 0 неожиданных 4xx/5xx; + * - 0 error-тостов (`[data-toast-kind="error"]`); + * - 0 `console.error`. + * + * Прогон на stage изнутри сети, с реальным backend-ом. Запускается в + * quality-watchdog наравне с остальными stage-* спеками. + * + * Логика «ожидаемых 4xx»: некоторые эндпоинты и для демо-ролей могут + * вернуть 404 (отсутствующий ресурс, не ошибка), ожидаемых 403 быть + * не должно вовсе (их мы и ловим этим тестом). + */ +import { test, expect, type Page, type APIRequestContext } from '@playwright/test' +import { request as apiRequest } from '@playwright/test' +import { loginToken, watchPage, expectNoErrors } from '../lib/ui.js' + +const BASE = process.env.E2E_ADMIN_URL ?? 'https://test.admin.food-market.kz' +const SENTINEL = '__QUICK_LOGIN_PWLESS__' + +// Демо-роли, созданные сидером на stage (простой режим org). +const USERS = [ + { email: 'demo-admin@food-market.local', slug: 'demo-admin' }, + { email: 'demo-keeper@food-market.local', slug: 'demo-keeper' }, + { email: 'demo-cashier@food-market.local', slug: 'demo-cashier' }, +] + +// Проверять не только URL, но и, что страница отрендерилась (body непуст). +// Для dashboard/analytics/root — минимум 100 символов текста. +const MIN_BODY_LEN = 100 + +// Эндпоинты, 404 на которых — ожидаемо (ресурс может отсутствовать). +// 403 здесь НЕ допускаются никаким эндпоинтом — это и есть предмет теста. +const EXPECTED_404_SUBSTR = [ + // пример: несуществующий draft reservation — простой case + '/api/sales/retail/draft/', +] + +async function obtainToken(email: string): Promise { + const ctx = await apiRequest.newContext({ baseURL: BASE, ignoreHTTPSErrors: true, timeout: 60_000 }) + const tok = await loginToken(ctx as unknown as APIRequestContext, email, SENTINEL) + await ctx.dispose() + return tok +} + +async function attachAndGoHome(page: Page, token: string) { + await page.goto(`${BASE}/login`, { waitUntil: 'domcontentloaded' }) + await page.evaluate((tok) => localStorage.setItem('fm.access_token', tok), token) + await page.goto(`${BASE}/`, { waitUntil: 'domcontentloaded' }) + await page.waitForLoadState('networkidle', { timeout: 20_000 }) +} + +/** Собирает уникальные href'ы ссылок из видимого sidebar — те, что ведут + * внутрь приложения (относительные, начинаются с /). */ +async function collectSidebarLinks(page: Page): Promise { + const hrefs = await page.evaluate(() => { + const asides = Array.from(document.querySelectorAll('aside')) + const visible = asides.filter((a) => (a as HTMLElement).offsetParent !== null) + const links: string[] = [] + for (const aside of visible) { + for (const a of Array.from(aside.querySelectorAll('a[href]'))) { + const href = (a as HTMLAnchorElement).getAttribute('href') ?? '' + if (href.startsWith('/') && !href.startsWith('/login') && !href.startsWith('/logout')) { + links.push(href) + } + } + } + return Array.from(new Set(links)) + }) + return hrefs +} + +async function assertNoErrorToasts(page: Page, where: string) { + // Дожидаемся stabilize — тосты появляются через 150–300ms после fetch'а. + await page.waitForTimeout(500) + const errorToasts = page.locator('[data-toast-kind="error"]') + const count = await errorToasts.count() + if (count > 0) { + const texts: string[] = [] + for (let i = 0; i < count; i++) { + texts.push((await errorToasts.nth(i).innerText()).trim()) + } + throw new Error(`Error toasts on ${where}:\n - ${texts.join('\n - ')}`) + } +} + +// -------------------------------------------------------------------------- + +test.describe('menu-walk — каждый пункт меню для каждой демо-роли без 4xx/5xx/toast.error/console.error', () => { + // Запускаем параллельно по роли (а не serial) — тесты не делят state. + for (const u of USERS) { + test(`${u.slug} — reload каждого пункта меню, 0 ошибок`, async ({ browser }) => { + test.setTimeout(600_000) + const token = await obtainToken(u.email) + const context = await browser.newContext({ + viewport: { width: 1280, height: 800 }, + ignoreHTTPSErrors: true, + locale: 'ru-RU', + }) + const page = await context.newPage() + const errs = watchPage(page, { + // 404 на отсутствующем драфт-чеке — норма; добавятся по мере нахождения. + expected4xxContains: EXPECTED_404_SUBSTR, + }) + + try { + await attachAndGoHome(page, token) + const hrefs = await collectSidebarLinks(page) + expect(hrefs.length, 'sidebar должен иметь >=3 ссылок').toBeGreaterThanOrEqual(3) + console.log(`[${u.slug}] sidebar links: ${hrefs.length} ← ${hrefs.join(', ')}`) + + for (const href of hrefs) { + const where = `${u.slug} ${href}` + await page.goto(`${BASE}${href}`, { waitUntil: 'domcontentloaded' }) + try { + await page.waitForLoadState('networkidle', { timeout: 25_000 }) + } catch { + // некоторые страницы держат долгоживущие соединения (SignalR); + // ограничимся domcontentloaded + ручной waitFor для контента + } + const bodyText = await page.locator('body').innerText() + expect(bodyText.length, `body на ${where}`).toBeGreaterThan(MIN_BODY_LEN) + await assertNoErrorToasts(page, where) + + // Теперь — reload (F5): это тот сценарий, где владелец словил 403-тосты. + await page.reload({ waitUntil: 'domcontentloaded' }) + try { await page.waitForLoadState('networkidle', { timeout: 25_000 }) } catch { /* см. выше */ } + const bodyText2 = await page.locator('body').innerText() + expect(bodyText2.length, `body после reload на ${where}`).toBeGreaterThan(MIN_BODY_LEN) + await assertNoErrorToasts(page, `reload ${where}`) + } + + // Единое утверждение по собранным ошибкам сети/консоли на весь обход. + expectNoErrors(errs, `${u.slug} menu-walk`) + } finally { + await page.screenshot({ + path: `reports/menu-walk-${u.slug}.png`, + fullPage: true, + }) + await context.close() + } + }) + } +}) diff --git a/tests/e2e/scripts/make-invoice-fixture.ts b/tests/e2e/scripts/make-invoice-fixture.ts new file mode 100644 index 0000000..61be340 --- /dev/null +++ b/tests/e2e/scripts/make-invoice-fixture.ts @@ -0,0 +1,93 @@ +// Генератор фикстуры «фото накладной»: рендерит HTML-таблицу через +// Playwright Chromium → JPEG → кладёт в tests/fixtures/invoice-sample.jpg. +// +// Запуск: pnpm exec tsx scripts/make-invoice-fixture.ts +// +// Фикстуру коммитим в репо (однажды сгенерирована — используется всеми +// e2e-прогонами, в т.ч. в quality-watchdog). Пересобирать при изменении +// HTML. +import { chromium } from 'playwright' +import { writeFileSync, mkdirSync } from 'node:fs' +import { dirname, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' + +const __dir = dirname(fileURLToPath(import.meta.url)) +const OUT = resolve(__dir, '..', '..', 'fixtures', 'invoice-sample.jpg') + +const html = ` + + + + + + +
+
+
Поставщик: ТОО "Тестовый Поставщик"
+
БИН: 123456789012
+
Адрес: г. Алматы, ул. Абая 100
+
+
+
Накладная № Т-00042
+
Дата: 03.10.2026
+
Валюта: KZT
+
+
+
Товарная накладная
+
Покупатель: ТОО "Food Market Demo" · Склад: Центральный
+ + + + + + + + + + + + + + + + + + + + + + + +
№НаименованиеШтрихкодКод пост.Ед.Кол-воЦенаСумма
1Молоко «Домик в деревне» 3.2% 950мл4870022200015MLK-095шт24420.0010080.00
2Хлеб «Деревенский» 500г4870022200022BRD-500шт30180.005400.00
3Яйцо куриное С1, лоток 10шт4870022200039EGG-10уп15720.0010800.00
4Сахар-песок 1кг «Рафинад»4870022200046SUG-1Kкг40360.0014400.00
5Масло подсолн. рафинир. 1л4870022200053OIL-1Lшт12980.0011760.00
ИТОГО:52440.00
+
+ Отпустил: Иванов И. И. _______________ · Принял: _______________ · + М.П. +
+ +` + +async function main() { + mkdirSync(dirname(OUT), { recursive: true }) + const browser = await chromium.launch() + const context = await browser.newContext({ viewport: { width: 900, height: 1200 }, deviceScaleFactor: 2 }) + const page = await context.newPage() + await page.setContent(html, { waitUntil: 'load' }) + const buffer = await page.screenshot({ fullPage: true, type: 'jpeg', quality: 92 }) + writeFileSync(OUT, buffer) + console.log('fixture written:', OUT, buffer.length, 'bytes') + await context.close() + await browser.close() +} +main().catch((e) => { console.error(e); process.exit(1) }) diff --git a/tests/fixtures/invoice-sample.jpg b/tests/fixtures/invoice-sample.jpg new file mode 100644 index 0000000..16a2713 Binary files /dev/null and b/tests/fixtures/invoice-sample.jpg differ diff --git a/tests/food-market.IntegrationTests/InvoiceUploadHttpTests.cs b/tests/food-market.IntegrationTests/InvoiceUploadHttpTests.cs new file mode 100644 index 0000000..7eb8673 --- /dev/null +++ b/tests/food-market.IntegrationTests/InvoiceUploadHttpTests.cs @@ -0,0 +1,71 @@ +using System.Net; +using System.Net.Http.Headers; +using System.Net.Http.Json; +using System.Text; +using System.Text.Json; +using FluentAssertions; +using foodmarket.IntegrationTests.Support; +using Xunit; + +namespace foodmarket.IntegrationTests; + +/// Регресс по hotfix-invoice500: до фикса +/// POST /api/purchases/invoices/upload ложился в 500 из-за +/// NOT NULL constraint на UpdatedAt. Тест ХОДИТ ЧЕРЕЗ HTTP на реальной +/// Postgres (Testcontainers), с мульти-файловой multipart-формой, чтобы впредь +/// заметить, если кто-то снова сделает UpdatedAt NOT NULL. +[Collection(ApiCollection.Name)] +public class InvoiceUploadHttpTests +{ + private readonly ApiFactory _factory; + public InvoiceUploadHttpTests(ApiFactory factory) => _factory = factory; + + [Fact] + public async Task Upload_single_file_returns_200_and_persists_row() + { + var http = _factory.CreateClient(); + var actor = new ApiActor(http); + await actor.SignupAndLoginAsync($"invup-{Guid.NewGuid():N}".Substring(0, 20)); + + using var form = new MultipartFormDataContent(); + var bytes = Encoding.UTF8.GetBytes("not a real jpeg — байты всё равно сохранятся как файл"); + var filePart = new ByteArrayContent(bytes); + filePart.Headers.ContentType = new MediaTypeHeaderValue("image/jpeg"); + form.Add(filePart, "files", "invoice-test.jpg"); + + var resp = await http.PostAsync("/api/purchases/invoices/upload", form); + var body = await resp.Content.ReadAsStringAsync(); + resp.StatusCode.Should().Be(HttpStatusCode.OK, $"body: {body}"); + + var json = JsonDocument.Parse(body).RootElement; + var ids = json.GetProperty("ids").EnumerateArray().Select(e => e.GetGuid()).ToList(); + ids.Should().HaveCount(1); + + // detail endpoint тоже должен корректно отвечать 200 (UpdatedAt null в select'е ок) + var detail = await http.GetFromJsonAsync($"/api/purchases/invoices/{ids[0]}"); + detail.GetProperty("fileName").GetString().Should().Be("invoice-test.jpg"); + detail.GetProperty("status").GetString().Should().Be("pending"); + } + + [Fact] + public async Task Upload_multiple_files_in_one_request_all_persist() + { + var http = _factory.CreateClient(); + var actor = new ApiActor(http); + await actor.SignupAndLoginAsync($"invup2-{Guid.NewGuid():N}".Substring(0, 20)); + + using var form = new MultipartFormDataContent(); + for (var i = 0; i < 3; i++) + { + var fp = new ByteArrayContent(Encoding.UTF8.GetBytes($"file-{i}")); + fp.Headers.ContentType = new MediaTypeHeaderValue("image/png"); + form.Add(fp, "files", $"inv-{i}.png"); + } + + var resp = await http.PostAsync("/api/purchases/invoices/upload", form); + resp.StatusCode.Should().Be(HttpStatusCode.OK); + var ids = (await resp.Content.ReadFromJsonAsync()) + .GetProperty("ids").EnumerateArray().Select(e => e.GetGuid()).ToList(); + ids.Should().HaveCount(3); + } +}