204 lines
5.9 KiB
JavaScript
204 lines
5.9 KiB
JavaScript
import assert from "node:assert/strict";
|
|
import test from "node:test";
|
|
|
|
import { apiClient } from "@/lib/api";
|
|
|
|
test("multiple realtime subscribers share one WebSocket connection", async () => {
|
|
const originalWindow = globalThis.window;
|
|
const originalLocalStorage = globalThis.localStorage;
|
|
const originalWebSocket = globalThis.WebSocket;
|
|
const originalRequest = apiClient.request;
|
|
|
|
class FakeWebSocket {
|
|
static CONNECTING = 0;
|
|
static OPEN = 1;
|
|
static CLOSING = 2;
|
|
static CLOSED = 3;
|
|
static instances = [];
|
|
|
|
constructor(url) {
|
|
this.url = url;
|
|
this.readyState = FakeWebSocket.CONNECTING;
|
|
this.sent = [];
|
|
FakeWebSocket.instances.push(this);
|
|
}
|
|
|
|
open() {
|
|
this.readyState = FakeWebSocket.OPEN;
|
|
this.onopen?.();
|
|
}
|
|
|
|
send(payload) {
|
|
this.sent.push(JSON.parse(payload));
|
|
}
|
|
|
|
receive(payload) {
|
|
this.onmessage?.({ data: JSON.stringify(payload) });
|
|
}
|
|
|
|
close() {
|
|
this.readyState = FakeWebSocket.CLOSED;
|
|
this.onclose?.({ code: 1000, reason: "", wasClean: true });
|
|
}
|
|
}
|
|
|
|
globalThis.window = { addEventListener() {} };
|
|
globalThis.localStorage = {
|
|
getItem(key) {
|
|
return key === "access_token" ? "test-token" : null;
|
|
},
|
|
};
|
|
globalThis.WebSocket = FakeWebSocket;
|
|
apiClient.request = async (endpoint) => {
|
|
assert.equal(endpoint, "/api/auth/ws-ticket/");
|
|
return { ticket: "single-use-ticket", expires_in: 30 };
|
|
};
|
|
|
|
try {
|
|
const { default: manager } = await import("./websocketManager.ts");
|
|
const unsubscribeMessages = manager.subscribe(() => {});
|
|
const unsubscribeStatus = manager.subscribeStatus(() => {});
|
|
|
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
|
|
assert.equal(FakeWebSocket.instances.length, 1);
|
|
assert.match(FakeWebSocket.instances[0].url, /ticket=single-use-ticket/);
|
|
assert.doesNotMatch(FakeWebSocket.instances[0].url, /token=/);
|
|
|
|
const socket = FakeWebSocket.instances[0];
|
|
socket.open();
|
|
manager.setActiveChat(114);
|
|
|
|
assert.equal(FakeWebSocket.instances.length, 1);
|
|
assert.deepEqual(socket.sent.at(-1), {
|
|
action: "open_chat",
|
|
chat_id: 114,
|
|
load_history: false,
|
|
});
|
|
|
|
socket.receive({ type: "ping", timestamp: "2026-08-17T12:00:00Z" });
|
|
assert.deepEqual(socket.sent.at(-1), {
|
|
action: "pong",
|
|
timestamp: "2026-08-17T12:00:00Z",
|
|
});
|
|
|
|
unsubscribeMessages();
|
|
unsubscribeStatus();
|
|
} finally {
|
|
globalThis.window = originalWindow;
|
|
globalThis.localStorage = originalLocalStorage;
|
|
globalThis.WebSocket = originalWebSocket;
|
|
apiClient.request = originalRequest;
|
|
}
|
|
});
|
|
|
|
test("connection recovers immediately when browser comes back online", async () => {
|
|
const originalWindow = globalThis.window;
|
|
const originalDocument = globalThis.document;
|
|
const originalLocalStorage = globalThis.localStorage;
|
|
const originalWebSocket = globalThis.WebSocket;
|
|
const originalRequest = apiClient.request;
|
|
const originalSetTimeout = globalThis.setTimeout;
|
|
const originalClearTimeout = globalThis.clearTimeout;
|
|
const originalSetInterval = globalThis.setInterval;
|
|
const originalClearInterval = globalThis.clearInterval;
|
|
const listeners = new Map();
|
|
const scheduledTimeouts = [];
|
|
const statuses = [];
|
|
|
|
class FakeWebSocket {
|
|
static OPEN = 1;
|
|
static instances = [];
|
|
|
|
constructor(url) {
|
|
this.url = url;
|
|
this.readyState = 0;
|
|
FakeWebSocket.instances.push(this);
|
|
}
|
|
|
|
open() {
|
|
this.readyState = FakeWebSocket.OPEN;
|
|
this.onopen?.();
|
|
}
|
|
|
|
send() {}
|
|
|
|
fail() {
|
|
this.readyState = 3;
|
|
this.onclose?.({ code: 1006, reason: "network", wasClean: false });
|
|
}
|
|
|
|
close() {
|
|
this.readyState = 3;
|
|
}
|
|
}
|
|
|
|
globalThis.window = {
|
|
addEventListener(type, handler) {
|
|
listeners.set(type, handler);
|
|
},
|
|
};
|
|
globalThis.document = {
|
|
visibilityState: "visible",
|
|
addEventListener(type, handler) {
|
|
listeners.set(type, handler);
|
|
},
|
|
};
|
|
globalThis.localStorage = {
|
|
getItem(key) {
|
|
return key === "access_token" ? "access" : null;
|
|
},
|
|
};
|
|
globalThis.WebSocket = FakeWebSocket;
|
|
globalThis.setTimeout = (handler, delay) => {
|
|
const timer = { handler, delay, cleared: false };
|
|
scheduledTimeouts.push(timer);
|
|
return timer;
|
|
};
|
|
globalThis.clearTimeout = (timer) => {
|
|
timer.cleared = true;
|
|
};
|
|
globalThis.setInterval = () => ({ interval: true });
|
|
globalThis.clearInterval = () => {};
|
|
apiClient.request = async () => ({
|
|
ticket: "recovery-ticket",
|
|
expires_in: 30,
|
|
});
|
|
|
|
try {
|
|
const { WebSocketManager } = await import("./websocketManager.ts");
|
|
const manager = new WebSocketManager();
|
|
const unsubscribe = manager.subscribe(() => {});
|
|
const unsubscribeStatus = manager.subscribeStatus((status) =>
|
|
statuses.push(status),
|
|
);
|
|
await new Promise((resolve) => originalSetTimeout(resolve, 0));
|
|
assert.equal(FakeWebSocket.instances.length, 1);
|
|
|
|
FakeWebSocket.instances[0].open();
|
|
FakeWebSocket.instances[0].fail();
|
|
assert.equal(statuses.at(-1).lastCloseCode, 1006);
|
|
assert.equal(statuses.at(-1).lastCloseReason, "network");
|
|
assert.equal(scheduledTimeouts.length, 1);
|
|
assert.ok(scheduledTimeouts[0].delay >= 1600);
|
|
assert.ok(scheduledTimeouts[0].delay <= 2400);
|
|
|
|
listeners.get("online")?.();
|
|
await new Promise((resolve) => originalSetTimeout(resolve, 0));
|
|
assert.equal(scheduledTimeouts[0].cleared, true);
|
|
assert.equal(FakeWebSocket.instances.length, 2);
|
|
unsubscribe();
|
|
unsubscribeStatus();
|
|
} finally {
|
|
globalThis.window = originalWindow;
|
|
globalThis.document = originalDocument;
|
|
globalThis.localStorage = originalLocalStorage;
|
|
globalThis.WebSocket = originalWebSocket;
|
|
globalThis.setTimeout = originalSetTimeout;
|
|
globalThis.clearTimeout = originalClearTimeout;
|
|
globalThis.setInterval = originalSetInterval;
|
|
globalThis.clearInterval = originalClearInterval;
|
|
apiClient.request = originalRequest;
|
|
}
|
|
});
|