dashboard.test.ts 40 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303
  1. import { describe, expect, test } from 'bun:test';
  2. import * as fs from 'node:fs/promises';
  3. import { createServer } from 'node:http';
  4. import * as path from 'node:path';
  5. import { createDashboardServer } from './dashboard';
  6. // Helper to find a free port (matches interview.test.ts pattern)
  7. function findFreePort(): Promise<number> {
  8. return new Promise((resolve, reject) => {
  9. const server = createServer();
  10. server.listen(0, () => {
  11. const address = server.address();
  12. if (address && typeof address !== 'string') {
  13. const port = address.port;
  14. server.close(() => resolve(port));
  15. } else {
  16. server.close(() => reject(new Error('Failed to get port')));
  17. }
  18. });
  19. });
  20. }
  21. // Helper to start a dashboard on a free port
  22. async function startDashboard() {
  23. const port = await findFreePort();
  24. const dashboard = createDashboardServer({
  25. port,
  26. outputFolder: 'interview',
  27. });
  28. const baseUrl = await dashboard.start();
  29. return {
  30. dashboard,
  31. baseUrl,
  32. authToken: dashboard.authToken,
  33. cleanup: () => {
  34. dashboard.close();
  35. },
  36. };
  37. }
  38. // Helper to create a temp directory with interview files
  39. async function createTempInterviewDir() {
  40. const tempDir = await fs.mkdtemp('/tmp/dashboard-test-');
  41. const interviewDir = path.join(tempDir, 'interview');
  42. await fs.mkdir(interviewDir, { recursive: true });
  43. return tempDir;
  44. }
  45. describe('dashboard server', () => {
  46. describe('health endpoint', () => {
  47. test('returns 200 with status ok and counts', async () => {
  48. const { baseUrl, cleanup } = await startDashboard();
  49. try {
  50. const response = await fetch(`${baseUrl}/api/health`);
  51. expect(response.status).toBe(200);
  52. const data = (await response.json()) as {
  53. status: string;
  54. sessions: number;
  55. interviews: number;
  56. };
  57. expect(data.status).toBe('ok');
  58. expect(data.sessions).toBe(0);
  59. expect(data.interviews).toBe(0);
  60. } finally {
  61. cleanup();
  62. }
  63. });
  64. test('works without auth', async () => {
  65. const { baseUrl, cleanup } = await startDashboard();
  66. try {
  67. const response = await fetch(`${baseUrl}/api/health`);
  68. expect(response.status).toBe(200);
  69. } finally {
  70. cleanup();
  71. }
  72. });
  73. });
  74. describe('auth gate', () => {
  75. test('POST /api/register without auth returns 401', async () => {
  76. const { baseUrl, cleanup } = await startDashboard();
  77. try {
  78. const response = await fetch(`${baseUrl}/api/register`, {
  79. method: 'POST',
  80. body: JSON.stringify({
  81. sessionID: 'test-session',
  82. directory: '/test/dir',
  83. }),
  84. headers: { 'content-type': 'application/json' },
  85. });
  86. expect(response.status).toBe(401);
  87. } finally {
  88. cleanup();
  89. }
  90. });
  91. test('POST /api/interviews without auth returns 401', async () => {
  92. const { baseUrl, cleanup } = await startDashboard();
  93. try {
  94. const response = await fetch(`${baseUrl}/api/interviews`, {
  95. method: 'POST',
  96. body: JSON.stringify({
  97. interviewId: 'test-interview',
  98. sessionID: 'test-session',
  99. idea: 'Test idea',
  100. }),
  101. headers: { 'content-type': 'application/json' },
  102. });
  103. expect(response.status).toBe(401);
  104. } finally {
  105. cleanup();
  106. }
  107. });
  108. test('GET /api/sessions without auth returns 401', async () => {
  109. const { baseUrl, cleanup } = await startDashboard();
  110. try {
  111. const response = await fetch(`${baseUrl}/api/sessions`);
  112. expect(response.status).toBe(401);
  113. } finally {
  114. cleanup();
  115. }
  116. });
  117. });
  118. describe('auth methods', () => {
  119. test('works with ?token= query param', async () => {
  120. const { baseUrl, authToken, cleanup } = await startDashboard();
  121. try {
  122. const response = await fetch(
  123. `${baseUrl}/api/sessions?token=${authToken}`,
  124. );
  125. expect(response.status).toBe(200);
  126. const data = (await response.json()) as { sessions: unknown[] };
  127. expect(Array.isArray(data.sessions)).toBe(true);
  128. } finally {
  129. cleanup();
  130. }
  131. });
  132. test('works with Cookie header', async () => {
  133. const { baseUrl, authToken, cleanup } = await startDashboard();
  134. try {
  135. const response = await fetch(`${baseUrl}/api/sessions`, {
  136. headers: {
  137. cookie: `dashboard_token=${authToken}`,
  138. },
  139. });
  140. expect(response.status).toBe(200);
  141. } finally {
  142. cleanup();
  143. }
  144. });
  145. test('works with Authorization: Bearer header', async () => {
  146. const { baseUrl, authToken, cleanup } = await startDashboard();
  147. try {
  148. const response = await fetch(`${baseUrl}/api/sessions`, {
  149. headers: {
  150. authorization: `Bearer ${authToken}`,
  151. },
  152. });
  153. expect(response.status).toBe(200);
  154. } finally {
  155. cleanup();
  156. }
  157. });
  158. });
  159. describe('session registration (POST /api/register)', () => {
  160. test('registers a valid session', async () => {
  161. const { baseUrl, authToken, dashboard, cleanup } = await startDashboard();
  162. try {
  163. const response = await fetch(
  164. `${baseUrl}/api/register?token=${authToken}`,
  165. {
  166. method: 'POST',
  167. body: JSON.stringify({
  168. sessionID: 'session-123',
  169. directory: '/test/directory',
  170. pid: 12345,
  171. }),
  172. headers: { 'content-type': 'application/json' },
  173. },
  174. );
  175. expect(response.status).toBe(200);
  176. const data = (await response.json()) as { status: string };
  177. expect(data.status).toBe('registered');
  178. // Verify session is registered
  179. const _state = dashboard.getState('dummy-interview');
  180. // State doesn't exist yet, but session was registered
  181. } finally {
  182. cleanup();
  183. }
  184. });
  185. test('rejects missing sessionID', async () => {
  186. const { baseUrl, authToken, cleanup } = await startDashboard();
  187. try {
  188. const response = await fetch(
  189. `${baseUrl}/api/register?token=${authToken}`,
  190. {
  191. method: 'POST',
  192. body: JSON.stringify({
  193. directory: '/test/directory',
  194. }),
  195. headers: { 'content-type': 'application/json' },
  196. },
  197. );
  198. expect(response.status).toBe(400);
  199. } finally {
  200. cleanup();
  201. }
  202. });
  203. test('rejects invalid sessionID (special chars)', async () => {
  204. const { baseUrl, authToken, cleanup } = await startDashboard();
  205. try {
  206. const response = await fetch(
  207. `${baseUrl}/api/register?token=${authToken}`,
  208. {
  209. method: 'POST',
  210. body: JSON.stringify({
  211. sessionID: 'session/with/slashes',
  212. directory: '/test/directory',
  213. }),
  214. headers: { 'content-type': 'application/json' },
  215. },
  216. );
  217. expect(response.status).toBe(400);
  218. } finally {
  219. cleanup();
  220. }
  221. });
  222. });
  223. describe('create interview (POST /api/interviews)', () => {
  224. test('creates interview entry in cache', async () => {
  225. const { baseUrl, authToken, dashboard, cleanup } = await startDashboard();
  226. try {
  227. const response = await fetch(
  228. `${baseUrl}/api/interviews?token=${authToken}`,
  229. {
  230. method: 'POST',
  231. body: JSON.stringify({
  232. interviewId: 'interview-1',
  233. sessionID: 'session-1',
  234. idea: 'Test Interview',
  235. }),
  236. headers: { 'content-type': 'application/json' },
  237. },
  238. );
  239. expect(response.status).toBe(200);
  240. const data = (await response.json()) as {
  241. interviewId: string;
  242. url: string;
  243. };
  244. expect(data.interviewId).toBe('interview-1');
  245. expect(data.url).toContain('interview-1');
  246. // Verify interview is in cache
  247. const state = dashboard.getState('interview-1');
  248. expect(state?.interviewId).toBe('interview-1');
  249. expect(state?.idea).toBe('Test Interview');
  250. expect(state?.mode).toBe('awaiting-agent');
  251. } finally {
  252. cleanup();
  253. }
  254. });
  255. test('returns interview URL', async () => {
  256. const { baseUrl, authToken, cleanup } = await startDashboard();
  257. try {
  258. const response = await fetch(
  259. `${baseUrl}/api/interviews?token=${authToken}`,
  260. {
  261. method: 'POST',
  262. body: JSON.stringify({
  263. interviewId: 'interview-2',
  264. sessionID: 'session-2',
  265. idea: 'Test URL',
  266. }),
  267. headers: { 'content-type': 'application/json' },
  268. },
  269. );
  270. const data = (await response.json()) as { url: string };
  271. expect(data.url).toBe(`${baseUrl}/interview/interview-2`);
  272. } finally {
  273. cleanup();
  274. }
  275. });
  276. test('rejects missing fields', async () => {
  277. const { baseUrl, authToken, cleanup } = await startDashboard();
  278. try {
  279. const response = await fetch(
  280. `${baseUrl}/api/interviews?token=${authToken}`,
  281. {
  282. method: 'POST',
  283. body: JSON.stringify({
  284. interviewId: 'interview-3',
  285. sessionID: 'session-3',
  286. }),
  287. headers: { 'content-type': 'application/json' },
  288. },
  289. );
  290. expect(response.status).toBe(400);
  291. } finally {
  292. cleanup();
  293. }
  294. });
  295. });
  296. describe('state push/merge (POST /api/interviews/:id/state)', () => {
  297. test('creates new entry when not in cache', async () => {
  298. const { baseUrl, authToken, dashboard, cleanup } = await startDashboard();
  299. try {
  300. const response = await fetch(
  301. `${baseUrl}/api/interviews/new-interview/state?token=${authToken}`,
  302. {
  303. method: 'POST',
  304. body: JSON.stringify({
  305. sessionID: 'session-new',
  306. idea: 'New Idea',
  307. summary: 'Test summary',
  308. questions: [
  309. { id: 'q-1', question: 'What?', options: ['A', 'B'] },
  310. ],
  311. }),
  312. headers: { 'content-type': 'application/json' },
  313. },
  314. );
  315. expect(response.status).toBe(200);
  316. const state = dashboard.getState('new-interview');
  317. expect(state?.interviewId).toBe('new-interview');
  318. expect(state?.summary).toBe('Test summary');
  319. expect(state?.questions.length).toBe(1);
  320. } finally {
  321. cleanup();
  322. }
  323. });
  324. test('merges partial state update when entry exists', async () => {
  325. const { baseUrl, authToken, dashboard, cleanup } = await startDashboard();
  326. try {
  327. // First push - create entry
  328. await fetch(
  329. `${baseUrl}/api/interviews/merge-test/state?token=${authToken}`,
  330. {
  331. method: 'POST',
  332. body: JSON.stringify({
  333. sessionID: 'session-merge',
  334. idea: 'Merge Test',
  335. summary: 'Initial summary',
  336. questions: [{ id: 'q-1', question: 'Q1?', options: ['A', 'B'] }],
  337. }),
  338. headers: { 'content-type': 'application/json' },
  339. },
  340. );
  341. // Second push - merge updates
  342. await fetch(
  343. `${baseUrl}/api/interviews/merge-test/state?token=${authToken}`,
  344. {
  345. method: 'POST',
  346. body: JSON.stringify({
  347. mode: 'awaiting-user',
  348. summary: 'Updated summary',
  349. title: 'Updated Title',
  350. questions: [{ id: 'q-2', question: 'Q2?', options: ['C', 'D'] }],
  351. }),
  352. headers: { 'content-type': 'application/json' },
  353. },
  354. );
  355. const state = dashboard.getState('merge-test');
  356. expect(state?.mode).toBe('awaiting-user');
  357. expect(state?.summary).toBe('Updated summary');
  358. expect(state?.title).toBe('Updated Title');
  359. expect(state?.questions.length).toBe(1);
  360. expect(state?.questions[0].id).toBe('q-2');
  361. } finally {
  362. cleanup();
  363. }
  364. });
  365. test('rejects invalid interview ID', async () => {
  366. const { baseUrl, authToken, cleanup } = await startDashboard();
  367. try {
  368. const response = await fetch(
  369. `${baseUrl}/api/interviews/invalid/id/state?token=${authToken}`,
  370. {
  371. method: 'POST',
  372. body: JSON.stringify({}),
  373. headers: { 'content-type': 'application/json' },
  374. },
  375. );
  376. expect(response.status).toBe(400);
  377. } finally {
  378. cleanup();
  379. }
  380. });
  381. });
  382. describe('get state (GET /api/interviews/:id/state)', () => {
  383. test('returns full state for existing interview', async () => {
  384. const { baseUrl, authToken, dashboard, cleanup } = await startDashboard();
  385. try {
  386. // Create interview
  387. dashboard.pushState({
  388. interviewId: 'get-state-test',
  389. sessionID: 'session-get',
  390. idea: 'Get State Test',
  391. mode: 'awaiting-user',
  392. summary: 'Test summary',
  393. title: 'Test Title',
  394. questions: [{ id: 'q-1', question: 'What?', options: ['A', 'B'] }],
  395. pendingAnswers: null,
  396. lastUpdatedAt: Date.now(),
  397. filePath: '',
  398. nudgeAction: null,
  399. });
  400. const response = await fetch(
  401. `${baseUrl}/api/interviews/get-state-test/state?token=${authToken}`,
  402. );
  403. expect(response.status).toBe(200);
  404. const data = (await response.json()) as {
  405. interview: { id: string; idea: string };
  406. mode: string;
  407. summary: string;
  408. questions: Array<{ id: string }>;
  409. };
  410. expect(data.interview.id).toBe('get-state-test');
  411. expect(data.interview.idea).toBe('Get State Test');
  412. expect(data.mode).toBe('awaiting-user');
  413. expect(data.summary).toBe('Test summary');
  414. expect(data.questions.length).toBe(1);
  415. } finally {
  416. cleanup();
  417. }
  418. });
  419. test('returns 404 for unknown interview', async () => {
  420. const { baseUrl, authToken, cleanup } = await startDashboard();
  421. try {
  422. const response = await fetch(
  423. `${baseUrl}/api/interviews/unknown/state?token=${authToken}`,
  424. );
  425. expect(response.status).toBe(404);
  426. } finally {
  427. cleanup();
  428. }
  429. });
  430. test('includes document content from .md file when filePath points to real file', async () => {
  431. const tempDir = await createTempInterviewDir();
  432. const { baseUrl, authToken, dashboard, cleanup } = await startDashboard();
  433. try {
  434. // Create a markdown file
  435. const mdPath = path.join(tempDir, 'interview', 'doc-test.md');
  436. await fs.writeFile(mdPath, '# Test Document\n\nContent here.', 'utf8');
  437. // Register the temp directory as a session
  438. dashboard.registerSession({
  439. sessionID: 'session-doc',
  440. directory: tempDir,
  441. pid: 0,
  442. registeredAt: Date.now(),
  443. });
  444. // Push state with filePath
  445. dashboard.pushState({
  446. interviewId: 'doc-test',
  447. sessionID: 'session-doc',
  448. idea: 'Doc Test',
  449. mode: 'completed',
  450. summary: 'Test',
  451. title: 'Doc Test',
  452. questions: [],
  453. pendingAnswers: null,
  454. lastUpdatedAt: Date.now(),
  455. filePath: mdPath,
  456. nudgeAction: null,
  457. });
  458. const response = await fetch(
  459. `${baseUrl}/api/interviews/doc-test/state?token=${authToken}`,
  460. );
  461. const data = (await response.json()) as { document: string };
  462. expect(data.document).toContain('# Test Document');
  463. expect(data.document).toContain('Content here.');
  464. await fs.rm(tempDir, { recursive: true, force: true });
  465. } finally {
  466. cleanup();
  467. }
  468. });
  469. test('returns isBusy true when mode is awaiting-agent', async () => {
  470. const { baseUrl, authToken, dashboard, cleanup } = await startDashboard();
  471. try {
  472. dashboard.pushState({
  473. interviewId: 'busy-test',
  474. sessionID: 'session-busy',
  475. idea: 'Busy Test',
  476. mode: 'awaiting-agent',
  477. summary: 'Test',
  478. title: 'Busy Test',
  479. questions: [],
  480. pendingAnswers: null,
  481. lastUpdatedAt: Date.now(),
  482. filePath: '',
  483. nudgeAction: null,
  484. });
  485. const response = await fetch(
  486. `${baseUrl}/api/interviews/busy-test/state?token=${authToken}`,
  487. );
  488. const data = (await response.json()) as { isBusy: boolean };
  489. expect(data.isBusy).toBe(true);
  490. } finally {
  491. cleanup();
  492. }
  493. });
  494. });
  495. describe('submit answers (POST /api/interviews/:id/answers)', () => {
  496. test('stores answers as pending', async () => {
  497. const { baseUrl, authToken, dashboard, cleanup } = await startDashboard();
  498. try {
  499. // Create interview
  500. dashboard.pushState({
  501. interviewId: 'answers-test',
  502. sessionID: 'session-answers',
  503. idea: 'Answers Test',
  504. mode: 'awaiting-user',
  505. summary: 'Test',
  506. title: 'Answers Test',
  507. questions: [
  508. {
  509. id: 'q-1',
  510. question: 'What?',
  511. options: ['A', 'B'],
  512. suggested: 'A',
  513. },
  514. ],
  515. pendingAnswers: null,
  516. lastUpdatedAt: Date.now(),
  517. filePath: '',
  518. nudgeAction: null,
  519. });
  520. // Submit answers
  521. const response = await fetch(
  522. `${baseUrl}/api/interviews/answers-test/answers?token=${authToken}`,
  523. {
  524. method: 'POST',
  525. body: JSON.stringify({
  526. answers: [{ questionId: 'q-1', answer: 'A' }],
  527. }),
  528. headers: { 'content-type': 'application/json' },
  529. },
  530. );
  531. expect(response.status).toBe(200);
  532. // Verify answers are stored
  533. const state = dashboard.getState('answers-test');
  534. expect(state?.pendingAnswers).toEqual([
  535. { questionId: 'q-1', answer: 'A' },
  536. ]);
  537. } finally {
  538. cleanup();
  539. }
  540. });
  541. test('sets mode to awaiting-agent', async () => {
  542. const { baseUrl, authToken, dashboard, cleanup } = await startDashboard();
  543. try {
  544. dashboard.pushState({
  545. interviewId: 'mode-test',
  546. sessionID: 'session-mode',
  547. idea: 'Mode Test',
  548. mode: 'awaiting-user',
  549. summary: 'Test',
  550. title: 'Mode Test',
  551. questions: [{ id: 'q-1', question: 'What?', options: ['A', 'B'] }],
  552. pendingAnswers: null,
  553. lastUpdatedAt: Date.now(),
  554. filePath: '',
  555. nudgeAction: null,
  556. });
  557. await fetch(
  558. `${baseUrl}/api/interviews/mode-test/answers?token=${authToken}`,
  559. {
  560. method: 'POST',
  561. body: JSON.stringify({
  562. answers: [{ questionId: 'q-1', answer: 'A' }],
  563. }),
  564. headers: { 'content-type': 'application/json' },
  565. },
  566. );
  567. const state = dashboard.getState('mode-test');
  568. expect(state?.mode).toBe('awaiting-agent');
  569. } finally {
  570. cleanup();
  571. }
  572. });
  573. test('rejects non-array answers', async () => {
  574. const { baseUrl, authToken, dashboard, cleanup } = await startDashboard();
  575. try {
  576. // Create an interview first so the route exists
  577. dashboard.pushState({
  578. interviewId: 'invalid-answers',
  579. sessionID: 'session-invalid',
  580. idea: 'Invalid Answers',
  581. mode: 'awaiting-user',
  582. summary: 'Test',
  583. title: 'Invalid Answers',
  584. questions: [],
  585. pendingAnswers: null,
  586. lastUpdatedAt: Date.now(),
  587. filePath: '',
  588. nudgeAction: null,
  589. });
  590. const response = await fetch(
  591. `${baseUrl}/api/interviews/invalid-answers/answers?token=${authToken}`,
  592. {
  593. method: 'POST',
  594. body: JSON.stringify({
  595. answers: 'not-an-array',
  596. }),
  597. headers: { 'content-type': 'application/json' },
  598. },
  599. );
  600. expect(response.status).toBe(400);
  601. } finally {
  602. cleanup();
  603. }
  604. });
  605. });
  606. describe('consume pending answers (GET /api/interviews/:id/pending)', () => {
  607. test('returns and clears pending answers atomically', async () => {
  608. const { baseUrl, authToken, dashboard, cleanup } = await startDashboard();
  609. try {
  610. // Create interview with pending answers
  611. dashboard.pushState({
  612. interviewId: 'pending-test',
  613. sessionID: 'session-pending',
  614. idea: 'Pending Test',
  615. mode: 'awaiting-agent',
  616. summary: 'Test',
  617. title: 'Pending Test',
  618. questions: [],
  619. pendingAnswers: [{ questionId: 'q-1', answer: 'A' }],
  620. lastUpdatedAt: Date.now(),
  621. filePath: '',
  622. nudgeAction: null,
  623. });
  624. // First call - returns answers
  625. const response1 = await fetch(
  626. `${baseUrl}/api/interviews/pending-test/pending?token=${authToken}`,
  627. );
  628. const data1 = (await response1.json()) as {
  629. answers: Array<{ questionId: string; answer: string }> | null;
  630. };
  631. expect(data1.answers).toEqual([{ questionId: 'q-1', answer: 'A' }]);
  632. // Verify state was cleared
  633. const state = dashboard.getState('pending-test');
  634. expect(state?.pendingAnswers).toBeNull();
  635. } finally {
  636. cleanup();
  637. }
  638. });
  639. test('returns null on second call (already consumed)', async () => {
  640. const { baseUrl, authToken, dashboard, cleanup } = await startDashboard();
  641. try {
  642. dashboard.pushState({
  643. interviewId: 'consume-test',
  644. sessionID: 'session-consume',
  645. idea: 'Consume Test',
  646. mode: 'awaiting-agent',
  647. summary: 'Test',
  648. title: 'Consume Test',
  649. questions: [],
  650. pendingAnswers: [{ questionId: 'q-1', answer: 'A' }],
  651. lastUpdatedAt: Date.now(),
  652. filePath: '',
  653. nudgeAction: null,
  654. });
  655. // First call
  656. await fetch(
  657. `${baseUrl}/api/interviews/consume-test/pending?token=${authToken}`,
  658. );
  659. // Second call - should return null
  660. const response2 = await fetch(
  661. `${baseUrl}/api/interviews/consume-test/pending?token=${authToken}`,
  662. );
  663. const data2 = (await response2.json()) as {
  664. answers: Array<{ questionId: string; answer: string }> | null;
  665. };
  666. expect(data2.answers).toBeNull();
  667. } finally {
  668. cleanup();
  669. }
  670. });
  671. test('returns 404 for unknown interview', async () => {
  672. const { baseUrl, authToken, cleanup } = await startDashboard();
  673. try {
  674. const response = await fetch(
  675. `${baseUrl}/api/interviews/unknown/pending?token=${authToken}`,
  676. );
  677. expect(response.status).toBe(404);
  678. } finally {
  679. cleanup();
  680. }
  681. });
  682. });
  683. describe('nudge (POST /api/interviews/:id/nudge)', () => {
  684. test('stores nudge action', async () => {
  685. const { baseUrl, authToken, dashboard, cleanup } = await startDashboard();
  686. try {
  687. dashboard.pushState({
  688. interviewId: 'nudge-test',
  689. sessionID: 'session-nudge',
  690. idea: 'Nudge Test',
  691. mode: 'awaiting-user',
  692. summary: 'Test',
  693. title: 'Nudge Test',
  694. questions: [],
  695. pendingAnswers: null,
  696. lastUpdatedAt: Date.now(),
  697. filePath: '',
  698. nudgeAction: null,
  699. });
  700. const response = await fetch(
  701. `${baseUrl}/api/interviews/nudge-test/nudge?token=${authToken}`,
  702. {
  703. method: 'POST',
  704. body: JSON.stringify({ action: 'more-questions' }),
  705. headers: { 'content-type': 'application/json' },
  706. },
  707. );
  708. expect(response.status).toBe(200);
  709. const state = dashboard.getState('nudge-test');
  710. expect(state?.nudgeAction).toBe('more-questions');
  711. } finally {
  712. cleanup();
  713. }
  714. });
  715. test('sets mode to awaiting-agent', async () => {
  716. const { baseUrl, authToken, dashboard, cleanup } = await startDashboard();
  717. try {
  718. dashboard.pushState({
  719. interviewId: 'nudge-mode-test',
  720. sessionID: 'session-nudge-mode',
  721. idea: 'Nudge Mode Test',
  722. mode: 'awaiting-user',
  723. summary: 'Test',
  724. title: 'Nudge Mode Test',
  725. questions: [],
  726. pendingAnswers: null,
  727. lastUpdatedAt: Date.now(),
  728. filePath: '',
  729. nudgeAction: null,
  730. });
  731. await fetch(
  732. `${baseUrl}/api/interviews/nudge-mode-test/nudge?token=${authToken}`,
  733. {
  734. method: 'POST',
  735. body: JSON.stringify({ action: 'confirm-complete' }),
  736. headers: { 'content-type': 'application/json' },
  737. },
  738. );
  739. const state = dashboard.getState('nudge-mode-test');
  740. expect(state?.mode).toBe('awaiting-agent');
  741. } finally {
  742. cleanup();
  743. }
  744. });
  745. test('rejects invalid action', async () => {
  746. const { baseUrl, authToken, dashboard, cleanup } = await startDashboard();
  747. try {
  748. // Create an interview first so the route exists
  749. dashboard.pushState({
  750. interviewId: 'nudge-invalid',
  751. sessionID: 'session-nudge-invalid',
  752. idea: 'Nudge Invalid',
  753. mode: 'awaiting-user',
  754. summary: 'Test',
  755. title: 'Nudge Invalid',
  756. questions: [],
  757. pendingAnswers: null,
  758. lastUpdatedAt: Date.now(),
  759. filePath: '',
  760. nudgeAction: null,
  761. });
  762. const response = await fetch(
  763. `${baseUrl}/api/interviews/nudge-invalid/nudge?token=${authToken}`,
  764. {
  765. method: 'POST',
  766. body: JSON.stringify({ action: 'invalid-action' }),
  767. headers: { 'content-type': 'application/json' },
  768. },
  769. );
  770. expect(response.status).toBe(400);
  771. } finally {
  772. cleanup();
  773. }
  774. });
  775. });
  776. describe('consume nudge (GET /api/interviews/:id/nudge)', () => {
  777. test('returns and clears nudge action atomically', async () => {
  778. const { baseUrl, authToken, dashboard, cleanup } = await startDashboard();
  779. try {
  780. dashboard.pushState({
  781. interviewId: 'consume-nudge-test',
  782. sessionID: 'session-consume-nudge',
  783. idea: 'Consume Nudge Test',
  784. mode: 'awaiting-agent',
  785. summary: 'Test',
  786. title: 'Consume Nudge Test',
  787. questions: [],
  788. pendingAnswers: null,
  789. lastUpdatedAt: Date.now(),
  790. filePath: '',
  791. nudgeAction: 'more-questions',
  792. });
  793. const response = await fetch(
  794. `${baseUrl}/api/interviews/consume-nudge-test/nudge?token=${authToken}`,
  795. );
  796. const data = (await response.json()) as {
  797. action: 'more-questions' | 'confirm-complete' | null;
  798. };
  799. expect(data.action).toBe('more-questions');
  800. const state = dashboard.getState('consume-nudge-test');
  801. expect(state?.nudgeAction).toBeNull();
  802. } finally {
  803. cleanup();
  804. }
  805. });
  806. test('returns null on second call', async () => {
  807. const { baseUrl, authToken, dashboard, cleanup } = await startDashboard();
  808. try {
  809. dashboard.pushState({
  810. interviewId: 'nudge-second-test',
  811. sessionID: 'session-nudge-second',
  812. idea: 'Nudge Second Test',
  813. mode: 'awaiting-agent',
  814. summary: 'Test',
  815. title: 'Nudge Second Test',
  816. questions: [],
  817. pendingAnswers: null,
  818. lastUpdatedAt: Date.now(),
  819. filePath: '',
  820. nudgeAction: 'confirm-complete',
  821. });
  822. // First call
  823. await fetch(
  824. `${baseUrl}/api/interviews/nudge-second-test/nudge?token=${authToken}`,
  825. );
  826. // Second call
  827. const response2 = await fetch(
  828. `${baseUrl}/api/interviews/nudge-second-test/nudge?token=${authToken}`,
  829. );
  830. const data2 = (await response2.json()) as {
  831. action: 'more-questions' | 'confirm-complete' | null;
  832. };
  833. expect(data2.action).toBeNull();
  834. } finally {
  835. cleanup();
  836. }
  837. });
  838. });
  839. describe('interview page (GET /interview/:id)', () => {
  840. test('returns HTML with proper content type', async () => {
  841. const { baseUrl, dashboard, cleanup } = await startDashboard();
  842. try {
  843. dashboard.pushState({
  844. interviewId: 'page-test',
  845. sessionID: 'session-page',
  846. idea: 'Page Test',
  847. mode: 'awaiting-user',
  848. summary: 'Test',
  849. title: 'Page Test',
  850. questions: [],
  851. pendingAnswers: null,
  852. lastUpdatedAt: Date.now(),
  853. filePath: '',
  854. nudgeAction: null,
  855. });
  856. const response = await fetch(`${baseUrl}/interview/page-test`);
  857. expect(response.status).toBe(200);
  858. expect(response.headers.get('content-type')).toContain('text/html');
  859. const html = await response.text();
  860. expect(html).toContain('page-test');
  861. } finally {
  862. cleanup();
  863. }
  864. });
  865. test('sets session cookie', async () => {
  866. const { baseUrl, dashboard, cleanup } = await startDashboard();
  867. try {
  868. dashboard.pushState({
  869. interviewId: 'cookie-test',
  870. sessionID: 'session-cookie',
  871. idea: 'Cookie Test',
  872. mode: 'awaiting-user',
  873. summary: 'Test',
  874. title: 'Cookie Test',
  875. questions: [],
  876. pendingAnswers: null,
  877. lastUpdatedAt: Date.now(),
  878. filePath: '',
  879. nudgeAction: null,
  880. });
  881. const response = await fetch(`${baseUrl}/interview/cookie-test`);
  882. const cookies = response.headers.get('set-cookie');
  883. expect(cookies).toContain('dashboard_token=');
  884. expect(cookies).toContain('HttpOnly');
  885. } finally {
  886. cleanup();
  887. }
  888. });
  889. test('returns 400 for invalid interview ID', async () => {
  890. const { baseUrl, cleanup } = await startDashboard();
  891. try {
  892. const response = await fetch(`${baseUrl}/interview/invalid/id`);
  893. expect(response.status).toBe(400);
  894. } finally {
  895. cleanup();
  896. }
  897. });
  898. });
  899. describe('dashboard page (GET /)', () => {
  900. test('returns HTML', async () => {
  901. const { baseUrl, cleanup } = await startDashboard();
  902. try {
  903. const response = await fetch(`${baseUrl}/`);
  904. expect(response.status).toBe(200);
  905. expect(response.headers.get('content-type')).toContain('text/html');
  906. const html = await response.text();
  907. expect(html).toContain('Interview');
  908. } finally {
  909. cleanup();
  910. }
  911. });
  912. test('requires auth', async () => {
  913. const { baseUrl, cleanup } = await startDashboard();
  914. try {
  915. // The root endpoint actually sets a cookie, so it doesn't require auth
  916. // Let's verify it works without auth (it sets cookie)
  917. const response = await fetch(`${baseUrl}/`);
  918. expect(response.status).toBe(200);
  919. } finally {
  920. cleanup();
  921. }
  922. });
  923. });
  924. describe('settings (GET /api/settings, POST /api/settings)', () => {
  925. test('GET returns current settings', async () => {
  926. const { baseUrl, authToken, cleanup } = await startDashboard();
  927. try {
  928. const response = await fetch(
  929. `${baseUrl}/api/settings?token=${authToken}`,
  930. );
  931. expect(response.status).toBe(200);
  932. const data = (await response.json()) as {
  933. scanDays: number;
  934. folders: string[];
  935. discoveredFolders: string[];
  936. registeredSessions: number;
  937. };
  938. expect(typeof data.scanDays).toBe('number');
  939. expect(Array.isArray(data.folders)).toBe(true);
  940. expect(Array.isArray(data.discoveredFolders)).toBe(true);
  941. expect(typeof data.registeredSessions).toBe('number');
  942. } finally {
  943. cleanup();
  944. }
  945. });
  946. test('POST updates scan days', async () => {
  947. const { baseUrl, authToken, dashboard, cleanup } = await startDashboard();
  948. try {
  949. const response = await fetch(
  950. `${baseUrl}/api/settings?token=${authToken}`,
  951. {
  952. method: 'POST',
  953. body: JSON.stringify({ scanDays: 60 }),
  954. headers: { 'content-type': 'application/json' },
  955. },
  956. );
  957. expect(response.status).toBe(200);
  958. const data = (await response.json()) as { scanDays: number };
  959. expect(data.scanDays).toBe(60);
  960. // Verify it was updated
  961. expect(dashboard.getScanDays()).toBe(60);
  962. } finally {
  963. cleanup();
  964. }
  965. });
  966. test('both require auth', async () => {
  967. const { baseUrl, cleanup } = await startDashboard();
  968. try {
  969. const getResponse = await fetch(`${baseUrl}/api/settings`);
  970. expect(getResponse.status).toBe(401);
  971. const postResponse = await fetch(`${baseUrl}/api/settings`, {
  972. method: 'POST',
  973. body: JSON.stringify({ scanDays: 30 }),
  974. headers: { 'content-type': 'application/json' },
  975. });
  976. expect(postResponse.status).toBe(401);
  977. } finally {
  978. cleanup();
  979. }
  980. });
  981. });
  982. describe('direct API methods', () => {
  983. test('registerSession adds session to registry', async () => {
  984. const { baseUrl, dashboard, cleanup } = await startDashboard();
  985. try {
  986. dashboard.registerSession({
  987. sessionID: 'direct-session',
  988. directory: '/direct/dir',
  989. pid: 999,
  990. registeredAt: Date.now(),
  991. });
  992. // Verify session is registered by checking it exists
  993. const response = await fetch(
  994. `${baseUrl}/api/sessions?token=${dashboard.authToken}`,
  995. );
  996. expect(response.status).toBe(200);
  997. const data = (await response.json()) as {
  998. sessions: Array<{ sessionID: string }>;
  999. };
  1000. expect(
  1001. data.sessions.some((s) => s.sessionID === 'direct-session'),
  1002. ).toBe(true);
  1003. } finally {
  1004. cleanup();
  1005. }
  1006. });
  1007. test('pushState updates cache', async () => {
  1008. const { dashboard, cleanup } = await startDashboard();
  1009. try {
  1010. dashboard.pushState({
  1011. interviewId: 'direct-push',
  1012. sessionID: 'session-direct',
  1013. idea: 'Direct Push',
  1014. mode: 'awaiting-user',
  1015. summary: 'Direct',
  1016. title: 'Direct Push',
  1017. questions: [],
  1018. pendingAnswers: null,
  1019. lastUpdatedAt: Date.now(),
  1020. filePath: '',
  1021. nudgeAction: null,
  1022. });
  1023. const state = dashboard.getState('direct-push');
  1024. expect(state?.idea).toBe('Direct Push');
  1025. } finally {
  1026. cleanup();
  1027. }
  1028. });
  1029. test('storeAnswers stores pending answers', async () => {
  1030. const { dashboard, cleanup } = await startDashboard();
  1031. try {
  1032. dashboard.pushState({
  1033. interviewId: 'store-answers',
  1034. sessionID: 'session-store',
  1035. idea: 'Store Answers',
  1036. mode: 'awaiting-user',
  1037. summary: 'Store',
  1038. title: 'Store Answers',
  1039. questions: [],
  1040. pendingAnswers: null,
  1041. lastUpdatedAt: Date.now(),
  1042. filePath: '',
  1043. nudgeAction: null,
  1044. });
  1045. dashboard.storeAnswers('store-answers', [
  1046. { questionId: 'q-1', answer: 'Direct' },
  1047. ]);
  1048. const state = dashboard.getState('store-answers');
  1049. expect(state?.pendingAnswers).toEqual([
  1050. { questionId: 'q-1', answer: 'Direct' },
  1051. ]);
  1052. } finally {
  1053. cleanup();
  1054. }
  1055. });
  1056. test('consumePendingAnswers clears pending answers', async () => {
  1057. const { dashboard, cleanup } = await startDashboard();
  1058. try {
  1059. dashboard.pushState({
  1060. interviewId: 'consume-direct',
  1061. sessionID: 'session-consume-direct',
  1062. idea: 'Consume Direct',
  1063. mode: 'awaiting-agent',
  1064. summary: 'Consume',
  1065. title: 'Consume Direct',
  1066. questions: [],
  1067. pendingAnswers: [{ questionId: 'q-1', answer: 'Test' }],
  1068. lastUpdatedAt: Date.now(),
  1069. filePath: '',
  1070. nudgeAction: null,
  1071. });
  1072. const answers = dashboard.consumePendingAnswers('consume-direct');
  1073. expect(answers).toEqual([{ questionId: 'q-1', answer: 'Test' }]);
  1074. const state = dashboard.getState('consume-direct');
  1075. expect(state?.pendingAnswers).toBeNull();
  1076. } finally {
  1077. cleanup();
  1078. }
  1079. });
  1080. test('consumeNudgeAction clears nudge action', async () => {
  1081. const { dashboard, cleanup } = await startDashboard();
  1082. try {
  1083. dashboard.pushState({
  1084. interviewId: 'nudge-direct',
  1085. sessionID: 'session-nudge-direct',
  1086. idea: 'Nudge Direct',
  1087. mode: 'awaiting-agent',
  1088. summary: 'Nudge',
  1089. title: 'Nudge Direct',
  1090. questions: [],
  1091. pendingAnswers: null,
  1092. lastUpdatedAt: Date.now(),
  1093. filePath: '',
  1094. nudgeAction: 'more-questions',
  1095. });
  1096. const action = dashboard.consumeNudgeAction('nudge-direct');
  1097. expect(action).toBe('more-questions');
  1098. const state = dashboard.getState('nudge-direct');
  1099. expect(state?.nudgeAction).toBeNull();
  1100. } finally {
  1101. cleanup();
  1102. }
  1103. });
  1104. test('addManualFolder and removeManualFolder', async () => {
  1105. const { dashboard, cleanup } = await startDashboard();
  1106. try {
  1107. dashboard.addManualFolder('/manual/folder1');
  1108. expect(dashboard.getManualFolders()).toContain('/manual/folder1');
  1109. dashboard.addManualFolder('/manual/folder2');
  1110. expect(dashboard.getManualFolders().length).toBe(2);
  1111. dashboard.removeManualFolder('/manual/folder1');
  1112. expect(dashboard.getManualFolders()).not.toContain('/manual/folder1');
  1113. expect(dashboard.getManualFolders()).toContain('/manual/folder2');
  1114. } finally {
  1115. cleanup();
  1116. }
  1117. });
  1118. test('setScanDays and getScanDays', async () => {
  1119. const { dashboard, cleanup } = await startDashboard();
  1120. try {
  1121. dashboard.setScanDays(45);
  1122. expect(dashboard.getScanDays()).toBe(45);
  1123. dashboard.setScanDays(0);
  1124. expect(dashboard.getScanDays()).toBe(0);
  1125. } finally {
  1126. cleanup();
  1127. }
  1128. });
  1129. });
  1130. describe('file scanning (GET /api/files)', () => {
  1131. test('lists interview files from registered sessions', async () => {
  1132. const tempDir = await createTempInterviewDir();
  1133. const { baseUrl, authToken, dashboard, cleanup } = await startDashboard();
  1134. try {
  1135. // Create a test markdown file
  1136. const mdPath = path.join(tempDir, 'interview', 'test-file.md');
  1137. await fs.writeFile(
  1138. mdPath,
  1139. '# Test File\n\n## Current spec\n\nSpec content.\n\n## Q&A history\n\n',
  1140. 'utf8',
  1141. );
  1142. // Register session with the temp directory
  1143. dashboard.registerSession({
  1144. sessionID: 'session-files',
  1145. directory: tempDir,
  1146. pid: 0,
  1147. registeredAt: Date.now(),
  1148. });
  1149. const response = await fetch(`${baseUrl}/api/files?token=${authToken}`);
  1150. expect(response.status).toBe(200);
  1151. const data = (await response.json()) as {
  1152. files: Array<{ fileName: string; title: string }>;
  1153. };
  1154. // os.homedir() is always scanned, so other files may appear.
  1155. // Assert our file is present rather than asserting exact count.
  1156. const ourFile = data.files.find((f) => f.fileName === 'test-file.md');
  1157. expect(ourFile).toBeDefined();
  1158. expect(ourFile?.title).toBe('Test File');
  1159. await fs.rm(tempDir, { recursive: true, force: true });
  1160. } finally {
  1161. cleanup();
  1162. }
  1163. });
  1164. test('requires auth', async () => {
  1165. const { baseUrl, cleanup } = await startDashboard();
  1166. try {
  1167. const response = await fetch(`${baseUrl}/api/files`);
  1168. expect(response.status).toBe(401);
  1169. } finally {
  1170. cleanup();
  1171. }
  1172. });
  1173. });
  1174. describe('error handling', () => {
  1175. test('returns 404 for unknown routes', async () => {
  1176. const { baseUrl, cleanup } = await startDashboard();
  1177. try {
  1178. const response = await fetch(`${baseUrl}/api/unknown`);
  1179. expect(response.status).toBe(404);
  1180. } finally {
  1181. cleanup();
  1182. }
  1183. });
  1184. test('handles invalid JSON body', async () => {
  1185. const { baseUrl, authToken, cleanup } = await startDashboard();
  1186. try {
  1187. const response = await fetch(
  1188. `${baseUrl}/api/register?token=${authToken}`,
  1189. {
  1190. method: 'POST',
  1191. body: 'invalid json',
  1192. headers: { 'content-type': 'application/json' },
  1193. },
  1194. );
  1195. expect(response.status).toBe(400);
  1196. } finally {
  1197. cleanup();
  1198. }
  1199. });
  1200. });
  1201. });