test(stage): smoke + signup на test.admin.food-market.kz
Some checks are pending
CI / Backend (.NET 8) (push) Waiting to run
CI / Web (React + Vite) (push) Waiting to run
CI / POS (WPF, Windows) (push) Waiting to run

stage-smoke (5 шагов): signup happy-path, bootstrap (Store/Roles/
Units/ProductGroup/PriceTypes/RetailPoint), login → access+refresh
+ /api/me с правильным orgId+role=Admin, edge-cases (дубликат
email, короткий пароль, пустое название, кривой телефон), проверка
public-сайта.

Informational gap: stage-public (test.food-market.kz) использует тот
же build что прод-public, поэтому его форма signup POST'ит в прод
admin. Для stage-testing регистрируемся напрямую POST на test.admin.

Чек-лист stage-testing: пункт 1 ✓.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
nns 2026-05-29 16:29:04 +05:00
parent 4675f38a0f
commit 0511cfacfd
4 changed files with 433 additions and 0 deletions

View file

@ -0,0 +1,49 @@
# Stage Testing Progress — test.admin.food-market.kz
Автономный прогон тестирования новых модулей на изолированном stage-стенде.
Стенд: https://test.admin.food-market.kz (отдельная БД, миграции применены).
Публичный сайт: https://test.food-market.kz.
Запуск: 2026-05-29. Исполнитель: Claude Opus 4.7 (автономный режим).
## Принципы
- Один пункт = один UI/API-сценарий + поиск багов + фикс + redeploy + retest.
- Сценарии складываются в `tests/e2e/scenarios/stage-{module}.{yml,steps.ts}`.
- Отчёты — `tests/e2e/reports/stage-{module}-{timestamp}.md`.
- После каждого набора фиксов — `~/deploy-stage.sh` (build+push+redeploy+health-wait).
- UX-проблемы (нет confirm на delete, ошибки не показываются юзеру и т.п.) трактуются как баги.
- Multi-tenant утечка — P0, фикс немедленно.
- Не трогать: global.json, gateway nginx, POS WPF, прод-стек.
## Чек-лист
- [x] **1. Smoke + signup flow** — signup создаёт org "TestStage", bootstrap (магазин/роли/единицы/группа "Все товары"), логин даёт access+refresh. *(stage-smoke.yml: 5/5 ✓)*
- [ ] **2. Каталог (товары/группы/контрагенты)** — UI CRUD, дубликаты, дочерние группы, FK-защита, multi-tenant изоляция (2 org).
- [ ] **3. Склад. Enter (Оприходование)** — UI создание/проведение/Unpost → Stock + StockMovement; RowVersion concurrency; multi-tenant.
- [ ] **4. Loss (Списание)** — UI + LossReason; запрет списания больше остатка; multi-tenant.
- [ ] **5. Transfer (Перемещение)** — два склада, From!=To, atomic post, Unpost без orphan-движений; multi-tenant.
- [ ] **6. Inventory (Инвентаризация)** — bookQty подгрузка, импорт CSV факта, корректирующий StockMovement на diff; multi-tenant.
- [ ] **7. CustomerReturn (Возврат от покупателя)** — UI из RetailSale, по чеку и без; возврат больше продано → 400.
- [ ] **8. SupplierReturn (Возврат поставщику)** — аналог для Supply.
- [ ] **9. Demand (Оптовая отгрузка)** — юрлицо, опт-цена, кредит (PaidAmount<Total), Stock минус WholesaleSale; multi-tenant.
- [ ] **10. Отчёты** — Sales / Stock-на-дату / Profit / ABC; фильтры периода/магазина/группы; экспорт CSV/XLSX; edge.
- [ ] **11. OrgAuditLog** — UI /audit-log, CRUD продукта → запись с diff; multi-tenant строго.
- [ ] **12. 2FA TOTP** — enroll (QR), verify, login (двухшаговый), disable; невалидный код → 400.
- [ ] **13. OpenAPI/Swagger** — все новые контроллеры в /swagger/v1/swagger.json.
- [ ] **14. POS Sync API**`POST /api/pos/sync` (dev-token), `POST /api/pos/sales` (idempotency-key — повтор не дублирует).
## Журнал
### 2026-05-29 — старт
- Проверена доступность `https://test.admin.food-market.kz/health/ready` → 200.
- Создан этот файл из задания.
### 2026-05-29 — пункт 1 ✓
- Сценарий `stage-smoke` (5 шагов): `tests/e2e/scenarios/stage-smoke.{yml,steps.ts}`.
- `POST /api/auth/signup` — счастливый путь, дубликат email, короткий пароль, пустое название, кривой телефон. Все валидации работают, сообщения локализованы, rate-limiter на signup активен (5+ запросов в окне → 429).
- Bootstrap новой org: Store `MAIN`, EmployeeRole `Администратор` (system), 5 enabled Units, корневая `Все товары`, retail PriceType, RetailPoint `Касса 1` — всё на месте.
- Логин → JWT с `org_id`, `role=Admin`, refresh_token выпускается (offline_access).
- **Logic gap (informational):** stage-public (test.food-market.kz) использует прод-build, поэтому его форма signup POST'ит в **прод admin** (admin.food-market.kz). Для stage-testing регистрируемся напрямую через `test.admin.food-market.kz/api/auth/signup` (как и делает сценарий). Если потребуется тестовая публичная воронка — отдельный stage-public с `PUBLIC_APP_URL=https://test.admin.food-market.kz` в build-args.

View file

@ -0,0 +1,72 @@
# E2E report: stage-smoke
Запущен: 2026-05-29T11:28:18.698Z
Длительность: 5.4с
**Итог:** 5 ✓ / 0 ✗ / 0 ⚠ / 0 ◯ (всего 5)
## ✓ Step stage01_signup_happy_path: POST /api/auth/signup создаёт новую org «TestStage-{ts}» + первого Admin
Длительность: 726мс
| Тип | Проверка | Результат |
|---|---|---|
| api | POST /api/auth/signup → 200 | ✓ org=bdaff44c-cc12-4971-9a0d-5da10090861d email=stage-smoke-1780054098701@food-market.local |
## ✓ Step stage02_bootstrap_entities_present: Bootstrap создал Store (MAIN), EmployeeRole «Администратор» (system), Units, ProductGroup «Все товары», PriceTypes (Розничная), RetailPoint «Касса 1»
Длительность: 1207мс
| Тип | Проверка | Результат |
|---|---|---|
| api | GET /api/catalog/stores содержит main store (isMain=true) | ✓ total=1 |
| api | EmployeeRoles содержит системную «Администратор» | ✓ roles=3 |
| api | UnitsOfMeasure для org содержат как минимум kg/l/m/упак (≥4 enabled) | ✓ enabled=5 |
| api | ProductGroups содержит корневую «Все товары» (parentId=null) | ✓ total=1 |
| api | PriceTypes содержит системную розничную (isRetail=true,isRequired=true) | ✓ total=2 |
| api | RetailPoints содержит хотя бы одну точку (bootstrap «Касса 1») | ✓ total=1 |
## ✓ Step stage03_login_returns_access_refresh: /connect/token (password grant) возвращает access+refresh, /api/me даёт orgId+role=Admin
Длительность: 573мс
| Тип | Проверка | Результат |
|---|---|---|
| api | Логин возвращает access_token | ✓ len=2337 |
| api | Логин возвращает refresh_token (offline_access scope) | ✓ len=2707 |
| api | /api/me возвращает orgId = тому что вернул signup | ✓ me.orgId=bdaff44c-cc12-4971-9a0d-5da10090861d, signup.orgId=bdaff44c-cc12-4971-9a0d-5da10090861d |
| api | /api/me возвращает role=Admin | ✓ Admin |
## ✓ Step stage04_signup_edge_cases: Edge — дубликат email / короткий пароль / пустое название / кривой телефон → 400 с понятным сообщением
Длительность: 2723мс
| Тип | Проверка | Результат |
|---|---|---|
| api | Дубликат email → 400 с понятным сообщением | ✓ 400 {"error":"Пользователь с таким email уже зарегистрирован."} |
| api | Пароль < 8 400 с понятным сообщением | 400 {"error":"Пароль минимум 8 символов."} |
| api | Пустое orgName → 400 | ✓ 400 {"error":"Email, пароль и название обязательны."} |
| api | Невалидный phone → 400 (валидация ФЛК Казахстана) | ✓ 400 {"error":"Введите корректный номер Казахстана. Пример: +7 700 123 45 67"} |
## ✓ Step stage05_public_app_url_check: Stage-public форма signup должна указывать на test.admin (PUBLIC_APP_URL deploy-config)
Длительность: 166мс
| Тип | Проверка | Результат |
|---|---|---|
| ui | Public site signup рендерится (хотя бы 1 ссылка на admin) | ✓ hosts=[admin.food-market.kz] |
## Summary
- Passed: 5
- Failed: 0
- Warnings: 0
- Skipped: 0
## Critical bugs
Нет.
## Logic gaps
- Stage-public (test.food-market.kz) использует тот же build что прод-public — signup-форма и ссылка «Войти» указывают на ПРОД admin (admin.food-market.kz). Для stage-testing нужно регистрироваться напрямую POST на test.admin.food-market.kz/api/auth/signup (stage-smoke это и делает), либо отдельно деплоить stage-public с PUBLIC_APP_URL=https://test.admin.food-market.kz.

View file

@ -0,0 +1,288 @@
/**
* Stage smoke: signup bootstrap login. Все проверки против
* https://test.admin.food-market.kz по HTTPS. БД на stage не сбрасываем
* (она у нас изолированная), поэтому используем timestamped email/org.
*/
import { login, makeClient, ADMIN_BASE } from '../lib/api.js'
import type { CheckResult, Step, Report } from '../lib/report.js'
type Ctx = {
apiOnly: boolean
ts: number
email: string
password: string
orgName: string
phone: string
organizationId?: string
accessToken?: string
refreshToken?: string
}
interface StepCtx { ctx: Ctx; step: Step; report: Report }
function check(step: Step, c: CheckResult) { step.checks.push(c) }
function asString(x: unknown): string {
if (x == null) return ''
if (typeof x === 'string') return x
return JSON.stringify(x)
}
function ensureCtx(ctx: Ctx): void {
if (ctx.ts) return
ctx.ts = Date.now()
ctx.email = `stage-smoke-${ctx.ts}@food-market.local`
ctx.password = 'Stage12345!'
ctx.orgName = `TestStage ${ctx.ts}`
ctx.phone = '+77011234567'
}
// ---------------------------------------------------------------------------
export async function stage01_signup_happy_path({ ctx, step, report }: StepCtx) {
ensureCtx(ctx)
const api = makeClient()
const res = await api.post('/api/auth/signup', {
email: ctx.email,
password: ctx.password,
organizationName: ctx.orgName,
phone: ctx.phone,
plan: 'start',
})
check(step, {
kind: 'api',
description: 'POST /api/auth/signup → 200',
ok: res.status === 200,
detail: res.status === 200 ? `org=${res.data?.organizationId} email=${res.data?.email}` : asString(res.data),
})
if (res.status !== 200) {
report.bug({
step: 'stage01',
severity: 'critical',
title: 'Публичный signup не работает на stage',
detail: `POST /api/auth/signup → ${res.status} ${asString(res.data)} (${ADMIN_BASE})`,
})
return
}
ctx.organizationId = res.data.organizationId
if (res.data.email !== ctx.email) {
report.bug({
step: 'stage01',
severity: 'medium',
title: 'Возвращённый email из signup не совпадает с введённым',
detail: `sent=${ctx.email}, got=${res.data.email}`,
})
}
}
// ---------------------------------------------------------------------------
export async function stage02_bootstrap_entities_present({ ctx, step, report }: StepCtx) {
if (!ctx.organizationId) { step.status = 'skip'; step.notes.push('Нет organization из stage01'); return }
// Логинимся под только что созданным admin'ом — токен нужен для tenant-scoped API.
const sess = await login(ctx.email, ctx.password)
ctx.accessToken = sess.accessToken
ctx.refreshToken = sess.refreshToken
const api = makeClient(sess.accessToken)
// Store
const stores = await api.get('/api/catalog/stores')
const mainStore = stores.data?.items?.find((s: { isMain: boolean }) => s.isMain)
check(step, {
kind: 'api',
description: 'GET /api/catalog/stores содержит main store (isMain=true)',
ok: stores.status === 200 && !!mainStore,
detail: stores.status === 200 ? `total=${stores.data?.total}` : asString(stores.data),
})
// EmployeeRoles
const roles = await api.get('/api/organization/employee-roles')
const adminRole = roles.data?.items?.find((r: { name: string; isSystem: boolean }) => r.isSystem && r.name === 'Администратор')
check(step, {
kind: 'api',
description: 'EmployeeRoles содержит системную «Администратор»',
ok: roles.status === 200 && !!adminRole,
detail: roles.status === 200 ? `roles=${roles.data?.items?.length}` : asString(roles.data),
})
if (!adminRole) {
report.bug({
step: 'stage02', severity: 'critical',
title: 'Bootstrap не создал системную роль «Администратор»',
detail: 'Без неё admin не сможет видеть страницы — это критично.',
})
}
// UnitsOfMeasure
const units = await api.get('/api/catalog/units-of-measure')
const enabledUnits = (units.data?.items ?? []).filter((u: { isEnabledForOrg: boolean }) => u.isEnabledForOrg)
check(step, {
kind: 'api',
description: 'UnitsOfMeasure для org содержат как минимум kg/l/m/упак (≥4 enabled)',
ok: units.status === 200 && enabledUnits.length >= 4,
detail: `enabled=${enabledUnits.length}`,
})
// ProductGroups
const groups = await api.get('/api/catalog/product-groups')
const rootGroup = (groups.data?.items ?? []).find((g: { name: string; parentId: string | null }) => g.name === 'Все товары' && g.parentId == null)
check(step, {
kind: 'api',
description: 'ProductGroups содержит корневую «Все товары» (parentId=null)',
ok: groups.status === 200 && !!rootGroup,
detail: groups.status === 200 ? `total=${groups.data?.total}` : asString(groups.data),
})
// PriceTypes
const prices = await api.get('/api/catalog/price-types')
const retailPrice = (prices.data?.items ?? []).find((p: { isRetail: boolean; isRequired: boolean }) => p.isRetail && p.isRequired)
check(step, {
kind: 'api',
description: 'PriceTypes содержит системную розничную (isRetail=true,isRequired=true)',
ok: prices.status === 200 && !!retailPrice,
detail: prices.status === 200 ? `total=${prices.data?.total}` : asString(prices.data),
})
// RetailPoints
const points = await api.get('/api/catalog/retail-points')
check(step, {
kind: 'api',
description: 'RetailPoints содержит хотя бы одну точку (bootstrap «Касса 1»)',
ok: points.status === 200 && (points.data?.items?.length ?? 0) >= 1,
detail: points.status === 200 ? `total=${points.data?.total}` : asString(points.data),
})
}
// ---------------------------------------------------------------------------
export async function stage03_login_returns_access_refresh({ ctx, step, report }: StepCtx) {
if (!ctx.email) { step.status = 'skip'; return }
const sess = await login(ctx.email, ctx.password)
check(step, {
kind: 'api',
description: 'Логин возвращает access_token',
ok: !!sess.accessToken && sess.accessToken.length > 100,
detail: `len=${sess.accessToken?.length}`,
})
check(step, {
kind: 'api',
description: 'Логин возвращает refresh_token (offline_access scope)',
ok: !!sess.refreshToken && sess.refreshToken.length > 100,
detail: `len=${sess.refreshToken?.length}`,
})
if (!sess.refreshToken) {
report.bug({
step: 'stage03', severity: 'high',
title: 'Refresh token не выдан при логине',
detail: 'OpenIddict должен выдавать refresh_token при scope offline_access — без него рефреш UI ломается.',
})
}
check(step, {
kind: 'api',
description: '/api/me возвращает orgId = тому что вернул signup',
ok: sess.orgId === ctx.organizationId,
detail: `me.orgId=${sess.orgId}, signup.orgId=${ctx.organizationId}`,
})
check(step, {
kind: 'api',
description: '/api/me возвращает role=Admin',
ok: sess.roles.includes('Admin'),
detail: sess.roles.join(','),
})
}
// ---------------------------------------------------------------------------
export async function stage04_signup_edge_cases({ ctx, step, report }: StepCtx) {
ensureCtx(ctx)
const api = makeClient()
const ts = ctx.ts
// Между запросами — задержка, чтобы не словить rate-limiter на /api/auth/signup
// (он включён и срабатывает на 5+ запросах в окне).
const wait = (ms: number) => new Promise((r) => setTimeout(r, ms))
// 1. Дубликат email — запрос делаем с уже использованным email из stage01.
const dup = await api.post('/api/auth/signup', {
email: ctx.email, password: ctx.password,
organizationName: 'Dup ' + ts, phone: ctx.phone, plan: 'start',
})
check(step, {
kind: 'api',
description: 'Дубликат email → 400 с понятным сообщением',
ok: dup.status === 400 && /уже зарегистр|already/i.test(asString(dup.data)),
detail: `${dup.status} ${asString(dup.data).slice(0, 200)}`,
})
await wait(800)
// 2. Короткий пароль
const short = await api.post('/api/auth/signup', {
email: `short-${ts}@food-market.local`, password: 'Ab1!',
organizationName: 'Short ' + ts, phone: ctx.phone, plan: 'start',
})
check(step, {
kind: 'api',
description: 'Пароль < 8 → 400 с понятным сообщением',
ok: short.status === 400 && /мин|min|8/i.test(asString(short.data)),
detail: `${short.status} ${asString(short.data).slice(0, 200)}`,
})
await wait(800)
// 3. Пустое название
const empty = await api.post('/api/auth/signup', {
email: `empty-${ts}@food-market.local`, password: ctx.password,
organizationName: '', phone: ctx.phone, plan: 'start',
})
check(step, {
kind: 'api',
description: 'Пустое orgName → 400',
ok: empty.status === 400,
detail: `${empty.status} ${asString(empty.data).slice(0, 200)}`,
})
await wait(800)
// 4. Кривой телефон
const badphone = await api.post('/api/auth/signup', {
email: `bad-${ts}@food-market.local`, password: ctx.password,
organizationName: 'BadPhone ' + ts, phone: 'abc-not-a-phone', plan: 'start',
})
check(step, {
kind: 'api',
description: 'Невалидный phone → 400 (валидация ФЛК Казахстана)',
ok: badphone.status === 400 && /кор?рект|казах/i.test(asString(badphone.data)),
detail: `${badphone.status} ${asString(badphone.data).slice(0, 200)}`,
})
}
// ---------------------------------------------------------------------------
export async function stage05_public_app_url_check({ ctx, step, report }: StepCtx) {
// Stage публичный сайт — отдельный поддомен с тем же кодом что прод-public
// (по постановке задачи). Это значит ссылка «Войти» и POST signup в нём
// указывают на ПРОД admin'a. Для stage-testing это означает, что forma
// signup на https://test.food-market.kz/signup НЕ годится — нужно
// регистрироваться напрямую на test.admin (как мы и делаем в stage01).
// Отмечаем как informational gap, чтобы не было сюрприза, что
// public-сайт «не тестит stage».
const api = makeClient()
const res = await api.get('https://test.food-market.kz/signup/')
const html = String(res.data)
const linkMatches = [...html.matchAll(/href="https?:\/\/([^"\/]+)\/login"/g)].map((m) => m[1])
const distinctHosts = [...new Set(linkMatches)]
const targetsProdAdmin = distinctHosts.length > 0 && distinctHosts.every((h) => h === 'admin.food-market.kz')
check(step, {
kind: 'ui',
description: 'Public site signup рендерится (хотя бы 1 ссылка на admin)',
ok: distinctHosts.length > 0,
detail: `hosts=[${distinctHosts.join(', ')}]`,
})
if (targetsProdAdmin) {
report.gap(
'Stage-public (test.food-market.kz) использует тот же build что прод-public — ' +
'signup-форма и ссылка «Войти» указывают на ПРОД admin (admin.food-market.kz). ' +
'Для stage-testing нужно регистрироваться напрямую POST на test.admin.food-market.kz/api/auth/signup ' +
'(stage-smoke это и делает), либо отдельно деплоить stage-public с PUBLIC_APP_URL=https://test.admin.food-market.kz.',
)
}
}

View file

@ -0,0 +1,24 @@
name: stage-smoke
description: |
Smoke-сценарий на stage-стенде test.admin.food-market.kz: signup
с публичного маркетинга → bootstrap новой org → логин → проверка
что в JWT есть org_id и role=Admin → проверка списка системных
справочников (Store, EmployeeRoles, Units, PriceTypes, ProductGroup,
RetailPoint) на изоляцию и наполнение. Edge: дубликат email, короткий
пароль, пустое название, кривой телефон.
preconditions:
reset_db: false
smoke_login_super_admin: false
steps:
- id: stage01_signup_happy_path
title: POST /api/auth/signup создаёт новую org «TestStage-{ts}» + первого Admin
- id: stage02_bootstrap_entities_present
title: Bootstrap создал Store (MAIN), EmployeeRole «Администратор» (system), Units, ProductGroup «Все товары», PriceTypes (Розничная), RetailPoint «Касса 1»
- id: stage03_login_returns_access_refresh
title: /connect/token (password grant) возвращает access+refresh, /api/me даёт orgId+role=Admin
- id: stage04_signup_edge_cases
title: Edge — дубликат email / короткий пароль / пустое название / кривой телефон → 400 с понятным сообщением
- id: stage05_public_app_url_check
title: Stage-public форма signup должна указывать на test.admin (PUBLIC_APP_URL deploy-config)