The realtime connection now carries the IMVU chats a client is in: the hub tells each proxy in a chat which other T5DE users are there and what community products they wear (adult products only to adult accounts), on joining and on every change. Rooms T5DE users enter are looked up on IMVU; those that are public, listed and whose owner turned on "List my public rooms" appear on the new Rooms page, with the T5DE users in them who show they're online. AP rooms are shown to adults only. Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
7db9fc0370
commit
6ba4d83317
18 files changed
+1838
-50
No files matched your search
@@ -0,0 +1,192 @@
|
||||
// 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 });
|
||||
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();
|
||||
});
|
||||
Reference in new issue
Block a user