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 = "Hello World",
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 });
}
}