fix(catalog): EF8 nav-collection bug в Products.Update + unique IX на Article
Some checks are pending
Some checks are pending
1. Products.Update: добавление нового barcode'а к существующему товару валилось с DbUpdateConcurrencyException 'Товар изменён в другом окне', хотя никакой конкурентной правки не было. Тот же EF8-баг, который в TD-6 чинили на Supplies/Demands/RetailSales: nav-collection.Add + client-side Id путает EF, UPDATE родителя получает 0 affected. Чиним тем же паттерном: ExecuteDelete старых ProductBarcodes/ProductPrices, DbSet.Add новых. Воспроизводится: создать товар с 1 barcode, PUT с 2 barcodes → 409. После фикса → 204. 2. IX_products_OrganizationId_Article был обычным (не уникальным), хотя контроллер ловил нарушение по имени индекса и возвращал 'Артикул уже занят'. Catch-блок никогда не срабатывал. Делаем индекс уникальным миграцией Phase8d. Перед созданием — нумеруем дубликаты по существующим данным (если есть). NULL/пустые article остаются distinct (Postgres NULL semantics). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
parent
0511cfacfd
commit
d54e1cb968
|
|
@ -263,47 +263,23 @@ public async Task<IActionResult> Update(Guid id, [FromBody] ProductInput input,
|
||||||
Apply(e, input);
|
Apply(e, input);
|
||||||
e.ReferencePrice = RoundIfNeeded(e.ReferencePrice, allowFractional);
|
e.ReferencePrice = RoundIfNeeded(e.ReferencePrice, allowFractional);
|
||||||
|
|
||||||
// Merge barcodes по Code: одинаковые — обновляем поля, лишние удаляем,
|
// Children-collections правим через ExecuteDelete (минует трекер) +
|
||||||
// новые добавляем. Это позволяет избежать массового DELETE+INSERT, на
|
// DbSet.Add — иначе EF8 на nav-collection путается и UPDATE на родителя
|
||||||
// котором EF может выдать DbUpdateConcurrencyException, если какой-то
|
// падает с DbUpdateConcurrencyException «0 rows affected». Тот же паттерн
|
||||||
// child был удалён параллельно из БД.
|
// что в Supplies/Demands/RetailSales.Update — см. их комментарии.
|
||||||
var inputBarcodes = (input.Barcodes ?? []).ToList();
|
await _db.ProductBarcodes.Where(b => b.ProductId == e.Id).ExecuteDeleteAsync(ct);
|
||||||
var byCode = e.Barcodes.ToDictionary(b => b.Code, b => b);
|
await _db.ProductPrices.Where(p => p.ProductId == e.Id).ExecuteDeleteAsync(ct);
|
||||||
var inputCodes = inputBarcodes.Select(b => b.Code).ToHashSet();
|
foreach (var b in input.Barcodes ?? [])
|
||||||
foreach (var existing in e.Barcodes.ToList())
|
_db.ProductBarcodes.Add(new ProductBarcode
|
||||||
if (!inputCodes.Contains(existing.Code)) _db.ProductBarcodes.Remove(existing);
|
|
||||||
foreach (var b in inputBarcodes)
|
|
||||||
{
|
|
||||||
if (byCode.TryGetValue(b.Code, out var ex))
|
|
||||||
{
|
{
|
||||||
ex.Type = b.Type;
|
ProductId = e.Id, Code = b.Code, Type = b.Type, IsPrimary = b.IsPrimary,
|
||||||
ex.IsPrimary = b.IsPrimary;
|
});
|
||||||
}
|
foreach (var pr in input.Prices ?? [])
|
||||||
else
|
_db.ProductPrices.Add(new ProductPrice
|
||||||
{
|
{
|
||||||
e.Barcodes.Add(new ProductBarcode { Code = b.Code, Type = b.Type, IsPrimary = b.IsPrimary });
|
ProductId = e.Id, PriceTypeId = pr.PriceTypeId,
|
||||||
}
|
Amount = RoundIfNeeded(pr.Amount, allowFractional), CurrencyId = pr.CurrencyId,
|
||||||
}
|
});
|
||||||
|
|
||||||
// Merge prices по PriceTypeId.
|
|
||||||
var inputPrices = (input.Prices ?? []).ToList();
|
|
||||||
var byPriceType = e.Prices.ToDictionary(p => p.PriceTypeId, p => p);
|
|
||||||
var inputPriceTypes = inputPrices.Select(p => p.PriceTypeId).ToHashSet();
|
|
||||||
foreach (var existing in e.Prices.ToList())
|
|
||||||
if (!inputPriceTypes.Contains(existing.PriceTypeId)) _db.ProductPrices.Remove(existing);
|
|
||||||
foreach (var pr in inputPrices)
|
|
||||||
{
|
|
||||||
var amount = RoundIfNeeded(pr.Amount, allowFractional);
|
|
||||||
if (byPriceType.TryGetValue(pr.PriceTypeId, out var ex))
|
|
||||||
{
|
|
||||||
ex.Amount = amount;
|
|
||||||
ex.CurrencyId = pr.CurrencyId;
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
e.Prices.Add(new ProductPrice { PriceTypeId = pr.PriceTypeId, Amount = amount, CurrencyId = pr.CurrencyId });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -144,7 +144,12 @@ private static void ConfigureProduct(EntityTypeBuilder<Product> b)
|
||||||
b.HasOne(x => x.PurchaseCurrency).WithMany().HasForeignKey(x => x.PurchaseCurrencyId).OnDelete(DeleteBehavior.Restrict);
|
b.HasOne(x => x.PurchaseCurrency).WithMany().HasForeignKey(x => x.PurchaseCurrencyId).OnDelete(DeleteBehavior.Restrict);
|
||||||
|
|
||||||
b.HasIndex(x => new { x.OrganizationId, x.Name });
|
b.HasIndex(x => new { x.OrganizationId, x.Name });
|
||||||
b.HasIndex(x => new { x.OrganizationId, x.Article });
|
// Article — уникальный в рамках организации. Контроллер ловит
|
||||||
|
// нарушение по имени индекса IX_products_OrganizationId_Article
|
||||||
|
// и возвращает 400 «Артикул уже занят». Уникальность включает NULL —
|
||||||
|
// несколько товаров без артикула допустимы (Postgres treat NULL как
|
||||||
|
// distinct), но раз указан — должен быть уникальным.
|
||||||
|
b.HasIndex(x => new { x.OrganizationId, x.Article }).IsUnique();
|
||||||
b.HasIndex(x => new { x.OrganizationId, x.ProductGroupId });
|
b.HasIndex(x => new { x.OrganizationId, x.ProductGroupId });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,61 @@
|
||||||
|
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
using foodmarket.Infrastructure.Persistence;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace foodmarket.Infrastructure.Persistence.Migrations
|
||||||
|
{
|
||||||
|
/// <summary>Phase8d — UNIQUE индекс на (OrganizationId, Article) у products.
|
||||||
|
///
|
||||||
|
/// Контроллер ProductsController.Create/Update УЖЕ ловит нарушение по
|
||||||
|
/// имени индекса IX_products_OrganizationId_Article и возвращает 400
|
||||||
|
/// «Артикул уже занят», но сам индекс был неуникальным — поэтому
|
||||||
|
/// дубликаты артикулов проскакивали. Postgres NULL-значения индекс
|
||||||
|
/// трактует как distinct по умолчанию: товары без Article можно
|
||||||
|
/// держать без ограничения; пустая строка приравнена к одной — при
|
||||||
|
/// необходимости несколько товаров без артикула пользователь оставляет
|
||||||
|
/// поле NULL (UI присылает null, а не ""). Дубликаты existing — чистим
|
||||||
|
/// в migration перед созданием индекса: дублям дописываем суффикс «-N».
|
||||||
|
///
|
||||||
|
/// Идемпотентен (создание DROP+CREATE).</summary>
|
||||||
|
[DbContext(typeof(AppDbContext))]
|
||||||
|
[Migration("20260529100000_Phase8d_ProductArticleUnique")]
|
||||||
|
public partial class Phase8d_ProductArticleUnique : Migration
|
||||||
|
{
|
||||||
|
protected override void Up(MigrationBuilder b)
|
||||||
|
{
|
||||||
|
b.Sql(@"
|
||||||
|
-- Чистим существующие дубликаты article в рамках одной orgId.
|
||||||
|
-- Сначала ловим (OrgId, Article) с count>1, потом нумеруем
|
||||||
|
-- row_number'ом и дописываем -2/-3/... ко второму и далее.
|
||||||
|
WITH dups AS (
|
||||||
|
SELECT
|
||||||
|
""Id"",
|
||||||
|
""Article"",
|
||||||
|
row_number() OVER (PARTITION BY ""OrganizationId"", ""Article"" ORDER BY ""CreatedAt"", ""Id"") AS rn
|
||||||
|
FROM public.products
|
||||||
|
WHERE ""Article"" IS NOT NULL AND ""Article"" <> ''
|
||||||
|
)
|
||||||
|
UPDATE public.products p
|
||||||
|
SET ""Article"" = p.""Article"" || '-' || d.rn
|
||||||
|
FROM dups d
|
||||||
|
WHERE p.""Id"" = d.""Id"" AND d.rn > 1;
|
||||||
|
|
||||||
|
-- Пересоздаём индекс уникальным. CREATE/DROP под IF EXISTS.
|
||||||
|
DROP INDEX IF EXISTS public.""IX_products_OrganizationId_Article"";
|
||||||
|
CREATE UNIQUE INDEX ""IX_products_OrganizationId_Article""
|
||||||
|
ON public.products (""OrganizationId"", ""Article"");
|
||||||
|
");
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override void Down(MigrationBuilder b)
|
||||||
|
{
|
||||||
|
b.Sql(@"
|
||||||
|
DROP INDEX IF EXISTS public.""IX_products_OrganizationId_Article"";
|
||||||
|
CREATE INDEX ""IX_products_OrganizationId_Article""
|
||||||
|
ON public.products (""OrganizationId"", ""Article"");
|
||||||
|
");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
91
tests/e2e/reports/stage-catalog-2026-05-29T11-45-48-560Z.md
Normal file
91
tests/e2e/reports/stage-catalog-2026-05-29T11-45-48-560Z.md
Normal file
|
|
@ -0,0 +1,91 @@
|
||||||
|
# E2E report: stage-catalog
|
||||||
|
|
||||||
|
Запущен: 2026-05-29T11:45:42.321Z
|
||||||
|
Длительность: 6.2с
|
||||||
|
|
||||||
|
**Итог:** 6 ✓ / 0 ✗ / 0 ⚠ / 0 ◯ (всего 6)
|
||||||
|
|
||||||
|
## ✓ Step cat01_setup_two_orgs: Создаём 2 свежие org через signup → 2 admin токена для multi-tenant проверок
|
||||||
|
|
||||||
|
Длительность: 3478мс
|
||||||
|
|
||||||
|
| Тип | Проверка | Результат |
|
||||||
|
|---|---|---|
|
||||||
|
| api | Org A создана, refs загружены | ✓ org=cb1c333d group=true price=true cur=true unit=true |
|
||||||
|
| api | Org B создана, refs загружены | ✓ org=41518f3a |
|
||||||
|
| api | Org A и Org B — разные organizationId | ✓ cb1c333d-fe72-454b-8d06-bf1623c50479 vs 41518f3a-7014-4780-8986-db02026561e8 |
|
||||||
|
| api | Корневая группа «Все товары» у org A и B — разные записи | ✓ A.root=6c3e8f44-3ce6-41c7-a543-d64f87b6e4b7 B.root=48b4385f-280f-42ba-9717-2200d970e88b |
|
||||||
|
|
||||||
|
## ✓ Step cat02_product_group_crud: Группы — создать корневую и дочернюю, обновить, удалить пустую, отказ на удалении с подгруппами
|
||||||
|
|
||||||
|
Длительность: 645мс
|
||||||
|
|
||||||
|
| Тип | Проверка | Результат |
|
||||||
|
|---|---|---|
|
||||||
|
| api | POST product-groups (root) → 201 | ✓ 201 {"id":"4f915396-6dda-45ec-885d-578d79034487","name":"Группа 1780055142321","parentId":null,"path":"Группа 1780055142321","sortOrder":10,"markupPercent":30,"organizationId":"cb1c333d-fe72-454b-8d06 |
|
||||||
|
| api | OrganizationId назначен автоматически (мультитенантность) | ✓ cb1c333d-fe72-454b-8d06-bf1623c50479 vs cb1c333d-fe72-454b-8d06-bf1623c50479 |
|
||||||
|
| api | POST product-groups (child) → 201, path "Группа .../Подгруппа A" | ✓ 201 path="Группа 1780055142321/Подгруппа A" |
|
||||||
|
| api | PUT product-groups child → 204 | ✓ 204 |
|
||||||
|
| api | DELETE root c подгруппой → 400 "Нельзя удалить группу с подгруппами" | ✓ 400 {"error":"Нельзя удалить группу с подгруппами"} |
|
||||||
|
| api | DELETE child (без товаров) → 204 | ✓ 204 |
|
||||||
|
| api | Cleanup DELETE root (теперь без детей) → 204 | ✓ 204 |
|
||||||
|
|
||||||
|
## ✓ Step cat03_product_crud: Товар — создать, обновить, прочитать, удалить; дубликат штрихкода → 400; дубликат артикула → 400
|
||||||
|
|
||||||
|
Длительность: 649мс
|
||||||
|
|
||||||
|
| Тип | Проверка | Результат |
|
||||||
|
|---|---|---|
|
||||||
|
| api | POST product → 201 | ✓ 201 {"id":"fef80e2d-59a1-4604-8d7d-ff187c28c983","name":"Product 1780055142321 A","article":"ART-1780055142321-A1","description":"тестовый","unitOfMeasureId":"e005c2ad-ea0b-43d6-b849-25b0f3c33618","un |
|
||||||
|
| api | GET product by id → 200 | ✓ 200 article=ART-1780055142321-A1 |
|
||||||
|
| api | PUT product → 204 | ✓ 204 |
|
||||||
|
| api | POST с дубликатом штрихкода → 400 "уже используется" | ✓ 400 {"error":"Штрихкод 2051464470012 уже используется товаром «Product 1780055142321 A (upd)»."} |
|
||||||
|
| api | POST с дубликатом article → 400 "Артикул уже занят" | ✓ 400 {"error":"Артикул «ART-1780055142321-A1» уже занят в этой организации."} |
|
||||||
|
| api | GET products?search возвращает наш product | ✓ 200 total=1 |
|
||||||
|
|
||||||
|
## ✓ Step cat04_counterparty_crud: Контрагент — создать (юрлицо + физлицо), валидация телефона, FK-защита при удалении использованного
|
||||||
|
|
||||||
|
Длительность: 371мс
|
||||||
|
|
||||||
|
| Тип | Проверка | Результат |
|
||||||
|
|---|---|---|
|
||||||
|
| api | POST counterparty (Legal) → 201 | ✓ 201 {"id":"205ba9d6-19d1-494d-a6a2-ec63e481cac4","name":"ТОО Тест 1780055142321","legalName":"ТОО Тест-Полное 1780055142321","type":1,"bin":"999888777666","iin":null,"taxNumber":null,"countryId":null, |
|
||||||
|
| api | POST counterparty (Individual) → 201 | ✓ 201 {"id":"5a5a224a-fbe4-4e8d-aac9-ebf7e57a13a6","name":"Иванов И.И. 1780055142321","legalName":null,"type":2,"bin":null,"iin":"880101300123","taxNumber":null,"countryId":null,"countryName":null,"addr |
|
||||||
|
| api | POST counterparty с кривым телефоном → 400 | ✓ 400 {"error":"Введите корректный номер Казахстана. Пример: +7 700 123 45 67"} |
|
||||||
|
| api | PUT counterparty → 204 | ✓ 204 |
|
||||||
|
|
||||||
|
## ✓ Step cat05_multi_tenant_isolation: Org A не видит товары/группы/контрагентов Org B и наоборот; GET by id чужой сущности → 404
|
||||||
|
|
||||||
|
Длительность: 532мс
|
||||||
|
|
||||||
|
| Тип | Проверка | Результат |
|
||||||
|
|---|---|---|
|
||||||
|
| api | Org B GET /products НЕ содержит товар org A | ✓ org B total=0, seenA=false |
|
||||||
|
| api | Org B GET /counterparties НЕ содержит контрагента org A | ✓ org B total=0, seenA=false |
|
||||||
|
| api | Org B GET /products/{id-чужой} → 404 | ✓ 404 |
|
||||||
|
| api | Org B GET /counterparties/{id-чужой} → 404 | ✓ 404 |
|
||||||
|
| api | Org B PUT /products/{id-чужой} → 404 (нельзя угнать) | ✓ 404 {"type":"https://tools.ietf.org/html/rfc9110#section-15.5.5","title":"Not Found","status":404,"traceId":"00-6113d4f60b97b701f6123eb42e8c8d12-15dba5518572a51c-00"} |
|
||||||
|
| api | Org B DELETE /products/{id-чужой} → 404 | ✓ 404 |
|
||||||
|
|
||||||
|
## ✓ Step cat06_fk_protection_groups: Удаление группы с товаром → 400, удаление системной «Все товары» → 400
|
||||||
|
|
||||||
|
Длительность: 562мс
|
||||||
|
|
||||||
|
| Тип | Проверка | Результат |
|
||||||
|
|---|---|---|
|
||||||
|
| api | DELETE системной «Все товары» → 400 "Системную группу" | ✓ 400 {"error":"Системную группу удалить нельзя."} |
|
||||||
|
| api | PUT product → перевешиваем в новую группу | ✓ 204 |
|
||||||
|
| api | DELETE не-системной группы с привязанным товаром → 400 "товарами" | ✓ 400 {"error":"Нельзя удалить группу с товарами"} |
|
||||||
|
| api | DELETE product → 204 | ✓ 204 |
|
||||||
|
| api | DELETE пустой группы (после удаления товара) → 204 | ✓ 204 |
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
- Passed: 6
|
||||||
|
- Failed: 0
|
||||||
|
- Warnings: 0
|
||||||
|
- Skipped: 0
|
||||||
|
|
||||||
|
## Critical bugs
|
||||||
|
|
||||||
|
Нет.
|
||||||
525
tests/e2e/scenarios/stage-catalog.steps.ts
Normal file
525
tests/e2e/scenarios/stage-catalog.steps.ts
Normal file
|
|
@ -0,0 +1,525 @@
|
||||||
|
/**
|
||||||
|
* Stage catalog: products/groups/counterparties CRUD + multi-tenant
|
||||||
|
* изоляция. Создаём 2 org-A/B через /api/auth/signup, под каждым
|
||||||
|
* admin'ом проверяем что объекты не пересекаются.
|
||||||
|
*/
|
||||||
|
import { login, makeClient } from '../lib/api.js'
|
||||||
|
import type { CheckResult, Step, Report } from '../lib/report.js'
|
||||||
|
import { generateEan13 } from '../lib/barcode.js'
|
||||||
|
|
||||||
|
type Org = {
|
||||||
|
orgId: string
|
||||||
|
email: string
|
||||||
|
password: string
|
||||||
|
token: string
|
||||||
|
productGroupRootId?: string // системная «Все товары»
|
||||||
|
priceTypeRetailId?: string
|
||||||
|
currencyKztId?: string
|
||||||
|
unitKgId?: string
|
||||||
|
}
|
||||||
|
type Ctx = {
|
||||||
|
apiOnly: boolean
|
||||||
|
ts: number
|
||||||
|
orgA?: Org
|
||||||
|
orgB?: Org
|
||||||
|
groupChildIdA?: string
|
||||||
|
productIdA?: string
|
||||||
|
counterpartyIdA?: 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 signupAndLogin(suffix: string): Promise<Org> {
|
||||||
|
const api = makeClient()
|
||||||
|
const email = `stage-cat-${suffix}-${TS}@food-market.local`
|
||||||
|
const password = 'StageCat12345!'
|
||||||
|
const orgName = `Cat Org ${suffix} ${TS}`
|
||||||
|
const r = await api.post('/api/auth/signup', {
|
||||||
|
email, password, organizationName: orgName,
|
||||||
|
phone: suffix === 'a' ? '+77011111111' : '+77022222222', plan: 'start',
|
||||||
|
})
|
||||||
|
if (r.status !== 200) throw new Error(`signup ${suffix} failed: ${r.status} ${JSON.stringify(r.data)}`)
|
||||||
|
const sess = await login(email, password)
|
||||||
|
return {
|
||||||
|
orgId: r.data.organizationId,
|
||||||
|
email, password,
|
||||||
|
token: sess.accessToken,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchCommonRefs(org: Org): Promise<void> {
|
||||||
|
const api = makeClient(org.token)
|
||||||
|
const [groups, prices, units, currencies] = await Promise.all([
|
||||||
|
api.get('/api/catalog/product-groups'),
|
||||||
|
api.get('/api/catalog/price-types'),
|
||||||
|
api.get('/api/catalog/units-of-measure'),
|
||||||
|
api.get('/api/catalog/currencies'),
|
||||||
|
])
|
||||||
|
org.productGroupRootId = groups.data?.items?.find((g: { name: string; parentId: string | null }) => g.parentId == null)?.id
|
||||||
|
org.priceTypeRetailId = prices.data?.items?.find((p: { isRequired: boolean }) => p.isRequired)?.id
|
||||||
|
org.currencyKztId = currencies.data?.items?.find((c: { code: string }) => c.code === 'KZT')?.id
|
||||||
|
org.unitKgId = units.data?.items?.find((u: { code: string }) => u.code === '166')?.id
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export async function cat01_setup_two_orgs({ ctx, step, report }: StepCtx) {
|
||||||
|
ctx.ts = TS
|
||||||
|
ctx.orgA = await signupAndLogin('a')
|
||||||
|
await new Promise(r => setTimeout(r, 1000)) // не словить rate-limit на signup
|
||||||
|
ctx.orgB = await signupAndLogin('b')
|
||||||
|
await fetchCommonRefs(ctx.orgA)
|
||||||
|
await fetchCommonRefs(ctx.orgB)
|
||||||
|
|
||||||
|
check(step, {
|
||||||
|
kind: 'api',
|
||||||
|
description: 'Org A создана, refs загружены',
|
||||||
|
ok: !!ctx.orgA.productGroupRootId && !!ctx.orgA.priceTypeRetailId && !!ctx.orgA.currencyKztId && !!ctx.orgA.unitKgId,
|
||||||
|
detail: `org=${ctx.orgA.orgId.slice(0,8)} group=${!!ctx.orgA.productGroupRootId} price=${!!ctx.orgA.priceTypeRetailId} cur=${!!ctx.orgA.currencyKztId} unit=${!!ctx.orgA.unitKgId}`,
|
||||||
|
})
|
||||||
|
check(step, {
|
||||||
|
kind: 'api',
|
||||||
|
description: 'Org B создана, refs загружены',
|
||||||
|
ok: !!ctx.orgB.productGroupRootId && !!ctx.orgB.priceTypeRetailId && !!ctx.orgB.currencyKztId && !!ctx.orgB.unitKgId,
|
||||||
|
detail: `org=${ctx.orgB.orgId.slice(0,8)}`,
|
||||||
|
})
|
||||||
|
check(step, {
|
||||||
|
kind: 'api',
|
||||||
|
description: 'Org A и Org B — разные organizationId',
|
||||||
|
ok: ctx.orgA.orgId !== ctx.orgB.orgId,
|
||||||
|
detail: `${ctx.orgA.orgId} vs ${ctx.orgB.orgId}`,
|
||||||
|
})
|
||||||
|
// Группы должны быть РАЗНЫЕ — у каждой org свой корневой «Все товары».
|
||||||
|
check(step, {
|
||||||
|
kind: 'api',
|
||||||
|
description: 'Корневая группа «Все товары» у org A и B — разные записи',
|
||||||
|
ok: ctx.orgA.productGroupRootId !== ctx.orgB.productGroupRootId,
|
||||||
|
detail: `A.root=${ctx.orgA.productGroupRootId} B.root=${ctx.orgB.productGroupRootId}`,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export async function cat02_product_group_crud({ ctx, step, report }: StepCtx) {
|
||||||
|
if (!ctx.orgA) { step.status = 'skip'; return }
|
||||||
|
const api = makeClient(ctx.orgA.token)
|
||||||
|
|
||||||
|
// Создаём корневую под Org A
|
||||||
|
const created = await api.post('/api/catalog/product-groups', {
|
||||||
|
name: `Группа ${TS}`, parentId: null, sortOrder: 10, markupPercent: 30,
|
||||||
|
})
|
||||||
|
check(step, {
|
||||||
|
kind: 'api',
|
||||||
|
description: 'POST product-groups (root) → 201',
|
||||||
|
ok: created.status === 201,
|
||||||
|
detail: `${created.status} ${asString(created.data).slice(0, 200)}`,
|
||||||
|
})
|
||||||
|
if (created.status !== 201) {
|
||||||
|
report.bug({ step: 'cat02', severity: 'high', title: 'Не создаётся product-group root', detail: asString(created.data) })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const rootId = created.data.id
|
||||||
|
check(step, {
|
||||||
|
kind: 'api',
|
||||||
|
description: 'OrganizationId назначен автоматически (мультитенантность)',
|
||||||
|
ok: created.data.organizationId === ctx.orgA.orgId,
|
||||||
|
detail: `${created.data.organizationId} vs ${ctx.orgA.orgId}`,
|
||||||
|
})
|
||||||
|
|
||||||
|
// Дочерняя
|
||||||
|
const child = await api.post('/api/catalog/product-groups', {
|
||||||
|
name: 'Подгруппа A', parentId: rootId, sortOrder: 0, markupPercent: 50,
|
||||||
|
})
|
||||||
|
check(step, {
|
||||||
|
kind: 'api',
|
||||||
|
description: 'POST product-groups (child) → 201, path "Группа .../Подгруппа A"',
|
||||||
|
ok: child.status === 201 && /\//.test(child.data?.path ?? ''),
|
||||||
|
detail: `${child.status} path="${child.data?.path}"`,
|
||||||
|
})
|
||||||
|
ctx.groupChildIdA = child.data?.id
|
||||||
|
|
||||||
|
// Обновляем дочернюю
|
||||||
|
const upd = await api.put(`/api/catalog/product-groups/${ctx.groupChildIdA}`, {
|
||||||
|
name: 'Подгруппа A (rename)', parentId: rootId, sortOrder: 1, markupPercent: 55,
|
||||||
|
})
|
||||||
|
check(step, {
|
||||||
|
kind: 'api',
|
||||||
|
description: 'PUT product-groups child → 204',
|
||||||
|
ok: upd.status === 204,
|
||||||
|
detail: `${upd.status} ${asString(upd.data).slice(0, 200)}`,
|
||||||
|
})
|
||||||
|
|
||||||
|
// Удалить group с подгруппами — должно отказаться
|
||||||
|
const delWithChild = await api.delete(`/api/catalog/product-groups/${rootId}`)
|
||||||
|
check(step, {
|
||||||
|
kind: 'api',
|
||||||
|
description: 'DELETE root c подгруппой → 400 "Нельзя удалить группу с подгруппами"',
|
||||||
|
ok: delWithChild.status === 400 && /подгруп/i.test(asString(delWithChild.data)),
|
||||||
|
detail: `${delWithChild.status} ${asString(delWithChild.data).slice(0, 200)}`,
|
||||||
|
})
|
||||||
|
|
||||||
|
// Удалить дочернюю — должно сработать
|
||||||
|
const delChild = await api.delete(`/api/catalog/product-groups/${ctx.groupChildIdA}`)
|
||||||
|
check(step, {
|
||||||
|
kind: 'api',
|
||||||
|
description: 'DELETE child (без товаров) → 204',
|
||||||
|
ok: delChild.status === 204,
|
||||||
|
detail: `${delChild.status}`,
|
||||||
|
})
|
||||||
|
|
||||||
|
// Cleanup: удалить и root тоже (она нам не нужна для cat06; cat06 использует фабричную «Все товары» + новый product)
|
||||||
|
const delRoot = await api.delete(`/api/catalog/product-groups/${rootId}`)
|
||||||
|
check(step, {
|
||||||
|
kind: 'api',
|
||||||
|
description: 'Cleanup DELETE root (теперь без детей) → 204',
|
||||||
|
ok: delRoot.status === 204,
|
||||||
|
detail: `${delRoot.status}`,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export async function cat03_product_crud({ ctx, step, report }: StepCtx) {
|
||||||
|
if (!ctx.orgA) { step.status = 'skip'; return }
|
||||||
|
const api = makeClient(ctx.orgA.token)
|
||||||
|
const a = ctx.orgA
|
||||||
|
|
||||||
|
const barcode1 = generateEan13(1)
|
||||||
|
const barcode2 = generateEan13(2)
|
||||||
|
const article1 = `ART-${TS}-A1`
|
||||||
|
|
||||||
|
// Create
|
||||||
|
const created = await api.post('/api/catalog/products', {
|
||||||
|
name: `Product ${TS} A`,
|
||||||
|
article: article1,
|
||||||
|
description: 'тестовый',
|
||||||
|
unitOfMeasureId: a.unitKgId,
|
||||||
|
vat: 0, vatEnabled: false,
|
||||||
|
productGroupId: a.productGroupRootId,
|
||||||
|
barcodes: [{ code: barcode1, type: 1, isPrimary: true }],
|
||||||
|
prices: [{ priceTypeId: a.priceTypeRetailId, amount: 1000, currencyId: a.currencyKztId }],
|
||||||
|
})
|
||||||
|
check(step, {
|
||||||
|
kind: 'api',
|
||||||
|
description: 'POST product → 201',
|
||||||
|
ok: created.status === 201,
|
||||||
|
detail: `${created.status} ${asString(created.data).slice(0, 250)}`,
|
||||||
|
})
|
||||||
|
if (created.status !== 201) return
|
||||||
|
ctx.productIdA = created.data.id
|
||||||
|
|
||||||
|
// Get
|
||||||
|
const got = await api.get(`/api/catalog/products/${ctx.productIdA}`)
|
||||||
|
check(step, {
|
||||||
|
kind: 'api',
|
||||||
|
description: 'GET product by id → 200',
|
||||||
|
ok: got.status === 200 && got.data?.article === article1,
|
||||||
|
detail: `${got.status} article=${got.data?.article}`,
|
||||||
|
})
|
||||||
|
|
||||||
|
// Update — меняем имя, добавляем второй штрихкод
|
||||||
|
const upd = await api.put(`/api/catalog/products/${ctx.productIdA}`, {
|
||||||
|
name: `Product ${TS} A (upd)`,
|
||||||
|
article: article1,
|
||||||
|
description: 'обновлённый',
|
||||||
|
unitOfMeasureId: a.unitKgId,
|
||||||
|
vat: 0, vatEnabled: false,
|
||||||
|
productGroupId: a.productGroupRootId,
|
||||||
|
barcodes: [
|
||||||
|
{ code: barcode1, type: 1, isPrimary: true },
|
||||||
|
{ code: barcode2, type: 1, isPrimary: false },
|
||||||
|
],
|
||||||
|
prices: [{ priceTypeId: a.priceTypeRetailId, amount: 1100, currencyId: a.currencyKztId }],
|
||||||
|
})
|
||||||
|
check(step, {
|
||||||
|
kind: 'api',
|
||||||
|
description: 'PUT product → 204',
|
||||||
|
ok: upd.status === 204,
|
||||||
|
detail: `${upd.status} ${asString(upd.data).slice(0, 200)}`,
|
||||||
|
})
|
||||||
|
|
||||||
|
// Дубликат штрихкода — создание второго товара с тем же barcode1 → 400
|
||||||
|
const dup = await api.post('/api/catalog/products', {
|
||||||
|
name: `Dup ${TS}`,
|
||||||
|
article: `ART-${TS}-A2`,
|
||||||
|
unitOfMeasureId: a.unitKgId,
|
||||||
|
vat: 0, vatEnabled: false,
|
||||||
|
productGroupId: a.productGroupRootId,
|
||||||
|
barcodes: [{ code: barcode1, type: 1, isPrimary: true }],
|
||||||
|
prices: [{ priceTypeId: a.priceTypeRetailId, amount: 500, currencyId: a.currencyKztId }],
|
||||||
|
})
|
||||||
|
check(step, {
|
||||||
|
kind: 'api',
|
||||||
|
description: 'POST с дубликатом штрихкода → 400 "уже используется"',
|
||||||
|
ok: dup.status === 400 && /используется|already/i.test(asString(dup.data)),
|
||||||
|
detail: `${dup.status} ${asString(dup.data).slice(0, 200)}`,
|
||||||
|
})
|
||||||
|
|
||||||
|
// Дубликат артикула — другая штрихкод но тот же article
|
||||||
|
const dupArt = await api.post('/api/catalog/products', {
|
||||||
|
name: `DupArt ${TS}`,
|
||||||
|
article: article1,
|
||||||
|
unitOfMeasureId: a.unitKgId,
|
||||||
|
vat: 0, vatEnabled: false,
|
||||||
|
productGroupId: a.productGroupRootId,
|
||||||
|
barcodes: [{ code: generateEan13(3), type: 1, isPrimary: true }],
|
||||||
|
prices: [{ priceTypeId: a.priceTypeRetailId, amount: 500, currencyId: a.currencyKztId }],
|
||||||
|
})
|
||||||
|
check(step, {
|
||||||
|
kind: 'api',
|
||||||
|
description: 'POST с дубликатом article → 400 "Артикул уже занят"',
|
||||||
|
ok: dupArt.status === 400 && /артикул|занят/i.test(asString(dupArt.data)),
|
||||||
|
detail: `${dupArt.status} ${asString(dupArt.data).slice(0, 200)}`,
|
||||||
|
})
|
||||||
|
|
||||||
|
// Поиск
|
||||||
|
const list = await api.get('/api/catalog/products?search=' + encodeURIComponent(`Product ${TS}`))
|
||||||
|
check(step, {
|
||||||
|
kind: 'api',
|
||||||
|
description: 'GET products?search возвращает наш product',
|
||||||
|
ok: list.status === 200 && list.data?.items?.some((p: { id: string }) => p.id === ctx.productIdA),
|
||||||
|
detail: `${list.status} total=${list.data?.total}`,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export async function cat04_counterparty_crud({ ctx, step, report }: StepCtx) {
|
||||||
|
if (!ctx.orgA) { step.status = 'skip'; return }
|
||||||
|
const api = makeClient(ctx.orgA.token)
|
||||||
|
|
||||||
|
// Юрлицо (CounterpartyType.LegalEntity=1)
|
||||||
|
const legal = await api.post('/api/catalog/counterparties', {
|
||||||
|
name: `ТОО Тест ${TS}`, legalName: `ТОО Тест-Полное ${TS}`, type: 1,
|
||||||
|
bin: '999888777666', iin: null, taxNumber: null,
|
||||||
|
countryId: null, address: 'Алматы', phone: '+77011112233', email: 'test@example.kz',
|
||||||
|
bankName: 'Halyk', bankAccount: 'KZ123', bik: 'HSBKKZKX', contactPerson: 'Иван', notes: null,
|
||||||
|
})
|
||||||
|
check(step, {
|
||||||
|
kind: 'api',
|
||||||
|
description: 'POST counterparty (Legal) → 201',
|
||||||
|
ok: legal.status === 201,
|
||||||
|
detail: `${legal.status} ${asString(legal.data).slice(0, 200)}`,
|
||||||
|
})
|
||||||
|
ctx.counterpartyIdA = legal.data?.id
|
||||||
|
|
||||||
|
// Физлицо (CounterpartyType.Individual=2)
|
||||||
|
const indiv = await api.post('/api/catalog/counterparties', {
|
||||||
|
name: `Иванов И.И. ${TS}`, legalName: null, type: 2,
|
||||||
|
bin: null, iin: '880101300123', taxNumber: null,
|
||||||
|
countryId: null, address: null, phone: '+77002223344', email: null,
|
||||||
|
bankName: null, bankAccount: null, bik: null, contactPerson: null, notes: null,
|
||||||
|
})
|
||||||
|
check(step, {
|
||||||
|
kind: 'api',
|
||||||
|
description: 'POST counterparty (Individual) → 201',
|
||||||
|
ok: indiv.status === 201,
|
||||||
|
detail: `${indiv.status} ${asString(indiv.data).slice(0, 200)}`,
|
||||||
|
})
|
||||||
|
|
||||||
|
// Невалидный телефон
|
||||||
|
const badPhone = await api.post('/api/catalog/counterparties', {
|
||||||
|
name: `Bad ${TS}`, type: 1, phone: 'not-a-phone',
|
||||||
|
bin: null, iin: null, taxNumber: null, countryId: null,
|
||||||
|
legalName: null, address: null, email: null, bankName: null, bankAccount: null,
|
||||||
|
bik: null, contactPerson: null, notes: null,
|
||||||
|
})
|
||||||
|
check(step, {
|
||||||
|
kind: 'api',
|
||||||
|
description: 'POST counterparty с кривым телефоном → 400',
|
||||||
|
ok: badPhone.status === 400 && /кор?рект|казах/i.test(asString(badPhone.data)),
|
||||||
|
detail: `${badPhone.status} ${asString(badPhone.data).slice(0, 200)}`,
|
||||||
|
})
|
||||||
|
|
||||||
|
// Update
|
||||||
|
if (ctx.counterpartyIdA) {
|
||||||
|
const upd = await api.put(`/api/catalog/counterparties/${ctx.counterpartyIdA}`, {
|
||||||
|
name: `ТОО Тест UPD ${TS}`, legalName: null, type: 1,
|
||||||
|
bin: '999888777666', iin: null, taxNumber: null,
|
||||||
|
countryId: null, address: null, phone: '+77011112233', email: null,
|
||||||
|
bankName: null, bankAccount: null, bik: null, contactPerson: null, notes: 'updated',
|
||||||
|
})
|
||||||
|
check(step, {
|
||||||
|
kind: 'api',
|
||||||
|
description: 'PUT counterparty → 204',
|
||||||
|
ok: upd.status === 204,
|
||||||
|
detail: `${upd.status} ${asString(upd.data).slice(0, 200)}`,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export async function cat05_multi_tenant_isolation({ ctx, step, report }: StepCtx) {
|
||||||
|
if (!ctx.orgA || !ctx.orgB || !ctx.productIdA || !ctx.counterpartyIdA) {
|
||||||
|
step.status = 'skip'; step.notes.push('Нужны orgA/B и созданные сущности из cat03/cat04')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const apiB = makeClient(ctx.orgB.token)
|
||||||
|
|
||||||
|
// List
|
||||||
|
const productsB = await apiB.get('/api/catalog/products?pageSize=200')
|
||||||
|
const seenA = productsB.data?.items?.some((p: { id: string }) => p.id === ctx.productIdA)
|
||||||
|
check(step, {
|
||||||
|
kind: 'api',
|
||||||
|
description: 'Org B GET /products НЕ содержит товар org A',
|
||||||
|
ok: !seenA,
|
||||||
|
detail: `org B total=${productsB.data?.total}, seenA=${seenA}`,
|
||||||
|
})
|
||||||
|
if (seenA) {
|
||||||
|
report.bug({
|
||||||
|
step: 'cat05', severity: 'critical',
|
||||||
|
title: 'P0 multi-tenant утечка: Org B видит товар Org A в /api/catalog/products',
|
||||||
|
detail: `Tenant query filter не применён или обходится. productId=${ctx.productIdA}`,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const cpsB = await apiB.get('/api/catalog/counterparties?pageSize=200')
|
||||||
|
const seenCpA = cpsB.data?.items?.some((c: { id: string }) => c.id === ctx.counterpartyIdA)
|
||||||
|
check(step, {
|
||||||
|
kind: 'api',
|
||||||
|
description: 'Org B GET /counterparties НЕ содержит контрагента org A',
|
||||||
|
ok: !seenCpA,
|
||||||
|
detail: `org B total=${cpsB.data?.total}, seenA=${seenCpA}`,
|
||||||
|
})
|
||||||
|
if (seenCpA) {
|
||||||
|
report.bug({
|
||||||
|
step: 'cat05', severity: 'critical',
|
||||||
|
title: 'P0 multi-tenant утечка: Org B видит контрагента Org A в /api/catalog/counterparties',
|
||||||
|
detail: `counterpartyId=${ctx.counterpartyIdA}`,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET by id
|
||||||
|
const productByIdB = await apiB.get(`/api/catalog/products/${ctx.productIdA}`)
|
||||||
|
check(step, {
|
||||||
|
kind: 'api',
|
||||||
|
description: 'Org B GET /products/{id-чужой} → 404',
|
||||||
|
ok: productByIdB.status === 404,
|
||||||
|
detail: `${productByIdB.status}`,
|
||||||
|
})
|
||||||
|
if (productByIdB.status !== 404) {
|
||||||
|
report.bug({
|
||||||
|
step: 'cat05', severity: 'critical',
|
||||||
|
title: 'P0: Org B читает product Org A по id',
|
||||||
|
detail: `status=${productByIdB.status}, body=${asString(productByIdB.data).slice(0, 200)}`,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const cpByIdB = await apiB.get(`/api/catalog/counterparties/${ctx.counterpartyIdA}`)
|
||||||
|
check(step, {
|
||||||
|
kind: 'api',
|
||||||
|
description: 'Org B GET /counterparties/{id-чужой} → 404',
|
||||||
|
ok: cpByIdB.status === 404,
|
||||||
|
detail: `${cpByIdB.status}`,
|
||||||
|
})
|
||||||
|
|
||||||
|
// UPDATE/DELETE
|
||||||
|
const updByB = await apiB.put(`/api/catalog/products/${ctx.productIdA}`, {
|
||||||
|
name: 'hijack', article: 'x',
|
||||||
|
unitOfMeasureId: ctx.orgB.unitKgId, vat: 0, vatEnabled: false,
|
||||||
|
productGroupId: ctx.orgB.productGroupRootId,
|
||||||
|
barcodes: [{ code: generateEan13(4), type: 1, isPrimary: true }],
|
||||||
|
prices: [{ priceTypeId: ctx.orgB.priceTypeRetailId, amount: 1, currencyId: ctx.orgB.currencyKztId }],
|
||||||
|
})
|
||||||
|
check(step, {
|
||||||
|
kind: 'api',
|
||||||
|
description: 'Org B PUT /products/{id-чужой} → 404 (нельзя угнать)',
|
||||||
|
ok: updByB.status === 404,
|
||||||
|
detail: `${updByB.status} ${asString(updByB.data).slice(0, 200)}`,
|
||||||
|
})
|
||||||
|
if (updByB.status !== 404) {
|
||||||
|
report.bug({
|
||||||
|
step: 'cat05', severity: 'critical',
|
||||||
|
title: 'P0: Org B может PUT товара Org A',
|
||||||
|
detail: `status=${updByB.status}`,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const delByB = await apiB.delete(`/api/catalog/products/${ctx.productIdA}`)
|
||||||
|
check(step, {
|
||||||
|
kind: 'api',
|
||||||
|
description: 'Org B DELETE /products/{id-чужой} → 404',
|
||||||
|
ok: delByB.status === 404,
|
||||||
|
detail: `${delByB.status}`,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export async function cat06_fk_protection_groups({ ctx, step, report }: StepCtx) {
|
||||||
|
if (!ctx.orgA || !ctx.productIdA) { step.status = 'skip'; return }
|
||||||
|
const api = makeClient(ctx.orgA.token)
|
||||||
|
|
||||||
|
// Bootstrap-группа «Все товары» создана с IsSystem=true. Контроллер
|
||||||
|
// проверяет IsSystem ПЕРВОЙ — поэтому даже с привязанным товаром мы
|
||||||
|
// получим «Системную группу удалить нельзя» (а не сообщение про товары).
|
||||||
|
// Это OK как защита: системную в принципе нельзя удалить никогда.
|
||||||
|
const delRoot = await api.delete(`/api/catalog/product-groups/${ctx.orgA.productGroupRootId}`)
|
||||||
|
check(step, {
|
||||||
|
kind: 'api',
|
||||||
|
description: 'DELETE системной «Все товары» → 400 "Системную группу"',
|
||||||
|
ok: delRoot.status === 400 && /систем/i.test(asString(delRoot.data)),
|
||||||
|
detail: `${delRoot.status} ${asString(delRoot.data).slice(0, 200)}`,
|
||||||
|
})
|
||||||
|
|
||||||
|
// Проверка реального FK-чека на не-системной группе: создадим обычную
|
||||||
|
// группу, привяжем к ней нашего товара через PUT, попробуем удалить группу.
|
||||||
|
const grp = await api.post('/api/catalog/product-groups', {
|
||||||
|
name: `FK Test ${TS}`, parentId: null, sortOrder: 0, markupPercent: null,
|
||||||
|
})
|
||||||
|
if (grp.status !== 201) {
|
||||||
|
step.notes.push(`grp.create: ${grp.status} ${asString(grp.data).slice(0, 120)}`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const grpId = grp.data.id
|
||||||
|
|
||||||
|
// Привяжем product к этой группе через PUT
|
||||||
|
const updProd = await api.put(`/api/catalog/products/${ctx.productIdA}`, {
|
||||||
|
name: 'rebind', article: `A-${TS}`,
|
||||||
|
unitOfMeasureId: ctx.orgA.unitKgId, vat: 0, vatEnabled: false,
|
||||||
|
productGroupId: grpId,
|
||||||
|
barcodes: [{ code: '2099811100015', type: 1, isPrimary: true }],
|
||||||
|
prices: [{ priceTypeId: ctx.orgA.priceTypeRetailId, amount: 100, currencyId: ctx.orgA.currencyKztId }],
|
||||||
|
})
|
||||||
|
check(step, {
|
||||||
|
kind: 'api',
|
||||||
|
description: 'PUT product → перевешиваем в новую группу',
|
||||||
|
ok: updProd.status === 204,
|
||||||
|
detail: `${updProd.status}`,
|
||||||
|
})
|
||||||
|
|
||||||
|
const delGrp = await api.delete(`/api/catalog/product-groups/${grpId}`)
|
||||||
|
check(step, {
|
||||||
|
kind: 'api',
|
||||||
|
description: 'DELETE не-системной группы с привязанным товаром → 400 "товарами"',
|
||||||
|
ok: delGrp.status === 400 && /товар/i.test(asString(delGrp.data)),
|
||||||
|
detail: `${delGrp.status} ${asString(delGrp.data).slice(0, 200)}`,
|
||||||
|
})
|
||||||
|
|
||||||
|
// Cleanup: удалим product → теперь группа удалится.
|
||||||
|
const delProduct = await api.delete(`/api/catalog/products/${ctx.productIdA}`)
|
||||||
|
check(step, {
|
||||||
|
kind: 'api',
|
||||||
|
description: 'DELETE product → 204',
|
||||||
|
ok: delProduct.status === 204,
|
||||||
|
detail: `${delProduct.status}`,
|
||||||
|
})
|
||||||
|
|
||||||
|
const delGrpEmpty = await api.delete(`/api/catalog/product-groups/${grpId}`)
|
||||||
|
check(step, {
|
||||||
|
kind: 'api',
|
||||||
|
description: 'DELETE пустой группы (после удаления товара) → 204',
|
||||||
|
ok: delGrpEmpty.status === 204,
|
||||||
|
detail: `${delGrpEmpty.status}`,
|
||||||
|
})
|
||||||
|
}
|
||||||
24
tests/e2e/scenarios/stage-catalog.yml
Normal file
24
tests/e2e/scenarios/stage-catalog.yml
Normal file
|
|
@ -0,0 +1,24 @@
|
||||||
|
name: stage-catalog
|
||||||
|
description: |
|
||||||
|
Каталог CRUD на test.admin.food-market.kz: товары, группы (включая
|
||||||
|
дочерние), контрагенты. Дубликаты. FK-защита. Multi-tenant
|
||||||
|
изоляция через 2 параллельные org (создаём вторую через signup,
|
||||||
|
обе видят только свои сущности).
|
||||||
|
|
||||||
|
preconditions:
|
||||||
|
reset_db: false
|
||||||
|
smoke_login_super_admin: false
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- id: cat01_setup_two_orgs
|
||||||
|
title: Создаём 2 свежие org через signup → 2 admin токена для multi-tenant проверок
|
||||||
|
- id: cat02_product_group_crud
|
||||||
|
title: Группы — создать корневую и дочернюю, обновить, удалить пустую, отказ на удалении с подгруппами
|
||||||
|
- id: cat03_product_crud
|
||||||
|
title: Товар — создать, обновить, прочитать, удалить; дубликат штрихкода → 400; дубликат артикула → 400
|
||||||
|
- id: cat04_counterparty_crud
|
||||||
|
title: Контрагент — создать (юрлицо + физлицо), валидация телефона, FK-защита при удалении использованного
|
||||||
|
- id: cat05_multi_tenant_isolation
|
||||||
|
title: Org A не видит товары/группы/контрагентов Org B и наоборот; GET by id чужой сущности → 404
|
||||||
|
- id: cat06_fk_protection_groups
|
||||||
|
title: Удаление группы с товаром → 400, удаление системной «Все товары» → 400
|
||||||
Loading…
Reference in a new issue