using System.Net; using System.Net.Http.Json; using System.Text.Json; using FluentAssertions; using foodmarket.Domain.Catalog; using foodmarket.Domain.Purchases; using foodmarket.Infrastructure.Persistence; using foodmarket.IntegrationTests.Support; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; using Xunit; namespace foodmarket.IntegrationTests; /// Порт поведения sklad 06.10 (разделы 1 / 10 / 11 handoff). /// Проверяем: /// /// §1: SaveLines/Apply со всеми необязательными строковыми полями = null → 200 /// (неявный [Required] отключён, FluentValidation не ругается). /// §11: Apply без выбранного поставщика → черновик на служебном /// «Поставщик не указан», idempotent при повторных apply. /// §10: GET /{id} applied-накладной отдаёт processedAt и supplyNumber. /// §2 (owner-policy): после apply физические файлы фото-приёмки /// удаляются (StoredPath пустая/не существует). /// /// [Collection(ApiCollection.Name)] public class InvoiceApplySklad0610Tests { private readonly ApiFactory _factory; public InvoiceApplySklad0610Tests(ApiFactory factory) => _factory = factory; private static string RandomBarcode() => $"4870{Random.Shared.Next(100000000, 999999999)}"; /// §1: клиент шлёт SaveLines, у строк все необязательные /// текстовые поля null (barcode/unit/supplierCode/note/barcodeSource/name/productName). /// До фикса ASP.NET MVC возвращал 400 «The Name field is required.». [Fact] public async Task SaveLines_with_all_optional_fields_null_returns_204() { var http = _factory.CreateClient(); var actor = new ApiActor(http); await actor.SignupAndLoginAsync($"inv06-null-{Guid.NewGuid():N}".Substring(0, 20)); var upId = await UploadMinimalFileAsync(http); var resp = await http.PutAsJsonAsync($"/api/purchases/invoices/{upId}/lines", new { lines = new object[] { new { barcode = (string?)null, supplierCode = (string?)null, name = (string?)null, quantity = 1, unit = (string?)null, price = 100m, sum = (decimal?)null, vat = (decimal?)null, productId = (Guid?)null, productName = (string?)null, matchedBy = (string?)null, matchScore = 0, photo = (int?)null, barcodeSource = (string?)null, note = (string?)null, }, }, supplierName = (string?)null, invoiceNumber = (string?)null, invoiceDate = (DateTime?)null, totalSum = (decimal?)null, }); var body = await resp.Content.ReadAsStringAsync(); resp.StatusCode.Should().Be(HttpStatusCode.NoContent, $"body: {body}"); } /// §11: apply без явного SupplierId использует служебного /// «Поставщик не указан». При повторном apply (другая накладная) того /// же сценария — используется ТОТ ЖЕ контрагент. [Fact] public async Task Apply_without_supplier_uses_placeholder_counterparty_and_is_idempotent() { var http = _factory.CreateClient(); var actor = new ApiActor(http); await actor.SignupAndLoginAsync($"inv06-ph-{Guid.NewGuid():N}".Substring(0, 20)); var refs = await actor.LoadRefsAsync(); // Создаём product и его штрихкод — чтобы в приёмке была реальная строка. var bc1 = RandomBarcode(); var p1 = await actor.CreateProductAsync(refs, $"ProdSklad-{Guid.NewGuid():N}", 150m, bc1); // Две независимые накладные → два apply на placeholder → один контрагент. var apply1 = await ApplyMinimalInvoiceAsync(http, actor, refs, p1, bc1, supplierId: null); var apply2 = await ApplyMinimalInvoiceAsync(http, actor, refs, p1, bc1, supplierId: null); apply1.supplyId.Should().NotBeEmpty(); apply2.supplyId.Should().NotBeEmpty(); // Проверяем, что обе Supply ссылаются на один и тот же (служебный) контрагент. using var scope = _factory.Services.CreateScope(); var db = scope.ServiceProvider.GetRequiredService(); var sup1 = await db.Supplies.IgnoreQueryFilters().FirstAsync(s => s.Id == apply1.supplyId); var sup2 = await db.Supplies.IgnoreQueryFilters().FirstAsync(s => s.Id == apply2.supplyId); sup1.SupplierId.Should().Be(sup2.SupplierId); var cp = await db.Counterparties.IgnoreQueryFilters().FirstAsync(c => c.Id == sup1.SupplierId); cp.IsPlaceholder.Should().BeTrue(); cp.Name.Should().Be("Поставщик не указан"); // И в списке контрагентов этот плейсхолдер — последним, c IsPlaceholder=true. var listJson = await actor.GetJsonAsync("/api/catalog/counterparties?pageSize=100"); var items = listJson.GetProperty("items").EnumerateArray().ToList(); items.Should().NotBeEmpty(); items[^1].GetProperty("isPlaceholder").GetBoolean().Should().BeTrue(); } /// §11: Delete и Update служебного контрагента — 409 Conflict. [Fact] public async Task Delete_and_update_of_placeholder_counterparty_returns_409() { var http = _factory.CreateClient(); var actor = new ApiActor(http); await actor.SignupAndLoginAsync($"inv06-pdel-{Guid.NewGuid():N}".Substring(0, 20)); var refs = await actor.LoadRefsAsync(); var bc = RandomBarcode(); var p = await actor.CreateProductAsync(refs, $"P-{Guid.NewGuid():N}", 50m, bc); await ApplyMinimalInvoiceAsync(http, actor, refs, p, bc, supplierId: null); using var scope = _factory.Services.CreateScope(); var db = scope.ServiceProvider.GetRequiredService(); var cp = await db.Counterparties.IgnoreQueryFilters().FirstAsync(c => c.IsPlaceholder); var delResp = await http.DeleteAsync($"/api/catalog/counterparties/{cp.Id}"); delResp.StatusCode.Should().Be(HttpStatusCode.Conflict); var updResp = await http.PutAsJsonAsync($"/api/catalog/counterparties/{cp.Id}", new { name = "ПопыткаПереименовать", legalName = (string?)null, type = CounterpartyType.LegalEntity, bin = (string?)null, iin = (string?)null, taxNumber = (string?)null, countryId = (Guid?)null, address = (string?)null, phone = (string?)null, email = (string?)null, bankName = (string?)null, bankAccount = (string?)null, bik = (string?)null, contactPerson = (string?)null, notes = (string?)null, }); updResp.StatusCode.Should().Be(HttpStatusCode.Conflict); } /// §10 / owner-policy: после apply стороны-держатели файлы /// удаляются (StoredPath очищен или папка пустая), detail отдаёт /// processedAt + supplyNumber, status=applied. [Fact] public async Task Applied_invoice_detail_has_processed_at_and_files_cleaned_up() { var http = _factory.CreateClient(); var actor = new ApiActor(http); await actor.SignupAndLoginAsync($"inv06-pa-{Guid.NewGuid():N}".Substring(0, 20)); var refs = await actor.LoadRefsAsync(); var bc = RandomBarcode(); var p = await actor.CreateProductAsync(refs, $"P-{Guid.NewGuid():N}", 150m, bc); var apply = await ApplyMinimalInvoiceAsync(http, actor, refs, p, bc, supplierId: null); var detail = await actor.GetJsonAsync($"/api/purchases/invoices/{apply.uploadId}"); detail.GetProperty("status").GetString().Should().Be("applied"); detail.GetProperty("processedAt").GetDateTime().Should().BeCloseTo(DateTime.UtcNow, TimeSpan.FromSeconds(60)); detail.GetProperty("supplyId").GetGuid().Should().Be(apply.supplyId); detail.GetProperty("supplyNumber").GetString().Should().StartWith("П-"); // Файлы снесены: hasFiles=false (папка/файл не существует). detail.GetProperty("hasFiles").GetBoolean().Should().BeFalse(); using var scope = _factory.Services.CreateScope(); var db = scope.ServiceProvider.GetRequiredService(); var up = await db.InvoiceUploads.IgnoreQueryFilters().FirstAsync(x => x.Id == apply.uploadId); // StoredPath очищен или файл отсутствует — DeleteStored выполнил работу. var pathAlive = !string.IsNullOrEmpty(up.StoredPath) && (System.IO.File.Exists(up.StoredPath) || System.IO.Directory.Exists(up.StoredPath)); pathAlive.Should().BeFalse("после apply физические файлы фото-приёмки удаляются (owner-policy)"); } /// §5: ApplyDto с Guid.Empty StoreId → ValidationProblemDetails в /// нашем формате (errors.StoreId[0] = «Не указан склад.»), humanizeError /// покажет это пользователю через errors[*][0]. [Fact] public async Task Apply_with_empty_store_returns_validation_problem_details() { var http = _factory.CreateClient(); var actor = new ApiActor(http); await actor.SignupAndLoginAsync($"inv06-val-{Guid.NewGuid():N}".Substring(0, 20)); var upId = await UploadMinimalFileAsync(http); // Чтобы apply добрался до валидации StoreId — у нас уже должен быть ParsedJson // непустой. Подложим одну валидную-пустую строку (sum=0) через SaveLines. var save = await http.PutAsJsonAsync($"/api/purchases/invoices/{upId}/lines", new { lines = new object[] { new { quantity = 0m, price = 0m, matchScore = 0 }, }, supplierName = (string?)null, invoiceNumber = (string?)null, invoiceDate = (DateTime?)null, totalSum = (decimal?)null, }); save.EnsureSuccessStatusCode(); var resp = await http.PostAsJsonAsync($"/api/purchases/invoices/{upId}/apply", new { supplierId = (Guid?)null, storeId = Guid.Empty, currencyId = Guid.Empty, notes = (string?)null, }); resp.StatusCode.Should().Be(HttpStatusCode.BadRequest); var json = await resp.Content.ReadFromJsonAsync(); json.GetProperty("errors").GetProperty("StoreId").EnumerateArray() .Select(e => e.GetString()).Should().Contain(s => s!.Contains("склад")); } // ── helpers ────────────────────────────────────────────────────────── private static async Task UploadMinimalFileAsync(HttpClient http) { using var form = new System.Net.Http.MultipartFormDataContent(); var fp = new System.Net.Http.ByteArrayContent(System.Text.Encoding.UTF8.GetBytes("x")); fp.Headers.ContentType = new System.Net.Http.Headers.MediaTypeHeaderValue("image/jpeg"); form.Add(fp, "files", "x.jpg"); var resp = await http.PostAsync("/api/purchases/invoices/upload", form); resp.EnsureSuccessStatusCode(); var body = await resp.Content.ReadFromJsonAsync(); return body.GetProperty("ids").EnumerateArray().First().GetGuid(); } private static async Task<(Guid uploadId, Guid supplyId)> ApplyMinimalInvoiceAsync( HttpClient http, ApiActor actor, ApiActor.Refs refs, string productId, string barcode, Guid? supplierId) { var upId = await UploadMinimalFileAsync(http); // Подкладываем одну распознанную строку с продуктом — минимум, чтобы Apply не отбил. var save = await http.PutAsJsonAsync($"/api/purchases/invoices/{upId}/lines", new { lines = new object[] { new { barcode, supplierCode = (string?)null, name = $"Строка-{Guid.NewGuid():N}", quantity = 2m, unit = (string?)null, price = 100m, sum = (decimal?)null, vat = (decimal?)null, productId, productName = "ignored", matchedBy = "barcode", matchScore = 1.0, }, }, supplierName = "ТОО Из Накладной", invoiceNumber = $"ИН-{Random.Shared.Next(1000, 9999)}", invoiceDate = DateTime.UtcNow.Date, totalSum = 200m, }); save.EnsureSuccessStatusCode(); var resp = await http.PostAsJsonAsync($"/api/purchases/invoices/{upId}/apply", new { supplierId, storeId = Guid.Parse(refs.StoreId), currencyId = Guid.Parse(refs.CurrencyId), notes = (string?)null, }); var body = await resp.Content.ReadAsStringAsync(); resp.StatusCode.Should().Be(HttpStatusCode.OK, $"apply body: {body}"); var json = JsonDocument.Parse(body).RootElement; return (upId, json.GetProperty("supplyId").GetGuid()); } }