using FluentAssertions; using foodmarket.Pos.Core.Data; using foodmarket.Pos.Core.Logic; using foodmarket.Pos.Core.Models; using foodmarket.Pos.Core.Sync; using foodmarket.Shared.Pos.V1; using Xunit; namespace foodmarket.UnitTests; public class PosBarcodeTests { [Fact] public void Normal_barcode_is_not_weighted() { var r = BarcodeParser.Parse("4870204010101"); r.IsWeighted.Should().BeFalse(); r.Lookup.Should().Be("4870204010101"); } [Fact] public void Weight_prefix_20_extracts_plu_and_kg() { // 21 | 12345 | 01500 | K → PLU=12345, вес 1.500 кг var r = BarcodeParser.Parse("2112345015007"); r.IsWeighted.Should().BeTrue(); r.Lookup.Should().Be("12345"); r.EmbeddedWeight.Should().Be(1.500m); r.EmbeddedPrice.Should().BeNull(); } [Fact] public void Price_prefix_25_extracts_plu_and_price() { // 25 | 54321 | 12345 | K → PLU=54321, цена 123.45 ₸ var r = BarcodeParser.Parse("2554321123456"); r.IsWeighted.Should().BeTrue(); r.Lookup.Should().Be("54321"); r.EmbeddedPrice.Should().Be(123.45m); r.EmbeddedWeight.Should().BeNull(); } } public class PosCartTests { private static PosProduct Piece(decimal price = 500m) => new() { Id = Guid.NewGuid(), Name = "Шт товар", Barcodes = new[] { "111" }, UnitCode = "шт", Packaging = PosPackaging.Piece, VatPercent = 12m, VatEnabled = true, Price = price }; private static PosProduct Weighed(decimal price = 1000m) => new() { Id = Guid.NewGuid(), Name = "Вес товар", Barcodes = new[] { "222" }, UnitCode = "кг", Packaging = PosPackaging.Weight, VatPercent = 12m, VatEnabled = true, Price = price }; [Fact] public void Piece_scans_increment_quantity() { var c = new Cart(); var p = Piece(); c.AddProduct(p); c.AddProduct(p); c.AddProduct(p); c.Lines.Should().HaveCount(1); c.Lines[0].Quantity.Should().Be(3); c.Totals().Total.Should().Be(1500m); } [Fact] public void Weighed_requires_weight_and_is_separate_line() { var c = new Cart(); var w = Weighed(1000m); c.AddProduct(w, 1.5m); c.AddProduct(w, 0.25m); c.Lines.Should().HaveCount(2); c.Totals().Total.Should().Be(1500m + 250m); } [Fact] public void Line_and_cart_discounts_apply_and_clamp() { var c = new Cart(); var p = Piece(500m); var line = c.AddProduct(p, 4m); // 2000 c.SetLineDiscount(line, 300m); // 1700 c.SetCartDiscount(200m); // 1500 c.Totals().Total.Should().Be(1500m); // перебор скидки обрезается c.SetLineDiscount(line, 999999m); c.Lines[0].Discount.Should().Be(2000m); } [Fact] public void Remove_and_clear() { var c = new Cart(); var line = c.AddProduct(Piece()); c.Remove(line); c.IsEmpty.Should().BeTrue(); } } public class PosPaymentShiftTests { [Fact] public void Cash_overpay_gives_change() { var r = PaymentMath.Compute(1500m, paidCash: 2000m, paidCard: 0m); r.Sufficient.Should().BeTrue(); r.Change.Should().Be(500m); } [Fact] public void Mixed_exact_no_change() { var r = PaymentMath.Compute(1500m, paidCash: 500m, paidCard: 1000m); r.Sufficient.Should().BeTrue(); r.Change.Should().Be(0m); } [Fact] public void Underpay_is_insufficient_with_shortfall() { var r = PaymentMath.Compute(1500m, paidCash: 1000m, paidCard: 0m); r.Sufficient.Should().BeFalse(); r.Shortfall.Should().Be(500m); } [Fact] public void Shift_expected_and_difference() { var exp = ShiftMath.ExpectedCash(5000m, 3775m, 1500m, 10000m, 3000m); exp.Should().Be(14275m); ShiftMath.CashDifference(14775m, exp).Should().Be(500m); } } public class PosStoreTests { private static PosSyncResponse Sync(params (Guid id, string name, string bc, decimal price, int pack)[] items) => new() { ServerTime = DateTime.UtcNow, Products = items.Select(i => new ProductSyncDto { Id = i.id, Name = i.name, Barcodes = new[] { i.bc }, UnitCode = i.pack == 2 ? "кг" : "шт", Packaging = i.pack, VatPercent = 12m, VatEnabled = true, IsMarked = false, IsArchived = false, UpdatedAt = DateTime.UtcNow, }).ToList(), Prices = items.Select(i => new PriceSyncDto { ProductId = i.id, PriceTypeId = Guid.NewGuid(), PriceTypeName = "Розн", IsSystem = true, Amount = i.price, CurrencyCode = "KZT", UpdatedAt = DateTime.UtcNow, }).ToList(), Stocks = Array.Empty(), Counterparties = Array.Empty(), DeletedProductIds = Array.Empty(), }; [Fact] public void Catalog_find_by_barcode_and_name() { using var s = PosStore.InMemory(); var id = Guid.NewGuid(); s.ApplySync(Sync((id, "Молоко 2.5%", "4870001112223", 450m, 1))); s.ProductCount().Should().Be(1); s.FindByBarcode("4870001112223")!.Name.Should().Be("Молоко 2.5%"); s.FindByBarcode("4870001112223")!.Price.Should().Be(450m); s.SearchByName("молок").Should().ContainSingle(); } [Fact] public void Queue_enqueue_pending_marksent() { using var s = PosStore.InMemory(); var sale = new PosSaleDto { ClientSaleId = Guid.NewGuid(), OccurredAt = DateTime.UtcNow, Payment = 0, PaidCash = 100m, PaidCard = 0m, Lines = new[] { new PosSaleLineDto { ProductId = Guid.NewGuid(), Quantity = 1m, UnitPrice = 100m, Discount = 0m } }, }; s.EnqueueSale(sale); s.EnqueueSale(sale); // повтор не дублирует (PK ClientSaleId) s.PendingCount().Should().Be(1); s.MarkSent(sale.ClientSaleId); s.PendingCount().Should().Be(0); } } public class PosSyncKeyTests { [Fact] public void Batch_key_is_stable_for_same_set_regardless_of_order() { var a = Guid.NewGuid(); var b = Guid.NewGuid(); SyncService.StableBatchKey(new[] { a, b }).Should().Be(SyncService.StableBatchKey(new[] { b, a })); } [Fact] public void Batch_key_differs_for_different_sets() { SyncService.StableBatchKey(new[] { Guid.NewGuid() }) .Should().NotBe(SyncService.StableBatchKey(new[] { Guid.NewGuid() })); } } /// Офлайн-сценарий задачи (п.8): нет сети → кассир пробивает 5 чеков → /// появляется сеть → синхронизация → ровно 5 продаж на «сервере», без дублей /// даже при повторном пуше. Прогоняется против фейкового IServerApi, который /// повторяет серверную идемпотентность по ClientSaleId. public class PosOfflineFlowTests { /// Фейковый сервер: хранит принятые продажи по ClientSaleId. /// Повторный приём той же продажи не создаёт дубля (как реальный сервер). private sealed class FakeServer : IServerApi { public readonly Dictionary ServerByClient = new(); public int BatchCalls; public bool Online; public Task AcceptSalesAsync(PosSaleBatchDto batch, CancellationToken ct = default) { if (!Online) throw new HttpRequestException("нет сети"); BatchCalls++; bool anyNew = false; var accepted = new List(); foreach (var s in batch.Sales) { if (!ServerByClient.TryGetValue(s.ClientSaleId, out var sid)) { sid = Guid.NewGuid(); ServerByClient[s.ClientSaleId] = sid; anyNew = true; } accepted.Add(new PosSaleAcceptedDto { ClientSaleId = s.ClientSaleId, ServerSaleId = sid, ServerSaleNumber = $"ПР-{sid:N}".Substring(0, 10) }); } return Task.FromResult(new PosSaleBatchResponse { IdempotencyKey = batch.IdempotencyKey, Accepted = accepted, Failed = Array.Empty(), ReplayedFromCache = !anyNew, }); } public Task VersionAsync(CancellationToken ct = default) => throw new NotImplementedException(); public Task SyncAsync(DateTime? since, CancellationToken ct = default) => throw new NotImplementedException(); public Task MeAsync(CancellationToken ct = default) => throw new NotImplementedException(); public Task> RetailPointsAsync(int pageSize = 200, CancellationToken ct = default) => throw new NotImplementedException(); public Task> MoneyAccountsAsync(int pageSize = 200, CancellationToken ct = default) => throw new NotImplementedException(); public Task> CurrentShiftAsync(Guid retailPointId, CancellationToken ct = default) => throw new NotImplementedException(); public Task OpenShiftAsync(OpenShiftBody body, CancellationToken ct = default) => throw new NotImplementedException(); public Task CloseShiftAsync(Guid id, CloseShiftBody body, CancellationToken ct = default) => throw new NotImplementedException(); public Task DrawerAsync(Guid id, DrawerBody body, CancellationToken ct = default) => throw new NotImplementedException(); public Task ShiftReportAsync(Guid id, CancellationToken ct = default) => throw new NotImplementedException(); } private static PosSaleDto Sale() => new() { ClientSaleId = Guid.NewGuid(), OccurredAt = DateTime.UtcNow, Payment = 0, PaidCash = 500m, PaidCard = 0m, Lines = new[] { new PosSaleLineDto { ProductId = Guid.NewGuid(), Quantity = 1m, UnitPrice = 500m, Discount = 0m } }, }; [Fact] public async Task No_network_then_five_sales_then_sync_yields_exactly_five_no_duplicates() { using var store = PosStore.InMemory(); var server = new FakeServer { Online = false }; var sync = new SyncService(store, server); // 1) Сети нет — попытка пуша кидает, очередь не теряется. store.EnqueueSale(Sale()); await Assert.ThrowsAnyAsync(() => sync.PushSalesAsync()); server.ServerByClient.Should().BeEmpty("офлайн — на сервер ничего не ушло"); // 2) Кассир пробивает ещё 4 чека офлайн — всего 5 в очереди. for (int i = 0; i < 4; i++) store.EnqueueSale(Sale()); store.PendingCount().Should().Be(5); // 3) Появилась сеть — синхронизация. server.Online = true; var push = await sync.PushSalesAsync(); push.Accepted.Should().Be(5); push.Failed.Should().Be(0); store.PendingCount().Should().Be(0, "все отправленные помечены sent"); server.ServerByClient.Should().HaveCount(5, "на сервере ровно 5 чеков"); // 4) Повторный пуш (например, двойной клик/ретрай) не плодит дублей. var replay = await sync.PushSalesAsync(); replay.Accepted.Should().Be(0, "очередь уже пуста — слать нечего"); server.ServerByClient.Should().HaveCount(5, "по-прежнему ровно 5"); } [Fact] public async Task Resend_of_same_queue_is_idempotent_on_server() { using var store = PosStore.InMemory(); var server = new FakeServer { Online = true }; var sync = new SyncService(store, server); for (int i = 0; i < 5; i++) store.EnqueueSale(Sale()); // Эмулируем: пуш прошёл на сервере, но ответ не дошёл (касса не пометила sent). var pending = store.PendingSales(); await server.AcceptSalesAsync(new PosSaleBatchDto { IdempotencyKey = SyncService.StableBatchKey(pending.Select(s => s.ClientSaleId)), Sales = pending }); server.ServerByClient.Should().HaveCount(5); // Касса повторяет весь набор — сервер распознаёт по ClientSaleId, дублей нет. var push = await sync.PushSalesAsync(); push.Accepted.Should().Be(5); server.ServerByClient.Should().HaveCount(5, "повтор того же набора не создал новых чеков"); store.PendingCount().Should().Be(0); } } /// Сборка серверного чека из корзины: распределение скидки на чек по /// строкам (сервер понимает только построчную скидку), способ оплаты, привязка /// к смене/точке, возвраты. public class PosCheckoutTests { private static PosProduct P(decimal price, bool weighed = false) => new() { Id = Guid.NewGuid(), Name = "Товар", Barcodes = new[] { "1" }, UnitCode = weighed ? "кг" : "шт", Packaging = weighed ? PosPackaging.Weight : PosPackaging.Piece, VatPercent = 12m, VatEnabled = true, Price = price, }; [Fact] public void Cart_discount_is_distributed_across_lines_and_sums_exactly() { var cart = new Cart(); cart.AddProduct(P(100m), 1); // 100 cart.AddProduct(P(200m), 1); // 200 cart.AddProduct(P(700m), 1); // 700 → сумма 1000 cart.SetCartDiscount(100m); // 10% на чек var sale = Checkout.BuildSale(cart, paidCash: 900m, paidCard: 0m, new CheckoutContext(null, null, null)); // Сумма построчных скидок точно равна скидке на чек. sale.Lines.Sum(l => l.Discount).Should().Be(100m); // Итог сервера (Σ qty*price − Σ disc) совпадает с итогом кассы. var serverTotal = sale.Lines.Sum(l => l.Quantity * l.UnitPrice - l.Discount); serverTotal.Should().Be(cart.Totals().Total); } [Fact] public void Payment_method_is_inferred_from_cash_and_card() { var cart = new Cart(); cart.AddProduct(P(500m), 1); Checkout.BuildSale(cart, 500m, 0m, new(null, null, null)).Payment.Should().Be((int)PosPaymentMethod.Cash); Checkout.BuildSale(cart, 0m, 500m, new(null, null, null)).Payment.Should().Be((int)PosPaymentMethod.Card); Checkout.BuildSale(cart, 200m, 300m, new(null, null, null)).Payment.Should().Be((int)PosPaymentMethod.Mixed); } [Fact] public void Shift_and_point_context_is_carried_into_sale() { var cart = new Cart(); cart.AddProduct(P(500m), 1); var rp = Guid.NewGuid(); var shift = Guid.NewGuid(); var cashier = Guid.NewGuid(); var sale = Checkout.BuildSale(cart, 500m, 0m, new(cashier, rp, shift)); sale.RetailPointId.Should().Be(rp); sale.CashShiftId.Should().Be(shift); sale.CashierUserId.Should().Be(cashier); sale.IsReturn.Should().BeFalse(); } [Fact] public void Return_sets_flag_and_reference() { var cart = new Cart(); cart.AddProduct(P(500m), 1); var refSale = Guid.NewGuid(); var sale = Checkout.BuildSale(cart, 0m, 0m, new(null, null, null), isReturn: true, referenceSaleId: refSale); sale.IsReturn.Should().BeTrue(); sale.ReferenceSaleId.Should().Be(refSale); } [Fact] public void Empty_cart_throws() { var cart = new Cart(); var act = () => Checkout.BuildSale(cart, 0m, 0m, new(null, null, null)); act.Should().Throw(); } } public class PosUpdateCheckTests { [Theory] [InlineData("2026.10.7", "2026.10.8", true)] [InlineData("2026.10.8", "2026.10.8", false)] [InlineData("2026.10.8", "2026.10.7", false)] [InlineData("1.9.0", "1.10.0", true)] [InlineData("2026.9.30", "2026.10.1", true)] [InlineData(null, "2026.10.1", true)] [InlineData("2026.10.1", null, false)] [InlineData("1.2.3", "1.2.3-beta", false)] public void Version_comparison(string? local, string? remote, bool expected) => UpdateCheck.IsNewer(local, remote).Should().Be(expected); }