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