food-market/tests/food-market.IntegrationTests/ConcurrencyTokenTests.cs
nns ec0cff7fc4 feat(concurrency): RowVersion на документах через Postgres xmin (TD-6)
Optimistic concurrency через системную колонку Postgres xmin — никакой
дополнительной колонки и миграции не нужно, xmin есть у каждой таблицы
и автоматически обновляется при UPDATE.

Конфигурация:
- IVersionedEntity (маркер) + uint Xmin на Supply, Demand, RetailSale,
  Transfer, InventoryDoc.
- e.UseXminAsConcurrencyToken() в EF-конфиге для каждой — создаёт shadow
  property "xmin" с IsConcurrencyToken + ValueGeneratedOnAddOrUpdate.
- e.Ignore(x => x.Xmin): .NET-property живёт только для транспорта в DTO,
  не маппится в БД (xmin тащим shadow'ом).
- GetInternal в SuppliesController читает xmin через
  EF.Property<uint>(s, "xmin") в LINQ-проекции и складывает в DTO.

Wire-up:
- SuppliesController.Update принимает input.Xmin (uint?), сверяет с
  shadow xmin загруженного supply через EF.Entry().Property("xmin").
  Несовпадение → 409 с code=concurrency_conflict. null/0 от клиента →
  legacy compat, проверки нет.
- SaveOrFkErrorAsync ловит DbUpdateConcurrencyException → 409 (двойная
  защита: и явная сверка, и EF auto-check в SaveChanges).

Bonus: Supply.Update перешёл на тот же паттерн что Demand/RetailSale —
ExecuteDelete старых строк + AddRange новых напрямую в DbSet. Старый
RemoveRange-then-Add через nav-collection ломал EF concurrency check
(UPDATE supply_lines одной из старых строк падал 0 affected внутри той
же SaveChanges-транзакции).

Тесты: 2 интеграционных:
- two parallel updates with same xmin → один 204, другой 409; retry
  с новым xmin тоже 204.
- legacy clients без xmin → PUT работает без concurrency-проверки.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 17:33:01 +05:00

115 lines
5.6 KiB
C#
Raw Permalink 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 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)));
/// <summary>Два параллельных PUT с одинаковым xmin: первый успешен (204),
/// второй падает 409 (concurrency_conflict). После 409 клиент должен
/// перезагрузить документ и попробовать снова — это тестируем третим PUT.</summary>
[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<JsonElement>();
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);
}
/// <summary>Старый клиент без поля xmin продолжает работать — Update без
/// concurrency-проверки (legacy compatibility).</summary>
[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<JsonElement>()).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);
}
}