food-market/tests/food-market.IntegrationTests/TransferPostUnpostTests.cs
nns fa1e123327 feat(transfers): атомарное перемещение между складами (P1-3)
Domain Transfer+TransferLine (FromStoreId → ToStoreId, обязательны и различны).
EF, миграция Phase6c_Transfers. Контроллер api/inventory/transfers: CRUD +
Post/Unpost. Post создаёт ПАРУ движений TransferOut(-) + TransferIn(+) в
одной Serializable-транзакции; Unpost — обратная пара тоже атомарно.
Защита от ухода в минус: post (на FromStore), unpost (на ToStore — товар
мог быть уже расходован).

Web: /inventory/transfers (list+edit) с двумя селекторами складов и
визуализацией «From → To». Пункт «Перемещения» в сайдбаре. Permission
TransferEdit добавлен в RolePermissions.

Тесты: 4 интеграционных (post создаёт пару движений, unpost оставляет
ровно 4 движения и обнуляет stock-диффы; same-store → 400; short-stock
на FromStore → 409 без побочных эффектов; tenant-изоляция).

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

162 lines
8.1 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 Xunit;
namespace foodmarket.IntegrationTests;
[Collection(ApiCollection.Name)]
public class TransferPostUnpostTests
{
private readonly ApiFactory _factory;
public TransferPostUnpostTests(ApiFactory factory) => _factory = factory;
private static string RandomBarcode()
=> string.Concat(Enumerable.Range(0, 13).Select(_ => Random.Shared.Next(0, 10)));
/// <summary>Создать второй склад через api (главный уже есть из bootstrap).</summary>
private async Task<string> CreateSecondStore(ApiActor api, string name)
{
var resp = await api.Http.PostAsJsonAsync("/api/catalog/stores", new
{
name, code = (string?)null, address = "Алматы 2",
phone = (string?)null, managerName = (string?)null,
isMain = false, isActive = true,
});
resp.EnsureSuccessStatusCode();
return (await resp.Content.ReadFromJsonAsync<JsonElement>()).GetProperty("id").GetString()!;
}
[Fact]
public async Task Post_creates_paired_movements_unpost_reverts_no_orphans()
{
var api = new ApiActor(_factory.CreateClient());
await api.SignupAndLoginAsync($"xfer-{Guid.NewGuid():N}");
var refs = await api.LoadRefsAsync();
var to = await CreateSecondStore(api, $"Склад-2-{Guid.NewGuid():N}");
var productId = await api.CreateProductAsync(refs, $"Prod-{Guid.NewGuid():N}", 200m, RandomBarcode());
// Оприходовать 10 шт на первый склад.
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, quantity = 10m, unitCost = 50m } },
});
enter.EnsureSuccessStatusCode();
var enterId = (await enter.Content.ReadFromJsonAsync<JsonElement>()).GetProperty("id").GetString();
(await api.Http.PostAsJsonAsync($"/api/inventory/enters/{enterId}/post", new { })).EnsureSuccessStatusCode();
(await api.StockOfAsync(refs.StoreId, productId)).Should().Be(10m);
// Создать перемещение: 4 шт с первого склада на второй.
var xfer = await api.Http.PostAsJsonAsync("/api/inventory/transfers", new
{
date = DateTime.UtcNow,
fromStoreId = refs.StoreId, toStoreId = to,
notes = "test",
lines = new[] { new { productId, quantity = 4m, unitCost = 50m } },
});
xfer.EnsureSuccessStatusCode();
var xferId = (await xfer.Content.ReadFromJsonAsync<JsonElement>()).GetProperty("id").GetString();
// Post → 6 на источнике, 4 на получателе.
using var post = await api.Http.PostAsJsonAsync($"/api/inventory/transfers/{xferId}/post", new { });
post.IsSuccessStatusCode.Should().BeTrue($"post вернул {(int)post.StatusCode}: {await post.Content.ReadAsStringAsync()}");
(await api.StockOfAsync(refs.StoreId, productId)).Should().Be(6m);
(await api.StockOfAsync(to, productId)).Should().Be(4m);
// Проверяем что движений ровно 2 (TransferOut + TransferIn) — никаких orphan.
var movs = await api.ListAsync($"/api/inventory/movements?productId={productId}&pageSize=100");
var byThisDoc = movs.Where(m =>
m.GetProperty("documentId").ValueKind == JsonValueKind.String &&
m.GetProperty("documentId").GetString() == xferId).ToList();
byThisDoc.Should().HaveCount(2, "перемещение порождает строго пару движений Out+In");
// Unpost → 10 и 0. Атомарность: ИЛИ оба отменены, ИЛИ ни одного.
using var unpost = await api.Http.PostAsJsonAsync($"/api/inventory/transfers/{xferId}/unpost", new { });
unpost.IsSuccessStatusCode.Should().BeTrue();
(await api.StockOfAsync(refs.StoreId, productId)).Should().Be(10m);
(await api.StockOfAsync(to, productId)).Should().Be(0m);
// По документу теперь 4 движения (2 forward + 2 reversal), но stock-инвариант держится.
movs = await api.ListAsync($"/api/inventory/movements?productId={productId}&pageSize=100");
var byThisDocAfter = movs.Where(m =>
m.GetProperty("documentId").ValueKind == JsonValueKind.String &&
m.GetProperty("documentId").GetString() == xferId).ToList();
byThisDocAfter.Should().HaveCount(4);
}
[Fact]
public async Task Cannot_create_transfer_to_same_store()
{
var api = new ApiActor(_factory.CreateClient());
await api.SignupAndLoginAsync($"xfer-same-{Guid.NewGuid():N}");
var refs = await api.LoadRefsAsync();
var productId = await api.CreateProductAsync(refs, $"Prod-{Guid.NewGuid():N}", 100m, RandomBarcode());
using var resp = await api.Http.PostAsJsonAsync("/api/inventory/transfers", new
{
date = DateTime.UtcNow,
fromStoreId = refs.StoreId, toStoreId = refs.StoreId,
notes = "same",
lines = new[] { new { productId, quantity = 1m, unitCost = 10m } },
});
((int)resp.StatusCode).Should().Be(400);
}
[Fact]
public async Task Post_blocked_when_from_store_short()
{
var api = new ApiActor(_factory.CreateClient());
await api.SignupAndLoginAsync($"xfer-short-{Guid.NewGuid():N}");
var refs = await api.LoadRefsAsync();
var to = await CreateSecondStore(api, $"Склад-S-{Guid.NewGuid():N}");
var productId = await api.CreateProductAsync(refs, $"Prod-{Guid.NewGuid():N}", 100m, RandomBarcode());
// На первом 0 шт; пытаемся перевести 5 — должно отбиться.
var xfer = await api.Http.PostAsJsonAsync("/api/inventory/transfers", new
{
date = DateTime.UtcNow,
fromStoreId = refs.StoreId, toStoreId = to,
notes = "short",
lines = new[] { new { productId, quantity = 5m, unitCost = 10m } },
});
xfer.EnsureSuccessStatusCode();
var xferId = (await xfer.Content.ReadFromJsonAsync<JsonElement>()).GetProperty("id").GetString();
using var post = await api.Http.PostAsJsonAsync($"/api/inventory/transfers/{xferId}/post", new { });
((int)post.StatusCode).Should().Be(409);
// Никаких движений не должно появиться.
(await api.StockOfAsync(refs.StoreId, productId)).Should().Be(0m);
(await api.StockOfAsync(to, productId)).Should().Be(0m);
}
[Fact]
public async Task Tenant_isolation_transfer()
{
var a = new ApiActor(_factory.CreateClient());
var b = new ApiActor(_factory.CreateClient());
await a.SignupAndLoginAsync($"xfer-iso-a-{Guid.NewGuid():N}");
await b.SignupAndLoginAsync($"xfer-iso-b-{Guid.NewGuid():N}");
var refsA = await a.LoadRefsAsync();
var toA = await CreateSecondStore(a, $"Склад-A2-{Guid.NewGuid():N}");
var productA = await a.CreateProductAsync(refsA, $"Prod-{Guid.NewGuid():N}", 100m, RandomBarcode());
var resp = await a.Http.PostAsJsonAsync("/api/inventory/transfers", new
{
date = DateTime.UtcNow,
fromStoreId = refsA.StoreId, toStoreId = toA,
notes = "iso",
lines = new[] { new { productId = productA, quantity = 1m, unitCost = 10m } },
});
resp.EnsureSuccessStatusCode();
var xferId = (await resp.Content.ReadFromJsonAsync<JsonElement>()).GetProperty("id").GetString();
var bList = await b.ListAsync("/api/inventory/transfers?pageSize=200");
bList.Should().NotContain(x => x.GetProperty("id").GetString() == xferId);
using var direct = await b.Http.GetAsync($"/api/inventory/transfers/{xferId}");
direct.StatusCode.Should().Be(System.Net.HttpStatusCode.NotFound);
}
}