using System.Net; using System.Net.Http.Json; using System.Text; using System.Text.Json; using FluentAssertions; using foodmarket.IntegrationTests.Support; using Xunit; namespace foodmarket.IntegrationTests; /// Sprint 24: интеграция-покрытие фич Sprint 18-23, которых не было /// в коде-base'е до этого. Цель — поднять coverage по новым контроллерам /// (BulkUpdate, UserPresets, OrgExport, ExternalAuth, MoySkladSync, /// audit-log/export, 1C-import) и зафиксировать защиту от регрессии багов /// Sprint 23 (bug-001 NUL byte → 400, bug-003 40001 → 409, bug-004 round /// → required check). /// /// Один файл с группой [Fact] чтобы получить компактный AAA-блок на каждую /// фичу. Использует существующий ApiActor + ApiFactory (Testcontainers /// Postgres). Каждый Fact создаёт изолированную org (slug = test-name + /// random) — нет cross-test leakage. [Collection(ApiCollection.Name)] public class Sprint18To23FeaturesTests { private readonly ApiFactory _factory; public Sprint18To23FeaturesTests(ApiFactory factory) => _factory = factory; // ── Sprint 19: bulk-update ──────────────────────────────────────────── [Fact] public async Task BulkUpdate_archive_marks_products_archived() { var a = new ApiActor(_factory.CreateClient()); await a.SignupAndLoginAsync($"bulk-arch-{Guid.NewGuid():N}"); var refs = await a.LoadRefsAsync(); var p1 = await a.CreateProductAsync(refs, $"P1-{Guid.NewGuid():N}", 100m, $"BC1-{Guid.NewGuid():N}"); var p2 = await a.CreateProductAsync(refs, $"P2-{Guid.NewGuid():N}", 200m, $"BC2-{Guid.NewGuid():N}"); using var resp = await a.Http.PostAsJsonAsync("/api/catalog/products/bulk-update", new { ids = new[] { p1, p2 }, op = "archive", @params = new { }, }); resp.StatusCode.Should().Be(HttpStatusCode.OK); var body = await resp.Content.ReadFromJsonAsync(); body.GetProperty("affected").GetInt32().Should().Be(2); // Оба товара должны иметь IsArchived = true. var read1 = await a.GetJsonAsync($"/api/catalog/products/{p1}"); read1.GetProperty("isArchived").GetBoolean().Should().BeTrue(); } [Fact] public async Task BulkUpdate_cross_tenant_returns_affected_zero() { var a = new ApiActor(_factory.CreateClient()); var b = new ApiActor(_factory.CreateClient()); await a.SignupAndLoginAsync($"bulk-iso-a-{Guid.NewGuid():N}"); await b.SignupAndLoginAsync($"bulk-iso-b-{Guid.NewGuid():N}"); var refs = await a.LoadRefsAsync(); var pA = await a.CreateProductAsync(refs, "A-prod", 100m, $"BCA-{Guid.NewGuid():N}"); using var resp = await b.Http.PostAsJsonAsync("/api/catalog/products/bulk-update", new { ids = new[] { pA }, op = "archive", @params = new { }, }); resp.StatusCode.Should().Be(HttpStatusCode.OK); var body = await resp.Content.ReadFromJsonAsync(); body.GetProperty("affected").GetInt32().Should().Be(0, "tenant isolation должна отбрасывать чужие id"); // У A товар не архивирован var read = await a.GetJsonAsync($"/api/catalog/products/{pA}"); read.GetProperty("isArchived").GetBoolean().Should().BeFalse(); } // ── Sprint 19: UserPresets ──────────────────────────────────────────── [Fact] public async Task UserPresets_per_user_in_org_isolated() { var a = new ApiActor(_factory.CreateClient()); await a.SignupAndLoginAsync($"preset-{Guid.NewGuid():N}"); using var create = await a.Http.PostAsJsonAsync("/api/user/presets", new { pageKey = "products", name = "Test", configJson = "{\"foo\":1}", }); create.StatusCode.Should().Be(HttpStatusCode.OK); var id = (await create.Content.ReadFromJsonAsync()).GetProperty("id").GetString()!; var list = await a.ListAsync("/api/user/presets?pageKey=products"); list.Should().ContainSingle(p => p.GetProperty("id").GetString() == id); using var del = await a.Http.DeleteAsync($"/api/user/presets/{id}"); del.StatusCode.Should().Be(HttpStatusCode.NoContent); } // ── Sprint 19: inline-edit price ───────────────────────────────────── [Fact] public async Task PatchPrice_updates_amount_with_rounding() { var a = new ApiActor(_factory.CreateClient()); await a.SignupAndLoginAsync($"price-{Guid.NewGuid():N}"); var refs = await a.LoadRefsAsync(); var pid = await a.CreateProductAsync(refs, "X", 100m, $"PB-{Guid.NewGuid():N}"); using var patch = await a.Http.PatchAsync($"/api/catalog/products/{pid}/price", JsonContent.Create(new { priceTypeId = refs.PriceTypeId, amount = 555m })); patch.StatusCode.Should().Be(HttpStatusCode.OK); var read = await a.GetJsonAsync($"/api/catalog/products/{pid}"); var amount = read.GetProperty("prices").EnumerateArray() .First(p => p.GetProperty("priceTypeId").GetString() == refs.PriceTypeId) .GetProperty("amount").GetDecimal(); amount.Should().Be(555m); } // ── Sprint 19: CSV import — transactional ──────────────────────────── [Fact] public async Task ImportCsv_two_rows_creates_two_products() { var a = new ApiActor(_factory.CreateClient()); await a.SignupAndLoginAsync($"csv-{Guid.NewGuid():N}"); var ts = Guid.NewGuid().ToString("N"); using var resp = await a.Http.PostAsJsonAsync("/api/catalog/products/import-csv", new { rows = new[] { new { name = $"CSV-A-{ts}", price = 100m, unitCode = "шт", groupName = $"CSV-grp-{ts}", barcode = $"CSV1-{ts}" }, new { name = $"CSV-B-{ts}", price = 200m, unitCode = "шт", groupName = $"CSV-grp-{ts}", barcode = $"CSV2-{ts}" }, }, autoCreateGroup = true, }); resp.StatusCode.Should().Be(HttpStatusCode.OK); var body = await resp.Content.ReadFromJsonAsync(); body.GetProperty("created").GetInt32().Should().Be(2); } // ── Sprint 22: GDPR org export ──────────────────────────────────────── [Fact] public async Task OrgExport_creates_pending_and_lists_self_only() { var a = new ApiActor(_factory.CreateClient()); await a.SignupAndLoginAsync($"export-{Guid.NewGuid():N}"); using var resp = await a.Http.PostAsync("/api/org/export", null); resp.StatusCode.Should().Be(HttpStatusCode.Accepted); var body = await resp.Content.ReadFromJsonAsync(); var id = body.GetProperty("id").GetString(); id.Should().NotBeNullOrEmpty(); // List видит как минимум этот один. var list = await a.ListAsync("/api/org/export"); list.Should().Contain(e => e.GetProperty("id").GetString() == id); } // ── Sprint 22: 1C CSV import (auto-detect charset) ─────────────────── [Fact] public async Task Import1cCsv_with_russian_headers_creates_product() { var a = new ApiActor(_factory.CreateClient()); await a.SignupAndLoginAsync($"1c-{Guid.NewGuid():N}"); var ts = Guid.NewGuid().ToString("N"); var csv = "\"Артикул\";\"Наименование\";\"Единица\";\"Цена\";\"Группа\";\"Штрихкод\"\n" + $"\"ART-{ts}\";\"Молоко-{ts}\";\"шт\";\"450\";\"1С-grp-{ts}\";\"1C-{ts}\""; var content = new ByteArrayContent(Encoding.UTF8.GetBytes(csv)); content.Headers.Add("Content-Type", "text/csv; charset=utf-8"); using var resp = await a.Http.PostAsync("/api/catalog/products/import/1c-csv?autoCreateGroup=true", content); resp.StatusCode.Should().Be(HttpStatusCode.OK); var body = await resp.Content.ReadFromJsonAsync(); body.GetProperty("created").GetInt32().Should().Be(1); } // ── Sprint 22: audit-log streaming export ──────────────────────────── [Fact] public async Task AuditLogExport_csv_streams_with_bom() { var a = new ApiActor(_factory.CreateClient()); await a.SignupAndLoginAsync($"audit-{Guid.NewGuid():N}"); // Create something audit-able. await a.CreateCounterpartyAsync($"audit-cp-{Guid.NewGuid():N}"); using var resp = await a.Http.PostAsync("/api/admin/audit-log/export?format=csv", null); resp.StatusCode.Should().Be(HttpStatusCode.OK); resp.Content.Headers.ContentType?.MediaType.Should().Be("text/csv"); var bytes = await resp.Content.ReadAsByteArrayAsync(); bytes.Length.Should().BeGreaterThan(3); // UTF-8 BOM bytes[0].Should().Be(0xEF); bytes[1].Should().Be(0xBB); bytes[2].Should().Be(0xBF); } // ── Sprint 22: MoySklad sync-status stub ───────────────────────────── [Fact] public async Task MoySkladSyncStatus_returns_configured_false_when_no_token() { var a = new ApiActor(_factory.CreateClient()); await a.SignupAndLoginAsync($"ms-{Guid.NewGuid():N}"); var body = await a.GetJsonAsync("/api/moysklad/sync-status"); body.GetProperty("configured").GetBoolean().Should().BeFalse(); body.GetProperty("pendingCount").GetInt32().Should().Be(0); } // ── Sprint 20: SSO external auth ────────────────────────────────────── [Fact] public async Task SsoProviders_returns_both_false_when_unconfigured() { var a = new ApiActor(_factory.CreateClient()); await a.SignupAndLoginAsync($"sso-{Guid.NewGuid():N}"); var body = await a.GetJsonAsync("/api/auth/external/providers"); body.GetProperty("google").GetBoolean().Should().BeFalse(); body.GetProperty("microsoft").GetBoolean().Should().BeFalse(); } [Fact] public async Task SsoChallenge_unconfigured_returns_503_with_hint() { var a = new ApiActor(_factory.CreateClient()); await a.SignupAndLoginAsync($"sso2-{Guid.NewGuid():N}"); using var resp = await a.Http.GetAsync("/api/auth/external/google"); resp.StatusCode.Should().Be(HttpStatusCode.ServiceUnavailable); var body = await resp.Content.ReadFromJsonAsync(); body.GetProperty("error").GetString().Should().Contain("Google"); } [Fact] public async Task SsoChallenge_unknown_provider_returns_400() { var a = new ApiActor(_factory.CreateClient()); await a.SignupAndLoginAsync($"sso3-{Guid.NewGuid():N}"); using var resp = await a.Http.GetAsync("/api/auth/external/whatever"); resp.StatusCode.Should().Be(HttpStatusCode.BadRequest); } // ── Sprint 23: bug-001 NUL byte → 400 ────────────────────────────── [Fact] public async Task ProductCreate_with_null_byte_in_name_returns_400() { var a = new ApiActor(_factory.CreateClient()); await a.SignupAndLoginAsync($"nul-{Guid.NewGuid():N}"); var refs = await a.LoadRefsAsync(); using var resp = await a.Http.PostAsJsonAsync("/api/catalog/products", new { name = "HelloWorld", unitOfMeasureId = refs.UnitId, productGroupId = refs.GroupId, vat = 0, vatEnabled = true, barcodes = new[] { new { code = $"NUL-{Guid.NewGuid():N}", type = 0, isPrimary = true } }, prices = new[] { new { priceTypeId = refs.PriceTypeId, amount = 100m, currencyId = refs.CurrencyId } }, }); resp.StatusCode.Should().Be(HttpStatusCode.BadRequest); } // ── Sprint 23: bug-004 tiny price round-then-validate ─────────────── [Fact] public async Task ProductCreate_with_tiny_price_returns_400_after_rounding() { var a = new ApiActor(_factory.CreateClient()); await a.SignupAndLoginAsync($"tiny-{Guid.NewGuid():N}"); var refs = await a.LoadRefsAsync(); using var resp = await a.Http.PostAsJsonAsync("/api/catalog/products", new { name = $"tiny-{Guid.NewGuid():N}", unitOfMeasureId = refs.UnitId, productGroupId = refs.GroupId, vat = 0, vatEnabled = true, barcodes = new[] { new { code = $"TINY-{Guid.NewGuid():N}", type = 0, isPrimary = true } }, // 0.0000001 — округлится в 0; required price > 0 теперь проверяется ПОСЛЕ rounding. prices = new[] { new { priceTypeId = refs.PriceTypeId, amount = 0.0000001m, currencyId = refs.CurrencyId } }, }); resp.StatusCode.Should().Be(HttpStatusCode.BadRequest); var body = await resp.Content.ReadFromJsonAsync(); body.GetProperty("error").GetString().Should().Contain("больше 0"); } // ── Sprint 19: export CSV ────────────────────────────────────────── [Fact] public async Task ProductsExport_csv_returns_text_csv_with_bom() { var a = new ApiActor(_factory.CreateClient()); await a.SignupAndLoginAsync($"exp-csv-{Guid.NewGuid():N}"); var refs = await a.LoadRefsAsync(); await a.CreateProductAsync(refs, "ExpProd", 100m, $"EXP-{Guid.NewGuid():N}"); using var resp = await a.Http.GetAsync("/api/catalog/products/export?format=csv"); resp.StatusCode.Should().Be(HttpStatusCode.OK); resp.Content.Headers.ContentType?.MediaType.Should().Be("text/csv"); var bytes = await resp.Content.ReadAsByteArrayAsync(); bytes.Length.Should().BeGreaterThan(100); // UTF-8 BOM bytes.Take(3).Should().Equal(new byte[] { 0xEF, 0xBB, 0xBF }); } }