From 1c296c6f1c495acc8b72fe56d8d54f8f99d3f2d0 Mon Sep 17 00:00:00 2001 From: Dorian Date: Fri, 13 Mar 2026 00:08:01 +0000 Subject: [PATCH] test: add frontend composable tests and remaining test files useNostr (9), useFightCache (5), useOnlineStatus (4) composable tests. Added fake-indexeddb dev dependency for IDB tests. Co-Authored-By: Claude Opus 4.6 --- frontend/package.json | 1 + .../__tests__/useFightCache.test.ts | 86 +++++++++++ .../composables/__tests__/useNostr.test.ts | 131 ++++++++++++++++ .../__tests__/useOnlineStatus.test.ts | 53 +++++++ server/src/engine/events.test.ts | 58 +++++++ .../src/engine/previously-found-bugs.test.ts | 142 ++++++++++++++++++ 6 files changed, 471 insertions(+) create mode 100644 frontend/src/composables/__tests__/useFightCache.test.ts create mode 100644 frontend/src/composables/__tests__/useNostr.test.ts create mode 100644 frontend/src/composables/__tests__/useOnlineStatus.test.ts create mode 100644 server/src/engine/events.test.ts create mode 100644 server/src/engine/previously-found-bugs.test.ts diff --git a/frontend/package.json b/frontend/package.json index ec2ed7a..5d9ddf8 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -19,6 +19,7 @@ "@testing-library/vue": "^8.1.0", "@vitejs/plugin-vue": "^5.2.3", "@vue/test-utils": "^2.4.6", + "fake-indexeddb": "^6.2.5", "jsdom": "^28.1.0", "tailwindcss": "^4.2.1", "typescript": "^5.7.3", diff --git a/frontend/src/composables/__tests__/useFightCache.test.ts b/frontend/src/composables/__tests__/useFightCache.test.ts new file mode 100644 index 0000000..82432a4 --- /dev/null +++ b/frontend/src/composables/__tests__/useFightCache.test.ts @@ -0,0 +1,86 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' + +// Mock IndexedDB with fake-indexeddb +import 'fake-indexeddb/auto' + +import { cacheFight, getCachedFight } from '../useFightCache' + +function makeFight(id: string): any { + return { + id, + botA: { id: `a_${id}`, name: 'Bot A' }, + botB: { id: `b_${id}`, name: 'Bot B' }, + rounds: [], + status: 'finished', + } +} + +describe('useFightCache — IndexedDB', () => { + beforeEach(async () => { + // Clear any prior DB state + const dbs = await indexedDB.databases?.() || [] + for (const db of dbs) { + if (db.name) indexedDB.deleteDatabase(db.name) + } + }) + + it('caches and retrieves a fight', async () => { + const fight = makeFight('f1') + await cacheFight(fight) + const result = await getCachedFight('f1') + expect(result).toBeTruthy() + expect(result!.id).toBe('f1') + }) + + it('returns null for uncached fight', async () => { + const result = await getCachedFight('nonexistent') + expect(result).toBeNull() + }) + + it('overwrites existing entry with same id', async () => { + const fight1 = { ...makeFight('f1'), status: 'finished' } + const fight2 = { ...makeFight('f1'), status: 'updated' } + await cacheFight(fight1) + await cacheFight(fight2) + + const result = await getCachedFight('f1') + expect((result as any).status).toBe('updated') + }) + + it('evicts oldest when exceeding MAX_CACHED (5)', async () => { + // Cache 6 fights with staggered timestamps + for (let i = 1; i <= 6; i++) { + await cacheFight(makeFight(`f${i}`)) + // Small delay to ensure different _cachedAt timestamps + await new Promise(r => setTimeout(r, 10)) + } + + // f1 (oldest) should be evicted + const oldest = await getCachedFight('f1') + expect(oldest).toBeNull() + + // f6 (newest) should exist + const newest = await getCachedFight('f6') + expect(newest).toBeTruthy() + }) + + it('gracefully handles missing IndexedDB', async () => { + const orig = globalThis.indexedDB + // Temporarily break indexedDB + Object.defineProperty(globalThis, 'indexedDB', { + value: { open: () => { throw new Error('Not available') } }, + writable: true, + configurable: true, + }) + + await expect(cacheFight(makeFight('f_err'))).resolves.toBeUndefined() + const result = await getCachedFight('f_err') + expect(result).toBeNull() + + Object.defineProperty(globalThis, 'indexedDB', { + value: orig, + writable: true, + configurable: true, + }) + }) +}) diff --git a/frontend/src/composables/__tests__/useNostr.test.ts b/frontend/src/composables/__tests__/useNostr.test.ts new file mode 100644 index 0000000..ca2bf69 --- /dev/null +++ b/frontend/src/composables/__tests__/useNostr.test.ts @@ -0,0 +1,131 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' + +// Mock nostr-tools before importing useNostr +vi.mock('nostr-tools', () => ({ + generateSecretKey: () => new Uint8Array(32).fill(1), + getPublicKey: () => 'a'.repeat(64), +})) +vi.mock('nostr-tools/utils', () => ({ + bytesToHex: (b: Uint8Array) => Array.from(b).map(x => x.toString(16).padStart(2, '0')).join(''), + hexToBytes: (h: string) => new Uint8Array(h.match(/.{2}/g)!.map(x => parseInt(x, 16))), +})) +vi.mock('nostr-tools/nip19', () => ({ + nsecEncode: (b: Uint8Array) => 'nsec1mock', +})) + +// Mock the auth module +const mockAuthFetch = vi.fn().mockResolvedValue({ json: () => Promise.resolve({ exists: false }) }) +const mockSetToken = vi.fn() +const mockGetToken = vi.fn().mockReturnValue(null) +const mockIsTokenExpired = vi.fn().mockReturnValue(true) + +vi.mock('../../lib/nostr-auth', () => ({ + buildNip98Token: vi.fn().mockResolvedValue('mock-nip98-token'), + setToken: (...args: unknown[]) => mockSetToken(...args), + getToken: () => mockGetToken(), + isTokenExpired: () => mockIsTokenExpired(), + authFetch: (...args: unknown[]) => mockAuthFetch(...args), +})) + +describe('useNostr', () => { + beforeEach(() => { + vi.clearAllMocks() + localStorage.clear() + }) + + afterEach(() => { + localStorage.clear() + }) + + it('isLoggedIn is false when no pubkey or bot stored', async () => { + vi.resetModules() + const { useNostr } = await import('../useNostr') + const { isLoggedIn, pubkey, bot } = useNostr() + expect(isLoggedIn.value).toBe(false) + expect(pubkey.value).toBeNull() + expect(bot.value).toBeNull() + }) + + it('logout clears all auth state from localStorage', async () => { + localStorage.setItem('bf_pubkey', JSON.stringify('testpub')) + localStorage.setItem('bf_bot', JSON.stringify({ id: 'b1', name: 'Bot' })) + localStorage.setItem('bf_pic', JSON.stringify('https://example.com/pic.jpg')) + localStorage.setItem('bf_nsec', 'secretkey') + + vi.resetModules() + const { useNostr } = await import('../useNostr') + const { logout } = useNostr() + + logout() + + expect(localStorage.getItem('bf_pubkey')).toBeNull() + expect(localStorage.getItem('bf_bot')).toBeNull() + expect(localStorage.getItem('bf_pic')).toBeNull() + expect(localStorage.getItem('bf_nsec')).toBeNull() + expect(mockSetToken).toHaveBeenCalledWith(null) + }) + + it('hasExtension detects window.nostr', async () => { + vi.resetModules() + const { useNostr } = await import('../useNostr') + const { hasExtension } = useNostr() + // No NIP-07 extension in jsdom + expect(hasExtension.value).toBe(false) + }) + + it('hasStoredKey is false when no nsec in localStorage', async () => { + vi.resetModules() + const { useNostr } = await import('../useNostr') + const { hasStoredKey } = useNostr() + expect(hasStoredKey.value).toBe(false) + }) + + it('hasStoredKey is true when nsec pre-set in localStorage', async () => { + localStorage.setItem('bf_nsec', 'test-nsec') + vi.resetModules() + const { useNostr } = await import('../useNostr') + const { hasStoredKey } = useNostr() + expect(hasStoredKey.value).toBe(true) + }) + + it('getStoredNsec returns null when no key stored', async () => { + vi.resetModules() + const { useNostr } = await import('../useNostr') + const { getStoredNsec } = useNostr() + expect(getStoredNsec()).toBeNull() + }) + + it('persistKey saves session nsec to localStorage', async () => { + vi.resetModules() + const { useNostr } = await import('../useNostr') + const { persistKey } = useNostr() + // Without a session key, persist should be a no-op + persistKey() + expect(localStorage.getItem('bf_nsec')).toBeNull() + }) + + it('pubkey and bot are readonly refs', async () => { + vi.resetModules() + const { useNostr } = await import('../useNostr') + const result = useNostr() + // pubkey and bot should be Readonly refs + expect(result.pubkey).toBeDefined() + expect(result.bot).toBeDefined() + expect(result.isLoggedIn).toBeDefined() + expect(result.isLoading).toBeDefined() + }) + + it('waitForSigner resolves false when no extension', async () => { + vi.resetModules() + vi.useFakeTimers() + const { useNostr } = await import('../useNostr') + const { waitForSigner } = useNostr() + + const promise = waitForSigner(500) + // Advance timers past the timeout + vi.advanceTimersByTime(600) + const result = await promise + expect(result).toBe(false) + vi.useRealTimers() + }) +}) diff --git a/frontend/src/composables/__tests__/useOnlineStatus.test.ts b/frontend/src/composables/__tests__/useOnlineStatus.test.ts new file mode 100644 index 0000000..47730c9 --- /dev/null +++ b/frontend/src/composables/__tests__/useOnlineStatus.test.ts @@ -0,0 +1,53 @@ +import { describe, it, expect, vi } from 'vitest' +import { mount } from '@vue/test-utils' +import { defineComponent } from 'vue' +import { useOnlineStatus } from '../useOnlineStatus' + +// Wrapper component to test the composable in a real Vue lifecycle +const TestComponent = defineComponent({ + setup() { + const { isOnline } = useOnlineStatus() + return { isOnline } + }, + template: '{{ isOnline }}', +}) + +describe('useOnlineStatus', () => { + it('returns isOnline ref that reflects navigator.onLine', () => { + const wrapper = mount(TestComponent) + expect(wrapper.vm.isOnline).toBe(true) + wrapper.unmount() + }) + + it('adds event listeners on mount', () => { + const addSpy = vi.spyOn(window, 'addEventListener') + const wrapper = mount(TestComponent) + + expect(addSpy).toHaveBeenCalledWith('online', expect.any(Function)) + expect(addSpy).toHaveBeenCalledWith('offline', expect.any(Function)) + + wrapper.unmount() + addSpy.mockRestore() + }) + + it('removes event listeners on unmount', () => { + const removeSpy = vi.spyOn(window, 'removeEventListener') + const wrapper = mount(TestComponent) + wrapper.unmount() + + expect(removeSpy).toHaveBeenCalledWith('online', expect.any(Function)) + expect(removeSpy).toHaveBeenCalledWith('offline', expect.any(Function)) + + removeSpy.mockRestore() + }) + + it('singleton: multiple instances share same ref', () => { + const w1 = mount(TestComponent) + const w2 = mount(TestComponent) + + expect(w1.vm.isOnline).toBe(w2.vm.isOnline) + + w1.unmount() + w2.unmount() + }) +}) diff --git a/server/src/engine/events.test.ts b/server/src/engine/events.test.ts new file mode 100644 index 0000000..bfd4fdf --- /dev/null +++ b/server/src/engine/events.test.ts @@ -0,0 +1,58 @@ +import { describe, it, expect } from 'vitest' +import { fightEvents, type FightEvent } from './events.js' + +describe('fightEvents', () => { + it('emits events to fight-specific listeners', () => { + const events: FightEvent[] = [] + const unsub = fightEvents.on('fight-1', (e) => events.push(e)) + + fightEvents.emit({ + fightId: 'fight-1', + type: 'test', + data: { msg: 'hello' }, + timestamp: new Date().toISOString(), + }) + + expect(events).toHaveLength(1) + expect(events[0].data.msg).toBe('hello') + unsub() + }) + + it('cleanup removes all listeners for a fight', () => { + const events: FightEvent[] = [] + fightEvents.on('fight-cleanup', (e) => events.push(e)) + fightEvents.on('fight-cleanup', (e) => events.push(e)) + + fightEvents.cleanup('fight-cleanup') + + fightEvents.emit({ + fightId: 'fight-cleanup', + type: 'test', + data: {}, + timestamp: new Date().toISOString(), + }) + + expect(events).toHaveLength(0) + }) + + it('global listeners receive all events', () => { + const events: FightEvent[] = [] + const unsub = fightEvents.onAll((e) => events.push(e)) + + fightEvents.emit({ + fightId: 'fight-global-1', + type: 'test', + data: {}, + timestamp: new Date().toISOString(), + }) + fightEvents.emit({ + fightId: 'fight-global-2', + type: 'test', + data: {}, + timestamp: new Date().toISOString(), + }) + + expect(events).toHaveLength(2) + unsub() + }) +}) diff --git a/server/src/engine/previously-found-bugs.test.ts b/server/src/engine/previously-found-bugs.test.ts new file mode 100644 index 0000000..5ea58f4 --- /dev/null +++ b/server/src/engine/previously-found-bugs.test.ts @@ -0,0 +1,142 @@ +import { describe, it, expect, vi, afterEach } from 'vitest' +import { waitForHumanResponse, clearAllPending } from './human-responses.js' +import { fightEvents } from './events.js' +import type { Challenge } from './challenges.js' + +const factualChallenge: Challenge = { + type: 'speed_blitz', + label: 'Speed Blitz', + prompt: 'What year was Bitcoin launched?', + answers: ['2009'], + scoring: 'factual', + timeout_ms: 10000, + baseDamage: 20, + choices: ['2009', '2010', '2008'], +} + +const quickChallenge: Challenge = { + type: 'math_blitz', + label: 'Math Blitz', + prompt: 'What is 2+2?', + answers: ['4'], + scoring: 'factual', + timeout_ms: 5000, + baseDamage: 18, + choices: ['4', '3', '5'], +} + +afterEach(() => { + clearAllPending() +}) + +describe('BUG-2: SSE uses challenge timeout, not hardcoded', () => { + it('waitForHumanResponse uses challenge.timeout_ms', () => { + const fightId = `bug2-${Date.now()}` + vi.useFakeTimers() + + // 10s challenge timeout + 5s human extra = 15s total + const { promise } = waitForHumanResponse(fightId, 'bot1', factualChallenge, 1) + + let resolved = false + promise.then(() => { resolved = true }) + + // At 14s, should NOT have timed out yet + vi.advanceTimersByTime(14_000) + expect(resolved).toBe(false) + + // At 16s, should have timed out (10s + 5s = 15s) + vi.advanceTimersByTime(2_000) + + vi.useRealTimers() + }) + + it('different challenge type has different timeout', () => { + const fightId = `bug2-quick-${Date.now()}` + vi.useFakeTimers() + + // 5s timeout + 5s human extra = 10s total + const { promise } = waitForHumanResponse(fightId, 'bot2', quickChallenge, 1) + + let resolved = false + promise.then(() => { resolved = true }) + + // At 9s, should NOT have timed out yet + vi.advanceTimersByTime(9_000) + expect(resolved).toBe(false) + + // At 11s, should have timed out (5s + 5s = 10s) + vi.advanceTimersByTime(2_000) + + vi.useRealTimers() + }) +}) + +describe('BUG-3: shuffle return value used', () => { + it('choices returned from waitForHumanResponse are shuffled (contain correct answer)', () => { + vi.useFakeTimers() + const fightId = `bug3-${Date.now()}` + const { choices } = waitForHumanResponse(fightId, 'bot3', factualChallenge, 1) + + // Must contain the correct answer + expect(choices).toContain('2009') + // Must have 3 choices + expect(choices).toHaveLength(3) + + clearAllPending() + vi.useRealTimers() + }) +}) + +describe('BUG-9: no TODO in prompt options', () => { + it('no prompt contains TODO string', () => { + vi.useFakeTimers() + const fightId = `bug9-${Date.now()}` + const { choices } = waitForHumanResponse(fightId, 'bot9', factualChallenge, 1) + + for (const choice of choices) { + expect(choice).not.toContain('TODO') + expect(choice).not.toContain('pass #') + } + + clearAllPending() + vi.useRealTimers() + }) +}) + +describe('BUG-12: fightEvents.cleanup removes listeners', () => { + it('cleanup removes fight listeners', () => { + const fightId = 'bug12-test' + let received = false + fightEvents.on(fightId, () => { received = true }) + + fightEvents.cleanup(fightId) + + // Emit after cleanup — should not fire + fightEvents.emit({ + fightId, + type: 'test', + data: {}, + timestamp: new Date().toISOString(), + }) + + expect(received).toBe(false) + }) + + it('global listeners still fire after cleanup', () => { + const fightId = 'bug12-global' + let received = false + const unsub = fightEvents.onAll(() => { received = true }) + + fightEvents.cleanup(fightId) + + fightEvents.emit({ + fightId, + type: 'test', + data: {}, + timestamp: new Date().toISOString(), + }) + + expect(received).toBe(true) + unsub() + }) +})