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.Application.Common; using SIGCM2.Application.Products.Prices; 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 ───────────────────────────────────── /// §P.8 — No token → 401. [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); } /// §P.8 — Token with no 'catalogo:productos:gestionar' → 403. [Fact] 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); } /// §P.6 — Producto sin histórico → 200 con items=[], total=0, page=1, pageSize=20. [Fact] public async Task GetPrices_EmptyHistory_Returns200WithPagedResultEmpty() { 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 paged = await resp.Content.ReadFromJsonAsync>(); paged.Should().NotBeNull(); paged!.Items.Should().BeEmpty(); paged.Page.Should().Be(1); paged.PageSize.Should().Be(20); paged.Total.Should().Be(0); } /// §P.1 — 10 precios, sin query params → defaults: page=1, pageSize=20, total=10, items=10. [Fact] public async Task GetPrices_TenPrices_NoParams_ReturnsDefaultsPagedResult() { var productId = await SeedProductAsync(); // 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); } 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>(); 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)); } /// §P.2 — 30 precios, page=2, pageSize=10 → items 11-20 ordenados DESC. [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); } 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>(); 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)); } /// §P.3 — 30 precios, page=10, pageSize=10 → items=[], total=30. [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>(); paged.Should().NotBeNull(); paged!.Page.Should().Be(10); paged.PageSize.Should().Be(10); paged.Total.Should().Be(30); paged.Items.Should().BeEmpty(); } /// §P.4 — pageSize=500 → clamp to 100 en la respuesta. [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>(); paged.Should().NotBeNull(); paged!.PageSize.Should().Be(100, "pageSize must be clamped to max 100"); paged.Items.Should().HaveCount(5); } /// §P.5 — page=0 → clamp to 1. [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>(); paged.Should().NotBeNull(); paged!.Page.Should().Be(1, "page=0 must be clamped to 1"); } /// §P.4 boundary — pageSize=100 exacto → no clamping, boundary inclusivo. [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>(); paged.Should().NotBeNull(); paged!.PageSize.Should().Be(100, "pageSize=100 is the upper boundary — must NOT be clamped further"); paged.Items.Should().HaveCount(3); } /// §P.4 boundary — pageSize=101 → clamp to 100. [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>(); paged.Should().NotBeNull(); paged!.PageSize.Should().Be(100, "pageSize=101 must be clamped to 100"); } /// §P.4 boundary — pageSize=1000 → clamp to 100. [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>(); paged.Should().NotBeNull(); paged!.PageSize.Should().Be(100, "pageSize=1000 must be clamped to max 100"); } /// §P.5 boundary — page=-5 → clamp to 1. [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>(); paged.Should().NotBeNull(); paged!.Page.Should().Be(1, "page=-5 must be clamped to 1"); } /// §P.4 boundary — pageSize=0 → clamp to 1 (minimum). [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>(); paged.Should().NotBeNull(); paged!.PageSize.Should().Be(1, "pageSize=0 must be clamped to minimum 1"); } /// §P.7 — Producto inexistente → 404. [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"); } /// /// §P.1 compat — 3 prices, no params → PagedResult with items ordered DESC. /// Replaces the old GetPrices_WithHistory_Returns200OrderedDescending. /// [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>(); 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)); } // ── 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); } }