660 lines
19 KiB
TypeScript
660 lines
19 KiB
TypeScript
import { describe, expect, mock, test } from "bun:test";
|
|
import {
|
|
type ArtifactServiceDependencies,
|
|
cdnifyService,
|
|
checkCdnRefsService,
|
|
deleteCdnService,
|
|
getAmberVhService,
|
|
setArtifactLimitService,
|
|
setTierlistPlacementsService,
|
|
setTierlistStateService,
|
|
submitArtifactService,
|
|
type TierlistStateDependencies,
|
|
} from "./api-services";
|
|
|
|
const validArtifact = {
|
|
name: "Traveler",
|
|
uid: "123456789",
|
|
character: "Furina",
|
|
comment: "Please review",
|
|
};
|
|
|
|
function artifactDeps(
|
|
overrides: Partial<ArtifactServiceDependencies> = {},
|
|
): ArtifactServiceDependencies {
|
|
return {
|
|
getConfig: async () => ({ locked: false, limit: -1 }),
|
|
countQueued: async () => 0,
|
|
uidExists: async () => false,
|
|
characterExists: async () => true,
|
|
insert: async () => ({ id: "new", queue: 1 }),
|
|
replace: async () => ({ id: "existing", queue: 1 }),
|
|
createEditToken: () => "next-token",
|
|
afterSubmit: async () => {},
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
describe("submitArtifactService", () => {
|
|
test("returns immediately while submissions are locked", async () => {
|
|
const countQueued = mock(async () => 0);
|
|
const result = await submitArtifactService(
|
|
validArtifact,
|
|
undefined,
|
|
artifactDeps({
|
|
getConfig: async () => ({ locked: true, limit: -1 }),
|
|
countQueued,
|
|
}),
|
|
);
|
|
|
|
expect(result).toContain("ปิดรับลงทะเบียน");
|
|
expect(countQueued).not.toHaveBeenCalled();
|
|
});
|
|
|
|
test("enforces zero and reached queue limits before validation", async () => {
|
|
const uidExists = mock(async () => false);
|
|
const result = await submitArtifactService(
|
|
validArtifact,
|
|
undefined,
|
|
artifactDeps({
|
|
getConfig: async () => ({ locked: false, limit: 2 }),
|
|
countQueued: async () => 2,
|
|
uidExists,
|
|
}),
|
|
);
|
|
|
|
expect(result).toBe("คิวลงทะเบียนเต็มแล้ว (2 ครั้ง)");
|
|
expect(uidExists).not.toHaveBeenCalled();
|
|
});
|
|
|
|
test("treats -1 as unlimited", async () => {
|
|
expect(
|
|
await submitArtifactService(
|
|
validArtifact,
|
|
undefined,
|
|
artifactDeps({
|
|
getConfig: async () => ({ locked: false, limit: -1 }),
|
|
countQueued: async () => 100,
|
|
}),
|
|
),
|
|
).toEqual({ id: "new", queue: 1 });
|
|
});
|
|
|
|
test("reports missing and invalid form fields without querying records", async () => {
|
|
const uidExists = mock(async () => false);
|
|
const deps = artifactDeps({ uidExists });
|
|
|
|
expect(
|
|
await submitArtifactService(
|
|
{ name: "Name", uid: "bad" },
|
|
undefined,
|
|
deps,
|
|
),
|
|
).toContain("UID ไม่ถูกต้อง");
|
|
expect(uidExists).not.toHaveBeenCalled();
|
|
});
|
|
|
|
test("enforces name and comment length limits", async () => {
|
|
const deps = artifactDeps();
|
|
const longName = await submitArtifactService(
|
|
{ ...validArtifact, name: "x".repeat(65) },
|
|
undefined,
|
|
deps,
|
|
);
|
|
const longComment = await submitArtifactService(
|
|
{ ...validArtifact, comment: "x".repeat(1025) },
|
|
undefined,
|
|
deps,
|
|
);
|
|
|
|
expect(longName).toContain("64");
|
|
expect(longComment).toContain("1024");
|
|
});
|
|
|
|
test("rejects a duplicate UID before checking the character", async () => {
|
|
const characterExists = mock(async () => true);
|
|
const result = await submitArtifactService(
|
|
validArtifact,
|
|
undefined,
|
|
artifactDeps({ uidExists: async () => true, characterExists }),
|
|
);
|
|
|
|
expect(result).toBe("คุณลงทะเบียนไปแล้ว");
|
|
expect(characterExists).not.toHaveBeenCalled();
|
|
});
|
|
|
|
test("rejects an unknown character before insertion", async () => {
|
|
const insert = mock(async () => ({ id: "new", queue: 1 }));
|
|
const result = await submitArtifactService(
|
|
validArtifact,
|
|
undefined,
|
|
artifactDeps({ characterExists: async () => false, insert }),
|
|
);
|
|
|
|
expect(result).toBe("ไม่พบตัวละครที่เลือก");
|
|
expect(insert).not.toHaveBeenCalled();
|
|
});
|
|
|
|
test("inserts validated data and runs submit side effects afterward", async () => {
|
|
const calls: string[] = [];
|
|
const result = await submitArtifactService(
|
|
validArtifact,
|
|
undefined,
|
|
artifactDeps({
|
|
insert: async (data) => {
|
|
expect(data).toEqual(validArtifact);
|
|
calls.push("insert");
|
|
return { id: "created", queue: 4 };
|
|
},
|
|
afterSubmit: async () => {
|
|
calls.push("after");
|
|
},
|
|
}),
|
|
);
|
|
|
|
expect(result).toEqual({ id: "created", queue: 4 });
|
|
expect(calls).toEqual(["insert", "after"]);
|
|
});
|
|
|
|
test("allows an edit when the queue is full and excludes its token from UID checks", async () => {
|
|
const countQueued = mock(async () => 10);
|
|
let excludedToken: string | undefined;
|
|
let replacementToken: string | undefined;
|
|
const edit = { sub: "submission", token: "current-token" };
|
|
const result = await submitArtifactService(
|
|
validArtifact,
|
|
edit,
|
|
artifactDeps({
|
|
getConfig: async () => ({ locked: false, limit: 1 }),
|
|
countQueued,
|
|
uidExists: async (_uid, excluded) => {
|
|
excludedToken = excluded;
|
|
return false;
|
|
},
|
|
replace: async (receivedEdit, data, token) => {
|
|
expect(receivedEdit).toEqual(edit);
|
|
expect(data).toEqual(validArtifact);
|
|
replacementToken = token;
|
|
return { id: "submission", queue: 1 };
|
|
},
|
|
}),
|
|
);
|
|
|
|
expect(result).toEqual({ id: "submission", queue: 1 });
|
|
expect(countQueued).not.toHaveBeenCalled();
|
|
expect(excludedToken).toBe("current-token");
|
|
expect(replacementToken).toBe("next-token");
|
|
});
|
|
|
|
test("does not run side effects when an edit is no longer eligible", async () => {
|
|
const afterSubmit = mock(async () => {});
|
|
const result = await submitArtifactService(
|
|
validArtifact,
|
|
{ sub: "submission", token: "token" },
|
|
artifactDeps({ replace: async () => undefined, afterSubmit }),
|
|
);
|
|
|
|
expect(result).toBe("คิวนี้แก้ไม่ได้แล้ว");
|
|
expect(afterSubmit).not.toHaveBeenCalled();
|
|
});
|
|
|
|
test("propagates replacement failure and never reports success", async () => {
|
|
const failure = new Error("transaction rolled back");
|
|
const afterSubmit = mock(async () => {});
|
|
expect(
|
|
submitArtifactService(
|
|
validArtifact,
|
|
{ sub: "submission", token: "token" },
|
|
artifactDeps({
|
|
replace: async () => {
|
|
throw failure;
|
|
},
|
|
afterSubmit,
|
|
}),
|
|
),
|
|
).rejects.toBe(failure);
|
|
expect(afterSubmit).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe("setArtifactLimitService", () => {
|
|
test("checks admin access before validation or persistence", async () => {
|
|
const persist = mock(async () => {});
|
|
expect(
|
|
setArtifactLimitService(Number.NaN, {
|
|
adminCheck: async () => null,
|
|
persist,
|
|
afterSet: async () => {},
|
|
}),
|
|
).rejects.toBe("Unauthorized");
|
|
expect(persist).not.toHaveBeenCalled();
|
|
});
|
|
|
|
test.each([
|
|
Number.NaN,
|
|
Infinity,
|
|
-Infinity,
|
|
-2,
|
|
1.5,
|
|
])("rejects invalid limit %p without side effects", async (limit) => {
|
|
const persist = mock(async () => {});
|
|
const afterSet = mock(async () => {});
|
|
expect(
|
|
setArtifactLimitService(limit, {
|
|
adminCheck: async () => true,
|
|
persist,
|
|
afterSet,
|
|
}),
|
|
).rejects.toBeInstanceOf(TypeError);
|
|
expect(persist).not.toHaveBeenCalled();
|
|
expect(afterSet).not.toHaveBeenCalled();
|
|
});
|
|
|
|
test.each([
|
|
-1, 0, 12,
|
|
])("persists valid limit %p before side effects", async (limit) => {
|
|
const calls: string[] = [];
|
|
await setArtifactLimitService(limit, {
|
|
adminCheck: async () => true,
|
|
persist: async (value) => {
|
|
calls.push(`persist:${value}`);
|
|
},
|
|
afterSet: async (value) => {
|
|
calls.push(`after:${value}`);
|
|
},
|
|
});
|
|
expect(calls).toEqual([`persist:${limit}`, `after:${limit}`]);
|
|
});
|
|
});
|
|
|
|
function tierStateDeps(
|
|
overrides: Partial<TierlistStateDependencies> = {},
|
|
): TierlistStateDependencies {
|
|
return {
|
|
adminCheck: async () => true,
|
|
find: async () => undefined,
|
|
update: async () => {},
|
|
insert: async () => {},
|
|
getStates: async () => [],
|
|
afterSet: async () => {},
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
describe("setTierlistStateService", () => {
|
|
test("blocks anonymous mutations before lookup", async () => {
|
|
const find = mock(async () => undefined);
|
|
expect(
|
|
setTierlistStateService(
|
|
{ uuid: "state" },
|
|
tierStateDeps({ adminCheck: async () => null, find }),
|
|
),
|
|
).rejects.toBe("Unauthorized");
|
|
expect(find).not.toHaveBeenCalled();
|
|
});
|
|
|
|
test.each([
|
|
{},
|
|
{ ref: "tile" },
|
|
{ list: "list" },
|
|
{ ref: "tile", list: "" },
|
|
])("rejects incomplete identifiers %#", async (data) => {
|
|
const find = mock(async () => undefined);
|
|
expect(
|
|
setTierlistStateService(data, tierStateDeps({ find })),
|
|
).rejects.toThrow();
|
|
expect(find).not.toHaveBeenCalled();
|
|
});
|
|
|
|
test("updates by UUID and resolves the existing list for side effects", async () => {
|
|
const calls: string[] = [];
|
|
await setTierlistStateService(
|
|
{ uuid: "state", comment: "updated" },
|
|
tierStateDeps({
|
|
find: async (identifier) => {
|
|
expect(identifier).toEqual({ uuid: "state" });
|
|
return {
|
|
uuid: "state",
|
|
char: "char",
|
|
ref: "tile",
|
|
list: "list",
|
|
comment: "old",
|
|
badges: [],
|
|
};
|
|
},
|
|
update: async (uuid, data) => {
|
|
calls.push(`update:${uuid}:${data.comment}`);
|
|
},
|
|
getStates: async (list) => {
|
|
calls.push(`states:${list}`);
|
|
return [];
|
|
},
|
|
afterSet: async (list) => {
|
|
calls.push(`after:${list}`);
|
|
},
|
|
}),
|
|
);
|
|
|
|
expect(calls).toEqual([
|
|
"update:state:updated",
|
|
"states:list",
|
|
"after:list",
|
|
]);
|
|
});
|
|
|
|
test("finds by ref/list and inserts a complete new state without requiring UUID", async () => {
|
|
let inserted: unknown;
|
|
await setTierlistStateService(
|
|
{ char: "char", ref: "tile", list: "list", badges: ["badge"] },
|
|
tierStateDeps({
|
|
find: async (identifier) => {
|
|
expect(identifier).toEqual({ ref: "tile", list: "list" });
|
|
return undefined;
|
|
},
|
|
insert: async (data) => {
|
|
inserted = data;
|
|
},
|
|
}),
|
|
);
|
|
expect(inserted).toEqual({
|
|
char: "char",
|
|
ref: "tile",
|
|
list: "list",
|
|
badges: ["badge"],
|
|
});
|
|
});
|
|
|
|
test("rejects incomplete inserts and more than four badges", async () => {
|
|
const insert = mock(async () => {});
|
|
expect(
|
|
setTierlistStateService(
|
|
{ ref: "tile", list: "list" },
|
|
tierStateDeps({ insert }),
|
|
),
|
|
).rejects.toThrow();
|
|
expect(
|
|
setTierlistStateService(
|
|
{
|
|
char: "char",
|
|
ref: "tile",
|
|
list: "list",
|
|
badges: ["1", "2", "3", "4", "5"],
|
|
},
|
|
tierStateDeps({ insert }),
|
|
),
|
|
).rejects.toThrow();
|
|
expect(insert).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe("setTierlistPlacementsService", () => {
|
|
test("requires admin before validating or persisting", async () => {
|
|
const persist = mock(async () => {});
|
|
expect(
|
|
setTierlistPlacementsService(
|
|
"",
|
|
{},
|
|
{
|
|
adminCheck: async () => null,
|
|
persist,
|
|
afterSet: async () => {},
|
|
},
|
|
),
|
|
).rejects.toBe("Unauthorized");
|
|
expect(persist).not.toHaveBeenCalled();
|
|
});
|
|
|
|
test("rejects empty list, placement, and character identifiers", async () => {
|
|
const deps = {
|
|
adminCheck: async () => true,
|
|
persist: mock(async () => {}),
|
|
afterSet: mock(async () => {}),
|
|
};
|
|
expect(setTierlistPlacementsService("", {}, deps)).rejects.toThrow();
|
|
expect(
|
|
setTierlistPlacementsService("list", { "": ["char"] }, deps),
|
|
).rejects.toThrow();
|
|
expect(
|
|
setTierlistPlacementsService("list", { tier: [""] }, deps),
|
|
).rejects.toThrow();
|
|
expect(deps.persist).not.toHaveBeenCalled();
|
|
});
|
|
|
|
test("does not persist untiered but publishes the complete placement state", async () => {
|
|
let persisted: unknown;
|
|
let published: unknown;
|
|
const placements = { untiered: ["char-1"], S: ["char-2"] };
|
|
await setTierlistPlacementsService("list", placements, {
|
|
adminCheck: async () => true,
|
|
persist: async (_list, value) => {
|
|
persisted = value;
|
|
},
|
|
afterSet: async (_list, value) => {
|
|
published = value;
|
|
},
|
|
});
|
|
expect(persisted).toEqual({ S: ["char-2"] });
|
|
expect(published).toEqual(placements);
|
|
});
|
|
});
|
|
|
|
describe("CDN services", () => {
|
|
test("formats and flattens references for one or many IDs", async () => {
|
|
const findRefs = async (id: string) =>
|
|
id === "a"
|
|
? [
|
|
{ table: "characters", id: "c1" },
|
|
{ table: "guides", id: "g1" },
|
|
]
|
|
: [{ table: "tierlist", id: "t1" }];
|
|
expect(await checkCdnRefsService("a", findRefs)).toEqual([
|
|
"a=>characters#c1",
|
|
"a=>guides#g1",
|
|
]);
|
|
expect(await checkCdnRefsService(["a", "b"], findRefs)).toEqual([
|
|
"a=>characters#c1",
|
|
"a=>guides#g1",
|
|
"b=>tierlist#t1",
|
|
]);
|
|
});
|
|
|
|
test.each([
|
|
false,
|
|
true,
|
|
])("requires admin before normal or force deletion (force=%p)", async (force) => {
|
|
const deleteMany = mock(async () => {});
|
|
let transactionCalls = 0;
|
|
const transaction = async <T>(callback: (tx: object) => Promise<T>) => {
|
|
transactionCalls++;
|
|
return callback({});
|
|
};
|
|
expect(
|
|
deleteCdnService(["a"], force, {
|
|
adminCheck: async () => null,
|
|
transaction,
|
|
findRefs: async () => [],
|
|
deleteOne: async () => {},
|
|
deleteMany,
|
|
afterDelete: async () => {},
|
|
}),
|
|
).rejects.toBe("Unauthorized");
|
|
expect(deleteMany).not.toHaveBeenCalled();
|
|
expect(transactionCalls).toBe(0);
|
|
});
|
|
|
|
test("deletes unreferenced files in order and records completion", async () => {
|
|
const calls: string[] = [];
|
|
const result = await deleteCdnService(["a", "b"], false, {
|
|
adminCheck: async () => true,
|
|
transaction: async (callback) => callback({ transaction: true }),
|
|
findRefs: async (id) => {
|
|
calls.push(`refs:${id}`);
|
|
return [];
|
|
},
|
|
deleteOne: async (id) => {
|
|
calls.push(`delete:${id}`);
|
|
},
|
|
deleteMany: async () => {},
|
|
afterDelete: async (count, ids, incomplete) => {
|
|
calls.push(`after:${count}:${ids.join(",")}:${incomplete}`);
|
|
},
|
|
});
|
|
expect(result).toBeUndefined();
|
|
expect(calls).toEqual([
|
|
"refs:a",
|
|
"delete:a",
|
|
"refs:b",
|
|
"delete:b",
|
|
"after:2:a,b:false",
|
|
]);
|
|
});
|
|
|
|
test("stops at the first referenced file and reports partial deletion", async () => {
|
|
const deleted: string[] = [];
|
|
let after: unknown;
|
|
const result = await deleteCdnService(["a", "b", "c"], false, {
|
|
adminCheck: async () => true,
|
|
transaction: async (callback) => callback({}),
|
|
findRefs: async (id) =>
|
|
id === "b" ? [{ table: "characters", id: "char" }] : [],
|
|
deleteOne: async (id) => {
|
|
deleted.push(id);
|
|
},
|
|
deleteMany: async () => {},
|
|
afterDelete: async (...args) => {
|
|
after = args;
|
|
},
|
|
});
|
|
expect(deleted).toEqual(["a"]);
|
|
expect(result).toEqual({
|
|
id: "b",
|
|
refs: ["b=>characters#char"],
|
|
});
|
|
expect(after).toEqual([1, ["a", "b", "c"], true]);
|
|
});
|
|
|
|
test("force deletion skips reference checks but still runs side effects", async () => {
|
|
const findRefs = mock(async () => []);
|
|
const calls: unknown[] = [];
|
|
await deleteCdnService(["a", "b"], true, {
|
|
adminCheck: async () => true,
|
|
transaction: async (callback) => callback({}),
|
|
findRefs,
|
|
deleteOne: async () => {},
|
|
deleteMany: async (ids) => {
|
|
calls.push(["delete", ids]);
|
|
},
|
|
afterDelete: async (...args) => {
|
|
calls.push(["after", ...args]);
|
|
},
|
|
});
|
|
expect(findRefs).not.toHaveBeenCalled();
|
|
expect(calls).toEqual([
|
|
["delete", ["a", "b"]],
|
|
["after", 2, ["a", "b"], false],
|
|
]);
|
|
});
|
|
|
|
test("cdnify stores exact metadata and bytes before logging", async () => {
|
|
const calls: string[] = [];
|
|
const file = new File([new Uint8Array([1, 2, 3])], "image.png", {
|
|
type: "image/png",
|
|
});
|
|
const id = await cdnifyService(file, file.name, {
|
|
insert: async (data) => {
|
|
expect(data.name).toBe("image.png");
|
|
expect(data.size).toBe("3");
|
|
expect(data.type).toBe("image/png");
|
|
expect([...data.data]).toEqual([1, 2, 3]);
|
|
calls.push("insert");
|
|
return "cdn-id";
|
|
},
|
|
afterUpload: async (assetId, name, size) => {
|
|
calls.push(`after:${assetId}:${name}:${size}`);
|
|
},
|
|
});
|
|
expect(id).toBe("cdn-id");
|
|
expect(calls).toEqual(["insert", "after:cdn-id:image.png:3"]);
|
|
});
|
|
|
|
test("cdnify does not log a failed insert", async () => {
|
|
const afterUpload = mock(async () => {});
|
|
const failure = new Error("insert failed");
|
|
expect(
|
|
cdnifyService(new Blob(["x"]), null, {
|
|
insert: async () => {
|
|
throw failure;
|
|
},
|
|
afterUpload,
|
|
}),
|
|
).rejects.toBe(failure);
|
|
expect(afterUpload).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe("getAmberVhService", () => {
|
|
test("returns a cached hash without fetching", async () => {
|
|
const fetchVersion = mock(async () => ({}));
|
|
expect(
|
|
await getAmberVhService({
|
|
getCached: async () => "cached-vh",
|
|
fetchVersion,
|
|
setCached: async () => {},
|
|
}),
|
|
).toBe("cached-vh");
|
|
expect(fetchVersion).not.toHaveBeenCalled();
|
|
});
|
|
|
|
test("falls back to the API after a cache read failure and schedules caching", async () => {
|
|
const scheduled: (() => void)[] = [];
|
|
const cached: string[] = [];
|
|
const result = await getAmberVhService({
|
|
getCached: async () => {
|
|
throw new Error("redis unavailable");
|
|
},
|
|
fetchVersion: async () => ({ response: 1, data: { vh: "fresh-vh" } }),
|
|
setCached: async (value) => {
|
|
cached.push(value);
|
|
},
|
|
schedule: (callback) => scheduled.push(callback),
|
|
});
|
|
expect(result).toBe("fresh-vh");
|
|
expect(cached).toEqual([]);
|
|
expect(scheduled).toHaveLength(1);
|
|
scheduled[0]();
|
|
await Promise.resolve();
|
|
expect(cached).toEqual(["fresh-vh"]);
|
|
});
|
|
|
|
test.each([
|
|
{},
|
|
{ response: 0, data: { vh: "hash" } },
|
|
{ response: 1, data: { vh: "hash-that-is-too-long" } },
|
|
{ response: 1, data: {} },
|
|
])("rejects malformed API payload %#", async (payload) => {
|
|
expect(
|
|
getAmberVhService({
|
|
getCached: async () => null,
|
|
fetchVersion: async () => payload,
|
|
setCached: async () => {},
|
|
}),
|
|
).rejects.toThrow();
|
|
});
|
|
|
|
test("ignores asynchronous cache write failures", async () => {
|
|
const scheduled: (() => void)[] = [];
|
|
expect(
|
|
await getAmberVhService({
|
|
getCached: async () => null,
|
|
fetchVersion: async () => ({ response: 1, data: { vh: "hash" } }),
|
|
setCached: async () => {
|
|
throw new Error("redis write failed");
|
|
},
|
|
schedule: (callback) => scheduled.push(callback),
|
|
}),
|
|
).toBe("hash");
|
|
scheduled[0]();
|
|
await Promise.resolve();
|
|
});
|
|
});
|