Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
167 changes: 167 additions & 0 deletions core/src/tests/views/AccountMenu.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,167 @@
/*!
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/

import { mount } from '@vue/test-utils'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import NcAvatar from '@nextcloud/vue/components/NcAvatar'
import NcHeaderMenu from '@nextcloud/vue/components/NcHeaderMenu'
import AccountMenu from '../../views/AccountMenu.vue'

const capabilities = vi.hoisted(() => ({
getCapabilities: vi.fn(() => ({}) as Record<string, unknown>),
}))
vi.mock('@nextcloud/capabilities', () => capabilities)

// Components in this tree read initial state at module scope, so the default
// must honour the fallback before any test runs
const initialState = vi.hoisted(() => ({
loadState: vi.fn((_app: string, _key: string, fallback: unknown) => fallback),
}))
vi.mock('@nextcloud/initial-state', () => initialState)

const eventBus = vi.hoisted(() => ({
emit: vi.fn(),
subscribe: vi.fn(),
unsubscribe: vi.fn(),
}))
vi.mock('@nextcloud/event-bus', () => eventBus)

const axiosMock = vi.hoisted(() => ({
default: { get: vi.fn() },
}))
vi.mock('@nextcloud/axios', () => axiosMock)

vi.mock('@nextcloud/auth', () => ({
getCurrentUser: () => ({ uid: 'alice', displayName: 'Alice' }),
getRequestToken: () => 'token',
}))

vi.mock('@nextcloud/password-confirmation', () => ({
addPasswordConfirmationInterceptors: vi.fn(),
PwdConfirmationMode: { Strict: 0 },
}))

const NO_STATUS = { status: null, icon: null, message: null }

const MOUNT_OPTIONS = {
stubs: {
AccountMenuEntry: true,
AccountMenuProfileEntry: true,
},
}

const SETTINGS_NAV_ENTRIES = {
profile: {
id: 'profile',
name: 'View profile',
href: '/u/alice',
active: false,
},
}

/**
* NcAvatar subscribes to the status event too, and children mount first, so
* indexing `subscribe.mock.calls` would grab the wrong handler.
*/
function emitToSubscribers(event: string, payload: unknown) {
const handlers = eventBus.subscribe.mock.calls
.filter(([name]) => name === event)
.map(([, handler]) => handler)

expect(handlers.length).toBeGreaterThan(0)
handlers.forEach((handler) => handler(payload))
}

function mockUserStatusState(userStatus: unknown) {
initialState.loadState.mockImplementation((app: string, key: string, fallback: unknown) => {
if (app === 'core' && key === 'settingsNavEntries') {
return SETTINGS_NAV_ENTRIES
}
if (app === 'user_status' && key === 'status') {
return userStatus
}
return fallback
})
}

describe('core: AccountMenu', () => {
beforeEach(() => {
vi.clearAllMocks()
capabilities.getCapabilities.mockReturnValue({ user_status: { enabled: true } })
mockUserStatusState({
userId: 'alice',
status: 'dnd',
icon: '🎉',
message: 'Party time',
statusIsUserDefined: true,
messageIsPredefined: false,
messageId: null,
clearAt: null,
})
})

it('preloads the avatar with the status from the initial state', () => {
const wrapper = mount(AccountMenu, MOUNT_OPTIONS)

expect(wrapper.findComponent(NcAvatar).props('preloadedUserStatus'))
.toEqual({ status: 'dnd', icon: '🎉', message: 'Party time' })
})

it('describes the status for assistive technologies', () => {
const wrapper = mount(AccountMenu, MOUNT_OPTIONS)

expect(wrapper.findComponent(NcHeaderMenu).props('description'))
.toBe('Avatar of Alice — Do not disturb — 🎉 — Party time')
})

it('does not request the status over the network', () => {
mount(AccountMenu, MOUNT_OPTIONS)

expect(axiosMock.default.get).not.toHaveBeenCalled()
})

it('preloads an empty status when the user_status app is disabled', () => {
capabilities.getCapabilities.mockReturnValue({})
mockUserStatusState(null)

const wrapper = mount(AccountMenu, MOUNT_OPTIONS)

expect(wrapper.findComponent(NcAvatar).props('preloadedUserStatus')).toEqual(NO_STATUS)
expect(wrapper.findComponent(NcHeaderMenu).props('description')).toBe('Avatar of Alice')
})

it('preloads an empty status when the app provided an unusable payload', () => {
mockUserStatusState([])

const wrapper = mount(AccountMenu, MOUNT_OPTIONS)

expect(wrapper.findComponent(NcAvatar).props('preloadedUserStatus')).toEqual(NO_STATUS)
expect(wrapper.findComponent(NcHeaderMenu).props('description')).toBe('Avatar of Alice')
})

it('updates the status when the event bus announces a change', async () => {
const wrapper = mount(AccountMenu, MOUNT_OPTIONS)

emitToSubscribers('user_status:status.updated', { userId: 'alice', status: 'online', icon: null, message: null })
await wrapper.vm.$nextTick()

expect(wrapper.findComponent(NcAvatar).props('preloadedUserStatus')).toEqual({
status: 'online',
icon: null,
message: null,
})
expect(wrapper.findComponent(NcHeaderMenu).props('description')).toBe('Avatar of Alice — Online')
})

it('ignores status updates for other users', async () => {
const wrapper = mount(AccountMenu, MOUNT_OPTIONS)

emitToSubscribers('user_status:status.updated', { userId: 'bob', status: 'online', icon: null, message: null })
await wrapper.vm.$nextTick()

expect(wrapper.findComponent(NcAvatar).props('preloadedUserStatus'))
.toEqual({ status: 'dnd', icon: '🎉', message: 'Party time' })
})
})
58 changes: 29 additions & 29 deletions core/src/views/AccountMenu.vue
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,7 @@
:aria-label="t('core', 'Settings menu')"
:description="avatarDescription">
<template #trigger>
<!-- The `key` is a hack as NcAvatar does not handle updating the preloaded status on show status change -->
<NcAvatar
:key="String(showUserStatus)"
class="account-menu__avatar"
disable-menu
disable-tooltip
Expand Down Expand Up @@ -40,18 +38,15 @@

<script lang="ts">
import { getCurrentUser } from '@nextcloud/auth'
import axios from '@nextcloud/axios'
import { getCapabilities } from '@nextcloud/capabilities'
import { emit, subscribe } from '@nextcloud/event-bus'
import { loadState } from '@nextcloud/initial-state'
import { t } from '@nextcloud/l10n'
import { generateOcsUrl } from '@nextcloud/router'
import { defineComponent } from 'vue'
import NcAvatar from '@nextcloud/vue/components/NcAvatar'
import NcHeaderMenu from '@nextcloud/vue/components/NcHeaderMenu'
import AccountMenuEntry from '../components/AccountMenu/AccountMenuEntry.vue'
import AccountMenuProfileEntry from '../components/AccountMenu/AccountMenuProfileEntry.vue'
import logger from '../logger.js'

interface ISettingsNavigationEntry {
/**
Expand Down Expand Up @@ -114,6 +109,34 @@ const USER_DEFINABLE_STATUSES = [{
subline: t('user_status', 'Appear offline'),
}]

interface IPreloadedUserStatus {
status: string | null
icon: string | null
message: string | null
}

/**
* NcAvatar fetches the status itself when `preloadedUserStatus` is falsy, so
* always hand it a shape, even when there is nothing to show.
*/
function loadInitialUserStatus(): { showUserStatus: boolean, userStatus: IPreloadedUserStatus } {
const userStatus: IPreloadedUserStatus = { status: null, icon: null, message: null }

if (!getCapabilities()?.user_status?.enabled) {
return { showUserStatus: false, userStatus }
}

// JSDataService emits `[]`, not an object, when there is no session user
const state = loadState<Partial<IPreloadedUserStatus> | unknown[] | null>('user_status', 'status', null)
if (state === null || typeof state !== 'object' || Array.isArray(state)) {
return { showUserStatus: false, userStatus }
}

// `avatarDescription` joins everything truthy in here, so drop the payload's other fields
const { status = null, icon = null, message = null } = state
return { showUserStatus: true, userStatus: { status, icon, message } }
}

export default defineComponent({
name: 'AccountMenu',

Expand All @@ -140,14 +163,7 @@ export default defineComponent({
},

data() {
return {
showUserStatus: false,
userStatus: {
status: null,
icon: null,
message: null,
},
}
return loadInitialUserStatus()
},

computed: {
Expand All @@ -167,22 +183,6 @@ export default defineComponent({
},
},

async created() {
if (!getCapabilities()?.user_status?.enabled) {
return
}

const url = generateOcsUrl('/apps/user_status/api/v1/user_status')
try {
const response = await axios.get(url)
const { status, icon, message } = response.data.ocs.data
this.userStatus = { status, icon, message }
} catch (error) {
logger.error('Failed to load user status', { error })
}
this.showUserStatus = true
},

mounted() {
subscribe('user_status:status.updated', this.handleUserStatusUpdated)
emit('core:user-menu:mounted')
Expand Down
Loading