feat: choose a product's category from the ones T5DE has seen
Pull request / title (pull_request) Successful in 1s
CI / test (pull_request) Successful in 1m8s
CI / image (apps/api/Dockerfile, t5de-hub-api) (pull_request) Skipped
CI / image (apps/web/Dockerfile, t5de-hub-web) (pull_request) Skipped

A product can be filed under another IMVU category than its parent's: any category path the hub has seen on a
revision's parent (and each step of it), within the parent's top-level category, so it stays the same kind of
product (the proxy tells clothing, furniture and rooms apart by category). It's an info field like the others:
lockable, in the history, undone by the same rules. Lists filter by the chosen path, and products show it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
dhkatzandClaude Opus 5.5 committed 2026-11-05 06:00:04 -08:00
1 parent 8793b5afd8
commit ceceb69eaf
12 files changed
+165 -22

No files matched your search

+22
View File
@@ -280,6 +280,28 @@ test("derived products carry their IMVU parent's image and categories", async ()
expect(await page(missingID)).toMatchObject({ parent: 7999, image: null, categories: [] });
expect(await hub.products.fillParentInfo(50)).toBe(0);
// A product can be filed under another category the hub has seen, in its parent's top-level category: a jacket
// made from a plain top goes under Coats & Jackets, never under Hair or Body (another kind of product).
imvuProducts.set(7004, { ...coat, category_path: coat.category_path.slice(0, 3) });
const jacketID = await upload("Dresser Jacket", 7004);
for (let i = 0; i < 100 && (await page(jacketID)).categories.length === 0; i++) await Bun.sleep(20);
const { choices, parent } = (await call(dresser, "GET", `/api/v1/products/${jacketID}/categories`)).json;
expect(parent.map((c: any) => c.id)).toEqual([106, 40, 128]);
expect(choices.map((c: any) => c.at(-1).id).sort()).toEqual([106, 128, 40, 716]);
expect((await call(undefined, "GET", `/api/v1/products/${jacketID}/categories`)).status).toBe(401);
expect((await call(dresser, "PATCH", `/api/v1/products/${jacketID}`, { category: 75 })).json.error).toBe("invalid");
const filed = await call(dresser, "PATCH", `/api/v1/products/${jacketID}`, { category: 716 });
expect(filed.json.fields).toEqual(["category"]);
expect(filed.json.product.categories).toEqual(coat.category_path);
expect(filed.json.product.category).toEqual(coat.category_path);
expect(await names("&category=716")).toEqual(["Dresser Coat", "Dresser Jacket"]);
// Back to its parent's: the history keeps paths, and undoing puts the chosen one back.
expect((await call(dresser, "PATCH", `/api/v1/products/${jacketID}`, { category: null })).json.product.categories).toEqual(parent);
expect(await names("&category=716")).toEqual(["Dresser Coat"]);
const [cleared] = (await call(dresser, "GET", `/api/v1/products/${jacketID}/history`)).json.entries;
expect(cleared.changes.category).toEqual({ from: coat.category_path, to: null });
expect((await call(dresser, "POST", `/api/v1/products/${jacketID}/history/${cleared.id}/revert`, {})).json.product.categories).toEqual(coat.category_path);
// 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 });