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
+21
-8
@@ -2,7 +2,7 @@
|
||||
// origin (Next.js rewrites /api to here), so session cookies are first-party; T5DE's proxy calls it with a bearer
|
||||
// token.
|
||||
import { Elysia, t } from "elysia";
|
||||
import { and, desc, eq, ilike, inArray, ne, or, sql, count, schema, type Database } from "@t5de/db";
|
||||
import { and, desc, eq, ilike, inArray, ne, or, sql, count, schema, type Database, type ParentInfo } from "@t5de/db";
|
||||
import type { Auth } from "./auth";
|
||||
import { AgeError, AgeService } from "./age";
|
||||
import { imvuAgeProvider, imvuResult } from "./age/imvu";
|
||||
@@ -76,7 +76,7 @@ class Limiter {
|
||||
export function createApp({ db, auth, imvu, realtime: rt, age: ageService, rooms: roomsService, apiURL }: Deps) {
|
||||
const age = ageService ?? new AgeService(db, [imvuAgeProvider(imvu)]);
|
||||
const signer = new Signer(db, apiURL ?? "http://localhost:4101");
|
||||
const products = new Products(db, signer);
|
||||
const products = new Products(db, signer, imvu);
|
||||
const rooms = roomsService ?? new Rooms(db, imvu);
|
||||
const realtime =
|
||||
rt ??
|
||||
@@ -178,10 +178,12 @@ export function createApp({ db, auth, imvu, realtime: rt, age: ageService, rooms
|
||||
}
|
||||
};
|
||||
|
||||
/** `latest`: the latest revision's parent and its info (the image and categories are the parent's). */
|
||||
const productView = (
|
||||
p: typeof schema.product.$inferSelect,
|
||||
creator?: string | null,
|
||||
creatorUsername?: string | null,
|
||||
latest?: { parent: number | null; parentInfo: ParentInfo | null } | null,
|
||||
) => ({
|
||||
id: p.id,
|
||||
name: p.name,
|
||||
@@ -190,6 +192,9 @@ export function createApp({ db, auth, imvu, realtime: rt, age: ageService, rooms
|
||||
latest: p.latest,
|
||||
creator: creator ?? creatorUsername ?? "",
|
||||
creatorUsername: creatorUsername ?? "",
|
||||
parent: latest?.parent ?? 0,
|
||||
image: latest?.parentInfo?.image ?? null,
|
||||
categories: latest?.parentInfo?.categories ?? [],
|
||||
createdAt: p.createdAt.toISOString(),
|
||||
updatedAt: p.updatedAt.toISOString(),
|
||||
});
|
||||
@@ -616,10 +621,17 @@ export function createApp({ db, auth, imvu, realtime: rt, age: ageService, rooms
|
||||
"/products",
|
||||
async ({ person, query }) => {
|
||||
const adult = await personAdult(person);
|
||||
const rows = await products.list({ q: query.q, creator: query.creator, adult });
|
||||
return { products: rows.map((r) => productView(r.product, r.creator, r.creatorUsername)) };
|
||||
const rows = await products.list({ ...query, adult });
|
||||
return { products: rows.map((r) => productView(r.product, r.creator, r.creatorUsername, r)) };
|
||||
},
|
||||
{
|
||||
query: t.Object({
|
||||
q: t.Optional(t.String({ maxLength: 100 })),
|
||||
creator: t.Optional(t.String({ maxLength: 64 })),
|
||||
category: t.Optional(t.Numeric({ minimum: 1, maximum: 2_147_483_647, multipleOf: 1 })),
|
||||
limit: t.Optional(t.Numeric({ minimum: 1, maximum: 200, multipleOf: 1 })),
|
||||
}),
|
||||
},
|
||||
{ query: t.Object({ q: t.Optional(t.String({ maxLength: 100 })), creator: t.Optional(t.String({ maxLength: 64 })) }) },
|
||||
)
|
||||
.get(
|
||||
"/products/:id",
|
||||
@@ -627,8 +639,9 @@ export function createApp({ db, auth, imvu, realtime: rt, age: ageService, rooms
|
||||
const adult = await personAdult(person);
|
||||
const p = await productCall(() => products.get(params.id, adult));
|
||||
const lib = person ? await products.inLibrary(person.id, params.id) : undefined;
|
||||
const latest = p.revisions.find((r) => r.number === p.product.latest);
|
||||
return {
|
||||
product: productView(p.product, p.creator, p.creatorUsername),
|
||||
product: productView(p.product, p.creator, p.creatorUsername, latest),
|
||||
revisions: p.revisions.map((r) => ({
|
||||
number: r.number,
|
||||
rating: r.rating,
|
||||
@@ -651,7 +664,7 @@ export function createApp({ db, auth, imvu, realtime: rt, age: ageService, rooms
|
||||
async ({ me, params, body }) => {
|
||||
const p = await productCall(() => products.update(me.id, params.id, body));
|
||||
await audited(me.id, "product.edit", p.id, { fields: Object.keys(body) });
|
||||
return { product: productView(p) };
|
||||
return { product: productView(p, undefined, undefined, await products.latestParent(p)) };
|
||||
},
|
||||
{
|
||||
signedIn: true,
|
||||
@@ -704,7 +717,7 @@ export function createApp({ db, auth, imvu, realtime: rt, age: ageService, rooms
|
||||
"/library",
|
||||
async ({ me }) => ({
|
||||
library: (await products.library(me.id)).map((l) => ({
|
||||
product: productView(l.product, l.creator),
|
||||
product: productView(l.product, l.creator, undefined, l),
|
||||
stayOn: l.stayOn,
|
||||
addedAt: l.addedAt.toISOString(),
|
||||
})),
|
||||
|
||||
+56
-4
@@ -1,6 +1,6 @@
|
||||
// IMVU's public API: who an account is, its tagline (for linking) and whether it's age verified or has Access Pass;
|
||||
// and rooms (for the rooms list). The hub calls it rarely (linking, age checks, rooms T5DE users enter) and depends
|
||||
// on it for nothing else.
|
||||
// rooms (for the rooms list); and products (the IMVU products T5 products derive from). The hub calls it rarely
|
||||
// (linking, age checks, rooms T5DE users enter, uploads) and depends on it for nothing else.
|
||||
|
||||
export type ImvuUser = {
|
||||
id: number;
|
||||
@@ -25,6 +25,13 @@ export type ImvuRoom = {
|
||||
expired: boolean;
|
||||
};
|
||||
|
||||
/** An IMVU product's public info, as T5 products derived from it show it. */
|
||||
export type ImvuProduct = {
|
||||
image: string | null; // https, on *.imvu.com
|
||||
categories: { id: number; name: string }[]; // outermost first, e.g. Clothing / Clothing for Female / Tops
|
||||
gender: string; // "female", "male", or "" (either)
|
||||
};
|
||||
|
||||
export class NoSuchImvuUser extends Error {
|
||||
constructor() {
|
||||
super("no such IMVU account");
|
||||
@@ -65,6 +72,20 @@ export class ImvuClient {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* An IMVU product's public info; null if IMVU shows no such product publicly (it answers 401 for some, such as
|
||||
* Access Pass ones). Throws ImvuUnavailable if IMVU can't be reached.
|
||||
*/
|
||||
async product(id: number): Promise<ImvuProduct | null> {
|
||||
if (!Number.isSafeInteger(id) || id <= 0) return null;
|
||||
try {
|
||||
return parseProduct(await this.get(`/product/product-${id}`, [401, 403]), id);
|
||||
} catch (e) {
|
||||
if (e instanceof NoSuchImvuUser) return null;
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
private async wait() {
|
||||
const now = Date.now();
|
||||
const at = Math.max(now, this.next);
|
||||
@@ -72,7 +93,8 @@ export class ImvuClient {
|
||||
if (at > now) await Bun.sleep(at - now);
|
||||
}
|
||||
|
||||
private async get(path: string): Promise<unknown> {
|
||||
/** GETs an IMVU API path; 404 (and the statuses in `missing`) throw NoSuchImvuUser. */
|
||||
private async get(path: string, missing: number[] = []): Promise<unknown> {
|
||||
await this.wait();
|
||||
let response: Response;
|
||||
try {
|
||||
@@ -83,7 +105,7 @@ export class ImvuClient {
|
||||
} catch (e) {
|
||||
throw new ImvuUnavailable(String(e));
|
||||
}
|
||||
if (response.status === 404) throw new NoSuchImvuUser();
|
||||
if (response.status === 404 || missing.includes(response.status)) throw new NoSuchImvuUser();
|
||||
if (!response.ok) throw new ImvuUnavailable(`IMVU answered ${response.status}`);
|
||||
return response.json();
|
||||
}
|
||||
@@ -115,6 +137,36 @@ export function parseRoom(answer: unknown, instanceId: string): ImvuRoom | undef
|
||||
};
|
||||
}
|
||||
|
||||
/** Whether an image address is one of IMVU's (https, on imvu.com or a subdomain). */
|
||||
export function imvuImage(v: unknown): string | null {
|
||||
if (typeof v !== "string" || v.length > 1000) return null;
|
||||
let url: URL;
|
||||
try {
|
||||
url = new URL(v.startsWith("//") ? "https:" + v : v);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (url.protocol !== "https:" || url.username || url.password || url.port) return null;
|
||||
if (url.hostname !== "imvu.com" && !url.hostname.endsWith(".imvu.com")) return null;
|
||||
return url.href;
|
||||
}
|
||||
|
||||
export function parseProduct(answer: unknown, id: number): ImvuProduct | null {
|
||||
const den = (answer as { denormalized?: Record<string, { data?: Record<string, unknown> }> })?.denormalized ?? {};
|
||||
const entry = Object.entries(den).find(([key]) => key.endsWith(`/product/product-${id}`));
|
||||
const d = entry?.[1].data;
|
||||
if (!d) return null;
|
||||
const path = Array.isArray(d.category_path) ? d.category_path : [];
|
||||
const categories = path.slice(0, 10).flatMap((c: unknown) => {
|
||||
const { id, name } = (c ?? {}) as { id?: unknown; name?: unknown };
|
||||
const n = Number(id);
|
||||
if (!Number.isSafeInteger(n) || n <= 0 || typeof name !== "string" || !name.trim()) return [];
|
||||
return [{ id: n, name: name.trim().slice(0, 100) }];
|
||||
});
|
||||
const gender = d.gender === "female" || d.gender === "male" ? d.gender : "";
|
||||
return { image: imvuImage(d.product_image), categories, gender };
|
||||
}
|
||||
|
||||
// IMVU's "denormalized" answers: the user is the one /user/user-<id> entry.
|
||||
export function parse(answer: unknown): ImvuUser {
|
||||
const den = (answer as { denormalized?: Record<string, { data?: Record<string, unknown> }> })?.denormalized ?? {};
|
||||
|
||||
@@ -22,12 +22,16 @@ const webURL = env.WEB_URL ?? "http://localhost:4100";
|
||||
|
||||
const { db } = await openDatabase(env.DATABASE_URL ?? "./data/db");
|
||||
const auth = createAuth(db, { apiURL, webURL, secret });
|
||||
const { app, age, rooms } = createApp({ db, auth, imvu: new ImvuClient(), apiURL });
|
||||
const { app, age, rooms, products } = createApp({ db, auth, imvu: new ImvuClient(), apiURL });
|
||||
app.listen({ port, hostname: env.HOST ?? "0.0.0.0" });
|
||||
|
||||
// Age answers that can change (IMVU's) are checked again when they expire, a few at a time.
|
||||
// Age answers that can change (IMVU's) are checked again when they expire, a few at a time; revisions missing their
|
||||
// parent's info (IMVU was unavailable, or they predate it) get it, starting now.
|
||||
const parentInfo = () => products.fillParentInfo(20).catch((e) => console.error("parent info:", e));
|
||||
parentInfo();
|
||||
setInterval(() => {
|
||||
age.recheckDue(50).catch((e) => console.error("age re-checks:", e));
|
||||
rooms.recheckDue(20).catch((e) => console.error("room re-checks:", e));
|
||||
parentInfo();
|
||||
}, 10 * 60 * 1000);
|
||||
console.log(`t5de-hub API on ${apiURL} (website ${webURL})`);
|
||||
+116
-7
@@ -1,7 +1,23 @@
|
||||
// The product registry, catalogs and libraries (docs/products.md). The routes are in app.ts; this is what they do.
|
||||
import { createHash, timingSafeEqual } from "node:crypto";
|
||||
import { and, desc, eq, ilike, inArray, ne, or, sql, schema, type Database, type FileEntry } from "@t5de/db";
|
||||
import {
|
||||
and,
|
||||
desc,
|
||||
eq,
|
||||
gt,
|
||||
ilike,
|
||||
inArray,
|
||||
isNull,
|
||||
ne,
|
||||
or,
|
||||
sql,
|
||||
schema,
|
||||
type Database,
|
||||
type FileEntry,
|
||||
type ParentInfo,
|
||||
} from "@t5de/db";
|
||||
import { cleanHTML, maxDescription } from "./html";
|
||||
import type { ImvuClient } from "./imvu";
|
||||
import type { Signer } from "./keys";
|
||||
|
||||
const { catalog, product, productRevision, hosting, library, user } = schema;
|
||||
@@ -55,10 +71,17 @@ export function checkFiles(files: unknown): FileEntry[] | undefined {
|
||||
const sameFiles = (a: FileEntry[], b: FileEntry[]) =>
|
||||
a.length === b.length && a.every((f, i) => f.name === b[i]!.name && f.sha256 === b[i]!.sha256 && f.size === b[i]!.size);
|
||||
|
||||
// A parent's info another revision looked up lately is reused rather than asking IMVU again.
|
||||
const parentInfoFresh = 7 * 24 * 60 * 60 * 1000;
|
||||
|
||||
/** Each product's latest revision (joined on product.latest, for lists). */
|
||||
const latestJoin = and(eq(productRevision.productId, product.id), eq(productRevision.number, product.latest));
|
||||
|
||||
export class Products {
|
||||
constructor(
|
||||
private readonly db: Database,
|
||||
private readonly signer: Signer,
|
||||
private readonly imvu?: ImvuClient,
|
||||
) {}
|
||||
|
||||
// ---- catalogs ----
|
||||
@@ -164,7 +187,7 @@ export class Products {
|
||||
const notes = (typeof body.notes === "string" ? body.notes.trim() : "").slice(0, 2000);
|
||||
const personId = claims.sub;
|
||||
|
||||
return this.db.transaction(async (tx) => {
|
||||
const made = await this.db.transaction(async (tx) => {
|
||||
let p: Product;
|
||||
if (typeof claims.product === "string") {
|
||||
const [found] = await tx.select().from(product).where(eq(product.id, claims.product)).for("update");
|
||||
@@ -209,6 +232,62 @@ export class Products {
|
||||
await tx.insert(hosting).values({ catalogId: c.id, productId: p.id, number });
|
||||
return { product: p.id, revision: number };
|
||||
});
|
||||
// The parent's image and categories: best effort, without holding up the upload (fillParentInfo retries).
|
||||
if (parent > 0) this.lookUpParent(made.product, made.revision, parent).catch(() => {});
|
||||
return made;
|
||||
}
|
||||
|
||||
// ---- parents (the IMVU products revisions derive from) ----
|
||||
|
||||
/** Looks up and stores a revision's parent info. Throws if IMVU can't be reached (the info stays null). */
|
||||
async lookUpParent(productId: string, number: number, parent: number): Promise<ParentInfo | undefined> {
|
||||
if (!this.imvu || parent <= 0) return undefined;
|
||||
const since = new Date(Date.now() - parentInfoFresh).toISOString();
|
||||
const [known] = await this.db
|
||||
.select({ info: productRevision.parentInfo })
|
||||
.from(productRevision)
|
||||
.where(
|
||||
and(
|
||||
eq(productRevision.parent, parent),
|
||||
sql`(${productRevision.parentInfo}->>'checkedAt')::timestamptz > ${since}::timestamptz`,
|
||||
),
|
||||
)
|
||||
.limit(1);
|
||||
let info = known?.info ?? undefined;
|
||||
if (!info) {
|
||||
const p = await this.imvu.product(parent);
|
||||
info = {
|
||||
found: p !== null,
|
||||
image: p?.image ?? null,
|
||||
categories: p?.categories ?? [],
|
||||
gender: p?.gender ?? "",
|
||||
checkedAt: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
await this.db
|
||||
.update(productRevision)
|
||||
.set({ parentInfo: info })
|
||||
.where(and(eq(productRevision.productId, productId), eq(productRevision.number, number)));
|
||||
return info;
|
||||
}
|
||||
|
||||
/** Looks up parent info for revisions that have a parent and no info yet, a few at a time. */
|
||||
async fillParentInfo(limit = 20): Promise<number> {
|
||||
const due = await this.db
|
||||
.select({ productId: productRevision.productId, number: productRevision.number, parent: productRevision.parent })
|
||||
.from(productRevision)
|
||||
.where(and(gt(productRevision.parent, 0), isNull(productRevision.parentInfo)))
|
||||
.orderBy(desc(productRevision.createdAt))
|
||||
.limit(limit);
|
||||
let n = 0;
|
||||
for (const r of due) {
|
||||
try {
|
||||
if (await this.lookUpParent(r.productId, r.number, r.parent)) n++;
|
||||
} catch {
|
||||
// IMVU unavailable: try again next time.
|
||||
}
|
||||
}
|
||||
return n;
|
||||
}
|
||||
|
||||
// ---- products ----
|
||||
@@ -220,19 +299,41 @@ export class Products {
|
||||
return p;
|
||||
}
|
||||
|
||||
/** Products, newest first; adult ones only if adult. */
|
||||
async list(opts: { q?: string; creator?: string; adult: boolean; limit?: number }) {
|
||||
/**
|
||||
* Products, newest first; adult ones only if adult. `category`: only those whose latest revision's parent is in
|
||||
* that IMVU category (anywhere in its path). Each comes with its latest revision's parent and parent info.
|
||||
*/
|
||||
async list(opts: { q?: string; creator?: string; category?: number; adult: boolean; limit?: number }) {
|
||||
const conds = [ne(product.latest, 0)];
|
||||
if (!opts.adult) conds.push(eq(product.rating, "general"));
|
||||
if (opts.q) conds.push(ilike(product.name, `%${opts.q.replace(/[%_\\]/g, (c) => "\\" + c)}%`));
|
||||
if (opts.creator) conds.push(eq(user.username, opts.creator.toLowerCase()));
|
||||
if (opts.category !== undefined)
|
||||
conds.push(sql`${productRevision.parentInfo}->'categories' @> ${JSON.stringify([{ id: opts.category }])}::jsonb`);
|
||||
const limit = Math.max(1, Math.min(Math.trunc(opts.limit ?? 60) || 60, 200));
|
||||
return this.db
|
||||
.select({ product, creator: user.displayUsername, creatorUsername: user.username })
|
||||
.select({
|
||||
product,
|
||||
creator: user.displayUsername,
|
||||
creatorUsername: user.username,
|
||||
parent: productRevision.parent,
|
||||
parentInfo: productRevision.parentInfo,
|
||||
})
|
||||
.from(product)
|
||||
.innerJoin(user, eq(user.id, product.creatorId))
|
||||
.leftJoin(productRevision, latestJoin)
|
||||
.where(and(...conds))
|
||||
.orderBy(desc(product.createdAt))
|
||||
.limit(Math.min(opts.limit ?? 60, 200));
|
||||
.limit(limit);
|
||||
}
|
||||
|
||||
/** A product's latest revision's parent and parent info (for views of one product). */
|
||||
async latestParent(p: Product) {
|
||||
const [r] = await this.db
|
||||
.select({ parent: productRevision.parent, parentInfo: productRevision.parentInfo })
|
||||
.from(productRevision)
|
||||
.where(and(eq(productRevision.productId, p.id), eq(productRevision.number, p.latest)));
|
||||
return r;
|
||||
}
|
||||
|
||||
async get(id: string, adult: boolean) {
|
||||
@@ -294,10 +395,18 @@ export class Products {
|
||||
|
||||
async library(personId: string) {
|
||||
return this.db
|
||||
.select({ product, stayOn: library.stayOn, addedAt: library.addedAt, creator: user.displayUsername })
|
||||
.select({
|
||||
product,
|
||||
stayOn: library.stayOn,
|
||||
addedAt: library.addedAt,
|
||||
creator: user.displayUsername,
|
||||
parent: productRevision.parent,
|
||||
parentInfo: productRevision.parentInfo,
|
||||
})
|
||||
.from(library)
|
||||
.innerJoin(product, eq(product.id, library.productId))
|
||||
.innerJoin(user, eq(user.id, product.creatorId))
|
||||
.leftJoin(productRevision, latestJoin)
|
||||
.where(eq(library.userId, personId))
|
||||
.orderBy(desc(library.addedAt));
|
||||
}
|
||||
|
||||
@@ -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