fix(stage-tests): IP-limit 60/min, locale ru-RU в playwright, исправлены payload'ы verify-spec'ов
Some checks failed
CI / Backend (.NET 8) (push) Waiting to run
CI / Web (React + Vite) (push) Waiting to run
CI / POS (WPF, Windows) (push) Waiting to run
Docker API / Build + push API (push) Has been cancelled
Docker API / Deploy API on stage (push) Has been cancelled

После предыдущего фикса 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:<csid-N> ✓
- V-15: 5 параллельных Post на остаток=3 → ровно 3 успешных (204), 2
  конфликта (409 'Недостаточно остатка'). Stock=0 после dust settles. ✓

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
nns 2026-06-04 17:38:36 +05:00
parent 9d48ca6483
commit 43a5552772
4 changed files with 88 additions and 58 deletions

View file

@ -25,8 +25,10 @@ public static class AuthRateLimiterExtensions
public const int DefaultPerUserPerHour = 20; public const int DefaultPerUserPerHour = 20;
// Дефолты per-IP (широкий, для CI/NAT) — переопределяются RateLimiting:PerIpPerMinute/Hour. // Дефолты per-IP (широкий, для CI/NAT) — переопределяются RateLimiting:PerIpPerMinute/Hour.
public const int DefaultPerIpPerMinute = 30; // 60/мин = ≤1 успешный signup+login на ~2с на одном IP, без блока для CI/тестов;
public const int DefaultPerIpPerHour = 200; // brute-force защиту даёт per-username (5/мин), а не этот.
public const int DefaultPerIpPerMinute = 60;
public const int DefaultPerIpPerHour = 600;
private const string NoLimitPartition = "__not-an-auth-endpoint"; private const string NoLimitPartition = "__not-an-auth-endpoint";

View file

@ -25,6 +25,10 @@ export default defineConfig({
baseURL, baseURL,
headless: true, headless: true,
ignoreHTTPSErrors: true, ignoreHTTPSErrors: true,
// По умолчанию Playwright Chromium = en-US → i18next отдаёт английский
// sidebar/labels. UI-deep тесты написаны под RU; для бенчмарка локалей
// i18n-спецы переключают вручную через localStorage.
locale: 'ru-RU',
viewport: { width: 1280, height: 800 }, viewport: { width: 1280, height: 800 },
actionTimeout: 15_000, actionTimeout: 15_000,
navigationTimeout: 30_000, navigationTimeout: 30_000,

View file

@ -22,37 +22,41 @@ test.describe('Verify POS Sync idempotency', () => {
extraHTTPHeaders: { Authorization: `Bearer ${sess.accessToken}` }, extraHTTPHeaders: { Authorization: `Bearer ${sess.accessToken}` },
}) })
// Bootstrap: склад, товар, цена, остаток через Enter. // Bootstrap: org свежая, bootstrap-сидер уже создал главный склад,
const stores = await ctx.get('/api/inventory/stores') // retail-point, единицы и валюты. Берём их из API.
expect(stores.status()).toBe(200) const stores = (await (await ctx.get('/api/catalog/stores')).json()).items as Array<{ id: string, isMain: boolean }>
const storeId = (await stores.json()).items[0]?.id const storeId = (stores.find(s => s.isMain) ?? stores[0]).id
?? (await (await ctx.post('/api/inventory/stores', { data: { name: 'Главный', address: 'Алматы' } })).json()).id const units = (await (await ctx.get('/api/catalog/units-of-measure?pageSize=200')).json()).items as Array<{ id: string, code: string }>
const units = await ctx.get('/api/catalog/units-of-measure') const unitId = (units.find(u => u.code === '796') ?? units[0]).id
const unitId = (await units.json()).items[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 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 as Array<{ id: string, isRetail: boolean }>
const ptList = (await (await ctx.get('/api/catalog/price-types')).json()).items const pt = (ptList.find(p => p.isRetail) ?? ptList[0]).id
const pt = (ptList.find((p: { isRetail: boolean }) => p.isRetail) ?? ptList[0]).id const groupId = (await (await ctx.get('/api/catalog/product-groups?pageSize=10')).json()).items[0].id
const groupsRes = (await (await ctx.get('/api/catalog/product-groups?pageSize=10')).json()) const ts = Date.now()
const groupId = groupsRes.items[0]?.id
?? (await (await ctx.post('/api/catalog/product-groups', { data: { name: 'G', parentId: null } })).json()).id
const prod = await ctx.post('/api/catalog/products', { const prod = await ctx.post('/api/catalog/products', {
data: { data: {
name: `POSverify ${Date.now()}`, unitOfMeasureId: unitId, productGroupId: groupId, name: `POSverify ${ts}`,
article: `POSV-${ts}`,
unitOfMeasureId: unitId, productGroupId: groupId,
vat: 12, vatEnabled: true, vat: 12, vatEnabled: true,
packaging: 1,
barcodes: [{ code: generateEan13(7), type: 1, isPrimary: true }], barcodes: [{ code: generateEan13(7), type: 1, isPrimary: true }],
prices: [{ priceTypeId: pt, currencyId: cur, amount: 500 }], prices: [{ priceTypeId: pt, currencyId: cur, amount: 500 }],
}, },
}) })
expect(prod.status()).toBe(201) expect([200, 201]).toContain(prod.status())
const productId = (await prod.json()).id const productId = (await prod.json()).id
// Enter (приход без поставщика) на 10 штук, чтобы был остаток для продажи. // Enter (приход без поставщика) на 10 штук, чтобы был остаток для продажи.
const ent = await ctx.post('/api/inventory/enter', { const ent = await ctx.post('/api/inventory/enters', {
data: { storeId, date: new Date().toISOString(), lines: [{ productId, qty: 10, costPrice: 200 }] }, 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 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 продажа. // POS batch — 1 продажа.
const idempKey = randomUUID() const idempKey = randomUUID()
@ -60,11 +64,11 @@ test.describe('Verify POS Sync idempotency', () => {
const body = { const body = {
idempotencyKey: idempKey, idempotencyKey: idempKey,
sales: [{ sales: [{
clientSaleId, storeId, clientSaleId,
soldAt: new Date().toISOString(), occurredAt: new Date().toISOString(),
paymentMethod: 1, // Cash payment: 0, // Cash
lines: [{ productId, qty: 1, unitPrice: 500, vat: 12 }], paidCash: 500, paidCard: 0,
paidCash: 500, paidCard: 0, change: 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.replayedFromCache, 'replayed flag').toBe(true)
expect(b2.accepted[0].serverSaleId, 'same serverSaleId').toBe(serverSaleId) expect(b2.accepted[0].serverSaleId, 'same serverSaleId').toBe(serverSaleId)
// Проверяем через API: GET /api/sales/retail должен показать ровно 1 чек // Проверяем через API: GET /api/sales/retail (list) показывает ровно 1
// в этой org (помимо seed-данных, у свежей org 0 sales до нашего батча). // POS-чек в свежей org, и detail GET /api/sales/retail/{id} имеет Notes,
// начинающийся с маркера pos:<clientSaleId-N>.
const list = await ctx.get('/api/sales/retail?pageSize=200') const list = await ctx.get('/api/sales/retail?pageSize=200')
expect(list.status()).toBe(200) expect(list.status()).toBe(200)
const items = (await list.json() as { items: Array<{ id: string, notes: string | null }> }).items const items = (await list.json() as { items: Array<{ id: string }> }).items
const posOnes = items.filter(s => s.notes != null && s.notes.startsWith(`pos:${clientSaleId.replace(/-/g, '')}`)) expect(items.length, 'ровно 1 чек в org после POS батча').toBe(1)
expect(posOnes.length, 'ровно 1 retail_sale с маркером pos:<clientSaleId>').toBe(1) expect(items[0].id).toBe(serverSaleId)
expect(posOnes[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:<clientSaleId-N>')
.toMatch(new RegExp(`^pos:${clientSaleId.replace(/-/g, '')}`))
await ctx.dispose() await ctx.dispose()
}) })

View file

@ -24,57 +24,71 @@ test.describe('Verify stock race', () => {
extraHTTPHeaders: { Authorization: `Bearer ${sess.accessToken}` }, extraHTTPHeaders: { Authorization: `Bearer ${sess.accessToken}` },
}) })
// Bootstrap. // Bootstrap: org свежая, bootstrap-сидер уже создал главный склад,
const stores = await ctx.get('/api/inventory/stores') // retail-point, единицы и валюты. Берём их из API.
const storeId = (await stores.json()).items[0]?.id const stores = (await (await ctx.get('/api/catalog/stores')).json()).items as Array<{ id: string, isMain: boolean }>
?? (await (await ctx.post('/api/inventory/stores', { data: { name: 'Главный', address: 'Алматы' } })).json()).id const storeId = (stores.find(s => s.isMain) ?? stores[0]).id
const units = await ctx.get('/api/catalog/units-of-measure') const retailPoints = (await (await ctx.get('/api/catalog/retail-points')).json()).items as Array<{ id: string }>
const unitId = (await units.json()).items[0].id const retailPointId = retailPoints[0].id
const cur = (await (await ctx.get('/api/catalog/currencies?pageSize=200')).json()).items.find((c: { code: string }) => c.code === 'KZT').id const units = (await (await ctx.get('/api/catalog/units-of-measure?pageSize=200')).json()).items as Array<{ id: string, code: string }>
const ptList = (await (await ctx.get('/api/catalog/price-types')).json()).items const unitId = (units.find(u => u.code === '796') ?? units[0]).id
const pt = (ptList.find((p: { isRetail: boolean }) => p.isRetail) ?? ptList[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 groupsRes = (await (await ctx.get('/api/catalog/product-groups?pageSize=10')).json()) const ptList = (await (await ctx.get('/api/catalog/price-types')).json()).items as Array<{ id: string, isRetail: boolean }>
const groupId = groupsRes.items[0]?.id const pt = (ptList.find(p => p.isRetail) ?? ptList[0]).id
?? (await (await ctx.post('/api/catalog/product-groups', { data: { name: 'G', parentId: null } })).json()).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', { const prod = await ctx.post('/api/catalog/products', {
data: { data: {
name: `RaceProd ${Date.now()}`, unitOfMeasureId: unitId, productGroupId: groupId, name: `RaceProd ${ts}`,
article: `RACE-${ts}`,
unitOfMeasureId: unitId, productGroupId: groupId,
vat: 12, vatEnabled: true, vat: 12, vatEnabled: true,
packaging: 1,
barcodes: [{ code: generateEan13(7), type: 1, isPrimary: true }], barcodes: [{ code: generateEan13(7), type: 1, isPrimary: true }],
prices: [{ priceTypeId: pt, currencyId: cur, amount: 500 }], prices: [{ priceTypeId: pt, currencyId: cur, amount: 500 }],
}, },
}) })
expect(prod.status()).toBe(201) expect([200, 201]).toContain(prod.status())
const productId = (await prod.json()).id const productId = (await prod.json()).id
// Enter: 3 штуки в остаток. // Enter: 3 штуки в остаток.
const ent = await ctx.post('/api/inventory/enter', { const ent = await ctx.post('/api/inventory/enters', {
data: { storeId, date: new Date().toISOString(), lines: [{ productId, qty: 3, costPrice: 200 }] }, 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 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 каждый. // Создаём 5 RetailSale-черновиков с qty=1 каждый.
const draftIds: string[] = [] const draftIds: string[] = []
for (let i = 0; i < 5; i++) { for (let i = 0; i < 5; i++) {
const r = await ctx.post('/api/sales/retail', { const r = await ctx.post('/api/sales/retail', {
data: { data: {
storeId, date: new Date().toISOString(), date: new Date().toISOString(),
lines: [{ productId, qty: 1, unitPrice: 500, vat: 12 }], storeId, retailPointId, currencyId: cur,
paidCash: 500, paidCard: 0, change: 0, 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) draftIds.push((await r.json()).id)
} }
// Параллельный Post всех 5. // Параллельный Post всех 5. Запоминаем body фейлов на случай если все
const postResults = await Promise.all(draftIds.map(id => // упадут — иначе нечем диагностировать.
ctx.post(`/api/sales/retail/${id}/post`).then(r => ({ id, status: r.status() })) const postResults = await Promise.all(draftIds.map(async (id) => {
)) const r = await ctx.post(`/api/sales/retail/${id}/post`)
const ok = postResults.filter(r => r.status === 200) 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) const failed = postResults.filter(r => r.status >= 400)
test.info().annotations.push({ type: 'post-results', description: JSON.stringify(postResults) }) 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(ok.length, 'ровно 3 успешных Post').toBe(3)
expect(failed.length, 'ровно 2 неуспешных Post').toBe(2) expect(failed.length, 'ровно 2 неуспешных Post').toBe(2)