Skip to main content

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.

controllers/scheduled-message.controller.spec.ts​

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).

TestEscenarioResultado esperadoMocks
returns 403 Forbidden when the resolved acronym set is missing the required permissionEl set de acrónimos resuelto está vacíoLa ruta responde 403ScheduledMessageService, TranslateService, PermissionResolverService, FakeAuthGuard
returns 200/201 when the resolved acronym set includes the required permissionEl set incluye el acrónimo requerido por la rutaResponde 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)​

TestEscenarioResultado esperadoMocks
passes the authenticated user (not a body-supplied author) into every service callGET /mine con MESSAGE_READ concedidofindMine recibe el usuario autenticado (_id del autor del guard, no otro autor)Los mismos

jobs/scheduled-message-reconciliation.job.spec.ts​

ScheduledMessageReconciliationJob​

TestEscenarioResultado esperadoMocks
delegates to ScheduledMessageSenderService.reconcile on every tickhandleInterval() con reconcile resueltoreconcile se llama exactamente 1 vezScheduledMessageSenderService.reconcile
swallows errors from reconcile so one bad tick does not stop future ticksreconcile rechaza con db downhandleInterval() resuelve a undefined (error absorbido)ScheduledMessageSenderService.reconcile rechaza

models/scheduled-message.model.spec.ts​

ScheduledMessageSchema indexes​

TestEscenarioResultado esperadoMocks
has a partial index on scheduledAt scoped to pending (the sweeper due-query)Se inspeccionan los índices del schemaExiste índice { scheduledAt: 1 } con partialFilterExpression { status: 'pending' }Ninguno
has a partial index on claimedAt scoped to processing (stale-claim recovery)IdemExiste índice { claimedAt: 1 } con filtro parcial { status: 'processing' }Ninguno
has a compound index for the ticket pending list + cancelForTicketIdemExiste índice { tenantId, ticketId, status }Ninguno
has a compound index for the per-agent list + quota checkIdemExiste índice { tenantId, authorUserId, status, scheduledAt }Ninguno

processors/scheduled-message.processor.spec.ts​

ScheduledMessageProcessor​

TestEscenarioResultado esperadoMocks
delegates job.data.id to sender.send as an ObjectId — thin adapter, no business logic hereJob con data.id stringsend se llama con un ObjectId equivalente al idScheduledMessageSenderService.send
propagates a sender rethrow so BullMQ applies its own retry/backoffsend rechaza con retry meprocess 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​

TestEscenarioResultado esperadoMocks
atomically transitions pending -> processing with the ONLY precondition being status:pendingclaimForSend(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 nullDevuelve nullModelo Mongoose
sets claimedAt and clears lastError on claimClaim exitoso$set.claimedAt es Date y $set.lastError es nullModelo Mongoose

ScheduledMessageRepository › cancelPending (author-scoped, CA5)​

TestEscenarioResultado esperadoMocks
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 cancelReasonModelo Mongoose
returns null when another agent tries to cancel (ownership fails)No hay documento que cumpla el filtroDevuelve nullModelo Mongoose

ScheduledMessageRepository › updatePending (CA5 edit)​

TestEscenarioResultado esperadoMocks
only updates when pending, ownership scoped, and rejects once processing/sentupdatePending con { body: 'new body' }Filtro con tenant, autor y status: pending; $set: { body }Modelo Mongoose

ScheduledMessageRepository › resetToPending​

TestEscenarioResultado esperadoMocks
resets a claimed row back to pending on a retryable failureresetToPending(id, 'ECONNRESET')$set con status: pending y lastError: 'ECONNRESET'Modelo Mongoose

ScheduledMessageRepository › markFailed​

TestEscenarioResultado esperadoMocks
marks a row failed with the last error, terminalmarkFailed(id, 'boom')$set con status: failed y lastError: 'boom'Modelo Mongoose

ScheduledMessageRepository › markSent​

TestEscenarioResultado esperadoMocks
marks a row sent with the resulting message id and timestampmarkSent(id, messageId)status = sent, sentMessageId = messageId y sentAt es DateModelo Mongoose

ScheduledMessageRepository › markCancelled​

TestEscenarioResultado esperadoMocks
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ónModelo Mongoose

ScheduledMessageRepository › countPendingByTicket / countPendingByAgent​

TestEscenarioResultado esperadoMocks
counts pending rows scoped to tenant+ticketcountPendingByTicket con conteo 2countDocuments con tenant, ticket y pending; devuelve 2Modelo Mongoose
counts pending rows scoped to tenant+authorcountPendingByAgent con conteo 5countDocuments con tenant, autor y pending; devuelve 5Modelo Mongoose

ScheduledMessageRepository › findPendingByTicket (CA6 cancellation seam)​

TestEscenarioResultado esperadoMocks
returns projection of pending rows for a ticket, scoped by tenantfind devuelve _id y authorUserIdConsulta con tenant, ticket y pending; devuelve la proyecciónModelo Mongoose

ScheduledMessageRepository › cancelManyByIds​

TestEscenarioResultado esperadoMocks
bulk-cancels only rows still pending, guarding the race with a concurrent claim/cancelcancelManyByIds([ID], TICKET_RESOLVED) con modifiedCount: 1updateMany con _id $in y status: pending; devuelve 1Modelo Mongoose

ScheduledMessageRepository › findOverduePending / findOrphanedPending / findStaleProcessing​

TestEscenarioResultado esperadoMocks
finds pending rows whose scheduledAt already elapsedfindOverduePending(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 thresholdfindStaleProcessing(staleBefore)find con processing y claimedAt: { $lt: staleBefore }Modelo Mongoose

services/scheduled-message-cancellation.service.spec.ts​

ScheduledMessageCancellationService › cancelForTicket​

TestEscenarioResultado esperadoMocks
does nothing and returns 0 when there are no pending rowsfindPendingByTicket devuelve []Devuelve 0; no cancela en repo, cola ni socketScheduledMessageRepository, ScheduledMessageQueueService, SocketBroadcastService
bulk-updates every pending row to cancelled with the given reasonDos filas pendientes, razón TICKET_DELETEDcancelManyByIds([ROW_1, ROW_2], razón) y devuelve 2Los mismos
removes the BullMQ job for every cancelled row, tolerating a missing jobqueue.cancel rechaza con missingcancelForTicket resuelve a 1 y cancel(ROW_1) fue llamadoLos mismos
broadcasts scheduled_message_cancelled to EACH row author agent-only roomDos filas de autores distintosUn broadcastScheduledMessageEvent por autor con { id, ticketId, reason }Los mismos

services/scheduled-message-queue.service.spec.ts​

Cola BullMQ simulada (add, remove, getJob).

ScheduledMessageQueueService › enqueue​

TestEscenarioResultado esperadoMocks
uses jobId = the row id, making the enqueue itself idempotentenqueue(ID, scheduledAt futuro)queue.add con jobId = id, intentos máximos, removeOnComplete: true, removeOnFail: false y backoff exponencialQueue BullMQ (token QUEUE_NAMES.SCHEDULED_MESSAGE)
never uses a negative delay for an overdue scheduledAtscheduledAt ya vencidooptions.delay es 0Queue BullMQ

ScheduledMessageQueueService › cancel​

TestEscenarioResultado esperadoMocks
removes the job, tolerating a missing job as a normal outcomeremove resuelvecancel no lanza y remove recibe el idQueue BullMQ
swallows a remove() throw (missing job is not an error)remove rechaza con job not foundcancel no lanzaQueue BullMQ

ScheduledMessageQueueService › reschedule​

TestEscenarioResultado esperadoMocks
removes then re-adds with the new delay (not changeDelay)reschedule(ID, newTime)remove(id) y luego add con el mismo jobIdQueue BullMQ

ScheduledMessageQueueService › enqueue — bounded by an app-level timeout (CRITICAL-B)​

Usa temporizadores falsos de Jest.

TestEscenarioResultado esperadoMocks
rejects with a clear error if queue.add() never settles within the enqueue timeoutqueue.add devuelve una promesa que nunca se resuelve; se avanza SCHEDULED_MESSAGE_ENQUEUE_TIMEOUT_MSenqueue rechaza con un error que coincide con timed out after 6000msQueue BullMQ, fake timers
does not fire the timeout when queue.add() resolves in timequeue.add resuelve a tiempoenqueue resuelve a undefinedQueue BullMQ, fake timers

ScheduledMessageQueueService › hasJob​

TestEscenarioResultado esperadoMocks
returns true when the queue still has the jobgetJob devuelve un jobtrueQueue BullMQ
returns false when the job is missing (orphaned timer)getJob devuelve undefinedfalseQueue 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​

TestEscenarioResultado esperadoMocks
enqueues a real job with a delay, readable back from the real queueenqueue con scheduledAt a 60 sEl job existe en la cola con data = { id }, delay > 0 y hay al menos 1 job delayed/waitingNinguno (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 sEl Worker no emite ningún evento errorNinguno (Redis real)
a real Worker picks up a near-immediate job without the commandTimeout in the wayenqueue con scheduledAt a 10 ms y un Worker realEl Worker recibe el job con el id encoladoNinguno (Redis real)

ScheduledMessageQueueService — real Redis integration (CRITICAL-B) › against an unreachable Redis (cold case, W18 revised)​

TestEscenarioResultado esperadoMocks
enqueue() against an unreachable Redis fails fast and bounded — never hangs foreverConexión a un puerto sin servicio con estrategia de reintento infinitaenqueue rechaza con un Error en menos de SCHEDULED_MESSAGE_ENQUEUE_TIMEOUT_MS + 5 sNinguno (conexión real fallida)

services/scheduled-message-sender.service.spec.ts​

ScheduledMessageSenderService › claim step​

TestEscenarioResultado esperadoMocks
returns silently (no-op) when the claim fails — already claimed or terminalclaimForSend devuelve nullsend resuelve a undefined; no consulta settings ni crea mensajeRepositorio, settings, tickets, usuarios, mensajes, socket, cola, router outbound

ScheduledMessageSenderService › feature disabled​

TestEscenarioResultado esperadoMocks
cancels with feature_disabled and emits the agent-only event, sends no messageSettings con enabled: falsemarkCancelled(FEATURE_DISABLED), sin mensaje y evento CANCELLED al autorLos mismos

ScheduledMessageSenderService › ticket deleted​

TestEscenarioResultado esperadoMocks
cancels with ticket_deleted when the ticket no longer existsfindByIdAndTenant devuelve nullmarkCancelled(TICKET_DELETED) y no se crea mensajeLos mismos

ScheduledMessageSenderService › ticket resolved (defense-in-depth re-check)​

TestEscenarioResultado esperadoMocks
cancels with ticket_resolved when the ticket is resolved and policy is cancelTicket RESOLVED, política onTicketResolved: cancelmarkCancelled(TICKET_RESOLVED) y no se crea mensajeLos mismos
sends normally when the ticket is resolved but policy is sendTicket RESOLVED, política sendNo se cancela y se crea el mensajeLos mismos

ScheduledMessageSenderService › owner inactive​

TestEscenarioResultado esperadoMocks
cancels with owner_inactive + agent-only thread notice when policy is cancelAutor inactivo, política cancelmarkCancelled(OWNER_INACTIVE), broadcastSystemMessage solo para agentes (true) y sin mensajeLos mismos
sends normally when the author is inactive but policy is sendAutor inactivo, política sendNo se cancela y se crea el mensajeLos mismos

ScheduledMessageSenderService › chat branch (6a)​

TestEscenarioResultado esperadoMocks
creates the message, updates last message, broadcasts agent message + ticket updated, marks sent, emits scheduled_message_sent — in that orderFila chat reclamadaCrea 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_sentLos mismos

ScheduledMessageSenderService › chat branch — switch de firma apagado​

TestEscenarioResultado esperadoMocks
no firma cuando el mensaje se programó con el switch apagadoFila con signature: falseEl mensaje se crea con el cuerpo sin firmaLos mismos

ScheduledMessageSenderService › note branch (6b)​

TestEscenarioResultado esperadoMocks
creates the note, broadcasts agent note only — no updateLastMessage, no ticket_updatedFila de tipo noteCrea la nota sin firma, difunde broadcastAgentNote; no actualiza último mensaje ni emite ticket_updatedLos mismos

ScheduledMessageSenderService › failure policy (B3.2)​

TestEscenarioResultado esperadoMocks
resets to pending and rethrows when attemptCount is below MAX_ATTEMPTS (retryable)attemptCount 1 y la creación falla con ECONNRESETsend rechaza, resetToPending(id, 'ECONNRESET') y no markFailedLos mismos
marks failed (no rethrow) and emits scheduled_message_failed once attemptCount reaches MAX_ATTEMPTSattemptCount = MAX_ATTEMPTS y falla con boomsend resuelve, markFailed(id, 'boom'), sin reset y evento FAILED con error: 'boom'Los mismos

ScheduledMessageSenderService › reconcile (reconciliation sweep, Mongo as source of truth)​

TestEscenarioResultado esperadoMocks
pass 1: sends every overdue pending row immediately, in-process (not via the queue)Una fila vencidaSe llama claimForSend(overdueId) y no se encolaLos mismos
pass 2: re-enqueues a not-yet-due row whose Redis timer was lost (Redis-flush insurance)Fila huérfana y hasJob es falseSe llama enqueue(id, scheduledAt)Los mismos
pass 2: leaves a not-yet-due row alone when the queue still has its jobhasJob es trueNo se encolaLos mismos
pass 3: resets a stale processing row (worker crashed mid-claim) back to pending and re-enqueuesFila processing obsoletaresetToPending(id, motivo) y enqueue(id, scheduledAt)Los mismos

services/scheduled-message.service.spec.ts​

ScheduledMessageService › create (CA1/CA2)​

TestEscenarioResultado esperadoMocks
rejects with 403 when the feature is disabled for the tenantscheduledMessages.enabled = falseForbiddenException y no se crea la filaScheduledMessageRepository, cola, TenantSettingsService, TicketRepository, socket, TranslateService
rejects a past scheduledAtscheduledAt en el pasadoRechaza y no creaLos mismos
rejects a scheduledAt under minLeadTimeMinutes1 min cuando el mínimo es 5Rechaza y no creaLos mismos
rejects a scheduledAt beyond maxHorizonDays40 días cuando el máximo es 30Rechaza y no creaLos mismos
rejects when the ticket already has maxPendingPerTicket pending itemscountPendingByTicket = 10Rechaza y no creaLos mismos
rejects when the agent already has maxPendingPerAgent pending itemscountPendingByAgent = 50Rechaza y no creaLos mismos
rejects a note when allowNotes is falsemessageType nota con allowNotes: falseRechaza y no creaLos mismos
rejects when the ticket does not belong to the tenantfindByIdAndTenant devuelve nullNotFoundException y no creaLos mismos
creates, enqueues the job, and broadcasts scheduled_message_created agent-only, on the happy pathDTO válidoCrea 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álidoEl payload es { item } con todos los campos del contrato (_id, tenantId, ..., updatedAt) y sin clave id sueltaLos mismos

ScheduledMessageService › update (CA5 edit)​

TestEscenarioResultado esperadoMocks
throws NotFoundException when the row is not pending or not owned by the callerupdatePending devuelve nullNotFoundExceptionLos mismos
reschedules the queue job when scheduledAt changesDTO con nuevo scheduledAtqueue.reschedule(ID, nuevaFecha)Los mismos
does NOT touch the queue when only the body changes (job carries only {id})DTO solo con bodyNo se llama rescheduleLos mismos
broadcasts scheduled_message_updated with the FULL ScheduledMessage contract the frontend expects (CRITICAL-2 fix: { item }, not a flat payload)Actualización de bodyDifunde UPDATED al autor con { item } completo y sin clave idLos mismos

ScheduledMessageService › cancel (CA5)​

TestEscenarioResultado esperadoMocks
throws NotFoundException when the row is not pending or not owned by the callercancelPending devuelve nullNotFoundException y no se toca la colaLos mismos
removes the queue job and broadcasts scheduled_message_cancelled with reason agentFila pendiente propiacancelPending(id, tenant, autor, AGENT), queue.cancel(ID) y evento CANCELLED con razón agentLos mismos

ScheduledMessageService › findMine / findByTicket (CA3, ownership scoped)​

TestEscenarioResultado esperadoMocks
findMine delegates to the repository scoped by tenant+authorfindMine(user, { page: 1, limit: 10 })findMineByAuthor(tenantId, autorId, 1, 10, undefined)Los mismos
findByTicket delegates to the repository scoped by tenant+ticket+authorfindByTicket(user, ticketId)findByTicketForAuthor(tenantId, ticketId, autorId)Los mismos