// 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(); const rooms = new Map>(); 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 = {}) => rooms.set(`${owner}-${n}`, { name: `Room ${n}`, description: "hi", 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; 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 { 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: "hi", // 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(); });