initial commit

This commit is contained in:
Madison Grubb
2026-02-10 23:32:26 -05:00
commit b7046dc0e6
133 changed files with 26080 additions and 0 deletions

View File

@@ -0,0 +1,85 @@
import { describe, it, expect } from 'vitest'
import { mountSuspended } from '@nuxt/test-utils/runtime'
import CameraViewer from '../../app/components/CameraViewer.vue'
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 },
})
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',
}
const wrapper = await mountSuspended(CameraViewer, {
props: { camera },
})
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',
}
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 } })
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')
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 } })
expect(wrapper.find('video').exists()).toBe(true)
})
})

View File

@@ -0,0 +1,78 @@
import { readFileSync } from 'node:fs'
import { resolve } from 'node:path'
import { describe, it, expect, vi } from 'vitest'
import { mountSuspended } from '@nuxt/test-utils/runtime'
import KestrelMap from '../../app/components/KestrelMap.vue'
vi.mock('leaflet', () => ({ default: {} }))
vi.mock('leaflet.offline', () => ({ tileLayerOffline: null, savetiles: null }))
describe('KestrelMap', () => {
it('renders map container', async () => {
const wrapper = await mountSuspended(KestrelMap, {
props: { feeds: [] },
})
expect(wrapper.find('[data-testid="kestrel-map"]').exists()).toBe(true)
})
it('accepts feeds prop', async () => {
const feeds = [
{ id: '1', name: 'A', lat: 37.7, lng: -122.4, streamUrl: '', sourceType: 'mjpeg' },
]
const wrapper = await mountSuspended(KestrelMap, {
props: { feeds },
})
expect(wrapper.props('feeds')).toEqual(feeds)
})
it('has select emit', async () => {
const wrapper = await mountSuspended(KestrelMap, {
props: { feeds: [] },
})
wrapper.vm.$emit('select', { id: 'x', name: 'X', lat: 0, lng: 0 })
expect(wrapper.emitted('select')).toHaveLength(1)
})
it('uses dark Carto tile URL with subdomains', async () => {
const componentPath = resolve(__dirname, '../../app/components/KestrelMap.vue')
const source = readFileSync(componentPath, 'utf-8')
expect(source).toContain('basemaps.cartocdn.com/dark_all')
expect(source).toContain('{s}.basemaps.cartocdn.com')
})
it('requests client location first with maximumAge 0 so browser prompts', async () => {
const componentPath = resolve(__dirname, '../../app/components/KestrelMap.vue')
const source = readFileSync(componentPath, 'utf-8')
expect(source).toContain('getCurrentPosition')
expect(source).toContain('enableHighAccuracy: true')
expect(source).toContain('maximumAge: 0')
expect(source).toContain('createMap([latitude, longitude])')
})
it('uses L.tileLayer for base display so tiles load from OSM', async () => {
const componentPath = resolve(__dirname, '../../app/components/KestrelMap.vue')
const source = readFileSync(componentPath, 'utf-8')
expect(source).toContain('L.tileLayer(TILE_URL')
expect(source).toContain('baseLayer.addTo(map)')
})
it('sets Leaflet default marker icon path to avoid Vue Router intercept', async () => {
const componentPath = resolve(__dirname, '../../app/components/KestrelMap.vue')
const source = readFileSync(componentPath, 'utf-8')
expect(source).toContain('Icon.Default.mergeOptions')
expect(source).toContain('marker-icon.png')
expect(source).toContain('marker-shadow.png')
})
it('accepts pois and canEditPois props', async () => {
const wrapper = await mountSuspended(KestrelMap, {
props: {
feeds: [],
pois: [{ id: 'p1', lat: 37.7, lng: -122.4, label: 'P', icon_type: 'pin' }],
canEditPois: false,
},
})
expect(wrapper.props('pois')).toHaveLength(1)
expect(wrapper.props('canEditPois')).toBe(false)
})
})

View File

@@ -0,0 +1,59 @@
import { describe, it, expect } 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' }), { method: 'GET' })
}
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)
})
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')
expect(document.body.textContent).toContain('Navigation')
})
it('emits update:modelValue when close is triggered', async () => {
withAuth()
const wrapper = await mountSuspended(NavDrawer, {
props: { modelValue: true },
attachTo: document.body,
})
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,
})
const mapLink = document.body.querySelector('aside nav a[href="/"]')
expect(mapLink).toBeTruthy()
expect(mapLink.className).toMatch(/kestrel-accent|border-kestrel-accent/)
})
})

14
test/nuxt/api.spec.js Normal file
View File

@@ -0,0 +1,14 @@
import { describe, it, expect } from 'vitest'
import { getValidFeeds } from '../../server/utils/feedUtils.js'
describe('API contract', () => {
it('getValidFeeds returns array suitable for API response', () => {
const raw = [
{ id: '1', name: 'A', lat: 1, lng: 2 },
{ id: '2', name: 'B', lat: 3, lng: 4 },
]
const out = getValidFeeds(raw)
expect(Array.isArray(out)).toBe(true)
expect(out).toHaveLength(2)
})
})

View File

@@ -0,0 +1,38 @@
import { describe, it, expect } from 'vitest'
import { mountSuspended, registerEndpoint } from '@nuxt/test-utils/runtime'
import Index from '../../app/pages/index.vue'
import Login from '../../app/pages/login.vue'
describe('auth middleware', () => {
it('allows /login without redirect when unauthenticated', async () => {
registerEndpoint('/api/me', () => null, { method: 'GET' })
const wrapper = await mountSuspended(Login)
expect(wrapper.text()).toContain('Sign in')
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' })
await mountSuspended(Index)
await new Promise(r => setTimeout(r, 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')
})
})

View File

@@ -0,0 +1,44 @@
import { describe, it, expect } 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' }), { method: 'GET' })
}
describe('default layout', () => {
it('renders KestrelOS header', async () => {
withAuth()
const wrapper = await mountSuspended(DefaultLayout)
expect(wrapper.text()).toContain('KestrelOS')
expect(wrapper.text()).toContain('Tactical Operations Center')
})
it('renders drawer toggle with accessible label', async () => {
withAuth()
const wrapper = await mountSuspended(DefaultLayout)
const toggle = wrapper.find('button[aria-label="Toggle navigation"]')
expect(toggle.exists()).toBe(true)
})
it('renders NavDrawer', async () => {
withAuth()
const wrapper = await mountSuspended(DefaultLayout)
expect(wrapper.findComponent(NavDrawer).exists()).toBe(true)
})
it('calls logout and navigates when Logout is clicked', async () => {
withAuth()
registerEndpoint('/api/auth/logout', () => null, { method: 'POST' })
const wrapper = await mountSuspended(DefaultLayout)
await new Promise(r => setTimeout(r, 100))
const logoutBtn = wrapper.findAll('button').find(b => b.text().includes('Logout'))
expect(logoutBtn).toBeDefined()
await logoutBtn.trigger('click')
await new Promise(r => setTimeout(r, 100))
const router = useRouter()
await router.isReady()
expect(router.currentRoute.value.path).toBe('/')
})
})

View File

@@ -0,0 +1,17 @@
import { describe, it, expect } from 'vitest'
import { mountSuspended, registerEndpoint } from '@nuxt/test-utils/runtime'
import Index from '../../app/pages/index.vue'
describe('index page', () => {
it('renders map and uses cameras', async () => {
registerEndpoint('/api/cameras', () => ({
devices: [{ id: '1', name: 'F1', lat: 37.7, lng: -122.4, streamUrl: '', sourceType: 'mjpeg', device_type: 'feed' }],
liveSessions: [],
}))
registerEndpoint('/api/pois', () => [])
registerEndpoint('/api/me', () => null, { method: 'GET' })
const wrapper = await mountSuspended(Index)
await new Promise(r => setTimeout(r, 150))
expect(wrapper.findComponent({ name: 'KestrelMap' }).exists()).toBe(true)
})
})

32
test/nuxt/login.spec.js Normal file
View File

@@ -0,0 +1,32 @@
import { describe, it, expect } from 'vitest'
import { mountSuspended, registerEndpoint } from '@nuxt/test-utils/runtime'
import Login from '../../app/pages/login.vue'
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))
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' })
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)
})
})

View File

@@ -0,0 +1,19 @@
import { describe, it, expect } from 'vitest'
import { mountSuspended, registerEndpoint } from '@nuxt/test-utils/runtime'
import Members from '../../app/pages/members.vue'
describe('members page', () => {
it('renders Members heading', async () => {
registerEndpoint('/api/me', () => null, { method: 'GET' })
registerEndpoint('/api/users', () => [])
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', () => [])
const wrapper = await mountSuspended(Members)
expect(wrapper.text()).toMatch(/Sign in to view members/)
})
})

View File

@@ -0,0 +1,19 @@
import { describe, it, expect } 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 () => {
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' })
const wrapper = await mountSuspended(Poi)
expect(wrapper.text()).toMatch(/View-only|Sign in as admin/)
})
})

View File

@@ -0,0 +1,28 @@
import { describe, it, expect } from 'vitest'
import { mountSuspended, registerEndpoint } from '@nuxt/test-utils/runtime'
import Index from '../../app/pages/index.vue'
describe('useCameras', () => {
it('page uses cameras from API', async () => {
registerEndpoint('/api/cameras', () => ({
devices: [{ id: '1', name: 'Test', lat: 37.7, lng: -122.4, streamUrl: '', sourceType: 'mjpeg', device_type: 'feed' }],
liveSessions: [],
}))
registerEndpoint('/api/pois', () => [])
registerEndpoint('/api/me', () => null, { method: 'GET' })
const wrapper = await mountSuspended(Index)
await new Promise(r => setTimeout(r, 100))
expect(wrapper.findComponent({ name: 'KestrelMap' }).exists()).toBe(true)
})
it('handles API error and falls back to empty devices and liveSessions', async () => {
registerEndpoint('/api/cameras', () => {
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))
expect(wrapper.findComponent({ name: 'KestrelMap' }).exists()).toBe(true)
})
})

View File

@@ -0,0 +1,40 @@
import { describe, it, expect } from 'vitest'
import { mountSuspended, registerEndpoint } from '@nuxt/test-utils/runtime'
import { defineComponent, h } from 'vue'
import { useLiveSessions } from '../../app/composables/useLiveSessions.js'
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) })
},
})
const wrapper = await mountSuspended(TestComponent)
await new Promise(r => setTimeout(r, 100))
expect(wrapper.find('[data-sessions]').exists()).toBe(true)
})
it('returns empty array when fetch fails', async () => {
registerEndpoint('/api/live', () => {
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 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([])
})
})

View File

@@ -0,0 +1,26 @@
import { describe, it, expect } from 'vitest'
import { registerEndpoint } from '@nuxt/test-utils/runtime'
import { getWebRTCFailureReason } from '../../app/composables/useWebRTCFailureReason.js'
describe('useWebRTCFailureReason', () => {
it('returns wrongHost null when server and client hostname match', async () => {
registerEndpoint('/api/live/debug-request-host', () => ({ hostname: 'localhost' }))
const result = await getWebRTCFailureReason()
expect(result).toEqual({ wrongHost: null })
})
it('returns wrongHost when server and client hostname differ', async () => {
registerEndpoint('/api/live/debug-request-host', () => ({ hostname: 'server.example.com' }))
const result = await getWebRTCFailureReason()
expect(result.wrongHost).toBeDefined()
expect(result.wrongHost?.serverHostname).toBe('server.example.com')
})
it('returns wrongHost null when fetch fails', async () => {
registerEndpoint('/api/live/debug-request-host', () => {
throw new Error('network')
})
const result = await getWebRTCFailureReason()
expect(result).toEqual({ wrongHost: null })
})
})