food-market/tests/food-market.IntegrationTests/Sprint18To23FeaturesTests.cs
nns 72d0a71307
Some checks are pending
Auto-tag / Create date-tag (push) Waiting to run
CI / Backend (.NET 8) (push) Waiting to run
CI / Web (React + Vite) (push) Waiting to run
CI / POS (WPF, Windows) (push) Waiting to run
Docker API / Build + push API (push) Waiting to run
Docker API / Deploy API on stage (push) Blocked by required conditions
docs(s24): docs cross-check + auto-gen + onboarding + test gap-fill (8/8 ✓)
1. Docs cross-check — обновил performance-baseline.md (Sprint 18/20/23
   фиксы), secrets.md (16 новых env-vars из Sprint 20+ — Authentication
   Google/Microsoft, Monitoring, Cleanup, Hangfire:Cron, Telegram,
   Maintenance, App, Storage, PUBLIC_GA_ID/YM_ID).

2. Auto-gen api-reference — ApiReferenceDocsJob (Hangfire weekly вс
   05:30 UTC) + Python-эквивалент `/tmp/gen-api-ref.py` для commit
   actual snapshot. docs/api-reference.md = 195 endpoints, 57 controllers.

3. Coverage gap-fill — Sprint18To23FeaturesTests.cs (16 Facts):
   - bulk-update + cross-tenant isolation
   - UserPresets CRUD
   - inline-edit price PATCH
   - CSV import 2 строки транзакцией
   - OrgExport create + list isolation
   - 1C-CSV import с русскими заголовками
   - audit-log export CSV streaming + BOM check
   - MoySklad sync-status stub
   - SSO providers + 503 unconfigured + 400 unknown provider
   - bug-001 NUL byte → 400
   - bug-004 tiny price → 400
   - export CSV BOM
   Покрывает все новые контроллеры Sprint 18-23 + regression-protect
   для критичных багов.

4. Contract tests — deploy/swagger-diff.sh: pull /swagger/v1/swagger.json
   с двух URL, diff endpoints+schemas через python3. Exit 0/1/2 для
   blue-green safety gate. Multi-path auto-detect.

5. docs/error-codes.md — каталог HTTP-кодов API (200-503) + humanizeError
   pattern для фронта + retry-policy таблица.

6. docs/glossary.md — 50+ доменных терминов (Tenant/Organization/Stock/
   StockMovement/RetailSale/Counterparty/Owner/Employee/Role/Permission/
   advisory lock/Serializable/…) с ссылками на code-сущности.

7. docs/ONBOARDING.md — first 3 days для нового разработчика
   (install → запуск → структура → первый PR + FAQ).

8. README.md — обновил под текущее состояние: React 19, Sprint-history
   1-24, ссылки на ключевые docs, корректный 5-min quick start.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-08 02:15:56 +05:00

310 lines
14 KiB
C#
Raw Permalink Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 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;
/// <summary>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.</summary>
[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<JsonElement>();
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<JsonElement>();
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<JsonElement>()).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<JsonElement>();
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<JsonElement>();
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<JsonElement>();
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<JsonElement>();
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<JsonElement>();
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 });
}
}