food-market/tests/food-market.IntegrationTests/SignalRNotificationsTests.cs
nns dd2e1e7af2
Some checks are pending
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(realtime): SignalR hub /hubs/notifications per-org + dashboard live
P2-7 Sprint 8 пункт 1.

Backend:
- src/food-market.api/Realtime/NotificationsHub.cs — SignalR-хаб, группы
  org:{orgId:N}. JWT через Authorization-хедер (стандартно) или через
  query ?access_token=... (для WebSocket — браузерные не могут слать
  кастомные хедеры). SuperAdmin override через ?orgOverride=<id>.
- NotificationsPublisher.cs — singleton, IHubContext-обёртка.
- Program.cs — AddSignalR + MapHub. Middleware копирует ?access_token=
  в Authorization для /hubs/* до UseAuthentication.
- RetailSalesController.Post → публикует SalePosted + LowStockPayload
  если после движения товара остаток < MinStock. Best-effort: notify
  ошибка не валит транзакцию.
- SuppliesController.Post → SupplyPosted.

Events (camelCase в JSON):
- SalePosted { saleId, number, total, storeId, cashierName, retailPointId, postedAt }
- SupplyPosted { supplyId, number, total, supplierId, supplierName, postedAt }
- LowStock { productId, productName, storeId, storeName, quantity, minStock }

Web:
- @microsoft/signalr 10.0.0 client.
- src/lib/useNotificationsHub.ts — hook с автореконнектом, accessTokenFactory.
- DashboardPage:
  • liveRevenueDelta / liveCountDelta — оптимистическое приращение
    «Выручка сегодня» сразу при SalePosted (до refetch stats);
  • toast.info на SupplyPosted; toast.error на LowStock;
  • Wifi/WifiOff индикатор в header.

Тесты:
- SignalRNotificationsTests: A постит retail-sale → A получает SalePosted,
  B (другая org) НЕ получает — multi-tenant. ✓ 1/1 локально.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-31 19:29:59 +05:00

157 lines
7.9 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 FluentAssertions;
using foodmarket.IntegrationTests.Support;
using Microsoft.AspNetCore.SignalR.Client;
using Xunit;
namespace foodmarket.IntegrationTests;
/// <summary>SignalR-хаб per-org. После Post-a розничной продажи владелец org
/// получает событие <c>SalePosted</c>, владелец другой org — НЕТ (multi-tenant).
/// Тест поднимает реальный API через WebApplicationFactory, использует TestServer
/// в качестве handler'a SignalR-клиента.</summary>
[Collection(ApiCollection.Name)]
public class SignalRNotificationsTests
{
private readonly ApiFactory _factory;
public SignalRNotificationsTests(ApiFactory factory) => _factory = factory;
[Fact]
public async Task Sale_posted_emits_event_to_own_org_only()
{
var actorA = new ApiActor(_factory.CreateClient());
var emailA = $"sigA-{Guid.NewGuid():N}@example.kz";
(await actorA.SignupAsync(emailA, "Passw0rd!", "SigA Org")).EnsureSuccessStatusCode();
var tokenA = await actorA.TokenAsync(emailA, "Passw0rd!");
actorA.UseToken(tokenA);
var actorB = new ApiActor(_factory.CreateClient());
var emailB = $"sigB-{Guid.NewGuid():N}@example.kz";
(await actorB.SignupAsync(emailB, "Passw0rd!", "SigB Org")).EnsureSuccessStatusCode();
var tokenB = await actorB.TokenAsync(emailB, "Passw0rd!");
actorB.UseToken(tokenB);
// Поднимаем хаб для обеих org через WebApplicationFactory-handler.
var hubA = await BuildHubAsync(tokenA);
var hubB = await BuildHubAsync(tokenB);
var aGotSale = new TaskCompletionSource<System.Text.Json.JsonElement>();
var bGotSale = new TaskCompletionSource<System.Text.Json.JsonElement>();
hubA.On<System.Text.Json.JsonElement>("SalePosted", p => aGotSale.TrySetResult(p));
hubB.On<System.Text.Json.JsonElement>("SalePosted", p => bGotSale.TrySetResult(p));
// Засеем у A продукт + остаток через приёмку.
var (productId, storeId) = await SeedProductWithStockAsync(actorA);
// A создаёт чек и проводит. Currency / retail-point — те же что уже есть в bootstrap'е.
var currencyId = (await actorA.GetJsonAsync("/api/catalog/currencies"))
.GetProperty("items").EnumerateArray()
.First(x => x.GetProperty("code").GetString() == "KZT")
.GetProperty("id").GetString();
var retailPointId = (await actorA.GetJsonAsync("/api/catalog/retail-points"))
.GetProperty("items").EnumerateArray().First()
.GetProperty("id").GetString();
var saleResp = await actorA.Http.PostAsJsonAsync("/api/sales/retail", new
{
date = DateTime.UtcNow,
storeId = storeId,
retailPointId = retailPointId,
currencyId = currencyId,
payment = 0,
isReturn = false,
lines = new[] { new { productId = productId, quantity = 1, unitPrice = 100m, discount = 0m, vatPercent = 12m } },
subtotal = 100m,
discountTotal = 0m,
total = 100m,
paidCash = 100m,
paidCard = 0m,
});
saleResp.EnsureSuccessStatusCode();
var sale = (await saleResp.Content.ReadFromJsonAsync<System.Text.Json.JsonElement>())!;
var saleId = sale.GetProperty("id").GetString();
var postResp = await actorA.Http.PostAsync($"/api/sales/retail/{saleId}/post", null);
postResp.EnsureSuccessStatusCode();
// Ждём событие у A (timeout 8с — Hub queue + SignalR handshake).
var aEvt = await Task.WhenAny(aGotSale.Task, Task.Delay(TimeSpan.FromSeconds(8)));
aEvt.Should().Be(aGotSale.Task, "A должен был получить SalePosted");
var payload = await aGotSale.Task;
// Поля camelCase (System.Text.Json default).
payload.GetProperty("number").GetString().Should().NotBeNullOrEmpty();
payload.GetProperty("total").GetDecimal().Should().Be(100m);
// B не должен получить ничего за то же время.
var bEvt = await Task.WhenAny(bGotSale.Task, Task.Delay(TimeSpan.FromSeconds(2)));
bEvt.Should().NotBe(bGotSale.Task, "B НЕ должен получать события другой org (multi-tenant leak)");
await hubA.StopAsync();
await hubB.StopAsync();
}
private async Task<HubConnection> BuildHubAsync(string token)
{
var conn = new HubConnectionBuilder()
.WithUrl("http://localhost/hubs/notifications", o =>
{
o.HttpMessageHandlerFactory = _ => _factory.Server.CreateHandler();
o.AccessTokenProvider = () => Task.FromResult<string?>(token);
})
.Build();
await conn.StartAsync();
return conn;
}
private async Task<(string productId, string storeId)> SeedProductWithStockAsync(ApiActor actor)
{
var units = (await actor.GetJsonAsync("/api/catalog/units-of-measure?pageSize=200"))
.GetProperty("items").EnumerateArray().First(x => x.GetProperty("code").GetString() == "796");
var groups = (await actor.GetJsonAsync("/api/catalog/product-groups"))
.GetProperty("items").EnumerateArray().First();
var priceTypes = (await actor.GetJsonAsync("/api/catalog/price-types"))
.GetProperty("items").EnumerateArray()
.First(x => x.GetProperty("isRetail").GetBoolean());
var currencies = (await actor.GetJsonAsync("/api/catalog/currencies"))
.GetProperty("items").EnumerateArray()
.First(x => x.GetProperty("code").GetString() == "KZT");
var stores = (await actor.GetJsonAsync("/api/catalog/stores"))
.GetProperty("items").EnumerateArray()
.First(x => x.GetProperty("isMain").GetBoolean());
var storeId = stores.GetProperty("id").GetString()!;
var prodResp = await actor.Http.PostAsJsonAsync("/api/catalog/products", new
{
name = "SignalR test", article = $"SR-{Guid.NewGuid():N}",
unitOfMeasureId = units.GetProperty("id").GetString(),
vat = 12, vatEnabled = true,
productGroupId = groups.GetProperty("id").GetString(),
packaging = 1,
prices = new[] { new { priceTypeId = priceTypes.GetProperty("id").GetString(), amount = 100m, currencyId = currencies.GetProperty("id").GetString() } },
barcodes = new[] { new { code = "5000000000010", type = 1, isPrimary = true } },
});
prodResp.EnsureSuccessStatusCode();
var prod = (await prodResp.Content.ReadFromJsonAsync<System.Text.Json.JsonElement>())!;
var productId = prod.GetProperty("id").GetString()!;
var supplierResp = await actor.Http.PostAsJsonAsync("/api/catalog/counterparties", new { name = "Supp SR", type = 2 });
supplierResp.EnsureSuccessStatusCode();
var supplier = (await supplierResp.Content.ReadFromJsonAsync<System.Text.Json.JsonElement>())!;
var supplierId = supplier.GetProperty("id").GetString()!;
var supplyResp = await actor.Http.PostAsJsonAsync("/api/purchases/supplies", new
{
date = DateTime.UtcNow,
supplierId = supplierId,
storeId = storeId,
currencyId = currencies.GetProperty("id").GetString(),
lines = new[] { new { productId = productId, quantity = 10m, unitPrice = 50m } },
});
supplyResp.EnsureSuccessStatusCode();
var supply = (await supplyResp.Content.ReadFromJsonAsync<System.Text.Json.JsonElement>())!;
var supplyId = supply.GetProperty("id").GetString()!;
(await actor.Http.PostAsync($"/api/purchases/supplies/{supplyId}/post", null)).EnsureSuccessStatusCode();
return (productId, storeId);
}
}