using System.Net.Http.Json; using FluentAssertions; using foodmarket.IntegrationTests.Support; using Microsoft.AspNetCore.SignalR.Client; using Xunit; namespace foodmarket.IntegrationTests; /// SignalR-хаб per-org. После Post-a розничной продажи владелец org /// получает событие SalePosted, владелец другой org — НЕТ (multi-tenant). /// Тест поднимает реальный API через WebApplicationFactory, использует TestServer /// в качестве handler'a SignalR-клиента. [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(); var bGotSale = new TaskCompletionSource(); hubA.On("SalePosted", p => aGotSale.TrySetResult(p)); hubB.On("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())!; 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 BuildHubAsync(string token) { var conn = new HubConnectionBuilder() .WithUrl("http://localhost/hubs/notifications", o => { o.HttpMessageHandlerFactory = _ => _factory.Server.CreateHandler(); o.AccessTokenProvider = () => Task.FromResult(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())!; 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())!; 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())!; var supplyId = supply.GetProperty("id").GetString()!; (await actor.Http.PostAsync($"/api/purchases/supplies/{supplyId}/post", null)).EnsureSuccessStatusCode(); return (productId, storeId); } }