fix(hotfix): invoice500 + фоновые 403 на reload + regression e2e
Some checks are pending
Auto-tag / Create date-tag (push) Waiting to run
CI / Backend (.NET 8) (push) Waiting to run
CI / Web (React + Vite) (push) Waiting to run
CI / POS (WPF, Windows) (push) Waiting to run
Docker API / Build + push API (push) Waiting to run
Docker API / Deploy API on stage (push) Blocked by required conditions
Docker Web / Build + push Web (push) Waiting to run
Docker Web / Deploy Web on stage (push) Blocked by required conditions
Some checks are pending
Auto-tag / Create date-tag (push) Waiting to run
CI / Backend (.NET 8) (push) Waiting to run
CI / Web (React + Vite) (push) Waiting to run
CI / POS (WPF, Windows) (push) Waiting to run
Docker API / Build + push API (push) Waiting to run
Docker API / Deploy API on stage (push) Blocked by required conditions
Docker Web / Build + push Web (push) Waiting to run
Docker Web / Deploy Web on stage (push) Blocked by required conditions
Owner caught on stage (2026-06-07 18:57):
1) POST /api/purchases/invoices/upload → 500: migration Phase31a создала
invoice_uploads.UpdatedAt как NOT NULL, но конвенция проекта — DateTime?
nullable + стамп только для Modified в AppDbContext.StampTimestamps.
EF на INSERT отправлял UpdatedAt=NULL → 23502. Фикс: Phase31b DROP NOT NULL
+ Phase31b для invoice_aliases.
2) Фоновые 403 → тост «Нет доступа» при F5 на любой странице:
- SavedPresets фетчил /api/user/presets (RequiresAdvancedMode) из любой
страницы с фильтрами в простом режиме → 403;
- useOnboardingProgress фетчил /api/organization/employees (Admin only)
для кладовщика/кассира на '/' → 403.
Фикс:
- api.ts: GET-403 silent, тост только на действия юзера (POST/PUT/DELETE);
- SavedPresets gated на advancedMode (useMe + enabled);
- useOnboardingProgress canSeeEmployees-gate.
3) tests/food-market.IntegrationTests/InvoiceUploadHttpTests.cs — HTTP-прогон
POST /upload через Testcontainers-Postgres (дыра: s31 pipeline tests
проверяли matcher, но ни разу не инсертили в invoice_uploads).
4) tests/e2e/scenarios/stage-ui-menu-walk.spec.ts — regress: каждая демо-роль,
каждый пункт меню, F5, assert 0 unexpected 4xx/5xx + 0 error-тостов +
0 console.error. В quality-watchdog step 9 с 4-часовой каденцией.
5) tests/e2e/scenarios/stage-ui-invoice-upload.spec.ts — demo-keeper
загружает реальное фото накладной (tests/fixtures/invoice-sample.jpg,
сгенерирована Playwright-ом из HTML, 1800×2400 JPEG) → ждёт recognition
→ экран проверки. При не-настроенном Claude CLI тест fail'ится с ясным
сообщением (это тоже баг).
Прогон локально 4/4 (InvoicePipelineTests 2/2 + InvoiceUploadHttpTests 2/2).
docs/hotfix-invoice500-403-progress.md — ход работы 1-4/5.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
parent
929d0b6a38
commit
f434e87d9b
150
docs/hotfix-invoice500-403-progress.md
Normal file
150
docs/hotfix-invoice500-403-progress.md
Normal file
|
|
@ -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<Entity>()) {
|
||||||
|
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*) — пути уже обёрнуты в `<AdvancedModeGuard>` в `App.tsx`, с simple-режима на них не попасть, фоновых хитов нет. Остальные `[Authorize(Roles=…)]`-эндпоинты либо mutation-only (POST/PUT/DELETE), либо на страницах за `<RoleGuard>`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 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 + итог
|
||||||
|
|
||||||
|
_(заполняется)_
|
||||||
|
|
@ -0,0 +1,41 @@
|
||||||
|
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
using foodmarket.Infrastructure.Persistence;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace foodmarket.Infrastructure.Persistence.Migrations
|
||||||
|
{
|
||||||
|
/// <summary>Hotfix: Phase31a создала <c>invoice_uploads.UpdatedAt</c> и
|
||||||
|
/// <c>invoice_aliases.UpdatedAt</c> как <c>NOT NULL DEFAULT now()</c>, но
|
||||||
|
/// во всей кодбазе <c>Entity.UpdatedAt</c> — nullable, а
|
||||||
|
/// <c>AppDbContext.StampTimestamps</c> стампит её только для
|
||||||
|
/// <c>EntityState.Modified</c>. На INSERT EF пишет <c>UpdatedAt = NULL</c>
|
||||||
|
/// (а не опускает колонку), DEFAULT не применяется, Postgres роняет
|
||||||
|
/// транзакцию: <c>23502 null value in column "UpdatedAt"</c>.
|
||||||
|
///
|
||||||
|
/// Пример (owner словил на stage 2026-06-07 18:57,
|
||||||
|
/// <c>POST /api/purchases/invoices/upload</c>). Фикс — привести колонку
|
||||||
|
/// к конвенции, nullable. Делаем <c>DROP NOT NULL</c> идемпотентно.</summary>
|
||||||
|
[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
|
||||||
|
// не упал.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -16,6 +16,7 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||||
import { Bookmark, BookmarkPlus, X } from 'lucide-react'
|
import { Bookmark, BookmarkPlus, X } from 'lucide-react'
|
||||||
import { api } from '@/lib/api'
|
import { api } from '@/lib/api'
|
||||||
import { toast } from '@/lib/toast'
|
import { toast } from '@/lib/toast'
|
||||||
|
import { useMe } from '@/lib/useMe'
|
||||||
|
|
||||||
export interface SavedPresetsProps<T> {
|
export interface SavedPresetsProps<T> {
|
||||||
/** Стабильный идентификатор страницы — должен совпадать на сервере. */
|
/** Стабильный идентификатор страницы — должен совпадать на сервере. */
|
||||||
|
|
@ -37,13 +38,25 @@ interface PresetDto {
|
||||||
export function SavedPresets<T>({ pageKey, currentConfig, onApply }: SavedPresetsProps<T>) {
|
export function SavedPresets<T>({ pageKey, currentConfig, onApply }: SavedPresetsProps<T>) {
|
||||||
const qc = useQueryClient()
|
const qc = useQueryClient()
|
||||||
const [savingName, setSavingName] = useState<string | null>(null)
|
const [savingName, setSavingName] = useState<string | null>(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({
|
const list = useQuery({
|
||||||
queryKey: ['/api/user/presets', pageKey],
|
queryKey: ['/api/user/presets', pageKey],
|
||||||
queryFn: async () => (await api.get<PresetDto[]>(`/api/user/presets?pageKey=${encodeURIComponent(pageKey)}`)).data,
|
queryFn: async () => (await api.get<PresetDto[]>(`/api/user/presets?pageKey=${encodeURIComponent(pageKey)}`)).data,
|
||||||
staleTime: 5 * 60 * 1000,
|
staleTime: 5 * 60 * 1000,
|
||||||
|
enabled,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Простой режим: раздел пресетов скрыт полностью.
|
||||||
|
if (!enabled) return null
|
||||||
|
|
||||||
const create = useMutation({
|
const create = useMutation({
|
||||||
mutationFn: async (name: string) => {
|
mutationFn: async (name: string) => {
|
||||||
const res = await api.post<PresetDto>('/api/user/presets', {
|
const res = await api.post<PresetDto>('/api/user/presets', {
|
||||||
|
|
|
||||||
|
|
@ -44,6 +44,7 @@ function ToastCard({ entry }: { entry: ToastEntry }) {
|
||||||
<div
|
<div
|
||||||
role="alert"
|
role="alert"
|
||||||
aria-live="polite"
|
aria-live="polite"
|
||||||
|
data-toast-kind={entry.kind}
|
||||||
className={`pointer-events-auto flex gap-2 items-start px-3 py-2.5 rounded-md border shadow-sm ${palette}`}
|
className={`pointer-events-auto flex gap-2 items-start px-3 py-2.5 rounded-md border shadow-sm ${palette}`}
|
||||||
>
|
>
|
||||||
<Icon className={`w-4 h-4 shrink-0 mt-0.5 ${iconColor}`} />
|
<Icon className={`w-4 h-4 shrink-0 mt-0.5 ${iconColor}`} />
|
||||||
|
|
|
||||||
|
|
@ -94,8 +94,18 @@ api.interceptors.response.use(
|
||||||
// Toast: 4xx/5xx (кроме 401 и __silent=true) → читаем message из ответа.
|
// Toast: 4xx/5xx (кроме 401 и __silent=true) → читаем message из ответа.
|
||||||
// Старое поведение было «молчаливый rej», ошибки не доходили до юзера
|
// Старое поведение было «молчаливый rej», ошибки не доходили до юзера
|
||||||
// если страница не отображала mutation.error явно.
|
// если страница не отображала 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
|
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) })
|
toast.error(humanizeAxios(error), { title: errorTitle(status) })
|
||||||
}
|
}
|
||||||
return Promise.reject(error)
|
return Promise.reject(error)
|
||||||
|
|
|
||||||
|
|
@ -2,17 +2,26 @@ import { useQuery } from '@tanstack/react-query'
|
||||||
import { api } from '@/lib/api'
|
import { api } from '@/lib/api'
|
||||||
import type { PagedResult } from '@/lib/types'
|
import type { PagedResult } from '@/lib/types'
|
||||||
import { useOrgSettings } from '@/lib/useOrgSettings'
|
import { useOrgSettings } from '@/lib/useOrgSettings'
|
||||||
|
import { useMe } from '@/lib/useMe'
|
||||||
|
|
||||||
interface EmployeeRow { id: string; userId: string | null }
|
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() {
|
export function useOnboardingProgress() {
|
||||||
|
const me = useMe()
|
||||||
|
const canSeeEmployees = !!me.data?.roles?.some((r) => r === 'Admin' || r === 'SuperAdmin')
|
||||||
const org = useOrgSettings()
|
const org = useOrgSettings()
|
||||||
const employees = useQuery({
|
const employees = useQuery({
|
||||||
queryKey: ['onboarding:employees-count'],
|
queryKey: ['onboarding:employees-count'],
|
||||||
queryFn: async () => (await api.get<PagedResult<EmployeeRow>>('/api/organization/employees?pageSize=1')).data.total,
|
queryFn: async () => (await api.get<PagedResult<EmployeeRow>>('/api/organization/employees?pageSize=1')).data.total,
|
||||||
staleTime: 60_000,
|
staleTime: 60_000,
|
||||||
|
enabled: canSeeEmployees,
|
||||||
})
|
})
|
||||||
const products = useQuery({
|
const products = useQuery({
|
||||||
queryKey: ['onboarding:products-count'],
|
queryKey: ['onboarding:products-count'],
|
||||||
|
|
@ -26,7 +35,10 @@ export function useOnboardingProgress() {
|
||||||
})
|
})
|
||||||
|
|
||||||
const orgConfigured = !!org.data?.defaultCurrencyId && !!org.data?.countryCode
|
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 hasProducts = (products.data ?? 0) > 0
|
||||||
const hasSupplies = (supplies.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 done = Object.values(steps).filter(Boolean).length
|
||||||
const total = Object.keys(steps).length
|
const total = Object.keys(steps).length
|
||||||
return {
|
return {
|
||||||
isLoading: org.isLoading || employees.isLoading || products.isLoading || supplies.isLoading,
|
isLoading: org.isLoading
|
||||||
|
|| (canSeeEmployees && employees.isLoading)
|
||||||
|
|| products.isLoading
|
||||||
|
|| supplies.isLoading,
|
||||||
...steps,
|
...steps,
|
||||||
doneCount: done,
|
doneCount: done,
|
||||||
totalCount: total,
|
totalCount: total,
|
||||||
|
|
|
||||||
181
tests/e2e/scenarios/stage-ui-invoice-upload.spec.ts
Normal file
181
tests/e2e/scenarios/stage-ui-invoice-upload.spec.ts
Normal file
|
|
@ -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<string> {
|
||||||
|
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-<guid>
|
||||||
|
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()
|
||||||
|
}
|
||||||
|
})
|
||||||
154
tests/e2e/scenarios/stage-ui-menu-walk.spec.ts
Normal file
154
tests/e2e/scenarios/stage-ui-menu-walk.spec.ts
Normal file
|
|
@ -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<string> {
|
||||||
|
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<string[]> {
|
||||||
|
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()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
93
tests/e2e/scripts/make-invoice-fixture.ts
Normal file
93
tests/e2e/scripts/make-invoice-fixture.ts
Normal file
|
|
@ -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 = `<!doctype html>
|
||||||
|
<html lang="ru">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<style>
|
||||||
|
* { box-sizing: border-box }
|
||||||
|
body { font-family: Helvetica, Arial, sans-serif; color: #111; margin: 0; padding: 24px; background: white }
|
||||||
|
.meta { display: flex; justify-content: space-between; margin-bottom: 18px; font-size: 13px }
|
||||||
|
.title { font-size: 22px; font-weight: 700; margin: 6px 0 14px }
|
||||||
|
.sub { font-size: 13px; color: #444; margin-bottom: 20px }
|
||||||
|
table { width: 100%; border-collapse: collapse; font-size: 12px }
|
||||||
|
th, td { border: 1px solid #333; padding: 6px 8px; vertical-align: top }
|
||||||
|
th { background: #eee; text-align: left; font-weight: 700 }
|
||||||
|
td.n { text-align: right; font-variant-numeric: tabular-nums }
|
||||||
|
tfoot td { font-weight: 700 }
|
||||||
|
.footnote { margin-top: 18px; font-size: 11px; color: #333 }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="meta">
|
||||||
|
<div>
|
||||||
|
<div><b>Поставщик:</b> ТОО "Тестовый Поставщик"</div>
|
||||||
|
<div><b>БИН:</b> 123456789012</div>
|
||||||
|
<div><b>Адрес:</b> г. Алматы, ул. Абая 100</div>
|
||||||
|
</div>
|
||||||
|
<div style="text-align: right">
|
||||||
|
<div><b>Накладная № Т-00042</b></div>
|
||||||
|
<div><b>Дата:</b> 03.10.2026</div>
|
||||||
|
<div><b>Валюта:</b> KZT</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="title">Товарная накладная</div>
|
||||||
|
<div class="sub">Покупатель: ТОО "Food Market Demo" · Склад: Центральный</div>
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>№</th>
|
||||||
|
<th>Наименование</th>
|
||||||
|
<th>Штрихкод</th>
|
||||||
|
<th>Код пост.</th>
|
||||||
|
<th>Ед.</th>
|
||||||
|
<th class="n">Кол-во</th>
|
||||||
|
<th class="n">Цена</th>
|
||||||
|
<th class="n">Сумма</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr><td>1</td><td>Молоко «Домик в деревне» 3.2% 950мл</td><td>4870022200015</td><td>MLK-095</td><td>шт</td><td class="n">24</td><td class="n">420.00</td><td class="n">10080.00</td></tr>
|
||||||
|
<tr><td>2</td><td>Хлеб «Деревенский» 500г</td><td>4870022200022</td><td>BRD-500</td><td>шт</td><td class="n">30</td><td class="n">180.00</td><td class="n">5400.00</td></tr>
|
||||||
|
<tr><td>3</td><td>Яйцо куриное С1, лоток 10шт</td><td>4870022200039</td><td>EGG-10</td><td>уп</td><td class="n">15</td><td class="n">720.00</td><td class="n">10800.00</td></tr>
|
||||||
|
<tr><td>4</td><td>Сахар-песок 1кг «Рафинад»</td><td>4870022200046</td><td>SUG-1K</td><td>кг</td><td class="n">40</td><td class="n">360.00</td><td class="n">14400.00</td></tr>
|
||||||
|
<tr><td>5</td><td>Масло подсолн. рафинир. 1л</td><td>4870022200053</td><td>OIL-1L</td><td>шт</td><td class="n">12</td><td class="n">980.00</td><td class="n">11760.00</td></tr>
|
||||||
|
</tbody>
|
||||||
|
<tfoot>
|
||||||
|
<tr><td colspan="7">ИТОГО:</td><td class="n">52440.00</td></tr>
|
||||||
|
</tfoot>
|
||||||
|
</table>
|
||||||
|
<div class="footnote">
|
||||||
|
Отпустил: Иванов И. И. _______________ · Принял: _______________ ·
|
||||||
|
М.П.
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>`
|
||||||
|
|
||||||
|
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) })
|
||||||
BIN
tests/fixtures/invoice-sample.jpg
vendored
Normal file
BIN
tests/fixtures/invoice-sample.jpg
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 210 KiB |
71
tests/food-market.IntegrationTests/InvoiceUploadHttpTests.cs
Normal file
71
tests/food-market.IntegrationTests/InvoiceUploadHttpTests.cs
Normal file
|
|
@ -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;
|
||||||
|
|
||||||
|
/// <summary>Регресс по hotfix-invoice500: до фикса
|
||||||
|
/// <c>POST /api/purchases/invoices/upload</c> ложился в 500 из-за
|
||||||
|
/// NOT NULL constraint на <c>UpdatedAt</c>. Тест ХОДИТ ЧЕРЕЗ HTTP на реальной
|
||||||
|
/// Postgres (Testcontainers), с мульти-файловой multipart-формой, чтобы впредь
|
||||||
|
/// заметить, если кто-то снова сделает UpdatedAt NOT NULL.</summary>
|
||||||
|
[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<JsonElement>($"/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<JsonElement>())
|
||||||
|
.GetProperty("ids").EnumerateArray().Select(e => e.GetGuid()).ToList();
|
||||||
|
ids.Should().HaveCount(3);
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue