using System.Net.Http.Json; using System.Text.Json; using FluentAssertions; using foodmarket.IntegrationTests.Support; using Xunit; namespace foodmarket.IntegrationTests; [Collection(ApiCollection.Name)] public class ConcurrencyTokenTests { private readonly ApiFactory _factory; public ConcurrencyTokenTests(ApiFactory factory) => _factory = factory; private static string RandomBarcode() => string.Concat(Enumerable.Range(0, 13).Select(_ => Random.Shared.Next(0, 10))); /// Два параллельных PUT с одинаковым xmin: первый успешен (204), /// второй падает 409 (concurrency_conflict). После 409 клиент должен /// перезагрузить документ и попробовать снова — это тестируем третим PUT. [Fact] public async Task Two_parallel_updates_with_same_xmin_one_wins_other_gets_409() { var api = new ApiActor(_factory.CreateClient()); await api.SignupAndLoginAsync($"conc-{Guid.NewGuid():N}"); var refs = await api.LoadRefsAsync(); var supplierId = await api.CreateCounterpartyAsync($"S-{Guid.NewGuid():N}"); var p1 = await api.CreateProductAsync(refs, $"P-{Guid.NewGuid():N}", 100m, RandomBarcode()); var create = await api.Http.PostAsJsonAsync("/api/purchases/supplies", new { date = DateTime.UtcNow, supplierId, storeId = refs.StoreId, currencyId = refs.CurrencyId, notes = "initial", lines = new[] { new { productId = p1, quantity = 1m, unitPrice = 50m } }, }); create.EnsureSuccessStatusCode(); var json = await create.Content.ReadFromJsonAsync(); var supplyId = json.GetProperty("id").GetString(); var xmin = json.GetProperty("xmin").GetUInt32(); xmin.Should().BeGreaterThan(0u); // Build two identical PUTs with the SAME xmin. var body1 = new { date = DateTime.UtcNow, supplierId, storeId = refs.StoreId, currencyId = refs.CurrencyId, notes = "writer-1", lines = new[] { new { productId = p1, quantity = 2m, unitPrice = 50m } }, xmin, }; var body2 = new { date = DateTime.UtcNow, supplierId, storeId = refs.StoreId, currencyId = refs.CurrencyId, notes = "writer-2", lines = new[] { new { productId = p1, quantity = 3m, unitPrice = 50m } }, xmin, }; // Sequential — гарантированно один 204, второй 409. Параллельный race в // Postgres даёт тот же исход, но интеграционный тест предпочитает // воспроизводимый порядок (а не «обычно зелёный»). using var r1 = await api.Http.PutAsJsonAsync($"/api/purchases/supplies/{supplyId}", body1); ((int)r1.StatusCode).Should().Be(204, await r1.Content.ReadAsStringAsync()); using var r2 = await api.Http.PutAsJsonAsync($"/api/purchases/supplies/{supplyId}", body2); ((int)r2.StatusCode).Should().Be(409); var body = await r2.Content.ReadAsStringAsync(); body.Should().Contain("concurrency_conflict"); // GET → видим writer-1 результат, xmin обновился. var after = await api.GetJsonAsync($"/api/purchases/supplies/{supplyId}"); after.GetProperty("notes").GetString().Should().Be("writer-1"); var newXmin = after.GetProperty("xmin").GetUInt32(); newXmin.Should().NotBe(xmin); // Retry с новым xmin — снова успех. using var r3 = await api.Http.PutAsJsonAsync($"/api/purchases/supplies/{supplyId}", new { date = DateTime.UtcNow, supplierId, storeId = refs.StoreId, currencyId = refs.CurrencyId, notes = "writer-2-retry", lines = new[] { new { productId = p1, quantity = 3m, unitPrice = 50m } }, xmin = newXmin, }); ((int)r3.StatusCode).Should().Be(204); } /// Старый клиент без поля xmin продолжает работать — Update без /// concurrency-проверки (legacy compatibility). [Fact] public async Task Update_without_xmin_field_works_legacy_compat() { var api = new ApiActor(_factory.CreateClient()); await api.SignupAndLoginAsync($"conc-leg-{Guid.NewGuid():N}"); var refs = await api.LoadRefsAsync(); var supplierId = await api.CreateCounterpartyAsync($"S-{Guid.NewGuid():N}"); var p1 = await api.CreateProductAsync(refs, $"P-{Guid.NewGuid():N}", 100m, RandomBarcode()); var create = await api.Http.PostAsJsonAsync("/api/purchases/supplies", new { date = DateTime.UtcNow, supplierId, storeId = refs.StoreId, currencyId = refs.CurrencyId, notes = "init", lines = new[] { new { productId = p1, quantity = 1m, unitPrice = 50m } }, }); create.EnsureSuccessStatusCode(); var supplyId = (await create.Content.ReadFromJsonAsync()).GetProperty("id").GetString(); // PUT без xmin — должен пройти. using var r = await api.Http.PutAsJsonAsync($"/api/purchases/supplies/{supplyId}", new { date = DateTime.UtcNow, supplierId, storeId = refs.StoreId, currencyId = refs.CurrencyId, notes = "no-xmin", lines = new[] { new { productId = p1, quantity = 2m, unitPrice = 50m } }, }); ((int)r.StatusCode).Should().Be(204); } }