Files
t5de-hub/apps/api/test/rooms.test.ts
dhkatzandClaude Opus 5.5 07bbc11565
CI / test (push) Successful in 1m11s
Website header: the T5DE logo, four links, and a profile menu
The header shows T5DE's logo (the client's, also the tab icon) and Online, Rooms, Products and
Catalogs; the person's pages (Account, Library, Console for moderators) and signing out are in a menu
under their picture: their first linked IMVU account's avatar picture, which /me now gives (kept an
hour), or their initial. Creating a product moves to Products (New product, /products/new; /create
redirects there), as creating a room is on Rooms.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 07:18:26 -07:00

193 lines
8.5 KiB
TypeScript

// The room channel (T5DE users in the same IMVU chat see each other's community products) and the rooms list.
import { afterAll, beforeAll, expect, test } from "bun:test";
import { openDatabase, schema, type Database } from "@t5de/db";
import { createApp } from "../src/app";
import { createAuth } from "../src/auth";
import { ImvuClient, type ImvuUser } from "../src/imvu";
const webURL = "http://web.test";
const accounts = new Map<number, ImvuUser>();
const rooms = new Map<string, Record<string, unknown>>();
const fakeIMVU: typeof fetch = (async (input: RequestInfo | URL) => {
const url = new URL(String(input));
const room = url.pathname.match(/^\/room\/room-(\d+-\d+)$/);
if (room) {
const r = rooms.get(room[1]!);
return Response.json({
denormalized: r ? { [`https://api.imvu.com/room/room-${room[1]}`]: { data: r } } : {},
});
}
const u = [...accounts.values()].find(
(u) => url.pathname === `/user/user-${u.id}` || url.searchParams.get("username")?.toLowerCase() === u.username.toLowerCase(),
);
return Response.json({
denormalized: u
? { [`https://api.imvu.com/user/user-${u.id}`]: { data: { legacy_cid: u.id, username: u.username, tagline: u.tagline, is_ageverified: u.ageVerified, is_ap: u.accessPass } } }
: {},
});
}) as typeof fetch;
const imvuRoom = (owner: number, n: number, over: Record<string, unknown> = {}) =>
rooms.set(`${owner}-${n}`, {
name: `Room ${n}`,
description: "<b>hi</b>",
type: "listed",
privacy: "public",
image_url: "//webasset-akm.imvu.com/room.gif",
occupancy: 2,
capacity: 10,
content_rating: "GA",
is_ap: false,
customers_id: owner,
is_expired: false,
...over,
});
let db: Database;
let close: () => Promise<void>;
let base: string;
let stop: () => void;
beforeAll(async () => {
({ db, close } = await openDatabase("memory"));
const auth = createAuth(db, { apiURL: "http://api.test", webURL, secret: "test-secret-test-secret-test-secret-1234", rateLimit: false });
const hub = createApp({ db, auth, imvu: new ImvuClient("http://imvu.test", fakeIMVU) });
hub.app.listen({ port: 0, hostname: "127.0.0.1" });
const server = hub.app.server as unknown as { port: number; stop: () => void };
stop = () => server.stop();
base = `http://127.0.0.1:${server.port}`;
});
afterAll(async () => {
stop();
await close();
});
type Person = { cookie: string; token: string };
async function call(p: Person | undefined, method: string, path: string, body?: unknown) {
const res = await fetch(base + path, {
method,
headers: { Origin: webURL, ...(body !== undefined ? { "Content-Type": "application/json" } : {}), ...(p ? { Cookie: p.cookie } : {}) },
body: body !== undefined ? JSON.stringify(body) : undefined,
});
const text = await res.text();
return { status: res.status, json: text ? JSON.parse(text) : undefined, res };
}
/** Signs a person up with one linked IMVU account; token is a session token for their proxy. */
async function person(name: string, imvu: { id: number; ageVerified?: boolean }): Promise<Person> {
const res = await fetch(base + "/api/auth/sign-up/email", {
method: "POST",
headers: { Origin: webURL, "Content-Type": "application/json" },
body: JSON.stringify({ email: `${name}@example.test`, password: "a long password", name, username: name }),
});
const p = {
cookie: res.headers.getSetCookie().map((c) => c.split(";")[0]).join("; "),
token: res.headers.get("set-auth-token") ?? "",
};
expect(p.token).not.toBe("");
accounts.set(imvu.id, { id: imvu.id, username: `${name}Av`, tagline: "", ageVerified: !!imvu.ageVerified, accessPass: false, picture: null });
const started = await call(p, "POST", "/api/v1/account/links", { username: `${name}Av` });
accounts.get(imvu.id)!.tagline = started.json.challenge.code;
expect((await call(p, "POST", `/api/v1/account/links/${imvu.id}/check`)).status).toBe(200);
return p;
}
/** A proxy's live connection: everything the hub sent it, and the latest "chat" message per chat. */
async function proxy(p: Person, imvuId: number) {
const ws = new WebSocket(`${base.replace("http", "ws")}/api/v1/realtime`, { headers: { Authorization: `Bearer ${p.token}` } } as any);
const got: any[] = [];
ws.onmessage = (e) => got.push(JSON.parse(String(e.data)));
await new Promise((r) => (ws.onopen = r));
const send = (m: object) => ws.send(JSON.stringify(m));
send({ type: "session", imvu_id: imvuId });
return {
ws,
got,
send,
chat: (chat: string) => got.filter((m) => m.type === "chat" && m.chat === chat).at(-1),
};
}
async function waitFor(ok: () => boolean) {
for (let i = 0; i < 200 && !ok(); i++) await Bun.sleep(10);
expect(ok()).toBe(true);
}
test("T5DE users in the same chat see each other's community products; adult ones only if adult", async () => {
const fiona = await person("fiona", { id: 101, ageVerified: true });
const gus = await person("gus", { id: 102 });
const f = await proxy(fiona, 101);
const g = await proxy(gus, 102);
f.send({ type: "outfit", items: [{ id: "coat", name: "Coat" }, { id: "lace", name: "Lace", adult: true }] });
g.send({ type: "outfit", items: [{ id: "hat", name: "Hat" }] });
f.send({ type: "join", chat: "555", room: "101-1" });
g.send({ type: "join", chat: "555", room: "101-1" });
await waitFor(() => g.chat("555")?.avatars.length === 1 && f.chat("555")?.avatars.length === 1);
expect(g.chat("555").avatars).toEqual([{ imvu_id: 101, items: [{ id: "coat", name: "Coat", adult: false }] }]);
expect(f.chat("555").avatars).toEqual([{ imvu_id: 102, items: [{ id: "hat", name: "Hat", adult: false }] }]);
// Outfit changes reach the others in the chat; a chat they're not in hears nothing.
g.send({ type: "join", chat: "777" });
g.send({ type: "outfit", items: [] });
await waitFor(() => f.chat("555")?.avatars[0]?.items.length === 0);
expect(f.chat("777")).toBeUndefined();
// Leaving (or disconnecting) takes them off the others' list.
g.send({ type: "leave", chat: "555" });
await waitFor(() => f.chat("555")?.avatars.length === 0);
g.send({ type: "join", chat: "555" });
await waitFor(() => f.chat("555")?.avatars.length === 1);
g.ws.close();
await waitFor(() => f.chat("555")?.avatars.length === 0);
// Nonsense is refused.
f.send({ type: "join", chat: "../x" });
await waitFor(() => f.got.some((m) => m.message === "bad chat"));
f.ws.close();
});
test("rooms are listed when public and listed, and their owner chose to list them", async () => {
const hana = await person("hana", { id: 201, ageVerified: true });
const ivan = await person("ivan", { id: 202 });
imvuRoom(201, 1);
imvuRoom(201, 2, { type: "unlisted" });
imvuRoom(201, 3, { content_rating: "AP", is_ap: true });
imvuRoom(201, 4, { privacy: "closed" });
const h = await proxy(hana, 201);
for (const [chat, room] of [["1", "201-1"], ["2", "201-2"], ["3", "201-3"], ["4", "201-4"]]) h.send({ type: "join", chat, room });
// A room whose owner isn't on T5DE is never stored.
imvuRoom(999, 1);
h.send({ type: "join", chat: "9", room: "999-1" });
const names = async (p?: Person) => ((await call(p, "GET", "/api/v1/rooms")).json.rooms as any[]).map((r) => r.name);
// The hub asks IMVU about each (spaced out); until Hana chooses to list them, none are listed.
for (let i = 0; i < 300 && (await db.select().from(schema.room)).length < 4; i++) await Bun.sleep(10);
expect((await db.select().from(schema.room)).map((r) => r.instanceId).sort()).toEqual(["201-1", "201-2", "201-3", "201-4"]);
expect(await names()).toEqual([]);
await call(hana, "PATCH", "/api/v1/account/links/201", { listRooms: true });
expect(await names()).toEqual(["Room 1"]);
expect(await names(ivan)).toEqual(["Room 1"]); // Ivan isn't adult
expect((await names(hana)).sort()).toEqual(["Room 1", "Room 3"]);
const [listed] = (await call(undefined, "GET", "/api/v1/rooms")).json.rooms;
expect(listed).toMatchObject({
instanceId: "201-1",
description: "<b>hi</b>", // the website shows it as text
imageURL: "https://webasset-akm.imvu.com/room.gif",
owner: { imvuId: 201, username: "hanaAv" },
url: "https://go.imvu.com/chat/room-201-1",
t5de: [], // Hana is there, but doesn't show she's online
});
await call(hana, "PATCH", "/api/v1/account/links/201", { showPresence: true });
expect((await call(undefined, "GET", "/api/v1/rooms")).json.rooms[0].t5de).toEqual([{ imvuId: 201, username: "hanaAv", badge: false }]);
// Unlinking forgets the account's rooms.
await call(hana, "DELETE", "/api/v1/account/links/201");
expect(await names(hana)).toEqual([]);
h.ws.close();
});