add test for html and sse requests
authorLaurent Mazet <mazet@softndesign.org>
Wed, 22 Jul 2026 17:43:21 +0000 (19:43 +0200)
committerLaurent Mazet <mazet@softndesign.org>
Tue, 11 Aug 2026 20:26:49 +0000 (22:26 +0200)
test/test-common.js
test/test-http.js
test/test-realtime.js

index 0fa23c0115e7d52f95d1eae4637aced6f951c471..5a4535bd93b2abf59c5c614a37f32f101f88aec5 100644 (file)
@@ -107,6 +107,35 @@ function postRaw(body, headers = {}) {
     });
 }
 
+/* Sends one raw HTTP request to an arbitrary path and returns the full */
+/* response. */
+function requestRaw(method, requestPath, body = "", headers = {}) {
+    return new Promise((resolve, reject) => {
+        const req = http.request({
+            host: HOST,
+            port: PORT,
+            path: requestPath,
+            method,
+            headers: {
+                ...headers,
+                ...(body ? {"Content-Length": Buffer.byteLength(body)} : {})
+            }
+        }, (res) => {
+            let data = "";
+            res.setEncoding("utf8");
+            res.on("data", (chunk) => {
+                data += chunk;
+            });
+            res.on("end", () => resolve({statusCode: res.statusCode, headers: res.headers, body: data}));
+        });
+        req.on("error", reject);
+        if (body) {
+            req.write(body);
+        }
+        req.end();
+    });
+}
+
 /* Opens one server-sent-events stream using the server's URL-encoded */
 /* payload format. */
 function sseConnect(initialPayload) {
@@ -126,6 +155,69 @@ function sseConnect(initialPayload) {
     });
 }
 
+/* Reads the first SSE chunk, then closes the request to avoid hanging tests. */
+function readFirstSseChunk(initialPayload) {
+    return new Promise((resolve, reject) => {
+        sseConnect(initialPayload).then(({req, res}) => {
+            let settled = false;
+
+            function finish(value) {
+                if (!settled) {
+                    settled = true;
+                    try {
+                        req.destroy();
+                    } catch (e) {
+                        /* ignore socket shutdown errors */
+                    }
+                    resolve({
+                        statusCode: res.statusCode,
+                        headers: res.headers,
+                        chunk: value
+                    });
+                }
+            }
+
+            res.once("data", (chunk) => finish(chunk));
+            res.once("error", reject);
+
+            setTimeout(() => finish(""), 1000);
+        }, reject);
+    });
+}
+
+/* Collects SSE chunks for a short time window, then closes the request. */
+function collectSseChunks(initialPayload, durationMs = 500) {
+    return new Promise((resolve, reject) => {
+        sseConnect(initialPayload).then(({req, res}) => {
+            let data = "";
+            let settled = false;
+
+            function finish() {
+                if (!settled) {
+                    settled = true;
+                    try {
+                        req.destroy();
+                    } catch (e) {
+                        /* ignore socket shutdown errors */
+                    }
+                    resolve({
+                        statusCode: res.statusCode,
+                        headers: res.headers,
+                        body: data
+                    });
+                }
+            }
+
+            res.on("data", (chunk) => {
+                data += chunk;
+            });
+            res.once("error", reject);
+
+            setTimeout(finish, durationMs);
+        }, reject);
+    });
+}
+
 /* Client websocket frames must be masked, so tests build frames manually. */
 function createMaskedWebSocketFrame(data) {
     const payload = Buffer.from(data, "utf8");
@@ -359,6 +451,9 @@ module.exports = {
     log,
     postJson,
     postRaw,
+    requestRaw,
+    readFirstSseChunk,
+    collectSseChunks,
     randomPlayer,
     runTestSection,
     sseConnect,
index 6dc3c3f2aa162d3b28be01c64ddf596d5a5dba82..0fe96593c9ae37a2deab8c7017cb5ae2279b34e4 100644 (file)
@@ -5,7 +5,7 @@
  * -----------------------------------------------------------------------------
  */
 
-const {assert, postJson, postRaw, randomPlayer, runTestSection} = require("./test-common");
+const {assert, postJson, postRaw, randomPlayer, readFirstSseChunk, requestRaw, runTestSection} = require("./test-common");
 
 /* Basic health check for the main JSON command endpoint. */
 async function testHello() {
@@ -34,6 +34,28 @@ async function testUnknownCommand() {
     assert(body[0].reason === "Unknown command", "unknown command should report the correct reason");
 }
 
+/* SSE GET requests should switch to event-stream mode and emit the initial */
+/* state. */
+async function testSseEndpointReturnsEventStream() {
+    const playerName = randomPlayer("sse-http");
+    const gameNumber = `sse-http-${Date.now()}-${Math.floor(Math.random() * 1e6)}`;
+
+    const res = await readFirstSseChunk({playerName, gameNumber});
+
+    assert(res.statusCode === 200, "SSE endpoint should return HTTP 200");
+    assert((res.headers["content-type"] || "").includes("text/event-stream"), "SSE endpoint should return event-stream content type");
+    assert(res.chunk.includes("data:"), "SSE endpoint should emit an initial data frame");
+    assert(res.chunk.includes(`"playerName":"${playerName}"`), "SSE initial payload should include the player name");
+}
+
+/* Invalid JSON delivered through the SSE URL should still be rejected */
+/* cleanly. */
+async function testSseEndpointRejectsInvalidJson() {
+    const res = await requestRaw("GET", "/sse/%7Bnot-json");
+    assert(res.statusCode === 400, "invalid SSE JSON should return 400");
+    assert(res.body.includes("Unable to parse your JSON data"), "invalid SSE JSON should explain the parse error");
+}
+
 /* Oversized request bodies should be rejected by closing the connection. */
 async function testOversizedPostClosesConnection() {
     const body = "x".repeat(1000001);
@@ -52,5 +74,7 @@ runTestSection("HTTP", [
     testHello,
     testInvalidJson,
     testUnknownCommand,
+    testSseEndpointReturnsEventStream,
+    testSseEndpointRejectsInvalidJson,
     testOversizedPostClosesConnection
 ]);
index 0d645ae09889f5c91013c429065a38d517b4e055..71bfbecc314a4386c0c73f373c7d017d8f7f5f80 100644 (file)
@@ -10,10 +10,11 @@ const {
     createMaskedWebSocketFrame,
     crypto,
     net,
+    collectSseChunks,
     postJson,
     randomPlayer,
+    readFirstSseChunk,
     runTestSection,
-    sseConnect,
     wait,
     wsConnect
 } = require("./test-common");
@@ -24,19 +25,19 @@ async function testSseReceivesEvents() {
     const listener = randomPlayer("listener");
     const actor = randomPlayer("actor");
 
-    const connection = await sseConnect({playerName: listener, gameNumber});
-    let stream = "";
-    connection.res.on("data", (chunk) => {
-        stream += chunk;
-    });
+    const initial = await readFirstSseChunk({playerName: listener, gameNumber});
+    assert(initial.statusCode === 200, "SSE stream should return HTTP 200");
+    assert(initial.chunk.includes("data:"), "SSE stream should emit an initial data frame");
+    assert(initial.chunk.includes(`"playerName":"${listener}"`), "SSE initial frame should include the listener name");
+
+    const streamPromise = collectSseChunks({playerName: listener, gameNumber}, 700);
 
     await wait(200);
     await postJson({playerName: actor, gameNumber, cmds: [{cmd: "hello"}]});
-    await wait(300);
 
-    connection.req.destroy();
-    assert(stream.includes("data:"), "SSE stream should contain data frames");
-    assert(stream.includes("players"), "SSE stream should include player event data");
+    const stream = await streamPromise;
+    assert(stream.body.includes("data:"), "SSE stream should contain data frames");
+    assert(stream.body.includes("players"), "SSE stream should include player event data");
 }
 
 /* WebSocket clients should be able to send a command and receive a reply. */