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 LossPostUnpostTests { private readonly ApiFactory _factory; public LossPostUnpostTests(ApiFactory factory) => _factory = factory; private static string RandomBarcode() => string.Concat(Enumerable.Range(0, 13).Select(_ => Random.Shared.Next(0, 10))); /// Положить через Enter, потом списать, проверить остаток и обратимость. [Fact] public async Task Posting_loss_decrements_stock_unpost_restores() { var api = new ApiActor(_factory.CreateClient()); await api.SignupAndLoginAsync($"loss-{Guid.NewGuid():N}"); var refs = await api.LoadRefsAsync(); var productId = await api.CreateProductAsync(refs, $"Prod-{Guid.NewGuid():N}", 200m, RandomBarcode()); // Сначала оприходуем 10 шт чтобы было что списывать. var enter = await api.Http.PostAsJsonAsync("/api/inventory/enters", new { date = DateTime.UtcNow, storeId = refs.StoreId, currencyId = refs.CurrencyId, notes = "seed", lines = new[] { new { productId, quantity = 10m, unitCost = 50m } }, }); enter.EnsureSuccessStatusCode(); var enterId = (await enter.Content.ReadFromJsonAsync()).GetProperty("id").GetString(); (await api.Http.PostAsJsonAsync($"/api/inventory/enters/{enterId}/post", new { })) .IsSuccessStatusCode.Should().BeTrue(); (await api.StockOfAsync(refs.StoreId, productId)).Should().Be(10m); // Списываем 3 шт с причиной Expired. var loss = await api.Http.PostAsJsonAsync("/api/inventory/losses", new { date = DateTime.UtcNow, storeId = refs.StoreId, currencyId = refs.CurrencyId, reason = 1, // Expired notes = "просрочка партии", lines = new[] { new { productId, quantity = 3m, unitCost = 50m } }, }); loss.EnsureSuccessStatusCode(); var lossId = (await loss.Content.ReadFromJsonAsync()).GetProperty("id").GetString(); using var post = await api.Http.PostAsJsonAsync($"/api/inventory/losses/{lossId}/post", new { }); post.IsSuccessStatusCode.Should().BeTrue($"post вернул {(int)post.StatusCode}: {await post.Content.ReadAsStringAsync()}"); (await api.StockOfAsync(refs.StoreId, productId)).Should().Be(7m); // Unpost → возвращаем на склад. using var unpost = await api.Http.PostAsJsonAsync($"/api/inventory/losses/{lossId}/unpost", new { }); unpost.IsSuccessStatusCode.Should().BeTrue(); (await api.StockOfAsync(refs.StoreId, productId)).Should().Be(10m); } [Fact] public async Task Cannot_write_off_more_than_available() { var api = new ApiActor(_factory.CreateClient()); await api.SignupAndLoginAsync($"loss-over-{Guid.NewGuid():N}"); var refs = await api.LoadRefsAsync(); var productId = await api.CreateProductAsync(refs, $"Prod-{Guid.NewGuid():N}", 200m, RandomBarcode()); // 2 шт на остатке. var enter = await api.Http.PostAsJsonAsync("/api/inventory/enters", new { date = DateTime.UtcNow, storeId = refs.StoreId, currencyId = refs.CurrencyId, notes = "seed-small", lines = new[] { new { productId, quantity = 2m, unitCost = 50m } }, }); enter.EnsureSuccessStatusCode(); var enterId = (await enter.Content.ReadFromJsonAsync()).GetProperty("id").GetString(); (await api.Http.PostAsJsonAsync($"/api/inventory/enters/{enterId}/post", new { })).EnsureSuccessStatusCode(); // Списать 5 шт — больше чем есть. var loss = await api.Http.PostAsJsonAsync("/api/inventory/losses", new { date = DateTime.UtcNow, storeId = refs.StoreId, currencyId = refs.CurrencyId, reason = 0, notes = "over", lines = new[] { new { productId, quantity = 5m, unitCost = 50m } }, }); loss.EnsureSuccessStatusCode(); var lossId = (await loss.Content.ReadFromJsonAsync()).GetProperty("id").GetString(); using var post = await api.Http.PostAsJsonAsync($"/api/inventory/losses/{lossId}/post", new { }); ((int)post.StatusCode).Should().Be(409); // Остаток не должен измениться. (await api.StockOfAsync(refs.StoreId, productId)).Should().Be(2m); } [Fact] public async Task Tenant_isolation_loss() { var a = new ApiActor(_factory.CreateClient()); var b = new ApiActor(_factory.CreateClient()); await a.SignupAndLoginAsync($"loss-iso-a-{Guid.NewGuid():N}"); await b.SignupAndLoginAsync($"loss-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/losses", new { date = DateTime.UtcNow, storeId = refsA.StoreId, currencyId = refsA.CurrencyId, reason = 0, notes = "iso", lines = new[] { new { productId = productA, quantity = 1m, unitCost = 10m } }, }); resp.EnsureSuccessStatusCode(); var lossId = (await resp.Content.ReadFromJsonAsync()).GetProperty("id").GetString(); var bList = await b.ListAsync("/api/inventory/losses?pageSize=200"); bList.Should().NotContain(x => x.GetProperty("id").GetString() == lossId); using var direct = await b.Http.GetAsync($"/api/inventory/losses/{lossId}"); direct.StatusCode.Should().Be(System.Net.HttpStatusCode.NotFound); } }