refactor testing
Some checks failed
ci/woodpecker/pr/pr Pipeline failed

This commit is contained in:
Madison Grubb
2026-02-17 11:05:57 -05:00
parent b0e8dd7ad9
commit 1a566e2d80
57 changed files with 1127 additions and 1760 deletions

View File

@@ -2,84 +2,58 @@ import { describe, it, expect } from 'vitest'
import { mountSuspended } from '@nuxt/test-utils/runtime'
import CameraViewer from '../../app/components/CameraViewer.vue'
const createCamera = (overrides = {}) => ({
id: 't1',
name: 'Test Camera',
streamUrl: 'https://example.com/stream.mjpg',
sourceType: 'mjpeg',
...overrides,
})
describe('CameraViewer (device stream)', () => {
it('renders device name and close button', async () => {
const camera = {
id: 't1',
name: 'Test Camera',
streamUrl: 'https://example.com/stream.mjpg',
sourceType: 'mjpeg',
}
const wrapper = await mountSuspended(CameraViewer, {
props: { camera },
props: { camera: createCamera({ name: 'Test Camera' }) },
})
expect(wrapper.text()).toContain('Test Camera')
expect(wrapper.find('button[aria-label="Close panel"]').exists()).toBe(true)
})
it('does not set img src for non-http streamUrl', async () => {
const camera = {
id: 't2',
name: 'Bad',
streamUrl: 'javascript:alert(1)',
sourceType: 'mjpeg',
}
it.each([
['javascript:alert(1)', false],
['https://example.com/cam.mjpg', true],
])('handles streamUrl: %s -> img exists: %s', async (streamUrl, shouldExist) => {
const wrapper = await mountSuspended(CameraViewer, {
props: { camera },
props: { camera: createCamera({ streamUrl }) },
})
const img = wrapper.find('img')
expect(img.exists()).toBe(false)
})
it('uses safe http streamUrl for img', async () => {
const camera = {
id: 't3',
name: 'OK',
streamUrl: 'https://example.com/cam.mjpg',
sourceType: 'mjpeg',
expect(img.exists()).toBe(shouldExist)
if (shouldExist) {
expect(img.attributes('src')).toBe(streamUrl)
}
const wrapper = await mountSuspended(CameraViewer, {
props: { camera },
})
const img = wrapper.find('img')
expect(img.exists()).toBe(true)
expect(img.attributes('src')).toBe('https://example.com/cam.mjpg')
})
it('emits close when close button clicked', async () => {
const camera = {
id: 't5',
name: 'Close me',
streamUrl: '',
sourceType: 'mjpeg',
}
const wrapper = await mountSuspended(CameraViewer, { props: { camera } })
const wrapper = await mountSuspended(CameraViewer, {
props: { camera: createCamera() },
})
await wrapper.find('button[aria-label="Close panel"]').trigger('click')
expect(wrapper.emitted('close')).toHaveLength(1)
})
it('shows stream unavailable when img errors', async () => {
const camera = {
id: 't6',
name: 'Broken',
streamUrl: 'https://example.com/bad.mjpg',
sourceType: 'mjpeg',
}
const wrapper = await mountSuspended(CameraViewer, { props: { camera } })
const img = wrapper.find('img')
await img.trigger('error')
const wrapper = await mountSuspended(CameraViewer, {
props: { camera: createCamera({ streamUrl: 'https://example.com/bad.mjpg' }) },
})
await wrapper.find('img').trigger('error')
await wrapper.vm.$nextTick()
expect(wrapper.text()).toContain('Stream unavailable')
})
it('renders video element for hls sourceType', async () => {
const camera = {
id: 't7',
name: 'HLS Camera',
streamUrl: 'https://example.com/stream.m3u8',
sourceType: 'hls',
}
const wrapper = await mountSuspended(CameraViewer, { props: { camera } })
const wrapper = await mountSuspended(CameraViewer, {
props: { camera: createCamera({ sourceType: 'hls', streamUrl: 'https://example.com/stream.m3u8' }) },
})
expect(wrapper.find('video').exists()).toBe(true)
})
})

View File

@@ -1,58 +1,43 @@
import { describe, it, expect } from 'vitest'
import { describe, it, expect, beforeEach } from 'vitest'
import { mountSuspended, registerEndpoint } from '@nuxt/test-utils/runtime'
import NavDrawer from '../../app/components/NavDrawer.vue'
const withAuth = () => {
registerEndpoint('/api/me', () => ({ id: '1', identifier: 'user', role: 'member', avatar_url: null }), { method: 'GET' })
const mountDrawer = (props = {}) => {
return mountSuspended(NavDrawer, {
props: { modelValue: true, ...props },
attachTo: document.body,
})
}
describe('NavDrawer', () => {
it('renders navigation links with correct paths', async () => {
withAuth()
await mountSuspended(NavDrawer, {
props: { modelValue: true },
attachTo: document.body,
})
const links = document.body.querySelectorAll('aside nav a[href]')
const hrefs = [...links].map(a => a.getAttribute('href'))
expect(hrefs).toContain('/')
expect(hrefs).toContain('/account')
expect(hrefs).toContain('/cameras')
expect(hrefs).toContain('/poi')
expect(hrefs).toContain('/members')
expect(hrefs).toContain('/settings')
expect(links.length).toBeGreaterThanOrEqual(6)
beforeEach(() => {
registerEndpoint('/api/me', () => ({ id: '1', identifier: 'user', role: 'member', avatar_url: null }), { method: 'GET' })
})
it('renders Map and Settings labels', async () => {
withAuth()
await mountSuspended(NavDrawer, {
props: { modelValue: true },
attachTo: document.body,
})
expect(document.body.textContent).toContain('Map')
expect(document.body.textContent).toContain('Settings')
it('renders navigation links with correct paths', async () => {
await mountDrawer()
const hrefs = [...document.body.querySelectorAll('aside nav a[href]')].map(a => a.getAttribute('href'))
expect(hrefs).toEqual(expect.arrayContaining(['/', '/account', '/cameras', '/poi', '/members', '/settings']))
})
it.each([
['Map'],
['Settings'],
])('renders %s label', async (label) => {
await mountDrawer()
expect(document.body.textContent).toContain(label)
})
it('emits update:modelValue when close is triggered', async () => {
withAuth()
const wrapper = await mountSuspended(NavDrawer, {
props: { modelValue: true },
attachTo: document.body,
})
const wrapper = await mountDrawer()
expect(document.body.querySelector('aside button[aria-label="Close navigation"]')).toBeTruthy()
await wrapper.vm.close()
expect(wrapper.emitted('update:modelValue')).toEqual([[false]])
})
it('applies active styling for current route', async () => {
withAuth()
await mountSuspended(NavDrawer, {
props: { modelValue: true },
attachTo: document.body,
})
await mountDrawer()
const mapLink = document.body.querySelector('aside nav a[href="/"]')
expect(mapLink).toBeTruthy()
expect(mapLink.className).toMatch(/kestrel-accent|border-kestrel-accent/)
expect(mapLink?.className).toMatch(/kestrel-accent|border-kestrel-accent/)
})
})

View File

@@ -3,6 +3,13 @@ import { mountSuspended, registerEndpoint } from '@nuxt/test-utils/runtime'
import Index from '../../app/pages/index.vue'
import Login from '../../app/pages/login.vue'
const wait = (ms = 200) => new Promise(r => setTimeout(r, ms))
const setupProtectedEndpoints = () => {
registerEndpoint('/api/cameras', () => ({ devices: [], liveSessions: [] }), { method: 'GET' })
registerEndpoint('/api/pois', () => [], { method: 'GET' })
}
describe('auth middleware', () => {
it('allows /login without redirect when unauthenticated', async () => {
registerEndpoint('/api/me', () => null, { method: 'GET' })
@@ -11,28 +18,25 @@ describe('auth middleware', () => {
expect(wrapper.find('input[type="password"]').exists()).toBe(true)
})
it('redirects to /login with redirect query when unauthenticated and visiting protected route', async () => {
registerEndpoint('/api/me', () => null, { method: 'GET' })
registerEndpoint('/api/cameras', () => ({ devices: [], liveSessions: [] }), { method: 'GET' })
registerEndpoint('/api/pois', () => [], { method: 'GET' })
it.each([
[() => null, '/login', { redirect: '/' }],
[
() => {
throw createError({ statusCode: 401 })
},
'/login',
undefined,
],
])('redirects to /login when unauthenticated: %s', async (meResponse, expectedPath, expectedQuery) => {
registerEndpoint('/api/me', meResponse, { method: 'GET' })
setupProtectedEndpoints()
await mountSuspended(Index)
await new Promise(r => setTimeout(r, 200))
await wait(meResponse.toString().includes('401') ? 250 : 200)
const router = useRouter()
await router.isReady()
expect(router.currentRoute.value.path).toBe('/login')
expect(router.currentRoute.value.query.redirect).toBe('/')
})
it('401 handler redirects to login when API returns 401', async () => {
registerEndpoint('/api/me', () => {
throw createError({ statusCode: 401 })
}, { method: 'GET' })
registerEndpoint('/api/cameras', () => ({ devices: [], liveSessions: [] }), { method: 'GET' })
registerEndpoint('/api/pois', () => [], { method: 'GET' })
await mountSuspended(Index)
await new Promise(r => setTimeout(r, 250))
const router = useRouter()
await router.isReady()
expect(router.currentRoute.value.path).toBe('/login')
expect(router.currentRoute.value.path).toBe(expectedPath)
if (expectedQuery) {
expect(router.currentRoute.value.query).toMatchObject(expectedQuery)
}
})
})

View File

@@ -1,46 +1,44 @@
import { describe, it, expect } from 'vitest'
import { describe, it, expect, beforeEach } from 'vitest'
import { mountSuspended, registerEndpoint } from '@nuxt/test-utils/runtime'
import DefaultLayout from '../../app/layouts/default.vue'
import NavDrawer from '../../app/components/NavDrawer.vue'
const withAuth = () => {
registerEndpoint('/api/me', () => ({ id: '1', identifier: 'user', role: 'member', avatar_url: null }), { method: 'GET' })
}
const wait = (ms = 100) => new Promise(r => setTimeout(r, ms))
describe('default layout', () => {
it('renders KestrelOS header', async () => {
withAuth()
const wrapper = await mountSuspended(DefaultLayout)
expect(wrapper.text()).toContain('KestrelOS')
beforeEach(() => {
registerEndpoint('/api/me', () => ({ id: '1', identifier: 'user', role: 'member', avatar_url: null }), { method: 'GET' })
})
it('renders drawer toggle with accessible label on mobile', async () => {
withAuth()
it.each([
['KestrelOS header', 'KestrelOS'],
['drawer toggle', 'button[aria-label="Toggle navigation"]'],
])('renders %s', async (description, selector) => {
const wrapper = await mountSuspended(DefaultLayout)
const toggle = wrapper.find('button[aria-label="Toggle navigation"]')
expect(toggle.exists()).toBe(true)
if (selector.startsWith('button')) {
expect(wrapper.find(selector).exists()).toBe(true)
}
else {
expect(wrapper.text()).toContain(selector)
}
})
it('renders NavDrawer', async () => {
withAuth()
const wrapper = await mountSuspended(DefaultLayout)
expect(wrapper.findComponent(NavDrawer).exists()).toBe(true)
})
it('renders user menu and sign out navigates home', async () => {
withAuth()
registerEndpoint('/api/auth/logout', () => null, { method: 'POST' })
const wrapper = await mountSuspended(DefaultLayout)
await new Promise(r => setTimeout(r, 100))
await wait()
const menuTrigger = wrapper.find('button[aria-label="User menu"]')
expect(menuTrigger.exists()).toBe(true)
await menuTrigger.trigger('click')
await new Promise(r => setTimeout(r, 50))
await wait(50)
const signOut = wrapper.find('button[role="menuitem"]')
expect(signOut.exists()).toBe(true)
expect(signOut.text()).toContain('Sign out')
await signOut.trigger('click')
await new Promise(r => setTimeout(r, 100))
await wait()
const router = useRouter()
await router.isReady()
expect(router.currentRoute.value.path).toBe('/')

View File

@@ -2,6 +2,8 @@ import { describe, it, expect } from 'vitest'
import { mountSuspended, registerEndpoint } from '@nuxt/test-utils/runtime'
import Index from '../../app/pages/index.vue'
const wait = (ms = 150) => new Promise(r => setTimeout(r, ms))
describe('index page', () => {
it('renders map and uses cameras', async () => {
registerEndpoint('/api/cameras', () => ({
@@ -11,7 +13,7 @@ describe('index page', () => {
registerEndpoint('/api/pois', () => [])
registerEndpoint('/api/me', () => null, { method: 'GET' })
const wrapper = await mountSuspended(Index)
await new Promise(r => setTimeout(r, 150))
await wait()
expect(wrapper.findComponent({ name: 'KestrelMap' }).exists()).toBe(true)
})
})

100
test/nuxt/logger.spec.js Normal file
View File

@@ -0,0 +1,100 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
import { registerEndpoint } from '@nuxt/test-utils/runtime'
import { readBody } from 'h3'
import { initLogger, logError, logWarn, logInfo, logDebug } from '../../app/utils/logger.js'
const wait = (ms = 10) => new Promise(resolve => setTimeout(resolve, ms))
describe('app/utils/logger', () => {
const consoleMocks = {}
const originalConsole = {}
let serverCalls
beforeEach(() => {
serverCalls = []
const calls = { log: [], error: [], warn: [], debug: [] }
Object.keys(calls).forEach((key) => {
originalConsole[key] = console[key]
consoleMocks[key] = vi.fn((...args) => calls[key].push(args))
console[key] = consoleMocks[key]
})
registerEndpoint('/api/log', async (event) => {
const body = event.body || (await readBody(event).catch(() => ({})))
serverCalls.push(body)
return { ok: true }
}, { method: 'POST' })
})
afterEach(() => {
Object.keys(originalConsole).forEach((key) => {
console[key] = originalConsole[key]
})
vi.restoreAllMocks()
})
describe('initLogger', () => {
it('sets sessionId and userId for server calls', async () => {
initLogger('session-123', 'user-456')
logError('Test message')
await wait()
expect(serverCalls[0]).toMatchObject({
sessionId: 'session-123',
userId: 'user-456',
})
})
})
describe('log functions', () => {
it.each([
['logError', logError, 'error', 'error'],
['logWarn', logWarn, 'warn', 'warn'],
['logInfo', logInfo, 'info', 'log'],
['logDebug', logDebug, 'debug', 'log'],
])('%s logs to console and sends to server', async (name, logFn, level, consoleKey) => {
initLogger('session-123', 'user-456')
logFn('Test message', { key: 'value' })
await wait()
expect(consoleMocks[consoleKey]).toHaveBeenCalledWith(`[Test message]`, { key: 'value' })
expect(serverCalls[0]).toMatchObject({
level,
message: 'Test message',
data: { key: 'value' },
})
})
it('handles server fetch failure gracefully', async () => {
registerEndpoint('/api/log', () => {
throw new Error('Network error')
}, { method: 'POST' })
initLogger('session-123', 'user-456')
expect(() => logError('Test error')).not.toThrow()
await wait()
expect(consoleMocks.error).toHaveBeenCalled()
})
})
describe('sendToServer', () => {
it('includes timestamp in server request', async () => {
initLogger('session-123', 'user-456')
logError('Test message')
await wait()
expect(serverCalls[0].timestamp).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/)
})
it('handles null sessionId and userId', async () => {
initLogger(null, null)
logError('Test message')
await wait()
const { sessionId, userId } = serverCalls[0]
expect(sessionId === null || sessionId === undefined).toBe(true)
expect(userId === null || userId === undefined).toBe(true)
})
})
})

View File

@@ -2,31 +2,34 @@ import { describe, it, expect } from 'vitest'
import { mountSuspended, registerEndpoint } from '@nuxt/test-utils/runtime'
import Login from '../../app/pages/login.vue'
const wait = (ms = 50) => new Promise(r => setTimeout(r, ms))
describe('login page', () => {
it('renders sign in form (local auth always shown)', async () => {
registerEndpoint('/api/auth/config', () => ({ oidc: { enabled: false, label: '' } }), { method: 'GET' })
const wrapper = await mountSuspended(Login)
await new Promise(r => setTimeout(r, 50))
await wait()
expect(wrapper.text()).toContain('Sign in')
expect(wrapper.find('input[type="text"]').exists()).toBe(true)
expect(wrapper.find('input[type="password"]').exists()).toBe(true)
})
it('shows OIDC button when OIDC is enabled', async () => {
registerEndpoint('/api/auth/config', () => ({ oidc: { enabled: true, label: 'Sign in with Authentik' } }), { method: 'GET' })
it.each([
[{ enabled: true, label: 'Sign in with Authentik' }, true, false],
[{ enabled: true, label: 'Sign in with OIDC' }, true, true],
])('shows OIDC when enabled: %j', async (oidcConfig, shouldShowButton, shouldShowPassword) => {
registerEndpoint('/api/auth/config', () => ({ oidc: oidcConfig }), { method: 'GET' })
await clearNuxtData('auth-config')
const wrapper = await mountSuspended(Login)
await new Promise(r => setTimeout(r, 150))
expect(wrapper.text()).toContain('Sign in with Authentik')
expect(wrapper.find('a[href*="/api/auth/oidc/authorize"]').exists()).toBe(true)
})
it('shows both OIDC button and password form when OIDC is enabled', async () => {
registerEndpoint('/api/auth/config', () => ({ oidc: { enabled: true, label: 'Sign in with OIDC' } }), { method: 'GET' })
await clearNuxtData('auth-config')
const wrapper = await mountSuspended(Login)
await new Promise(r => setTimeout(r, 150))
expect(wrapper.find('a[href*="/api/auth/oidc/authorize"]').exists()).toBe(true)
expect(wrapper.find('input[type="password"]').exists()).toBe(true)
await wait(150)
if (shouldShowButton) {
expect(wrapper.find('a[href*="/api/auth/oidc/authorize"]').exists()).toBe(true)
if (oidcConfig.label) {
expect(wrapper.text()).toContain(oidcConfig.label)
}
}
if (shouldShowPassword) {
expect(wrapper.find('input[type="password"]').exists()).toBe(true)
}
})
})

View File

@@ -2,36 +2,41 @@ import { describe, it, expect } from 'vitest'
import { mountSuspended, registerEndpoint } from '@nuxt/test-utils/runtime'
import Members from '../../app/pages/members.vue'
const wait = (ms = 100) => new Promise(r => setTimeout(r, ms))
const setupEndpoints = (userResponse) => {
registerEndpoint('/api/me', userResponse, { method: 'GET' })
registerEndpoint('/api/users', () => [])
}
describe('members page', () => {
it('renders Members heading', async () => {
registerEndpoint('/api/me', () => null, { method: 'GET' })
registerEndpoint('/api/users', () => [])
setupEndpoints(() => null)
const wrapper = await mountSuspended(Members)
expect(wrapper.text()).toContain('Members')
})
it('shows sign in message when no user', async () => {
registerEndpoint('/api/me', () => null, { method: 'GET' })
registerEndpoint('/api/users', () => [])
setupEndpoints(() => null)
const wrapper = await mountSuspended(Members)
expect(wrapper.text()).toMatch(/Sign in to view members/)
})
it('shows members list and Add user when user is admin', async () => {
registerEndpoint('/api/me', () => ({ id: '1', identifier: 'admin', role: 'admin', avatar_url: null }), { method: 'GET' })
registerEndpoint('/api/users', () => [])
it.each([
[
{ id: '1', identifier: 'admin', role: 'admin', avatar_url: null },
['Add user', /Only admins can change roles/],
],
[
{ id: '2', identifier: 'leader', role: 'leader', avatar_url: null },
['Members', 'Identifier'],
],
])('shows content for %s role', async (user, expectedTexts) => {
setupEndpoints(() => user)
const wrapper = await mountSuspended(Members)
await new Promise(r => setTimeout(r, 100))
expect(wrapper.text()).toContain('Add user')
expect(wrapper.text()).toMatch(/Only admins can change roles/)
})
it('shows members content when user has canEditPois (leader)', async () => {
registerEndpoint('/api/me', () => ({ id: '2', identifier: 'leader', role: 'leader', avatar_url: null }), { method: 'GET' })
registerEndpoint('/api/users', () => [])
const wrapper = await mountSuspended(Members)
await new Promise(r => setTimeout(r, 150))
expect(wrapper.text()).toContain('Members')
expect(wrapper.text()).toContain('Identifier')
await wait(user.role === 'leader' ? 150 : 100)
expectedTexts.forEach((text) => {
expect(wrapper.text()).toMatch(text)
})
})
})

View File

@@ -1,19 +1,18 @@
import { describe, it, expect } from 'vitest'
import { describe, it, expect, beforeEach } from 'vitest'
import { mountSuspended, registerEndpoint } from '@nuxt/test-utils/runtime'
import Poi from '../../app/pages/poi.vue'
describe('poi page', () => {
it('renders POI placement heading', async () => {
beforeEach(() => {
registerEndpoint('/api/pois', () => [])
registerEndpoint('/api/me', () => null, { method: 'GET' })
const wrapper = await mountSuspended(Poi)
expect(wrapper.text()).toContain('POI placement')
})
it('shows view-only message when cannot edit', async () => {
registerEndpoint('/api/pois', () => [])
registerEndpoint('/api/me', () => null, { method: 'GET' })
it.each([
['POI placement heading', 'POI placement'],
['view-only message', /View-only|Sign in as admin/],
])('renders %s', async (description, expected) => {
const wrapper = await mountSuspended(Poi)
expect(wrapper.text()).toMatch(/View-only|Sign in as admin/)
expect(wrapper.text()).toMatch(expected)
})
})

View File

@@ -2,44 +2,45 @@ import { describe, it, expect } from 'vitest'
import { mountSuspended, registerEndpoint } from '@nuxt/test-utils/runtime'
import Index from '../../app/pages/index.vue'
const wait = (ms = 100) => new Promise(r => setTimeout(r, ms))
const setupEndpoints = (camerasResponse) => {
registerEndpoint('/api/cameras', camerasResponse)
registerEndpoint('/api/pois', () => [])
registerEndpoint('/api/me', () => null, { method: 'GET' })
}
describe('useCameras', () => {
it('page uses cameras from API', async () => {
registerEndpoint('/api/cameras', () => ({
setupEndpoints(() => ({
devices: [{ id: '1', name: 'Test', lat: 37.7, lng: -122.4, streamUrl: '', sourceType: 'mjpeg', device_type: 'feed' }],
liveSessions: [],
cotEntities: [],
}))
registerEndpoint('/api/pois', () => [])
registerEndpoint('/api/me', () => null, { method: 'GET' })
const wrapper = await mountSuspended(Index)
await new Promise(r => setTimeout(r, 100))
await wait()
expect(wrapper.findComponent({ name: 'KestrelMap' }).exists()).toBe(true)
})
it('exposes cotEntities from API', async () => {
const cotEntities = [{ id: 'cot-1', lat: 38, lng: -123, label: 'ATAK1' }]
registerEndpoint('/api/cameras', () => ({
setupEndpoints(() => ({
devices: [],
liveSessions: [],
cotEntities,
}))
registerEndpoint('/api/pois', () => [])
registerEndpoint('/api/me', () => null, { method: 'GET' })
const wrapper = await mountSuspended(Index)
await new Promise(r => setTimeout(r, 100))
await wait()
const map = wrapper.findComponent({ name: 'KestrelMap' })
expect(map.exists()).toBe(true)
expect(map.props('cotEntities')).toEqual(cotEntities)
})
it('handles API error and falls back to empty devices and liveSessions', async () => {
registerEndpoint('/api/cameras', () => {
setupEndpoints(() => {
throw new Error('network')
})
registerEndpoint('/api/pois', () => [])
registerEndpoint('/api/me', () => null, { method: 'GET' })
const wrapper = await mountSuspended(Index)
await new Promise(r => setTimeout(r, 150))
await wait(150)
expect(wrapper.findComponent({ name: 'KestrelMap' }).exists()).toBe(true)
})
})

View File

@@ -3,55 +3,56 @@ import { mountSuspended, registerEndpoint } from '@nuxt/test-utils/runtime'
import { defineComponent, h } from 'vue'
import { useLiveSessions } from '../../app/composables/useLiveSessions.js'
const wait = (ms = 100) => new Promise(r => setTimeout(r, ms))
const createTestComponent = (setupFn) => {
return defineComponent({
setup: setupFn,
})
}
const setupEndpoints = (liveResponse) => {
registerEndpoint('/api/live', liveResponse)
registerEndpoint('/api/me', () => ({ id: '1', identifier: 'u', role: 'member' }), { method: 'GET' })
}
describe('useLiveSessions', () => {
it('fetches sessions from API and returns sessions ref', async () => {
registerEndpoint('/api/live', () => [
{ id: 's1', label: 'Live 1', hasStream: true, lat: 37, lng: -122 },
])
registerEndpoint('/api/me', () => ({ id: '1', identifier: 'u', role: 'member' }), { method: 'GET' })
const TestComponent = defineComponent({
setup() {
const { sessions } = useLiveSessions()
return () => h('div', { 'data-sessions': JSON.stringify(sessions.value) })
},
setupEndpoints(() => [{ id: 's1', label: 'Live 1', hasStream: true, lat: 37, lng: -122 }])
const TestComponent = createTestComponent(() => {
const { sessions } = useLiveSessions()
return () => h('div', { 'data-sessions': JSON.stringify(sessions.value) })
})
const wrapper = await mountSuspended(TestComponent)
await new Promise(r => setTimeout(r, 100))
await wait()
expect(wrapper.find('[data-sessions]').exists()).toBe(true)
})
it('returns empty array when fetch fails', async () => {
registerEndpoint('/api/live', () => {
setupEndpoints(() => {
throw new Error('fetch failed')
})
registerEndpoint('/api/me', () => ({ id: '1', identifier: 'u', role: 'member' }), { method: 'GET' })
const TestComponent = defineComponent({
setup() {
const { sessions } = useLiveSessions()
return () => h('div', { 'data-sessions': JSON.stringify(sessions.value) })
},
const TestComponent = createTestComponent(() => {
const { sessions } = useLiveSessions()
return () => h('div', { 'data-sessions': JSON.stringify(sessions.value) })
})
const wrapper = await mountSuspended(TestComponent)
await new Promise(r => setTimeout(r, 150))
const el = wrapper.find('[data-sessions]')
expect(el.exists()).toBe(true)
expect(JSON.parse(el.attributes('data-sessions'))).toEqual([])
await wait(150)
const sessions = JSON.parse(wrapper.find('[data-sessions]').attributes('data-sessions'))
expect(sessions).toEqual([])
})
it('startPolling and stopPolling manage interval', async () => {
registerEndpoint('/api/live', () => [])
registerEndpoint('/api/me', () => ({ id: '1', identifier: 'u', role: 'member' }), { method: 'GET' })
const TestComponent = defineComponent({
setup() {
const { startPolling, stopPolling } = useLiveSessions()
return () => h('div', {
onClick: () => {
startPolling()
startPolling()
stopPolling()
},
})
},
setupEndpoints(() => [])
const TestComponent = createTestComponent(() => {
const { startPolling, stopPolling } = useLiveSessions()
return () => h('div', {
onClick: () => {
startPolling()
startPolling()
stopPolling()
},
})
})
const wrapper = await mountSuspended(TestComponent)
await wrapper.trigger('click')