testsAndMisc/cloud_gallery/src/api/dufs-client.test.ts
Krzysztof kuhy Rudnicki c6f1b85c78 cloud_gallery: fix build, 100% coverage, aggressive lint; whitelist dufs_client
- dufs-client.test.ts: type fetch mocks as vi.fn<typeof fetch>() (fixes the
  TS2493/TS2352 tuple/undefined build errors that broke `pnpm build`)
- dufs-client.ts: explicit method arg drops the unreachable `?? "GET"` branch
- gallery/grid: -1 viewer-index sentinel + pass media entry, removing three
  unreachable defensive guards; onOpenMedia now takes the entry
- use-hash-path: extract serverPath() so the SSR snapshot is covered
- eslint: strict+stylistic type-checked, react-hooks, reportUnusedDisable
  Directives; drop eslint-plugin-react (incompatible with ESLint 10)
- add grid.test.tsx + use-listing.test.ts; coverage thresholds 100/100/100/100
  (62 tests: statements/branches/functions/lines all 100%)
- phone_focus_mode/config.sh: whitelist com.kuhy.dufs_client

Deployed live via setup_cloud_gallery.sh (render-spa); fixed ffmpeg by
renaming a stale /usr/local/bin shadow linking the removed libvpx.so.11;
regenerated thumbnails (10 made, 0 failed). APK installed + launch-verified.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0126QvdMrv2WNeUVShFiLTc3
2026-07-12 17:29:39 +02:00

135 lines
4.8 KiB
TypeScript

import { describe, it, expect, vi } from "vitest";
import {
createDufsClient,
parsePropfind,
sortEntries,
} from "./dufs-client.ts";
import type { DirEntry } from "./types.ts";
const PROPFIND_XML = `<?xml version="1.0" encoding="utf-8"?>
<D:multistatus xmlns:D="DAV:">
<D:response>
<D:href>/Media/2026/07/</D:href>
<D:propstat><D:prop><D:resourcetype><D:collection/></D:resourcetype></D:prop></D:propstat>
</D:response>
<D:response>
<D:href>/Media/2026/07/sub/</D:href>
<D:propstat><D:prop>
<D:resourcetype><D:collection/></D:resourcetype>
<D:getlastmodified>Sat, 12 Jul 2026 03:00:00 GMT</D:getlastmodified>
</D:prop></D:propstat>
</D:response>
<D:response>
<D:href>/Media/2026/07/a%20b.jpg</D:href>
<D:propstat><D:prop>
<D:resourcetype/>
<D:getcontentlength>2048</D:getcontentlength>
<D:getlastmodified>Sat, 12 Jul 2026 03:00:00 GMT</D:getlastmodified>
</D:prop></D:propstat>
</D:response>
<D:response><D:href></D:href></D:response>
</D:multistatus>`;
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 = `<D:multistatus xmlns:D="DAV:"><D:response><D:href>/x.bin</D:href><D:propstat><D:prop><D:resourcetype/></D:prop></D:propstat></D:response></D:multistatus>`;
const [entry] = parsePropfind(xml, "/");
expect(entry?.size).toBe(0);
expect(entry?.mtimeMs).toBe(0);
});
it("coerces an unparsable size/mtime to zero", () => {
const xml = `<D:multistatus xmlns:D="DAV:"><D:response><D:href>/y.bin</D:href><D:propstat><D:prop><D:resourcetype/><D:getcontentlength>not-a-number</D:getcontentlength><D:getlastmodified>never</D:getlastmodified></D:prop></D:propstat></D:response></D:multistatus>`;
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<typeof fetch>(() =>
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<typeof fetch>());
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<typeof fetch>(() =>
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<typeof fetch>(() =>
Promise.resolve(jsonResponse("", false, 404)),
);
const client = createDufsClient(fetchImpl);
await expect(client.remove("/nope")).rejects.toThrow("404");
});
});