import { describe, it, expect, vi } from "vitest";
import {
createDufsClient,
parsePropfind,
sortEntries,
} from "./dufs-client.ts";
import type { DirEntry } from "./types.ts";
const PROPFIND_XML = `
/Media/2026/07/
/Media/2026/07/sub/
Sat, 12 Jul 2026 03:00:00 GMT
/Media/2026/07/a%20b.jpg
2048
Sat, 12 Jul 2026 03:00:00 GMT
`;
describe("parsePropfind", () => {
it("parses entries, skips self, decodes names, reads size/mtime", () => {
const entries = parsePropfind(PROPFIND_XML, "/Media/2026/07");
// self ("/Media/2026/07/") and the empty-href entry are dropped
expect(entries.map((e) => e.name)).toEqual(["sub", "a b.jpg"]);
const file = entries.find((e) => e.name === "a b.jpg");
expect(file?.kind).toBe("file");
expect(file?.size).toBe(2048);
expect(file?.mtimeMs).toBeGreaterThan(0);
expect(entries.find((e) => e.name === "sub")?.kind).toBe("dir");
});
it("handles missing size/mtime as zero", () => {
const xml = `/x.bin`;
const [entry] = parsePropfind(xml, "/");
expect(entry?.size).toBe(0);
expect(entry?.mtimeMs).toBe(0);
});
it("coerces an unparsable size/mtime to zero", () => {
const xml = `/y.binnot-a-numbernever`;
const [entry] = parsePropfind(xml, "/");
expect(entry?.size).toBe(0);
expect(entry?.mtimeMs).toBe(0);
});
});
describe("sortEntries", () => {
it("dirs first then case-insensitive name", () => {
const mk = (name: string, kind: "dir" | "file"): DirEntry => ({
name,
path: `/${name}`,
kind,
size: 0,
mtimeMs: 0,
});
const sorted = sortEntries([
mk("banana.jpg", "file"),
mk("Apple", "dir"),
mk("apple.png", "file"),
mk("Zeta", "dir"),
]);
expect(sorted.map((e) => e.name)).toEqual([
"Apple",
"Zeta",
"apple.png",
"banana.jpg",
]);
});
});
function jsonResponse(body: string, ok = true, status = 200): Response {
return {
ok,
status,
text: () => Promise.resolve(body),
} as unknown as Response;
}
describe("createDufsClient", () => {
it("list() PROPFINDs, sorts, and returns entries", async () => {
const fetchImpl = vi.fn(() =>
Promise.resolve(jsonResponse(PROPFIND_XML)),
);
const client = createDufsClient(fetchImpl);
const entries = await client.list("/Media/2026/07");
const firstCall = fetchImpl.mock.calls.at(0);
expect(firstCall?.[0]).toBe("/Media/2026/07");
expect(firstCall?.[1]?.method).toBe("PROPFIND");
expect(entries.length).toBe(2);
});
it("builds file and thumbnail URLs (encoded)", () => {
const client = createDufsClient(vi.fn());
expect(client.fileUrl("/Media/a b.jpg")).toBe("/Media/a%20b.jpg");
expect(client.thumbUrl("/Media/a b.jpg")).toBe(
"/.thumbs/Media/a%20b.jpg.jpg",
);
});
it("upload PUTs to dir/name, remove DELETEs, writeText PUTs, readText GETs", async () => {
const fetchImpl = vi.fn(() =>
Promise.resolve(jsonResponse("content")),
);
const client = createDufsClient(fetchImpl);
const file = new File(["x"], "n.txt");
await client.upload("/dir", file);
await client.remove("/dir/n.txt");
await client.writeText("/dir/n.txt", "hi");
const text = await client.readText("/dir/n.txt");
expect(text).toBe("content");
const methods = fetchImpl.mock.calls.map((c) => c[1]?.method);
expect(methods).toEqual(["PUT", "DELETE", "PUT", "GET"]);
expect(fetchImpl.mock.calls.at(0)?.[0]).toBe("/dir/n.txt");
});
it("throws on non-ok responses", async () => {
const fetchImpl = vi.fn(() =>
Promise.resolve(jsonResponse("", false, 404)),
);
const client = createDufsClient(fetchImpl);
await expect(client.remove("/nope")).rejects.toThrow("404");
});
});