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
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>
157 lines
7.9 KiB
C#
157 lines
7.9 KiB
C#
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);
|
||
}
|
||
}
|