From 476bda02a4debda13e3a12c7c3d2177fca776e31 Mon Sep 17 00:00:00 2001 From: Steven Vandevelde Date: Fri, 11 Sep 2026 17:38:03 +0200 Subject: [PATCH] test: s3 & local input components --- src/components/input/local/common.js | 12 +- tests/components/input/local/integration.ts | 372 ++++++++++++++++++++ tests/components/input/s3/integration.ts | 346 ++++++++++++++++++ 3 files changed, 726 insertions(+), 4 deletions(-) create mode 100644 tests/components/input/local/integration.ts create mode 100644 tests/components/input/s3/integration.ts diff --git a/src/components/input/local/common.js b/src/components/input/local/common.js index 24b7a13a..92ca13bd 100644 --- a/src/components/input/local/common.js +++ b/src/components/input/local/common.js @@ -1,6 +1,5 @@ import * as IDB from "idb-keyval"; import * as URI from "fast-uri"; - import { isAudioFile } from "~/components/input/common.js"; import { safeDecodeURIComponent } from "~/common/utils.js"; import { IDB_HANDLES, SCHEME } from "./constants.js"; @@ -163,14 +162,19 @@ export async function loadHandles() { * @returns {{ tid: string; path: string } | undefined} */ export function parseURI(uriString) { + // NOTE: we can't use `new URL()` here — in browsers, non-special + // schemes like `local:` don't get an authority, so `new URL("local://tid/...").host` + // is empty and every URI would fail to parse. `fast-uri` parses the + // authority for any scheme (RFC 3986), which is also what other input + // components (e.g. s3) use. try { - const url = new URL(uriString); - if (url.protocol !== `${SCHEME}:`) return undefined; + const url = URI.parse(uriString); + if (url.scheme !== SCHEME) return undefined; if (!url.host) return undefined; return { tid: url.host, - path: safeDecodeURIComponent(url.pathname), + path: safeDecodeURIComponent(url.path || "/"), }; } catch { return undefined; diff --git a/tests/components/input/local/integration.ts b/tests/components/input/local/integration.ts new file mode 100644 index 00000000..9a45accf --- /dev/null +++ b/tests/components/input/local/integration.ts @@ -0,0 +1,372 @@ +import { describe, it } from "@std/testing/bdd"; +import { expect } from "@std/expect"; + +import { testWeb } from "@tests/common/index.ts"; + +/** + * Integration tests for the local input that verify the File System handle + * lifecycle (store → list → resolve → detach) against a real browser. + * + * These run in the browser via {@link testWeb} because the worker relies on + * IndexedDB (via idb-keyval), FileSystemHandles and `URL.createObjectURL`, + * which are only available in a DOM context. Handles are created using the + * Origin Private File System (`navigator.storage.getDirectory()`), which + * yields real, structured-cloneable `FileSystemDirectoryHandle`s — the same + * kind of handles a user picking a directory would produce. + */ +describe("components/input/local (integration)", () => { + it("list enumerates audio files from a stored directory handle, preserving cached metadata", async () => { + const result = await testWeb(async () => { + const IDB = await import("idb-keyval"); + const { IDB_HANDLES } = await import("~/components/input/local/constants.js"); + const W = await import("~/components/input/local/worker.js"); + + // Real OPFS tree: tid-aaa/music/{song.mp3, cover.jpg, notes.txt} and + // tid-aaa/music/nested/song.flac. + const storage = await navigator.storage.getDirectory(); + const library = await storage.getDirectoryHandle("tid-aaa", { create: true }); + const music = await library.getDirectoryHandle("music", { create: true }); + const nested = await music.getDirectoryHandle("nested", { create: true }); + + const a = await music.getFileHandle("song.mp3", { create: true }); + const aw = await a.createWritable(); + await aw.write(new TextEncoder().encode("audio-bytes-1")); + await aw.close(); + + const b = await music.getFileHandle("cover.jpg", { create: true }); + const bw = await b.createWritable(); + await bw.write(new TextEncoder().encode("image-bytes")); + await bw.close(); + + const c = await music.getFileHandle("notes.txt", { create: true }); + const cw = await c.createWritable(); + await cw.write(new TextEncoder().encode("not audio")); + await cw.close(); + + const d = await nested.getFileHandle("song.flac", { create: true }); + const dw = await d.createWritable(); + await dw.write(new TextEncoder().encode("audio-bytes-2")); + await dw.close(); + + await IDB.set(IDB_HANDLES, { "tid-aaa": library }); + + return await W.list([{ + $type: "sh.diffuse.output.track", + id: "c1", + uri: "local://tid-aaa/music/song.mp3", + stats: { duration: 183000 }, + tags: { title: "Cached Song" }, + }]); + }); + + // Only audio files become tracks; notes.txt and cover.jpg are excluded. + expect(result.length).toBe(2); + const uris = result.map((t) => t.uri); + expect(uris).toContain("local://tid-aaa/music/song.mp3"); + expect(uris).toContain("local://tid-aaa/music/nested/song.flac"); + expect(uris.some((u) => u.includes("notes.txt"))).toBe(false); + + // Cached stats/tags come back on the refreshed track. + const song = result.find((t) => t.uri.endsWith("song.mp3")); + expect(song).toBeDefined(); + if (song) { + expect(song.id).toBe("c1"); + expect(song.stats).toEqual({ duration: 183000 }); + expect(song.tags).toEqual({ title: "Cached Song" }); + } + }); + + it("list returns a placeholder track when the directory has no audio files", async () => { + const result = await testWeb(async () => { + const IDB = await import("idb-keyval"); + const { IDB_HANDLES } = await import("~/components/input/local/constants.js"); + const W = await import("~/components/input/local/worker.js"); + + const storage = await navigator.storage.getDirectory(); + const library = await storage.getDirectoryHandle("tid-aaa", { create: true }); + await IDB.set(IDB_HANDLES, { "tid-aaa": library }); + + return await W.list([{ + $type: "sh.diffuse.output.track", + id: "p1", + kind: "placeholder", + uri: "local://tid-aaa/", + }]); + }); + + expect(result.length).toBe(1); + expect(result[0].kind).toBe("placeholder"); + expect(result[0].uri).toBe("local://tid-aaa/"); + // The empty-directory placeholder always gets a fresh id. + expect(typeof result[0].id).toBe("string"); + expect(result[0].id).not.toBe(""); + }); + + it("list keeps cached tracks for a TID that has no stored handle", async () => { + const result = await testWeb(async () => { + const W = await import("~/components/input/local/worker.js"); + + return await W.list([{ + $type: "sh.diffuse.output.track", + id: "c2", + uri: "local://tid-unknown/song.mp3", + }]); + }); + + expect(result.length).toBe(1); + expect(result[0].id).toBe("c2"); + expect(result[0].uri).toBe("local://tid-unknown/song.mp3"); + }); + + it("list and resolve work with a single stored file handle", async () => { + const result = await testWeb(async () => { + const IDB = await import("idb-keyval"); + const { IDB_HANDLES } = await import("~/components/input/local/constants.js"); + const W = await import("~/components/input/local/worker.js"); + + const storage = await navigator.storage.getDirectory(); + const file = await storage.getFileHandle("track.mp3", { create: true }); + const writable = await file.createWritable(); + await writable.write(new TextEncoder().encode("audio-bytes")); + await writable.close(); + + await IDB.set(IDB_HANDLES, { "tid-file": file }); + + const listed = await W.list([{ + $type: "sh.diffuse.output.track", + id: "c3", + uri: "local://tid-file/", + }]); + const resolved = await W.resolve({ uri: "local://tid-file/" }); + + return { + listed, + blobUrl: resolved && "url" in resolved ? resolved.url : null, + expiresAtIsInfinity: resolved !== undefined && "expiresAt" in resolved + ? resolved.expiresAt === Infinity + : false, + fetchedText: resolved && "url" in resolved + ? await fetch(resolved.url).then((r) => r.text()) + : null, + }; + }); + + expect(result.listed.length).toBe(1); + expect(result.listed[0].id).toBe("c3"); + expect(result.listed[0].uri).toBe("local://tid-file/"); + + expect(result.blobUrl).toMatch(/^blob:/); + expect(result.expiresAtIsInfinity).toBe(true); + expect(result.fetchedText).toBe("audio-bytes"); + }); + + it("resolve creates a fetchable blob URL for a file in a stored directory handle", async () => { + const result = await testWeb(async () => { + const IDB = await import("idb-keyval"); + const { IDB_HANDLES } = await import("~/components/input/local/constants.js"); + const W = await import("~/components/input/local/worker.js"); + + const storage = await navigator.storage.getDirectory(); + const library = await storage.getDirectoryHandle("tid-aaa", { create: true }); + const music = await library.getDirectoryHandle("music", { create: true }); + const a = await music.getFileHandle("a.mp3", { create: true }); + const aw = await a.createWritable(); + await aw.write(new TextEncoder().encode("audio-bytes")); + await aw.close(); + + await IDB.set(IDB_HANDLES, { "tid-aaa": library }); + + const resolved = await W.resolve({ uri: "local://tid-aaa/music/a.mp3" }); + return { + blobUrl: resolved && "url" in resolved ? resolved.url : null, + expiresAtIsInfinity: resolved !== undefined && "expiresAt" in resolved + ? resolved.expiresAt === Infinity + : false, + fetchedText: resolved && "url" in resolved + ? await fetch(resolved.url).then((r) => r.text()) + : null, + }; + }); + + expect(result.blobUrl).toMatch(/^blob:/); + expect(result.expiresAtIsInfinity).toBe(true); + expect(result.fetchedText).toBe("audio-bytes"); + }); + + it("resolve returns undefined for the directory URI itself", async () => { + const result = await testWeb(async () => { + const IDB = await import("idb-keyval"); + const { IDB_HANDLES } = await import("~/components/input/local/constants.js"); + const W = await import("~/components/input/local/worker.js"); + + const storage = await navigator.storage.getDirectory(); + const library = await storage.getDirectoryHandle("tid-aaa", { create: true }); + await IDB.set(IDB_HANDLES, { "tid-aaa": library }); + + return await W.resolve({ uri: "local://tid-aaa/" }); + }); + + expect(result).toBe(undefined); + }); + + it("detach with a specific URI removes that TID's tracks and its stored handle", async () => { + const result = await testWeb(async () => { + const IDB = await import("idb-keyval"); + const { IDB_HANDLES } = await import("~/components/input/local/constants.js"); + const W = await import("~/components/input/local/worker.js"); + + const storage = await navigator.storage.getDirectory(); + const a = await storage.getDirectoryHandle("tid-aaa", { create: true }); + const b = await storage.getDirectoryHandle("tid-bbb", { create: true }); + await IDB.set(IDB_HANDLES, { "tid-aaa": a, "tid-bbb": b }); + + const remaining = await W.detach({ + fileUriOrScheme: "local://tid-aaa/track1.mp3", + tracks: [ + { $type: "sh.diffuse.output.track", id: "1", uri: "local://tid-aaa/track1.mp3" }, + { $type: "sh.diffuse.output.track", id: "2", uri: "local://tid-bbb/track2.mp3" }, + ], + }); + const handlesAfter = await IDB.get(IDB_HANDLES); + + return { + remaining, + remainingTids: Object.keys(handlesAfter ?? {}), + }; + }); + + expect(result.remaining.length).toBe(1); + expect(result.remaining[0].id).toBe("2"); + // The detached TID's handle is removed from IndexedDB; the other stays. + expect(result.remainingTids).toEqual(["tid-bbb"]); + }); + + it("detach with the local scheme removes all local tracks", async () => { + const result = await testWeb(async () => { + const W = await import("~/components/input/local/worker.js"); + + return await W.detach({ + fileUriOrScheme: "local", + tracks: [ + { $type: "sh.diffuse.output.track", id: "1", uri: "local://tid-aaa/track1.mp3" }, + { $type: "sh.diffuse.output.track", id: "2", uri: "local://tid-bbb/track2.mp3" }, + ], + }); + }); + + expect(result.length).toBe(0); + }); + + it("artwork returns the cover image bytes from the track's directory", async () => { + const result = await testWeb(async () => { + const IDB = await import("idb-keyval"); + const { IDB_HANDLES } = await import("~/components/input/local/constants.js"); + const W = await import("~/components/input/local/worker.js"); + + const storage = await navigator.storage.getDirectory(); + const library = await storage.getDirectoryHandle("tid-aaa", { create: true }); + const music = await library.getDirectoryHandle("music", { create: true }); + + const track = await music.getFileHandle("track.mp3", { create: true }); + const tw = await track.createWritable(); + await tw.write(new TextEncoder().encode("audio-bytes")); + await tw.close(); + + const cover = await music.getFileHandle("cover.jpg", { create: true }); + const cw = await cover.createWritable(); + await cw.write(new TextEncoder().encode("image-bytes-cover")); + await cw.close(); + + const folder = await music.getFileHandle("folder.png", { create: true }); + const fw = await folder.createWritable(); + await fw.write(new TextEncoder().encode("image-bytes-folder")); + await fw.close(); + + await IDB.set(IDB_HANDLES, { "tid-aaa": library }); + + const art = await W.artwork("local://tid-aaa/music/track.mp3"); + return art ? new TextDecoder().decode(art) : null; + }); + + // cover.jpg is preferred over folder.png. + expect(result).toBe("image-bytes-cover"); + }); + + it("artwork returns null when the directory has no images", async () => { + const result = await testWeb(async () => { + const IDB = await import("idb-keyval"); + const { IDB_HANDLES } = await import("~/components/input/local/constants.js"); + const W = await import("~/components/input/local/worker.js"); + + const storage = await navigator.storage.getDirectory(); + const library = await storage.getDirectoryHandle("tid-aaa", { create: true }); + const music = await library.getDirectoryHandle("music", { create: true }); + const track = await music.getFileHandle("track.mp3", { create: true }); + const writable = await track.createWritable(); + await writable.write(new TextEncoder().encode("audio-bytes")); + await writable.close(); + + await IDB.set(IDB_HANDLES, { "tid-aaa": library }); + + return await W.artwork("local://tid-aaa/music/track.mp3"); + }); + + expect(result).toBe(null); + }); + + it("consult reports availability based on the stored handle's permission", async () => { + const result = await testWeb(async () => { + const IDB = await import("idb-keyval"); + const { IDB_HANDLES } = await import("~/components/input/local/constants.js"); + const W = await import("~/components/input/local/worker.js"); + + // The browser needs to report File System Access support for consult + // to get past its isSupported() check. + const storage = await navigator.storage.getDirectory(); + globalThis.showDirectoryPicker = async () => storage; + + const library = await storage.getDirectoryHandle("tid-aaa", { create: true }); + await IDB.set(IDB_HANDLES, { "tid-aaa": library }); + + const granted = await W.consult("local://tid-aaa/"); + const unknown = await W.consult("local://tid-unknown/"); + + return { + granted: granted.supported ? granted.consult : "unsupported", + unknownSupported: unknown.supported, + }; + }); + + // OPFS handles are always readable by default, so consult says "yes". + expect(result.granted).toBe("yes"); + expect(result.unknownSupported).toBe(false); + }); + + it("groupConsult groups URIs by TID and skips unknown TIDs", async () => { + const result = await testWeb(async () => { + const IDB = await import("idb-keyval"); + const { IDB_HANDLES } = await import("~/components/input/local/constants.js"); + const W = await import("~/components/input/local/worker.js"); + + const storage = await navigator.storage.getDirectory(); + globalThis.showDirectoryPicker = async () => storage; + + const library = await storage.getDirectoryHandle("tid-aaa", { create: true }); + await IDB.set(IDB_HANDLES, { "tid-aaa": library }); + + return await W.groupConsult([ + "local://tid-aaa/a.mp3", + "local://tid-aaa/b.mp3", + "local://tid-unknown/x.mp3", + ]); + }); + + expect(Object.keys(result)).toEqual(["local://tid-aaa"]); + expect(result["local://tid-aaa"].available).toBe("yes"); + expect(result["local://tid-aaa"].scheme).toBe("local"); + expect(result["local://tid-aaa"].uris).toEqual([ + "local://tid-aaa/a.mp3", + "local://tid-aaa/b.mp3", + ]); + }); +}); \ No newline at end of file diff --git a/tests/components/input/s3/integration.ts b/tests/components/input/s3/integration.ts new file mode 100644 index 00000000..c9987be4 --- /dev/null +++ b/tests/components/input/s3/integration.ts @@ -0,0 +1,346 @@ +import { afterAll, beforeAll, describe, it } from "@std/testing/bdd"; +import { expect } from "@std/expect"; + +import type { Bucket } from "@specs/components/input/s3/types.d.ts"; +import type { Track } from "~/definitions/types.d.ts"; +import * as Worker from "~/components/input/s3/worker.js"; +import { buildURI, parseURI } from "~/components/input/s3/common.js"; + +/** + * Integration tests for the s3 input that verify the worker's S3 API usage + * (list, consult, resolve, artwork, detach) against an in-memory mock S3 + * endpoint. + * + * The worker talks to S3 through `@bradenmacdonald/s3-lite-client`, which + * performs its HTTP requests using the global `fetch`. We intercept `fetch` + * and respond with S3 API XML (`ListBucketResult` / `Error`), so no real + * network, endpoints or credentials are involved. + */ + +type StoredObject = { key: string; bytes: Uint8Array }; + +type MockBucket = { + bucketName: string; + host: string; + objects: StoredObject[]; + /** When set, the mock endpoint fails all requests (transient network blip). */ + failRequests?: boolean; +}; + +const mockBuckets: MockBucket[] = []; + +function listResultXml(bucketName: string, keys: string[]): string { + const contents = keys.map((key) => + ` + ${key} + 2026-01-01T00:00:00.000Z + "a1b2c3d4" + 1024 + STANDARD + ` + ).join("\n"); + + return ` + + ${bucketName} + + ${keys.length} + 1000 + false +${contents} +`; +} + +function errorXml(code: string, message: string): string { + return ` + + ${code} + ${message} +`; +} + +function registerMockBucket(bucket: Bucket, objects: StoredObject[]): MockBucket { + const mock: MockBucket = { bucketName: bucket.bucketName, host: bucket.host, objects }; + mockBuckets.push(mock); + return mock; +} + +const ACCESS_KEY = "AKIAINTEGRATIONTEST"; +const SECRET_KEY = "integration-secret"; + +function makeBucket(host: string, bucketName = "music"): Bucket { + return { + accessKey: ACCESS_KEY, + secretKey: SECRET_KEY, + bucketName, + host, + path: "/", + region: "us-east-1", + }; +} + +let originalFetch: typeof globalThis.fetch; + +beforeAll(() => { + originalFetch = globalThis.fetch; + + globalThis.fetch = (async (input: string | URL | Request, _init?: RequestInit) => { + const url = new URL( + typeof input === "string" ? input : input instanceof URL ? input.href : input.url, + ); + + // The client uses virtual-host style requests: https://bucket.host/... + const mock = mockBuckets.find((b) => url.hostname === `${b.bucketName}.${b.host}`); + + if (!mock) { + return new Response( + errorXml("NoSuchBucket", "The specified bucket does not exist."), + { status: 404, headers: { "content-type": "application/xml" } }, + ); + } + + if (mock.failRequests) { + throw new TypeError("fetch failed"); + } + + // ListObjectsV2 — used by list (no prefix) and artwork (directory prefix). + if (url.pathname === "/") { + const prefix = url.searchParams.get("prefix") ?? ""; + const keys = mock.objects.filter((o) => o.key.startsWith(prefix)) + .map((o) => o.key); + return new Response(listResultXml(mock.bucketName, keys), { + status: 200, + headers: { "content-type": "application/xml" }, + }); + } + + // Object fetch through a presigned URL (artwork). + const key = url.pathname.replace(/^\//, ""); + const object = mock.objects.find((o) => o.key === key); + if (object) { + return new Response(object.bytes, { status: 200 }); + } + + return new Response( + errorXml("NoSuchKey", "The specified key does not exist."), + { status: 404, headers: { "content-type": "application/xml" } }, + ); + }) as typeof globalThis.fetch; +}); + +afterAll(() => { + globalThis.fetch = originalFetch; +}); + +describe("components/input/s3 (integration)", () => { + it("list returns tracks for the audio objects in a bucket, preserving cached metadata", async () => { + const bucket = makeBucket("list.example.com"); + registerMockBucket(bucket, [ + { key: "albums-a/track1.mp3", bytes: new Uint8Array(1) }, + { key: "albums-a/track2.flac", bytes: new Uint8Array(1) }, + { key: "albums-a/cover.jpg", bytes: new Uint8Array(1) }, + { key: "albums-a/notes.txt", bytes: new Uint8Array(1) }, + ]); + + const cached: Track[] = [{ + $type: "sh.diffuse.output.track", + id: "c1", + uri: buildURI(bucket, "/albums-a/track1.mp3"), + stats: { duration: 183000 }, + tags: { title: "Cached Song" }, + }]; + + const tracks = await Worker.list(cached); + + // Only audio objects become tracks; non-audio objects are filtered out. + expect(tracks.length).toBe(2); + const paths = tracks.map((t) => parseURI(t.uri)?.path); + expect(paths).toContain("albums-a/track1.mp3"); + expect(paths).toContain("albums-a/track2.flac"); + expect(paths.some((p) => p?.includes("cover.jpg"))).toBe(false); + expect(paths.some((p) => p?.includes("notes.txt"))).toBe(false); + + // Cached stats/tags come back on the refreshed track. + const cachedTrack = tracks.find((t) => t.id === "c1"); + expect(cachedTrack).toBeDefined(); + if (cachedTrack) { + expect(cachedTrack.stats).toEqual({ duration: 183000 }); + expect(cachedTrack.tags).toEqual({ title: "Cached Song" }); + } + }); + + it("list returns a placeholder track when the bucket has no audio objects", async () => { + const bucket = makeBucket("empty.example.com"); + registerMockBucket(bucket, [{ key: "readme.txt", bytes: new Uint8Array(1) }]); + + const tracks = await Worker.list([{ + $type: "sh.diffuse.output.track", + id: "p1", + kind: "placeholder", + uri: buildURI(bucket), + }]); + + expect(tracks.length).toBe(1); + expect(tracks[0].kind).toBe("placeholder"); + expect(parseURI(tracks[0].uri)?.path).toBe(""); + }); + + it("resolve returns a presigned GET URL with a one-week expiry", async () => { + const bucket = makeBucket("resolve.example.com"); + const resolved = await Worker.resolve({ + uri: buildURI(bucket, "/albums-a/track1.mp3"), + }); + + expect(resolved).not.toBe(undefined); + if (resolved && "url" in resolved) { + expect(resolved.url).toContain(`https://${bucket.bucketName}.${bucket.host}`); + expect(resolved.url).toContain("/albums-a/track1.mp3"); + expect(resolved.url).toContain("X-Amz-Expires=604800"); + expect(resolved.url).toContain("X-Amz-Credential="); + expect(resolved.url).toContain("X-Amz-Signature="); + + const nowSeconds = Math.round(Date.now() / 1000); + const weekSeconds = 7 * 24 * 60 * 60; + expect(resolved.expiresAt).toBeGreaterThan(nowSeconds + weekSeconds - 10); + expect(resolved.expiresAt).toBeLessThanOrEqual(nowSeconds + weekSeconds); + } + }); + + it("resolve returns undefined for an unparseable URI", async () => { + const resolved = await Worker.resolve({ + uri: "s3://no-credentials.example.com/track.mp3?bucketName=music®ion=us-east-1", + }); + expect(resolved).toBe(undefined); + }); + + it("consult returns yes for an existing bucket", async () => { + const bucket = makeBucket("consult-yes.example.com"); + registerMockBucket(bucket, []); + + const result = await Worker.consult(buildURI(bucket)); + expect(result.supported).toBe(true); + if (result.supported) { + expect(result.consult).toBe("yes"); + } + }); + + it("consult returns no for a bucket that does not exist", async () => { + const bucket = makeBucket("consult-no.example.com"); + // No bucket registered — the mock endpoint answers 404 NoSuchBucket. + + const result = await Worker.consult(buildURI(bucket)); + expect(result.supported).toBe(true); + if (result.supported) { + expect(result.consult).toBe("no"); + } + }); + + it("consult retries after an inconclusive network failure (unsure is not cached)", async () => { + const bucket = makeBucket("consult-retry.example.com"); + const mock = registerMockBucket(bucket, []); + + // First consult: the endpoint errors out (transient network blip). + mock.failRequests = true; + const first = await Worker.consult(buildURI(bucket)); + expect(first.supported).toBe(true); + if (first.supported) { + // An inconclusive consult is surfaced as "no" to callers... + expect(first.consult).toBe("no"); + } + + // Second consult: endpoint healthy again. The inconclusive result was + // *not* cached, so this consults the endpoint afresh instead of waiting + // out the consult TTL. + mock.failRequests = false; + const second = await Worker.consult(buildURI(bucket)); + expect(second.supported).toBe(true); + if (second.supported) { + expect(second.consult).toBe("yes"); + } + }); + + it("groupConsult groups URIs by bucket with availability", async () => { + const bucket = makeBucket("group.example.com"); + registerMockBucket(bucket, []); + + const uris = [ + buildURI(bucket, "/a/track1.mp3"), + buildURI(bucket, "/a/track2.mp3"), + ]; + const result = await Worker.groupConsult(uris); + + const key = `s3://${bucket.accessKey}:${bucket.secretKey}@${bucket.host}`; + expect(Object.keys(result)).toEqual([key]); + expect(result[key].available).toBe("yes"); + expect(result[key].scheme).toBe("s3"); + expect(result[key].uris).toEqual(uris); + }); + + it("groupConsult reports unavailable for an unknown bucket", async () => { + const bucket = makeBucket("group-missing.example.com"); + const uri = buildURI(bucket, "/x.mp3"); + const result = await Worker.groupConsult([uri]); + + const key = `s3://${bucket.accessKey}:${bucket.secretKey}@${bucket.host}`; + expect(Object.keys(result)).toEqual([key]); + expect(result[key].available).toBe("no"); + expect(result[key].reason).toBe("Bucket unavailable"); + }); + + it("artwork returns the cover image from the track's directory", async () => { + const bucket = makeBucket("artwork.example.com"); + registerMockBucket(bucket, [ + { key: "albums-a/track1.mp3", bytes: new Uint8Array(1) }, + { key: "albums-a/cover.jpg", bytes: new TextEncoder().encode("fake-jpeg-bytes") }, + ]); + + const art = await Worker.artwork(buildURI(bucket, "/albums-a/track1.mp3")); + expect(art).not.toBe(null); + if (art) { + expect(new TextDecoder().decode(art)).toBe("fake-jpeg-bytes"); + } + }); + + it("artwork returns null when there are no images in the directory", async () => { + const noImages = makeBucket("artwork-none.example.com"); + registerMockBucket(noImages, [ + { key: "albums-a/track1.mp3", bytes: new Uint8Array(1) }, + ]); + expect(await Worker.artwork(buildURI(noImages, "/albums-a/track1.mp3"))).toBe(null); + + // Images nested in subdirectories don't count — only the track's own directory. + const nested = makeBucket("artwork-nested.example.com"); + registerMockBucket(nested, [ + { key: "albums-a/track1.mp3", bytes: new Uint8Array(1) }, + { key: "albums-a/sub/cover.jpg", bytes: new Uint8Array(1) }, + ]); + expect(await Worker.artwork(buildURI(nested, "/albums-a/track1.mp3"))).toBe(null); + }); + + it("detach with a specific bucket URI removes only that bucket's tracks", async () => { + const bucketA = makeBucket("detach-a.example.com"); + const bucketB = makeBucket("detach-b.example.com"); + + const tracks: Track[] = [ + { $type: "sh.diffuse.output.track", id: "1", uri: buildURI(bucketA, "/x/a.mp3") }, + { $type: "sh.diffuse.output.track", id: "2", uri: buildURI(bucketB, "/x/b.mp3") }, + ]; + const remaining = await Worker.detach({ + fileUriOrScheme: buildURI(bucketA, "/x/a.mp3"), + tracks, + }); + + expect(remaining.length).toBe(1); + expect(remaining[0].id).toBe("2"); + }); + + it("detach with the s3 scheme removes all s3 tracks", async () => { + const bucket = makeBucket("detach-scheme.example.com"); + const tracks: Track[] = [ + { $type: "sh.diffuse.output.track", id: "1", uri: buildURI(bucket, "/a.mp3") }, + { $type: "sh.diffuse.output.track", id: "2", uri: buildURI(bucket, "/b.mp3") }, + ]; + const remaining = await Worker.detach({ fileUriOrScheme: "s3", tracks }); + expect(remaining.length).toBe(0); + }); +}); \ No newline at end of file -- 2.51.2