From f6f24bc4be36650195a6f753d2ebe64ff34b712f Mon Sep 17 00:00:00 2001 From: dmolinari Date: Sun, 19 Apr 2026 18:26:24 -0300 Subject: [PATCH] feat(api): ProductPricesController + DI + ExceptionFilter integration (PRD-003) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - GET /api/v1/products/{id}/prices [Authorize] → 200 IReadOnlyList - 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 --- .../Controllers/ProductPricesController.cs | 93 ++++ src/api/SIGCM2.Api/Filters/ExceptionFilter.cs | 39 ++ .../AddPrice/AddProductPriceCommandHandler.cs | 43 +- .../Products/ProductPricesControllerTests.cs | 450 ++++++++++++++++++ 4 files changed, 606 insertions(+), 19 deletions(-) create mode 100644 src/api/SIGCM2.Api/Controllers/ProductPricesController.cs create mode 100644 tests/SIGCM2.Api.Tests/Products/ProductPricesControllerTests.cs diff --git a/src/api/SIGCM2.Api/Controllers/ProductPricesController.cs b/src/api/SIGCM2.Api/Controllers/ProductPricesController.cs new file mode 100644 index 0000000..b3c8fbc --- /dev/null +++ b/src/api/SIGCM2.Api/Controllers/ProductPricesController.cs @@ -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; + +/// +/// 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'. +/// +[ApiController] +public sealed class ProductPricesController : ControllerBase +{ + private readonly IDispatcher _dispatcher; + private readonly IValidator _addValidator; + + public ProductPricesController( + IDispatcher dispatcher, + IValidator addValidator) + { + _dispatcher = dispatcher; + _addValidator = addValidator; + } + + // ── READ endpoint ────────────────────────────────────────────────────────── + + /// + /// 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. + /// + [HttpGet("api/v1/products/{id:int}/prices")] + [Authorize] + [ProducesResponseType(typeof(IReadOnlyList), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task GetProductPrices([FromRoute] int id) + { + var query = new GetProductPricesQuery(id); + var result = await _dispatcher.Send>(query); + return Ok(result); + } + + // ── WRITE endpoint ───────────────────────────────────────────────────────── + + /// + /// 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. + /// + [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 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(command); + return CreatedAtAction(nameof(GetProductPrices), new { id }, result); + } +} + +// ── Request body record ─────────────────────────────────────────────────────── + +/// PRD-003: Add ProductPrice request body. +public sealed record AddProductPriceRequest( + decimal Price, + DateOnly PriceValidFrom); diff --git a/src/api/SIGCM2.Api/Filters/ExceptionFilter.cs b/src/api/SIGCM2.Api/Filters/ExceptionFilter.cs index 8fc1884..9b3602e 100644 --- a/src/api/SIGCM2.Api/Filters/ExceptionFilter.cs +++ b/src/api/SIGCM2.Api/Filters/ExceptionFilter.cs @@ -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 diff --git a/src/api/SIGCM2.Application/Products/Prices/AddPrice/AddProductPriceCommandHandler.cs b/src/api/SIGCM2.Application/Products/Prices/AddPrice/AddProductPriceCommandHandler.cs index 99861ff..d54ee9f 100644 --- a/src/api/SIGCM2.Application/Products/Prices/AddPrice/AddProductPriceCommandHandler.cs +++ b/src/api/SIGCM2.Application/Products/Prices/AddPrice/AddProductPriceCommandHandler.cs @@ -44,30 +44,35 @@ 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); + TransactionScopeAsyncFlowOption.Enabled)) + { + (newId, closedId) = await _pricesRepo.AddAsync( + command.ProductId, command.Price, command.PriceValidFrom); - var (newId, closedId) = await _pricesRepo.AddAsync( - command.ProductId, command.Price, command.PriceValidFrom); - - await _audit.LogAsync( - action: "product_price.created", - targetType: "ProductPrice", - targetId: newId.ToString(), - metadata: new - { - after = new + await _audit.LogAsync( + action: "product_price.created", + targetType: "ProductPrice", + targetId: newId.ToString(), + metadata: new { - command.ProductId, - command.Price, - priceValidFrom = command.PriceValidFrom.ToString("yyyy-MM-dd"), - }, - closedPriceId = closedId - }); + after = new + { + command.ProductId, + command.Price, + priceValidFrom = command.PriceValidFrom.ToString("yyyy-MM-dd"), + }, + closedPriceId = closedId + }); - tx.Complete(); + 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); diff --git a/tests/SIGCM2.Api.Tests/Products/ProductPricesControllerTests.cs b/tests/SIGCM2.Api.Tests/Products/ProductPricesControllerTests.cs new file mode 100644 index 0000000..132293f --- /dev/null +++ b/tests/SIGCM2.Api.Tests/Products/ProductPricesControllerTests.cs @@ -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; + +/// +/// 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. +/// +[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( + "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( + "SELECT TOP 1 Id FROM dbo.ProductType WHERE IsActive = 1 ORDER BY Id"); + if (ptId is null) + { + ptId = await conn.QuerySingleAsync(""" + 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 ────────────────────────────────────────────────────────── + + /// Generates a bearer token for admin (has catalogo:productos:gestionar via 'admin' role). + private string GetAdminToken() + { + var jwt = _factory.Services.GetRequiredService(); + return jwt.GenerateAccessToken(new Usuario( + id: 1, username: "admin", passwordHash: "x", + nombre: "Admin", apellido: "Sys", email: null, + rol: "admin", permisosJson: """{"grant":[],"deny":[]}""", activo: true)); + } + + /// Generates a bearer token for cajero (does NOT have catalogo:productos:gestionar). + private string GetCajeroToken() + { + var jwt = _factory.Services.GetRequiredService(); + 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 ─────────────────────────────────────────────────────── + + /// Seeds a unique product and returns its Id. + private async Task 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(""" + 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 }); + } + + /// + /// Inserts a ProductPrice row directly (bypasses SP forward-only guard) to set up test scenarios. + /// + private async Task SeedPriceDirectAsync( + int productId, decimal price, DateOnly pvf, DateOnly? pvt) + { + await using var conn = new SqlConnection(ConnectionString); + await conn.OpenAsync(); + return await conn.QuerySingleAsync(""" + 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(); + 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(); + 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(); + 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(); + 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(); + 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(); + 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(); + 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(); + 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(); + 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(""" + 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(); + 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); + } +}