import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' import { mount, VueWrapper } from '@vue/test-utils' import { nextTick } from 'vue' import LoginButton from '../LoginButton.vue' import { authenticateParent, logoutParent } from '../../../stores/auth' vi.mock('vue-router', () => ({ useRouter: vi.fn(() => ({ push: vi.fn() })), })) // Mock imageCache module vi.mock('@/common/imageCache', () => ({ getCachedImageUrl: vi.fn(async (imageId: string) => `blob:mock-url-${imageId}`), revokeImageUrl: vi.fn(), revokeAllImageUrls: vi.fn(), })) // Create real Vue refs for isParentAuthenticated and isParentPersistent using vi.hoisted. // Real Vue refs are required so Vue templates auto-unwrap them correctly in v-if conditions. const { isParentAuthenticatedRef, isParentPersistentRef } = vi.hoisted(() => { // eslint-disable-next-line @typescript-eslint/no-require-imports const { ref } = require('vue') return { isParentAuthenticatedRef: ref(false), isParentPersistentRef: ref(false), } }) vi.mock('../../../stores/auth', () => ({ authenticateParent: vi.fn(), isParentAuthenticated: isParentAuthenticatedRef, isParentPersistent: isParentPersistentRef, logoutParent: vi.fn(), logoutUser: vi.fn(), consumePendingReturnUrl: vi.fn(() => null), hasPendingReturnUrl: vi.fn(() => false), clearPendingReturnUrl: vi.fn(), })) global.fetch = vi.fn() describe('LoginButton', () => { let wrapper: VueWrapper beforeEach(() => { vi.clearAllMocks() isParentAuthenticatedRef.value = false isParentPersistentRef.value = false ;(global.fetch as any).mockResolvedValue({ ok: true, json: async () => ({ image_id: 'test-image-id', first_name: 'John', email: 'john@example.com', }), }) }) afterEach(() => { if (wrapper) { wrapper.unmount() } }) describe('Avatar Rendering', () => { it('renders avatar with image when image_id is available', async () => { wrapper = mount(LoginButton) await nextTick() // Wait for fetchUserProfile to complete await new Promise((resolve) => setTimeout(resolve, 100)) // Component should be mounted and functional expect(wrapper.exists()).toBe(true) // Should have attempted to load user profile with credentials expect(global.fetch).toHaveBeenCalledWith('/api/user/profile', { credentials: 'include', }) }) it('renders avatar with initial when no image_id', async () => { ;(global.fetch as any).mockResolvedValueOnce({ ok: true, json: async () => ({ first_name: 'Jane' }), }) wrapper = mount(LoginButton) await nextTick() await new Promise((resolve) => setTimeout(resolve, 50)) const avatarText = wrapper.find('.avatar-text') expect(avatarText.exists()).toBe(true) expect(avatarText.text()).toBe('J') }) it('renders ? when no first_name available', async () => { ;(global.fetch as any).mockResolvedValueOnce({ ok: true, json: async () => ({}), }) wrapper = mount(LoginButton) await nextTick() await new Promise((resolve) => setTimeout(resolve, 50)) const avatarText = wrapper.find('.avatar-text') expect(avatarText.exists()).toBe(true) expect(avatarText.text()).toBe('?') }) it('shows loading state initially', async () => { wrapper = mount(LoginButton) const loading = wrapper.find('.avatar-text') expect(loading.exists()).toBe(true) expect(loading.text()).toBe('...') }) }) describe('Dropdown Interactions', () => { beforeEach(async () => { isParentAuthenticatedRef.value = true wrapper = mount(LoginButton) await nextTick() await new Promise((resolve) => setTimeout(resolve, 50)) }) it('opens dropdown on click when authenticated', async () => { const button = wrapper.find('.avatar-btn') await button.trigger('click') await nextTick() const dropdown = wrapper.find('.dropdown-menu') expect(dropdown.exists()).toBe(true) }) it('closes dropdown on Escape key', async () => { const button = wrapper.find('.avatar-btn') await button.trigger('click') await nextTick() let dropdown = wrapper.find('.dropdown-menu') expect(dropdown.exists()).toBe(true) await button.trigger('keydown', { key: 'Escape' }) await nextTick() dropdown = wrapper.find('.dropdown-menu') expect(dropdown.exists()).toBe(false) }) it('closes dropdown on outside click', async () => { const button = wrapper.find('.avatar-btn') await button.trigger('click') await nextTick() let dropdown = wrapper.find('.dropdown-menu') expect(dropdown.exists()).toBe(true) // Simulate outside click by directly calling the handler const outsideClick = new MouseEvent('mousedown', { bubbles: true, cancelable: true, }) document.dispatchEvent(outsideClick) await nextTick() dropdown = wrapper.find('.dropdown-menu') expect(dropdown.exists()).toBe(false) }) }) describe('Keyboard Navigation', () => { beforeEach(async () => { isParentAuthenticatedRef.value = true wrapper = mount(LoginButton) await nextTick() await new Promise((resolve) => setTimeout(resolve, 50)) }) it('opens dropdown on Enter key', async () => { const button = wrapper.find('.avatar-btn') await button.trigger('keydown', { key: 'Enter' }) await nextTick() const dropdown = wrapper.find('.dropdown-menu') expect(dropdown.exists()).toBe(true) }) it('opens dropdown on Space key', async () => { const button = wrapper.find('.avatar-btn') await button.trigger('keydown', { key: ' ' }) await nextTick() const dropdown = wrapper.find('.dropdown-menu') expect(dropdown.exists()).toBe(true) }) it('navigates menu items with arrow keys', async () => { const button = wrapper.find('.avatar-btn') await button.trigger('click') await nextTick() const dropdown = wrapper.find('.dropdown-menu') expect(dropdown.exists()).toBe(true) // First item should be focused let menuItems = wrapper.findAll('.menu-item') expect(menuItems[0].attributes('aria-selected')).toBe('true') // Press down arrow await button.trigger('keydown', { key: 'ArrowDown' }) await nextTick() menuItems = wrapper.findAll('.menu-item') expect(menuItems[1].attributes('aria-selected')).toBe('true') }) }) describe('ARIA Attributes', () => { it('has correct ARIA attributes', async () => { // Note: Due to vi.mock hoisting limitations, isParentAuthenticated // value is set when the mock is first created wrapper = mount(LoginButton) await nextTick() const button = wrapper.find('.avatar-btn') // Should have ar haspopup regardless of auth state expect(button.attributes('aria-haspopup')).toBe('menu') expect(button.attributes('aria-expanded')).toBe('false') // aria-label will vary based on the initial mock state expect(button.attributes('aria-label')).toBeTruthy() }) it('has correct ARIA attributes when authenticated', async () => { isParentAuthenticatedRef.value = true wrapper = mount(LoginButton) await nextTick() await new Promise((resolve) => setTimeout(resolve, 50)) const button = wrapper.find('.avatar-btn') expect(button.attributes('aria-label')).toBe('Parent menu') expect(button.attributes('aria-haspopup')).toBe('menu') expect(button.attributes('aria-expanded')).toBe('false') }) it('updates aria-expanded when dropdown opens', async () => { isParentAuthenticatedRef.value = true wrapper = mount(LoginButton) await nextTick() await new Promise((resolve) => setTimeout(resolve, 50)) const button = wrapper.find('.avatar-btn') await button.trigger('click') await nextTick() expect(button.attributes('aria-expanded')).toBe('true') }) it('menu items have correct roles', async () => { isParentAuthenticatedRef.value = true wrapper = mount(LoginButton) await nextTick() await new Promise((resolve) => setTimeout(resolve, 50)) const button = wrapper.find('.avatar-btn') await button.trigger('click') await nextTick() const dropdown = wrapper.find('.dropdown-menu') expect(dropdown.attributes('role')).toBe('menu') const menuItems = wrapper.findAll('.menu-item') menuItems.forEach((item) => { expect(item.attributes('role')).toBe('menuitem') }) }) }) describe('Mode Logic', () => { it('button click handler works correctly', async () => { // Due to vi.mock limitations with reactivity, we test that // click handler is wired up correctly wrapper = mount(LoginButton, { global: { mocks: { $router: { push: vi.fn(), }, }, }, }) await nextTick() await new Promise((resolve) => setTimeout(resolve, 50)) const button = wrapper.find('.avatar-btn') await button.trigger('click') await nextTick() // Verify button is clickable and doesn't throw expect(button.exists()).toBe(true) }) it('dropdown visibility controlled by auth state and open/close', async () => { //Test dropdown behavior when authenticated isParentAuthenticatedRef.value = true wrapper = mount(LoginButton) await nextTick() await new Promise((resolve) => setTimeout(resolve, 50)) // Initially closed let dropdown = wrapper.find('.dropdown-menu') expect(dropdown.exists()).toBe(false) // Click to open const button = wrapper.find('.avatar-btn') await button.trigger('click') await nextTick() dropdown = wrapper.find('.dropdown-menu') expect(dropdown.exists()).toBe(true) }) }) describe('Focus Ring', () => { it('shows focus ring on keyboard focus', async () => { wrapper = mount(LoginButton) await nextTick() const button = wrapper.find('.avatar-btn') await button.trigger('focus') await nextTick() // Check for focus ring styles expect(button.classes()).toContain('avatar-btn') }) }) describe('Stub Icons', () => { it('renders stub icons for menu items', async () => { isParentAuthenticatedRef.value = true wrapper = mount(LoginButton) await nextTick() await new Promise((resolve) => setTimeout(resolve, 50)) const button = wrapper.find('.avatar-btn') await button.trigger('click') await nextTick() const stubs = wrapper.findAll('.menu-icon-stub') expect(stubs.length).toBe(3) }) }) describe('PIN Modal - checkbox and persistent mode', () => { beforeEach(async () => { isParentAuthenticatedRef.value = false wrapper = mount(LoginButton) await nextTick() await new Promise((resolve) => setTimeout(resolve, 50)) }) it('checkbox is unchecked by default when modal opens', async () => { // Open modal by triggering the open-login event path // Mock has-pin response ;(global.fetch as any) .mockResolvedValueOnce({ ok: true, json: async () => ({ has_pin: true }) }) .mockResolvedValueOnce({ ok: true, json: async () => ({ valid: true }) }) const vm = wrapper.vm as any await vm.open() await nextTick() const checkbox = wrapper.find('.stay-checkbox') expect(checkbox.exists()).toBe(true) expect((checkbox.element as HTMLInputElement).checked).toBe(false) }) it('submitting with checkbox checked calls authenticateParent(true)', async () => { const { authenticateParent } = await import('../../../stores/auth') ;(global.fetch as any) .mockResolvedValueOnce({ ok: true, json: async () => ({ has_pin: true }) }) .mockResolvedValueOnce({ ok: true, json: async () => ({ valid: true }) }) const vm = wrapper.vm as any await vm.open() await nextTick() const checkbox = wrapper.find('.stay-checkbox') await checkbox.setValue(true) await nextTick() const pinInput = wrapper.find('.pin-input') await pinInput.setValue('1234') await wrapper.find('form').trigger('submit') await nextTick() expect(authenticateParent).toHaveBeenCalledWith(true) }) it('submitting without checking checkbox calls authenticateParent(false)', async () => { const { authenticateParent } = await import('../../../stores/auth') ;(global.fetch as any) .mockResolvedValueOnce({ ok: true, json: async () => ({ has_pin: true }) }) .mockResolvedValueOnce({ ok: true, json: async () => ({ valid: true }) }) const vm = wrapper.vm as any await vm.open() await nextTick() const pinInput = wrapper.find('.pin-input') await pinInput.setValue('1234') await wrapper.find('form').trigger('submit') await nextTick() expect(authenticateParent).toHaveBeenCalledWith(false) }) }) describe('Lock badge visibility', () => { it('lock badge is hidden when not authenticated', async () => { isParentAuthenticatedRef.value = false isParentPersistentRef.value = false wrapper = mount(LoginButton) await nextTick() const badge = wrapper.find('.persistent-badge') expect(badge.exists()).toBe(false) }) it('lock badge is hidden when authenticated but non-persistent', async () => { isParentAuthenticatedRef.value = true isParentPersistentRef.value = false wrapper = mount(LoginButton) await nextTick() const badge = wrapper.find('.persistent-badge') expect(badge.exists()).toBe(false) }) it('lock badge is visible when authenticated and persistent', async () => { isParentAuthenticatedRef.value = true isParentPersistentRef.value = true wrapper = mount(LoginButton) await nextTick() const badge = wrapper.find('.persistent-badge') expect(badge.exists()).toBe(true) expect(badge.text()).toBe('🔒') }) }) describe('User Email Display', () => { it('displays email in dropdown header when available', async () => { isParentAuthenticatedRef.value = true wrapper = mount(LoginButton) await nextTick() await new Promise((resolve) => setTimeout(resolve, 50)) const button = wrapper.find('.avatar-btn') await button.trigger('click') await nextTick() const email = wrapper.find('.dropdown-user-email') expect(email.exists()).toBe(true) expect(email.text()).toBe('john@example.com') }) it('does not display email element when email is not available', async () => { ;(global.fetch as any).mockResolvedValueOnce({ ok: true, json: async () => ({ image_id: 'test-image-id', first_name: 'Jane' }), }) isParentAuthenticatedRef.value = true wrapper = mount(LoginButton) await nextTick() await new Promise((resolve) => setTimeout(resolve, 50)) const button = wrapper.find('.avatar-btn') await button.trigger('click') await nextTick() const email = wrapper.find('.dropdown-user-email') expect(email.exists()).toBe(false) }) }) })