Browse Source

test: harden dashboard SSE lifecycle coverage

Zerdeşt Taifour 1 month ago
parent
commit
a3aaa6f8bf
2 changed files with 65 additions and 13 deletions
  1. 64 11
      src/interview/dashboard.test.ts
  2. 1 2
      src/interview/dashboard.ts

+ 64 - 11
src/interview/dashboard.test.ts

@@ -56,31 +56,66 @@ async function openSseConnection(
     firstChunk: Promise<string>;
     closed: Promise<void>;
   }>((resolve, reject) => {
+    let responded = false;
+    let sawStateEvent = false;
+
+    let firstChunkResolve!: (value: string) => void;
+    let firstChunkReject!: (reason: Error) => void;
+    const firstChunk = new Promise<string>((resolveFirst, rejectFirst) => {
+      firstChunkResolve = resolveFirst;
+      firstChunkReject = rejectFirst;
+    });
+
+    let closedResolve!: () => void;
+    let closedReject!: (reason: Error) => void;
+    const closed = new Promise<void>((resolveClosed, rejectClosed) => {
+      closedResolve = resolveClosed;
+      closedReject = rejectClosed;
+    });
+
+    const rejectStreams = (error: Error) => {
+      firstChunkReject(error);
+      closedReject(error);
+    };
+
+    const onError = (error: Error) => {
+      rejectStreams(error);
+      if (!responded) {
+        responded = true;
+        reject(error);
+      }
+    };
+
     const request = get(
       `${baseUrl}/api/interviews/${interviewId}/events?token=${authToken}`,
       (response) => {
+        responded = true;
         response.setEncoding('utf8');
 
         let buffer = '';
-        const firstChunk = new Promise<string>((resolveFirst) => {
-          response.on('data', (chunk: string) => {
-            buffer += chunk;
-            if (buffer.includes('event: state')) {
-              resolveFirst(buffer);
-            }
-          });
+        response.on('data', (chunk: string) => {
+          buffer += chunk;
+          if (!sawStateEvent && buffer.includes('event: state')) {
+            sawStateEvent = true;
+            firstChunkResolve(buffer);
+          }
         });
 
-        const closed = new Promise<void>((resolveClosed) => {
-          response.once('close', resolveClosed);
+        response.once('close', () => {
+          if (!sawStateEvent) {
+            firstChunkReject(
+              new Error('SSE response closed before initial state event'),
+            );
+          }
+          closedResolve();
         });
 
-        response.once('error', reject);
+        response.once('error', onError);
         resolve({ firstChunk, closed });
       },
     );
 
-    request.once('error', reject);
+    request.once('error', onError);
   });
 }
 
@@ -129,6 +164,24 @@ describe('dashboard server', () => {
         cleanup();
       }
     });
+
+    test('rejects SSE firstChunk when response closes before state', async () => {
+      const { baseUrl, authToken, cleanup } = await startDashboard();
+      try {
+        const connection = await openSseConnection(
+          baseUrl,
+          authToken,
+          'missing-lifecycle-sse',
+        );
+
+        await expect(connection.firstChunk).rejects.toThrow(
+          'SSE response closed before initial state event',
+        );
+        await connection.closed;
+      } finally {
+        cleanup();
+      }
+    });
   });
 
   describe('health endpoint', () => {

+ 1 - 2
src/interview/dashboard.ts

@@ -276,8 +276,7 @@ export function createDashboardServer(config: DashboardConfig): {
     return timer;
   }
 
-  let cleanupTimer: ReturnType<typeof setInterval> | null =
-    createCleanupTimer();
+  let cleanupTimer: ReturnType<typeof setInterval> | null = null;
 
   // File scan cache (TTL 10s)
   let fileCache: { items: InterviewFileItem[]; at: number } | null = null;