feat: consolidations, aduanero portal, payments admin, bug fixes, tests

## Bugs corregidos
- bodega/page.tsx: estados correctos §08 (RECIBIDO_BODEGA, EN_VERIFICACION, EN_TRANSITO_ECUADOR)
- admin/page.tsx: carga paquetes reales, alertas de incidencias/B2B/cobros pendientes, barras de estado

## Nuevos portales y páginas
- /aduanero/ — portal propio para AGENTE_ADUANERO (layout + dashboard §11 + declaraciones DSI)
- /aduanero/declaraciones — cola de declaraciones con formulario DSI y detección DAI automática
- /admin/pagos — gestión de cobros: KPIs, filtros por estado, tabla con breakdown
- /portal/consolidacion — cliente crea/gestiona consolidaciones §21
- /bodega/consolidacion — operador cierra y despacha consolidaciones (→ EN_TRANSITO_ECUADOR)

## API nueva (ConsolidationsModule)
- GET/POST /consolidations
- GET /consolidations/:id
- POST /consolidations/:id/packages
- DELETE /consolidations/:id/packages/:packageId
- POST /consolidations/:id/close
- POST /consolidations/:id/dispatch (→ actualiza paquetes a EN_TRANSITO_ECUADOR)

## Prisma schema v0.5
- ConsolidationStatus enum (ABIERTA, CERRADA, DESPACHADA, ENTREGADA, CANCELADA)
- Consolidation model con totales calculados (totalWeightLb, totalValue)
- ConsolidationPackage (tabla intermedia, un paquete = una consolidación)
- db push aplicado a remote DB (46.202.93.92)

## Navegación
- Login: AGENTE_ADUANERO → /aduanero (ya no /bodega)
- Bodega nav: añadido Consolidaciones
- Portal nav: añadido Consolidar
- Admin nav: añadido Cobros

## Tests (104 total, 8 suites)
- payments.service.spec.ts: 16 tests (createIntent, confirm, list, findByPackageForUser)
- notifications.service.spec.ts: 12 tests (getTemplates, updateTemplate, seedDefaultTemplates, notifyStatusChange, findByUser)

## Legal §21
- Registro: aviso LOPDP Ecuador + normativa NJ en footer del formulario
- Landing footer: aviso detallado de protección de datos LOPDP/NJ

## api.ts: consolidations.* client methods
This commit is contained in:
Lizandro Guarnizo
2026-06-01 18:29:26 -05:00
parent 5565eef554
commit 7682b11dff
23 changed files with 2158 additions and 95 deletions
@@ -0,0 +1,168 @@
import { Test, TestingModule } from "@nestjs/testing";
import { NotFoundException } from "@nestjs/common";
import { NotificationsService } from "./notifications.service";
import { PrismaService } from "../prisma/prisma.service";
const mockTemplate = {
id: "tpl-1",
tenantId: "tenant-1",
event: "REGISTRADO",
channel: "EMAIL",
subject: "Tu paquete {{trackingId}} fue registrado",
body: "Hola {{firstName}}, tu paquete {{trackingId}} fue registrado.",
isActive: true,
updatedAt: new Date(),
};
const mockPackage = {
id: "pkg-1",
tenantId: "tenant-1",
userId: "user-1",
trackingId: "EC-20260601-ABCDEF",
status: "REGISTRADO",
};
const mockUser = { id: "user-1", firstName: "Juan", lastName: "Pérez", suite: { code: "EC-00001" } };
const mockPrisma = {
client: {
notificationTemplate: {
findMany: jest.fn(),
findUnique: jest.fn(),
update: jest.fn(),
upsert: jest.fn(),
},
notification: {
create: jest.fn(),
update: jest.fn(),
updateMany: jest.fn(),
findMany: jest.fn(),
},
},
};
describe("NotificationsService", () => {
let service: NotificationsService;
beforeEach(async () => {
jest.clearAllMocks();
const module: TestingModule = await Test.createTestingModule({
providers: [
NotificationsService,
{ provide: PrismaService, useValue: mockPrisma },
],
}).compile();
service = module.get<NotificationsService>(NotificationsService);
});
// ─── getTemplates ────────────────────────────────────────────
describe("getTemplates", () => {
it("devuelve plantillas del tenant", async () => {
mockPrisma.client.notificationTemplate.findMany.mockResolvedValue([mockTemplate]);
const result = await service.getTemplates("tenant-1");
expect(result).toHaveLength(1);
expect(mockPrisma.client.notificationTemplate.findMany).toHaveBeenCalledWith(
expect.objectContaining({ where: { tenantId: "tenant-1" } })
);
});
});
// ─── updateTemplate ──────────────────────────────────────────
describe("updateTemplate", () => {
it("actualiza una plantilla correctamente", async () => {
mockPrisma.client.notificationTemplate.findUnique.mockResolvedValue(mockTemplate);
mockPrisma.client.notificationTemplate.update.mockResolvedValue({ ...mockTemplate, body: "Nuevo cuerpo" });
const result = await service.updateTemplate("tpl-1", "tenant-1", "Nuevo cuerpo", undefined, true);
expect(mockPrisma.client.notificationTemplate.update).toHaveBeenCalledWith(
expect.objectContaining({ where: { id: "tpl-1" } })
);
});
it("lanza NotFoundException si la plantilla no existe", async () => {
mockPrisma.client.notificationTemplate.findUnique.mockResolvedValue(null);
await expect(service.updateTemplate("invalid", "tenant-1", "body"))
.rejects.toThrow(NotFoundException);
});
it("lanza NotFoundException si el tenantId no coincide", async () => {
mockPrisma.client.notificationTemplate.findUnique.mockResolvedValue({ ...mockTemplate, tenantId: "otro-tenant" });
await expect(service.updateTemplate("tpl-1", "tenant-1", "body"))
.rejects.toThrow(NotFoundException);
});
});
// ─── seedDefaultTemplates ───────────────────────────────────
describe("seedDefaultTemplates", () => {
it("crea upserts para 11 eventos × 3 canales = 33 plantillas", async () => {
mockPrisma.client.notificationTemplate.upsert.mockResolvedValue(mockTemplate);
const result = await service.seedDefaultTemplates("tenant-1");
expect(result.seeded).toBe(33);
expect(mockPrisma.client.notificationTemplate.upsert).toHaveBeenCalledTimes(33);
});
it("es idempotente — usa upsert con update vacío", async () => {
mockPrisma.client.notificationTemplate.upsert.mockResolvedValue(mockTemplate);
await service.seedDefaultTemplates("tenant-1");
const call = mockPrisma.client.notificationTemplate.upsert.mock.calls[0][0];
expect(call.update).toEqual({});
});
});
// ─── notifyStatusChange ──────────────────────────────────────
describe("notifyStatusChange", () => {
beforeEach(() => {
mockPrisma.client.notificationTemplate.findUnique.mockResolvedValue(null); // usa fallback
mockPrisma.client.notification.create.mockResolvedValue({ id: "notif-1" });
mockPrisma.client.notification.update.mockResolvedValue({});
});
it("crea notificaciones para los 3 canales por defecto", async () => {
await service.notifyStatusChange(mockPackage, mockUser);
expect(mockPrisma.client.notification.create).toHaveBeenCalledTimes(3); // EMAIL, WHATSAPP, PUSH
});
it("interpola {{trackingId}} y {{firstName}} en el body", async () => {
mockPrisma.client.notificationTemplate.findUnique.mockResolvedValue({
...mockTemplate,
body: "Hola {{firstName}}, tu paquete es {{trackingId}}.",
});
await service.notifyStatusChange(mockPackage, mockUser);
const createCall = mockPrisma.client.notification.create.mock.calls[0][0];
expect(createCall.data.body).toContain("Juan");
expect(createCall.data.body).toContain("EC-20260601-ABCDEF");
});
it("no envía notificación si la plantilla está inactiva", async () => {
mockPrisma.client.notificationTemplate.findUnique.mockResolvedValue({ ...mockTemplate, isActive: false });
await service.notifyStatusChange(mockPackage, mockUser);
// Solo EMAIL está deshabilitado, WHATSAPP y PUSH usan fallback (activo)
// El mock devuelve el template inactivo para todos
expect(mockPrisma.client.notification.create).not.toHaveBeenCalled();
});
it("no lanza excepción si la creación de notificación falla", async () => {
mockPrisma.client.notification.create.mockRejectedValue(new Error("DB error"));
await expect(service.notifyStatusChange(mockPackage, mockUser)).resolves.not.toThrow();
});
});
// ─── findByUser ──────────────────────────────────────────────
describe("findByUser", () => {
it("devuelve notificaciones del usuario ordenadas por fecha", async () => {
mockPrisma.client.notification.findMany.mockResolvedValue([]);
await service.findByUser("user-1");
expect(mockPrisma.client.notification.findMany).toHaveBeenCalledWith(
expect.objectContaining({
where: { userId: "user-1" },
orderBy: { createdAt: "desc" },
take: 20,
})
);
});
});
});