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 TransferPostUnpostTests { private readonly ApiFactory _factory; public TransferPostUnpostTests(ApiFactory factory) => _factory = factory; private static string RandomBarcode() => string.Concat(Enumerable.Range(0, 13).Select(_ => Random.Shared.Next(0, 10))); /// Создать второй склад через api (главный уже есть из bootstrap). private async Task CreateSecondStore(ApiActor api, string name) { var resp = await api.Http.PostAsJsonAsync("/api/catalog/stores", new { name, code = (string?)null, address = "Алматы 2", phone = (string?)null, managerName = (string?)null, isMain = false, isActive = true, }); resp.EnsureSuccessStatusCode(); return (await resp.Content.ReadFromJsonAsync()).GetProperty("id").GetString()!; } [Fact] public async Task Post_creates_paired_movements_unpost_reverts_no_orphans() { var api = new ApiActor(_factory.CreateClient()); await api.SignupAndLoginAsync($"xfer-{Guid.NewGuid():N}"); var refs = await api.LoadRefsAsync(); var to = await CreateSecondStore(api, $"Склад-2-{Guid.NewGuid():N}"); 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 { })).EnsureSuccessStatusCode(); (await api.StockOfAsync(refs.StoreId, productId)).Should().Be(10m); // Создать перемещение: 4 шт с первого склада на второй. var xfer = await api.Http.PostAsJsonAsync("/api/inventory/transfers", new { date = DateTime.UtcNow, fromStoreId = refs.StoreId, toStoreId = to, notes = "test", lines = new[] { new { productId, quantity = 4m, unitCost = 50m } }, }); xfer.EnsureSuccessStatusCode(); var xferId = (await xfer.Content.ReadFromJsonAsync()).GetProperty("id").GetString(); // Post → 6 на источнике, 4 на получателе. using var post = await api.Http.PostAsJsonAsync($"/api/inventory/transfers/{xferId}/post", new { }); post.IsSuccessStatusCode.Should().BeTrue($"post вернул {(int)post.StatusCode}: {await post.Content.ReadAsStringAsync()}"); (await api.StockOfAsync(refs.StoreId, productId)).Should().Be(6m); (await api.StockOfAsync(to, productId)).Should().Be(4m); // Проверяем что движений ровно 2 (TransferOut + TransferIn) — никаких orphan. var movs = await api.ListAsync($"/api/inventory/movements?productId={productId}&pageSize=100"); var byThisDoc = movs.Where(m => m.GetProperty("documentId").ValueKind == JsonValueKind.String && m.GetProperty("documentId").GetString() == xferId).ToList(); byThisDoc.Should().HaveCount(2, "перемещение порождает строго пару движений Out+In"); // Unpost → 10 и 0. Атомарность: ИЛИ оба отменены, ИЛИ ни одного. using var unpost = await api.Http.PostAsJsonAsync($"/api/inventory/transfers/{xferId}/unpost", new { }); unpost.IsSuccessStatusCode.Should().BeTrue(); (await api.StockOfAsync(refs.StoreId, productId)).Should().Be(10m); (await api.StockOfAsync(to, productId)).Should().Be(0m); // По документу теперь 4 движения (2 forward + 2 reversal), но stock-инвариант держится. movs = await api.ListAsync($"/api/inventory/movements?productId={productId}&pageSize=100"); var byThisDocAfter = movs.Where(m => m.GetProperty("documentId").ValueKind == JsonValueKind.String && m.GetProperty("documentId").GetString() == xferId).ToList(); byThisDocAfter.Should().HaveCount(4); } [Fact] public async Task Cannot_create_transfer_to_same_store() { var api = new ApiActor(_factory.CreateClient()); await api.SignupAndLoginAsync($"xfer-same-{Guid.NewGuid():N}"); var refs = await api.LoadRefsAsync(); var productId = await api.CreateProductAsync(refs, $"Prod-{Guid.NewGuid():N}", 100m, RandomBarcode()); using var resp = await api.Http.PostAsJsonAsync("/api/inventory/transfers", new { date = DateTime.UtcNow, fromStoreId = refs.StoreId, toStoreId = refs.StoreId, notes = "same", lines = new[] { new { productId, quantity = 1m, unitCost = 10m } }, }); ((int)resp.StatusCode).Should().Be(400); } [Fact] public async Task Post_blocked_when_from_store_short() { var api = new ApiActor(_factory.CreateClient()); await api.SignupAndLoginAsync($"xfer-short-{Guid.NewGuid():N}"); var refs = await api.LoadRefsAsync(); var to = await CreateSecondStore(api, $"Склад-S-{Guid.NewGuid():N}"); var productId = await api.CreateProductAsync(refs, $"Prod-{Guid.NewGuid():N}", 100m, RandomBarcode()); // На первом 0 шт; пытаемся перевести 5 — должно отбиться. var xfer = await api.Http.PostAsJsonAsync("/api/inventory/transfers", new { date = DateTime.UtcNow, fromStoreId = refs.StoreId, toStoreId = to, notes = "short", lines = new[] { new { productId, quantity = 5m, unitCost = 10m } }, }); xfer.EnsureSuccessStatusCode(); var xferId = (await xfer.Content.ReadFromJsonAsync()).GetProperty("id").GetString(); using var post = await api.Http.PostAsJsonAsync($"/api/inventory/transfers/{xferId}/post", new { }); ((int)post.StatusCode).Should().Be(409); // Никаких движений не должно появиться. (await api.StockOfAsync(refs.StoreId, productId)).Should().Be(0m); (await api.StockOfAsync(to, productId)).Should().Be(0m); } [Fact] public async Task Tenant_isolation_transfer() { var a = new ApiActor(_factory.CreateClient()); var b = new ApiActor(_factory.CreateClient()); await a.SignupAndLoginAsync($"xfer-iso-a-{Guid.NewGuid():N}"); await b.SignupAndLoginAsync($"xfer-iso-b-{Guid.NewGuid():N}"); var refsA = await a.LoadRefsAsync(); var toA = await CreateSecondStore(a, $"Склад-A2-{Guid.NewGuid():N}"); var productA = await a.CreateProductAsync(refsA, $"Prod-{Guid.NewGuid():N}", 100m, RandomBarcode()); var resp = await a.Http.PostAsJsonAsync("/api/inventory/transfers", new { date = DateTime.UtcNow, fromStoreId = refsA.StoreId, toStoreId = toA, notes = "iso", lines = new[] { new { productId = productA, quantity = 1m, unitCost = 10m } }, }); resp.EnsureSuccessStatusCode(); var xferId = (await resp.Content.ReadFromJsonAsync()).GetProperty("id").GetString(); var bList = await b.ListAsync("/api/inventory/transfers?pageSize=200"); bList.Should().NotContain(x => x.GetProperty("id").GetString() == xferId); using var direct = await b.Http.GetAsync($"/api/inventory/transfers/{xferId}"); direct.StatusCode.Should().Be(System.Net.HttpStatusCode.NotFound); } }