Hosted rooms phase 2: moderators, bans, privacy, invites, images, favorites, ratings, recent rooms, occupancy watch
CI / test (push) Successful in 1m10s
CI / test (push) Successful in 1m10s
Moderators (up to 20 IMVU accounts, set by ID or name) and the owner account boot and ban over realtime (room.boot); bans are listed and lifted over REST. Private rooms are unlisted and joined by the owner, moderators and holders of an accepted invite (room.invite / room.invited / room.invite_reply / room.invite_answer). Room images (PNG/JPEG by magic bytes, up to 512 KB) are stored in the database and served with cache headers. Per-person favorites, ratings and recent rooms. room.watch pushes room.occupancy; participants carry their IMVU name, and GET .../participants lists them. Contract in docs/hosted-rooms.md, "Phase 2". Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
43183ec7ee
commit
76c8e0342e
16 files changed
+3851
-73
No files matched your search
@@ -1,6 +1,6 @@
|
||||
// T5DE-hosted rooms (docs/hosted-rooms.md): the REST routes and the realtime room protocol, with fake proxies.
|
||||
import { afterAll, beforeAll, expect, test } from "bun:test";
|
||||
import { openDatabase, type Database } from "@t5de/db";
|
||||
import { eq, openDatabase, schema, type Database } from "@t5de/db";
|
||||
import { createApp } from "../src/app";
|
||||
import { createAuth } from "../src/auth";
|
||||
import { ImvuClient, type ImvuUser } from "../src/imvu";
|
||||
@@ -95,7 +95,7 @@ async function proxy(p: Person, imvuId: number) {
|
||||
};
|
||||
const join = (room: number) =>
|
||||
ask({ type: "room.join", room }, (m) => (m.type === "room.joined" || m.type === "room.error") && m.room === room);
|
||||
return { ws, got, send, last, join };
|
||||
return { ws, got, send, last, join, ask };
|
||||
}
|
||||
|
||||
async function waitFor(ok: () => boolean) {
|
||||
@@ -237,11 +237,13 @@ test("joining, seats and messages in a hosted room", async () => {
|
||||
|
||||
// Dee and Eli join; each sees the other.
|
||||
const dj = await d.join(n);
|
||||
expect(dj).toEqual({ type: "room.joined", room: n, chat: 2_100_000_000 + n, participants: [{ imvu_id: 401, seat: 0 }], sequence: 0 });
|
||||
expect(dj).toEqual({ type: "room.joined", room: n, chat: 2_100_000_000 + n, participants: [{ imvu_id: 401, seat: 0, name: "deenAv" }], sequence: 0 });
|
||||
const ej = await e.join(n);
|
||||
expect(ej.participants).toEqual([{ imvu_id: 401, seat: 0 }, { imvu_id: 402, seat: 0 }]);
|
||||
expect(ej.participants).toEqual([{ imvu_id: 401, seat: 0, name: "deenAv" }, { imvu_id: 402, seat: 0, name: "eliaAv" }]);
|
||||
await waitFor(() => d.last("room.participants", n)?.participants.length === 2);
|
||||
expect((await call(undefined, "GET", `/api/v1/hosted-rooms/${n}`)).json.room.occupancy).toBe(2);
|
||||
expect((await call(undefined, "GET", `/api/v1/hosted-rooms/${n}/participants`)).json.participants).toEqual(ej.participants);
|
||||
expect((await call(eli, "GET", `/api/v1/hosted-rooms/${adultRoom}/participants`)).status).toBe(404);
|
||||
|
||||
// Full at two people; joining again (a reconnect) answers the same.
|
||||
expect((await f.join(n)).reason).toBe("full");
|
||||
@@ -320,3 +322,335 @@ test("a room made adult sends non-adults out; an unlinked account leaves its roo
|
||||
g.ws.close();
|
||||
h.ws.close();
|
||||
});
|
||||
|
||||
// ---- Phase 2 ----
|
||||
|
||||
test("moderators boot and ban; owners lift bans", async () => {
|
||||
const own = await person("ownr", { id: 601 });
|
||||
const mod = await person("modr", { id: 602 });
|
||||
const usr = await person("usrr", { id: 603 });
|
||||
const md2 = await person("mod2", { id: 604 });
|
||||
accounts.set(605, { id: 605, username: "NotOnT5", tagline: "", ageVerified: false, accessPass: false }); // IMVU only
|
||||
const n = (await call(own, "POST", "/api/v1/hosted-rooms", newRoom(601))).json.room.n;
|
||||
|
||||
// Moderators: IDs or names (linked accounts, else IMVU); the owner's own account is left out; up to 20.
|
||||
expect((await call(mod, "PATCH", `/api/v1/hosted-rooms/${n}`, { moderators: [602] })).status).toBe(403);
|
||||
const many = Array.from({ length: 21 }, (_, i) => i + 1);
|
||||
expect((await call(own, "PATCH", `/api/v1/hosted-rooms/${n}`, { moderators: many })).status).toBe(400);
|
||||
expect((await call(own, "PATCH", `/api/v1/hosted-rooms/${n}`, { moderators: ["nobody_here"] })).json.error).toBe(
|
||||
"no_such_imvu_account",
|
||||
);
|
||||
const set = await call(own, "PATCH", `/api/v1/hosted-rooms/${n}`, { moderators: [602, "MOD2av", "notont5", 601, 602] });
|
||||
expect(set.status).toBe(200);
|
||||
expect(set.json.room.moderators).toEqual([602, 604, 605]);
|
||||
expect(set.json.room.moderatorNames).toEqual(["modrAv", "mod2Av", "NotOnT5"]);
|
||||
await call(own, "PATCH", `/api/v1/hosted-rooms/${n}`, { moderators: ["602", 604] });
|
||||
expect((await call(usr, "GET", `/api/v1/hosted-rooms/${n}`)).json.room.moderators).toEqual([602, 604]);
|
||||
|
||||
const o = await proxy(own, 601);
|
||||
const m = await proxy(mod, 602);
|
||||
const u = await proxy(usr, 603);
|
||||
const m2 = await proxy(md2, 604);
|
||||
for (const p of [o, m, u, m2]) expect((await p.join(n)).type).toBe("room.joined");
|
||||
const boot = (p: typeof o, imvu_id: number, extra: object = {}) =>
|
||||
p.ask({ type: "room.boot", room: n, imvu_id, ...extra } as any, (x) => x.type === "room.boot_result" && x.imvu_id === imvu_id);
|
||||
|
||||
// Who may boot whom.
|
||||
expect(await boot(u, 602)).toMatchObject({ ok: false, reason: "forbidden" });
|
||||
expect((await boot(m, 604)).reason).toBe("forbidden"); // a moderator
|
||||
expect((await boot(m, 601)).reason).toBe("forbidden"); // the owner
|
||||
expect((await boot(o, 601)).reason).toBe("forbidden"); // themselves
|
||||
m.send({ type: "room.boot", room: n, imvu_id: 603, minutes: -1 });
|
||||
await waitFor(() => m.got.some((x) => x.message === "bad minutes"));
|
||||
|
||||
// A boot: out, but may come back.
|
||||
expect(await boot(m, 603)).toEqual({ type: "room.boot_result", room: n, imvu_id: 603, ok: true, ban: false });
|
||||
await waitFor(() => u.last("room.closed", n)?.reason === "booted");
|
||||
await waitFor(() => o.last("room.participants", n)?.participants.length === 3);
|
||||
expect((await u.join(n)).type).toBe("room.joined");
|
||||
|
||||
// A ban: out, and refused.
|
||||
expect((await boot(m, 603, { ban: true })).ban).toBe(true);
|
||||
await waitFor(() => u.got.filter((x) => x.type === "room.closed" && x.reason === "booted").length === 2);
|
||||
expect(await u.join(n)).toMatchObject({ type: "room.error", reason: "banned" });
|
||||
|
||||
// Bans: listed for the owner and moderators; lifted by the owner only.
|
||||
expect((await call(usr, "GET", `/api/v1/hosted-rooms/${n}/bans`)).status).toBe(403);
|
||||
const bans = await call(mod, "GET", `/api/v1/hosted-rooms/${n}/bans`);
|
||||
expect(bans.json.bans).toMatchObject([{ imvuId: 603, byImvuId: 602, until: null }]);
|
||||
expect((await call(mod, "DELETE", `/api/v1/hosted-rooms/${n}/bans/603`)).status).toBe(403);
|
||||
expect((await call(own, "DELETE", `/api/v1/hosted-rooms/${n}/bans/603`)).status).toBe(204);
|
||||
expect((await call(own, "DELETE", `/api/v1/hosted-rooms/${n}/bans/603`)).status).toBe(404);
|
||||
expect((await u.join(n)).type).toBe("room.joined");
|
||||
|
||||
// A ban for a while; the owner may boot a moderator.
|
||||
await boot(o, 603, { ban: true, minutes: 60 });
|
||||
const until = (await call(own, "GET", `/api/v1/hosted-rooms/${n}/bans`)).json.bans[0].until;
|
||||
expect(Date.parse(until) - Date.now()).toBeGreaterThan(59 * 60_000);
|
||||
expect((await boot(o, 604)).ok).toBe(true);
|
||||
await waitFor(() => m2.last("room.closed", n)?.reason === "booted");
|
||||
|
||||
// Logged.
|
||||
const log = await db.select().from(schema.audit).where(eq(schema.audit.target, String(n)));
|
||||
const actions = log.map((e) => e.action);
|
||||
expect(actions.filter((a) => a === "hosted_room.boot").length).toBe(2);
|
||||
expect(actions.filter((a) => a === "hosted_room.ban").length).toBe(2);
|
||||
expect(actions).toContain("hosted_room.unban");
|
||||
expect(log.find((e) => e.action === "hosted_room.ban")!.detail).toEqual({ imvu: 603, by: 602, minutes: 0 });
|
||||
for (const p of [o, m, u, m2]) p.ws.close();
|
||||
});
|
||||
|
||||
test("private rooms and invitations", async () => {
|
||||
const pam = await person("pamm", { id: 701 });
|
||||
const quin = await person("quin", { id: 702 });
|
||||
const rex = await person("rexx", { id: 703 }, { id: 705 });
|
||||
const n = (await call(pam, "POST", "/api/v1/hosted-rooms", newRoom(701, { name: "Hideout", privacy: "private" }))).json.room.n;
|
||||
expect((await call(pam, "GET", `/api/v1/hosted-rooms/${n}`)).json.room.privacy).toBe("private");
|
||||
await call(pam, "PATCH", `/api/v1/hosted-rooms/${n}`, { moderators: [702] });
|
||||
const pub = (await call(rex, "POST", "/api/v1/hosted-rooms", newRoom(703, { name: "Open" }))).json.room.n;
|
||||
|
||||
// Listed only for the owner and moderators.
|
||||
const listed = async (p?: Person, q = "") =>
|
||||
((await call(p, "GET", `/api/v1/hosted-rooms${q}`)).json.rooms as any[]).some((r) => r.n === n);
|
||||
expect(await listed()).toBe(false);
|
||||
expect(await listed(rex)).toBe(false);
|
||||
expect(await listed(pam)).toBe(true);
|
||||
expect(await listed(quin)).toBe(true);
|
||||
expect(await listed(pam, "?mine=1")).toBe(true);
|
||||
expect((await call(rex, "GET", `/api/v1/hosted-rooms/${n}`)).status).toBe(200); // not listed, but there
|
||||
|
||||
const p = await proxy(pam, 701);
|
||||
const q = await proxy(quin, 702);
|
||||
const r = await proxy(rex, 703);
|
||||
expect((await r.join(n)).reason).toBe("private");
|
||||
expect((await q.join(n)).type).toBe("room.joined");
|
||||
const invite = (from: typeof p, room: number, imvu_id: number) =>
|
||||
from.ask({ type: "room.invite", room, imvu_id } as any, (x) => x.type === "room.invite_result" && x.room === room && x.imvu_id === imvu_id);
|
||||
const joinWith = (x: typeof p, invite: string) =>
|
||||
x.ask({ type: "room.join", room: n, invite } as any, (m) => m.room === n && (m.type === "room.joined" || m.type === "room.error"));
|
||||
|
||||
// Only the owner and moderators invite to a private room (anyone in it, to a public one).
|
||||
expect(await invite(r, n, 702)).toMatchObject({ ok: false, reason: "forbidden" });
|
||||
expect(await invite(p, pub, 702)).toMatchObject({ ok: false, reason: "not_in_room" });
|
||||
|
||||
// Invited: the invitee's sessions are told; it answers; the inviter hears.
|
||||
const sent = await invite(p, n, 703);
|
||||
expect(sent.ok).toBe(true);
|
||||
const id: string = sent.invite;
|
||||
await waitFor(() => r.last("room.invited", n) !== undefined);
|
||||
expect(r.last("room.invited", n)).toEqual({
|
||||
type: "room.invited",
|
||||
room: n,
|
||||
invite: id,
|
||||
from: 701,
|
||||
room_name: "Hideout",
|
||||
owner_name: "pammAv",
|
||||
});
|
||||
expect((await joinWith(r, id)).reason).toBe("private"); // not accepted yet
|
||||
q.send({ type: "room.invite_reply", invite: id, accept: true }); // not Quin's to answer
|
||||
await waitFor(() => q.got.some((x) => x.message === "no such invite"));
|
||||
r.send({ type: "room.invite_reply", invite: id, accept: true, note: " on my way\n" });
|
||||
await waitFor(() => p.last("room.invite_answer") !== undefined);
|
||||
expect(p.last("room.invite_answer")).toEqual({ type: "room.invite_answer", invite: id, to: 703, accept: true, note: "on my way" });
|
||||
r.send({ type: "room.invite_reply", invite: id, accept: false });
|
||||
await waitFor(() => r.got.some((x) => x.message === "already answered"));
|
||||
|
||||
// An accepted invite lets the account in once; reconnecting is fine, leaving ends it.
|
||||
expect((await joinWith(r, id)).type).toBe("room.joined");
|
||||
r.ws.close();
|
||||
const r2 = await proxy(rex, 703);
|
||||
expect((await r2.join(n)).type).toBe("room.joined");
|
||||
r2.send({ type: "room.leave", room: n });
|
||||
await waitFor(() => q.last("room.participants", n)?.participants.length === 1);
|
||||
expect((await joinWith(r2, id)).reason).toBe("private");
|
||||
|
||||
// Declined.
|
||||
const second = (await invite(q, n, 703)).invite;
|
||||
await waitFor(() => r2.got.some((x) => x.invite === second));
|
||||
r2.send({ type: "room.invite_reply", invite: second, accept: false });
|
||||
await waitFor(() => q.got.some((x) => x.type === "room.invite_answer" && x.invite === second));
|
||||
expect(q.got.find((x) => x.type === "room.invite_answer" && x.invite === second).accept).toBe(false);
|
||||
expect((await joinWith(r2, second)).reason).toBe("private");
|
||||
|
||||
// Nobody online as that account; banned accounts aren't invited.
|
||||
const off = await invite(p, n, 705);
|
||||
expect(off.ok).toBe(true);
|
||||
await waitFor(() => p.got.some((x) => x.type === "room.invite_answer" && x.invite === off.invite));
|
||||
expect(p.got.find((x) => x.type === "room.invite_answer" && x.invite === off.invite)).toEqual({
|
||||
type: "room.invite_answer",
|
||||
invite: off.invite,
|
||||
to: 705,
|
||||
accept: false,
|
||||
offline: true,
|
||||
});
|
||||
await p.ask({ type: "room.boot", room: n, imvu_id: 703, ban: true } as any, (x) => x.type === "room.boot_result");
|
||||
expect(await invite(p, n, 703)).toMatchObject({ ok: false, reason: "banned" });
|
||||
|
||||
// Public rooms: anyone in it invites.
|
||||
expect((await r2.join(pub)).type).toBe("room.joined");
|
||||
expect((await invite(r2, pub, 701)).ok).toBe(true);
|
||||
await waitFor(() => p.last("room.invited", pub) !== undefined);
|
||||
for (const x of [p, q, r2]) x.ws.close();
|
||||
});
|
||||
|
||||
const png = (bytes = 64) => {
|
||||
const b = new Uint8Array(bytes);
|
||||
b.set([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
|
||||
return b;
|
||||
};
|
||||
|
||||
async function upload(p: Person, n: number, data: Uint8Array, type = "image/png") {
|
||||
const form = new FormData();
|
||||
form.set("image", new File([data as Uint8Array<ArrayBuffer>], "room.png", { type }));
|
||||
const res = await fetch(`${base}/api/v1/hosted-rooms/${n}/image`, {
|
||||
method: "PUT",
|
||||
headers: { Origin: webURL, Cookie: p.cookie },
|
||||
body: form,
|
||||
});
|
||||
const text = await res.text();
|
||||
return { status: res.status, json: text ? JSON.parse(text) : undefined };
|
||||
}
|
||||
|
||||
test("room images", async () => {
|
||||
const ivy = await person("ivyy", { id: 801 });
|
||||
const jon = await person("jonn", { id: 802 });
|
||||
const n = (await call(ivy, "POST", "/api/v1/hosted-rooms", newRoom(801))).json.room.n;
|
||||
expect((await call(jon, "GET", `/api/v1/hosted-rooms/${n}`)).json.room.image).toBeNull();
|
||||
expect((await fetch(`${base}/api/v1/hosted-rooms/${n}/image`)).status).toBe(404);
|
||||
|
||||
expect((await upload(jon, n, png())).status).toBe(403);
|
||||
const gif = new TextEncoder().encode("GIF89a................................");
|
||||
expect((await upload(ivy, n, gif)).json.error).toBe("invalid");
|
||||
const svg = new TextEncoder().encode('<svg xmlns="http://www.w3.org/2000/svg"/>');
|
||||
expect((await upload(ivy, n, svg)).status).toBe(400);
|
||||
expect((await upload(ivy, n, png(512 * 1024 + 1))).status).toBe(400);
|
||||
const up = await upload(ivy, n, png());
|
||||
expect(up.status).toBe(200);
|
||||
expect(up.json.room.image).toMatch(new RegExp(`^http://localhost:4101/api/v1/hosted-rooms/${n}/image\\?v=[0-9a-f]{16}$`));
|
||||
|
||||
const got = await fetch(`${base}/api/v1/hosted-rooms/${n}/image`);
|
||||
expect(got.status).toBe(200);
|
||||
expect(got.headers.get("content-type")).toBe("image/png");
|
||||
expect(got.headers.get("cache-control")).toBe("public, max-age=3600");
|
||||
expect(got.headers.get("x-content-type-options")).toBe("nosniff");
|
||||
expect(new Uint8Array(await got.arrayBuffer())).toEqual(png());
|
||||
const etag = got.headers.get("etag")!;
|
||||
expect((await fetch(`${base}/api/v1/hosted-rooms/${n}/image`, { headers: { "If-None-Match": etag } })).status).toBe(304);
|
||||
|
||||
const jpeg = new Uint8Array([0xff, 0xd8, 0xff, 0xe0, 0, 16, 0x4a, 0x46]);
|
||||
expect((await upload(ivy, n, jpeg)).status).toBe(200);
|
||||
const again = await fetch(`${base}/api/v1/hosted-rooms/${n}/image`);
|
||||
expect(again.headers.get("content-type")).toBe("image/jpeg");
|
||||
expect(again.headers.get("etag")).not.toBe(etag);
|
||||
|
||||
expect((await call(jon, "DELETE", `/api/v1/hosted-rooms/${n}/image`)).status).toBe(403);
|
||||
expect((await call(ivy, "DELETE", `/api/v1/hosted-rooms/${n}/image`)).json.room.image).toBeNull();
|
||||
expect((await fetch(`${base}/api/v1/hosted-rooms/${n}/image`)).status).toBe(404);
|
||||
});
|
||||
|
||||
test("favorites, ratings and recent rooms", async () => {
|
||||
const kim = await person("kimm", { id: 901 });
|
||||
const lou = await person("louu", { id: 902 });
|
||||
const mia = await person("miaa", { id: 903 });
|
||||
const ns: number[] = [];
|
||||
for (const name of ["One", "Two", "Three"])
|
||||
ns.push((await call(kim, "POST", "/api/v1/hosted-rooms", newRoom(901, { name }))).json.room.n);
|
||||
const [a, b, c] = ns as [number, number, number];
|
||||
|
||||
// Favorites.
|
||||
expect((await call(undefined, "GET", "/api/v1/hosted-rooms?favorites=1")).status).toBe(401);
|
||||
expect((await call(undefined, "PUT", `/api/v1/hosted-rooms/${a}/favorite`)).status).toBe(401);
|
||||
expect((await call(lou, "PUT", `/api/v1/hosted-rooms/${a}/favorite`)).status).toBe(204);
|
||||
expect((await call(lou, "PUT", `/api/v1/hosted-rooms/${c}/favorite`)).status).toBe(204);
|
||||
expect((await call(lou, "PUT", `/api/v1/hosted-rooms/${c}/favorite`)).status).toBe(204);
|
||||
expect((await call(lou, "PUT", "/api/v1/hosted-rooms/999999/favorite")).status).toBe(404);
|
||||
const favs = async (p: Person) => ((await call(p, "GET", "/api/v1/hosted-rooms?favorites=1")).json.rooms as any[]).map((r) => r.n);
|
||||
expect(await favs(lou)).toEqual([c, a]);
|
||||
expect(await favs(mia)).toEqual([]);
|
||||
const flag = async (p: Person | undefined, n: number) =>
|
||||
((await call(p, "GET", "/api/v1/hosted-rooms")).json.rooms as any[]).find((r) => r.n === n).favorite;
|
||||
expect(await flag(lou, a)).toBe(true);
|
||||
expect(await flag(mia, a)).toBe(false);
|
||||
expect(await flag(undefined, a)).toBe(false);
|
||||
expect((await call(lou, "GET", `/api/v1/hosted-rooms/${a}`)).json.room.favorite).toBe(true);
|
||||
expect((await call(lou, "DELETE", `/api/v1/hosted-rooms/${a}/favorite`)).status).toBe(204);
|
||||
expect(await favs(lou)).toEqual([c]);
|
||||
|
||||
// Ratings.
|
||||
expect((await call(lou, "PUT", `/api/v1/hosted-rooms/${a}/rating`, { score: 6 })).status).toBe(400);
|
||||
expect((await call(lou, "PUT", `/api/v1/hosted-rooms/${a}/rating`, { score: 0 })).status).toBe(400);
|
||||
expect((await call(kim, "PUT", `/api/v1/hosted-rooms/${a}/rating`, { score: 5 })).status).toBe(403); // own room
|
||||
expect((await call(lou, "PUT", `/api/v1/hosted-rooms/${a}/rating`, { score: 3 })).json.myRating).toBe(3);
|
||||
expect((await call(lou, "PUT", `/api/v1/hosted-rooms/${a}/rating`, { score: 4 })).json.room).toMatchObject({ score: 4, ratings: 1 });
|
||||
await call(mia, "PUT", `/api/v1/hosted-rooms/${a}/rating`, { score: 5 });
|
||||
const one = await call(lou, "GET", `/api/v1/hosted-rooms/${a}`);
|
||||
expect(one.json.room).toMatchObject({ score: 4.5, ratings: 2 });
|
||||
expect(one.json.myRating).toBe(4);
|
||||
expect((await call(undefined, "GET", `/api/v1/hosted-rooms/${a}`)).json.myRating).toBe(0);
|
||||
expect((await call(undefined, "GET", `/api/v1/hosted-rooms/${b}`)).json.room).toMatchObject({ score: 0, ratings: 0 });
|
||||
expect((await call(mia, "DELETE", `/api/v1/hosted-rooms/${a}/rating`)).status).toBe(204);
|
||||
expect((await call(undefined, "GET", `/api/v1/hosted-rooms/${a}`)).json.room).toMatchObject({ score: 4, ratings: 1 });
|
||||
|
||||
// Recent rooms: joined, latest first, each once.
|
||||
expect((await call(undefined, "GET", "/api/v1/hosted-rooms?recent=1")).status).toBe(401);
|
||||
const l = await proxy(lou, 902);
|
||||
const recent = async () => ((await call(lou, "GET", "/api/v1/hosted-rooms?recent=1")).json.rooms as any[]).map((r) => r.n);
|
||||
for (const n of [a, b, c]) {
|
||||
await l.join(n);
|
||||
await Bun.sleep(20);
|
||||
}
|
||||
for (let i = 0; i < 100 && (await recent()).length < 3; i++) await Bun.sleep(10);
|
||||
expect(await recent()).toEqual([c, b, a]);
|
||||
l.send({ type: "room.leave", room: a });
|
||||
await Bun.sleep(30);
|
||||
await l.join(a);
|
||||
for (let i = 0; i < 100 && (await recent())[0] !== a; i++) await Bun.sleep(10);
|
||||
expect(await recent()).toEqual([a, c, b]);
|
||||
const visit = await db.select().from(schema.hostedRoomVisit).where(eq(schema.hostedRoomVisit.n, a));
|
||||
expect(visit.map((v) => v.imvuId)).toEqual([902]);
|
||||
l.ws.close();
|
||||
});
|
||||
|
||||
test("watching rooms' occupancy", async () => {
|
||||
const ned = await person("nedd", { id: 1001 });
|
||||
const ola = await person("olaa", { id: 1002 });
|
||||
const a = (await call(ned, "POST", "/api/v1/hosted-rooms", newRoom(1001, { name: "A" }))).json.room.n;
|
||||
const b = (await call(ned, "POST", "/api/v1/hosted-rooms", newRoom(1001, { name: "B" }))).json.room.n;
|
||||
const hidden = (await call(ned, "POST", "/api/v1/hosted-rooms", newRoom(1001, { name: "C", privacy: "private" }))).json.room.n;
|
||||
|
||||
const w = await proxy(ola, 1002);
|
||||
const n = await proxy(ned, 1001);
|
||||
w.send({ type: "room.watch", rooms: Array.from({ length: 101 }, (_, i) => i + 1) });
|
||||
await waitFor(() => w.got.some((x) => x.message === "bad rooms"));
|
||||
w.send({ type: "room.watch", rooms: [a, b, hidden, 999_999] });
|
||||
await waitFor(() => w.last("room.occupancy", a) !== undefined && w.last("room.occupancy", b) !== undefined);
|
||||
expect(w.last("room.occupancy", a)).toEqual({ type: "room.occupancy", room: a, participants: [] });
|
||||
// The private room's owner may watch it.
|
||||
n.send({ type: "room.watch", rooms: [hidden] });
|
||||
await waitFor(() => n.last("room.occupancy", hidden) !== undefined);
|
||||
|
||||
await n.join(a);
|
||||
await waitFor(() => w.last("room.occupancy", a)?.participants.length === 1);
|
||||
expect(w.last("room.occupancy", a).participants).toEqual([{ imvu_id: 1001, seat: 0, name: "neddAv" }]);
|
||||
n.send({ type: "room.seat", room: a, seat: 2 });
|
||||
await waitFor(() => w.last("room.occupancy", a)?.participants[0]?.seat === 2);
|
||||
n.send({ type: "room.leave", room: a });
|
||||
await waitFor(() => w.last("room.occupancy", a)?.participants.length === 0);
|
||||
await n.join(hidden);
|
||||
await Bun.sleep(50);
|
||||
expect(w.last("room.occupancy", hidden)).toBeUndefined();
|
||||
expect(n.last("room.occupancy", hidden)?.participants).toEqual([{ imvu_id: 1001, seat: 0, name: "neddAv" }]);
|
||||
|
||||
// Replaced; closing tells watchers it's empty.
|
||||
w.send({ type: "room.watch", rooms: [b] });
|
||||
await Bun.sleep(50);
|
||||
const before = w.got.filter((x) => x.type === "room.occupancy" && x.room === a).length;
|
||||
await n.join(a);
|
||||
await n.join(b);
|
||||
await waitFor(() => w.last("room.occupancy", b)?.participants.length === 1);
|
||||
expect(w.got.filter((x) => x.type === "room.occupancy" && x.room === a).length).toBe(before);
|
||||
await call(ned, "DELETE", `/api/v1/hosted-rooms/${b}`);
|
||||
await waitFor(() => w.last("room.occupancy", b)?.participants.length === 0);
|
||||
w.ws.close();
|
||||
n.ws.close();
|
||||
});
|
||||
Reference in new issue
Block a user