feat: add extensive test suite

This commit is contained in:
2026-08-30 11:33:53 +07:00 Unverified
parent 061cce00dc
commit 8359c7272e
131 changed files with 8717 additions and 2200 deletions
+10 -26
View File
@@ -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) {
+110
View File
@@ -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);
});
});
+62
View File
@@ -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
View File
@@ -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");
},
});
}
+30
View File
@@ -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"]);
});
});
+14
View File
@@ -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();
}
+48 -22
View File
@@ -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 });
},
});
}
+57
View File
@@ -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"]);
});
});
+25
View File
@@ -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;
}
+49 -63
View File
@@ -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}`);
},
},
);
}
+101
View File
@@ -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"]);
});
});
+33 -1
View File
@@ -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();
}
+30 -47
View File
@@ -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");
}