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:
parent
e988a7dbbc
commit
f2dad91e05
|
|
@ -23,6 +23,8 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{F29E3026
|
|||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "food-market.UnitTests", "tests\food-market.UnitTests\food-market.UnitTests.csproj", "{D8556BE1-70E3-49AD-84FD-209C80B17B57}"
|
||||
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
|
||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||
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}.Release|Any CPU.ActiveCfg = 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
|
||||
GlobalSection(NestedProjects) = preSolution
|
||||
{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}
|
||||
{B178B74E-A739-4722-BFA8-D9AB694024BB} = {8EAE9F35-BE6B-4B77-A1F4-383EF17D9870}
|
||||
{D8556BE1-70E3-49AD-84FD-209C80B17B57} = {F29E3026-31A5-4277-A265-081E87C76A28}
|
||||
{9122ECF4-9111-40B3-BB59-ED7C112FF575} = {F29E3026-31A5-4277-A265-081E87C76A28}
|
||||
EndGlobalSection
|
||||
EndGlobal
|
||||
|
|
|
|||
|
|
@ -323,3 +323,7 @@
|
|||
}),
|
||||
});
|
||||
}
|
||||
|
||||
// Делает сгенерированный из top-level statements класс Program видимым для
|
||||
// интеграционных тестов (WebApplicationFactory<Program>).
|
||||
public partial class Program;
|
||||
|
|
|
|||
64
tests/food-market.IntegrationTests/PermissionTests.cs
Normal file
64
tests/food-market.IntegrationTests/PermissionTests.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
70
tests/food-market.IntegrationTests/RetailOversellingTests.cs
Normal file
70
tests/food-market.IntegrationTests/RetailOversellingTests.cs
Normal 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, "недоплата должна блокировать проведение");
|
||||
}
|
||||
}
|
||||
54
tests/food-market.IntegrationTests/SignupFlowTests.cs
Normal file
54
tests/food-market.IntegrationTests/SignupFlowTests.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
79
tests/food-market.IntegrationTests/SupplyPostUnpostTests.cs
Normal file
79
tests/food-market.IntegrationTests/SupplyPostUnpostTests.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
152
tests/food-market.IntegrationTests/Support/ApiActor.cs
Normal file
152
tests/food-market.IntegrationTests/Support/ApiActor.cs
Normal 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);
|
||||
}
|
||||
11
tests/food-market.IntegrationTests/Support/ApiCollection.cs
Normal file
11
tests/food-market.IntegrationTests/Support/ApiCollection.cs
Normal 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";
|
||||
}
|
||||
59
tests/food-market.IntegrationTests/Support/ApiFactory.cs
Normal file
59
tests/food-market.IntegrationTests/Support/ApiFactory.cs
Normal 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(),
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
37
tests/food-market.IntegrationTests/TenantIsolationTests.cs
Normal file
37
tests/food-market.IntegrationTests/TenantIsolationTests.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
Loading…
Reference in a new issue