Refactored frontend directory
Some checks failed
Chore App Build, Test, and Push Docker Images / build-and-push (push) Failing after 2m46s
Some checks failed
Chore App Build, Test, and Push Docker Images / build-and-push (push) Failing after 2m46s
This commit is contained in:
486
frontend/src/components/shared/__tests__/LoginButton.spec.ts
Normal file
486
frontend/src/components/shared/__tests__/LoginButton.spec.ts
Normal file
@@ -0,0 +1,486 @@
|
||||
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<any>
|
||||
|
||||
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)
|
||||
})
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user