food-market/tests/food-market.IntegrationTests/Support/ApiActor.cs
nns f2dad91e05 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>
2026-05-27 03:14:01 +05:00

153 lines
7.2 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

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.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);
}