Casos de prueba: módulo Message (controladores y repositorios)
Documenta los tests de la capa HTTP y de persistencia del módulo message.
Specs fuente (src/app/modules/message/):
controllers/message.controller.spec.ts
controllers/message.controller.permissions.spec.ts
repositories/message.repository.spec.ts
repositories/message.repository.ack.integration.spec.ts
repositories/message.repository.media.integration.spec.ts
La lógica de servicios está en Message (servicios).
Resumen: 5 archivos de spec, 6 suites raíz, 117 tests it() (28 + 2 de controladores, 58 unitarios de repositorio y 27 + 2 de integración con MongoDB en memoria).
message.controller.spec.ts
Suite raíz: MessageController. El servicio se reemplaza por un mock; las respuestas siguen el sobre { message, data }.
create
| Test | Escenario | Resultado esperado | Mocks |
|---|
should return message and created message | Se crea un mensaje con un DTO | Llama a service.create(dto) y responde { message: 'message.created', data } | mockService.create |
findAll
| Test | Escenario | Resultado esperado | Mocks |
|---|
should return all messages when no ticketId filter | Sin ticketId | Usa findAll y no findAllByTicket | mockService.findAll |
should return paginated messages with meta when ticketId provided | Con ticketId, page y limit | Usa findAllByTicket, no findAll, y devuelve resultados con meta | mockService.findAllByTicket |
should use default page=1 and limit=10 | ticketId sin paginación | Se usan page 1 y limit 10 | mockService.findAllByTicket |
should cap limit to MAX_LIMIT (100) | Límite mayor a 100 | El límite se recorta a 100 | mockService.findAllByTicket |
should return empty results when no messages exist | Servicio devuelve [] | Respuesta con datos vacíos | mockService.findAll |
findOne
| Test | Escenario | Resultado esperado | Mocks |
|---|
should return message and found message | El mensaje existe | Llama a findOne(id, lang) y responde con el mensaje | mockService.findOne |
should propagate NotFoundException when message not found | El servicio rechaza con NotFoundException | La excepción se propaga | mockService.findOne |
update
| Test | Escenario | Resultado esperado | Mocks |
|---|
should return message and updated message | Actualización válida | Llama a update(id, dto, lang) y responde message.updated | mockService.update |
should propagate NotFoundException when message not found | El servicio rechaza | La excepción se propaga | mockService.update |
remove
| Test | Escenario | Resultado esperado | Mocks |
|---|
should return message and removed message | Eliminación válida | Responde message.deleted con el mensaje eliminado | mockService.remove |
should propagate NotFoundException when message not found | El servicio rechaza | La excepción se propaga | mockService.remove |
markAsRead
| Test | Escenario | Resultado esperado | Mocks |
|---|
should return translated message and updated document | Marcar un mensaje como leído | Responde message.markedRead con el documento actualizado | mockService.markAsRead |
should propagate NotFoundException when message not found | El servicio rechaza | La excepción se propaga | mockService.markAsRead |
markTicketMessagesAsRead
| Test | Escenario | Resultado esperado | Mocks |
|---|
should return translated message without data | Marcar todos los mensajes de un ticket | Responde solo { message: 'message.markedRead' } sin data | mockService.markTicketMessagesAsRead |
getWindow
| Test | Escenario | Resultado esperado | Mocks |
|---|
should delegate to the service with id, tenantId, radius and lang | Ventana alrededor de un mensaje | Llama a getMessageWindow con id, tenantId, radio e idioma | mockService.getMessageWindow |
should default radius to 15 when not provided | No se envía radio | El radio por defecto es 15 | mockService.getMessageWindow |
should return standard envelope with the window results | Ventana válida | Devuelve el sobre estándar con los resultados | mockService.getMessageWindow |
should propagate NotFoundException when the anchor message is not found | El ancla no existe | La excepción se propaga | mockService.getMessageWindow |
| Test | Escenario | Resultado esperado | Mocks |
|---|
should delegate to service with contactId, tenantId and filter | Tickets de un contacto | Delegación con contactId, tenantId y filtro | mockService.getTicketsByContact |
should return standard envelope with findAll message key | Resultado normal | Sobre con la clave message.findAll | mockService.getTicketsByContact |
should return empty results when contact has no tickets | Contacto sin tickets | Devuelve resultados vacíos en el sobre | mockService.getTicketsByContact |
| Test | Escenario | Resultado esperado | Mocks |
|---|
should delegate to service with contactId, tenantId and filter | Mensajes de un contacto | Delegación con contactId, tenantId y filtro | mockService.findAllByContact |
should return standard envelope with findAll message key | Resultado normal | Sobre con la clave message.findAll | mockService.findAllByContact |
should return empty results when contact has no messages | Contacto sin mensajes | Devuelve resultados vacíos | mockService.findAllByContact |
| Test | Escenario | Resultado esperado | Mocks |
|---|
should delegate to service with contactId, ticketId, tenantId, filter and lang | Mensajes de un ticket de un contacto | Delegación con los cinco argumentos | mockService.findAllByContactTicket |
should return standard envelope with findAll message key | Resultado normal | Sobre con la clave message.findAll | mockService.findAllByContactTicket |
should propagate NotFoundException when ticket does not belong to the contact | El ticket no pertenece al contacto | La excepción se propaga | mockService.findAllByContactTicket |
message.controller.permissions.spec.ts
Suite raíz: MessageController — read/write split permission guard integration. Levanta una app Nest con el guard de permisos real y usa supertest.
| Test | Escenario | Resultado esperado | Mocks |
|---|
a role with only MESSAGE_READ can read but cannot write | Rol con solo MESSAGE_READ | GET /v1/messages/get-all responde 200; PATCH /v1/messages/update/:id responde 403 y no llega al servicio | MessageService simulado, guard con permisos del rol |
a role with only MESSAGE_UPDATE can write but cannot read | Rol con solo MESSAGE_UPDATE | PATCH responde 200; GET /v1/messages/get-all responde 403 y no llega al servicio | MessageService simulado, guard con permisos del rol |
message.repository.spec.ts
Suite raíz: MessageRepository. Usa un modelo Mongoose simulado y valida las etapas de los pipelines de agregación.
create
| Test | Escenario | Resultado esperado | Mocks |
|---|
should cast tenantId and ticketId strings to ObjectId | Se crea con ids como texto | tenantId y ticketId se convierten a ObjectId | mockModel.create |
should cast optional sendUserId, parentId and MediaId to ObjectId when present | Se envían los ids opcionales | sendUserId, parentId y MediaId se convierten a ObjectId | mockModel.create |
should omit sendUserId, parentId and MediaId when not provided | No se envían los opcionales | Esos campos quedan undefined | mockModel.create |
should return the created document | Creación exitosa | Devuelve el documento creado | mockModel.create |
findAllByTicket
| Test | Escenario | Resultado esperado | Mocks |
|---|
should query using ObjectId for ticketId | Consulta por ticket | La etapa $match usa ObjectId | mockModel.aggregate |
should apply sort by createdAt desc, skip and limit for pagination | Página 2, límite 5 | $sort por createdAt descendente, $skip 5 y $limit 5 | mockModel.aggregate |
should return { results, total } | Facet con un resultado | Devuelve { results: [msg], total: 1 } | mockModel.aggregate |
should return empty results and total 0 when no messages found | Facet vacío | Devuelve { results: [], total: 0 } | mockModel.aggregate |
should NOT add a messageType exclusion match when excludeNotes option is omitted | Sin opción excludeNotes | No hay etapa que excluya notas | mockModel.aggregate |
should NOT add a messageType exclusion match when excludeNotes is explicitly false | excludeNotes: false | No hay etapa que excluya notas | mockModel.aggregate |
should add a $match excluding messageType note when excludeNotes is true | excludeNotes: true | Agrega $match con messageType: { $ne: 'note' } | mockModel.aggregate |
markAsRead
| Test | Escenario | Resultado esperado | Mocks |
|---|
should only advance messages still in sent or delivered | Marcar como leído | El filtro exige ack en sent o delivered con _id como ObjectId | mockModel.findOneAndUpdate |
should set ack read, readAt and backfill deliveredAt when it was never set | Actualización con pipeline | Fija ack en read, readAt y rellena deliveredAt si faltaba; usa la opción requerida por Mongoose para pipelines | mockModel.findOneAndUpdate |
should return the updated message | Hay mensaje avanzado | Devuelve el mensaje actualizado | mockModel.findOneAndUpdate |
should return null when nothing advanced | El mensaje ya estaba leído | Devuelve null | mockModel.findOneAndUpdate |
advanceTicketAck
| Test | Escenario | Resultado esperado | Mocks |
|---|
should scope the agent audience to contact messages, excluding notes and internals | Audiencia agent | Filtro por ticket, ack en sent/delivered, sendType contacto, sin internos ni notas | mockModel.find, updateMany |
should scope the contact audience to every backoffice-authored sendType | Audiencia contact | sendType en user, agent, system y ack solo sent | mockModel.find, updateMany |
should return the ids it actually advanced | Hay mensajes pendientes | Devuelve los ids avanzados y actualiza solo los estados previos | mockModel.find, updateMany |
should skip the update entirely when nothing is pending | No hay pendientes | Devuelve [] y no llama a updateMany | mockModel.find, updateMany |
should be a no-op for `sent`, which has no preceding state | Se pide avanzar a sent | Devuelve [] y no consulta con find | mockModel.find |
advanceMessagesAck
| Test | Escenario | Resultado esperado | Mocks |
|---|
should scope to the ids the client acknowledged, cast to ObjectId | Ids acusados por el cliente | El filtro usa los ids como ObjectId y el ticket | mockModel.find, updateMany |
should ignore ids that no longer sit in a preceding state | Los ids ya no están en un estado previo | Devuelve [] y no llama a updateMany | mockModel.find, updateMany |
should be a no-op on an empty id list | Lista de ids vacía | Devuelve [] y no consulta find | mockModel.find |
| Test | Escenario | Resultado esperado | Mocks |
|---|
should call aggregate and return results and total from facet | Consulta por contacto | Un solo aggregate; devuelve resultados y total | mockModel.aggregate |
should use tenantId as ObjectId in the initial $match stage | Primera etapa | tenantId como ObjectId | mockModel.aggregate |
should include a $lookup on the tickets collection | Pipeline de contacto | Incluye $lookup a tickets por _id | mockModel.aggregate |
should filter by ContactId as ObjectId after the ticket lookup | Tras el lookup | Filtra ticket.ContactId como ObjectId | mockModel.aggregate |
should include a $sort stage with createdAt -1 | Orden | $sort por createdAt descendente | mockModel.aggregate |
should end the pipeline with a $facet stage | Estructura final | La última etapa es $facet con data y total | mockModel.aggregate |
should re-sort ascending inside the facet data branch for correct chat display order | Rama de datos del facet | Reordena ascendente por createdAt para mostrar el chat | mockModel.aggregate |
should use $or with $toString in the contact sender lookup for robust ObjectId matching | Lookup del remitente contacto | Condición con $or de dos alternativas (ObjectId y $toString) | mockModel.aggregate |
should apply messageType filter to the initial $match | Filtro messageType: chat | Se aplica en el primer $match | mockModel.aggregate |
should apply sendType filter to the initial $match | Filtro sendType: contact | Se aplica en el primer $match | mockModel.aggregate |
should apply createdFrom as $gte date filter | Filtro createdFrom | createdAt.$gte es un Date con el valor dado | mockModel.aggregate |
should apply createdTo as $lte date filter | Filtro createdTo | createdAt.$lte es un Date | mockModel.aggregate |
should return { results: [], total: 0 } when aggregate yields no facet result | aggregate sin documento de facet | Devuelve { results: [], total: 0 } | mockModel.aggregate |
| Test | Escenario | Resultado esperado | Mocks |
|---|
should call aggregate and return results and total from facet | Consulta por contacto y ticket | Un solo aggregate; devuelve resultados y total | mockModel.aggregate |
should use ticketId and tenantId as ObjectIds in the initial $match | Primera etapa | ticketId y tenantId como ObjectId | mockModel.aggregate |
should include a $sort stage with createdAt -1 | Orden | $sort descendente por createdAt | mockModel.aggregate |
should end the pipeline with a $facet stage | Estructura final | La última etapa es $facet | mockModel.aggregate |
should re-sort ascending inside the facet data branch for correct chat display order | Rama de datos | Reordena ascendente | mockModel.aggregate |
should apply messageType filter to the initial $match | Filtro messageType: media | Se aplica en el primer $match | mockModel.aggregate |
should apply createdFrom and createdTo as date range | Rango de fechas | $gte y $lte son instancias de Date | mockModel.aggregate |
should return { results: [], total: 0 } when aggregate yields no facet result | Sin documento de facet | { results: [], total: 0 } | mockModel.aggregate |
should apply correct skip for page 2 | Página 2 | $skip es 10 | mockModel.aggregate |
findWindow
| Test | Escenario | Resultado esperado | Mocks |
|---|
scopes the pipeline match stage by ticketId as ObjectId | Ventana por ticket | $match con ticketId como ObjectId | mockModel.aggregate |
ends with a $facet containing before/anchor/after branches | Estructura final | $facet con ramas before, anchor y after | mockModel.aggregate |
limits the before/after branches to the given radius | Radio 7 | $limit 7 en ambas ramas | mockModel.aggregate |
sorts the before branch descending (closest-to-anchor first) and the after branch ascending | Orden de ramas | before descendente y after ascendente | mockModel.aggregate |
returns before (reversed) + anchor + after in ascending chronological order | Facet con anteriores, ancla y posteriores | Devuelve [before2, before1, ancla, after1] (invierte before) | mockModel.aggregate |
returns just the anchor when there are no messages before or after | Solo hay ancla | Devuelve [ancla] | mockModel.aggregate |
returns an empty array when the facet yields no document | Facet sin documento | Devuelve [] | mockModel.aggregate |
resolves signed media urls for the combined window results | Mensajes con media en la ventana | Reemplaza la URL por una firmada con getSignedUrl | mockModel.aggregate, StorageService.getSignedUrl |
El bucket es privado: la URL persistida no sirve, por lo que toda lectura de historial debe firmar la URL desde storageKey.
| Test | Escenario | Resultado esperado | Mocks |
|---|
should include storageKey in the file lookup projection so it can be signed later | Pipeline de historial | El $project del lookup de archivos incluye storageKey: 1 | mockModel.aggregate |
findAllByTicket should replace the persisted media url with a freshly signed one | Mensaje con media | La URL se reemplaza por la firmada y storageKey no se expone | aggregate, getSignedUrl |
findAllByContact should replace the persisted media url with a freshly signed one | Igual, por contacto | URL firmada en el resultado | aggregate, getSignedUrl |
findAllByContactTicket should replace the persisted media url with a freshly signed one | Igual, por contacto y ticket | URL firmada en el resultado | aggregate, getSignedUrl |
should not call getSignedUrl for messages without media | Mensajes sin media | No se llama a getSignedUrl | aggregate, getSignedUrl |
message.repository.ack.integration.spec.ts
Suite raíz: MessageRepository — ack transitions (integration). Usa mongodb-memory-server con MongoDB real en memoria para validar transiciones de acuse (sent a delivered a read, y failed).
advanceTicketAck → read
| Test | Escenario | Resultado esperado | Mocks |
|---|
moves the backoffice side to read and stamps readAt (contact audience) | Mensaje de usuario en delivered, audiencia contacto | Devuelve su id; queda ack read con readAt | MongoDB en memoria |
backfills deliveredAt when the read lands before any delivered ack | Lectura sin delivered previo | Rellena deliveredAt y fija readAt | MongoDB en memoria |
preserves the original deliveredAt instead of overwriting it | Ya existe deliveredAt | Se conserva el valor original | MongoDB en memoria |
never advances the ack of the audience own messages | Mensajes de la propia audiencia | Devuelve [] y el ack sigue en sent | MongoDB en memoria |
is monotonic: a delivered arriving late cannot degrade a read message | Llega delivered sobre un mensaje read | Devuelve [] y el ack sigue en read | MongoDB en memoria |
advanceMessagesAck → read
| Test | Escenario | Resultado esperado | Mocks |
|---|
advances only the acked ids | Dos mensajes, se acusa solo uno | Solo avanza el acusado; el otro conserva su ack | MongoDB en memoria |
advanceAckByExternalMessageId
| Test | Escenario | Resultado esperado | Mocks |
|---|
advances delivered and stamps deliveredAt | Acuse delivered por id externo | Ack delivered con deliveredAt y devuelve el resultado | MongoDB en memoria |
rellena deliveredAt al saltar directo a read | Salta directo a read | Ack read con readAt y deliveredAt rellenado | MongoDB en memoria |
does not downgrade a message already read | Mensaje ya leído recibe delivered | Devuelve null y sigue en read | MongoDB en memoria |
is a no-op for sent — there is nothing before it | Se pide sent | No modifica nada | MongoDB en memoria |
never crosses tenants | Id externo de otro tenant | No modifica el mensaje | MongoDB en memoria |
advanceAckUpTo (Instagram read receipts)
| Test | Escenario | Resultado esperado | Mocks |
|---|
marks the read message and every outbound one before it | Recibo de lectura sobre un mensaje intermedio | Marca ese y los salientes anteriores; el más nuevo sigue en sent | MongoDB en memoria |
never touches what the contact wrote | Hay mensajes del contacto anteriores | No se modifican | MongoDB en memoria |
skips internal traffic and notes | Mensajes internos y notas | Devuelve [] y quedan en sent | MongoDB en memoria |
is idempotent: a repeated receipt advances nothing | Mismo recibo repetido | La segunda vez no avanza nada | MongoDB en memoria |
conversation history
| Test | Escenario | Resultado esperado | Mocks |
|---|
returns the failure reason of a rejected message but never the raw provider detail | Mensaje failed con motivo y detalle | El historial incluye failureReason pero no failureDetail | MongoDB en memoria |
markAsRead
| Test | Escenario | Resultado esperado | Mocks |
|---|
returns the updated document with ack read | Marcar un mensaje como leído | Devuelve el documento con ack read y readAt | MongoDB en memoria |
markFailed
| Test | Escenario | Resultado esperado | Mocks |
|---|
moves a sent message to failed with its reason and raw detail | Mensaje sent rechazado fuera de ventana | Queda failed con failureReason y failureDetail | MongoDB en memoria |
never stamps deliveredAt — the message was rejected, not delivered | Se marca fallo | deliveredAt queda sin definir | MongoDB en memoria |
falls back to unknown so a failed bubble is never left unexplained | Fallo sin motivo | failureReason es UNKNOWN | MongoDB en memoria |
does not downgrade a delivered message | Mensaje ya delivered | Devuelve null y se mantiene delivered | MongoDB en memoria |
does not downgrade a read message | Mensaje ya read | Devuelve null y se mantiene read | MongoDB en memoria |
markFailedByExternalMessageId
| Test | Escenario | Resultado esperado | Mocks |
|---|
fails the message matching tenant + external id | Id externo y tenant coinciden | El mensaje queda failed con el motivo | MongoDB en memoria |
is scoped by tenant | Tenant distinto | No modifica ningún mensaje | MongoDB en memoria |
the generic ack advances refuse failed
Un mensaje failed tiene precedente (sent), por lo que los avances genéricos deben rechazarlo explícitamente.
| Test | Escenario | Resultado esperado | Mocks |
|---|
advanceAckByExternalMessageId returns null | Avance genérico sobre un failed | Devuelve null | MongoDB en memoria |
advanceTicketAck returns an empty list and touches nothing | Avance por ticket | Devuelve [] y el mensaje no cambia | MongoDB en memoria |
advanceMessagesAck returns an empty list and touches nothing | Avance por ids | Devuelve [] y el mensaje no cambia | MongoDB en memoria |
Suite raíz: MessageRepository — media projection (integration). MongoDB en memoria.
| Test | Escenario | Resultado esperado | Mocks |
|---|
returns duration and waveform so the reloaded history keeps the real audio shape | Audio con duración y forma de onda guardadas | El historial devuelve durationSeconds 6 y waveform de 64 elementos | MongoDB en memoria, StorageService |
returns an empty waveform for an audio stored before they were computed | Audio antiguo sin onda | Devuelve filename y waveform vacío | MongoDB en memoria, StorageService |