OASIS_MOBILE/test/karvan_model.test.js
s1to 6020c7f8df [0.9.1] FORK_IA_UX: Fase 2 cross-device — relay Karvan messages over SSB private msgs
Makes a Karvan room work between two different phones once they're connected to a pub:
- karvan_model: rooms track remoteFeeds (SSB ids of other participants); addRemoteFeed/
  getRemoteFeeds/anyRemoteRooms (feed-id validated, capped at MAX_MEMBERS).
- backend: inviting registers the invitee's feed; joining by link registers the inviter's
  feed (read from the invite PM). Posting a message publishes a private 'karvan-relay' to
  those feeds; an efficient LIVE log-stream subscriber (createLogStream old:false live:true)
  ingests incoming relays and injects them into the local mirror room (dedup by mid,
  skips own → no loop). WebRTC SIGNAL frames are NOT relayed (too many/slow for the log).
- Chosen over a muxrpc plugin because a classic pub only replicates the LOG (store-and-forward);
  live muxrpc wouldn't reach the other phone. No SSB-startup changes → no boot risk.
Verified: boot OK with the live stream, invite/adopt register feeds, message posts + relay
publishes, server stays up, 16/16 tests. Cross-device delivery needs the user's 2-phone+pub test.
Note: text chat only — video still needs the wrapper camera permission (Fase 3).
2026-08-07 21:06:13 +02:00

183 lines
8.4 KiB
JavaScript

"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
});
await test("adoptRoom: espejo local con id dado, idempotente, rechaza id inválido", () => {
const m = makeModel();
const id = "abc123def456abc789";
const r = m.adoptRoom({ id, title: "espejo" });
assert.strictEqual(r.id, id);
assert.strictEqual(r.title, "espejo");
assert.ok(m.getRoom(id));
const again = m.adoptRoom({ id, title: "otro" }); // idempotente
assert.strictEqual(again.id, id);
assert.strictEqual(m.listRooms().filter((x) => x.id === id).length, 1);
assert.strictEqual(m.adoptRoom({ id: "NO-hex!" }), null); // id inválido
assert.ok(m.postMessage(id, { from: "a", text: "hola" }), "se puede postear en la adoptada");
});
await test("cap de miembros: no crece más allá de MAX_MEMBERS (50)", () => {
const m = makeModel();
const r = m.createRoom({});
for (let i = 0; i < 80; i++) m.postSignal(r.id, { from: "peer" + i, to: "", payload: { k: "hi" } });
const g = m.getSignals(r.id, { forId: "nadie", since: 0 });
assert.ok(g.members.length <= 50, "miembros capados a 50, got " + g.members.length);
});
await test("rechaza payload de señal gigante (>16KB), acepta pequeño", () => {
const m = makeModel();
const r = m.createRoom({});
assert.strictEqual(m.postSignal(r.id, { from: "a", to: "b", payload: { blob: "x".repeat(20000) } }), null);
assert.ok(m.postSignal(r.id, { from: "a", to: "b", payload: { sdp: "small" } }), "payload pequeño pasa");
});
await test("feeds remotos: addRemoteFeed / getRemoteFeeds / anyRemoteRooms + validación", () => {
const m = makeModel();
const r = m.createRoom({});
assert.strictEqual(m.anyRemoteRooms(), false);
const feed = "@" + "A".repeat(43) + "=.ed25519";
assert.strictEqual(m.addRemoteFeed(r.id, feed), true);
assert.deepStrictEqual(m.getRemoteFeeds(r.id), [feed]);
assert.strictEqual(m.anyRemoteRooms(), true);
assert.strictEqual(m.addRemoteFeed(r.id, "no-es-feed"), false); // formato inválido
assert.strictEqual(m.addRemoteFeed("noexiste", feed), false); // sala inexistente
});
console.log("\n" + passed + " passed, " + failed + " failed");
process.exit(failed ? 1 : 0);
})();