diff --git a/src/_data/elements.js b/src/_data/elements.js index 67489bec..c5c99424 100644 --- a/src/_data/elements.js +++ b/src/_data/elements.js @@ -302,12 +302,6 @@ export default { ], transformers: [ - { - url: "components/transformer/output/bytes/automerge/element.js", - title: "Output / Bytes / Automerge", - desc: "Translate data to and from an Automerge CRDT.", - todo: true, - }, { url: "components/transformer/output/bytes/dasl-sync/element.js", title: "Output / Bytes / DASL Sync", @@ -374,7 +368,13 @@ export default { url: "components/upload/s3/element.js", title: "S3", desc: - "Uploads and deletes files in an S3-compatible bucket using the S3 HTTP API.", + "Uploads and deletes files in an S3-compatible bucket using the S3 HTTP API. Buckets are connected on the S3 connect page.", + }, + { + url: "components/upload/webdav/element.js", + title: "WebDAV", + desc: + "Uploads and deletes files on a WebDAV server using PUT and DELETE requests.", }, ], @@ -401,11 +401,6 @@ export default { title: "Output / Playlist Item Bundle", desc: "A bundle of playlist items.", }, - { - title: "Output / Progress", - desc: "Used to track progress of (long) audio playback.", - todo: true, - }, { url: "definitions/output/setting.json", title: "Output / Setting", diff --git a/src/components/index.js b/src/components/index.js index 013177d4..13ba8d4f 100644 --- a/src/components/index.js +++ b/src/components/index.js @@ -51,6 +51,7 @@ import * as _TransformerOutputRefinerPathCollections from "./transformer/output/ import * as _TransformerOutputStringJson from "./transformer/output/string/json/element.js" import * as _UploadDropbox from "./upload/dropbox/element.js" import * as _UploadS3 from "./upload/s3/element.js" +import * as _UploadWebdav from "./upload/webdav/element.js" export const artwork = { audioMetadata: _ArtworkAudioMetadata, @@ -149,4 +150,5 @@ export const transformer = { export const upload = { dropbox: _UploadDropbox, s3: _UploadS3, + webdav: _UploadWebdav, } diff --git a/src/components/upload/s3/worker.js b/src/components/upload/s3/worker.js index 3a1084c5..1d236764 100644 --- a/src/components/upload/s3/worker.js +++ b/src/components/upload/s3/worker.js @@ -58,6 +58,7 @@ export async function deleteFn(uri) { /** * @type {Actions['createSource']} */ +// deno-lint-ignore require-await export async function createSource(_args) { // S3 buckets are connected (with their credentials) via the Connect page — // there's no OAuth flow that could mint a new source here. diff --git a/src/components/upload/webdav/common.js b/src/components/upload/webdav/common.js new file mode 100644 index 00000000..84b93894 --- /dev/null +++ b/src/components/upload/webdav/common.js @@ -0,0 +1,143 @@ +import { + authHeader, + parseURI, + toHttpUrl, +} from "~/components/input/webdav/common.js"; + +/** + * @import { Server } from "@specs/components/input/webdav/types.d.ts" + */ + +//////////////////////////////////////////// +// WEBDAV API +//////////////////////////////////////////// + +/** + * Upload a file to a WebDAV server. Overwrites any existing resource at the + * same path — safe because uploads are content-addressed (the filename is + * `.`), so the same bytes always map to the same path (overwrite is + * a no-op) and different bytes get a different path. This makes re-uploads + * idempotent. + * + * @param {Server} server + * @param {string} destinationPath Full server path (e.g. "/Music/song.mp3"). + * @param {File} file + * @returns {Promise<{ path: string }>} The path of the uploaded file. + */ +export async function uploadFile(server, destinationPath, file) { + try { + const response = await fetch(toHttpUrl(server, destinationPath), { + method: "PUT", + headers: { + "Authorization": authHeader(server), + ...(file.type ? { "Content-Type": file.type } : {}), + }, + body: file, + }); + + if (!response.ok) { + throw new Error( + `Unexpected response code from the server (${response.status} ${response.statusText}).`, + ); + } + } catch (err) { + const detail = err instanceof Error && err.message + ? ` (${err.message})` + : ""; + throw new Error( + `Failed to upload "${file.name}" to WebDAV server "${server.host}"${detail}`, + ); + } + + return { path: destinationPath }; +} + +/** + * Delete a file from a WebDAV server. + * + * @param {Server} server + * @param {string} filePath Full server path (e.g. "/Music/song.mp3"). + * @returns {Promise} + */ +export async function deleteFile(server, filePath) { + try { + const response = await fetch(toHttpUrl(server, filePath), { + method: "DELETE", + headers: { + "Authorization": authHeader(server), + }, + }); + + if (!response.ok) { + throw new Error( + `Unexpected response code from the server (${response.status} ${response.statusText}).`, + ); + } + } catch (err) { + const detail = err instanceof Error && err.message + ? ` (${err.message})` + : ""; + throw new Error( + `Failed to delete "${filePath}" from WebDAV server "${server.host}"${detail}`, + ); + } + + return true; +} + +//////////////////////////////////////////// +// PATH HELPERS +//////////////////////////////////////////// + +/** + * Resolve the destination path for an upload. + * + * If `path` is provided it is used as-is (a full server path). Otherwise the + * file is placed inside the server's configured directory — the directory the + * matching input component lists files under. + * + * @param {Server} server + * @param {File} file + * @param {string} [path] + * @returns {string} + */ +export function resolveDestinationPath(server, file, path) { + if (path) return path; + + const dir = server.dir.replace(/\/+$/, ""); + return `${dir}/${file.name}`; +} + +/** + * Extract the server config from an account URI. + * Throws if the URI is not a valid WebDAV URI. + * + * @param {string} uri + * @returns {Server} + */ +export function serverFromURI(uri) { + const parsed = parseURI(uri); + if (!parsed) { + throw new Error(`Invalid WebDAV URI: ${uri}`); + } + return parsed.server; +} + +/** + * Extract the server config and file path from a track URI. + * Throws if the URI is not a valid WebDAV file URI. + * + * @param {string} uri + * @returns {{ server: Server; path: string }} + */ +export function serverAndPathFromURI(uri) { + const parsed = parseURI(uri); + if (!parsed) { + throw new Error(`Invalid WebDAV URI: ${uri}`); + } + if (!parsed.path) { + throw new Error(`Invalid WebDAV file URI: ${uri}`); + } + + return { server: parsed.server, path: parsed.path }; +} diff --git a/src/components/upload/webdav/element.js b/src/components/upload/webdav/element.js new file mode 100644 index 00000000..1803dbe8 --- /dev/null +++ b/src/components/upload/webdav/element.js @@ -0,0 +1,48 @@ +import { defineElement, DiffuseElement } from "~/common/element.js"; +import { SCHEME } from "~/components/input/webdav/constants.js"; + +/** + * @import {UploadActions, UploadSchemeProvider} from "@specs/components/upload/types.d.ts" + * @import {ProxiedActions} from "~/common/worker.d.ts" + */ + +//////////////////////////////////////////// +// ELEMENT +//////////////////////////////////////////// + +/** + * @implements {ProxiedActions} + * @implements {UploadSchemeProvider} + */ +class WebdavUpload extends DiffuseElement { + static NAME = "diffuse/upload/webdav"; + static WORKER_URL = "components/upload/webdav/worker.js"; + + SCHEME = SCHEME; + + /** Where a server for this scheme is connected. */ + CONNECT_PATH = "facets/connect/webdav/index.tile"; + + constructor() { + super(); + + /** @type {ProxiedActions} */ + this.proxy = this.workerProxy(); + + this.consult = this.proxy.consult; + this.upload = this.proxy.upload; + this.delete = this.proxy.delete; + this.createSource = this.proxy.createSource; + } +} + +export default WebdavUpload; + +//////////////////////////////////////////// +// REGISTER +//////////////////////////////////////////// + +export const CLASS = WebdavUpload; +export const NAME = "du-webdav"; + +defineElement(NAME, CLASS); diff --git a/src/components/upload/webdav/worker.js b/src/components/upload/webdav/worker.js new file mode 100644 index 00000000..173eaf5f --- /dev/null +++ b/src/components/upload/webdav/worker.js @@ -0,0 +1,87 @@ +import { ostiary, rpc } from "~/common/worker.js"; +import { + buildURI, + checkAccessCached, + parseURI, +} from "~/components/input/webdav/common.js"; +import { + deleteFile, + resolveDestinationPath, + serverAndPathFromURI, + serverFromURI, + uploadFile, +} from "./common.js"; + +/** + * @import { UploadActions as Actions } from "@specs/components/upload/types.d.ts"; + */ + +//////////////////////////////////////////// +// ACTIONS +//////////////////////////////////////////// + +/** + * @type {Actions['consult']} + */ +export async function consult(fileUriOrScheme) { + if (!fileUriOrScheme.includes(":")) { + return { supported: true, consult: "undetermined" }; + } + + const parsed = parseURI(fileUriOrScheme); + if (!parsed) return { supported: true, consult: "undetermined" }; + + const accessible = await checkAccessCached(parsed.server); + return { supported: true, consult: accessible }; +} + +/** + * @type {Actions['upload']} + */ +export async function upload({ file, uri, path }) { + const server = serverFromURI(uri); + const destinationPath = resolveDestinationPath(server, file, path); + + await uploadFile(server, destinationPath, file); + + // Percent-encode the path (per segment) so the returned URI matches what + // the matching input component's listing produces (PROPFIND hrefs are + // percent-encoded). + const encodedPath = destinationPath.split("/").map(encodeURIComponent).join( + "/", + ); + return buildURI(server, encodedPath); +} + +/** + * @type {Actions['delete']} + */ +export async function deleteFn(uri) { + const { server, path } = serverAndPathFromURI(uri); + await deleteFile(server, path); +} + +/** + * @type {Actions['createSource']} + */ +// deno-lint-ignore require-await +export async function createSource(_args) { + // WebDAV servers are connected (with their credentials) via the Connect + // page — there's no OAuth flow that could mint a new source here. + throw new Error( + "WebDAV does not use OAuth. Connect a server on the Connect page first, then select it for syncing.", + ); +} + +//////////////////////////////////////////// +// ⚡️ +//////////////////////////////////////////// + +ostiary((context) => { + rpc(context, { + consult, + upload, + delete: deleteFn, + createSource, + }); +}); diff --git a/src/facets/data/file-manager/facet.js b/src/facets/data/file-manager/facet.js index 607393f7..ae96f2f0 100644 --- a/src/facets/data/file-manager/facet.js +++ b/src/facets/data/file-manager/facet.js @@ -11,6 +11,8 @@ import { isAudioFile } from "~/components/input/common.js"; import { SCHEME as SCHEME_DROPBOX } from "~/components/input/dropbox/constants.js"; import { buildURI } from "~/components/input/s3/common.js"; import { SCHEME as SCHEME_S3 } from "~/components/input/s3/constants.js"; +import { buildURI as buildWebdavURI } from "~/components/input/webdav/common.js"; +import { SCHEME as SCHEME_WEBDAV } from "~/components/input/webdav/constants.js"; import { effect, signal } from "~/common/signal.js"; import { safeDecodeURIComponent } from "~/common/utils.js"; import foundation from "~/common/foundation.js"; @@ -20,6 +22,7 @@ import foundation from "~/common/foundation.js"; * @import {Track} from "~/definitions/types.d.ts" * @import {UploadElement} from "@specs/components/upload/types.d.ts" * @import {Bucket} from "@specs/components/input/s3/types.d.ts" + * @import {Server} from "@specs/components/input/webdav/types.d.ts" */ /** Human-readable labels for upload component schemes. */ @@ -27,6 +30,7 @@ import foundation from "~/common/foundation.js"; const SCHEME_LABELS = { [SCHEME_DROPBOX]: "Dropbox", [SCHEME_S3]: "S3", + [SCHEME_WEBDAV]: "WebDAV", }; /** Directory in the cloud where uploaded files are stored. */ @@ -276,26 +280,21 @@ const { setLocalItems, setRemoteItems, setError } = setup({ class="ph-bold ph-spinner animate-spin upload-indicator" hidden> - +
- Connect an S3 bucket +
-
-
`, @@ -387,90 +386,209 @@ dropzone?.addEventListener("drop", async (e) => { stopSyncBtn?.addEventListener("click", () => stopSyncing()); //////////////////////////////////////////// -// S3 CONNECT DIALOG +// CONNECT SOURCE DIALOG //////////////////////////////////////////// -const s3ConnectDialog = /** @type {HTMLDialogElement | null} */ ( - document.querySelector("#s3-connect-dialog") +/** + * @typedef {{ + * id: string; + * label: string; + * required?: boolean; + * placeholder?: string; + * type?: "text" | "password"; + * }} ConnectField + */ + +/** + * Describes how to connect a source for a scheme without an OAuth flow, + * so the file manager can host the connect form itself. + * + * @typedef {{ + * scheme: string; + * title: string; + * fields: ConnectField[]; + * build: (values: Record) => string; + * caption: string; + * }} ConnectConfig + */ + +/** @type {Record} */ +const CONNECT_CONFIGS = { + [SCHEME_S3]: { + scheme: SCHEME_S3, + title: "Connect an S3 bucket", + + fields: [ + { id: "access-key", label: "Access key", required: true }, + { + id: "secret-key", + label: "Secret key", + required: true, + type: "password", + }, + { + id: "bucket-name", + label: "Bucket name", + required: true, + placeholder: "my-bucket", + }, + { id: "host", label: "Host", placeholder: "s3.amazonaws.com" }, + { id: "region", label: "Region", placeholder: "us-east-1" }, + { id: "path", label: "Path", placeholder: "/" }, + ], + + build: (values) => { + /** @type {Bucket} */ + const bucket = { + accessKey: values["access-key"], + bucketName: values["bucket-name"], + host: values.host + ? values.host.replace(/^\w+:\/\//, "") + : "s3.amazonaws.com", + path: values.path || "/", + region: values.region || "us-east-1", + secretKey: values["secret-key"], + }; + return buildURI(bucket); + }, + + caption: "Uploaded files are placed inside the bucket's path.", + }, + + [SCHEME_WEBDAV]: { + scheme: SCHEME_WEBDAV, + title: "Connect a WebDAV server", + + fields: [ + { + id: "host", + label: "Host", + required: true, + placeholder: "music.example.com", + }, + { id: "dir", label: "Directory", placeholder: "/" }, + { id: "username", label: "Username" }, + { id: "password", label: "Password", type: "password" }, + ], + + build: (values) => { + /** @type {Server} */ + const server = { + dir: values.dir || "/", + host: values.host, + password: values.password || "", + username: values.username || "", + }; + return buildWebdavURI(server); + }, + + caption: "Uploaded files are placed inside the server's directory.", + }, +}; + +const connectSourceDialog = /** @type {HTMLDialogElement | null} */ ( + document.querySelector("#connect-source-dialog") ); -const s3ConnectForm = /** @type {HTMLFormElement | null} */ ( - document.querySelector("#s3-connect-form") +const connectSourceForm = /** @type {HTMLFormElement | null} */ ( + document.querySelector("#connect-source-form") ); -const s3ConnectErrorEl = +const connectSourceErrorEl = + /** @type {HTMLElement | null} */ (document.querySelector( + "#connect-source-error", + )); +const connectSourceFieldsEl = /** @type {HTMLElement | null} */ (document.querySelector( - "#s3-connect-error", + "#connect-source-fields", )); -const s3ConnectSubmitBtn = /** @type {HTMLButtonElement | null} */ ( - document.querySelector("#s3-connect-submit-btn") +const connectSourceCaptionEl = + /** @type {HTMLElement | null} */ (document.querySelector( + "#connect-source-caption", + )); +const connectSourceTitleEl = + /** @type {HTMLElement | null} */ (document.querySelector( + "#connect-source-title", + )); +const connectSourceSubmitBtn = /** @type {HTMLButtonElement | null} */ ( + document.querySelector("#connect-source-submit-btn") ); +/** The config of the scheme the dialog is currently open for. */ +let currentConnectConfig = /** @type {ConnectConfig | null} */ (null); + /** - * Shows the dialog-level error inside the S3 connect dialog. + * Shows the dialog-level error inside the connect source dialog. * * @param {string | null} message */ -const setS3ConnectError = (message) => { - if (!s3ConnectErrorEl) return; - s3ConnectErrorEl.hidden = message === null; - s3ConnectErrorEl.textContent = message; +const setConnectSourceError = (message) => { + if (!connectSourceErrorEl) return; + connectSourceErrorEl.hidden = message === null; + connectSourceErrorEl.textContent = message; }; /** - * Opens the S3 connect dialog, resetting any previous input. - */ -function openS3ConnectDialog() { - if (!s3ConnectDialog || !s3ConnectForm) return; - s3ConnectForm.reset(); - setS3ConnectError(null); - s3ConnectDialog.showModal(); -} - -/** - * Collects the bucket credentials from the dialog's form fields. Returns null - * when a required field is empty (the inputs are also `required`, so this is - * belt and suspenders for programmatic submissions). + * Opens the connect source dialog for the given scheme, resetting any + * previous input. * - * @returns {Bucket | null} + * @param {ConnectConfig} config */ -function s3BucketFromForm() { - /** @param {string} id */ - const value = (id) => - /** @type {HTMLInputElement | null} */ (document.querySelector(id))?.value - ?.trim() ?? ""; - - const accessKey = value("#s3-connect-access-key"); - const secretKey = value("#s3-connect-secret-key"); - const bucketName = value("#s3-connect-bucket-name"); - if (!accessKey || !secretKey || !bucketName) return null; - - const rawHost = value("#s3-connect-host"); - return { - accessKey, - bucketName, - host: rawHost.length - ? rawHost.replace(/^\w+:\/\//, "") - : "s3.amazonaws.com", - path: value("#s3-connect-path") || "/", - region: value("#s3-connect-region") || "us-east-1", - secretKey, - }; +function openConnectSourceDialog(config) { + if ( + !connectSourceDialog || !connectSourceForm || !connectSourceFieldsEl + ) return; + + currentConnectConfig = config; + + if (connectSourceTitleEl) connectSourceTitleEl.textContent = config.title; + if (connectSourceCaptionEl) { + connectSourceCaptionEl.textContent = config.caption; + } + + litRender( + config.fields.map((field) => + html`` + ), + connectSourceFieldsEl, + ); + + connectSourceForm.reset(); + setConnectSourceError(null); + connectSourceDialog.showModal(); } -s3ConnectForm?.addEventListener("submit", async (e) => { +connectSourceForm?.addEventListener("submit", async (e) => { e.preventDefault(); - if (!s3ConnectDialog) return; + const config = currentConnectConfig; + if (!connectSourceDialog || !config) return; + + // Collect the field values. + const values = /** @type {Record} */ ({}); + for (const field of config.fields) { + values[field.id] = /** @type {HTMLInputElement | null} */ ( + document.querySelector(`#connect-source-field-${field.id}`) + )?.value?.trim() ?? ""; + } - const bucket = s3BucketFromForm(); - if (!bucket) return; + // Required fields are enforced by the browser as well — this covers + // programmatic submissions. + if (config.fields.some((f) => f.required && !values[f.id])) return; - setS3ConnectError(null); - if (s3ConnectSubmitBtn) { - s3ConnectSubmitBtn.setAttribute("disabled", ""); - s3ConnectSubmitBtn.textContent = "Connecting …"; + setConnectSourceError(null); + if (connectSourceSubmitBtn) { + connectSourceSubmitBtn.setAttribute("disabled", ""); + connectSourceSubmitBtn.textContent = "Connecting …"; } try { - // Add the bucket as a source, the same way the Connect page does. + // Add the source, the same way the Connect page does. const now = new Date().toISOString(); /** @type {Track} */ const placeholderTrack = { @@ -479,35 +597,35 @@ s3ConnectForm?.addEventListener("submit", async (e) => { createdAt: now, updatedAt: now, kind: "placeholder", - uri: buildURI(bucket), + uri: config.build(values), }; const tracks = await Output.data(outputOrchestrator.tracks); await outputOrchestrator.tracks.save([...tracks, placeholderTrack]); - s3ConnectDialog.close(); + connectSourceDialog.close(); - // Kick off syncing against the new source — it consults the bucket and - // uploads any pending local tracks. If the bucket can't be reached, the - // (scheme-specific) error callout explains what to fix. - const uploadElement = uploadConfigurator.uploaders()[SCHEME_S3]; - if (uploadElement) await startUpload(SCHEME_S3, uploadElement); + // Kick off syncing against the new source — it consults the source and + // uploads any pending local tracks. If the source can't be reached, the + // error callout explains what to fix. + const uploadElement = uploadConfigurator.uploaders()[config.scheme]; + if (uploadElement) await startUpload(config.scheme, uploadElement); } catch (err) { - setS3ConnectError( - err instanceof Error ? err.message : "Failed to connect the bucket", + setConnectSourceError( + err instanceof Error ? err.message : "Failed to connect the source", ); } finally { - if (s3ConnectSubmitBtn) { - s3ConnectSubmitBtn.removeAttribute("disabled"); - s3ConnectSubmitBtn.textContent = "Connect"; + if (connectSourceSubmitBtn) { + connectSourceSubmitBtn.removeAttribute("disabled"); + connectSourceSubmitBtn.textContent = "Connect"; } } }); -document.querySelector("#s3-connect-cancel-btn")?.addEventListener( +document.querySelector("#connect-source-cancel-btn")?.addEventListener( "click", () => { - setS3ConnectError(null); - s3ConnectDialog?.close(); + setConnectSourceError(null); + connectSourceDialog?.close(); }, ); @@ -1090,8 +1208,9 @@ async function startUpload(scheme, uploadElement) { // No source connected: let the user connect one right here instead of // sending them elsewhere. - if (scheme === SCHEME_S3 && allSources.length === 0) { - openS3ConnectDialog(); + const connectConfig = CONNECT_CONFIGS[scheme]; + if (connectConfig && allSources.length === 0) { + openConnectSourceDialog(connectConfig); return; } diff --git a/src/facets/data/file-manager/styles.css b/src/facets/data/file-manager/styles.css index 1b0a24b3..a5844c5a 100644 --- a/src/facets/data/file-manager/styles.css +++ b/src/facets/data/file-manager/styles.css @@ -81,3 +81,9 @@ color: var(--text-color); font-size: var(--fs-sm); } + +#connect-source-fields { + display: flex; + flex-direction: column; + gap: var(--space-sm); +} diff --git a/src/facets/data/upload-bundle/facet.js b/src/facets/data/upload-bundle/facet.js index 2f34872c..46017824 100644 --- a/src/facets/data/upload-bundle/facet.js +++ b/src/facets/data/upload-bundle/facet.js @@ -3,6 +3,7 @@ import { effect } from "~/common/signal.js"; import { NAME as DROPBOX_NAME } from "~/components/upload/dropbox/element.js"; import { NAME as S3_NAME } from "~/components/upload/s3/element.js"; +import { NAME as WEBDAV_NAME } from "~/components/upload/webdav/element.js"; /** * @import UploadConfigurator from "~/components/configurator/upload/element.js" @@ -17,6 +18,7 @@ effect(() => { dropbox(upload); s3(upload); + webdav(upload); }); //////////////////////////////////////////// @@ -36,3 +38,10 @@ export function dropbox(upload) { export function s3(upload) { upload.append(document.createElement(S3_NAME)); } + +/** + * @param {UploadConfigurator} upload + */ +export function webdav(upload) { + upload.append(document.createElement(WEBDAV_NAME)); +} diff --git a/tests/components/upload/webdav/integration.ts b/tests/components/upload/webdav/integration.ts new file mode 100644 index 00000000..8dd3389a --- /dev/null +++ b/tests/components/upload/webdav/integration.ts @@ -0,0 +1,263 @@ +import { afterAll, beforeAll, describe, it } from "@std/testing/bdd"; +import { expect } from "@std/expect"; + +import type { Server } from "@specs/components/input/webdav/types.d.ts"; +import * as Worker from "~/components/upload/webdav/worker.js"; +import { buildURI } from "~/components/input/webdav/common.js"; + +/** + * Integration tests for the webdav upload worker that verify its WebDAV API + * usage (upload, delete, consult) against an in-memory mock WebDAV endpoint. + * + * The worker performs its HTTP requests using the global `fetch`. We + * intercept `fetch` and respond with WebDAV responses (`multistatus` for + * PROPFIND), so no real network, servers or credentials are involved. + */ + +type StoredFile = { path: string; bytes: Uint8Array }; + +type MockServer = { + host: string; + files: StoredFile[]; + /** When set, the mock endpoint fails all requests (transient network blip). */ + failRequests?: boolean; +}; + +const mockServers: MockServer[] = []; + +function multistatusXml(hrefs: string[]): string { + const responses = hrefs.map((href) => + ` + ${href} + + + HTTP/1.1 200 OK + + ` + ).join("\n"); + + return ` + +${responses} +`; +} + +function registerMockServer(server: Server, files: StoredFile[]): MockServer { + const mock: MockServer = { host: server.host, files }; + mockServers.push(mock); + return mock; +} + +function makeServer(host: string, dir = "/"): Server { + return { + username: "user", + password: "pass", + // Unique host per server: the consult cache is keyed by the server id, + // so shared hosts would leak cached availability between tests. + host, + dir, + }; +} + +function makeFile(name: string, contents: string, type = "audio/mpeg"): File { + return new File([new TextEncoder().encode(contents)], name, { type }); +} + +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, + ); + + const mock = mockServers.find((s) => url.hostname === s.host); + + if (!mock) { + return new Response(null, { status: 404 }); + } + + if (mock.failRequests) { + throw new TypeError("fetch failed"); + } + + const method = init?.method ?? "GET"; + const path = url.pathname; + + // PROPFIND — used by consult (Depth: 0 reachability check). + if (method === "PROPFIND") { + return new Response(multistatusXml([path]), { + status: 207, + headers: { "content-type": "application/xml" }, + }); + } + + // PUT — store the body under the path. + if (method === "PUT") { + const bytes = new Uint8Array( + await new Response(init?.body).arrayBuffer(), + ); + const existing = mock.files.findIndex((f) => f.path === path); + const stored = { path, bytes }; + if (existing >= 0) mock.files[existing] = stored; + else mock.files.push(stored); + return new Response(null, { status: 201 }); + } + + // DELETE — remove the file under the path. + if (method === "DELETE") { + const existing = mock.files.findIndex((f) => f.path === path); + if (existing >= 0) mock.files.splice(existing, 1); + return new Response(null, { status: 204 }); + } + + return new Response(null, { status: 405 }); + }) as typeof globalThis.fetch; +}); + +afterAll(() => { + globalThis.fetch = originalFetch; +}); + +describe("components/upload/webdav (integration)", () => { + it("upload stores the file under the server's directory and returns a parseable URI", async () => { + const server = makeServer("upload-dir.example.com", "/Music"); + const mock = registerMockServer(server, []); + + const file = makeFile("my song.mp3", "fake-audio-bytes"); + const fileUri = await Worker.upload({ file, uri: buildURI(server) }); + + // The stored path is the server directory plus the file name; the + // returned URI is the canonical (percent-encoded) form — matching what + // the matching input component's listing produces. + expect(mock.files.length).toBe(1); + expect(mock.files[0].path).toBe("/Music/my%20song.mp3"); + expect(new TextDecoder().decode(mock.files[0].bytes)).toBe( + "fake-audio-bytes", + ); + + const parsed = buildURI(server, "/Music/my%20song.mp3"); + expect(fileUri).toBe(parsed); + }); + + it("upload places files at the root when no directory is configured", async () => { + const server = makeServer("upload-root.example.com"); + const mock = registerMockServer(server, []); + + const file = makeFile("bafkqaaa.flac", "fake-audio-bytes", "audio/flac"); + const fileUri = await Worker.upload({ file, uri: buildURI(server) }); + + expect(mock.files.length).toBe(1); + expect(mock.files[0].path).toBe("/bafkqaaa.flac"); + expect(fileUri).toBe(buildURI(server, "/bafkqaaa.flac")); + }); + + it("upload overwrites an existing file at the same path (idempotent re-upload)", async () => { + const server = makeServer("upload-overwrite.example.com"); + const mock = registerMockServer(server, []); + + await Worker.upload({ + file: makeFile("bafkqaaa.mp3", "first-version"), + uri: buildURI(server), + }); + await Worker.upload({ + file: makeFile("bafkqaaa.mp3", "second-version"), + uri: buildURI(server), + }); + + expect(mock.files.length).toBe(1); + expect(new TextDecoder().decode(mock.files[0].bytes)).toBe( + "second-version", + ); + }); + + it("upload throws a descriptive error when the server is unreachable", async () => { + const server = makeServer("upload-failing.example.com"); + const mock = registerMockServer(server, []); + mock.failRequests = true; + + await expect(Worker.upload({ + file: makeFile("bafkqaaa.mp3", "fake-audio-bytes"), + uri: buildURI(server), + })).rejects.toThrow( + /Failed to upload "bafkqaaa.mp3" to WebDAV server "upload-failing.example.com"/, + ); + }); + + it("delete removes the file via the URI produced by upload", async () => { + const server = makeServer("delete.example.com", "/Music"); + const mock = registerMockServer(server, [ + { path: "/Music/my%20song.mp3", bytes: new Uint8Array(1) }, + ]); + + await Worker.deleteFn(buildURI(server, "/Music/my%20song.mp3")); + + expect(mock.files.length).toBe(0); + }); + + it("delete throws for an account URI without a file path", async () => { + const server = makeServer("delete-account.example.com"); + registerMockServer(server, []); + + await expect(Worker.deleteFn(buildURI(server))).rejects.toThrow( + /Invalid WebDAV file URI/, + ); + }); + + it("delete throws for an unparseable URI", async () => { + await expect(Worker.deleteFn("webdav://")).rejects.toThrow( + /Invalid WebDAV URI/, + ); + }); + + it("consult returns yes for a reachable server", async () => { + const server = makeServer("consult-yes.example.com"); + registerMockServer(server, []); + + const result = await Worker.consult(buildURI(server)); + expect(result.supported).toBe(true); + if (result.supported) { + expect(result.consult).toBe("yes"); + } + }); + + it("consult returns no for a server that does not exist", async () => { + const server = makeServer("consult-no.example.com"); + // No server registered — the mock endpoint answers 404. + + const result = await Worker.consult(buildURI(server)); + expect(result.supported).toBe(true); + if (result.supported) { + expect(result.consult).toBe("no"); + } + }); + + it("consult returns undetermined for scheme only and invalid URIs", async () => { + const schemeResult = await Worker.consult("webdav"); + expect(schemeResult.supported).toBe(true); + if (schemeResult.supported) { + expect(schemeResult.consult).toBe("undetermined"); + } + + const invalidResult = await Worker.consult("webdav://"); + expect(invalidResult.supported).toBe(true); + if (invalidResult.supported) { + expect(invalidResult.consult).toBe("undetermined"); + } + }); + + it("createSource is rejected — WebDAV servers are connected via the Connect page", async () => { + await expect(Worker.createSource({ + scheme: "webdav", + refreshToken: "irrelevant", + directoryPath: "/Diffuse", + })).rejects.toThrow(/Connect page/); + }); +}); diff --git a/tests/components/upload/webdav/test.ts b/tests/components/upload/webdav/test.ts new file mode 100644 index 00000000..225cf61d --- /dev/null +++ b/tests/components/upload/webdav/test.ts @@ -0,0 +1,56 @@ +import { describe, it } from "@std/testing/bdd"; +import { expect } from "@std/expect"; + +import { testWeb } from "@tests/common/index.ts"; + +describe("components/upload/webdav", () => { + it("has correct SCHEME property", async () => { + const scheme = await testWeb(async () => { + const mod = await import("~/components/upload/webdav/element.js"); + const upload = new mod.CLASS(); + document.body.append(upload); + return upload.SCHEME; + }); + + expect(scheme).toBe("webdav"); + }); + + it("consult returns undetermined for scheme only", async () => { + const result = await testWeb(async () => { + const mod = await import("~/components/upload/webdav/element.js"); + const upload = new mod.CLASS(); + document.body.append(upload); + return await upload.consult("webdav"); + }); + + expect(result.supported).toBe(true); + if (result.supported) { + expect(result.consult).toBe("undetermined"); + } + }); + + it("consult returns undetermined for an invalid WebDAV URI", async () => { + const result = await testWeb(async () => { + const mod = await import("~/components/upload/webdav/element.js"); + const upload = new mod.CLASS(); + document.body.append(upload); + return await upload.consult("webdav://"); + }); + + expect(result.supported).toBe(true); + if (result.supported) { + expect(result.consult).toBe("undetermined"); + } + }); + + it("does not define authorize (WebDAV has no OAuth flow)", async () => { + const hasAuthorize = await testWeb(async () => { + const mod = await import("~/components/upload/webdav/element.js"); + const upload = new mod.CLASS(); + document.body.append(upload); + return "authorize" in upload; + }); + + expect(hasAuthorize).toBe(false); + }); +});