food-market/tests/food-market.IntegrationTests/PosSyncTests.cs
nns 640c8d9c22 feat(pos-api): GET /sync и POST /sales с двойной идемпотентностью (P1-12b)
Endpoints:
- GET /api/pos/v1/sync?since=ISO&storeId=Guid - выгрузка изменений
  (Products / Prices / Stocks / Counterparties) после reference time;
  Stocks - всегда полный снимок на момент ответа (POS нужен актуальный
  остаток на полке).
- POST /api/pos/v1/sales - батч продаж с idempotency.

Двойная идемпотентность:
1. Batch-level: PosBatchAck (новая таблица, unique idx по OrgId+Key) -
   повтор того же батча возвращает кешированный ответ. При параллельном
   race ловим 23505 на уникальном индексе и тоже возвращаем кеш.
2. Per-sale: ClientSaleId записывается в RetailSale.Notes как prefix
   "pos:GUID32". Перед созданием продажи проверяем что такой маркер ещё
   не встречался - если есть, возвращаем существующую продажу. Это
   спасает и при разных batch-ключах с пересекающимися ClientSaleId.

Pre-flight: проверка остатка ДО создания черновика - sale, которая не
влезает в полку, попадает в Failed, остальные в батче проводятся.

Domain: PosBatchAck (TenantEntity), миграция Phase7a_PosBatchAcks
(jsonb для ResponseJson, unique idx).

Контракты v1 из food-market.shared.

Тесты: 7 интеграционных - полная sync, дельта по since, POST батч
списывает stock, replay того же батча no-duplicates, ClientSaleId через
разные batch-keys тоже no-duplicates, недостача попадает в Failed,
tenant-изоляция.

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

238 lines
12 KiB
C#
Raw 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 foodmarket.Shared.Pos.V1;
using Xunit;
namespace foodmarket.IntegrationTests;
[Collection(ApiCollection.Name)]
public class PosSyncTests
{
private readonly ApiFactory _factory;
public PosSyncTests(ApiFactory factory) => _factory = factory;
private static string RandomBarcode()
=> string.Concat(Enumerable.Range(0, 13).Select(_ => Random.Shared.Next(0, 10)));
[Fact]
public async Task Sync_returns_products_prices_stocks()
{
var api = new ApiActor(_factory.CreateClient());
await api.SignupAndLoginAsync($"pos-sync-{Guid.NewGuid():N}");
var refs = await api.LoadRefsAsync();
var p1 = await api.CreateProductAsync(refs, $"P-{Guid.NewGuid():N}", 250m, RandomBarcode());
// Подкормим остаток через Enter, чтобы было что синхронизировать.
var enter = await api.Http.PostAsJsonAsync("/api/inventory/enters", new
{
date = DateTime.UtcNow, storeId = refs.StoreId, currencyId = refs.CurrencyId,
notes = "seed", lines = new[] { new { productId = p1, quantity = 5m, unitCost = 100m } },
});
enter.EnsureSuccessStatusCode();
var eid = (await enter.Content.ReadFromJsonAsync<JsonElement>()).GetProperty("id").GetString();
(await api.Http.PostAsJsonAsync($"/api/inventory/enters/{eid}/post", new { })).EnsureSuccessStatusCode();
// POS sync без since → полная выгрузка.
using var resp = await api.Http.GetAsync("/api/pos/v1/sync");
resp.EnsureSuccessStatusCode();
var sync = await resp.Content.ReadFromJsonAsync<PosSyncResponse>();
sync!.Products.Should().Contain(p => p.Id.ToString() == p1);
sync.Prices.Should().Contain(pr => pr.ProductId.ToString() == p1 && pr.Amount == 250m);
sync.Stocks.Should().Contain(s => s.ProductId.ToString() == p1 && s.Quantity == 5m);
sync.ServerTime.Should().BeBefore(DateTime.UtcNow.AddSeconds(5));
}
[Fact]
public async Task Sync_with_since_returns_only_delta()
{
var api = new ApiActor(_factory.CreateClient());
await api.SignupAndLoginAsync($"pos-delta-{Guid.NewGuid():N}");
var refs = await api.LoadRefsAsync();
var pOld = await api.CreateProductAsync(refs, $"OLD-{Guid.NewGuid():N}", 100m, RandomBarcode());
// Маркер времени между двумя продуктами.
await Task.Delay(1100);
var marker = DateTime.UtcNow;
await Task.Delay(1100);
var pNew = await api.CreateProductAsync(refs, $"NEW-{Guid.NewGuid():N}", 200m, RandomBarcode());
using var resp = await api.Http.GetAsync($"/api/pos/v1/sync?since={Uri.EscapeDataString(marker.ToString("o"))}");
resp.EnsureSuccessStatusCode();
var sync = await resp.Content.ReadFromJsonAsync<PosSyncResponse>();
sync!.Products.Should().Contain(p => p.Id.ToString() == pNew);
sync.Products.Should().NotContain(p => p.Id.ToString() == pOld);
}
[Fact]
public async Task Post_sales_batch_creates_retail_sales_and_decrements_stock()
{
var api = new ApiActor(_factory.CreateClient());
await api.SignupAndLoginAsync($"pos-sale-{Guid.NewGuid():N}");
var refs = await api.LoadRefsAsync();
var p1 = await api.CreateProductAsync(refs, $"P-{Guid.NewGuid():N}", 100m, RandomBarcode());
var enter = await api.Http.PostAsJsonAsync("/api/inventory/enters", new
{
date = DateTime.UtcNow, storeId = refs.StoreId, currencyId = refs.CurrencyId,
notes = "seed", lines = new[] { new { productId = p1, quantity = 20m, unitCost = 50m } },
});
enter.EnsureSuccessStatusCode();
var eid = (await enter.Content.ReadFromJsonAsync<JsonElement>()).GetProperty("id").GetString();
(await api.Http.PostAsJsonAsync($"/api/inventory/enters/{eid}/post", new { })).EnsureSuccessStatusCode();
(await api.StockOfAsync(refs.StoreId, p1)).Should().Be(20m);
var batch = new PosSaleBatchDto
{
IdempotencyKey = Guid.NewGuid(),
Sales = new[]
{
MakeSale(p1, qty: 2m, price: 100m),
MakeSale(p1, qty: 3m, price: 100m),
MakeSale(p1, qty: 1m, price: 100m),
MakeSale(p1, qty: 4m, price: 100m),
MakeSale(p1, qty: 1m, price: 100m),
},
};
using var post = await api.Http.PostAsJsonAsync("/api/pos/v1/sales", batch);
post.IsSuccessStatusCode.Should().BeTrue($"POST вернул {(int)post.StatusCode}: {await post.Content.ReadAsStringAsync()}");
var resp = await post.Content.ReadFromJsonAsync<PosSaleBatchResponse>();
resp!.Accepted.Should().HaveCount(5);
resp.Failed.Should().BeEmpty();
resp.ReplayedFromCache.Should().BeFalse();
// 20 (2+3+1+4+1) = 9 на складе.
(await api.StockOfAsync(refs.StoreId, p1)).Should().Be(9m);
}
/// <summary>Ключевой тест задачи: повтор того же батча → no duplicates,
/// тот же ответ, остаток не уменьшается дважды.</summary>
[Fact]
public async Task Replay_same_batch_returns_cached_response_no_duplicates()
{
var api = new ApiActor(_factory.CreateClient());
await api.SignupAndLoginAsync($"pos-idem-{Guid.NewGuid():N}");
var refs = await api.LoadRefsAsync();
var p1 = await api.CreateProductAsync(refs, $"P-{Guid.NewGuid():N}", 100m, RandomBarcode());
var enter = await api.Http.PostAsJsonAsync("/api/inventory/enters", new
{
date = DateTime.UtcNow, storeId = refs.StoreId, currencyId = refs.CurrencyId,
notes = "seed", lines = new[] { new { productId = p1, quantity = 10m, unitCost = 50m } },
});
enter.EnsureSuccessStatusCode();
var eid = (await enter.Content.ReadFromJsonAsync<JsonElement>()).GetProperty("id").GetString();
(await api.Http.PostAsJsonAsync($"/api/inventory/enters/{eid}/post", new { })).EnsureSuccessStatusCode();
var batch = new PosSaleBatchDto
{
IdempotencyKey = Guid.NewGuid(),
Sales = new[]
{
MakeSale(p1, qty: 3m, price: 100m),
MakeSale(p1, qty: 2m, price: 100m),
},
};
using var first = await api.Http.PostAsJsonAsync("/api/pos/v1/sales", batch);
first.EnsureSuccessStatusCode();
var r1 = await first.Content.ReadFromJsonAsync<PosSaleBatchResponse>();
r1!.Accepted.Should().HaveCount(2);
r1.ReplayedFromCache.Should().BeFalse();
(await api.StockOfAsync(refs.StoreId, p1)).Should().Be(5m);
using var second = await api.Http.PostAsJsonAsync("/api/pos/v1/sales", batch);
second.EnsureSuccessStatusCode();
var r2 = await second.Content.ReadFromJsonAsync<PosSaleBatchResponse>();
r2!.Accepted.Should().HaveCount(2);
r2.ReplayedFromCache.Should().BeTrue();
// ServerSaleId должны совпасть — это тот же чек, что и в первом ответе.
r2.Accepted.Select(a => a.ServerSaleId).Should().BeEquivalentTo(r1.Accepted.Select(a => a.ServerSaleId));
// Stock не списался дважды.
(await api.StockOfAsync(refs.StoreId, p1)).Should().Be(5m);
}
/// <summary>Per-sale ClientSaleId идемпотентность: если ту же продажу
/// прислали в РАЗНЫХ батчах (с разными idempotency-key'ями), сервер всё
/// равно вернёт ссылку на ранее созданный чек, не создавая дубль.</summary>
[Fact]
public async Task ClientSaleId_idempotency_across_batches()
{
var api = new ApiActor(_factory.CreateClient());
await api.SignupAndLoginAsync($"pos-csid-{Guid.NewGuid():N}");
var refs = await api.LoadRefsAsync();
var p1 = await api.CreateProductAsync(refs, $"P-{Guid.NewGuid():N}", 100m, RandomBarcode());
var enter = await api.Http.PostAsJsonAsync("/api/inventory/enters", new
{
date = DateTime.UtcNow, storeId = refs.StoreId, currencyId = refs.CurrencyId,
notes = "seed", lines = new[] { new { productId = p1, quantity = 10m, unitCost = 50m } },
});
enter.EnsureSuccessStatusCode();
var eid = (await enter.Content.ReadFromJsonAsync<JsonElement>()).GetProperty("id").GetString();
(await api.Http.PostAsJsonAsync($"/api/inventory/enters/{eid}/post", new { })).EnsureSuccessStatusCode();
var sharedSale = MakeSale(p1, qty: 4m, price: 100m);
var batch1 = new PosSaleBatchDto { IdempotencyKey = Guid.NewGuid(), Sales = new[] { sharedSale } };
var batch2 = new PosSaleBatchDto { IdempotencyKey = Guid.NewGuid(), Sales = new[] { sharedSale } };
var r1 = await (await api.Http.PostAsJsonAsync("/api/pos/v1/sales", batch1)).Content.ReadFromJsonAsync<PosSaleBatchResponse>();
var r2 = await (await api.Http.PostAsJsonAsync("/api/pos/v1/sales", batch2)).Content.ReadFromJsonAsync<PosSaleBatchResponse>();
r1!.Accepted.Should().HaveCount(1);
r2!.Accepted.Should().HaveCount(1);
r2.Accepted[0].ServerSaleId.Should().Be(r1.Accepted[0].ServerSaleId);
(await api.StockOfAsync(refs.StoreId, p1)).Should().Be(6m, "товар списался 1 раз");
}
[Fact]
public async Task Insufficient_stock_goes_to_failed_not_accepted()
{
var api = new ApiActor(_factory.CreateClient());
await api.SignupAndLoginAsync($"pos-short-{Guid.NewGuid():N}");
var refs = await api.LoadRefsAsync();
var p1 = await api.CreateProductAsync(refs, $"P-{Guid.NewGuid():N}", 100m, RandomBarcode());
// Не подкармливаем — остаток 0.
var batch = new PosSaleBatchDto
{
IdempotencyKey = Guid.NewGuid(),
Sales = new[] { MakeSale(p1, qty: 3m, price: 100m) },
};
using var post = await api.Http.PostAsJsonAsync("/api/pos/v1/sales", batch);
post.EnsureSuccessStatusCode();
var resp = await post.Content.ReadFromJsonAsync<PosSaleBatchResponse>();
resp!.Accepted.Should().BeEmpty();
resp.Failed.Should().HaveCount(1);
resp.Failed[0].Error.Should().Contain("Недостаточный остаток");
}
[Fact]
public async Task Tenant_isolation_pos_sync()
{
var a = new ApiActor(_factory.CreateClient());
var b = new ApiActor(_factory.CreateClient());
await a.SignupAndLoginAsync($"pos-iso-a-{Guid.NewGuid():N}");
await b.SignupAndLoginAsync($"pos-iso-b-{Guid.NewGuid():N}");
var refsA = await a.LoadRefsAsync();
var pA = await a.CreateProductAsync(refsA, $"PA-{Guid.NewGuid():N}", 100m, RandomBarcode());
var syncB = await (await b.Http.GetAsync("/api/pos/v1/sync")).Content.ReadFromJsonAsync<PosSyncResponse>();
syncB!.Products.Should().NotContain(p => p.Id.ToString() == pA);
}
private static PosSaleDto MakeSale(string productId, decimal qty, decimal price) => new()
{
ClientSaleId = Guid.NewGuid(),
OccurredAt = DateTime.UtcNow,
Payment = 0, PaidCash = qty * price, PaidCard = 0m,
Lines = new[]
{
new PosSaleLineDto
{
ProductId = Guid.Parse(productId), Quantity = qty,
UnitPrice = price, Discount = 0m, VatPercent = 12m,
},
},
};
}