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);
+ }
+}