test(e2e): сквозной сценарий full-chain «жизнь магазина» + харнес со скриншотами

16 шагов против настоящего стенда: регистрация→справочники→сотрудники/точки→
деньги→3 приёмки→оплаты→смена/продажи/возврат/Z→склад→отчёты→инварианты→откат.
Сверяет конкретные числа и инварианты, 0×5xx/0×тостов/0×console.error. Режимы
C (temp-орг) и B (Демо на реальном каталоге). Hi-res скриншоты каждого процесса +
галерея. Включён в nightly-verify и quality-watchdog (скрипты в $HOME).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
nns 2026-10-08 02:10:11 +05:00
parent fddf552411
commit 886cb92f30
8 changed files with 1983 additions and 1 deletions

View file

@ -0,0 +1,212 @@
# Full-chain E2E — прогресс
Автономная задача владельца (07.10.2026): один запускаемый сценарий «жизнь магазина
от регистрации до отчётов», проходящий ВСЕ процессы простого режима через настоящий
стенд `https://test.admin.food-market.kz`, сверяющий ЧИСЛА и инварианты, печатающий
отчёт; повторяемый и включённый в ночной прогон.
Поправка владельца (07.10, применяется сразу):
- **A.** Шаг 0 — резервная копия stage-БД, затем очистка «Демо-магазина» от всего кроме
каталога/сотрудников/точек продаж.
- **B.** Полная имитация дня (пп.1–13) ОДИН раз «как в жизни» прямо в «Демо-магазине» на
настоящих товарах (вход demo-admin/keeper/cashier, регистрацию пропустить).
- **C.** Повторяемый ночной тест — свежая временная организация каждый прогон, «Демо» не трогает.
## Топология (выяснено 07.10)
- Стенд `test.admin.food-market.kz` крутится на **prod-vm `192.168.1.190`**.
- DB: контейнер `food-market-stage-postgres` (127.0.0.1:5435 на prod-vm), db/user `food_market`.
- API: `food-market-stage-api` (порт 8085), web: `food-market-stage-web`.
- Доступ: `ssh nns@192.168.1.190 "docker exec -i food-market-stage-postgres psql -U food_market -d food_market ..."`.
- uploads на диске: `/opt/food-market-stage-data/uploads` (prod-vm).
- Я на **dev-vm `192.168.1.192`**. Локальный контейнер `food-market-postgres` (127.0.0.1:5434) —
это DEV-БД, НЕ стенд. Поэтому `lib/db.ts` (`docker exec food-market-postgres`) для стенда НЕ годится.
**Решение:** все инварианты в полном сценарии считаем через API; для Демо-очистки и ночной
уборки ходим в stage-БД по ssh.
- Деплой фикса на стенд: `~/deploy-stage.sh` (билд на dev-vm → push в registry 192.168.1.193:5001 →
ssh prod-vm `docker compose -p food-market-stage pull/up`). Есть guard stage↔prod isolation.
## Орги на стенде
- «Демо-магазин» = `b7c403fe-7fc8-48af-9490-4a9a2eafe2c2` — 30894 товара (ЦЕЛЬ очистки A/B).
- «Demo Market» = `f406452c-3d33-4c1d-84bd-e1d445a132f7` — 50 товаров, НЕ трогать.
- Всего 453 орги (много autotest-мусора Reg-*, чистится prune-логикой).
## Классификация таблиц для очистки Демо (A)
KEEP (каталог + сотрудники + точки продаж + reference):
`products, product_prices, product_barcodes, product_groups, product_images,
org_units_of_measure, price_types, employees, employee_roles, users, roles, stores,
retail_points, employee_retail_point_assignments, expense_items, user_presets`.
DELETE (org-scoped, по OrganizationId, retry-loop для FK):
`supplies, supply_lines, enters, enter_lines, losses, loss_lines, inventories,
inventory_lines, transfers, transfer_lines, demands, demand_lines, supplier_returns,
supplier_return_lines, retail_sales, retail_sale_lines, cash_shifts, cash_drawer_operations,
pos_batch_acks, stocks, stock_movements, money_accounts, money_transactions,
money_transaction_links, counterparties, counterparty_adjustments, counterparty_tags,
loyalty_cards, loyalty_programs, promotions, import_jobs, invoice_uploads, invoice_aliases,
org_exports, org_audit_log`.
Плюс файлы invoice_uploads с диска `/opt/food-market-stage-data/uploads`.
Проверка после A: products=30894, и документы/остатки=0 (supplies/retail_sales/enters/losses/
inventories/transfers/demands/supplier_returns/stocks/stock_movements/money_*/cash_*=0).
## Чек-лист
### Шаг 0 (A) — backup + очистка Демо ✅ ВЫПОЛНЕНО 08.10
- [x] pg_dump stage → `~/backups/stage-before-reset-20261008-000016.dump` (17M, заголовок PGDMP ок)
- [x] scoped clean Демо через `deploy/stage-reset-demo.sql` (dry-run в транзакции → commit); удалено 100740 строк
- [x] verify: products=30894 ✓, product_prices=30894, product_barcodes=30907, product_groups=53,
employees=3, users=3 (demo-admin/keeper/cashier), stores=1, retail_points=2;
supplies/retail_sales/enters/losses/inventories/stocks/stock_movements/money_*/cash_*/
counterparties/invoice_uploads/org_audit_log = 0 ✓
- [x] disk: удалён demo export-каталог; invoice StoredPath=NULL (снимки уже не на диске)
### Поправка 08.10 (ночь) — скриншоты каждого процесса ✅
- [x] Реальные Playwright-скриншоты каждого шага (вкл. 2а): форма/результат/документ/отчёт с числами
- [x] 1920×1080 @2x PNG (файлы 3840×2216), fullPage для отчётов; мобильные 390×844 @3x (инвент./фото-приёмка)
- [x] Имена `NN-шаг-что.png` в `reports/full-chain-<RUN_ID>/screenshots/`; B — всегда (~34 кадра), C — только при ✗
- [x] В отчёте каждый скрин рядом со строкой шага; `index.html` галерея (рус. подписи, превью→оригинал)
- [x] Без паролей/токенов (вход токеном в localStorage, поле пароля пустое); ключевые экраны — реальный браузер
### Шаг 2а (поправка 08.10) — сотрудники и точки продаж ✅
- [x] 2 точки (каждая своя касса + эквайринг), правка, деактивация/активация; смена на неактивной → отказ (400)
- [x] Сотрудники кассир/кладовщик/админ; правка ФИО/тел/роль; смена пароля (новый работает, старый нет);
блокировка → ни token (invalid_grant), ни refresh (400)
- [x] Привязка кассира: на точке 1 (закреплён) → успех; на точке 2 → отказ (retail-point-not-assigned);
две смены на одной точке → 409; параллельные смены т.1/т.2 не смешивают деньги (свои «должно быть»)
- [x] Права API(403: /money/accounts,/transactions,/employees) + UI (в меню кассира нет модуля «Деньги»/
«Настроек», есть только своя касса; кладовщик без денег); чужую орг никто (изоляция)
- [x] Отчёты по точкам (register) и кассирам (cashier): сумма = общей выручке дня (3300=3300)
- [x] В Демо (B): новые с меткой «(e2e)»/`fc-*`, в конце деактивированы (не удалены)
### Сценарий ✅ (все 16 шагов зелёные; C — temp-org, B — Демо на реальных товарах)
1. [x] Регистрация → вход → простой режим; скрытые модули 403 (demands/transfers/promo/abc)
2. [x] Группа, 5 товаров (разные ед.изм., 1 весовой 12.5) + цены + штрихкоды; поставщик (БИН+тел); права кассира/кладовщика
3. [x] Касса(Cash,50000)+банк(Bank,номер,200000)+эквайринг(Acquiring); balances = начальным
4. [x] Долг через корректировку WeOwe 100000 → долг=100000
5. [x] Приёмка №1 (кладовщик) → остатки +кол-во ровно; себестоимость=цене приёмки
6. [x] Приёмка №2 из табличной накладной (CSV по шаблону) → «Поставщик не указан» → провести; остатки
7. [x] Приёмка №3 по фото (OCR Claude CLI, до 3 попыток) → ручное сопоставление → провести; снимки с диска удалены (404)
8. [x] Оплата №1 частично нал из кассы, №2 полностью с банка; PayedSum; долг=начальный+приёмки−оплаты (точно)
9. [x] Смена: размен 5000, 3 продажи (нал/карта/смеш), oversell→409, возврат по чеку, внесение/выплата,
закрытие с расхождением 500 → expectedCash/difference + Z-отчёт; продажа без смены→409
10. [x] Оприходование +8, списание −2, инвентаризация CSV (часть): расхождение применено, непосчитанные НЕ обнулены
11. [x] Отчёты: продажи/прибыль(=выручка−cost)/остатки на дату/«Деньги»(=начальный+движения)/долги; по точкам/кассирам
12. [x] Инварианты: товар остаток≡Σдвижений; счёт≡начальный+Σопераций; долг≡формуле; изоляция (demo не видит temp)
13. [x] Отмена приёмки с проданным товаром→отказ; отмена платежа→долг вернулся (точно)
14. [x] Отчёт `reports/full-chain-<RUN_ID>.md` (ожид/факт/✓✗/длит + скрины) + галерея; в `~/nightly-verify.sh`
(04:00, C+UI) и `~/quality-watchdog.sh` step 11 (HR=03, api-only)
15. [x] 3 бага найдено и починено (repro→fix→deploy→rerun зелёный); прогоны зелёные (см. ниже)
## Найденные и исправленные баги
1. **EmployeesController.Update → 500** (DbUpdateConcurrencyException «0 rows affected») при правке
сотрудника, у которого есть привязка к точке продаж. Причина: EF8 путается на nav-collection
`RemoveRange+Clear+Add` (тот же баг, что чинили в Supplies/Enters/Losses/Demands.Update).
Фикс: `ExecuteDeleteAsync` по EmployeeId + `DbSet.Add` (без Clear nav). **Исправлено в коде, ждёт deploy.**
Воспроизведение: создать сотрудника с retailPointIds=[точка], затем PUT любой правки → было 500.
Фикс в `EmployeesController.Update`. ✅ Задеплоено, прогон зелёный.
2. **Кассир видел модуль «Деньги»** (GET /api/money/accounts → 200): роль «Кассир» имела `MoneyView`.
Фикс: дефолт кассира `MoneyView=false` (DevDataSeeder) + backfill существующих орг (вкл. демо) в
`EnsureMoneyDefaultsAsync` (снимает MoneyView, оставляет CashShiftOperate). Эндпоинты своей смены/Z
(`CashShiftsController` List/Get/Report) → `RequiresPermission("MoneyView,CashShiftOperate")`; добавлена
ИЛИ-семантика прав в `PermissionRequirement`/`PermissionAuthorizationHandler`. Веб уже корректно прячет
модуль «Деньги»/«Настройки» у кассира (оставляет только «Смены»). ✅ Задеплоено, кассир→403 на деньги, 200 на свою смену.
3. **Смена открывалась на неактивной точке; кассир мог открыть смену на непривязанной точке.**
Фикс в `MoneyService.OpenShiftAsync`: проверка `RetailPoint.IsActive` (`retail-point-inactive`) и
привязки кассира (`retail-point-not-assigned`; у кого привязок нет — на любой). ✅ Задеплоено, прогон зелёный.
Прочие найденные дефекты оказались **в самом тесте** (не в продукте): неуникальный EAN13-генератор,
`r.data.employee.id` вместо `r.data.id`, groupBy `register` (а не `retailPoint`), пароль quick-login
`__QUICK_LOGIN_PWLESS__` (а не 123456), слишком строгая UI-проверка меню кассира (/money/shifts — его касса). Все поправлены.
### Баги из тестирования iOS-приложения (поправка владельца 08.10)
4. **Кладовщик без прав на склад.** Роль «Кладовщик» не имела InventoryEdit/EnterEdit/LossEdit → API 403
на инвентаризацию/оприходование/списание, хотя веб их показывал (рассинхрон). Фикс: добавлены права в
дефолт роли (DevDataSeeder) + backfill существующих орг. Репро в шаге 10 (кладовщик→оприходование/списание не 403). ✅
5. **Инвентаризация обнуляла непосчитанное.** Create с пустыми строками авто-подтягивал ВСЕ товары склада с
ActualQty=0 (Diff=−stock) → проведение обнуляло всё непересчитанное (и всё вне выбранной группы). Фикс:
авто-подтянутые строки создаются как ActualQty=BookQty (Diff=0) → проведение их не трогает; кладовщик меняет
ActualQty только у пересчитанных (истинный ноль вводится явно). Репро в шаге 10 (пустая инвентаризация →
непосчитанный не обнулён). ✅ (без миграции/правок веба)
6. **Внутренний EAN-13 с неверной контрольной цифрой.** Веб-генератор (`lib/barcode.ts` digitsChecksum) считал
веса слева, а для 12-значного тела чётность переворачивается. Фикс: веса справа налево (EAN-8/UPC-A не изменились).
Уже выданные коды починены скриптом `deploy/fix-internal-ean13.sql`: исправлен **41** код (0 конфликтов, 0 осталось);
список — `docs/ean13-fixed-20261008.txt`. Прод НЕ трогали (скрипт ручной, не auto-migration). ✅
7. **«Приёмка сканированием» не сохранялась.** SupplyScanPage читал валюты как массив
(`Array.isArray(r.data)`), а `/api/catalog/currencies` отдаёт PagedResult `{items}` → список валют пуст,
нет KZT по умолчанию. Фикс: `r.data.items`. ✅
8. **Поиск товара на экране накладной не работал.** InvoiceReviewPage слал `?q=`, а API ждёт `?search=`
(PagedRequest.Search). Фикс: `search`. ✅
9. **«Сегодня» считалось по UTC, а не Asia/Almaty (UTC+5).** Дашборд (RevenueToday/Week/Month + график),
`/api/reports/sales` и `/profit` группировка `period:day` — все считали день по UTC. Фикс: хелпер
`BusinessTime` (UTC+5) + применён в RetailSalesController /stats и report day-bucket'ах. ✅
## API-карта (выверено по контроллерам, для сценария)
Base: `E2E_ADMIN_URL=https://test.admin.food-market.kz`. Логин: `POST /connect/token`
(password grant, client_id=food-market-web). Демо-трио (режим B): username
`demo-admin|demo-keeper|demo-cashier@food-market.local`, пароль-sentinel `__QUICK_LOGIN_PWLESS__` (quick-login беспарольный вход на стенде; не `123456`).
- Регистрация (C): `POST /api/auth/signup {email,password,organizationName,phone,plan:'start'}` → 200; орг по умолчанию простой режим.
- Простой режим: `[RequiresAdvancedMode]` → 403 code=`advanced-mode-required`. Закрыты: demands(`/api/sales/demands`),
transfers(`/api/inventory/transfers`), promotions, loyalty, abc-report, telegram, audit-log, price-types мутации, user-presets.
- Каталог: units `/api/catalog/units-of-measure`; groups `/api/catalog/product-groups` {name,parentId,sortOrder};
price-types `/api/catalog/price-types` (isRetail); currencies `/api/catalog/currencies` (KZT);
products `POST /api/catalog/products` {name,article,unitOfMeasureId,vat,vatEnabled,productGroupId,isService,packaging,
referencePrice,purchaseCurrencyId,prices:[{priceTypeId,amount,currencyId}],barcodes:[{code,type,isPrimary}]}.
- Контрагент: `POST /api/catalog/counterparties` {name,legalName,type(0=ЮЛ),bin,countryId,...}.
- Магазин/точки: `/api/catalog/stores` {name,code,isMain,isActive}; `/api/catalog/retail-points` {name,code,storeId,isActive}.
- Сотрудники: `/api/organization/employees` POST {lastName,firstName,position,email,phone,roleId,isActive,retailPointIds,createAccount} → generatedPassword;
PUT/DELETE требуют роли Admin; роли `/api/organization/employee-roles` (системные «Кассир»,«Кладовщик»,Admin).
- Деньги (perm MoneyView/MoneyEdit/CashShiftOperate):
- Счета `POST /api/money/accounts` {Name,Kind(Cash=1,Safe=2,Bank=3,Acquiring=4),BankName?,AccountNumber?,StoreId?(обяз.для Cash),CurrencyId,OpeningBalance,OpeningDate,IsActive}.
Баланс = OpeningBalance + Σ(PaymentIn/CashIn/TransferIn) − Σ(PaymentOut/CashOut/TransferOut) по Posted. Report `GET /api/money/reports/balances`.
- Корректировка долга `POST /api/money/adjustments` {Date,CounterpartyId,Direction(WeOwe=1,TheyOwe=2),Amount,Notes} + `/{id}/post`.
- Транзакция `POST /api/money/transactions` {Date,Kind(PaymentIn=1,PaymentOut=2,CashIn=3,CashOut=4),AccountId,CounterpartyId?,ExpenseItemId?,CurrencyId,Amount,Links:[{SupplyId?,SupplierReturnId?,LinkedSum}]} + `/{id}/post`.
Оплата приёмки = PaymentOut/CashOut с Links[{SupplyId,LinkedSum}] → поднимает `supply.PayedSum` (инвариант 0≤PayedSum≤Total).
- Смена `POST /api/money/shifts/open` {RetailPointId,CashAccountId,AcquiringAccountId?,OpeningFloat}; `/{id}/drawer` {Direction(In=1,Out=2),Amount,Reason};
`/{id}/close` {ActualCash,Notes} → {expectedCash,difference}; `/{id}/report` (Z). 409 ShiftAlreadyOpen.
ExpectedCash = OpeningFloat + salesCash − returnsCash + drawerIn − drawerOut; salesCash=Σ PaidCash non-return по CashShiftId; CashDifference = Actual − Expected.
- Приёмка `POST /api/purchases/supplies` {date,supplierId,storeId,currencyId,notes,lines:[{productId,quantity,unitPrice}]} + `/{id}/post` (perm SuppliesPost). Повторный post → 409.
- Инвойсы `POST /api/purchases/invoices/upload` (form `files`, xlsx/csv/pdf); `/upload-photos` (form `files`,`codes`); фон InvoiceWorker: pending→parsing→needs_review|failed;
`PUT /{id}/lines` {Lines,SupplierName,...} (ручной матч ProductId+MatchedBy=manual); `POST /{id}/apply` {SupplierId?(null→«Поставщик не указан»),StoreId,CurrencyId,Notes} → создаёт Supply Draft; снимки с диска удаляются.
- Продажа `POST /api/sales/retail` {date,storeId,retailPointId,customerId,currencyId,payment(Cash=0,Card=1,Mixed=99),paidCash,paidCard,notes,lines:[{productId,quantity,unitPrice,discount,vatPercent}],isReturn,referenceSaleId}
+ `/{id}/post` (требует откр.смену на точке иначе 409 code=cash-shift-required; oversell→409; недоплата→400). Возврат: `/{id}/create-return`→draft(isReturn)→post. `/{id}/unpost` (при наличии проведённых возвратов→409).
- Склад: enters `POST /api/inventory/enters` {date,storeId,currencyId,notes,lines:[{productId,quantity,unitCost}]}+post (оприходование, +qty);
losses `POST /api/inventory/losses` {date,storeId,currencyId,reason,notes,lines:[{productId,quantity,unitCost}]}+post (−qty);
инвентаризация `POST /api/inventory/inventories/import` (form file csv/xlsx: «Штрихкод»/«Количество»→{found,unknown}); `POST /api/inventory/inventories` {date,storeId,notes,lines:[{productId,actualQty}]}(частичная=только эти товары)+post (двигает только Diff≠0, непосчитанные не трогаются).
stock `GET /api/inventory/stock?productId&storeId`; movements `GET /api/inventory/movements`.
- Отчёты: sales `GET /api/reports/sales?from&to&groupBy=period:day|retailPoint|cashier|product&storeId` →[{Key,Label,Revenue,Discount,Transactions,Quantity}];
profit `GET /api/reports/profit?from&to` →[{..,Revenue,Cost,Profit,MarginPercent}]; stock `GET /api/reports/stock?date&storeId&includeZero` →[{..,Quantity,Cost,Value}] (остатки на дату);
деньги balances/movement/supplier-debts (долг = (Σприёмки−Σоплачено)−(Σвозвраты−Σполучено)+(WeOwe−TheyOwe)).
Фикстура фото-накладной: `tests/fixtures/invoice-sample.jpg`.
## Журнал
- 07.10 — старт; выяснена топология стенда; классификация таблиц; план зафиксирован.
- 08.10 — Шаг 0 (A) выполнен (backup+clean Демо). Выверена полная API-карта по контроллерам. Приступаю к сценарию.
- 08.10 — сценарий зелёный (16/16); 3 бага (EF8 nav, кассир-MoneyView, привязка/неактивная точка) + 2а строгие
проверки; скриншоты+галерея; B-прогон в Демо зелёный; в nightly+watchdog. 2 зелёных прогона C подряд.
- 08.10 — iOS-cleanup Демо: транзакционные ios-документы удалены ещё pre-B reset'ом (reset-demo.sql: приёмки/
оприходования/списания/инвентаризации/контрагенты в DELETE-set). Остаточный ios-товар «Тестовый товар (ios-тест)»
(83b8254c, создан после reset) + его 3 штрихкода + 1 цена — удалены. Реальных правок цены после reset не
найдено (ios-правка цены была на этом удалённом товаре). «Gerlax D2 iOS» — реальный товар каталога (04.10), оставлен.
- 08.10 — поправка: 6 багов из iOS-тестирования (см. список выше, пп.4–9) — репро в сценарии, фикс, деплой,
EAN-13 скрипт (41 код). Все репро после деплоя зелёные (кладовщик 201; пустая инвентаризация: непосчитанное
35→35; EAN-13 валиден). Дашборд revenueToday по Алматы верен (10650, 6 чеков сегодня).
- 08.10 — ✅ ФИНАЛ: B-showcase v2 на чистом Демо — 16/16 зелёный (36 скринов, галерея). Два прогона C подряд —
оба 16/16 ЗЕЛЁНЫЙ. 9 багов (3 + 6) исправлены и задеплоены. touch ~/.fm-watchdog/DONE.
## Статус: ГОТОВО ✅
- Сценарий `full-chain`: 16/16 зелёный (режимы C и B). В `~/nightly-verify.sh` (04:00, C+UI) и
`~/quality-watchdog.sh` step 11 (HR=03, api-only).
- 9 исправленных багов (3 из разработки теста + 6 из iOS): все с репро в сценарии, задеплоены, повторный прогон зелёный.
- Демо-магазин: backup сохранён; очищен (A); проведён показательный день (B) на реальном каталоге; e2e-сущности
деактивированы; ios-мусор удалён.
- Код-правки — в working tree (задеплоены на stage через ~/deploy-stage.sh), в git НЕ закоммичены
(policy «коммит только по просьбе»). Готов закоммитить по команде.

48
tests/e2e/full-chain.sh Executable file
View file

@ -0,0 +1,48 @@
#!/usr/bin/env bash
# Запускаемый сквозной тест «жизнь магазина от регистрации до отчётов».
# Один прогон = свои свежие данные против настоящего стенда, сверка ЧИСЕЛ и
# инвариантов, markdown-отчёт со скриншотами. Выход != 0 если есть падения —
# чтобы ночной прогон/quality-watchdog ловил регресс.
#
# Использование:
# tests/e2e/full-chain.sh # режим C: свежая temp-орг (для ночного прогона)
# tests/e2e/full-chain.sh --api-only # без браузера (быстрее, только API+инварианты)
# FULLCHAIN_TARGET=demo tests/e2e/full-chain.sh # режим B: прогон в «Демо-магазине»
#
# Env:
# E2E_ADMIN_URL — стенд (default https://test.admin.food-market.kz)
# FULLCHAIN_TARGET — temp (default) | demo
set -uo pipefail
cd "$(dirname "$0")"
export E2E_ADMIN_URL="${E2E_ADMIN_URL:-https://test.admin.food-market.kz}"
if [ ! -d node_modules ]; then
pnpm install --silent
fi
# chromium для UI-шагов (если не ставили)
if [ "${1:-}" != "--api-only" ]; then
pnpm exec playwright install chromium >/dev/null 2>&1 || true
fi
echo "[full-chain] стенд=$E2E_ADMIN_URL режим=${FULLCHAIN_TARGET:-temp} $*"
pnpm exec tsx runner.ts full-chain "$@"
RC=$?
REPORT="$(ls -t reports/full-chain-*.md 2>/dev/null | head -1)"
if [ -z "$REPORT" ]; then
echo "[full-chain] ОТЧЁТ НЕ НАЙДЕН — прогон не состоялся"
exit 2
fi
echo "[full-chain] отчёт: $REPORT"
# Итоговая строка: «**Итог:** N ✓ / M ✗ / ...»
SUMMARY="$(grep -m1 '\*\*Итог:\*\*' "$REPORT" || true)"
echo "[full-chain] $SUMMARY"
FAILS="$(echo "$SUMMARY" | grep -oE '[0-9]+ ✗' | grep -oE '[0-9]+' || echo 0)"
if [ "${FAILS:-0}" -gt 0 ] || [ "$RC" -ne 0 ]; then
echo "[full-chain] РЕЗУЛЬТАТ: КРАСНЫЙ (падений: ${FAILS:-?})"
exit 1
fi
echo "[full-chain] РЕЗУЛЬТАТ: ЗЕЛЁНЫЙ"
exit 0

213
tests/e2e/lib/fc-ui.ts Normal file
View file

@ -0,0 +1,213 @@
/**
* UI-харнесс full-chain: реальный headless-браузер (Playwright chromium).
* Делает скриншоты КАЖДОГО процесса в хорошем разрешении (поправка владельца
* 08.10): десктоп 1920×1080 @2x (Retina) + мобильный 390×844 @3x для экранов,
* которыми пользуются с телефона. fullPage для длинных отчётов. Имена файлов
* `NN-шаг-что.png`, папка reports/full-chain-<RUN_ID>/screenshots/.
*
* На каждой странице слушаем console.error / сетевые 5xx / error-тосты —
* шаги проверяют, что их 0. Логин кладём токеном в localStorage, поле пароля
* на экранах не светится (нет паролей/токенов на скринах).
*/
import { chromium, type Browser, type BrowserContext, type Page } from 'playwright'
import { mkdirSync, writeFileSync } from 'node:fs'
import { resolve, basename } from 'node:path'
import { BASE } from './fc.js'
export interface UiErrors { console: string[]; network: string[]; toast: string[] }
export interface Shot { file: string; rel: string; caption: string; step: string; mobile: boolean }
function attachErrorListeners(page: Page, errors: UiErrors) {
page.on('console', (m) => {
if (m.type() !== 'error') return
const t = m.text()
if (/^Failed to load resource: the server responded with a status of \d+/i.test(t)) return
if (/net::(ERR_NETWORK_CHANGED|ERR_INTERNET_DISCONNECTED|ERR_CONNECTION_RESET|ERR_NAME_NOT_RESOLVED|ERR_CONNECTION_REFUSED|ERR_TIMED_OUT|ERR_ABORTED)/i.test(t)) return
errors.console.push(t)
})
page.on('response', (r) => {
const s = r.status()
if (s < 400) return
const url = r.url()
if (s === 401 && /\/(api|connect)\//.test(url)) return
errors.network.push(`${s} ${r.request().method()} ${url}`)
})
}
export class Ui {
browser!: Browser
deskCtx!: BrowserContext
mobCtx?: BrowserContext
page!: Page // desktop
mobPage?: Page
errors: UiErrors = { console: [], network: [], toast: [] }
shots: Shot[] = []
dir!: string
private token = ''
static async launch(runId: string): Promise<Ui> {
const u = new Ui()
u.dir = resolve(import.meta.dirname, '..', 'reports', `full-chain-${runId}`, 'screenshots')
mkdirSync(u.dir, { recursive: true })
u.browser = await chromium.launch({ headless: true, args: ['--ignore-certificate-errors'] })
u.deskCtx = await u.browser.newContext({
ignoreHTTPSErrors: true, locale: 'ru-RU',
viewport: { width: 1920, height: 1080 }, deviceScaleFactor: 2, baseURL: BASE,
})
u.page = await u.deskCtx.newPage()
attachErrorListeners(u.page, u.errors)
return u
}
private async ensureMobile(): Promise<Page> {
if (!this.mobPage) {
this.mobCtx = await this.browser.newContext({
ignoreHTTPSErrors: true, locale: 'ru-RU', isMobile: true, hasTouch: true,
viewport: { width: 390, height: 844 }, deviceScaleFactor: 3, baseURL: BASE,
})
this.mobPage = await this.mobCtx.newPage()
attachErrorListeners(this.mobPage, this.errors)
if (this.token) {
await this.mobPage.goto('/login', { waitUntil: 'domcontentloaded' })
await this.mobPage.evaluate((t) => localStorage.setItem('fm.access_token', t), this.token)
}
}
return this.mobPage
}
/** Залогинить браузер токеном (оба контекста). */
async attach(token: string, path = '/dashboard') {
this.token = token
await this.page.goto('/login', { waitUntil: 'domcontentloaded' })
await this.page.evaluate((t) => localStorage.setItem('fm.access_token', t), token)
await this.page.goto(path, { waitUntil: 'domcontentloaded' })
await this.page.waitForTimeout(500)
}
async goto(path: string, waitMs = 600) {
await this.page.goto(path, { waitUntil: 'domcontentloaded' })
await this.page.waitForTimeout(waitMs)
}
async gotoMobile(path: string, waitMs = 700): Promise<Page> {
const m = await this.ensureMobile()
await m.goto(path, { waitUntil: 'domcontentloaded' })
await m.waitForTimeout(waitMs)
return m
}
async collectToasts() {
for (const p of [this.page, this.mobPage]) {
if (!p) continue
try {
const texts = await p.$$eval('[data-toast-kind="error"]', (els) => els.map((e) => (e.textContent ?? '').trim()))
for (const t of texts) if (t && !this.errors.toast.includes(t)) this.errors.toast.push(t)
} catch { /* навигация */ }
}
}
/** Снимок десктопа. name → NN-шаг-что (без .png). caption — рус. подпись для галереи/отчёта. */
async shot(step: string, name: string, caption: string, opts?: { fullPage?: boolean }): Promise<Shot> {
const file = resolve(this.dir, `${name}.png`)
try { await this.page.screenshot({ path: file, fullPage: opts?.fullPage ?? false }) } catch { /* ignore */ }
const s: Shot = { file, rel: this.relFor(name), caption, step, mobile: false }
this.shots.push(s)
return s
}
/** Мобильный снимок (экран, которым пользуются с телефона). Навигирует mobile-страницу сам. */
async shotMobile(step: string, name: string, caption: string, path: string, opts?: { fullPage?: boolean }): Promise<Shot> {
const m = await this.gotoMobile(path)
const file = resolve(this.dir, `${name}.png`)
try { await m.screenshot({ path: file, fullPage: opts?.fullPage ?? true }) } catch { /* ignore */ }
const s: Shot = { file, rel: this.relFor(name), caption, step, mobile: true }
this.shots.push(s)
return s
}
private relFor(name: string): string {
// относительный путь от reports/<report>.md к скрину
return `${basename(resolve(this.dir, '..'))}/screenshots/${name}.png`
}
errorSummary(): string {
const parts: string[] = []
if (this.errors.console.length) parts.push(`console: ${this.errors.console.slice(0, 3).join(' | ')}`)
if (this.errors.network.length) parts.push(`net: ${this.errors.network.slice(0, 3).join(' | ')}`)
if (this.errors.toast.length) parts.push(`toast: ${this.errors.toast.slice(0, 3).join(' | ')}`)
return parts.join('; ')
}
resetErrors() { this.errors = { console: [], network: [], toast: [] } }
/** HTML-галерея со всеми скриншотами (крупные превью, клик → оригинал). */
writeGallery(runId: string, meta: { target: string; when: string }) {
const byStep = new Map<string, Shot[]>()
for (const s of this.shots) { if (!byStep.has(s.step)) byStep.set(s.step, []); byStep.get(s.step)!.push(s) }
const sections = [...byStep.entries()].map(([stepName, shots]) => {
const cards = shots.map((s) => `
<figure>
<a href="screenshots/${basename(s.file)}" target="_blank">
<img src="screenshots/${basename(s.file)}" loading="lazy" alt="${esc(s.caption)}"/>
</a>
<figcaption>${s.mobile ? '📱 ' : '🖥 '}${esc(s.caption)}<br><small>${basename(s.file)}</small></figcaption>
</figure>`).join('')
return `<section><h2>${esc(stepName)}</h2><div class="grid">${cards}</div></section>`
}).join('\n')
const html = `<!doctype html><html lang="ru"><head><meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Full-chain e2e — скриншоты процессов (${esc(meta.target)})</title>
<style>
body{font:14px/1.5 system-ui,Segoe UI,Roboto,sans-serif;margin:0;background:#0f172a;color:#e2e8f0}
header{padding:20px 24px;background:#1e293b;position:sticky;top:0;box-shadow:0 1px 0 #334155}
h1{margin:0 0 4px;font-size:20px} .sub{color:#94a3b8;font-size:13px}
section{padding:8px 24px 24px} h2{font-size:15px;color:#7dd3fc;border-bottom:1px solid #334155;padding-bottom:6px}
.grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(360px,1fr));gap:16px}
figure{margin:0;background:#1e293b;border:1px solid #334155;border-radius:10px;overflow:hidden}
img{width:100%;display:block;background:#fff;cursor:zoom-in}
figcaption{padding:8px 10px;font-size:12px;color:#cbd5e1}
small{color:#64748b}
</style></head><body>
<header><h1>Жизнь магазина — скриншоты процессов</h1>
<div class="sub">Прогон: ${esc(meta.target)} · ${esc(meta.when)} · всего кадров: ${this.shots.length}</div></header>
${sections}
</body></html>`
const out = resolve(this.dir, '..', 'index.html')
writeFileSync(out, html, 'utf8')
return out
}
/** Ссылки сайдбара для пользователя с данным токеном (эфемерный контекст). */
async sidebarHrefs(token: string): Promise<string[]> {
const c = await this.browser.newContext({ ignoreHTTPSErrors: true, locale: 'ru-RU', viewport: { width: 1920, height: 1080 }, baseURL: BASE })
const p = await c.newPage()
try {
await p.goto('/login', { waitUntil: 'domcontentloaded' })
await p.evaluate((t) => localStorage.setItem('fm.access_token', t), token)
await p.goto('/dashboard', { waitUntil: 'domcontentloaded' })
await p.waitForTimeout(900)
return (await p.$$eval('a[href^="/"]', (els) => els.map((e) => e.getAttribute('href') ?? ''))).filter(Boolean)
} finally { await c.close() }
}
/** Скрин экрана под чужим токеном (напр. кабинет кассира — без пунктов «Деньги»/«Настройки»). */
async shotAs(step: string, token: string, path: string, name: string, caption: string): Promise<Shot> {
const c = await this.browser.newContext({ ignoreHTTPSErrors: true, locale: 'ru-RU', viewport: { width: 1920, height: 1080 }, deviceScaleFactor: 2, baseURL: BASE })
const p = await c.newPage()
const file = resolve(this.dir, `${name}.png`)
try {
await p.goto('/login', { waitUntil: 'domcontentloaded' })
await p.evaluate((t) => localStorage.setItem('fm.access_token', t), token)
await p.goto(path, { waitUntil: 'domcontentloaded' })
await p.waitForTimeout(900)
await p.screenshot({ path: file, fullPage: false })
} catch { /* ignore */ } finally { await c.close() }
const s: Shot = { file, rel: this.relFor(name), caption, step, mobile: false }
this.shots.push(s)
return s
}
async close() { try { await this.browser.close() } catch { /* ignore */ } }
}
function esc(s: string): string {
return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;')
}

131
tests/e2e/lib/fc.ts Normal file
View file

@ -0,0 +1,131 @@
/**
* Хелперы для сценария full-chain (жизнь магазина от регистрации до отчётов).
*
* Работает против НАСТОЯЩЕГО стенда (E2E_ADMIN_URL, по умолчанию
* https://test.admin.food-market.kz). Без моков. Инварианты считаем через API
* (не через БД: stage-БД живёт на prod-vm, docker exec сюда не достаёт).
*
* Два режима запуска:
* - C (по умолчанию): своя свежая организация через /api/auth/signup,
* имя `e2e-fullchain-<ts>`. Чистится ночной уборкой (>3 дней).
* - B (FULLCHAIN_TARGET=demo): вход в «Демо-магазин» demo-admin/keeper/cashier,
* работа на реальном каталоге; новые сущности с меткой «(e2e)».
*/
import axios, { AxiosInstance } from 'axios'
import { Agent as HttpsAgent } from 'node:https'
export const BASE = process.env.E2E_ADMIN_URL ?? 'https://test.admin.food-market.kz'
export const TARGET = (process.env.FULLCHAIN_TARGET ?? 'temp').toLowerCase() // 'temp' | 'demo'
export const IS_DEMO = TARGET === 'demo'
const httpsAgent = new HttpsAgent({ rejectUnauthorized: false })
export function makeClient(token?: string): AxiosInstance {
return axios.create({
baseURL: BASE,
httpsAgent,
timeout: 60_000,
headers: token ? { Authorization: `Bearer ${token}` } : {},
validateStatus: () => true,
})
}
export interface Session {
email: string
password: string
token: string
orgId: string
roles: string[]
}
async function tokenFor(email: string, password: string): Promise<string> {
const body = new URLSearchParams({
grant_type: 'password', username: email, password,
client_id: 'food-market-web', scope: 'openid profile email roles api offline_access',
})
const r = await makeClient().post('/connect/token', body, {
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
})
if (r.status !== 200) throw new Error(`token ${email}: ${r.status} ${JSON.stringify(r.data).slice(0, 200)}`)
return r.data.access_token as string
}
export async function me(token: string): Promise<{ orgId: string; roles: string[]; advancedMode: boolean; sub: string }> {
const r = await makeClient(token).get('/api/me')
if (r.status !== 200) throw new Error(`/api/me: ${r.status}`)
return { orgId: r.data.orgId, roles: r.data.roles ?? [], advancedMode: !!r.data.advancedMode, sub: r.data.sub }
}
/** Регистрация свежей организации (режим C). Имя с меткой e2e-fullchain-<ts>. */
export async function signupOrg(): Promise<Session> {
const ts = Date.now()
const email = `e2e-fullchain-${ts}@food-market.local`
const password = 'UiTest12345!'
const orgName = `e2e-fullchain-${ts}`
const api = makeClient()
let r = await api.post('/api/auth/signup', { email, password, organizationName: orgName, phone: '+77011190001', plan: 'start' })
for (let i = 0; i < 6 && r.status === 429; i++) {
await sleep(12_000)
r = await api.post('/api/auth/signup', { email, password, organizationName: orgName, phone: '+77011190001', plan: 'start' })
}
if (r.status !== 200) throw new Error(`signup: ${r.status} ${JSON.stringify(r.data).slice(0, 200)}`)
const token = await tokenFor(email, password)
const m = await me(token)
return { email, password, token, orgId: m.orgId, roles: m.roles }
}
/** Вход в «Демо-магазин» одним из demo-* (режим B); пароль — quick-login master. */
export async function demoLogin(which: 'admin' | 'keeper' | 'cashier'): Promise<Session> {
const email = `demo-${which}@food-market.local`
// На стенде demo-трио логинится quick-login sentinel-паролем (беспарольный
// вход). Реальный пароль demo-юзеров неизвестен; sentinel работает пока
// QuickLogin:Enabled=true и Issuer не прод.
const password = process.env.FULLCHAIN_DEMO_PASS ?? '__QUICK_LOGIN_PWLESS__'
const token = await tokenFor(email, password)
const m = await me(token)
return { email, password, token, orgId: m.orgId, roles: m.roles }
}
/** Логин по известным кредам (для проверки блокировки, повторных входов). */
export async function loginCreds(email: string, password: string): Promise<{ ok: boolean; status: number; token?: string; refresh?: string }> {
const body = new URLSearchParams({
grant_type: 'password', username: email, password,
client_id: 'food-market-web', scope: 'openid profile email roles api offline_access',
})
const r = await makeClient().post('/connect/token', body, { headers: { 'Content-Type': 'application/x-www-form-urlencoded' } })
return { ok: r.status === 200, status: r.status, token: r.data?.access_token, refresh: r.data?.refresh_token }
}
export async function refreshToken(refresh: string): Promise<{ ok: boolean; status: number }> {
const body = new URLSearchParams({
grant_type: 'refresh_token', refresh_token: refresh, client_id: 'food-market-web',
})
const r = await makeClient().post('/connect/token', body, { headers: { 'Content-Type': 'application/x-www-form-urlencoded' } })
return { ok: r.status === 200, status: r.status }
}
// ── числовые помощники ─────────────────────────────────────────────────────
export const round2 = (x: number) => Math.round((x + Number.EPSILON) * 100) / 100
export const approxEq = (a: number, b: number, eps = 0.01) => Math.abs(a - b) < eps
export const sum = (xs: number[]) => xs.reduce((a, b) => a + b, 0)
export const sleep = (ms: number) => new Promise<void>((res) => setTimeout(res, ms))
/** EAN-13 с валидной контрольной цифрой. idx (0..999) кодируется в младших
* разрядах 12-значного тела ДО контрольной цифры, время прогона — в середине,
* поэтому коды уникальны и между прогонами, и между товарами (idx). */
export function ean13(idx: number): string {
const timePart = String(Date.now() % 100_000_000).padStart(8, '0') // 8 цифр
const base = ('2' + timePart + String(idx % 1000).padStart(3, '0')).slice(0, 12) // 1+8+3=12
let s = 0
for (let i = 0; i < 12; i++) s += Number(base[i]) * (i % 2 === 0 ? 1 : 3)
const check = (10 - (s % 10)) % 10
return base + check
}
/** Проверка контрольной цифры EAN-13 (справа налево, веса 3,1,3,1…). */
export function isValidEan13(code: string): boolean {
if (!/^\d{13}$/.test(code)) return false
let s = 0, w = 3
for (let i = 11; i >= 0; i--) { s += Number(code[i]) * w; w = w === 3 ? 1 : 3 }
return (10 - (s % 10)) % 10 === Number(code[12])
}

View file

@ -23,6 +23,7 @@ export interface Step {
durationMs: number
notes: string[]
screenshot?: string
screenshots?: { rel: string; caption: string }[]
}
export interface Bug {
@ -112,6 +113,17 @@ export class Report {
lines.push('')
lines.push(`Скриншот: \`${step.screenshot}\``)
}
if (step.screenshots && step.screenshots.length > 0) {
lines.push('')
lines.push('Скриншоты процесса:')
lines.push('')
for (const sh of step.screenshots) {
lines.push(`[${sh.caption}](${sh.rel})`)
lines.push('')
lines.push(`![${sh.caption}](${sh.rel})`)
lines.push('')
}
}
lines.push('')
}

View file

@ -83,7 +83,9 @@ async function main() {
console.error(`[${stepDecl.id}] ${step.status} (${step.durationMs}ms)`)
}
const ts = new Date().toISOString().replace(/[:.]/g, '-')
// FULLCHAIN_RUN_ID — чтобы имя отчёта совпадало с папкой скриншотов
// (reports/full-chain-<RUN_ID>/screenshots/). Если не задан — timestamp.
const ts = process.env.FULLCHAIN_RUN_ID ?? new Date().toISOString().replace(/[:.]/g, '-')
const reportPath = resolve(import.meta.dirname, 'reports', `${name}-${ts}.md`)
report.finalize(reportPath)
console.error(`[done] report → ${reportPath}`)

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,45 @@
name: full-chain
description: |
Жизнь магазина от регистрации до отчётов — сквозной прогон ВСЕХ процессов
простого режима через настоящий стенд. Каждый шаг сверяет конкретные ЧИСЛА
(ожидаемое считает сам тест) и инварианты; фиксирует 0×5xx / 0×error-тостов /
0×console.error на UI-шагах. Режим C (temp-org через signup) по умолчанию;
режим B (FULLCHAIN_TARGET=demo) — на «Демо-магазине» на реальном каталоге.
preconditions:
reset_db: false
smoke_login_super_admin: false
steps:
- id: step01_register_and_login
title: "Регистрация организации → вход владельцем → простой режим (скрытые модули 403)"
- id: step02_catalog_and_supplier
title: "Справочники — группа, 5 товаров (разные ед.изм., 1 весовой) + цены + штрихкоды; поставщик"
- id: step02a_employees_and_points
title: "Сотрудники (кассир/кладовщик/админ) + 2 точки продаж; права (API 403); правка/пароль/блокировка"
- id: step03_money_accounts
title: "Деньги — касса, банковский счёт (номер), эквайринг; начальные остатки"
- id: step04_initial_supplier_debt
title: "Начальный долг поставщику через корректировку взаиморасчётов (WeOwe) → долг = сумме"
- id: step05_supply1_manual
title: "Приёмка №1 вручную (кладовщик) → провести → остатки +кол-во; себестоимость"
- id: step06_supply2_excel
title: "Приёмка №2 из табличной накладной (поставщик не указан → «Поставщик не указан») → провести"
- id: step07_supply3_photo
title: "Приёмка №3 по фото-накладной → ручное сопоставление → провести; снимки удалены с диска"
- id: step08_supplier_payments
title: "Оплата — №1 частично нал из кассы, №2 полностью с банка; статусы + долг = формуле"
- id: step09_cashier_shift
title: "Смена — размен, 3 продажи (нал/карта/смеш), oversell→отказ, возврат, внесение/выплата, закрытие с расхождением + Z; продажа без смены→отказ"
- id: step09a_points_binding
title: "Привязка кассира к точкам; параллельные смены на 2 точках не смешивают деньги; продажа на неактивной→отказ"
- id: step10_warehouse_ops
title: "Склад — оприходование излишка, списание брака, инвентаризация CSV (часть товаров): непосчитанные не обнулены"
- id: step11_reports
title: "Отчёты — продажи за день, прибыль, остатки на дату, «Деньги», долги; по точкам/кассирам"
- id: step12_invariants
title: "Инварианты — остаток≡Σдвижений; счёт≡начальный+Σопераций; долг≡формуле; изоляция орг"
- id: step13_cancel_rollback
title: "Отмена/откат — отмена приёмки с проданным товаром→отказ; отмена платежа→долг вернулся"
- id: step14_finalize
title: "Финал — скриншоты ключевых экранов, деактивация e2e-сущностей (режим B)"