diff --git a/src/components/topbar/CurrentUserButton.spec.ts b/src/components/topbar/CurrentUserButton.spec.ts
new file mode 100644
index 000000000..abb2a08b7
--- /dev/null
+++ b/src/components/topbar/CurrentUserButton.spec.ts
@@ -0,0 +1,122 @@
+import { VueWrapper, mount } from '@vue/test-utils'
+import Button from 'primevue/button'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import { h } from 'vue'
+import { createI18n } from 'vue-i18n'
+
+import enMessages from '@/locales/en/main.json'
+
+import CurrentUserButton from './CurrentUserButton.vue'
+
+// Mock all firebase modules
+vi.mock('firebase/app', () => ({
+ initializeApp: vi.fn(),
+ getApp: vi.fn()
+}))
+
+vi.mock('firebase/auth', () => ({
+ getAuth: vi.fn(),
+ setPersistence: vi.fn(),
+ browserLocalPersistence: {},
+ onAuthStateChanged: vi.fn(),
+ signInWithEmailAndPassword: vi.fn(),
+ signOut: vi.fn()
+}))
+
+// Mock pinia
+vi.mock('pinia')
+
+// Mock the useCurrentUser composable
+vi.mock('@/composables/auth/useCurrentUser', () => ({
+ useCurrentUser: vi.fn(() => ({
+ isLoggedIn: true,
+ userPhotoUrl: 'https://example.com/avatar.jpg',
+ userDisplayName: 'Test User',
+ userEmail: 'test@example.com'
+ }))
+}))
+
+// Mock the UserAvatar component
+vi.mock('@/components/common/UserAvatar.vue', () => ({
+ default: {
+ name: 'UserAvatarMock',
+ render() {
+ return h('div', 'Avatar')
+ }
+ }
+}))
+
+// Mock the CurrentUserPopover component
+vi.mock('./CurrentUserPopover.vue', () => ({
+ default: {
+ name: 'CurrentUserPopoverMock',
+ render() {
+ return h('div', 'Popover Content')
+ },
+ emits: ['close']
+ }
+}))
+
+describe('CurrentUserButton', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ })
+
+ const mountComponent = (): VueWrapper => {
+ const i18n = createI18n({
+ legacy: false,
+ locale: 'en',
+ messages: { en: enMessages }
+ })
+
+ return mount(CurrentUserButton, {
+ global: {
+ plugins: [i18n],
+ stubs: {
+ // Use shallow mount for popover to make testing easier
+ Popover: {
+ template: '
',
+ methods: {
+ toggle: vi.fn(),
+ hide: vi.fn()
+ }
+ },
+ Button: true
+ }
+ }
+ })
+ }
+
+ it('renders correctly when user is logged in', () => {
+ const wrapper = mountComponent()
+ expect(wrapper.findComponent(Button).exists()).toBe(true)
+ })
+
+ it('toggles popover on button click', async () => {
+ const wrapper = mountComponent()
+ const popoverToggleSpy = vi.fn()
+
+ // Override the ref with a mock implementation
+ // @ts-expect-error - accessing internal Vue component vm
+ wrapper.vm.popover = { toggle: popoverToggleSpy }
+
+ await wrapper.findComponent(Button).trigger('click')
+ expect(popoverToggleSpy).toHaveBeenCalled()
+ })
+
+ it('hides popover when closePopover is called', async () => {
+ const wrapper = mountComponent()
+
+ // Replace the popover.hide method with a spy
+ const popoverHideSpy = vi.fn()
+ // @ts-expect-error - accessing internal Vue component vm
+ wrapper.vm.popover = { hide: popoverHideSpy }
+
+ // Directly call the closePopover method through the component instance
+ // @ts-expect-error - accessing internal Vue component vm
+ wrapper.vm.closePopover()
+
+ // Verify that popover.hide was called
+ expect(popoverHideSpy).toHaveBeenCalled()
+ })
+})
diff --git a/src/components/topbar/CurrentUserButton.vue b/src/components/topbar/CurrentUserButton.vue
index a7644143e..c5d8809d4 100644
--- a/src/components/topbar/CurrentUserButton.vue
+++ b/src/components/topbar/CurrentUserButton.vue
@@ -19,7 +19,7 @@
-
+
@@ -40,4 +40,8 @@ const popover = ref | null>(null)
const photoURL = computed(
() => userPhotoUrl.value ?? undefined
)
+
+const closePopover = () => {
+ popover.value?.hide()
+}
diff --git a/src/components/topbar/CurrentUserPopover.spec.ts b/src/components/topbar/CurrentUserPopover.spec.ts
new file mode 100644
index 000000000..954359021
--- /dev/null
+++ b/src/components/topbar/CurrentUserPopover.spec.ts
@@ -0,0 +1,173 @@
+import { VueWrapper, mount } from '@vue/test-utils'
+import Button from 'primevue/button'
+import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest'
+import { h } from 'vue'
+import { createI18n } from 'vue-i18n'
+
+import enMessages from '@/locales/en/main.json'
+
+import CurrentUserPopover from './CurrentUserPopover.vue'
+
+// Mock all firebase modules
+vi.mock('firebase/app', () => ({
+ initializeApp: vi.fn(),
+ getApp: vi.fn()
+}))
+
+vi.mock('firebase/auth', () => ({
+ getAuth: vi.fn(),
+ setPersistence: vi.fn(),
+ browserLocalPersistence: {},
+ onAuthStateChanged: vi.fn(),
+ signInWithEmailAndPassword: vi.fn(),
+ signOut: vi.fn()
+}))
+
+// Mock pinia
+vi.mock('pinia')
+
+// Mock showSettingsDialog and showTopUpCreditsDialog
+const mockShowSettingsDialog = vi.fn()
+const mockShowTopUpCreditsDialog = vi.fn()
+
+// Mock window.open
+const originalWindowOpen = window.open
+beforeEach(() => {
+ window.open = vi.fn()
+})
+
+afterAll(() => {
+ window.open = originalWindowOpen
+})
+
+// Mock the useCurrentUser composable
+vi.mock('@/composables/auth/useCurrentUser', () => ({
+ useCurrentUser: vi.fn(() => ({
+ userPhotoUrl: 'https://example.com/avatar.jpg',
+ userDisplayName: 'Test User',
+ userEmail: 'test@example.com'
+ }))
+}))
+
+// Mock the useFirebaseAuthActions composable
+vi.mock('@/composables/auth/useFirebaseAuthActions', () => ({
+ useFirebaseAuthActions: vi.fn(() => ({
+ fetchBalance: vi.fn().mockResolvedValue(undefined)
+ }))
+}))
+
+// Mock the dialog service
+vi.mock('@/services/dialogService', () => ({
+ useDialogService: vi.fn(() => ({
+ showSettingsDialog: mockShowSettingsDialog,
+ showTopUpCreditsDialog: mockShowTopUpCreditsDialog
+ }))
+}))
+
+// Mock UserAvatar component
+vi.mock('@/components/common/UserAvatar.vue', () => ({
+ default: {
+ name: 'UserAvatarMock',
+ render() {
+ return h('div', 'Avatar')
+ }
+ }
+}))
+
+// Mock UserCredit component
+vi.mock('@/components/common/UserCredit.vue', () => ({
+ default: {
+ name: 'UserCreditMock',
+ render() {
+ return h('div', 'Credit: 100')
+ }
+ }
+}))
+
+describe('CurrentUserPopover', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ })
+
+ const mountComponent = (): VueWrapper => {
+ const i18n = createI18n({
+ legacy: false,
+ locale: 'en',
+ messages: { en: enMessages }
+ })
+
+ return mount(CurrentUserPopover, {
+ global: {
+ plugins: [i18n],
+ stubs: {
+ Divider: true,
+ Button: true
+ }
+ }
+ })
+ }
+
+ it('renders user information correctly', () => {
+ const wrapper = mountComponent()
+
+ expect(wrapper.text()).toContain('Test User')
+ expect(wrapper.text()).toContain('test@example.com')
+ })
+
+ it('opens user settings and emits close event when settings button is clicked', async () => {
+ const wrapper = mountComponent()
+
+ // Find all buttons and get the settings button (first one)
+ const buttons = wrapper.findAllComponents(Button)
+ const settingsButton = buttons[0]
+
+ // Click the settings button
+ await settingsButton.trigger('click')
+
+ // Verify showSettingsDialog was called with 'user'
+ expect(mockShowSettingsDialog).toHaveBeenCalledWith('user')
+
+ // Verify close event was emitted
+ expect(wrapper.emitted('close')).toBeTruthy()
+ expect(wrapper.emitted('close')!.length).toBe(1)
+ })
+
+ it('opens API pricing docs and emits close event when API pricing button is clicked', async () => {
+ const wrapper = mountComponent()
+
+ // Find all buttons and get the API pricing button (second one)
+ const buttons = wrapper.findAllComponents(Button)
+ const apiPricingButton = buttons[1]
+
+ // Click the API pricing button
+ await apiPricingButton.trigger('click')
+
+ // Verify window.open was called with the correct URL
+ expect(window.open).toHaveBeenCalledWith(
+ 'https://docs.comfy.org/tutorials/api-nodes/pricing',
+ '_blank'
+ )
+
+ // Verify close event was emitted
+ expect(wrapper.emitted('close')).toBeTruthy()
+ expect(wrapper.emitted('close')!.length).toBe(1)
+ })
+
+ it('opens top-up dialog and emits close event when top-up button is clicked', async () => {
+ const wrapper = mountComponent()
+
+ // Find all buttons and get the top-up button (last one)
+ const buttons = wrapper.findAllComponents(Button)
+ const topUpButton = buttons[buttons.length - 1]
+
+ // Click the top-up button
+ await topUpButton.trigger('click')
+
+ // Verify showTopUpCreditsDialog was called
+ expect(mockShowTopUpCreditsDialog).toHaveBeenCalled()
+
+ // Verify close event was emitted
+ expect(wrapper.emitted('close')).toBeTruthy()
+ expect(wrapper.emitted('close')!.length).toBe(1)
+ })
+})
diff --git a/src/components/topbar/CurrentUserPopover.vue b/src/components/topbar/CurrentUserPopover.vue
index 8f8751526..52e364d95 100644
--- a/src/components/topbar/CurrentUserPopover.vue
+++ b/src/components/topbar/CurrentUserPopover.vue
@@ -72,20 +72,27 @@ import { useCurrentUser } from '@/composables/auth/useCurrentUser'
import { useFirebaseAuthActions } from '@/composables/auth/useFirebaseAuthActions'
import { useDialogService } from '@/services/dialogService'
+const emit = defineEmits<{
+ close: []
+}>()
+
const { userDisplayName, userEmail, userPhotoUrl } = useCurrentUser()
const authActions = useFirebaseAuthActions()
const dialogService = useDialogService()
const handleOpenUserSettings = () => {
dialogService.showSettingsDialog('user')
+ emit('close')
}
const handleTopUp = () => {
dialogService.showTopUpCreditsDialog()
+ emit('close')
}
const handleOpenApiPricing = () => {
window.open('https://docs.comfy.org/tutorials/api-nodes/pricing', '_blank')
+ emit('close')
}
onMounted(() => {