Casos de prueba — Chat interno
Casos de prueba unitarios del módulo src/app/modules/internal-chat (conversaciones directas y grupales entre agentes). Fuentes:
controllers/internal-conversation.controller.spec.tsrepositories/internal-message.repository.spec.tsservices/conversation.service.spec.tsservices/internal-message.service.spec.ts
Resumen: 4 archivos spec, 22 bloques describe (incluidos los anidados), 66 tests (it): 4 en el controlador, 9 en el repositorio, 31 en ConversationService y 22 en InternalMessageService. Todos usan mocks de repositorios y de InternalChatBroadcastService; ninguno toca una base de datos real.
internal-conversation.controller.spec.ts
Los guards AuthGuard y PermissionsGuard se sobrescriben para dejar pasar siempre.
getMessageWindow
| Test | Escenario | Resultado esperado | Mocks |
|---|---|---|---|
should delegate to the service with tenantId, userId, conversationId, messageId, radius and lang | Se invoca con radius: 20 | Llama getMessageWindow(tenantId, userId, conversationId, messageId, 20, lang) | InternalMessageService.getMessageWindow |
should default radius to 15 when not provided | Query sin radius | El servicio recibe 15 | InternalMessageService.getMessageWindow |
should return standard envelope with the window results | El servicio devuelve { results: [], anchorId } | Responde { message: 'internalChat.findAll', data: windowResult } | InternalMessageService, TranslateService |
should propagate NotFoundException when the anchor message or membership is not found | El servicio rechaza con NotFoundException | El controlador propaga NotFoundException | InternalMessageService rechazado |
internal-message.repository.spec.ts
countUnread
| Test | Escenario | Resultado esperado | Mocks |
|---|---|---|---|
excludes system messages from the count query for every participant, including the actor | countUnread(conversationId, tenantId, since, userId) | El filtro incluye contentType: { $ne: SYSTEM } | model.countDocuments |
still excludes the excludeSenderId from the count query (existing behavior preserved) | Misma llamada | El filtro mantiene senderId: { $ne: userId }, conversationId, tenantId y createdAt: { $gt: since } | model.countDocuments |
findById
| Test | Escenario | Resultado esperado | Mocks |
|---|---|---|---|
delegates to the model and returns the document | findById('some-id') con documento existente | Llama model.findById y devuelve el documento | model.findById |
returns null when not found | El modelo devuelve null | null | model.findById |
findWindow
| Test | Escenario | Resultado esperado | Mocks |
|---|---|---|---|
queries before/after messages scoped by conversationId and returns before(reversed)+anchor+after | Hay un mensaje anterior, el ancla y uno posterior | Devuelve [before, anchor, after] (los anteriores en orden cronológico) | model.find, sort, limit, findById |
scopes both before/after queries by conversationId | Ejecución normal | Las dos consultas filtran por conversationId | model.find |
limits both before/after queries to the given radius | Radio 7 | limit(7) | limit |
returns just the anchor when there are no messages before or after | Sin mensajes alrededor | Devuelve solo el ancla | model.find, findById |
create
| Test | Escenario | Resultado esperado | Mocks |
|---|---|---|---|
persists a system message with no senderId and a systemEvent subdoc | Datos con contentType: SYSTEM, senderId: undefined y systemEvent member_added | model.create recibe senderId indefinido, el tipo SYSTEM y el subdocumento systemEvent íntegro | model.create |
conversation.service.spec.ts
Mocks comunes: ConversationRepository, ConversationMemberRepository, InternalMessageRepository, UserRepository, DepartmentUserRepository, FileRepository, StorageService, InternalChatBroadcastService e InternalMessageService. Por defecto el roster de miembros es [], la validación de tenant devuelve un usuario y createSystemMessage resuelve.
createDirect
| Test | Escenario | Resultado esperado | Mocks |
|---|---|---|---|
returns the existing conversation when a direct conversation for the pair already exists | findDirectByKey devuelve una conversación | Devuelve la existente sin invocar create | conversationRepository |
computes the same directKey regardless of argument order (idempotent pairing) | Se crea con (A, B) y luego con (B, A) | El directKey es idéntico en ambas creaciones | conversationRepository, memberRepository.insertMany |
recovers the winning conversation on a duplicate key race (Mongo error 11000) | create rechaza con code: 11000 | Recupera y devuelve la conversación ganadora de la carrera | conversationRepository.findDirectByKey (2 llamadas) |
throws NotFoundException without creating anything when targetUserId does not belong to the tenant | findManyByIdsAndTenant devuelve [] | NotFoundException y no se crea conversación | userRepository, conversationRepository |
createGroup
| Test | Escenario | Resultado esperado | Mocks |
|---|---|---|---|
throws ForbiddenException when the requesting user is not a tenant admin | Solicitante con userType: 'agent' | ForbiddenException sin crear | conversationRepository |
creates the group and adds the creator as a group admin when the requesting user is a tenant admin | Solicitante admin con un memberId válido | Entre los miembros insertados el creador tiene role: 'admin' | conversationRepository.create, memberRepository.insertMany |
throws NotFoundException and creates nothing when a memberId does not belong to the tenant | findManyByIdsAndTenant devuelve [] | NotFoundException; no se llama create ni insertMany | userRepository |
updateGroup (assertCanManageGroup)
| Test | Escenario | Resultado esperado | Mocks |
|---|---|---|---|
allows a tenant admin to manage a group even without a group-admin membership role | Admin del tenant con rol member en el grupo | La actualización se resuelve | conversationRepository.updateGroup, memberRepository.findOne |
allows a group admin to manage the group even without a tenant-admin userType | Agente con rol admin en el grupo | La actualización se resuelve | Igual al anterior |
throws ForbiddenException for a plain member who is neither a tenant admin nor a group admin | Agente con rol member | ForbiddenException y no se llama updateGroup | memberRepository.findOne |
addMembers
| Test | Escenario | Resultado esperado | Mocks |
|---|---|---|---|
throws NotFoundException and adds nobody when a userId does not belong to the tenant | Se piden 2 ids pero el tenant solo resuelve 1 | NotFoundException y no se llama addMember | userRepository.findManyByIdsAndTenant |
adds the members and broadcasts memberAdded once tenant membership is validated | Un usuario válido | addMember una vez con el id correcto y broadcastMemberAdded(conversationId, miembros, usuarioAgregado) | memberRepository, broadcastService |
persists ONE member_added system message grouping all added userIds | Se agregan dos usuarios | createSystemMessage una sola vez con MEMBER_ADDED, actorId y targetIds agrupados, y el idioma | internalMessageService.createSystemMessage |
removeMember
| Test | Escenario | Resultado esperado | Mocks |
|---|---|---|---|
throws BadRequestException when a group-admin requester tries to remove themself as the only remaining admin | Admin de grupo no admin de tenant intenta removerse siendo el único admin | BadRequestException y no se llama removeMember | memberRepository |
allows a tenant admin to remove the group's only admin | Admin de tenant remueve al único admin del grupo | Devuelve el miembro removido y emite broadcastMemberRemoved | memberRepository, broadcastService |
persists a member_removed system message with the admin as actorId and the removed member as targetIds | Admin de tenant remueve a un miembro | createSystemMessage con MEMBER_REMOVED, actorId del solicitante y targetIds del removido | internalMessageService |
allows removing a member when at least one other admin remains | Quedan otros administradores | Se ejecuta removeMember | memberRepository |
leave
| Test | Escenario | Resultado esperado | Mocks |
|---|---|---|---|
throws BadRequestException when leaving a direct conversation | Conversación de tipo direct | BadRequestException sin remover | conversationRepository, memberRepository |
auto-promotes the oldest remaining member to admin when the leaving member was the group's only admin | Sale el único admin; quedan dos miembros | updateRole(conversationId, miembroMásAntiguo, tenantId, 'admin') | memberRepository.updateRole |
does not promote anyone when another admin remains after leaving | Queda otro admin | No se llama updateRole | memberRepository |
persists a left system message with the leaving member as both actorId and targetIds[0] | Un miembro sale del grupo | createSystemMessage con LEFT, actorId y targetIds iguales al que sale | internalMessageService |
system message resilience
| Test | Escenario | Resultado esperado | Mocks |
|---|---|---|---|
addMembers still adds the members and broadcasts even when createSystemMessage rejects | createSystemMessage rechaza (db write error) | addMembers responde y emite broadcastMemberAdded (el error del mensaje de sistema es fire-and-forget) | createSystemMessage rechazado |
removeMember still removes the member and broadcasts even when createSystemMessage rejects | Igual en removeMember | Devuelve el miembro removido y emite broadcastMemberRemoved | createSystemMessage rechazado |
leave still removes the member and broadcasts even when createSystemMessage rejects | Igual en leave | Devuelve el miembro que sale y emite broadcastMemberRemoved | createSystemMessage rechazado |
listAgents
| Test | Escenario | Resultado esperado | Mocks |
|---|---|---|---|
never leaks password (or roleId/signature/externalId/identifier) on returned agent items | El repositorio devuelve un agente con campos sensibles | El resultado solo contiene campos públicos más isOnline: false; sin password, roleId, signature, externalId ni identifier | userRepository.findAgentsAndAdmins |
tags isOnline true for every agent present in onlineUserIds, even when onlineOnly is not set | Dos agentes y uno en línea | Devuelve ambos; el conectado con isOnline: true y el otro false | userRepository |
narrows results to only online agents when onlineOnly is true | Igual con onlineOnly: true | Solo el agente en línea | userRepository |
returns every matching agent (isOnline: false) when onlineUserIds is empty | Lista de en línea vacía | Devuelve todos con isOnline: false | userRepository |
findMembers
| Test | Escenario | Resultado esperado | Mocks |
|---|---|---|---|
throws NotFoundException when the conversation does not exist | findByIdAndTenant devuelve null | NotFoundException | conversationRepository |
throws the same NotFoundException for a non-member as for a nonexistent conversation | El solicitante no es miembro | NotFoundException y no se lista el roster | memberRepository.findOne |
returns the enriched member list (with isOnline) for an actual member | Solicitante miembro, un miembro con datos de usuario y en línea | Devuelve la lista enriquecida (userId, username, nombre, role, lastReadAt, joinedAt, isOnline: true) sin password | memberRepository, userRepository, broadcastService.getOnlineAgentIds |
internal-message.service.spec.ts
El TranslateService mock reproduce las plantillas reales ({{actor}} agregó a {{targets}}, etc.) para validar el contenido interpolado.
sendMessage
| Test | Escenario | Resultado esperado | Mocks |
|---|---|---|---|
throws NotFoundException and persists nothing when fileId does not belong to the tenant | findByIdAndTenant devuelve null | NotFoundException, se consultó con (fileId, tenantId) y no se llama create | fileRepository, messageRepository |
persists the message when fileId belongs to the tenant | Archivo válido del tenant | Se llama create | fileRepository, messageRepository.create |
never checks the file repository when no fileId is provided | DTO solo con content | No se consulta findByIdAndTenant | fileRepository |
throws BadRequestException when neither content nor fileId is provided | DTO vacío | BadRequestException sin persistir | messageRepository |
mentions
| Test | Escenario | Resultado esperado | Mocks |
|---|---|---|---|
persists mentions as ObjectIds when the dto provides them | DTO con mentions en texto | create recibe mentions como ObjectId | messageRepository.create |
persists undefined mentions when the dto omits them | DTO sin mentions | mentions es undefined | messageRepository.create |
includes mentions (as string ids) in the im:new-message broadcast payload | DTO con menciones | broadcastNewMessage recibe mentions como ids de texto | broadcastService.broadcastNewMessage |
createSystemMessage
| Test | Escenario | Resultado esperado | Mocks |
|---|---|---|---|
persists a SYSTEM message with no senderId and the systemEvent subdoc | Evento MEMBER_ADDED con dos destinos | create con senderId indefinido, contentType SYSTEM y systemEvent completo | messageRepository.create |
resolves actor/target names BEFORE translating, and interpolates them into the content | Actor y destino resueltos | translate.get con { actor: 'Ana Gomez', targets: 'Luis Perez' } y contenido Ana Gomez agregó a Luis Perez | userRepository, translate.get |
interpolates real actor and target names into the persisted + broadcast content (grouped add, 2 targets) | Agregado de 2 destinos | Contenido Ana Gomez agregó a Luis Perez y Eva Diaz en el mensaje, en updateLastMessage (lastMessagePreview) y en el broadcast a todos los miembros | userRepository, conversationRepository, broadcastService |
joins 3+ resolved target names with the localized connector (Oxford-comma style) | Tres destinos | Ana Gomez agregó a Luis Perez, Eva Diaz y Marco Ruiz | userRepository |
falls back to a generic actor name when the actor cannot be resolved (deleted/outside-tenant user) | El actor no se resuelve | Usa Alguien como actor | userRepository |
falls back to a generic target name when no target can be resolved | El destino no se resuelve (MEMBER_REMOVED) | Ana Gomez removió a Alguien | userRepository |
triangulates content translation for member_removed and left event types | Eventos MEMBER_REMOVED y LEFT | Usa la clave internalChat.systemMemberRemoved y el contenido Ana Gomez se fue del grupo | userRepository, translate.get |
updates the conversation lastMessage with the actor as lastMessageSenderId | Evento MEMBER_ADDED | updateLastMessage con lastMessageSenderId igual al actor | conversationRepository.updateLastMessage |
broadcasts im:new-message with systemEvent + resolved actorName/targetNames, and im:conversation-updated with system:true | Agregado de 2 destinos | broadcastNewMessage con systemEvent (ids en texto), actorName y targetNames; broadcastConversationUpdated con system: true | broadcastService |
resolves actor/target names exactly once — no duplicate findManyByIdsAndTenant query between content-building and broadcast | Evento con broadcast | findManyByIdsAndTenant se invoca una sola vez | userRepository |
getMessageWindow
| Test | Escenario | Resultado esperado | Mocks |
|---|---|---|---|
throws NotFoundException when the caller is not a conversation member | findOne devuelve null | NotFoundException sin llamar findWindow | memberRepository |
throws NotFoundException when the anchor message does not exist | findById devuelve null | NotFoundException sin llamar findWindow | messageRepository.findById |
throws NotFoundException when the anchor message belongs to a different conversation | El ancla pertenece a otra conversación | NotFoundException sin llamar findWindow | messageRepository.findById |
throws NotFoundException when the anchor message belongs to a different tenant | El ancla pertenece a otro tenant | NotFoundException sin llamar findWindow | messageRepository.findById |
delegates to the repository with the anchor createdAt and radius, and returns hydrated results + anchorId | Ancla válida y un mensaje en la ventana | findWindow(anchorId, conversationId, createdAt, 15); devuelve anchorId y resultados con sender hidratado | messageRepository.findWindow, userRepository |