feat: add extensive test suite
This commit is contained in:
+10
-26
@@ -1,39 +1,23 @@
|
||||
"use server";
|
||||
|
||||
import { eq } from "drizzle-orm";
|
||||
import type { PgDatabase } from "drizzle-orm/pg-core";
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { actionLog, cdnify } from "@/lib/api";
|
||||
import { adminCheck } from "@/lib/auth";
|
||||
import { db } from "@/lib/db";
|
||||
import { cdn } from "@/lib/db/schema";
|
||||
import { fetchToCdnService } from "./service";
|
||||
|
||||
export async function fetchToCdn(urls: string[]) {
|
||||
if (!(await adminCheck())) throw "Unauthorized";
|
||||
|
||||
async function toFile(res: Response) {
|
||||
if (!res.ok) throw "File failed to download";
|
||||
return new File(
|
||||
[await res.blob()],
|
||||
res.headers
|
||||
.get("Content-Disposition")
|
||||
?.match(/^attachment;.*filename="([a-zA-Z0-9-_ .]+)"/)?.[1] ||
|
||||
decodeURIComponent(new URL(res.url).pathname).match(
|
||||
/\/([a-zA-Z0-9-_ .]+)$/,
|
||||
)?.[1] ||
|
||||
`file.${res.headers.get("Content-Type")?.split("/")[1] || "dat"}`,
|
||||
);
|
||||
}
|
||||
return db
|
||||
.transaction(async (tx) =>
|
||||
Promise.all(
|
||||
urls.map((url) =>
|
||||
fetch(url, { redirect: "follow" })
|
||||
.then(toFile)
|
||||
.then((f) => cdnify(f, { tx })),
|
||||
),
|
||||
),
|
||||
)
|
||||
.finally(() => revalidatePath("/admin/cdn"));
|
||||
// biome-ignore lint/suspicious/noExplicitAny: Drizzle transaction type parameters
|
||||
return fetchToCdnService<PgDatabase<any, any, any>, string>(urls, {
|
||||
adminCheck,
|
||||
fetch: (url) => fetch(url, { redirect: "follow" }),
|
||||
transaction: (callback) => db.transaction(callback),
|
||||
importFile: (file, tx) => cdnify(file, { tx }),
|
||||
afterImport: () => revalidatePath("/admin/cdn"),
|
||||
});
|
||||
}
|
||||
|
||||
export async function rename(id: string, name: string) {
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
import { describe, expect, mock, test } from "bun:test";
|
||||
import { fetchToCdnService, responseToFile } from "./service";
|
||||
|
||||
function response(
|
||||
headers: HeadersInit = {},
|
||||
options: { status?: number; url?: string } = {},
|
||||
) {
|
||||
const result = new Response("file-data", {
|
||||
status: options.status ?? 200,
|
||||
headers,
|
||||
});
|
||||
if (options.url) Object.defineProperty(result, "url", { value: options.url });
|
||||
return result;
|
||||
}
|
||||
|
||||
describe("responseToFile", () => {
|
||||
test("prefers the content-disposition filename", async () => {
|
||||
const file = await responseToFile(
|
||||
response({
|
||||
"Content-Disposition": 'attachment; filename="report final.pdf"',
|
||||
}),
|
||||
);
|
||||
expect(file.name).toBe("report final.pdf");
|
||||
});
|
||||
|
||||
test("uses the decoded final URL filename after redirects", async () => {
|
||||
const file = await responseToFile(
|
||||
response({}, { url: "https://cdn.test/assets/my%20image.png" }),
|
||||
);
|
||||
expect(file.name).toBe("my image.png");
|
||||
});
|
||||
|
||||
test("falls back to the response content type", async () => {
|
||||
const file = await responseToFile(
|
||||
response({ "Content-Type": "image/webp; charset=binary" }),
|
||||
);
|
||||
expect(file.name).toBe("file.webp");
|
||||
expect(file.type).toBe("image/webp");
|
||||
});
|
||||
|
||||
test("rejects non-success status responses", async () => {
|
||||
expect(responseToFile(response({}, { status: 404 }))).rejects.toThrow(
|
||||
"404",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("fetchToCdnService", () => {
|
||||
test("requires admin before fetching or opening a transaction", async () => {
|
||||
const fetch = mock(async (_url: string) => response());
|
||||
let transactionCalled = false;
|
||||
expect(
|
||||
fetchToCdnService(["https://cdn.test/file"], {
|
||||
adminCheck: async () => false,
|
||||
fetch,
|
||||
transaction: async (callback) => {
|
||||
transactionCalled = true;
|
||||
return callback({});
|
||||
},
|
||||
importFile: async () => "id",
|
||||
afterImport: async () => {},
|
||||
}),
|
||||
).rejects.toBe("Unauthorized");
|
||||
expect(fetch).not.toHaveBeenCalled();
|
||||
expect(transactionCalled).toBe(false);
|
||||
});
|
||||
|
||||
test("imports every file in one transaction and refreshes afterward", async () => {
|
||||
const transactionObject = { id: "tx" };
|
||||
const imported: string[] = [];
|
||||
let refreshed = false;
|
||||
const result = await fetchToCdnService(
|
||||
["https://cdn.test/a", "https://cdn.test/b"],
|
||||
{
|
||||
adminCheck: async () => true,
|
||||
fetch: async (url) =>
|
||||
response({
|
||||
"Content-Disposition": `attachment; filename=${url.at(-1)}.txt`,
|
||||
}),
|
||||
transaction: async (callback) => callback(transactionObject),
|
||||
importFile: async (file, tx) => {
|
||||
expect(tx).toBe(transactionObject);
|
||||
imported.push(file.name);
|
||||
return file.name;
|
||||
},
|
||||
afterImport: async () => {
|
||||
refreshed = true;
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
expect(result).toEqual(["a.txt", "b.txt"]);
|
||||
expect(imported).toEqual(["a.txt", "b.txt"]);
|
||||
expect(refreshed).toBe(true);
|
||||
});
|
||||
|
||||
test("refreshes after a failed download", async () => {
|
||||
const afterImport = mock(async () => {});
|
||||
expect(
|
||||
fetchToCdnService(["https://cdn.test/missing"], {
|
||||
adminCheck: async () => true,
|
||||
fetch: async () => response({}, { status: 503 }),
|
||||
transaction: async (callback) => callback({}),
|
||||
importFile: async () => "id",
|
||||
afterImport,
|
||||
}),
|
||||
).rejects.toThrow("503");
|
||||
expect(afterImport).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,62 @@
|
||||
export interface CdnImportDependencies<TTransaction, TResult> {
|
||||
adminCheck: () => Promise<unknown>;
|
||||
fetch: (url: string) => Promise<Response>;
|
||||
transaction: <T>(
|
||||
callback: (transaction: TTransaction) => Promise<T>,
|
||||
) => Promise<T>;
|
||||
importFile: (file: File, transaction: TTransaction) => Promise<TResult>;
|
||||
afterImport: () => Promise<void> | void;
|
||||
}
|
||||
|
||||
function filenameFromDisposition(value: string | null) {
|
||||
return value?.match(/filename="?([^";]+)"?/i)?.[1];
|
||||
}
|
||||
|
||||
export async function responseToFile(response: Response) {
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
`File failed to download (${response.status} ${response.statusText})`,
|
||||
);
|
||||
}
|
||||
|
||||
const dispositionName = filenameFromDisposition(
|
||||
response.headers.get("Content-Disposition"),
|
||||
);
|
||||
const pathName = response.url
|
||||
? decodeURIComponent(new URL(response.url).pathname).match(
|
||||
/\/([^/]+)$/,
|
||||
)?.[1]
|
||||
: undefined;
|
||||
const contentType = response.headers.get("Content-Type")?.split(";")[0];
|
||||
const extension = contentType?.split("/")[1];
|
||||
const blob = await response.blob();
|
||||
|
||||
return new File(
|
||||
[blob],
|
||||
dispositionName || pathName || `file.${extension || "dat"}`,
|
||||
{ type: contentType || blob.type },
|
||||
);
|
||||
}
|
||||
|
||||
export async function fetchToCdnService<TTransaction, TResult>(
|
||||
urls: string[],
|
||||
dependencies: CdnImportDependencies<TTransaction, TResult>,
|
||||
) {
|
||||
if (!(await dependencies.adminCheck())) throw "Unauthorized";
|
||||
|
||||
try {
|
||||
return await dependencies.transaction((transaction) =>
|
||||
Promise.all(
|
||||
urls.map(async (url) => {
|
||||
const response = await dependencies.fetch(url);
|
||||
return dependencies.importFile(
|
||||
await responseToFile(response),
|
||||
transaction,
|
||||
);
|
||||
}),
|
||||
),
|
||||
);
|
||||
} finally {
|
||||
await dependencies.afterImport();
|
||||
}
|
||||
}
|
||||
+17
-10
@@ -7,16 +7,23 @@ import { adminCheck } from "@/lib/auth";
|
||||
import { db } from "@/lib/db";
|
||||
import { guides } from "@/lib/db/schema";
|
||||
import { forceRefresh } from "../settings/api";
|
||||
import { hideGuideService } from "./service";
|
||||
|
||||
export async function hideGuide(id: string) {
|
||||
if (!(await adminCheck())) redirect("/login");
|
||||
await db
|
||||
.update(guides)
|
||||
.set({
|
||||
hidden: not(guides.hidden),
|
||||
})
|
||||
.where(eq(guides.id, id));
|
||||
|
||||
forceRefresh("/guide");
|
||||
revalidatePath("/admin/guide");
|
||||
return hideGuideService(id, {
|
||||
adminCheck: async () => {
|
||||
if (!(await adminCheck())) redirect("/login");
|
||||
return true;
|
||||
},
|
||||
toggle: async (guideId) => {
|
||||
await db
|
||||
.update(guides)
|
||||
.set({ hidden: not(guides.hidden) })
|
||||
.where(eq(guides.id, guideId));
|
||||
},
|
||||
afterToggle: async () => {
|
||||
await forceRefresh("/guide");
|
||||
revalidatePath("/admin/guide");
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import { describe, expect, mock, test } from "bun:test";
|
||||
import { hideGuideService } from "./service";
|
||||
|
||||
describe("hideGuideService", () => {
|
||||
test("rejects anonymous callers before mutation", async () => {
|
||||
const toggle = mock(async (_id: string) => {});
|
||||
expect(
|
||||
hideGuideService("guide", {
|
||||
adminCheck: async () => false,
|
||||
toggle,
|
||||
afterToggle: async () => {},
|
||||
}),
|
||||
).rejects.toBe("Unauthorized");
|
||||
expect(toggle).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("toggles before refreshing guide views", async () => {
|
||||
const calls: string[] = [];
|
||||
await hideGuideService("guide", {
|
||||
adminCheck: async () => true,
|
||||
toggle: async (id) => {
|
||||
calls.push(`toggle:${id}`);
|
||||
},
|
||||
afterToggle: async () => {
|
||||
calls.push("refresh");
|
||||
},
|
||||
});
|
||||
expect(calls).toEqual(["toggle:guide", "refresh"]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,14 @@
|
||||
export interface HideGuideDependencies {
|
||||
adminCheck: () => Promise<unknown>;
|
||||
toggle: (id: string) => Promise<void>;
|
||||
afterToggle: () => Promise<void> | void;
|
||||
}
|
||||
|
||||
export async function hideGuideService(
|
||||
id: string,
|
||||
dependencies: HideGuideDependencies,
|
||||
) {
|
||||
if (!(await dependencies.adminCheck())) throw "Unauthorized";
|
||||
await dependencies.toggle(id);
|
||||
await dependencies.afterToggle();
|
||||
}
|
||||
@@ -2,30 +2,48 @@
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { actionLog } from "@/lib/api";
|
||||
import { adminCheck } from "@/lib/auth";
|
||||
import { db } from "@/lib/db";
|
||||
import { settings } from "@/lib/db/schema";
|
||||
import { adminCheck } from "@/lib/auth";
|
||||
import { sse } from "@/lib/db/sse-endpoints";
|
||||
import { syncAmber as syncAmberRaw } from "@/util/sync";
|
||||
import { getSettingsService, updateSettingService } from "./service";
|
||||
|
||||
export async function getSettongs() {
|
||||
if (!(await adminCheck())) throw "Unauthorized";
|
||||
const [
|
||||
settangs = { enka: true, donatePromptpay: true, donateTruemoney: true },
|
||||
] = await db.select().from(settings).limit(1);
|
||||
return settangs;
|
||||
return getSettingsService(
|
||||
{ enka: true, donatePromptpay: true, donateTruemoney: true },
|
||||
{
|
||||
adminCheck,
|
||||
read: async () =>
|
||||
(
|
||||
await db
|
||||
.select({
|
||||
enka: settings.enka,
|
||||
donatePromptpay: settings.donatePromptpay,
|
||||
donateTruemoney: settings.donateTruemoney,
|
||||
})
|
||||
.from(settings)
|
||||
.limit(1)
|
||||
)[0],
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export async function toggleEnka(state: boolean) {
|
||||
if (!(await adminCheck())) throw "Unauthorized";
|
||||
await db
|
||||
.insert(settings)
|
||||
.values({ enka: state })
|
||||
.onConflictDoUpdate({ target: settings.id, set: { enka: state } });
|
||||
|
||||
await actionLog("Changed a settings", { enka: state });
|
||||
revalidatePath("/admin/settings");
|
||||
revalidatePath("/artifact");
|
||||
return updateSettingService(state, {
|
||||
adminCheck,
|
||||
persist: async (value) => {
|
||||
await db
|
||||
.insert(settings)
|
||||
.values({ enka: value })
|
||||
.onConflictDoUpdate({ target: settings.id, set: { enka: value } });
|
||||
},
|
||||
afterPersist: async (value) => {
|
||||
await actionLog("Changed a settings", { enka: value });
|
||||
revalidatePath("/admin/settings");
|
||||
revalidatePath("/artifact");
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function syncAmber() {
|
||||
@@ -48,11 +66,19 @@ export async function toggleDonatePaymentMethod(
|
||||
method: "donatePromptpay" | "donateTruemoney",
|
||||
state: boolean,
|
||||
) {
|
||||
if (!(await adminCheck())) throw "Unauthorized";
|
||||
await db
|
||||
.insert(settings)
|
||||
.values({ [method]: state })
|
||||
.onConflictDoUpdate({ target: settings.id, set: { [method]: state } });
|
||||
|
||||
await actionLog("Changed a settings", { [method]: state });
|
||||
return updateSettingService(state, {
|
||||
adminCheck,
|
||||
persist: async (value) => {
|
||||
await db
|
||||
.insert(settings)
|
||||
.values({ [method]: value })
|
||||
.onConflictDoUpdate({
|
||||
target: settings.id,
|
||||
set: { [method]: value },
|
||||
});
|
||||
},
|
||||
afterPersist: async (value) => {
|
||||
await actionLog("Changed a settings", { [method]: value });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
import { describe, expect, mock, test } from "bun:test";
|
||||
import { getSettingsService, updateSettingService } from "./service";
|
||||
|
||||
describe("settings services", () => {
|
||||
test("requires admin before reading settings", async () => {
|
||||
const read = mock(async () => ({ enka: false }));
|
||||
expect(
|
||||
getSettingsService(
|
||||
{ enka: true },
|
||||
{ adminCheck: async () => false, read },
|
||||
),
|
||||
).rejects.toBe("Unauthorized");
|
||||
expect(read).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("uses defaults only when no settings row exists", async () => {
|
||||
const defaults = { enka: true };
|
||||
expect(
|
||||
await getSettingsService(defaults, {
|
||||
adminCheck: async () => true,
|
||||
read: async () => undefined,
|
||||
}),
|
||||
).toBe(defaults);
|
||||
expect(
|
||||
await getSettingsService(defaults, {
|
||||
adminCheck: async () => true,
|
||||
read: async () => ({ enka: false }),
|
||||
}),
|
||||
).toEqual({ enka: false });
|
||||
});
|
||||
|
||||
test("blocks unauthorized updates before persistence", async () => {
|
||||
const persist = mock(async (_value: boolean) => {});
|
||||
expect(
|
||||
updateSettingService(false, {
|
||||
adminCheck: async () => null,
|
||||
persist,
|
||||
afterPersist: async () => {},
|
||||
}),
|
||||
).rejects.toBe("Unauthorized");
|
||||
expect(persist).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("persists the exact state before logging and revalidation", async () => {
|
||||
const calls: string[] = [];
|
||||
await updateSettingService(false, {
|
||||
adminCheck: async () => true,
|
||||
persist: async (value) => {
|
||||
calls.push(`persist:${value}`);
|
||||
},
|
||||
afterPersist: async (value) => {
|
||||
calls.push(`after:${value}`);
|
||||
},
|
||||
});
|
||||
expect(calls).toEqual(["persist:false", "after:false"]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,25 @@
|
||||
export interface SettingsDependencies {
|
||||
adminCheck: () => Promise<unknown>;
|
||||
persist: (value: boolean) => Promise<void>;
|
||||
afterPersist: (value: boolean) => Promise<void> | void;
|
||||
}
|
||||
|
||||
export async function updateSettingService(
|
||||
value: boolean,
|
||||
dependencies: SettingsDependencies,
|
||||
) {
|
||||
if (!(await dependencies.adminCheck())) throw "Unauthorized";
|
||||
await dependencies.persist(value);
|
||||
await dependencies.afterPersist(value);
|
||||
}
|
||||
|
||||
export async function getSettingsService<T>(
|
||||
defaults: T,
|
||||
dependencies: {
|
||||
adminCheck: () => Promise<unknown>;
|
||||
read: () => Promise<T | undefined>;
|
||||
},
|
||||
) {
|
||||
if (!(await dependencies.adminCheck())) throw "Unauthorized";
|
||||
return (await dependencies.read()) ?? defaults;
|
||||
}
|
||||
@@ -7,76 +7,62 @@ import { actionLog } from "@/lib/api";
|
||||
import { adminCheck } from "@/lib/auth";
|
||||
import { db } from "@/lib/db";
|
||||
import { tierlistBadges } from "@/lib/db/schema";
|
||||
import { midpointOrder, normalizedOrders } from "../components/order";
|
||||
|
||||
function sameIds(left: string[], right: string[]) {
|
||||
if (left.length !== right.length) return false;
|
||||
|
||||
const a = [...left].sort();
|
||||
const b = [...right].sort();
|
||||
return a.every((id, index) => id === b[index]);
|
||||
}
|
||||
import { reorderService } from "../components/reorder-service";
|
||||
|
||||
export async function reorderBadges(args: {
|
||||
typeId: string;
|
||||
activeId: string;
|
||||
ids: string[];
|
||||
}) {
|
||||
if (!(await adminCheck())) redirect("/login");
|
||||
|
||||
const { typeId, activeId, ids } = args;
|
||||
|
||||
await db.transaction(async (tx) => {
|
||||
const badges = await tx
|
||||
.select()
|
||||
.from(tierlistBadges)
|
||||
.where(or(isNull(tierlistBadges.type), eq(tierlistBadges.type, typeId)))
|
||||
.orderBy(tierlistBadges.order, tierlistBadges.id);
|
||||
|
||||
const visibleIds = badges.map((badge) => badge.id);
|
||||
if (!sameIds(visibleIds, ids)) {
|
||||
throw new Error("Badge order is out of date. Refresh and try again.");
|
||||
}
|
||||
|
||||
const index = ids.indexOf(activeId);
|
||||
if (index === -1) throw new Error("Moved badge is missing.");
|
||||
|
||||
const prev =
|
||||
index > 0
|
||||
? badges.find((badge) => badge.id === ids[index - 1])
|
||||
: undefined;
|
||||
const next =
|
||||
index < ids.length - 1
|
||||
? badges.find((badge) => badge.id === ids[index + 1])
|
||||
: undefined;
|
||||
const nextOrder = midpointOrder(prev?.order, next?.order);
|
||||
|
||||
if (nextOrder == null) {
|
||||
for (const item of normalizedOrders(ids)) {
|
||||
await tx
|
||||
.update(tierlistBadges)
|
||||
.set({ order: item.order })
|
||||
.where(eq(tierlistBadges.id, item.id));
|
||||
}
|
||||
} else {
|
||||
await tx
|
||||
.update(tierlistBadges)
|
||||
.set({ order: nextOrder })
|
||||
.where(
|
||||
and(
|
||||
eq(tierlistBadges.id, activeId),
|
||||
or(isNull(tierlistBadges.type), eq(tierlistBadges.type, typeId)),
|
||||
),
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
await actionLog(`Reordered tierlist badges for ${typeId}`, {
|
||||
typeId,
|
||||
return reorderService(
|
||||
activeId,
|
||||
ids,
|
||||
});
|
||||
|
||||
revalidatePath(`/admin/tl/${typeId}`);
|
||||
revalidatePath(`/tl/${typeId}`);
|
||||
"Badge order is out of date. Refresh and try again.",
|
||||
{
|
||||
adminCheck: async () => {
|
||||
if (!(await adminCheck())) redirect("/login");
|
||||
return true;
|
||||
},
|
||||
transaction: (callback) =>
|
||||
db.transaction((tx) =>
|
||||
callback({
|
||||
list: () =>
|
||||
tx
|
||||
.select({ id: tierlistBadges.id, order: tierlistBadges.order })
|
||||
.from(tierlistBadges)
|
||||
.where(
|
||||
or(
|
||||
isNull(tierlistBadges.type),
|
||||
eq(tierlistBadges.type, typeId),
|
||||
),
|
||||
)
|
||||
.orderBy(tierlistBadges.order, tierlistBadges.id),
|
||||
update: async (id, order) => {
|
||||
await tx
|
||||
.update(tierlistBadges)
|
||||
.set({ order })
|
||||
.where(
|
||||
and(
|
||||
eq(tierlistBadges.id, id),
|
||||
or(
|
||||
isNull(tierlistBadges.type),
|
||||
eq(tierlistBadges.type, typeId),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
}),
|
||||
),
|
||||
afterReorder: async () => {
|
||||
await actionLog(`Reordered tierlist badges for ${typeId}`, {
|
||||
typeId,
|
||||
activeId,
|
||||
ids,
|
||||
});
|
||||
revalidatePath(`/admin/tl/${typeId}`);
|
||||
revalidatePath(`/tl/${typeId}`);
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import {
|
||||
midpointOrder,
|
||||
normalizedOrders,
|
||||
ORDER_STEP,
|
||||
reorderFlatItems,
|
||||
} from "./order";
|
||||
|
||||
const items = [{ id: "a" }, { id: "b" }, { id: "c" }, { id: "d" }];
|
||||
|
||||
describe("reorderFlatItems", () => {
|
||||
test.each([
|
||||
["a", "d", ["b", "c", "d", "a"]],
|
||||
["d", "a", ["d", "a", "b", "c"]],
|
||||
["b", "c", ["a", "c", "b", "d"]],
|
||||
["c", "b", ["a", "c", "b", "d"]],
|
||||
])("moves %s over %s", (active, over, expected) => {
|
||||
const result = reorderFlatItems(items, active, over);
|
||||
expect(result.map(({ id }) => id)).toEqual(expected);
|
||||
expect(result).not.toBe(items);
|
||||
expect(items.map(({ id }) => id)).toEqual(["a", "b", "c", "d"]);
|
||||
});
|
||||
|
||||
test("returns the original array when IDs are equal", () => {
|
||||
expect(reorderFlatItems(items, "b", "b")).toBe(items);
|
||||
});
|
||||
|
||||
test.each([
|
||||
["missing", "a"],
|
||||
["a", "missing"],
|
||||
["missing", "other"],
|
||||
])("returns the original array for unknown IDs", (active, over) => {
|
||||
expect(reorderFlatItems(items, active, over)).toBe(items);
|
||||
});
|
||||
|
||||
test("moves the first matching item when IDs are duplicated", () => {
|
||||
const duplicate = [
|
||||
{ id: "a", value: 1 },
|
||||
{ id: "a", value: 2 },
|
||||
{ id: "b", value: 3 },
|
||||
];
|
||||
expect(
|
||||
reorderFlatItems(duplicate, "a", "b").map(({ value }) => value),
|
||||
).toEqual([2, 3, 1]);
|
||||
});
|
||||
|
||||
test("handles an empty array", () => {
|
||||
const empty: { id: string }[] = [];
|
||||
expect(reorderFlatItems(empty, "a", "b")).toBe(empty);
|
||||
});
|
||||
});
|
||||
|
||||
describe("midpointOrder", () => {
|
||||
test.each([
|
||||
[undefined, undefined, ORDER_STEP],
|
||||
[undefined, 30, 20],
|
||||
[20, undefined, 30],
|
||||
[10, 30, 20],
|
||||
[10, 31, 20],
|
||||
[-20, -10, -15],
|
||||
[0, 1, null],
|
||||
[1, 2, null],
|
||||
[-2, -1, null],
|
||||
[10, 10, null],
|
||||
] as const)("returns %p between %p and %p", (prev, next, expected) => {
|
||||
expect(midpointOrder(prev, next)).toBe(expected);
|
||||
});
|
||||
|
||||
test("treats null like a missing boundary", () => {
|
||||
expect(midpointOrder(null as unknown as undefined, 20)).toBe(10);
|
||||
expect(midpointOrder(20, null as unknown as undefined)).toBe(30);
|
||||
});
|
||||
});
|
||||
|
||||
describe("normalizedOrders", () => {
|
||||
test("assigns stable ORDER_STEP increments", () => {
|
||||
expect(normalizedOrders(["c", "a", "b"])).toEqual([
|
||||
{ id: "c", order: 10 },
|
||||
{ id: "a", order: 20 },
|
||||
{ id: "b", order: 30 },
|
||||
]);
|
||||
});
|
||||
|
||||
test("returns an empty list for no IDs", () => {
|
||||
expect(normalizedOrders([])).toEqual([]);
|
||||
});
|
||||
|
||||
test("preserves duplicate and empty IDs", () => {
|
||||
expect(normalizedOrders(["", "x", "x"])).toEqual([
|
||||
{ id: "", order: 10 },
|
||||
{ id: "x", order: 20 },
|
||||
{ id: "x", order: 30 },
|
||||
]);
|
||||
});
|
||||
|
||||
test("does not mutate the input", () => {
|
||||
const ids = ["b", "a"];
|
||||
normalizedOrders(ids);
|
||||
expect(ids).toEqual(["b", "a"]);
|
||||
});
|
||||
});
|
||||
@@ -24,13 +24,45 @@ export function midpointOrder(prev?: number, next?: number) {
|
||||
return middle;
|
||||
}
|
||||
|
||||
export function normalizedOrders(ids: string[]) {
|
||||
export function normalizedOrders(ids: readonly string[]) {
|
||||
return ids.map((id, index) => ({
|
||||
id,
|
||||
order: (index + 1) * ORDER_STEP,
|
||||
}));
|
||||
}
|
||||
|
||||
export type OrderItem = { id: string; order: number };
|
||||
|
||||
export function plannedOrderUpdates(
|
||||
items: OrderItem[],
|
||||
ids: readonly string[],
|
||||
activeId: string,
|
||||
staleMessage: string,
|
||||
) {
|
||||
const currentIds = items.map(({ id }) => id);
|
||||
const uniqueIds = new Set(ids);
|
||||
if (
|
||||
ids.length === 0 ||
|
||||
uniqueIds.size !== ids.length ||
|
||||
currentIds.length !== ids.length ||
|
||||
currentIds.some((id) => !uniqueIds.has(id))
|
||||
) {
|
||||
throw new Error(staleMessage);
|
||||
}
|
||||
|
||||
const index = ids.indexOf(activeId);
|
||||
if (index === -1) throw new Error("Moved item is missing.");
|
||||
|
||||
const byId = new Map(items.map((item) => [item.id, item]));
|
||||
const previous = index > 0 ? byId.get(ids[index - 1]) : undefined;
|
||||
const next = index < ids.length - 1 ? byId.get(ids[index + 1]) : undefined;
|
||||
const nextOrder = midpointOrder(previous?.order, next?.order);
|
||||
|
||||
return nextOrder == null
|
||||
? normalizedOrders(ids)
|
||||
: [{ id: activeId, order: nextOrder }];
|
||||
}
|
||||
|
||||
function arrayMove<T>(arr: T[], oldIndex: number, newIndex: number) {
|
||||
return arr.toSpliced(oldIndex, 1).toSpliced(newIndex, 0, arr[oldIndex]);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { plannedOrderUpdates } from "./order";
|
||||
import { reorderService } from "./reorder-service";
|
||||
|
||||
const items = [
|
||||
{ id: "a", order: 10 },
|
||||
{ id: "b", order: 20 },
|
||||
{ id: "c", order: 30 },
|
||||
];
|
||||
|
||||
describe("plannedOrderUpdates", () => {
|
||||
test("uses a midpoint when sufficient order space exists", () => {
|
||||
expect(plannedOrderUpdates(items, ["b", "a", "c"], "a", "stale")).toEqual([
|
||||
{ id: "a", order: 25 },
|
||||
]);
|
||||
});
|
||||
|
||||
test("normalizes every item when adjacent orders have no midpoint", () => {
|
||||
expect(
|
||||
plannedOrderUpdates(
|
||||
[
|
||||
{ id: "a", order: 1 },
|
||||
{ id: "b", order: 2 },
|
||||
{ id: "c", order: 3 },
|
||||
],
|
||||
["b", "a", "c"],
|
||||
"a",
|
||||
"stale",
|
||||
),
|
||||
).toEqual([
|
||||
{ id: "b", order: 10 },
|
||||
{ id: "a", order: 20 },
|
||||
{ id: "c", order: 30 },
|
||||
]);
|
||||
});
|
||||
|
||||
test.each([
|
||||
{ ids: [] as string[] },
|
||||
{ ids: ["a", "a", "c"] },
|
||||
{ ids: ["a", "b"] },
|
||||
{ ids: ["a", "b", "stale"] },
|
||||
{ ids: ["a", "b", "c", "extra"] },
|
||||
])("rejects malformed, duplicate, or stale IDs: %p", ({ ids }) => {
|
||||
expect(() => plannedOrderUpdates(items, ids, "a", "refresh")).toThrow(
|
||||
"refresh",
|
||||
);
|
||||
});
|
||||
|
||||
test("rejects an active ID absent from an otherwise valid order", () => {
|
||||
expect(() =>
|
||||
plannedOrderUpdates(items, ["a", "b", "c"], "missing", "stale"),
|
||||
).toThrow("Moved item is missing");
|
||||
});
|
||||
});
|
||||
|
||||
describe("reorderService", () => {
|
||||
test("requires admin before opening a transaction", async () => {
|
||||
let transactionCalled = false;
|
||||
expect(
|
||||
reorderService("a", ["a"], "stale", {
|
||||
adminCheck: async () => false,
|
||||
transaction: async (callback) => {
|
||||
transactionCalled = true;
|
||||
return callback({
|
||||
list: async () => [],
|
||||
update: async () => {},
|
||||
});
|
||||
},
|
||||
afterReorder: async () => {},
|
||||
}),
|
||||
).rejects.toBe("Unauthorized");
|
||||
expect(transactionCalled).toBe(false);
|
||||
});
|
||||
|
||||
test("applies planned updates atomically before side effects", async () => {
|
||||
const calls: string[] = [];
|
||||
await reorderService("a", ["b", "a", "c"], "stale", {
|
||||
adminCheck: async () => true,
|
||||
transaction: async (callback) => {
|
||||
await callback({
|
||||
list: async () => items,
|
||||
update: async (id, order) => {
|
||||
calls.push(`update:${id}:${order}`);
|
||||
},
|
||||
});
|
||||
},
|
||||
afterReorder: async () => {
|
||||
calls.push("after");
|
||||
},
|
||||
});
|
||||
expect(calls).toEqual(["update:a:25", "after"]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,35 @@
|
||||
import { type OrderItem, plannedOrderUpdates } from "./order";
|
||||
|
||||
export interface ReorderDependencies {
|
||||
adminCheck: () => Promise<unknown>;
|
||||
transaction: (
|
||||
callback: (repository: {
|
||||
list: () => Promise<OrderItem[]>;
|
||||
update: (id: string, order: number) => Promise<void>;
|
||||
}) => Promise<void>,
|
||||
) => Promise<void>;
|
||||
afterReorder: () => Promise<void> | void;
|
||||
}
|
||||
|
||||
export async function reorderService(
|
||||
activeId: string,
|
||||
ids: readonly string[],
|
||||
staleMessage: string,
|
||||
dependencies: ReorderDependencies,
|
||||
) {
|
||||
if (!(await dependencies.adminCheck())) throw "Unauthorized";
|
||||
|
||||
await dependencies.transaction(async (repository) => {
|
||||
const updates = plannedOrderUpdates(
|
||||
await repository.list(),
|
||||
ids,
|
||||
activeId,
|
||||
staleMessage,
|
||||
);
|
||||
for (const update of updates) {
|
||||
await repository.update(update.id, update.order);
|
||||
}
|
||||
});
|
||||
|
||||
await dependencies.afterReorder();
|
||||
}
|
||||
@@ -7,15 +7,7 @@ import { actionLog } from "@/lib/api";
|
||||
import { adminCheck } from "@/lib/auth";
|
||||
import { db } from "@/lib/db";
|
||||
import { tierlistColumns, tierlistTiers } from "@/lib/db/schema";
|
||||
import { midpointOrder, normalizedOrders } from "../components/order";
|
||||
|
||||
function sameIds(left: string[], right: string[]) {
|
||||
if (left.length !== right.length) return false;
|
||||
|
||||
const a = [...left].sort();
|
||||
const b = [...right].sort();
|
||||
return a.every((id, index) => id === b[index]);
|
||||
}
|
||||
import { reorderService } from "../components/reorder-service";
|
||||
|
||||
async function reorderTable(
|
||||
ids: string[],
|
||||
@@ -24,46 +16,38 @@ async function reorderTable(
|
||||
) {
|
||||
const table = type === "tiers" ? tierlistTiers : tierlistColumns;
|
||||
|
||||
await db.transaction(async (tx) => {
|
||||
const items = await tx.select().from(table).orderBy(table.order, table.id);
|
||||
const visibleIds = items.map((item) => item.id);
|
||||
if (!sameIds(visibleIds, ids)) {
|
||||
throw new Error("Layout order is out of date. Refresh and try again.");
|
||||
}
|
||||
|
||||
const index = ids.indexOf(activeId);
|
||||
if (index === -1) throw new Error("Moved item is missing.");
|
||||
|
||||
const prev =
|
||||
index > 0 ? items.find((item) => item.id === ids[index - 1]) : undefined;
|
||||
const next =
|
||||
index < ids.length - 1
|
||||
? items.find((item) => item.id === ids[index + 1])
|
||||
: undefined;
|
||||
const nextOrder = midpointOrder(prev?.order, next?.order);
|
||||
|
||||
if (nextOrder == null) {
|
||||
for (const item of normalizedOrders(ids)) {
|
||||
await tx
|
||||
.update(table)
|
||||
.set({ order: item.order })
|
||||
.where(eq(table.id, item.id));
|
||||
}
|
||||
} else {
|
||||
await tx
|
||||
.update(table)
|
||||
.set({ order: nextOrder })
|
||||
.where(eq(table.id, activeId));
|
||||
}
|
||||
});
|
||||
|
||||
await actionLog(`Reordered tierlist ${type}`, { activeId, ids });
|
||||
revalidatePath("/admin/tl/layout");
|
||||
revalidatePath("/tl");
|
||||
return reorderService(
|
||||
activeId,
|
||||
ids,
|
||||
"Layout order is out of date. Refresh and try again.",
|
||||
{
|
||||
adminCheck: async () => {
|
||||
if (!(await adminCheck())) redirect("/login");
|
||||
return true;
|
||||
},
|
||||
transaction: (callback) =>
|
||||
db.transaction((tx) =>
|
||||
callback({
|
||||
list: () =>
|
||||
tx
|
||||
.select({ id: table.id, order: table.order })
|
||||
.from(table)
|
||||
.orderBy(table.order, table.id),
|
||||
update: async (id, order) => {
|
||||
await tx.update(table).set({ order }).where(eq(table.id, id));
|
||||
},
|
||||
}),
|
||||
),
|
||||
afterReorder: async () => {
|
||||
await actionLog(`Reordered tierlist ${type}`, { activeId, ids });
|
||||
revalidatePath("/admin/tl/layout");
|
||||
revalidatePath("/tl");
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export async function reorderTiers(args: { activeId: string; ids: string[] }) {
|
||||
if (!(await adminCheck())) redirect("/login");
|
||||
return reorderTable(args.ids, args.activeId, "tiers");
|
||||
}
|
||||
|
||||
@@ -71,6 +55,5 @@ export async function reorderColumns(args: {
|
||||
activeId: string;
|
||||
ids: string[];
|
||||
}) {
|
||||
if (!(await adminCheck())) redirect("/login");
|
||||
return reorderTable(args.ids, args.activeId, "columns");
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user