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);
}
}