fix(stage-tests): IP-limit 60/min, locale ru-RU в playwright, исправлены payload'ы verify-spec'ов
Some checks failed
Some checks failed
После предыдущего фикса 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:
parent
9d48ca6483
commit
43a5552772
|
|
@ -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";
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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:<clientSaleId-N>.
|
||||
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:<clientSaleId>').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:<clientSaleId-N>')
|
||||
.toMatch(new RegExp(`^pos:${clientSaleId.replace(/-/g, '')}`))
|
||||
|
||||
await ctx.dispose()
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Reference in a new issue