Skip to main content

Whaticket: lista de chats

Documentación de los casos de prueba unitarios de ChatListComponent, el contenedor principal de la bandeja de chats de Whaticket (lista de tickets, panel de detalles del contacto, filtros de operador, pestañas de estado y alcance). Todo el spec vive en un único archivo con varios bloques describe de nivel superior; cada bloque tiene su propia configuración de TestBed.

chat-list.component.spec.ts​

  • Ruta: src/app/features/whaticket/components/chat/chat-list/chat-list.component.spec.ts
  • SUT: ChatListComponent (./chat-list.component).
  • Enfoque de mocks: TestBed.configureTestingModule con declarations: [ChatListComponent], NO_ERRORS_SCHEMA y TranslateModule.forRoot con TranslateFakeLoader. Las dependencias del constructor se proveen con useValue mínimos (Store, GlobalService, ActivatedRoute, Router, ChatSocketService con subjects/EMPTY, servicios de API como jasmine.createSpyObj, etc.). Existe la constante ALWAYS_ALLOWED_MY_PERMISSIONS_STUB (hasPermission y hasAnyPermission devuelven of(true)) que se provee como MyPermissionsService en las suites que no prueban la lógica de permisos; sin ella Angular caería al servicio real (providedIn: 'root') y fallaría con NullInjectorError. Las suites de permisos proveen su propio jasmine.SpyObj<MyPermissionsService>.
  • No hay xit, fdescribe ni fit en el archivo.

Bloque: ChatListComponent — starred messages tab​

Suite que no llama a ngOnInit salvo donde se indica (fixture.detectChanges()). Store es { select: () => of([]), dispatch: () => {} }; StarredMessageService es un spy con findByContact y changed$: EMPTY; MessageService es un spy con getContactNotes; newNote$ es un Subject. La conversación seleccionada tiene otherParticipantId: 'contact-1'.

loadStarredMessages​

it()EscenarioResultado esperadoMocks/spies
does nothing when there is no selected conversationDado selectedConversation = null, cuando se llama loadStarredMessages().No se invoca findByContact.starredMessageService.findByContact
loads page 1 for the selected conversation contactIdDado un servicio que devuelve página 1 de 2, cuando se carga.Se llama findByContact('contact-1', 1, 20), isLoadingStarred es false y starredMeta es { total: 40, page: 1, limit: 20, totalPages: 2 }.findByContact devuelve of(page)
does not reload when already loaded for the same contactIdDado un contacto ya cargado, cuando se llama dos veces seguidas.findByContact se invoca una sola vez.findByContact
resets starredMessages/meta and stops loading on errorDado que el servicio emite error (throwError), cuando se carga.starredMessages es [], starredMeta es null e isLoadingStarred es false.findByContact devuelve throwError

loadMoreStarredMessages​

it()EscenarioResultado esperadoMocks/spies
fetches the next page and appends results when more pages remainDado página 1 de 2 cargada, cuando se llama loadMoreStarredMessages() y el servicio devuelve la página 2.Se pide ('contact-1', 2, 20) y starredMessages es [first, second].findByContact con dos respuestas sucesivas
does nothing when already on the last pageDado una sola página cargada y el spy reseteado, cuando se llama a cargar más.No se invoca findByContact.findByContact.calls.reset()
does nothing while a request is already in flightDado isLoadingStarred = true con más páginas disponibles, cuando se llama a cargar más.No se invoca findByContact.findByContact.calls.reset()

selectDetailsTab​

it()EscenarioResultado esperadoMocks/spies
triggers loadStarredMessages when switching to the "starred" tabCuando se llama selectDetailsTab('starred').activeDetailsTab es 'starred' y se llama findByContact('contact-1', 1, 20).findByContact

getStarredMessagePreview​

it()EscenarioResultado esperadoMocks/spies
returns the hydrated file filename when a file is present (WU5)Dado un item con file.filename = 'contrato.pdf'.Devuelve 'contrato.pdf'.Ninguno (objeto literal)
falls back to "[archivo]" when the file has no filenameDado un item con archivo sin filename.Devuelve '[archivo]'.Ninguno
returns the raw body when there is no attached fileDado file: null y message.body = 'hola mundo'.Devuelve 'hola mundo'.Ninguno
returns an empty string when the message failed to hydrate (orphan)Dado message: null y file: null (huérfano).Devuelve ''.Ninguno

getStarredSenderName​

it()EscenarioResultado esperadoMocks/spies
returns firstname + lastName when both are presentDado un remitente con firstname y lastName.Devuelve 'Ada Lovelace'.Ninguno
falls back to username when firstname/lastName are missingDado un remitente solo con username.Devuelve 'ada'.Ninguno
returns an empty string when sender is undefined (e.g. system message)Dado sender: undefined.Devuelve ''.Ninguno

hasStarredFile / isStarredFileImage / getStarredFileIconClass​

it()EscenarioResultado esperadoMocks/spies
reports no file when file is nullDado file: null.hasStarredFile es false.Ninguno
reports a file and detects an image mimetypeDado un archivo con mimetype image/png.hasStarredFile y isStarredFileImage son true; el icono es 'fa-image'.Ninguno
resolves the document icon for a non-image fileDado un archivo application/pdf.isStarredFileImage es false; el icono es 'fa-file-text-o'.Ninguno

jumpToStarredMessage​

it()EscenarioResultado esperadoMocks/spies
delegates to the rendered ChatConversationComponent.jumpToMessage with the messageIdDado chatConversationRef montado, cuando se salta a un mensaje destacado.Se llama jumpToMessage('msg-42').jasmine.createSpy('jumpToMessage')
does not throw when the conversation view is not mountedDado chatConversationRef = undefined.No lanza excepción.Ninguno
it()EscenarioResultado esperadoMocks/spies
delegates immediately when chatConversationRef is already mountedDado chatConversationRef ya montado.jumpToMessage('msg-42') se llama de inmediato.spy jumpToMessage
retries (bounded) until chatConversationRef mountsDado chatConversationRef no montado; luego se monta y se espera 150 ms (test asíncrono con done).Al inicio no se llama; tras el reintento se llama jumpToMessage('msg-42').spy jumpToMessage, setTimeout real
triggers via initializeRoutedConversation when both chatId and highlightMessageId are routedDado queryParams con chatId y highlightMessageId y la conversación en la lista.Se llama jumpToMessage('msg-42')._route reemplazado por { queryParams: of(...) }, spy jumpToMessage

onTicketAction — delete (sdd/delete-ticket Phase 6)​

ConfirmDialogService.confirm y Store.dispatch se sustituyen por jasmine.createSpy en un beforeEach.

it()EscenarioResultado esperadoMocks/spies
asks for confirmation with an irreversibility warning before dispatching deleteTicketDado un diálogo cuya promesa no se resuelve, cuando llega la acción delete.confirm se llama con título, mensaje, icono warning y etiquetas de confirmar/cancelar de whaticket.chats.deleteConfirm.*; no se hace dispatch.confirm devuelve new Promise(() => {}), dispatch
dispatches deleteTicket and closes the conversation when confirmed and it is the open oneDado confirmación true y selectedConversationId = 'ticket-1'.Se despacha TicketActions.deleteTicket({ ticketId: 'ticket-1' }) y se llama closeConversation.confirm resuelve true, spyOn(component, 'closeConversation')
does not dispatch deleteTicket and leaves state unchanged when cancelledDado que el usuario cancela (false).No se despacha nada ni se cierra la conversación.confirm resuelve false, spy closeConversation
does not close the conversation when a different ticket than the open one is deletedDado confirmación true pero selectedConversationId = 'ticket-other'.Se despacha deleteTicket pero no se llama closeConversation.confirm resuelve true, spy closeConversation

onTicketAction — lock/unlock​

it()EscenarioResultado esperadoMocks/spies
dispatches lockTicket on the "lock" actionCuando llega la acción lock.Se despacha TicketActions.lockTicket({ ticketId: 'ticket-1' }).store.dispatch spy
dispatches unlockTicket on the "unlock" actionCuando llega la acción unlock.Se despacha TicketActions.unlockTicket({ ticketId: 'ticket-1' }).store.dispatch spy

onTicketAction — locked-first guard for transfer/return​

Pre-chequeo en cliente sobre lockedBy. confirm devuelve una promesa pendiente.

it()EscenarioResultado esperadoMocks/spies
shows the unlock-first modal and does NOT dispatch unassignTicket when returning a locked ticketDado un ticket con lockedBy: 'user-1', acción return.confirm se llama con título whaticket.ticketActions.lockedFirstTitle y no se despacha unassignTicket.confirm, dispatch spies
shows the unlock-first modal and does NOT open the transfer panel when transferring a locked ticketDado un ticket bloqueado, acción transfer.Se muestra el modal de desbloqueo primero y no se llama openTransferPanel.confirm, spyOn<any>(component, 'openTransferPanel')
proceeds with the normal return confirmation when the ticket is unlockedDado lockedBy: null, acción return.confirm se llama con título whaticket.chats.returnConfirm.title.confirm spy
proceeds to open the transfer panel when the ticket is unlockedDado lockedBy: null, acción transfer.Se llama openTransferPanel con la conversación.spy openTransferPanel

isTicketOwner / isLockedByCurrentUser (sdd/ticket-lock FE-07)​

El usuario actual se fija como { _id: 'user-1' }.

it()EscenarioResultado esperadoMocks/spies
isTicketOwner is true when the conversation is assigned to the requesterassignedUserId: 'user-1'.true.Ninguno
isTicketOwner is false when assigned to someone elseassignedUserId: 'user-2'.false.Ninguno
isTicketOwner is false when unassignedassignedUserId: null.false.Ninguno
isLockedByCurrentUser is true when lockedBy matches the requesterlockedBy: 'user-1'.true.Ninguno
isLockedByCurrentUser is false when lockedBy belongs to someone elselockedBy: 'user-2'.false.Ninguno
isLockedByCurrentUser is false when unlockedlockedBy: null.false.Ninguno

ticketToConversation — lockedBy mapping (sdd/ticket-lock FE-07)​

it()EscenarioResultado esperadoMocks/spies
maps ticket.lockedBy onto the conversationDado un ticket con lockedBy: 'user-1'.result.lockedBy es 'user-1'.Ninguno
defaults to null when the ticket has no lockedByDado un ticket sin lockedBy.result.lockedBy es null.Ninguno

ticketToConversation — assignedUserAvatarUrl mapping (sdd/ticket-list-agent-avatar)​

it()EscenarioResultado esperadoMocks/spies
maps ticket.assignedUser.avatarUrl onto the conversationDado un ticket con agente asignado y avatarUrl.result.assignedUserAvatarUrl es 'https://cdn.test/agent1.png'.Ninguno
defaults to null when there is no assigned agentDado un ticket sin agente asignado.result.assignedUserAvatarUrl es null.Ninguno

resetDetailsPanelState (via closeContactDetailsPanel path)​

it()EscenarioResultado esperadoMocks/spies
clears starred state so the next contact starts freshDado un destacado cargado, cuando se llama resetDetailsPanelState().starredMessages es [], starredMeta es null y _starredLoadedForContactId es null.findByContact
clears internal-notes state so the next contact starts freshDado una nota interna cargada, cuando se resetea.internalNotes es [], notesMeta es null, isLoadingNotes es false y _notesLoadedForContactId es null.messageService.getContactNotes

internal notes tab > loadInternalNotes​

it()EscenarioResultado esperadoMocks/spies
does nothing when there is no selected conversationselectedConversation = null.No se llama getContactNotes.getContactNotes
loads page 1 for the selected conversation contactId with limit 10Dado página 1 de 2.Se llama ('contact-1', 1, 10), isLoadingNotes es false y notesMeta es { total: 20, page: 1, limit: 10, totalPages: 2 }.getContactNotes devuelve of(page)
does not reload when already loaded for the same contactIdDos llamadas seguidas.getContactNotes se invoca una vez.getContactNotes
resets internalNotes/meta and stops loading on error, without throwingEl servicio emite error.No lanza; internalNotes es [], notesMeta es null, isLoadingNotes es false.getContactNotes devuelve throwError

internal notes tab > loadMoreInternalNotes​

it()EscenarioResultado esperadoMocks/spies
appends page 2 results and keeps createdAt strictly non-increasing across pages (multi-page order)Dado página 1 con 10 notas (11..2) y página 2 con la nota 1, ya en orden descendente.Se pide la página 2 con límite 10; hay 11 notas igual a [...page1Notes, ...page2Notes] y los createdAt son no crecientes de arriba abajo.getContactNotes con respuestas sucesivas
does nothing when already on the last pageSolo una página cargada.No se llama getContactNotes.calls.reset()
does nothing while a request is already in flight (re-entrancy guard)isLoadingNotes = true con más páginas.No se llama getContactNotes.calls.reset()

internal notes tab > selectDetailsTab​

it()EscenarioResultado esperadoMocks/spies
triggers loadInternalNotes when switching to the "notes" tabCuando se llama selectDetailsTab('notes').activeDetailsTab es 'notes' y se llama getContactNotes('contact-1', 1, 10).getContactNotes

internal notes tab > getNoteSenderName​

it()EscenarioResultado esperadoMocks/spies
returns firstname + lastName when both are presentRemitente con nombre y apellido.'Ada Lovelace'.Ninguno
falls back to username when firstname/lastName are missingRemitente solo con username.'ada'.Ninguno
returns an empty string when sender is undefinedsender = undefined.''.Ninguno

internal notes tab > live invalidation via newNote$ (ngOnInit subscription)​

Estos tests llaman fixture.detectChanges() para ejecutar ngOnInit y suscribirse a newNote$.

it()EscenarioResultado esperadoMocks/spies
reloads notes when a new note arrives for the open contact while the notes tab is activePanel abierto, pestaña notes activa y notas cargadas; llega una nota de contact-1.Se recarga: getContactNotes('contact-1', 1, 10).noteSubject.next, getContactNotes
ignores a new-note event for a different contactIdMisma preparación; la nota es de contact-other.No se llama getContactNotes.noteSubject, getContactNotes
invalidates the per-contact cache even when the notes tab is not the active onePestaña activa info; llega una nota de contact-1.No se recarga de inmediato, pero una nueva llamada a loadInternalNotes() sí consulta el servicio (1 vez).noteSubject, getContactNotes

Total del bloque: 59 tests.

Bloque: ChatListComponent — contact details panel open persistence (MC-010)​

El panel de detalles no debe cerrarse al cambiar de conversación y su estado abierto/cerrado se persiste en localStorage (clave chat_contact_details_panel_open). Helper configure() con TestBed (Router.navigate devuelve Promise.resolve(true), NotificationCenterService.setActiveTicket no-op, StarredMessageService con changed$: EMPTY, MessageService como spy de getContactNotes).

selectConversation​

it()EscenarioResultado esperadoMocks/spies
keeps the panel open when switching to a different conversationDado el panel abierto con la conversación A seleccionada, cuando se selecciona la B.showContactDetailsPanel sigue true y selectedConversation es la B.Stubs de TestBed

openContactDetailsPanel / closeContactDetailsPanel​

it()EscenarioResultado esperadoMocks/spies
opens the panel and persists "true" under the exact storage keyCuando se llama openContactDetailsPanel().showContactDetailsPanel es true y localStorage.setItem('chat_contact_details_panel_open', 'true').spyOn(localStorage, 'setItem')
closes the panel and persists "false" under the exact storage keyCuando se llama closeContactDetailsPanel().El panel es false y se guarda 'false' en la misma clave.spyOn(localStorage, 'setItem')

clearSelectionIfNotVisible / closeConversation — do not persist​

it()EscenarioResultado esperadoMocks/spies
clearSelectionIfNotVisible closes the panel without writing to localStorageDado panel abierto y la conversación filtrada fuera de la vista (conversations = []).El panel se cierra y no se escribe en localStorage.spyOn(localStorage, 'setItem') con calls.reset()
closeConversation closes the panel without writing to localStorageDado panel abierto y conversación seleccionada, cuando se llama closeConversation().El panel se cierra y no se escribe en localStorage.spyOn(localStorage, 'setItem')

restore on load via initializeRoutedConversation​

it()EscenarioResultado esperadoMocks/spies
reopens the panel once a routed chatId resolves when the stored preference was "true"Dado getItem devolviendo 'true' para la clave y un chatId ruteado.Antes del ruteo el panel está cerrado; tras initializeRoutedConversation() está abierto y la conversación A seleccionada.spyOn(localStorage, 'getItem').and.callFake, _route reemplazado
leaves the panel closed once a routed chatId resolves when there was no stored preferenceDado getItem devolviendo null.El panel permanece cerrado tras resolver el chatId.spyOn(localStorage, 'getItem') devuelve null

Total del bloque: 7 tests.

Bloque: ChatListComponent — presence re-evaluation tick​

Este bloque sí ejecuta ngOnInit (fixture.detectChanges()) porque el tick de 30 s se arma ahí; cubre el caso de un pod que cae sin evento de desconexión. Store.select devuelve un Subject de tickets para selectTickets y of(false) para el resto. Los tests usan fakeAsync, tick y discardPeriodicTasks.

it()EscenarioResultado esperadoMocks/spies
keeps a fresh contact online across ticks within the 90s staleness thresholdDado un contacto online con lastSeen actual, tras pasar 60 s.isOnline sigue true (por debajo del umbral de 90 s).ticketsSubject, fakeAsync/tick(60_000)
flips a contact to offline after the tick once staleness is crossed, with no new socket eventMismo contacto; pasan 60 s y luego 31 s más (91 s) sin eventos.A los 60 s sigue true; pasado PRESENCE_STALE_MS pasa a false.ticketsSubject, tick
unsubscribes the tick when the component is destroyed (no further recomputation)Dado un tick activo, se destruye el fixture y pasan 120 s.Tras el primer tick ticketToConversation se llamó más de una vez; tras destroy() el contador no aumenta.spyOn<any>(component, 'ticketToConversation').and.callThrough()

Total del bloque: 3 tests.

Bloque: ChatListComponent — resync after a socket reconnection​

Store.dispatch acumula las acciones en un arreglo dispatched; reconnected$ es un Subject. Los eventos emitidos con el socket caído no se reenvían, por eso se recarga la lista al reconectar.

it()EscenarioResultado esperadoMocks/spies
reloads the ticket list when the socket reconnectsDado el componente inicializado, cuando reconnected$ emite.Se despacha exactamente un TicketActions.loadTickets adicional respecto a los del init.reconnected$, dispatched, fakeAsync
stops reloading once the component is destroyedDado el componente destruido, cuando reconnected$ emite.No se despachan nuevos loadTickets.reconnected$, dispatched, fakeAsync

Total del bloque: 2 tests.

Bloque: ChatListComponent — starred list refresh after a toggle in the thread​

StarredMessageService es un spy con findByContact (devuelve página vacía) y changed$ basado en un Subject. La conversación seleccionada tiene contacto contact-1.

it()EscenarioResultado esperadoMocks/spies
reloads the starred list when a message is starred while the panel is openPanel abierto, pestaña starred activa y lista cargada; changed$ emite un destacado.Se recarga con findByContact('contact-1', 1, 20).changed$.next, fakeAsync
invalidates the per-contact cache even with the panel closedPanel cerrado; changed$ emite.No se consulta de inmediato, pero una nueva llamada a loadStarredMessages() sí consulta (1 vez), pues el caché se invalidó.changed$, findByContact.calls.reset()
does not refetch while another details tab is showingPanel abierto con pestaña info; changed$ emite (starred: false).No se llama findByContact.changed$, fakeAsync

Total del bloque: 3 tests.

Bloque: ChatListComponent — scheduled messages pending hydration (verify W17)​

Antes, el badge del reloj solo se hidrataba para el ticket abierto; ngOnInit ahora despacha loadMine para toda la lista.

it()EscenarioResultado esperadoMocks/spies
dispatches ScheduledMessageActions.loadMine({ status: "pending" }) on ngOnInitCuando se ejecuta ngOnInit.dispatch se llama con ScheduledMessageActions.loadMine({ status: 'pending' }).dispatchSpy, fakeAsync

Total del bloque: 1 test.

Bloque: ChatListComponent — syncPopouts (sdd/ticket-lock W2)​

Los floatingConversations se capturan al abrir el popout y no se refrescaban, por lo que un bloqueo posterior era invisible al pre-chequeo de _blockIfLocked(). syncPopouts() lo corrige. Store.select entrega un Subject de tickets.

it()EscenarioResultado esperadoMocks/spies
refreshes an already-open popout when a lock lands after it was opened, so the pre-check sees itPopout abierto con ticket sin bloqueo; luego llega el mismo ticket con lockedBy: 'user-1'.Antes lockedBy es null; después, 'user-1'.ticketsSubject
does not throw and leaves floatingConversations untouched when there are no open popoutsSin popouts abiertos, llega un ticket bloqueado.No lanza y floatingConversations es [].ticketsSubject
leaves a popout unchanged when its ticket is no longer present in the latest snapshotPopout abierto y luego la emisión es [].La conversación del popout es la misma referencia anterior (toBe).ticketsSubject

Total del bloque: 3 tests.

Bloque: ChatListComponent — contact-details info tab restructure (email/labels removed, metadata renamed, contact section added)​

Reestructuración de la pestaña info: se eliminan Email/Fecha de registro/Última conexión y Etiquetas, "Metadata" pasa a "Información adicional" (siempre visible, con estado vacío) y aparece primero la sección "Contacto" con el teléfono. Se prueba contra el DOM (NoopAnimationsModule, Store.select devuelve of([])); TranslateFakeLoader no traduce, así que las aserciones comparan contra las claves i18n. El beforeEach ejecuta fixture.detectChanges(), deja selectedConversationId = 'ticket-1', panel abierto y pestaña info. Helpers: renderWith(overrides) y cdpSections(). Los tests de portapapeles redefinen navigator.clipboard con Object.defineProperty y un spy writeText.

it()EscenarioResultado esperadoMocks/spies
no longer renders an "Información" section with Email/Fecha de registro/Última conexión at allDada una conversación con email, fecha de alta y última conexión.Ningún título de sección es whaticket.contactPanel.info.title y el texto no contiene las claves info.email, info.registeredAt ni info.lastConnection.DOM, renderWith
no longer renders an Etiquetas (labels) section in the info tabDada una conversación con rawLabels.No existen .cdp-labels ni .cdp-label-chip.DOM
always renders the "Información adicional" (metadata) section title, even without metadatacontactMetadata: [].La segunda sección tiene el título whaticket.contactPanel.metadataTitle.DOM
shows the metadata empty-state message when there is no metadatacontactMetadata: [].Se muestra .cdp-empty-hint con whaticket.contactPanel.metadataEmpty y no hay .cdp-meta-item.DOM
renders each metadata item with a copy button instead of the empty-state messageDados dos ítems de metadata.No hay mensaje vacío; hay 2 .cdp-meta-item, cada uno con botón .cdp-meta-item__copy.DOM
copyMetadataValue writes the item value to the clipboard and toggles copiedMetadataIndex by indexCuando se llama copyMetadataValue('12.345.678', 0).writeText('12.345.678'); copiedMetadataIndex pasa de null a 0 y vuelve a null a los 1500 ms.spy writeText, fakeAsync/tick
clicking a metadata item copy button swaps its icon from fa-copy to fa-check and backClick en el botón de copiar de un ítem.Copia el valor, el icono cambia a fa-check (sin fa-copy) y tras 1500 ms vuelve a fa-copy.spy writeText, fakeAsync
renders the "Contact" section FIRST with the phone, without duplicating the contact name (already shown in the header)Conversación con nombre y teléfono.La primera sección tiene título whaticket.contactPanel.contact.title, no incluye contact.name y .cdp-phone contiene el teléfono.DOM
no longer renders the phone in the header identity card (moved to the Contact section)Conversación con teléfono..cdp-card no contiene .cdp-phone.DOM
clicking the phone button in the new Contact section copies the phone via the existing copyContactPhone()/phoneCopied mechanismClick en el botón de teléfono.writeText('5491122334455'), phoneCopied es true, icono fa-check; tras 1500 ms vuelve a fa-copy.spy writeText, fakeAsync

Total del bloque: 10 tests.

Bloque: ChatListComponent — operator filter control visibility (sdd/chat-operator-filter)​

El filtro multi-operador está protegido con *appShowWithPermission="'TICKET_FILTER_BY_AGENT'"; la directiva es la puerta, por lo que no debe consultarse el roster si el nodo nunca se monta. TestBed declara ChatListComponent y ShowWithPermissionDirective; función setup(allowed) con MyPermissionsService como jasmine.SpyObj (hasPermission devuelve of(allowed), hasAnyPermission devuelve of(true)) y TicketService como spy de getFilterableAgents.

it()EscenarioResultado esperadoMocks/spies
renders the .chat-operator-filter control when the user holds TICKET_FILTER_BY_AGENThasPermission resuelve true.Se consultó hasPermission('TICKET_FILTER_BY_AGENT') y existe .chat-operator-filter.myPermissionsService
hides the control and never fetches the agent roster when the user lacks TICKET_FILTER_BY_AGENThasPermission resuelve false.No existe el control y getFilterableAgents no se llama.myPermissionsService, ticketService

Total del bloque: 2 tests.

Bloque: ChatListComponent — status tab permission gating (chat-status-tab-gating)​

Cada pestaña de estado se oculta/muestra con su propio permiso (TICKET_FILTER_STATUS_*), solo a nivel de UI. setup(allowed) provee un MyPermissionsService spy (hasPermission según el mapa allowed, por defecto false; hasAnyPermission devuelve of(true)), renderiza y abre el desplegable de overflow (showStatusDropdown = true) para que se monten las pestañas restantes. Se consulta el DOM con [data-status-tab="..."]. Nota del spec: statusTabs no tiene hoy una entrada con value: null ("Todos").

Los dos primeros it() se generan en un bucle Object.entries(ACRONYM_BY_STATUS).forEach (7 estados: open, inProgress, pending, postponed, resolved, closed, cancelled), por lo que se ejecutan 14 veces en runtime pero aparecen 2 veces en el código fuente.

it()EscenarioResultado esperadoMocks/spies
`hides the "${status}" tab when ${acronym} resolves false` (plantilla, 7 ejecuciones)El permiso de ese estado resuelve false.Se llamó hasPermission(acronym) y no existe la pestaña.myPermissionsService
`shows the "${status}" tab when ${acronym} resolves true` (plantilla, 7 ejecuciones)El permiso de ese estado resuelve true.La pestaña existe en el DOM.myPermissionsService
renders every concrete tab when all seven acronyms resolve trueLos 7 permisos resuelven true.Las 7 pestañas están presentes (withContext(status)).myPermissionsService
a tab is unaffected by another tab's permission (e.g. "resolved" stays visible when only "pending" is denied)pending denegado y resolved permitido.pending no existe; resolved sí.myPermissionsService
getGatedTabPermission returns null for a tab with a null value (the "Todos" case, kept for forward-compat)Se llama getGatedTabPermission({ value: null, labelKey: 'x' }).Devuelve null (no hay nodo DOM que lo respalde hoy).myPermissionsService

Total del bloque: 3 it() fijos + 2 plantillas = 5 it() en el código (17 ejecuciones en runtime).

Bloque: ChatListComponent — scope dropdown permission gating (chat-scope-dropdown-gating)​

Las opciones de alcance "Mis chats", "Todos" y "Sin asignar" son 3 botones con *appShowWithPermission (TICKET_FILTER_SCOPE_MINE, _ALL, _UNASSIGNED); el disparador también se oculta si no hay ninguno permitido. setup(allowed) usa un MyPermissionsService spy donde hasPermission lee de allowed y hasAnyPermission evalúa acronyms.some(...) sobre allowed. Se abre el desplegable (showScopeDropdown = true) y se buscan .chat-scope-dropdown__item por clave i18n; helper triggerEl() para .chat-scope-dropdown__trigger.

Los dos primeros it() se generan en un bucle por alcance (mine, all, unassigned): 6 ejecuciones en runtime, 2 en el código fuente.

it()EscenarioResultado esperadoMocks/spies
`hides the "${scope}" scope option when ${acronym} resolves false (trigger stays visible via the other granted scopes)` (plantilla, 3 ejecuciones)Se conceden los otros dos alcances y se niega el de la iteración.Se llamó hasPermission(acronym), el disparador sigue visible y la opción no existe.myPermissionsService
`shows the "${scope}" scope option when ${acronym} resolves true` (plantilla, 3 ejecuciones)Solo ese permiso resuelve true.La opción existe en el DOM.myPermissionsService
renders all 3 scope options when all 3 permissions are grantedLos 3 permisos en true.Existen las 3 opciones (mine, all, unassigned).myPermissionsService
does not render the scope dropdown trigger at all when all 3 scope permissions are deniedLos 3 permisos en false.No existe el disparador ni .chat-scope-dropdown.myPermissionsService
renders the trigger with only the permitted item when just 1 of 3 scope permissions is grantedSolo TICKET_FILTER_SCOPE_MINE en true.Hay disparador y solo la opción mine.myPermissionsService

Total del bloque: 3 it() fijos + 2 plantillas = 5 it() en el código (9 ejecuciones en runtime).

Bloque: ChatListComponent — buildBaseFilter operator precedence (sdd/chat-operator-filter D1)​

Prueba el método privado buildBaseFilter() sin ejecutar ngOnInit. ChatSocketService, StarredMessageService y Router son objetos vacíos; Store.select devuelve of([]). El estado se fija directamente (selectedScope, currentUser, operatorFilterIds) vía (component as any).

it()EscenarioResultado esperadoMocks/spies
APPROVAL: selectedScope "unassigned" sets filter.unassigned when no operator filter is activeTest de aprobación del comportamiento previo: alcance unassigned sin filtro de operador.filter.unassigned es true y assignedUserIds es undefined.Stubs de TestBed
APPROVAL: agent auto-scoping still applies when no operator filter is activeUsuario agent con alcance all y sin filtro de operador.assignedUserId es 'agent-1', includeUnassigned es true y assignedUserIds es undefined.Stubs de TestBed
operatorFilterIds overrides selectedScope "unassigned"operatorFilterIds = ['op-1','op-2'] con alcance unassigned.assignedUserIds es ['op-1','op-2'] y unassigned es undefined.Stubs de TestBed
operatorFilterIds overrides agent auto-scoping (assignedUserId/includeUnassigned)Usuario agent, alcance all y operatorFilterIds = ['op-1'].assignedUserIds es ['op-1']; assignedUserId e includeUnassigned son undefined.Stubs de TestBed
falls back to legacy scoping when operatorFilterIds is emptyoperatorFilterIds = [], alcance mine y usuario user-9.assignedUserId es 'user-9' y assignedUserIds es undefined.Stubs de TestBed

Total del bloque: 5 tests.

Bloque: ChatListComponent — operator filter persistence across status tabs (sdd/chat-operator-filter AC3)​

Store.dispatch es un jasmine.createSpy; no se ejecuta ngOnInit.

it()EscenarioResultado esperadoMocks/spies
keeps the operator selection scoping the query filter after switching status tabsDado operatorFilterIds = ['op-1','op-2'], cuando se llama onStatusChange('resolved').Se despacha exactamente un loadTickets con filter.assignedUserIds ['op-1','op-2'] y filter.status 'resolved'.dispatchSpy

Total del bloque: 1 test.

Bloque: ChatListComponent — loadOperatorsOnce / onOperatorFilterChange (sdd/chat-operator-filter D9)​

TicketService es un spy cuyo getFilterableAgents devuelve un of con { data: { results: agents } } (dos agentes, el segundo solo con username). Store.dispatch es un spy.

it()EscenarioResultado esperadoMocks/spies
fetches the roster lazily and populates operatorOptionsCuando se llama loadOperatorsOnce().getFilterableAgents se llama 1 vez, operatorOptions es la lista de agentes y loadingOperators es false.ticketService.getFilterableAgents
does not refetch the roster on subsequent calls (lazy-once guard)Dos llamadas seguidas a loadOperatorsOnce().getFilterableAgents se llama solo 1 vez.ticketService.getFilterableAgents
onOperatorFilterChange sets operatorFilterIds and re-dispatches loadTickets scoped to the new selectionCuando se llama onOperatorFilterChange(['op-1']).operatorFilterIds es ['op-1'] y se despacha 1 loadTickets con filter.assignedUserIds ['op-1'].dispatchSpy
getAgentName prefers firstname+lastName and falls back to usernameDado un agente con nombre y apellido y otro solo con username.'Ada Lovelace' y 'grace'.Ninguno

Total del bloque: 4 tests.

Bloque: ChatListComponent — overflow trigger reflects visible-only tabs (chat-status-tab-gating verify bug 1)​

Usa la función auxiliar de módulo configureChatListTestBed(myPermissionsService) (declara ChatListComponent y ShowWithPermissionDirective, ChatSocketService con EMPTY, Router.navigate resuelto). hasPermission lee de allowed y hasAnyPermission devuelve of(true). El disparador +N del desplegable debe contar solo las pestañas de overflow visibles (visibleOverflowTabs$), no la longitud cruda.

it()EscenarioResultado esperadoMocks/spies
shows "+1" (not "+5") when only 1 of the 5 overflow permissions is grantedSolo RESOLVED concedido entre las pestañas de overflow.El disparador existe y su texto es '+1'.myPermissionsService
does not render the overflow trigger at all when 0 overflow permissions are grantedSolo OPEN e IN_PROGRESS concedidos.No existe el disparador ni .chat-status-dropdown.myPermissionsService
shows "+5" when all 5 overflow permissions are granted (raw count == visible count)Los 7 permisos concedidos.El texto del disparador es '+5'.myPermissionsService

Total del bloque: 3 tests.

Bloque: ChatListComponent — status tab row readiness gate (chat-status-tab-gating verify bug 2)​

ShowWithPermissionDirective deniega mientras la comprobación está pendiente y MyPermissionsService hace una llamada HTTP en frío; chatTagRowReady$ evita que las pestañas aparezcan una a una. hasPermission devuelve un ReplaySubject<boolean>(1) (ready$), no un Subject simple, porque suscripciones tardías necesitan la emisión; hasAnyPermission devuelve of(true).

it()EscenarioResultado esperadoMocks/spies
renders only the skeleton (no gated tab buttons, no scope dropdown) while the readiness check is still pendingTras ngOnInit con ready$ sin emitir.Existe [data-testid="chat-tag-row-skeleton"]; no hay [data-status-tab] ni .chat-scope-dropdown.ready$ sin emisión
renders the whole row at once (skeleton gone, scope dropdown + default tabs present) once readiness resolves — no per-control flashready$ emite true y completa.Desaparece el skeleton; aparecen el disparador de alcance y las pestañas open e inProgress.ready$, fakeAsync/tick, discardPeriodicTasks

Total del bloque: 2 tests.

Bloque: ChatListComponent — permission-aware default scope/status (default-scope-status bug fix)​

Antes selectedScope/selectedStatus estaban fijos en 'all'/'open' sin importar los permisos. Se verifica el filtro realmente despachado (TicketActions.loadTickets). GlobalService.profile emite un usuario admin (user-1, no agente) para distinguir mine de all; hasPermission/hasAnyPermission son síncronos según allowed. Un beforeEach/afterEach limpia chat_last_used_scope de localStorage; helper lastLoadTicketsFilter() toma el primer loadTickets despachado.

it()EscenarioResultado esperadoMocks/spies
defaults selectedScope to "mine" (not the hardcoded "all") when only TICKET_FILTER_SCOPE_MINE is grantedSolo SCOPE_MINE y STATUS_OPEN concedidos.selectedScope es 'mine', el filtro tiene assignedUserId 'user-1' y unassigned undefined.dispatchSpy, myPermissionsService
defaults selectedScope to "all" when only TICKET_FILTER_SCOPE_ALL is grantedSolo SCOPE_ALL concedido.'all'; el filtro no tiene assignedUserId ni unassigned.idem
defaults selectedScope to "unassigned" when only TICKET_FILTER_SCOPE_UNASSIGNED is grantedSolo SCOPE_UNASSIGNED concedido.'unassigned' y filter.unassigned es true.idem
prefers "mine" over "all" when both are granted (priority order)SCOPE_MINE y SCOPE_ALL concedidos.'mine' y assignedUserId 'user-1'.idem
keeps the hardcoded "all" fallback when the user has none of the 3 scope permissionsNingún permiso de alcance.selectedScope es 'all'.idem
restores the last-used scope from localStorage instead of the priority order, when still grantedlocalStorage guarda 'unassigned' y los 3 alcances están concedidos.selectedScope es 'unassigned' y filter.unassigned es true.localStorage real
falls back to priority order when the last-used scope is no longer grantedGuardado 'unassigned' pero solo MINE y ALL concedidos.'mine'.localStorage real
selectScope() persists the chosen scope to localStorage so it survives a reloadSe llama selectScope('all', new Event('click')).localStorage.getItem('chat_last_used_scope') es 'all'.localStorage real
defaults selectedStatus to "resolved" (not the hardcoded "open") when only TICKET_FILTER_STATUS_RESOLVED is grantedSolo STATUS_RESOLVED (y SCOPE_ALL).selectedStatus y filter.status son 'resolved'.idem
prefers "open" over other granted statuses when both are granted (tab order priority)OPEN y RESOLVED concedidos.'open' en estado y filtro.idem
picks the first granted status in tab order when "open" is not grantedPENDING y CLOSED concedidos, sin OPEN.selectedStatus es 'pending'.idem
keeps the hardcoded "open" fallback when the user has none of the 7 status permissionsNingún permiso de estado.selectedStatus es 'open'.idem

Total del bloque: 12 tests.


Total de la página​

chat-list.component.spec.ts: 127 it() en el código fuente (59 + 7 + 3 + 2 + 3 + 1 + 3 + 10 + 2 + 5 + 5 + 5 + 1 + 4 + 3 + 2 + 12), contados con rg -c "\bit\(" . Cuatro it() con plantilla se expanden en bucles (7 estados y 3 alcances), de modo que en ejecución corren 143 especificaciones. No hay xit, fdescribe, fit ni specs vacíos.