feat: audit & sync with documentacion.html v1.1 — schema, tests, modules, web

Schema (packages/database/prisma/schema.prisma):
- Add SenaeCategory, B2BRequestStatus, NotificationChannel enums
- Add Package fields: lengthCm, widthCm, heightCm, hasDiscrepancy, photos[],
  senaeCategory, senaeAuthNumber, senaeDeclarationId
- Add userId field to PreAlert
- Add RefreshToken model (JWT auth — Fase 1)
- Add Tariff model (configurable rates: pricePerLb, insurancePct, etc.)
- Add B2BRequest model (heavy cargo imports — doc §13)
- Add Notification model (multi-channel: email, whatsapp, sms, push — doc §16)
- Add userAgent to AuditLog

API (apps/api):
- Add CalculatorModule: calculates SENAE cost breakdown (flete, seguro,
  FODINFA, arancel, IVA) per formulas in doc §15
- Add TrackingModule: public GET /api/tracking/:id endpoint (doc §07)
- Register both modules in AppModule
- Add Jest config + test scripts (test, test:watch, test:cov, test:ci)

Tests (80 tests, 97.45% coverage):
- calculator.util.spec.ts: 35 tests — SENAE formulas, volumetric weight,
  4x4 regime, tariff rates, error validation, breakdown
- tracking-id.util.spec.ts: 13 tests — format, uniqueness, validation
- suite-code.util.spec.ts: 13 tests — generation, parsing, address builder
- health.controller.spec.ts: 5 tests — ok/degraded/timestamp
- calculator.spec.ts: 8 tests — controller + service unit tests
- tracking.controller.spec.ts: 9 tests — public search, 404, no sensitive data

Web (apps/web):
- page.tsx: Full landing page matching documentation (Hero, Services,
  8 Modules, Tracking states, SENAE calculator preview, 6 Roles, Companies)
- /calculadora: SENAE categories + API usage guide
- /tracking: Tracking ID format + 11 states reference

Seed (packages/database/prisma/seed.ts):
- Creates all 6 roles (SUPER_ADMIN, ADMIN_EMPRESA, OPERADOR_BODEGA,
  AGENTE_ADUANERO, CLIENTE, SOPORTE)
- Assigns Suite EC-00001 to demo client
- Creates demo Package EC-20260506-000001 with status history
- Creates demo PreAlert and B2BRequest

.env.example: Added all 9 integrations from doc §18:
  Amazon SP-API, SENAE WebService, Stripe/PayPhone, WhatsApp Business API,
  SendGrid/SES, SMS/Twilio, FedEx/DHL/UPS couriers, S3/MinIO, Sentry

migration.sql: Updated to reflect all new models and fields
turbo.json + package.json: Added test, test:cov, test:ci tasks + db:seed script
This commit is contained in:
Lizandro Guarnizo
2026-06-01 08:25:01 -05:00
parent 094ea9cf81
commit 93db76897d
26 changed files with 4651 additions and 201 deletions
@@ -0,0 +1,110 @@
import { Test, TestingModule } from "@nestjs/testing";
import { TrackingController } from "./tracking.controller";
import { PrismaService } from "../prisma/prisma.service";
import { NotFoundException } from "@nestjs/common";
describe("TrackingController (doc §07 — búsqueda pública sin login)", () => {
let controller: TrackingController;
let prismaService: { client: { package: { findUnique: jest.Mock } } };
const mockPackage = {
trackingId: "EC-20260506-082341",
status: "REGISTRADO",
description: "Tenis Nike Air Max",
store: "Amazon",
vendorTracking: "1Z999AA10123456784",
senaeCategory: "REGIMEN_4X4",
createdAt: new Date("2026-05-06T10:00:00.000Z"),
updatedAt: new Date("2026-05-06T10:00:00.000Z"),
statusHistory: [
{
status: "REGISTRADO",
note: "Paquete registrado por el cliente",
createdAt: new Date("2026-05-06T10:00:00.000Z"),
},
],
};
beforeEach(async () => {
prismaService = {
client: {
package: {
findUnique: jest.fn().mockResolvedValue(mockPackage),
},
},
};
const module: TestingModule = await Test.createTestingModule({
controllers: [TrackingController],
providers: [
{
provide: PrismaService,
useValue: prismaService,
},
],
}).compile();
controller = module.get<TrackingController>(TrackingController);
});
it("debe estar definido", () => {
expect(controller).toBeDefined();
});
it("retorna el paquete cuando el Tracking ID es válido y existe", async () => {
const result = await controller.findByTrackingId("EC-20260506-082341");
expect(result.trackingId).toBe("EC-20260506-082341");
expect(result.status).toBe("REGISTRADO");
expect(result.description).toBe("Tenis Nike Air Max");
expect(result.timeline).toHaveLength(1);
});
it("incluye el timeline (historial de estados)", async () => {
const result = await controller.findByTrackingId("EC-20260506-082341");
expect(Array.isArray(result.timeline)).toBe(true);
expect(result.timeline[0].status).toBe("REGISTRADO");
});
it("lanza NotFoundException si el Tracking ID no existe en DB", async () => {
prismaService.client.package.findUnique.mockResolvedValueOnce(null);
await expect(
controller.findByTrackingId("EC-20260506-999999"),
).rejects.toThrow(NotFoundException);
});
it("lanza NotFoundException para formato de ID inválido (evita consultas innecesarias)", async () => {
await expect(
controller.findByTrackingId("INVALIDO"),
).rejects.toThrow(NotFoundException);
// No debe llegar a consultar la DB
expect(prismaService.client.package.findUnique).not.toHaveBeenCalled();
});
it("lanza NotFoundException para ID con prefijo incorrecto", async () => {
await expect(
controller.findByTrackingId("US-20260506-082341"),
).rejects.toThrow(NotFoundException);
});
it("lanza NotFoundException para ID con fecha malformada", async () => {
await expect(
controller.findByTrackingId("EC-2026050-082341"),
).rejects.toThrow(NotFoundException);
});
it("retorna null para senaeCategory si no está definida", async () => {
prismaService.client.package.findUnique.mockResolvedValueOnce({
...mockPackage,
senaeCategory: null,
});
const result = await controller.findByTrackingId("EC-20260506-082341");
expect(result.senaeCategory).toBeNull();
});
it("no expone datos sensibles (sin userId ni tenantId)", async () => {
const result = await controller.findByTrackingId("EC-20260506-082341") as Record<string, unknown>;
expect(result["userId"]).toBeUndefined();
expect(result["tenantId"]).toBeUndefined();
expect(result["declaredValue"]).toBeUndefined();
});
});
@@ -0,0 +1,60 @@
import { Controller, Get, Param, NotFoundException } from "@nestjs/common";
import { PrismaService } from "../prisma/prisma.service";
import { isValidTrackingId } from "../common/utils/tracking-id.util";
@Controller("tracking")
export class TrackingController {
constructor(private readonly prisma: PrismaService) {}
/**
* GET /api/tracking/:id
* Búsqueda pública por Tracking ID — sin necesidad de login (doc §07 Módulo de Tracking).
* Retorna el estado y el historial del paquete.
*/
@Get(":id")
async findByTrackingId(@Param("id") id: string) {
if (!isValidTrackingId(id)) {
throw new NotFoundException(
`Formato de Tracking ID inválido. Esperado: EC-YYYYMMDD-XXXXXX. Recibido: ${id}`,
);
}
const pkg = await this.prisma.client.package.findUnique({
where: { trackingId: id },
select: {
trackingId: true,
status: true,
description: true,
store: true,
vendorTracking: true,
senaeCategory: true,
createdAt: true,
updatedAt: true,
statusHistory: {
orderBy: { createdAt: "asc" },
select: {
status: true,
note: true,
createdAt: true,
},
},
},
});
if (!pkg) {
throw new NotFoundException(`No se encontró un paquete con el Tracking ID: ${id}`);
}
return {
trackingId: pkg.trackingId,
status: pkg.status,
description: pkg.description,
store: pkg.store ?? null,
vendorTracking: pkg.vendorTracking ?? null,
senaeCategory: pkg.senaeCategory ?? null,
timeline: pkg.statusHistory,
lastUpdate: pkg.updatedAt,
registered: pkg.createdAt,
};
}
}
+7
View File
@@ -0,0 +1,7 @@
import { Module } from "@nestjs/common";
import { TrackingController } from "./tracking.controller";
@Module({
controllers: [TrackingController],
})
export class TrackingModule {}