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
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>
310 lines
14 KiB
C#
310 lines
14 KiB
C#
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 = "Hello |