CauseUserId в уведомлениях о минусах был null: StockService писал автора только если вызывающий явно передал CreatedByUserId в черновике, а большинство документов (касса/списание/перемещение/приёмка/…) его не передавали. Теперь автор берётся из черновика, иначе — из текущего пользователя (ITenantContext.UserId) в единой точке ApplyMovementAsync, поэтому пишется и в StockMovement, и в алерт для всех типов документов. Тест: минус от списания → causeUserId = текущий юзер. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
198 lines
11 KiB
C#
198 lines
11 KiB
C#
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");
|
||
// Автор документа записан (раньше CauseUserId был null). Сверяем с /api/me.
|
||
var me = await api.GetJsonAsync("/api/me");
|
||
var myId = me.GetProperty("sub").GetString();
|
||
alerts[0].TryGetProperty("causeUserId", out var cu).Should().BeTrue();
|
||
cu.GetString().Should().NotBeNullOrEmpty("автор минусового документа должен быть записан");
|
||
cu.GetString().Should().Be(myId, "автор — текущий пользователь");
|
||
|
||
// «Пересчитать эти товары» → черновик инвентаризации с этим товаром.
|
||
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);
|
||
}
|
||
}
|