food-market/tests/food-market.IntegrationTests/NegativeStockTests.cs
nns 560d52e8cd
Some checks are pending
Auto-tag / Create date-tag (push) Waiting to run
CI / Backend (.NET 8) (push) Waiting to run
CI / Web (React + Vite) (push) Waiting to run
CI / POS (WPF, Windows) (push) Waiting to run
Docker API / Build + push API (push) Waiting to run
Docker API / Deploy API on stage (push) Blocked by required conditions
Docker Web / Build + push Web (push) Waiting to run
Docker Web / Deploy Web on stage (push) Blocked by required conditions
feat(notifications): Web Push (VAPID) — браузерный push о минусах
Канал Web Push для центра уведомлений (item 5). Ключи VAPID генерируются один раз
и хранятся в platform_settings (без ручной настройки секретов); публичный отдаётся
фронту. Таблица web_push_subscriptions (Phase42) + /api/web-push/{public-key,
subscribe,unsubscribe}. Отправка через web-push-csharp из фонового джоба минусов
(best-effort, протухшие подписки чистятся). SW: обработчики push/notificationclick
(am-v3→am-v4), клик открывает нужный путь. Кнопка «Вкл. push» в колокольчике.
Работает в т.ч. в iOS Safari для PWA на домашнем экране.

Тесты: integration — публичный ключ стабилен, подписка идемпотентна, 400/unsubscribe
(4/4 группы); unit 299/299.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-09 03:49:32 +05:00

192 lines
11 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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<JsonElement>()).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<JsonElement>()).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<JsonElement>();
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<JsonElement>()).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<JsonElement>()).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<NegativeStockNotificationJob>();
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<NegativeStockNotificationJob>();
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);
}
}