food-market/tests/food-market.IntegrationTests/NegativeStockTests.cs
nns 9dc8f6e044 fix(inventory): записывать автора движения (CauseUserId) для всех документов
CauseUserId в уведомлениях о минусах был null: StockService писал автора только
если вызывающий явно передал CreatedByUserId в черновике, а большинство
документов (касса/списание/перемещение/приёмка/…) его не передавали. Теперь
автор берётся из черновика, иначе — из текущего пользователя (ITenantContext.UserId)
в единой точке ApplyMovementAsync, поэтому пишется и в StockMovement, и в алерт
для всех типов документов. Тест: минус от списания → causeUserId = текущий юзер.

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

198 lines
11 KiB
C#
Raw Permalink 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");
// Автор документа записан (раньше 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);
}
}