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>
This commit is contained in:
nns 2026-10-04 21:46:10 +05:00
parent f42329281c
commit 457d8f5877
5 changed files with 305 additions and 3 deletions

View file

@ -42,7 +42,7 @@ Storekeeper.
### Общее
- [ ] 7. Мобильная вёрстка 375×667 + PWA. Тесты: unit (матчинг,
- [x] 7. Мобильная вёрстка 375×667 + PWA. Тесты: unit (матчинг,
запоминание маппингов, bookQty, расхождения, частичная инв.),
integration (накладная → Supply Draft; частичная инв. не трогает
другие товары; bookQty = текущий Stock), e2e на стенде.

View file

@ -146,7 +146,7 @@ public async Task<ParsedInvoice> ExtractAsync(string filePath, CancellationToken
/// <summary><c>--output-format json</c> отдаёт конверт Claude Code; нужный JSON
/// лежит в поле <c>result</c> строкой, иногда обёрнутой в <c>```json</c>.</summary>
internal static ParsedInvoice Parse(string cliOutput)
public static ParsedInvoice Parse(string cliOutput)
{
string payload = cliOutput.Trim();
int inTok = 0, outTok = 0;
@ -209,7 +209,7 @@ private static string StripFence(string s)
return string.IsNullOrWhiteSpace(s) ? null : s.Trim();
}
internal static ParsedInvoice FromJson(string json)
public static ParsedInvoice FromJson(string json)
{
using var doc = JsonDocument.Parse(json);
var r = doc.RootElement;

View file

@ -0,0 +1,107 @@
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);
}
}

View file

@ -0,0 +1,79 @@
using FluentAssertions;
using foodmarket.Api.Infrastructure.Invoices;
using Xunit;
namespace foodmarket.UnitTests;
/// <summary>Проверка парсинга ответа Claude CLI. CLI отдаёт конверт
/// <c>{ result: "…json…", usage: {...} }</c>; result иногда обёрнут в
/// <c>```json</c>. Нужно вытащить payload и привести к ParsedInvoice.</summary>
public class ClaudeCliInvoiceExtractorParseTests
{
[Fact]
public void Parse_plain_json_payload()
{
var json = """{"supplier":"ТОО Фуд","number":"Н-1","date":"2026-10-04","total":1500.00,"lines":[{"barcode":"4870123456789","supplier_code":"ABC","name":"Молоко 3.2%","qty":10,"unit":"шт","price":150.00,"sum":1500.00,"vat":12}]}""";
var inv = ClaudeCliInvoiceExtractor.Parse(json);
inv.SupplierName.Should().Be("ТОО Фуд");
inv.InvoiceNumber.Should().Be("Н-1");
inv.TotalSum.Should().Be(1500m);
inv.Lines.Should().HaveCount(1);
var l = inv.Lines[0];
l.Barcode.Should().Be("4870123456789");
l.SupplierCode.Should().Be("ABC");
l.Quantity.Should().Be(10m);
l.Price.Should().Be(150m);
}
[Fact]
public void Parse_envelope_with_result_string_and_markdown_fence()
{
// Payload wrapped в ```json ``` — Claude иногда так возвращает.
var fence = "`" + "`" + "`";
var payloadInside = fence + "json\n{\"supplier\":\"X\",\"lines\":[{\"name\":\"А\",\"qty\":1,\"price\":10}]}\n" + fence;
var envelope = System.Text.Json.JsonSerializer.Serialize(new
{
result = payloadInside,
usage = new { input_tokens = 123, output_tokens = 45 }
});
var inv = ClaudeCliInvoiceExtractor.Parse(envelope);
inv.SupplierName.Should().Be("X");
inv.Lines.Should().ContainSingle();
inv.InputTokens.Should().Be(123);
inv.OutputTokens.Should().Be(45);
}
[Fact]
public void Parse_barcode_with_spaces_is_stripped()
{
var json = """{"lines":[{"name":"X","qty":1,"price":1,"barcode":"4870 123 456 789"}]}""";
var inv = ClaudeCliInvoiceExtractor.Parse(json);
inv.Lines[0].Barcode.Should().Be("4870123456789");
}
[Fact]
public void Parse_null_fields_tolerated()
{
var json = """{"supplier":null,"number":null,"date":null,"total":null,"lines":[{"name":"X","qty":"2,5","price":"100.5"}]}""";
var inv = ClaudeCliInvoiceExtractor.Parse(json);
inv.SupplierName.Should().BeNull();
inv.TotalSum.Should().BeNull();
inv.Lines[0].Quantity.Should().Be(2.5m); // «2,5» → 2.5
inv.Lines[0].Price.Should().Be(100.5m);
}
[Fact]
public void Parse_empty_raises()
{
var act = () => ClaudeCliInvoiceExtractor.Parse("");
act.Should().Throw<InvoiceExtractionException>();
}
[Fact]
public void Parse_lines_without_name_are_skipped()
{
var json = """{"lines":[{"name":"A","qty":1,"price":1},{"name":"","qty":2,"price":2},{"name":null,"qty":3,"price":3}]}""";
var inv = ClaudeCliInvoiceExtractor.Parse(json);
inv.Lines.Should().ContainSingle(l => l.Name == "A");
}
}

View file

@ -0,0 +1,116 @@
using FluentAssertions;
using foodmarket.Api.Infrastructure.Invoices;
using Xunit;
namespace foodmarket.UnitTests;
/// <summary>Sprint 31: порядок сопоставления строки накладной с товаром.
/// (1) штрихкод, (2) alias (запомненное сопоставление), (3) по названию
/// — НЕ автоматически (sklad-урок: 2-5 из 10 ошибок).</summary>
public class InvoiceMatcherTests
{
private static InvoiceMatcher.CatalogItem Prod(Guid id, string name, params string[] barcodes) =>
new() { Id = id, Name = name, Barcodes = barcodes.ToList() };
[Fact]
public void Match_by_barcode_wins()
{
var p1 = Guid.NewGuid(); var p2 = Guid.NewGuid();
var matcher = new InvoiceMatcher(new[] {
Prod(p1, "Молоко 3.2%", "4870123456789"),
Prod(p2, "Хлеб ржаной"),
});
var line = new InvoiceLine { Barcode = "4870 123 456 789", Name = "Молоко что-то совсем другое" };
matcher.Match(line, "ТОО Поставщик");
line.MatchedBy.Should().Be("barcode");
line.ProductId.Should().Be(p1);
}
[Fact]
public void Short_or_missing_barcode_falls_through()
{
var p1 = Guid.NewGuid();
var matcher = new InvoiceMatcher(new[] { Prod(p1, "Молоко", "4870123456789") });
var line = new InvoiceLine { Barcode = "12345", Name = "Молоко" };
matcher.Match(line, "ТОО Поставщик");
line.MatchedBy.Should().Be("none");
}
[Fact]
public void Alias_by_supplier_code_matches_on_second_invoice()
{
var p1 = Guid.NewGuid();
var matcher = new InvoiceMatcher(new[] { Prod(p1, "Молоко 3.2%") });
matcher.AddAlias("тоо поставщик", sourceCode: "ABC-123", sourceNameNorm: null, p1, "Молоко 3.2%");
var line = new InvoiceLine { SupplierCode = "ABC-123", Name = "Что угодно" };
matcher.Match(line, "тоо поставщик");
line.MatchedBy.Should().Be("alias");
line.ProductId.Should().Be(p1);
}
[Fact]
public void Alias_by_normalized_name_matches()
{
var p1 = Guid.NewGuid();
var matcher = new InvoiceMatcher(new[] { Prod(p1, "Хлеб ржаной 500 г") });
var nameNorm = InvoiceMatcher.NormalizeName("Хлеб ржаной 500 г");
matcher.AddAlias("хлебокомбинат астана", sourceCode: null, sourceNameNorm: nameNorm, p1, "Хлеб ржаной 500 г");
var line = new InvoiceLine { Name = "Хлеб ржаной 500 г" };
matcher.Match(line, "хлебокомбинат астана");
line.MatchedBy.Should().Be("alias");
}
[Fact]
public void Alias_is_supplier_scoped()
{
var p1 = Guid.NewGuid();
var matcher = new InvoiceMatcher(new[] { Prod(p1, "Молоко") });
matcher.AddAlias("supplier a", "CODE1", null, p1, "Молоко");
var line = new InvoiceLine { SupplierCode = "CODE1", Name = "Молоко" };
matcher.Match(line, "supplier b"); // другой поставщик
line.MatchedBy.Should().Be("none");
}
[Fact]
public void Does_not_match_by_name_automatically()
{
var p1 = Guid.NewGuid();
var matcher = new InvoiceMatcher(new[] { Prod(p1, "LD Super Slims") });
var line = new InvoiceLine { Name = "LD Violet Super Slims" };
matcher.Match(line, "any");
// Это sklad-урок: по названию с частичным совпадением matches приводят
// к подмене товара. Политика — НЕ матчить, подсветить как «none».
line.MatchedBy.Should().Be("none");
}
[Theory]
[InlineData("Хлеб ржаной «Бородинский» 500 г", "ХЛЕБ РЖАНОЙ БОРОДИНСКИЙ 500Г")]
[InlineData("молоко 3,2%", "МОЛОКО 3,2%")]
[InlineData("Йогурт ё-мобиль", "ЙОГУРТ Е МОБИЛЬ")]
public void NormalizeName_normalizes_whitespace_quotes_unit_glue_and_yo(string input, string expected)
{
InvoiceMatcher.NormalizeName(input).Should().Be(expected);
}
[Theory]
[InlineData("4870 123 456 789", "4870123456789")]
[InlineData("abc-123", "123")]
[InlineData("", "")]
[InlineData(null, "")]
public void OnlyDigits_strips(string? input, string expected)
{
InvoiceMatcher.OnlyDigits(input).Should().Be(expected);
}
[Fact]
public void AliasKeys_emits_only_for_non_empty_fields()
{
InvoiceMatcher.AliasKeys("sup", "C1", "N1").Should().HaveCount(2);
InvoiceMatcher.AliasKeys("sup", "C1", null).Should().HaveCount(1);
InvoiceMatcher.AliasKeys("sup", null, null).Should().BeEmpty();
}
}