CI / test (push) Successful in 1m11s
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]>
193 lines
8.5 KiB
TypeScript
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();
|
|
});
|