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>
108 lines
4.8 KiB
C#
108 lines
4.8 KiB
C#
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);
|
||
}
|
||
}
|