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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user