Whaticket: componentes del chat
Casos de prueba unitarios (Karma + Jasmine) de los componentes del chat de src/app/features/whaticket, más los dos specs de rutas. Se documentan 14 specs. El spec de chat-list.component.spec.ts se documenta en otra página. Ningún spec usa xit, fit, xdescribe ni fdescribe, y ninguno está vacío.
src/app/features/whaticket/whaticket-routing.module.spec.ts
- SUT: constante
routesdewhaticket-routing.module.ts. - Mocking: sin
TestBed; se inspecciona el arreglo de rutas importado y se compara conpermissionGuard.
WhaticketRoutingModule routes (permission wiring)
| it() | Escenario | Resultado esperado | Mocks/spies |
|---|---|---|---|
should gate the lazy "chat" child route on TICKET_READ via permissionGuard | Dado el arreglo routes, cuando se busca la ruta con path === 'chat', entonces se revisa su configuración. | data es igual a { permission: 'TICKET_READ' } y canActivate contiene permissionGuard. | Ninguno |
Tests en el archivo: 1.
src/app/features/whaticket/chat/chat-routing.module.spec.ts
- SUT: constante
routesdechat-routing.module.ts. - Mocking: sin
TestBed; inspección directa de las rutas.
ChatRoutingModule routes (permission wiring)
| it() | Escenario | Resultado esperado | Mocks/spies |
|---|---|---|---|
should gate the chat page route on TICKET_READ via permissionGuard | Dado routes, cuando se busca la ruta raíz (path === ''), entonces se revisa su configuración. | data es { permission: 'TICKET_READ' } y canActivate contiene permissionGuard. | Ninguno |
Tests en el archivo: 1.
src/app/features/whaticket/components/chat/chat-conversation/chat-conversation.component.spec.ts
- SUT:
ChatConversationComponent(conversación de un ticket: composer, respuestas, mensajes programados, favoritos, adjuntos, cabecera). - Mocking:
TestBedcondeclarations: [ChatConversationComponent, ShowWithPermissionDirective],FormsModule,TranslateModule.forRootconTranslateFakeLoaderyNO_ERRORS_SCHEMA. Todos los servicios se reemplazan conjasmine.createSpyObj(ChatSocketServicecon propiedades observablesnewMessage$,reconnected$,connectionStatus$, etc.;MessageService,FileApiService,StarredMessageService,ChatStateManagerService,Store,ScheduleMessageDialogService,MyPermissionsService...). ElStore.selectdevuelve unBehaviorSubjectparaselectScheduledMessagesEnabledyof([])para el resto.component.userId = 'ticket-1'se fija en elbeforeEach.
ChatConversationComponent (raíz)
| it() | Escenario | Resultado esperado | Mocks/spies |
|---|---|---|---|
should create | Dado el componente instanciado por TestBed, cuando no se hace nada más. | El componente existe. | Todos los servicios espiados |
startReply
| it() | Escenario | Resultado esperado | Mocks/spies |
|---|---|---|---|
sets replyingTo to the given message | Dado replyingTo nulo, cuando se llama startReply(mockReplyTarget). | replyingTo pasa a ser el mensaje dado. | Ninguno |
cancelReply
| it() | Escenario | Resultado esperado | Mocks/spies |
|---|---|---|---|
clears replyingTo without touching newMessage | Dado un texto escrito en el composer y una respuesta iniciada, cuando se llama cancelReply(). | replyingTo es null y newMessage conserva el texto. | Ninguno |
sendMessage
| it() | Escenario | Resultado esperado | Mocks/spies |
|---|---|---|---|
sends without parentId when not replying | Dado newMessage = 'hola' sin respuesta activa, cuando se llama sendMessage(). | chatSocket.sendMessage recibe ticketId, content y signature: true (sin parentId); replyingTo queda null. | chatSocket.sendMessage |
includes parentId when replying and clears replyingTo afterward | Dado un mensaje escrito y startReply(mockReplyTarget), cuando se envía. | sendMessage incluye parentId: 'msg-001' y replyingTo se limpia. | chatSocket.sendMessage |
does nothing when the message is empty | Dado newMessage = ' ' (solo espacios), cuando se envía. | sendMessage no se invoca. | chatSocket.sendMessage |
scheduledMessagesEnabled$ (verify CRITICAL-1)
| it() | Escenario | Resultado esperado | Mocks/spies |
|---|---|---|---|
reflects selectScheduledMessagesEnabled from the store | Dado el BehaviorSubject del selector, cuando emite false y luego true. | scheduledMessagesEnabled$ entrega false y luego true. | store.select con BehaviorSubject |
rehydration on chat open (verify CRITICAL-3)
| it() | Escenario | Resultado esperado | Mocks/spies |
|---|---|---|---|
dispatches ScheduledMessageActions.loadByTicket() on ngOnInit | Dado userId = 'ticket-1', cuando se ejecuta detectChanges() (ngOnInit). | store.dispatch recibe loadByTicket({ ticketId: 'ticket-1' }). | store.dispatch |
dispatches loadByTicket again with the new ticketId when userId changes | Dado el componente inicializado y dispatch reiniciado, cuando userId cambia a 'ticket-2' y se llama ngOnChanges. | Se vuelve a despachar loadByTicket({ ticketId: 'ticket-2' }). | store.dispatch (calls.reset()) |
reconnect rejoin
| it() | Escenario | Resultado esperado | Mocks/spies |
|---|---|---|---|
rejoins the ticket room on every authenticated transition, not just the first | Dado el componente inicializado, cuando connectionStatus$ emite connecting, authenticated, idle y de nuevo authenticated. | joinTicket no se llama con connecting; se llama con 'ticket-1' en el primer authenticated (1 vez) y en el segundo (2 veces). | connectionStatus$ (Subject), chatSocket.joinTicket |
keeps the composer text when the socket is down instead of dropping it | Dado el socket en estado idle y un texto en el composer, cuando se llama sendMessage(). | No se envía, newMessage conserva el texto e isConnectionLive es false. | connectionStatus$, chatSocket.sendMessage |
sends again once the socket is authenticated | Dado el socket authenticated y un texto escrito, cuando se envía. | sendMessage se invoca y newMessage queda vacío. | connectionStatus$, chatSocket.sendMessage |
refetches the thread on reconnect, not only on the first load | Dado un stateManager que permite cargar, getContactMessages devolviendo página vacía y una conversación con otherParticipantId, cuando reconnected$ emite. | getContactMessages se llama una vez más que en la carga inicial. | reconnected$, messageService.getContactMessages, stateManagerSpy (canLoadMore, startLoading, finishLoading) |
keeps the draft the agent was typing when the socket reconnects | Mismo montaje, con newMessage = 'a medio escribir', cuando reconnected$ emite. | newMessage sigue siendo 'a medio escribir'. | reconnected$, messageService.getContactMessages, stateManagerSpy |
composer schedule button DOM visibility (verify W19)
| it() | Escenario | Resultado esperado | Mocks/spies |
|---|---|---|---|
renders app-schedule-send-button when scheduledMessagesEnabled$ is true | Dado el flag en true, cuando se renderiza. | Existe un elemento app-schedule-send-button. | scheduledMessagesEnabled$ |
hides app-schedule-send-button from the DOM when scheduledMessagesEnabled$ is false | Dado el flag en false, cuando se renderiza. | No existe app-schedule-send-button. | scheduledMessagesEnabled$ |
onScheduleSend
| it() | Escenario | Resultado esperado | Mocks/spies |
|---|---|---|---|
does nothing when the message is empty (same guard as sendMessage) | Dado newMessage = ' ', cuando se llama onScheduleSend(). | No se abre el diálogo ni se envía el mensaje. | scheduleDialog.open, chatSocket.sendMessage |
opens the dialog and, on confirm, dispatches create — never calling sendMessage | Dado un texto y un diálogo que resuelve con { body editado, scheduledAt }, cuando se llama y se hace tick() (fakeAsync). | Se abre el diálogo; se despacha ScheduledMessageActions.create con messageType: 'chat' y signature: true; sendMessage no se llama; newMessage queda vacío. | scheduleDialog.open (promesa), store.dispatch, chatSocket.sendMessage |
dispatches create with messageType "note" when isNoteMode is on | Dado isNoteMode = true y un diálogo que confirma. | create con messageType: 'note' y signature: false; isNoteMode vuelve a false. | scheduleDialog.open, store.dispatch |
includes parentId when replying | Dado una respuesta iniciada y un diálogo que confirma. | create incluye parentId: 'msg-001'; replyingTo queda null. | scheduleDialog.open, store.dispatch |
does not dispatch when the dialog is dismissed | Dado un diálogo que resuelve null (cancelado). | No se despacha nada y newMessage conserva 'hola'. | scheduleDialog.open (null), store.dispatch |
ticketMessageToChat (private mapper)
| it() | Escenario | Resultado esperado | Mocks/spies |
|---|---|---|---|
carries parentId and parent through onto the resulting ChatMessage | Dado un TicketMessage con parentId y parent, cuando se invoca el mapper privado ticketMessageToChat. | El resultado conserva parentId: 'msg-001' y el objeto parent completo. | Ninguno (acceso vía as any) |
defaults parentId/parent to null when the source has none | Dado un TicketMessage sin parentId ni parent. | parentId y parent son null. | Ninguno |
media download affordance
| it() | Escenario | Resultado esperado | Mocks/spies |
|---|---|---|---|
renders getMediaType() as "image" and "audio" for the respective mock messages | Dado un mensaje media de imagen y uno de audio. | getMediaType devuelve 'image' y 'audio'. | Ninguno |
renders the image preview without a download badge over it | Dado un mensaje de imagen con url, cuando se renderiza (_messages$.next). | Existe el enlace .msg-media-download--image con atributo download y href igual a la url; la vista previa .msg-media-image tiene src igual a la url; no existe .msg-media-download. | _messages$ (acceso privado) |
renders the video preview without inline controls | Dado un mensaje de video. | Existe .msg-media-video sin atributo controls y con .msg-media-video__play. | _messages$ |
renders the custom audio player for an audio message | Dado un mensaje de audio. | Se renderiza app-audio-player y no hay audio[controls]. | _messages$ |
does not render a download link for image/audio messages still pending an uploaded url | Dado un mensaje media con media: null (subida pendiente). | No existe .msg-media-download. | _messages$ |
seedFavoriteMessages (ngOnInit)
| it() | Escenario | Resultado esperado | Mocks/spies |
|---|---|---|---|
seeds favoriteMessageIds from getIdsByScope, scoped by contactId | Dado getIdsByScope devolviendo ['msg-a','msg-b'] y otherParticipantId = 'contact-1', cuando se inicializa. | Se llama con { contactId: 'contact-1' } y favoriteMessageIds es el Set de ambos ids. | starredMessageService.getIdsByScope |
does not call getIdsByScope when there is no contactId yet | Dado conversation = null. | getIdsByScope no se llama. | starredMessageService.getIdsByScope |
resets favoriteMessageIds to an empty set when the seed request fails | Dado getIdsByScope que lanza error. | favoriteMessageIds.size es 0. | getIdsByScope con throwError |
onMessageToggleFavorite
| it() | Escenario | Resultado esperado | Mocks/spies |
|---|---|---|---|
optimistically adds and calls toggle(messageId, "contact") | Dado un mensaje no favorito. | El id se agrega de inmediato y se llama toggle('msg-1', 'contact'). | starredMessageService.toggle |
optimistically removes when the message was already favorited | Dado un mensaje ya favorito. | El id se elimina de favoriteMessageIds. | starredMessageService.toggle |
rolls back the optimistic add and shows an error toast when toggle() fails | Dado toggle que falla. | Se revierte la adición y se llama showNotification con type: 'error'. | toggle con throwError, ToastrNotificationService.showNotification |
rolls back the optimistic removal when toggle() fails on an already-favorited message | Dado un favorito existente y toggle que falla. | El id vuelve a estar en favoriteMessageIds. | toggle con throwError |
jumpToMessage
| it() | Escenario | Resultado esperado | Mocks/spies |
|---|---|---|---|
scrolls to and highlights the message directly when it is already loaded | Dado el mensaje ya cargado en _messages$ (fakeAsync), cuando se llama jumpToMessage('msg-loaded'). | No se llama fetchMessageWindow; highlightedMessageId es el id; el elemento tiene la clase msg-highlighted; tras 2000 ms el resaltado vuelve a null. | starredMessageService.fetchMessageWindow, _messages$ |
fetches the message window and merges it into history when the message is not loaded | Dado _messages$ vacío y fetchMessageWindow devolviendo el mensaje. | Se llama fetchMessageWindow('msg-window', 'contact'), el mensaje se incorpora al historial y queda resaltado. | fetchMessageWindow con of(...) |
fails soft (no throw, no highlight) when the window fetch errors out | Dado fetchMessageWindow que falla. | No lanza excepción y highlightedMessageId es null. | fetchMessageWindow con throwError |
isTicketOwner / isLockedByCurrentUser getters (sdd/ticket-lock W1)
| it() | Escenario | Resultado esperado | Mocks/spies |
|---|---|---|---|
isTicketOwner is true only when conversation.assignedUserId matches the current user | Dado currentUser._id = 'agent-1', con assignedUserId 'agent-1', 'agent-2' y null. | true, false, false. | currentUser fijado vía as any |
isLockedByCurrentUser is true only when conversation.lockedBy matches the current user | Dado el mismo usuario, con lockedBy 'agent-1', 'agent-2' y null. | true, false, false. | currentUser vía as any |
ticket-actions-menu lock bindings (sdd/ticket-lock W1)
| it() | Escenario | Resultado esperado | Mocks/spies |
|---|---|---|---|
binds lockedBy/isOwner/isLockedByMe onto the header app-ticket-actions-menu | Dado un ticket asignado y bloqueado por agent-1 y currentUser agent-1. | El app-ticket-actions-menu de la cabecera recibe lockedBy = 'agent-1', isOwner = true, isLockedByMe = true. | currentUser vía as any |
reflects an unlocked, non-owned ticket as false/null on the menu bindings | Dado un ticket asignado a agent-2, sin bloqueo, y currentUser agent-1. | lockedBy nulo, isOwner y isLockedByMe en false. | currentUser vía as any |
adjuntos con caption
Helper selectAnImage(): validateImage devuelve null, upload devuelve { _id: 'file-1' } y se invoca onImageSelected.
| it() | Escenario | Resultado esperado | Mocks/spies |
|---|---|---|---|
manda lo escrito en el composer como caption del adjunto | Dado newMessage = 'te mando la captura', cuando se selecciona una imagen. | sendFile recibe ticketId, fileId: 'file-1' y caption: 'te mando la captura'. | fileApi.validateImage, fileApi.upload, chatSocket.sendFile |
limpia el composer después de adjuntar | Mismo montaje. | newMessage queda vacío. | Igual |
no manda caption cuando el composer está vacío | Dado newMessage = ' '. | sendFile se llama sin la propiedad caption. | Igual |
channelOption (MC-008)
| it() | Escenario | Resultado esperado | Mocks/spies |
|---|---|---|---|
returns the CONNECTION_CHANNELS entry matching conversation.channelType | Dado channelType = 'whatsapp-qr'. | channelOption es la entrada de CONNECTION_CHANNELS con ese type. | Constante real CONNECTION_CHANNELS |
returns null when channelType does not match any known channel | Dado channelType = 'unknown-channel'. | null. | Ninguno |
returns null when conversation is null or undefined | Dado conversation null y luego undefined. | null en ambos casos. | Ninguno |
header department badge (MC-008)
| it() | Escenario | Resultado esperado | Mocks/spies |
|---|---|---|---|
shows the department name and its color when conversation.department is set | Dado un departamento Soporte con color #ff0000. | .conv-header__department contiene Soporte y borderColor es rgb(255, 0, 0). | TranslateFakeLoader |
shows the no-department fallback when conversation.department is absent | Dado department: null. | El badge contiene la clave players.chats.list.noDepartment. | TranslateFakeLoader |
header assigned operator indicator (MC-008)
| it() | Escenario | Resultado esperado | Mocks/spies |
|---|---|---|---|
shows the assigned operator name together with the operator icon and the desktop-only label | Dado assignedUserName = 'Juan Perez'. | .conv-header__assigned contiene el nombre, el icono .conv-header__assigned-icon.fa-user y la etiqueta con la clave whaticket.chats.conversation.assignedLabel. | TranslateFakeLoader |
shows the unassigned fallback with the same operator icon and desktop-only label | Dado sin usuario asignado. | Existe .conv-header__assigned--empty con la clave whaticket.chats.conversation.unassigned, el mismo icono y la misma etiqueta. | TranslateFakeLoader |
sets the same assignedTooltip title on both the assigned and the unassigned indicator | Dado un usuario asignado y luego sin asignar (re-render). | En ambos estados el atributo title es igual a component.assignedTooltip. | TranslateFakeLoader |
assignedTooltip getter (MC-008)
| it() | Escenario | Resultado esperado | Mocks/spies |
|---|---|---|---|
resolves the whaticket.chats.conversation.assignedTooltip translation key | Dado el getter sin conversación. | Devuelve whaticket.chats.conversation.assignedTooltip. | TranslateFakeLoader |
Tests en el archivo: 54.
src/app/features/whaticket/components/chat/chat-filter-panel/chat-filter-panel.component.spec.ts
- SUT:
ChatFilterPanelComponent(panel de filtros; el selector de agente único fue retirado). - Mocking:
TestBedconFormsModule;LabelServicecomojasmine.createSpyObj(findLabelsPaginateddevuelveof({ results: [], total: 0 })) yContactServicecomo{}.
ChatFilterPanelComponent — legacy single-agent picker removal (sdd/chat-operator-filter D8)
| it() | Escenario | Resultado esperado | Mocks/spies |
|---|---|---|---|
does not render the "Usuarios asignados" section | Dado el componente renderizado. | El texto del DOM no contiene Usuarios asignados. | LabelService |
does not render any .cfp__avatar item (only the users section used it) | Dado el componente renderizado. | No existe ningún .cfp__avatar. | LabelService |
does not expose users/loadingUsers/selectUser/getUserName/open.users anymore | Dado la instancia. | No tiene users, loadingUsers, selectUser, getUserName ni open.users. | Ninguno |
never injects TicketService (no /users/get-all call from this panel) | Dado la instancia. | _ticketService es undefined. | Ninguno |
applyFilters still emits the remaining selection fields untouched | Dado selection = { labelId, contactId, status }, cuando se llama applyFilters(). | apply emite exactamente esa selección, sin assignedUserId. | Suscripción a component.apply |
Tests en el archivo: 5.
src/app/features/whaticket/components/chat/chat-list/chat-list-item/chat-list-item.component.spec.ts
- SUT:
ChatListItemComponent(tarjeta de un chat en la lista). - Mocking:
TestBedcondeclarations: [ChatListItemComponent],TranslateModule.forRootconTranslateFakeLoader,Overlaycomo{}yNO_ERRORS_SCHEMA. El últimodescribedeclara ademásShowWithPermissionDirectivey reemplazaMyPermissionsServiceconjasmine.createSpyObj.
ChatListItemComponent — presence dot
| it() | Escenario | Resultado esperado | Mocks/spies |
|---|---|---|---|
defaults isOnline to false when the parent does not bind it | Dado el componente sin binding de isOnline. | isOnline es false. | Overlay vacío |
renders the presence dot when isOnline is true | Dado isOnline = true. | Existe .chat-card__presence-dot. | Overlay vacío |
does not render the presence dot when isOnline is false | Dado isOnline = false. | No existe .chat-card__presence-dot. | Overlay vacío |
ChatListItemComponent — padlock indicator
| it() | Escenario | Resultado esperado | Mocks/spies |
|---|---|---|---|
renders the padlock icon when the conversation carries a lockedBy | Dado lockedBy: 'user-1'. | Existe .chat-card__lock-icon. | Overlay vacío |
does not render the padlock icon when the conversation has no lockedBy | Dado lockedBy: null. | No existe el candado. | Overlay vacío |
does not render the padlock icon when lockedBy is absent entirely (default unlocked) | Dado una conversación sin la propiedad lockedBy. | No existe el candado. | Overlay vacío |
ChatListItemComponent — padlock icon styling (sdd/ticket-lock W3)
| it() | Escenario | Resultado esperado | Mocks/spies |
|---|---|---|---|
does not shrink inside the flex header (stays a fixed-size icon, not squeezed by name/time) | Dado un chat bloqueado, cuando se lee el estilo computado del candado. | flexShrink es '0'. | Overlay vacío |
renders with a muted, header-consistent font size instead of the browser default icon size | Mismo montaje. | fontSize es '11px'. | Overlay vacío |
ChatListItemComponent — assigned agent badge
| it() | Escenario | Resultado esperado | Mocks/spies |
|---|---|---|---|
renders the agent avatar image when the assigned agent has a photo | Dado agente asignado con assignedUserAvatarUrl. | Existe .chat-card__agent-badge-img con src igual a la url. | MyPermissionsService.hasPermission devuelve of(true) |
renders the agent initials when there is a name but no photo | Dado nombre Agent One y sin foto. | El badge muestra AO; no hay imagen ni .fa-user. | hasPermission of(true) |
renders the generic icon when there is an assigned agent id but no resolvable name | Dado assignedUserId pero sin nombre ni foto. | Existe i.fa-user dentro del badge. | hasPermission of(true) |
does not render the badge when the ticket has no assigned agent | Dado sin agente asignado. | No existe .chat-card__agent-badge. | hasPermission of(true) |
still renders the badge when the ticket status is closed/resolved (all-states requirement) | Dado ticketStatus: 'closed' con agente asignado. | El badge se renderiza. | hasPermission of(true) |
hides the badge when TICKET_VIEW_AGENT_AVATAR is not granted, even with an assigned agent | Dado hasPermission devolviendo of(false) y agente con foto. | Se consulta hasPermission('TICKET_VIEW_AGENT_AVATAR') y no se renderiza el badge. | hasPermission of(false) |
Tests en el archivo: 14.
src/app/features/whaticket/components/chat/chat-popout/chat-popout.component.spec.ts
- SUT:
ChatPopoutComponent(ventana emergente del chat). - Mocking:
TestBedconTranslateModule.forRoot(TranslateFakeLoader) yNO_ERRORS_SCHEMA; sin servicios simulados. Se usa una conversación base mínima.
ChatPopoutComponent
| it() | Escenario | Resultado esperado | Mocks/spies |
|---|---|---|---|
should create | Dado el componente con conversation base y detectChanges(). | El componente existe. | Ninguno |
defaults isOwner and isLockedByMe to false when the parent does not bind them | Dado sin bindings del padre. | Ambos son false. | Ninguno |
ticket-actions-menu lock bindings
| it() | Escenario | Resultado esperado | Mocks/spies |
|---|---|---|---|
binds lockedBy from the conversation and isOwner/isLockedByMe from the parent-computed inputs | Dado lockedBy: 'agent-1', isOwner = true, isLockedByMe = true. | app-ticket-actions-menu se renderiza y recibe lockedBy = 'agent-1', isOwner = true, isLockedByMe = true. | NO_ERRORS_SCHEMA |
reflects an unlocked, non-owned ticket as false/null on the menu bindings | Dado lockedBy: null y flags en false. | lockedBy nulo, isOwner y isLockedByMe en false. | NO_ERRORS_SCHEMA |
toggleMinimize
| it() | Escenario | Resultado esperado | Mocks/spies |
|---|---|---|---|
flips minimized and emits the new value | Dado una suscripción a minimizedChange, cuando se llama toggleMinimize(). | minimized es true y se emite [true]. | Suscripción a minimizedChange |
Tests en el archivo: 5.
src/app/features/whaticket/components/chat/media-viewer/media-viewer.component.spec.ts
- SUT:
MediaViewerComponent(visor modal de imagen/video). - Mocking:
TestBedconNgbActiveModalcomojasmine.createSpyObj(close,dismiss) yNO_ERRORS_SCHEMA;urlyfilenamefijados enbeforeEach.
MediaViewerComponent
| it() | Escenario | Resultado esperado | Mocks/spies |
|---|---|---|---|
shows an image without a player | Dado kind = 'image'. | Existe .media-viewer__image y no hay video. | NgbActiveModal |
shows a video with its controls | Dado kind = 'video'. | Existe .media-viewer__video con atributo controls. | NgbActiveModal |
closes on escape | Dado una imagen, cuando se llama onEsc(). | activeModal.dismiss se invoca. | activeModal.dismiss |
closes when clicking the backdrop area | Cuando se hace clic en .media-viewer. | dismiss se invoca. | activeModal.dismiss |
does not close when clicking the media itself | Cuando se hace clic en .media-viewer__image. | dismiss no se invoca. | activeModal.dismiss |
closes when clicking the empty area beside the media | Cuando se hace clic en .media-viewer__stage. | dismiss se invoca. | activeModal.dismiss |
does not close when clicking the video player controls | Dado un video, cuando se hace clic en .media-viewer__video. | dismiss no se invoca. | activeModal.dismiss |
Tests en el archivo: 7.
src/app/features/whaticket/components/chat/message-context-menu/message-context-menu.component.spec.ts
- SUT:
MessageContextMenuComponent(menú contextual de un mensaje, renderizado en un overlay CDK). - Mocking:
TestBedconOverlayModulereal yNO_ERRORS_SCHEMA; mensajes construidos con los helpersbuildMessageybuildMedia. El helperopenMenuLabels()abre el menú y lee las etiquetas del DOM global;afterEachcierra el menú.
MessageContextMenuComponent
| it() | Escenario | Resultado esperado | Mocks/spies |
|---|---|---|---|
offers copy for a text message | Dado un mensaje de texto, cuando se abre el menú. | Etiquetas: Reenviar, Responder, Seleccionar, Destacar, Copiar mensaje. | OverlayModule real |
offers download instead of copy for a media message | Dado un mensaje media con imagen. | Etiquetas: Reenviar, Responder, Seleccionar, Destacar, Descargar medios. | OverlayModule real |
treats audio, video and documents as media too | Dado mensajes media de tipo audio, video y document. | hasMedia es true en los tres. | Ninguno |
falls back to copy when the media has no url yet | Dado messageType: 'media' con media: null. | hasMedia es false. | Ninguno |
emits downloadMedia with the message and closes | Dado un mensaje media y el menú abierto, cuando se llama onDownloadMedia(). | downloadMedia emite el mensaje e isOpen queda false. | Suscripción a downloadMedia |
shows the delete option only on own messages | Dado isOwn = false. | Las etiquetas no incluyen Borrar. | OverlayModule real |
reflects the favorite state in the label | Dado isFavorite = true. | Las etiquetas incluyen Quitar destacado. | OverlayModule real |
Tests en el archivo: 7.
src/app/features/whaticket/components/chat/postpone-dialog/postpone-dialog.component.spec.ts
- SUT:
PostponeDialogComponent(diálogo para posponer, standalone). - Mocking:
TestBedimportando el componente yTranslateModule.forRoot();NgbActiveModalcomojasmine.createSpyObj(close,dismiss). HelperfutureDateStruct(hours).
PostponeDialogComponent
| it() | Escenario | Resultado esperado | Mocks/spies |
|---|---|---|---|
treats a past date/time as invalid | Dado fecha y hora una hora en el pasado. | isValidFutureSelection() es false. | NgbActiveModal |
treats a future date/time as valid | Dado fecha y hora una hora en el futuro. | isValidFutureSelection() es true. | NgbActiveModal |
does not close the modal when confirming with a past date/time | Dado fecha pasada, cuando se llama onConfirm(). | activeModal.close no se invoca. | activeModal.close |
resolves the combined Date when confirming with a valid future date/time | Dado fecha y hora dos horas en el futuro. | close se llama una vez con un Date cuyo año y hora coinciden con la selección. | activeModal.close |
dismisses the modal on cancel | Cuando se llama onCancel(). | dismiss se llama con null. | activeModal.dismiss |
Tests en el archivo: 5.
src/app/features/whaticket/components/chat/schedule-message-dialog/schedule-message-dialog.component.spec.ts
- SUT:
ScheduleMessageDialogComponent(diálogo para programar/editar un mensaje, standalone). - Mocking:
TestBedconTranslateModule.forRoot()yNgbActiveModalcomojasmine.createSpyObj(close,dismiss).
ScheduleMessageDialogComponent
| it() | Escenario | Resultado esperado | Mocks/spies |
|---|---|---|---|
pre-fills the body from initialBody | Dado initialBody = 'hello there' y ngOnInit(). | body es 'hello there'. | NgbActiveModal |
pre-fills scheduledAt from initialScheduledAt and allows confirming without touching the picker (verify CRITICAL-4) | Dado initialScheduledAt 48 h adelante, cuerpo editado a 'typo fixed' y sin tocar el selector, cuando se confirma. | close recibe { body: 'typo fixed', scheduledAt: existing }. | activeModal.close |
does not confirm with a past date/time | Dado cuerpo válido y fecha pasada marcada como inválida. | close no se invoca. | activeModal.close |
does not confirm with an empty body | Dado cuerpo ' ' y fecha futura válida. | close no se invoca. | activeModal.close |
confirms with { body, scheduledAt } when body and date are valid | Dado cuerpo y fecha futura válidos. | close recibe { body: 'hello there', scheduledAt: future }. | activeModal.close |
dismisses the modal on cancel | Cuando se llama onCancel(). | dismiss se llama con null. | activeModal.dismiss |
Tests en el archivo: 6.
src/app/features/whaticket/components/chat/schedule-send-button/schedule-send-button.component.spec.ts
- SUT:
ScheduleSendButtonComponent(botón de envío programado, standalone). - Mocking:
TestBedconTranslateModule.forRoot();jasmine.createSpy('schedule')suscrito alEventEmitter.
ScheduleSendButtonComponent
| it() | Escenario | Resultado esperado | Mocks/spies |
|---|---|---|---|
emits schedule on click when not disabled | Dado el botón habilitado, cuando se llama onClick(). | schedule emite una vez. | jasmine.createSpy('schedule') |
does not emit schedule on click when disabled | Dado disabled = true. | schedule no emite. | jasmine.createSpy('schedule') |
Tests en el archivo: 2.
src/app/features/whaticket/components/chat/scheduled-message-strip/scheduled-message-strip.component.spec.ts
- SUT:
ScheduledMessageStripComponent(franja de mensajes programados pendientes, standalone). - Mocking:
TestBedconTranslateModule.forRoot();Storecomojasmine.createSpyObj(selectdevuelveof([makeItem()]),dispatch) yScheduleMessageDialogServiceconopenespiado.ticketId = 'tk1'.
ScheduledMessageStripComponent
| it() | Escenario | Resultado esperado | Mocks/spies |
|---|---|---|---|
exposes the pending items for the given ticket via items$ (a sibling stream to messages$, never merged into it) | Dado el store con un item sm1. | items$ entrega 1 elemento con _id = 'sm1'. | store.select |
dispatches cancel with the id and item when the agent cancels a pending row | Cuando se llama onCancel(item). | Se despacha ScheduledMessageActions.cancel({ id, item }). | store.dispatch |
onEdit
| it() | Escenario | Resultado esperado | Mocks/spies |
|---|---|---|---|
opens the dialog pre-loaded with the existing scheduledAt | Dado un item con scheduledAt y diálogo que resuelve null. | open se llama con initialBody, isNote: false e initialScheduledAt como Date. | scheduleDialog.open |
omits scheduledAt from the update dispatch when the agent only changed the body | Dado un diálogo que devuelve nuevo cuerpo y la misma fecha. | Se despacha update({ id, body: 'texto corregido' }) sin scheduledAt. | scheduleDialog.open, store.dispatch |
includes scheduledAt in the update dispatch when the agent actually changed it | Dado un diálogo que devuelve otra fecha. | update incluye scheduledAt en ISO. | scheduleDialog.open, store.dispatch |
does not dispatch when the dialog is dismissed | Dado un diálogo que resuelve null. | dispatch no se invoca. | scheduleDialog.open, store.dispatch |
Tests en el archivo: 6.
src/app/features/whaticket/components/chat/ticket-actions-menu/ticket-actions-menu.component.spec.ts
- SUT:
TicketActionsMenuComponent(acciones bloquear/desbloquear; solo se prueban getters yselect(), no la plantilla en overlay). - Mocking:
TestBedconOverlaycomo{}yNO_ERRORS_SCHEMA;spyOn(component.action, 'emit').
canLockTicket
| it() | Escenario | Resultado esperado | Mocks/spies |
|---|---|---|---|
is true when the ticket is unlocked and the requester owns it | lockedBy = null, isOwner = true. | true. | Ninguno |
is false when the ticket is already locked (by anyone) | lockedBy = 'user-1', isOwner = true. | false. | Ninguno |
is false when the requester does not own the ticket | lockedBy = null, isOwner = false. | false. | Ninguno |
canUnlockTicket
| it() | Escenario | Resultado esperado | Mocks/spies |
|---|---|---|---|
is true when the ticket is locked by the requester themselves | lockedBy = 'user-1', isLockedByMe = true. | true. | Ninguno |
is false when the ticket is unlocked | lockedBy = null, isLockedByMe = false. | false. | Ninguno |
is false when the ticket is locked by someone else (not authorized here — force-unlock lives in the admin view) | lockedBy = 'user-2', isLockedByMe = false. | false. | Ninguno |
select()
| it() | Escenario | Resultado esperado | Mocks/spies |
|---|---|---|---|
emits "lock" when selecting the lock action | Cuando se llama select('lock'). | action.emit se llama con 'lock'. | spyOn(component.action, 'emit') |
emits "unlock" when selecting the unlock action | Cuando se llama select('unlock'). | action.emit se llama con 'unlock'. | spyOn(component.action, 'emit') |
Tests en el archivo: 8.
src/app/features/whaticket/components/chat/transfer-panel/transfer-panel.component.spec.ts
- SUT:
TransferPanelComponent(formulario de transferencia a agente y departamento). - Mocking:
TestBedconReactiveFormsModule,TranslateModule.forRoot()yNO_ERRORS_SCHEMA.TicketServiceyDepartmentServicecon funciones que devuelvenof(...); los tokens'ngAgents'y'ngDepartments'usan una fábricasearchServiceMock()con espías (resetNgSelect,extendFilter,triggerFetchData,getData...).overrideComponentdeja la plantilla vacía a propósito: se prueba la lógica del formulario, no el markup.
TransferPanelComponent — department scoped to the selected agent
| it() | Escenario | Resultado esperado | Mocks/spies |
|---|---|---|---|
keeps the department control disabled until an agent is chosen | Dado el formulario recién creado. | departmentId está deshabilitado y triggerFetchData no se ha llamado. | ngDepartments.triggerFetchData |
loads the departments filtered by the selected agent membership | Cuando agentId se fija en 'agent-1'. | departmentId se habilita; se llaman resetNgSelect, extendFilter({ userId: 'agent-1' }) y triggerFetchData. | ngDepartments |
clears the chosen department and refetches when the agent changes | Dado agente y departamento elegidos, cuando el agente cambia a 'agent-2'. | El departamento vuelve a null y se llama extendFilter({ userId: 'agent-2' }). | ngDepartments.extendFilter |
blocks the transfer until both the agent and the department are chosen | Sin agente, con solo agente y con ambos. | canTransfer es false, false y true. | Ninguno |
does not emit the selection while the department is missing | Dado solo agente elegido, cuando se llama onTransfer(). | select.emit no se llama. | spyOn(component.select, 'emit') |
emits the chosen department so the card can be repainted without a refetch | Dado getData con el departamento Soporte (#0d6efd), un componente nuevo suscrito manualmente a departments$, y agente y departamento elegidos. | select.emit recibe departmentId: 'dept-1' y department: { _id: 'dept-1', name: 'Soporte', color: '#0d6efd' }. | ngDepartments.getData, spyOn(fresh.select, 'emit') |
disables and clears the department when the agent is cleared | Dado agente y departamento elegidos, cuando agentId pasa a null. | departmentId queda deshabilitado y con valor null. | Ninguno |
Tests en el archivo: 7.
Total de la página
14 specs, 128 tests (it()): rutas whaticket 1, rutas chat 1, chat-conversation 54, chat-filter-panel 5, chat-list-item 14, chat-popout 5, media-viewer 7, message-context-menu 7, postpone-dialog 5, schedule-message-dialog 6, schedule-send-button 2, scheduled-message-strip 6, ticket-actions-menu 8, transfer-panel 7.