using System.Net.Http.Json; using System.Text.Json; using FluentAssertions; using foodmarket.Api.Background; using foodmarket.IntegrationTests.Support; using Microsoft.Extensions.DependencyInjection; using Xunit; namespace foodmarket.IntegrationTests; [Collection(ApiCollection.Name)] public class NegativeStockTests { private readonly ApiFactory _factory; public NegativeStockTests(ApiFactory factory) => _factory = factory; private static string RandomBarcode() => string.Concat(Enumerable.Range(0, 13).Select(_ => Random.Shared.Next(0, 10))); private async Task SeedStockAsync(ApiActor api, ApiActor.Refs refs, string productId, decimal qty) { 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 = productId, quantity = qty, unitCost = 50m } }, }); enter.EnsureSuccessStatusCode(); var eid = (await enter.Content.ReadFromJsonAsync()).GetProperty("id").GetString(); (await api.Http.PostAsJsonAsync($"/api/inventory/enters/{eid}/post", new { })).EnsureSuccessStatusCode(); } [Fact] public async Task Loss_into_negative_is_allowed_creates_alert_recount_and_resolves_on_restock() { var api = new ApiActor(_factory.CreateClient()); await api.SignupAndLoginAsync($"neg-{Guid.NewGuid():N}"); var refs = await api.LoadRefsAsync(); var p = await api.CreateProductAsync(refs, $"P-{Guid.NewGuid():N}", 100m, RandomBarcode()); // 5 шт на складе, минусов нет. await SeedStockAsync(api, refs, p, 5m); (await api.StockOfAsync(refs.StoreId, p)).Should().Be(5m); var c0 = await api.GetJsonAsync("/api/negative-stock/count"); c0.GetProperty("total").GetInt32().Should().Be(0); // Списание 8 → остаток −3. Списание в минус РАЗРЕШЕНО (решение владельца). var loss = await api.Http.PostAsJsonAsync("/api/inventory/losses", new { date = DateTime.UtcNow, storeId = refs.StoreId, currencyId = refs.CurrencyId, reason = 0, notes = "тест минус", lines = new[] { new { productId = p, quantity = 8m, unitCost = 50m } }, }); loss.EnsureSuccessStatusCode(); var lid = (await loss.Content.ReadFromJsonAsync()).GetProperty("id").GetString(); using var lpost = await api.Http.PostAsJsonAsync($"/api/inventory/losses/{lid}/post", new { }); lpost.IsSuccessStatusCode.Should().BeTrue($"списание в минус разрешено: {(int)lpost.StatusCode} {await lpost.Content.ReadAsStringAsync()}"); (await api.StockOfAsync(refs.StoreId, p)).Should().Be(-3m); // Предупреждение создано. var c1 = await api.GetJsonAsync("/api/negative-stock/count"); c1.GetProperty("total").GetInt32().Should().Be(1); var alerts = await api.ListAsync("/api/negative-stock"); alerts.Should().ContainSingle(); alerts[0].GetProperty("quantity").GetDecimal().Should().Be(-3m); alerts[0].GetProperty("productId").GetString().Should().Be(p); alerts[0].GetProperty("causeDocType").GetString().Should().Be("loss"); // «Пересчитать эти товары» → черновик инвентаризации с этим товаром. var rc = await api.Http.PostAsJsonAsync("/api/negative-stock/recount", new { storeId = refs.StoreId }); rc.EnsureSuccessStatusCode(); var rcj = await rc.Content.ReadFromJsonAsync(); rcj.GetProperty("lines").GetInt32().Should().Be(1); var invId = rcj.GetProperty("inventoryId").GetString(); var inv = await api.GetJsonAsync($"/api/inventory/inventories/{invId}"); inv.GetProperty("lines").EnumerateArray() .Should().ContainSingle(l => l.GetProperty("productId").GetString() == p); // Довоз 3 → остаток 0 → предупреждение закрылось автоматически. await SeedStockAsync(api, refs, p, 3m); (await api.StockOfAsync(refs.StoreId, p)).Should().Be(0m); var c2 = await api.GetJsonAsync("/api/negative-stock/count"); c2.GetProperty("total").GetInt32().Should().Be(0); (await api.ListAsync("/api/negative-stock")).Should().BeEmpty("минус закрыт — алерт resolved"); } [Fact] public async Task Negative_stock_report_filter_returns_only_negatives() { var api = new ApiActor(_factory.CreateClient()); await api.SignupAndLoginAsync($"neg-rep-{Guid.NewGuid():N}"); var refs = await api.LoadRefsAsync(); var p = await api.CreateProductAsync(refs, $"P-{Guid.NewGuid():N}", 100m, RandomBarcode()); await SeedStockAsync(api, refs, p, 2m); var loss = await api.Http.PostAsJsonAsync("/api/inventory/losses", new { date = DateTime.UtcNow, storeId = refs.StoreId, currencyId = refs.CurrencyId, reason = 0, notes = "minus", lines = new[] { new { productId = p, quantity = 5m, unitCost = 50m } }, }); loss.EnsureSuccessStatusCode(); var lid = (await loss.Content.ReadFromJsonAsync()).GetProperty("id").GetString(); (await api.Http.PostAsJsonAsync($"/api/inventory/losses/{lid}/post", new { })).EnsureSuccessStatusCode(); var neg = await api.GetJsonAsync("/api/inventory/stock?negativeOnly=true&pageSize=200"); var items = neg.GetProperty("items").EnumerateArray().ToList(); items.Should().NotBeEmpty(); items.Should().OnlyContain(x => x.GetProperty("quantity").GetDecimal() < 0); } [Fact] public async Task Notify_job_creates_persistent_notification_for_admin_and_marks_read() { var api = new ApiActor(_factory.CreateClient()); await api.SignupAndLoginAsync($"neg-notif-{Guid.NewGuid():N}"); var refs = await api.LoadRefsAsync(); var p = await api.CreateProductAsync(refs, $"P-{Guid.NewGuid():N}", 100m, RandomBarcode()); // Уходим в минус (−4). await SeedStockAsync(api, refs, p, 1m); var loss = await api.Http.PostAsJsonAsync("/api/inventory/losses", new { date = DateTime.UtcNow, storeId = refs.StoreId, currencyId = refs.CurrencyId, reason = 0, notes = "minus", lines = new[] { new { productId = p, quantity = 5m, unitCost = 50m } }, }); loss.EnsureSuccessStatusCode(); var lid = (await loss.Content.ReadFromJsonAsync()).GetProperty("id").GetString(); (await api.Http.PostAsJsonAsync($"/api/inventory/losses/{lid}/post", new { })).EnsureSuccessStatusCode(); // До прогона джоба — уведомлений нет. (await api.GetJsonAsync("/api/notifications/count")).GetProperty("unread").GetInt32().Should().Be(0); // Прогоняем фоновый джоб (агрегация по складам) напрямую. using (var scope = _factory.Services.CreateScope()) { var job = scope.ServiceProvider.GetRequiredService(); await job.RunAsync(); } // Админу (он же владелец) создано персистентное уведомление «в минус». var list = await api.GetJsonAsync("/api/notifications"); list.GetProperty("unread").GetInt32().Should().BeGreaterThanOrEqualTo(1); var mine = list.GetProperty("items").EnumerateArray() .Where(x => x.GetProperty("type").GetString() == "negative-stock").ToList(); mine.Should().NotBeEmpty("джоб агрегирует минусы по складу в одно уведомление"); mine[0].GetProperty("title").GetString().Should().Contain("минус"); mine[0].GetProperty("storeId").GetString().Should().Be(refs.StoreId); // Повторный прогон в пределах интервала — новых уведомлений по складу нет (дебаунс). var unreadAfterFirst = (await api.GetJsonAsync("/api/notifications/count")).GetProperty("unread").GetInt32(); using (var scope = _factory.Services.CreateScope()) { var job = scope.ServiceProvider.GetRequiredService(); await job.RunAsync(); } (await api.GetJsonAsync("/api/notifications/count")).GetProperty("unread").GetInt32() .Should().Be(unreadAfterFirst, "дебаунс: в пределах интервала повторно не шлём"); // Пометка «прочитано всё» обнуляет счётчик. (await api.Http.PostAsJsonAsync("/api/notifications/read-all", new { })).EnsureSuccessStatusCode(); (await api.GetJsonAsync("/api/notifications/count")).GetProperty("unread").GetInt32().Should().Be(0); } [Fact] public async Task WebPush_public_key_generated_and_subscription_roundtrips() { var api = new ApiActor(_factory.CreateClient()); await api.SignupAndLoginAsync($"wp-{Guid.NewGuid():N}"); // Публичный VAPID-ключ генерируется при первом обращении и стабилен. var k1 = (await api.GetJsonAsync("/api/web-push/public-key")).GetProperty("publicKey").GetString(); k1.Should().NotBeNullOrEmpty(); var k2 = (await api.GetJsonAsync("/api/web-push/public-key")).GetProperty("publicKey").GetString(); k2.Should().Be(k1, "ключ хранится в platform_settings, не перегенерируется"); // Подписка идемпотентна по endpoint. var sub = new { endpoint = "https://push.example.com/" + Guid.NewGuid().ToString("N"), keys = new { p256dh = "BBindkeyp256dhExampleValue", auth = "authSecretExample" } }; (await api.Http.PostAsJsonAsync("/api/web-push/subscribe", sub)).StatusCode .Should().Be(System.Net.HttpStatusCode.NoContent); (await api.Http.PostAsJsonAsync("/api/web-push/subscribe", sub)).StatusCode .Should().Be(System.Net.HttpStatusCode.NoContent); // Невалидная подписка → 400. (await api.Http.PostAsJsonAsync("/api/web-push/subscribe", new { endpoint = "", keys = new { p256dh = "", auth = "" } })).StatusCode .Should().Be(System.Net.HttpStatusCode.BadRequest); // Снятие подписки. (await api.Http.PostAsJsonAsync("/api/web-push/unsubscribe", new { endpoint = sub.endpoint })).StatusCode .Should().Be(System.Net.HttpStatusCode.NoContent); } }