API REST para gestión de almacén construida con ASP.NET Core 8, EF Core y PostgreSQL.
Módulos disponibles:
| Módulo | Rutas | Descripción |
|---|---|---|
| Auth | /api/Auth |
Registro, login JWT, renovación y revocación de tokens |
| Users | /api/User |
CRUD con paginación, filtros dinámicos, borrado lógico y contraseñas con BCrypt |
| Products | /api/Product |
CRUD con SKU único, precio decimal y los mismos patrones que Users |
| Warehouses | /api/Warehouse |
CRUD de almacenes (nombre, ubicación, estado) |
| Inventory | /api/InventoryItem |
Existencias por par almacén–producto y movimientos restock/consume |
Todos los endpoints salvo /api/Auth/register, /login, /refresh y /revoke requieren un access token Bearer (ver Autenticación).
- .NET SDK 8.0.424 (fijado en
global.json) - PostgreSQL 14 o superior
- Opcional: Docker y Docker Compose (para levantar todo sin instalar PostgreSQL local)
Levanta la API y una PostgreSQL 18 aislada, sin tocar tu base local:
cp .env.example .env # ajusta POSTGRES_PASSWORD y AUTH_SECRET
docker compose up --build- API y Swagger:
http://localhost:5259/swagger - PostgreSQL (para psql/otras herramientas):
localhost:5433, credenciales del.env - Las migraciones se aplican solas al arrancar (
Database:ApplyMigrationsOnStartup). - El volumen
pgdatapersiste los datos;docker compose down -vlo elimina.
.envestá en.gitignore: nunca se versiona. La cadena de conexión y el secreto JWT se pasan al contenedor desde ese archivo (ConnectionStrings__DefaultConnection,Auth__Secret). La API no arranca si faltaAUTH_SECRET: el compose lo exige con${AUTH_SECRET:?...}.
dotnet restore
dotnet buildLa cadena de conexión y el secreto JWT no están en el repositorio. En desarrollo se guardan con User Secrets:
dotnet user-secrets set "ConnectionStrings:DefaultConnection" \
"Host=localhost;Port=5432;Database=warehouse_db;Username=<usuario>;Password=<password>" \
--project src/WMS.Api
# Secreto de firma JWT (mínimo 32 caracteres). Genera uno con: openssl rand -hex 32
dotnet user-secrets set "Auth:Secret" "<valor de 32+ caracteres>" --project src/WMS.ApiEn producción usa variables de entorno en lugar de user secrets:
export ConnectionStrings__DefaultConnection="Host=...;Database=...;Username=...;Password=..."
export Auth__Secret="<valor de 32+ caracteres>"Nunca subas credenciales a Git. Verifica con
git diffqueappsettings.jsonno contenga ninguno de los dos valores antes de commitear.
Si falta Auth:Secret, la API no arranca: lanza un error claro indicando cómo configurarlo.
dotnet tool install --global dotnet-ef
dotnet ef database update --project src/Apidotnet run --project src/WMS.Api- Swagger UI:
http://localhost:5259/swagger - Health/info:
GET /→ versión y entorno
Flujo de sesión (Sprint 11):
POST /api/Auth/register ──► usuario creado (201, sin tokens)
POST /api/Auth/login ──► accessToken (JWT, 15 min) + refreshToken (7 días)
│
├── Authorization: Bearer <accessToken> ──► endpoints protegidos
│
POST /api/Auth/refresh ──► rota el refresh token y devuelve uno nuevo + accessToken
POST /api/Auth/revoke ──► logout (204); el refresh token queda invalidado
| Método | Ruta | Auth | Éxito | Descripción |
|---|---|---|---|---|
POST |
/api/Auth/register |
pública | 201 UserViewModel |
Crea un usuario (no emite tokens) |
POST |
/api/Auth/login |
pública | 200 AuthViewModel |
Devuelve accessToken, refreshToken, expiresIn, tokenType, userId, email |
POST |
/api/Auth/refresh |
pública | 200 AuthViewModel |
Canjea el refresh token por uno nuevo (rotación) |
POST |
/api/Auth/revoke |
pública | 204 |
Revoca el refresh token (logout) |
GET |
/api/Auth/me |
Bearer | 200 UserViewModel |
Perfil del usuario del token |
Decisiones tomadas:
- Access token: JWT HMAC-SHA256 con
Auth:Secret, vida corta (Auth:AccessTokenMinutes, 15 por defecto). No guarda estado: no consulta la BD al validar. - Refresh token: 64 bytes aleatorios en base64url. En BD solo se guarda su hash SHA-256, por lo que una filtración de la tabla no permite usarlos.
- Rotación y detección de robo: cada
refreshrevoca el token usado y emite otro. Reutilizar un token ya rotado devuelve401(señal de que alguien más lo tiene). - Revocación adicional: cambiar la contraseña (
PUT /api/User/{id}) y darse de baja revocan todos los refresh tokens del usuario. - Registro abierto: se puede desactivar con
Auth:RegistrationEnabled=false→403registration_disabled. - Sin roles ni políticas: hoy solo se distingue autenticado/no autenticado (
[Authorize]plano). Los roles se añadirán cuando exista un requisito real. - Retos y prohibiciones responden ProblemDetails (
401unauthorized,403forbidden) desdeAuthenticationConfiguration, en vez del401vacío por defecto de JWT.
Auth:Secretes obligatorio (mínimo 32 caracteres) y nunca se versiona: user-secrets en desarrollo,Auth__Secreten despliegue. Ver Puesta en marcha.
Base paths: /api/User, /api/Product, /api/Warehouse, /api/InventoryItem.
| Método | Ruta | Descripción | Éxito |
|---|---|---|---|
GET |
/api/User |
Listado paginado con filtros | 200 PagedResult<UserListViewModel> |
GET |
/api/User?id={id} |
Detalle de un usuario | 200 UserViewModel |
POST |
/api/User |
Crea un usuario | 201 UserViewModel |
PUT |
/api/User/{id} |
Actualiza un usuario | 200 UserViewModel |
PATCH |
/api/User/{id} |
Actualización parcial (mismo modelo que PUT) | 200 UserViewModel |
DELETE |
/api/User/{id} |
Borrado lógico (SoftDelete) |
204 |
| Parámetro | Tipo | Default | Descripción |
|---|---|---|---|
id |
int? |
null |
Si viene con valor, devuelve el detalle en vez del listado |
search |
string? |
null |
Búsqueda case-insensitive por nombre o correo |
isActive |
bool? |
null |
Filtra por estado solo si se envía |
page |
int |
1 |
Página (mínimo 1) |
pageSize |
int |
10 |
Tamaño de página (1–100) |
orderDesc |
bool |
true |
Orden por Id descendente/ascendente |
Los filtros se combinan entre sí. El conteo (TotalItems) se calcula después de filtrar y antes de paginar.
# Listado con filtros combinados
GET /api/User?search=ana&isActive=true&page=1&pageSize=20
# Detalle por id
GET /api/User?id=5
# Crear
POST /api/User
{ "name": "Ana Garcia", "email": "ana@test.com", "password": "Secret123!" }
# Actualizar (la contraseña es opcional: hay que OMITIR el campo para
# conservar la actual; enviar "" produce un 400 por MinLength(8))
PUT /api/User/5
{ "name": "Ana G.", "email": "ana@test.com" }
# Cambiar la contraseña (mínimo 8 caracteres; revoca los refresh tokens del usuario)
PUT /api/User/5
{ "name": "Ana G.", "email": "ana@test.com", "password": "NuevaClave1!" }
# Borrado lógico
DELETE /api/User/5Mismo patrón que Users: GET, GET?id=, POST, PUT/{id}, PATCH/{id}, DELETE/{id}.
| Parámetro | Tipo | Default | Descripción |
|---|---|---|---|
id |
int? |
null |
Detalle en vez de listado |
search |
string? |
null |
Búsqueda case-insensitive por nombre o SKU |
isActive |
bool? |
null |
Filtra por estado solo si se envía |
page / pageSize / orderDesc |
— | — | Igual que en Users (PagedQuery) |
| Campo | Tipo | Notas |
|---|---|---|
SKU |
string |
Obligatorio, 2–50; único (índice filtrado por SoftDelete, igual que el correo) |
Name |
string |
Obligatorio, 2–100 |
Description |
string? |
Opcional, máx. 500 |
Price |
decimal |
Obligatorio, 0–999999999; se almacena como decimal(18,2) (nunca double) |
# Crear
POST /api/Product
{ "sku": "SKU-0001", "name": "Teclado mecánico", "description": "TKL", "price": 49.90 }
# SKU duplicado → 400 ProblemDetails (mismo tratamiento que el correo duplicado)
GET /api/Product?search=tecl&isActive=true&orderDesc=falseMismo patrón que Users.
| Parámetro | Tipo | Descripción |
|---|---|---|
id |
int? |
Detalle en vez de listado |
search |
string? |
Búsqueda case-insensitive por nombre o ubicación |
isActive |
bool? |
Filtra por estado solo si se envía |
| Campo | Tipo | Notas |
|---|---|---|
Name |
string |
Obligatorio, 2–100; único filtrado por SoftDelete |
Location |
string? |
Opcional, máx. 200 |
POST /api/Warehouse
{ "name": "Almacén Norte", "location": "Nave 2, bahía B" }| Método | Ruta | Éxito | Descripción |
|---|---|---|---|
GET |
/api/InventoryItem |
200 PagedResult<InventoryItemViewModel> |
Existencias; filtros warehouseId, productId, page, pageSize, orderDesc |
POST |
/api/InventoryItem/restock |
200 InventoryItemViewModel |
Entrada: suma quantity; crea la ficha si no existe |
POST |
/api/InventoryItem/consume |
200 InventoryItemViewModel |
Salida: resta quantity |
POST /api/InventoryItem/restock
{ "warehouseId": 1, "productId": 1, "quantity": 25 }
POST /api/InventoryItem/consume
{ "warehouseId": 1, "productId": 1, "quantity": 5 }Los movimientos nunca se hacen editando Quantity desde un PUT genérico: hay dos endpoints dedicados para que la entrada y la salida sean explícitas y auditables.
Concurrencia y reglas de negocio:
- Saldo nunca negativo: el consumo se ejecuta como
UPDATE ... WHERE Quantity >= @qty; si no alcanza, no se modifica nada y responde409insufficient_stock. - Upsert con reintento: si dos peticiones crean la misma ficha a la vez, violan el índice único
(WarehouseId, ProductId)y el repositorio reintenta la actualización (409conflictsi el reintento también pierde). - Cantidades:
quantity ≥ 1en los movimientos (validación de DTO) yCHECK (Quantity >= 0)en BD. - Almacén/producto inactivos o dados de baja →
400; inexistentes →404. - FKs con
DeleteBehavior.Restrict: un producto o almacén con existencias no se puede borrar en cascada.
Todas las respuestas de error usan ProblemDetails (RFC 7807) con Content-Type: application/problem+json y el campo adicional code.
| Código HTTP | Origen | code |
|---|---|---|
400 |
Validación de DTO / parámetros de paginación / cantidad inválida | validation_error |
401 |
Sin token, token inválido o caducado, credenciales incorrectas, refresh token usado o revocado | unauthorized, invalid_credentials |
403 |
UnauthorizedAccessException o registro abierto desactivado |
forbidden, registration_disabled |
404 |
Usuario, producto, almacén o ficha de existencias inexistente o dado de baja | not_found |
409 |
Conflicto de negocio: SKU/nombre duplicado, saldo insuficiente, carrera de concurrencia | conflict, insufficient_stock |
429 |
Límite de peticiones por IP superado (ver Rate limiting) | rate_limit_exceeded |
500 |
Excepción no controlada | internal_error |
Ejemplo de respuesta 404:
{
"type": "https://httpstatuses.com/404",
"title": "Not Found",
"status": 404,
"detail": "User with ID 42 not found.",
"instance": "/api/User",
"code": "not_found",
"traceId": "0HN..."
}Los detalles internos de las excepciones nunca se devuelven al cliente: quedan únicamente en los logs. La validación automática de [ApiController] está configurada en ValidationConfiguration para que también responda application/problem+json.
El manejo está centralizado en src/WMS.Api/middleware/ErrorHandlingMiddleware.cs; los controladores no usan try/catch.
- Contraseñas: se almacenan con BCrypt (salt integrado, work factor 11), nunca en texto plano.
BCryptPasswordHasherimplementa la abstracciónIPasswordHasher<User>de ASP.NET Core Identity. - Nunca se expone
Password: ni enUserViewModelni enUserListViewModel(verificado por pruebas). - Correos únicos: índice único en
Emailfiltrado porSoftDelete IS NULL, de modo que un correo dado de baja puede reutilizarse pero no duplicarse mientras esté activo. - Autenticación/Autorización: login y renovación con JWT (módulo
auth/), refresh tokens rotatorios guardados solo como hash y[Authorize]en todos los endpoints salvo los públicos de/api/Auth. Swagger trae el esquemaBearerlisto para probarlos desde la UI.
Configurado en CorsConfiguration según el entorno:
- Development: cualquier origen (
AllowAnyOrigin), métodos y cabeceras. - Resto de entornos: solo los orígenes de
Cors:AllowedOrigins. Si la lista está vacía, no se admite ningún origen externo (más seguro queAllowAll) y el arranque escribe un aviso en el log.
export Cors__AllowedOrigins="https://app.example.com,https://admin.example.com"Middleware nativo de .NET 8 (RateLimitConfiguration), ventana fija por dirección IP, con contadores separados para /api/Auth y para el resto (los intentos de login no consumen la cuota general):
| Clave | Default | Uso |
|---|---|---|
RateLimit:PermitLimit |
100 |
Peticiones por ventana en el resto de rutas |
RateLimit:AuthPermitLimit |
10 |
Peticiones por ventana en /api/Auth |
RateLimit:WindowSeconds |
60 |
Duración de la ventana |
Al superarlo responde 429 con ProblemDetails (code = rate_limit_exceeded) y cabecera Retry-After.
DTOs validados con Data Annotations y validados automáticamente por [ApiController]:
Name: obligatorio, 2–100 caracteresEmail: obligatorio, formato de correoPassword: obligatorio en creación (mín. 8); opcional en actualización (mín. 8 si se envía)page/pageSize:Rangesobre los límites permitidos
Warehouse-API/
├── WarehouseApi.sln
├── src/
│ ├── Api/
│ │ ├── Program.cs # Pipeline y composición
│ │ ├── Api.http # Ejemplos de peticiones para REST Client
│ │ ├── config/ # BD, Auth, CORS, RateLimit, Swagger, Identity, validación…
│ │ ├── Common/ # PagedQuery, PagedResult, DomainException
│ │ ├── middleware/
│ │ │ └── ErrorHandlingMiddleware.cs # ProblemDetails global
│ │ ├── security/
│ │ │ └── BCryptPasswordHasher.cs # IPasswordHasher<User> con BCrypt
│ │ ├── auth/ # Sprint 11: login/refresh JWT
│ │ │ ├── AuthController.cs # register, login, refresh, revoke, me
│ │ │ ├── AuthService.cs # Reglas de sesión y rotación de tokens
│ │ │ ├── JwtTokenService.cs # Firma de access tokens + refresh tokens
│ │ │ ├── RefreshTokenRepository.cs # Persistencia y revocación
│ │ │ ├── AuthOptions.cs # Sección "Auth" de la configuración
│ │ │ ├── interfaces/
│ │ │ └── models/ # LoginDTO, RefreshTokenDTO, AuthViewModel, RefreshToken
│ │ ├── users/ # Módulo Users (el de aprendizaje)
│ │ ├── products/ # Sprint 13
│ │ ├── warehouses/ # Sprint 14
│ │ ├── inventory/ # Sprint 14: existencias y movimientos
│ │ └── data/
│ │ ├── AppDbContext.cs
│ │ ├── AppDbContextDesignTimeFactory.cs
│ │ └── migrations/
│ └── Test/
│ ├── *Tests.cs # Unitarios: services, repos (SQLite), middleware, auth
│ └── Integration/ # WebApplicationFactory + SQLite
│ ├── IntegrationTestBase.cs # Registro/login/Bearer y limpieza de BD
│ ├── WarehouseApiFactory.cs # Host en memoria con Auth:Secret de prueba
│ └── *ApiTests.cs # Auth, User, Product, Warehouse, InventoryItem
Controller → Service → Repository → EF Core → PostgreSQL
HTTP reglas de IQueryable SQL datos
negocio + mapeo
- Controller: solo traduce HTTP. Lanza/mapea estados, delega al servicio.
- Service: reglas de negocio (existencia de correo, borrado lógico, hash), sanitiza la paginación y devuelve ViewModels.
- Repository: construye la consulta de forma progresiva con
IQueryabley la ejecuta contra la BD.
AsNoTracking()en todas las lecturas (GetAllUsersAsync,GetUserByIdAsync,GetUserByEmailAsync).CancellationTokenpropagado desde el controlador hastaSaveChangesAsync.CountAsync()se ejecuta con los filtros aplicados y antes delSkip/Take.- Un único
SaveChangesAsyncya es atómico (transacción implícita de EF Core).BeginTransactionAsyncsolo haría falta si participaran variosDbContexto operaciones ajenas a EF Core. - Diseño de migraciones resuelto sin arrancar la API vía
AppDbContextDesignTimeFactory.
dotnet test188 pruebas con NUnit, sin advertencias de compilación. Cubren:
- Unitarias — servicios:
UserService,ProductService,WarehouseService,InventoryService,AuthService(login, registro, rotación y revocación de refresh tokens) yJwtTokenService(firma, vida del token, hash de refresh tokens). - Unitarias — repositorios contra SQLite en memoria: paginación y filtros de Users/Products, índice único filtrado por
SoftDelete, y losUPDATEcondicionales de inventario (WHERE Quantity >= @qty) junto con el reintento en carrera por el índice(WarehouseId, ProductId). - Unitarias — infraestructura:
ErrorHandlingMiddleware(400/403/404/409/500 y ausencia de detalles internos), validaciones de Data Annotations yBCryptPasswordHasher. - Integración HTTP con
WebApplicationFactory+ SQLite en memoria:AuthApiTests,UserApiTests,ProductApiTests,WarehouseApiTestseInventoryApiTests. Cubren el flujo completo de sesión (register → login → llamada autenticada → refresh con rotación → revoke),401sin token y con token manipulado,400/404/409de cada módulo y los movimientos de inventario.
La base común (IntegrationTestBase) arranca la API, limpia la BD entre pruebas y monta el Bearer; WarehouseApiFactory inyecta un Auth:Secret en memoria y sube los límites de rate limiting para que la suite no se corte sola.
Documentado en SwaggerConfiguration y con comentarios XML generados desde el código:
- Parámetros de consulta de cada módulo (
UserQuery,ProductQuery,WarehouseQuery,InventoryQuery) - Códigos de estado por endpoint (
200,201,204,400,401,403,404,409,429,500) - Esquemas
ProblemDetails/ValidationProblemDetails - Esquema de seguridad
Bearer(JWT) activo: desde Swagger UI se puede pulsar Authorize, pegar el access token y probar cualquier endpoint protegido
Cómo leerlo:
[x]= hecho y verificado,[ ]= pendiente. Los comentarios explican el estado real y las decisiones tomadas, para que este documento sea útil al retomar el proyecto dentro de un mes.Estado global hoy: los cinco módulos están implementados y documentados — Auth (JWT con refresh token rotatorio), Users, Products, Warehouses e Inventory (restock/consume con concurrencia) — más CORS por entorno, rate limiting y 188 pruebas en verde con compilación sin advertencias. Siguen abiertos solo los ítems que dependen de métricas o requisitos reales (Sprint 15 y el
SortBy/fieldsdel Sprint 8).
Objetivo: una API que arranque y un entorno reproducible.
- Crear proyecto ASP.NET Core Web API — estructura en
src/Apiysrc/Test. - Fijar y mantener la versión de .NET —
global.jsoncon SDK8.0.424; el Dockerfile usa el mismo tag para no desincronizar. - Configurar PostgreSQL — cadena de conexión en User Secrets (dev) y variables de entorno (Docker/prod).
- Instalar paquetes EF Core para PostgreSQL —
Npgsql.EntityFrameworkCore.PostgreSQL8.0.0. - Organizar configuraciones de desarrollo y producción —
appsettings.json+appsettings.Development.json, sin secretos dentro. - Configurar Git y
.gitignore— ignorabin/,obj/,.vs/y.env. - Verificar que la API arranque y responda vía Swagger —
GET /y/swaggerresponden en local y en contenedor. - Evitar subir secretos al repositorio — User Secrets para dev;
.env(gitignored) +.env.examplepara Docker; verificado congit check-ignore.
Objetivo: que cada capa tenga un trabajo claro.
- Estructura de carpetas por funcionalidad —
users/,config/,middleware/,security/,data/,Common/. - Crear Controllers —
UserControllersolo traduce HTTP; sintry/catch(el middleware centraliza todo). - Crear Services —
UserServicecon reglas de negocio y mapeo a ViewModels. - Crear Repositories e interfaces —
UserRepository+IUserRepository,IUserService. - Configurar inyección de dependencias — métodos de extensión por capa (
AddApplicationServices,AddDatabaseConfiguration, etc.). - Definir reglas de dependencia — Controller → Service → Repository →
AppDbContext; el Controller nunca ve el DbContext. - Separar modelos de persistencia, DTOs y ViewModels —
User(entidad),UserCreateDTO/UserUpdateDTO(entrada),UserViewModel/UserListViewModel(salida). - Evitar lógica de negocio en Controllers — el Controller delega siempre en
IUserService. - Revisar responsabilidad única de cada clase — verificado en la revisión de cierre del sprint.
Objetivo: el esquema de la BD refleje los modelos y cambie de forma controlada.
- Crear
AppDbContext—src/Api/data/AppDbContext.cs. - Configurar conexión con PostgreSQL —
UseNpgsqlenDatabaseConfiguration. - Registrar entidades —
DbSet<User>declarado comopublic newpara ocultar elUsersheredado deIdentityDbContext. - Configurar Identity —
IdentityConfigurationregistraIdentityUser/IdentityRole(base para futura autenticación). - Crear y ejecutar migraciones — tras un squash, una única migración
20261017175658_InitialMigration. - Revisar tipos, claves y restricciones —
Id(PK),UUID(único),Email(único filtrado), longitudes deName/Email/Password. - Definir convenciones de nombres — tabla
usersen minúsculas con[Table("users")]; columnas en PascalCase (nativo de PostgreSQL/Npgsql). - Verificar que las migraciones reflejan los modelos —
dotnet ef migrations has-pending-model-changessin cambios. - Resolver el conflicto de
AppDbContext.Users— propiedadUserspropia connew(oculta la de Identity sin romperla).
Objetivo: contratos de entrada/salida que representen bien el recurso sin exponer internos.
- Entidad
User— conSoftDelete,CreatedAt,UpdatedAt,IsActive. - DTOs de operación —
UserCreateDTO(Name, Email, Password obligatorios) yUserUpdateDTO(Password opcional). - ViewModels —
UserViewModel(detalle) yUserListViewModel(listado, sin Password). - Interfaz del Repository —
IUserRepositorycon métodosIQueryabley asíncronos conCancellationToken. - Interfaz del Service —
IUserService. - Conexión entre capas — registrado en
AddApplicationServices. - Campos obligatorios vs opcionales — documentado en la sección de Validación.
- Diferenciar usuarios de negocio y de Identity — decisión:
Useres la entidad de negocio; NO se fusiona conIdentityUser(Identity queda listo para cuando exista login, pero los usuarios del CRUD no dependen de ella). - Definir qué no debe devolverse nunca —
Passwordausente de ambos ViewModels (verificado por pruebas). - Asignar
NamedesdeNamedel DTO, no desde el correo — mapeo corregido enUserService.CreateUserAsync.
Objetivo: CRUD completo y consistente.
- Creación de usuarios —
POSTcon hash de contraseña y201. - Consulta por ID —
GET ?id=devuelve404con ProblemDetails si no existe. - Actualización —
PUT/PATCH; siPasswordviene vacío se conserva el hash actual. - Eliminación lógica (
SoftDelete) — marca el registro; no se borra físicamente. - Casos de ID inexistente —
404en GET/PUT/PATCH/DELETE. - Correo duplicado — el Service lanza
DomainException→409 Conflict(más semántico que un400genérico). - Usuario borrado lógicamente no aparece en consultas normales — filtro
SoftDelete == nullen el Repository. - Campos que el cliente no controla —
Id,UUID,CreatedAt,UpdatedAtse generan/actualizan en servidor; el DTO no los acepta.
Objetivo: que la API sea predecible para quien la consume.
- Endpoints principales definidos —
GET,GET?id=,POST,PUT/{id},PATCH/{id},DELETE/{id}bajo/api/User. - Códigos
200/201/204/400/404/409revisados — mapeados y probados (incluidos en pruebas de integración). -
201 Createdal crear — conCreatedAtActionapuntando aGety devolviendo la ubicación del recurso. - Diferencia
PUTvsPATCH— hoy comparten el mismo DTO y la misma lógica (actualización completa tolerante a campos vacíos); se documentó así a propósito para no inventar una semántica de parcheo parcial sin necesidad real. - Nombres y formatos de rutas — singular
api/[controller]→/api/User; documentado en README yApi.http. - Probar cada operación — vía Swagger,
Api.httpy pruebas de integración.
Objetivo: no devolver tablas enteras de golpe.
-
PagedQuery— enCommon/, conPageyPageSize. -
PagedResult<T>— genérico, con metadatos de paginación. -
UserQueryhereda dePagedQuery— reutilizable para futuros módulos (Products heredará lo mismo). -
Skip()/Take()— aplicados en el Repository, nunca en memoria. -
TotalItems/TotalPagescalculados en el flujo de consulta —CountAsynccon filtros aplicados y antes del paginado. - Página sin resultados — se verificó; decisión: colección vacía devuelve
TotalPages = 0(no 1) para no sugerir que existe una página con ítems. - Límites de
Page/PageSize—SanitizeQueryen el Service (Page ≥ 1, PageSize 1–100) + atributosRangeen el query. - Orden estable antes de paginar — orden por
Idantes deSkip/Takepara que las páginas no se solapen ni se salten registros.
Objetivo: que el GET sirva para distintos escenarios reales.
-
Searchpor nombre y correo — case-insensitive conEF.Functions.ILike(PostgreSQL) /Containssensible al motor en SQLite de pruebas. -
IsActivecomo filtro opcional — solo aplica si el parámetro viene con valor (bool?). - Filtro de
SoftDeleteen consultas normales — siempre activo en el Repository. - Combinar búsqueda, estado y paginación — los filtros se encadenan en
IQueryableantes del conteo. -
CountAsync()después de filtros y antes de paginar — garantiza metadatos correctos. - Sin materializar antes de filtrar — todo se queda como
IQueryablehasta la ejecución final. - Probar vacíos, sin coincidencias y combinaciones — cubierto en
UserRepositoryTests(SQLite) e integración.
Objetivo: flexibilidad sin endpoints ni abstracciones innecesarias.
-
OrderDescimplementado — orden porIdascendente/descendente, verificado. - Evaluar
SortBycon lista blanca de columnas — pendiente; solo si aparece una necesidad real de ordenar por otra columna (p. ej.EmailoCreatedAt). -
fields(selección de columnas) — decisión tomada: no implementar por ahora; agregarlo solo si un consumidor lo pide explícitamente y con lista blanca estricta. - Respuesta de colecciones estandarizada — se usa
PagedResult<T>directamente (sin wrapper extradata/pagination); se decidió no añadir otra capa de envoltorio porque no aporta nada hoy.
Objetivo: rechazar datos malos antes de la lógica de negocio.
- Validación de rango en
PagedQuery—Range(1, int.MaxValue)en Page,Range(1, 100)en PageSize + saneo en Service. - Campos obligatorios de DTOs —
[Required]en Name, Email, Password (creación). - Formato y longitud de correo —
[EmailAddress], longitud máxima 100. - Longitudes mínimas/máximas — Name 2–100, Password mínimo 8.
- Reglas de unicidad — correo único vía índice filtrado en BD + comprobación en Service →
409. - Comportamiento de
[ApiController]— respuestas automáticas400conValidationProblemDetails; configurado enValidationConfigurationpara servirapplication/problem+json(consistente con el middleware). - Diferenciar validación de negocio — validación →
400; negocio (correo duplicado, usuario inexistente) →409/404.
Objetivo: una sola estrategia de errores, respuestas seguras.
-
ErrorHandlingMiddleware— registrado primero en el pipeline; atrapa todas las excepciones. - Sin manejo genérico duplicado en Controllers — se eliminó todo
try/catchde los Controllers a propósito; el middleware es el único punto. - Estrategia única — mapeo:
KeyNotFound→404,ArgumentException→400,UnauthorizedAccessException→403,DomainException→status propio (409), resto→500. -
ProblemDetailsRFC 7807 — con campo extracodeytraceIdpara correlación. - Errores conocidos con su significado — tabla en la sección de Manejo de errores.
- Logs de errores internos — el middleware registra el
500con excepción completa en logs; solo al cliente se le da eltraceId. - Sin stack traces al cliente — el
500devuelve un mensaje genérico; verificado por prueba (ErrorHandlingMiddlewareTests).
Objetivo: credenciales protegidas y acceso controlado.
- Integración User/Identity revisada — decisión: no fusionar;
Useres la entidad de negocio e Identity queda disponible como base (aportaIPasswordHasher<User>y el registro de esquemas de autenticación). - Nunca contraseñas en texto plano — resuelto:
BCryptPasswordHasher(IPasswordHasher<User>, work factor 11, salt integrado). Nota del roadmap original sobre "contraseñas en texto plano" → ya no aplica. - Identity para gestionar credenciales/hashes — se usa su abstracción
IPasswordHasher<User>;AddIdentityregistrado como base. - Secretos fuera del repositorio — User Secrets +
.env(gitignored); ahora tambiénAuth:Secret/AUTH_SECRET. - Revisar campos expuestos —
Passwordnunca sale en ViewModels. - Configurar autenticación real (JWT: login/refresh) — hecho: módulo
auth/conPOST /api/Auth/register|login|refresh|revokeyGET /me. Access token HMAC-SHA256 de 15 min + refresh token opaco de 7 días guardado como hash SHA-256, con rotación (reutilizar un token usado →401) y revocación al cambiar la contraseña. ElAuth:Secretes obligatorio y el arranque falla sin él. - Proteger endpoints con roles/políticas — hecho (alcance acordado):
[Authorize]enUser,Product,WarehouseeInventoryItem;/api/Auth/{register,login,refresh,revoke}públicas. Sin roles: solo autenticado/no autenticado, hasta que exista un requisito real. Nota:AddIdentityfija el esquema por defecto a la cookieIdentity.Application;AuthenticationConfigurationlo anula aBearer(si no, el reto devolvía401pero la autenticación se resolvía contra la cookie y todo caía en401). - Revisar CORS para producción — hecho:
AllowAllsolo en Development; en el resto soloCors:AllowedOrigins(vacío = ningún origen externo + aviso en el log al arrancar). Configurable conCors__AllowedOrigins. - Rate limiting — hecho con el middleware nativo de .NET 8: ventana fija por IP, contador aparte para
/api/Auth(10/min por defecto) frente al general (100/min),429con ProblemDetailsrate_limit_exceededy cabeceraRetry-After. Ajustable con la secciónRateLimit.
Objetivo: detectar rotos sin depender de pruebas manuales.
- Pruebas unitarias del Service — creación, actualización, búsqueda, hash, correo duplicado, ausencia de
Passworden ViewModels. - IDs inexistentes y errores de negocio — cubiertos en Service y middleware.
- Paginación y filtros combinados —
UserRepositoryTestscontra SQLite en memoria. - Eliminación lógica — verificada en repo y en integración.
- Integración de endpoints —
WebApplicationFactorycon SQLite; códigos HTTP reales 200/201/204/400/404. - Estructura de respuestas y validaciones — incluido ProblemDetails de errores centralizados.
- Base de datos de prueba adecuada — SQLite en memoria (suficiente para este alcance; no hace falta Postgres de pruebas todavía).
Users es el módulo de aprendizaje. Products y Warehouses repiten los patrones ya probados, sin copiar código ciegamente.
Objetivo: administrar productos del inventario.
- Definir entidad
Product—SKU,Name,Description?,Price,IsActive, timestamps ySoftDelete, con las mismas convenciones queUser. - Configurar restricciones y tipos —
SKUúnico (índice filtrado porSoftDelete, igual queEmail) yPricecondecimal(18,2)(HasPrecision), nuncadouble. - Crear DTOs y ViewModels —
ProductCreateDTO,ProductUpdateDTO,ProductViewModel,ProductListViewModelyProductQuery : PagedQuery. - Implementar Repository, Service y Controller — mismo patrón de capas que Users, sin
try/catchen el controlador. - CRUD completo — mismos códigos HTTP y ProblemDetails que Users (
200/201/204/400/404). - Paginación, búsqueda, filtros y orden —
Searchpor nombre/SKU, filtroIsActive,PagedQueryreutilizado. - Validar SKU único — Service (→
400validation_error) + índice filtrado en BD, mismo tratamiento que el correo. - Migración y pruebas — migración
20261101051548_AddProductsWarehousesAndInventory;ProductServiceTests,ProductRepositoryTests(SQLite) yProductApiTests.
Objetivo: representar dónde están los productos y cuánto hay.
- Definir entidad
Warehouse—Name(único filtrado),Location?,IsActive, timestamps ySoftDelete. - Relaciones almacén–producto — entidad intermedia
InventoryItem(tabla puente explícita, no many-to-many oculta, para poder guardar cantidad y auditoría); FKs conDeleteBehavior.Restrict. - Representar cantidad disponible —
Quantityentero conCHECK (Quantity >= 0)en BD; nunca negativo. - Reglas contra cantidades inválidas — validación en el DTO (
Range(1, …)en los movimientos) y en el Service antes deSaveChangesAsync; almacén/producto inactivo →400, inexistente →404. - Consultas de existencias —
GET /api/InventoryItemcon filtroswarehouseIdyproductId, paginación y nombres/SKU resueltos en la respuesta. - Operaciones de entrada/salida de inventario —
POST /api/InventoryItem/restocky/consume;Quantityno se edita desde unPUTgenérico. - Concurrencia —
ExecuteUpdateAsynccondicional (UPDATE … WHERE Quantity >= @qty) para que dos retiros simultáneos no dejen saldo negativo, e upsert con reintento si dos peticiones crean la misma ficha a la vez (índice único(WarehouseId, ProductId)→409). - Pruebas de movimientos — saldo resultante, saldo insuficiente (
409insufficient_stock), ficha inexistente (404), cantidad inválida (400) y carrera de actualización enInventoryRepositoryTests(SQLite) +InventoryApiTests.
Objetivo: primera versión mantenible por otra persona.
- Documentar endpoints con OpenAPI — Swagger con comentarios XML generados desde el código.
- Ejemplos de peticiones y respuestas — en
src/WMS.Api/Api.http(Auth, Users, Products, Warehouses, Inventory) y en este README. - Documentar códigos de error — tabla de ProblemDetails en la sección de Manejo de errores (incluidos
401,409y429). - Documentar autenticación — sección Autenticación (JWT) con el flujo completo, decisiones tomadas y ejemplo de uso;
Api.httpincluye register/login/me/refresh/revoke. - Revisar índices de PostgreSQL —
Email,SKUyNameúnicos filtrados porSoftDelete;UUIDúnicos;(WarehouseId, ProductId)único en inventario; FKs indexadas (UserId,ProductId,WarehouseId). - Revisar unicidad e integridad referencial — unicidad resuelta con índices filtrados; integridad referencial con
DeleteBehavior.RestrictenInventoryItem(no se puede borrar en cascada un producto/almacén con existencias) yCascadeenrefresh_tokens. -
AsNoTracking()en lecturas — aplicado en todos losGet*de los repositories. -
CancellationTokenen operaciones asíncronas — propagado desde el Controller hastaSaveChangesAsync. - Revisar SQL generado por EF Core — verificado durante el desarrollo (conteo antes de paginar, sin materialización prematura;
ExecuteUpdateAsynccondicional para inventario). - Medir rendimiento antes de optimizar — deferido a propósito: no hay métricas ni volumen real que justifique optimizar, y hacerlo a ciegas solo añadiría complejidad. Revisar cuando existan flujos reales (candidatos:
GET /api/InventoryItemcon muchos almacenes ysearchen listados grandes). - Configurar logs y variables de entorno para producción — parcial: las variables ya están definidas, cableadas y documentadas (
ConnectionStrings__DefaultConnection,Auth__Secretcon arranque obligatorio,Cors__AllowedOrigins,Database__ApplyMigrationsOnStartup, secciónRateLimit) yappsettings.jsonfijaDefault: Information/Microsoft.AspNetCore: Warning. Falta elegir el destino de los logs (consola, Seq, ELK, servicio de la nube) cuando exista el entorno real de despliegue; no se decide a ciegas. - Instrucciones para ejecutar desde cero — README con Opción A (Docker) y Opción B (manual).
No meter todos de una vez. Cada tema se estudia cuando aparezca un problema real que lo justifique.
| Tema | Cuándo estudiarlo |
|---|---|
| SOLID y separación de responsabilidades | Durante los sprints 1–5 (ya ejercitado sin nombrarlo) |
| LINQ y SQL generado por EF Core | Durante los sprints 6–8 (ya ejercitado; profundizar con ToQueryString()) |
| CQRS | Después de consolidar los servicios actuales, si Services crecen demasiado |
| Clean Architecture / hexagonal | En un proyecto experimental posterior o si el tamaño lo justifica; no migrar Warehouse API solo por conocerla |
| Caché | Cuando identifiques consultas costosas y repetidas (p. ej. existencias por almacén) |
| Docker y despliegue | Hecho a nivel de desarrollo; falta pipeline de despliegue real |
| Multiempresa (multi-tenant) | Cuando definas ese requisito para el ERP |
| Observabilidad y rendimiento | Cuando existan flujos y métricas reales que analizar |
- Objetivo: qué problema se resuelve.
- Conceptos: lo mínimo que hay que entender antes de programar.
- Tareas pequeñas: una responsabilidad por paso.
- Implementación: se escribe el código y se explica cada decisión.
- Pruebas: casos normales y de error.
- Cierre: qué se aprendió, qué quedó pendiente y qué desbloquea el siguiente.
Los sprints 11, 13, 14 y el bloque implementable del 15 están cerrados y verificados (dotnet test → 188 pruebas, build sin advertencias). Lo que queda, en orden de utilidad:
- Desplegar y observar — levantar en un entorno real con
ASPNETCORE_ENVIRONMENT=Production,Auth__SecretyCors__AllowedOriginsdefinidos; elegir entonces el destino de los logs (ítem abierto del Sprint 15). - Métricas de rendimiento — medir antes de optimizar (ítem abierto del Sprint 15). Sin volumen real no hay nada que justifique tocar el código.
- Sprint 8 (pendientes menores) —
SortBycon lista blanca yfieldssolo si un consumidor lo pide explícitamente. - Sprint 12 ampliado — si se toca inventario con más tráfico, subir el nivel de las pruebas de concurrencia a paralelismo real entre peticiones.
- Roles y políticas — cuando haya más de un tipo de usuario; hoy solo se distingue autenticado/no autenticado.