feat(api): ProductPricesController + DI + ExceptionFilter integration (PRD-003)
- GET /api/v1/products/{id}/prices [Authorize] → 200 IReadOnlyList<ProductPriceDto>
- POST /api/v1/admin/products/{id}/prices [RequirePermission catalogo:productos:gestionar] → 201 AddProductPriceResponse + Location header
- ExceptionFilter: 3 new cases (ProductPriceForwardOnlyException→409, ProductPriceInvalidException→400, ProductSinPrecioActivoException→404)
- Fix AddProductPriceCommandHandler: move GetByProductIdAsync outside TransactionScope using block to avoid InvalidOperationException (scope already complete)
- 16 e2e tests in ProductPricesControllerTests: 401/403, 200 history ordered DESC, 404 not found, 201 first/second price, 400 validation, 409 forward-only, audit event, DateOnly yyyy-MM-dd roundtrip
- 305 Api.Tests + 1088 Application.Tests = 1393 total, 0 red
2026-04-19 18:26:24 -03:00
|
|
|
using System.Net;
|
|
|
|
|
using System.Net.Http.Headers;
|
|
|
|
|
using System.Net.Http.Json;
|
|
|
|
|
using System.Text.Json;
|
|
|
|
|
using Dapper;
|
|
|
|
|
using FluentAssertions;
|
|
|
|
|
using Microsoft.Data.SqlClient;
|
|
|
|
|
using Microsoft.Extensions.DependencyInjection;
|
|
|
|
|
using SIGCM2.Application.Abstractions.Security;
|
2026-04-19 19:47:18 -03:00
|
|
|
using SIGCM2.Application.Common;
|
|
|
|
|
using SIGCM2.Application.Products.Prices;
|
feat(api): ProductPricesController + DI + ExceptionFilter integration (PRD-003)
- GET /api/v1/products/{id}/prices [Authorize] → 200 IReadOnlyList<ProductPriceDto>
- POST /api/v1/admin/products/{id}/prices [RequirePermission catalogo:productos:gestionar] → 201 AddProductPriceResponse + Location header
- ExceptionFilter: 3 new cases (ProductPriceForwardOnlyException→409, ProductPriceInvalidException→400, ProductSinPrecioActivoException→404)
- Fix AddProductPriceCommandHandler: move GetByProductIdAsync outside TransactionScope using block to avoid InvalidOperationException (scope already complete)
- 16 e2e tests in ProductPricesControllerTests: 401/403, 200 history ordered DESC, 404 not found, 201 first/second price, 400 validation, 409 forward-only, audit event, DateOnly yyyy-MM-dd roundtrip
- 305 Api.Tests + 1088 Application.Tests = 1393 total, 0 red
2026-04-19 18:26:24 -03:00
|
|
|
using SIGCM2.Domain.Entities;
|
|
|
|
|
using SIGCM2.TestSupport;
|
|
|
|
|
using Xunit;
|
|
|
|
|
|
|
|
|
|
namespace SIGCM2.Api.Tests.Products;
|
|
|
|
|
|
|
|
|
|
/// <summary>
|
|
|
|
|
/// PRD-003 — Integration tests for:
|
|
|
|
|
/// GET /api/v1/products/{id}/prices (requires authentication)
|
|
|
|
|
/// POST /api/v1/admin/products/{id}/prices (requires catalogo:productos:gestionar)
|
|
|
|
|
/// DB: SIGCM2_Test_Api (ApiIntegration collection — shared TestWebAppFactory).
|
|
|
|
|
///
|
|
|
|
|
/// Each test creates its own product (via SQL) to avoid inter-test dependencies.
|
|
|
|
|
/// </summary>
|
|
|
|
|
[Collection("ApiIntegration")]
|
|
|
|
|
public sealed class ProductPricesControllerTests : IAsyncLifetime
|
|
|
|
|
{
|
|
|
|
|
private const string ConnectionString = TestConnectionStrings.ApiTestDb;
|
|
|
|
|
|
|
|
|
|
private readonly TestWebAppFactory _factory;
|
|
|
|
|
private readonly HttpClient _client;
|
|
|
|
|
|
|
|
|
|
// Seeded once per class — valid MedioId and ProductTypeId from canonical data
|
|
|
|
|
private int _medioId;
|
|
|
|
|
private int _productTypeId;
|
|
|
|
|
|
|
|
|
|
public ProductPricesControllerTests(TestWebAppFactory factory)
|
|
|
|
|
{
|
|
|
|
|
_factory = factory;
|
|
|
|
|
_client = factory.CreateClient();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ── Lifecycle ─────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
public async Task InitializeAsync()
|
|
|
|
|
{
|
|
|
|
|
await using var conn = new SqlConnection(ConnectionString);
|
|
|
|
|
await conn.OpenAsync();
|
|
|
|
|
|
|
|
|
|
// Resolve a valid MedioId (dbo.Medio uses 'Activo' column)
|
|
|
|
|
_medioId = await conn.QuerySingleAsync<int>(
|
|
|
|
|
"SELECT TOP 1 Id FROM dbo.Medio WHERE Activo = 1 ORDER BY Id");
|
|
|
|
|
|
|
|
|
|
// Resolve or seed a valid ProductTypeId (dbo.ProductType uses 'IsActive' column)
|
|
|
|
|
var ptId = await conn.QuerySingleOrDefaultAsync<int?>(
|
|
|
|
|
"SELECT TOP 1 Id FROM dbo.ProductType WHERE IsActive = 1 ORDER BY Id");
|
|
|
|
|
if (ptId is null)
|
|
|
|
|
{
|
|
|
|
|
ptId = await conn.QuerySingleAsync<int>("""
|
|
|
|
|
INSERT INTO dbo.ProductType (Nombre, HasDuration, RequiresText, RequiresCategory, IsBundle, AllowImages, IsActive, FechaCreacion)
|
|
|
|
|
VALUES ('PT_Prices_Test', 0, 0, 0, 0, 0, 1, SYSUTCDATETIME());
|
|
|
|
|
SELECT CAST(SCOPE_IDENTITY() AS INT);
|
|
|
|
|
""");
|
|
|
|
|
}
|
|
|
|
|
_productTypeId = ptId.Value;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public Task DisposeAsync() => Task.CompletedTask;
|
|
|
|
|
|
|
|
|
|
// ── Auth helpers ──────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
/// <summary>Generates a bearer token for admin (has catalogo:productos:gestionar via 'admin' role).</summary>
|
|
|
|
|
private string GetAdminToken()
|
|
|
|
|
{
|
|
|
|
|
var jwt = _factory.Services.GetRequiredService<IJwtService>();
|
|
|
|
|
return jwt.GenerateAccessToken(new Usuario(
|
|
|
|
|
id: 1, username: "admin", passwordHash: "x",
|
|
|
|
|
nombre: "Admin", apellido: "Sys", email: null,
|
|
|
|
|
rol: "admin", permisosJson: """{"grant":[],"deny":[]}""", activo: true));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// <summary>Generates a bearer token for cajero (does NOT have catalogo:productos:gestionar).</summary>
|
|
|
|
|
private string GetCajeroToken()
|
|
|
|
|
{
|
|
|
|
|
var jwt = _factory.Services.GetRequiredService<IJwtService>();
|
|
|
|
|
return jwt.GenerateAccessToken(new Usuario(
|
|
|
|
|
id: 9999, username: "cajero_test", passwordHash: "x",
|
|
|
|
|
nombre: "Cajero", apellido: "Test", email: null,
|
|
|
|
|
rol: "cajero", permisosJson: """{"grant":[],"deny":[]}""", activo: true));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private HttpRequestMessage BuildRequest(
|
|
|
|
|
HttpMethod method, string url, object? body = null, string? token = null)
|
|
|
|
|
{
|
|
|
|
|
var req = new HttpRequestMessage(method, url);
|
|
|
|
|
if (token is not null)
|
|
|
|
|
req.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
|
|
|
|
|
if (body is not null)
|
|
|
|
|
req.Content = JsonContent.Create(body);
|
|
|
|
|
return req;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ── DB seed helpers ───────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
/// <summary>Seeds a unique product and returns its Id.</summary>
|
|
|
|
|
private async Task<int> SeedProductAsync(bool activo = true)
|
|
|
|
|
{
|
|
|
|
|
await using var conn = new SqlConnection(ConnectionString);
|
|
|
|
|
await conn.OpenAsync();
|
|
|
|
|
var nombre = $"PP_{Guid.NewGuid():N}"[..35];
|
|
|
|
|
// dbo.Product uses 'IsActive' column (PRD-002 convention)
|
|
|
|
|
return await conn.QuerySingleAsync<int>("""
|
|
|
|
|
INSERT INTO dbo.Product (Nombre, MedioId, ProductTypeId, BasePrice, PriceDurationDays, IsActive, FechaCreacion)
|
|
|
|
|
VALUES (@Nombre, @MedioId, @PtId, 100.00, NULL, @IsActive, SYSUTCDATETIME());
|
|
|
|
|
SELECT CAST(SCOPE_IDENTITY() AS INT);
|
|
|
|
|
""",
|
|
|
|
|
new { Nombre = nombre, MedioId = _medioId, PtId = _productTypeId, IsActive = activo ? 1 : 0 });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// <summary>
|
|
|
|
|
/// Inserts a ProductPrice row directly (bypasses SP forward-only guard) to set up test scenarios.
|
|
|
|
|
/// </summary>
|
|
|
|
|
private async Task<long> SeedPriceDirectAsync(
|
|
|
|
|
int productId, decimal price, DateOnly pvf, DateOnly? pvt)
|
|
|
|
|
{
|
|
|
|
|
await using var conn = new SqlConnection(ConnectionString);
|
|
|
|
|
await conn.OpenAsync();
|
|
|
|
|
return await conn.QuerySingleAsync<long>("""
|
|
|
|
|
INSERT INTO dbo.ProductPrices (ProductId, Price, PriceValidFrom, PriceValidTo, FechaCreacion)
|
|
|
|
|
VALUES (@ProductId, @Price, @PriceValidFrom, @PriceValidTo, SYSUTCDATETIME());
|
|
|
|
|
SELECT CAST(SCOPE_IDENTITY() AS BIGINT);
|
|
|
|
|
""",
|
|
|
|
|
new
|
|
|
|
|
{
|
|
|
|
|
ProductId = productId,
|
|
|
|
|
Price = price,
|
|
|
|
|
PriceValidFrom = pvf.ToDateTime(TimeOnly.MinValue),
|
|
|
|
|
PriceValidTo = pvt.HasValue ? (DateTime?)pvt.Value.ToDateTime(TimeOnly.MinValue) : null
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ── GET /api/v1/products/{id}/prices ─────────────────────────────────────
|
|
|
|
|
|
2026-04-19 19:47:18 -03:00
|
|
|
/// <summary>§P.8 — No token → 401.</summary>
|
feat(api): ProductPricesController + DI + ExceptionFilter integration (PRD-003)
- GET /api/v1/products/{id}/prices [Authorize] → 200 IReadOnlyList<ProductPriceDto>
- POST /api/v1/admin/products/{id}/prices [RequirePermission catalogo:productos:gestionar] → 201 AddProductPriceResponse + Location header
- ExceptionFilter: 3 new cases (ProductPriceForwardOnlyException→409, ProductPriceInvalidException→400, ProductSinPrecioActivoException→404)
- Fix AddProductPriceCommandHandler: move GetByProductIdAsync outside TransactionScope using block to avoid InvalidOperationException (scope already complete)
- 16 e2e tests in ProductPricesControllerTests: 401/403, 200 history ordered DESC, 404 not found, 201 first/second price, 400 validation, 409 forward-only, audit event, DateOnly yyyy-MM-dd roundtrip
- 305 Api.Tests + 1088 Application.Tests = 1393 total, 0 red
2026-04-19 18:26:24 -03:00
|
|
|
[Fact]
|
|
|
|
|
public async Task GetPrices_WithoutAuth_Returns401()
|
|
|
|
|
{
|
|
|
|
|
using var req = BuildRequest(HttpMethod.Get, "/api/v1/products/1/prices");
|
|
|
|
|
var resp = await _client.SendAsync(req);
|
|
|
|
|
resp.StatusCode.Should().Be(HttpStatusCode.Unauthorized);
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-19 19:47:18 -03:00
|
|
|
/// <summary>§P.8 — Token with no 'catalogo:productos:gestionar' → 403.</summary>
|
feat(api): ProductPricesController + DI + ExceptionFilter integration (PRD-003)
- GET /api/v1/products/{id}/prices [Authorize] → 200 IReadOnlyList<ProductPriceDto>
- POST /api/v1/admin/products/{id}/prices [RequirePermission catalogo:productos:gestionar] → 201 AddProductPriceResponse + Location header
- ExceptionFilter: 3 new cases (ProductPriceForwardOnlyException→409, ProductPriceInvalidException→400, ProductSinPrecioActivoException→404)
- Fix AddProductPriceCommandHandler: move GetByProductIdAsync outside TransactionScope using block to avoid InvalidOperationException (scope already complete)
- 16 e2e tests in ProductPricesControllerTests: 401/403, 200 history ordered DESC, 404 not found, 201 first/second price, 400 validation, 409 forward-only, audit event, DateOnly yyyy-MM-dd roundtrip
- 305 Api.Tests + 1088 Application.Tests = 1393 total, 0 red
2026-04-19 18:26:24 -03:00
|
|
|
[Fact]
|
2026-04-19 19:47:18 -03:00
|
|
|
public async Task GetPrices_WithoutPermission_Returns403()
|
|
|
|
|
{
|
|
|
|
|
var productId = await SeedProductAsync();
|
|
|
|
|
var token = GetCajeroToken();
|
|
|
|
|
using var req = BuildRequest(HttpMethod.Get, $"/api/v1/products/{productId}/prices", token: token);
|
|
|
|
|
var resp = await _client.SendAsync(req);
|
|
|
|
|
resp.StatusCode.Should().Be(HttpStatusCode.Forbidden);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// <summary>§P.6 — Producto sin histórico → 200 con items=[], total=0, page=1, pageSize=20.</summary>
|
|
|
|
|
[Fact]
|
|
|
|
|
public async Task GetPrices_EmptyHistory_Returns200WithPagedResultEmpty()
|
feat(api): ProductPricesController + DI + ExceptionFilter integration (PRD-003)
- GET /api/v1/products/{id}/prices [Authorize] → 200 IReadOnlyList<ProductPriceDto>
- POST /api/v1/admin/products/{id}/prices [RequirePermission catalogo:productos:gestionar] → 201 AddProductPriceResponse + Location header
- ExceptionFilter: 3 new cases (ProductPriceForwardOnlyException→409, ProductPriceInvalidException→400, ProductSinPrecioActivoException→404)
- Fix AddProductPriceCommandHandler: move GetByProductIdAsync outside TransactionScope using block to avoid InvalidOperationException (scope already complete)
- 16 e2e tests in ProductPricesControllerTests: 401/403, 200 history ordered DESC, 404 not found, 201 first/second price, 400 validation, 409 forward-only, audit event, DateOnly yyyy-MM-dd roundtrip
- 305 Api.Tests + 1088 Application.Tests = 1393 total, 0 red
2026-04-19 18:26:24 -03:00
|
|
|
{
|
|
|
|
|
var productId = await SeedProductAsync();
|
|
|
|
|
var token = GetAdminToken();
|
|
|
|
|
|
|
|
|
|
using var req = BuildRequest(HttpMethod.Get, $"/api/v1/products/{productId}/prices", token: token);
|
|
|
|
|
var resp = await _client.SendAsync(req);
|
|
|
|
|
|
|
|
|
|
resp.StatusCode.Should().Be(HttpStatusCode.OK);
|
2026-04-19 19:47:18 -03:00
|
|
|
var paged = await resp.Content.ReadFromJsonAsync<PagedResult<ProductPriceDto>>();
|
|
|
|
|
paged.Should().NotBeNull();
|
|
|
|
|
paged!.Items.Should().BeEmpty();
|
|
|
|
|
paged.Page.Should().Be(1);
|
|
|
|
|
paged.PageSize.Should().Be(20);
|
|
|
|
|
paged.Total.Should().Be(0);
|
feat(api): ProductPricesController + DI + ExceptionFilter integration (PRD-003)
- GET /api/v1/products/{id}/prices [Authorize] → 200 IReadOnlyList<ProductPriceDto>
- POST /api/v1/admin/products/{id}/prices [RequirePermission catalogo:productos:gestionar] → 201 AddProductPriceResponse + Location header
- ExceptionFilter: 3 new cases (ProductPriceForwardOnlyException→409, ProductPriceInvalidException→400, ProductSinPrecioActivoException→404)
- Fix AddProductPriceCommandHandler: move GetByProductIdAsync outside TransactionScope using block to avoid InvalidOperationException (scope already complete)
- 16 e2e tests in ProductPricesControllerTests: 401/403, 200 history ordered DESC, 404 not found, 201 first/second price, 400 validation, 409 forward-only, audit event, DateOnly yyyy-MM-dd roundtrip
- 305 Api.Tests + 1088 Application.Tests = 1393 total, 0 red
2026-04-19 18:26:24 -03:00
|
|
|
}
|
|
|
|
|
|
2026-04-19 19:47:18 -03:00
|
|
|
/// <summary>§P.1 — 10 precios, sin query params → defaults: page=1, pageSize=20, total=10, items=10.</summary>
|
feat(api): ProductPricesController + DI + ExceptionFilter integration (PRD-003)
- GET /api/v1/products/{id}/prices [Authorize] → 200 IReadOnlyList<ProductPriceDto>
- POST /api/v1/admin/products/{id}/prices [RequirePermission catalogo:productos:gestionar] → 201 AddProductPriceResponse + Location header
- ExceptionFilter: 3 new cases (ProductPriceForwardOnlyException→409, ProductPriceInvalidException→400, ProductSinPrecioActivoException→404)
- Fix AddProductPriceCommandHandler: move GetByProductIdAsync outside TransactionScope using block to avoid InvalidOperationException (scope already complete)
- 16 e2e tests in ProductPricesControllerTests: 401/403, 200 history ordered DESC, 404 not found, 201 first/second price, 400 validation, 409 forward-only, audit event, DateOnly yyyy-MM-dd roundtrip
- 305 Api.Tests + 1088 Application.Tests = 1393 total, 0 red
2026-04-19 18:26:24 -03:00
|
|
|
[Fact]
|
2026-04-19 19:47:18 -03:00
|
|
|
public async Task GetPrices_TenPrices_NoParams_ReturnsDefaultsPagedResult()
|
feat(api): ProductPricesController + DI + ExceptionFilter integration (PRD-003)
- GET /api/v1/products/{id}/prices [Authorize] → 200 IReadOnlyList<ProductPriceDto>
- POST /api/v1/admin/products/{id}/prices [RequirePermission catalogo:productos:gestionar] → 201 AddProductPriceResponse + Location header
- ExceptionFilter: 3 new cases (ProductPriceForwardOnlyException→409, ProductPriceInvalidException→400, ProductSinPrecioActivoException→404)
- Fix AddProductPriceCommandHandler: move GetByProductIdAsync outside TransactionScope using block to avoid InvalidOperationException (scope already complete)
- 16 e2e tests in ProductPricesControllerTests: 401/403, 200 history ordered DESC, 404 not found, 201 first/second price, 400 validation, 409 forward-only, audit event, DateOnly yyyy-MM-dd roundtrip
- 305 Api.Tests + 1088 Application.Tests = 1393 total, 0 red
2026-04-19 18:26:24 -03:00
|
|
|
{
|
|
|
|
|
var productId = await SeedProductAsync();
|
2026-04-19 19:47:18 -03:00
|
|
|
// Seed 10 prices — all but the last have explicit PVT to respect UX_ProductPrices_Active
|
|
|
|
|
for (var i = 1; i <= 10; i++)
|
|
|
|
|
{
|
|
|
|
|
var pvt = i < 10 ? (DateOnly?)new DateOnly(2026, 1, i) : null;
|
|
|
|
|
await SeedPriceDirectAsync(productId, i * 10m, new DateOnly(2026, 1, i), pvt);
|
|
|
|
|
}
|
feat(api): ProductPricesController + DI + ExceptionFilter integration (PRD-003)
- GET /api/v1/products/{id}/prices [Authorize] → 200 IReadOnlyList<ProductPriceDto>
- POST /api/v1/admin/products/{id}/prices [RequirePermission catalogo:productos:gestionar] → 201 AddProductPriceResponse + Location header
- ExceptionFilter: 3 new cases (ProductPriceForwardOnlyException→409, ProductPriceInvalidException→400, ProductSinPrecioActivoException→404)
- Fix AddProductPriceCommandHandler: move GetByProductIdAsync outside TransactionScope using block to avoid InvalidOperationException (scope already complete)
- 16 e2e tests in ProductPricesControllerTests: 401/403, 200 history ordered DESC, 404 not found, 201 first/second price, 400 validation, 409 forward-only, audit event, DateOnly yyyy-MM-dd roundtrip
- 305 Api.Tests + 1088 Application.Tests = 1393 total, 0 red
2026-04-19 18:26:24 -03:00
|
|
|
|
|
|
|
|
var token = GetAdminToken();
|
|
|
|
|
using var req = BuildRequest(HttpMethod.Get, $"/api/v1/products/{productId}/prices", token: token);
|
|
|
|
|
var resp = await _client.SendAsync(req);
|
|
|
|
|
|
|
|
|
|
resp.StatusCode.Should().Be(HttpStatusCode.OK);
|
2026-04-19 19:47:18 -03:00
|
|
|
var paged = await resp.Content.ReadFromJsonAsync<PagedResult<ProductPriceDto>>();
|
|
|
|
|
paged.Should().NotBeNull();
|
|
|
|
|
paged!.Page.Should().Be(1);
|
|
|
|
|
paged.PageSize.Should().Be(20);
|
|
|
|
|
paged.Total.Should().Be(10);
|
|
|
|
|
paged.Items.Should().HaveCount(10);
|
|
|
|
|
// First item must be most recent (Jan 10)
|
|
|
|
|
paged.Items[0].PriceValidFrom.Should().Be(new DateOnly(2026, 1, 10));
|
|
|
|
|
}
|
feat(api): ProductPricesController + DI + ExceptionFilter integration (PRD-003)
- GET /api/v1/products/{id}/prices [Authorize] → 200 IReadOnlyList<ProductPriceDto>
- POST /api/v1/admin/products/{id}/prices [RequirePermission catalogo:productos:gestionar] → 201 AddProductPriceResponse + Location header
- ExceptionFilter: 3 new cases (ProductPriceForwardOnlyException→409, ProductPriceInvalidException→400, ProductSinPrecioActivoException→404)
- Fix AddProductPriceCommandHandler: move GetByProductIdAsync outside TransactionScope using block to avoid InvalidOperationException (scope already complete)
- 16 e2e tests in ProductPricesControllerTests: 401/403, 200 history ordered DESC, 404 not found, 201 first/second price, 400 validation, 409 forward-only, audit event, DateOnly yyyy-MM-dd roundtrip
- 305 Api.Tests + 1088 Application.Tests = 1393 total, 0 red
2026-04-19 18:26:24 -03:00
|
|
|
|
2026-04-19 19:47:18 -03:00
|
|
|
/// <summary>§P.2 — 30 precios, page=2, pageSize=10 → items 11-20 ordenados DESC.</summary>
|
|
|
|
|
[Fact]
|
|
|
|
|
public async Task GetPrices_ThirtyPrices_Page2PageSize10_ReturnsCorrectPage()
|
|
|
|
|
{
|
|
|
|
|
var productId = await SeedProductAsync();
|
|
|
|
|
// Seed 30 prices — all but the last have explicit PVT to respect UX_ProductPrices_Active
|
|
|
|
|
for (var i = 1; i <= 30; i++)
|
|
|
|
|
{
|
|
|
|
|
var pvt = i < 30 ? (DateOnly?)new DateOnly(2026, 1, i) : null;
|
|
|
|
|
await SeedPriceDirectAsync(productId, i * 5m, new DateOnly(2026, 1, i), pvt);
|
|
|
|
|
}
|
feat(api): ProductPricesController + DI + ExceptionFilter integration (PRD-003)
- GET /api/v1/products/{id}/prices [Authorize] → 200 IReadOnlyList<ProductPriceDto>
- POST /api/v1/admin/products/{id}/prices [RequirePermission catalogo:productos:gestionar] → 201 AddProductPriceResponse + Location header
- ExceptionFilter: 3 new cases (ProductPriceForwardOnlyException→409, ProductPriceInvalidException→400, ProductSinPrecioActivoException→404)
- Fix AddProductPriceCommandHandler: move GetByProductIdAsync outside TransactionScope using block to avoid InvalidOperationException (scope already complete)
- 16 e2e tests in ProductPricesControllerTests: 401/403, 200 history ordered DESC, 404 not found, 201 first/second price, 400 validation, 409 forward-only, audit event, DateOnly yyyy-MM-dd roundtrip
- 305 Api.Tests + 1088 Application.Tests = 1393 total, 0 red
2026-04-19 18:26:24 -03:00
|
|
|
|
2026-04-19 19:47:18 -03:00
|
|
|
var token = GetAdminToken();
|
|
|
|
|
using var req = BuildRequest(
|
|
|
|
|
HttpMethod.Get, $"/api/v1/products/{productId}/prices?page=2&pageSize=10", token: token);
|
|
|
|
|
var resp = await _client.SendAsync(req);
|
|
|
|
|
|
|
|
|
|
resp.StatusCode.Should().Be(HttpStatusCode.OK);
|
|
|
|
|
var paged = await resp.Content.ReadFromJsonAsync<PagedResult<ProductPriceDto>>();
|
|
|
|
|
paged.Should().NotBeNull();
|
|
|
|
|
paged!.Page.Should().Be(2);
|
|
|
|
|
paged.PageSize.Should().Be(10);
|
|
|
|
|
paged.Total.Should().Be(30);
|
|
|
|
|
paged.Items.Should().HaveCount(10);
|
|
|
|
|
// Ordered DESC by PVF: rank 11-20 from newest = Jan 20 down to Jan 11
|
|
|
|
|
paged.Items[0].PriceValidFrom.Should().Be(new DateOnly(2026, 1, 20));
|
|
|
|
|
paged.Items[9].PriceValidFrom.Should().Be(new DateOnly(2026, 1, 11));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// <summary>§P.3 — 30 precios, page=10, pageSize=10 → items=[], total=30.</summary>
|
|
|
|
|
[Fact]
|
|
|
|
|
public async Task GetPrices_ThirtyPrices_PageBeyondTotal_ReturnsEmptyItems()
|
|
|
|
|
{
|
|
|
|
|
var productId = await SeedProductAsync();
|
|
|
|
|
for (var i = 1; i <= 30; i++)
|
|
|
|
|
{
|
|
|
|
|
var pvt = i < 30 ? (DateOnly?)new DateOnly(2026, 1, i) : null;
|
|
|
|
|
await SeedPriceDirectAsync(productId, i * 5m, new DateOnly(2026, 1, i), pvt);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
var token = GetAdminToken();
|
|
|
|
|
using var req = BuildRequest(
|
|
|
|
|
HttpMethod.Get, $"/api/v1/products/{productId}/prices?page=10&pageSize=10", token: token);
|
|
|
|
|
var resp = await _client.SendAsync(req);
|
|
|
|
|
|
|
|
|
|
resp.StatusCode.Should().Be(HttpStatusCode.OK);
|
|
|
|
|
var paged = await resp.Content.ReadFromJsonAsync<PagedResult<ProductPriceDto>>();
|
|
|
|
|
paged.Should().NotBeNull();
|
|
|
|
|
paged!.Page.Should().Be(10);
|
|
|
|
|
paged.PageSize.Should().Be(10);
|
|
|
|
|
paged.Total.Should().Be(30);
|
|
|
|
|
paged.Items.Should().BeEmpty();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// <summary>§P.4 — pageSize=500 → clamp to 100 en la respuesta.</summary>
|
|
|
|
|
[Fact]
|
|
|
|
|
public async Task GetPrices_PageSizeOver100_ClampsTo100()
|
|
|
|
|
{
|
|
|
|
|
var productId = await SeedProductAsync();
|
|
|
|
|
// Seed 5 prices — all but the last have explicit PVT to respect UX_ProductPrices_Active
|
|
|
|
|
for (var i = 1; i <= 5; i++)
|
|
|
|
|
{
|
|
|
|
|
var pvt = i < 5 ? (DateOnly?)new DateOnly(2026, 1, i) : null;
|
|
|
|
|
await SeedPriceDirectAsync(productId, i * 10m, new DateOnly(2026, 1, i), pvt);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
var token = GetAdminToken();
|
|
|
|
|
using var req = BuildRequest(
|
|
|
|
|
HttpMethod.Get, $"/api/v1/products/{productId}/prices?pageSize=500", token: token);
|
|
|
|
|
var resp = await _client.SendAsync(req);
|
|
|
|
|
|
|
|
|
|
resp.StatusCode.Should().Be(HttpStatusCode.OK);
|
|
|
|
|
var paged = await resp.Content.ReadFromJsonAsync<PagedResult<ProductPriceDto>>();
|
|
|
|
|
paged.Should().NotBeNull();
|
|
|
|
|
paged!.PageSize.Should().Be(100, "pageSize must be clamped to max 100");
|
|
|
|
|
paged.Items.Should().HaveCount(5);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// <summary>§P.5 — page=0 → clamp to 1.</summary>
|
|
|
|
|
[Fact]
|
|
|
|
|
public async Task GetPrices_PageZero_ClampsToOne()
|
|
|
|
|
{
|
|
|
|
|
var productId = await SeedProductAsync();
|
|
|
|
|
await SeedPriceDirectAsync(productId, 100m, new DateOnly(2026, 1, 1), null); // single active row
|
|
|
|
|
|
|
|
|
|
var token = GetAdminToken();
|
|
|
|
|
using var req = BuildRequest(
|
|
|
|
|
HttpMethod.Get, $"/api/v1/products/{productId}/prices?page=0", token: token);
|
|
|
|
|
var resp = await _client.SendAsync(req);
|
|
|
|
|
|
|
|
|
|
resp.StatusCode.Should().Be(HttpStatusCode.OK);
|
|
|
|
|
var paged = await resp.Content.ReadFromJsonAsync<PagedResult<ProductPriceDto>>();
|
|
|
|
|
paged.Should().NotBeNull();
|
|
|
|
|
paged!.Page.Should().Be(1, "page=0 must be clamped to 1");
|
feat(api): ProductPricesController + DI + ExceptionFilter integration (PRD-003)
- GET /api/v1/products/{id}/prices [Authorize] → 200 IReadOnlyList<ProductPriceDto>
- POST /api/v1/admin/products/{id}/prices [RequirePermission catalogo:productos:gestionar] → 201 AddProductPriceResponse + Location header
- ExceptionFilter: 3 new cases (ProductPriceForwardOnlyException→409, ProductPriceInvalidException→400, ProductSinPrecioActivoException→404)
- Fix AddProductPriceCommandHandler: move GetByProductIdAsync outside TransactionScope using block to avoid InvalidOperationException (scope already complete)
- 16 e2e tests in ProductPricesControllerTests: 401/403, 200 history ordered DESC, 404 not found, 201 first/second price, 400 validation, 409 forward-only, audit event, DateOnly yyyy-MM-dd roundtrip
- 305 Api.Tests + 1088 Application.Tests = 1393 total, 0 red
2026-04-19 18:26:24 -03:00
|
|
|
}
|
|
|
|
|
|
2026-04-19 20:01:09 -03:00
|
|
|
/// <summary>§P.4 boundary — pageSize=100 exacto → no clamping, boundary inclusivo.</summary>
|
|
|
|
|
[Fact]
|
|
|
|
|
public async Task GetPrices_PageSize100_Exact_Returns200()
|
|
|
|
|
{
|
|
|
|
|
var productId = await SeedProductAsync();
|
|
|
|
|
// Seed 3 prices — all but the last have explicit PVT
|
|
|
|
|
for (var i = 1; i <= 3; i++)
|
|
|
|
|
{
|
|
|
|
|
var pvt = i < 3 ? (DateOnly?)new DateOnly(2026, 1, i) : null;
|
|
|
|
|
await SeedPriceDirectAsync(productId, i * 10m, new DateOnly(2026, 1, i), pvt);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
var token = GetAdminToken();
|
|
|
|
|
using var req = BuildRequest(
|
|
|
|
|
HttpMethod.Get, $"/api/v1/products/{productId}/prices?pageSize=100", token: token);
|
|
|
|
|
var resp = await _client.SendAsync(req);
|
|
|
|
|
|
|
|
|
|
resp.StatusCode.Should().Be(HttpStatusCode.OK);
|
|
|
|
|
var paged = await resp.Content.ReadFromJsonAsync<PagedResult<ProductPriceDto>>();
|
|
|
|
|
paged.Should().NotBeNull();
|
|
|
|
|
paged!.PageSize.Should().Be(100, "pageSize=100 is the upper boundary — must NOT be clamped further");
|
|
|
|
|
paged.Items.Should().HaveCount(3);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// <summary>§P.4 boundary — pageSize=101 → clamp to 100.</summary>
|
|
|
|
|
[Fact]
|
|
|
|
|
public async Task GetPrices_PageSize101_ClampsTo100()
|
|
|
|
|
{
|
|
|
|
|
var productId = await SeedProductAsync();
|
|
|
|
|
await SeedPriceDirectAsync(productId, 50m, new DateOnly(2026, 2, 1), null);
|
|
|
|
|
|
|
|
|
|
var token = GetAdminToken();
|
|
|
|
|
using var req = BuildRequest(
|
|
|
|
|
HttpMethod.Get, $"/api/v1/products/{productId}/prices?pageSize=101", token: token);
|
|
|
|
|
var resp = await _client.SendAsync(req);
|
|
|
|
|
|
|
|
|
|
resp.StatusCode.Should().Be(HttpStatusCode.OK);
|
|
|
|
|
var paged = await resp.Content.ReadFromJsonAsync<PagedResult<ProductPriceDto>>();
|
|
|
|
|
paged.Should().NotBeNull();
|
|
|
|
|
paged!.PageSize.Should().Be(100, "pageSize=101 must be clamped to 100");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// <summary>§P.4 boundary — pageSize=1000 → clamp to 100.</summary>
|
|
|
|
|
[Fact]
|
|
|
|
|
public async Task GetPrices_PageSize1000_ClampsTo100()
|
|
|
|
|
{
|
|
|
|
|
var productId = await SeedProductAsync();
|
|
|
|
|
await SeedPriceDirectAsync(productId, 75m, new DateOnly(2026, 3, 1), null);
|
|
|
|
|
|
|
|
|
|
var token = GetAdminToken();
|
|
|
|
|
using var req = BuildRequest(
|
|
|
|
|
HttpMethod.Get, $"/api/v1/products/{productId}/prices?pageSize=1000", token: token);
|
|
|
|
|
var resp = await _client.SendAsync(req);
|
|
|
|
|
|
|
|
|
|
resp.StatusCode.Should().Be(HttpStatusCode.OK);
|
|
|
|
|
var paged = await resp.Content.ReadFromJsonAsync<PagedResult<ProductPriceDto>>();
|
|
|
|
|
paged.Should().NotBeNull();
|
|
|
|
|
paged!.PageSize.Should().Be(100, "pageSize=1000 must be clamped to max 100");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// <summary>§P.5 boundary — page=-5 → clamp to 1.</summary>
|
|
|
|
|
[Fact]
|
|
|
|
|
public async Task GetPrices_PageNegative_ClampsToOne()
|
|
|
|
|
{
|
|
|
|
|
var productId = await SeedProductAsync();
|
|
|
|
|
await SeedPriceDirectAsync(productId, 120m, new DateOnly(2026, 4, 1), null);
|
|
|
|
|
|
|
|
|
|
var token = GetAdminToken();
|
|
|
|
|
using var req = BuildRequest(
|
|
|
|
|
HttpMethod.Get, $"/api/v1/products/{productId}/prices?page=-5", token: token);
|
|
|
|
|
var resp = await _client.SendAsync(req);
|
|
|
|
|
|
|
|
|
|
resp.StatusCode.Should().Be(HttpStatusCode.OK);
|
|
|
|
|
var paged = await resp.Content.ReadFromJsonAsync<PagedResult<ProductPriceDto>>();
|
|
|
|
|
paged.Should().NotBeNull();
|
|
|
|
|
paged!.Page.Should().Be(1, "page=-5 must be clamped to 1");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// <summary>§P.4 boundary — pageSize=0 → clamp to 1 (minimum).</summary>
|
|
|
|
|
[Fact]
|
|
|
|
|
public async Task GetPrices_PageSizeZero_ClampsToOne()
|
|
|
|
|
{
|
|
|
|
|
var productId = await SeedProductAsync();
|
|
|
|
|
await SeedPriceDirectAsync(productId, 90m, new DateOnly(2026, 5, 1), null);
|
|
|
|
|
|
|
|
|
|
var token = GetAdminToken();
|
|
|
|
|
using var req = BuildRequest(
|
|
|
|
|
HttpMethod.Get, $"/api/v1/products/{productId}/prices?pageSize=0", token: token);
|
|
|
|
|
var resp = await _client.SendAsync(req);
|
|
|
|
|
|
|
|
|
|
resp.StatusCode.Should().Be(HttpStatusCode.OK);
|
|
|
|
|
var paged = await resp.Content.ReadFromJsonAsync<PagedResult<ProductPriceDto>>();
|
|
|
|
|
paged.Should().NotBeNull();
|
|
|
|
|
paged!.PageSize.Should().Be(1, "pageSize=0 must be clamped to minimum 1");
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-19 19:47:18 -03:00
|
|
|
/// <summary>§P.7 — Producto inexistente → 404.</summary>
|
feat(api): ProductPricesController + DI + ExceptionFilter integration (PRD-003)
- GET /api/v1/products/{id}/prices [Authorize] → 200 IReadOnlyList<ProductPriceDto>
- POST /api/v1/admin/products/{id}/prices [RequirePermission catalogo:productos:gestionar] → 201 AddProductPriceResponse + Location header
- ExceptionFilter: 3 new cases (ProductPriceForwardOnlyException→409, ProductPriceInvalidException→400, ProductSinPrecioActivoException→404)
- Fix AddProductPriceCommandHandler: move GetByProductIdAsync outside TransactionScope using block to avoid InvalidOperationException (scope already complete)
- 16 e2e tests in ProductPricesControllerTests: 401/403, 200 history ordered DESC, 404 not found, 201 first/second price, 400 validation, 409 forward-only, audit event, DateOnly yyyy-MM-dd roundtrip
- 305 Api.Tests + 1088 Application.Tests = 1393 total, 0 red
2026-04-19 18:26:24 -03:00
|
|
|
[Fact]
|
|
|
|
|
public async Task GetPrices_ProductNotFound_Returns404()
|
|
|
|
|
{
|
|
|
|
|
var token = GetAdminToken();
|
|
|
|
|
using var req = BuildRequest(HttpMethod.Get, "/api/v1/products/999999999/prices", token: token);
|
|
|
|
|
var resp = await _client.SendAsync(req);
|
|
|
|
|
|
|
|
|
|
resp.StatusCode.Should().Be(HttpStatusCode.NotFound);
|
|
|
|
|
var body = await resp.Content.ReadFromJsonAsync<JsonElement>();
|
|
|
|
|
body.GetProperty("error").GetString().Should().Be("product_not_found");
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-19 19:47:18 -03:00
|
|
|
/// <summary>
|
|
|
|
|
/// §P.1 compat — 3 prices, no params → PagedResult with items ordered DESC.
|
|
|
|
|
/// Replaces the old GetPrices_WithHistory_Returns200OrderedDescending.
|
|
|
|
|
/// </summary>
|
|
|
|
|
[Fact]
|
|
|
|
|
public async Task GetPrices_WithHistory_Returns200OrderedDescendingPaged()
|
|
|
|
|
{
|
|
|
|
|
var productId = await SeedProductAsync();
|
|
|
|
|
|
|
|
|
|
// Seed 3 prices: 2 closed + 1 active (ascending order to verify API returns DESC)
|
|
|
|
|
await SeedPriceDirectAsync(productId, 50m, new DateOnly(2026, 1, 1), new DateOnly(2026, 1, 31));
|
|
|
|
|
await SeedPriceDirectAsync(productId, 75m, new DateOnly(2026, 2, 1), new DateOnly(2026, 2, 28));
|
|
|
|
|
await SeedPriceDirectAsync(productId, 100m, new DateOnly(2026, 3, 1), null);
|
|
|
|
|
|
|
|
|
|
var token = GetAdminToken();
|
|
|
|
|
using var req = BuildRequest(HttpMethod.Get, $"/api/v1/products/{productId}/prices", token: token);
|
|
|
|
|
var resp = await _client.SendAsync(req);
|
|
|
|
|
|
|
|
|
|
resp.StatusCode.Should().Be(HttpStatusCode.OK);
|
|
|
|
|
var paged = await resp.Content.ReadFromJsonAsync<PagedResult<ProductPriceDto>>();
|
|
|
|
|
paged.Should().NotBeNull();
|
|
|
|
|
paged!.Total.Should().Be(3);
|
|
|
|
|
paged.Items.Should().HaveCount(3);
|
|
|
|
|
|
|
|
|
|
// First item = most recent (active, March)
|
|
|
|
|
paged.Items[0].PriceValidFrom.Should().Be(new DateOnly(2026, 3, 1));
|
|
|
|
|
paged.Items[0].IsActive.Should().BeTrue();
|
|
|
|
|
paged.Items[0].PriceValidTo.Should().BeNull();
|
|
|
|
|
|
|
|
|
|
// Last item = oldest (January)
|
|
|
|
|
paged.Items[2].PriceValidFrom.Should().Be(new DateOnly(2026, 1, 1));
|
|
|
|
|
paged.Items[2].IsActive.Should().BeFalse();
|
|
|
|
|
paged.Items[2].PriceValidTo.Should().Be(new DateOnly(2026, 1, 31));
|
|
|
|
|
}
|
|
|
|
|
|
feat(api): ProductPricesController + DI + ExceptionFilter integration (PRD-003)
- GET /api/v1/products/{id}/prices [Authorize] → 200 IReadOnlyList<ProductPriceDto>
- POST /api/v1/admin/products/{id}/prices [RequirePermission catalogo:productos:gestionar] → 201 AddProductPriceResponse + Location header
- ExceptionFilter: 3 new cases (ProductPriceForwardOnlyException→409, ProductPriceInvalidException→400, ProductSinPrecioActivoException→404)
- Fix AddProductPriceCommandHandler: move GetByProductIdAsync outside TransactionScope using block to avoid InvalidOperationException (scope already complete)
- 16 e2e tests in ProductPricesControllerTests: 401/403, 200 history ordered DESC, 404 not found, 201 first/second price, 400 validation, 409 forward-only, audit event, DateOnly yyyy-MM-dd roundtrip
- 305 Api.Tests + 1088 Application.Tests = 1393 total, 0 red
2026-04-19 18:26:24 -03:00
|
|
|
// ── POST /api/v1/admin/products/{id}/prices ───────────────────────────────
|
|
|
|
|
|
|
|
|
|
[Fact]
|
|
|
|
|
public async Task PostPrice_WithoutAuth_Returns401()
|
|
|
|
|
{
|
|
|
|
|
using var req = BuildRequest(HttpMethod.Post, "/api/v1/admin/products/1/prices",
|
|
|
|
|
body: new { price = 200m, priceValidFrom = "2026-05-01" });
|
|
|
|
|
var resp = await _client.SendAsync(req);
|
|
|
|
|
resp.StatusCode.Should().Be(HttpStatusCode.Unauthorized);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
[Fact]
|
|
|
|
|
public async Task PostPrice_WithoutPermission_Returns403()
|
|
|
|
|
{
|
|
|
|
|
var productId = await SeedProductAsync();
|
|
|
|
|
var token = GetCajeroToken();
|
|
|
|
|
var pvf = DateOnly.FromDateTime(DateTime.UtcNow).AddDays(1).ToString("yyyy-MM-dd");
|
|
|
|
|
|
|
|
|
|
using var req = BuildRequest(HttpMethod.Post, $"/api/v1/admin/products/{productId}/prices",
|
|
|
|
|
body: new { price = 200m, priceValidFrom = pvf },
|
|
|
|
|
token: token);
|
|
|
|
|
var resp = await _client.SendAsync(req);
|
|
|
|
|
resp.StatusCode.Should().Be(HttpStatusCode.Forbidden);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
[Fact]
|
|
|
|
|
public async Task PostPrice_FirstPrice_Returns201WithClosedNull()
|
|
|
|
|
{
|
|
|
|
|
var productId = await SeedProductAsync();
|
|
|
|
|
var token = GetAdminToken();
|
|
|
|
|
// Use tomorrow to ensure >= hoy_AR passes FluentValidation
|
|
|
|
|
var pvf = DateOnly.FromDateTime(DateTime.UtcNow).AddDays(1).ToString("yyyy-MM-dd");
|
|
|
|
|
|
|
|
|
|
using var req = BuildRequest(HttpMethod.Post, $"/api/v1/admin/products/{productId}/prices",
|
|
|
|
|
body: new { price = 250m, priceValidFrom = pvf },
|
|
|
|
|
token: token);
|
|
|
|
|
var resp = await _client.SendAsync(req);
|
|
|
|
|
|
|
|
|
|
resp.StatusCode.Should().Be(HttpStatusCode.Created);
|
|
|
|
|
|
|
|
|
|
var body = await resp.Content.ReadFromJsonAsync<JsonElement>();
|
|
|
|
|
body.TryGetProperty("created", out var created).Should().BeTrue();
|
|
|
|
|
body.TryGetProperty("closed", out var closed).Should().BeTrue();
|
|
|
|
|
|
|
|
|
|
created.GetProperty("price").GetDecimal().Should().Be(250m);
|
|
|
|
|
created.GetProperty("priceValidFrom").GetString().Should().Be(pvf);
|
|
|
|
|
created.GetProperty("isActive").GetBoolean().Should().BeTrue();
|
|
|
|
|
closed.ValueKind.Should().Be(JsonValueKind.Null);
|
|
|
|
|
|
|
|
|
|
// Location header must be present
|
|
|
|
|
resp.Headers.Location.Should().NotBeNull();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
[Fact]
|
|
|
|
|
public async Task PostPrice_SecondPrice_Returns201WithClosedNotNull()
|
|
|
|
|
{
|
|
|
|
|
var productId = await SeedProductAsync();
|
|
|
|
|
var token = GetAdminToken();
|
|
|
|
|
|
|
|
|
|
// Seed an existing active price at a past date (direct insert bypasses SP)
|
|
|
|
|
await SeedPriceDirectAsync(productId, 100m, new DateOnly(2026, 4, 1), null);
|
|
|
|
|
|
|
|
|
|
// New price must be strictly greater than active PVF
|
|
|
|
|
var newPvf = DateOnly.FromDateTime(DateTime.UtcNow).AddDays(2).ToString("yyyy-MM-dd");
|
|
|
|
|
using var req = BuildRequest(HttpMethod.Post, $"/api/v1/admin/products/{productId}/prices",
|
|
|
|
|
body: new { price = 300m, priceValidFrom = newPvf },
|
|
|
|
|
token: token);
|
|
|
|
|
var resp = await _client.SendAsync(req);
|
|
|
|
|
|
|
|
|
|
resp.StatusCode.Should().Be(HttpStatusCode.Created);
|
|
|
|
|
|
|
|
|
|
var body = await resp.Content.ReadFromJsonAsync<JsonElement>();
|
|
|
|
|
var closed = body.GetProperty("closed");
|
|
|
|
|
closed.ValueKind.Should().NotBe(JsonValueKind.Null);
|
|
|
|
|
closed.GetProperty("price").GetDecimal().Should().Be(100m);
|
|
|
|
|
|
|
|
|
|
// PriceValidTo of closed = newPvf - 1 day
|
|
|
|
|
var expectedPvt = DateOnly.ParseExact(newPvf, "yyyy-MM-dd").AddDays(-1).ToString("yyyy-MM-dd");
|
|
|
|
|
closed.GetProperty("priceValidTo").GetString().Should().Be(expectedPvt);
|
|
|
|
|
|
|
|
|
|
var created = body.GetProperty("created");
|
|
|
|
|
created.GetProperty("price").GetDecimal().Should().Be(300m);
|
|
|
|
|
created.GetProperty("priceValidFrom").GetString().Should().Be(newPvf);
|
|
|
|
|
created.GetProperty("isActive").GetBoolean().Should().BeTrue();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
[Fact]
|
|
|
|
|
public async Task PostPrice_PriceZero_Returns400()
|
|
|
|
|
{
|
|
|
|
|
var productId = await SeedProductAsync();
|
|
|
|
|
var token = GetAdminToken();
|
|
|
|
|
var pvf = DateOnly.FromDateTime(DateTime.UtcNow).AddDays(1).ToString("yyyy-MM-dd");
|
|
|
|
|
|
|
|
|
|
using var req = BuildRequest(HttpMethod.Post, $"/api/v1/admin/products/{productId}/prices",
|
|
|
|
|
body: new { price = 0m, priceValidFrom = pvf },
|
|
|
|
|
token: token);
|
|
|
|
|
var resp = await _client.SendAsync(req);
|
|
|
|
|
resp.StatusCode.Should().Be(HttpStatusCode.BadRequest);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
[Fact]
|
|
|
|
|
public async Task PostPrice_NegativePrice_Returns400()
|
|
|
|
|
{
|
|
|
|
|
var productId = await SeedProductAsync();
|
|
|
|
|
var token = GetAdminToken();
|
|
|
|
|
var pvf = DateOnly.FromDateTime(DateTime.UtcNow).AddDays(1).ToString("yyyy-MM-dd");
|
|
|
|
|
|
|
|
|
|
using var req = BuildRequest(HttpMethod.Post, $"/api/v1/admin/products/{productId}/prices",
|
|
|
|
|
body: new { price = -5m, priceValidFrom = pvf },
|
|
|
|
|
token: token);
|
|
|
|
|
var resp = await _client.SendAsync(req);
|
|
|
|
|
resp.StatusCode.Should().Be(HttpStatusCode.BadRequest);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
[Fact]
|
|
|
|
|
public async Task PostPrice_PastDate_Returns400()
|
|
|
|
|
{
|
|
|
|
|
var productId = await SeedProductAsync();
|
|
|
|
|
var token = GetAdminToken();
|
|
|
|
|
// Clearly in the past
|
|
|
|
|
const string pastDate = "2020-01-01";
|
|
|
|
|
|
|
|
|
|
using var req = BuildRequest(HttpMethod.Post, $"/api/v1/admin/products/{productId}/prices",
|
|
|
|
|
body: new { price = 100m, priceValidFrom = pastDate },
|
|
|
|
|
token: token);
|
|
|
|
|
var resp = await _client.SendAsync(req);
|
|
|
|
|
resp.StatusCode.Should().Be(HttpStatusCode.BadRequest);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
[Fact]
|
|
|
|
|
public async Task PostPrice_ProductNotFound_Returns404()
|
|
|
|
|
{
|
|
|
|
|
var token = GetAdminToken();
|
|
|
|
|
var pvf = DateOnly.FromDateTime(DateTime.UtcNow).AddDays(1).ToString("yyyy-MM-dd");
|
|
|
|
|
|
|
|
|
|
using var req = BuildRequest(HttpMethod.Post, "/api/v1/admin/products/999999999/prices",
|
|
|
|
|
body: new { price = 100m, priceValidFrom = pvf },
|
|
|
|
|
token: token);
|
|
|
|
|
var resp = await _client.SendAsync(req);
|
|
|
|
|
|
|
|
|
|
resp.StatusCode.Should().Be(HttpStatusCode.NotFound);
|
|
|
|
|
var body = await resp.Content.ReadFromJsonAsync<JsonElement>();
|
|
|
|
|
body.GetProperty("error").GetString().Should().Be("product_not_found");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
[Fact]
|
|
|
|
|
public async Task PostPrice_ForwardOnlyViolation_Returns409()
|
|
|
|
|
{
|
|
|
|
|
var productId = await SeedProductAsync();
|
|
|
|
|
var token = GetAdminToken();
|
|
|
|
|
|
|
|
|
|
// Seed active price at a far-future date
|
|
|
|
|
await SeedPriceDirectAsync(productId, 200m, new DateOnly(2099, 12, 1), null);
|
|
|
|
|
|
|
|
|
|
// Try to add price with a date BEFORE the active one
|
|
|
|
|
const string retroPvf = "2099-11-01";
|
|
|
|
|
using var req = BuildRequest(HttpMethod.Post, $"/api/v1/admin/products/{productId}/prices",
|
|
|
|
|
body: new { price = 150m, priceValidFrom = retroPvf },
|
|
|
|
|
token: token);
|
|
|
|
|
var resp = await _client.SendAsync(req);
|
|
|
|
|
|
|
|
|
|
resp.StatusCode.Should().Be(HttpStatusCode.Conflict);
|
|
|
|
|
var body = await resp.Content.ReadFromJsonAsync<JsonElement>();
|
|
|
|
|
body.GetProperty("error").GetString().Should().Be("product_price_forward_only");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
[Fact]
|
|
|
|
|
public async Task PostPrice_SamePvfAsActive_Returns409()
|
|
|
|
|
{
|
|
|
|
|
var productId = await SeedProductAsync();
|
|
|
|
|
var token = GetAdminToken();
|
|
|
|
|
|
|
|
|
|
// Seed active price at a far-future date
|
|
|
|
|
await SeedPriceDirectAsync(productId, 200m, new DateOnly(2099, 12, 1), null);
|
|
|
|
|
|
|
|
|
|
// Try to add price with the SAME date — also rejected (forward-only: must be strictly greater)
|
|
|
|
|
const string samePvf = "2099-12-01";
|
|
|
|
|
using var req = BuildRequest(HttpMethod.Post, $"/api/v1/admin/products/{productId}/prices",
|
|
|
|
|
body: new { price = 250m, priceValidFrom = samePvf },
|
|
|
|
|
token: token);
|
|
|
|
|
var resp = await _client.SendAsync(req);
|
|
|
|
|
|
|
|
|
|
resp.StatusCode.Should().Be(HttpStatusCode.Conflict);
|
|
|
|
|
var body = await resp.Content.ReadFromJsonAsync<JsonElement>();
|
|
|
|
|
body.GetProperty("error").GetString().Should().Be("product_price_forward_only");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ── Audit Event ───────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
[Fact]
|
|
|
|
|
public async Task PostPrice_Success_CreatesAuditEvent()
|
|
|
|
|
{
|
|
|
|
|
var productId = await SeedProductAsync();
|
|
|
|
|
var token = GetAdminToken();
|
|
|
|
|
var pvf = DateOnly.FromDateTime(DateTime.UtcNow).AddDays(1).ToString("yyyy-MM-dd");
|
|
|
|
|
|
|
|
|
|
using var req = BuildRequest(HttpMethod.Post, $"/api/v1/admin/products/{productId}/prices",
|
|
|
|
|
body: new { price = 500m, priceValidFrom = pvf },
|
|
|
|
|
token: token);
|
|
|
|
|
var resp = await _client.SendAsync(req);
|
|
|
|
|
resp.StatusCode.Should().Be(HttpStatusCode.Created);
|
|
|
|
|
|
|
|
|
|
var body = await resp.Content.ReadFromJsonAsync<JsonElement>();
|
|
|
|
|
var newId = body.GetProperty("created").GetProperty("id").GetInt64();
|
|
|
|
|
|
|
|
|
|
// Verify audit event row in SIGCM2_Test_Api
|
|
|
|
|
await using var conn = new SqlConnection(ConnectionString);
|
|
|
|
|
await conn.OpenAsync();
|
|
|
|
|
var auditCount = await conn.QuerySingleAsync<int>("""
|
|
|
|
|
SELECT COUNT(1) FROM dbo.AuditEvent
|
|
|
|
|
WHERE Action = 'product_price.created'
|
|
|
|
|
AND TargetType = 'ProductPrice'
|
|
|
|
|
AND TargetId = @TargetId
|
|
|
|
|
""", new { TargetId = newId.ToString() });
|
|
|
|
|
|
|
|
|
|
auditCount.Should().Be(1, because: "IAuditLogger must record product_price.created after a successful POST");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ── DateOnly JSON format ──────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
[Fact]
|
|
|
|
|
public async Task PostPrice_DateOnly_SerializesAsYyyyMmDd()
|
|
|
|
|
{
|
|
|
|
|
var productId = await SeedProductAsync();
|
|
|
|
|
var token = GetAdminToken();
|
|
|
|
|
var pvf = DateOnly.FromDateTime(DateTime.UtcNow).AddDays(1).ToString("yyyy-MM-dd");
|
|
|
|
|
|
|
|
|
|
using var req = BuildRequest(HttpMethod.Post, $"/api/v1/admin/products/{productId}/prices",
|
|
|
|
|
body: new { price = 999m, priceValidFrom = pvf },
|
|
|
|
|
token: token);
|
|
|
|
|
var resp = await _client.SendAsync(req);
|
|
|
|
|
resp.StatusCode.Should().Be(HttpStatusCode.Created);
|
|
|
|
|
|
|
|
|
|
var body = await resp.Content.ReadFromJsonAsync<JsonElement>();
|
|
|
|
|
var returnedPvf = body.GetProperty("created").GetProperty("priceValidFrom").GetString();
|
|
|
|
|
|
|
|
|
|
// DateOnlyJsonConverter must produce "yyyy-MM-dd" — no time, no TZ suffix
|
|
|
|
|
returnedPvf.Should().MatchRegex(@"^\d{4}-\d{2}-\d{2}$",
|
|
|
|
|
because: "DateOnlyJsonConverter (UDT-011) must serialize DateOnly as yyyy-MM-dd");
|
|
|
|
|
returnedPvf.Should().Be(pvf);
|
|
|
|
|
}
|
|
|
|
|
}
|