feat: PRD-003 ProductPrices históricos (ValidFrom/ValidTo) #45
93
src/api/SIGCM2.Api/Controllers/ProductPricesController.cs
Normal file
93
src/api/SIGCM2.Api/Controllers/ProductPricesController.cs
Normal file
@@ -0,0 +1,93 @@
|
||||
using FluentValidation;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using SIGCM2.Api.Authorization;
|
||||
using SIGCM2.Application.Abstractions;
|
||||
using SIGCM2.Application.Products.Prices;
|
||||
using SIGCM2.Application.Products.Prices.AddPrice;
|
||||
using SIGCM2.Application.Products.Prices.GetHistory;
|
||||
|
||||
namespace SIGCM2.Api.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// PRD-003: ProductPrices historic pricing management.
|
||||
/// Read endpoint at GET /api/v1/products/{id}/prices — requires authentication (any role).
|
||||
/// Write endpoint at POST /api/v1/admin/products/{id}/prices — requires 'catalogo:productos:gestionar'.
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
public sealed class ProductPricesController : ControllerBase
|
||||
{
|
||||
private readonly IDispatcher _dispatcher;
|
||||
private readonly IValidator<AddProductPriceCommand> _addValidator;
|
||||
|
||||
public ProductPricesController(
|
||||
IDispatcher dispatcher,
|
||||
IValidator<AddProductPriceCommand> addValidator)
|
||||
{
|
||||
_dispatcher = dispatcher;
|
||||
_addValidator = addValidator;
|
||||
}
|
||||
|
||||
// ── READ endpoint ──────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Returns the full price history for a Product, ordered descending by PriceValidFrom.
|
||||
/// Returns 200 with empty array if the product has no prices yet.
|
||||
/// Returns 404 if the product does not exist.
|
||||
/// </summary>
|
||||
[HttpGet("api/v1/products/{id:int}/prices")]
|
||||
[Authorize]
|
||||
[ProducesResponseType(typeof(IReadOnlyList<ProductPriceDto>), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<IActionResult> GetProductPrices([FromRoute] int id)
|
||||
{
|
||||
var query = new GetProductPricesQuery(id);
|
||||
var result = await _dispatcher.Send<GetProductPricesQuery, IReadOnlyList<ProductPriceDto>>(query);
|
||||
return Ok(result);
|
||||
}
|
||||
|
||||
// ── WRITE endpoint ─────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Adds a new price to a Product. Closes the current active price if one exists.
|
||||
/// PriceValidFrom must be >= today_AR and strictly greater than the active price's PriceValidFrom.
|
||||
/// Returns 201 Created with Location header pointing to GET /api/v1/products/{id}/prices.
|
||||
/// </summary>
|
||||
[HttpPost("api/v1/admin/products/{id:int}/prices")]
|
||||
[RequirePermission("catalogo:productos:gestionar")]
|
||||
[ProducesResponseType(typeof(AddProductPriceResponse), StatusCodes.Status201Created)]
|
||||
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
||||
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
||||
public async Task<IActionResult> AddProductPrice(
|
||||
[FromRoute] int id,
|
||||
[FromBody] AddProductPriceRequest request)
|
||||
{
|
||||
var command = new AddProductPriceCommand(
|
||||
ProductId: id,
|
||||
Price: request.Price,
|
||||
PriceValidFrom: request.PriceValidFrom);
|
||||
|
||||
var validation = await _addValidator.ValidateAsync(command);
|
||||
if (!validation.IsValid)
|
||||
{
|
||||
var errors = validation.Errors
|
||||
.GroupBy(e => e.PropertyName)
|
||||
.ToDictionary(g => g.Key, g => g.Select(e => e.ErrorMessage).ToArray());
|
||||
return BadRequest(new { errors });
|
||||
}
|
||||
|
||||
var result = await _dispatcher.Send<AddProductPriceCommand, AddProductPriceResponse>(command);
|
||||
return CreatedAtAction(nameof(GetProductPrices), new { id }, result);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Request body record ───────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>PRD-003: Add ProductPrice request body.</summary>
|
||||
public sealed record AddProductPriceRequest(
|
||||
decimal Price,
|
||||
DateOnly PriceValidFrom);
|
||||
@@ -475,6 +475,45 @@ public sealed class ExceptionFilter : IExceptionFilter
|
||||
context.ExceptionHandled = true;
|
||||
break;
|
||||
|
||||
// PRD-003: ProductPrices exceptions
|
||||
case ProductPriceForwardOnlyException forwardOnlyEx:
|
||||
context.Result = new ObjectResult(new
|
||||
{
|
||||
error = "product_price_forward_only",
|
||||
message = forwardOnlyEx.Message,
|
||||
productId = forwardOnlyEx.ProductId
|
||||
})
|
||||
{
|
||||
StatusCode = StatusCodes.Status409Conflict
|
||||
};
|
||||
context.ExceptionHandled = true;
|
||||
break;
|
||||
|
||||
case ProductPriceInvalidException priceInvalidEx:
|
||||
context.Result = new ObjectResult(new
|
||||
{
|
||||
error = "product_price_invalid",
|
||||
message = priceInvalidEx.Message,
|
||||
field = priceInvalidEx.Field
|
||||
})
|
||||
{
|
||||
StatusCode = StatusCodes.Status400BadRequest
|
||||
};
|
||||
context.ExceptionHandled = true;
|
||||
break;
|
||||
|
||||
case ProductSinPrecioActivoException sinPrecioEx:
|
||||
context.Result = new ObjectResult(new
|
||||
{
|
||||
error = "product_sin_precio_activo",
|
||||
message = sinPrecioEx.Message
|
||||
})
|
||||
{
|
||||
StatusCode = StatusCodes.Status404NotFound
|
||||
};
|
||||
context.ExceptionHandled = true;
|
||||
break;
|
||||
|
||||
// PRD-002: Product exceptions
|
||||
case ProductNotFoundException productNotFoundEx:
|
||||
context.Result = new ObjectResult(new
|
||||
|
||||
@@ -44,12 +44,16 @@ public sealed class AddProductPriceCommandHandler
|
||||
|
||||
// 2. TX + SP + audit (fail-closed).
|
||||
// El audit.LogAsync enlista en el mismo TransactionScope — si falla, rollback total.
|
||||
using var tx = new TransactionScope(
|
||||
// GetByProductIdAsync se ejecuta FUERA del scope (post-commit) para evitar
|
||||
// "TransactionScope is already complete" al abrir una nueva conexión dentro del using.
|
||||
long newId;
|
||||
long? closedId;
|
||||
using (var tx = new TransactionScope(
|
||||
TransactionScopeOption.Required,
|
||||
new TransactionOptions { IsolationLevel = System.Transactions.IsolationLevel.ReadCommitted },
|
||||
TransactionScopeAsyncFlowOption.Enabled);
|
||||
|
||||
var (newId, closedId) = await _pricesRepo.AddAsync(
|
||||
TransactionScopeAsyncFlowOption.Enabled))
|
||||
{
|
||||
(newId, closedId) = await _pricesRepo.AddAsync(
|
||||
command.ProductId, command.Price, command.PriceValidFrom);
|
||||
|
||||
await _audit.LogAsync(
|
||||
@@ -68,6 +72,7 @@ public sealed class AddProductPriceCommandHandler
|
||||
});
|
||||
|
||||
tx.Complete();
|
||||
} // TX disposed (committed) here — BEFORE the post-commit read below.
|
||||
|
||||
// 3. Compongo la respuesta post-commit con lectura de historial actualizado.
|
||||
var prices = await _pricesRepo.GetByProductIdAsync(command.ProductId);
|
||||
|
||||
450
tests/SIGCM2.Api.Tests/Products/ProductPricesControllerTests.cs
Normal file
450
tests/SIGCM2.Api.Tests/Products/ProductPricesControllerTests.cs
Normal file
@@ -0,0 +1,450 @@
|
||||
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;
|
||||
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 ─────────────────────────────────────
|
||||
|
||||
[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);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetPrices_EmptyHistory_Returns200WithEmptyArray()
|
||||
{
|
||||
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);
|
||||
var json = await resp.Content.ReadFromJsonAsync<JsonElement>();
|
||||
json.ValueKind.Should().Be(JsonValueKind.Array);
|
||||
json.GetArrayLength().Should().Be(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetPrices_WithHistory_Returns200OrderedDescending()
|
||||
{
|
||||
var productId = await SeedProductAsync();
|
||||
|
||||
// Seed 3 prices: 2 closed + 1 active (in 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 items = await resp.Content.ReadFromJsonAsync<JsonElement>();
|
||||
items.GetArrayLength().Should().Be(3);
|
||||
|
||||
// First item = most recent (active, March)
|
||||
var first = items[0];
|
||||
first.GetProperty("priceValidFrom").GetString().Should().Be("2026-03-01");
|
||||
first.GetProperty("isActive").GetBoolean().Should().BeTrue();
|
||||
first.GetProperty("priceValidTo").ValueKind.Should().Be(JsonValueKind.Null);
|
||||
|
||||
// Last item = oldest (January)
|
||||
var last = items[2];
|
||||
last.GetProperty("priceValidFrom").GetString().Should().Be("2026-01-01");
|
||||
last.GetProperty("isActive").GetBoolean().Should().BeFalse();
|
||||
last.GetProperty("priceValidTo").GetString().Should().Be("2026-01-31");
|
||||
}
|
||||
|
||||
[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");
|
||||
}
|
||||
|
||||
// ── 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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user