From d54e1cb96883d1fe46bd5974f420520e6cdf9f90 Mon Sep 17 00:00:00 2001 From: nns Date: Fri, 29 May 2026 16:46:10 +0500 Subject: [PATCH] =?UTF-8?q?fix(catalog):=20EF8=20nav-collection=20bug=20?= =?UTF-8?q?=D0=B2=20Products.Update=20+=20unique=20IX=20=D0=BD=D0=B0=20Art?= =?UTF-8?q?icle?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../Controllers/Catalog/ProductsController.cs | 54 +- .../Configurations/CatalogConfigurations.cs | 7 +- ...0529100000_Phase8d_ProductArticleUnique.cs | 61 ++ .../stage-catalog-2026-05-29T11-45-48-560Z.md | 91 +++ tests/e2e/scenarios/stage-catalog.steps.ts | 525 ++++++++++++++++++ tests/e2e/scenarios/stage-catalog.yml | 24 + 6 files changed, 722 insertions(+), 40 deletions(-) create mode 100644 src/food-market.infrastructure/Persistence/Migrations/20260529100000_Phase8d_ProductArticleUnique.cs create mode 100644 tests/e2e/reports/stage-catalog-2026-05-29T11-45-48-560Z.md create mode 100644 tests/e2e/scenarios/stage-catalog.steps.ts create mode 100644 tests/e2e/scenarios/stage-catalog.yml diff --git a/src/food-market.api/Controllers/Catalog/ProductsController.cs b/src/food-market.api/Controllers/Catalog/ProductsController.cs index c7a02f3..0cd4449 100644 --- a/src/food-market.api/Controllers/Catalog/ProductsController.cs +++ b/src/food-market.api/Controllers/Catalog/ProductsController.cs @@ -263,47 +263,23 @@ public async Task Update(Guid id, [FromBody] ProductInput input, Apply(e, input); e.ReferencePrice = RoundIfNeeded(e.ReferencePrice, allowFractional); - // Merge barcodes по Code: одинаковые — обновляем поля, лишние удаляем, - // новые добавляем. Это позволяет избежать массового DELETE+INSERT, на - // котором EF может выдать DbUpdateConcurrencyException, если какой-то - // child был удалён параллельно из БД. - var inputBarcodes = (input.Barcodes ?? []).ToList(); - var byCode = e.Barcodes.ToDictionary(b => b.Code, b => b); - var inputCodes = inputBarcodes.Select(b => b.Code).ToHashSet(); - foreach (var existing in e.Barcodes.ToList()) - if (!inputCodes.Contains(existing.Code)) _db.ProductBarcodes.Remove(existing); - foreach (var b in inputBarcodes) - { - if (byCode.TryGetValue(b.Code, out var ex)) + // Children-collections правим через ExecuteDelete (минует трекер) + + // DbSet.Add — иначе EF8 на nav-collection путается и UPDATE на родителя + // падает с DbUpdateConcurrencyException «0 rows affected». Тот же паттерн + // что в Supplies/Demands/RetailSales.Update — см. их комментарии. + await _db.ProductBarcodes.Where(b => b.ProductId == e.Id).ExecuteDeleteAsync(ct); + await _db.ProductPrices.Where(p => p.ProductId == e.Id).ExecuteDeleteAsync(ct); + foreach (var b in input.Barcodes ?? []) + _db.ProductBarcodes.Add(new ProductBarcode { - ex.Type = b.Type; - ex.IsPrimary = b.IsPrimary; - } - else + ProductId = e.Id, Code = b.Code, Type = b.Type, IsPrimary = b.IsPrimary, + }); + foreach (var pr in input.Prices ?? []) + _db.ProductPrices.Add(new ProductPrice { - e.Barcodes.Add(new ProductBarcode { Code = b.Code, Type = b.Type, IsPrimary = b.IsPrimary }); - } - } - - // 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 }); - } - } + ProductId = e.Id, PriceTypeId = pr.PriceTypeId, + Amount = RoundIfNeeded(pr.Amount, allowFractional), CurrencyId = pr.CurrencyId, + }); try { diff --git a/src/food-market.infrastructure/Persistence/Configurations/CatalogConfigurations.cs b/src/food-market.infrastructure/Persistence/Configurations/CatalogConfigurations.cs index 64ba2ef..962cf6a 100644 --- a/src/food-market.infrastructure/Persistence/Configurations/CatalogConfigurations.cs +++ b/src/food-market.infrastructure/Persistence/Configurations/CatalogConfigurations.cs @@ -144,7 +144,12 @@ private static void ConfigureProduct(EntityTypeBuilder b) 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.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 }); } diff --git a/src/food-market.infrastructure/Persistence/Migrations/20260529100000_Phase8d_ProductArticleUnique.cs b/src/food-market.infrastructure/Persistence/Migrations/20260529100000_Phase8d_ProductArticleUnique.cs new file mode 100644 index 0000000..6c78e9e --- /dev/null +++ b/src/food-market.infrastructure/Persistence/Migrations/20260529100000_Phase8d_ProductArticleUnique.cs @@ -0,0 +1,61 @@ +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using foodmarket.Infrastructure.Persistence; + +#nullable disable + +namespace foodmarket.Infrastructure.Persistence.Migrations +{ + /// 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). + [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""); + "); + } + } +} diff --git a/tests/e2e/reports/stage-catalog-2026-05-29T11-45-48-560Z.md b/tests/e2e/reports/stage-catalog-2026-05-29T11-45-48-560Z.md new file mode 100644 index 0000000..5a9f8d1 --- /dev/null +++ b/tests/e2e/reports/stage-catalog-2026-05-29T11-45-48-560Z.md @@ -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 + +Нет. diff --git a/tests/e2e/scenarios/stage-catalog.steps.ts b/tests/e2e/scenarios/stage-catalog.steps.ts new file mode 100644 index 0000000..0ce0a15 --- /dev/null +++ b/tests/e2e/scenarios/stage-catalog.steps.ts @@ -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 { + 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 { + 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}`, + }) +} diff --git a/tests/e2e/scenarios/stage-catalog.yml b/tests/e2e/scenarios/stage-catalog.yml new file mode 100644 index 0000000..1103a24 --- /dev/null +++ b/tests/e2e/scenarios/stage-catalog.yml @@ -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