food-market/tests/food-market.UnitTests/InvoiceMatcherTests.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

117 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 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();
}
}