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;
|
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";
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue