feat: PRD-002 Product CRUD #40

Merged
dmolinari merged 14 commits from feature/PRD-002 into main 2026-04-19 16:49:58 +00:00
5 changed files with 581 additions and 8 deletions
Showing only changes of commit 165abc8245 - Show all commits

View File

@@ -0,0 +1,169 @@
using FluentValidation;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using SIGCM2.Api.Authorization;
using SIGCM2.Application.Abstractions;
using SIGCM2.Application.Common;
using SIGCM2.Application.Products.Create;
using SIGCM2.Application.Products.Deactivate;
using SIGCM2.Application.Products.GetById;
using SIGCM2.Application.Products.List;
using SIGCM2.Application.Products.Update;
namespace SIGCM2.Api.Controllers;
/// <summary>
/// PRD-002: Product catalog management.
/// Read endpoints at /api/v1/products — require authentication (any role).
/// Write endpoints at /api/v1/admin/products — require 'catalogo:productos:gestionar'.
/// </summary>
[ApiController]
public sealed class ProductsController : ControllerBase
{
private readonly IDispatcher _dispatcher;
private readonly IValidator<CreateProductCommand> _createValidator;
private readonly IValidator<UpdateProductCommand> _updateValidator;
public ProductsController(
IDispatcher dispatcher,
IValidator<CreateProductCommand> createValidator,
IValidator<UpdateProductCommand> updateValidator)
{
_dispatcher = dispatcher;
_createValidator = createValidator;
_updateValidator = updateValidator;
}
// ── READ endpoints ─────────────────────────────────────────────────────────
/// <summary>Returns a paginated list of Products. Requires authentication.</summary>
[HttpGet("api/v1/products")]
[Authorize]
[ProducesResponseType(typeof(PagedResult<ProductListItemDto>), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
public async Task<IActionResult> ListProducts(
[FromQuery] int page = 1,
[FromQuery] int pageSize = 20,
[FromQuery] bool? activo = true,
[FromQuery] string? search = null,
[FromQuery] int? medioId = null,
[FromQuery] int? productTypeId = null,
[FromQuery] int? rubroId = null)
{
var query = new ListProductsQuery(page, pageSize, activo, search, medioId, productTypeId, rubroId);
var result = await _dispatcher.Send<ListProductsQuery, PagedResult<ProductListItemDto>>(query);
return Ok(result);
}
/// <summary>Returns a single Product by id. Requires authentication.</summary>
[HttpGet("api/v1/products/{id:int}")]
[Authorize]
[ProducesResponseType(typeof(ProductDetailDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> GetProductById([FromRoute] int id)
{
var query = new GetProductByIdQuery(id);
var result = await _dispatcher.Send<GetProductByIdQuery, ProductDetailDto>(query);
return Ok(result);
}
// ── WRITE endpoints ────────────────────────────────────────────────────────
/// <summary>Creates a new Product. Requires catalogo:productos:gestionar.</summary>
[HttpPost("api/v1/admin/products")]
[RequirePermission("catalogo:productos:gestionar")]
[ProducesResponseType(typeof(ProductCreatedDto), StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
[ProducesResponseType(StatusCodes.Status422UnprocessableEntity)]
public async Task<IActionResult> CreateProduct([FromBody] CreateProductRequest request)
{
var command = new CreateProductCommand(
Nombre: request.Nombre ?? string.Empty,
MedioId: request.MedioId,
ProductTypeId: request.ProductTypeId,
RubroId: request.RubroId,
BasePrice: request.BasePrice,
PriceDurationDays: request.PriceDurationDays);
var validation = await _createValidator.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<CreateProductCommand, ProductCreatedDto>(command);
return CreatedAtAction(nameof(GetProductById), new { id = result.Id }, result);
}
/// <summary>Updates a Product. Requires catalogo:productos:gestionar.</summary>
[HttpPut("api/v1/admin/products/{id:int}")]
[RequirePermission("catalogo:productos:gestionar")]
[ProducesResponseType(typeof(ProductUpdatedDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
[ProducesResponseType(StatusCodes.Status422UnprocessableEntity)]
public async Task<IActionResult> UpdateProduct([FromRoute] int id, [FromBody] UpdateProductRequest request)
{
var command = new UpdateProductCommand(
Id: id,
Nombre: request.Nombre ?? string.Empty,
RubroId: request.RubroId,
BasePrice: request.BasePrice,
PriceDurationDays: request.PriceDurationDays);
var validation = await _updateValidator.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<UpdateProductCommand, ProductUpdatedDto>(command);
return Ok(result);
}
/// <summary>Soft-deletes (deactivates) a Product. Requires catalogo:productos:gestionar.</summary>
[HttpDelete("api/v1/admin/products/{id:int}")]
[RequirePermission("catalogo:productos:gestionar")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> DeactivateProduct([FromRoute] int id)
{
var command = new DeactivateProductCommand(id);
await _dispatcher.Send<DeactivateProductCommand, ProductStatusDto>(command);
return NoContent();
}
}
// ── Request body records ──────────────────────────────────────────────────────
/// <summary>PRD-002: Create Product request body.</summary>
public sealed record CreateProductRequest(
string? Nombre,
int MedioId = 0,
int ProductTypeId = 0,
int? RubroId = null,
decimal BasePrice = 0m,
int? PriceDurationDays = null);
/// <summary>PRD-002: Update Product request body.</summary>
public sealed record UpdateProductRequest(
string? Nombre,
int? RubroId = null,
decimal BasePrice = 0m,
int? PriceDurationDays = null);

View File

@@ -463,6 +463,68 @@ public sealed class ExceptionFilter : IExceptionFilter
context.ExceptionHandled = true;
break;
// PRD-002: Product exceptions
case ProductNotFoundException productNotFoundEx:
context.Result = new ObjectResult(new
{
error = "product_not_found",
message = productNotFoundEx.Message
})
{
StatusCode = StatusCodes.Status404NotFound
};
context.ExceptionHandled = true;
break;
case ProductNombreDuplicadoEnMedioTipoException productDupEx:
context.Result = new ObjectResult(new
{
error = "product_nombre_duplicado",
message = productDupEx.Message
})
{
StatusCode = StatusCodes.Status409Conflict
};
context.ExceptionHandled = true;
break;
case ProductTipoFlagsIncoherentesException productFlagsEx:
context.Result = new ObjectResult(new
{
error = "product_flags_incoherentes",
field = productFlagsEx.Field,
message = productFlagsEx.Message
})
{
StatusCode = StatusCodes.Status422UnprocessableEntity
};
context.ExceptionHandled = true;
break;
case ProductTypeInactivoException productTypeInactivoEx:
context.Result = new ObjectResult(new
{
error = "product_type_inactivo",
message = productTypeInactivoEx.Message
})
{
StatusCode = StatusCodes.Status422UnprocessableEntity
};
context.ExceptionHandled = true;
break;
case RubroInactivoException rubroInactivoEx:
context.Result = new ObjectResult(new
{
error = "rubro_inactivo",
message = rubroInactivoEx.Message
})
{
StatusCode = StatusCodes.Status422UnprocessableEntity
};
context.ExceptionHandled = true;
break;
// ADM-008: PuntoDeVenta exceptions
case PuntoDeVentaNotFoundException puntoDeVentaNotFoundEx:
context.Result = new ObjectResult(new

View File

@@ -51,8 +51,9 @@ public class AuthControllerTests
// V013 (ADM-008) adds 'administracion:puntos_de_venta:gestionar' → 23
// V014 (ADM-009) adds 'administracion:fiscal:gestionar' → 24
// V016 (CAT-001) adds 'catalogo:rubros:gestionar' → 25
// V017 (PRD-001) adds 'catalogo:tipos:gestionar' → 26 total
Assert.Equal(26, permisos.GetArrayLength());
// V017 (PRD-001) adds 'catalogo:tipos:gestionar' → 26
// V018 (PRD-002) adds 'catalogo:productos:gestionar' → 27 total
Assert.Equal(27, permisos.GetArrayLength());
}
// Scenario: invalid credentials return 401 with opaque error

View File

@@ -129,7 +129,7 @@ public sealed class PermisosEndpointTests : IAsyncLifetime
// ── GET /api/v1/permisos — catalog ───────────────────────────────────────
[Fact]
public async Task GetPermisos_WithAdmin_Returns200With26Items()
public async Task GetPermisos_WithAdmin_Returns200With27Items()
{
var token = await GetBearerTokenAsync(AdminUsername, AdminPassword);
using var req = BuildRequest(HttpMethod.Get, "/api/v1/permisos", bearerToken: token);
@@ -141,8 +141,9 @@ public sealed class PermisosEndpointTests : IAsyncLifetime
// V013 (ADM-008) adds 'administracion:puntos_de_venta:gestionar' → 23
// V014 (ADM-009) adds 'administracion:fiscal:gestionar' → 24
// V016 (CAT-001) adds 'catalogo:rubros:gestionar' → 25
// V017 (PRD-001) adds 'catalogo:tipos:gestionar' → 26 total
Assert.Equal(26, list.GetArrayLength());
// V017 (PRD-001) adds 'catalogo:tipos:gestionar' → 26
// V018 (PRD-002) adds 'catalogo:productos:gestionar' → 27 total
Assert.Equal(27, list.GetArrayLength());
}
[Fact]
@@ -185,7 +186,7 @@ public sealed class PermisosEndpointTests : IAsyncLifetime
// ── GET /api/v1/roles/{codigo}/permisos ──────────────────────────────────
[Fact]
public async Task GetRolPermisos_AdminRol_Returns200With26Items()
public async Task GetRolPermisos_AdminRol_Returns200With27Items()
{
var token = await GetBearerTokenAsync(AdminUsername, AdminPassword);
using var req = BuildRequest(HttpMethod.Get, "/api/v1/roles/admin/permisos", bearerToken: token);
@@ -197,8 +198,9 @@ public sealed class PermisosEndpointTests : IAsyncLifetime
// V013 (ADM-008) adds 'administracion:puntos_de_venta:gestionar' → 23
// V014 (ADM-009) adds 'administracion:fiscal:gestionar' → 24
// V016 (CAT-001) adds 'catalogo:rubros:gestionar' → 25
// V017 (PRD-001) adds 'catalogo:tipos:gestionar' → 26 total
Assert.Equal(26, list.GetArrayLength());
// V017 (PRD-001) adds 'catalogo:tipos:gestionar' → 26
// V018 (PRD-002) adds 'catalogo:productos:gestionar' → 27 total
Assert.Equal(27, list.GetArrayLength());
}
[Fact]

View File

@@ -0,0 +1,339 @@
using System.Net;
using System.Net.Http.Headers;
using System.Net.Http.Json;
using System.Text.Json;
using SIGCM2.TestSupport;
namespace SIGCM2.Api.Tests.Products;
/// <summary>
/// PRD-002 — Integration tests for /api/v1/products and /api/v1/admin/products.
/// Read endpoints require authentication (any role).
/// Write endpoints require permission 'catalogo:productos:gestionar'.
/// Verifies HTTP status codes, response shapes, and ExceptionFilter mappings.
/// </summary>
[Collection("ApiIntegration")]
public sealed class ProductsControllerTests : IAsyncLifetime
{
private const string ReadEndpoint = "/api/v1/products";
private const string AdminEndpoint = "/api/v1/admin/products";
private const string AdminUsername = "admin";
private const string AdminPassword = "@Diego550@";
private readonly HttpClient _client;
public ProductsControllerTests(TestWebAppFactory factory)
{
_client = factory.CreateClient();
}
public Task InitializeAsync() => Task.CompletedTask;
public Task DisposeAsync() => Task.CompletedTask;
// ── Helpers ───────────────────────────────────────────────────────────────
private async Task<string> GetAdminTokenAsync()
{
var response = await _client.PostAsJsonAsync("/api/v1/auth/login", new
{
username = AdminUsername,
password = AdminPassword
});
response.EnsureSuccessStatusCode();
var json = await response.Content.ReadFromJsonAsync<JsonElement>();
return json.GetProperty("accessToken").GetString()!;
}
private HttpRequestMessage BuildRequest(HttpMethod method, string url, object? body = null, string? bearerToken = null)
{
var request = new HttpRequestMessage(method, url);
if (bearerToken is not null)
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", bearerToken);
if (body is not null)
request.Content = JsonContent.Create(body);
return request;
}
private async Task<(int MedioId, int ProductTypeId)> EnsureMedioAndProductTypeAsync(string token)
{
// Create a Medio via SQL (we don't have a Medio controller endpoint available here)
// Use product-types endpoint to create a ProductType and insert Medio directly
var medioResp = await _client.SendAsync(BuildRequest(HttpMethod.Post, "/api/v1/admin/medios", new
{
codigo = $"PM{Guid.NewGuid():N}"[..6],
nombre = $"Medio Test {Guid.NewGuid():N}"[..30],
tipo = 1
}, token));
medioResp.EnsureSuccessStatusCode();
var medioJson = await medioResp.Content.ReadFromJsonAsync<JsonElement>();
var medioId = medioJson.GetProperty("id").GetInt32();
var ptResp = await _client.SendAsync(BuildRequest(HttpMethod.Post, "/api/v1/admin/product-types", new
{
nombre = $"PT_{Guid.NewGuid():N}"[..30],
hasDuration = false, requiresText = false, requiresCategory = false, isBundle = false,
allowImages = false
}, token));
ptResp.EnsureSuccessStatusCode();
var ptJson = await ptResp.Content.ReadFromJsonAsync<JsonElement>();
var productTypeId = ptJson.GetProperty("id").GetInt32();
return (medioId, productTypeId);
}
// ── 401 guards on READ endpoints ───────────────────────────────────────────
[Fact]
public async Task List_WithoutAuth_Returns401()
{
using var req = BuildRequest(HttpMethod.Get, ReadEndpoint);
var resp = await _client.SendAsync(req);
Assert.Equal(HttpStatusCode.Unauthorized, resp.StatusCode);
}
[Fact]
public async Task GetById_WithoutAuth_Returns401()
{
using var req = BuildRequest(HttpMethod.Get, $"{ReadEndpoint}/999999");
var resp = await _client.SendAsync(req);
Assert.Equal(HttpStatusCode.Unauthorized, resp.StatusCode);
}
// ── 401 guards on WRITE endpoints ──────────────────────────────────────────
[Fact]
public async Task Create_WithoutAuth_Returns401()
{
using var req = BuildRequest(HttpMethod.Post, AdminEndpoint, new { nombre = "Test" });
var resp = await _client.SendAsync(req);
Assert.Equal(HttpStatusCode.Unauthorized, resp.StatusCode);
}
[Fact]
public async Task Update_WithoutAuth_Returns401()
{
using var req = BuildRequest(HttpMethod.Put, $"{AdminEndpoint}/1", new { nombre = "Test", basePrice = 10m });
var resp = await _client.SendAsync(req);
Assert.Equal(HttpStatusCode.Unauthorized, resp.StatusCode);
}
[Fact]
public async Task Deactivate_WithoutAuth_Returns401()
{
using var req = BuildRequest(HttpMethod.Delete, $"{AdminEndpoint}/1");
var resp = await _client.SendAsync(req);
Assert.Equal(HttpStatusCode.Unauthorized, resp.StatusCode);
}
// ── POST /api/v1/admin/products ───────────────────────────────────────────
[Fact]
public async Task Create_WithAdmin_Returns201WithId()
{
var token = await GetAdminTokenAsync();
var (medioId, productTypeId) = await EnsureMedioAndProductTypeAsync(token);
var uniqueName = $"Prod_{Guid.NewGuid():N}"[..30];
using var req = BuildRequest(HttpMethod.Post, AdminEndpoint, new
{
nombre = uniqueName,
medioId,
productTypeId,
basePrice = 100.50m
}, token);
var resp = await _client.SendAsync(req);
Assert.Equal(HttpStatusCode.Created, resp.StatusCode);
var json = await resp.Content.ReadFromJsonAsync<JsonElement>();
Assert.True(json.GetProperty("id").GetInt32() > 0);
Assert.Equal(uniqueName, json.GetProperty("nombre").GetString());
Assert.True(json.GetProperty("isActive").GetBoolean());
}
[Fact]
public async Task Create_InvalidBody_Returns400()
{
var token = await GetAdminTokenAsync();
using var req = BuildRequest(HttpMethod.Post, AdminEndpoint, new
{
nombre = string.Empty, // invalid
medioId = 1,
productTypeId = 1,
basePrice = 10m
}, token);
var resp = await _client.SendAsync(req);
Assert.Equal(HttpStatusCode.BadRequest, resp.StatusCode);
}
[Fact]
public async Task Create_DuplicateNombre_Returns409()
{
var token = await GetAdminTokenAsync();
var (medioId, productTypeId) = await EnsureMedioAndProductTypeAsync(token);
var uniqueName = $"Dup_{Guid.NewGuid():N}"[..30];
var body = new { nombre = uniqueName, medioId, productTypeId, basePrice = 50m };
using var req1 = BuildRequest(HttpMethod.Post, AdminEndpoint, body, token);
var resp1 = await _client.SendAsync(req1);
Assert.Equal(HttpStatusCode.Created, resp1.StatusCode);
using var req2 = BuildRequest(HttpMethod.Post, AdminEndpoint, body, token);
var resp2 = await _client.SendAsync(req2);
Assert.Equal(HttpStatusCode.Conflict, resp2.StatusCode);
}
[Fact]
public async Task Create_MedioNotFound_Returns404()
{
var token = await GetAdminTokenAsync();
using var req = BuildRequest(HttpMethod.Post, AdminEndpoint, new
{
nombre = $"Prod_{Guid.NewGuid():N}"[..30],
medioId = 999999,
productTypeId = 1,
basePrice = 50m
}, token);
var resp = await _client.SendAsync(req);
Assert.Equal(HttpStatusCode.NotFound, resp.StatusCode);
}
// ── GET /api/v1/products ──────────────────────────────────────────────────
[Fact]
public async Task List_WithAuth_Returns200WithPaginatedResult()
{
var token = await GetAdminTokenAsync();
using var req = BuildRequest(HttpMethod.Get, $"{ReadEndpoint}?page=1&pageSize=10", bearerToken: token);
var resp = await _client.SendAsync(req);
Assert.Equal(HttpStatusCode.OK, resp.StatusCode);
var json = await resp.Content.ReadFromJsonAsync<JsonElement>();
Assert.True(json.TryGetProperty("items", out _));
Assert.True(json.TryGetProperty("total", out _));
}
// ── GET /api/v1/products/{id} ─────────────────────────────────────────────
[Fact]
public async Task GetById_NotFound_Returns404()
{
var token = await GetAdminTokenAsync();
using var req = BuildRequest(HttpMethod.Get, $"{ReadEndpoint}/999999999", bearerToken: token);
var resp = await _client.SendAsync(req);
Assert.Equal(HttpStatusCode.NotFound, resp.StatusCode);
}
[Fact]
public async Task GetById_ExistingId_Returns200()
{
var token = await GetAdminTokenAsync();
var (medioId, productTypeId) = await EnsureMedioAndProductTypeAsync(token);
var uniqueName = $"GetById_{Guid.NewGuid():N}"[..28];
using var createReq = BuildRequest(HttpMethod.Post, AdminEndpoint, new
{
nombre = uniqueName, medioId, productTypeId, basePrice = 75m
}, token);
var createResp = await _client.SendAsync(createReq);
Assert.Equal(HttpStatusCode.Created, createResp.StatusCode);
var createJson = await createResp.Content.ReadFromJsonAsync<JsonElement>();
var productId = createJson.GetProperty("id").GetInt32();
using var getReq = BuildRequest(HttpMethod.Get, $"{ReadEndpoint}/{productId}", bearerToken: token);
var getResp = await _client.SendAsync(getReq);
Assert.Equal(HttpStatusCode.OK, getResp.StatusCode);
var getJson = await getResp.Content.ReadFromJsonAsync<JsonElement>();
Assert.Equal(productId, getJson.GetProperty("id").GetInt32());
Assert.Equal(uniqueName, getJson.GetProperty("nombre").GetString());
}
// ── DELETE /api/v1/admin/products/{id} ────────────────────────────────────
[Fact]
public async Task Deactivate_ExistingId_Returns204()
{
var token = await GetAdminTokenAsync();
var (medioId, productTypeId) = await EnsureMedioAndProductTypeAsync(token);
var uniqueName = $"Del_{Guid.NewGuid():N}"[..28];
using var createReq = BuildRequest(HttpMethod.Post, AdminEndpoint, new
{
nombre = uniqueName, medioId, productTypeId, basePrice = 50m
}, token);
var createResp = await _client.SendAsync(createReq);
Assert.Equal(HttpStatusCode.Created, createResp.StatusCode);
var createJson = await createResp.Content.ReadFromJsonAsync<JsonElement>();
var productId = createJson.GetProperty("id").GetInt32();
using var delReq = BuildRequest(HttpMethod.Delete, $"{AdminEndpoint}/{productId}", bearerToken: token);
var delResp = await _client.SendAsync(delReq);
Assert.Equal(HttpStatusCode.NoContent, delResp.StatusCode);
}
[Fact]
public async Task Deactivate_NotFound_Returns404()
{
var token = await GetAdminTokenAsync();
using var req = BuildRequest(HttpMethod.Delete, $"{AdminEndpoint}/999999999", bearerToken: token);
var resp = await _client.SendAsync(req);
Assert.Equal(HttpStatusCode.NotFound, resp.StatusCode);
}
// ── PUT /api/v1/admin/products/{id} ───────────────────────────────────────
[Fact]
public async Task Update_ExistingProduct_Returns200()
{
var token = await GetAdminTokenAsync();
var (medioId, productTypeId) = await EnsureMedioAndProductTypeAsync(token);
var uniqueName = $"Upd_{Guid.NewGuid():N}"[..28];
using var createReq = BuildRequest(HttpMethod.Post, AdminEndpoint, new
{
nombre = uniqueName, medioId, productTypeId, basePrice = 50m
}, token);
var createResp = await _client.SendAsync(createReq);
Assert.Equal(HttpStatusCode.Created, createResp.StatusCode);
var createJson = await createResp.Content.ReadFromJsonAsync<JsonElement>();
var productId = createJson.GetProperty("id").GetInt32();
var newName = $"Upd2_{Guid.NewGuid():N}"[..28];
using var updateReq = BuildRequest(HttpMethod.Put, $"{AdminEndpoint}/{productId}", new
{
nombre = newName,
basePrice = 200m
}, token);
var updateResp = await _client.SendAsync(updateReq);
Assert.Equal(HttpStatusCode.OK, updateResp.StatusCode);
var updateJson = await updateResp.Content.ReadFromJsonAsync<JsonElement>();
Assert.Equal(newName, updateJson.GetProperty("nombre").GetString());
}
[Fact]
public async Task Update_NotFound_Returns404()
{
var token = await GetAdminTokenAsync();
using var req = BuildRequest(HttpMethod.Put, $"{AdminEndpoint}/999999999", new
{
nombre = "Test", basePrice = 10m
}, token);
var resp = await _client.SendAsync(req);
Assert.Equal(HttpStatusCode.NotFound, resp.StatusCode);
}
}