food-market/tests/food-market.IntegrationTests/RetailOversellingTests.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

71 lines
3.3 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.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, "недоплата должна блокировать проведение");
}
}