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:
1 parent
d57674d90c
commit
6668b55117
15 files changed
+2026
-29
No files matched your search
@@ -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 });
|
||||
});
|
||||
Reference in new issue
Block a user