test(integration): Testcontainers.PostgreSql + WebApplicationFactory, 10 тестов (P1-21)

ApiFactory поднимает реальный API на одноразовом postgres:16-alpine (Ryuk off —
сеть к Docker Hub нестабильна, образ закэширован; RateLimiting off через env, т.к.
лимитер читает конфиг эагерно). Program сделан public partial для фабрики.

Сценарии (10 зелёных):
- signup-flow: signup→token→/api/me с org; дубль-signup 400; слабый пароль 400.
- tenant isolation A vs B: контрагент A не виден B (список + прямой GET 404).
- permission: кастомная роль без ProductsEdit → PUT товара 403, GET 200; админ не 403.
- supply post→unpost: остаток 0→10, Cost=70 (скользящее среднее), unpost→0; двойной post 409.
- retail overselling: продажа сверх остатка → 409; недоплата → 400.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
nns 2026-05-27 03:14:01 +05:00
parent e988a7dbbc
commit f2dad91e05
11 changed files with 564 additions and 0 deletions

View file

@ -23,6 +23,8 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{F29E3026
EndProject EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "food-market.UnitTests", "tests\food-market.UnitTests\food-market.UnitTests.csproj", "{D8556BE1-70E3-49AD-84FD-209C80B17B57}" Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "food-market.UnitTests", "tests\food-market.UnitTests\food-market.UnitTests.csproj", "{D8556BE1-70E3-49AD-84FD-209C80B17B57}"
EndProject EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "food-market.IntegrationTests", "tests\food-market.IntegrationTests\food-market.IntegrationTests.csproj", "{9122ECF4-9111-40B3-BB59-ED7C112FF575}"
EndProject
Global Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU Debug|Any CPU = Debug|Any CPU
@ -64,6 +66,10 @@ Global
{D8556BE1-70E3-49AD-84FD-209C80B17B57}.Debug|Any CPU.Build.0 = Debug|Any CPU {D8556BE1-70E3-49AD-84FD-209C80B17B57}.Debug|Any CPU.Build.0 = Debug|Any CPU
{D8556BE1-70E3-49AD-84FD-209C80B17B57}.Release|Any CPU.ActiveCfg = Release|Any CPU {D8556BE1-70E3-49AD-84FD-209C80B17B57}.Release|Any CPU.ActiveCfg = Release|Any CPU
{D8556BE1-70E3-49AD-84FD-209C80B17B57}.Release|Any CPU.Build.0 = Release|Any CPU {D8556BE1-70E3-49AD-84FD-209C80B17B57}.Release|Any CPU.Build.0 = Release|Any CPU
{9122ECF4-9111-40B3-BB59-ED7C112FF575}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{9122ECF4-9111-40B3-BB59-ED7C112FF575}.Debug|Any CPU.Build.0 = Debug|Any CPU
{9122ECF4-9111-40B3-BB59-ED7C112FF575}.Release|Any CPU.ActiveCfg = Release|Any CPU
{9122ECF4-9111-40B3-BB59-ED7C112FF575}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection EndGlobalSection
GlobalSection(NestedProjects) = preSolution GlobalSection(NestedProjects) = preSolution
{BB7142C2-94F3-423F-938C-A44FF79133C0} = {8EAE9F35-BE6B-4B77-A1F4-383EF17D9870} {BB7142C2-94F3-423F-938C-A44FF79133C0} = {8EAE9F35-BE6B-4B77-A1F4-383EF17D9870}
@ -74,5 +80,6 @@ Global
{BF3FBFD2-F40D-4510-8067-37305FFE1D14} = {8EAE9F35-BE6B-4B77-A1F4-383EF17D9870} {BF3FBFD2-F40D-4510-8067-37305FFE1D14} = {8EAE9F35-BE6B-4B77-A1F4-383EF17D9870}
{B178B74E-A739-4722-BFA8-D9AB694024BB} = {8EAE9F35-BE6B-4B77-A1F4-383EF17D9870} {B178B74E-A739-4722-BFA8-D9AB694024BB} = {8EAE9F35-BE6B-4B77-A1F4-383EF17D9870}
{D8556BE1-70E3-49AD-84FD-209C80B17B57} = {F29E3026-31A5-4277-A265-081E87C76A28} {D8556BE1-70E3-49AD-84FD-209C80B17B57} = {F29E3026-31A5-4277-A265-081E87C76A28}
{9122ECF4-9111-40B3-BB59-ED7C112FF575} = {F29E3026-31A5-4277-A265-081E87C76A28}
EndGlobalSection EndGlobalSection
EndGlobal EndGlobal

View file

@ -323,3 +323,7 @@
}), }),
}); });
} }
// Делает сгенерированный из top-level statements класс Program видимым для
// интеграционных тестов (WebApplicationFactory&lt;Program&gt;).
public partial class Program;

View file

@ -0,0 +1,64 @@
using System.Net;
using System.Net.Http.Json;
using System.Text.Json;
using FluentAssertions;
using foodmarket.IntegrationTests.Support;
using Xunit;
namespace foodmarket.IntegrationTests;
[Collection(ApiCollection.Name)]
public class PermissionTests
{
private readonly ApiFactory _factory;
public PermissionTests(ApiFactory factory) => _factory = factory;
[Fact]
public async Task Custom_role_without_ProductsEdit_gets_403_on_put_product()
{
var admin = new ApiActor(_factory.CreateClient());
await admin.SignupAndLoginAsync($"permadmin-{Guid.NewGuid():N}");
// Кастомная роль: просмотр товаров, но БЕЗ права правки.
var roleResp = await admin.Http.PostAsJsonAsync("/api/organization/employee-roles", new
{
name = $"Viewer-{Guid.NewGuid():N}",
description = "view only",
permissions = new { productsView = true, productsEdit = false },
});
roleResp.EnsureSuccessStatusCode();
var roleId = (await roleResp.Content.ReadFromJsonAsync<JsonElement>()).GetProperty("id").GetString();
// Сотрудник с логином на этой роли.
var empEmail = $"viewer-{Guid.NewGuid():N}@example.kz";
var empResp = await admin.Http.PostAsJsonAsync("/api/organization/employees", new
{
lastName = "Просмотров", firstName = "Вью", roleId,
isActive = true, createAccount = true, email = empEmail,
});
empResp.EnsureSuccessStatusCode();
var pwd = (await empResp.Content.ReadFromJsonAsync<JsonElement>()).GetProperty("generatedPassword").GetString()!;
var viewer = new ApiActor(_factory.CreateClient());
viewer.UseToken(await viewer.TokenAsync(empEmail, pwd));
// Авторизация проверяется до биндинга тела: несуществующий id → 403, если нет права.
using var put = await viewer.Http.PutAsJsonAsync($"/api/catalog/products/{Guid.NewGuid()}", new { });
put.StatusCode.Should().Be(HttpStatusCode.Forbidden);
// Чтение (ProductsView) разрешено.
using var get = await viewer.Http.GetAsync("/api/catalog/products");
get.StatusCode.Should().Be(HttpStatusCode.OK);
}
[Fact]
public async Task Admin_passes_permission_gate()
{
var admin = new ApiActor(_factory.CreateClient());
await admin.SignupAndLoginAsync($"permadmin2-{Guid.NewGuid():N}");
// Админ проходит permission-гейт (дальше — 404/400 на несуществующем товаре, но НЕ 403).
using var put = await admin.Http.PutAsJsonAsync($"/api/catalog/products/{Guid.NewGuid()}", new { });
put.StatusCode.Should().NotBe(HttpStatusCode.Forbidden);
}
}

View file

@ -0,0 +1,70 @@
using System.Net.Http.Json;
using System.Text.Json;
using FluentAssertions;
using foodmarket.IntegrationTests.Support;
using Xunit;
namespace foodmarket.IntegrationTests;
[Collection(ApiCollection.Name)]
public class RetailOversellingTests
{
private readonly ApiFactory _factory;
public RetailOversellingTests(ApiFactory factory) => _factory = factory;
private static string RandomBarcode()
=> string.Concat(Enumerable.Range(0, 13).Select(_ => Random.Shared.Next(0, 10)));
[Fact]
public async Task Posting_sale_above_available_stock_is_rejected()
{
var api = new ApiActor(_factory.CreateClient());
await api.SignupAndLoginAsync($"oversell-{Guid.NewGuid():N}");
var refs = await api.LoadRefsAsync();
var productId = await api.CreateProductAsync(refs, $"Prod-{Guid.NewGuid():N}", retailPrice: 100m, RandomBarcode());
// Остатка нет (приёмок не делали). Продаём 5 шт. paidCash покрывает итог,
// чтобы дойти до проверки остатка (а не упасть на валидации оплаты).
var draftResp = await api.Http.PostAsJsonAsync("/api/sales/retail", new
{
date = DateTime.UtcNow,
storeId = refs.StoreId,
retailPointId = (string?)null,
customerId = (string?)null,
currencyId = refs.CurrencyId,
payment = 0,
paidCash = 500m,
paidCard = 0m,
notes = "oversell",
lines = new[] { new { productId, quantity = 5m, unitPrice = 100m, discount = 0m, vatPercent = 0m } },
});
draftResp.EnsureSuccessStatusCode();
var saleId = (await draftResp.Content.ReadFromJsonAsync<JsonElement>()).GetProperty("id").GetString();
using var post = await api.Http.PostAsJsonAsync($"/api/sales/retail/{saleId}/post", new { });
((int)post.StatusCode).Should().Be(409, "продажа сверх остатка должна блокироваться");
}
[Fact]
public async Task Posting_sale_with_underpayment_is_rejected()
{
var api = new ApiActor(_factory.CreateClient());
await api.SignupAndLoginAsync($"underpay-{Guid.NewGuid():N}");
var refs = await api.LoadRefsAsync();
var productId = await api.CreateProductAsync(refs, $"Prod-{Guid.NewGuid():N}", 100m, RandomBarcode());
// Итог 200, оплачено 100 → недоплата → 400 (до проверки остатка).
var draftResp = await api.Http.PostAsJsonAsync("/api/sales/retail", new
{
date = DateTime.UtcNow, storeId = refs.StoreId, retailPointId = (string?)null,
customerId = (string?)null, currencyId = refs.CurrencyId,
payment = 0, paidCash = 100m, paidCard = 0m, notes = "underpay",
lines = new[] { new { productId, quantity = 2m, unitPrice = 100m, discount = 0m, vatPercent = 0m } },
});
draftResp.EnsureSuccessStatusCode();
var saleId = (await draftResp.Content.ReadFromJsonAsync<JsonElement>()).GetProperty("id").GetString();
using var post = await api.Http.PostAsJsonAsync($"/api/sales/retail/{saleId}/post", new { });
((int)post.StatusCode).Should().Be(400, "недоплата должна блокировать проведение");
}
}

View file

@ -0,0 +1,54 @@
using System.Net;
using System.Net.Http.Json;
using System.Text.Json;
using FluentAssertions;
using foodmarket.IntegrationTests.Support;
using Xunit;
namespace foodmarket.IntegrationTests;
[Collection(ApiCollection.Name)]
public class SignupFlowTests
{
private readonly ApiFactory _factory;
public SignupFlowTests(ApiFactory factory) => _factory = factory;
[Fact]
public async Task Signup_then_login_yields_token_with_org()
{
var actor = new ApiActor(_factory.CreateClient());
var email = $"signup-{Guid.NewGuid():N}@example.kz";
const string password = "Passw0rd!";
var signup = await actor.SignupAsync(email, password, "Signup Org");
signup.StatusCode.Should().Be(HttpStatusCode.OK);
var token = await actor.TokenAsync(email, password);
token.Should().NotBeNullOrEmpty();
actor.UseToken(token);
var me = await actor.GetJsonAsync("/api/me");
me.GetProperty("email").GetString().Should().Be(email);
me.GetProperty("orgId").GetString().Should().NotBeNullOrEmpty();
me.GetProperty("hasLiveOrg").GetBoolean().Should().BeTrue();
}
[Fact]
public async Task Duplicate_signup_is_rejected()
{
var actor = new ApiActor(_factory.CreateClient());
var email = $"dup-{Guid.NewGuid():N}@example.kz";
(await actor.SignupAsync(email, "Passw0rd!", "Dup Org 1")).StatusCode.Should().Be(HttpStatusCode.OK);
var second = await actor.SignupAsync(email, "Passw0rd!", "Dup Org 2");
second.StatusCode.Should().Be(HttpStatusCode.BadRequest);
}
[Fact]
public async Task Weak_password_is_rejected()
{
var actor = new ApiActor(_factory.CreateClient());
var resp = await actor.SignupAsync($"weak-{Guid.NewGuid():N}@example.kz", "short", "Weak Org");
resp.StatusCode.Should().Be(HttpStatusCode.BadRequest);
}
}

View file

@ -0,0 +1,79 @@
using System.Net.Http.Json;
using System.Text.Json;
using FluentAssertions;
using foodmarket.IntegrationTests.Support;
using Xunit;
namespace foodmarket.IntegrationTests;
[Collection(ApiCollection.Name)]
public class SupplyPostUnpostTests
{
private readonly ApiFactory _factory;
public SupplyPostUnpostTests(ApiFactory factory) => _factory = factory;
private static string RandomBarcode()
=> string.Concat(Enumerable.Range(0, 13).Select(_ => Random.Shared.Next(0, 10)));
[Fact]
public async Task Posting_supply_raises_stock_and_sets_cost_unpost_reverts()
{
var api = new ApiActor(_factory.CreateClient());
await api.SignupAndLoginAsync($"supply-{Guid.NewGuid():N}");
var refs = await api.LoadRefsAsync();
var supplierId = await api.CreateCounterpartyAsync($"Supplier-{Guid.NewGuid():N}");
var productId = await api.CreateProductAsync(refs, $"Prod-{Guid.NewGuid():N}", retailPrice: 150m, RandomBarcode());
(await api.StockOfAsync(refs.StoreId, productId)).Should().Be(0m, "новый товар без приёмок");
// Draft supply: 10 шт по 70.
var draftResp = await api.Http.PostAsJsonAsync("/api/purchases/supplies", new
{
date = DateTime.UtcNow,
supplierId,
storeId = refs.StoreId,
currencyId = refs.CurrencyId,
notes = "integration",
lines = new[] { new { productId, quantity = 10m, unitPrice = 70m } },
});
draftResp.EnsureSuccessStatusCode();
var supplyId = (await draftResp.Content.ReadFromJsonAsync<JsonElement>()).GetProperty("id").GetString();
// Post → остаток 10, себестоимость 70 (скользящее среднее с нуля).
using var post = await api.Http.PostAsJsonAsync($"/api/purchases/supplies/{supplyId}/post", new { });
post.IsSuccessStatusCode.Should().BeTrue($"post вернул {(int)post.StatusCode}: {await post.Content.ReadAsStringAsync()}");
(await api.StockOfAsync(refs.StoreId, productId)).Should().Be(10m);
var product = await api.GetJsonAsync($"/api/catalog/products/{productId}");
product.GetProperty("cost").GetDecimal().Should().Be(70m);
// Unpost → остаток возвращается к 0.
using var unpost = await api.Http.PostAsJsonAsync($"/api/purchases/supplies/{supplyId}/unpost", new { });
unpost.IsSuccessStatusCode.Should().BeTrue($"unpost вернул {(int)unpost.StatusCode}: {await unpost.Content.ReadAsStringAsync()}");
(await api.StockOfAsync(refs.StoreId, productId)).Should().Be(0m);
}
[Fact]
public async Task Double_post_is_rejected()
{
var api = new ApiActor(_factory.CreateClient());
await api.SignupAndLoginAsync($"dblpost-{Guid.NewGuid():N}");
var refs = await api.LoadRefsAsync();
var supplierId = await api.CreateCounterpartyAsync($"Supplier-{Guid.NewGuid():N}");
var productId = await api.CreateProductAsync(refs, $"Prod-{Guid.NewGuid():N}", 150m, RandomBarcode());
var draftResp = await api.Http.PostAsJsonAsync("/api/purchases/supplies", new
{
date = DateTime.UtcNow, supplierId, storeId = refs.StoreId, currencyId = refs.CurrencyId,
notes = "dbl", lines = new[] { new { productId, quantity = 3m, unitPrice = 50m } },
});
draftResp.EnsureSuccessStatusCode();
var supplyId = (await draftResp.Content.ReadFromJsonAsync<JsonElement>()).GetProperty("id").GetString();
(await api.Http.PostAsJsonAsync($"/api/purchases/supplies/{supplyId}/post", new { }))
.IsSuccessStatusCode.Should().BeTrue();
using var second = await api.Http.PostAsJsonAsync($"/api/purchases/supplies/{supplyId}/post", new { });
((int)second.StatusCode).Should().Be(409);
}
}

View file

@ -0,0 +1,152 @@
using System.Net.Http.Headers;
using System.Net.Http.Json;
using System.Text.Json;
namespace foodmarket.IntegrationTests.Support;
/// <summary>Тонкая обёртка над HttpClient для интеграционных тестов: логин,
/// JSON-хелперы, выборка справочников орг-бутстрапа. Один экземпляр = один
/// «актор» (свой токен/орг).</summary>
public sealed class ApiActor
{
public HttpClient Http { get; }
public ApiActor(HttpClient http) => Http = http;
public void UseToken(string token)
=> Http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
// ── Auth ────────────────────────────────────────────────────────────────
public Task<HttpResponseMessage> SignupAsync(string email, string password, string orgName, string phone = "+77001234567")
=> Http.PostAsJsonAsync("/api/auth/signup",
new { email, password, organizationName = orgName, phone, plan = (string?)null });
public async Task<string> TokenAsync(string email, string password)
{
using var resp = await Http.PostAsync("/connect/token", new FormUrlEncodedContent(new Dictionary<string, string>
{
["grant_type"] = "password",
["username"] = email,
["password"] = password,
["client_id"] = "food-market-web",
["scope"] = "openid profile email roles api offline_access",
}));
resp.EnsureSuccessStatusCode();
return (await resp.Content.ReadFromJsonAsync<JsonElement>()).GetProperty("access_token").GetString()!;
}
/// <summary>Регистрирует орг, логинит её администратора и проставляет токен.
/// Возвращает email/пароль администратора.</summary>
public async Task<(string Email, string Password)> SignupAndLoginAsync(string slug)
{
var email = $"{slug}@example.kz";
const string password = "Passw0rd!";
(await SignupAsync(email, password, $"Org {slug}")).EnsureSuccessStatusCode();
UseToken(await TokenAsync(email, password));
return (email, password);
}
// ── JSON helpers ──────────────────────────────────────────────────────────
public async Task<JsonElement> GetJsonAsync(string url)
{
using var resp = await Http.GetAsync(url);
resp.EnsureSuccessStatusCode();
return await resp.Content.ReadFromJsonAsync<JsonElement>();
}
/// <summary>items[] из PagedResult (или сам массив, если endpoint отдаёт список).</summary>
public async Task<List<JsonElement>> ListAsync(string url)
{
var json = await GetJsonAsync(url);
var arr = json.ValueKind == JsonValueKind.Array ? json
: json.TryGetProperty("items", out var items) ? items
: default;
return arr.ValueKind == JsonValueKind.Array ? arr.EnumerateArray().ToList() : new();
}
public async Task<string> FirstIdAsync(string url, Func<JsonElement, bool>? match = null)
{
var list = await ListAsync(url);
var hit = match is null ? list.FirstOrDefault() : list.FirstOrDefault(match);
return hit.ValueKind == JsonValueKind.Object ? hit.GetProperty("id").GetString()! : throw new InvalidOperationException($"No item in {url}");
}
// ── Org bootstrap refs ─────────────────────────────────────────────────────
public async Task<Refs> LoadRefsAsync()
{
var unit = await FirstIdAsync("/api/catalog/units-of-measure?pageSize=50");
var group = await FirstIdAsync("/api/catalog/product-groups?pageSize=200");
var store = await FirstIdAsync("/api/catalog/stores?pageSize=200");
var currency = await FirstIdAsync("/api/catalog/currencies?pageSize=50",
e => e.GetProperty("code").GetString() == "KZT");
// Системный/розничный тип цены.
var priceType = await FirstIdAsync("/api/catalog/price-types?pageSize=200",
e => e.TryGetProperty("isSystem", out var s) && s.GetBoolean());
return new Refs(unit, group, store, currency, priceType);
}
public async Task<string> CreateCounterpartyAsync(string name)
{
var resp = await Http.PostAsJsonAsync("/api/catalog/counterparties", new
{
name,
legalName = name,
type = 0,
bin = "987654321098",
iin = (string?)null,
taxNumber = (string?)null,
countryId = (string?)null,
address = "Алматы",
phone = "+77003332211",
email = "cp@example.kz",
bankName = (string?)null,
bankAccount = (string?)null,
bik = (string?)null,
contactPerson = "Иванов",
notes = (string?)null,
});
resp.EnsureSuccessStatusCode();
return (await resp.Content.ReadFromJsonAsync<JsonElement>()).GetProperty("id").GetString()!;
}
public async Task<string> CreateProductAsync(Refs refs, string name, decimal retailPrice, string barcode)
{
var resp = await Http.PostAsJsonAsync("/api/catalog/products", new
{
name,
article = (string?)null,
description = (string?)null,
unitOfMeasureId = refs.UnitId,
vat = 12,
vatEnabled = true,
productGroupId = refs.GroupId,
defaultSupplierId = (string?)null,
countryOfOriginId = (string?)null,
isService = false,
packaging = 0,
isMarked = false,
minStock = (decimal?)null,
maxStock = (decimal?)null,
referencePrice = retailPrice * 0.7m,
purchaseCurrencyId = refs.CurrencyId,
imageUrl = (string?)null,
prices = new[] { new { priceTypeId = refs.PriceTypeId, amount = retailPrice, currencyId = refs.CurrencyId } },
barcodes = new[] { new { code = barcode, type = 0, isPrimary = true } },
});
var body = await resp.Content.ReadAsStringAsync();
if (!resp.IsSuccessStatusCode)
throw new InvalidOperationException($"CreateProduct failed {(int)resp.StatusCode}: {body}");
return JsonDocument.Parse(body).RootElement.GetProperty("id").GetString()!;
}
public async Task<decimal> StockOfAsync(string storeId, string productId)
{
var list = await ListAsync($"/api/inventory/stock?productId={productId}&pageSize=200");
var row = list.FirstOrDefault(i =>
i.GetProperty("productId").GetString() == productId &&
i.GetProperty("storeId").GetString() == storeId);
return row.ValueKind == JsonValueKind.Object ? row.GetProperty("quantity").GetDecimal() : 0m;
}
public sealed record Refs(string UnitId, string GroupId, string StoreId, string CurrencyId, string PriceTypeId);
}

View file

@ -0,0 +1,11 @@
using Xunit;
namespace foodmarket.IntegrationTests.Support;
/// <summary>Один Postgres-контейнер + host на всю коллекцию интеграционных тестов
/// (старт контейнера ~секунды — не хотим повторять на каждый класс).</summary>
[CollectionDefinition(Name)]
public sealed class ApiCollection : ICollectionFixture<ApiFactory>
{
public const string Name = "api";
}

View file

@ -0,0 +1,59 @@
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.Extensions.Configuration;
using Testcontainers.PostgreSql;
using Xunit;
namespace foodmarket.IntegrationTests.Support;
/// <summary>Поднимает реальный API (WebApplicationFactory) поверх одноразового
/// Postgres-контейнера (Testcontainers). Миграции применяются на старте host'а
/// (Program.Migrate), сидеры наполняют справочники и SuperAdmin. Запускается
/// один раз на всю коллекцию тестов.</summary>
public sealed class ApiFactory : WebApplicationFactory<Program>, IAsyncLifetime
{
static ApiFactory()
{
// Ryuk (reaper) тянет образ из Docker Hub — на этом хосте сеть к внешним
// реестрам нестабильна, а postgres:16-alpine уже закэширован. Выключаем.
Environment.SetEnvironmentVariable("TESTCONTAINERS_RYUK_DISABLED", "true");
// Лимитер читает конфиг ЭАГЕРНО при регистрации сервисов — поэтому
// переопределяем через env-переменную (её CreateBuilder подхватывает до
// регистрации), а не через ConfigureAppConfiguration (применяется позже).
// Тесты логинятся десятки раз с одного loopback-IP — иначе 429.
Environment.SetEnvironmentVariable("RateLimiting__Enabled", "false");
// Тише логи в тестовом прогоне (OpenIddict сыплет Debug-событиями).
Environment.SetEnvironmentVariable("Serilog__MinimumLevel__Default", "Warning");
}
private readonly PostgreSqlContainer _db = new PostgreSqlBuilder()
.WithImage("postgres:16-alpine")
.WithDatabase("food_market")
.WithUsername("food_market")
.WithPassword("food_market_test")
.Build();
public async Task InitializeAsync() => await _db.StartAsync();
public new async Task DisposeAsync()
{
await _db.DisposeAsync();
await base.DisposeAsync();
}
protected override void ConfigureWebHost(IWebHostBuilder builder)
{
// Development — простые dev-ключи OpenIddict (без генерации сертификатов),
// 3-сегментный токен. Лимитер выключаем: тесты логинятся много раз с одного IP.
builder.UseEnvironment("Development");
builder.ConfigureAppConfiguration((_, cfg) =>
{
// Строку подключения AddDbContext читает лениво — здесь override
// срабатывает. RateLimiting выключён через env (см. static ctor).
cfg.AddInMemoryCollection(new Dictionary<string, string?>
{
["ConnectionStrings:Default"] = _db.GetConnectionString(),
});
});
}
}

View file

@ -0,0 +1,37 @@
using FluentAssertions;
using foodmarket.IntegrationTests.Support;
using Xunit;
namespace foodmarket.IntegrationTests;
[Collection(ApiCollection.Name)]
public class TenantIsolationTests
{
private readonly ApiFactory _factory;
public TenantIsolationTests(ApiFactory factory) => _factory = factory;
[Fact]
public async Task Org_B_cannot_see_org_A_data()
{
var a = new ApiActor(_factory.CreateClient());
var b = new ApiActor(_factory.CreateClient());
await a.SignupAndLoginAsync($"iso-a-{Guid.NewGuid():N}");
await b.SignupAndLoginAsync($"iso-b-{Guid.NewGuid():N}");
var marker = $"A-CP-{Guid.NewGuid():N}";
var createdId = await a.CreateCounterpartyAsync(marker);
// A видит свой контрагент.
var aList = await a.ListAsync("/api/catalog/counterparties?pageSize=200");
aList.Should().Contain(c => c.GetProperty("id").GetString() == createdId);
// B не видит ни id, ни имя контрагента A.
var bList = await b.ListAsync("/api/catalog/counterparties?pageSize=200");
bList.Should().NotContain(c => c.GetProperty("id").GetString() == createdId);
bList.Should().NotContain(c => c.GetProperty("name").GetString() == marker);
// B не может прочитать контрагент A напрямую по id (query-filter → 404).
using var direct = await b.Http.GetAsync($"/api/catalog/counterparties/{createdId}");
direct.StatusCode.Should().Be(System.Net.HttpStatusCode.NotFound);
}
}

View file

@ -0,0 +1,27 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<RootNamespace>foodmarket.IntegrationTests</RootNamespace>
<AssemblyName>foodmarket.IntegrationTests</AssemblyName>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\food-market.api\food-market.api.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" />
<PackageReference Include="xunit" />
<PackageReference Include="xunit.runner.visualstudio" />
<PackageReference Include="FluentAssertions" />
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" />
<PackageReference Include="Testcontainers.PostgreSql" />
<PackageReference Include="coverlet.collector" />
</ItemGroup>
</Project>