Newer
Older
navi-1 / webclient / tests / unit / components / settings / settingsView.test.js
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
import { nextTick } from 'vue'
import { setActivePinia, createPinia } from 'pinia'
import { mount } from '@vue/test-utils'
import { useAuthStore } from '@/stores/auth.js'
import SettingsView from '@/components/settings/SettingsView.vue'

// Stub every panel — the view only orchestrates tabs and mounts one at a time.
vi.mock('@/components/settings/ApiKeysPanel.vue', () => ({
  default: { name: 'ApiKeysPanel', template: '<div data-test="account-stub" />' },
}))
vi.mock('@/components/settings/NotificationSettingsPanel.vue', () => ({
  default: { name: 'NotificationSettingsPanel', template: '<div data-test="notifications-stub" />' },
}))
vi.mock('@/components/settings/SynapseReactionsPanel.vue', () => ({
  default: { name: 'SynapseReactionsPanel', template: '<div data-test="reactions-stub" />' },
}))
vi.mock('@/components/settings/SynapseTargetsPanel.vue', () => ({
  default: { name: 'SynapseTargetsPanel', template: '<div data-test="targets-stub" />' },
}))
vi.mock('@/components/settings/McpKeysPanel.vue', () => ({
  default: { name: 'McpKeysPanel', template: '<div data-test="mcp-stub" />' },
}))
vi.mock('@/components/settings/AdminToolsPanel.vue', () => ({
  default: { name: 'AdminToolsPanel', template: '<div data-test="admin-tools-stub" />' },
}))
vi.mock('@/components/settings/AppPanel.vue', () => ({
  default: { name: 'AppPanel', template: '<div data-test="app-stub" />' },
}))

function panel(wrapper, test) {
  return wrapper.find(`[data-test="${test}"]`)
}

function tabs(wrapper) {
  return wrapper.findAll('[role="tab"]')
}

// replaceState rather than location.hash: it sets the address without also
// firing the hashchange this very component listens to.
function setHash(hash) {
  history.replaceState(null, '', hash ?? location.pathname)
}

function lastHash() {
  return history.replaceState.mock.calls.at(-1)?.[2]
}

describe('SettingsView sections', () => {
  beforeEach(() => {
    setActivePinia(createPinia())
    setHash()
    vi.spyOn(history, 'replaceState')
  })

  afterEach(() => {
    vi.restoreAllMocks()
    setHash()
  })

  it('opens App by default, with its panel mounted', () => {
    const wrapper = mount(SettingsView)
    expect(tabs(wrapper)).toHaveLength(5)
    expect(tabs(wrapper).map(t => t.text())).toEqual([
      'App',
      'Account',
      'Notifications',
      'Synapse',
      'MCP',
    ])
    expect(tabs(wrapper)[0].classes()).toContain('tab-active')
    expect(panel(wrapper, 'app-stub').exists()).toBe(true)
    expect(panel(wrapper, 'account-stub').exists()).toBe(false)
  })

  it('mounts a single panel per tab', async () => {
    const wrapper = mount(SettingsView)
    await tabs(wrapper)[1].trigger('click')

    expect(tabs(wrapper)[1].classes()).toContain('tab-active')
    expect(panel(wrapper, 'account-stub').exists()).toBe(true)
    expect(panel(wrapper, 'app-stub').exists()).toBe(false)
  })

  it('shows both Synapse panels together on the Synapse tab', async () => {
    const wrapper = mount(SettingsView)
    const synapseTab = tabs(wrapper)[3]
    await synapseTab.trigger('click')

    expect(synapseTab.classes()).toContain('tab-active')
    expect(panel(wrapper, 'account-stub').exists()).toBe(false)
    expect(panel(wrapper, 'reactions-stub').exists()).toBe(true)
    expect(panel(wrapper, 'targets-stub').exists()).toBe(true)
  })

  it('mounts MCP keys only on the MCP tab', async () => {
    const wrapper = mount(SettingsView)
    await tabs(wrapper)[4].trigger('click')

    expect(panel(wrapper, 'mcp-stub').exists()).toBe(true)
    expect(panel(wrapper, 'account-stub').exists()).toBe(false)
  })

  it('hides the tool reload from a non-admin', async () => {
    useAuthStore().user = { id: 'u1', role: 'user' }
    const wrapper = mount(SettingsView)
    await tabs(wrapper)[4].trigger('click')

    expect(panel(wrapper, 'mcp-stub').exists()).toBe(true)
    expect(panel(wrapper, 'admin-tools-stub').exists()).toBe(false)
  })

  it('shows the tool reload to an admin, inside the MCP tab', async () => {
    useAuthStore().user = { id: 'u1', role: 'admin' }
    const wrapper = mount(SettingsView)

    // Not on the default tab...
    expect(panel(wrapper, 'admin-tools-stub').exists()).toBe(false)

    await tabs(wrapper)[4].trigger('click')
    expect(panel(wrapper, 'admin-tools-stub').exists()).toBe(true)
    expect(panel(wrapper, 'mcp-stub').exists()).toBe(true)
  })
})

// A section can be linked to directly — `#settings/mcp` has to open MCP, and
// the address has to keep naming what is on screen.
describe('SettingsView section anchors', () => {
  beforeEach(() => {
    setActivePinia(createPinia())
    vi.spyOn(history, 'replaceState')
  })

  afterEach(() => {
    vi.restoreAllMocks()
    setHash()
  })

  it('opens the section named in the hash', () => {
    setHash('#settings/synapse')
    const wrapper = mount(SettingsView)

    expect(panel(wrapper, 'reactions-stub').exists()).toBe(true)
    expect(panel(wrapper, 'app-stub').exists()).toBe(false)
  })

  it('writes the section into the address when the tab changes', async () => {
    setHash('#settings/app')
    const wrapper = mount(SettingsView)
    await tabs(wrapper)[4].trigger('click')

    expect(lastHash()).toBe('#settings/mcp')
  })

  // Back should leave Settings, not walk back through the tabs.
  it('replaces rather than pushes history entries', async () => {
    setHash('#settings/app')
    const push = vi.spyOn(history, 'pushState')
    const wrapper = mount(SettingsView)
    await tabs(wrapper)[1].trigger('click')

    expect(push).not.toHaveBeenCalled()
  })

  it('falls back to the first tab for an unknown section, and says so in the address', () => {
    setHash('#settings/nope')
    const wrapper = mount(SettingsView)

    expect(panel(wrapper, 'app-stub').exists()).toBe(true)
    expect(lastHash()).toBe('#settings/app')
  })

  it('names the first tab even when the hash carries no section', () => {
    setHash('#settings')
    mount(SettingsView)

    expect(lastHash()).toBe('#settings/app')
  })

  // The sidebar button writes plain `#settings` while this view is already
  // mounted — that should bring the reader back to the first tab.
  it('returns to the first tab when the sidebar writes a bare #settings', async () => {
    setHash('#settings/mcp')
    const wrapper = mount(SettingsView)
    expect(panel(wrapper, 'mcp-stub').exists()).toBe(true)

    setHash('#settings')
    window.dispatchEvent(new Event('hashchange'))
    await nextTick()

    expect(panel(wrapper, 'app-stub').exists()).toBe(true)
    expect(panel(wrapper, 'mcp-stub').exists()).toBe(false)
  })

  it('ignores a hashchange that is not ours', async () => {
    setHash('#settings/mcp')
    const wrapper = mount(SettingsView)

    setHash('#some-session-id')
    window.dispatchEvent(new Event('hashchange'))
    await nextTick()

    expect(panel(wrapper, 'mcp-stub').exists()).toBe(true)
  })
})