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>
153 lines
7.2 KiB
C#
153 lines
7.2 KiB
C#
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);
|
||
}
|