Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions src/celemod-ui/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
"test:catalog-download": "node --import tsx --test src/catalogDownload.test.ts",
"test:crash-trace": "node --import tsx --test src/components/crashTrace.test.ts",
"test:deep-link": "node --import tsx --test src/deepLink.test.ts",
"test:catalog-response": "node --import tsx --test src/api/catalogResponse.test.ts",
"test:mod-updates": "node --import tsx --test src/modUpdates.test.ts",
"test:update-notes": "node --import tsx --test src/components/UpdateNotes.test.tsx"
},
Expand Down
50 changes: 50 additions & 0 deletions src/celemod-ui/src/api/catalogResponse.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import assert from "node:assert/strict";
import test from "node:test";
import { parseCatalogResponse } from "./catalogResponse";

test("a normal catalog response yields its Mods", () => {
const mods = parseCatalogResponse<{ name: string }>(
'{"data":[{"name":"SpeedrunTool"}],"code":200,"message":"OK"}',
);
assert.deepEqual(mods, [{ name: "SpeedrunTool" }]);
});

test("an empty catalog is still a valid answer", () => {
assert.deepEqual(
parseCatalogResponse('{"data":[],"code":200,"message":"OK"}'),
[],
);
});

/**
* A mirror that renames or drops `data` used to look like an empty catalog:
* no Mods, no error, nothing in the log.
*/
test("a response without a Mod list is an error instead of an empty catalog", () => {
assert.throws(
() => parseCatalogResponse('{"mods":[],"code":200,"message":"OK"}'),
/no Mod list/,
);
assert.throws(
() => parseCatalogResponse('{"data":null,"code":200,"message":"OK"}'),
/no Mod list/,
);
});

test("a failed catalog request keeps its reason", () => {
assert.throws(
() =>
parseCatalogResponse(
'{"data":null,"code":404,"message":"接口未找到"}',
),
/code 404/,
);
assert.throws(
() => parseCatalogResponse('{"data":[],"code":500,"message":"boom"}'),
/code 500.*boom/,
);
});

test("a non-JSON body is an error", () => {
assert.throws(() => parseCatalogResponse("<html>502</html>"), /not valid JSON/);
});
34 changes: 34 additions & 0 deletions src/celemod-ui/src/api/catalogResponse.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
/**
* Parsing of the raw Mod catalog response.
*
* The catalog comes from the mirror (`/api/v2/mod/list`), and the UI used to
* treat anything that was not an array as an empty catalog. A mirror that
* renames or drops a top-level field therefore looked exactly like a mirror
* with no Mods at all: an empty list, no error, and nothing in the log. The
* response shape is checked here so that case fails loudly instead.
*/
export interface CatalogResponse<T> {
data: T[];
code: number;
message: string;
}

export const parseCatalogResponse = <T>(raw: string): T[] => {
let parsed: Partial<CatalogResponse<T>>;
try {
parsed = JSON.parse(raw) as Partial<CatalogResponse<T>>;
} catch (error) {
throw new Error(`Mod catalog response is not valid JSON: ${String(error)}`);
}
if (!Array.isArray(parsed?.data)) {
throw new Error(
`Mod catalog response has no Mod list (code ${parsed?.code ?? "?"}, message ${parsed?.message ?? ""})`,
);
}
if (typeof parsed.code === "number" && parsed.code !== 200) {
throw new Error(
`Mod catalog request failed (code ${parsed.code}, message ${parsed.message ?? ""})`,
);
}
return parsed.data;
};
10 changes: 2 additions & 8 deletions src/celemod-ui/src/api/modCatalog.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import type { Content, File as SubmissionFile } from "./wegfan";
import { callRemote, sleep } from "../utils";
import { waitForCatalogDownload } from "../catalogDownload";
import { parseCatalogResponse } from "./catalogResponse";

export interface CatalogSubmission {
id: string;
Expand Down Expand Up @@ -44,12 +45,6 @@ export interface CatalogMod {
submissionFile: CatalogSubmissionFile;
}

interface CatalogResponse {
data: CatalogMod[];
code: number;
message: string;
}

export interface LocalCatalogFilters {
search: string;
category: string;
Expand Down Expand Up @@ -98,8 +93,7 @@ export const isCatalogDownloading = async (): Promise<boolean> => {

const requestModCatalog = (requestId: number, forceRefresh: boolean) =>
callRemote<string>("get_mod_catalog", forceRefresh).then((raw) => {
const parsed = JSON.parse(raw) as CatalogResponse;
const nextCatalog = Array.isArray(parsed.data) ? parsed.data : [];
const nextCatalog = parseCatalogResponse<CatalogMod>(raw);
if (requestId === catalogRequestId) catalog = nextCatalog;
return nextCatalog;
});
Expand Down
Loading