diff --git a/deno.jsonc b/deno.jsonc index dc3b0062..4ca883da 100644 --- a/deno.jsonc +++ b/deno.jsonc @@ -44,7 +44,7 @@ "bs58check": "npm:bs58check@^4.0.0", "codemirror": "npm:codemirror@^6.0.2", "fast-average-color": "npm:fast-average-color@^9.5.0", - "fast-uri": "npm:fast-uri@^3.1.0", + "fast-uri": "./src/common/fast-uri.js", "idb-keyval": "npm:idb-keyval@^6.2.2", "kmenu": "npm:kmenu@^2.0.3", "iso-base": "npm:iso-base@^4.3.0", @@ -196,6 +196,7 @@ "./common/element.d.ts": "./src/common/element.d.ts", "./common/signal.d.ts": "./src/common/signal.d.ts", "./common/worker.d.ts": "./src/common/worker.d.ts", + "./common/fast-uri.js": "./src/common/fast-uri.js", "./components/artwork/types.d.ts": "./specs/components/artwork/types.d.ts", "./components/configurator/artwork/types.d.ts": "./specs/components/configurator/artwork/types.d.ts", "./components/configurator/input/types.d.ts": "./specs/components/configurator/input/types.d.ts", diff --git a/src/common/fast-uri.js b/src/common/fast-uri.js new file mode 100644 index 00000000..cf40b655 --- /dev/null +++ b/src/common/fast-uri.js @@ -0,0 +1,16 @@ +// ESM shim for fast-uri. +// +// Deno's npm:CJS interop puts `module.exports` on the `default` export but +// does not hoist properties to namespace members — so `import * as URI from +// "fast-uri"` gives `URI.parse === undefined` in Deno. In esbuild (browser +// build) the hoisting works, but we use this shim so both runtimes see the +// same named-export interface. +// +// Mapped via `deno.jsonc` → `"fast-uri": "./src/common/fast-uri.js"`. + +import fastUri from "npm:fast-uri@^3.1.0"; + +export const parse = fastUri.parse; +export const serialize = fastUri.serialize; + +export default fastUri; diff --git a/src/common/loader.js b/src/common/loader.js index eee42d9a..7e868878 100644 --- a/src/common/loader.js +++ b/src/common/loader.js @@ -68,7 +68,7 @@ export function createLoader(config) { /** @type {string | null} */ let loader = null; - effect(async () => { + effect(() => { /** @type {LoadableItem | undefined} */ let item = undefined; @@ -115,7 +115,7 @@ export function createLoader(config) { } // Make sure HTML is loaded when a URI is specified - await ensureHTML(item).catch((err) => { + ensureHTML(item).catch((err) => { if (swControllerChanging) return; renderError(container, `Failed to load URI: ${item.uri}`, { context: err, diff --git a/tests/common/server.ts b/tests/common/server.ts new file mode 100644 index 00000000..e7394ee8 --- /dev/null +++ b/tests/common/server.ts @@ -0,0 +1,61 @@ +/** + * Helpers for standing up in-process HTTP mock servers for integration tests. + * Each helper returns the running `Deno.HttpServer` plus its bound port so + * tests can construct URLs that point back at the mock. + */ + +/** + * Start a mock HTTP server and call the provided handler for each request. + * Resolves with the running server and its port. + * + * @param {(req: Request, url: URL) => Response | Promise} handler + * @returns {Promise<{ server: Deno.HttpServer; port: number }>} + * + * @example + * ```ts + * import { mockServer } from "@tests/common/server.ts"; + * import { expect } from "@std/expect"; + * + * const { server, port } = await mockServer((_req, url) => { + * if (url.pathname === "/hello") return new Response("hi"); + * return new Response("", { status: 404 }); + * }); + * + * const resp = await fetch(`http://localhost:${port}/hello`); + * expect(await resp.text()).toBe("hi"); + * await server.shutdown(); + * ``` + */ +export async function mockServer( + handler: (req: Request, url: URL) => Response | Promise, +): Promise<{ server: Deno.HttpServer; port: number }> { + const server = Deno.serve( + { port: 0, hostname: "127.0.0.1" }, + async (req: Request): Promise => { + const url = new URL(req.url); + + if (req.method === "OPTIONS") { + return new Response(null, { + status: 204, + headers: { + "Access-Control-Allow-Origin": "*", + "Access-Control-Allow-Headers": "*", + "Access-Control-Allow-Methods": "GET, HEAD, POST, PROPFIND, OPTIONS", + }, + }); + } + + const response = await Promise.resolve(handler(req, url)); + response.headers.set("Access-Control-Allow-Origin", "*"); + response.headers.set("Access-Control-Allow-Headers", "*"); + response.headers.set( + "Access-Control-Allow-Methods", + "GET, HEAD, POST, PROPFIND, OPTIONS", + ); + return response; + }, + ); + + const port = (server.addr as Deno.NetAddr).port; + return { server, port }; +} diff --git a/tests/components/input/dropbox/integration.ts b/tests/components/input/dropbox/integration.ts new file mode 100644 index 00000000..f5a2ecbb --- /dev/null +++ b/tests/components/input/dropbox/integration.ts @@ -0,0 +1,232 @@ +import { describe, it, beforeAll, afterAll } from "@std/testing/bdd"; +import { expect } from "@std/expect"; + +import { mockServer } from "@tests/common/server.ts"; +import type { Track } from "~/definitions/types.d.ts"; +import * as Worker from "~/components/input/dropbox/worker.js"; +import { buildURI, parseURI } from "~/components/input/dropbox/common.js"; + +// Mock Dropbox API server. +// The dropbox worker hardcodes `https://api.dropboxapi.com/...` URLs, so we +// monkey-patch `globalThis.fetch` before each test to redirect those calls +// to our in-process mock server. + +const FILES = [ + { ".tag": "file", name: "song1.mp3", path_lower: "/music/song1.mp3" }, + { ".tag": "file", name: "song2.flac", path_lower: "/music/song2.flac" }, + { ".tag": "file", name: "readme.txt", path_lower: "/readme.txt" }, + { ".tag": "folder", name: "music", path_lower: "/music" }, +]; + +let server: Deno.HttpServer; +let port: number; +let originalFetch: typeof globalThis.fetch; + +beforeAll(async () => { + const started = await mockServer((req, url) => { + const p = url.pathname; + + // GET /2/users/get_current_account — validates the access token + if (p === "/2/users/get_current_account") { + const auth = req.headers.get("authorization"); + if (auth === "Bearer valid-token") { + return new Response(JSON.stringify({ account_id: "dbid:123" }), { + status: 200, + headers: { "content-type": "application/json" }, + }); + } + return new Response("", { status: 401 }); + } + + // POST /2/files/list_folder — list files in a directory + if (p === "/2/files/list_folder") { + const auth = req.headers.get("authorization"); + if (auth !== "Bearer valid-token") { + return new Response("", { status: 401 }); + } + return new Response( + JSON.stringify({ + entries: FILES, + has_more: false, + cursor: null, + }), + { status: 200, headers: { "content-type": "application/json" } }, + ); + } + + // POST /2/files/list_folder/continue — paginate + if (p === "/2/files/list_folder/continue") { + return new Response( + JSON.stringify({ entries: [], has_more: false, cursor: null }), + { status: 200, headers: { "content-type": "application/json" } }, + ); + } + + // POST /2/files/get_temporary_link — get a temporary download link + if (p === "/2/files/get_temporary_link") { + const auth = req.headers.get("authorization"); + if (auth !== "Bearer valid-token") { + return new Response("", { status: 401 }); + } + return new Response( + JSON.stringify({ + link: `http://127.0.0.1:${port}/dl/temp-link`, + metadata: { name: "song1.mp3" }, + }), + { status: 200, headers: { "content-type": "application/json" } }, + ); + } + + // The temporary download link itself + if (p === "/dl/temp-link") { + return new Response(new Uint8Array(64), { + status: 200, + headers: { "content-type": "audio/mpeg" }, + }); + } + + return new Response("", { status: 404 }); + }); + server = started.server; + port = started.port; + + // Monkey-patch fetch to redirect Dropbox API calls to our mock. + originalFetch = globalThis.fetch; + globalThis.fetch = ((input: string | URL | Request, init?: RequestInit) => { + const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url; + const redirected = url + .replace("https://api.dropboxapi.com", `http://127.0.0.1:${port}`) + .replace("https://content.dropboxapi.com", `http://127.0.0.1:${port}`); + return originalFetch(redirected, init); + }) as typeof globalThis.fetch; +}); + +afterAll(async () => { + globalThis.fetch = originalFetch; + await server.shutdown(); +}); + +describe("components/input/dropbox (integration)", () => { + it("consult returns true for a valid access token", async () => { + const uri = buildURI({ accessToken: "valid-token", directoryPath: "/" }, "/"); + const result = await Worker.consult(uri); + expect(result.supported).toBe(true); + if (result.supported) { + expect(result.consult).toBe(true); + } + }); + + it("consult returns false for an invalid access token", async () => { + const uri = buildURI({ accessToken: "invalid-token", directoryPath: "/" }, "/"); + const result = await Worker.consult(uri); + expect(result.supported).toBe(true); + if (result.supported) { + expect(result.consult).toBe(false); + } + }); + + it("list returns audio files from Dropbox", async () => { + const account = { accessToken: "valid-token", directoryPath: "/" }; + const uri = buildURI(account, "/"); + const tracks = await Worker.list([{ + $type: "sh.diffuse.output.track", + id: "p1", + kind: "placeholder", + uri, + }]); + + // Should find song1.mp3 and song2.flac; readme.txt is not audio + expect(tracks.length).toBe(2); + const paths = tracks.map((t) => { + const parsed = parseURI(t.uri); + return parsed?.path; + }); + expect(paths).toContain("/music/song1.mp3"); + expect(paths).toContain("/music/song2.flac"); + }); + + it("list returns placeholder when API returns error", async () => { + const account = { accessToken: "bad-token", directoryPath: "/" }; + const uri = buildURI(account, "/"); + const tracks = await Worker.list([{ + $type: "sh.diffuse.output.track", + id: "p1", + kind: "placeholder", + uri, + }]); + + // listFiles returns null on error, so worker returns a placeholder + expect(tracks.length).toBe(1); + expect(tracks[0].kind).toBe("placeholder"); + }); + + it("resolve returns a temporary link URL", async () => { + const uri = buildURI( + { accessToken: "valid-token", directoryPath: "/" }, + "/music/song1.mp3", + ); + const result = await Worker.resolve({ uri }); + expect(result).not.toBe(undefined); + if (result && "url" in result) { + expect(result.url).toContain("temp-link"); + // Dropbox temporary links expire after 4 hours + const fourHours = 4 * 60 * 60; + const now = Math.round(Date.now() / 1000); + expect(result.expiresAt).toBeGreaterThan(now); + expect(result.expiresAt).toBeLessThanOrEqual(now + fourHours + 10); + } + }); + + it("resolve returns undefined for root path", async () => { + const uri = buildURI( + { accessToken: "valid-token", directoryPath: "/" }, + "/", + ); + const result = await Worker.resolve({ uri }); + expect(result).toBe(undefined); + }); + + it("groupConsult reports available for a valid token", async () => { + const uri = buildURI( + { accessToken: "valid-token", directoryPath: "/" }, + "/music/song1.mp3", + ); + const result = await Worker.groupConsult([uri]); + const keys = Object.keys(result); + expect(keys.length).toBe(1); + expect(result[keys[0]].available).toBe(true); + }); + + it("groupConsult reports unavailable for an invalid token", async () => { + const uri = buildURI( + { accessToken: "invalid-token", directoryPath: "/" }, + "/music/song1.mp3", + ); + const result = await Worker.groupConsult([uri]); + const keys = Object.keys(result); + expect(keys.length).toBe(1); + expect(result[keys[0]].available).toBe(false); + }); + + it("detach with scheme removes all dropbox tracks", async () => { + const tracks: Track[] = [ + { $type: "sh.diffuse.output.track", id: "1", uri: buildURI({ accessToken: "t1", directoryPath: "/" }, "/a.mp3") }, + { $type: "sh.diffuse.output.track", id: "2", uri: buildURI({ accessToken: "t1", directoryPath: "/" }, "/b.mp3") }, + ]; + const remaining = await Worker.detach({ fileUriOrScheme: "dropbox", tracks }); + expect(remaining.length).toBe(0); + }); + + it("detach with a specific account URI removes only that account's tracks", async () => { + const tracks: Track[] = [ + { $type: "sh.diffuse.output.track", id: "1", uri: buildURI({ accessToken: "token-a", directoryPath: "/" }, "/a.mp3") }, + { $type: "sh.diffuse.output.track", id: "2", uri: buildURI({ accessToken: "token-b", directoryPath: "/" }, "/b.mp3") }, + ]; + const remaining = await Worker.detach({ + fileUriOrScheme: buildURI({ accessToken: "token-a", directoryPath: "/" }, "/a.mp3"), + tracks, + }); + expect(remaining.length).toBe(1); + expect(remaining[0].id).toBe("2"); + }); +}); diff --git a/tests/components/input/ephemeral-cache/integration.ts b/tests/components/input/ephemeral-cache/integration.ts new file mode 100644 index 00000000..e5520ad1 --- /dev/null +++ b/tests/components/input/ephemeral-cache/integration.ts @@ -0,0 +1,161 @@ +import { describe, it } from "@std/testing/bdd"; +import { expect } from "@std/expect"; + +import { testWeb } from "@tests/common/index.ts"; + +/** + * Integration tests for the ephemeral-cache input that verify the blob URL + * lifecycle (create → fetch → revoke → fetch fails) and cache entry cleanup. + * + * These run in the browser via {@link testWeb} because `indexedDB` and + * `URL.createObjectURL` are only available in a DOM context. + */ +describe("components/input/ephemeral-cache (integration)", () => { + it("resolve creates a playable blob URL that is revoked after detach by scheme", async () => { + const result = await testWeb(async () => { + const IDB = await import("idb-keyval"); + const { CACHE_KEY_PREFIX } = await import( + "~/components/input/ephemeral-cache/constants.js" + ); + const W = await import("~/components/input/ephemeral-cache/worker.js"); + + const uri = "ephemeral+cache://bafk-lifecycle-scheme"; + await IDB.set( + CACHE_KEY_PREFIX + uri, + new Blob(["audio-bytes"], { type: "audio/mpeg" }), + ); + + // Resolve → creates a blob URL + const resolved = await W.resolve({ uri }); + if (!resolved || !("url" in resolved)) return { error: "resolve failed" }; + + // Blob URL should be fetchable + const resp = await fetch(resolved.url); + const text = await resp.text(); + + // Detach by scheme → revokes blob URL and removes cache entry + await W.detach({ + fileUriOrScheme: "ephemeral+cache", + tracks: [ + { $type: "sh.diffuse.output.track", id: "t1", uri }, + ], + }); + + // Blob URL should now be revoked + let revoked = false; + try { + await fetch(resolved.url); + } catch { + revoked = true; + } + + // Cache entry should be removed + const cached = await IDB.get(CACHE_KEY_PREFIX + uri); + await IDB.del(CACHE_KEY_PREFIX + uri); + + return { + blobUrl: resolved.url, + fetchedText: text, + revoked, + cacheRemoved: cached === undefined, + }; + }); + + expect(result.blobUrl).toMatch(/^blob:/); + expect(result.fetchedText).toBe("audio-bytes"); + expect(result.revoked).toBe(true); + expect(result.cacheRemoved).toBe(true); + }); + + it("detach by specific URI revokes only that blob URL, leaving others playable", async () => { + const result = await testWeb(async () => { + const IDB = await import("idb-keyval"); + const { CACHE_KEY_PREFIX } = await import( + "~/components/input/ephemeral-cache/constants.js" + ); + const W = await import("~/components/input/ephemeral-cache/worker.js"); + + const keepUri = "ephemeral+cache://bafk-lifecycle-keep"; + const removeUri = "ephemeral+cache://bafk-lifecycle-remove"; + await IDB.set( + CACHE_KEY_PREFIX + keepUri, + new Blob(["keep-audio"], { type: "audio/mpeg" }), + ); + await IDB.set( + CACHE_KEY_PREFIX + removeUri, + new Blob(["remove-audio"], { type: "audio/mpeg" }), + ); + + const rKeep = await W.resolve({ uri: keepUri }); + const rRemove = await W.resolve({ uri: removeUri }); + if (!rKeep || !("url" in rKeep) || !rRemove || !("url" in rRemove)) { + return { error: "resolve failed" }; + } + + // Detach by specific URI + const remaining = await W.detach({ + fileUriOrScheme: removeUri, + tracks: [ + { $type: "sh.diffuse.output.track", id: "t1", uri: keepUri }, + { $type: "sh.diffuse.output.track", id: "t2", uri: removeUri }, + ], + }); + + // Removed blob URL should be revoked + let removeRevoked = false; + try { + await fetch(rRemove.url); + } catch { + removeRevoked = true; + } + + // Kept blob URL should still be fetchable + const keepResp = await fetch(rKeep.url); + const keepText = await keepResp.text(); + + // Cleanup + await IDB.del(CACHE_KEY_PREFIX + keepUri); + + return { + remainingCount: remaining.length, + remainingId: remaining[0]?.id, + removeRevoked, + keepText, + }; + }); + + expect(result.remainingCount).toBe(1); + expect(result.remainingId).toBe("t1"); + expect(result.removeRevoked).toBe(true); + expect(result.keepText).toBe("keep-audio"); + }); + + it("resolve returns the same blob URL on repeated calls (cached)", async () => { + const result = await testWeb(async () => { + const IDB = await import("idb-keyval"); + const { CACHE_KEY_PREFIX } = await import( + "~/components/input/ephemeral-cache/constants.js" + ); + const W = await import("~/components/input/ephemeral-cache/worker.js"); + + const uri = "ephemeral+cache://bafk-lifecycle-stable"; + await IDB.set( + CACHE_KEY_PREFIX + uri, + new Blob(["audio"], { type: "audio/mpeg" }), + ); + + const r1 = await W.resolve({ uri }); + const r2 = await W.resolve({ uri }); + + await IDB.del(CACHE_KEY_PREFIX + uri); + + return { + url1: r1 && "url" in r1 ? r1.url : null, + url2: r2 && "url" in r2 ? r2.url : null, + }; + }); + + expect(result.url1).toMatch(/^blob:/); + expect(result.url1).toBe(result.url2); + }); +}); diff --git a/tests/components/input/https-json/integration.ts b/tests/components/input/https-json/integration.ts new file mode 100644 index 00000000..812fc201 --- /dev/null +++ b/tests/components/input/https-json/integration.ts @@ -0,0 +1,167 @@ +import { describe, it, beforeAll, afterAll } from "@std/testing/bdd"; +import { expect } from "@std/expect"; + +import { mockServer } from "@tests/common/server.ts"; +import type { Track } from "~/definitions/types.d.ts"; +import * as Worker from "~/components/input/https-json/worker.js"; +import { buildURI, parseURI } from "~/components/input/https-json/common.js"; + +// Mock JSON directory listing server. +// The https-json input fetches directories with `Accept: application/json` +// and expects an array of `{ name, type: "directory" | "file" }` entries. +const FILESYSTEM = { + "/": [ + { name: "music", type: "directory" }, + { name: "readme.txt", type: "file" }, + ], + "/music": [ + { name: "album1", type: "directory" }, + { name: "track1.mp3", type: "file" }, + ], + "/music/album1": [ + { name: "song1.flac", type: "file" }, + { name: "song2.mp3", type: "file" }, + { name: "cover.jpg", type: "file" }, + ], +}; + +let server: Deno.HttpServer; +let port: number; + +beforeAll(async () => { + const started = await mockServer((req, url) => { + // Normalise path: ensure leading slash, no trailing slash (except root) + let dir = url.pathname; + if (!dir.startsWith("/")) dir = "/" + dir; + if (dir.length > 1 && dir.endsWith("/")) dir = dir.slice(0, -1); + + const entries = (FILESYSTEM as Record)[dir]; + if (!entries) return new Response("", { status: 404 }); + + return new Response(JSON.stringify(entries), { + status: 200, + headers: { "content-type": "application/json" }, + }); + }); + server = started.server; + port = started.port; +}); + +afterAll(async () => { + await server.shutdown(); +}); + +describe("components/input/https-json (integration)", () => { + it("consult returns true when the server responds ok", async () => { + const uri = buildURI({ host: `127.0.0.1:${port}`, dir: "/" }, ""); + const result = await Worker.consult(uri); + expect(result.supported).toBe(true); + if (result.supported) { + expect(result.consult).toBe(true); + } + }); + + it("consult returns false when the server is unreachable", async () => { + const uri = buildURI({ host: `127.0.0.1:${port + 9999}`, dir: "/" }, ""); + const result = await Worker.consult(uri); + expect(result.supported).toBe(true); + if (result.supported) { + expect(result.consult).toBe(false); + } + }); + + it("list recurses directories and returns audio files", async () => { + const server = { host: `127.0.0.1:${port}`, dir: "/", exclude: [] }; + const uri = buildURI(server, ""); + const parsed = parseURI(uri); + if (!parsed) throw new Error("parseURI returned undefined"); + + const tracks = await Worker.list([{ + $type: "sh.diffuse.output.track", + id: "placeholder-1", + kind: "placeholder", + uri, + }]); + + // Should find: /music/track1.mp3, /music/album1/song1.flac, /music/album1/song2.mp3 + // cover.jpg and readme.txt should be filtered out + expect(tracks.length).toBe(3); + const uris = tracks.map((t) => t.uri); + expect(uris.some((u) => u.includes("track1.mp3"))).toBe(true); + expect(uris.some((u) => u.includes("song1.flac"))).toBe(true); + expect(uris.some((u) => u.includes("song2.mp3"))).toBe(true); + expect(uris.some((u) => u.includes("cover.jpg"))).toBe(false); + expect(uris.some((u) => u.includes("readme.txt"))).toBe(false); + }); + + it("list respects exclude list", async () => { + const serverObj = { host: `127.0.0.1:${port}`, dir: "/", exclude: ["music"] }; + const uri = buildURI(serverObj, ""); + const tracks = await Worker.list([{ + $type: "sh.diffuse.output.track", + id: "p1", + kind: "placeholder", + uri, + }]); + + // "music" directory is excluded, so no audio files should be found. + // The worker returns a placeholder track when no files are found. + expect(tracks.length).toBe(1); + expect(tracks[0].kind).toBe("placeholder"); + }); + + it("resolve returns the HTTP URL for a track path", async () => { + const uri = buildURI({ host: `127.0.0.1:${port}`, dir: "/" }, "/music/track1.mp3"); + const result = await Worker.resolve({ uri }); + expect(result).not.toBe(undefined); + if (result && "url" in result) { + expect(result.url).toContain("http://127.0.0.1"); + expect(result.url).toContain("/music/track1.mp3"); + expect(result.expiresAt).toBeGreaterThan(Date.now() / 1000); + } + }); + + it("resolve returns undefined for a URI without path", async () => { + const uri = buildURI({ host: `127.0.0.1:${port}`, dir: "/" }, ""); + const result = await Worker.resolve({ uri }); + expect(result).toBe(undefined); + }); + + it("groupConsult reports available for a reachable server", async () => { + const uri = buildURI({ host: `127.0.0.1:${port}`, dir: "/" }, "/music/track1.mp3"); + const result = await Worker.groupConsult([uri]); + const keys = Object.keys(result); + expect(keys.length).toBe(1); + expect(result[keys[0]].available).toBe(true); + }); + + it("groupConsult reports unavailable for an unreachable server", async () => { + const uri = buildURI({ host: `127.0.0.1:${port + 9999}`, dir: "/" }, "/track.mp3"); + const result = await Worker.groupConsult([uri]); + const keys = Object.keys(result); + expect(keys.length).toBe(1); + expect(result[keys[0]].available).toBe(false); + }); + + it("detach with scheme removes all https-json tracks", async () => { + const tracks: Track[] = [ + { $type: "sh.diffuse.output.track", id: "1", uri: buildURI({ host: `127.0.0.1:${port}`, dir: "/" }, "/a.mp3") }, + { $type: "sh.diffuse.output.track", id: "2", uri: buildURI({ host: `127.0.0.1:${port}`, dir: "/" }, "/b.mp3") }, + ]; + const remaining = await Worker.detach({ fileUriOrScheme: "https-json", tracks }); + expect(remaining.length).toBe(0); + }); + + it("detach with a specific server URI removes only that server's tracks", async () => { + const tracks: Track[] = [ + { $type: "sh.diffuse.output.track", id: "1", uri: buildURI({ host: `127.0.0.1:${port}`, dir: "/" }, "/a.mp3") }, + { $type: "sh.diffuse.output.track", id: "2", uri: buildURI({ host: "other.example.com", dir: "/" }, "/b.mp3") }, + ]; + const remaining = await Worker.detach({ + fileUriOrScheme: buildURI({ host: `127.0.0.1:${port}`, dir: "/" }, "/a.mp3"), + tracks, + }); + expect(remaining.length).toBe(1); + expect(remaining[0].id).toBe("2"); + }); +}); diff --git a/tests/components/input/https/integration.ts b/tests/components/input/https/integration.ts new file mode 100644 index 00000000..ab3dd8d2 --- /dev/null +++ b/tests/components/input/https/integration.ts @@ -0,0 +1,121 @@ +import { describe, it } from "@std/testing/bdd"; +import { expect } from "@std/expect"; + +import type { Track } from "~/definitions/types.d.ts"; +import * as Worker from "~/components/input/https/worker.js"; + +describe("components/input/https (integration)", () => { + it("resolve returns the URL as-is with a far-future expiry", async () => { + const resolved = await Worker.resolve({ + uri: "https://example.com/audio.mp3", + }); + expect(resolved).not.toBe(undefined); + if (resolved && "url" in resolved) { + expect(resolved.url).toBe("https://example.com/audio.mp3"); + expect(resolved.expiresAt).toBeGreaterThan(Date.now() / 1000); + } + }); + + it("resolve passes through blob: URLs unchanged", async () => { + const resolved = await Worker.resolve({ + uri: "blob:https://example.com/123-456", + }); + expect(resolved).not.toBe(undefined); + if (resolved && "url" in resolved) { + expect(resolved.url).toBe("blob:https://example.com/123-456"); + } + }); + + it("resolve returns undefined for a non-HTTPS URI", async () => { + const resolved = await Worker.resolve({ uri: "http://example.com/audio.mp3" }); + expect(resolved).toBe(undefined); + }); + + it("consult returns false for an unreachable host", async () => { + // Port 1 on localhost — nothing listens, connection refused. + const result = await Worker.consult("https://127.0.0.1:1/audio.mp3"); + expect(result.supported).toBe(true); + if (result.supported) { + expect(result.consult).toBe(false); + } + }); + + it("consult returns false for a non-HTTPS URL", async () => { + const result = await Worker.consult("http://example.com/audio.mp3"); + expect(result.supported).toBe(false); + }); + + it("list clears placeholder kind from cached tracks", async () => { + const tracks: Track[] = [ + { + $type: "sh.diffuse.output.track", + id: "t1", + uri: "https://example.com/a.mp3", + kind: "placeholder", + }, + ]; + const refreshed = await Worker.list(tracks); + expect(refreshed[0].kind).toBe(undefined); + }); + + it("list preserves existing non-placeholder kind", async () => { + const tracks: Track[] = [ + { + $type: "sh.diffuse.output.track", + id: "t1", + uri: "https://example.com/a.mp3", + kind: "music", + }, + ]; + const refreshed = await Worker.list(tracks); + expect(refreshed[0].kind).toBe("music"); + }); + + it("groupConsult groups URIs by host and reports availability", async () => { + const result = await Worker.groupConsult([ + "https://127.0.0.1:1/a.mp3", + "https://127.0.0.1:1/b.mp3", + ]); + const key = "https://127.0.0.1:1"; + expect(result[key]?.available).toBe(false); + if (!result[key]?.available) { + expect(result[key]?.reason).toBeDefined(); + } + expect(result[key]?.uris).toEqual([ + "https://127.0.0.1:1/a.mp3", + "https://127.0.0.1:1/b.mp3", + ]); + }); + + it("detach with scheme removes all HTTPS tracks", async () => { + const tracks: Track[] = [ + { $type: "sh.diffuse.output.track", id: "1", uri: "https://a.com/1.mp3" }, + { $type: "sh.diffuse.output.track", id: "2", uri: "https://b.com/2.mp3" }, + ]; + const remaining = await Worker.detach({ fileUriOrScheme: "https", tracks }); + expect(remaining.length).toBe(0); + }); + + it("detach with a specific host URI removes only that host's tracks", async () => { + const tracks: Track[] = [ + { $type: "sh.diffuse.output.track", id: "1", uri: "https://example.com/a.mp3" }, + { $type: "sh.diffuse.output.track", id: "2", uri: "https://cdn.example.com/b.mp3" }, + { $type: "sh.diffuse.output.track", id: "3", uri: "https://example.com/c.mp3" }, + ]; + const remaining = await Worker.detach({ + fileUriOrScheme: "https://example.com/a.mp3", + tracks, + }); + // detach by URI removes the entire host group ("example.com") + expect(remaining.length).toBe(1); + expect(remaining[0].id).toBe("2"); + }); + + it("detach with non-HTTPS scheme returns all tracks", async () => { + const tracks: Track[] = [ + { $type: "sh.diffuse.output.track", id: "1", uri: "https://example.com/a.mp3" }, + ]; + const remaining = await Worker.detach({ fileUriOrScheme: "icecast", tracks }); + expect(remaining.length).toBe(1); + }); +}); diff --git a/tests/components/input/icecast/integration.ts b/tests/components/input/icecast/integration.ts new file mode 100644 index 00000000..5f452f90 --- /dev/null +++ b/tests/components/input/icecast/integration.ts @@ -0,0 +1,175 @@ +import { describe, it, beforeAll, afterAll } from "@std/testing/bdd"; +import { expect } from "@std/expect"; + +import { mockServer } from "@tests/common/server.ts"; +import type { Track } from "~/definitions/types.d.ts"; +import * as Worker from "~/components/input/icecast/worker.js"; + +const METAINT = 16; +const META_STRING = "StreamTitle='Pink Floyd - Time';StreamUrl='';"; +const META_PADDED = (() => { + const encoded = new TextEncoder().encode(META_STRING); + const len = Math.ceil(encoded.length / 16) * 16; + const out = new Uint8Array(len); + out.set(encoded); + return out; +})(); +const META_LENGTH_BYTE = new Uint8Array([META_PADDED.length / 16]); + +function streamBody() { + const audio = new Uint8Array(METAINT).fill(0xAA); + return new Blob([audio, META_LENGTH_BYTE, META_PADDED]).stream(); +} + +// Two servers on distinct ports so the per-host consult cache doesn't bleed +// between scenarios. +let goodServer: Deno.HttpServer; +let goodPort: number; +let plainServer: Deno.HttpServer; +let plainPort: number; +let deadPort: number; + +beforeAll(async () => { + const good = await mockServer((_req, url) => { + if (url.pathname === "/stream.mp3") { + return new Response(streamBody(), { + status: 200, + headers: { + "Content-Type": "audio/mpeg", + "icy-name": "TestRadio", + "icy-genre": "Prog Rock", + "icy-br": "128", + "icy-metaint": String(METAINT), + }, + }); + } + return new Response("", { status: 404 }); + }); + goodServer = good.server; + goodPort = good.port; + + const plain = await mockServer((_req, url) => { + if (url.pathname === "/noicy") { + return new Response(new Uint8Array(32), { + status: 200, + headers: { "Content-Type": "audio/mpeg" }, + }); + } + return new Response("", { status: 404 }); + }); + plainServer = plain.server; + plainPort = plain.port; + + // A port that nothing listens on. + deadPort = plainPort + 1; +}); + +afterAll(async () => { + await goodServer.shutdown(); + await plainServer.shutdown(); +}); + +describe("components/input/icecast (integration)", () => { + it("consult returns true for a live stream with ICY metadata", async () => { + const result = await Worker.consult(`icecast://127.0.0.1:${goodPort}/stream.mp3?tls=0`); + expect(result.supported).toBe(true); + if (result.supported) { + expect(result.consult).toBe(true); + } + }); + + it("consult returns false for a stream without icy-metaint", async () => { + const result = await Worker.consult(`icecast://127.0.0.1:${plainPort}/noicy?tls=0`); + expect(result.supported).toBe(true); + if (result.supported) { + expect(result.consult).toBe(false); + } + }); + + it("consult returns false for an unreachable host", async () => { + const result = await Worker.consult(`icecast://127.0.0.1:${deadPort}/stream.mp3?tls=0`); + expect(result.supported).toBe(true); + if (result.supported) { + expect(result.consult).toBe(false); + } + }); + + it("resolve returns the HTTPS stream URL by default", async () => { + const resolved = await Worker.resolve({ uri: "icecast://radio.example.com/stream.mp3" }); + expect(resolved).not.toBe(undefined); + if (resolved && "url" in resolved) { + expect(resolved.url).toContain("https://"); + expect(resolved.url).toContain("radio.example.com"); + expect(resolved.expiresAt).toBeGreaterThan(Date.now() / 1000); + } + }); + + it("resolve returns an HTTP stream URL when tls=0", async () => { + const resolved = await Worker.resolve({ + uri: `icecast://127.0.0.1:${goodPort}/stream.mp3?tls=0`, + }); + expect(resolved).not.toBe(undefined); + if (resolved && "url" in resolved) { + expect(resolved.url).toContain("http://127.0.0.1"); + expect(resolved.url).toContain("/stream.mp3"); + } + }); + + it("list enriches tracks with ICY metadata from the stream", async () => { + const tracks: Track[] = [{ + $type: "sh.diffuse.output.track", + id: "t1", + uri: `icecast://127.0.0.1:${goodPort}/stream.mp3?tls=0`, + }]; + const refreshed = await Worker.list(tracks); + expect(refreshed.length).toBe(1); + expect(refreshed[0].kind).toBe("stream"); + expect(refreshed[0].tags?.title).toBe("TestRadio"); + expect(refreshed[0].tags?.genres).toEqual(["Prog Rock"]); + expect(refreshed[0].stats?.bitrate).toBe(128_000); + }); + + it("groupConsult reports available for a reachable host", async () => { + const result = await Worker.groupConsult([ + `icecast://127.0.0.1:${goodPort}/stream.mp3?tls=0`, + ]); + const key = `icecast://127.0.0.1:${goodPort}`; + expect(result[key]?.available).toBe(true); + expect(result[key]?.uris).toEqual([ + `icecast://127.0.0.1:${goodPort}/stream.mp3?tls=0`, + ]); + }); + + it("groupConsult reports unavailable for an unreachable host", async () => { + const result = await Worker.groupConsult([ + `icecast://127.0.0.1:${deadPort}/stream.mp3?tls=0`, + ]); + const key = `icecast://127.0.0.1:${deadPort}`; + expect(result[key]?.available).toBe(false); + if (!result[key]?.available) { + expect(result[key]?.reason).toBeDefined(); + } + }); + + it("detach with scheme removes all icecast tracks", async () => { + const tracks: Track[] = [ + { $type: "sh.diffuse.output.track", id: "1", uri: `icecast://127.0.0.1:${goodPort}/a?tls=0` }, + { $type: "sh.diffuse.output.track", id: "2", uri: `icecast://127.0.0.1:${goodPort}/b?tls=0` }, + ]; + const remaining = await Worker.detach({ fileUriOrScheme: "icecast", tracks }); + expect(remaining.length).toBe(0); + }); + + it("detach with a specific host URI removes only that host's tracks", async () => { + const tracks: Track[] = [ + { $type: "sh.diffuse.output.track", id: "1", uri: `icecast://127.0.0.1:${goodPort}/a?tls=0` }, + { $type: "sh.diffuse.output.track", id: "2", uri: "icecast://other.example.com/c" }, + ]; + const remaining = await Worker.detach({ + fileUriOrScheme: `icecast://127.0.0.1:${goodPort}/a?tls=0`, + tracks, + }); + expect(remaining.length).toBe(1); + expect(remaining[0].id).toBe("2"); + }); +}); diff --git a/tests/components/input/opensubsonic/integration.ts b/tests/components/input/opensubsonic/integration.ts new file mode 100644 index 00000000..283d36a5 --- /dev/null +++ b/tests/components/input/opensubsonic/integration.ts @@ -0,0 +1,210 @@ +import { describe, it, beforeAll, afterAll } from "@std/testing/bdd"; +import { expect } from "@std/expect"; + +import { mockServer } from "@tests/common/server.ts"; +import type { Track } from "~/definitions/types.d.ts"; +import * as Worker from "~/components/input/opensubsonic/worker.js"; + +const SONGS = [ + { + id: "s1", + title: "Echoes", + artist: "Pink Floyd", + displayArtist: "Pink Floyd", + type: "music", + path: "folder/echoes.mp3", + duration: 1414, + bitRate: 320, + track: 1, + discNumber: 1, + year: 1971, + isVideo: false, + genres: ["Prog Rock"], + album: "Meddle", + }, + { + id: "s2", + title: "Time", + artist: "Pink Floyd", + displayArtist: "Pink Floyd", + type: "music", + path: "folder/time.mp3", + duration: 421, + bitRate: 320, + track: 4, + discNumber: 1, + year: 1973, + isVideo: false, + genres: ["Prog Rock"], + album: "The Dark Side of the Moon", + }, +]; + +let server: Deno.HttpServer; +let port: number; + +beforeAll(async () => { + const started = await mockServer((req, url) => { + const p = url.pathname; + + if (p === "/rest/ping.view") { + return new Response( + JSON.stringify({ "subsonic-response": { status: "ok", version: "1.16.1" } }), + { headers: { "content-type": "application/json" } }, + ); + } + + if (p === "/rest/search3.view") { + return new Response( + JSON.stringify({ + "subsonic-response": { status: "ok", "searchResult3": { song: SONGS } }, + }), + { headers: { "content-type": "application/json" } }, + ); + } + + if (p === "/rest/stream.view") { + return new Response(new Uint8Array(64), { + status: 200, + headers: { "content-type": "audio/mpeg" }, + }); + } + + if (p === "/rest/getCoverArt.view") { + const png = new Uint8Array([ + 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, + ]); + return new Response(png, { + status: 200, + headers: { "content-type": "image/png" }, + }); + } + + return new Response("", { status: 404 }); + }); + server = started.server; + port = started.port; +}); + +afterAll(async () => { + await server.shutdown(); +}); + +describe("components/input/opensubsonic (integration)", () => { + it("consult returns true when the server pings ok", async () => { + const uri = `opensubsonic://user:pass@127.0.0.1:${port}?tls=f`; + const result = await Worker.consult(uri); + expect(result.supported).toBe(true); + if (result.supported) { + expect(result.consult).toBe(true); + } + }); + + it("consult returns false when the server is unreachable", async () => { + const uri = `opensubsonic://user:pass@127.0.0.1:${port + 9999}?tls=f`; + const result = await Worker.consult(uri); + expect(result.supported).toBe(true); + if (result.supported) { + expect(result.consult).toBe(false); + } + }); + + it("list returns tracks from search3", async () => { + // Pass a cached track for s1 with existing metadata to verify it's preserved. + const cachedTrack: Track = { + $type: "sh.diffuse.output.track", + id: "cached-1", + uri: `opensubsonic://user:pass@127.0.0.1:${port}/folder/echoes.mp3?tls=f&songId=s1`, + tags: { title: "Echoes (Cached)", album: "Meddle" }, + }; + const tracks = await Worker.list([cachedTrack]); + expect(tracks.length).toBe(2); + + // Cached track preserves its id and original tags + const cached = tracks.find((t) => t.id === "cached-1"); + expect(cached).toBeDefined(); + expect(cached?.tags?.title).toBe("Echoes (Cached)"); + + // New track from server has proper metadata + const time = tracks.find((t) => t.tags?.title === "Time"); + expect(time).toBeDefined(); + expect(time?.tags?.artist).toBe("Pink Floyd"); + expect(time?.tags?.album).toBe("The Dark Side of the Moon"); + expect(time?.kind).toBe("music"); + expect(time?.stats?.duration).toBe(421_000); + }); + + it("resolve returns the stream URL for a songId", async () => { + const uri = `opensubsonic://user:pass@127.0.0.1:${port}?tls=f&songId=s1`; + const result = await Worker.resolve({ uri }); + expect(result).not.toBe(undefined); + if (result && "url" in result) { + expect(result.url).toContain("/rest/stream.view"); + expect(result.url).toContain("id=s1"); + expect(result.expiresAt).toBe(Infinity); + } + }); + + it("resolve returns undefined for a URI without songId", async () => { + const uri = `opensubsonic://user:pass@127.0.0.1:${port}?tls=f`; + const result = await Worker.resolve({ uri }); + expect(result).toBe(undefined); + }); + + it("artwork returns image bytes for a songId", async () => { + const uri = `opensubsonic://user:pass@127.0.0.1:${port}?tls=f&songId=s1`; + const result = await Worker.artwork(uri); + expect(result).not.toBe(null); + if (result) { + expect(result.length).toBeGreaterThan(0); + expect(Array.from(result.slice(0, 4))).toEqual([0x89, 0x50, 0x4E, 0x47]); + } + }); + + it("artwork returns null for a URI without songId", async () => { + const uri = `opensubsonic://user:pass@127.0.0.1:${port}?tls=f`; + const result = await Worker.artwork(uri); + expect(result).toBe(null); + }); + + it("groupConsult reports available for a reachable server", async () => { + const uri = `opensubsonic://user:pass@127.0.0.1:${port}?tls=f&songId=s1`; + const result = await Worker.groupConsult([uri]); + const keys = Object.keys(result); + expect(keys.length).toBe(1); + const grouping = result[keys[0]]; + expect(grouping.available).toBe(true); + expect(grouping.uris).toEqual([uri]); + }); + + it("groupConsult reports unavailable for an unreachable server", async () => { + const uri = `opensubsonic://user:pass@127.0.0.1:${port + 9999}?tls=f&songId=s1`; + const result = await Worker.groupConsult([uri]); + const keys = Object.keys(result); + expect(keys.length).toBe(1); + const grouping = result[keys[0]]; + expect(grouping.available).toBe(false); + }); + + it("detach with scheme removes all opensubsonic tracks", async () => { + const tracks: Track[] = [ + { $type: "sh.diffuse.output.track", id: "1", uri: `opensubsonic://user:pass@127.0.0.1:${port}?tls=f&songId=s1` }, + { $type: "sh.diffuse.output.track", id: "2", uri: `opensubsonic://user:pass@127.0.0.1:${port}?tls=f&songId=s2` }, + ]; + const remaining = await Worker.detach({ fileUriOrScheme: "opensubsonic", tracks }); + expect(remaining.length).toBe(0); + }); + + it("detach with a specific server URI removes only that server's tracks", async () => { + const tracks: Track[] = [ + { $type: "sh.diffuse.output.track", id: "1", uri: `opensubsonic://user:pass@127.0.0.1:${port}?tls=f&songId=s1` }, + { $type: "sh.diffuse.output.track", id: "2", uri: "opensubsonic://user:pass@other.example.com?tls=t&songId=s3" }, + ]; + const remaining = await Worker.detach({ + fileUriOrScheme: `opensubsonic://user:pass@127.0.0.1:${port}?tls=f`, + tracks, + }); + expect(remaining.length).toBe(1); + expect(remaining[0].id).toBe("2"); + }); +}); diff --git a/tests/components/input/webdav/integration.ts b/tests/components/input/webdav/integration.ts new file mode 100644 index 00000000..02bc4877 --- /dev/null +++ b/tests/components/input/webdav/integration.ts @@ -0,0 +1,218 @@ +import { describe, it, beforeAll, afterAll } from "@std/testing/bdd"; +import { expect } from "@std/expect"; + +import { mockServer } from "@tests/common/server.ts"; +import type { Track } from "~/definitions/types.d.ts"; +import * as Worker from "~/components/input/webdav/worker.js"; +import { buildURI, parseURI } from "~/components/input/webdav/common.js"; + +// Mock WebDAV server that responds to PROPFIND with XML. +// The webdav input sends PROPFIND with Depth:1 and parses the multistatus XML. +function propfindResponse(baseUrl: string, dir: string): string { + const entries: Record = { + "/": [ + { name: "music", isCollection: true }, + { name: "doc.txt", isCollection: false }, + ], + "/music": [ + { name: "album", isCollection: true }, + { name: "track1.mp3", isCollection: false }, + ], + "/music/album": [ + { name: "song1.flac", isCollection: false }, + { name: "song2.mp3", isCollection: false }, + { name: "cover.jpg", isCollection: false }, + ], + }; + + const normDir = dir.endsWith("/") && dir.length > 1 ? dir.slice(0, -1) : dir; + const items = entries[normDir] || []; + + const responses = items.map((item) => { + const href = `${normDir === "/" ? "" : normDir}/${encodeURIComponent(item.name)}${item.isCollection ? "/" : ""}`; + const resourcetype = item.isCollection + ? "" + : ""; + return ` + + ${href} + + ${resourcetype} + HTTP/1.1 200 OK + + `; + }).join(""); + + return ` + + ${responses} + `; +} + +let server: Deno.HttpServer; +let port: number; + +beforeAll(async () => { + const started = await mockServer((req, url) => { + if (req.method === "PROPFIND") { + const dir = url.pathname; + return new Response(propfindResponse(`http://127.0.0.1:${port}`, dir), { + status: 207, + headers: { "content-type": "application/xml; charset=utf-8" }, + }); + } + return new Response("", { status: 404 }); + }); + server = started.server; + port = started.port; +}); + +afterAll(async () => { + await server.shutdown(); +}); + +describe("components/input/webdav (integration)", () => { + it("consult returns true when the server responds with 207", async () => { + const uri = buildURI( + { username: "user", password: "pass", host: `127.0.0.1:${port}`, dir: "/" }, + "", + ); + const result = await Worker.consult(uri); + expect(result.supported).toBe(true); + if (result.supported) { + expect(result.consult).toBe(true); + } + }); + + it("consult returns false when the server is unreachable", async () => { + const uri = buildURI( + { username: "user", password: "pass", host: `127.0.0.1:${port + 9999}`, dir: "/" }, + "", + ); + const result = await Worker.consult(uri); + expect(result.supported).toBe(true); + if (result.supported) { + expect(result.consult).toBe(false); + } + }); + + it("list recurses PROPFIND and returns audio files", async () => { + const serverObj = { username: "user", password: "pass", host: `127.0.0.1:${port}`, dir: "/" }; + const uri = buildURI(serverObj, ""); + const tracks = await Worker.list([{ + $type: "sh.diffuse.output.track", + id: "p1", + kind: "placeholder", + uri, + }]); + + // Should find: /music/track1.mp3, /music/album/song1.flac, /music/album/song2.mp3 + // doc.txt and cover.jpg should be filtered out + expect(tracks.length).toBe(3); + const uris = tracks.map((t) => t.uri); + expect(uris.some((u) => u.includes("track1.mp3"))).toBe(true); + expect(uris.some((u) => u.includes("song1.flac"))).toBe(true); + expect(uris.some((u) => u.includes("song2.mp3"))).toBe(true); + expect(uris.some((u) => u.includes("cover.jpg"))).toBe(false); + expect(uris.some((u) => u.includes("doc.txt"))).toBe(false); + }); + + it("resolve returns the HTTP URL with basic-auth query param", async () => { + const uri = buildURI( + { username: "user", password: "pass", host: `127.0.0.1:${port}`, dir: "/" }, + "/music/track1.mp3", + ); + const result = await Worker.resolve({ uri }); + expect(result).not.toBe(undefined); + if (result && "url" in result) { + expect(result.url).toContain("http://127.0.0.1"); + expect(result.url).toContain("/music/track1.mp3"); + expect(result.url).toContain("diffuse%3Abasic-auth="); + expect(result.expiresAt).toBeGreaterThan(Date.now() / 1000); + } + }); + + it("resolve returns undefined for a URI without path", async () => { + const uri = buildURI( + { username: "user", password: "pass", host: `127.0.0.1:${port}`, dir: "/" }, + "", + ); + const result = await Worker.resolve({ uri }); + expect(result).toBe(undefined); + }); + + it("groupConsult reports available for a reachable server", async () => { + const uri = buildURI( + { username: "user", password: "pass", host: `127.0.0.1:${port}`, dir: "/" }, + "/music/track1.mp3", + ); + const result = await Worker.groupConsult([uri]); + const keys = Object.keys(result); + expect(keys.length).toBe(1); + expect(result[keys[0]].available).toBe(true); + }); + + it("groupConsult reports unavailable for an unreachable server", async () => { + const uri = buildURI( + { username: "user", password: "pass", host: `127.0.0.1:${port + 9999}`, dir: "/" }, + "/track.mp3", + ); + const result = await Worker.groupConsult([uri]); + const keys = Object.keys(result); + expect(keys.length).toBe(1); + expect(result[keys[0]].available).toBe(false); + }); + + it("detach with scheme removes all webdav tracks", async () => { + const tracks: Track[] = [ + { + $type: "sh.diffuse.output.track", + id: "1", + uri: buildURI( + { username: "user", password: "pass", host: `127.0.0.1:${port}`, dir: "/" }, + "/a.mp3", + ), + }, + { + $type: "sh.diffuse.output.track", + id: "2", + uri: buildURI( + { username: "user", password: "pass", host: `127.0.0.1:${port}`, dir: "/" }, + "/b.mp3", + ), + }, + ]; + const remaining = await Worker.detach({ fileUriOrScheme: "webdav", tracks }); + expect(remaining.length).toBe(0); + }); + + it("detach with a specific server URI removes only that server's tracks", async () => { + const tracks: Track[] = [ + { + $type: "sh.diffuse.output.track", + id: "1", + uri: buildURI( + { username: "user", password: "pass", host: `127.0.0.1:${port}`, dir: "/" }, + "/a.mp3", + ), + }, + { + $type: "sh.diffuse.output.track", + id: "2", + uri: buildURI( + { username: "user", password: "pass", host: "other.example.com", dir: "/" }, + "/b.mp3", + ), + }, + ]; + const remaining = await Worker.detach({ + fileUriOrScheme: buildURI( + { username: "user", password: "pass", host: `127.0.0.1:${port}`, dir: "/" }, + "/a.mp3", + ), + tracks, + }); + expect(remaining.length).toBe(1); + expect(remaining[0].id).toBe("2"); + }); +}); diff --git a/tests/deno.lock b/tests/deno.lock new file mode 100644 index 00000000..19c0ad60 --- /dev/null +++ b/tests/deno.lock @@ -0,0 +1,133 @@ +{ + "version": "5", + "specifiers": { + "jsr:@okikio/transferables@^1.0.2": "1.0.2", + "jsr:@std/xml@0.1": "0.1.2", + "jsr:@vicary/debounce-microtask@~0.1.8": "0.1.8", + "npm:@atcute/tid@^1.1.2": "1.1.2", + "npm:fast-uri@3.1.0": "3.1.0", + "npm:query-string@^9.3.1": "9.4.0", + "npm:subsonic-api@^3.2.0": "3.3.1", + "npm:xxh32@^2.0.5": "2.0.5" + }, + "jsr": { + "@okikio/transferables@1.0.2": { + "integrity": "46a80015a1c4672b0b246e38838b3ea1e2edc6c775a235184a2f8eb49a8314f7" + }, + "@std/xml@0.1.2": { + "integrity": "a17a771548f10895b9a2a54b39b52e2e27e0617e0e05bb5919c04c87f89007f0" + }, + "@vicary/debounce-microtask@0.1.8": { + "integrity": "fe180e0c599903ccf7a93e719ea986c48affc1ff78951a1bc0ccb874aa30fd0e" + } + }, + "npm": { + "@atcute/tid@1.1.2": { + "integrity": "sha512-bmPuOX/TOfcm/vsK9vM98spjkcx2wgd9S2PeK5oLgEr8IbNRPq7iMCAPzOL1nu5XAW3LlkOYQEbYRcw5vcQ37w==", + "dependencies": [ + "@atcute/time-ms" + ] + }, + "@atcute/time-ms@1.3.2": { + "integrity": "sha512-F+qOyR9pO55g1d/QmN+Gr+fimoUQQLusdGSB6pjV0wW5KPILR4oQ4e2ZhWzqUbeHLAgWvgoTTMsMDdz62Xa2tg==" + }, + "decode-uri-component@0.4.1": { + "integrity": "sha512-+8VxcR21HhTy8nOt6jf20w0c9CADrw1O8d+VZ/YzzCt4bJ3uBjw+D1q2osAB8RnpwwaeYBxy0HyKQxD5JBMuuQ==" + }, + "fast-uri@3.1.0": { + "integrity": "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==" + }, + "filter-obj@5.1.0": { + "integrity": "sha512-qWeTREPoT7I0bifpPUXtxkZJ1XJzxWtfoWWkdVGqa+eCr3SHW/Ocp89o8vLvbUuQnadybJpjOKu4V+RwO6sGng==" + }, + "query-string@9.4.0": { + "integrity": "sha512-ivvWyHqU9K1Log4hJFhqVIIMoEi0nzmlRhvk2pPcTuQH/Y0K5iTTMxEx7R0PRHD2Z1hMVbWnjfsEWbIKIK+3IA==", + "dependencies": [ + "decode-uri-component", + "filter-obj", + "split-on-first" + ] + }, + "split-on-first@3.0.0": { + "integrity": "sha512-qxQJTx2ryR0Dw0ITYyekNQWpz6f8dGd7vffGNflQQ3Iqj9NJ6qiZ7ELpZsJ/QBhIVAiDfXdag3+Gp8RvWa62AA==" + }, + "subsonic-api@3.3.1": { + "integrity": "sha512-0320kOsY8ym4wSR1rwVUqEYpMaa7zjbER26SsI8akO6P22DRW7iEliJLreuj7aKSE0t7FT/NbvbVXgm8PqU4yg==" + }, + "xxh32@2.0.5": { + "integrity": "sha512-glQIaPvLHV4xG2Sn0E4mZWY25JT34+XcG4e2c8OMIH2SXxVrm6MmJ8miCsqGBLtf+rn2YcaeS11vq/66vkXGUQ==" + } + }, + "workspace": { + "dependencies": [ + "jsr:@astral/astral@0.5.5", + "jsr:@bradenmacdonald/s3-lite-client@~0.9.5", + "jsr:@char/cbor@~0.1.4", + "jsr:@cloudradio/icy-parser@^1.0.2", + "jsr:@fcrozatier/htmlcrunch@^1.5.1", + "jsr:@fry69/deep-diff@~0.1.10", + "jsr:@mary/ds-queue@~0.1.3", + "jsr:@okikio/transferables@^1.0.2", + "jsr:@paulmillr/qr@0.6", + "jsr:@std/expect@1.0.18", + "jsr:@std/fs@^1.0.23", + "jsr:@std/html@^1.0.5", + "jsr:@std/path@^1.1.4", + "jsr:@std/semver@^1.0.8", + "jsr:@std/testing@1.0.17", + "jsr:@std/xml@0.1", + "jsr:@vicary/debounce-microtask@~0.1.8", + "jsr:@zip-js/zip-js@2", + "npm:98.css@~0.1.21", + "npm:@atcute/atproto@^3.1.10", + "npm:@atcute/car@^5.1.1", + "npm:@atcute/cbor@^2.3.2", + "npm:@atcute/cid@^2.4.1", + "npm:@atcute/client@^4.2.1", + "npm:@atcute/firehose@0.1", + "npm:@atcute/identity-resolver@^1.2.2", + "npm:@atcute/lex-cli@^2.5.3", + "npm:@atcute/lexicons@^1.2.9", + "npm:@atcute/oauth-browser-client@3", + "npm:@atcute/repo@~0.1.3", + "npm:@atcute/tid@^1.1.2", + "npm:@atcute/uint8array@^1.1.1", + "npm:@automerge/automerge@^3.2.4", + "npm:@awesome.me/webawesome@^3.3.1", + "npm:@codemirror/autocomplete@^6.20.1", + "npm:@codemirror/lang-css@^6.3.1", + "npm:@codemirror/lang-html@^6.4.11", + "npm:@codemirror/lang-javascript@^6.2.5", + "npm:@dotenv-run/esbuild@^1.5.1", + "npm:@fortawesome/free-regular-svg-icons@^7.2.0", + "npm:@noble/ciphers@^2.1.1", + "npm:@noble/hashes@^2.0.1", + "npm:@orama/orama@^3.1.18", + "npm:@phosphor-icons/web@^2.1.2", + "npm:@tokenizer/http@~0.9.2", + "npm:@tokenizer/range@~0.13.1", + "npm:@types/wicg-file-system-access@^2023.10.7", + "npm:alien-signals@^3.1.2", + "npm:autoprefixer@^10.4.27", + "npm:bs58check@4", + "npm:butterchurn-presets@3.0.0-beta.4", + "npm:butterchurn@3.0.0-beta.5", + "npm:codemirror@^6.0.2", + "npm:cssnano@^7.1.3", + "npm:esbuild-plugin-wasm@^1.1.0", + "npm:esbuild-plugins-node-modules-polyfill@^1.8.1", + "npm:fast-average-color@^9.5.0", + "npm:idb-keyval@^6.2.2", + "npm:iso-base@^4.3.0", + "npm:kmenu@^2.0.3", + "npm:lit-html@^3.3.2", + "npm:marked@^17.0.4", + "npm:music-metadata@^11.12.2", + "npm:query-string@^9.3.1", + "npm:subsonic-api@^3.2.0", + "npm:temporal-polyfill@~0.3.2", + "npm:throttle-debounce@^5.0.2", + "npm:xxh32@^2.0.5" + ] + } +}