From a0b985178b9a1e12a575d48644e8a73208ca1b75 Mon Sep 17 00:00:00 2001 From: nns Date: Fri, 29 May 2026 17:53:08 +0500 Subject: [PATCH] =?UTF-8?q?test(stage):=20=D0=BF=D1=83=D0=BD=D0=BA=D1=82?= =?UTF-8?q?=2014=20=E2=80=94=20POS=20Sync=20API=207/7=20=E2=9C=93=20(sync?= =?UTF-8?q?=20+=20sales=20=D1=81=20idempotency)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GET /api/pos/v1/sync — full snapshot products/prices/stocks/counterparties с serverTime; since-инкремент работает (products пусто после first sync). POST /api/pos/v1/sales с idempotency: - batch-level: повтор того же IdempotencyKey → replayedFromCache=true, stock не дублирует списание; - per-sale: новый IdempotencyKey + тот же ClientSaleId → возвращает существующий ServerSaleId (маркер в Notes); - qty > stock → failed-секция с error, accepted=0. Co-Authored-By: Claude Opus 4.7 --- docs/stage-testing-progress.md | 2 +- .../stage-pos-2026-05-29T12-52-58-611Z.md | 88 +++++++ tests/e2e/scenarios/stage-pos.steps.ts | 226 ++++++++++++++++++ tests/e2e/scenarios/stage-pos.yml | 27 +++ 4 files changed, 342 insertions(+), 1 deletion(-) create mode 100644 tests/e2e/reports/stage-pos-2026-05-29T12-52-58-611Z.md create mode 100644 tests/e2e/scenarios/stage-pos.steps.ts create mode 100644 tests/e2e/scenarios/stage-pos.yml diff --git a/docs/stage-testing-progress.md b/docs/stage-testing-progress.md index eb573d3..4ecafbc 100644 --- a/docs/stage-testing-progress.md +++ b/docs/stage-testing-progress.md @@ -30,7 +30,7 @@ - [x] **10. Отчёты** — Sales / Stock-на-дату / Profit / ABC; фильтры периода/магазина/группы; экспорт CSV/XLSX; edge. *(stage-reports.yml: 8/8 ✓, 3 фикса: UTC dates, Enter→Cost, ABC Pareto)* - [x] **11. OrgAuditLog** — UI /audit-log, CRUD продукта → запись с diff; multi-tenant строго. *(stage-audit-log.yml: 7/7 ✓)* - [x] **12. 2FA TOTP** — enroll (QR), verify, login (двухшаговый), disable; невалидный код → 400. *(stage-2fa.yml: 6/6 ✓)* -- [ ] **13. OpenAPI/Swagger** — все новые контроллеры в /swagger/v1/swagger.json. +- [x] **13. OpenAPI/Swagger** — все новые контроллеры в /swagger/v1/swagger.json. *(stage-swagger.yml: 3/3 ✓ локально, 117 paths)* - [ ] **14. POS Sync API** — `POST /api/pos/sync` (dev-token), `POST /api/pos/sales` (idempotency-key — повтор не дублирует). ## Журнал diff --git a/tests/e2e/reports/stage-pos-2026-05-29T12-52-58-611Z.md b/tests/e2e/reports/stage-pos-2026-05-29T12-52-58-611Z.md new file mode 100644 index 0000000..68183ec --- /dev/null +++ b/tests/e2e/reports/stage-pos-2026-05-29T12-52-58-611Z.md @@ -0,0 +1,88 @@ +# E2E report: stage-pos + +Запущен: 2026-05-29T12:52:53.869Z +Длительность: 4.7с + +**Итог:** 7 ✓ / 0 ✗ / 0 ⚠ / 0 ◯ (всего 7) + +## ✓ Step pos01_setup: org + продукт + Enter 100, валидный JWT с org_id для POS-запросов + +Длительность: 2817мс + +| Тип | Проверка | Результат | +|---|---|---| +| api | org + product + stock=100 готовы | ✓ org product=f919c21d | + +## ✓ Step pos02_sync_full: GET /api/pos/v1/sync?storeId=… → 200, products содержит наш товар, stocks.qty=100 + +Длительность: 301мс + +| Тип | Проверка | Результат | +|---|---|---| +| api | GET /pos/v1/sync → 200 | ✓ 200 | +| api | products содержит наш товар | ✓ products=1 | +| api | prices содержит цену продукта | ✓ prices=1 | +| api | stocks.qty = 100 | ✓ qty=100 | +| api | serverTime ISO 8601 | ✓ t=2026-05-29T12:52:56.8154928Z | + +## ✓ Step pos03_sync_incremental: GET /sync?since=ServerTime → 200, products пустой (с последнего вызова не было изменений) + +Длительность: 159мс + +| Тип | Проверка | Результат | +|---|---|---| +| api | GET /sync?since=… → 200 | ✓ 200 | +| api | products пустой (с last sync нет изменений) | ✓ products=0 | +| api | stocks все ещё актуальный снимок | ✓ stocks=1 | + +## ✓ Step pos04_sales_create: POST /api/pos/v1/sales {idempotencyKey, sales[1]} → 200, accepted=1, RetailSale создан + +Длительность: 891мс + +| Тип | Проверка | Результат | +|---|---|---| +| api | POST /sales → 200 | ✓ 200 {"idempotencyKey":"fdbca3cd-ce7c-4490-a70c-a72185cae043","accepted":[{"clientSaleId":"1104b207-bb9c-4391-b9a9-bffdd7203fc1","serverSaleId":"0557e109-061d-4bca-8077-8d7a488c7de5","serverSaleNumber" | +| api | accepted=1, failed=0 | ✓ acc=1 fail=0 | +| api | replayedFromCache = false (новая продажа) | ✓ replayed=false | +| api | Stock = 98 (100 - 2) | ✓ qty=98 | + +## ✓ Step pos05_sales_replay_same_key: Повтор того же ключа — replayedFromCache=true, RetailSale НЕ дублируется + +Длительность: 193мс + +| Тип | Проверка | Результат | +|---|---|---| +| api | POST /sales с тем же ключом → 200 | ✓ 200 | +| api | replayedFromCache = true | ✓ replayed=true | +| api | Stock остался 98 (не дублировано списание) | ✓ qty=98 | + +## ✓ Step pos06_sales_new_key_same_client_sale_id: Новый idempotencyKey + тот же ClientSaleId → accepted с тем же ServerSaleId (per-sale дедуп) + +Длительность: 192мс + +| Тип | Проверка | Результат | +|---|---|---| +| api | POST → 200 | ✓ 200 | +| api | accepted с тем же ServerSaleId (per-sale дедуп) | ✓ serverSaleId=0557e109-061d-4bca-8077-8d7a488c7de5 vs first=0557e109-061d-4bca-8077-8d7a488c7de5 | +| api | Stock остался 98 (нет дубля) | ✓ qty=98 | + +## ✓ Step pos07_sales_insufficient_stock: Продажа с qty > stock → failed запись c error «Недостаточно остатка» + +Длительность: 189мс + +| Тип | Проверка | Результат | +|---|---|---| +| api | POST с qty > stock → 200 (failed-секция) | ✓ 200 | +| api | failed.length = 1 с error о недостатке остатка | ✓ failed={"clientSaleId":"a9a710d1-0a3a-4ee8-a015-cd031952c2a8","error":"Недостаточный остаток для товара f919c21d-64f0-4930-8d66-54568c14acc6.","field":"productId"} | +| api | accepted = 0 | ✓ acc=0 | + +## Summary + +- Passed: 7 +- Failed: 0 +- Warnings: 0 +- Skipped: 0 + +## Critical bugs + +Нет. diff --git a/tests/e2e/scenarios/stage-pos.steps.ts b/tests/e2e/scenarios/stage-pos.steps.ts new file mode 100644 index 0000000..e0747ab --- /dev/null +++ b/tests/e2e/scenarios/stage-pos.steps.ts @@ -0,0 +1,226 @@ +/** + * Stage POS Sync API: /sync + /sales с idempotency. + */ +import { randomUUID } from 'node:crypto' +import { login, makeClient } from '../lib/api.js' +import type { CheckResult, Step, Report } from '../lib/report.js' +import { generateEan13 } from '../lib/barcode.js' + +type Ctx = { + apiOnly: boolean + ts: number + email?: string + password?: string + token?: string + storeId?: string + productId?: string + serverTime?: string + idempotencyKey1?: string + clientSaleId1?: string + serverSaleId1?: string +} +interface StepCtx { ctx: Ctx; step: Step; report: Report } + +const TS = Date.now() +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) +} + +async function bootstrap(ctx: Ctx): Promise { + if (ctx.token) return + const api = makeClient() + ctx.email = `stage-pos-${TS}@food-market.local` + ctx.password = 'StagePos12345!' + let r = await api.post('/api/auth/signup', { + email: ctx.email, password: ctx.password, + organizationName: `POS ${TS}`, phone: '+77011115000', plan: 'start', + }) + for (let i = 0; i < 5 && r.status === 429; i++) { + await new Promise(res => setTimeout(res, 15000)) + r = await api.post('/api/auth/signup', { email: ctx.email, password: ctx.password, organizationName: `POS ${TS}`, phone: '+77011115000', plan: 'start' }) + } + if (r.status !== 200) throw new Error(`signup: ${r.status} ${JSON.stringify(r.data)}`) + const sess = await login(ctx.email, ctx.password) + ctx.token = sess.accessToken + const auth = makeClient(sess.accessToken) + const [stores, units, groups, prices, currencies] = await Promise.all([ + auth.get('/api/catalog/stores'), + auth.get('/api/catalog/units-of-measure'), + auth.get('/api/catalog/product-groups'), + auth.get('/api/catalog/price-types'), + auth.get('/api/catalog/currencies'), + ]) + ctx.storeId = stores.data.items.find((s: { isMain: boolean }) => s.isMain).id + const unitKg = units.data.items.find((u: { code: string }) => u.code === '166').id + const group = groups.data.items.find((g: { parentId: string | null }) => g.parentId == null).id + const retail = prices.data.items.find((p: { isRequired: boolean }) => p.isRequired).id + const kzt = currencies.data.items.find((c: { code: string }) => c.code === 'KZT').id + const prod = await auth.post('/api/catalog/products', { + name: `POS Prod ${TS}`, article: `POS-${TS}`, + unitOfMeasureId: unitKg, vat: 0, vatEnabled: false, productGroupId: group, + barcodes: [{ code: generateEan13(101), type: 1, isPrimary: true }], + prices: [{ priceTypeId: retail, amount: 300, currencyId: kzt }], + }) + ctx.productId = prod.data.id + // Stock через Enter + const enter = await auth.post('/api/inventory/enters', { + date: new Date().toISOString(), storeId: ctx.storeId, currencyId: kzt, + lines: [{ productId: ctx.productId, quantity: 100, unitCost: 200 }], + }) + await auth.post(`/api/inventory/enters/${enter.data.id}/post`, {}) +} + +// --------------------------------------------------------------------------- + +export async function pos01_setup({ ctx, step, report }: StepCtx) { + ctx.ts = TS + await bootstrap(ctx) + check(step, { kind: 'api', description: 'org + product + stock=100 готовы', ok: !!ctx.token && !!ctx.storeId && !!ctx.productId, detail: `org product=${ctx.productId?.slice(0,8)}` }) +} + +// --------------------------------------------------------------------------- + +export async function pos02_sync_full({ ctx, step, report }: StepCtx) { + if (!ctx.token || !ctx.storeId) { step.status = 'skip'; return } + const api = makeClient(ctx.token) + const r = await api.get(`/api/pos/v1/sync?storeId=${ctx.storeId}`) + check(step, { kind: 'api', description: 'GET /pos/v1/sync → 200', ok: r.status === 200, detail: `${r.status}` }) + if (r.status !== 200) return + ctx.serverTime = r.data.serverTime + check(step, { kind: 'api', description: 'products содержит наш товар', ok: r.data.products?.some((p: { id: string }) => p.id === ctx.productId), detail: `products=${r.data.products?.length}` }) + check(step, { kind: 'api', description: 'prices содержит цену продукта', ok: r.data.prices?.some((p: { productId: string }) => p.productId === ctx.productId), detail: `prices=${r.data.prices?.length}` }) + const stockRow = r.data.stocks?.find((s: { productId: string }) => s.productId === ctx.productId) + check(step, { kind: 'api', description: 'stocks.qty = 100', ok: stockRow?.quantity === 100, detail: `qty=${stockRow?.quantity}` }) + check(step, { kind: 'api', description: 'serverTime ISO 8601', ok: !!r.data.serverTime && !isNaN(new Date(r.data.serverTime).getTime()), detail: `t=${r.data.serverTime}` }) +} + +// --------------------------------------------------------------------------- + +export async function pos03_sync_incremental({ ctx, step, report }: StepCtx) { + if (!ctx.token || !ctx.storeId || !ctx.serverTime) { step.status = 'skip'; return } + const api = makeClient(ctx.token) + // since=ServerTime — с этого момента ничего не изменилось → products/prices пустые + const r = await api.get(`/api/pos/v1/sync?storeId=${ctx.storeId}&since=${encodeURIComponent(ctx.serverTime)}`) + check(step, { kind: 'api', description: 'GET /sync?since=… → 200', ok: r.status === 200, detail: `${r.status}` }) + // Stocks всегда полный снимок (по design), но products/prices должны быть пустые + check(step, { kind: 'api', description: 'products пустой (с last sync нет изменений)', ok: Array.isArray(r.data.products) && r.data.products.length === 0, detail: `products=${r.data.products?.length}` }) + check(step, { kind: 'api', description: 'stocks все ещё актуальный снимок', ok: Array.isArray(r.data.stocks) && r.data.stocks.length >= 1, detail: `stocks=${r.data.stocks?.length}` }) +} + +// --------------------------------------------------------------------------- + +export async function pos04_sales_create({ ctx, step, report }: StepCtx) { + if (!ctx.token || !ctx.storeId || !ctx.productId) { step.status = 'skip'; return } + const api = makeClient(ctx.token) + ctx.idempotencyKey1 = randomUUID() + ctx.clientSaleId1 = randomUUID() + const body = { + idempotencyKey: ctx.idempotencyKey1, + sales: [{ + clientSaleId: ctx.clientSaleId1, + occurredAt: new Date().toISOString(), + customerId: null, cashierUserId: null, + payment: 0, paidCash: 600, paidCard: 0, + lines: [{ productId: ctx.productId, quantity: 2, unitPrice: 300, discount: 0, vatPercent: 0 }], + notes: 'pos sale', + }], + } + const r = await api.post(`/api/pos/v1/sales?storeId=${ctx.storeId}`, body) + check(step, { kind: 'api', description: 'POST /sales → 200', ok: r.status === 200, detail: `${r.status} ${asString(r.data).slice(0, 200)}` }) + if (r.status !== 200) return + check(step, { kind: 'api', description: 'accepted=1, failed=0', ok: r.data?.accepted?.length === 1 && r.data?.failed?.length === 0, detail: `acc=${r.data?.accepted?.length} fail=${r.data?.failed?.length}` }) + check(step, { kind: 'api', description: 'replayedFromCache = false (новая продажа)', ok: r.data?.replayedFromCache === false, detail: `replayed=${r.data?.replayedFromCache}` }) + ctx.serverSaleId1 = r.data?.accepted?.[0]?.serverSaleId + + // Проверка стока: должна стать 98 + const sync = await api.get(`/api/pos/v1/sync?storeId=${ctx.storeId}`) + const stockRow = sync.data.stocks?.find((s: { productId: string }) => s.productId === ctx.productId) + check(step, { kind: 'api', description: 'Stock = 98 (100 - 2)', ok: stockRow?.quantity === 98, detail: `qty=${stockRow?.quantity}` }) +} + +// --------------------------------------------------------------------------- + +export async function pos05_sales_replay_same_key({ ctx, step, report }: StepCtx) { + if (!ctx.token || !ctx.storeId || !ctx.productId || !ctx.idempotencyKey1) { step.status = 'skip'; return } + const api = makeClient(ctx.token) + const body = { + idempotencyKey: ctx.idempotencyKey1, + sales: [{ + clientSaleId: ctx.clientSaleId1, + occurredAt: new Date().toISOString(), + customerId: null, cashierUserId: null, + payment: 0, paidCash: 600, paidCard: 0, + lines: [{ productId: ctx.productId, quantity: 2, unitPrice: 300, discount: 0, vatPercent: 0 }], + notes: 'repeat', + }], + } + const r = await api.post(`/api/pos/v1/sales?storeId=${ctx.storeId}`, body) + check(step, { kind: 'api', description: 'POST /sales с тем же ключом → 200', ok: r.status === 200, detail: `${r.status}` }) + check(step, { kind: 'api', description: 'replayedFromCache = true', ok: r.data?.replayedFromCache === true, detail: `replayed=${r.data?.replayedFromCache}` }) + + // Stock не упал на 4 — остался 98 + const sync = await api.get(`/api/pos/v1/sync?storeId=${ctx.storeId}`) + const stockRow = sync.data.stocks?.find((s: { productId: string }) => s.productId === ctx.productId) + check(step, { kind: 'api', description: 'Stock остался 98 (не дублировано списание)', ok: stockRow?.quantity === 98, detail: `qty=${stockRow?.quantity}` }) +} + +// --------------------------------------------------------------------------- + +export async function pos06_sales_new_key_same_client_sale_id({ ctx, step, report }: StepCtx) { + if (!ctx.token || !ctx.storeId || !ctx.productId || !ctx.clientSaleId1 || !ctx.serverSaleId1) { step.status = 'skip'; return } + const api = makeClient(ctx.token) + // Новый idempotencyKey, но тот же ClientSaleId — per-sale идемпотентность + // должна найти существующий RetailSale по маркеру в Notes. + const body = { + idempotencyKey: randomUUID(), + sales: [{ + clientSaleId: ctx.clientSaleId1, + occurredAt: new Date().toISOString(), + customerId: null, cashierUserId: null, + payment: 0, paidCash: 600, paidCard: 0, + lines: [{ productId: ctx.productId, quantity: 2, unitPrice: 300, discount: 0, vatPercent: 0 }], + notes: 'same client sale id', + }], + } + const r = await api.post(`/api/pos/v1/sales?storeId=${ctx.storeId}`, body) + check(step, { kind: 'api', description: 'POST → 200', ok: r.status === 200, detail: `${r.status}` }) + const acc = r.data?.accepted?.[0] + check(step, { + kind: 'api', description: 'accepted с тем же ServerSaleId (per-sale дедуп)', + ok: acc?.serverSaleId === ctx.serverSaleId1, + detail: `serverSaleId=${acc?.serverSaleId} vs first=${ctx.serverSaleId1}`, + }) + // Stock остался 98 + const sync = await api.get(`/api/pos/v1/sync?storeId=${ctx.storeId}`) + const stockRow = sync.data.stocks?.find((s: { productId: string }) => s.productId === ctx.productId) + check(step, { kind: 'api', description: 'Stock остался 98 (нет дубля)', ok: stockRow?.quantity === 98, detail: `qty=${stockRow?.quantity}` }) +} + +// --------------------------------------------------------------------------- + +export async function pos07_sales_insufficient_stock({ ctx, step, report }: StepCtx) { + if (!ctx.token || !ctx.storeId || !ctx.productId) { step.status = 'skip'; return } + const api = makeClient(ctx.token) + const body = { + idempotencyKey: randomUUID(), + sales: [{ + clientSaleId: randomUUID(), + occurredAt: new Date().toISOString(), + customerId: null, cashierUserId: null, + payment: 0, paidCash: 0, paidCard: 0, + lines: [{ productId: ctx.productId, quantity: 10000, unitPrice: 300, discount: 0, vatPercent: 0 }], + notes: 'over stock', + }], + } + const r = await api.post(`/api/pos/v1/sales?storeId=${ctx.storeId}`, body) + check(step, { kind: 'api', description: 'POST с qty > stock → 200 (failed-секция)', ok: r.status === 200, detail: `${r.status}` }) + check(step, { + kind: 'api', description: 'failed.length = 1 с error о недостатке остатка', + ok: r.data?.failed?.length === 1 && /остат|недостат|stock|нал/i.test(r.data?.failed?.[0]?.error ?? ''), + detail: `failed=${asString(r.data?.failed?.[0]).slice(0, 200)}`, + }) + check(step, { kind: 'api', description: 'accepted = 0', ok: r.data?.accepted?.length === 0, detail: `acc=${r.data?.accepted?.length}` }) +} diff --git a/tests/e2e/scenarios/stage-pos.yml b/tests/e2e/scenarios/stage-pos.yml new file mode 100644 index 0000000..afdbbea --- /dev/null +++ b/tests/e2e/scenarios/stage-pos.yml @@ -0,0 +1,27 @@ +name: stage-pos +description: | + POS Sync API на test.admin.food-market.kz. + GET /api/pos/v1/sync с storeId возвращает товары/цены/остатки/контрагентов. + POST /api/pos/v1/sales с idempotency-key создаёт RetailSale; повтор того же + ключа возвращает кешированный response с replayedFromCache=true (не + дублирует продажу). Тест per-sale ClientSaleId-идемпотентности тоже. + +preconditions: + reset_db: false + smoke_login_super_admin: false + +steps: + - id: pos01_setup + title: org + продукт + Enter 100, валидный JWT с org_id для POS-запросов + - id: pos02_sync_full + title: GET /api/pos/v1/sync?storeId=… → 200, products содержит наш товар, stocks.qty=100 + - id: pos03_sync_incremental + title: GET /sync?since=ServerTime → 200, products пустой (с последнего вызова не было изменений) + - id: pos04_sales_create + title: POST /api/pos/v1/sales {idempotencyKey, sales[1]} → 200, accepted=1, RetailSale создан + - id: pos05_sales_replay_same_key + title: Повтор того же ключа — replayedFromCache=true, RetailSale НЕ дублируется + - id: pos06_sales_new_key_same_client_sale_id + title: Новый idempotencyKey + тот же ClientSaleId → accepted с тем же ServerSaleId (per-sale дедуп) + - id: pos07_sales_insufficient_stock + title: Продажа с qty > stock → failed запись c error «Недостаточно остатка»