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 EnterPostUnpostTests { private readonly ApiFactory _factory; public EnterPostUnpostTests(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_enter_raises_stock_unpost_reverts() { var api = new ApiActor(_factory.CreateClient()); await api.SignupAndLoginAsync($"enter-{Guid.NewGuid():N}"); var refs = await api.LoadRefsAsync(); var productId = await api.CreateProductAsync(refs, $"Prod-{Guid.NewGuid():N}", 200m, RandomBarcode()); // Draft enter: 7 шт по 50 (балансовая цена). var draftResp = await api.Http.PostAsJsonAsync("/api/inventory/enters", new { date = DateTime.UtcNow, storeId = refs.StoreId, currencyId = refs.CurrencyId, notes = "начальный остаток", lines = new[] { new { productId, quantity = 7m, unitCost = 50m } }, }); draftResp.EnsureSuccessStatusCode(); var enterId = (await draftResp.Content.ReadFromJsonAsync()).GetProperty("id").GetString(); // Стартовый остаток 0. (await api.StockOfAsync(refs.StoreId, productId)).Should().Be(0m); // Post → остаток 7. Cost не пересчитывается — оприходование не имеет «закупочной цены». using var post = await api.Http.PostAsJsonAsync($"/api/inventory/enters/{enterId}/post", new { }); post.IsSuccessStatusCode.Should().BeTrue($"post вернул {(int)post.StatusCode}: {await post.Content.ReadAsStringAsync()}"); (await api.StockOfAsync(refs.StoreId, productId)).Should().Be(7m); // Unpost → остаток обратно 0. using var unpost = await api.Http.PostAsJsonAsync($"/api/inventory/enters/{enterId}/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($"enter-dbl-{Guid.NewGuid():N}"); var refs = await api.LoadRefsAsync(); var productId = await api.CreateProductAsync(refs, $"Prod-{Guid.NewGuid():N}", 100m, RandomBarcode()); var draftResp = await api.Http.PostAsJsonAsync("/api/inventory/enters", new { date = DateTime.UtcNow, storeId = refs.StoreId, currencyId = refs.CurrencyId, notes = "dbl", lines = new[] { new { productId, quantity = 2m, unitCost = 30m } }, }); draftResp.EnsureSuccessStatusCode(); var enterId = (await draftResp.Content.ReadFromJsonAsync()).GetProperty("id").GetString(); (await api.Http.PostAsJsonAsync($"/api/inventory/enters/{enterId}/post", new { })) .IsSuccessStatusCode.Should().BeTrue(); using var second = await api.Http.PostAsJsonAsync($"/api/inventory/enters/{enterId}/post", new { }); ((int)second.StatusCode).Should().Be(409); } [Fact] public async Task Tenant_isolation_enter() { var a = new ApiActor(_factory.CreateClient()); var b = new ApiActor(_factory.CreateClient()); await a.SignupAndLoginAsync($"enter-iso-a-{Guid.NewGuid():N}"); await b.SignupAndLoginAsync($"enter-iso-b-{Guid.NewGuid():N}"); var refsA = await a.LoadRefsAsync(); var productA = await a.CreateProductAsync(refsA, $"Prod-{Guid.NewGuid():N}", 100m, RandomBarcode()); var resp = await a.Http.PostAsJsonAsync("/api/inventory/enters", new { date = DateTime.UtcNow, storeId = refsA.StoreId, currencyId = refsA.CurrencyId, notes = "iso", lines = new[] { new { productId = productA, quantity = 1m, unitCost = 10m } }, }); resp.EnsureSuccessStatusCode(); var enterId = (await resp.Content.ReadFromJsonAsync()).GetProperty("id").GetString(); // B список не показывает документ A. var bList = await b.ListAsync("/api/inventory/enters?pageSize=200"); bList.Should().NotContain(x => x.GetProperty("id").GetString() == enterId); // B напрямую → 404. using var direct = await b.Http.GetAsync($"/api/inventory/enters/{enterId}"); direct.StatusCode.Should().Be(System.Net.HttpStatusCode.NotFound); } [Fact] public async Task Unpost_blocked_when_stock_would_go_negative() { var api = new ApiActor(_factory.CreateClient()); await api.SignupAndLoginAsync($"enter-neg-{Guid.NewGuid():N}"); var refs = await api.LoadRefsAsync(); var productId = await api.CreateProductAsync(refs, $"Prod-{Guid.NewGuid():N}", 200m, RandomBarcode()); // Оприходовали 5 шт. var create = await api.Http.PostAsJsonAsync("/api/inventory/enters", new { date = DateTime.UtcNow, storeId = refs.StoreId, currencyId = refs.CurrencyId, notes = "stock-guard", lines = new[] { new { productId, quantity = 5m, unitCost = 100m } }, }); create.EnsureSuccessStatusCode(); var enterId = (await create.Content.ReadFromJsonAsync()).GetProperty("id").GetString(); (await api.Http.PostAsJsonAsync($"/api/inventory/enters/{enterId}/post", new { })) .IsSuccessStatusCode.Should().BeTrue(); // Списали 5 шт — оставшийся стартовый остаток = 0, при unpost ушёл бы в -5. // Делаем розничную продажу 5 единиц и проводим. // Сначала надо чтобы товар имел розничную цену — она задана при CreateProductAsync. var sale = 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, // Cash paidCash = 1000m, paidCard = 0m, lines = new[] { new { productId, quantity = 5m, unitPrice = 200m, discount = 0m, vatPercent = 12m } }, notes = "drain", }); sale.EnsureSuccessStatusCode(); var saleId = (await sale.Content.ReadFromJsonAsync()).GetProperty("id").GetString(); (await api.Http.PostAsJsonAsync($"/api/sales/retail/{saleId}/post", new { })) .IsSuccessStatusCode.Should().BeTrue(); (await api.StockOfAsync(refs.StoreId, productId)).Should().Be(0m); // Unpost оприходования должен быть отбит (остаток уйдёт в минус). using var unpost = await api.Http.PostAsJsonAsync($"/api/inventory/enters/{enterId}/unpost", new { }); ((int)unpost.StatusCode).Should().Be(409); } }