diff --git a/apps/admin-desktop/package.json b/apps/admin-desktop/package.json index 358e6824..fdf2845d 100644 --- a/apps/admin-desktop/package.json +++ b/apps/admin-desktop/package.json @@ -19,9 +19,13 @@ "react-router-dom": "^7.14.1" }, "devDependencies": { + "@testing-library/jest-dom": "^6.4.0", + "@testing-library/react": "^16.0.0", "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", "@vitejs/plugin-react": "^6.0.1", + "happy-dom": "^15.11.0", + "vitest": "^4.1.4", "vite": "^8.0.8" } } diff --git a/apps/admin-desktop/src/__tests__/command-channel-panel.test.tsx b/apps/admin-desktop/src/__tests__/command-channel-panel.test.tsx new file mode 100644 index 00000000..f3ce03ff --- /dev/null +++ b/apps/admin-desktop/src/__tests__/command-channel-panel.test.tsx @@ -0,0 +1,138 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { render, screen, fireEvent, waitFor } from '@testing-library/react' +import '@testing-library/jest-dom/vitest' +import { CommandChannelPanel } from '../components/CommandChannelPanel.js' +import { httpsCallable } from 'firebase/functions' + +const { mockOnSnapshot, mockHttpsCallable, mockGetFunctions, mockDb } = vi.hoisted(() => ({ + mockOnSnapshot: vi.fn(), + mockHttpsCallable: vi.fn(), + mockGetFunctions: vi.fn(() => ({})), + mockDb: {}, +})) + +interface MockQueryRef { + type: string + filters: { type: string; field?: string }[] +} + +vi.mock('firebase/firestore', () => { + const collection = vi.fn((_db, name) => ({ type: 'collection', name })) + const where = vi.fn((field, _op, value) => ({ type: 'where', field, value })) + const orderBy = vi.fn((field, dir) => ({ type: 'orderBy', field, dir })) + const limit = vi.fn((n) => ({ type: 'limit', n })) + const query = vi.fn((...args) => ({ type: 'query', filters: args.slice(1) })) + return { + getFirestore: vi.fn(() => mockDb), + collection, + query, + where, + orderBy, + limit, + onSnapshot: mockOnSnapshot, + doc: vi.fn(), + } +}) + +vi.mock('firebase/functions', () => ({ + httpsCallable: vi.fn(() => mockHttpsCallable), + getFunctions: mockGetFunctions, +})) + +const threadSnap = { + docs: [ + { + id: 'th1', + data: () => ({ + threadType: 'agency_assistance', + subject: 'Test thread', + participantUids: { u1: true, u2: true }, + lastMessageAt: 1000, + }), + }, + ], + empty: false, +} + +const msgSnap = { + docs: [ + { + id: 'm1', + data: () => ({ + authorUid: 'u2', + body: 'Hello world', + createdAt: 1000, + }), + }, + ], + empty: false, +} + +beforeEach(() => { + mockOnSnapshot.mockReset() + mockHttpsCallable.mockReset() + mockHttpsCallable.mockResolvedValue({ data: { status: 'sent' } }) + + mockOnSnapshot.mockImplementation((ref: MockQueryRef, cb) => { + if (ref.type === 'query') { + const isMessages = ref.filters.some((f) => f.type === 'where' && f.field === 'threadId') + cb(isMessages ? msgSnap : threadSnap) + } + return vi.fn() + }) +}) + +describe('CommandChannelPanel', () => { + it('renders nothing when no threads', () => { + mockOnSnapshot.mockImplementation((ref: MockQueryRef, cb) => { + if (ref.type === 'query') { + cb({ docs: [], empty: true }) + } + return vi.fn() + }) + const { container } = render() + expect(container.firstChild).toBeNull() + }) + + it('renders thread tabs and messages', () => { + render() + expect(screen.getByText('πŸ₯ Agency')).toBeInTheDocument() + expect(screen.getByText('Test thread')).toBeInTheDocument() + expect(screen.getByText('Hello world')).toBeInTheDocument() + }) + + it('sends a message when clicking Send', async () => { + render() + const textarea = screen.getByPlaceholderText('Type a message...') + fireEvent.change(textarea, { target: { value: 'Units dispatched' } }) + fireEvent.click(screen.getByText('Send')) + await waitFor(() => { + expect(httpsCallable).toHaveBeenCalledWith(expect.any(Object), 'addCommandChannelMessage') + expect(mockHttpsCallable).toHaveBeenCalledWith( + expect.objectContaining({ + threadId: 'th1', + body: 'Units dispatched', + idempotencyKey: expect.any(String), + }), + ) + }) + expect(textarea).toHaveValue('') + }) + + it('displays an error when send fails', async () => { + mockHttpsCallable.mockRejectedValue(new Error('Network error')) + render() + const textarea = screen.getByPlaceholderText('Type a message...') + fireEvent.change(textarea, { target: { value: 'Fail me' } }) + fireEvent.click(screen.getByText('Send')) + await waitFor(() => { + expect(screen.getByText('Network error')).toBeInTheDocument() + }) + }) + + it('enforces max length of 2000', () => { + render() + const textarea = screen.getByPlaceholderText('Type a message...') + expect(textarea).toHaveAttribute('maxLength', '2000') + }) +}) diff --git a/apps/admin-desktop/src/__tests__/field-mode-store.test.ts b/apps/admin-desktop/src/__tests__/field-mode-store.test.ts new file mode 100644 index 00000000..9059b5b9 --- /dev/null +++ b/apps/admin-desktop/src/__tests__/field-mode-store.test.ts @@ -0,0 +1,75 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import { renderHook, act } from '@testing-library/react' +import { useFieldModeStore } from '../hooks/useFieldModeStore.js' + +const { mockOnSnapshot, mockHttpsCallable, mockHttpsCallableFn } = vi.hoisted(() => { + const httpsCallableFn = vi.fn() + return { + mockOnSnapshot: vi.fn(), + mockHttpsCallable: vi.fn(() => httpsCallableFn), + mockHttpsCallableFn: httpsCallableFn, + } +}) + +vi.mock('firebase/firestore', () => ({ + doc: vi.fn(), + onSnapshot: mockOnSnapshot, + getFirestore: vi.fn(() => ({})), +})) + +vi.mock('firebase/functions', () => ({ + httpsCallable: mockHttpsCallable, + getFunctions: vi.fn(() => ({})), +})) + +const ts = 1700000000000 // fixed timestamp for test determinism + +beforeEach(() => { + mockOnSnapshot.mockReset() + mockHttpsCallable.mockClear() + mockHttpsCallableFn.mockReset() + mockHttpsCallableFn.mockResolvedValue({ data: { status: 'exited' } }) +}) +afterEach(() => { + vi.useRealTimers() +}) + +describe('useFieldModeStore', () => { + it('shows isActive true when session snapshot has isActive true', () => { + mockOnSnapshot.mockImplementation((_ref, cb) => { + cb({ + exists: () => true, + data: () => ({ isActive: true, expiresAt: ts + 3600000 }), + }) + return vi.fn() + }) + const { result } = renderHook(() => useFieldModeStore('uid-1')) + expect(result.current.isActive).toBe(true) + }) + + it('shows isActive false when no session exists', () => { + mockOnSnapshot.mockImplementation((_ref, cb) => { + cb({ exists: () => false, data: () => undefined }) + return vi.fn() + }) + const { result } = renderHook(() => useFieldModeStore('uid-1')) + expect(result.current.isActive).toBe(false) + }) + + it('calls exitFieldMode when session expires', () => { + vi.useFakeTimers() + const expiredAt = Date.now() - 1000 + mockOnSnapshot.mockImplementation((_ref, cb) => { + cb({ + exists: () => true, + data: () => ({ isActive: true, expiresAt: expiredAt }), + }) + return vi.fn() + }) + renderHook(() => useFieldModeStore('uid-1')) + act(() => { + vi.advanceTimersByTime(65000) + }) + expect(mockHttpsCallable).toHaveBeenCalledWith(expect.anything(), 'exitFieldMode') + }) +}) diff --git a/apps/admin-desktop/src/components/CommandChannelPanel.tsx b/apps/admin-desktop/src/components/CommandChannelPanel.tsx new file mode 100644 index 00000000..4242830d --- /dev/null +++ b/apps/admin-desktop/src/components/CommandChannelPanel.tsx @@ -0,0 +1,160 @@ +// apps/admin-desktop/src/components/CommandChannelPanel.tsx +import { useState, useEffect, useRef } from 'react' +import { + collection, + query, + where, + orderBy, + limit, + onSnapshot, + type QueryDocumentSnapshot, + getFirestore, +} from 'firebase/firestore' +import { httpsCallable, getFunctions } from 'firebase/functions' + +const db = getFirestore() +const functions = getFunctions() + +interface Thread { + id: string + threadType: 'agency_assistance' | 'border_share' + subject: string + participantUids: Record + lastMessageAt?: number +} + +interface Message { + id: string + authorUid: string + body: string + createdAt: number +} + +interface Props { + reportId: string + currentUserUid: string +} + +export function CommandChannelPanel({ reportId, currentUserUid }: Props) { + const [threads, setThreads] = useState([]) + const [activeThreadId, setActiveThreadId] = useState(null) + const initialSelectionDoneRef = useRef(false) + const [messages, setMessages] = useState([]) + const [input, setInput] = useState('') + const [error, setError] = useState(null) + + useEffect(() => { + // Reset selection state when reportId changes + initialSelectionDoneRef.current = false + queueMicrotask(() => { + setActiveThreadId(null) + setMessages([]) + }) + const q = query(collection(db, 'command_channel_threads'), where('reportId', '==', reportId)) + return onSnapshot(q, (snap) => { + const found = snap.docs.map((d: QueryDocumentSnapshot) => ({ + id: d.id, + ...(d.data() as Omit), + })) + setThreads(found) + if (found.length > 0 && !initialSelectionDoneRef.current) { + const first = found[0] + if (first) { + setActiveThreadId(first.id) + initialSelectionDoneRef.current = true + } + } + }) + }, [reportId]) + + useEffect(() => { + if (!activeThreadId) return + const q = query( + collection(db, 'command_channel_messages'), + where('threadId', '==', activeThreadId), + orderBy('createdAt', 'desc'), + limit(50), + ) + return onSnapshot(q, (snap) => { + setMessages( + snap.docs + .map((d: QueryDocumentSnapshot) => ({ id: d.id, ...(d.data() as Omit) })) + .reverse(), + ) + }) + }, [activeThreadId]) + + async function handleSend() { + if (!activeThreadId || !input.trim()) return + setError(null) + try { + const fn = httpsCallable(functions, 'addCommandChannelMessage') + await fn({ + threadId: activeThreadId, + body: input.trim(), + idempotencyKey: crypto.randomUUID(), + }) + setInput('') + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to send') + } + } + + if (threads.length === 0) return null + + const activeThread = threads.find((t) => t.id === activeThreadId) + + return ( +
+
+ {threads.map((t) => ( + + ))} +
+ + {activeThread &&

{activeThread.subject}

} + +
+ {messages.map((m) => ( +
+ {m.authorUid} +

{m.body}

+
+ ))} +
+ +
+