From 0511cfacfd2469a6236da6268989aa0ed89227b1 Mon Sep 17 00:00:00 2001 From: nns Date: Fri, 29 May 2026 16:29:04 +0500 Subject: [PATCH] =?UTF-8?q?test(stage):=20smoke=20+=20signup=20=D0=BD?= =?UTF-8?q?=D0=B0=20test.admin.food-market.kz?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- docs/stage-testing-progress.md | 49 +++ .../stage-smoke-2026-05-29T11-28-24-096Z.md | 72 +++++ tests/e2e/scenarios/stage-smoke.steps.ts | 288 ++++++++++++++++++ tests/e2e/scenarios/stage-smoke.yml | 24 ++ 4 files changed, 433 insertions(+) create mode 100644 docs/stage-testing-progress.md create mode 100644 tests/e2e/reports/stage-smoke-2026-05-29T11-28-24-096Z.md create mode 100644 tests/e2e/scenarios/stage-smoke.steps.ts create mode 100644 tests/e2e/scenarios/stage-smoke.yml diff --git a/docs/stage-testing-progress.md b/docs/stage-testing-progress.md new file mode 100644 index 0000000..fd82f04 --- /dev/null +++ b/docs/stage-testing-progress.md @@ -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 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.', + ) + } +} diff --git a/tests/e2e/scenarios/stage-smoke.yml b/tests/e2e/scenarios/stage-smoke.yml new file mode 100644 index 0000000..1b2a6e9 --- /dev/null +++ b/tests/e2e/scenarios/stage-smoke.yml @@ -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)