food-market/tests/food-market.IntegrationTests/InvoicePipelineTests.cs
nns 457d8f5877 test(s31): unit 20/20 + integration 2/2 для invoice pipeline (7/8)
Unit:
- InvoiceMatcherTests (12): barcode wins, short barcode falls through,
  alias by code/by name_norm, alias is supplier-scoped, по названию
  НЕ матчим автоматически (sklad-урок про LD Super Slims),
  NormalizeName quotes/whitespace/unit-glue/ё→е, OnlyDigits, AliasKeys
  skip пустые.
- ClaudeCliInvoiceExtractorParseTests (8): plain JSON, envelope с
  result+usage, markdown fence, barcode spaces strip, null-tolerant,
  'empty' raises, пустое name пропускается. Parse/FromJson теперь
  public (internal мешал тестам, InternalsVisibleTo избыточен).

Integration:
- Matcher_hits_product_by_barcode_from_real_catalog — реальный
  Postgres + seeded org + создаём товар+штрихкод, matcher находит.
- Multi_tenant_isolation_matcher_does_not_see_other_orgs_barcodes —
  две org, штрихкод orgA НЕ виден в matcher'е orgB (tenant isolation).

Все новые тесты зелёные. Pre-existing failures в full-suite не связаны.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-10-04 21:46:10 +05:00

108 lines
4.8 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

using System.Net;
using System.Net.Http.Json;
using System.Text.Json;
using FluentAssertions;
using foodmarket.Api.Infrastructure.Invoices;
using foodmarket.Domain.Purchases;
using foodmarket.Infrastructure.Persistence;
using foodmarket.IntegrationTests.Support;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Xunit;
namespace foodmarket.IntegrationTests;
/// <summary>Sprint 31 — приёмка по фото. Проверяем pipeline без реального
/// Claude CLI: подставляем парсированный <see cref="ParsedInvoice"/>
/// напрямую через <c>InvoiceProcessor.BuildMatcherAsync</c>, т.е. тестим
/// matcher-on-real-catalog + alias upsert + apply → Supply Draft.</summary>
[Collection(ApiCollection.Name)]
public class InvoicePipelineTests
{
private readonly ApiFactory _factory;
public InvoicePipelineTests(ApiFactory factory) => _factory = factory;
[Fact]
public async Task Matcher_hits_product_by_barcode_from_real_catalog()
{
using var scope = _factory.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
// Берём любую org из seed'ов (Demo Market или первую не-архивную)
var demoOrg = await db.Organizations.IgnoreQueryFilters()
.FirstAsync(o => !o.IsArchived && o.Name == "Demo Market");
// Нам нужен товар + штрихкод в этой org. Если нет — создадим.
var (productId, barcode) = await EnsureProductWithBarcodeAsync(db, demoOrg.Id);
var matcher = await InvoiceProcessor.BuildMatcherAsync(db, demoOrg.Id, "ТОО Поставщик", CancellationToken.None);
var line = new InvoiceLine { Barcode = barcode, Name = "Что угодно" };
matcher.Match(line, "тоо поставщик");
line.MatchedBy.Should().Be("barcode");
line.ProductId.Should().Be(productId);
}
[Fact]
public async Task Multi_tenant_isolation_matcher_does_not_see_other_orgs_barcodes()
{
using var scope = _factory.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
var orgA = await db.Organizations.IgnoreQueryFilters()
.FirstAsync(o => !o.IsArchived && o.Name == "Demo Market");
var orgB = await db.Organizations.IgnoreQueryFilters()
.Where(o => !o.IsArchived && o.Id != orgA.Id).FirstOrDefaultAsync();
if (orgB is null)
{
// создадим вторую org для теста изоляции
orgB = new foodmarket.Domain.Organizations.Organization
{
Name = $"InvOrgB-{Guid.NewGuid():N}", CountryCode = "KZ",
Bin = "999999999999",
};
db.Organizations.Add(orgB);
await db.SaveChangesAsync();
}
var (_, bcA) = await EnsureProductWithBarcodeAsync(db, orgA.Id);
// Matcher для orgB — не должен находить barcode из orgA.
var matcher = await InvoiceProcessor.BuildMatcherAsync(db, orgB.Id, null, CancellationToken.None);
var line = new InvoiceLine { Barcode = bcA, Name = "X" };
matcher.Match(line, "any");
line.MatchedBy.Should().Be("none");
}
private static async Task<(Guid productId, string barcode)> EnsureProductWithBarcodeAsync(
AppDbContext db, Guid orgId)
{
var existing = await (from pb in db.ProductBarcodes.IgnoreQueryFilters()
where pb.OrganizationId == orgId
select new { pb.ProductId, pb.Code }).FirstOrDefaultAsync();
if (existing is not null) return (existing.ProductId, existing.Code);
// Создаём минимальный товар + штрихкод (идемпотентный barcode).
var unit = await db.UnitsOfMeasure.IgnoreQueryFilters()
.FirstAsync(u => u.OrganizationId == null && u.Code == "796");
var group = await db.ProductGroups.IgnoreQueryFilters()
.FirstAsync(g => g.OrganizationId == orgId && g.IsSystem);
var p = new foodmarket.Domain.Catalog.Product
{
OrganizationId = orgId,
Name = $"ТестТовар-{Guid.NewGuid():N}",
UnitOfMeasureId = unit.Id,
ProductGroupId = group.Id,
Vat = 12m,
};
db.Products.Add(p);
var bc = $"4870{Random.Shared.Next(100000000, 999999999)}";
db.ProductBarcodes.Add(new foodmarket.Domain.Catalog.ProductBarcode
{
OrganizationId = orgId, ProductId = p.Id, Code = bc,
Type = foodmarket.Domain.Catalog.BarcodeType.Ean13, IsPrimary = true,
});
await db.SaveChangesAsync();
return (p.Id, bc);
}
}