Casos de prueba — Scheduled Message
Catálogo de los tests del módulo scheduled-message. Specs fuente (relativas a src/app/modules/scheduled-message/):
controllers/scheduled-message.controller.spec.ts
jobs/scheduled-message-reconciliation.job.spec.ts
models/scheduled-message.model.spec.ts
processors/scheduled-message.processor.spec.ts
repositories/scheduled-message.repository.spec.ts
services/scheduled-message-cancellation.service.spec.ts
services/scheduled-message-queue.service.spec.ts
services/scheduled-message-queue.service.redis.integration.spec.ts
services/scheduled-message-sender.service.spec.ts
services/scheduled-message.service.spec.ts
Resumen: 10 specs y 79 tests (it). El spec del controlador define 2 it dentro de un describe.each de 5 rutas, por lo que en ejecución corren 10 casos parametrizados más 1 adicional.
Integración HTTP (supertest) con PermissionsGuard real y ResponseInterceptor; AuthGuard se sustituye por un FakeAuthGuard que inyecta un usuario con _id, tenantId y roleId.
ScheduledMessageController — permission guard + ownership integration › $method $path (describe.each)
Se ejecuta para cada ruta: POST /v1/scheduled-messages/create (MESSAGE_CREATE), GET /v1/scheduled-messages/mine (MESSAGE_READ), GET /v1/scheduled-messages/by-ticket/:ticketId (MESSAGE_READ), PATCH /v1/scheduled-messages/update/:id (MESSAGE_UPDATE) y PATCH /v1/scheduled-messages/cancel/:id (MESSAGE_DELETE).
| Test | Escenario | Resultado esperado | Mocks |
|---|
returns 403 Forbidden when the resolved acronym set is missing the required permission | El set de acrónimos resuelto está vacío | La ruta responde 403 | ScheduledMessageService, TranslateService, PermissionResolverService, FakeAuthGuard |
returns 200/201 when the resolved acronym set includes the required permission | El set incluye el acrónimo requerido por la ruta | Responde 200 o 201 y el resolver se invoca con (tenantId, roleId) | Los mismos |
ScheduledMessageController — permission guard + ownership integration › ownership (no new permission key — MESSAGE_* reused, service enforces per-author scoping)
| Test | Escenario | Resultado esperado | Mocks |
|---|
passes the authenticated user (not a body-supplied author) into every service call | GET /mine con MESSAGE_READ concedido | findMine recibe el usuario autenticado (_id del autor del guard, no otro autor) | Los mismos |
jobs/scheduled-message-reconciliation.job.spec.ts
ScheduledMessageReconciliationJob
| Test | Escenario | Resultado esperado | Mocks |
|---|
delegates to ScheduledMessageSenderService.reconcile on every tick | handleInterval() con reconcile resuelto | reconcile se llama exactamente 1 vez | ScheduledMessageSenderService.reconcile |
swallows errors from reconcile so one bad tick does not stop future ticks | reconcile rechaza con db down | handleInterval() resuelve a undefined (error absorbido) | ScheduledMessageSenderService.reconcile rechaza |
models/scheduled-message.model.spec.ts
ScheduledMessageSchema indexes
| Test | Escenario | Resultado esperado | Mocks |
|---|
has a partial index on scheduledAt scoped to pending (the sweeper due-query) | Se inspeccionan los índices del schema | Existe índice { scheduledAt: 1 } con partialFilterExpression { status: 'pending' } | Ninguno |
has a partial index on claimedAt scoped to processing (stale-claim recovery) | Idem | Existe índice { claimedAt: 1 } con filtro parcial { status: 'processing' } | Ninguno |
has a compound index for the ticket pending list + cancelForTicket | Idem | Existe índice { tenantId, ticketId, status } | Ninguno |
has a compound index for the per-agent list + quota check | Idem | Existe índice { tenantId, authorUserId, status, scheduledAt } | Ninguno |
ScheduledMessageProcessor
| Test | Escenario | Resultado esperado | Mocks |
|---|
delegates job.data.id to sender.send as an ObjectId — thin adapter, no business logic here | Job con data.id string | send se llama con un ObjectId equivalente al id | ScheduledMessageSenderService.send |
propagates a sender rethrow so BullMQ applies its own retry/backoff | send rechaza con retry me | process rechaza con el mismo error (BullMQ aplica reintento) | ScheduledMessageSenderService.send rechaza |
repositories/scheduled-message.repository.spec.ts
Modelo Mongoose simulado (create, findOneAndUpdate, findById, find, countDocuments, updateMany) con cadenas exec/select/sort/skip/limit.
ScheduledMessageRepository › claimForSend
| Test | Escenario | Resultado esperado | Mocks |
|---|
atomically transitions pending -> processing with the ONLY precondition being status:pending | claimForSend(id) | findOneAndUpdate con filtro { _id, status: pending }, $set.status = processing, $inc.attemptCount = 1 y returnDocument: 'after' | Modelo Mongoose |
returns null when the row is not pending (already claimed or terminal) | findOneAndUpdate devuelve null | Devuelve null | Modelo Mongoose |
sets claimedAt and clears lastError on claim | Claim exitoso | $set.claimedAt es Date y $set.lastError es null | Modelo Mongoose |
ScheduledMessageRepository › cancelPending (author-scoped, CA5)
| Test | Escenario | Resultado esperado | Mocks |
|---|
only cancels when status is pending AND scoped to tenant+author (ownership) | cancelPending(id, tenantId, authorId, AGENT) | Filtro por _id, tenantId, authorUserId y status: pending; $set a cancelled con cancelReason | Modelo Mongoose |
returns null when another agent tries to cancel (ownership fails) | No hay documento que cumpla el filtro | Devuelve null | Modelo Mongoose |
ScheduledMessageRepository › updatePending (CA5 edit)
| Test | Escenario | Resultado esperado | Mocks |
|---|
only updates when pending, ownership scoped, and rejects once processing/sent | updatePending con { body: 'new body' } | Filtro con tenant, autor y status: pending; $set: { body } | Modelo Mongoose |
ScheduledMessageRepository › resetToPending
| Test | Escenario | Resultado esperado | Mocks |
|---|
resets a claimed row back to pending on a retryable failure | resetToPending(id, 'ECONNRESET') | $set con status: pending y lastError: 'ECONNRESET' | Modelo Mongoose |
ScheduledMessageRepository › markFailed
| Test | Escenario | Resultado esperado | Mocks |
|---|
marks a row failed with the last error, terminal | markFailed(id, 'boom') | $set con status: failed y lastError: 'boom' | Modelo Mongoose |
ScheduledMessageRepository › markSent
| Test | Escenario | Resultado esperado | Mocks |
|---|
marks a row sent with the resulting message id and timestamp | markSent(id, messageId) | status = sent, sentMessageId = messageId y sentAt es Date | Modelo Mongoose |
ScheduledMessageRepository › markCancelled
| Test | Escenario | Resultado esperado | Mocks |
|---|
marks a single row cancelled with a reason, no ownership precondition (system-driven) | markCancelled(id, TICKET_DELETED) | Filtro solo { _id }; $set a cancelled con la razón | Modelo Mongoose |
ScheduledMessageRepository › countPendingByTicket / countPendingByAgent
| Test | Escenario | Resultado esperado | Mocks |
|---|
counts pending rows scoped to tenant+ticket | countPendingByTicket con conteo 2 | countDocuments con tenant, ticket y pending; devuelve 2 | Modelo Mongoose |
counts pending rows scoped to tenant+author | countPendingByAgent con conteo 5 | countDocuments con tenant, autor y pending; devuelve 5 | Modelo Mongoose |
ScheduledMessageRepository › findPendingByTicket (CA6 cancellation seam)
| Test | Escenario | Resultado esperado | Mocks |
|---|
returns projection of pending rows for a ticket, scoped by tenant | find devuelve _id y authorUserId | Consulta con tenant, ticket y pending; devuelve la proyección | Modelo Mongoose |
ScheduledMessageRepository › cancelManyByIds
| Test | Escenario | Resultado esperado | Mocks |
|---|
bulk-cancels only rows still pending, guarding the race with a concurrent claim/cancel | cancelManyByIds([ID], TICKET_RESOLVED) con modifiedCount: 1 | updateMany con _id $in y status: pending; devuelve 1 | Modelo Mongoose |
ScheduledMessageRepository › findOverduePending / findOrphanedPending / findStaleProcessing
| Test | Escenario | Resultado esperado | Mocks |
|---|
finds pending rows whose scheduledAt already elapsed | findOverduePending(now) | find con pending y scheduledAt: { $lte: now } | Modelo Mongoose |
finds pending rows not yet due (candidates for orphaned-timer check) | findOrphanedPending(now) | find con pending y scheduledAt: { $gt: now } | Modelo Mongoose |
finds processing rows claimed before the stale threshold | findStaleProcessing(staleBefore) | find con processing y claimedAt: { $lt: staleBefore } | Modelo Mongoose |
services/scheduled-message-cancellation.service.spec.ts
ScheduledMessageCancellationService › cancelForTicket
| Test | Escenario | Resultado esperado | Mocks |
|---|
does nothing and returns 0 when there are no pending rows | findPendingByTicket devuelve [] | Devuelve 0; no cancela en repo, cola ni socket | ScheduledMessageRepository, ScheduledMessageQueueService, SocketBroadcastService |
bulk-updates every pending row to cancelled with the given reason | Dos filas pendientes, razón TICKET_DELETED | cancelManyByIds([ROW_1, ROW_2], razón) y devuelve 2 | Los mismos |
removes the BullMQ job for every cancelled row, tolerating a missing job | queue.cancel rechaza con missing | cancelForTicket resuelve a 1 y cancel(ROW_1) fue llamado | Los mismos |
broadcasts scheduled_message_cancelled to EACH row author agent-only room | Dos filas de autores distintos | Un broadcastScheduledMessageEvent por autor con { id, ticketId, reason } | Los mismos |
services/scheduled-message-queue.service.spec.ts
Cola BullMQ simulada (add, remove, getJob).
ScheduledMessageQueueService › enqueue
| Test | Escenario | Resultado esperado | Mocks |
|---|
uses jobId = the row id, making the enqueue itself idempotent | enqueue(ID, scheduledAt futuro) | queue.add con jobId = id, intentos máximos, removeOnComplete: true, removeOnFail: false y backoff exponencial | Queue BullMQ (token QUEUE_NAMES.SCHEDULED_MESSAGE) |
never uses a negative delay for an overdue scheduledAt | scheduledAt ya vencido | options.delay es 0 | Queue BullMQ |
ScheduledMessageQueueService › cancel
| Test | Escenario | Resultado esperado | Mocks |
|---|
removes the job, tolerating a missing job as a normal outcome | remove resuelve | cancel no lanza y remove recibe el id | Queue BullMQ |
swallows a remove() throw (missing job is not an error) | remove rechaza con job not found | cancel no lanza | Queue BullMQ |
ScheduledMessageQueueService › reschedule
| Test | Escenario | Resultado esperado | Mocks |
|---|
removes then re-adds with the new delay (not changeDelay) | reschedule(ID, newTime) | remove(id) y luego add con el mismo jobId | Queue BullMQ |
ScheduledMessageQueueService › enqueue — bounded by an app-level timeout (CRITICAL-B)
Usa temporizadores falsos de Jest.
| Test | Escenario | Resultado esperado | Mocks |
|---|
rejects with a clear error if queue.add() never settles within the enqueue timeout | queue.add devuelve una promesa que nunca se resuelve; se avanza SCHEDULED_MESSAGE_ENQUEUE_TIMEOUT_MS | enqueue rechaza con un error que coincide con timed out after 6000ms | Queue BullMQ, fake timers |
does not fire the timeout when queue.add() resolves in time | queue.add resuelve a tiempo | enqueue resuelve a undefined | Queue BullMQ, fake timers |
ScheduledMessageQueueService › hasJob
| Test | Escenario | Resultado esperado | Mocks |
|---|
returns true when the queue still has the job | getJob devuelve un job | true | Queue BullMQ |
returns false when the job is missing (orphaned timer) | getJob devuelve undefined | false | Queue BullMQ |
services/scheduled-message-queue.service.redis.integration.spec.ts
Integración contra Redis y BullMQ reales (sin mocks de ioredis/bullmq). Usa REDIS_HOST/REDIS_PORT (por defecto localhost:6379, sobrescribibles con REDIS_HOST_TEST/REDIS_PORT_TEST). Si Redis no es alcanzable, cada test sale temprano sin fallar (soft-skip).
ScheduledMessageQueueService — real Redis integration (CRITICAL-B) › against a real, reachable Redis
| Test | Escenario | Resultado esperado | Mocks |
|---|
enqueues a real job with a delay, readable back from the real queue | enqueue con scheduledAt a 60 s | El job existe en la cola con data = { id }, delay > 0 y hay al menos 1 job delayed/waiting | Ninguno (Redis real) |
a real Worker survives an idle poll well past the old (removed) 5s commandTimeout, with no error and no retry churn (CRITICAL-B) | Worker real sobre cola inactiva durante 7 s | El Worker no emite ningún evento error | Ninguno (Redis real) |
a real Worker picks up a near-immediate job without the commandTimeout in the way | enqueue con scheduledAt a 10 ms y un Worker real | El Worker recibe el job con el id encolado | Ninguno (Redis real) |
ScheduledMessageQueueService — real Redis integration (CRITICAL-B) › against an unreachable Redis (cold case, W18 revised)
| Test | Escenario | Resultado esperado | Mocks |
|---|
enqueue() against an unreachable Redis fails fast and bounded — never hangs forever | Conexión a un puerto sin servicio con estrategia de reintento infinita | enqueue rechaza con un Error en menos de SCHEDULED_MESSAGE_ENQUEUE_TIMEOUT_MS + 5 s | Ninguno (conexión real fallida) |
services/scheduled-message-sender.service.spec.ts
ScheduledMessageSenderService › claim step
| Test | Escenario | Resultado esperado | Mocks |
|---|
returns silently (no-op) when the claim fails — already claimed or terminal | claimForSend devuelve null | send resuelve a undefined; no consulta settings ni crea mensaje | Repositorio, settings, tickets, usuarios, mensajes, socket, cola, router outbound |
ScheduledMessageSenderService › feature disabled
| Test | Escenario | Resultado esperado | Mocks |
|---|
cancels with feature_disabled and emits the agent-only event, sends no message | Settings con enabled: false | markCancelled(FEATURE_DISABLED), sin mensaje y evento CANCELLED al autor | Los mismos |
ScheduledMessageSenderService › ticket deleted
| Test | Escenario | Resultado esperado | Mocks |
|---|
cancels with ticket_deleted when the ticket no longer exists | findByIdAndTenant devuelve null | markCancelled(TICKET_DELETED) y no se crea mensaje | Los mismos |
ScheduledMessageSenderService › ticket resolved (defense-in-depth re-check)
| Test | Escenario | Resultado esperado | Mocks |
|---|
cancels with ticket_resolved when the ticket is resolved and policy is cancel | Ticket RESOLVED, política onTicketResolved: cancel | markCancelled(TICKET_RESOLVED) y no se crea mensaje | Los mismos |
sends normally when the ticket is resolved but policy is send | Ticket RESOLVED, política send | No se cancela y se crea el mensaje | Los mismos |
ScheduledMessageSenderService › owner inactive
| Test | Escenario | Resultado esperado | Mocks |
|---|
cancels with owner_inactive + agent-only thread notice when policy is cancel | Autor inactivo, política cancel | markCancelled(OWNER_INACTIVE), broadcastSystemMessage solo para agentes (true) y sin mensaje | Los mismos |
sends normally when the author is inactive but policy is send | Autor inactivo, política send | No se cancela y se crea el mensaje | Los mismos |
ScheduledMessageSenderService › chat branch (6a)
| Test | Escenario | Resultado esperado | Mocks |
|---|
creates the message, updates last message, broadcasts agent message + ticket updated, marks sent, emits scheduled_message_sent — in that order | Fila chat reclamada | Crea el mensaje con cuerpo firmado *Agente Uno*:\nHola desde el futuro, actualiza último mensaje, enruta outbound, difunde mensaje y ticket, marca sent y emite SENT; broadcastAgentMessage ocurre antes del evento scheduled_message_sent | Los mismos |
ScheduledMessageSenderService › chat branch — switch de firma apagado
| Test | Escenario | Resultado esperado | Mocks |
|---|
no firma cuando el mensaje se programó con el switch apagado | Fila con signature: false | El mensaje se crea con el cuerpo sin firma | Los mismos |
ScheduledMessageSenderService › note branch (6b)
| Test | Escenario | Resultado esperado | Mocks |
|---|
creates the note, broadcasts agent note only — no updateLastMessage, no ticket_updated | Fila de tipo note | Crea la nota sin firma, difunde broadcastAgentNote; no actualiza último mensaje ni emite ticket_updated | Los mismos |
ScheduledMessageSenderService › failure policy (B3.2)
| Test | Escenario | Resultado esperado | Mocks |
|---|
resets to pending and rethrows when attemptCount is below MAX_ATTEMPTS (retryable) | attemptCount 1 y la creación falla con ECONNRESET | send rechaza, resetToPending(id, 'ECONNRESET') y no markFailed | Los mismos |
marks failed (no rethrow) and emits scheduled_message_failed once attemptCount reaches MAX_ATTEMPTS | attemptCount = MAX_ATTEMPTS y falla con boom | send resuelve, markFailed(id, 'boom'), sin reset y evento FAILED con error: 'boom' | Los mismos |
ScheduledMessageSenderService › reconcile (reconciliation sweep, Mongo as source of truth)
| Test | Escenario | Resultado esperado | Mocks |
|---|
pass 1: sends every overdue pending row immediately, in-process (not via the queue) | Una fila vencida | Se llama claimForSend(overdueId) y no se encola | Los mismos |
pass 2: re-enqueues a not-yet-due row whose Redis timer was lost (Redis-flush insurance) | Fila huérfana y hasJob es false | Se llama enqueue(id, scheduledAt) | Los mismos |
pass 2: leaves a not-yet-due row alone when the queue still has its job | hasJob es true | No se encola | Los mismos |
pass 3: resets a stale processing row (worker crashed mid-claim) back to pending and re-enqueues | Fila processing obsoleta | resetToPending(id, motivo) y enqueue(id, scheduledAt) | Los mismos |
services/scheduled-message.service.spec.ts
ScheduledMessageService › create (CA1/CA2)
| Test | Escenario | Resultado esperado | Mocks |
|---|
rejects with 403 when the feature is disabled for the tenant | scheduledMessages.enabled = false | ForbiddenException y no se crea la fila | ScheduledMessageRepository, cola, TenantSettingsService, TicketRepository, socket, TranslateService |
rejects a past scheduledAt | scheduledAt en el pasado | Rechaza y no crea | Los mismos |
rejects a scheduledAt under minLeadTimeMinutes | 1 min cuando el mínimo es 5 | Rechaza y no crea | Los mismos |
rejects a scheduledAt beyond maxHorizonDays | 40 días cuando el máximo es 30 | Rechaza y no crea | Los mismos |
rejects when the ticket already has maxPendingPerTicket pending items | countPendingByTicket = 10 | Rechaza y no crea | Los mismos |
rejects when the agent already has maxPendingPerAgent pending items | countPendingByAgent = 50 | Rechaza y no crea | Los mismos |
rejects a note when allowNotes is false | messageType nota con allowNotes: false | Rechaza y no crea | Los mismos |
rejects when the ticket does not belong to the tenant | findByIdAndTenant devuelve null | NotFoundException y no crea | Los mismos |
creates, enqueues the job, and broadcasts scheduled_message_created agent-only, on the happy path | DTO válido | Crea con snapshots (contacto, autor, zona horaria), encola (ID, Date) y difunde CREATED al autor con { item } | Los mismos |
broadcasts scheduled_message_created with the FULL ScheduledMessage contract the frontend expects (CRITICAL-2 fix: { item }, not a flat payload) | DTO válido | El payload es { item } con todos los campos del contrato (_id, tenantId, ..., updatedAt) y sin clave id suelta | Los mismos |
ScheduledMessageService › update (CA5 edit)
| Test | Escenario | Resultado esperado | Mocks |
|---|
throws NotFoundException when the row is not pending or not owned by the caller | updatePending devuelve null | NotFoundException | Los mismos |
reschedules the queue job when scheduledAt changes | DTO con nuevo scheduledAt | queue.reschedule(ID, nuevaFecha) | Los mismos |
does NOT touch the queue when only the body changes (job carries only {id}) | DTO solo con body | No se llama reschedule | Los mismos |
broadcasts scheduled_message_updated with the FULL ScheduledMessage contract the frontend expects (CRITICAL-2 fix: { item }, not a flat payload) | Actualización de body | Difunde UPDATED al autor con { item } completo y sin clave id | Los mismos |
ScheduledMessageService › cancel (CA5)
| Test | Escenario | Resultado esperado | Mocks |
|---|
throws NotFoundException when the row is not pending or not owned by the caller | cancelPending devuelve null | NotFoundException y no se toca la cola | Los mismos |
removes the queue job and broadcasts scheduled_message_cancelled with reason agent | Fila pendiente propia | cancelPending(id, tenant, autor, AGENT), queue.cancel(ID) y evento CANCELLED con razón agent | Los mismos |
ScheduledMessageService › findMine / findByTicket (CA3, ownership scoped)
| Test | Escenario | Resultado esperado | Mocks |
|---|
findMine delegates to the repository scoped by tenant+author | findMine(user, { page: 1, limit: 10 }) | findMineByAuthor(tenantId, autorId, 1, 10, undefined) | Los mismos |
findByTicket delegates to the repository scoped by tenant+ticket+author | findByTicket(user, ticketId) | findByTicketForAuthor(tenantId, ticketId, autorId) | Los mismos |