Some checks failed
Chore App Build, Test, and Push Docker Images / build-and-push (push) Failing after 2m46s
487 lines
15 KiB
TypeScript
487 lines
15 KiB
TypeScript
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)
|
|
})
|
|
})
|
|
})
|