From 43a55527722b9dce209be6eb6c94ac2f01430a03 Mon Sep 17 00:00:00 2001 From: nns Date: Thu, 4 Jun 2026 17:38:36 +0500 Subject: [PATCH] =?UTF-8?q?fix(stage-tests):=20IP-limit=2060/min,=20locale?= =?UTF-8?q?=20ru-RU=20=D0=B2=20playwright,=20=D0=B8=D1=81=D0=BF=D1=80?= =?UTF-8?q?=D0=B0=D0=B2=D0=BB=D0=B5=D0=BD=D1=8B=20payload'=D1=8B=20verify-?= =?UTF-8?q?spec'=D0=BE=D0=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit После предыдущего фикса 5/мин per-username — per-IP 30/мин всё равно ломал stage e2e (multi-tenant специ делают 4 signup+token подряд → накапливается за минуту). Поднял до 60/мин token, 600/час; per-username 5/мин остаётся как анти-bruteforce. Также: playwright.config.ts добавлен locale: 'ru-RU' — без этого Chromium шлёт en-US, i18next отдаёт английский sidebar, а тесты ищут русские лейблы (2.2 'Главная', 6.1 'Поставщик/Склад/Дата'). Verify-spec'и V-14 (POS Sync) и V-15 (Stock race) — починены payload'ы под актуальную схему API (/api/catalog/stores не /api/inventory/stores, quantity не qty, unitCost не costPrice, polnyy retail-sale body с retailPointId/currencyId/payment/isReturn). Проверено: - V-14: 1-й POS-батч 200 (accepted=1), 2-й replayedFromCache=true с тем же serverSaleId; detail GET показывает notes=pos: ✓ - V-15: 5 параллельных Post на остаток=3 → ровно 3 успешных (204), 2 конфликта (409 'Недостаточно остатка'). Stock=0 после dust settles. ✓ Co-Authored-By: Claude Opus 4.7 --- .../RateLimiting/AuthRateLimiterExtensions.cs | 6 +- tests/e2e/playwright.config.ts | 4 ++ .../stage-ui-verify-pos-sync.spec.ts | 70 +++++++++++-------- .../stage-ui-verify-stock-race.spec.ts | 66 ++++++++++------- 4 files changed, 88 insertions(+), 58 deletions(-) diff --git a/src/food-market.api/Infrastructure/RateLimiting/AuthRateLimiterExtensions.cs b/src/food-market.api/Infrastructure/RateLimiting/AuthRateLimiterExtensions.cs index 9f605cd..6ef2e75 100644 --- a/src/food-market.api/Infrastructure/RateLimiting/AuthRateLimiterExtensions.cs +++ b/src/food-market.api/Infrastructure/RateLimiting/AuthRateLimiterExtensions.cs @@ -25,8 +25,10 @@ public static class AuthRateLimiterExtensions public const int DefaultPerUserPerHour = 20; // Дефолты per-IP (широкий, для CI/NAT) — переопределяются RateLimiting:PerIpPerMinute/Hour. - public const int DefaultPerIpPerMinute = 30; - public const int DefaultPerIpPerHour = 200; + // 60/мин = ≤1 успешный signup+login на ~2с на одном IP, без блока для CI/тестов; + // brute-force защиту даёт per-username (5/мин), а не этот. + public const int DefaultPerIpPerMinute = 60; + public const int DefaultPerIpPerHour = 600; private const string NoLimitPartition = "__not-an-auth-endpoint"; diff --git a/tests/e2e/playwright.config.ts b/tests/e2e/playwright.config.ts index bfafe58..de5fa80 100644 --- a/tests/e2e/playwright.config.ts +++ b/tests/e2e/playwright.config.ts @@ -25,6 +25,10 @@ export default defineConfig({ baseURL, headless: true, ignoreHTTPSErrors: true, + // По умолчанию Playwright Chromium = en-US → i18next отдаёт английский + // sidebar/labels. UI-deep тесты написаны под RU; для бенчмарка локалей + // i18n-спецы переключают вручную через localStorage. + locale: 'ru-RU', viewport: { width: 1280, height: 800 }, actionTimeout: 15_000, navigationTimeout: 30_000, diff --git a/tests/e2e/scenarios/stage-ui-verify-pos-sync.spec.ts b/tests/e2e/scenarios/stage-ui-verify-pos-sync.spec.ts index 1a5bf21..36388a0 100644 --- a/tests/e2e/scenarios/stage-ui-verify-pos-sync.spec.ts +++ b/tests/e2e/scenarios/stage-ui-verify-pos-sync.spec.ts @@ -22,37 +22,41 @@ test.describe('Verify POS Sync idempotency', () => { extraHTTPHeaders: { Authorization: `Bearer ${sess.accessToken}` }, }) - // Bootstrap: склад, товар, цена, остаток через Enter. - const stores = await ctx.get('/api/inventory/stores') - expect(stores.status()).toBe(200) - const storeId = (await stores.json()).items[0]?.id - ?? (await (await ctx.post('/api/inventory/stores', { data: { name: 'Главный', address: 'Алматы' } })).json()).id - const units = await ctx.get('/api/catalog/units-of-measure') - const unitId = (await units.json()).items[0].id - const cur = (await (await ctx.get('/api/catalog/currencies?pageSize=200')).json()).items.find((c: { code: string }) => c.code === 'KZT').id - const ptList = (await (await ctx.get('/api/catalog/price-types')).json()).items - const pt = (ptList.find((p: { isRetail: boolean }) => p.isRetail) ?? ptList[0]).id - const groupsRes = (await (await ctx.get('/api/catalog/product-groups?pageSize=10')).json()) - const groupId = groupsRes.items[0]?.id - ?? (await (await ctx.post('/api/catalog/product-groups', { data: { name: 'G', parentId: null } })).json()).id + // Bootstrap: org свежая, bootstrap-сидер уже создал главный склад, + // retail-point, единицы и валюты. Берём их из API. + const stores = (await (await ctx.get('/api/catalog/stores')).json()).items as Array<{ id: string, isMain: boolean }> + const storeId = (stores.find(s => s.isMain) ?? stores[0]).id + const units = (await (await ctx.get('/api/catalog/units-of-measure?pageSize=200')).json()).items as Array<{ id: string, code: string }> + const unitId = (units.find(u => u.code === '796') ?? units[0]).id + const cur = ((await (await ctx.get('/api/catalog/currencies?pageSize=200')).json()).items as Array<{ id: string, code: string }>).find(c => c.code === 'KZT')!.id + const ptList = (await (await ctx.get('/api/catalog/price-types')).json()).items as Array<{ id: string, isRetail: boolean }> + const pt = (ptList.find(p => p.isRetail) ?? ptList[0]).id + const groupId = (await (await ctx.get('/api/catalog/product-groups?pageSize=10')).json()).items[0].id + const ts = Date.now() const prod = await ctx.post('/api/catalog/products', { data: { - name: `POSverify ${Date.now()}`, unitOfMeasureId: unitId, productGroupId: groupId, + name: `POSverify ${ts}`, + article: `POSV-${ts}`, + unitOfMeasureId: unitId, productGroupId: groupId, vat: 12, vatEnabled: true, + packaging: 1, barcodes: [{ code: generateEan13(7), type: 1, isPrimary: true }], prices: [{ priceTypeId: pt, currencyId: cur, amount: 500 }], }, }) - expect(prod.status()).toBe(201) + expect([200, 201]).toContain(prod.status()) const productId = (await prod.json()).id // Enter (приход без поставщика) на 10 штук, чтобы был остаток для продажи. - const ent = await ctx.post('/api/inventory/enter', { - data: { storeId, date: new Date().toISOString(), lines: [{ productId, qty: 10, costPrice: 200 }] }, + const ent = await ctx.post('/api/inventory/enters', { + data: { + date: new Date().toISOString(), storeId, currencyId: cur, + lines: [{ productId, quantity: 10, unitCost: 200 }], + }, }) - expect(ent.status()).toBe(201) + expect([200, 201]).toContain(ent.status()) const entId = (await ent.json()).id - expect((await ctx.post(`/api/inventory/enter/${entId}/post`)).status()).toBe(200) + expect([200, 204]).toContain((await ctx.post(`/api/inventory/enters/${entId}/post`)).status()) // POS batch — 1 продажа. const idempKey = randomUUID() @@ -60,11 +64,11 @@ test.describe('Verify POS Sync idempotency', () => { const body = { idempotencyKey: idempKey, sales: [{ - clientSaleId, storeId, - soldAt: new Date().toISOString(), - paymentMethod: 1, // Cash - lines: [{ productId, qty: 1, unitPrice: 500, vat: 12 }], - paidCash: 500, paidCard: 0, change: 0, + clientSaleId, + occurredAt: new Date().toISOString(), + payment: 0, // Cash + paidCash: 500, paidCard: 0, + lines: [{ productId, quantity: 1, unitPrice: 500, discount: 0, vatPercent: 12 }], }], } @@ -83,14 +87,20 @@ test.describe('Verify POS Sync idempotency', () => { expect(b2.replayedFromCache, 'replayed flag').toBe(true) expect(b2.accepted[0].serverSaleId, 'same serverSaleId').toBe(serverSaleId) - // Проверяем через API: GET /api/sales/retail должен показать ровно 1 чек - // в этой org (помимо seed-данных, у свежей org 0 sales до нашего батча). + // Проверяем через API: GET /api/sales/retail (list) показывает ровно 1 + // POS-чек в свежей org, и detail GET /api/sales/retail/{id} имеет Notes, + // начинающийся с маркера pos:. const list = await ctx.get('/api/sales/retail?pageSize=200') expect(list.status()).toBe(200) - const items = (await list.json() as { items: Array<{ id: string, notes: string | null }> }).items - const posOnes = items.filter(s => s.notes != null && s.notes.startsWith(`pos:${clientSaleId.replace(/-/g, '')}`)) - expect(posOnes.length, 'ровно 1 retail_sale с маркером pos:').toBe(1) - expect(posOnes[0].id).toBe(serverSaleId) + const items = (await list.json() as { items: Array<{ id: string }> }).items + expect(items.length, 'ровно 1 чек в org после POS батча').toBe(1) + expect(items[0].id).toBe(serverSaleId) + + const detail = await ctx.get(`/api/sales/retail/${serverSaleId}`) + expect(detail.status()).toBe(200) + const sale = await detail.json() as { id: string, notes: string | null } + expect(sale.notes, 'notes должен начинаться с pos:') + .toMatch(new RegExp(`^pos:${clientSaleId.replace(/-/g, '')}`)) await ctx.dispose() }) diff --git a/tests/e2e/scenarios/stage-ui-verify-stock-race.spec.ts b/tests/e2e/scenarios/stage-ui-verify-stock-race.spec.ts index e7ad5fc..074e97c 100644 --- a/tests/e2e/scenarios/stage-ui-verify-stock-race.spec.ts +++ b/tests/e2e/scenarios/stage-ui-verify-stock-race.spec.ts @@ -24,57 +24,71 @@ test.describe('Verify stock race', () => { extraHTTPHeaders: { Authorization: `Bearer ${sess.accessToken}` }, }) - // Bootstrap. - const stores = await ctx.get('/api/inventory/stores') - const storeId = (await stores.json()).items[0]?.id - ?? (await (await ctx.post('/api/inventory/stores', { data: { name: 'Главный', address: 'Алматы' } })).json()).id - const units = await ctx.get('/api/catalog/units-of-measure') - const unitId = (await units.json()).items[0].id - const cur = (await (await ctx.get('/api/catalog/currencies?pageSize=200')).json()).items.find((c: { code: string }) => c.code === 'KZT').id - const ptList = (await (await ctx.get('/api/catalog/price-types')).json()).items - const pt = (ptList.find((p: { isRetail: boolean }) => p.isRetail) ?? ptList[0]).id - const groupsRes = (await (await ctx.get('/api/catalog/product-groups?pageSize=10')).json()) - const groupId = groupsRes.items[0]?.id - ?? (await (await ctx.post('/api/catalog/product-groups', { data: { name: 'G', parentId: null } })).json()).id + // Bootstrap: org свежая, bootstrap-сидер уже создал главный склад, + // retail-point, единицы и валюты. Берём их из API. + const stores = (await (await ctx.get('/api/catalog/stores')).json()).items as Array<{ id: string, isMain: boolean }> + const storeId = (stores.find(s => s.isMain) ?? stores[0]).id + const retailPoints = (await (await ctx.get('/api/catalog/retail-points')).json()).items as Array<{ id: string }> + const retailPointId = retailPoints[0].id + const units = (await (await ctx.get('/api/catalog/units-of-measure?pageSize=200')).json()).items as Array<{ id: string, code: string }> + const unitId = (units.find(u => u.code === '796') ?? units[0]).id + const cur = ((await (await ctx.get('/api/catalog/currencies?pageSize=200')).json()).items as Array<{ id: string, code: string }>).find(c => c.code === 'KZT')!.id + const ptList = (await (await ctx.get('/api/catalog/price-types')).json()).items as Array<{ id: string, isRetail: boolean }> + const pt = (ptList.find(p => p.isRetail) ?? ptList[0]).id + const groupId = (await (await ctx.get('/api/catalog/product-groups?pageSize=10')).json()).items[0].id + const ts = Date.now() const prod = await ctx.post('/api/catalog/products', { data: { - name: `RaceProd ${Date.now()}`, unitOfMeasureId: unitId, productGroupId: groupId, + name: `RaceProd ${ts}`, + article: `RACE-${ts}`, + unitOfMeasureId: unitId, productGroupId: groupId, vat: 12, vatEnabled: true, + packaging: 1, barcodes: [{ code: generateEan13(7), type: 1, isPrimary: true }], prices: [{ priceTypeId: pt, currencyId: cur, amount: 500 }], }, }) - expect(prod.status()).toBe(201) + expect([200, 201]).toContain(prod.status()) const productId = (await prod.json()).id // Enter: 3 штуки в остаток. - const ent = await ctx.post('/api/inventory/enter', { - data: { storeId, date: new Date().toISOString(), lines: [{ productId, qty: 3, costPrice: 200 }] }, + const ent = await ctx.post('/api/inventory/enters', { + data: { + date: new Date().toISOString(), storeId, currencyId: cur, + lines: [{ productId, quantity: 3, unitCost: 200 }], + }, }) + expect([200, 201]).toContain(ent.status()) const entId = (await ent.json()).id - expect((await ctx.post(`/api/inventory/enter/${entId}/post`)).status()).toBe(200) + expect([200, 204]).toContain((await ctx.post(`/api/inventory/enters/${entId}/post`)).status()) // Создаём 5 RetailSale-черновиков с qty=1 каждый. const draftIds: string[] = [] for (let i = 0; i < 5; i++) { const r = await ctx.post('/api/sales/retail', { data: { - storeId, date: new Date().toISOString(), - lines: [{ productId, qty: 1, unitPrice: 500, vat: 12 }], - paidCash: 500, paidCard: 0, change: 0, + date: new Date().toISOString(), + storeId, retailPointId, currencyId: cur, + payment: 0, isReturn: false, + lines: [{ productId, quantity: 1, unitPrice: 500, discount: 0, vatPercent: 12 }], + subtotal: 500, discountTotal: 0, total: 500, + paidCash: 500, paidCard: 0, }, }) - expect(r.status(), `draft ${i}`).toBe(201) + expect([200, 201]).toContain(r.status()) draftIds.push((await r.json()).id) } - // Параллельный Post всех 5. - const postResults = await Promise.all(draftIds.map(id => - ctx.post(`/api/sales/retail/${id}/post`).then(r => ({ id, status: r.status() })) - )) - const ok = postResults.filter(r => r.status === 200) + // Параллельный Post всех 5. Запоминаем body фейлов на случай если все + // упадут — иначе нечем диагностировать. + const postResults = await Promise.all(draftIds.map(async (id) => { + const r = await ctx.post(`/api/sales/retail/${id}/post`) + return { id, status: r.status(), body: r.status() >= 400 ? (await r.text()).slice(0, 200) : null } + })) + const ok = postResults.filter(r => r.status >= 200 && r.status < 300) const failed = postResults.filter(r => r.status >= 400) test.info().annotations.push({ type: 'post-results', description: JSON.stringify(postResults) }) + console.log('[V-15] postResults:', JSON.stringify(postResults)) expect(ok.length, 'ровно 3 успешных Post').toBe(3) expect(failed.length, 'ровно 2 неуспешных Post').toBe(2)