|
|
@@ -0,0 +1,1303 @@
|
|
|
+import { describe, expect, test } from 'bun:test';
|
|
|
+import * as fs from 'node:fs/promises';
|
|
|
+import { createServer } from 'node:http';
|
|
|
+import * as path from 'node:path';
|
|
|
+import { createDashboardServer } from './dashboard';
|
|
|
+
|
|
|
+// Helper to find a free port (matches interview.test.ts pattern)
|
|
|
+function findFreePort(): Promise<number> {
|
|
|
+ return new Promise((resolve, reject) => {
|
|
|
+ const server = createServer();
|
|
|
+ server.listen(0, () => {
|
|
|
+ const address = server.address();
|
|
|
+ if (address && typeof address !== 'string') {
|
|
|
+ const port = address.port;
|
|
|
+ server.close(() => resolve(port));
|
|
|
+ } else {
|
|
|
+ server.close(() => reject(new Error('Failed to get port')));
|
|
|
+ }
|
|
|
+ });
|
|
|
+ });
|
|
|
+}
|
|
|
+
|
|
|
+// Helper to start a dashboard on a free port
|
|
|
+async function startDashboard() {
|
|
|
+ const port = await findFreePort();
|
|
|
+ const dashboard = createDashboardServer({
|
|
|
+ port,
|
|
|
+ outputFolder: 'interview',
|
|
|
+ });
|
|
|
+ const baseUrl = await dashboard.start();
|
|
|
+
|
|
|
+ return {
|
|
|
+ dashboard,
|
|
|
+ baseUrl,
|
|
|
+ authToken: dashboard.authToken,
|
|
|
+ cleanup: () => {
|
|
|
+ dashboard.close();
|
|
|
+ },
|
|
|
+ };
|
|
|
+}
|
|
|
+
|
|
|
+// Helper to create a temp directory with interview files
|
|
|
+async function createTempInterviewDir() {
|
|
|
+ const tempDir = await fs.mkdtemp('/tmp/dashboard-test-');
|
|
|
+ const interviewDir = path.join(tempDir, 'interview');
|
|
|
+ await fs.mkdir(interviewDir, { recursive: true });
|
|
|
+ return tempDir;
|
|
|
+}
|
|
|
+
|
|
|
+describe('dashboard server', () => {
|
|
|
+ describe('health endpoint', () => {
|
|
|
+ test('returns 200 with status ok and counts', async () => {
|
|
|
+ const { baseUrl, cleanup } = await startDashboard();
|
|
|
+ try {
|
|
|
+ const response = await fetch(`${baseUrl}/api/health`);
|
|
|
+ expect(response.status).toBe(200);
|
|
|
+ const data = (await response.json()) as {
|
|
|
+ status: string;
|
|
|
+ sessions: number;
|
|
|
+ interviews: number;
|
|
|
+ };
|
|
|
+ expect(data.status).toBe('ok');
|
|
|
+ expect(data.sessions).toBe(0);
|
|
|
+ expect(data.interviews).toBe(0);
|
|
|
+ } finally {
|
|
|
+ cleanup();
|
|
|
+ }
|
|
|
+ });
|
|
|
+
|
|
|
+ test('works without auth', async () => {
|
|
|
+ const { baseUrl, cleanup } = await startDashboard();
|
|
|
+ try {
|
|
|
+ const response = await fetch(`${baseUrl}/api/health`);
|
|
|
+ expect(response.status).toBe(200);
|
|
|
+ } finally {
|
|
|
+ cleanup();
|
|
|
+ }
|
|
|
+ });
|
|
|
+ });
|
|
|
+
|
|
|
+ describe('auth gate', () => {
|
|
|
+ test('POST /api/register without auth returns 401', async () => {
|
|
|
+ const { baseUrl, cleanup } = await startDashboard();
|
|
|
+ try {
|
|
|
+ const response = await fetch(`${baseUrl}/api/register`, {
|
|
|
+ method: 'POST',
|
|
|
+ body: JSON.stringify({
|
|
|
+ sessionID: 'test-session',
|
|
|
+ directory: '/test/dir',
|
|
|
+ }),
|
|
|
+ headers: { 'content-type': 'application/json' },
|
|
|
+ });
|
|
|
+ expect(response.status).toBe(401);
|
|
|
+ } finally {
|
|
|
+ cleanup();
|
|
|
+ }
|
|
|
+ });
|
|
|
+
|
|
|
+ test('POST /api/interviews without auth returns 401', async () => {
|
|
|
+ const { baseUrl, cleanup } = await startDashboard();
|
|
|
+ try {
|
|
|
+ const response = await fetch(`${baseUrl}/api/interviews`, {
|
|
|
+ method: 'POST',
|
|
|
+ body: JSON.stringify({
|
|
|
+ interviewId: 'test-interview',
|
|
|
+ sessionID: 'test-session',
|
|
|
+ idea: 'Test idea',
|
|
|
+ }),
|
|
|
+ headers: { 'content-type': 'application/json' },
|
|
|
+ });
|
|
|
+ expect(response.status).toBe(401);
|
|
|
+ } finally {
|
|
|
+ cleanup();
|
|
|
+ }
|
|
|
+ });
|
|
|
+
|
|
|
+ test('GET /api/sessions without auth returns 401', async () => {
|
|
|
+ const { baseUrl, cleanup } = await startDashboard();
|
|
|
+ try {
|
|
|
+ const response = await fetch(`${baseUrl}/api/sessions`);
|
|
|
+ expect(response.status).toBe(401);
|
|
|
+ } finally {
|
|
|
+ cleanup();
|
|
|
+ }
|
|
|
+ });
|
|
|
+ });
|
|
|
+
|
|
|
+ describe('auth methods', () => {
|
|
|
+ test('works with ?token= query param', async () => {
|
|
|
+ const { baseUrl, authToken, cleanup } = await startDashboard();
|
|
|
+ try {
|
|
|
+ const response = await fetch(
|
|
|
+ `${baseUrl}/api/sessions?token=${authToken}`,
|
|
|
+ );
|
|
|
+ expect(response.status).toBe(200);
|
|
|
+ const data = (await response.json()) as { sessions: unknown[] };
|
|
|
+ expect(Array.isArray(data.sessions)).toBe(true);
|
|
|
+ } finally {
|
|
|
+ cleanup();
|
|
|
+ }
|
|
|
+ });
|
|
|
+
|
|
|
+ test('works with Cookie header', async () => {
|
|
|
+ const { baseUrl, authToken, cleanup } = await startDashboard();
|
|
|
+ try {
|
|
|
+ const response = await fetch(`${baseUrl}/api/sessions`, {
|
|
|
+ headers: {
|
|
|
+ cookie: `dashboard_token=${authToken}`,
|
|
|
+ },
|
|
|
+ });
|
|
|
+ expect(response.status).toBe(200);
|
|
|
+ } finally {
|
|
|
+ cleanup();
|
|
|
+ }
|
|
|
+ });
|
|
|
+
|
|
|
+ test('works with Authorization: Bearer header', async () => {
|
|
|
+ const { baseUrl, authToken, cleanup } = await startDashboard();
|
|
|
+ try {
|
|
|
+ const response = await fetch(`${baseUrl}/api/sessions`, {
|
|
|
+ headers: {
|
|
|
+ authorization: `Bearer ${authToken}`,
|
|
|
+ },
|
|
|
+ });
|
|
|
+ expect(response.status).toBe(200);
|
|
|
+ } finally {
|
|
|
+ cleanup();
|
|
|
+ }
|
|
|
+ });
|
|
|
+ });
|
|
|
+
|
|
|
+ describe('session registration (POST /api/register)', () => {
|
|
|
+ test('registers a valid session', async () => {
|
|
|
+ const { baseUrl, authToken, dashboard, cleanup } = await startDashboard();
|
|
|
+ try {
|
|
|
+ const response = await fetch(
|
|
|
+ `${baseUrl}/api/register?token=${authToken}`,
|
|
|
+ {
|
|
|
+ method: 'POST',
|
|
|
+ body: JSON.stringify({
|
|
|
+ sessionID: 'session-123',
|
|
|
+ directory: '/test/directory',
|
|
|
+ pid: 12345,
|
|
|
+ }),
|
|
|
+ headers: { 'content-type': 'application/json' },
|
|
|
+ },
|
|
|
+ );
|
|
|
+ expect(response.status).toBe(200);
|
|
|
+ const data = (await response.json()) as { status: string };
|
|
|
+ expect(data.status).toBe('registered');
|
|
|
+
|
|
|
+ // Verify session is registered
|
|
|
+ const _state = dashboard.getState('dummy-interview');
|
|
|
+ // State doesn't exist yet, but session was registered
|
|
|
+ } finally {
|
|
|
+ cleanup();
|
|
|
+ }
|
|
|
+ });
|
|
|
+
|
|
|
+ test('rejects missing sessionID', async () => {
|
|
|
+ const { baseUrl, authToken, cleanup } = await startDashboard();
|
|
|
+ try {
|
|
|
+ const response = await fetch(
|
|
|
+ `${baseUrl}/api/register?token=${authToken}`,
|
|
|
+ {
|
|
|
+ method: 'POST',
|
|
|
+ body: JSON.stringify({
|
|
|
+ directory: '/test/directory',
|
|
|
+ }),
|
|
|
+ headers: { 'content-type': 'application/json' },
|
|
|
+ },
|
|
|
+ );
|
|
|
+ expect(response.status).toBe(400);
|
|
|
+ } finally {
|
|
|
+ cleanup();
|
|
|
+ }
|
|
|
+ });
|
|
|
+
|
|
|
+ test('rejects invalid sessionID (special chars)', async () => {
|
|
|
+ const { baseUrl, authToken, cleanup } = await startDashboard();
|
|
|
+ try {
|
|
|
+ const response = await fetch(
|
|
|
+ `${baseUrl}/api/register?token=${authToken}`,
|
|
|
+ {
|
|
|
+ method: 'POST',
|
|
|
+ body: JSON.stringify({
|
|
|
+ sessionID: 'session/with/slashes',
|
|
|
+ directory: '/test/directory',
|
|
|
+ }),
|
|
|
+ headers: { 'content-type': 'application/json' },
|
|
|
+ },
|
|
|
+ );
|
|
|
+ expect(response.status).toBe(400);
|
|
|
+ } finally {
|
|
|
+ cleanup();
|
|
|
+ }
|
|
|
+ });
|
|
|
+ });
|
|
|
+
|
|
|
+ describe('create interview (POST /api/interviews)', () => {
|
|
|
+ test('creates interview entry in cache', async () => {
|
|
|
+ const { baseUrl, authToken, dashboard, cleanup } = await startDashboard();
|
|
|
+ try {
|
|
|
+ const response = await fetch(
|
|
|
+ `${baseUrl}/api/interviews?token=${authToken}`,
|
|
|
+ {
|
|
|
+ method: 'POST',
|
|
|
+ body: JSON.stringify({
|
|
|
+ interviewId: 'interview-1',
|
|
|
+ sessionID: 'session-1',
|
|
|
+ idea: 'Test Interview',
|
|
|
+ }),
|
|
|
+ headers: { 'content-type': 'application/json' },
|
|
|
+ },
|
|
|
+ );
|
|
|
+ expect(response.status).toBe(200);
|
|
|
+ const data = (await response.json()) as {
|
|
|
+ interviewId: string;
|
|
|
+ url: string;
|
|
|
+ };
|
|
|
+ expect(data.interviewId).toBe('interview-1');
|
|
|
+ expect(data.url).toContain('interview-1');
|
|
|
+
|
|
|
+ // Verify interview is in cache
|
|
|
+ const state = dashboard.getState('interview-1');
|
|
|
+ expect(state?.interviewId).toBe('interview-1');
|
|
|
+ expect(state?.idea).toBe('Test Interview');
|
|
|
+ expect(state?.mode).toBe('awaiting-agent');
|
|
|
+ } finally {
|
|
|
+ cleanup();
|
|
|
+ }
|
|
|
+ });
|
|
|
+
|
|
|
+ test('returns interview URL', async () => {
|
|
|
+ const { baseUrl, authToken, cleanup } = await startDashboard();
|
|
|
+ try {
|
|
|
+ const response = await fetch(
|
|
|
+ `${baseUrl}/api/interviews?token=${authToken}`,
|
|
|
+ {
|
|
|
+ method: 'POST',
|
|
|
+ body: JSON.stringify({
|
|
|
+ interviewId: 'interview-2',
|
|
|
+ sessionID: 'session-2',
|
|
|
+ idea: 'Test URL',
|
|
|
+ }),
|
|
|
+ headers: { 'content-type': 'application/json' },
|
|
|
+ },
|
|
|
+ );
|
|
|
+ const data = (await response.json()) as { url: string };
|
|
|
+ expect(data.url).toBe(`${baseUrl}/interview/interview-2`);
|
|
|
+ } finally {
|
|
|
+ cleanup();
|
|
|
+ }
|
|
|
+ });
|
|
|
+
|
|
|
+ test('rejects missing fields', async () => {
|
|
|
+ const { baseUrl, authToken, cleanup } = await startDashboard();
|
|
|
+ try {
|
|
|
+ const response = await fetch(
|
|
|
+ `${baseUrl}/api/interviews?token=${authToken}`,
|
|
|
+ {
|
|
|
+ method: 'POST',
|
|
|
+ body: JSON.stringify({
|
|
|
+ interviewId: 'interview-3',
|
|
|
+ sessionID: 'session-3',
|
|
|
+ }),
|
|
|
+ headers: { 'content-type': 'application/json' },
|
|
|
+ },
|
|
|
+ );
|
|
|
+ expect(response.status).toBe(400);
|
|
|
+ } finally {
|
|
|
+ cleanup();
|
|
|
+ }
|
|
|
+ });
|
|
|
+ });
|
|
|
+
|
|
|
+ describe('state push/merge (POST /api/interviews/:id/state)', () => {
|
|
|
+ test('creates new entry when not in cache', async () => {
|
|
|
+ const { baseUrl, authToken, dashboard, cleanup } = await startDashboard();
|
|
|
+ try {
|
|
|
+ const response = await fetch(
|
|
|
+ `${baseUrl}/api/interviews/new-interview/state?token=${authToken}`,
|
|
|
+ {
|
|
|
+ method: 'POST',
|
|
|
+ body: JSON.stringify({
|
|
|
+ sessionID: 'session-new',
|
|
|
+ idea: 'New Idea',
|
|
|
+ summary: 'Test summary',
|
|
|
+ questions: [
|
|
|
+ { id: 'q-1', question: 'What?', options: ['A', 'B'] },
|
|
|
+ ],
|
|
|
+ }),
|
|
|
+ headers: { 'content-type': 'application/json' },
|
|
|
+ },
|
|
|
+ );
|
|
|
+ expect(response.status).toBe(200);
|
|
|
+
|
|
|
+ const state = dashboard.getState('new-interview');
|
|
|
+ expect(state?.interviewId).toBe('new-interview');
|
|
|
+ expect(state?.summary).toBe('Test summary');
|
|
|
+ expect(state?.questions.length).toBe(1);
|
|
|
+ } finally {
|
|
|
+ cleanup();
|
|
|
+ }
|
|
|
+ });
|
|
|
+
|
|
|
+ test('merges partial state update when entry exists', async () => {
|
|
|
+ const { baseUrl, authToken, dashboard, cleanup } = await startDashboard();
|
|
|
+ try {
|
|
|
+ // First push - create entry
|
|
|
+ await fetch(
|
|
|
+ `${baseUrl}/api/interviews/merge-test/state?token=${authToken}`,
|
|
|
+ {
|
|
|
+ method: 'POST',
|
|
|
+ body: JSON.stringify({
|
|
|
+ sessionID: 'session-merge',
|
|
|
+ idea: 'Merge Test',
|
|
|
+ summary: 'Initial summary',
|
|
|
+ questions: [{ id: 'q-1', question: 'Q1?', options: ['A', 'B'] }],
|
|
|
+ }),
|
|
|
+ headers: { 'content-type': 'application/json' },
|
|
|
+ },
|
|
|
+ );
|
|
|
+
|
|
|
+ // Second push - merge updates
|
|
|
+ await fetch(
|
|
|
+ `${baseUrl}/api/interviews/merge-test/state?token=${authToken}`,
|
|
|
+ {
|
|
|
+ method: 'POST',
|
|
|
+ body: JSON.stringify({
|
|
|
+ mode: 'awaiting-user',
|
|
|
+ summary: 'Updated summary',
|
|
|
+ title: 'Updated Title',
|
|
|
+ questions: [{ id: 'q-2', question: 'Q2?', options: ['C', 'D'] }],
|
|
|
+ }),
|
|
|
+ headers: { 'content-type': 'application/json' },
|
|
|
+ },
|
|
|
+ );
|
|
|
+
|
|
|
+ const state = dashboard.getState('merge-test');
|
|
|
+ expect(state?.mode).toBe('awaiting-user');
|
|
|
+ expect(state?.summary).toBe('Updated summary');
|
|
|
+ expect(state?.title).toBe('Updated Title');
|
|
|
+ expect(state?.questions.length).toBe(1);
|
|
|
+ expect(state?.questions[0].id).toBe('q-2');
|
|
|
+ } finally {
|
|
|
+ cleanup();
|
|
|
+ }
|
|
|
+ });
|
|
|
+
|
|
|
+ test('rejects invalid interview ID', async () => {
|
|
|
+ const { baseUrl, authToken, cleanup } = await startDashboard();
|
|
|
+ try {
|
|
|
+ const response = await fetch(
|
|
|
+ `${baseUrl}/api/interviews/invalid/id/state?token=${authToken}`,
|
|
|
+ {
|
|
|
+ method: 'POST',
|
|
|
+ body: JSON.stringify({}),
|
|
|
+ headers: { 'content-type': 'application/json' },
|
|
|
+ },
|
|
|
+ );
|
|
|
+ expect(response.status).toBe(400);
|
|
|
+ } finally {
|
|
|
+ cleanup();
|
|
|
+ }
|
|
|
+ });
|
|
|
+ });
|
|
|
+
|
|
|
+ describe('get state (GET /api/interviews/:id/state)', () => {
|
|
|
+ test('returns full state for existing interview', async () => {
|
|
|
+ const { baseUrl, authToken, dashboard, cleanup } = await startDashboard();
|
|
|
+ try {
|
|
|
+ // Create interview
|
|
|
+ dashboard.pushState({
|
|
|
+ interviewId: 'get-state-test',
|
|
|
+ sessionID: 'session-get',
|
|
|
+ idea: 'Get State Test',
|
|
|
+ mode: 'awaiting-user',
|
|
|
+ summary: 'Test summary',
|
|
|
+ title: 'Test Title',
|
|
|
+ questions: [{ id: 'q-1', question: 'What?', options: ['A', 'B'] }],
|
|
|
+ pendingAnswers: null,
|
|
|
+ lastUpdatedAt: Date.now(),
|
|
|
+ filePath: '',
|
|
|
+ nudgeAction: null,
|
|
|
+ });
|
|
|
+
|
|
|
+ const response = await fetch(
|
|
|
+ `${baseUrl}/api/interviews/get-state-test/state?token=${authToken}`,
|
|
|
+ );
|
|
|
+ expect(response.status).toBe(200);
|
|
|
+ const data = (await response.json()) as {
|
|
|
+ interview: { id: string; idea: string };
|
|
|
+ mode: string;
|
|
|
+ summary: string;
|
|
|
+ questions: Array<{ id: string }>;
|
|
|
+ };
|
|
|
+ expect(data.interview.id).toBe('get-state-test');
|
|
|
+ expect(data.interview.idea).toBe('Get State Test');
|
|
|
+ expect(data.mode).toBe('awaiting-user');
|
|
|
+ expect(data.summary).toBe('Test summary');
|
|
|
+ expect(data.questions.length).toBe(1);
|
|
|
+ } finally {
|
|
|
+ cleanup();
|
|
|
+ }
|
|
|
+ });
|
|
|
+
|
|
|
+ test('returns 404 for unknown interview', async () => {
|
|
|
+ const { baseUrl, authToken, cleanup } = await startDashboard();
|
|
|
+ try {
|
|
|
+ const response = await fetch(
|
|
|
+ `${baseUrl}/api/interviews/unknown/state?token=${authToken}`,
|
|
|
+ );
|
|
|
+ expect(response.status).toBe(404);
|
|
|
+ } finally {
|
|
|
+ cleanup();
|
|
|
+ }
|
|
|
+ });
|
|
|
+
|
|
|
+ test('includes document content from .md file when filePath points to real file', async () => {
|
|
|
+ const tempDir = await createTempInterviewDir();
|
|
|
+ const { baseUrl, authToken, dashboard, cleanup } = await startDashboard();
|
|
|
+ try {
|
|
|
+ // Create a markdown file
|
|
|
+ const mdPath = path.join(tempDir, 'interview', 'doc-test.md');
|
|
|
+ await fs.writeFile(mdPath, '# Test Document\n\nContent here.', 'utf8');
|
|
|
+
|
|
|
+ // Register the temp directory as a session
|
|
|
+ dashboard.registerSession({
|
|
|
+ sessionID: 'session-doc',
|
|
|
+ directory: tempDir,
|
|
|
+ pid: 0,
|
|
|
+ registeredAt: Date.now(),
|
|
|
+ });
|
|
|
+
|
|
|
+ // Push state with filePath
|
|
|
+ dashboard.pushState({
|
|
|
+ interviewId: 'doc-test',
|
|
|
+ sessionID: 'session-doc',
|
|
|
+ idea: 'Doc Test',
|
|
|
+ mode: 'completed',
|
|
|
+ summary: 'Test',
|
|
|
+ title: 'Doc Test',
|
|
|
+ questions: [],
|
|
|
+ pendingAnswers: null,
|
|
|
+ lastUpdatedAt: Date.now(),
|
|
|
+ filePath: mdPath,
|
|
|
+ nudgeAction: null,
|
|
|
+ });
|
|
|
+
|
|
|
+ const response = await fetch(
|
|
|
+ `${baseUrl}/api/interviews/doc-test/state?token=${authToken}`,
|
|
|
+ );
|
|
|
+ const data = (await response.json()) as { document: string };
|
|
|
+ expect(data.document).toContain('# Test Document');
|
|
|
+ expect(data.document).toContain('Content here.');
|
|
|
+
|
|
|
+ await fs.rm(tempDir, { recursive: true, force: true });
|
|
|
+ } finally {
|
|
|
+ cleanup();
|
|
|
+ }
|
|
|
+ });
|
|
|
+
|
|
|
+ test('returns isBusy true when mode is awaiting-agent', async () => {
|
|
|
+ const { baseUrl, authToken, dashboard, cleanup } = await startDashboard();
|
|
|
+ try {
|
|
|
+ dashboard.pushState({
|
|
|
+ interviewId: 'busy-test',
|
|
|
+ sessionID: 'session-busy',
|
|
|
+ idea: 'Busy Test',
|
|
|
+ mode: 'awaiting-agent',
|
|
|
+ summary: 'Test',
|
|
|
+ title: 'Busy Test',
|
|
|
+ questions: [],
|
|
|
+ pendingAnswers: null,
|
|
|
+ lastUpdatedAt: Date.now(),
|
|
|
+ filePath: '',
|
|
|
+ nudgeAction: null,
|
|
|
+ });
|
|
|
+
|
|
|
+ const response = await fetch(
|
|
|
+ `${baseUrl}/api/interviews/busy-test/state?token=${authToken}`,
|
|
|
+ );
|
|
|
+ const data = (await response.json()) as { isBusy: boolean };
|
|
|
+ expect(data.isBusy).toBe(true);
|
|
|
+ } finally {
|
|
|
+ cleanup();
|
|
|
+ }
|
|
|
+ });
|
|
|
+ });
|
|
|
+
|
|
|
+ describe('submit answers (POST /api/interviews/:id/answers)', () => {
|
|
|
+ test('stores answers as pending', async () => {
|
|
|
+ const { baseUrl, authToken, dashboard, cleanup } = await startDashboard();
|
|
|
+ try {
|
|
|
+ // Create interview
|
|
|
+ dashboard.pushState({
|
|
|
+ interviewId: 'answers-test',
|
|
|
+ sessionID: 'session-answers',
|
|
|
+ idea: 'Answers Test',
|
|
|
+ mode: 'awaiting-user',
|
|
|
+ summary: 'Test',
|
|
|
+ title: 'Answers Test',
|
|
|
+ questions: [
|
|
|
+ {
|
|
|
+ id: 'q-1',
|
|
|
+ question: 'What?',
|
|
|
+ options: ['A', 'B'],
|
|
|
+ suggested: 'A',
|
|
|
+ },
|
|
|
+ ],
|
|
|
+ pendingAnswers: null,
|
|
|
+ lastUpdatedAt: Date.now(),
|
|
|
+ filePath: '',
|
|
|
+ nudgeAction: null,
|
|
|
+ });
|
|
|
+
|
|
|
+ // Submit answers
|
|
|
+ const response = await fetch(
|
|
|
+ `${baseUrl}/api/interviews/answers-test/answers?token=${authToken}`,
|
|
|
+ {
|
|
|
+ method: 'POST',
|
|
|
+ body: JSON.stringify({
|
|
|
+ answers: [{ questionId: 'q-1', answer: 'A' }],
|
|
|
+ }),
|
|
|
+ headers: { 'content-type': 'application/json' },
|
|
|
+ },
|
|
|
+ );
|
|
|
+ expect(response.status).toBe(200);
|
|
|
+
|
|
|
+ // Verify answers are stored
|
|
|
+ const state = dashboard.getState('answers-test');
|
|
|
+ expect(state?.pendingAnswers).toEqual([
|
|
|
+ { questionId: 'q-1', answer: 'A' },
|
|
|
+ ]);
|
|
|
+ } finally {
|
|
|
+ cleanup();
|
|
|
+ }
|
|
|
+ });
|
|
|
+
|
|
|
+ test('sets mode to awaiting-agent', async () => {
|
|
|
+ const { baseUrl, authToken, dashboard, cleanup } = await startDashboard();
|
|
|
+ try {
|
|
|
+ dashboard.pushState({
|
|
|
+ interviewId: 'mode-test',
|
|
|
+ sessionID: 'session-mode',
|
|
|
+ idea: 'Mode Test',
|
|
|
+ mode: 'awaiting-user',
|
|
|
+ summary: 'Test',
|
|
|
+ title: 'Mode Test',
|
|
|
+ questions: [{ id: 'q-1', question: 'What?', options: ['A', 'B'] }],
|
|
|
+ pendingAnswers: null,
|
|
|
+ lastUpdatedAt: Date.now(),
|
|
|
+ filePath: '',
|
|
|
+ nudgeAction: null,
|
|
|
+ });
|
|
|
+
|
|
|
+ await fetch(
|
|
|
+ `${baseUrl}/api/interviews/mode-test/answers?token=${authToken}`,
|
|
|
+ {
|
|
|
+ method: 'POST',
|
|
|
+ body: JSON.stringify({
|
|
|
+ answers: [{ questionId: 'q-1', answer: 'A' }],
|
|
|
+ }),
|
|
|
+ headers: { 'content-type': 'application/json' },
|
|
|
+ },
|
|
|
+ );
|
|
|
+
|
|
|
+ const state = dashboard.getState('mode-test');
|
|
|
+ expect(state?.mode).toBe('awaiting-agent');
|
|
|
+ } finally {
|
|
|
+ cleanup();
|
|
|
+ }
|
|
|
+ });
|
|
|
+
|
|
|
+ test('rejects non-array answers', async () => {
|
|
|
+ const { baseUrl, authToken, dashboard, cleanup } = await startDashboard();
|
|
|
+ try {
|
|
|
+ // Create an interview first so the route exists
|
|
|
+ dashboard.pushState({
|
|
|
+ interviewId: 'invalid-answers',
|
|
|
+ sessionID: 'session-invalid',
|
|
|
+ idea: 'Invalid Answers',
|
|
|
+ mode: 'awaiting-user',
|
|
|
+ summary: 'Test',
|
|
|
+ title: 'Invalid Answers',
|
|
|
+ questions: [],
|
|
|
+ pendingAnswers: null,
|
|
|
+ lastUpdatedAt: Date.now(),
|
|
|
+ filePath: '',
|
|
|
+ nudgeAction: null,
|
|
|
+ });
|
|
|
+
|
|
|
+ const response = await fetch(
|
|
|
+ `${baseUrl}/api/interviews/invalid-answers/answers?token=${authToken}`,
|
|
|
+ {
|
|
|
+ method: 'POST',
|
|
|
+ body: JSON.stringify({
|
|
|
+ answers: 'not-an-array',
|
|
|
+ }),
|
|
|
+ headers: { 'content-type': 'application/json' },
|
|
|
+ },
|
|
|
+ );
|
|
|
+ expect(response.status).toBe(400);
|
|
|
+ } finally {
|
|
|
+ cleanup();
|
|
|
+ }
|
|
|
+ });
|
|
|
+ });
|
|
|
+
|
|
|
+ describe('consume pending answers (GET /api/interviews/:id/pending)', () => {
|
|
|
+ test('returns and clears pending answers atomically', async () => {
|
|
|
+ const { baseUrl, authToken, dashboard, cleanup } = await startDashboard();
|
|
|
+ try {
|
|
|
+ // Create interview with pending answers
|
|
|
+ dashboard.pushState({
|
|
|
+ interviewId: 'pending-test',
|
|
|
+ sessionID: 'session-pending',
|
|
|
+ idea: 'Pending Test',
|
|
|
+ mode: 'awaiting-agent',
|
|
|
+ summary: 'Test',
|
|
|
+ title: 'Pending Test',
|
|
|
+ questions: [],
|
|
|
+ pendingAnswers: [{ questionId: 'q-1', answer: 'A' }],
|
|
|
+ lastUpdatedAt: Date.now(),
|
|
|
+ filePath: '',
|
|
|
+ nudgeAction: null,
|
|
|
+ });
|
|
|
+
|
|
|
+ // First call - returns answers
|
|
|
+ const response1 = await fetch(
|
|
|
+ `${baseUrl}/api/interviews/pending-test/pending?token=${authToken}`,
|
|
|
+ );
|
|
|
+ const data1 = (await response1.json()) as {
|
|
|
+ answers: Array<{ questionId: string; answer: string }> | null;
|
|
|
+ };
|
|
|
+ expect(data1.answers).toEqual([{ questionId: 'q-1', answer: 'A' }]);
|
|
|
+
|
|
|
+ // Verify state was cleared
|
|
|
+ const state = dashboard.getState('pending-test');
|
|
|
+ expect(state?.pendingAnswers).toBeNull();
|
|
|
+ } finally {
|
|
|
+ cleanup();
|
|
|
+ }
|
|
|
+ });
|
|
|
+
|
|
|
+ test('returns null on second call (already consumed)', async () => {
|
|
|
+ const { baseUrl, authToken, dashboard, cleanup } = await startDashboard();
|
|
|
+ try {
|
|
|
+ dashboard.pushState({
|
|
|
+ interviewId: 'consume-test',
|
|
|
+ sessionID: 'session-consume',
|
|
|
+ idea: 'Consume Test',
|
|
|
+ mode: 'awaiting-agent',
|
|
|
+ summary: 'Test',
|
|
|
+ title: 'Consume Test',
|
|
|
+ questions: [],
|
|
|
+ pendingAnswers: [{ questionId: 'q-1', answer: 'A' }],
|
|
|
+ lastUpdatedAt: Date.now(),
|
|
|
+ filePath: '',
|
|
|
+ nudgeAction: null,
|
|
|
+ });
|
|
|
+
|
|
|
+ // First call
|
|
|
+ await fetch(
|
|
|
+ `${baseUrl}/api/interviews/consume-test/pending?token=${authToken}`,
|
|
|
+ );
|
|
|
+
|
|
|
+ // Second call - should return null
|
|
|
+ const response2 = await fetch(
|
|
|
+ `${baseUrl}/api/interviews/consume-test/pending?token=${authToken}`,
|
|
|
+ );
|
|
|
+ const data2 = (await response2.json()) as {
|
|
|
+ answers: Array<{ questionId: string; answer: string }> | null;
|
|
|
+ };
|
|
|
+ expect(data2.answers).toBeNull();
|
|
|
+ } finally {
|
|
|
+ cleanup();
|
|
|
+ }
|
|
|
+ });
|
|
|
+
|
|
|
+ test('returns 404 for unknown interview', async () => {
|
|
|
+ const { baseUrl, authToken, cleanup } = await startDashboard();
|
|
|
+ try {
|
|
|
+ const response = await fetch(
|
|
|
+ `${baseUrl}/api/interviews/unknown/pending?token=${authToken}`,
|
|
|
+ );
|
|
|
+ expect(response.status).toBe(404);
|
|
|
+ } finally {
|
|
|
+ cleanup();
|
|
|
+ }
|
|
|
+ });
|
|
|
+ });
|
|
|
+
|
|
|
+ describe('nudge (POST /api/interviews/:id/nudge)', () => {
|
|
|
+ test('stores nudge action', async () => {
|
|
|
+ const { baseUrl, authToken, dashboard, cleanup } = await startDashboard();
|
|
|
+ try {
|
|
|
+ dashboard.pushState({
|
|
|
+ interviewId: 'nudge-test',
|
|
|
+ sessionID: 'session-nudge',
|
|
|
+ idea: 'Nudge Test',
|
|
|
+ mode: 'awaiting-user',
|
|
|
+ summary: 'Test',
|
|
|
+ title: 'Nudge Test',
|
|
|
+ questions: [],
|
|
|
+ pendingAnswers: null,
|
|
|
+ lastUpdatedAt: Date.now(),
|
|
|
+ filePath: '',
|
|
|
+ nudgeAction: null,
|
|
|
+ });
|
|
|
+
|
|
|
+ const response = await fetch(
|
|
|
+ `${baseUrl}/api/interviews/nudge-test/nudge?token=${authToken}`,
|
|
|
+ {
|
|
|
+ method: 'POST',
|
|
|
+ body: JSON.stringify({ action: 'more-questions' }),
|
|
|
+ headers: { 'content-type': 'application/json' },
|
|
|
+ },
|
|
|
+ );
|
|
|
+ expect(response.status).toBe(200);
|
|
|
+
|
|
|
+ const state = dashboard.getState('nudge-test');
|
|
|
+ expect(state?.nudgeAction).toBe('more-questions');
|
|
|
+ } finally {
|
|
|
+ cleanup();
|
|
|
+ }
|
|
|
+ });
|
|
|
+
|
|
|
+ test('sets mode to awaiting-agent', async () => {
|
|
|
+ const { baseUrl, authToken, dashboard, cleanup } = await startDashboard();
|
|
|
+ try {
|
|
|
+ dashboard.pushState({
|
|
|
+ interviewId: 'nudge-mode-test',
|
|
|
+ sessionID: 'session-nudge-mode',
|
|
|
+ idea: 'Nudge Mode Test',
|
|
|
+ mode: 'awaiting-user',
|
|
|
+ summary: 'Test',
|
|
|
+ title: 'Nudge Mode Test',
|
|
|
+ questions: [],
|
|
|
+ pendingAnswers: null,
|
|
|
+ lastUpdatedAt: Date.now(),
|
|
|
+ filePath: '',
|
|
|
+ nudgeAction: null,
|
|
|
+ });
|
|
|
+
|
|
|
+ await fetch(
|
|
|
+ `${baseUrl}/api/interviews/nudge-mode-test/nudge?token=${authToken}`,
|
|
|
+ {
|
|
|
+ method: 'POST',
|
|
|
+ body: JSON.stringify({ action: 'confirm-complete' }),
|
|
|
+ headers: { 'content-type': 'application/json' },
|
|
|
+ },
|
|
|
+ );
|
|
|
+
|
|
|
+ const state = dashboard.getState('nudge-mode-test');
|
|
|
+ expect(state?.mode).toBe('awaiting-agent');
|
|
|
+ } finally {
|
|
|
+ cleanup();
|
|
|
+ }
|
|
|
+ });
|
|
|
+
|
|
|
+ test('rejects invalid action', async () => {
|
|
|
+ const { baseUrl, authToken, dashboard, cleanup } = await startDashboard();
|
|
|
+ try {
|
|
|
+ // Create an interview first so the route exists
|
|
|
+ dashboard.pushState({
|
|
|
+ interviewId: 'nudge-invalid',
|
|
|
+ sessionID: 'session-nudge-invalid',
|
|
|
+ idea: 'Nudge Invalid',
|
|
|
+ mode: 'awaiting-user',
|
|
|
+ summary: 'Test',
|
|
|
+ title: 'Nudge Invalid',
|
|
|
+ questions: [],
|
|
|
+ pendingAnswers: null,
|
|
|
+ lastUpdatedAt: Date.now(),
|
|
|
+ filePath: '',
|
|
|
+ nudgeAction: null,
|
|
|
+ });
|
|
|
+
|
|
|
+ const response = await fetch(
|
|
|
+ `${baseUrl}/api/interviews/nudge-invalid/nudge?token=${authToken}`,
|
|
|
+ {
|
|
|
+ method: 'POST',
|
|
|
+ body: JSON.stringify({ action: 'invalid-action' }),
|
|
|
+ headers: { 'content-type': 'application/json' },
|
|
|
+ },
|
|
|
+ );
|
|
|
+ expect(response.status).toBe(400);
|
|
|
+ } finally {
|
|
|
+ cleanup();
|
|
|
+ }
|
|
|
+ });
|
|
|
+ });
|
|
|
+
|
|
|
+ describe('consume nudge (GET /api/interviews/:id/nudge)', () => {
|
|
|
+ test('returns and clears nudge action atomically', async () => {
|
|
|
+ const { baseUrl, authToken, dashboard, cleanup } = await startDashboard();
|
|
|
+ try {
|
|
|
+ dashboard.pushState({
|
|
|
+ interviewId: 'consume-nudge-test',
|
|
|
+ sessionID: 'session-consume-nudge',
|
|
|
+ idea: 'Consume Nudge Test',
|
|
|
+ mode: 'awaiting-agent',
|
|
|
+ summary: 'Test',
|
|
|
+ title: 'Consume Nudge Test',
|
|
|
+ questions: [],
|
|
|
+ pendingAnswers: null,
|
|
|
+ lastUpdatedAt: Date.now(),
|
|
|
+ filePath: '',
|
|
|
+ nudgeAction: 'more-questions',
|
|
|
+ });
|
|
|
+
|
|
|
+ const response = await fetch(
|
|
|
+ `${baseUrl}/api/interviews/consume-nudge-test/nudge?token=${authToken}`,
|
|
|
+ );
|
|
|
+ const data = (await response.json()) as {
|
|
|
+ action: 'more-questions' | 'confirm-complete' | null;
|
|
|
+ };
|
|
|
+ expect(data.action).toBe('more-questions');
|
|
|
+
|
|
|
+ const state = dashboard.getState('consume-nudge-test');
|
|
|
+ expect(state?.nudgeAction).toBeNull();
|
|
|
+ } finally {
|
|
|
+ cleanup();
|
|
|
+ }
|
|
|
+ });
|
|
|
+
|
|
|
+ test('returns null on second call', async () => {
|
|
|
+ const { baseUrl, authToken, dashboard, cleanup } = await startDashboard();
|
|
|
+ try {
|
|
|
+ dashboard.pushState({
|
|
|
+ interviewId: 'nudge-second-test',
|
|
|
+ sessionID: 'session-nudge-second',
|
|
|
+ idea: 'Nudge Second Test',
|
|
|
+ mode: 'awaiting-agent',
|
|
|
+ summary: 'Test',
|
|
|
+ title: 'Nudge Second Test',
|
|
|
+ questions: [],
|
|
|
+ pendingAnswers: null,
|
|
|
+ lastUpdatedAt: Date.now(),
|
|
|
+ filePath: '',
|
|
|
+ nudgeAction: 'confirm-complete',
|
|
|
+ });
|
|
|
+
|
|
|
+ // First call
|
|
|
+ await fetch(
|
|
|
+ `${baseUrl}/api/interviews/nudge-second-test/nudge?token=${authToken}`,
|
|
|
+ );
|
|
|
+
|
|
|
+ // Second call
|
|
|
+ const response2 = await fetch(
|
|
|
+ `${baseUrl}/api/interviews/nudge-second-test/nudge?token=${authToken}`,
|
|
|
+ );
|
|
|
+ const data2 = (await response2.json()) as {
|
|
|
+ action: 'more-questions' | 'confirm-complete' | null;
|
|
|
+ };
|
|
|
+ expect(data2.action).toBeNull();
|
|
|
+ } finally {
|
|
|
+ cleanup();
|
|
|
+ }
|
|
|
+ });
|
|
|
+ });
|
|
|
+
|
|
|
+ describe('interview page (GET /interview/:id)', () => {
|
|
|
+ test('returns HTML with proper content type', async () => {
|
|
|
+ const { baseUrl, dashboard, cleanup } = await startDashboard();
|
|
|
+ try {
|
|
|
+ dashboard.pushState({
|
|
|
+ interviewId: 'page-test',
|
|
|
+ sessionID: 'session-page',
|
|
|
+ idea: 'Page Test',
|
|
|
+ mode: 'awaiting-user',
|
|
|
+ summary: 'Test',
|
|
|
+ title: 'Page Test',
|
|
|
+ questions: [],
|
|
|
+ pendingAnswers: null,
|
|
|
+ lastUpdatedAt: Date.now(),
|
|
|
+ filePath: '',
|
|
|
+ nudgeAction: null,
|
|
|
+ });
|
|
|
+
|
|
|
+ const response = await fetch(`${baseUrl}/interview/page-test`);
|
|
|
+ expect(response.status).toBe(200);
|
|
|
+ expect(response.headers.get('content-type')).toContain('text/html');
|
|
|
+ const html = await response.text();
|
|
|
+ expect(html).toContain('page-test');
|
|
|
+ } finally {
|
|
|
+ cleanup();
|
|
|
+ }
|
|
|
+ });
|
|
|
+
|
|
|
+ test('sets session cookie', async () => {
|
|
|
+ const { baseUrl, dashboard, cleanup } = await startDashboard();
|
|
|
+ try {
|
|
|
+ dashboard.pushState({
|
|
|
+ interviewId: 'cookie-test',
|
|
|
+ sessionID: 'session-cookie',
|
|
|
+ idea: 'Cookie Test',
|
|
|
+ mode: 'awaiting-user',
|
|
|
+ summary: 'Test',
|
|
|
+ title: 'Cookie Test',
|
|
|
+ questions: [],
|
|
|
+ pendingAnswers: null,
|
|
|
+ lastUpdatedAt: Date.now(),
|
|
|
+ filePath: '',
|
|
|
+ nudgeAction: null,
|
|
|
+ });
|
|
|
+
|
|
|
+ const response = await fetch(`${baseUrl}/interview/cookie-test`);
|
|
|
+ const cookies = response.headers.get('set-cookie');
|
|
|
+ expect(cookies).toContain('dashboard_token=');
|
|
|
+ expect(cookies).toContain('HttpOnly');
|
|
|
+ } finally {
|
|
|
+ cleanup();
|
|
|
+ }
|
|
|
+ });
|
|
|
+
|
|
|
+ test('returns 400 for invalid interview ID', async () => {
|
|
|
+ const { baseUrl, cleanup } = await startDashboard();
|
|
|
+ try {
|
|
|
+ const response = await fetch(`${baseUrl}/interview/invalid/id`);
|
|
|
+ expect(response.status).toBe(400);
|
|
|
+ } finally {
|
|
|
+ cleanup();
|
|
|
+ }
|
|
|
+ });
|
|
|
+ });
|
|
|
+
|
|
|
+ describe('dashboard page (GET /)', () => {
|
|
|
+ test('returns HTML', async () => {
|
|
|
+ const { baseUrl, cleanup } = await startDashboard();
|
|
|
+ try {
|
|
|
+ const response = await fetch(`${baseUrl}/`);
|
|
|
+ expect(response.status).toBe(200);
|
|
|
+ expect(response.headers.get('content-type')).toContain('text/html');
|
|
|
+ const html = await response.text();
|
|
|
+ expect(html).toContain('Interview');
|
|
|
+ } finally {
|
|
|
+ cleanup();
|
|
|
+ }
|
|
|
+ });
|
|
|
+
|
|
|
+ test('requires auth', async () => {
|
|
|
+ const { baseUrl, cleanup } = await startDashboard();
|
|
|
+ try {
|
|
|
+ // The root endpoint actually sets a cookie, so it doesn't require auth
|
|
|
+ // Let's verify it works without auth (it sets cookie)
|
|
|
+ const response = await fetch(`${baseUrl}/`);
|
|
|
+ expect(response.status).toBe(200);
|
|
|
+ } finally {
|
|
|
+ cleanup();
|
|
|
+ }
|
|
|
+ });
|
|
|
+ });
|
|
|
+
|
|
|
+ describe('settings (GET /api/settings, POST /api/settings)', () => {
|
|
|
+ test('GET returns current settings', async () => {
|
|
|
+ const { baseUrl, authToken, cleanup } = await startDashboard();
|
|
|
+ try {
|
|
|
+ const response = await fetch(
|
|
|
+ `${baseUrl}/api/settings?token=${authToken}`,
|
|
|
+ );
|
|
|
+ expect(response.status).toBe(200);
|
|
|
+ const data = (await response.json()) as {
|
|
|
+ scanDays: number;
|
|
|
+ folders: string[];
|
|
|
+ discoveredFolders: string[];
|
|
|
+ registeredSessions: number;
|
|
|
+ };
|
|
|
+ expect(typeof data.scanDays).toBe('number');
|
|
|
+ expect(Array.isArray(data.folders)).toBe(true);
|
|
|
+ expect(Array.isArray(data.discoveredFolders)).toBe(true);
|
|
|
+ expect(typeof data.registeredSessions).toBe('number');
|
|
|
+ } finally {
|
|
|
+ cleanup();
|
|
|
+ }
|
|
|
+ });
|
|
|
+
|
|
|
+ test('POST updates scan days', async () => {
|
|
|
+ const { baseUrl, authToken, dashboard, cleanup } = await startDashboard();
|
|
|
+ try {
|
|
|
+ const response = await fetch(
|
|
|
+ `${baseUrl}/api/settings?token=${authToken}`,
|
|
|
+ {
|
|
|
+ method: 'POST',
|
|
|
+ body: JSON.stringify({ scanDays: 60 }),
|
|
|
+ headers: { 'content-type': 'application/json' },
|
|
|
+ },
|
|
|
+ );
|
|
|
+ expect(response.status).toBe(200);
|
|
|
+ const data = (await response.json()) as { scanDays: number };
|
|
|
+ expect(data.scanDays).toBe(60);
|
|
|
+
|
|
|
+ // Verify it was updated
|
|
|
+ expect(dashboard.getScanDays()).toBe(60);
|
|
|
+ } finally {
|
|
|
+ cleanup();
|
|
|
+ }
|
|
|
+ });
|
|
|
+
|
|
|
+ test('both require auth', async () => {
|
|
|
+ const { baseUrl, cleanup } = await startDashboard();
|
|
|
+ try {
|
|
|
+ const getResponse = await fetch(`${baseUrl}/api/settings`);
|
|
|
+ expect(getResponse.status).toBe(401);
|
|
|
+
|
|
|
+ const postResponse = await fetch(`${baseUrl}/api/settings`, {
|
|
|
+ method: 'POST',
|
|
|
+ body: JSON.stringify({ scanDays: 30 }),
|
|
|
+ headers: { 'content-type': 'application/json' },
|
|
|
+ });
|
|
|
+ expect(postResponse.status).toBe(401);
|
|
|
+ } finally {
|
|
|
+ cleanup();
|
|
|
+ }
|
|
|
+ });
|
|
|
+ });
|
|
|
+
|
|
|
+ describe('direct API methods', () => {
|
|
|
+ test('registerSession adds session to registry', async () => {
|
|
|
+ const { baseUrl, dashboard, cleanup } = await startDashboard();
|
|
|
+ try {
|
|
|
+ dashboard.registerSession({
|
|
|
+ sessionID: 'direct-session',
|
|
|
+ directory: '/direct/dir',
|
|
|
+ pid: 999,
|
|
|
+ registeredAt: Date.now(),
|
|
|
+ });
|
|
|
+
|
|
|
+ // Verify session is registered by checking it exists
|
|
|
+ const response = await fetch(
|
|
|
+ `${baseUrl}/api/sessions?token=${dashboard.authToken}`,
|
|
|
+ );
|
|
|
+ expect(response.status).toBe(200);
|
|
|
+ const data = (await response.json()) as {
|
|
|
+ sessions: Array<{ sessionID: string }>;
|
|
|
+ };
|
|
|
+ expect(
|
|
|
+ data.sessions.some((s) => s.sessionID === 'direct-session'),
|
|
|
+ ).toBe(true);
|
|
|
+ } finally {
|
|
|
+ cleanup();
|
|
|
+ }
|
|
|
+ });
|
|
|
+
|
|
|
+ test('pushState updates cache', async () => {
|
|
|
+ const { dashboard, cleanup } = await startDashboard();
|
|
|
+ try {
|
|
|
+ dashboard.pushState({
|
|
|
+ interviewId: 'direct-push',
|
|
|
+ sessionID: 'session-direct',
|
|
|
+ idea: 'Direct Push',
|
|
|
+ mode: 'awaiting-user',
|
|
|
+ summary: 'Direct',
|
|
|
+ title: 'Direct Push',
|
|
|
+ questions: [],
|
|
|
+ pendingAnswers: null,
|
|
|
+ lastUpdatedAt: Date.now(),
|
|
|
+ filePath: '',
|
|
|
+ nudgeAction: null,
|
|
|
+ });
|
|
|
+
|
|
|
+ const state = dashboard.getState('direct-push');
|
|
|
+ expect(state?.idea).toBe('Direct Push');
|
|
|
+ } finally {
|
|
|
+ cleanup();
|
|
|
+ }
|
|
|
+ });
|
|
|
+
|
|
|
+ test('storeAnswers stores pending answers', async () => {
|
|
|
+ const { dashboard, cleanup } = await startDashboard();
|
|
|
+ try {
|
|
|
+ dashboard.pushState({
|
|
|
+ interviewId: 'store-answers',
|
|
|
+ sessionID: 'session-store',
|
|
|
+ idea: 'Store Answers',
|
|
|
+ mode: 'awaiting-user',
|
|
|
+ summary: 'Store',
|
|
|
+ title: 'Store Answers',
|
|
|
+ questions: [],
|
|
|
+ pendingAnswers: null,
|
|
|
+ lastUpdatedAt: Date.now(),
|
|
|
+ filePath: '',
|
|
|
+ nudgeAction: null,
|
|
|
+ });
|
|
|
+
|
|
|
+ dashboard.storeAnswers('store-answers', [
|
|
|
+ { questionId: 'q-1', answer: 'Direct' },
|
|
|
+ ]);
|
|
|
+
|
|
|
+ const state = dashboard.getState('store-answers');
|
|
|
+ expect(state?.pendingAnswers).toEqual([
|
|
|
+ { questionId: 'q-1', answer: 'Direct' },
|
|
|
+ ]);
|
|
|
+ } finally {
|
|
|
+ cleanup();
|
|
|
+ }
|
|
|
+ });
|
|
|
+
|
|
|
+ test('consumePendingAnswers clears pending answers', async () => {
|
|
|
+ const { dashboard, cleanup } = await startDashboard();
|
|
|
+ try {
|
|
|
+ dashboard.pushState({
|
|
|
+ interviewId: 'consume-direct',
|
|
|
+ sessionID: 'session-consume-direct',
|
|
|
+ idea: 'Consume Direct',
|
|
|
+ mode: 'awaiting-agent',
|
|
|
+ summary: 'Consume',
|
|
|
+ title: 'Consume Direct',
|
|
|
+ questions: [],
|
|
|
+ pendingAnswers: [{ questionId: 'q-1', answer: 'Test' }],
|
|
|
+ lastUpdatedAt: Date.now(),
|
|
|
+ filePath: '',
|
|
|
+ nudgeAction: null,
|
|
|
+ });
|
|
|
+
|
|
|
+ const answers = dashboard.consumePendingAnswers('consume-direct');
|
|
|
+ expect(answers).toEqual([{ questionId: 'q-1', answer: 'Test' }]);
|
|
|
+
|
|
|
+ const state = dashboard.getState('consume-direct');
|
|
|
+ expect(state?.pendingAnswers).toBeNull();
|
|
|
+ } finally {
|
|
|
+ cleanup();
|
|
|
+ }
|
|
|
+ });
|
|
|
+
|
|
|
+ test('consumeNudgeAction clears nudge action', async () => {
|
|
|
+ const { dashboard, cleanup } = await startDashboard();
|
|
|
+ try {
|
|
|
+ dashboard.pushState({
|
|
|
+ interviewId: 'nudge-direct',
|
|
|
+ sessionID: 'session-nudge-direct',
|
|
|
+ idea: 'Nudge Direct',
|
|
|
+ mode: 'awaiting-agent',
|
|
|
+ summary: 'Nudge',
|
|
|
+ title: 'Nudge Direct',
|
|
|
+ questions: [],
|
|
|
+ pendingAnswers: null,
|
|
|
+ lastUpdatedAt: Date.now(),
|
|
|
+ filePath: '',
|
|
|
+ nudgeAction: 'more-questions',
|
|
|
+ });
|
|
|
+
|
|
|
+ const action = dashboard.consumeNudgeAction('nudge-direct');
|
|
|
+ expect(action).toBe('more-questions');
|
|
|
+
|
|
|
+ const state = dashboard.getState('nudge-direct');
|
|
|
+ expect(state?.nudgeAction).toBeNull();
|
|
|
+ } finally {
|
|
|
+ cleanup();
|
|
|
+ }
|
|
|
+ });
|
|
|
+
|
|
|
+ test('addManualFolder and removeManualFolder', async () => {
|
|
|
+ const { dashboard, cleanup } = await startDashboard();
|
|
|
+ try {
|
|
|
+ dashboard.addManualFolder('/manual/folder1');
|
|
|
+ expect(dashboard.getManualFolders()).toContain('/manual/folder1');
|
|
|
+
|
|
|
+ dashboard.addManualFolder('/manual/folder2');
|
|
|
+ expect(dashboard.getManualFolders().length).toBe(2);
|
|
|
+
|
|
|
+ dashboard.removeManualFolder('/manual/folder1');
|
|
|
+ expect(dashboard.getManualFolders()).not.toContain('/manual/folder1');
|
|
|
+ expect(dashboard.getManualFolders()).toContain('/manual/folder2');
|
|
|
+ } finally {
|
|
|
+ cleanup();
|
|
|
+ }
|
|
|
+ });
|
|
|
+
|
|
|
+ test('setScanDays and getScanDays', async () => {
|
|
|
+ const { dashboard, cleanup } = await startDashboard();
|
|
|
+ try {
|
|
|
+ dashboard.setScanDays(45);
|
|
|
+ expect(dashboard.getScanDays()).toBe(45);
|
|
|
+
|
|
|
+ dashboard.setScanDays(0);
|
|
|
+ expect(dashboard.getScanDays()).toBe(0);
|
|
|
+ } finally {
|
|
|
+ cleanup();
|
|
|
+ }
|
|
|
+ });
|
|
|
+ });
|
|
|
+
|
|
|
+ describe('file scanning (GET /api/files)', () => {
|
|
|
+ test('lists interview files from registered sessions', async () => {
|
|
|
+ const tempDir = await createTempInterviewDir();
|
|
|
+ const { baseUrl, authToken, dashboard, cleanup } = await startDashboard();
|
|
|
+ try {
|
|
|
+ // Create a test markdown file
|
|
|
+ const mdPath = path.join(tempDir, 'interview', 'test-file.md');
|
|
|
+ await fs.writeFile(
|
|
|
+ mdPath,
|
|
|
+ '# Test File\n\n## Current spec\n\nSpec content.\n\n## Q&A history\n\n',
|
|
|
+ 'utf8',
|
|
|
+ );
|
|
|
+
|
|
|
+ // Register session with the temp directory
|
|
|
+ dashboard.registerSession({
|
|
|
+ sessionID: 'session-files',
|
|
|
+ directory: tempDir,
|
|
|
+ pid: 0,
|
|
|
+ registeredAt: Date.now(),
|
|
|
+ });
|
|
|
+
|
|
|
+ const response = await fetch(`${baseUrl}/api/files?token=${authToken}`);
|
|
|
+ expect(response.status).toBe(200);
|
|
|
+ const data = (await response.json()) as {
|
|
|
+ files: Array<{ fileName: string; title: string }>;
|
|
|
+ };
|
|
|
+ // os.homedir() is always scanned, so other files may appear.
|
|
|
+ // Assert our file is present rather than asserting exact count.
|
|
|
+ const ourFile = data.files.find((f) => f.fileName === 'test-file.md');
|
|
|
+ expect(ourFile).toBeDefined();
|
|
|
+ expect(ourFile?.title).toBe('Test File');
|
|
|
+
|
|
|
+ await fs.rm(tempDir, { recursive: true, force: true });
|
|
|
+ } finally {
|
|
|
+ cleanup();
|
|
|
+ }
|
|
|
+ });
|
|
|
+
|
|
|
+ test('requires auth', async () => {
|
|
|
+ const { baseUrl, cleanup } = await startDashboard();
|
|
|
+ try {
|
|
|
+ const response = await fetch(`${baseUrl}/api/files`);
|
|
|
+ expect(response.status).toBe(401);
|
|
|
+ } finally {
|
|
|
+ cleanup();
|
|
|
+ }
|
|
|
+ });
|
|
|
+ });
|
|
|
+
|
|
|
+ describe('error handling', () => {
|
|
|
+ test('returns 404 for unknown routes', async () => {
|
|
|
+ const { baseUrl, cleanup } = await startDashboard();
|
|
|
+ try {
|
|
|
+ const response = await fetch(`${baseUrl}/api/unknown`);
|
|
|
+ expect(response.status).toBe(404);
|
|
|
+ } finally {
|
|
|
+ cleanup();
|
|
|
+ }
|
|
|
+ });
|
|
|
+
|
|
|
+ test('handles invalid JSON body', async () => {
|
|
|
+ const { baseUrl, authToken, cleanup } = await startDashboard();
|
|
|
+ try {
|
|
|
+ const response = await fetch(
|
|
|
+ `${baseUrl}/api/register?token=${authToken}`,
|
|
|
+ {
|
|
|
+ method: 'POST',
|
|
|
+ body: 'invalid json',
|
|
|
+ headers: { 'content-type': 'application/json' },
|
|
|
+ },
|
|
|
+ );
|
|
|
+ expect(response.status).toBe(400);
|
|
|
+ } finally {
|
|
|
+ cleanup();
|
|
|
+ }
|
|
|
+ });
|
|
|
+ });
|
|
|
+});
|