Products: carry the IMVU parent's image and categories
CI / test (push) Successful in 49s

Revisions with an IMVU parent store its public info (image, category path, gender)
from IMVU's product API, looked up after registering and in a periodic pass.
Product views gain parent, image and categories; GET /products takes category and
limit. The website shows the image on cards and product pages.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
dhkatzandClaude Opus 5.5 committed 2026-10-30 01:03:02 -07:00
1 parent d57674d90c
commit 6668b55117
15 files changed
+2026 -29

No files matched your search

+101 -2
View File
@@ -4,13 +4,23 @@ import { createLocalJWKSet, jwtVerify } from "jose";
import { openDatabase, type Database } from "@t5de/db";
import { createApp } from "../src/app";
import { createAuth } from "../src/auth";
import { ImvuClient, type ImvuUser } from "../src/imvu";
import { ImvuClient, imvuImage, parseProduct, type ImvuUser } from "../src/imvu";
const webURL = "http://web.test";
const apiURL = "http://api.test";
const accounts = new Map<number, ImvuUser>();
// IMVU products by ID, as IMVU's API answers them ("down": IMVU can't be reached for it).
const imvuProducts = new Map<number, Record<string, unknown> | "down">();
const fakeIMVU: typeof fetch = (async (input: RequestInfo | URL) => {
const url = new URL(String(input));
const pm = url.pathname.match(/^\/product\/product-(\d+)$/);
if (pm) {
const pid = Number(pm[1]);
const data = imvuProducts.get(pid);
if (data === "down") throw new Error("connection refused");
if (!data) return Response.json({ status: "failure", error: "NODE-001" }, { status: 404 });
return Response.json({ status: "success", denormalized: { [`https://api.imvu.com/product/product-${pid}`]: { data } } });
}
const u = [...accounts.values()].find(
(u) => url.pathname === `/user/user-${u.id}` || url.searchParams.get("username")?.toLowerCase() === u.username.toLowerCase(),
);
@@ -25,11 +35,12 @@ let db: Database;
let close: () => Promise<void>;
let base: string;
let stop: () => void;
let hub: ReturnType<typeof createApp>;
beforeAll(async () => {
({ db, close } = await openDatabase("memory"));
const auth = createAuth(db, { apiURL, webURL, secret: "test-secret-test-secret-test-secret-1234", rateLimit: false });
const hub = createApp({ db, auth, imvu: new ImvuClient("http://imvu.test", fakeIMVU), apiURL });
hub = createApp({ db, auth, imvu: new ImvuClient("http://imvu.test", fakeIMVU), apiURL });
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();
@@ -74,6 +85,9 @@ async function person(name: string, imvu?: { id: number; ageVerified: boolean })
return p;
}
// Set by the first test for the next: the admin and an approved catalog.
let shared: { admin: Person; catalogID: string; auth: Record<string, string> };
const file = (name: string, n: number) => ({ name, sha256: n.toString(16).padStart(64, "0"), size: 100 * n });
test("catalogs register uploads; products have revisions, info and libraries", async () => {
@@ -92,6 +106,7 @@ test("catalogs register uploads; products have revisions, info and libraries", a
expect(reg.status).toBe(201);
const catalogID = reg.json.catalog.id as string;
const auth = { Authorization: `Catalog ${catalogID}:${reg.json.key}` };
shared = { admin, catalogID, auth };
expect(reg.json.catalog.url).toBe("http://localhost:4102");
expect((await call(undefined, "GET", "/api/v1/catalogs")).json.catalogs).toHaveLength(0);
expect((await call(maker, "POST", "/api/v1/products/upload", { catalog: catalogID })).status).toBe(404);
@@ -171,3 +186,87 @@ test("catalogs register uploads; products have revisions, info and libraries", a
expect((await call(kid, "DELETE", `/api/v1/library/${id}`)).status).toBe(204);
expect((await call(kid, "GET", "/api/v1/library")).json.library).toEqual([]);
});
test("IMVU product info: parsing and image addresses", () => {
expect(imvuImage("https://userimages-akm.imvu.com/productdata/a.png")).toBe("https://userimages-akm.imvu.com/productdata/a.png");
expect(imvuImage("//userimages-akm.imvu.com/a.png")).toBe("https://userimages-akm.imvu.com/a.png");
for (const bad of ["http://userimages-akm.imvu.com/a.png", "https://imvu.com.evil.test/a.png", "https://evilimvu.com/a.png", "https://[email protected]/a.png", "javascript:alert(1)", 5, null])
expect(imvuImage(bad)).toBeNull();
const answer = {
denormalized: {
"https://api.imvu.com/product/product-9": {
data: { product_image: "https://x.imvu.com/i.png", gender: "female", category_path: [{ id: 106, name: "Clothing" }, { id: "x", name: "Bad" }, { id: 716, name: " Coats & Jackets " }] },
},
},
};
expect(parseProduct(answer, 9)).toEqual({
image: "https://x.imvu.com/i.png",
categories: [{ id: 106, name: "Clothing" }, { id: 716, name: "Coats & Jackets" }],
gender: "female",
});
expect(parseProduct(answer, 10)).toBeNull();
});
test("derived products carry their IMVU parent's image and categories", async () => {
const { catalogID, auth } = shared;
const dresser = await person("dresser", { id: 601, ageVerified: false });
const coat = {
product_id: 7001,
product_name: "Coat",
rating: "GA",
product_image: "https://userimages-akm.imvu.com/productdata/coat.png",
gender: "female",
category_path: [
{ id: 106, name: "Clothing" },
{ id: 40, name: "Clothing for Female" },
{ id: 128, name: "Tops" },
{ id: 716, name: "Coats & Jackets" },
],
};
imvuProducts.set(7001, coat);
imvuProducts.set(7002, { ...coat, product_image: "https://elsewhere.test/hat.png", category_path: [{ id: 75, name: "Hair" }] });
imvuProducts.set(7003, "down");
const upload = async (name: string, parent: number) => {
const token = (await call(dresser, "POST", "/api/v1/products/upload", { catalog: catalogID })).json.token;
const r = await call(undefined, "POST", "/api/v1/catalog/revisions", { uploadToken: token, files: [file("index.xml", parent || 9)], rating: "general", parent, name, notes: "" }, auth);
expect(r.status).toBe(201);
return r.json.product as string;
};
const coatID = await upload("Dresser Coat", 7001);
const hatID = await upload("Dresser Hat", 7002);
const downID = await upload("Dresser Down", 7003);
const plainID = await upload("Dresser Plain", 0);
const missingID = await upload("Dresser Missing", 7999);
const page = async (id: string) => (await call(undefined, "GET", `/api/v1/products/${id}`)).json.product;
// Looked up in the background after registering.
for (let i = 0; i < 100 && (await page(coatID)).image === null; i++) await Bun.sleep(20);
expect(await page(coatID)).toMatchObject({ parent: 7001, image: coat.product_image, categories: coat.category_path });
for (let i = 0; i < 100 && (await page(hatID)).categories.length === 0; i++) await Bun.sleep(20);
expect(await page(hatID)).toMatchObject({ parent: 7002, image: null, categories: [{ id: 75, name: "Hair" }] });
expect(await page(plainID)).toMatchObject({ parent: 0, image: null, categories: [] });
expect(await page(downID)).toMatchObject({ parent: 7003, image: null, categories: [] });
// Lists carry the same fields; category filters by any ID in the path.
const list = async (qs: string) => (await call(undefined, "GET", `/api/v1/products?creator=dresser${qs}`)).json.products;
const names = async (qs: string) => (await list(qs)).map((p: any) => p.name).sort();
expect((await list("")).find((p: any) => p.id === coatID)).toMatchObject({ parent: 7001, image: coat.product_image, categories: coat.category_path });
expect(await names("&category=128")).toEqual(["Dresser Coat"]);
expect(await names("&category=106")).toEqual(["Dresser Coat"]);
expect(await names("&category=75")).toEqual(["Dresser Hat"]);
expect(await names("&category=1")).toEqual([]);
expect(await names("&limit=2")).toHaveLength(2);
expect((await call(undefined, "GET", "/api/v1/products?limit=201")).status).toBe(400);
expect((await call(undefined, "GET", "/api/v1/products?category=x")).status).toBe(400);
// IMVU was down for one: the periodic pass fills it in once IMVU answers; one IMVU doesn't have stays empty.
imvuProducts.set(7003, { ...coat, category_path: [{ id: 3114, name: "Body" }] });
await hub.products.fillParentInfo(50);
expect(await page(downID)).toMatchObject({ image: coat.product_image, categories: [{ id: 3114, name: "Body" }] });
expect(await page(missingID)).toMatchObject({ parent: 7999, image: null, categories: [] });
expect(await hub.products.fillParentInfo(50)).toBe(0);
// The library shows them too.
expect((await call(dresser, "PUT", `/api/v1/library/${coatID}`)).status).toBe(200);
expect((await call(dresser, "GET", "/api/v1/library")).json.library[0].product).toMatchObject({ image: coat.product_image });
});