From 813bf1695864de6326faf937c56d6745e956cd4c Mon Sep 17 00:00:00 2001 From: s1to Date: Fri, 7 Aug 2026 20:01:42 +0200 Subject: [PATCH] [0.9.1] FORK_IA_UX: tests for the Karvan ephemeral-rooms model (12 cases, no framework) Oasis ships no test runner, so this is a dependency-free node script (node test/karvan_model.test.js). Covers rooms, ephemeral messages, the 250-msg ring buffer, truncation/validation, self-destruct by idle+absolute TTL, idle reset on activity, MAX_ROOMS eviction, and the WebRTC signaling mailbox. Lives in test/ (outside src/), so it is not bundled into the APK. --- test/karvan_model.test.js | 142 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 142 insertions(+) create mode 100644 test/karvan_model.test.js diff --git a/test/karvan_model.test.js b/test/karvan_model.test.js new file mode 100644 index 0000000..21f818c --- /dev/null +++ b/test/karvan_model.test.js @@ -0,0 +1,142 @@ +"use strict"; +// Tests del módulo KARVAN (salas efímeras + mensajes temporales + señalización WebRTC). +// Sin framework (Oasis no tiene): `node test/karvan_model.test.js` o `npm test`. +// Cubre: creación/listado de salas, mensajes efímeros, buffer circular, truncado, validación, +// AUTODESTRUCCIÓN por TTL (idle y absoluto), desalojo por límite, y el mailbox de señalización. + +const assert = require("assert"); +const path = require("path"); +const makeModel = require(path.join(__dirname, "..", "src", "models", "karvan_model.js")); + +let passed = 0, failed = 0; +const wait = (ms) => new Promise((r) => setTimeout(r, ms)); +async function test(name, fn) { + try { await fn(); passed++; console.log("ok - " + name); } + catch (e) { failed++; console.log("FAIL - " + name + "\n " + (e && e.message)); } +} + +(async () => { + await test("createRoom: id de 18 hex, count 0, expira en el futuro", () => { + const m = makeModel(); + const r = m.createRoom({ title: "hola" }); + assert.strictEqual(typeof r.id, "string"); + assert.strictEqual(r.id.length, 18); + assert.strictEqual(r.title, "hola"); + assert.strictEqual(r.count, 0); + assert.ok(r.expiresAt > r.createdAt); + }); + + await test("postMessage + listMessages: seq incremental y contenido", () => { + const m = makeModel(); + const r = m.createRoom({}); + const msg = m.postMessage(r.id, { from: "alice", text: "hi" }); + assert.strictEqual(msg.seq, 1); + assert.strictEqual(msg.from, "alice"); + assert.strictEqual(msg.text, "hi"); + const list = m.listMessages(r.id, 0); + assert.strictEqual(list.length, 1); + assert.strictEqual(list[0].text, "hi"); + }); + + await test("listMessages(since) filtra por seq", () => { + const m = makeModel(); + const r = m.createRoom({}); + m.postMessage(r.id, { from: "a", text: "1" }); + m.postMessage(r.id, { from: "a", text: "2" }); + assert.strictEqual(m.listMessages(r.id, 1).length, 1); // solo seq > 1 + assert.strictEqual(m.listMessages(r.id, 0).length, 2); + }); + + await test("rechaza texto vacío y sala inexistente (devuelve null)", () => { + const m = makeModel(); + const r = m.createRoom({}); + assert.strictEqual(m.postMessage(r.id, { from: "a", text: "" }), null); + assert.strictEqual(m.postMessage("noexiste", { from: "a", text: "x" }), null); + assert.strictEqual(m.listMessages("noexiste", 0), null); + }); + + await test("trunca texto a 2000 y 'from' a 80", () => { + const m = makeModel(); + const r = m.createRoom({}); + const big = "x".repeat(3000); + const msg = m.postMessage(r.id, { from: "y".repeat(200), text: big }); + assert.strictEqual(msg.text.length, 2000); + assert.strictEqual(msg.from.length, 80); + }); + + await test("buffer circular: máx 250 en RAM, seq sigue creciendo", () => { + const m = makeModel(); + const r = m.createRoom({}); + for (let i = 1; i <= 260; i++) m.postMessage(r.id, { from: "a", text: "m" + i }); + const all = m.listMessages(r.id, 0); + assert.strictEqual(all.length, 250); // solo los últimos 250 + assert.strictEqual(all[all.length - 1].seq, 260); // el seq no se reinicia + assert.strictEqual(all[0].seq, 11); // los 10 primeros se descartaron + }); + + await test("ttlMinutes fija expiresAt y se capa a 24h", () => { + const m = makeModel(); + const r5 = m.createRoom({ ttlMinutes: 5 }); + const dur = r5.expiresAt - r5.createdAt; + assert.ok(Math.abs(dur - 5 * 60000) < 50); + const rBig = m.createRoom({ ttlMinutes: 999999 }); + assert.ok(rBig.expiresAt - rBig.createdAt <= 24 * 60 * 60 * 1000); + }); + + await test("señalización: getSignals excluye los propios y respeta 'to'", () => { + const m = makeModel(); + const r = m.createRoom({}); + m.postSignal(r.id, { from: "A", to: "", payload: { k: "hello" } }); // broadcast de A + m.postSignal(r.id, { from: "B", to: "A", payload: { k: "desc" } }); // dirigido a A + const forA = m.getSignals(r.id, { forId: "A", since: 0 }); + // A no debe ver su propio broadcast; sí el dirigido a A + assert.strictEqual(forA.signals.length, 1); + assert.strictEqual(forA.signals[0].from, "B"); + // los miembros incluyen a B pero no a A (uno mismo) + assert.ok(forA.members.indexOf("B") !== -1); + assert.ok(forA.members.indexOf("A") === -1); + // para B: ve el broadcast de A pero no el dirigido a A + const forB = m.getSignals(r.id, { forId: "B", since: 0 }); + assert.strictEqual(forB.signals.length, 1); + assert.strictEqual(forB.signals[0].payload.k, "hello"); + }); + + await test("AUTODESTRUCCIÓN por TTL de inactividad (idle)", async () => { + const m = makeModel({ idleTtlMs: 40, absTtlMs: 100000 }); + const r = m.createRoom({}); + assert.ok(m.getRoom(r.id)); // existe + await wait(90); // > idle (40ms) sin actividad + assert.strictEqual(m.getRoom(r.id), null); // se autodestruyó + assert.strictEqual(m.listMessages(r.id, 0), null); + }); + + await test("la actividad RESETEA el idle (no se destruye si hay tráfico)", async () => { + const m = makeModel({ idleTtlMs: 60, absTtlMs: 100000 }); + const r = m.createRoom({}); + await wait(40); m.postMessage(r.id, { from: "a", text: "keepalive" }); // resetea a los 40ms + await wait(40); // 80ms totales pero el idle se reseteó -> sigue viva + assert.ok(m.getRoom(r.id), "debe seguir viva tras actividad"); + await wait(90); // ahora sí, sin actividad > idle + assert.strictEqual(m.getRoom(r.id), null); + }); + + await test("AUTODESTRUCCIÓN por TTL absoluto (no lo resetea la actividad)", async () => { + const m = makeModel({ idleTtlMs: 100000, absTtlMs: 60 }); + const r = m.createRoom({}); + await wait(30); m.postMessage(r.id, { from: "a", text: "hola" }); // actividad, NO resetea abs + await wait(60); // 90ms > abs (60ms) + assert.strictEqual(m.getRoom(r.id), null); + }); + + await test("desalojo por MAX_ROOMS (100): la más antigua se descarta", () => { + const m = makeModel(); + const ids = []; + for (let i = 0; i < 101; i++) ids.push(m.createRoom({ title: "r" + i }).id); + assert.strictEqual(m.listRooms().length, 100); // capado a 100 + assert.strictEqual(m.getRoom(ids[0]), null); // la primera (más antigua) fuera + assert.ok(m.getRoom(ids[100])); // la última sigue + }); + + console.log("\n" + passed + " passed, " + failed + " failed"); + process.exit(failed ? 1 : 0); +})();