food-market/tests/food-market.IntegrationTests/Support/ApiActor.cs
nns deef65d819
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
test(money): unit (MoneyMath 25) + integration (7 на Postgres) + MoneyMath refactor
- MoneyMath: вынесены чистые формулы (статус оплаты, ожидаемая наличность,
  разница смены, остаток счёта, долг поставщика, защита от переплаты);
  сервис/SuppliesController/MoneyReportsController теперь зовут их.
- UnitTests/MoneyMathTests: 25 кейсов на формулы + money-флаги RolePermissions.
- IntegrationTests/MoneyModuleTests (реальный Postgres, 7 [Fact]):
  мультитенантность (Б не видит счета А), инвариант остатка, частичная→полная
  оплата приёмки, переплата→409, долг поставщика, продажа без смены→409 +
  после open→ok, расчёт expected/difference при закрытии смены.
- ApiActor: helpers CreateRetailPoint/CreateMoneyAccount/CreatePostedSupply.
- tests/e2e/scrape-moysklad.ts — скрейпер для docs/money-moysklad-reference.md.

Прогон: UnitTests 25/25 ✓, IntegrationTests money 7/7 ✓ (33s, Testcontainers).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-07 22:11:19 +05:00

193 lines
9.6 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()!;
}
// ── Money module helpers (Sprint 33) ───────────────────────────────────
public async Task<string> CreateRetailPointAsync(string storeId, string name)
{
var resp = await Http.PostAsJsonAsync("/api/catalog/retail-points", new { name, code = (string?)null, storeId });
var body = await resp.Content.ReadAsStringAsync();
if (!resp.IsSuccessStatusCode) throw new InvalidOperationException($"CreateRetailPoint failed {(int)resp.StatusCode}: {body}");
return JsonDocument.Parse(body).RootElement.GetProperty("id").GetString()!;
}
/// <summary>Создаёт счёт/кассу. kind: 1=Cash,2=Safe,3=Bank,4=Acquiring.</summary>
public async Task<string> CreateMoneyAccountAsync(string name, int kind, string currencyId,
string? storeId = null, decimal openingBalance = 0m, string? accountNumber = null)
{
var resp = await Http.PostAsJsonAsync("/api/money/accounts", new
{
name, kind, bankName = (string?)null, accountNumber,
storeId, currencyId, openingBalance,
openingDate = DateTime.UtcNow, isActive = true, isDefault = false,
});
var body = await resp.Content.ReadAsStringAsync();
if (!resp.IsSuccessStatusCode) throw new InvalidOperationException($"CreateMoneyAccount failed {(int)resp.StatusCode}: {body}");
return JsonDocument.Parse(body).RootElement.GetProperty("id").GetString()!;
}
/// <summary>Создаёт и проводит приёмку, возвращает (supplyId, total).</summary>
public async Task<(string Id, decimal Total)> CreatePostedSupplyAsync(Refs refs, string supplierId,
string productId, decimal qty, decimal unitPrice)
{
var draft = await Http.PostAsJsonAsync("/api/purchases/supplies", new
{
date = DateTime.UtcNow, supplierId, storeId = refs.StoreId, currencyId = refs.CurrencyId,
notes = (string?)null, lines = new[] { new { productId, quantity = qty, unitPrice } },
});
draft.EnsureSuccessStatusCode();
var supplyId = (await draft.Content.ReadFromJsonAsync<JsonElement>()).GetProperty("id").GetString()!;
using var post = await Http.PostAsJsonAsync($"/api/purchases/supplies/{supplyId}/post", new { });
post.EnsureSuccessStatusCode();
return (supplyId, qty * unitPrice);
}
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);
}