using System.Net.Http.Json; using System.Text.Json; using FluentAssertions; using foodmarket.IntegrationTests.Support; using foodmarket.Shared.Pos.V1; using Xunit; namespace foodmarket.IntegrationTests; [Collection(ApiCollection.Name)] public class PosSyncTests { private readonly ApiFactory _factory; public PosSyncTests(ApiFactory factory) => _factory = factory; private static string RandomBarcode() => string.Concat(Enumerable.Range(0, 13).Select(_ => Random.Shared.Next(0, 10))); [Fact] public async Task Sync_returns_products_prices_stocks() { var api = new ApiActor(_factory.CreateClient()); await api.SignupAndLoginAsync($"pos-sync-{Guid.NewGuid():N}"); var refs = await api.LoadRefsAsync(); var p1 = await api.CreateProductAsync(refs, $"P-{Guid.NewGuid():N}", 250m, RandomBarcode()); // Подкормим остаток через Enter, чтобы было что синхронизировать. 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 = p1, quantity = 5m, unitCost = 100m } }, }); enter.EnsureSuccessStatusCode(); var eid = (await enter.Content.ReadFromJsonAsync()).GetProperty("id").GetString(); (await api.Http.PostAsJsonAsync($"/api/inventory/enters/{eid}/post", new { })).EnsureSuccessStatusCode(); // POS sync без since → полная выгрузка. using var resp = await api.Http.GetAsync("/api/pos/v1/sync"); resp.EnsureSuccessStatusCode(); var sync = await resp.Content.ReadFromJsonAsync(); sync!.Products.Should().Contain(p => p.Id.ToString() == p1); sync.Prices.Should().Contain(pr => pr.ProductId.ToString() == p1 && pr.Amount == 250m); sync.Stocks.Should().Contain(s => s.ProductId.ToString() == p1 && s.Quantity == 5m); sync.ServerTime.Should().BeBefore(DateTime.UtcNow.AddSeconds(5)); } [Fact] public async Task Sync_with_since_returns_only_delta() { var api = new ApiActor(_factory.CreateClient()); await api.SignupAndLoginAsync($"pos-delta-{Guid.NewGuid():N}"); var refs = await api.LoadRefsAsync(); var pOld = await api.CreateProductAsync(refs, $"OLD-{Guid.NewGuid():N}", 100m, RandomBarcode()); // Маркер времени между двумя продуктами. await Task.Delay(1100); var marker = DateTime.UtcNow; await Task.Delay(1100); var pNew = await api.CreateProductAsync(refs, $"NEW-{Guid.NewGuid():N}", 200m, RandomBarcode()); using var resp = await api.Http.GetAsync($"/api/pos/v1/sync?since={Uri.EscapeDataString(marker.ToString("o"))}"); resp.EnsureSuccessStatusCode(); var sync = await resp.Content.ReadFromJsonAsync(); sync!.Products.Should().Contain(p => p.Id.ToString() == pNew); sync.Products.Should().NotContain(p => p.Id.ToString() == pOld); } [Fact] public async Task Post_sales_batch_creates_retail_sales_and_decrements_stock() { var api = new ApiActor(_factory.CreateClient()); await api.SignupAndLoginAsync($"pos-sale-{Guid.NewGuid():N}"); var refs = await api.LoadRefsAsync(); var p1 = await api.CreateProductAsync(refs, $"P-{Guid.NewGuid():N}", 100m, RandomBarcode()); 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 = p1, quantity = 20m, unitCost = 50m } }, }); enter.EnsureSuccessStatusCode(); var eid = (await enter.Content.ReadFromJsonAsync()).GetProperty("id").GetString(); (await api.Http.PostAsJsonAsync($"/api/inventory/enters/{eid}/post", new { })).EnsureSuccessStatusCode(); (await api.StockOfAsync(refs.StoreId, p1)).Should().Be(20m); var batch = new PosSaleBatchDto { IdempotencyKey = Guid.NewGuid(), Sales = new[] { MakeSale(p1, qty: 2m, price: 100m), MakeSale(p1, qty: 3m, price: 100m), MakeSale(p1, qty: 1m, price: 100m), MakeSale(p1, qty: 4m, price: 100m), MakeSale(p1, qty: 1m, price: 100m), }, }; using var post = await api.Http.PostAsJsonAsync("/api/pos/v1/sales", batch); post.IsSuccessStatusCode.Should().BeTrue($"POST вернул {(int)post.StatusCode}: {await post.Content.ReadAsStringAsync()}"); var resp = await post.Content.ReadFromJsonAsync(); resp!.Accepted.Should().HaveCount(5); resp.Failed.Should().BeEmpty(); resp.ReplayedFromCache.Should().BeFalse(); // 20 − (2+3+1+4+1) = 9 на складе. (await api.StockOfAsync(refs.StoreId, p1)).Should().Be(9m); } /// Ключевой тест задачи: повтор того же батча → no duplicates, /// тот же ответ, остаток не уменьшается дважды. [Fact] public async Task Replay_same_batch_returns_cached_response_no_duplicates() { var api = new ApiActor(_factory.CreateClient()); await api.SignupAndLoginAsync($"pos-idem-{Guid.NewGuid():N}"); var refs = await api.LoadRefsAsync(); var p1 = await api.CreateProductAsync(refs, $"P-{Guid.NewGuid():N}", 100m, RandomBarcode()); 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 = p1, quantity = 10m, unitCost = 50m } }, }); enter.EnsureSuccessStatusCode(); var eid = (await enter.Content.ReadFromJsonAsync()).GetProperty("id").GetString(); (await api.Http.PostAsJsonAsync($"/api/inventory/enters/{eid}/post", new { })).EnsureSuccessStatusCode(); var batch = new PosSaleBatchDto { IdempotencyKey = Guid.NewGuid(), Sales = new[] { MakeSale(p1, qty: 3m, price: 100m), MakeSale(p1, qty: 2m, price: 100m), }, }; using var first = await api.Http.PostAsJsonAsync("/api/pos/v1/sales", batch); first.EnsureSuccessStatusCode(); var r1 = await first.Content.ReadFromJsonAsync(); r1!.Accepted.Should().HaveCount(2); r1.ReplayedFromCache.Should().BeFalse(); (await api.StockOfAsync(refs.StoreId, p1)).Should().Be(5m); using var second = await api.Http.PostAsJsonAsync("/api/pos/v1/sales", batch); second.EnsureSuccessStatusCode(); var r2 = await second.Content.ReadFromJsonAsync(); r2!.Accepted.Should().HaveCount(2); r2.ReplayedFromCache.Should().BeTrue(); // ServerSaleId должны совпасть — это тот же чек, что и в первом ответе. r2.Accepted.Select(a => a.ServerSaleId).Should().BeEquivalentTo(r1.Accepted.Select(a => a.ServerSaleId)); // Stock не списался дважды. (await api.StockOfAsync(refs.StoreId, p1)).Should().Be(5m); } /// Per-sale ClientSaleId идемпотентность: если ту же продажу /// прислали в РАЗНЫХ батчах (с разными idempotency-key'ями), сервер всё /// равно вернёт ссылку на ранее созданный чек, не создавая дубль. [Fact] public async Task ClientSaleId_idempotency_across_batches() { var api = new ApiActor(_factory.CreateClient()); await api.SignupAndLoginAsync($"pos-csid-{Guid.NewGuid():N}"); var refs = await api.LoadRefsAsync(); var p1 = await api.CreateProductAsync(refs, $"P-{Guid.NewGuid():N}", 100m, RandomBarcode()); 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 = p1, quantity = 10m, unitCost = 50m } }, }); enter.EnsureSuccessStatusCode(); var eid = (await enter.Content.ReadFromJsonAsync()).GetProperty("id").GetString(); (await api.Http.PostAsJsonAsync($"/api/inventory/enters/{eid}/post", new { })).EnsureSuccessStatusCode(); var sharedSale = MakeSale(p1, qty: 4m, price: 100m); var batch1 = new PosSaleBatchDto { IdempotencyKey = Guid.NewGuid(), Sales = new[] { sharedSale } }; var batch2 = new PosSaleBatchDto { IdempotencyKey = Guid.NewGuid(), Sales = new[] { sharedSale } }; var r1 = await (await api.Http.PostAsJsonAsync("/api/pos/v1/sales", batch1)).Content.ReadFromJsonAsync(); var r2 = await (await api.Http.PostAsJsonAsync("/api/pos/v1/sales", batch2)).Content.ReadFromJsonAsync(); r1!.Accepted.Should().HaveCount(1); r2!.Accepted.Should().HaveCount(1); r2.Accepted[0].ServerSaleId.Should().Be(r1.Accepted[0].ServerSaleId); (await api.StockOfAsync(refs.StoreId, p1)).Should().Be(6m, "товар списался 1 раз"); } [Fact] public async Task Insufficient_stock_goes_to_failed_not_accepted() { var api = new ApiActor(_factory.CreateClient()); await api.SignupAndLoginAsync($"pos-short-{Guid.NewGuid():N}"); var refs = await api.LoadRefsAsync(); var p1 = await api.CreateProductAsync(refs, $"P-{Guid.NewGuid():N}", 100m, RandomBarcode()); // Не подкармливаем — остаток 0. var batch = new PosSaleBatchDto { IdempotencyKey = Guid.NewGuid(), Sales = new[] { MakeSale(p1, qty: 3m, price: 100m) }, }; using var post = await api.Http.PostAsJsonAsync("/api/pos/v1/sales", batch); post.EnsureSuccessStatusCode(); var resp = await post.Content.ReadFromJsonAsync(); resp!.Accepted.Should().BeEmpty(); resp.Failed.Should().HaveCount(1); resp.Failed[0].Error.Should().Contain("Недостаточный остаток"); } [Fact] public async Task Tenant_isolation_pos_sync() { var a = new ApiActor(_factory.CreateClient()); var b = new ApiActor(_factory.CreateClient()); await a.SignupAndLoginAsync($"pos-iso-a-{Guid.NewGuid():N}"); await b.SignupAndLoginAsync($"pos-iso-b-{Guid.NewGuid():N}"); var refsA = await a.LoadRefsAsync(); var pA = await a.CreateProductAsync(refsA, $"PA-{Guid.NewGuid():N}", 100m, RandomBarcode()); var syncB = await (await b.Http.GetAsync("/api/pos/v1/sync")).Content.ReadFromJsonAsync(); syncB!.Products.Should().NotContain(p => p.Id.ToString() == pA); } private static PosSaleDto MakeSale(string productId, decimal qty, decimal price) => new() { ClientSaleId = Guid.NewGuid(), OccurredAt = DateTime.UtcNow, Payment = 0, PaidCash = qty * price, PaidCard = 0m, Lines = new[] { new PosSaleLineDto { ProductId = Guid.Parse(productId), Quantity = qty, UnitPrice = price, Discount = 0m, VatPercent = 12m, }, }, }; }