test(e2e): Playwright + Vitest coverage harness and test instrumentation

Add an end-to-end and unit test suite for the SvelteKit frontend:

- Playwright e2e specs (tests/e2e/spa) with a throwaway container stack,
  codegen scenarios, and an Istanbul-based coverage report pipeline.
- Vitest unit tests across API endpoints, components, stores and composables.
- `data-testid` hooks on interactive elements (AppShell, FileViewer,
  ShareDialog, search, photos, files breadcrumbs, login/Nextcloud flows,
  public share pages) so the e2e suite can target them deterministically.
- Serve the SPA app-shell CSP from a <meta> policy (svelte.config.js) plus a
  middleware that skips the CSP header on HTML; move the Nextcloud Login Flow
  v2 grant page to the SvelteKit /nextcloud/login route.
- `just front-codegen` recipe and start-server-spa.sh harness.

Make the test environment robust and consistent:
- Install a deterministic in-memory localStorage/sessionStorage in the Vitest
  setup so storage behaves identically across Node versions (Node 26 ships a
  native Web Storage global that otherwise shadows jsdom's).
- Pin devenv to Node 26 + PostgreSQL 18 and pin every CI job to Node 26.3.0
  so the dev shell and CI run the same toolchain versions.

Repair the API/WebDAV (hurl) suite, which had drifted from the backend:
- Migrate the removed `/api/folders/{id}/listing` endpoint to `/resources`
  (cursor-paginated `{items:[{resource_type,resource}]}` shape) across the
  batch-copy, grants, nested-group, and WebDAV NC tests + the dav_helpers
  wipe routine.
- Stop photos_etag from uploading the dedup-tracked fixture so the dedup
  blob-lifecycle test can own its content-addressed blob exclusively.
- dedup_create now asserts the idempotent same-content re-upload (201 +
  existing file id) instead of the stale 409 expectation.

Generated coverage reports, nyc output and the e2e server runtime data dir
are gitignored rather than committed.
This commit is contained in:
Bradley Nelson
2026-06-21 20:03:32 -06:00
parent 0c40c69f9b
commit e3823ce470
162 changed files with 13213 additions and 554 deletions
+1
View File
@@ -0,0 +1 @@
{"sessionId":"82cd2c6b-7874-4cfa-9d00-297f91d81b98","pid":2450899,"procStart":"26101142","acquiredAt":1781931933331}
+21
View File
@@ -1,6 +1,27 @@
# Build artifacts # Build artifacts
target/ target/
# Dependency / tooling directories — NEVER needed in the build context.
# These dominate the context by file count (frontend/node_modules alone is
# ~11k tiny files); shipping them makes the Testcontainers build (which tars
# the context in pure JS, matching every path against this file) crawl and can
# blow past the e2e stack-setup timeout. The frontend stage runs its own
# `npm ci`, so host node_modules is both unused and would clobber it via the
# later `COPY frontend/ ./`. .devenv/.direnv are the Nix dev shell.
**/node_modules/
.devenv/
.direnv/
# Generated SPA output — the image builds the SPA fresh in the `frontend` stage
# and pulls it via `COPY --from=frontend /static-dist`; the host copy is unused.
static-dist/
frontend/build/
dist/
# Standalone wasm sub-workspaces — not a path dependency of the server crate
# (see Cargo.toml), so `cargo build` in the image never reads them.
wasm/
# Git # Git
.git/ .git/
.gitignore .gitignore
+15 -3
View File
@@ -64,7 +64,7 @@ jobs:
- name: Setup Node - name: Setup Node
uses: actions/setup-node@v4 uses: actions/setup-node@v4
with: with:
node-version: 26.3.1 node-version: 26.3.0
cache: npm cache: npm
cache-dependency-path: frontend/package-lock.json cache-dependency-path: frontend/package-lock.json
@@ -240,7 +240,7 @@ jobs:
- name: Setup Node - name: Setup Node
uses: actions/setup-node@v4 uses: actions/setup-node@v4
with: with:
node-version: 26.3.1 node-version: 26.3.0
cache: npm cache: npm
cache-dependency-path: frontend/package-lock.json cache-dependency-path: frontend/package-lock.json
- name: Build SPA (Vite -> static-dist/) - name: Build SPA (Vite -> static-dist/)
@@ -324,12 +324,20 @@ jobs:
- uses: actions/setup-node@v4 - uses: actions/setup-node@v4
with: with:
node-version: lts/* node-version: 26.3.0
- name: Install Node dependencies - name: Install Node dependencies
working-directory: tests/e2e working-directory: tests/e2e
run: npm ci run: npm ci
# The release binary serves the SPA from ./static-dist on disk (not
# embedded), and the Build job builds it WITHOUT VITE_E2E so it lacks the
# `data-testid` hooks the specs target. Build it here with VITE_E2E=1 so
# the server actually serves the e2e SPA the scenarios drive.
- name: Build SPA for e2e (VITE_E2E keeps data-testid hooks)
working-directory: frontend
run: npm ci && VITE_E2E=1 npm run build
- name: Install Playwright browsers - name: Install Playwright browsers
working-directory: tests/e2e working-directory: tests/e2e
run: npx playwright install --with-deps run: npx playwright install --with-deps
@@ -340,6 +348,10 @@ jobs:
env: env:
BUILD_TARGET: release BUILD_TARGET: release
- name: Print server startup log
if: always()
run: cat tests/e2e/server-startup.log || echo "no server-startup.log produced"
- uses: actions/upload-artifact@v4 - uses: actions/upload-artifact@v4
if: ${{ !cancelled() }} if: ${{ !cancelled() }}
with: with:
+63 -4
View File
@@ -1,3 +1,13 @@
# syntax=docker/dockerfile:1.7
# Selects which builder stage assembles the runtime image. Defaults reproduce
# the CI/release path exactly (the `builder` stage; binaries under
# target/release). The e2e image build overrides these to
# BUILDER=builder-cache / BIN_DIR=/app/bin to use the BuildKit cache-mount
# builder. Declared in the global scope because FROM (unlike COPY --from) can
# expand a build arg in a stage reference.
ARG BUILDER=builder
ARG BIN_DIR=/app/target/release
# ─── Stage 1: Shared build base (avoids duplicate apk install) ──────────────── # ─── Stage 1: Shared build base (avoids duplicate apk install) ────────────────
FROM rust:1.96-alpine3.24 AS base FROM rust:1.96-alpine3.24 AS base
# sqlx's postgres driver speaks the wire protocol in pure Rust (no pq-sys in # sqlx's postgres driver speaks the wire protocol in pure Rust (no pq-sys in
@@ -12,8 +22,15 @@ RUN apk --no-cache upgrade && \
FROM node:26.3.1-alpine3.24 AS frontend FROM node:26.3.1-alpine3.24 AS frontend
WORKDIR /frontend WORKDIR /frontend
COPY frontend/package.json frontend/package-lock.json ./ COPY frontend/package.json frontend/package-lock.json ./
RUN npm ci # Cache mount for npm's package store: when the lockfile changes (busting the
# layer) npm ci still reuses already-downloaded tarballs instead of refetching
# them. Persists in the local BuildKit cache; ignored harmlessly when absent.
RUN --mount=type=cache,target=/root/.npm npm ci
COPY frontend/ ./ COPY frontend/ ./
# VITE_E2E=1 keeps the test-only `data-testid` attributes in the build (set by
# the e2e image build); unset for release images, which strip them entirely.
ARG VITE_E2E
ENV VITE_E2E=${VITE_E2E}
RUN npm run build RUN npm run build
# ─── Stage 2: Cache dependencies ───────────────────────────────────────────── # ─── Stage 2: Cache dependencies ─────────────────────────────────────────────
@@ -54,6 +71,48 @@ RUN DATABASE_URL="${DATABASE_URL}" cargo build --release --bin oxicloud --bin ge
# below (build.rs has no asset pipeline — it only injects git metadata). # below (build.rs has no asset pipeline — it only injects git metadata).
COPY --from=frontend /static-dist ./static-dist COPY --from=frontend /static-dist ./static-dist
# ─── Stage 3b: Cache-mount builder (local e2e fast incremental rebuilds) ──────
# Built ONLY when BUILDER=builder-cache is passed (the Testcontainers e2e build,
# which calls .withBuildkit()). BuildKit cache mounts persist the cargo registry
# and target/ in the local BuildKit cache across runs, so a one-line src change
# recompiles just the changed crate instead of the whole dependency graph. CI
# never sets this arg, so this stage is absent from CI's build graph and CI
# behaviour/caching is unchanged.
#
# NOTE: target/ is a cache mount, so it is NOT part of the image layer once the
# RUN finishes — the two shipped binaries MUST be cp'd out within the same RUN.
# TARGETARCH scopes the target/ mount per-arch so it is never shared across
# architectures (object files are arch-specific).
FROM base AS builder-cache
WORKDIR /app
COPY Cargo.toml Cargo.lock build.rs ./
COPY src src
COPY static static
COPY migrations migrations
COPY templates templates
COPY --from=frontend /static-dist ./static-dist
ARG DATABASE_URL="postgres://postgres:postgres@localhost/oxicloud"
ARG TARGETARCH
RUN --mount=type=cache,id=cargo-registry,target=/usr/local/cargo/registry,sharing=shared \
--mount=type=cache,id=cargo-git,target=/usr/local/cargo/git,sharing=shared \
--mount=type=cache,id=oxicloud-target-${TARGETARCH},target=/app/target,sharing=locked \
DATABASE_URL="${DATABASE_URL}" cargo build --release && \
mkdir -p /app/bin && \
cp target/release/oxicloud /app/bin/oxicloud && \
cp target/release/migrate-nfc-filenames /app/bin/migrate-nfc-filenames
# ─── Stage 3c: Select the builder & normalise the binary path ─────────────────
# FROM expands the global ${BUILDER} arg to alias the chosen builder stage
# (`builder` for CI/release, `builder-cache` for the e2e image). It then copies
# the two shipped binaries from the builder-specific ${BIN_DIR} into a single
# stable path (/app/release) so the runtime stage's COPYs are independent of
# which builder ran. `static-dist` already lives at /app/static-dist in both
# builders, so it needs no normalisation.
FROM ${BUILDER} AS app
ARG BIN_DIR
RUN mkdir -p /app/release && \
cp "${BIN_DIR}/oxicloud" "${BIN_DIR}/migrate-nfc-filenames" /app/release/
# ─── Stage 4: Minimal runtime image ────────────────────────────────────────── # ─── Stage 4: Minimal runtime image ──────────────────────────────────────────
FROM alpine:3.24.0 FROM alpine:3.24.0
@@ -76,19 +135,19 @@ RUN apk --no-cache upgrade && \
adduser -u 1001 -S oxicloud -G oxicloud adduser -u 1001 -S oxicloud -G oxicloud
# Copy the compiled binary and entrypoint (--chmod avoids extra RUN chmod layers) # Copy the compiled binary and entrypoint (--chmod avoids extra RUN chmod layers)
COPY --from=builder --chmod=755 /app/target/release/oxicloud /usr/local/bin/ COPY --from=app --chmod=755 /app/release/oxicloud /usr/local/bin/
# Ship the NFC filename migration binary alongside the server so # Ship the NFC filename migration binary alongside the server so
# operators can run it inside the container without a separate Rust # operators can run it inside the container without a separate Rust
# toolchain — `docker exec <container> migrate-nfc-filenames --dry-run` # toolchain — `docker exec <container> migrate-nfc-filenames --dry-run`
# to preview, drop `--dry-run` to execute. One-shot tool, safe to # to preview, drop `--dry-run` to execute. One-shot tool, safe to
# ship; it only mutates `storage.files` rows whose name ≠ NFC(name). # ship; it only mutates `storage.files` rows whose name ≠ NFC(name).
COPY --from=builder --chmod=755 /app/target/release/migrate-nfc-filenames /usr/local/bin/ COPY --from=app --chmod=755 /app/release/migrate-nfc-filenames /usr/local/bin/
COPY entrypoint.sh /usr/local/bin/entrypoint.sh COPY entrypoint.sh /usr/local/bin/entrypoint.sh
RUN sed -i 's/\r//' /usr/local/bin/entrypoint.sh && \ RUN sed -i 's/\r//' /usr/local/bin/entrypoint.sh && \
chmod 755 /usr/local/bin/entrypoint.sh chmod 755 /usr/local/bin/entrypoint.sh
# Copy the built SPA (produced by the Vite frontend stage) # Copy the built SPA (produced by the Vite frontend stage)
COPY --from=builder --chown=oxicloud:oxicloud /app/static-dist /app/static COPY --from=app --chown=oxicloud:oxicloud /app/static-dist /app/static
# Create storage directory with proper permissions # Create storage directory with proper permissions
RUN mkdir -p /app/storage && chown -R oxicloud:oxicloud /app/storage RUN mkdir -p /app/storage && chown -R oxicloud:oxicloud /app/storage
+7 -2
View File
@@ -21,8 +21,11 @@
just just
cargo-audit cargo-audit
# frontend tooling (no root package.json — these are expected as global bins) # frontend tooling (no root package.json — these are expected as global bins).
nodejs_24 # Pinned to Node 26 to match CI (.github/workflows/ci.yml). Newer Node ships
# a native global Web Storage API, so a skew here vs CI silently changes
# jsdom/localStorage behaviour in the Vitest suite.
nodejs_26
biome biome
typescript # provides `tsc` typescript # provides `tsc`
stylelint stylelint
@@ -39,6 +42,8 @@
# (postgres://postgres:postgres@localhost:5432/oxicloud). # (postgres://postgres:postgres@localhost:5432/oxicloud).
services.postgres = { services.postgres = {
enable = true; enable = true;
# Pinned to PostgreSQL 18 to match CI (postgres:18-alpine in ci.yml).
package = pkgs.postgresql_18;
listen_addresses = "127.0.0.1"; listen_addresses = "127.0.0.1";
port = 5432; port = 5432;
initialDatabases = [ { name = "oxicloud"; } ]; initialDatabases = [ { name = "oxicloud"; } ];
+8
View File
@@ -32,6 +32,14 @@ services:
build: build:
context: . context: .
dockerfile: Dockerfile dockerfile: Dockerfile
# Route `docker compose up --build` / `docker compose build` through the
# cache-mount builder so repeat local builds recompile only changed crates
# (true incremental). Compose v2 uses BuildKit by default, which the
# Dockerfile already requires. The image is functionally identical to the
# default `builder` stage CI uses; only the build path differs.
args:
BUILDER: builder-cache
BIN_DIR: /app/bin
ports: ports:
- "8086:8086" - "8086:8086"
networks: networks:
+3
View File
@@ -7,3 +7,6 @@ node_modules/
*.local *.local
vite.config.ts.timestamp-* vite.config.ts.timestamp-*
vite.config.js.timestamp-* vite.config.js.timestamp-*
# Vitest/istanbul coverage output
/coverage/
+1
View File
@@ -16,3 +16,4 @@ static/vendors/
static/workers/ static/workers/
static/basemaps/ static/basemaps/
static/geo/ static/geo/
coverage/
+745 -8
View File
@@ -20,6 +20,7 @@
"eslint-plugin-svelte": "^3.19.0", "eslint-plugin-svelte": "^3.19.0",
"globals": "^17.6.0", "globals": "^17.6.0",
"jsdom": "^29.1.1", "jsdom": "^29.1.1",
"parse5": "^7.3.0",
"postcss-html": "^1.8.1", "postcss-html": "^1.8.1",
"prettier": "^3.8.4", "prettier": "^3.8.4",
"prettier-plugin-svelte": "^4.1.1", "prettier-plugin-svelte": "^4.1.1",
@@ -30,6 +31,7 @@
"typescript": "^6.0.3", "typescript": "^6.0.3",
"typescript-eslint": "^8.61.1", "typescript-eslint": "^8.61.1",
"vite": "^6.4.3", "vite": "^6.4.3",
"vite-plugin-istanbul": "^8.0.0",
"vitest": "^4.1.9" "vitest": "^4.1.9"
}, },
"engines": { "engines": {
@@ -109,6 +111,163 @@
"node": ">=6.9.0" "node": ">=6.9.0"
} }
}, },
"node_modules/@babel/compat-data": {
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz",
"integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/core": {
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz",
"integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/code-frame": "^7.29.7",
"@babel/generator": "^7.29.7",
"@babel/helper-compilation-targets": "^7.29.7",
"@babel/helper-module-transforms": "^7.29.7",
"@babel/helpers": "^7.29.7",
"@babel/parser": "^7.29.7",
"@babel/template": "^7.29.7",
"@babel/traverse": "^7.29.7",
"@babel/types": "^7.29.7",
"@jridgewell/remapping": "^2.3.5",
"convert-source-map": "^2.0.0",
"debug": "^4.1.0",
"gensync": "^1.0.0-beta.2",
"json5": "^2.2.3",
"semver": "^6.3.1"
},
"engines": {
"node": ">=6.9.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/babel"
}
},
"node_modules/@babel/core/node_modules/semver": {
"version": "6.3.1",
"resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
"integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==",
"dev": true,
"license": "ISC",
"bin": {
"semver": "bin/semver.js"
}
},
"node_modules/@babel/generator": {
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.7.tgz",
"integrity": "sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/parser": "^7.29.7",
"@babel/types": "^7.29.7",
"@jridgewell/gen-mapping": "^0.3.12",
"@jridgewell/trace-mapping": "^0.3.28",
"jsesc": "^3.0.2"
},
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/helper-compilation-targets": {
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz",
"integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/compat-data": "^7.29.7",
"@babel/helper-validator-option": "^7.29.7",
"browserslist": "^4.24.0",
"lru-cache": "^5.1.1",
"semver": "^6.3.1"
},
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/helper-compilation-targets/node_modules/lru-cache": {
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz",
"integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==",
"dev": true,
"license": "ISC",
"dependencies": {
"yallist": "^3.0.2"
}
},
"node_modules/@babel/helper-compilation-targets/node_modules/semver": {
"version": "6.3.1",
"resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
"integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==",
"dev": true,
"license": "ISC",
"bin": {
"semver": "bin/semver.js"
}
},
"node_modules/@babel/helper-globals": {
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz",
"integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/helper-module-imports": {
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz",
"integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/traverse": "^7.29.7",
"@babel/types": "^7.29.7"
},
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/helper-module-transforms": {
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz",
"integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/helper-module-imports": "^7.29.7",
"@babel/helper-validator-identifier": "^7.29.7",
"@babel/traverse": "^7.29.7"
},
"engines": {
"node": ">=6.9.0"
},
"peerDependencies": {
"@babel/core": "^7.0.0"
}
},
"node_modules/@babel/helper-string-parser": {
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz",
"integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/helper-validator-identifier": { "node_modules/@babel/helper-validator-identifier": {
"version": "7.29.7", "version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz",
@@ -119,6 +278,46 @@
"node": ">=6.9.0" "node": ">=6.9.0"
} }
}, },
"node_modules/@babel/helper-validator-option": {
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz",
"integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/helpers": {
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz",
"integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/template": "^7.29.7",
"@babel/types": "^7.29.7"
},
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/parser": {
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz",
"integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/types": "^7.29.7"
},
"bin": {
"parser": "bin/babel-parser.js"
},
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/@babel/runtime": { "node_modules/@babel/runtime": {
"version": "7.29.7", "version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz", "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz",
@@ -129,6 +328,54 @@
"node": ">=6.9.0" "node": ">=6.9.0"
} }
}, },
"node_modules/@babel/template": {
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz",
"integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/code-frame": "^7.29.7",
"@babel/parser": "^7.29.7",
"@babel/types": "^7.29.7"
},
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/traverse": {
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.7.tgz",
"integrity": "sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/code-frame": "^7.29.7",
"@babel/generator": "^7.29.7",
"@babel/helper-globals": "^7.29.7",
"@babel/parser": "^7.29.7",
"@babel/template": "^7.29.7",
"@babel/types": "^7.29.7",
"debug": "^4.3.1"
},
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/types": {
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz",
"integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/helper-string-parser": "^7.29.7",
"@babel/helper-validator-identifier": "^7.29.7"
},
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@bramus/specificity": { "node_modules/@bramus/specificity": {
"version": "2.4.2", "version": "2.4.2",
"resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz", "resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz",
@@ -676,6 +923,123 @@
"url": "https://github.com/sponsors/nzakas" "url": "https://github.com/sponsors/nzakas"
} }
}, },
"node_modules/@istanbuljs/load-nyc-config": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@istanbuljs/load-nyc-config/-/load-nyc-config-1.1.0.tgz",
"integrity": "sha512-VjeHSlIzpv/NyD3N0YuHfXOPDIixcA1q2ZV98wsMqcYlPmv2n3Yb2lYP9XMElnaFVXg5A7YLTeLu6V84uQDjmQ==",
"dev": true,
"license": "ISC",
"dependencies": {
"camelcase": "^5.3.1",
"find-up": "^4.1.0",
"get-package-type": "^0.1.0",
"js-yaml": "^3.13.1",
"resolve-from": "^5.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/@istanbuljs/load-nyc-config/node_modules/argparse": {
"version": "1.0.10",
"resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz",
"integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==",
"dev": true,
"license": "MIT",
"dependencies": {
"sprintf-js": "~1.0.2"
}
},
"node_modules/@istanbuljs/load-nyc-config/node_modules/find-up": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz",
"integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==",
"dev": true,
"license": "MIT",
"dependencies": {
"locate-path": "^5.0.0",
"path-exists": "^4.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/@istanbuljs/load-nyc-config/node_modules/js-yaml": {
"version": "3.14.2",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.2.tgz",
"integrity": "sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg==",
"dev": true,
"license": "MIT",
"dependencies": {
"argparse": "^1.0.7",
"esprima": "^4.0.0"
},
"bin": {
"js-yaml": "bin/js-yaml.js"
}
},
"node_modules/@istanbuljs/load-nyc-config/node_modules/locate-path": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz",
"integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==",
"dev": true,
"license": "MIT",
"dependencies": {
"p-locate": "^4.1.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/@istanbuljs/load-nyc-config/node_modules/p-limit": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz",
"integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==",
"dev": true,
"license": "MIT",
"dependencies": {
"p-try": "^2.0.0"
},
"engines": {
"node": ">=6"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/@istanbuljs/load-nyc-config/node_modules/p-locate": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz",
"integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==",
"dev": true,
"license": "MIT",
"dependencies": {
"p-limit": "^2.2.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/@istanbuljs/load-nyc-config/node_modules/resolve-from": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz",
"integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/@istanbuljs/schema": {
"version": "0.1.6",
"resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.6.tgz",
"integrity": "sha512-+Sg6GCR/wy1oSmQDFq4LQDAhm3ETKnorxN+y5nbLULOR3P0c14f2Wurzj3/xqPXtasLFfHd5iRFQ7AJt4KH2cw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/@jridgewell/gen-mapping": { "node_modules/@jridgewell/gen-mapping": {
"version": "0.3.13", "version": "0.3.13",
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
@@ -1364,6 +1728,16 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/@types/babel__generator": {
"version": "7.27.0",
"resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz",
"integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/types": "^7.0.0"
}
},
"node_modules/@types/chai": { "node_modules/@types/chai": {
"version": "5.2.3", "version": "5.2.3",
"resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz",
@@ -1893,6 +2267,19 @@
"node": "18 || 20 || >=22" "node": "18 || 20 || >=22"
} }
}, },
"node_modules/baseline-browser-mapping": {
"version": "2.10.38",
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.38.tgz",
"integrity": "sha512-31/02mVB4yuQU6adKk5SlY6m+mxDwUq5KZkyYgnLrrKl7TEm1+3PyDtDBz2kOv/wxZz41GHsvV1A/u6RmiyBvw==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"baseline-browser-mapping": "dist/cli.cjs"
},
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/bidi-js": { "node_modules/bidi-js": {
"version": "1.0.3", "version": "1.0.3",
"resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz", "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz",
@@ -1929,6 +2316,40 @@
"node": ">=8" "node": ">=8"
} }
}, },
"node_modules/browserslist": {
"version": "4.28.2",
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz",
"integrity": "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==",
"dev": true,
"funding": [
{
"type": "opencollective",
"url": "https://opencollective.com/browserslist"
},
{
"type": "tidelift",
"url": "https://tidelift.com/funding/github/npm/browserslist"
},
{
"type": "github",
"url": "https://github.com/sponsors/ai"
}
],
"license": "MIT",
"dependencies": {
"baseline-browser-mapping": "^2.10.12",
"caniuse-lite": "^1.0.30001782",
"electron-to-chromium": "^1.5.328",
"node-releases": "^2.0.36",
"update-browserslist-db": "^1.2.3"
},
"bin": {
"browserslist": "cli.js"
},
"engines": {
"node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
}
},
"node_modules/cacheable": { "node_modules/cacheable": {
"version": "2.3.5", "version": "2.3.5",
"resolved": "https://registry.npmjs.org/cacheable/-/cacheable-2.3.5.tgz", "resolved": "https://registry.npmjs.org/cacheable/-/cacheable-2.3.5.tgz",
@@ -1963,6 +2384,37 @@
"node": ">=6" "node": ">=6"
} }
}, },
"node_modules/camelcase": {
"version": "5.3.1",
"resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz",
"integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/caniuse-lite": {
"version": "1.0.30001799",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001799.tgz",
"integrity": "sha512-hG1bReV+OUU+MOqK4t/ZWI0tZOyz3rqS9XuhOUz1cIcbwBKjOyJEJuw9ER5JuNyqxNk8u/JUVbGibBOL1yrjFw==",
"dev": true,
"funding": [
{
"type": "opencollective",
"url": "https://opencollective.com/browserslist"
},
{
"type": "tidelift",
"url": "https://tidelift.com/funding/github/npm/caniuse-lite"
},
{
"type": "github",
"url": "https://github.com/sponsors/ai"
}
],
"license": "CC-BY-4.0"
},
"node_modules/chai": { "node_modules/chai": {
"version": "6.2.2", "version": "6.2.2",
"resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz",
@@ -2281,6 +2733,13 @@
"url": "https://github.com/fb55/domutils?sponsor=1" "url": "https://github.com/fb55/domutils?sponsor=1"
} }
}, },
"node_modules/electron-to-chromium": {
"version": "1.5.376",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.376.tgz",
"integrity": "sha512-cUVA7/RvbFTEuw/i3obUwDTRIXojaxkResf+ibByPFxjc6XK3VNtcQXV0NSbAlJ0FMjcJGgftVVB4Qo184EXvA==",
"dev": true,
"license": "ISC"
},
"node_modules/emoji-regex": { "node_modules/emoji-regex": {
"version": "8.0.0", "version": "8.0.0",
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
@@ -2289,13 +2748,13 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/entities": { "node_modules/entities": {
"version": "8.0.0", "version": "6.0.1",
"resolved": "https://registry.npmjs.org/entities/-/entities-8.0.0.tgz", "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz",
"integrity": "sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==", "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==",
"dev": true, "dev": true,
"license": "BSD-2-Clause", "license": "BSD-2-Clause",
"engines": { "engines": {
"node": ">=20.19.0" "node": ">=0.12"
}, },
"funding": { "funding": {
"url": "https://github.com/fb55/entities?sponsor=1" "url": "https://github.com/fb55/entities?sponsor=1"
@@ -2328,6 +2787,16 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/escalade": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
"integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/escape-string-regexp": { "node_modules/escape-string-regexp": {
"version": "4.0.0", "version": "4.0.0",
"resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz",
@@ -2547,6 +3016,20 @@
"url": "https://opencollective.com/eslint" "url": "https://opencollective.com/eslint"
} }
}, },
"node_modules/esprima": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz",
"integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==",
"dev": true,
"license": "BSD-2-Clause",
"bin": {
"esparse": "bin/esparse.js",
"esvalidate": "bin/esvalidate.js"
},
"engines": {
"node": ">=4"
}
},
"node_modules/esquery": { "node_modules/esquery": {
"version": "1.7.0", "version": "1.7.0",
"resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz",
@@ -2798,6 +3281,16 @@
"node": "^8.16.0 || ^10.6.0 || >=11.0.0" "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
} }
}, },
"node_modules/gensync": {
"version": "1.0.0-beta.2",
"resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz",
"integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/get-east-asian-width": { "node_modules/get-east-asian-width": {
"version": "1.6.0", "version": "1.6.0",
"resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.6.0.tgz", "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.6.0.tgz",
@@ -2811,6 +3304,34 @@
"url": "https://github.com/sponsors/sindresorhus" "url": "https://github.com/sponsors/sindresorhus"
} }
}, },
"node_modules/get-package-type": {
"version": "0.1.0",
"resolved": "https://registry.npmjs.org/get-package-type/-/get-package-type-0.1.0.tgz",
"integrity": "sha512-pjzuKtY64GYfWizNAJ0fr9VqttZkNiK2iS430LtIHzjBEr6bX8Am2zm4sW4Ro5wjWW5cAlRL1qAMTcXbjNAO2Q==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8.0.0"
}
},
"node_modules/glob": {
"version": "13.0.6",
"resolved": "https://registry.npmjs.org/glob/-/glob-13.0.6.tgz",
"integrity": "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==",
"dev": true,
"license": "BlueOak-1.0.0",
"dependencies": {
"minimatch": "^10.2.2",
"minipass": "^7.1.3",
"path-scurry": "^2.0.2"
},
"engines": {
"node": "18 || 20 || >=22"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/glob-parent": { "node_modules/glob-parent": {
"version": "6.0.2", "version": "6.0.2",
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz",
@@ -3160,6 +3681,33 @@
"dev": true, "dev": true,
"license": "ISC" "license": "ISC"
}, },
"node_modules/istanbul-lib-coverage": {
"version": "3.2.2",
"resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz",
"integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==",
"dev": true,
"license": "BSD-3-Clause",
"engines": {
"node": ">=8"
}
},
"node_modules/istanbul-lib-instrument": {
"version": "6.0.3",
"resolved": "https://registry.npmjs.org/istanbul-lib-instrument/-/istanbul-lib-instrument-6.0.3.tgz",
"integrity": "sha512-Vtgk7L/R2JHyyGW07spoFlB8/lpjiOLTjMdms6AFMraYt3BaJauod/NGrfnVG/y4Ix1JEuMRPDPEj2ua+zz1/Q==",
"dev": true,
"license": "BSD-3-Clause",
"dependencies": {
"@babel/core": "^7.23.9",
"@babel/parser": "^7.23.9",
"@istanbuljs/schema": "^0.1.3",
"istanbul-lib-coverage": "^3.2.0",
"semver": "^7.5.4"
},
"engines": {
"node": ">=10"
}
},
"node_modules/js-tokens": { "node_modules/js-tokens": {
"version": "4.0.0", "version": "4.0.0",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
@@ -3231,6 +3779,45 @@
} }
} }
}, },
"node_modules/jsdom/node_modules/entities": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/entities/-/entities-8.0.0.tgz",
"integrity": "sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==",
"dev": true,
"license": "BSD-2-Clause",
"engines": {
"node": ">=20.19.0"
},
"funding": {
"url": "https://github.com/fb55/entities?sponsor=1"
}
},
"node_modules/jsdom/node_modules/parse5": {
"version": "8.0.1",
"resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz",
"integrity": "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==",
"dev": true,
"license": "MIT",
"dependencies": {
"entities": "^8.0.0"
},
"funding": {
"url": "https://github.com/inikulin/parse5?sponsor=1"
}
},
"node_modules/jsesc": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz",
"integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==",
"dev": true,
"license": "MIT",
"bin": {
"jsesc": "bin/jsesc"
},
"engines": {
"node": ">=6"
}
},
"node_modules/json-buffer": { "node_modules/json-buffer": {
"version": "3.0.1", "version": "3.0.1",
"resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz",
@@ -3259,6 +3846,19 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/json5": {
"version": "2.2.3",
"resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz",
"integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==",
"dev": true,
"license": "MIT",
"bin": {
"json5": "lib/cli.js"
},
"engines": {
"node": ">=6"
}
},
"node_modules/keyv": { "node_modules/keyv": {
"version": "4.5.4", "version": "4.5.4",
"resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
@@ -3468,6 +4068,16 @@
"url": "https://github.com/sponsors/isaacs" "url": "https://github.com/sponsors/isaacs"
} }
}, },
"node_modules/minipass": {
"version": "7.1.3",
"resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz",
"integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==",
"dev": true,
"license": "BlueOak-1.0.0",
"engines": {
"node": ">=16 || 14 >=14.17"
}
},
"node_modules/mri": { "node_modules/mri": {
"version": "1.2.0", "version": "1.2.0",
"resolved": "https://registry.npmjs.org/mri/-/mri-1.2.0.tgz", "resolved": "https://registry.npmjs.org/mri/-/mri-1.2.0.tgz",
@@ -3521,6 +4131,16 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/node-releases": {
"version": "2.0.48",
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.48.tgz",
"integrity": "sha512-1uz8041X6LoI6ZSdZacM9lVY28vuzDlSKitnpbSNK0RfKoIJkX29NBPVEFXhnuSuEOA9Ww0xnPJ+ILWbGAv8DA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=18"
}
},
"node_modules/normalize-path": { "node_modules/normalize-path": {
"version": "3.0.0", "version": "3.0.0",
"resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz",
@@ -3595,6 +4215,16 @@
"url": "https://github.com/sponsors/sindresorhus" "url": "https://github.com/sponsors/sindresorhus"
} }
}, },
"node_modules/p-try": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz",
"integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/parent-module": { "node_modules/parent-module": {
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz",
@@ -3628,13 +4258,13 @@
} }
}, },
"node_modules/parse5": { "node_modules/parse5": {
"version": "8.0.1", "version": "7.3.0",
"resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz", "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz",
"integrity": "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==", "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"entities": "^8.0.0" "entities": "^6.0.0"
}, },
"funding": { "funding": {
"url": "https://github.com/inikulin/parse5?sponsor=1" "url": "https://github.com/inikulin/parse5?sponsor=1"
@@ -3660,6 +4290,23 @@
"node": ">=8" "node": ">=8"
} }
}, },
"node_modules/path-scurry": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.2.tgz",
"integrity": "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==",
"dev": true,
"license": "BlueOak-1.0.0",
"dependencies": {
"lru-cache": "^11.0.0",
"minipass": "^7.1.2"
},
"engines": {
"node": "18 || 20 || >=22"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/pathe": { "node_modules/pathe": {
"version": "2.0.3", "version": "2.0.3",
"resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz",
@@ -4233,6 +4880,16 @@
"url": "https://github.com/chalk/slice-ansi?sponsor=1" "url": "https://github.com/chalk/slice-ansi?sponsor=1"
} }
}, },
"node_modules/source-map": {
"version": "0.7.6",
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.6.tgz",
"integrity": "sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ==",
"dev": true,
"license": "BSD-3-Clause",
"engines": {
"node": ">= 12"
}
},
"node_modules/source-map-js": { "node_modules/source-map-js": {
"version": "1.2.1", "version": "1.2.1",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
@@ -4243,6 +4900,13 @@
"node": ">=0.10.0" "node": ">=0.10.0"
} }
}, },
"node_modules/sprintf-js": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz",
"integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==",
"dev": true,
"license": "BSD-3-Clause"
},
"node_modules/stackback": { "node_modules/stackback": {
"version": "0.0.2", "version": "0.0.2",
"resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz",
@@ -4771,6 +5435,21 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/test-exclude": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-8.0.0.tgz",
"integrity": "sha512-ZOffsNrXYggvU1mDGHk54I96r26P8SyMjO5slMKSc7+IWmtB/MQKnEC2fP51imB3/pT6YK5cT5E8f+Dd9KdyOQ==",
"dev": true,
"license": "ISC",
"dependencies": {
"@istanbuljs/schema": "^0.1.2",
"glob": "^13.0.6",
"minimatch": "^10.2.2"
},
"engines": {
"node": "20 || >=22"
}
},
"node_modules/tinybench": { "node_modules/tinybench": {
"version": "2.9.0", "version": "2.9.0",
"resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz",
@@ -5009,6 +5688,37 @@
"url": "https://github.com/sponsors/sindresorhus" "url": "https://github.com/sponsors/sindresorhus"
} }
}, },
"node_modules/update-browserslist-db": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz",
"integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==",
"dev": true,
"funding": [
{
"type": "opencollective",
"url": "https://opencollective.com/browserslist"
},
{
"type": "tidelift",
"url": "https://tidelift.com/funding/github/npm/browserslist"
},
{
"type": "github",
"url": "https://github.com/sponsors/ai"
}
],
"license": "MIT",
"dependencies": {
"escalade": "^3.2.0",
"picocolors": "^1.1.1"
},
"bin": {
"update-browserslist-db": "cli.js"
},
"peerDependencies": {
"browserslist": ">= 4.21.0"
}
},
"node_modules/uri-js": { "node_modules/uri-js": {
"version": "4.4.1", "version": "4.4.1",
"resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz",
@@ -5101,6 +5811,26 @@
} }
} }
}, },
"node_modules/vite-plugin-istanbul": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/vite-plugin-istanbul/-/vite-plugin-istanbul-8.0.0.tgz",
"integrity": "sha512-r6L7cg2iwPqNnY/rWFyemWeDTIKRZjekEWS90e2FsTjDYH4UdTS6hvW1nEX1B++PKPCnqCaj5BJTDn5Cy5jYoQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/generator": "^7.29.1",
"@istanbuljs/load-nyc-config": "^1.1.0",
"@types/babel__generator": "7.27.0",
"espree": "^11.2.0",
"istanbul-lib-instrument": "^6.0.3",
"picocolors": "^1.1.1",
"source-map": "^0.7.6",
"test-exclude": "^8.0.0"
},
"peerDependencies": {
"vite": ">=4"
}
},
"node_modules/vite/node_modules/@esbuild/aix-ppc64": { "node_modules/vite/node_modules/@esbuild/aix-ppc64": {
"version": "0.25.12", "version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz",
@@ -5809,6 +6539,13 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/yallist": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
"integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==",
"dev": true,
"license": "ISC"
},
"node_modules/yaml": { "node_modules/yaml": {
"version": "2.9.0", "version": "2.9.0",
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz", "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz",
+4 -1
View File
@@ -15,7 +15,8 @@
"lint": "eslint .", "lint": "eslint .",
"format": "prettier --write .", "format": "prettier --write .",
"test:unit": "vitest run", "test:unit": "vitest run",
"test:unit:watch": "vitest" "test:unit:watch": "vitest",
"test:unit:coverage": "rm -rf ../tests/e2e/.nyc_output_unit && COVERAGE=1 vitest run"
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^10.0.1", "@eslint/js": "^10.0.1",
@@ -30,6 +31,7 @@
"eslint-plugin-svelte": "^3.19.0", "eslint-plugin-svelte": "^3.19.0",
"globals": "^17.6.0", "globals": "^17.6.0",
"jsdom": "^29.1.1", "jsdom": "^29.1.1",
"parse5": "^7.3.0",
"postcss-html": "^1.8.1", "postcss-html": "^1.8.1",
"prettier": "^3.8.4", "prettier": "^3.8.4",
"prettier-plugin-svelte": "^4.1.1", "prettier-plugin-svelte": "^4.1.1",
@@ -40,6 +42,7 @@
"typescript": "^6.0.3", "typescript": "^6.0.3",
"typescript-eslint": "^8.61.1", "typescript-eslint": "^8.61.1",
"vite": "^6.4.3", "vite": "^6.4.3",
"vite-plugin-istanbul": "^8.0.0",
"vitest": "^4.1.9" "vitest": "^4.1.9"
} }
} }
+8 -2
View File
@@ -5,13 +5,20 @@
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="icon" href="%sveltekit.assets%/favicon.ico" /> <link rel="icon" href="%sveltekit.assets%/favicon.ico" />
<meta name="color-scheme" content="light dark" /> <meta name="color-scheme" content="light dark" />
%sveltekit.head%
<!-- <!--
Anti-FOUC theme init — runs synchronously before first paint. Anti-FOUC theme init — runs synchronously before first paint.
Ported from static/js/core/theme-init.js. Keeps the legacy Ported from static/js/core/theme-init.js. Keeps the legacy
`oxicloud_theme` localStorage key and `data-color-scheme` attribute `oxicloud_theme` localStorage key and `data-color-scheme` attribute
so existing users keep their preference across the migration. so existing users keep their preference across the migration.
Placed AFTER %sveltekit.head% so it follows the CSP <meta> SvelteKit
injects there, hence it IS governed by that policy. svelte.config.js
parses this file, finds this <script> by id="theme-init", and adds its
SHA-256 to script-src automatically — so it's allowed inline (zero extra
request, no 'unsafe-inline') and the hash stays in sync if you edit it.
--> -->
<script> <script id="theme-init">
(function () { (function () {
try { try {
var s = localStorage.getItem('oxicloud_theme'); var s = localStorage.getItem('oxicloud_theme');
@@ -64,7 +71,6 @@
} }
} }
</style> </style>
%sveltekit.head%
</head> </head>
<body data-sveltekit-preload-data="hover"> <body data-sveltekit-preload-data="hover">
<div id="app-splash" role="status" aria-label="Loading"> <div id="app-splash" role="status" aria-label="Loading">
+13
View File
@@ -128,3 +128,16 @@ describe('createApiFetch — 401 refresh/retry parity', () => {
expect(rawFetch).toHaveBeenCalledTimes(3); expect(rawFetch).toHaveBeenCalledTimes(3);
}); });
}); });
describe('ApiError + apiJson', () => {
it('ApiError carries status, statusText, and a descriptive message', async () => {
const { ApiError } = await import('./client');
const e = new ApiError(404, 'Not Found', '/api/files/x');
expect(e.status).toBe(404);
expect(e.statusText).toBe('Not Found');
expect(e.name).toBe('ApiError');
expect(e.message).toContain('404');
expect(e.message).toContain('/api/files/x');
expect(e).toBeInstanceOf(Error);
});
});
@@ -0,0 +1,126 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
vi.mock('$lib/api/client', () => ({ apiFetch: vi.fn(), apiJson: vi.fn() }));
vi.mock('$lib/api/csrf', () => ({ getCsrfHeaders: () => ({ 'x-csrf-token': 't' }) }));
import { apiFetch, apiJson } from '$lib/api/client';
import * as admin from './admin';
const okRes = (body: unknown = {}) =>
({ ok: true, status: 200, json: async () => body }) as unknown as Response;
const errRes = (status = 400, body: unknown = { message: 'nope' }) =>
({ ok: false, status, json: async () => body }) as unknown as Response;
const fetchMock = apiFetch as unknown as ReturnType<typeof vi.fn>;
const jsonMock = apiJson as unknown as ReturnType<typeof vi.fn>;
beforeEach(() => {
vi.clearAllMocks();
fetchMock.mockResolvedValue(okRes());
jsonMock.mockResolvedValue({});
});
describe('admin mutate-based endpoints', () => {
it('resolve on success and call the expected URL/method', async () => {
await admin.createUser({
username: 'u',
password: 'p',
email: null,
role: 'user',
quota_bytes: 0
});
expect(fetchMock).toHaveBeenCalledWith(
'/api/admin/users',
expect.objectContaining({ method: 'POST' })
);
await admin.setUserRole('1', 'admin');
await admin.setUserActive('1', false);
await admin.setUserQuota('1', 100);
await admin.resetUserPassword('1', 'newpw');
await admin.deleteUser('1');
await admin.setRegistrationEnabled(true);
await admin.saveOidc({ enabled: true });
await admin.saveStorage({ backend: 'local' });
await admin.savePluginRetention('id', { retention_days: 30, max_bytes: 100 });
await admin.clearPluginLogs('id');
await admin.setPluginEnabled('id', true);
await admin.deletePlugin('id');
await admin.migrationAction('start');
await admin.migrationAction('pause');
expect(fetchMock).toHaveBeenCalled();
});
it('throw the server message on failure', async () => {
fetchMock.mockResolvedValue(errRes(409, { message: 'conflict' }));
await expect(admin.deleteUser('1')).rejects.toThrow('conflict');
});
it('throw a generic message when the error body has none', async () => {
fetchMock.mockResolvedValue(errRes(500, {}));
await expect(admin.setUserActive('1', true)).rejects.toThrow(/failed: 500/);
});
});
describe('admin read endpoints', () => {
it('call apiJson for the listing/settings reads', async () => {
await admin.listUsers(25, 0);
expect(jsonMock).toHaveBeenCalledWith(
expect.stringContaining('/api/admin/users?limit=25&offset=0'),
expect.anything()
);
await admin.getDashboard();
await admin.getSmtpInfo();
await admin.getOidcSettings();
await admin.getStorageSettings();
await admin.getMigration();
await admin.listPlugins();
await admin.getPluginLogs('id', { limit: 50, offset: 0 });
expect(jsonMock).toHaveBeenCalled();
});
it('getPluginRetention returns null when the request is not ok', async () => {
fetchMock.mockResolvedValueOnce(errRes(404, {}));
await expect(admin.getPluginRetention('id')).resolves.toBeNull();
fetchMock.mockResolvedValueOnce(okRes({ max_age_days: 7, max_entries: 50 }));
await expect(admin.getPluginRetention('id')).resolves.toMatchObject({ max_age_days: 7 });
});
});
describe('admin test/probe endpoints', () => {
it('sendSmtpTest maps 503 to an unconfigured message', async () => {
fetchMock.mockResolvedValue({ status: 503, json: async () => ({}) } as unknown as Response);
await expect(admin.sendSmtpTest('to@x.test')).resolves.toMatchObject({ success: false });
});
it('sendSmtpTest returns the parsed result otherwise', async () => {
fetchMock.mockResolvedValue(okRes({ success: true }));
await expect(admin.sendSmtpTest('to@x.test')).resolves.toMatchObject({ success: true });
});
it('testOidc / testStorage return parsed results', async () => {
fetchMock.mockResolvedValue(okRes({ success: true }));
await expect(admin.testOidc('https://idp')).resolves.toBeTruthy();
fetchMock.mockResolvedValue(okRes({ connected: true }));
await expect(admin.testStorage({ backend: 's3' })).resolves.toMatchObject({ connected: true });
});
it('verifyMigration fills defaults and throws on error', async () => {
fetchMock.mockResolvedValue(okRes({ passed: true }));
await expect(admin.verifyMigration(10)).resolves.toMatchObject({
passed: true,
sample_checked: 0
});
fetchMock.mockResolvedValue(errRes(500, {}));
await expect(admin.verifyMigration()).rejects.toThrow(/verify failed/);
});
it('installPlugin posts a FormData bundle', async () => {
fetchMock.mockResolvedValue(okRes({ id: 'com.example.hello' }));
const file = new File([new Uint8Array([1, 2, 3])], 'p.zip', { type: 'application/zip' });
await admin.installPlugin(file);
expect(fetchMock).toHaveBeenCalledWith(
'/api/admin/plugins',
expect.objectContaining({ method: 'POST' })
);
});
});
@@ -0,0 +1,44 @@
import { it, expect, vi, beforeEach, afterEach } from 'vitest';
vi.mock('$lib/api/client', () => ({ apiFetch: vi.fn(), apiJson: vi.fn() }));
vi.mock('$lib/api/csrf', () => ({ getCsrfHeaders: () => ({}) }));
import { apiFetch, apiJson } from '$lib/api/client';
import * as auth from './auth';
const f = apiFetch as unknown as ReturnType<typeof vi.fn>;
const j = apiJson as unknown as ReturnType<typeof vi.fn>;
// Several auth probes use the raw global fetch (NOT apiFetch) on purpose.
const okRes = { ok: true, status: 200, json: async () => ({}) };
beforeEach(() => {
vi.clearAllMocks();
f.mockResolvedValue(okRes);
j.mockResolvedValue({});
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(okRes));
});
afterEach(() => vi.unstubAllGlobals());
it('exercises the auth endpoints (success paths)', async () => {
await auth.fetchMe().catch(() => {});
await auth.tryRefresh().catch(() => {});
await auth.login('u', 'p').catch(() => {});
await auth.getOidcProviders().catch(() => {});
await auth.getAuthStatus().catch(() => {});
await auth.setupAdmin('e@x.test', 'p').catch(() => {});
await auth.exchangeOidcCode('code').catch(() => {});
await auth.register('u', 'e@x.test', 'p').catch(() => {});
await auth.sendMagicLink('e@x.test').catch(() => {});
await auth.logout().catch(() => {});
const fc = (globalThis.fetch as unknown as ReturnType<typeof vi.fn>).mock.calls.length;
expect(fc + f.mock.calls.length).toBeGreaterThan(3);
});
it('fetchMe returns null when the probe is not ok', async () => {
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue({ ok: false, status: 401, json: async () => ({}) })
);
await expect(auth.fetchMe()).resolves.toBeNull();
});
it('tryRefresh returns false when the refresh fails', async () => {
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue({ ok: false, status: 401, json: async () => ({}) })
);
await expect(auth.tryRefresh()).resolves.toBe(false);
});
@@ -0,0 +1,39 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
vi.mock('$lib/api/client', () => ({ apiFetch: vi.fn() }));
vi.mock('$lib/api/csrf', () => ({ getCsrfHeaders: () => ({}) }));
import { apiFetch } from '$lib/api/client';
import { copyFiles, copyFolders } from './batch';
const fetchMock = apiFetch as unknown as ReturnType<typeof vi.fn>;
describe('batch copy', () => {
beforeEach(() => {
vi.clearAllMocks();
fetchMock.mockResolvedValue({ ok: true, status: 200, json: async () => ({}) });
});
it('short-circuits on empty input', async () => {
await copyFiles([], null);
await copyFolders([], 'x');
expect(fetchMock).not.toHaveBeenCalled();
});
it('posts copy requests for files and folders', async () => {
await copyFiles(['a'], 't');
await copyFolders(['b'], null);
expect(fetchMock).toHaveBeenCalledTimes(2);
expect(fetchMock).toHaveBeenCalledWith(
'/api/batch/files/copy',
expect.objectContaining({ method: 'POST' })
);
});
it('throws the server error/message on failure', async () => {
fetchMock.mockResolvedValue({ ok: false, status: 400, json: async () => ({ error: 'bad' }) });
await expect(copyFiles(['a'], 't')).rejects.toThrow('bad');
fetchMock.mockResolvedValue({ ok: false, status: 500, json: async () => ({}) });
await expect(copyFolders(['b'], 't')).rejects.toThrow(/failed: 500/);
});
});
@@ -1,4 +1,6 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
vi.mock('$lib/api/csrf', () => ({ getCsrfToken: () => 'tok' }));
// vi.mock is hoisted; build the spies with vi.hoisted so the factories can use them. // vi.mock is hoisted; build the spies with vi.hoisted so the factories can use them.
const { blake3Mock, byHashMock, batchMock } = vi.hoisted(() => ({ const { blake3Mock, byHashMock, batchMock } = vi.hoisted(() => ({
@@ -13,7 +15,96 @@ vi.mock('$lib/api/endpoints/files', () => ({
dedupCheckBatch: batchMock dedupCheckBatch: batchMock
})); }));
import { DELTA_UPLOAD_MIN_SIZE, instantUploadOwned, resolveOwnedHashes } from './deltaUpload'; import {
DELTA_UPLOAD_MIN_SIZE,
instantUploadOwned,
resolveOwnedHashes,
tryDeltaUpload
} from './deltaUpload';
describe('tryDeltaUpload (delta worker)', () => {
type Handler = ((e: { data: unknown }) => void) | null;
let lastWorker: FakeWorker | null = null;
class FakeWorker {
onmessage: Handler = null;
onerror: (() => void) | null = null;
posted: unknown[] = [];
terminated = false;
constructor() {
// Capture the instance the code-under-test creates so the test can drive its events.
// eslint-disable-next-line @typescript-eslint/no-this-alias
lastWorker = this;
}
postMessage(msg: unknown) {
this.posted.push(msg);
}
terminate() {
this.terminated = true;
}
emit(data: unknown) {
this.onmessage?.({ data });
}
}
function bigFile(): File {
const f = new File(['x'], 'big.bin');
Object.defineProperty(f, 'size', { value: DELTA_UPLOAD_MIN_SIZE + 1 });
return f;
}
beforeEach(() => {
lastWorker = null;
vi.stubGlobal('Worker', FakeWorker as unknown as typeof Worker);
});
afterEach(() => vi.unstubAllGlobals());
it('skips delta for a small file', async () => {
const small = new File(['x'], 's.txt');
expect(await tryDeltaUpload(small, 'folder')).toBeNull();
});
it('skips delta when there is no folder', async () => {
expect(await tryDeltaUpload(bigFile(), null)).toBeNull();
});
it('resolves ok on a 201 done message and reports progress', async () => {
const onProgress = vi.fn();
const p = tryDeltaUpload(bigFile(), 'folder', onProgress);
await Promise.resolve();
expect(lastWorker).toBeTruthy();
lastWorker!.emit({ type: 'progress', reusedBytes: 50, uploadedBytes: 50, totalBytes: 100 });
expect(onProgress).toHaveBeenCalledWith(99);
lastWorker!.emit({ type: 'done', status: 201, body: { message: 'ok' } });
const res = await p;
expect(res?.ok).toBe(true);
expect(res?.savedBytes).toBe(50);
expect(lastWorker!.terminated).toBe(true);
});
it('falls back to null on a fallback message', async () => {
const p = tryDeltaUpload(bigFile(), 'folder');
await Promise.resolve();
lastWorker!.emit({ type: 'fallback', reason: 'no wasm' });
expect(await p).toBeNull();
});
it('reports a quota error on a 507 done message', async () => {
const p = tryDeltaUpload(bigFile(), 'folder');
await Promise.resolve();
lastWorker!.emit({ type: 'done', status: 507, body: { error: 'over quota' } });
const res = await p;
expect(res?.isQuotaError).toBe(true);
expect(res?.errorMsg).toBe('over quota');
});
it('resolves null on a worker error', async () => {
const p = tryDeltaUpload(bigFile(), 'folder');
await Promise.resolve();
lastWorker!.onerror?.();
expect(await p).toBeNull();
});
});
const fakeFile = (size: number, name = 'x.bin') => ({ size, name }) as unknown as File; const fakeFile = (size: number, name = 'x.bin') => ({ size, name }) as unknown as File;
const hashOf = (name: string) => name.padEnd(64, '0'); const hashOf = (name: string) => name.padEnd(64, '0');
@@ -0,0 +1,16 @@
import { it, expect, vi, beforeEach } from 'vitest';
vi.mock('$lib/api/client', () => ({ apiFetch: vi.fn(), apiJson: vi.fn() }));
vi.mock('$lib/api/csrf', () => ({ getCsrfHeaders: () => ({}) }));
import { apiFetch } from '$lib/api/client';
import { lookupDeviceCode, decideDevice } from './device';
const f = apiFetch as unknown as ReturnType<typeof vi.fn>;
beforeEach(() => {
vi.clearAllMocks();
f.mockResolvedValue({ ok: true, status: 200, json: async () => ({}) });
});
it('looks up and decides device codes', async () => {
await lookupDeviceCode('ABCD').catch(() => {});
await decideDevice('ABCD', 'approve').catch(() => {});
await decideDevice('ABCD', 'deny').catch(() => {});
expect(f).toHaveBeenCalled();
});
@@ -0,0 +1,67 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
vi.mock('$lib/api/client', () => ({ apiFetch: vi.fn(), apiJson: vi.fn() }));
vi.mock('$lib/api/csrf', () => ({ getCsrfHeaders: () => ({}) }));
import { apiFetch, apiJson } from '$lib/api/client';
import { dateBucket, sizeBucket, typeLabel, addFavorite, removeFavorite } from './favorites';
const fetchMock = apiFetch as unknown as ReturnType<typeof vi.fn>;
const jsonMock = apiJson as unknown as ReturnType<typeof vi.fn>;
describe('dateBucket', () => {
beforeEach(() => {
vi.useFakeTimers();
vi.setSystemTime(new Date('2024-06-15T12:00:00Z'));
});
afterEach(() => vi.useRealTimers());
it('buckets by recency and falls back to the year', () => {
expect(dateBucket(Date.now())).toBeTruthy();
expect(dateBucket(Date.now() - 3 * 86_400_000)).toBeTruthy();
expect(dateBucket(Date.now() - 20 * 86_400_000)).toBeTruthy();
expect(dateBucket('2019-06-15')).toBe('2019'); // mid-year is timezone-safe
});
it('returns null for null/invalid', () => {
expect(dateBucket(null)).toBeNull();
expect(dateBucket(undefined)).toBeNull();
expect(dateBucket('not a date')).toBeNull();
});
});
describe('sizeBucket', () => {
it('maps byte ranges to distinct labels', () => {
const labels = [
sizeBucket(null),
sizeBucket(0),
sizeBucket(500),
sizeBucket(50 * 1_048_576),
sizeBucket(500 * 1_048_576),
sizeBucket(2 * 1_073_741_824),
sizeBucket(10 * 1_073_741_824)
];
expect(new Set(labels).size).toBe(labels.length); // all distinct
labels.forEach((l) => expect(l).toBeTruthy());
});
});
describe('typeLabel', () => {
it('maps known categories and passes through unknown ones', () => {
expect(typeLabel('PDF')).toBe('PDF');
expect(typeLabel('Image')).toBeTruthy();
expect(typeLabel('Weird')).toBe('Weird');
});
});
describe('favorites mutations', () => {
beforeEach(() => {
vi.clearAllMocks();
fetchMock.mockResolvedValue({ ok: true, status: 200, json: async () => ({}) });
jsonMock.mockResolvedValue({});
});
it('addFavorite / removeFavorite call the API', async () => {
await addFavorite('file', 'id1');
await removeFavorite('file', 'id1');
expect(fetchMock).toHaveBeenCalled();
});
});
@@ -0,0 +1,34 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
vi.mock('$lib/api/client', () => ({ apiFetch: vi.fn(), apiJson: vi.fn() }));
vi.mock('$lib/api/csrf', () => ({ getCsrfHeaders: () => ({}) }));
import { apiFetch } from '$lib/api/client';
import {
uploadFile,
renameFile,
moveFile,
deleteFile,
fileDownloadUrl,
fileInlineUrl
} from './files';
const f = apiFetch as unknown as ReturnType<typeof vi.fn>;
describe('files endpoint URL builders', () => {
it('build download/inline URLs', () => {
expect(fileDownloadUrl('id1')).toContain('id1');
expect(fileDownloadUrl('id1')).toContain('/api/files/');
expect(fileInlineUrl('id1')).toContain('id1');
});
});
describe('files endpoint mutations', () => {
beforeEach(() => {
vi.clearAllMocks();
f.mockResolvedValue({ ok: true, status: 200, json: async () => ({ id: 'x' }) });
});
it('call the API for upload/rename/move/delete', async () => {
const file = new File([new Uint8Array([1])], 'f.txt', { type: 'text/plain' });
await uploadFile('fid', file).catch(() => {});
await renameFile('id', 'new').catch(() => {});
await moveFile('id', 'dest').catch(() => {});
await deleteFile('id').catch(() => {});
expect(f).toHaveBeenCalled();
});
});
@@ -0,0 +1,67 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
vi.mock('$lib/api/client', () => ({ apiFetch: vi.fn(), apiJson: vi.fn() }));
vi.mock('$lib/api/csrf', () => ({ getCsrfHeaders: () => ({}) }));
import { apiFetch, apiJson } from '$lib/api/client';
import {
displayRole,
expiryToIso,
createGrant,
updateGrantRole,
revokeGrant,
notifyGrantRecipient
} from './grants';
const fetchMock = apiFetch as unknown as ReturnType<typeof vi.fn>;
const jsonMock = apiJson as unknown as ReturnType<typeof vi.fn>;
describe('displayRole', () => {
it('passes through canonical roles', () => {
expect(displayRole('owner')).toBe('owner');
expect(displayRole('editor')).toBe('editor');
expect(displayRole('viewer')).toBe('viewer');
});
it('maps legacy roles and defaults to viewer', () => {
expect(displayRole('contributor')).toBe('editor');
expect(displayRole('commenter')).toBe('viewer');
expect(displayRole('mystery')).toBe('viewer');
expect(displayRole(undefined)).toBe('viewer');
});
});
describe('expiryToIso', () => {
it('converts a date to an ISO string at UTC midnight', () => {
expect(expiryToIso('2030-01-02')).toBe('2030-01-02T00:00:00.000Z');
});
it('returns null for empty input', () => {
expect(expiryToIso(null)).toBeNull();
expect(expiryToIso(undefined)).toBeNull();
expect(expiryToIso('')).toBeNull();
});
});
describe('grant mutations', () => {
beforeEach(() => {
vi.clearAllMocks();
fetchMock.mockResolvedValue({ ok: true, status: 200, json: async () => ({}) });
jsonMock.mockResolvedValue({});
});
it('call the API for create/update/revoke/notify', async () => {
await createGrant(
{ type: 'user', id: 'u1' },
{ type: 'folder', id: 'rid' },
'viewer',
null
).catch(() => {});
await updateGrantRole(
{ type: 'user', id: 'u1' },
{ type: 'folder', id: 'rid' },
'editor',
null
).catch(() => {});
await revokeGrant('g1').catch(() => {});
await notifyGrantRecipient('g1').catch(() => {});
expect(fetchMock).toHaveBeenCalled();
});
});
@@ -0,0 +1,28 @@
import { it, expect, vi, beforeEach } from 'vitest';
vi.mock('$lib/api/client', () => ({ apiFetch: vi.fn(), apiJson: vi.fn() }));
vi.mock('$lib/api/csrf', () => ({ getCsrfHeaders: () => ({}) }));
import { apiFetch, apiJson } from '$lib/api/client';
import * as music from './music';
const f = apiFetch as unknown as ReturnType<typeof vi.fn>;
const j = apiJson as unknown as ReturnType<typeof vi.fn>;
beforeEach(() => {
vi.clearAllMocks();
f.mockResolvedValue({ ok: true, status: 200, json: async () => ({}), text: async () => 'fid' });
j.mockResolvedValue([]);
});
it('exercises the music endpoints', async () => {
await music.listPlaylists().catch(() => {});
await music.listTracks('p').catch(() => {});
await music.createPlaylist('n').catch(() => {});
await music.updatePlaylist('p', { name: 'x' } as never).catch(() => {});
await music.renamePlaylist('p', 'n').catch(() => {});
await music.deletePlaylist('p').catch(() => {});
await music.addTracks('p', ['f']).catch(() => {});
await music.removeTrack('p', 'f').catch(() => {});
await music.reorderTracks('p', ['a', 'b']).catch(() => {});
await music.listShares('p').catch(() => {});
await music.removeShare('p', 'u').catch(() => {});
const file = new File([new Uint8Array([1])], 'c.png', { type: 'image/png' });
await music.uploadCoverImage(file).catch(() => {});
expect(f.mock.calls.length + j.mock.calls.length).toBeGreaterThan(3);
});
@@ -0,0 +1,20 @@
import { it, expect, vi, beforeEach } from 'vitest';
vi.mock('$lib/api/client', () => ({ apiFetch: vi.fn(), apiJson: vi.fn() }));
vi.mock('$lib/api/csrf', () => ({ getCsrfHeaders: () => ({}) }));
import { apiFetch, apiJson } from '$lib/api/client';
import * as people from './people';
const f = apiFetch as unknown as ReturnType<typeof vi.fn>;
const j = apiJson as unknown as ReturnType<typeof vi.fn>;
beforeEach(() => {
vi.clearAllMocks();
f.mockResolvedValue({ ok: true, status: 200, json: async () => ({}) });
j.mockResolvedValue([]);
});
it('exercises the people endpoints', async () => {
await people.fetchPeople().catch(() => {});
await people.peopleEnabled().catch(() => {});
await people.fetchPersonPhotos('p').catch(() => {});
await people.renamePerson('p', 'Alice').catch(() => {});
await people.renamePerson('p', null).catch(() => {});
expect(f.mock.calls.length + j.mock.calls.length).toBeGreaterThan(0);
});
@@ -0,0 +1,20 @@
import { it, expect, vi, beforeEach } from 'vitest';
vi.mock('$lib/api/client', () => ({ apiFetch: vi.fn(), apiJson: vi.fn() }));
vi.mock('$lib/api/csrf', () => ({ getCsrfHeaders: () => ({}) }));
import { apiFetch, apiJson } from '$lib/api/client';
import * as photos from './photos';
const f = apiFetch as unknown as ReturnType<typeof vi.fn>;
const j = apiJson as unknown as ReturnType<typeof vi.fn>;
beforeEach(() => {
vi.clearAllMocks();
f.mockResolvedValue({ ok: true, status: 200, json: async () => ({}) });
j.mockResolvedValue({ photos: [], clusters: [] });
});
it('exercises the photos endpoints', async () => {
await photos.fetchPhotosGeo('0,0,1,1', 5).catch(() => {});
await photos.fetchPhotos(60).catch(() => {});
await photos.fetchFileMetadata('fid').catch(() => {});
await photos.batchTrash(['a', 'b']).catch(() => {});
await photos.batchTrash([]).catch(() => {});
expect(f.mock.calls.length + j.mock.calls.length).toBeGreaterThan(0);
});
@@ -0,0 +1,28 @@
import { it, expect, vi, beforeEach } from 'vitest';
vi.mock('$lib/api/client', () => ({ apiFetch: vi.fn(), apiJson: vi.fn() }));
vi.mock('$lib/api/csrf', () => ({ getCsrfHeaders: () => ({}) }));
import { apiFetch, apiJson } from '$lib/api/client';
import * as profile from './profile';
const f = apiFetch as unknown as ReturnType<typeof vi.fn>;
const j = apiJson as unknown as ReturnType<typeof vi.fn>;
beforeEach(() => {
vi.clearAllMocks();
f.mockResolvedValue({ ok: true, status: 200, json: async () => ({}) });
j.mockResolvedValue([]);
});
it('isAutoAppPassword flags generated labels', () => {
const r1 = profile.isAutoAppPassword({ label: 'Device login (auto)' });
const r2 = profile.isAutoAppPassword({ label: 'my token' });
expect(typeof r1).toBe('boolean');
expect(typeof r2).toBe('boolean');
});
it('exercises the profile endpoints', async () => {
await profile.updateProfile({ given_name: 'A' } as never).catch(() => {});
await profile.changePassword('old', 'new').catch(() => {});
await profile.updateAvatar('data:image/png;base64,AAAA').catch(() => {});
await profile.updateAvatar(null).catch(() => {});
await profile.listAppPasswords().catch(() => {});
await profile.createAppPassword('label').catch(() => {});
await profile.revokeAppPassword('id').catch(() => {});
expect(f.mock.calls.length + j.mock.calls.length).toBeGreaterThan(2);
});
@@ -0,0 +1,19 @@
import { it, expect, vi, beforeEach } from 'vitest';
vi.mock('$lib/api/client', () => ({ apiFetch: vi.fn(), apiJson: vi.fn() }));
vi.mock('$lib/api/csrf', () => ({ getCsrfHeaders: () => ({}) }));
import { apiFetch } from '$lib/api/client';
import { fetchRecentPage, clearRecent } from './recent';
const f = apiFetch as unknown as ReturnType<typeof vi.fn>;
beforeEach(() => {
vi.clearAllMocks();
f.mockResolvedValue({
ok: true,
status: 200,
json: async () => ({ items: [], next_cursor: null })
});
});
it('fetches and clears recent', async () => {
await fetchRecentPage({}).catch(() => {});
await clearRecent().catch(() => {});
expect(f).toHaveBeenCalled();
});
@@ -0,0 +1,37 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
vi.mock('$lib/api/client', () => ({ apiFetch: vi.fn(), apiJson: vi.fn() }));
vi.mock('$lib/api/csrf', () => ({ getCsrfHeaders: () => ({}) }));
import { apiFetch } from '$lib/api/client';
import {
isDirectoryAvailable,
resolveLabel,
resolveRecipient,
searchRecipients
} from './recipients';
const f = apiFetch as unknown as ReturnType<typeof vi.fn>;
describe('recipients pure helpers', () => {
it('isDirectoryAvailable defaults to true', () => {
expect(isDirectoryAvailable()).toBe(true);
});
it('resolveLabel falls back to the id when uncached', () => {
expect(resolveLabel('group', 'g1')).toBe('g1');
expect(resolveLabel('user', 'u1')).toBe('u1');
});
it('resolveRecipient builds a recipient object', () => {
expect(resolveRecipient('group', 'g1')).toMatchObject({ type: 'group', id: 'g1', label: 'g1' });
expect(resolveRecipient('user', 'u1')).toMatchObject({ type: 'user', id: 'u1' });
});
});
describe('searchRecipients', () => {
beforeEach(() => {
vi.clearAllMocks();
// system contacts + groups both return arrays from .json()
f.mockResolvedValue({ ok: true, status: 200, json: async () => [] });
});
it('returns an array of recipients', async () => {
const r = await searchRecipients('alice').catch(() => []);
expect(Array.isArray(r)).toBe(true);
const e = await searchRecipients('a@b.test').catch(() => []);
expect(Array.isArray(e)).toBe(true);
});
});
@@ -0,0 +1,25 @@
import { it, expect, vi, beforeEach } from 'vitest';
vi.mock('$lib/api/client', () => ({ apiFetch: vi.fn(), apiJson: vi.fn() }));
vi.mock('$lib/api/csrf', () => ({ getCsrfHeaders: () => ({}) }));
import { apiFetch, apiJson } from '$lib/api/client';
import { searchFiles, searchSuggest, clearSearchCache } from './search';
const f = apiFetch as unknown as ReturnType<typeof vi.fn>;
const j = apiJson as unknown as ReturnType<typeof vi.fn>;
beforeEach(() => {
vi.clearAllMocks();
f.mockResolvedValue({ ok: true, status: 200, json: async () => ({}) });
j.mockResolvedValue({ files: [], folders: [] });
});
it('builds search requests including filters', async () => {
await searchFiles('q', {
recursive: true,
fileTypes: ['mp3', 'wav'],
minSize: 1,
maxSize: 9,
sortBy: 'date'
}).catch(() => {});
expect(j).toHaveBeenCalledWith(expect.stringContaining('type=mp3%2Cwav'), expect.anything());
await searchSuggest('q').catch(() => {});
await clearSearchCache().catch(() => {});
expect(f.mock.calls.length + j.mock.calls.length).toBeGreaterThan(1);
});
+3 -1
View File
@@ -26,7 +26,9 @@ export function searchFiles(query: string, opts: SearchOptions = {}): Promise<Se
params.append('query', query); params.append('query', query);
if (opts.folderId) params.append('folder_id', opts.folderId); if (opts.folderId) params.append('folder_id', opts.folderId);
if (opts.recursive !== undefined) params.append('recursive', String(opts.recursive)); if (opts.recursive !== undefined) params.append('recursive', String(opts.recursive));
for (const ft of opts.fileTypes ?? []) params.append('type', ft); // The backend expects a single comma-separated `type` param (it splits on
// ','); appending one param per type yields a "duplicate field" 400.
if (opts.fileTypes?.length) params.append('type', opts.fileTypes.join(','));
if (opts.minSize != null) params.append('min_size', String(opts.minSize)); if (opts.minSize != null) params.append('min_size', String(opts.minSize));
if (opts.maxSize != null) params.append('max_size', String(opts.maxSize)); if (opts.maxSize != null) params.append('max_size', String(opts.maxSize));
if (opts.createdAfter != null) params.append('created_after', String(opts.createdAfter)); if (opts.createdAfter != null) params.append('created_after', String(opts.createdAfter));
@@ -0,0 +1,92 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
vi.mock('$lib/api/client', () => ({ apiFetch: vi.fn(), apiJson: vi.fn() }));
vi.mock('$lib/api/csrf', () => ({ getCsrfHeaders: () => ({}) }));
import { apiFetch, apiJson } from '$lib/api/client';
import {
shareDownloadUrl,
shareFileUrl,
shareZipUrl,
getShareMeta,
verifySharePassword,
getShareContents
} from './share';
const fetchMock = apiFetch as unknown as ReturnType<typeof vi.fn>;
const jsonMock = apiJson as unknown as ReturnType<typeof vi.fn>;
describe('share URL builders', () => {
it('build encoded share URLs', () => {
expect(shareDownloadUrl('tok en')).toBe('/api/s/tok%20en/download');
expect(shareFileUrl('t', 'f/1')).toBe('/api/s/t/file/f%2F1');
expect(shareZipUrl('t')).toBe('/api/s/t/zip');
expect(shareZipUrl('t', 'fid')).toBe('/api/s/t/zip/fid');
});
});
describe('share API calls', () => {
beforeEach(() => {
vi.clearAllMocks();
fetchMock.mockResolvedValue({ ok: true, status: 200, json: async () => ({}) });
jsonMock.mockResolvedValue({});
});
it('hit the API for meta / verify / contents', async () => {
await getShareMeta('t').catch(() => {});
await verifySharePassword('t', 'pw').catch(() => {});
await getShareContents('t').catch(() => {});
expect(fetchMock.mock.calls.length + jsonMock.mock.calls.length).toBeGreaterThan(0);
});
});
describe('share status branches', () => {
const resp = (over: Record<string, unknown>) => ({
ok: false,
status: 200,
json: async () => ({}),
...over
});
beforeEach(() => vi.clearAllMocks());
it('returns ok meta on 200', async () => {
fetchMock.mockResolvedValue(
resp({ ok: true, json: async () => ({ item_type: 'folder', item_name: 'Docs' }) })
);
expect(await getShareMeta('t')).toEqual({
status: 'ok',
data: { item_type: 'folder', item_name: 'Docs' }
});
});
it('maps 401+requiresPassword to a password prompt', async () => {
fetchMock.mockResolvedValue(
resp({ status: 401, json: async () => ({ requiresPassword: true }) })
);
expect(await getShareMeta('t')).toEqual({ status: 'password' });
});
it('maps meta 410 to expired and 404 to invalid', async () => {
fetchMock.mockResolvedValueOnce(resp({ status: 410 }));
expect(await getShareMeta('t')).toEqual({ status: 'expired' });
fetchMock.mockResolvedValueOnce(resp({ status: 404 }));
expect(await getShareMeta('t')).toEqual({ status: 'invalid' });
});
it('verifies a password: true on ok, false on 401', async () => {
fetchMock.mockResolvedValueOnce(resp({ ok: true }));
expect(await verifySharePassword('t', 'pw')).toBe(true);
fetchMock.mockResolvedValueOnce(resp({ status: 401 }));
expect(await verifySharePassword('t', 'bad')).toBe(false);
});
it('lists contents and maps 401→password, 410→expired', async () => {
fetchMock.mockResolvedValueOnce(
resp({ ok: true, json: async () => ({ folders: [], files: [] }) })
);
expect((await getShareContents('t')).status).toBe('ok');
fetchMock.mockResolvedValueOnce(resp({ status: 401 }));
expect((await getShareContents('t')).status).toBe('password');
fetchMock.mockResolvedValueOnce(resp({ status: 410 }));
expect((await getShareContents('t', 'fid')).status).toBe('expired');
});
});
@@ -0,0 +1,20 @@
import { it, expect, vi, beforeEach } from 'vitest';
vi.mock('$lib/api/client', () => ({ apiFetch: vi.fn(), apiJson: vi.fn() }));
vi.mock('$lib/api/csrf', () => ({ getCsrfHeaders: () => ({}) }));
import { apiFetch, apiJson } from '$lib/api/client';
import * as shares from './shares';
const f = apiFetch as unknown as ReturnType<typeof vi.fn>;
const j = apiJson as unknown as ReturnType<typeof vi.fn>;
beforeEach(() => {
vi.clearAllMocks();
f.mockResolvedValue({ ok: true, status: 200, json: async () => ({}) });
j.mockResolvedValue({});
});
it('exercises the shares endpoints', async () => {
await shares.createShare({ item_id: 'i', item_type: 'folder' } as never).catch(() => {});
await shares.listSharesForItem('i', 'folder' as never).catch(() => {});
await shares.getShareById('s').catch(() => {});
await shares.updateShare('s', {} as never).catch(() => {});
await shares.deleteShare('s').catch(() => {});
expect(f.mock.calls.length + j.mock.calls.length).toBeGreaterThan(0);
});
@@ -0,0 +1,67 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
vi.mock('$lib/api/client', () => ({ apiFetch: vi.fn(), apiJson: vi.fn() }));
vi.mock('$lib/api/csrf', () => ({ getCsrfHeaders: () => ({}) }));
import { apiFetch } from '$lib/api/client';
import {
expiryChip,
remainingDaysBucket,
restoreTrashItem,
deleteTrashItem,
emptyTrash
} from './trash';
const fetchMock = apiFetch as unknown as ReturnType<typeof vi.fn>;
const DAY = 86_400_000;
describe('expiryChip', () => {
beforeEach(() => {
vi.useFakeTimers();
vi.setSystemTime(new Date('2024-06-15T12:00:00Z'));
});
afterEach(() => vi.useRealTimers());
it('returns the matching tier for each time horizon', () => {
expect(expiryChip(null).tier).toBe('never');
expect(expiryChip(Date.now() - DAY).tier).toBe('expired');
expect(expiryChip(Date.now() + DAY / 2).tier).toBe('urgent'); // today
expect(expiryChip(Date.now() + 1.5 * DAY).tier).toBe('urgent'); // tomorrow
expect(expiryChip(Date.now() + 4 * DAY).tier).toBe('soon');
expect(expiryChip(Date.now() + 20 * DAY).tier).toBe('caution');
expect(expiryChip(Date.now() + 100 * DAY).tier).toBe('normal');
});
it('renders an unparseable value as a normal chip', () => {
expect(expiryChip('garbage').tier).toBe('normal');
});
});
describe('remainingDaysBucket', () => {
beforeEach(() => {
vi.useFakeTimers();
vi.setSystemTime(new Date('2024-06-15T12:00:00Z'));
});
afterEach(() => vi.useRealTimers());
it('produces a label for no-expiry, expired, and future', () => {
expect(remainingDaysBucket(null)).toBeTruthy();
expect(remainingDaysBucket(Date.now() - DAY)).toBeTruthy();
expect(remainingDaysBucket(Date.now() + 10 * DAY)).toBeTruthy();
});
});
describe('trash mutations', () => {
beforeEach(() => {
vi.clearAllMocks();
fetchMock.mockResolvedValue({ ok: true, status: 200, json: async () => ({}) });
});
it('call the API for restore/delete/empty', async () => {
await restoreTrashItem('t1').catch(() => {});
await deleteTrashItem('t1').catch(() => {});
await emptyTrash().catch(() => {});
expect(fetchMock).toHaveBeenCalledTimes(3);
});
it('emptyTrash throws on a failed response', async () => {
fetchMock.mockResolvedValue({ ok: false, status: 500, json: async () => ({}) });
await expect(emptyTrash()).rejects.toThrow();
});
});
@@ -0,0 +1,64 @@
import { it, expect, vi, beforeEach } from 'vitest';
vi.mock('$lib/api/client', () => ({ apiFetch: vi.fn(), apiJson: vi.fn() }));
import { apiFetch, apiJson } from '$lib/api/client';
import {
getSupportedExtensions,
canEditWithWopi,
getEditorUrl,
getEditorUrlWithFallback
} from './wopi';
const af = apiFetch as unknown as ReturnType<typeof vi.fn>;
const j = apiJson as unknown as ReturnType<typeof vi.fn>;
beforeEach(() => {
vi.clearAllMocks();
j.mockResolvedValue({ extensions: ['docx', 'xlsx'] });
});
it('reports WOPI edit support based on extension', async () => {
await getSupportedExtensions().catch(() => {});
const editable = await canEditWithWopi('report.docx').catch(() => false);
const notEditable = await canEditWithWopi('photo.png').catch(() => false);
expect(typeof editable).toBe('boolean');
expect(typeof notEditable).toBe('boolean');
});
it('fetches an editor URL for a file', async () => {
af.mockResolvedValue({
ok: true,
json: async () => ({
editor_url: 'https://wopi/edit',
access_token: 'tok',
access_token_ttl: 9
})
});
const data = await getEditorUrl('f1', 'edit');
expect(data.editor_url).toBe('https://wopi/edit');
expect(af).toHaveBeenCalledWith(
expect.stringContaining('file_id=f1'),
expect.objectContaining({ credentials: 'same-origin' })
);
});
it('throws when the editor URL request fails', async () => {
af.mockResolvedValue({ ok: false, status: 500, text: async () => 'boom' });
await expect(getEditorUrl('f1')).rejects.toThrow('500');
});
it('falls back to view mode when an edit request 422s on a PDF', async () => {
af.mockResolvedValueOnce({
ok: false,
status: 422,
text: async () => 'not editable'
}).mockResolvedValueOnce({
ok: true,
json: async () => ({ editor_url: 'https://wopi/view', access_token: 't', access_token_ttl: 1 })
});
const data = await getEditorUrlWithFallback('f1', 'doc.pdf', 'edit');
expect(data.editor_url).toBe('https://wopi/view');
expect(af).toHaveBeenCalledTimes(2);
});
it('does not fall back for non-PDF edit failures', async () => {
af.mockResolvedValue({ ok: false, status: 500, text: async () => 'server error' });
await expect(getEditorUrlWithFallback('f1', 'sheet.xlsx', 'edit')).rejects.toThrow('500');
expect(af).toHaveBeenCalledTimes(1);
});
+69 -12
View File
@@ -268,7 +268,7 @@
></div> ></div>
<div class="sidebar" class:open={sidebarOpen}> <div class="sidebar" class:open={sidebarOpen}>
<a href={resolve('/files')} class="logo-container"> <a href={resolve('/files')} class="logo-container" data-testid="appshell-logo-link">
<div class="logo"> <div class="logo">
<svg viewBox="95 67 320 320" aria-hidden="true"> <svg viewBox="95 67 320 320" aria-hidden="true">
<path <path
@@ -286,6 +286,7 @@
class:active={active(link.href)} class:active={active(link.href)}
href={resolve(link.href)} href={resolve(link.href)}
data-section={link.section} data-section={link.section}
data-testid={`appshell-nav-${link.href.replace(/^\//, '')}-link`}
onclick={() => (sidebarOpen = false)} onclick={() => (sidebarOpen = false)}
> >
<Icon name={link.icon} /> <Icon name={link.icon} />
@@ -324,6 +325,7 @@
class="sidebar-toggle" class="sidebar-toggle"
aria-label={t('nav.toggle', 'Toggle navigation menu')} aria-label={t('nav.toggle', 'Toggle navigation menu')}
aria-expanded={sidebarOpen} aria-expanded={sidebarOpen}
data-testid="appshell-sidebar-toggle-btn"
onclick={() => (sidebarOpen = !sidebarOpen)} onclick={() => (sidebarOpen = !sidebarOpen)}
> >
<Icon name="bars" /> <Icon name="bars" />
@@ -334,6 +336,7 @@
class="search-toggle-btn" class="search-toggle-btn"
id="search-toggle-btn" id="search-toggle-btn"
aria-label={t('actions.search_btn', 'Search')} aria-label={t('actions.search_btn', 'Search')}
data-testid="appshell-search-toggle-btn"
onclick={openMobileSearch} onclick={openMobileSearch}
> >
<Icon name="search" /> <Icon name="search" />
@@ -343,6 +346,7 @@
<button <button
class="search-back-btn" class="search-back-btn"
aria-label={t('common.close', 'Close')} aria-label={t('common.close', 'Close')}
data-testid="appshell-search-back-btn"
onclick={closeMobileSearch} onclick={closeMobileSearch}
> >
<Icon name="arrow-left" /> <Icon name="arrow-left" />
@@ -355,6 +359,7 @@
type="text" type="text"
bind:this={searchInputEl} bind:this={searchInputEl}
bind:value={searchQuery} bind:value={searchQuery}
data-testid="appshell-search-input"
oninput={onSearchInput} oninput={onSearchInput}
onfocus={() => (suggestOpen = suggestions.length > 0)} onfocus={() => (suggestOpen = suggestions.length > 0)}
onblur={() => setTimeout(() => (suggestOpen = false), 150)} onblur={() => setTimeout(() => (suggestOpen = false), 150)}
@@ -367,6 +372,7 @@
type="button" type="button"
title={t('common.clear', 'Clear')} title={t('common.clear', 'Clear')}
aria-label={t('common.clear', 'Clear')} aria-label={t('common.clear', 'Clear')}
data-testid="appshell-search-clear-btn"
onclick={clearSearch} onclick={clearSearch}
> >
<Icon name="times" /> <Icon name="times" />
@@ -377,6 +383,7 @@
type="submit" type="submit"
title={t('actions.search_btn', 'Search')} title={t('actions.search_btn', 'Search')}
aria-label={t('actions.search_btn', 'Search')} aria-label={t('actions.search_btn', 'Search')}
data-testid="appshell-search-submit-btn"
> >
<Icon name="search" /> <Icon name="search" />
</button> </button>
@@ -385,7 +392,12 @@
<ul class="suggest"> <ul class="suggest">
{#each suggestions as s (s.kind + s.item.id)} {#each suggestions as s (s.kind + s.item.id)}
<li> <li>
<button class="suggest__item" type="button" onmousedown={() => pickSuggestion(s)}> <button
class="suggest__item"
type="button"
data-testid={`appshell-search-suggestion-${s.kind}-${s.item.id}-item`}
onmousedown={() => pickSuggestion(s)}
>
<span class="suggest__icon"> <span class="suggest__icon">
{#if s.kind === 'folder'} {#if s.kind === 'folder'}
<Icon name="folder" /> <Icon name="folder" />
@@ -398,7 +410,12 @@
</li> </li>
{/each} {/each}
<li> <li>
<button class="suggest__all" type="button" onmousedown={goToResults}> <button
class="suggest__all"
type="button"
data-testid="appshell-search-see-all-btn"
onmousedown={goToResults}
>
{t('search.see_all', 'See all results')} {t('search.see_all', 'See all results')}
</button> </button>
</li> </li>
@@ -411,13 +428,14 @@
<div class="user-controls"> <div class="user-controls">
<!-- Notifications --> <!-- Notifications -->
<div class="notif-wrapper" class:open={notifOpen}> <div class="notif-wrapper" class:open={notifOpen} data-testid="appshell-notif-menu">
<button <button
class="notif-bell-btn" class="notif-bell-btn"
class:active={notifOpen} class:active={notifOpen}
class:ring={bellRinging} class:ring={bellRinging}
aria-label={t('notifications.title', 'Notifications')} aria-label={t('notifications.title', 'Notifications')}
aria-haspopup="true" aria-haspopup="true"
data-testid="appshell-notif-bell-btn"
onclick={(e) => { onclick={(e) => {
e.stopPropagation(); e.stopPropagation();
notifOpen = !notifOpen; notifOpen = !notifOpen;
@@ -436,6 +454,7 @@
class="notif-clear-btn" class="notif-clear-btn"
title={t('notifications.clear', 'Clear all')} title={t('notifications.clear', 'Clear all')}
aria-label={t('notifications.clear', 'Clear all')} aria-label={t('notifications.clear', 'Clear all')}
data-testid="appshell-notif-clear-btn"
onclick={(e) => { onclick={(e) => {
e.stopPropagation(); e.stopPropagation();
ui.clearNotifications(); ui.clearNotifications();
@@ -494,11 +513,12 @@
</div> </div>
<!-- User menu --> <!-- User menu -->
<div class="user-menu-wrapper" class:open={menuOpen}> <div class="user-menu-wrapper" class:open={menuOpen} data-testid="appshell-user-menu">
<button <button
class="user-avatar-btn" class="user-avatar-btn"
aria-label={t('user_menu.title', 'User menu')} aria-label={t('user_menu.title', 'User menu')}
aria-haspopup="true" aria-haspopup="true"
data-testid="appshell-user-menu-btn"
onclick={(e) => { onclick={(e) => {
e.stopPropagation(); e.stopPropagation();
menuOpen = !menuOpen; menuOpen = !menuOpen;
@@ -555,15 +575,30 @@
<div class="user-menu-divider"></div> <div class="user-menu-divider"></div>
{#if isAdmin} {#if isAdmin}
<a class="user-menu-item" href={resolve('/admin')} onclick={() => (menuOpen = false)}> <a
class="user-menu-item"
href={resolve('/admin')}
data-testid="appshell-user-menu-admin-item"
onclick={() => (menuOpen = false)}
>
<Icon name="cogs" /> <span>{t('user_menu.admin_panel', 'Admin panel')}</span> <Icon name="cogs" /> <span>{t('user_menu.admin_panel', 'Admin panel')}</span>
</a> </a>
<a class="user-menu-item" href={resolve('/groups')} onclick={() => (menuOpen = false)}> <a
class="user-menu-item"
href={resolve('/groups')}
data-testid="appshell-user-menu-groups-item"
onclick={() => (menuOpen = false)}
>
<Icon name="user-group" /> <Icon name="user-group" />
<span>{t('user_menu.manage_groups', 'Manage groups')}</span> <span>{t('user_menu.manage_groups', 'Manage groups')}</span>
</a> </a>
{/if} {/if}
<a class="user-menu-item" href={resolve('/profile')} onclick={() => (menuOpen = false)}> <a
class="user-menu-item"
href={resolve('/profile')}
data-testid="appshell-user-menu-profile-item"
onclick={() => (menuOpen = false)}
>
<Icon name="user-circle" /> <span>{t('user_menu.profile', 'My profile')}</span> <Icon name="user-circle" /> <span>{t('user_menu.profile', 'My profile')}</span>
</a> </a>
@@ -572,12 +607,17 @@
<div class="user-menu-item user-menu-item--lang"> <div class="user-menu-item user-menu-item--lang">
<Icon name="globe" /> <Icon name="globe" />
<span>{t('settings.language', 'Language')}</span> <span>{t('settings.language', 'Language')}</span>
<div class="lang-selector" class:lang-selector--open={langOpen}> <div
class="lang-selector"
class:lang-selector--open={langOpen}
data-testid="appshell-lang-menu"
>
<button <button
type="button" type="button"
class="lang-selector__toggle" class="lang-selector__toggle"
aria-haspopup="listbox" aria-haspopup="listbox"
aria-expanded={langOpen} aria-expanded={langOpen}
data-testid="appshell-lang-toggle-btn"
onclick={(e) => { onclick={(e) => {
e.stopPropagation(); e.stopPropagation();
langOpen = !langOpen; langOpen = !langOpen;
@@ -597,6 +637,7 @@
class:lang-option--active={lang.code === i18n.locale} class:lang-option--active={lang.code === i18n.locale}
role="option" role="option"
aria-selected={lang.code === i18n.locale} aria-selected={lang.code === i18n.locale}
data-testid={`appshell-lang-${lang.code}-option`}
onclick={(e) => { onclick={(e) => {
e.stopPropagation(); e.stopPropagation();
chooseLocale(lang.code); chooseLocale(lang.code);
@@ -622,6 +663,7 @@
class="theme-segmented" class="theme-segmented"
role="radiogroup" role="radiogroup"
aria-label={t('user_menu.appearance', 'Appearance')} aria-label={t('user_menu.appearance', 'Appearance')}
data-testid="appshell-theme-toggle"
> >
{#each THEMES as th (th.mode)} {#each THEMES as th (th.mode)}
<button <button
@@ -632,6 +674,7 @@
aria-checked={theme.current === th.mode} aria-checked={theme.current === th.mode}
title={th.label} title={th.label}
aria-label={th.label} aria-label={th.label}
data-testid={`appshell-theme-${th.mode}-option`}
onclick={(e) => { onclick={(e) => {
e.stopPropagation(); e.stopPropagation();
theme.set(th.mode); theme.set(th.mode);
@@ -643,13 +686,21 @@
</div> </div>
</div> </div>
<button class="user-menu-item" onclick={openAbout}> <button
class="user-menu-item"
data-testid="appshell-user-menu-about-item"
onclick={openAbout}
>
<Icon name="info-circle" /> <span>{t('user_menu.about', 'About OxiCloud')}</span> <Icon name="info-circle" /> <span>{t('user_menu.about', 'About OxiCloud')}</span>
</button> </button>
<div class="user-menu-divider"></div> <div class="user-menu-divider"></div>
<button class="user-menu-item user-menu-logout" onclick={onLogout}> <button
class="user-menu-item user-menu-logout"
data-testid="appshell-user-menu-logout-btn"
onclick={onLogout}
>
<Icon name="sign-out-alt" /> <span>{t('actions.logout', 'Log out')}</span> <Icon name="sign-out-alt" /> <span>{t('actions.logout', 'Log out')}</span>
</button> </button>
</div> </div>
@@ -708,6 +759,7 @@
href="https://github.com/AtalayaLabs/OxiCloud/" href="https://github.com/AtalayaLabs/OxiCloud/"
target="_blank" target="_blank"
rel="noopener" rel="noopener"
data-testid="appshell-about-github-link"
> >
<Icon name="github" /> GitHub <Icon name="github" /> GitHub
</a> </a>
@@ -716,12 +768,17 @@
href="https://github.com/AtalayaLabs/OxiCloud/blob/main/LICENSE" href="https://github.com/AtalayaLabs/OxiCloud/blob/main/LICENSE"
target="_blank" target="_blank"
rel="noopener" rel="noopener"
data-testid="appshell-about-license-link"
> >
<Icon name="file-alt" /> <Icon name="file-alt" />
{t('user_menu.mit_license', 'MIT License')} {t('user_menu.mit_license', 'MIT License')}
</a> </a>
</div> </div>
<button class="about-modal__close" onclick={() => (aboutOpen = false)}> <button
class="about-modal__close"
data-testid="appshell-about-close-btn"
onclick={() => (aboutOpen = false)}
>
{t('actions.close', 'Close')} {t('actions.close', 'Close')}
</button> </button>
</div> </div>
@@ -0,0 +1,71 @@
import { it, expect, vi, beforeEach } from 'vitest';
import { render, screen, fireEvent, waitFor } from '@testing-library/svelte';
import { createRawSnippet } from 'svelte';
const { goto, pageState } = vi.hoisted(() => ({
goto: vi.fn(),
pageState: { url: new URL('http://localhost/files'), route: { id: '/files/[...path]' } }
}));
vi.mock('$app/navigation', () => ({ goto }));
vi.mock('$app/state', () => ({ page: pageState }));
vi.mock('$lib/api/endpoints/auth', () => ({ logout: vi.fn() }));
vi.mock('$lib/api/endpoints/search', () => ({ searchFiles: vi.fn(async () => ({ items: [] })) }));
vi.mock('$lib/api/endpoints/files', () => ({ fileInlineUrl: () => '/in' }));
import { logout } from '$lib/api/endpoints/auth';
import { session } from '$lib/stores/session.svelte';
import AppShell from './AppShell.svelte';
const m = (fn: unknown) => fn as ReturnType<typeof vi.fn>;
const children = createRawSnippet(() => ({
render: () => '<div data-testid="shell-child">hi</div>'
}));
beforeEach(() => {
vi.clearAllMocks();
pageState.url = new URL('http://localhost/files');
session.user = {
id: '1',
username: 'admin',
email: 'a@x.test',
given_name: 'A',
family_name: 'B',
role: 'admin',
storage_used_bytes: 10,
storage_quota_bytes: 100,
is_external: false
} as never;
});
it('renders the shell chrome and its children', async () => {
render(AppShell, { props: { children } });
expect(screen.getByTestId('shell-child')).toBeTruthy();
expect(screen.getByTestId('appshell-user-menu-btn')).toBeTruthy();
});
it('opens the user menu, exposing profile and admin links', async () => {
render(AppShell, { props: { children } });
await fireEvent.click(screen.getByTestId('appshell-user-menu-btn'));
const profile = await screen.findByTestId('appshell-user-menu-profile-item');
expect(profile.getAttribute('href')).toBe('/profile');
// Admin link only shows for admin users (session.user.role === 'admin').
expect(screen.getByTestId('appshell-user-menu-admin-item')).toBeTruthy();
});
it('logs out: clears the session and redirects to /login', async () => {
m(logout).mockResolvedValue(undefined);
render(AppShell, { props: { children } });
await fireEvent.click(screen.getByTestId('appshell-user-menu-btn'));
await fireEvent.click(await screen.findByTestId('appshell-user-menu-logout-btn'));
await waitFor(() => expect(logout).toHaveBeenCalled());
await waitFor(() => expect(goto).toHaveBeenCalledWith('/login'));
expect(session.user).toBeNull();
});
it('submits a search and routes to /search', async () => {
render(AppShell, { props: { children } });
const input = screen.getByTestId('appshell-search-input');
await fireEvent.input(input, { target: { value: 'report' } });
await fireEvent.click(screen.getByTestId('appshell-search-submit-btn'));
await waitFor(() => expect(goto).toHaveBeenCalledWith('/search?q=report'));
});
+4 -1
View File
@@ -17,6 +17,8 @@
onclick?: (e: MouseEvent) => void; onclick?: (e: MouseEvent) => void;
/** Extra classes appended after the base `.btn` classes. */ /** Extra classes appended after the base `.btn` classes. */
class?: string; class?: string;
/** e2e test hook forwarded to the underlying `<button>`. */
'data-testid'?: string;
children?: Snippet; children?: Snippet;
} }
@@ -29,6 +31,7 @@
title, title,
onclick, onclick,
class: cls = '', class: cls = '',
'data-testid': testid,
children children
}: Props = $props(); }: Props = $props();
@@ -37,7 +40,7 @@
); );
</script> </script>
<button class={className} {type} {disabled} {title} {onclick}> <button class={className} {type} {disabled} {title} data-testid={testid} {onclick}>
{#if icon}<Icon name={icon} />{/if} {#if icon}<Icon name={icon} />{/if}
{@render children?.()} {@render children?.()}
</button> </button>
@@ -277,6 +277,7 @@
role="dialog" role="dialog"
aria-modal="true" aria-modal="true"
aria-label={t('cmdk.title', 'Command palette')} aria-label={t('cmdk.title', 'Command palette')}
data-testid="command-palette-panel"
> >
<div class="cmdk__search"> <div class="cmdk__search">
<Icon name="search" /> <Icon name="search" />
@@ -288,6 +289,7 @@
placeholder={t('cmdk.placeholder', 'Type a command or search…')} placeholder={t('cmdk.placeholder', 'Type a command or search…')}
autocomplete="off" autocomplete="off"
autofocus autofocus
data-testid="command-palette-input"
/> />
</div> </div>
{#if filtered.length === 0} {#if filtered.length === 0}
@@ -301,6 +303,7 @@
class:active={i === index} class:active={i === index}
role="option" role="option"
aria-selected={i === index} aria-selected={i === index}
data-testid={`command-palette-${cmd.id}-item`}
onmouseenter={() => (index = i)} onmouseenter={() => (index = i)}
onclick={cmd.run} onclick={cmd.run}
> >
@@ -0,0 +1,47 @@
import { it, expect, vi, beforeEach } from 'vitest';
import { render, screen, fireEvent, waitFor } from '@testing-library/svelte';
const { goto } = vi.hoisted(() => ({ goto: vi.fn() }));
vi.mock('$app/navigation', () => ({ goto }));
vi.mock('$lib/api/endpoints/auth', () => ({ logout: vi.fn() }));
vi.mock('$lib/api/endpoints/search', () => ({ searchFiles: vi.fn(async () => ({ items: [] })) }));
vi.mock('$lib/api/endpoints/files', () => ({ fileInlineUrl: () => '/in' }));
vi.mock('$lib/stores/dialogs.svelte', () => ({ confirmDialog: vi.fn() }));
import { searchFiles } from '$lib/api/endpoints/search';
import { session } from '$lib/stores/session.svelte';
import CommandPalette from './CommandPalette.svelte';
const m = (fn: unknown) => fn as ReturnType<typeof vi.fn>;
function openPalette() {
return fireEvent.keyDown(window, { key: 'k', ctrlKey: true });
}
beforeEach(() => {
vi.clearAllMocks();
session.user = { id: '1', username: 'admin', role: 'admin', is_external: false } as never;
});
it('opens on Ctrl+K and shows the command panel', async () => {
render(CommandPalette);
await openPalette();
await screen.findByTestId('command-palette-panel');
expect(screen.getByTestId('command-palette-input')).toBeTruthy();
});
it('runs a navigation command', async () => {
render(CommandPalette);
await openPalette();
await fireEvent.click(await screen.findByTestId('command-palette-files-item'));
await waitFor(() => expect(goto).toHaveBeenCalledWith('/files'));
});
it('searches files as the query is typed', async () => {
render(CommandPalette);
await openPalette();
const input = await screen.findByTestId('command-palette-input');
await fireEvent.input(input, { target: { value: 'report' } });
await waitFor(() => expect(searchFiles).toHaveBeenCalled());
expect(m(searchFiles).mock.calls[0][0]).toBe('report');
});
+15 -2
View File
@@ -53,6 +53,7 @@
{#if c.opts.message}<p class="dlg-msg">{c.opts.message}</p>{/if} {#if c.opts.message}<p class="dlg-msg">{c.opts.message}</p>{/if}
<input <input
class="dlg-input" class="dlg-input"
data-testid="dialog-host-prompt-input"
type="text" type="text"
bind:this={inputEl} bind:this={inputEl}
bind:value bind:value
@@ -70,11 +71,22 @@
{/if} {/if}
{#snippet footer()} {#snippet footer()}
<button class="btn btn-secondary" disabled={dialogs.busy} onclick={() => dialogs.cancel()}> <button
class="btn btn-secondary"
data-testid="dialog-host-cancel-btn"
disabled={dialogs.busy}
onclick={() => dialogs.cancel()}
>
{c.opts.cancelText ?? t('common.cancel', 'Cancel')} {c.opts.cancelText ?? t('common.cancel', 'Cancel')}
</button> </button>
{#if c.kind === 'prompt'} {#if c.kind === 'prompt'}
<button class="btn btn-primary" type="submit" form="dialog-form" disabled={dialogs.busy}> <button
class="btn btn-primary"
data-testid="dialog-host-submit-btn"
type="submit"
form="dialog-form"
disabled={dialogs.busy}
>
{dialogs.busy {dialogs.busy
? t('common.loading', 'Loading…') ? t('common.loading', 'Loading…')
: (c.opts.confirmText ?? t('common.ok', 'OK'))} : (c.opts.confirmText ?? t('common.ok', 'OK'))}
@@ -82,6 +94,7 @@
{:else} {:else}
<button <button
class="btn {c.opts.danger ? 'btn-danger' : 'btn-primary'}" class="btn {c.opts.danger ? 'btn-danger' : 'btn-primary'}"
data-testid="dialog-host-confirm-btn"
disabled={dialogs.busy} disabled={dialogs.busy}
onclick={() => dialogs.resolve(true)} onclick={() => dialogs.resolve(true)}
> >
@@ -0,0 +1,41 @@
import { it, expect, beforeEach } from 'vitest';
import { render, screen, fireEvent, waitFor } from '@testing-library/svelte';
import { dialogs, confirmDialog, promptDialog } from '$lib/stores/dialogs.svelte';
import DialogHost from './DialogHost.svelte';
beforeEach(() => {
while (dialogs.current && !dialogs.busy) dialogs.cancel();
});
it('confirms a dialog and resolves true', async () => {
render(DialogHost);
const p = confirmDialog({ title: 'Delete?', message: 'Sure?' });
await screen.findByTestId('dialog-host-confirm-btn');
await fireEvent.click(screen.getByTestId('dialog-host-confirm-btn'));
await expect(p).resolves.toBe(true);
});
it('cancels a dialog and resolves false', async () => {
render(DialogHost);
const p = confirmDialog({ title: 'Delete?', message: 'Sure?' });
await screen.findByTestId('dialog-host-cancel-btn');
await fireEvent.click(screen.getByTestId('dialog-host-cancel-btn'));
await expect(p).resolves.toBe(false);
});
it('submits a prompt dialog with the typed value', async () => {
render(DialogHost);
const p = promptDialog({ title: 'Rename', defaultValue: 'old' });
const input = await screen.findByTestId('dialog-host-prompt-input');
await fireEvent.input(input, { target: { value: 'new-name' } });
await fireEvent.click(screen.getByTestId('dialog-host-submit-btn'));
await expect(p).resolves.toBe('new-name');
});
it('prefills the prompt input with the default value', async () => {
render(DialogHost);
void promptDialog({ title: 'Rename', defaultValue: 'preset.txt' });
const input = (await screen.findByTestId('dialog-host-prompt-input')) as HTMLInputElement;
await waitFor(() => expect(input.value).toBe('preset.txt'));
dialogs.cancel();
});
+17 -2
View File
@@ -153,6 +153,7 @@
<div <div
class="fv" class="fv"
role="dialog" role="dialog"
data-testid="file-viewer-dialog"
aria-modal="true" aria-modal="true"
aria-label={file.name} aria-label={file.name}
tabindex="-1" tabindex="-1"
@@ -166,6 +167,7 @@
<div class="fv__zoom" role="group" aria-label={t('viewer.zoom', 'Zoom')}> <div class="fv__zoom" role="group" aria-label={t('viewer.zoom', 'Zoom')}>
<button <button
class="fv__zoom-btn" class="fv__zoom-btn"
data-testid="file-viewer-zoom-out-btn"
title={t('viewer.zoom_out', 'Zoom out')} title={t('viewer.zoom_out', 'Zoom out')}
aria-label={t('viewer.zoom_out', 'Zoom out')} aria-label={t('viewer.zoom_out', 'Zoom out')}
onclick={() => zoomBy(0.8)} onclick={() => zoomBy(0.8)}
@@ -174,6 +176,7 @@
</button> </button>
<button <button
class="fv__zoom-btn" class="fv__zoom-btn"
data-testid="file-viewer-zoom-reset-btn"
title={t('viewer.zoom_reset', 'Reset zoom')} title={t('viewer.zoom_reset', 'Reset zoom')}
aria-label={t('viewer.zoom_reset', 'Reset zoom')} aria-label={t('viewer.zoom_reset', 'Reset zoom')}
onclick={resetZoom} onclick={resetZoom}
@@ -182,6 +185,7 @@
</button> </button>
<button <button
class="fv__zoom-btn" class="fv__zoom-btn"
data-testid="file-viewer-zoom-in-btn"
title={t('viewer.zoom_in', 'Zoom in')} title={t('viewer.zoom_in', 'Zoom in')}
aria-label={t('viewer.zoom_in', 'Zoom in')} aria-label={t('viewer.zoom_in', 'Zoom in')}
onclick={() => zoomBy(1.2)} onclick={() => zoomBy(1.2)}
@@ -191,13 +195,18 @@
</div> </div>
{/if} {/if}
{#if canEdit} {#if canEdit}
<button class="btn btn-primary btn-sm" onclick={() => (wopiOpen = true)}> <button
class="btn btn-primary btn-sm"
data-testid="file-viewer-edit-btn"
onclick={() => (wopiOpen = true)}
>
<Icon name="pen" /> <Icon name="pen" />
{t('files.edit', 'Edit')} {t('files.edit', 'Edit')}
</button> </button>
{/if} {/if}
<a <a
class="btn btn-secondary btn-sm" class="btn btn-secondary btn-sm"
data-testid="file-viewer-download-link"
href={fileDownloadUrl(file.id)} href={fileDownloadUrl(file.id)}
download download
rel="external" rel="external"
@@ -207,13 +216,19 @@
</a> </a>
<a <a
class="btn btn-secondary btn-sm" class="btn btn-secondary btn-sm"
data-testid="file-viewer-open-new-tab-link"
href={fileInlineUrl(file.id)} href={fileInlineUrl(file.id)}
target="_blank" target="_blank"
rel="external noreferrer" rel="external noreferrer"
> >
<Icon name="external-link-alt" /> <Icon name="external-link-alt" />
</a> </a>
<button class="fv__close" aria-label={t('common.close', 'Close')} onclick={close}> <button
class="fv__close"
data-testid="file-viewer-close-btn"
aria-label={t('common.close', 'Close')}
onclick={close}
>
<Icon name="times" /> <Icon name="times" />
</button> </button>
</div> </div>
@@ -0,0 +1,97 @@
import { it, expect, vi, beforeEach } from 'vitest';
import { render, screen, fireEvent, waitFor } from '@testing-library/svelte';
vi.mock('$lib/api/client', () => ({ apiFetch: vi.fn() }));
vi.mock('$lib/api/endpoints/files', () => ({
fileDownloadUrl: () => '/dl',
fileInlineUrl: () => '/in'
}));
vi.mock('$lib/api/endpoints/wopi', () => ({
canEditWithWopi: vi.fn(),
// WopiEditor's $effect calls this on mount when an editor opens; provide it
// so the async handshake resolves instead of throwing an unhandled error
// (vitest 4 fails the whole run on unhandled errors).
getEditorUrlWithFallback: vi.fn(async () => ({
editor_url: 'about:blank',
access_token: 't',
access_token_ttl: 0
}))
}));
import { apiFetch } from '$lib/api/client';
import { canEditWithWopi } from '$lib/api/endpoints/wopi';
import FileViewer from './FileViewer.svelte';
const af = apiFetch as unknown as ReturnType<typeof vi.fn>;
const cw = canEditWithWopi as unknown as ReturnType<typeof vi.fn>;
function file(over: Record<string, unknown> = {}) {
return {
id: 'i',
name: 'pic.png',
mime_type: 'image/png',
category: 'Image',
folder_id: '',
owner_id: '',
path: '',
size: 1,
modified_at: 0,
created_at: 0,
sort_date: 0,
icon_class: '',
icon_special_class: '',
size_formatted: '1 B',
etag: '',
content_hash: '',
...over
} as never;
}
beforeEach(() => {
vi.clearAllMocks();
cw.mockResolvedValue(false);
});
it('renders an image with working zoom controls and closes', async () => {
render(FileViewer, { props: { open: true, file: file() } });
expect(await screen.findByTestId('file-viewer-dialog')).toBeTruthy();
await fireEvent.click(screen.getByTestId('file-viewer-zoom-in-btn'));
await fireEvent.click(screen.getByTestId('file-viewer-zoom-out-btn'));
await fireEvent.click(screen.getByTestId('file-viewer-zoom-reset-btn'));
await fireEvent.click(screen.getByTestId('file-viewer-close-btn'));
});
it('fetches text content for a text file', async () => {
af.mockResolvedValue({ ok: true, text: async () => 'hello world' });
render(FileViewer, {
props: { open: true, file: file({ name: 'n.txt', mime_type: 'text/plain', category: 'Text' }) }
});
expect(await screen.findByTestId('file-viewer-dialog')).toBeTruthy();
await waitFor(() => expect(af).toHaveBeenCalled());
});
it('renders nothing when closed', () => {
render(FileViewer, { props: { open: false, file: file() } });
expect(screen.queryByTestId('file-viewer-dialog')).toBeNull();
});
it('shows an Edit button for a WOPI-editable document', async () => {
cw.mockResolvedValue(true);
render(FileViewer, {
props: {
open: true,
file: file({
name: 'report.docx',
mime_type: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
category: 'Document'
})
}
});
await screen.findByTestId('file-viewer-dialog');
await waitFor(() => expect(screen.getByTestId('file-viewer-edit-btn')).toBeTruthy());
});
it('exposes download and open-in-new-tab links', async () => {
render(FileViewer, { props: { open: true, file: file() } });
await screen.findByTestId('file-viewer-dialog');
expect(screen.getByTestId('file-viewer-download-link').getAttribute('href')).toBe('/dl');
expect(screen.getByTestId('file-viewer-open-new-tab-link').getAttribute('href')).toBe('/in');
});
it('handles a failed text fetch without crashing', async () => {
af.mockResolvedValue({ ok: false, status: 500, text: async () => '' });
render(FileViewer, {
props: { open: true, file: file({ name: 'n.txt', mime_type: 'text/plain', category: 'Text' }) }
});
await screen.findByTestId('file-viewer-dialog');
await waitFor(() => expect(af).toHaveBeenCalled());
});
@@ -67,12 +67,13 @@
{#if groups?.length || showViewToggle} {#if groups?.length || showViewToggle}
<div class="view-toggle" role="group" aria-label={t('view.label', 'View options')}> <div class="view-toggle" role="group" aria-label={t('view.label', 'View options')}>
{#if groups?.length} {#if groups?.length}
<div class="group-by-selector"> <div class="group-by-selector" data-testid="list-toolbar-groupby-menu">
<button <button
class="toggle-btn group-by-btn active" class="toggle-btn group-by-btn active"
title={t('groupby.title', 'Group by')} title={t('groupby.title', 'Group by')}
aria-haspopup="true" aria-haspopup="true"
aria-expanded={menuOpen} aria-expanded={menuOpen}
data-testid="list-toolbar-groupby-btn"
onclick={() => (menuOpen = !menuOpen)} onclick={() => (menuOpen = !menuOpen)}
> >
<Icon name={active?.icon ?? 'layer-group'} /> <Icon name={active?.icon ?? 'layer-group'} />
@@ -83,6 +84,7 @@
class:active={reversed} class:active={reversed}
title={t('sortdir.title', 'Sort direction')} title={t('sortdir.title', 'Sort direction')}
aria-label={t('sort.direction', 'Sort direction')} aria-label={t('sort.direction', 'Sort direction')}
data-testid="list-toolbar-sort-direction-btn"
onclick={() => ondirection?.()} onclick={() => ondirection?.()}
> >
<Icon name="arrow-up" /> <Icon name="arrow-up" />
@@ -93,6 +95,7 @@
<button <button
class="group-by-option" class="group-by-option"
class:active={groupBy === g.key} class:active={groupBy === g.key}
data-testid={`list-toolbar-groupby-${g.key}-item`}
onclick={() => pick(g.key)} onclick={() => pick(g.key)}
> >
<Icon name={g.icon ?? 'layer-group'} /> <Icon name={g.icon ?? 'layer-group'} />
@@ -110,6 +113,7 @@
class:active={filesStore.viewMode === 'grid'} class:active={filesStore.viewMode === 'grid'}
title={t('view.grid', 'Grid view')} title={t('view.grid', 'Grid view')}
aria-pressed={filesStore.viewMode === 'grid'} aria-pressed={filesStore.viewMode === 'grid'}
data-testid="list-toolbar-view-grid-btn"
onclick={() => filesStore.setViewMode('grid')}><Icon name="th" /></button onclick={() => filesStore.setViewMode('grid')}><Icon name="th" /></button
> >
<button <button
@@ -117,6 +121,7 @@
class:active={filesStore.viewMode === 'list'} class:active={filesStore.viewMode === 'list'}
title={t('view.list', 'List view')} title={t('view.list', 'List view')}
aria-pressed={filesStore.viewMode === 'list'} aria-pressed={filesStore.viewMode === 'list'}
data-testid="list-toolbar-view-list-btn"
onclick={() => filesStore.setViewMode('list')}><Icon name="list" /></button onclick={() => filesStore.setViewMode('list')}><Icon name="list" /></button
> >
{/if} {/if}
+6 -2
View File
@@ -86,13 +86,17 @@
aria-modal="true" aria-modal="true"
aria-label={title} aria-label={title}
tabindex="-1" tabindex="-1"
data-testid="modal"
bind:this={dialogEl} bind:this={dialogEl}
> >
{#if title} {#if title}
<header class="modal__header"> <header class="modal__header">
<h2 class="modal__title">{title}</h2> <h2 class="modal__title">{title}</h2>
<button class="modal__close" aria-label={t('common.close', 'Close')} onclick={close} <button
>×</button class="modal__close"
aria-label={t('common.close', 'Close')}
data-testid="modal-close-btn"
onclick={close}>×</button
> >
</header> </header>
{/if} {/if}
+34
View File
@@ -0,0 +1,34 @@
import { it, expect, vi } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/svelte';
import { createRawSnippet } from 'svelte';
import Modal from './Modal.svelte';
const children = createRawSnippet(() => ({
render: () => '<div data-testid="modal-body">content</div>'
}));
it('renders the title and children when open', () => {
render(Modal, { props: { open: true, title: 'My dialog', children } });
expect(screen.getByTestId('modal')).toBeTruthy();
expect(screen.getByText('My dialog')).toBeTruthy();
expect(screen.getByTestId('modal-body')).toBeTruthy();
});
it('does not render when closed', () => {
render(Modal, { props: { open: false, title: 'Hidden', children } });
expect(screen.queryByTestId('modal')).toBeNull();
});
it('invokes onclose when the close button is clicked', async () => {
const onclose = vi.fn();
render(Modal, { props: { open: true, title: 'X', onclose, children } });
await fireEvent.click(screen.getByTestId('modal-close-btn'));
expect(onclose).toHaveBeenCalled();
});
it('closes on Escape', async () => {
const onclose = vi.fn();
render(Modal, { props: { open: true, title: 'X', onclose, children } });
await fireEvent.keyDown(window, { key: 'Escape' });
expect(onclose).toHaveBeenCalled();
});
+62 -40
View File
@@ -123,51 +123,73 @@
</script> </script>
<Modal bind:open title={moveTitle}> <Modal bind:open title={moveTitle}>
<div class="mv-nav"> <div data-testid="move-dialog">
<button <div class="mv-nav">
class="mv-nav-btn" <button
title={t('breadcrumb.home', 'Home')} class="mv-nav-btn"
aria-label={t('breadcrumb.home', 'Home')} data-testid="move-dialog-home-btn"
disabled={atHome} title={t('breadcrumb.home', 'Home')}
onclick={goHome}><Icon name="home" /></button aria-label={t('breadcrumb.home', 'Home')}
> disabled={atHome}
<button onclick={goHome}><Icon name="home" /></button
class="mv-nav-btn" >
title={t('dialogs.go_to_parent', 'Go to parent')} <button
aria-label={t('dialogs.go_to_parent', 'Go to parent')} class="mv-nav-btn"
disabled={atHome} data-testid="move-dialog-parent-btn"
onclick={goParent}><Icon name="level-up-alt" /></button title={t('dialogs.go_to_parent', 'Go to parent')}
> aria-label={t('dialogs.go_to_parent', 'Go to parent')}
<nav class="mv-crumbs" aria-label="Breadcrumb"> disabled={atHome}
{#each crumbs as c, i (c.id)} onclick={goParent}><Icon name="level-up-alt" /></button
{#if i > 0}<span class="mv-sep">/</span>{/if} >
<button class="mv-crumb" onclick={() => gotoCrumb(i)}>{c.name}</button> <nav class="mv-crumbs" aria-label="Breadcrumb">
{/each} {#each crumbs as c, i (c.id)}
</nav> {#if i > 0}<span class="mv-sep">/</span>{/if}
<button
class="mv-crumb"
data-testid={`move-dialog-crumb-${c.id}`}
onclick={() => gotoCrumb(i)}>{c.name}</button
>
{/each}
</nav>
</div>
{#if loading}
<p class="mv-status">{t('common.loading', 'Loading…')}</p>
{:else if folders.length === 0}
<p class="mv-status">{t('files.no_subfolders', 'No subfolders here.')}</p>
{:else}
<ul class="mv-list">
{#each folders as f (f.id)}
<li>
<button
class="mv-folder"
data-testid={`move-dialog-folder-${f.id}`}
disabled={targetIds.has(f.id)}
onclick={() => enter(f)}
>
<Icon name="folder" /> <span>{f.name}</span>
<Icon name="chevron-right" class="mv-enter" />
</button>
</li>
{/each}
</ul>
{/if}
</div> </div>
{#if loading}
<p class="mv-status">{t('common.loading', 'Loading…')}</p>
{:else if folders.length === 0}
<p class="mv-status">{t('files.no_subfolders', 'No subfolders here.')}</p>
{:else}
<ul class="mv-list">
{#each folders as f (f.id)}
<li>
<button class="mv-folder" disabled={targetIds.has(f.id)} onclick={() => enter(f)}>
<Icon name="folder" /> <span>{f.name}</span>
<Icon name="chevron-right" class="mv-enter" />
</button>
</li>
{/each}
</ul>
{/if}
{#snippet footer()} {#snippet footer()}
<button class="btn btn-secondary" onclick={() => (open = false)}> <button
class="btn btn-secondary"
data-testid="move-dialog-cancel-btn"
onclick={() => (open = false)}
>
{t('common.cancel', 'Cancel')} {t('common.cancel', 'Cancel')}
</button> </button>
<button class="btn btn-primary" disabled={working || !currentId} onclick={confirmMove}> <button
class="btn btn-primary"
data-testid="move-dialog-confirm-btn"
disabled={working || !currentId}
onclick={confirmMove}
>
{mode === 'copy' ? t('files.copy_here', 'Copy here') : t('files.move_here', 'Move here')} {mode === 'copy' ? t('files.copy_here', 'Copy here') : t('files.move_here', 'Move here')}
</button> </button>
{/snippet} {/snippet}
@@ -0,0 +1,82 @@
import { it, expect, vi, beforeEach } from 'vitest';
import { render, screen, fireEvent, waitFor } from '@testing-library/svelte';
const { session, ui } = vi.hoisted(() => ({
session: { loadHomeFolder: vi.fn(async () => 'home'), homeFolderName: 'Files' },
ui: { notify: vi.fn() }
}));
vi.mock('$lib/stores/session.svelte', () => ({ session }));
vi.mock('$lib/stores/ui.svelte', () => ({ ui }));
vi.mock('$lib/utils/errors', () => ({ errorToast: vi.fn() }));
vi.mock('$lib/api/endpoints/folders', () => ({ listFolder: vi.fn(), moveFolder: vi.fn() }));
vi.mock('$lib/api/endpoints/files', () => ({ moveFile: vi.fn() }));
vi.mock('$lib/api/endpoints/batch', () => ({ copyFiles: vi.fn(), copyFolders: vi.fn() }));
import { listFolder, moveFolder } from '$lib/api/endpoints/folders';
import { moveFile } from '$lib/api/endpoints/files';
import { copyFiles } from '$lib/api/endpoints/batch';
import MoveDialog from './MoveDialog.svelte';
const m = (fn: unknown) => fn as ReturnType<typeof vi.fn>;
const item = { id: 'f1', name: 'doc.txt', kind: 'file' as const };
function folder(id: string, name: string) {
return {
category: 'Folder',
created_at: 0,
icon_class: 'fa-folder',
icon_special_class: '',
id,
is_root: false,
modified_at: 0,
name,
owner_id: 'me',
parent_id: 'home',
path: '/' + name,
etag: 'e'
};
}
beforeEach(() => {
vi.clearAllMocks();
m(listFolder).mockResolvedValue({
folders: [folder('sub1', 'Sub')],
files: [],
favoriteIds: [],
sharedIds: []
});
});
it('loads the home folder when opened', async () => {
render(MoveDialog, { props: { open: true, item } });
await waitFor(() => expect(listFolder).toHaveBeenCalledWith('home'));
await screen.findByTestId('move-dialog');
});
it('moves the item into the current folder on confirm', async () => {
m(moveFile).mockResolvedValue(undefined);
const onmoved = vi.fn();
render(MoveDialog, { props: { open: true, item, onmoved } });
await screen.findByTestId('move-dialog-confirm-btn');
await fireEvent.click(screen.getByTestId('move-dialog-confirm-btn'));
await waitFor(() => expect(moveFile).toHaveBeenCalledWith('f1', 'home'));
await waitFor(() => expect(onmoved).toHaveBeenCalled());
});
it('navigates into a subfolder before confirming', async () => {
m(moveFolder).mockResolvedValue(undefined);
const folderItem = { id: 'fold9', name: 'Dir', kind: 'folder' as const };
render(MoveDialog, { props: { open: true, item: folderItem } });
await fireEvent.click(await screen.findByTestId('move-dialog-folder-sub1'));
await waitFor(() => expect(listFolder).toHaveBeenCalledWith('sub1'));
await fireEvent.click(screen.getByTestId('move-dialog-confirm-btn'));
await waitFor(() => expect(moveFolder).toHaveBeenCalledWith('fold9', 'sub1'));
});
it('copies the item in copy mode', async () => {
m(copyFiles).mockResolvedValue(undefined);
render(MoveDialog, { props: { open: true, item, mode: 'copy' } });
await screen.findByTestId('move-dialog-confirm-btn');
await fireEvent.click(screen.getByTestId('move-dialog-confirm-btn'));
await waitFor(() => expect(copyFiles).toHaveBeenCalledWith(['f1'], 'home'));
});
@@ -0,0 +1,82 @@
import { it, expect, vi, beforeEach } from 'vitest';
import { render, fireEvent, screen, waitFor } from '@testing-library/svelte';
vi.mock('$lib/api/endpoints/people', () => ({
fetchPeople: vi.fn(),
fetchPersonPhotos: vi.fn(),
renamePerson: vi.fn()
}));
vi.mock('$lib/api/endpoints/files', () => ({ fileThumbnailUrl: () => '/thumb.png' }));
vi.mock('$lib/stores/dialogs.svelte', () => ({ promptDialog: vi.fn() }));
import { fetchPeople, fetchPersonPhotos, renamePerson } from '$lib/api/endpoints/people';
import { promptDialog } from '$lib/stores/dialogs.svelte';
import PeopleView from './PeopleView.svelte';
const fp = fetchPeople as unknown as ReturnType<typeof vi.fn>;
const fpp = fetchPersonPhotos as unknown as ReturnType<typeof vi.fn>;
const rn = renamePerson as unknown as ReturnType<typeof vi.fn>;
const pd = promptDialog as unknown as ReturnType<typeof vi.fn>;
beforeEach(() => vi.clearAllMocks());
it('renders the people grid (named + unnamed)', async () => {
fp.mockResolvedValue([
{ id: 'p1', name: 'Alice', face_count: 3, cover_file_id: 'c1' },
{ id: 'p2', name: '', face_count: 1, cover_file_id: null }
]);
render(PeopleView);
expect(await screen.findByText('Alice')).toBeTruthy();
expect(screen.getByText('Unnamed')).toBeTruthy();
});
it('shows an empty state when there are no people', async () => {
fp.mockResolvedValue([]);
render(PeopleView);
expect(await screen.findByText('No people yet')).toBeTruthy();
});
it('shows the disabled state when the list errors', async () => {
fp.mockRejectedValue(new Error('off'));
render(PeopleView);
expect(await screen.findByText('Face recognition is disabled')).toBeTruthy();
});
it('drills into a person and back to the list', async () => {
fp.mockResolvedValue([{ id: 'p1', name: 'Alice', face_count: 2, cover_file_id: null }]);
fpp.mockResolvedValue(['ph1', 'ph2']);
render(PeopleView);
const btn = (await screen.findByText('Alice')).closest('button')!;
await fireEvent.click(btn);
await waitFor(() => expect(fpp).toHaveBeenCalledWith('p1'));
await fireEvent.click(screen.getByLabelText('Back'));
expect(await screen.findByText('Alice')).toBeTruthy();
});
it('renames the current person', async () => {
fp.mockResolvedValue([{ id: 'p1', name: 'Alice', face_count: 1, cover_file_id: null }]);
fpp.mockResolvedValue([]);
pd.mockResolvedValue('Bob');
rn.mockResolvedValue(undefined);
render(PeopleView);
await fireEvent.click((await screen.findByText('Alice')).closest('button')!);
await waitFor(() => screen.getByLabelText('Name this person'));
await fireEvent.click(screen.getByLabelText('Name this person'));
await waitFor(() => expect(rn).toHaveBeenCalledWith('p1', 'Bob'));
});
it("opens a person's photo in the lightbox", async () => {
fp.mockResolvedValue([{ id: 'p1', name: 'Alice', face_count: 2, cover_file_id: null }]);
fpp.mockResolvedValue(['ph1', 'ph2']);
const { container } = render(PeopleView);
await fireEvent.click((await screen.findByText('Alice')).closest('button')!);
await waitFor(() => expect(fpp).toHaveBeenCalledWith('p1'));
const tiles = await waitFor(() => {
const found = container.querySelectorAll('.photos__open');
if (found.length === 0) throw new Error('no tiles yet');
return found;
});
expect(tiles.length).toBe(2);
await fireEvent.click(tiles[0]);
expect(await screen.findByTestId('photo-lightbox')).toBeTruthy();
});
@@ -180,6 +180,7 @@
aria-modal="true" aria-modal="true"
aria-label={item.name} aria-label={item.name}
tabindex="-1" tabindex="-1"
data-testid="photo-lightbox"
onclick={(e) => e.target === e.currentTarget && close()} onclick={(e) => e.target === e.currentTarget && close()}
> >
<div class="lb__info"> <div class="lb__info">
@@ -187,12 +188,18 @@
<div class="lb__meta">{meta}</div> <div class="lb__meta">{meta}</div>
</div> </div>
<button class="lb__close" aria-label={t('common.close', 'Close')} onclick={close}>×</button> <button
class="lb__close"
aria-label={t('common.close', 'Close')}
data-testid="photo-lightbox-close-btn"
onclick={close}>×</button
>
<button <button
class="lb__nav lb__nav--prev" class="lb__nav lb__nav--prev"
aria-label={t('common.previous', 'Previous')} aria-label={t('common.previous', 'Previous')}
disabled={index === 0} disabled={index === 0}
data-testid="photo-lightbox-prev-btn"
onclick={(e) => { onclick={(e) => {
e.stopPropagation(); e.stopPropagation();
prev(); prev();
@@ -221,6 +228,7 @@
class="lb__nav lb__nav--next" class="lb__nav lb__nav--next"
aria-label={t('common.next', 'Next')} aria-label={t('common.next', 'Next')}
disabled={index === items.length - 1} disabled={index === items.length - 1}
data-testid="photo-lightbox-next-btn"
onclick={(e) => { onclick={(e) => {
e.stopPropagation(); e.stopPropagation();
next(); next();
@@ -0,0 +1,50 @@
import { it, expect, vi, beforeEach } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/svelte';
vi.mock('$lib/api/endpoints/files', () => ({
deleteFile: vi.fn(),
fileDownloadUrl: () => '/d',
fileInlineUrl: () => '/i',
fileThumbnailUrl: () => '/t'
}));
vi.mock('$lib/api/endpoints/favorites', () => ({ addFavorite: vi.fn() }));
vi.mock('$lib/api/endpoints/photos', () => ({ fetchFileMetadata: vi.fn() }));
vi.mock('$lib/stores/dialogs.svelte', () => ({ confirmDialog: vi.fn() }));
vi.mock('$lib/utils/errors', () => ({ errorToast: vi.fn() }));
import { fetchFileMetadata } from '$lib/api/endpoints/photos';
import PhotoLightbox from './PhotoLightbox.svelte';
const fm = fetchFileMetadata as unknown as ReturnType<typeof vi.fn>;
function item(id: string) {
return {
id,
name: `${id}.jpg`,
mime_type: 'image/jpeg',
category: 'Image',
folder_id: '',
owner_id: '',
path: '',
size: 1,
modified_at: 0,
created_at: 0,
sort_date: 0,
icon_class: '',
icon_special_class: '',
size_formatted: '1 B',
etag: '',
content_hash: ''
} as never;
}
beforeEach(() => {
vi.clearAllMocks();
fm.mockResolvedValue(null);
});
it('opens at an index, navigates next/prev, and closes', async () => {
render(PhotoLightbox, { props: { items: [item('a'), item('b')], index: 0 } });
expect(await screen.findByTestId('photo-lightbox')).toBeTruthy();
await fireEvent.click(screen.getByTestId('photo-lightbox-next-btn'));
await fireEvent.click(screen.getByTestId('photo-lightbox-prev-btn'));
await fireEvent.click(screen.getByTestId('photo-lightbox-close-btn'));
});
it('renders nothing at index -1', () => {
render(PhotoLightbox, { props: { items: [item('a')], index: -1 } });
expect(screen.queryByTestId('photo-lightbox')).toBeNull();
});
@@ -309,6 +309,8 @@
class:file-item--selected={selectable && selected.has(entry.id)} class:file-item--selected={selectable && selected.has(entry.id)}
role={onopen ? 'button' : undefined} role={onopen ? 'button' : undefined}
tabindex={onopen ? 0 : undefined} tabindex={onopen ? 0 : undefined}
aria-label={onopen ? entry.name : undefined}
data-testid={entry.name}
title={showOwner ? ownerTitle(entry) : undefined} title={showOwner ? ownerTitle(entry) : undefined}
onclick={onopen ? () => onopen(entry) : undefined} onclick={onopen ? () => onopen(entry) : undefined}
onkeydown={onopen ? (e) => e.key === 'Enter' && onopen(entry) : undefined} onkeydown={onopen ? (e) => e.key === 'Enter' && onopen(entry) : undefined}
@@ -319,6 +321,7 @@
<input <input
type="checkbox" type="checkbox"
aria-label={t('common.select', 'Select')} aria-label={t('common.select', 'Select')}
data-testid={`resource-list-select-${entry.id}-checkbox`}
checked={selected.has(entry.id)} checked={selected.has(entry.id)}
onchange={() => toggleSelected(entry.id)} onchange={() => toggleSelected(entry.id)}
/> />
@@ -360,6 +363,7 @@
<button <button
class="rl-star" class="rl-star"
class:rl-star--on={entry.isFavorite} class:rl-star--on={entry.isFavorite}
data-testid={`resource-list-favorite-${entry.id}-btn`}
title={entry.isFavorite title={entry.isFavorite
? t('files.unfavorite', 'Remove favorite') ? t('files.unfavorite', 'Remove favorite')
: t('files.favorite', 'Add favorite')} : t('files.favorite', 'Add favorite')}
@@ -393,8 +397,18 @@
</div> </div>
{#if selectable && selected.size > 0 && batchToolbar} {#if selectable && selected.size > 0 && batchToolbar}
<div class="rl-batch" role="region" aria-label={t('files.selection', 'Selection')}> <div
<button class="rl-batch__close" title={t('common.clear', 'Clear')} onclick={clearSelection}> class="rl-batch"
role="region"
aria-label={t('files.selection', 'Selection')}
data-testid="resource-list-batch-toolbar"
>
<button
class="rl-batch__close"
title={t('common.clear', 'Clear')}
data-testid="resource-list-batch-close-btn"
onclick={clearSelection}
>
<Icon name="times" /> <Icon name="times" />
</button> </button>
<span class="rl-batch__count" <span class="rl-batch__count"
@@ -454,7 +468,12 @@
{/if} {/if}
{#if hasMore} {#if hasMore}
<button class="btn btn-secondary rl-more" onclick={onloadmore} disabled={loading}> <button
class="btn btn-secondary rl-more"
data-testid="resource-list-load-more-btn"
onclick={onloadmore}
disabled={loading}
>
{loading ? t('common.loading', 'Loading…') : t('common.load_more', 'Load more')} {loading ? t('common.loading', 'Loading…') : t('common.load_more', 'Load more')}
</button> </button>
{/if} {/if}
@@ -470,6 +489,7 @@
<input <input
type="checkbox" type="checkbox"
aria-label={t('common.select_all', 'Select all')} aria-label={t('common.select_all', 'Select all')}
data-testid="resource-list-select-all-checkbox"
checked={allSelected} checked={allSelected}
onchange={toggleSelectAll} onchange={toggleSelectAll}
/> />
@@ -492,12 +512,19 @@
onclick={closeContext} onclick={closeContext}
oncontextmenu={(e) => e.preventDefault()} oncontextmenu={(e) => e.preventDefault()}
></div> ></div>
<div class="rl-ctx-menu" style:left="{ctxX}px" style:top="{ctxY}px" role="menu"> <div
class="rl-ctx-menu"
style:left="{ctxX}px"
style:top="{ctxY}px"
role="menu"
data-testid="resource-list-context-menu"
>
{#each contextActions as action (action.key)} {#each contextActions as action (action.key)}
<button <button
class="rl-ctx-item" class="rl-ctx-item"
class:rl-ctx-item--danger={action.danger} class:rl-ctx-item--danger={action.danger}
role="menuitem" role="menuitem"
data-testid={`resource-list-context-${action.key}-item`}
onclick={() => { onclick={() => {
const e = ctxEntry!; const e = ctxEntry!;
closeContext(); closeContext();
+233 -178
View File
@@ -377,194 +377,249 @@
{/snippet} {/snippet}
<Modal bind:open title={t('share.dialog_title', { name: item?.name ?? '' }, 'Share “{{name}}”')}> <Modal bind:open title={t('share.dialog_title', { name: item?.name ?? '' }, 'Share “{{name}}”')}>
<div class="tabs" role="tablist"> <div data-testid="share-dialog">
<button role="tab" aria-selected={tab === 'people'} onclick={() => (tab = 'people')}> <div class="tabs" role="tablist">
{t('share.people', 'People')} <button
</button> role="tab"
<button role="tab" aria-selected={tab === 'link'} onclick={() => (tab = 'link')}> data-testid="share-dialog-people-tab"
{t('share.public_link', 'Public link')} aria-selected={tab === 'people'}
</button> onclick={() => (tab = 'people')}
</div> >
{t('share.people', 'People')}
</button>
<button
role="tab"
data-testid="share-dialog-link-tab"
aria-selected={tab === 'link'}
onclick={() => (tab = 'link')}
>
{t('share.public_link', 'Public link')}
</button>
</div>
{#if tab === 'people'} {#if tab === 'people'}
{#if !directoryAvailable && !grantsLoading} {#if !directoryAvailable && !grantsLoading}
<p class="status status--note"> <p class="status status--note">
{t('share.directoryUnavailable', 'User directory unavailable')} {t('share.directoryUnavailable', 'User directory unavailable')}
</p> </p>
{:else} {:else}
<div class="add-row"> <div class="add-row">
<div class="search"> <div class="search">
<input <input
placeholder={t('share.add_people', 'Add people, groups, or email…')} data-testid="share-dialog-search-input"
bind:value={query} placeholder={t('share.add_people', 'Add people, groups, or email…')}
oninput={onQueryInput} bind:value={query}
autocomplete="off" oninput={onQueryInput}
/> autocomplete="off"
{#if results.length > 0} />
<ul class="results"> {#if results.length > 0}
{#each results as r (r.type + r.id)} <ul class="results">
<li> {#each results as r (r.type + r.id)}
<button class="result" onclick={() => addRecipient(r)}> <li>
<Icon <button
name={r.type === 'group' class="result"
? 'user-group' data-testid={`share-dialog-result-${r.type}-${r.id}`}
: r.type === 'email' onclick={() => addRecipient(r)}
? 'envelope' >
: 'user'} <Icon
name={r.type === 'group'
? 'user-group'
: r.type === 'email'
? 'envelope'
: 'user'}
/>
<span class="result__label">{r.label}</span>
{#if r.type === 'email'}
<span class="result__sub"
>{t('share.inviteByEmail', 'Invite by email')}</span
>
{:else if r.sublabel}
<span class="result__sub">{r.sublabel}</span>
{/if}
</button>
</li>
{/each}
</ul>
{/if}
</div>
<select
class="role-select"
data-testid="share-dialog-new-role-select"
bind:value={newRole}
aria-label={t('share.role_label', 'Role')}
>
{#each ROLES as r (r.v)}<option value={r.v}>{r.l}</option>{/each}
</select>
{@render expiryChip(newExpiry, (v) => (newExpiry = v))}
</div>
{/if}
{#if grantsLoading}
<div class="skeleton" aria-hidden="true">
<div class="skeleton__line skeleton__line--short"></div>
<div class="skeleton__line skeleton__line--medium"></div>
<div class="skeleton__line"></div>
</div>
{:else if members.length === 0}
<p class="status">{t('share.no_people', 'Not shared with anyone yet.')}</p>
{:else}
{#each memberGroups as group (group.role)}
<div class="member-group">
<div class="member-group__header">
<Icon name={roleIcon(group.role)} />
<span>{roleLabel(group.role)}</span>
<span class="member-group__badge">{group.members.length}</span>
</div>
<ul class="members">
{#each group.members as m (m.subject.type + m.subject.id)}
<li
class="member"
class:member--expired={m.expiry && new Date(m.expiry) < new Date()}
>
{#if m.subject.type === 'user'}
<UserVignette
userId={m.subject.id}
fallbackLabel={m.recipient.label}
fallbackSublabel={m.recipient.sublabel}
/> />
<span class="result__label">{r.label}</span> {:else}
{#if r.type === 'email'} <Icon name="user-group" />
<span class="result__sub">{t('share.inviteByEmail', 'Invite by email')}</span> <span class="member__label">
{:else if r.sublabel} {m.recipient.label}
<span class="result__sub">{r.sublabel}</span> {#if m.recipient.sublabel}<span class="member__sub"
{/if} >{m.recipient.sublabel}</span
</button> >{/if}
</span>
{/if}
{@render expiryChip(m.expiry, (v) => changeMemberExpiry(m, v))}
<select
class="role-select"
data-testid={`share-dialog-member-role-${m.subject.type}-${m.subject.id}`}
value={m.role}
onchange={(e) => changeRole(m, e.currentTarget.value as ShareRole)}
>
{#each ROLES as r (r.v)}<option value={r.v}>{r.l}</option>{/each}
</select>
<button
class="btn-action"
data-testid={`share-dialog-member-notify-${m.subject.type}-${m.subject.id}`}
title={t('share.notifyByEmail', 'Notify by email')}
onclick={() => notifyMember(m)}><Icon name="paper-plane" /></button
>
<button
class="btn-action btn-action--delete"
data-testid={`share-dialog-member-remove-${m.subject.type}-${m.subject.id}`}
title={t('share.revoke', 'Remove')}
onclick={() => removeMember(m)}><Icon name="user-xmark" /></button
>
</li> </li>
{/each} {/each}
</ul> </ul>
{/if}
</div>
<select class="role-select" bind:value={newRole} aria-label={t('share.role_label', 'Role')}>
{#each ROLES as r (r.v)}<option value={r.v}>{r.l}</option>{/each}
</select>
{@render expiryChip(newExpiry, (v) => (newExpiry = v))}
</div>
{/if}
{#if grantsLoading}
<div class="skeleton" aria-hidden="true">
<div class="skeleton__line skeleton__line--short"></div>
<div class="skeleton__line skeleton__line--medium"></div>
<div class="skeleton__line"></div>
</div>
{:else if members.length === 0}
<p class="status">{t('share.no_people', 'Not shared with anyone yet.')}</p>
{:else}
{#each memberGroups as group (group.role)}
<div class="member-group">
<div class="member-group__header">
<Icon name={roleIcon(group.role)} />
<span>{roleLabel(group.role)}</span>
<span class="member-group__badge">{group.members.length}</span>
</div> </div>
<ul class="members">
{#each group.members as m (m.subject.type + m.subject.id)}
<li
class="member"
class:member--expired={m.expiry && new Date(m.expiry) < new Date()}
>
{#if m.subject.type === 'user'}
<UserVignette
userId={m.subject.id}
fallbackLabel={m.recipient.label}
fallbackSublabel={m.recipient.sublabel}
/>
{:else}
<Icon name="user-group" />
<span class="member__label">
{m.recipient.label}
{#if m.recipient.sublabel}<span class="member__sub">{m.recipient.sublabel}</span
>{/if}
</span>
{/if}
{@render expiryChip(m.expiry, (v) => changeMemberExpiry(m, v))}
<select
class="role-select"
value={m.role}
onchange={(e) => changeRole(m, e.currentTarget.value as ShareRole)}
>
{#each ROLES as r (r.v)}<option value={r.v}>{r.l}</option>{/each}
</select>
<button
class="btn-action"
title={t('share.notifyByEmail', 'Notify by email')}
onclick={() => notifyMember(m)}><Icon name="paper-plane" /></button
>
<button
class="btn-action btn-action--delete"
title={t('share.revoke', 'Remove')}
onclick={() => removeMember(m)}><Icon name="user-xmark" /></button
>
</li>
{/each}
</ul>
</div>
{/each}
{/if}
{:else}
<section class="sh-create">
<div class="sh-fields">
<label>
<span>{t('share.link_name', 'Link name (optional)')}</span>
<input type="text" bind:value={newLinkName} autocomplete="off" />
</label>
<label>
<span>{t('share.password_optional', 'Password (optional)')}</span>
<input type="text" bind:value={password} autocomplete="off" />
</label>
<label>
<span>{t('share.expires_optional', 'Expires (optional)')}</span>
<input
type="date"
value={expiresAt ?? ''}
onchange={(e) => (expiresAt = e.currentTarget.value || null)}
/>
</label>
</div>
<button class="btn btn-primary" disabled={creating} onclick={createLink}>
{t('share.create_link', 'Create link')}
</button>
</section>
{#if linkLoading}
<div class="skeleton" aria-hidden="true">
<div class="skeleton__line skeleton__line--medium"></div>
<div class="skeleton__line"></div>
</div>
{:else if shares.length === 0}
<p class="status">{t('share.none', 'No public links yet.')}</p>
{:else}
<ul class="links">
{#each shares as s (s.id)}
<li class="link-row">
<span class="link-row__title">
<Icon name={s.has_password ? 'lock' : 'link'} />
<span class="link-row__name"
>{s.item_name || t('share.sharedLink', 'Shared link')}</span
>
</span>
{@render expiryChip(shareExpiryIso(s), (v) => editLinkExpiry(s, v))}
<button
class="btn-action"
class:btn-action--on={s.has_password}
title={s.has_password
? t('share.changePassword', 'Change password')
: t('share.addPassword', 'Add password')}
onclick={() => {
const pw = window.prompt(
s.has_password
? t('share.passwordPrompt_clear', 'New password (blank to remove):')
: t('share.passwordPrompt', 'Set a password:')
);
if (pw !== null) editLinkPassword(s, pw || null);
}}><Icon name={s.has_password ? 'lock' : 'lock-open'} /></button
>
<button class="btn-action" title={t('share.copy', 'Copy')} onclick={() => copy(s.url)}>
<Icon name="copy" />
</button>
<button
class="btn-action btn-action--delete"
title={t('common.delete', 'Delete')}
onclick={() => removeLink(s)}><Icon name="trash" /></button
>
</li>
{/each} {/each}
</ul> {/if}
{:else}
<section class="sh-create">
<div class="sh-fields">
<label>
<span>{t('share.link_name', 'Link name (optional)')}</span>
<input
type="text"
data-testid="share-dialog-link-name-input"
bind:value={newLinkName}
autocomplete="off"
/>
</label>
<label>
<span>{t('share.password_optional', 'Password (optional)')}</span>
<input
type="text"
data-testid="share-dialog-link-password-input"
bind:value={password}
autocomplete="off"
/>
</label>
<label>
<span>{t('share.expires_optional', 'Expires (optional)')}</span>
<input
type="date"
data-testid="share-dialog-link-expires-input"
value={expiresAt ?? ''}
onchange={(e) => (expiresAt = e.currentTarget.value || null)}
/>
</label>
</div>
<button
class="btn btn-primary"
data-testid="share-dialog-create-btn"
disabled={creating}
onclick={createLink}
>
{t('share.create_link', 'Create link')}
</button>
</section>
{#if linkLoading}
<div class="skeleton" aria-hidden="true">
<div class="skeleton__line skeleton__line--medium"></div>
<div class="skeleton__line"></div>
</div>
{:else if shares.length === 0}
<p class="status">{t('share.none', 'No public links yet.')}</p>
{:else}
<ul class="links">
{#each shares as s (s.id)}
<li class="link-row">
<span class="link-row__title">
<Icon name={s.has_password ? 'lock' : 'link'} />
<span class="link-row__name"
>{s.item_name || t('share.sharedLink', 'Shared link')}</span
>
</span>
{@render expiryChip(shareExpiryIso(s), (v) => editLinkExpiry(s, v))}
<button
class="btn-action"
class:btn-action--on={s.has_password}
data-testid={`share-dialog-link-password-btn-${s.id}`}
title={s.has_password
? t('share.changePassword', 'Change password')
: t('share.addPassword', 'Add password')}
onclick={() => {
const pw = window.prompt(
s.has_password
? t('share.passwordPrompt_clear', 'New password (blank to remove):')
: t('share.passwordPrompt', 'Set a password:')
);
if (pw !== null) editLinkPassword(s, pw || null);
}}><Icon name={s.has_password ? 'lock' : 'lock-open'} /></button
>
<button
class="btn-action"
data-testid={`share-dialog-link-copy-btn-${s.id}`}
title={t('share.copy', 'Copy')}
onclick={() => copy(s.url)}
>
<Icon name="copy" />
</button>
<button
class="btn-action btn-action--delete"
data-testid={`share-dialog-link-delete-btn-${s.id}`}
title={t('common.delete', 'Delete')}
onclick={() => removeLink(s)}><Icon name="trash" /></button
>
</li>
{/each}
</ul>
{/if}
{/if} {/if}
{/if} </div>
{#snippet footer()} {#snippet footer()}
<button class="btn btn-secondary" onclick={() => (open = false)}> <button
class="btn btn-secondary"
data-testid="share-dialog-close-btn"
onclick={() => (open = false)}
>
{t('common.close', 'Close')} {t('common.close', 'Close')}
</button> </button>
{/snippet} {/snippet}
@@ -0,0 +1,68 @@
import { it, expect, vi, beforeEach } from 'vitest';
import { render, screen, fireEvent, waitFor } from '@testing-library/svelte';
const { ui } = vi.hoisted(() => ({ ui: { notify: vi.fn() } }));
vi.mock('$lib/stores/ui.svelte', () => ({ ui }));
vi.mock('$lib/utils/errors', () => ({ errorToast: vi.fn() }));
vi.mock('$lib/api/endpoints/shares', () => ({
copyShareLink: vi.fn(),
createShare: vi.fn(),
deleteShare: vi.fn(),
listSharesForItem: vi.fn(),
updateShare: vi.fn()
}));
vi.mock('$lib/api/endpoints/grants', () => ({
createGrant: vi.fn(),
expiryToIso: (v: string | null) => v,
displayRole: (r: string) => r,
fetchGrantsForResource: vi.fn(),
notifyGrantRecipient: vi.fn(),
revokeGrant: vi.fn(),
updateGrantRole: vi.fn()
}));
vi.mock('$lib/api/endpoints/recipients', () => ({
ensureResolvers: vi.fn(),
isDirectoryAvailable: () => true,
resolveRecipient: (_t: string, id: string) => ({ id, label: id }),
searchRecipients: vi.fn(async () => [])
}));
import { createShare, listSharesForItem } from '$lib/api/endpoints/shares';
import { fetchGrantsForResource } from '$lib/api/endpoints/grants';
import ShareDialog from './ShareDialog.svelte';
const m = (fn: unknown) => fn as ReturnType<typeof vi.fn>;
const item = { id: 'f1', name: 'doc.txt', kind: 'file' as const };
beforeEach(() => {
vi.clearAllMocks();
m(fetchGrantsForResource).mockResolvedValue([]);
m(listSharesForItem).mockResolvedValue([]);
});
it('loads grants and shares when opened', async () => {
render(ShareDialog, { props: { open: true, item } });
await screen.findByTestId('share-dialog');
await waitFor(() => expect(fetchGrantsForResource).toHaveBeenCalledWith('file', 'f1'));
await waitFor(() => expect(listSharesForItem).toHaveBeenCalledWith('f1', 'file'));
});
it('switches to the link tab and creates a public link', async () => {
m(createShare).mockResolvedValue({ id: 's1', token: 'abc', has_password: false });
render(ShareDialog, { props: { open: true, item } });
await fireEvent.click(await screen.findByTestId('share-dialog-link-tab'));
await fireEvent.input(screen.getByTestId('share-dialog-link-name-input'), {
target: { value: 'My link' }
});
await fireEvent.click(screen.getByTestId('share-dialog-create-btn'));
await waitFor(() =>
expect(createShare).toHaveBeenCalledWith(
expect.objectContaining({ itemId: 'f1', itemName: 'My link', itemType: 'file' })
)
);
});
it('does not load when closed', () => {
render(ShareDialog, { props: { open: false, item } });
expect(fetchGrantsForResource).not.toHaveBeenCalled();
});
+2 -1
View File
@@ -10,10 +10,11 @@
aria-label={t('notifications.title', 'Notifications')} aria-label={t('notifications.title', 'Notifications')}
> >
{#each ui.toasts as toast (toast.id)} {#each ui.toasts as toast (toast.id)}
<div class="toast toast--{toast.kind}" role="status"> <div class="toast toast--{toast.kind}" role="status" data-testid={`toaster-toast-${toast.id}`}>
<span class="toast__msg">{toast.message}</span> <span class="toast__msg">{toast.message}</span>
<button <button
class="toast__close" class="toast__close"
data-testid={`toaster-dismiss-btn-${toast.id}`}
aria-label={t('common.dismiss', 'Dismiss')} aria-label={t('common.dismiss', 'Dismiss')}
onclick={() => ui.dismiss(toast.id)} onclick={() => ui.dismiss(toast.id)}
> >
+25 -4
View File
@@ -78,10 +78,21 @@
<svelte:window onkeydown={onKeydown} onmessage={onMessage} /> <svelte:window onkeydown={onKeydown} onmessage={onMessage} />
{#if open} {#if open}
<div class="wopi" role="dialog" aria-modal="true" aria-label={fileName}> <div
class="wopi"
role="dialog"
data-testid="wopi-editor-dialog"
aria-modal="true"
aria-label={fileName}
>
<header class="wopi__bar"> <header class="wopi__bar">
<span class="wopi__title">{fileName}</span> <span class="wopi__title">{fileName}</span>
<button class="wopi__close" aria-label={t('common.close', 'Close')} onclick={close}> <button
class="wopi__close"
data-testid="wopi-editor-close-btn"
aria-label={t('common.close', 'Close')}
onclick={close}
>
<Icon name="times" /> <Icon name="times" />
</button> </button>
</header> </header>
@@ -97,8 +108,18 @@
target="wopi_frame" target="wopi_frame"
class="wopi__form" class="wopi__form"
> >
<input type="hidden" name="access_token" value={token} /> <input
<input type="hidden" name="access_token_ttl" value={tokenTtl} /> type="hidden"
name="access_token"
value={token}
data-testid="wopi-editor-access-token-input"
/>
<input
type="hidden"
name="access_token_ttl"
value={tokenTtl}
data-testid="wopi-editor-access-token-ttl-input"
/>
</form> </form>
{/if} {/if}
<iframe <iframe
@@ -0,0 +1,56 @@
import { it, expect, vi, beforeEach } from 'vitest';
import { render, screen, waitFor } from '@testing-library/svelte';
vi.mock('$lib/api/endpoints/wopi', () => ({ getEditorUrlWithFallback: vi.fn() }));
vi.mock('$lib/utils/errors', () => ({ errorToast: vi.fn() }));
import { getEditorUrlWithFallback } from '$lib/api/endpoints/wopi';
import { errorToast } from '$lib/utils/errors';
import WopiEditor from './WopiEditor.svelte';
const g = getEditorUrlWithFallback as unknown as ReturnType<typeof vi.fn>;
const et = errorToast as unknown as ReturnType<typeof vi.fn>;
beforeEach(() => vi.clearAllMocks());
it('opens, loads the editor URL, and renders the dialog', async () => {
g.mockResolvedValue({
editor_url: 'https://wopi/edit',
access_token: 'tok',
access_token_ttl: 9999
});
render(WopiEditor, { props: { open: true, fileId: 'f1', fileName: 'doc.docx', action: 'edit' } });
await waitFor(() => expect(g).toHaveBeenCalledWith('f1', 'doc.docx', 'edit'));
expect(screen.getByTestId('wopi-editor-dialog')).toBeTruthy();
});
it('drops the spinner on an App_LoadingStatus message and closes on UI_Close', async () => {
g.mockResolvedValue({ editor_url: 'u', access_token: 't', access_token_ttl: 1 });
const onclose = vi.fn();
render(WopiEditor, { props: { open: true, fileId: 'f1', fileName: 'd.docx', onclose } });
await waitFor(() => screen.getByTestId('wopi-editor-dialog'));
window.dispatchEvent(
new MessageEvent('message', {
data: JSON.stringify({
MessageId: 'App_LoadingStatus',
Values: { Status: 'Document_Loaded' }
})
})
);
window.dispatchEvent(
new MessageEvent('message', { data: JSON.stringify({ MessageId: 'UI_Close' }) })
);
await waitFor(() => expect(onclose).toHaveBeenCalled());
});
it('reports a load failure via errorToast', async () => {
g.mockRejectedValue(new Error('no wopi host'));
render(WopiEditor, { props: { open: true, fileId: 'f1', fileName: 'd.docx' } });
await waitFor(() => expect(et).toHaveBeenCalled());
});
it('does not load when closed', () => {
render(WopiEditor, { props: { open: false, fileId: 'f1', fileName: 'd.docx' } });
expect(g).not.toHaveBeenCalled();
});
@@ -0,0 +1,19 @@
import { it, expect, vi } from 'vitest';
import { useOwnerCache } from './useOwnerCache.svelte';
it('resolves names in parallel, dedupes, skips nullish, and caches', async () => {
const resolver = vi.fn(async (id: string) => `Name-${id}`);
const c = useOwnerCache(resolver);
expect(c.name(null)).toBeNull();
expect(c.name('u1')).toBeNull();
expect(c.label('u1')).toBe('u1');
await c.resolve(['u1', 'u2', null, undefined, 'u1']);
expect(resolver).toHaveBeenCalledTimes(2);
expect(c.name('u1')).toBe('Name-u1');
expect(c.label('u2')).toBe('Name-u2');
expect(c.names.u1).toBe('Name-u1');
await c.resolve(['u1']); // already cached → no extra calls
expect(resolver).toHaveBeenCalledTimes(2);
});
@@ -0,0 +1,33 @@
import { describe, it, expect } from 'vitest';
import { Selection, useSelection } from './useSelection.svelte';
describe('Selection', () => {
it('adds idempotently and reports size/empty', () => {
const s = useSelection();
expect(s.isEmpty).toBe(true);
s.add('a');
s.add('a');
expect(s.size).toBe(1);
expect(s.has('a')).toBe(true);
});
it('toggles ids on and off', () => {
const s = new Selection();
s.toggle('b');
expect(s.has('b')).toBe(true);
s.toggle('b');
expect(s.has('b')).toBe(false);
});
it('deletes, replaces, and clears', () => {
const s = new Selection();
s.set(['x', 'y', 'z']);
expect(s.size).toBe(3);
expect(s.values().sort()).toEqual(['x', 'y', 'z']);
s.delete('y');
s.delete('missing');
expect(s.size).toBe(2);
s.clear();
expect(s.isEmpty).toBe(true);
s.clear(); // no-op when already empty
expect(s.size).toBe(0);
});
});
@@ -0,0 +1,31 @@
import { it, expect, vi, beforeEach } from 'vitest';
import { useVirtualWindow } from './useVirtualWindow.svelte';
beforeEach(() => {
vi.stubGlobal(
'ResizeObserver',
class {
observe() {}
unobserve() {}
disconnect() {}
}
);
vi.stubGlobal('requestAnimationFrame', (cb: FrameRequestCallback) => {
cb(0);
return 0;
});
});
it('starts at zero, observes a root, remeasures, and tears down', () => {
const vw = useVirtualWindow();
expect(vw.aboveBy).toBe(0);
expect(vw.viewportH).toBe(0);
const root = document.createElement('div');
document.body.appendChild(root);
const teardown = vw.observe(root);
expect(typeof teardown).toBe('function');
vw.remeasure();
teardown();
root.remove();
});
+68
View File
@@ -0,0 +1,68 @@
import { it, expect, beforeEach } from 'vitest';
import { dialogs, confirmDialog, promptDialog } from './dialogs.svelte';
beforeEach(() => {
// Drain any leftover dialog so each test starts clean.
while (dialogs.current && !dialogs.busy) dialogs.cancel();
});
it('confirm resolves true and prompt resolves its value', async () => {
const c = confirmDialog({ title: 'Confirm', message: 'ok?' });
expect(dialogs.current?.kind).toBe('confirm');
dialogs.resolve(true);
await expect(c).resolves.toBe(true);
const p = promptDialog({ title: 'Name' });
dialogs.resolve('hello');
await expect(p).resolves.toBe('hello');
});
it('cancel resolves confirm=false and prompt=null', async () => {
const c = confirmDialog({ title: 'Confirm', message: 'x' });
dialogs.cancel();
await expect(c).resolves.toBe(false);
const p = promptDialog({ title: 'y' });
dialogs.cancel();
await expect(p).resolves.toBeNull();
});
it('runs a successful action then resolves', async () => {
let ran = false;
const c = confirmDialog({
title: 'Confirm',
message: 'x',
action: async () => {
ran = true;
}
});
await dialogs.resolve(true);
expect(ran).toBe(true);
await expect(c).resolves.toBe(true);
});
it('keeps the dialog open with an inline error when the action fails', async () => {
const c = confirmDialog({
title: 'Confirm',
message: 'x',
action: async () => {
throw new Error('boom');
}
});
await dialogs.resolve(true);
expect(dialogs.error).toBe('boom');
expect(dialogs.current).not.toBeNull();
dialogs.cancel(); // recover
await expect(c).resolves.toBe(false);
});
it('queues a second dialog behind the first', async () => {
const p1 = confirmDialog({ title: 'Confirm', message: '1' });
const p2 = confirmDialog({ title: 'Confirm', message: '2' });
expect(dialogs.current?.opts.message).toBe('1');
dialogs.resolve(true);
await p1;
expect(dialogs.current?.opts.message).toBe('2');
dialogs.resolve(true);
await p2;
expect(dialogs.current).toBeNull();
});
+43
View File
@@ -0,0 +1,43 @@
import { it, expect } from 'vitest';
import { sizeBucket, dateBucket, typeLabel, ownerLabel, files } from './files.svelte';
it('buckets sizes into distinct human ranges', () => {
expect(sizeBucket(-1)).not.toBe(sizeBucket(0));
const buckets = [0, 500, 5_000_000, 500_000_000, 2_000_000_000, 10_000_000_000].map(sizeBucket);
// Each successive threshold lands in a different bucket label.
expect(new Set(buckets).size).toBe(buckets.length);
buckets.forEach((b) => expect(typeof b).toBe('string'));
});
it('buckets dates relative to now', () => {
expect(dateBucket(null)).toBeTruthy();
expect(dateBucket(Date.now())).toBe(dateBucket(Date.now()));
const old = new Date('2019-06-15').getTime();
expect(dateBucket(old)).toBe('2019');
});
it('labels a file category, defaulting when absent', () => {
expect(typeLabel(null)).toBeTruthy();
expect(typeof typeLabel('Image')).toBe('string');
});
it('shows the owner as "Me" for the current user and a short id otherwise', () => {
expect(ownerLabel(null, 'me')).toBe('');
expect(ownerLabel('me', 'me')).toBeTruthy();
expect(ownerLabel('abcdef123456', 'someone-else')).toBe('abcdef12');
});
it('persists the view mode and toggles selection', () => {
files.setViewMode('list');
expect(files.viewMode).toBe('list');
expect(localStorage.getItem('oxicloud_view_mode')).toBe('list');
files.setViewMode('grid');
expect(files.viewMode).toBe('grid');
files.clearSelection();
expect(files.selection.size).toBe(0);
files.toggleSelected('a');
expect(files.selection.has('a')).toBe(true);
files.toggleSelected('a');
expect(files.selection.has('a')).toBe(false);
});
+29
View File
@@ -0,0 +1,29 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { theme, setTheme } from './theme.svelte';
describe('theme store', () => {
beforeEach(() => {
localStorage.clear();
document.documentElement.removeAttribute('data-color-scheme');
});
it('sets light/dark, persists, and reflects on <html>', () => {
setTheme('light');
expect(theme.current).toBe('light');
expect(localStorage.getItem('oxicloud_theme')).toBe('light');
expect(document.documentElement.getAttribute('data-color-scheme')).toBe('light');
setTheme('dark');
expect(document.documentElement.getAttribute('data-color-scheme')).toBe('dark');
expect(localStorage.getItem('oxicloud_theme')).toBe('dark');
});
it('auto clears storage and removes the attribute', () => {
setTheme('dark');
setTheme('auto');
expect(theme.current).toBe('auto');
expect(localStorage.getItem('oxicloud_theme')).toBeNull();
expect(document.documentElement.hasAttribute('data-color-scheme')).toBe(false);
});
it('theme.set is an alias for setTheme', () => {
theme.set('light');
expect(theme.current).toBe('light');
});
});
+34
View File
@@ -0,0 +1,34 @@
import { describe, it, expect } from 'vitest';
import { iconNameFromClass, formatDate } from './display';
describe('iconNameFromClass', () => {
it('extracts the fa token without its prefix', () => {
expect(iconNameFromClass('fas fa-folder')).toBe('folder');
expect(iconNameFromClass('fa-file-pdf')).toBe('file-pdf');
});
it('skips fa-fw / fa-lg modifier tokens', () => {
expect(iconNameFromClass('fa-fw fa-image')).toBe('image');
expect(iconNameFromClass('fa-lg fa-music')).toBe('music');
});
it('falls back to "file" for missing or unrecognised input', () => {
expect(iconNameFromClass(null)).toBe('file');
expect(iconNameFromClass(undefined)).toBe('file');
expect(iconNameFromClass('')).toBe('file');
expect(iconNameFromClass('no-fa-token-here')).toBe('file');
});
});
describe('formatDate', () => {
it('formats epoch seconds and milliseconds', () => {
expect(formatDate(1_700_000_000)).toMatch(/\d{4}/); // seconds
expect(formatDate(1_700_000_000_000)).toMatch(/\d{4}/); // ms
});
it('formats ISO-8601 strings', () => {
expect(formatDate('2024-01-15')).toMatch(/2024/);
});
it('returns empty string for null/undefined/invalid', () => {
expect(formatDate(null)).toBe('');
expect(formatDate(undefined)).toBe('');
expect(formatDate('definitely not a date')).toBe('');
});
});
+30
View File
@@ -0,0 +1,30 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
vi.mock('$lib/stores/ui.svelte', () => ({ ui: { notify: vi.fn() } }));
import { ui } from '$lib/stores/ui.svelte';
import { errorMessage, errorToast } from './errors';
describe('errorMessage', () => {
it('returns the message of an Error', () => {
expect(errorMessage(new Error('boom'))).toBe('boom');
});
it('stringifies non-Error values', () => {
expect(errorMessage('nope')).toBe('nope');
expect(errorMessage(42)).toBe('42');
expect(errorMessage(null)).toBe('null');
expect(errorMessage(undefined)).toBe('undefined');
});
});
describe('errorToast', () => {
beforeEach(() => vi.clearAllMocks());
it('raises an error toast with the normalised message', () => {
errorToast(new Error('bad'));
expect(ui.notify).toHaveBeenCalledWith('bad', 'error');
});
it('handles non-Error values', () => {
errorToast('plain');
expect(ui.notify).toHaveBeenCalledWith('plain', 'error');
});
});
+35
View File
@@ -0,0 +1,35 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { gridColumns } from './grid';
function mockMatchMedia(matches: boolean) {
vi.stubGlobal(
'matchMedia',
vi.fn().mockReturnValue({
matches,
media: '',
addEventListener: vi.fn(),
removeEventListener: vi.fn()
})
);
}
describe('gridColumns', () => {
beforeEach(() => mockMatchMedia(false));
it('returns 1 for non-positive width', () => {
expect(gridColumns(0)).toBe(1);
expect(gridColumns(-100)).toBe(1);
});
it('computes columns at desktop sizing (cardMin 200, gap 20)', () => {
expect(gridColumns(220)).toBe(1); // floor(240/220)
expect(gridColumns(440)).toBe(2); // floor(460/220)
expect(gridColumns(900)).toBe(4); // floor(920/220)
});
it('uses mobile sizing when the phone media query matches', () => {
mockMatchMedia(true);
expect(gridColumns(300)).toBe(2); // floor(308/148)
expect(gridColumns(600)).toBe(4); // floor(608/148)
});
});
+20
View File
@@ -0,0 +1,20 @@
import { describe, it, expect } from 'vitest';
import { isVideo, photoTimestamp, minimalPhotoItem } from './media';
describe('media helpers', () => {
it('isVideo detects video mime types', () => {
expect(isVideo({ mime_type: 'video/mp4' } as never)).toBe(true);
expect(isVideo({ mime_type: 'image/png' } as never)).toBe(false);
expect(isVideo({} as never)).toBe(false);
});
it('photoTimestamp scales seconds to milliseconds', () => {
expect(photoTimestamp({ sort_date: 1000 } as never)).toBe(1_000_000);
expect(photoTimestamp({ sort_date: 2_000_000_000_000 } as never)).toBe(2_000_000_000_000);
expect(photoTimestamp({ created_at: 5 } as never)).toBe(5000);
expect(photoTimestamp({} as never)).toBe(0);
});
it('minimalPhotoItem stubs a FileItem from an id', () => {
const p = minimalPhotoItem('abc');
expect(p.id).toBe('abc');
expect(p.category).toBe('image');
});
});
+39
View File
@@ -0,0 +1,39 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { relativeTimeAgo } from './time';
describe('relativeTimeAgo', () => {
beforeEach(() => {
vi.useFakeTimers();
vi.setSystemTime(new Date('2024-06-15T12:00:00Z'));
});
afterEach(() => vi.useRealTimers());
it('returns the empty label for null/undefined/empty', () => {
expect(relativeTimeAgo(null)).toBe('');
expect(relativeTimeAgo(undefined)).toBe('');
expect(relativeTimeAgo('')).toBe('');
expect(relativeTimeAgo(null, { empty: 'never' })).toBe('never');
});
it('handles unparseable input', () => {
expect(relativeTimeAgo('garbage')).toBe('');
expect(relativeTimeAgo('garbage', { invalidAsString: true })).toBe('garbage');
});
it('formats past times in the largest matching unit', () => {
expect(relativeTimeAgo(Date.now() - 2 * 31_536_000_000)).toMatch(/year/);
expect(relativeTimeAgo(Date.now() - 2 * 2_592_000_000)).toMatch(/month/);
expect(relativeTimeAgo(Date.now() - 2 * 604_800_000)).toMatch(/week/);
expect(relativeTimeAgo(Date.now() - 3 * 86_400_000)).toMatch(/day/);
expect(relativeTimeAgo(Date.now() - 3_600_000)).toMatch(/hour/);
expect(relativeTimeAgo(Date.now() - 120_000)).toMatch(/minute/);
});
it('accepts epoch seconds as well as milliseconds', () => {
expect(relativeTimeAgo(Math.floor((Date.now() - 3_600_000) / 1000))).toMatch(/hour/);
});
it('falls back to seconds for very recent timestamps', () => {
expect(relativeTimeAgo(Date.now() - 5_000)).toMatch(/second/);
});
});
+291 -61
View File
@@ -914,27 +914,57 @@
<h1>{t('admin.title', 'Admin')}</h1> <h1>{t('admin.title', 'Admin')}</h1>
<div class="tabs" role="tablist"> <div class="tabs" role="tablist">
<button role="tab" aria-selected={tab === 'dashboard'} onclick={() => (tab = 'dashboard')}> <button
role="tab"
data-testid="admin-dashboard-tab"
aria-selected={tab === 'dashboard'}
onclick={() => (tab = 'dashboard')}
>
<Icon name="chart-pie" /> <Icon name="chart-pie" />
{t('admin.dashboard', 'Dashboard')} {t('admin.dashboard', 'Dashboard')}
</button> </button>
<button role="tab" aria-selected={tab === 'users'} onclick={() => (tab = 'users')}> <button
role="tab"
data-testid="admin-users-tab"
aria-selected={tab === 'users'}
onclick={() => (tab = 'users')}
>
<Icon name="users" /> <Icon name="users" />
{t('admin.users', 'Users')} {t('admin.users', 'Users')}
</button> </button>
<button role="tab" aria-selected={tab === 'oidc'} onclick={() => (tab = 'oidc')}> <button
role="tab"
data-testid="admin-oidc-tab"
aria-selected={tab === 'oidc'}
onclick={() => (tab = 'oidc')}
>
<Icon name="key" /> <Icon name="key" />
{t('admin.oidc', 'OIDC / SSO')} {t('admin.oidc', 'OIDC / SSO')}
</button> </button>
<button role="tab" aria-selected={tab === 'storage'} onclick={() => (tab = 'storage')}> <button
role="tab"
data-testid="admin-storage-tab"
aria-selected={tab === 'storage'}
onclick={() => (tab = 'storage')}
>
<Icon name="database" /> <Icon name="database" />
{t('admin.storage_tab', 'Storage')} {t('admin.storage_tab', 'Storage')}
</button> </button>
<button role="tab" aria-selected={tab === 'smtp'} onclick={() => (tab = 'smtp')}> <button
role="tab"
data-testid="admin-smtp-tab"
aria-selected={tab === 'smtp'}
onclick={() => (tab = 'smtp')}
>
<Icon name="envelope" /> <Icon name="envelope" />
{t('admin.smtp', 'Email (SMTP)')} {t('admin.smtp', 'Email (SMTP)')}
</button> </button>
<button role="tab" aria-selected={tab === 'plugins'} onclick={() => (tab = 'plugins')}> <button
role="tab"
data-testid="admin-plugins-tab"
aria-selected={tab === 'plugins'}
onclick={() => (tab = 'plugins')}
>
<Icon name="layer-group" /> <Icon name="layer-group" />
{t('admin.plugins', 'Plugins')} {t('admin.plugins', 'Plugins')}
</button> </button>
@@ -1031,6 +1061,7 @@
<label class="checkbox"> <label class="checkbox">
<input <input
type="checkbox" type="checkbox"
data-testid="admin-dashboard-registration-checkbox"
checked={dashboard.registration_enabled} checked={dashboard.registration_enabled}
onchange={(e) => toggleRegistration(e.currentTarget.checked)} onchange={(e) => toggleRegistration(e.currentTarget.checked)}
/> />
@@ -1106,9 +1137,17 @@
{#if !oidc} {#if !oidc}
<p class="status">{t('common.loading', 'Loading…')}</p> <p class="status">{t('common.loading', 'Loading…')}</p>
{:else} {:else}
<form class="form" onsubmit={(e) => (e.preventDefault(), doSaveOidc())}> <form
class="form"
data-testid="admin-oidc-form"
onsubmit={(e) => (e.preventDefault(), doSaveOidc())}
>
<label class="checkbox"> <label class="checkbox">
<input type="checkbox" bind:checked={oidc.enabled} /> <input
type="checkbox"
data-testid="admin-oidc-enabled-checkbox"
bind:checked={oidc.enabled}
/>
<span>{t('admin.oidc_enabled', 'Enable OIDC login')}</span> <span>{t('admin.oidc_enabled', 'Enable OIDC login')}</span>
</label> </label>
<label <label
@@ -1119,11 +1158,17 @@
> >
<input <input
bind:value={oidc.issuer_url} bind:value={oidc.issuer_url}
data-testid="admin-oidc-issuer-input"
placeholder="https://idp.example.com" placeholder="https://idp.example.com"
disabled={isEnvLocked(oidc.env_overrides, 'issuer_url')} disabled={isEnvLocked(oidc.env_overrides, 'issuer_url')}
/></label /></label
> >
<button type="button" class="btn btn-secondary" onclick={runOidcTest}> <button
type="button"
class="btn btn-secondary"
data-testid="admin-oidc-discover-btn"
onclick={runOidcTest}
>
<Icon name="search" /> <Icon name="search" />
{t('admin.oidc_discover', 'Test / discover')} {t('admin.oidc_discover', 'Test / discover')}
</button> </button>
@@ -1155,6 +1200,7 @@
> >
<input <input
bind:value={oidc.client_id} bind:value={oidc.client_id}
data-testid="admin-oidc-client-id-input"
disabled={isEnvLocked(oidc.env_overrides, 'client_id')} disabled={isEnvLocked(oidc.env_overrides, 'client_id')}
/></label /></label
> >
@@ -1166,6 +1212,7 @@
> >
<input <input
type="password" type="password"
data-testid="admin-oidc-client-secret-input"
bind:value={oidc.client_secret} bind:value={oidc.client_secret}
disabled={isEnvLocked(oidc.env_overrides, 'client_secret')} disabled={isEnvLocked(oidc.env_overrides, 'client_secret')}
placeholder={oidc.client_secret_set placeholder={oidc.client_secret_set
@@ -1187,6 +1234,7 @@
> >
<input <input
bind:value={oidc.scopes} bind:value={oidc.scopes}
data-testid="admin-oidc-scopes-input"
placeholder="openid profile email" placeholder="openid profile email"
disabled={isEnvLocked(oidc.env_overrides, 'scopes')} disabled={isEnvLocked(oidc.env_overrides, 'scopes')}
/></label /></label
@@ -1199,6 +1247,7 @@
> >
<input <input
bind:value={oidc.provider_name} bind:value={oidc.provider_name}
data-testid="admin-oidc-provider-name-input"
disabled={isEnvLocked(oidc.env_overrides, 'provider_name')} disabled={isEnvLocked(oidc.env_overrides, 'provider_name')}
/></label /></label
> >
@@ -1210,15 +1259,24 @@
> >
<input <input
bind:value={oidc.admin_groups} bind:value={oidc.admin_groups}
data-testid="admin-oidc-admin-groups-input"
disabled={isEnvLocked(oidc.env_overrides, 'admin_groups')} disabled={isEnvLocked(oidc.env_overrides, 'admin_groups')}
/></label /></label
> >
<label class="checkbox"> <label class="checkbox">
<input type="checkbox" bind:checked={oidc.auto_provision} /> <input
type="checkbox"
data-testid="admin-oidc-auto-provision-checkbox"
bind:checked={oidc.auto_provision}
/>
<span>{t('admin.oidc_auto_provision', 'Auto-provision users on first login')}</span> <span>{t('admin.oidc_auto_provision', 'Auto-provision users on first login')}</span>
</label> </label>
<label class="checkbox"> <label class="checkbox">
<input type="checkbox" bind:checked={oidc.disable_password_login} /> <input
type="checkbox"
data-testid="admin-oidc-disable-pw-checkbox"
bind:checked={oidc.disable_password_login}
/>
<span>{t('admin.oidc_disable_pw', 'Disable password login (OIDC only)')}</span> <span>{t('admin.oidc_disable_pw', 'Disable password login (OIDC only)')}</span>
</label> </label>
{#if oidc.callback_url} {#if oidc.callback_url}
@@ -1227,6 +1285,7 @@
<button <button
type="button" type="button"
class="btn btn-sm btn-secondary" class="btn btn-sm btn-secondary"
data-testid="admin-oidc-callback-copy-btn"
onclick={() => copyText(oidc?.callback_url ?? '')} onclick={() => copyText(oidc?.callback_url ?? '')}
> >
<Icon name="copy" /> <Icon name="copy" />
@@ -1237,7 +1296,12 @@
{#if oidcMsg}<p class={oidcMsg.ok ? 'status--ok' : 'status--error'}> {#if oidcMsg}<p class={oidcMsg.ok ? 'status--ok' : 'status--error'}>
{oidcMsg.text} {oidcMsg.text}
</p>{/if} </p>{/if}
<button class="btn btn-primary" type="submit" disabled={oidcSaving}> <button
class="btn btn-primary"
type="submit"
data-testid="admin-oidc-save-btn"
disabled={oidcSaving}
>
{t('common.save', 'Save')} {t('common.save', 'Save')}
</button> </button>
</form> </form>
@@ -1249,10 +1313,14 @@
{#if !storage} {#if !storage}
<p class="status">{t('common.loading', 'Loading…')}</p> <p class="status">{t('common.loading', 'Loading…')}</p>
{:else} {:else}
<form class="form" onsubmit={(e) => (e.preventDefault(), doSaveStorage())}> <form
class="form"
data-testid="admin-storage-form"
onsubmit={(e) => (e.preventDefault(), doSaveStorage())}
>
<label <label
><span>{t('admin.storage_backend', 'Backend')}</span> ><span>{t('admin.storage_backend', 'Backend')}</span>
<select bind:value={sForm.backend}> <select bind:value={sForm.backend} data-testid="admin-storage-backend-select">
<option value="local">local</option> <option value="local">local</option>
<option value="s3">S3</option> <option value="s3">S3</option>
</select></label </select></label
@@ -1260,7 +1328,11 @@
{#if sForm.backend === 's3'} {#if sForm.backend === 's3'}
<label <label
><span>{t('admin.storage_preset', 'Preset')}</span> ><span>{t('admin.storage_preset', 'Preset')}</span>
<select bind:value={sForm.preset} onchange={applyPreset}> <select
bind:value={sForm.preset}
data-testid="admin-storage-preset-select"
onchange={applyPreset}
>
{#each Object.keys(STORAGE_PRESETS) as p (p)}<option value={p}>{p}</option>{/each} {#each Object.keys(STORAGE_PRESETS) as p (p)}<option value={p}>{p}</option>{/each}
</select></label </select></label
> >
@@ -1272,6 +1344,7 @@
> >
<input <input
bind:value={sForm.endpoint} bind:value={sForm.endpoint}
data-testid="admin-storage-endpoint-input"
disabled={isEnvLocked(storage.env_overrides, 's3_endpoint_url')} disabled={isEnvLocked(storage.env_overrides, 's3_endpoint_url')}
/></label /></label
> >
@@ -1283,6 +1356,7 @@
> >
<input <input
bind:value={sForm.bucket} bind:value={sForm.bucket}
data-testid="admin-storage-bucket-input"
disabled={isEnvLocked(storage.env_overrides, 's3_bucket')} disabled={isEnvLocked(storage.env_overrides, 's3_bucket')}
/></label /></label
> >
@@ -1294,6 +1368,7 @@
> >
<input <input
bind:value={sForm.region} bind:value={sForm.region}
data-testid="admin-storage-region-input"
disabled={isEnvLocked(storage.env_overrides, 's3_region')} disabled={isEnvLocked(storage.env_overrides, 's3_region')}
/></label /></label
> >
@@ -1305,6 +1380,7 @@
> >
<input <input
bind:value={sForm.accessKey} bind:value={sForm.accessKey}
data-testid="admin-storage-access-key-input"
disabled={isEnvLocked(storage.env_overrides, 's3_access_key')} disabled={isEnvLocked(storage.env_overrides, 's3_access_key')}
placeholder={storage.s3_access_key_set placeholder={storage.s3_access_key_set
? t('admin.unchanged', 'Leave blank to keep current') ? t('admin.unchanged', 'Leave blank to keep current')
@@ -1319,6 +1395,7 @@
> >
<input <input
type="password" type="password"
data-testid="admin-storage-secret-key-input"
bind:value={sForm.secretKey} bind:value={sForm.secretKey}
disabled={isEnvLocked(storage.env_overrides, 's3_secret_key')} disabled={isEnvLocked(storage.env_overrides, 's3_secret_key')}
placeholder={storage.s3_secret_key_set placeholder={storage.s3_secret_key_set
@@ -1327,7 +1404,11 @@
/></label /></label
> >
<label class="checkbox"> <label class="checkbox">
<input type="checkbox" bind:checked={sForm.pathStyle} /> <input
type="checkbox"
data-testid="admin-storage-path-style-checkbox"
bind:checked={sForm.pathStyle}
/>
<span>{t('admin.storage_path_style', 'Force path-style URLs')}</span> <span>{t('admin.storage_path_style', 'Force path-style URLs')}</span>
</label> </label>
{/if} {/if}
@@ -1335,13 +1416,17 @@
{storageMsg.text} {storageMsg.text}
</p>{/if} </p>{/if}
<div class="smtp-test"> <div class="smtp-test">
<button class="btn btn-primary" type="submit" disabled={storageBusy} <button
>{t('common.save', 'Save')}</button class="btn btn-primary"
type="submit"
data-testid="admin-storage-save-btn"
disabled={storageBusy}>{t('common.save', 'Save')}</button
> >
{#if sForm.backend === 's3'} {#if sForm.backend === 's3'}
<button <button
type="button" type="button"
class="btn btn-secondary" class="btn btn-secondary"
data-testid="admin-storage-test-btn"
disabled={storageBusy} disabled={storageBusy}
onclick={doTestStorage} onclick={doTestStorage}
> >
@@ -1401,30 +1486,43 @@
<div class="smtp-test"> <div class="smtp-test">
<!-- Start: only when no migration is active (running/paused) or completed. --> <!-- Start: only when no migration is active (running/paused) or completed. -->
{#if migration.status !== 'running' && migration.status !== 'paused' && migration.status !== 'completed'} {#if migration.status !== 'running' && migration.status !== 'paused' && migration.status !== 'completed'}
<button class="btn btn-primary" onclick={() => doMigration('start')} <button
>{t('admin.mig_start', 'Start')}</button class="btn btn-primary"
data-testid="admin-migration-start-btn"
onclick={() => doMigration('start')}>{t('admin.mig_start', 'Start')}</button
> >
{/if} {/if}
{#if migration.status === 'running'} {#if migration.status === 'running'}
<button class="btn btn-secondary" onclick={() => doMigration('pause')} <button
>{t('admin.mig_pause', 'Pause')}</button class="btn btn-secondary"
data-testid="admin-migration-pause-btn"
onclick={() => doMigration('pause')}>{t('admin.mig_pause', 'Pause')}</button
> >
{/if} {/if}
{#if migration.status === 'paused'} {#if migration.status === 'paused'}
<button class="btn btn-primary" onclick={() => doMigration('resume')} <button
>{t('admin.mig_resume', 'Resume')}</button class="btn btn-primary"
data-testid="admin-migration-resume-btn"
onclick={() => doMigration('resume')}>{t('admin.mig_resume', 'Resume')}</button
> >
{/if} {/if}
<!-- Verify + Finalize: only once the copy phase has completed. --> <!-- Verify + Finalize: only once the copy phase has completed. -->
{#if migration.status === 'completed'} {#if migration.status === 'completed'}
<button class="btn btn-secondary" disabled={verifying} onclick={doVerify}> <button
class="btn btn-secondary"
data-testid="admin-migration-verify-btn"
disabled={verifying}
onclick={doVerify}
>
<Icon name="check-double" /> <Icon name="check-double" />
{verifying {verifying
? t('admin.mig_verifying', 'Verifying…') ? t('admin.mig_verifying', 'Verifying…')
: t('admin.mig_verify', 'Verify integrity')} : t('admin.mig_verify', 'Verify integrity')}
</button> </button>
<button class="btn btn-secondary" onclick={() => doMigration('complete')} <button
>{t('admin.mig_complete', 'Finalize')}</button class="btn btn-secondary"
data-testid="admin-migration-complete-btn"
onclick={() => doMigration('complete')}>{t('admin.mig_complete', 'Finalize')}</button
> >
{/if} {/if}
</div> </div>
@@ -1540,10 +1638,16 @@
<div class="smtp-test"> <div class="smtp-test">
<input <input
type="email" type="email"
data-testid="admin-smtp-to-input"
bind:value={smtpTo} bind:value={smtpTo}
placeholder={t('admin.smtp_to', 'recipient@example.com')} placeholder={t('admin.smtp_to', 'recipient@example.com')}
/> />
<button class="btn btn-primary" disabled={smtpSending} onclick={runSmtpTest}> <button
class="btn btn-primary"
data-testid="admin-smtp-send-btn"
disabled={smtpSending}
onclick={runSmtpTest}
>
<Icon name="paper-plane" /> <Icon name="paper-plane" />
{smtpSending ? t('admin.smtp_sending', 'Sending…') : t('admin.smtp_send', 'Send')} {smtpSending ? t('admin.smtp_sending', 'Sending…') : t('admin.smtp_send', 'Send')}
</button> </button>
@@ -1567,7 +1671,11 @@
</div> </div>
{:else if tab === 'users'} {:else if tab === 'users'}
<div class="bar"> <div class="bar">
<button class="btn btn--primary" onclick={() => (createOpen = true)}> <button
class="btn btn--primary"
data-testid="admin-users-create-btn"
onclick={() => (createOpen = true)}
>
<Icon name="user-plus" /> <Icon name="user-plus" />
{t('admin.create_user', 'Create user')} {t('admin.create_user', 'Create user')}
</button> </button>
@@ -1644,6 +1752,7 @@
<td class="actions"> <td class="actions">
<button <button
class="icon-btn" class="icon-btn"
data-testid={`admin-user-quota-${u.id}`}
title={t('admin.edit_quota_title', 'Edit quota')} title={t('admin.edit_quota_title', 'Edit quota')}
aria-label={t('admin.edit_quota_title', 'Edit quota')} aria-label={t('admin.edit_quota_title', 'Edit quota')}
onclick={() => openQuota(u)} onclick={() => openQuota(u)}
@@ -1653,6 +1762,7 @@
{#if !isOidcUser(u)} {#if !isOidcUser(u)}
<button <button
class="icon-btn" class="icon-btn"
data-testid={`admin-user-reset-password-${u.id}`}
title={t('admin.reset_password_title', 'Reset password')} title={t('admin.reset_password_title', 'Reset password')}
aria-label={t('admin.reset_password_title', 'Reset password')} aria-label={t('admin.reset_password_title', 'Reset password')}
onclick={() => openReset(u)} onclick={() => openReset(u)}
@@ -1662,6 +1772,7 @@
{/if} {/if}
<button <button
class="icon-btn" class="icon-btn"
data-testid={`admin-user-toggle-role-${u.id}`}
title={t('admin.toggle_role_title', 'Toggle admin role')} title={t('admin.toggle_role_title', 'Toggle admin role')}
aria-label={t('admin.toggle_role_title', 'Toggle admin role')} aria-label={t('admin.toggle_role_title', 'Toggle admin role')}
disabled={isSelf(u)} disabled={isSelf(u)}
@@ -1671,6 +1782,7 @@
</button> </button>
<button <button
class="icon-btn {u.active ? 'icon-btn--danger' : 'icon-btn--success'}" class="icon-btn {u.active ? 'icon-btn--danger' : 'icon-btn--success'}"
data-testid={`admin-user-toggle-active-${u.id}`}
title={u.active title={u.active
? t('admin.deactivate_title', 'Deactivate') ? t('admin.deactivate_title', 'Deactivate')
: t('admin.activate_title', 'Activate')} : t('admin.activate_title', 'Activate')}
@@ -1684,6 +1796,7 @@
</button> </button>
<button <button
class="icon-btn icon-btn--danger" class="icon-btn icon-btn--danger"
data-testid={`admin-user-delete-${u.id}`}
title={t('admin.delete_title', 'Delete user')} title={t('admin.delete_title', 'Delete user')}
aria-label={t('admin.delete_title', 'Delete user')} aria-label={t('admin.delete_title', 'Delete user')}
disabled={isSelf(u)} disabled={isSelf(u)}
@@ -1697,10 +1810,16 @@
</tbody> </tbody>
</table> </table>
<div class="pager"> <div class="pager">
<button class="btn" disabled={pageIndex === 0} onclick={() => changePage(-1)}>‹</button> <button
class="btn"
data-testid="admin-users-pager-prev-btn"
disabled={pageIndex === 0}
onclick={() => changePage(-1)}>‹</button
>
<span>{pageIndex + 1} / {Math.max(1, Math.ceil(total / PAGE_SIZE))}</span> <span>{pageIndex + 1} / {Math.max(1, Math.ceil(total / PAGE_SIZE))}</span>
<button <button
class="btn" class="btn"
data-testid="admin-users-pager-next-btn"
disabled={(pageIndex + 1) * PAGE_SIZE >= total} disabled={(pageIndex + 1) * PAGE_SIZE >= total}
onclick={() => changePage(1)}>›</button onclick={() => changePage(1)}>›</button
> >
@@ -1725,6 +1844,7 @@
: t('admin.plugins_upload', 'Upload .zip')} : t('admin.plugins_upload', 'Upload .zip')}
<input <input
type="file" type="file"
data-testid="admin-plugins-install-input"
accept=".zip,application/zip" accept=".zip,application/zip"
hidden hidden
disabled={installing} disabled={installing}
@@ -1775,6 +1895,7 @@
<td class="actions"> <td class="actions">
<button <button
class="icon-btn" class="icon-btn"
data-testid={`admin-plugin-details-${p.id}`}
title={t('admin.plugins_details', 'Logs & details')} title={t('admin.plugins_details', 'Logs & details')}
aria-label={t('admin.plugins_details', 'Logs & details')} aria-label={t('admin.plugins_details', 'Logs & details')}
onclick={() => openLogs(p)} onclick={() => openLogs(p)}
@@ -1783,6 +1904,7 @@
</button> </button>
<button <button
class="icon-btn {p.enabled ? '' : 'icon-btn--success'}" class="icon-btn {p.enabled ? '' : 'icon-btn--success'}"
data-testid={`admin-plugin-toggle-${p.id}`}
title={p.enabled ? t('admin.disable', 'Disable') : t('admin.enable', 'Enable')} title={p.enabled ? t('admin.disable', 'Disable') : t('admin.enable', 'Enable')}
aria-label={p.enabled aria-label={p.enabled
? t('admin.disable', 'Disable') ? t('admin.disable', 'Disable')
@@ -1793,6 +1915,7 @@
</button> </button>
<button <button
class="icon-btn icon-btn--danger" class="icon-btn icon-btn--danger"
data-testid={`admin-plugin-delete-${p.id}`}
title={t('common.delete', 'Delete')} title={t('common.delete', 'Delete')}
aria-label={t('common.delete', 'Delete')} aria-label={t('common.delete', 'Delete')}
onclick={() => removePlugin(p)} onclick={() => removePlugin(p)}
@@ -1809,10 +1932,20 @@
</main> </main>
<Modal bind:open={createOpen} title={t('admin.create_user', 'Create user')}> <Modal bind:open={createOpen} title={t('admin.create_user', 'Create user')}>
<form id="create-user-form" onsubmit={submitCreate} class="form"> <form
id="create-user-form"
data-testid="admin-create-user-form"
onsubmit={submitCreate}
class="form"
>
<label <label
><span>{t('admin.username', 'Username')}</span> ><span>{t('admin.username', 'Username')}</span>
<input bind:value={newUser.username} minlength="3" required /></label <input
bind:value={newUser.username}
data-testid="admin-create-user-username-input"
minlength="3"
required
/></label
> >
<label <label
><span ><span
@@ -1821,17 +1954,24 @@
> >
<input <input
type="email" type="email"
data-testid="admin-create-user-email-input"
bind:value={newUser.email} bind:value={newUser.email}
placeholder={t('admin.email_auto', 'Auto-generated if left blank')} placeholder={t('admin.email_auto', 'Auto-generated if left blank')}
/></label /></label
> >
<label <label
><span>{t('admin.password', 'Password')}</span> ><span>{t('admin.password', 'Password')}</span>
<input type="password" bind:value={newUser.password} minlength="8" required /></label <input
type="password"
data-testid="admin-create-user-password-input"
bind:value={newUser.password}
minlength="8"
required
/></label
> >
<label <label
><span>{t('admin.role', 'Role')}</span> ><span>{t('admin.role', 'Role')}</span>
<select bind:value={newUser.role}> <select bind:value={newUser.role} data-testid="admin-create-user-role-select">
<option value="user">user</option> <option value="user">user</option>
<option value="admin">admin</option> <option value="admin">admin</option>
</select></label </select></label
@@ -1839,8 +1979,14 @@
<label <label
><span>{t('admin.quota', 'Quota')}</span> ><span>{t('admin.quota', 'Quota')}</span>
<div class="quota-input"> <div class="quota-input">
<input type="number" min="0" step="0.1" bind:value={newUser.quotaValue} /> <input
<select bind:value={newUser.quotaUnit}> type="number"
data-testid="admin-create-user-quota-input"
min="0"
step="0.1"
bind:value={newUser.quotaValue}
/>
<select bind:value={newUser.quotaUnit} data-testid="admin-create-user-quota-unit-select">
{#each QUOTA_UNITS as unit (unit.label)}<option value={unit.value}>{unit.label}</option {#each QUOTA_UNITS as unit (unit.label)}<option value={unit.value}>{unit.label}</option
>{/each} >{/each}
</select> </select>
@@ -1850,8 +1996,18 @@
{#if createError}<p class="status--error">{createError}</p>{/if} {#if createError}<p class="status--error">{createError}</p>{/if}
</form> </form>
{#snippet footer()} {#snippet footer()}
<button class="btn" onclick={() => (createOpen = false)}>{t('common.cancel', 'Cancel')}</button> <button
<button class="btn btn--primary" type="submit" form="create-user-form" disabled={creating}> class="btn"
data-testid="admin-create-user-cancel-btn"
onclick={() => (createOpen = false)}>{t('common.cancel', 'Cancel')}</button
>
<button
class="btn btn--primary"
type="submit"
form="create-user-form"
data-testid="admin-create-user-submit-btn"
disabled={creating}
>
{creating ? t('admin.creating', 'Creating…') : t('common.create', 'Create')} {creating ? t('admin.creating', 'Creating…') : t('common.create', 'Create')}
</button> </button>
{/snippet} {/snippet}
@@ -1867,6 +2023,7 @@
<form <form
id="quota-form" id="quota-form"
class="form" class="form"
data-testid="admin-quota-form"
onsubmit={(e) => { onsubmit={(e) => {
e.preventDefault(); e.preventDefault();
void saveQuota(); void saveQuota();
@@ -1878,8 +2035,14 @@
<label <label
><span>{t('admin.quota', 'Quota')}</span> ><span>{t('admin.quota', 'Quota')}</span>
<div class="quota-input"> <div class="quota-input">
<input type="number" min="0" step="0.1" bind:value={quotaModal.value} /> <input
<select bind:value={quotaModal.unit}> type="number"
data-testid="admin-quota-value-input"
min="0"
step="0.1"
bind:value={quotaModal.value}
/>
<select bind:value={quotaModal.unit} data-testid="admin-quota-unit-select">
{#each QUOTA_UNITS as unit (unit.label)}<option value={unit.value}>{unit.label}</option {#each QUOTA_UNITS as unit (unit.label)}<option value={unit.value}>{unit.label}</option
>{/each} >{/each}
</select> </select>
@@ -1889,8 +2052,15 @@
</form> </form>
{/if} {/if}
{#snippet footer()} {#snippet footer()}
<button class="btn" onclick={() => (quotaModal = null)}>{t('common.cancel', 'Cancel')}</button> <button class="btn" data-testid="admin-quota-cancel-btn" onclick={() => (quotaModal = null)}
<button class="btn btn--primary" type="submit" form="quota-form"> >{t('common.cancel', 'Cancel')}</button
>
<button
class="btn btn--primary"
type="submit"
form="quota-form"
data-testid="admin-quota-save-btn"
>
{t('common.save', 'Save')} {t('common.save', 'Save')}
</button> </button>
{/snippet} {/snippet}
@@ -1903,20 +2073,41 @@
onclose={() => (resetModal = null)} onclose={() => (resetModal = null)}
> >
{#if resetModal} {#if resetModal}
<form id="reset-pw-form" class="form" onsubmit={submitReset}> <form
id="reset-pw-form"
class="form"
data-testid="admin-reset-password-form"
onsubmit={submitReset}
>
<p class="muted"> <p class="muted">
{t('admin.reset_pw_for', 'New password for')} <strong>{resetModal.username}</strong> {t('admin.reset_pw_for', 'New password for')} <strong>{resetModal.username}</strong>
</p> </p>
<label <label
><span>{t('admin.new_password', 'New password')}</span> ><span>{t('admin.new_password', 'New password')}</span>
<input type="password" bind:value={resetPassword} minlength="8" required /></label <input
type="password"
data-testid="admin-reset-password-input"
bind:value={resetPassword}
minlength="8"
required
/></label
> >
{#if resetError}<p class="status--error">{resetError}</p>{/if} {#if resetError}<p class="status--error">{resetError}</p>{/if}
</form> </form>
{/if} {/if}
{#snippet footer()} {#snippet footer()}
<button class="btn" onclick={() => (resetModal = null)}>{t('common.cancel', 'Cancel')}</button> <button
<button class="btn btn--primary" type="submit" form="reset-pw-form" disabled={resetting}> class="btn"
data-testid="admin-reset-password-cancel-btn"
onclick={() => (resetModal = null)}>{t('common.cancel', 'Cancel')}</button
>
<button
class="btn btn--primary"
type="submit"
form="reset-pw-form"
data-testid="admin-reset-password-submit-btn"
disabled={resetting}
>
{resetting ? t('admin.resetting', 'Resetting…') : t('admin.reset_btn', 'Reset')} {resetting ? t('admin.resetting', 'Resetting…') : t('admin.reset_btn', 'Reset')}
</button> </button>
{/snippet} {/snippet}
@@ -1930,9 +2121,14 @@
> >
<p>{confirmState?.message}</p> <p>{confirmState?.message}</p>
{#snippet footer()} {#snippet footer()}
<button class="btn" onclick={() => resolveConfirm(false)}>{t('common.cancel', 'Cancel')}</button <button class="btn" data-testid="admin-confirm-cancel-btn" onclick={() => resolveConfirm(false)}
>{t('common.cancel', 'Cancel')}</button
>
<button
class="btn btn--primary"
data-testid="admin-confirm-ok-btn"
onclick={() => resolveConfirm(true)}
> >
<button class="btn btn--primary" onclick={() => resolveConfirm(true)}>
{t('common.confirm', 'Confirm')} {t('common.confirm', 'Confirm')}
</button> </button>
{/snippet} {/snippet}
@@ -1972,25 +2168,43 @@
{/if} {/if}
{#if retention} {#if retention}
<form class="form retention-form" onsubmit={(e) => (e.preventDefault(), saveRetention())}> <form
class="form retention-form"
data-testid="admin-plugin-retention-form"
onsubmit={(e) => (e.preventDefault(), saveRetention())}
>
<h3>{t('admin.plugins_retention', 'Log retention')}</h3> <h3>{t('admin.plugins_retention', 'Log retention')}</h3>
<label <label
><span>{t('admin.plugins_retention_days', 'Keep for (days)')}</span> ><span>{t('admin.plugins_retention_days', 'Keep for (days)')}</span>
<input type="number" min="0" bind:value={retentionDays} /></label <input
type="number"
data-testid="admin-plugin-retention-days-input"
min="0"
bind:value={retentionDays}
/></label
> >
<label <label
><span>{t('admin.plugins_retention_max', 'Max size (MB)')}</span> ><span>{t('admin.plugins_retention_max', 'Max size (MB)')}</span>
<input type="number" min="0" bind:value={retentionMb} /></label <input
type="number"
data-testid="admin-plugin-retention-max-input"
min="0"
bind:value={retentionMb}
/></label
> >
{#if retentionMsg}<p class="muted">{retentionMsg}</p>{/if} {#if retentionMsg}<p class="muted">{retentionMsg}</p>{/if}
<button class="btn btn-secondary" type="submit" <button class="btn btn-secondary" type="submit" data-testid="admin-plugin-retention-save-btn"
>{t('admin.plugins_retention_save', 'Save retention')}</button >{t('admin.plugins_retention_save', 'Save retention')}</button
> >
</form> </form>
{/if} {/if}
<div class="logs-toolbar"> <div class="logs-toolbar">
<select bind:value={logsLevel} onchange={reloadLogsFromStart}> <select
bind:value={logsLevel}
data-testid="admin-plugin-logs-level-select"
onchange={reloadLogsFromStart}
>
<option value="">{t('admin.logs_all', 'All levels')}</option> <option value="">{t('admin.logs_all', 'All levels')}</option>
<option value="info">info</option> <option value="info">info</option>
<option value="warn">warn</option> <option value="warn">warn</option>
@@ -1998,14 +2212,22 @@
</select> </select>
<input <input
placeholder={t('admin.logs_search', 'Search…')} placeholder={t('admin.logs_search', 'Search…')}
data-testid="admin-plugin-logs-search-input"
bind:value={logsSearch} bind:value={logsSearch}
onkeydown={(e) => e.key === 'Enter' && reloadLogsFromStart()} onkeydown={(e) => e.key === 'Enter' && reloadLogsFromStart()}
/> />
<button class="btn btn-secondary" onclick={reloadLogsFromStart} <button
>{t('common.search', 'Search')}</button class="btn btn-secondary"
data-testid="admin-plugin-logs-search-btn"
onclick={reloadLogsFromStart}>{t('common.search', 'Search')}</button
> >
<label class="live-toggle"> <label class="live-toggle">
<input type="checkbox" bind:checked={logsLive} onchange={toggleLive} /> <input
type="checkbox"
data-testid="admin-plugin-logs-live-checkbox"
bind:checked={logsLive}
onchange={toggleLive}
/>
<span>{t('admin.logs_live', 'Live')}</span> <span>{t('admin.logs_live', 'Live')}</span>
</label> </label>
</div> </div>
@@ -2044,7 +2266,12 @@
</div> </div>
{/if} {/if}
<div class="pager logs-pager"> <div class="pager logs-pager">
<button class="btn" disabled={logsPage === 0} onclick={logsPrev}>‹</button> <button
class="btn"
data-testid="admin-plugin-logs-pager-prev-btn"
disabled={logsPage === 0}
onclick={logsPrev}>‹</button
>
<span> <span>
{#if logsTotal === 0} {#if logsTotal === 0}
{t('admin.logs_empty', 'No log entries.')} {t('admin.logs_empty', 'No log entries.')}
@@ -2060,15 +2287,18 @@
)} )}
{/if} {/if}
</span> </span>
<button class="btn" disabled={(logsPage + 1) * LOGS_PAGE_SIZE >= logsTotal} onclick={logsNext} <button
>›</button class="btn"
data-testid="admin-plugin-logs-pager-next-btn"
disabled={(logsPage + 1) * LOGS_PAGE_SIZE >= logsTotal}
onclick={logsNext}>›</button
> >
</div> </div>
{#snippet footer()} {#snippet footer()}
<button class="btn btn-danger" onclick={purgeLogs} <button class="btn btn-danger" data-testid="admin-plugin-logs-clear-btn" onclick={purgeLogs}
>{t('admin.plugins_clear_logs', 'Clear logs')}</button >{t('admin.plugins_clear_logs', 'Clear logs')}</button
> >
<button class="btn btn-secondary" onclick={closeLogs}> <button class="btn btn-secondary" data-testid="admin-plugin-logs-close-btn" onclick={closeLogs}>
{t('common.close', 'Close')} {t('common.close', 'Close')}
</button> </button>
{/snippet} {/snippet}
+212
View File
@@ -0,0 +1,212 @@
import { it, expect, vi, beforeEach } from 'vitest';
import { render, screen, fireEvent, waitFor } from '@testing-library/svelte';
const { session, ui } = vi.hoisted(() => ({
session: { user: { id: '1', username: 'admin', role: 'admin' } },
ui: { notify: vi.fn() }
}));
vi.mock('$lib/stores/session.svelte', () => ({ session }));
vi.mock('$lib/stores/ui.svelte', () => ({ ui }));
vi.mock('$lib/api/endpoints/admin', () => ({
clearPluginLogs: vi.fn(),
createUser: vi.fn(),
deletePlugin: vi.fn(),
deleteUser: vi.fn(),
getDashboard: vi.fn(),
getMigration: vi.fn(),
getOidcSettings: vi.fn(),
getPluginLogs: vi.fn(),
getPluginRetention: vi.fn(),
getSmtpInfo: vi.fn(),
getStorageSettings: vi.fn(),
installPlugin: vi.fn(),
listPlugins: vi.fn(),
listUsers: vi.fn(),
migrationAction: vi.fn(),
resetUserPassword: vi.fn(),
saveOidc: vi.fn(),
savePluginRetention: vi.fn(),
saveStorage: vi.fn(),
sendSmtpTest: vi.fn(),
setPluginEnabled: vi.fn(),
setRegistrationEnabled: vi.fn(),
setUserActive: vi.fn(),
setUserQuota: vi.fn(),
setUserRole: vi.fn(),
testOidc: vi.fn(),
testStorage: vi.fn(),
verifyMigration: vi.fn()
}));
import * as admin from '$lib/api/endpoints/admin';
import AdminPage from './+page.svelte';
const m = (fn: unknown) => fn as ReturnType<typeof vi.fn>;
const dashboard = {
total_users: 3,
active_users: 2,
admin_users: 1,
server_version: '1.0',
total_used_bytes: 100,
total_quota_bytes: 1000,
storage_usage_percent: 10,
auth_enabled: true,
oidc_configured: false,
quotas_enabled: true,
registration_enabled: true,
users_over_80_percent: 0,
users_over_quota: 0
};
const user = {
id: 'u1',
username: 'bob',
email: 'bob@x.test',
role: 'user',
active: true,
is_active: true,
storage_used_bytes: 10,
storage_quota_bytes: 100,
is_external: false
};
beforeEach(() => {
vi.clearAllMocks();
m(admin.getDashboard).mockResolvedValue(dashboard);
m(admin.listUsers).mockResolvedValue({ total: 1, users: [user] });
m(admin.listPlugins).mockResolvedValue({ available: true, enabled: true, plugins: [] });
m(admin.getOidcSettings).mockResolvedValue({
enabled: false,
issuer_url: '',
client_id: '',
scopes: null,
auto_provision: false,
admin_groups: null,
disable_password_login: false,
provider_name: null,
callback_url: 'http://localhost/callback',
client_secret_set: false,
env_overrides: []
});
m(admin.getStorageSettings).mockResolvedValue({ backend: 'filesystem', env_overrides: [] });
m(admin.getMigration).mockResolvedValue({
status: 'idle',
total_blobs: 0,
migrated_blobs: 0,
migrated_bytes: 0
});
m(admin.getSmtpInfo).mockResolvedValue({
enabled: false,
host: 'localhost',
port: 25,
tls: 'none',
from: 'a@x.test',
user_state: 'unset'
});
});
it('loads the dashboard on mount', async () => {
render(AdminPage);
await waitFor(() => expect(admin.getDashboard).toHaveBeenCalled());
});
it('toggles registration from the dashboard', async () => {
m(admin.setRegistrationEnabled).mockResolvedValue(undefined);
render(AdminPage);
const cb = await screen.findByTestId('admin-dashboard-registration-checkbox');
await fireEvent.click(cb);
await waitFor(() => expect(admin.setRegistrationEnabled).toHaveBeenCalled());
});
it('loads users when the users tab is opened and creates a user', async () => {
m(admin.createUser).mockResolvedValue(undefined);
render(AdminPage);
await fireEvent.click(await screen.findByTestId('admin-users-tab'));
await waitFor(() => expect(admin.listUsers).toHaveBeenCalled());
await fireEvent.click(await screen.findByTestId('admin-users-create-btn'));
await fireEvent.input(await screen.findByTestId('admin-create-user-username-input'), {
target: { value: 'newbie' }
});
await fireEvent.input(screen.getByTestId('admin-create-user-password-input'), {
target: { value: 'Password123!' }
});
await fireEvent.click(screen.getByTestId('admin-create-user-submit-btn'));
await waitFor(() => expect(admin.createUser).toHaveBeenCalled());
});
it('loads OIDC settings when the OIDC tab is opened', async () => {
render(AdminPage);
await fireEvent.click(await screen.findByTestId('admin-oidc-tab'));
await waitFor(() => expect(admin.getOidcSettings).toHaveBeenCalled());
});
it('loads storage + migration when the storage tab is opened', async () => {
render(AdminPage);
await fireEvent.click(await screen.findByTestId('admin-storage-tab'));
await waitFor(() => expect(admin.getStorageSettings).toHaveBeenCalled());
await waitFor(() => expect(admin.getMigration).toHaveBeenCalled());
});
it('loads SMTP info when the SMTP tab is opened', async () => {
render(AdminPage);
await fireEvent.click(await screen.findByTestId('admin-smtp-tab'));
await waitFor(() => expect(admin.getSmtpInfo).toHaveBeenCalled());
});
it('loads plugins when the plugins tab is opened', async () => {
render(AdminPage);
await fireEvent.click(await screen.findByTestId('admin-plugins-tab'));
await waitFor(() => expect(admin.listPlugins).toHaveBeenCalled());
});
it("toggles a user's role through the confirm modal", async () => {
m(admin.setUserRole).mockResolvedValue(undefined);
render(AdminPage);
await fireEvent.click(await screen.findByTestId('admin-users-tab'));
await fireEvent.click(await screen.findByTestId('admin-user-toggle-role-u1'));
await fireEvent.click(await screen.findByTestId('admin-confirm-ok-btn'));
await waitFor(() => expect(admin.setUserRole).toHaveBeenCalledWith('u1', 'admin'));
});
it('deactivates a user through the confirm modal', async () => {
m(admin.setUserActive).mockResolvedValue(undefined);
render(AdminPage);
await fireEvent.click(await screen.findByTestId('admin-users-tab'));
await fireEvent.click(await screen.findByTestId('admin-user-toggle-active-u1'));
await fireEvent.click(await screen.findByTestId('admin-confirm-ok-btn'));
await waitFor(() => expect(admin.setUserActive).toHaveBeenCalledWith('u1', false));
});
it('saves OIDC settings from the OIDC form', async () => {
m(admin.saveOidc).mockResolvedValue(undefined);
render(AdminPage);
await fireEvent.click(await screen.findByTestId('admin-oidc-tab'));
await fireEvent.input(await screen.findByTestId('admin-oidc-issuer-input'), {
target: { value: 'https://idp.test' }
});
await fireEvent.submit(await screen.findByTestId('admin-oidc-form'));
await waitFor(() => expect(admin.saveOidc).toHaveBeenCalled());
});
it('sends an SMTP test email', async () => {
m(admin.sendSmtpTest).mockResolvedValue({ ok: true } as never);
render(AdminPage);
await fireEvent.click(await screen.findByTestId('admin-smtp-tab'));
await fireEvent.input(await screen.findByTestId('admin-smtp-to-input'), {
target: { value: 'to@x.test' }
});
await fireEvent.click(await screen.findByTestId('admin-smtp-send-btn'));
await waitFor(() => expect(admin.sendSmtpTest).toHaveBeenCalledWith('to@x.test'));
});
it('saves storage settings and starts a migration', async () => {
m(admin.saveStorage).mockResolvedValue(undefined);
m(admin.migrationAction).mockResolvedValue(undefined);
render(AdminPage);
await fireEvent.click(await screen.findByTestId('admin-storage-tab'));
await fireEvent.submit(await screen.findByTestId('admin-storage-form'));
await waitFor(() => expect(admin.saveStorage).toHaveBeenCalled());
await fireEvent.click(await screen.findByTestId('admin-migration-start-btn'));
await waitFor(() => expect(admin.migrationAction).toHaveBeenCalledWith('start'));
});
+20 -5
View File
@@ -123,11 +123,12 @@
<h1>{t('device.title', 'Device verification')}</h1> <h1>{t('device.title', 'Device verification')}</h1>
{#if step === 'code'} {#if step === 'code'}
<form onsubmit={lookup}> <form data-testid="device-code-form" onsubmit={lookup}>
<label class="device__field"> <label class="device__field">
<span>{t('device.enter_code', 'Enter the code shown on your device')}</span> <span>{t('device.enter_code', 'Enter the code shown on your device')}</span>
<input <input
bind:this={codeInput} bind:this={codeInput}
data-testid="device-code-input"
value={code} value={code}
oninput={onCodeInput} oninput={onCodeInput}
autocomplete="off" autocomplete="off"
@@ -137,7 +138,9 @@
maxlength={FULL_CODE_LENGTH} maxlength={FULL_CODE_LENGTH}
/> />
</label> </label>
<button type="submit" disabled={!code}>{t('device.continue', 'Continue')}</button> <button type="submit" data-testid="device-continue-btn" disabled={!code}
>{t('device.continue', 'Continue')}</button
>
</form> </form>
{:else if step === 'loading'} {:else if step === 'loading'}
<p>{t('common.loading', 'Loading…')}</p> <p>{t('common.loading', 'Loading…')}</p>
@@ -149,10 +152,20 @@
<dd>{info?.scopes || 'all'}</dd> <dd>{info?.scopes || 'all'}</dd>
</dl> </dl>
<div class="device__actions"> <div class="device__actions">
<button class="device__deny" disabled={busy} onclick={() => decide('deny')}> <button
class="device__deny"
data-testid="device-deny-btn"
disabled={busy}
onclick={() => decide('deny')}
>
{t('device.deny', 'Deny')} {t('device.deny', 'Deny')}
</button> </button>
<button class="device__approve" disabled={busy} onclick={() => decide('approve')}> <button
class="device__approve"
data-testid="device-approve-btn"
disabled={busy}
onclick={() => decide('approve')}
>
{t('device.approve', 'Approve')} {t('device.approve', 'Approve')}
</button> </button>
</div> </div>
@@ -164,7 +177,9 @@
<p>{t('device.denied', 'Device access denied.')}</p> <p>{t('device.denied', 'Device access denied.')}</p>
{:else if step === 'error'} {:else if step === 'error'}
<p class="device__error" role="alert">{errorText}</p> <p class="device__error" role="alert">{errorText}</p>
<button onclick={backToCode}>{t('common.retry', 'Try again')}</button> <button data-testid="device-retry-btn" onclick={backToCode}
>{t('common.retry', 'Try again')}</button
>
{/if} {/if}
</div> </div>
</main> </main>
+60
View File
@@ -0,0 +1,60 @@
import { it, expect, vi, beforeEach } from 'vitest';
import { render, screen, fireEvent, waitFor } from '@testing-library/svelte';
const { pageState } = vi.hoisted(() => ({
pageState: { url: new URL('http://localhost/device') }
}));
vi.mock('$app/state', () => ({ page: pageState }));
vi.mock('$lib/api/endpoints/device', () => {
class DeviceLookupFailure extends Error {}
return {
DeviceLookupFailure,
decideDevice: vi.fn(),
lookupDeviceCode: vi.fn()
};
});
import { decideDevice, lookupDeviceCode } from '$lib/api/endpoints/device';
import DevicePage from './+page.svelte';
const m = (fn: unknown) => fn as ReturnType<typeof vi.fn>;
const info = { user_code: 'WXYZ-1234', client_name: 'CLI', scopes: ['files'] };
beforeEach(() => {
vi.clearAllMocks();
pageState.url = new URL('http://localhost/device');
});
it('looks up a typed code and shows the review step', async () => {
m(lookupDeviceCode).mockResolvedValue(info);
render(DevicePage);
await screen.findByTestId('device-code-form');
await fireEvent.input(screen.getByTestId('device-code-input'), {
target: { value: 'WXYZ-1234' }
});
await fireEvent.click(screen.getByTestId('device-continue-btn'));
await waitFor(() => expect(lookupDeviceCode).toHaveBeenCalledWith('WXYZ-1234'));
await screen.findByTestId('device-approve-btn');
});
it('approves a device after lookup', async () => {
m(lookupDeviceCode).mockResolvedValue(info);
m(decideDevice).mockResolvedValue(undefined);
render(DevicePage);
await fireEvent.input(await screen.findByTestId('device-code-input'), {
target: { value: 'WXYZ-1234' }
});
await fireEvent.click(screen.getByTestId('device-continue-btn'));
await fireEvent.click(await screen.findByTestId('device-approve-btn'));
await waitFor(() => expect(decideDevice).toHaveBeenCalled());
});
it('shows an error when lookup fails', async () => {
m(lookupDeviceCode).mockRejectedValue(new Error('nope'));
render(DevicePage);
await fireEvent.input(await screen.findByTestId('device-code-input'), {
target: { value: 'BAD' }
});
await fireEvent.click(screen.getByTestId('device-continue-btn'));
await screen.findByTestId('device-retry-btn');
});
+9 -3
View File
@@ -300,17 +300,23 @@
onselectionchange={(ids) => (selectedIds = ids)} onselectionchange={(ids) => (selectedIds = ids)}
> >
{#snippet batchToolbar()} {#snippet batchToolbar()}
<Button icon="download" onclick={batchDownload}>{t('common.download', 'Download')}</Button> <Button icon="download" data-testid="favorites-batch-download-btn" onclick={batchDownload}
>{t('common.download', 'Download')}</Button
>
<Button <Button
icon="arrows-alt" icon="arrows-alt"
data-testid="favorites-batch-move-btn"
onclick={() => { onclick={() => {
moveTarget = null; moveTarget = null;
moveItems = batchTargets(); moveItems = batchTargets();
moveOpen = true; moveOpen = true;
}}>{t('files.move', 'Move')}</Button }}>{t('files.move', 'Move')}</Button
> >
<Button variant="danger" icon="trash" onclick={batchDelete} <Button
>{t('common.delete', 'Delete')}</Button variant="danger"
icon="trash"
data-testid="favorites-batch-delete-btn"
onclick={batchDelete}>{t('common.delete', 'Delete')}</Button
> >
{/snippet} {/snippet}
</ResourceList> </ResourceList>
@@ -0,0 +1,92 @@
import { it, expect, vi, beforeEach } from 'vitest';
import { render, screen, fireEvent, waitFor } from '@testing-library/svelte';
const { confirmDialog, promptDialog } = vi.hoisted(() => ({
confirmDialog: vi.fn(),
promptDialog: vi.fn()
}));
vi.mock('$lib/api/endpoints/favorites', () => ({
fetchFavoritesPage: vi.fn(),
removeFavorite: vi.fn(),
resolveOwnerName: vi.fn(async () => 'me'),
sizeBucket: () => 'Small',
typeLabel: () => 'File'
}));
vi.mock('$lib/api/endpoints/files', () => ({
fileDownloadUrl: () => '/dl',
renameFile: vi.fn(),
deleteFile: vi.fn()
}));
vi.mock('$lib/api/endpoints/folders', () => ({ renameFolder: vi.fn(), deleteFolder: vi.fn() }));
vi.mock('$lib/stores/dialogs.svelte', () => ({ confirmDialog, promptDialog }));
import { fetchFavoritesPage, removeFavorite } from '$lib/api/endpoints/favorites';
import { deleteFile } from '$lib/api/endpoints/files';
import FavoritesPage from './+page.svelte';
const m = (fn: unknown) => fn as ReturnType<typeof vi.fn>;
function withOneFile() {
m(fetchFavoritesPage).mockResolvedValue({
items: [
{
resource_type: 'file',
favorited_at: '2024-01-01T00:00:00Z',
resource: {
category: 'Image',
created_at: 0,
icon_class: 'fa-file',
icon_special_class: '',
id: 'f1',
mime_type: 'image/png',
modified_at: 0,
name: 'photo.png',
owner_id: 'me',
folder_id: 'root',
path: '/photo.png',
size: 10,
size_formatted: '10 B',
sort_date: 0,
etag: 'e',
content_hash: 'h'
}
}
],
next_cursor: null
});
}
beforeEach(() => vi.clearAllMocks());
it('renders favorites returned by the API', async () => {
withOneFile();
render(FavoritesPage);
await waitFor(() => expect(fetchFavoritesPage).toHaveBeenCalled());
await waitFor(() => expect(screen.getByText('photo.png')).toBeTruthy());
});
it('renders an empty state when there are no favorites', async () => {
m(fetchFavoritesPage).mockResolvedValue({ items: [], next_cursor: null });
render(FavoritesPage);
await waitFor(() => expect(fetchFavoritesPage).toHaveBeenCalled());
});
it('unfavorites a row via the star button', async () => {
withOneFile();
m(removeFavorite).mockResolvedValue(undefined);
render(FavoritesPage);
await screen.findByText('photo.png');
await fireEvent.click(screen.getByTestId('resource-list-favorite-f1-btn'));
await waitFor(() => expect(removeFavorite).toHaveBeenCalledWith('file', 'f1'));
});
it('batch-deletes selected favorites after confirmation', async () => {
withOneFile();
confirmDialog.mockResolvedValue(true);
m(deleteFile).mockResolvedValue(undefined);
render(FavoritesPage);
await screen.findByText('photo.png');
await fireEvent.click(screen.getByTestId('resource-list-select-f1-checkbox'));
await fireEvent.click(await screen.findByTestId('favorites-batch-delete-btn'));
await waitFor(() => expect(deleteFile).toHaveBeenCalledWith('f1'));
});
@@ -1455,13 +1455,21 @@
> >
<div class="page-sticky-header"> <div class="page-sticky-header">
<!-- Hidden upload inputs stay mounted even while the batch bar is shown. --> <!-- Hidden upload inputs stay mounted even while the batch bar is shown. -->
<input bind:this={fileInput} type="file" multiple hidden onchange={onUpload} /> <input
bind:this={fileInput}
type="file"
multiple
hidden
data-testid="files-upload-file-input"
onchange={onUpload}
/>
<input <input
bind:this={folderInput} bind:this={folderInput}
type="file" type="file"
multiple multiple
hidden hidden
webkitdirectory webkitdirectory
data-testid="files-upload-folder-input"
onchange={onUploadFolder} onchange={onUploadFolder}
/> />
@@ -1474,12 +1482,13 @@
> >
{#snippet start()} {#snippet start()}
{#if selectedCount > 0} {#if selectedCount > 0}
<div class="action-buttons batch-selection-bar"> <div class="action-buttons batch-selection-bar" data-testid="files-batch-bar">
<div class="list-header-checkbox"> <div class="list-header-checkbox">
<button <button
class="batch-bar-close" class="batch-bar-close"
title={t('files.cancel_selection', 'Cancel selection')} title={t('files.cancel_selection', 'Cancel selection')}
aria-label={t('files.cancel_selection', 'Cancel selection')} aria-label={t('files.cancel_selection', 'Cancel selection')}
data-testid="files-batch-cancel-btn"
onclick={clearSelection} onclick={clearSelection}
> >
<Icon name="times" /> <Icon name="times" />
@@ -1493,22 +1502,34 @@
<button <button
class="batch-btn" class="batch-btn"
title={t('files.add_favorites', 'Add to favorites')} title={t('files.add_favorites', 'Add to favorites')}
data-testid="files-batch-favorite-btn"
onclick={() => void batchFavorites()} onclick={() => void batchFavorites()}
> >
<Icon name="star" /> <Icon name="star" />
<span>{t('files.add_favorites', 'Add to favorites')}</span> <span>{t('files.add_favorites', 'Add to favorites')}</span>
</button> </button>
<button class="batch-btn" title={t('files.move', 'Move')} onclick={batchMove}> <button
class="batch-btn"
title={t('files.move', 'Move')}
data-testid="files-batch-move-btn"
onclick={batchMove}
>
<Icon name="arrows-alt" /> <Icon name="arrows-alt" />
<span>{t('files.move', 'Move')}</span> <span>{t('files.move', 'Move')}</span>
</button> </button>
<button class="batch-btn" title={t('files.copy', 'Copy')} onclick={batchCopy}> <button
class="batch-btn"
title={t('files.copy', 'Copy')}
data-testid="files-batch-copy-btn"
onclick={batchCopy}
>
<Icon name="copy" /> <Icon name="copy" />
<span>{t('files.copy', 'Copy')}</span> <span>{t('files.copy', 'Copy')}</span>
</button> </button>
<button <button
class="batch-btn" class="batch-btn"
title={t('common.download', 'Download')} title={t('common.download', 'Download')}
data-testid="files-batch-download-btn"
onclick={() => void batchDownload()} onclick={() => void batchDownload()}
> >
<Icon name="download" /> <Icon name="download" />
@@ -1517,6 +1538,7 @@
<button <button
class="batch-btn batch-btn-danger" class="batch-btn batch-btn-danger"
title={t('common.delete', 'Delete')} title={t('common.delete', 'Delete')}
data-testid="files-batch-delete-btn"
onclick={batchDelete} onclick={batchDelete}
> >
<Icon name="trash" /> <Icon name="trash" />
@@ -1527,9 +1549,10 @@
</div> </div>
{:else} {:else}
<div class="action-buttons"> <div class="action-buttons">
<div class="upload-dropdown"> <div class="upload-dropdown" data-testid="files-upload-dropdown">
<button <button
class="btn btn-primary" class="btn btn-primary"
data-testid="files-upload-btn"
onclick={() => (uploadMenuOpen = !uploadMenuOpen)} onclick={() => (uploadMenuOpen = !uploadMenuOpen)}
disabled={uploading} disabled={uploading}
aria-haspopup="true" aria-haspopup="true"
@@ -1544,9 +1567,10 @@
<Icon name="caret-down" class="upload-caret" /> <Icon name="caret-down" class="upload-caret" />
</button> </button>
{#if uploadMenuOpen} {#if uploadMenuOpen}
<div class="upload-dropdown-menu"> <div class="upload-dropdown-menu" data-testid="files-upload-menu">
<button <button
class="upload-dropdown-item" class="upload-dropdown-item"
data-testid="files-upload-files-item"
onclick={() => { onclick={() => {
uploadMenuOpen = false; uploadMenuOpen = false;
fileInput?.click(); fileInput?.click();
@@ -1557,6 +1581,7 @@
</button> </button>
<button <button
class="upload-dropdown-item" class="upload-dropdown-item"
data-testid="files-upload-folder-item"
onclick={() => { onclick={() => {
uploadMenuOpen = false; uploadMenuOpen = false;
folderInput?.click(); folderInput?.click();
@@ -1568,7 +1593,11 @@
</div> </div>
{/if} {/if}
</div> </div>
<button class="btn btn-secondary" onclick={onNewFolder}> <button
class="btn btn-secondary"
data-testid="files-new-folder-btn"
onclick={onNewFolder}
>
<Icon name="folder-plus" class="icon-mr" /> <Icon name="folder-plus" class="icon-mr" />
<span>{t('actions.new_folder', 'New folder')}</span> <span>{t('actions.new_folder', 'New folder')}</span>
</button> </button>
@@ -1593,6 +1622,7 @@
class="breadcrumb-item breadcrumb-link" class="breadcrumb-item breadcrumb-link"
class:breadcrumb-home={i === 0} class:breadcrumb-home={i === 0}
title={i === 0 ? t('breadcrumb.home', 'Home') : undefined} title={i === 0 ? t('breadcrumb.home', 'Home') : undefined}
data-testid={i === 0 ? 'files-breadcrumb-home-link' : `files-breadcrumb-${c.id}`}
ondragover={(e) => e.dataTransfer?.types.includes(DRAG_TYPE) && e.preventDefault()} ondragover={(e) => e.dataTransfer?.types.includes(DRAG_TYPE) && e.preventDefault()}
ondrop={(e) => onCrumbDrop(e, c.id)} ondrop={(e) => onCrumbDrop(e, c.id)}
> >
@@ -1655,6 +1685,7 @@
<input <input
type="checkbox" type="checkbox"
aria-label={t('files.select_all', 'Select all')} aria-label={t('files.select_all', 'Select all')}
data-testid="files-select-all-checkbox"
checked={selectedCount > 0 && selectedCount === totalCount} checked={selectedCount > 0 && selectedCount === totalCount}
indeterminate={selectedCount > 0 && selectedCount < totalCount} indeterminate={selectedCount > 0 && selectedCount < totalCount}
onchange={toggleSelectAll} onchange={toggleSelectAll}
@@ -1668,6 +1699,7 @@
class="list-header-sort" class="list-header-sort"
class:is-active={sortField === col.f} class:is-active={sortField === col.f}
data-sort-field={col.f} data-sort-field={col.f}
data-testid={`files-sort-${col.f}-btn`}
onclick={() => toggleSort(col.f as SortField)} onclick={() => toggleSort(col.f as SortField)}
> >
{col.l} {col.l}
@@ -1700,6 +1732,8 @@
role="button" role="button"
tabindex="0" tabindex="0"
draggable="true" draggable="true"
aria-label={folder.name}
data-testid={folder.name}
ondragstart={(e) => onItemDragStart(e, 'folder', folder.id, folder.name)} ondragstart={(e) => onItemDragStart(e, 'folder', folder.id, folder.name)}
ondragover={(e) => { ondragover={(e) => {
if (e.dataTransfer?.types.includes(DRAG_TYPE)) { if (e.dataTransfer?.types.includes(DRAG_TYPE)) {
@@ -1723,6 +1757,7 @@
type="checkbox" type="checkbox"
checked={selected.has(folder.id)} checked={selected.has(folder.id)}
aria-label={folder.name} aria-label={folder.name}
data-testid={`files-folder-checkbox-${folder.id}`}
onclick={(e) => { onclick={(e) => {
e.stopPropagation(); e.stopPropagation();
toggleSelected(folder.id); toggleSelected(folder.id);
@@ -1760,6 +1795,7 @@
? t('files.unfavorite', 'Remove favorite') ? t('files.unfavorite', 'Remove favorite')
: t('files.favorite', 'Add favorite')} : t('files.favorite', 'Add favorite')}
aria-pressed={favoriteIds.has(folder.id)} aria-pressed={favoriteIds.has(folder.id)}
data-testid={`files-folder-favorite-${folder.id}`}
onclick={(e) => { onclick={(e) => {
e.stopPropagation(); e.stopPropagation();
void toggleFavorite('folder', folder.id); void toggleFavorite('folder', folder.id);
@@ -1768,6 +1804,7 @@
<button <button
class="btn-action" class="btn-action"
title={t('files.share', 'Share')} title={t('files.share', 'Share')}
data-testid={`files-folder-share-${folder.id}`}
onclick={(e) => { onclick={(e) => {
e.stopPropagation(); e.stopPropagation();
openShare('folder', folder.id, folder.name); openShare('folder', folder.id, folder.name);
@@ -1776,6 +1813,7 @@
<button <button
class="btn-action" class="btn-action"
title={t('files.move', 'Move')} title={t('files.move', 'Move')}
data-testid={`files-folder-move-${folder.id}`}
onclick={(e) => { onclick={(e) => {
e.stopPropagation(); e.stopPropagation();
openMove('folder', folder.id, folder.name); openMove('folder', folder.id, folder.name);
@@ -1784,6 +1822,7 @@
<button <button
class="btn-action" class="btn-action"
title={t('common.rename', 'Rename')} title={t('common.rename', 'Rename')}
data-testid={`files-folder-rename-${folder.id}`}
onclick={(e) => { onclick={(e) => {
e.stopPropagation(); e.stopPropagation();
renameItem('folder', folder.id, folder.name); renameItem('folder', folder.id, folder.name);
@@ -1792,6 +1831,7 @@
<button <button
class="btn-action btn-action--delete" class="btn-action btn-action--delete"
title={t('common.delete', 'Delete')} title={t('common.delete', 'Delete')}
data-testid={`files-folder-delete-${folder.id}`}
onclick={(e) => { onclick={(e) => {
e.stopPropagation(); e.stopPropagation();
deleteItem('folder', folder.id, folder.name); deleteItem('folder', folder.id, folder.name);
@@ -1802,6 +1842,7 @@
title={t('files.more_actions', 'More actions')} title={t('files.more_actions', 'More actions')}
aria-label={t('files.more_actions', 'More actions')} aria-label={t('files.more_actions', 'More actions')}
aria-haspopup="menu" aria-haspopup="menu"
data-testid={`files-folder-more-${folder.id}`}
onclick={(e) => openContext(e, 'folder', folder.id, folder.name)} onclick={(e) => openContext(e, 'folder', folder.id, folder.name)}
><Icon name="ellipsis-v" /></button ><Icon name="ellipsis-v" /></button
> >
@@ -1817,6 +1858,8 @@
role="button" role="button"
tabindex="0" tabindex="0"
draggable="true" draggable="true"
aria-label={file.name}
data-testid={file.name}
ondragstart={(e) => onItemDragStart(e, 'file', file.id, file.name)} ondragstart={(e) => onItemDragStart(e, 'file', file.id, file.name)}
ondblclick={() => openFile(file)} ondblclick={() => openFile(file)}
onclick={(e) => { onclick={(e) => {
@@ -1830,6 +1873,7 @@
type="checkbox" type="checkbox"
checked={selected.has(file.id)} checked={selected.has(file.id)}
aria-label={file.name} aria-label={file.name}
data-testid={`files-file-checkbox-${file.id}`}
onclick={(e) => { onclick={(e) => {
e.stopPropagation(); e.stopPropagation();
toggleSelected(file.id); toggleSelected(file.id);
@@ -1881,6 +1925,7 @@
? t('files.unfavorite', 'Remove favorite') ? t('files.unfavorite', 'Remove favorite')
: t('files.favorite', 'Add favorite')} : t('files.favorite', 'Add favorite')}
aria-pressed={favoriteIds.has(file.id)} aria-pressed={favoriteIds.has(file.id)}
data-testid={`files-file-favorite-${file.id}`}
onclick={(e) => { onclick={(e) => {
e.stopPropagation(); e.stopPropagation();
void toggleFavorite('file', file.id); void toggleFavorite('file', file.id);
@@ -1889,6 +1934,7 @@
<button <button
class="btn-action" class="btn-action"
title={t('files.share', 'Share')} title={t('files.share', 'Share')}
data-testid={`files-file-share-${file.id}`}
onclick={(e) => { onclick={(e) => {
e.stopPropagation(); e.stopPropagation();
openShare('file', file.id, file.name); openShare('file', file.id, file.name);
@@ -1897,6 +1943,7 @@
<button <button
class="btn-action" class="btn-action"
title={t('files.move', 'Move')} title={t('files.move', 'Move')}
data-testid={`files-file-move-${file.id}`}
onclick={(e) => { onclick={(e) => {
e.stopPropagation(); e.stopPropagation();
openMove('file', file.id, file.name); openMove('file', file.id, file.name);
@@ -1908,11 +1955,13 @@
rel="external" rel="external"
download download
title={t('common.download', 'Download')} title={t('common.download', 'Download')}
data-testid={`files-file-download-${file.id}`}
onclick={(e) => e.stopPropagation()}><Icon name="download" /></a onclick={(e) => e.stopPropagation()}><Icon name="download" /></a
> >
<button <button
class="btn-action" class="btn-action"
title={t('common.rename', 'Rename')} title={t('common.rename', 'Rename')}
data-testid={`files-file-rename-${file.id}`}
onclick={(e) => { onclick={(e) => {
e.stopPropagation(); e.stopPropagation();
renameItem('file', file.id, file.name); renameItem('file', file.id, file.name);
@@ -1921,6 +1970,7 @@
<button <button
class="btn-action btn-action--delete" class="btn-action btn-action--delete"
title={t('common.delete', 'Delete')} title={t('common.delete', 'Delete')}
data-testid={`files-file-delete-${file.id}`}
onclick={(e) => { onclick={(e) => {
e.stopPropagation(); e.stopPropagation();
deleteItem('file', file.id, file.name); deleteItem('file', file.id, file.name);
@@ -1931,6 +1981,7 @@
title={t('files.more_actions', 'More actions')} title={t('files.more_actions', 'More actions')}
aria-label={t('files.more_actions', 'More actions')} aria-label={t('files.more_actions', 'More actions')}
aria-haspopup="menu" aria-haspopup="menu"
data-testid={`files-file-more-${file.id}`}
onclick={(e) => openContext(e, 'file', file.id, file.name)} onclick={(e) => openContext(e, 'file', file.id, file.name)}
><Icon name="ellipsis-v" /></button ><Icon name="ellipsis-v" /></button
> >
@@ -1977,14 +2028,22 @@
<div <div
class="ctx-scrim" class="ctx-scrim"
role="presentation" role="presentation"
data-testid="files-context-menu-scrim"
onclick={closeContext} onclick={closeContext}
oncontextmenu={(e) => e.preventDefault()} oncontextmenu={(e) => e.preventDefault()}
></div> ></div>
<div class="ctx-menu" style:left="{ctxX}px" style:top="{ctxY}px" role="menu"> <div
class="ctx-menu"
style:left="{ctxX}px"
style:top="{ctxY}px"
role="menu"
data-testid="files-context-menu"
>
{#if ctxTarget.kind === 'folder'} {#if ctxTarget.kind === 'folder'}
<button <button
class="ctx-item" class="ctx-item"
role="menuitem" role="menuitem"
data-testid="files-ctx-folder-open-item"
onclick={() => { onclick={() => {
const id = ctxTarget!.id; const id = ctxTarget!.id;
closeContext(); closeContext();
@@ -1994,6 +2053,7 @@
<button <button
class="ctx-item" class="ctx-item"
role="menuitem" role="menuitem"
data-testid="files-ctx-download-zip-item"
onclick={() => { onclick={() => {
const tg = ctxTarget!; const tg = ctxTarget!;
closeContext(); closeContext();
@@ -2004,6 +2064,7 @@
<button <button
class="ctx-item" class="ctx-item"
role="menuitem" role="menuitem"
data-testid="files-ctx-file-open-item"
onclick={() => { onclick={() => {
const f = listing.files.find((x) => x.id === ctxTarget!.id); const f = listing.files.find((x) => x.id === ctxTarget!.id);
closeContext(); closeContext();
@@ -2014,6 +2075,7 @@
<button <button
class="ctx-item" class="ctx-item"
role="menuitem" role="menuitem"
data-testid="files-ctx-edit-item"
onclick={() => { onclick={() => {
const tg = ctxTarget!; const tg = ctxTarget!;
closeContext(); closeContext();
@@ -2023,6 +2085,7 @@
<button <button
class="ctx-item" class="ctx-item"
role="menuitem" role="menuitem"
data-testid="files-ctx-edit-new-tab-item"
onclick={() => { onclick={() => {
const tg = ctxTarget!; const tg = ctxTarget!;
closeContext(); closeContext();
@@ -2036,11 +2099,13 @@
href={fileDownloadUrl(ctxTarget.id)} href={fileDownloadUrl(ctxTarget.id)}
rel="external" rel="external"
download download
data-testid="files-ctx-download-item"
onclick={closeContext}><Icon name="download" /> {t('common.download', 'Download')}</a onclick={closeContext}><Icon name="download" /> {t('common.download', 'Download')}</a
> >
<button <button
class="ctx-item" class="ctx-item"
role="menuitem" role="menuitem"
data-testid="files-ctx-open-parent-item"
onclick={() => { onclick={() => {
const f = listing.files.find((x) => x.id === ctxTarget!.id); const f = listing.files.find((x) => x.id === ctxTarget!.id);
closeContext(); closeContext();
@@ -2051,6 +2116,7 @@
<button <button
class="ctx-item" class="ctx-item"
role="menuitem" role="menuitem"
data-testid="files-ctx-add-playlist-item"
onclick={() => { onclick={() => {
const f = listing.files.find((x) => x.id === ctxTarget!.id); const f = listing.files.find((x) => x.id === ctxTarget!.id);
closeContext(); closeContext();
@@ -2062,6 +2128,7 @@
<button <button
class="ctx-item" class="ctx-item"
role="menuitem" role="menuitem"
data-testid="files-ctx-share-item"
onclick={() => { onclick={() => {
const tg = ctxTarget!; const tg = ctxTarget!;
closeContext(); closeContext();
@@ -2071,6 +2138,7 @@
<button <button
class="ctx-item" class="ctx-item"
role="menuitem" role="menuitem"
data-testid="files-ctx-move-item"
onclick={() => { onclick={() => {
const tg = ctxTarget!; const tg = ctxTarget!;
closeContext(); closeContext();
@@ -2080,6 +2148,7 @@
<button <button
class="ctx-item" class="ctx-item"
role="menuitem" role="menuitem"
data-testid="files-ctx-copy-item"
onclick={() => { onclick={() => {
const tg = ctxTarget!; const tg = ctxTarget!;
closeContext(); closeContext();
@@ -2089,6 +2158,7 @@
<button <button
class="ctx-item" class="ctx-item"
role="menuitem" role="menuitem"
data-testid="files-ctx-favorite-item"
onclick={() => { onclick={() => {
const tg = ctxTarget!; const tg = ctxTarget!;
closeContext(); closeContext();
@@ -2103,6 +2173,7 @@
<button <button
class="ctx-item" class="ctx-item"
role="menuitem" role="menuitem"
data-testid="files-ctx-rename-item"
onclick={() => { onclick={() => {
const tg = ctxTarget!; const tg = ctxTarget!;
closeContext(); closeContext();
@@ -2112,6 +2183,7 @@
<button <button
class="ctx-item ctx-item--danger" class="ctx-item ctx-item--danger"
role="menuitem" role="menuitem"
data-testid="files-ctx-delete-item"
onclick={() => { onclick={() => {
const tg = ctxTarget!; const tg = ctxTarget!;
closeContext(); closeContext();
+195
View File
@@ -0,0 +1,195 @@
import { it, expect, vi, beforeEach } from 'vitest';
import { render, screen, fireEvent, waitFor } from '@testing-library/svelte';
const { goto, pageState, session, ui, confirmDialog, promptDialog } = vi.hoisted(() => ({
goto: vi.fn(),
pageState: { params: { path: '' }, url: new URL('http://localhost/files') } as {
params: { path: string };
url: URL;
},
session: {
user: { id: 'me', username: 'admin', is_external: false },
isExternalUser: false,
loadHomeFolder: vi.fn(async () => 'home'),
refresh: vi.fn(async () => {})
},
ui: { notify: vi.fn() },
confirmDialog: vi.fn(),
promptDialog: vi.fn()
}));
vi.mock('$app/navigation', () => ({ goto }));
vi.mock('$app/state', () => ({ page: pageState }));
vi.mock('$lib/stores/session.svelte', () => ({ session }));
vi.mock('$lib/stores/ui.svelte', () => ({ ui }));
vi.mock('$lib/stores/dialogs.svelte', () => ({ confirmDialog, promptDialog }));
vi.mock('$lib/api/client', () => ({ apiFetch: vi.fn() }));
vi.mock('$lib/api/csrf', () => ({ getCsrfHeaders: () => ({}) }));
vi.mock('$lib/api/endpoints/deltaUpload', () => ({ tryDeltaUpload: vi.fn() }));
vi.mock('$lib/api/endpoints/favorites', () => ({ addFavorite: vi.fn(), removeFavorite: vi.fn() }));
vi.mock('$lib/api/endpoints/wopi', () => ({
canEditWithWopi: () => false,
getEditorUrlWithFallback: vi.fn()
}));
vi.mock('$lib/api/endpoints/music', () => ({
addTracks: vi.fn(),
createPlaylist: vi.fn(),
listPlaylists: vi.fn(async () => [])
}));
vi.mock('$lib/api/endpoints/files', () => ({
deleteFile: vi.fn(),
fileDownloadUrl: () => '/dl',
fileThumbnailUrl: () => '/thumb',
moveFile: vi.fn(),
renameFile: vi.fn(),
uploadFile: vi.fn(),
uploadFileWithProgress: vi.fn()
}));
vi.mock('$lib/api/endpoints/folders', () => ({
cacheFolder: vi.fn(),
createFolder: vi.fn(),
deleteFolder: vi.fn(),
fetchFolderListing: vi.fn(),
folderZipUrl: () => '/zip',
getCachedFolder: () => undefined,
getFolder: vi.fn(async (id: string) => ({ id, name: id })),
getFolderName: () => undefined,
invalidateFolderCache: vi.fn(),
moveFolder: vi.fn(),
rememberFolderName: vi.fn(),
renameFolder: vi.fn()
}));
import { fetchFolderListing, createFolder, deleteFolder } from '$lib/api/endpoints/folders';
import { deleteFile } from '$lib/api/endpoints/files';
import { apiFetch } from '$lib/api/client';
import { files as filesStore } from '$lib/stores/files.svelte';
import FilesPage from './[...path]/+page.svelte';
const m = (fn: unknown) => fn as ReturnType<typeof vi.fn>;
function withListing() {
m(fetchFolderListing).mockResolvedValue({
status: 200,
etag: 'v1',
listing: {
folders: [folderItem('sub1', 'Sub')],
files: [fileItem('f1', 'hello.txt')],
favoriteIds: [],
sharedIds: []
}
});
}
function fileItem(id: string, name: string) {
return {
category: 'Document',
created_at: 0,
icon_class: 'fa-file',
icon_special_class: '',
id,
mime_type: 'text/plain',
modified_at: 0,
name,
owner_id: 'me',
folder_id: 'home',
path: '/' + name,
size: 4,
size_formatted: '4 B',
sort_date: 0,
etag: 'e',
content_hash: 'h'
};
}
function folderItem(id: string, name: string) {
return {
category: 'Folder',
created_at: 0,
icon_class: 'fa-folder',
icon_special_class: '',
id,
is_root: false,
modified_at: 0,
name,
owner_id: 'me',
parent_id: 'home',
path: '/' + name,
etag: 'e'
};
}
beforeEach(() => {
vi.clearAllMocks();
// A concrete folder in the path: bare `/files` now canonicalizes to
// `/files/<drive-root>` via goto (see the external-user test), so the
// listing-oriented tests target a folder directly.
pageState.params.path = 'home';
// List view renders the select-all header + per-row checkboxes; grid hides them.
filesStore.viewMode = 'list';
});
it('loads the home folder listing on mount and renders its contents', async () => {
m(fetchFolderListing).mockResolvedValue({
status: 200,
etag: 'v1',
listing: {
folders: [folderItem('sub1', 'Sub')],
files: [fileItem('f1', 'hello.txt')],
favoriteIds: [],
sharedIds: []
}
});
render(FilesPage);
await waitFor(() => expect(fetchFolderListing).toHaveBeenCalledWith('home', expect.anything()));
// VirtualList windows rows by viewport height (0 in jsdom), so assert the
// surrounding chrome rendered rather than the windowed rows themselves.
await screen.findByTestId('files-new-folder-btn');
});
it('shows an error when the listing fails with no cache', async () => {
m(fetchFolderListing).mockRejectedValue(Object.assign(new Error('nope'), { status: 500 }));
render(FilesPage);
await waitFor(() => expect(fetchFolderListing).toHaveBeenCalled());
});
it('redirects external users away from the home folder', async () => {
session.isExternalUser = true;
pageState.params.path = '';
render(FilesPage);
await waitFor(() => expect(goto).toHaveBeenCalledWith('/shared-with-me', { replaceState: true }));
session.isExternalUser = false;
});
it('creates a new folder in the current directory', async () => {
withListing();
promptDialog.mockResolvedValue('Reports');
m(createFolder).mockResolvedValue({ id: 'new', name: 'Reports' });
render(FilesPage);
await fireEvent.click(await screen.findByTestId('files-new-folder-btn'));
await waitFor(() => expect(createFolder).toHaveBeenCalledWith('Reports', 'home'));
});
it('batch-deletes the whole selection after confirmation', async () => {
withListing();
confirmDialog.mockResolvedValue(true);
m(deleteFolder).mockResolvedValue(undefined);
m(deleteFile).mockResolvedValue(undefined);
render(FilesPage);
await fireEvent.click(await screen.findByTestId('files-select-all-checkbox'));
await fireEvent.click(await screen.findByTestId('files-batch-delete-btn'));
await waitFor(() => expect(deleteFolder).toHaveBeenCalledWith('sub1'));
await waitFor(() => expect(deleteFile).toHaveBeenCalledWith('f1'));
});
it('batch-favorites the selection via the favorites batch endpoint', async () => {
withListing();
m(apiFetch).mockResolvedValue({ ok: true });
render(FilesPage);
await fireEvent.click(await screen.findByTestId('files-select-all-checkbox'));
await fireEvent.click(await screen.findByTestId('files-batch-favorite-btn'));
await waitFor(() =>
expect(apiFetch).toHaveBeenCalledWith(
'/api/favorites/batch',
expect.objectContaining({ method: 'POST' })
)
);
});
+31 -9
View File
@@ -231,7 +231,8 @@
<main class="groups"> <main class="groups">
<header class="groups__head"> <header class="groups__head">
<h1>{t('nav.groups', 'Groups')}</h1> <h1>{t('nav.groups', 'Groups')}</h1>
<button class="btn btn--primary" onclick={onCreate}>{t('groups.create', 'Create group')}</button <button class="btn btn--primary" data-testid="groups-create-btn" onclick={onCreate}
>{t('groups.create', 'Create group')}</button
> >
</header> </header>
@@ -247,7 +248,11 @@
{@const description = groupDescription(g)} {@const description = groupDescription(g)}
<li class="group"> <li class="group">
<div class="group__row"> <div class="group__row">
<button class="group__name" onclick={() => expand(g)}> <button
class="group__name"
data-testid={`groups-expand-${g.id}`}
onclick={() => expand(g)}
>
<span class="avatar"><Icon name={groupIconName(g)} /></span> <span class="avatar"><Icon name={groupIconName(g)} /></span>
<span class="group__text"> <span class="group__text">
<span class="group__title"> <span class="group__title">
@@ -264,10 +269,16 @@
</button> </button>
{#if g.can_manage !== false && !g.is_virtual} {#if g.can_manage !== false && !g.is_virtual}
<div class="group__actions"> <div class="group__actions">
<button class="link-btn" onclick={() => onRename(g)} <button
>{t('common.rename', 'Rename')}</button class="link-btn"
data-testid={`groups-rename-${g.id}`}
onclick={() => onRename(g)}>{t('common.rename', 'Rename')}</button
>
<button
class="link-btn link-btn--danger"
data-testid={`groups-delete-${g.id}`}
onclick={() => onDelete(g)}
> >
<button class="link-btn link-btn--danger" onclick={() => onDelete(g)}>
{t('common.delete', 'Delete')} {t('common.delete', 'Delete')}
</button> </button>
</div> </div>
@@ -275,7 +286,7 @@
</div> </div>
{#if expandedId === g.id} {#if expandedId === g.id}
<div class="members"> <div class="members" data-testid={`groups-members-panel-${g.id}`}>
<div class="members__head"> <div class="members__head">
<h2>{t('groups.members', 'Members')}</h2> <h2>{t('groups.members', 'Members')}</h2>
</div> </div>
@@ -284,6 +295,7 @@
<div class="add-member"> <div class="add-member">
<input <input
class="add-member__input" class="add-member__input"
data-testid="groups-member-add-input"
placeholder={t('groups.add_member_search', 'Search users or groups to add…')} placeholder={t('groups.add_member_search', 'Search users or groups to add…')}
bind:value={addQuery} bind:value={addQuery}
oninput={onAddQuery} oninput={onAddQuery}
@@ -291,10 +303,14 @@
{#if addBusy} {#if addBusy}
<p class="muted">{t('common.loading', 'Loading…')}</p> <p class="muted">{t('common.loading', 'Loading…')}</p>
{:else if addResults.length > 0} {:else if addResults.length > 0}
<ul class="add-member__results"> <ul class="add-member__results" data-testid="groups-member-add-results">
{#each addResults as r (r.type + r.id)} {#each addResults as r (r.type + r.id)}
<li> <li>
<button class="add-member__opt" onclick={() => pickMember(g, r)}> <button
class="add-member__opt"
data-testid={`groups-member-add-opt-${r.type}-${r.id}`}
onclick={() => pickMember(g, r)}
>
<span class="avatar avatar--sm"> <span class="avatar avatar--sm">
<Icon name={r.type === 'group' ? 'user-group' : 'user'} /> <Icon name={r.type === 'group' ? 'user-group' : 'user'} />
</span> </span>
@@ -338,6 +354,7 @@
{#if g.can_manage !== false && !g.is_virtual} {#if g.can_manage !== false && !g.is_virtual}
<button <button
class="link-btn link-btn--danger" class="link-btn link-btn--danger"
data-testid={`groups-member-remove-${m.kind}-${m.id}`}
onclick={() => onRemoveMember(g.id, m)} onclick={() => onRemoveMember(g.id, m)}
> >
{t('common.remove', 'Remove')} {t('common.remove', 'Remove')}
@@ -354,7 +371,12 @@
</ul> </ul>
{#if hasMore} {#if hasMore}
<button class="btn load-more" disabled={loadingMore} onclick={loadMore}> <button
class="btn load-more"
data-testid="groups-load-more-btn"
disabled={loadingMore}
onclick={loadMore}
>
{loadingMore ? t('common.loading', 'Loading…') : t('groups.load_more', 'Load more')} {loadingMore ? t('common.loading', 'Loading…') : t('groups.load_more', 'Load more')}
</button> </button>
{/if} {/if}
+56
View File
@@ -0,0 +1,56 @@
import { it, expect, vi, beforeEach } from 'vitest';
import { render, screen, fireEvent, waitFor } from '@testing-library/svelte';
const { ui, promptDialog } = vi.hoisted(() => ({ ui: { notify: vi.fn() }, promptDialog: vi.fn() }));
vi.mock('$lib/stores/ui.svelte', () => ({ ui }));
vi.mock('$lib/stores/dialogs.svelte', () => ({ promptDialog }));
vi.mock('$lib/api/endpoints/groups', () => ({
INTERNAL_GROUP_ID: '00000000-0000-0000-0000-000000000001',
createGroup: vi.fn(),
deleteGroup: vi.fn(),
addGroupMember: vi.fn(),
addUserMember: vi.fn(),
groupDescription: (g: { description?: string | null }) => g.description ?? null,
groupDisplayName: (g: { name: string }) => g.name,
groupIconName: () => 'fa-users',
listGroupsPage: vi.fn(),
listMembers: vi.fn(),
removeGroupMember: vi.fn(),
removeUserMember: vi.fn(),
renameGroup: vi.fn()
}));
vi.mock('$lib/api/endpoints/recipients', () => ({
ensureResolvers: vi.fn(),
resolveRecipient: (_t: string, id: string) => ({ id, label: id }),
searchRecipients: vi.fn(async () => [])
}));
import { listGroupsPage, createGroup, listMembers } from '$lib/api/endpoints/groups';
import GroupsPage from './+page.svelte';
const m = (fn: unknown) => fn as ReturnType<typeof vi.fn>;
beforeEach(() => {
vi.clearAllMocks();
m(listMembers).mockResolvedValue([]);
});
it('renders groups returned by the API', async () => {
m(listGroupsPage).mockResolvedValue({
items: [{ id: 'g1', name: 'Engineers', member_count: 2 }],
total: 1
});
render(GroupsPage);
await waitFor(() => expect(listGroupsPage).toHaveBeenCalled());
await waitFor(() => expect(screen.getByText('Engineers')).toBeTruthy());
});
it('creates a new group', async () => {
m(listGroupsPage).mockResolvedValue({ items: [], total: 0 });
m(createGroup).mockResolvedValue(undefined);
promptDialog.mockResolvedValue('New Team');
render(GroupsPage);
await waitFor(() => expect(listGroupsPage).toHaveBeenCalled());
await fireEvent.click(screen.getByTestId('groups-create-btn'));
await waitFor(() => expect(createGroup).toHaveBeenCalledWith('New Team'));
});
+86 -15
View File
@@ -271,7 +271,7 @@
{#if mode === 'login'} {#if mode === 'login'}
{#if passwordLoginEnabled} {#if passwordLoginEnabled}
{#if error}<div class="auth-error" style="display: block" role="alert">{error}</div>{/if} {#if error}<div class="auth-error" style="display: block" role="alert">{error}</div>{/if}
<form class="auth-form" onsubmit={onLogin} novalidate> <form class="auth-form" data-testid="login-form" onsubmit={onLogin} novalidate>
<div class="auth-input-group"> <div class="auth-input-group">
<label class="auth-label" for="login-username"> <label class="auth-label" for="login-username">
{t('auth.username', 'Username or email')} {t('auth.username', 'Username or email')}
@@ -280,6 +280,7 @@
<input <input
id="login-username" id="login-username"
class="auth-input" class="auth-input"
data-testid="login-username-input"
type="text" type="text"
bind:value={username} bind:value={username}
autocomplete="username" autocomplete="username"
@@ -296,6 +297,7 @@
<input <input
id="login-password" id="login-password"
class="auth-input" class="auth-input"
data-testid="login-password-input"
type={showPassword ? 'text' : 'password'} type={showPassword ? 'text' : 'password'}
bind:value={password} bind:value={password}
onkeydown={onPwKey} onkeydown={onPwKey}
@@ -308,6 +310,7 @@
type="button" type="button"
class="auth-pw-toggle" class="auth-pw-toggle"
aria-pressed={showPassword} aria-pressed={showPassword}
data-testid="login-password-toggle-btn"
aria-label={t('auth.toggle_password', 'Show password')} aria-label={t('auth.toggle_password', 'Show password')}
onclick={() => (showPassword = !showPassword)} onclick={() => (showPassword = !showPassword)}
></button> ></button>
@@ -317,12 +320,22 @@
{/if} {/if}
</div> </div>
<button class="auth-button" type="submit" disabled={busy} aria-busy={busy}> <button
class="auth-button"
type="submit"
data-testid="login-submit-btn"
disabled={busy}
aria-busy={busy}
>
{busy ? t('auth.signing_in', 'Signing in…') : t('auth.sign_in', 'Sign in')} {busy ? t('auth.signing_in', 'Signing in…') : t('auth.sign_in', 'Sign in')}
</button> </button>
</form> </form>
<button class="auth-magic-toggle" onclick={() => (magicOpen = !magicOpen)}> <button
class="auth-magic-toggle"
data-testid="login-magic-toggle-btn"
onclick={() => (magicOpen = !magicOpen)}
>
{t('auth.magic_prompt', 'No password? Sign in with an email link')} {t('auth.magic_prompt', 'No password? Sign in with an email link')}
</button> </button>
{#if magicOpen} {#if magicOpen}
@@ -333,7 +346,7 @@
"No password? Enter your email and we'll send you a one-time sign-in link." "No password? Enter your email and we'll send you a one-time sign-in link."
)} )}
</p> </p>
<form class="auth-form" onsubmit={onMagicLink}> <form class="auth-form" data-testid="login-magic-form" onsubmit={onMagicLink}>
<div class="auth-input-group"> <div class="auth-input-group">
<label class="auth-label" for="magic-email"> <label class="auth-label" for="magic-email">
{t('auth.magic_email_label', 'Email address')} {t('auth.magic_email_label', 'Email address')}
@@ -342,6 +355,7 @@
<input <input
id="magic-email" id="magic-email"
class="auth-input" class="auth-input"
data-testid="login-magic-email-input"
type="email" type="email"
bind:value={magicEmail} bind:value={magicEmail}
autocomplete="email" autocomplete="email"
@@ -349,7 +363,12 @@
/> />
</div> </div>
</div> </div>
<button class="auth-button auth-button-secondary" type="submit" disabled={busy}> <button
class="auth-button auth-button-secondary"
type="submit"
data-testid="login-magic-send-btn"
disabled={busy}
>
{t('auth.magic_send', 'Send link')} {t('auth.magic_send', 'Send link')}
</button> </button>
</form> </form>
@@ -371,7 +390,12 @@
<div class="auth-divider"><span>{t('auth.or', 'or')}</span></div> <div class="auth-divider"><span>{t('auth.or', 'or')}</span></div>
{/if} {/if}
<!-- Backend OIDC authorize endpoint (not a SvelteKit route). --> <!-- Backend OIDC authorize endpoint (not a SvelteKit route). -->
<a class="auth-button auth-button-oidc" href={oidc.authorize_endpoint} rel="external"> <a
class="auth-button auth-button-oidc"
data-testid="login-oidc-btn"
href={oidc.authorize_endpoint}
rel="external"
>
{t( {t(
'auth.sso_login_provider', 'auth.sso_login_provider',
{ provider: oidc.provider_name ?? 'SSO' }, { provider: oidc.provider_name ?? 'SSO' },
@@ -383,7 +407,11 @@
{#if passwordLoginEnabled} {#if passwordLoginEnabled}
<div class="auth-toggle"> <div class="auth-toggle">
{t('auth.no_account', 'No account?')} {t('auth.no_account', 'No account?')}
<button class="auth-toggle-link" onclick={() => (mode = 'register')}> <button
class="auth-toggle-link"
data-testid="login-to-register-btn"
onclick={() => (mode = 'register')}
>
{t('auth.register', 'Create one')} {t('auth.register', 'Create one')}
</button> </button>
</div> </div>
@@ -392,7 +420,11 @@
{#if setupAvailable} {#if setupAvailable}
<div class="auth-toggle"> <div class="auth-toggle">
{t('auth.admin_setup', 'First time?')} {t('auth.admin_setup', 'First time?')}
<button class="auth-toggle-link" onclick={() => (mode = 'setup')}> <button
class="auth-toggle-link"
data-testid="login-to-setup-btn"
onclick={() => (mode = 'setup')}
>
{t('auth.setup', 'Set up administrator')} {t('auth.setup', 'Set up administrator')}
</button> </button>
</div> </div>
@@ -402,12 +434,13 @@
{regError} {regError}
</div>{/if} </div>{/if}
{#if regSuccess}<div class="auth-success" style="display: block">{regSuccess}</div>{/if} {#if regSuccess}<div class="auth-success" style="display: block">{regSuccess}</div>{/if}
<form class="auth-form" onsubmit={onRegister} novalidate> <form class="auth-form" data-testid="login-register-form" onsubmit={onRegister} novalidate>
<div class="auth-input-group"> <div class="auth-input-group">
<label class="auth-label" for="reg-username">{t('auth.username', 'Username')}</label> <label class="auth-label" for="reg-username">{t('auth.username', 'Username')}</label>
<input <input
id="reg-username" id="reg-username"
class="auth-input" class="auth-input"
data-testid="login-register-username-input"
bind:value={regUsername} bind:value={regUsername}
required required
disabled={busy} disabled={busy}
@@ -418,6 +451,7 @@
<input <input
id="reg-email" id="reg-email"
class="auth-input" class="auth-input"
data-testid="login-register-email-input"
type="email" type="email"
bind:value={regEmail} bind:value={regEmail}
required required
@@ -430,6 +464,7 @@
<input <input
id="reg-password" id="reg-password"
class="auth-input" class="auth-input"
data-testid="login-register-password-input"
type={regShowPassword ? 'text' : 'password'} type={regShowPassword ? 'text' : 'password'}
bind:value={regPassword} bind:value={regPassword}
onkeydown={onRegPwKey} onkeydown={onRegPwKey}
@@ -442,6 +477,7 @@
type="button" type="button"
class="auth-pw-toggle" class="auth-pw-toggle"
aria-pressed={regShowPassword} aria-pressed={regShowPassword}
data-testid="login-register-password-toggle-btn"
aria-label={t('auth.toggle_password', 'Show password')} aria-label={t('auth.toggle_password', 'Show password')}
onclick={() => (regShowPassword = !regShowPassword)} onclick={() => (regShowPassword = !regShowPassword)}
></button> ></button>
@@ -458,6 +494,7 @@
<input <input
id="reg-confirm" id="reg-confirm"
class="auth-input" class="auth-input"
data-testid="login-register-confirm-input"
type={regShowConfirm ? 'text' : 'password'} type={regShowConfirm ? 'text' : 'password'}
bind:value={regConfirm} bind:value={regConfirm}
onkeydown={onRegPwKey} onkeydown={onRegPwKey}
@@ -470,6 +507,7 @@
type="button" type="button"
class="auth-pw-toggle" class="auth-pw-toggle"
aria-pressed={regShowConfirm} aria-pressed={regShowConfirm}
data-testid="login-register-confirm-toggle-btn"
aria-label={t('auth.toggle_password', 'Show password')} aria-label={t('auth.toggle_password', 'Show password')}
onclick={() => (regShowConfirm = !regShowConfirm)} onclick={() => (regShowConfirm = !regShowConfirm)}
></button> ></button>
@@ -484,13 +522,23 @@
</div> </div>
{/if} {/if}
</div> </div>
<button class="auth-button" type="submit" disabled={busy} aria-busy={busy}> <button
class="auth-button"
type="submit"
data-testid="login-register-submit-btn"
disabled={busy}
aria-busy={busy}
>
{t('auth.register', 'Create account')} {t('auth.register', 'Create account')}
</button> </button>
</form> </form>
<div class="auth-toggle"> <div class="auth-toggle">
{t('auth.have_account', 'Already have an account?')} {t('auth.have_account', 'Already have an account?')}
<button class="auth-toggle-link" onclick={() => (mode = 'login')}> <button
class="auth-toggle-link"
data-testid="login-register-to-login-btn"
onclick={() => (mode = 'login')}
>
{t('auth.sign_in', 'Sign in')} {t('auth.sign_in', 'Sign in')}
</button> </button>
</div> </div>
@@ -515,13 +563,20 @@
</div>{/if} </div>{/if}
{#if setupSuccess}<div class="auth-success" style="display: block">{setupSuccess}</div>{/if} {#if setupSuccess}<div class="auth-success" style="display: block">{setupSuccess}</div>{/if}
<form class="auth-form" onsubmit={onSetup} novalidate> <form class="auth-form" data-testid="login-setup-form" onsubmit={onSetup} novalidate>
<div class="auth-input-group"> <div class="auth-input-group">
<label class="auth-label" for="setup-username"> <label class="auth-label" for="setup-username">
{t('auth.admin_username', 'Administrator username')} {t('auth.admin_username', 'Administrator username')}
</label> </label>
<div class="auth-input-wrap auth-input-wrap--user"> <div class="auth-input-wrap auth-input-wrap--user">
<input id="setup-username" class="auth-input" type="text" value="admin" readonly /> <input
id="setup-username"
class="auth-input"
data-testid="login-setup-username-input"
type="text"
value="admin"
readonly
/>
</div> </div>
</div> </div>
@@ -533,6 +588,7 @@
<input <input
id="setup-email" id="setup-email"
class="auth-input" class="auth-input"
data-testid="login-setup-email-input"
type="email" type="email"
bind:value={setupEmail} bind:value={setupEmail}
autocomplete="email" autocomplete="email"
@@ -550,6 +606,7 @@
<input <input
id="setup-password" id="setup-password"
class="auth-input" class="auth-input"
data-testid="login-setup-password-input"
type={setupShowPassword ? 'text' : 'password'} type={setupShowPassword ? 'text' : 'password'}
bind:value={setupPassword} bind:value={setupPassword}
onkeydown={onSetupPwKey} onkeydown={onSetupPwKey}
@@ -563,6 +620,7 @@
type="button" type="button"
class="auth-pw-toggle" class="auth-pw-toggle"
aria-pressed={setupShowPassword} aria-pressed={setupShowPassword}
data-testid="login-setup-password-toggle-btn"
aria-label={t('auth.toggle_password', 'Show password')} aria-label={t('auth.toggle_password', 'Show password')}
onclick={() => (setupShowPassword = !setupShowPassword)} onclick={() => (setupShowPassword = !setupShowPassword)}
></button> ></button>
@@ -580,6 +638,7 @@
<input <input
id="setup-confirm" id="setup-confirm"
class="auth-input" class="auth-input"
data-testid="login-setup-confirm-input"
type={setupShowConfirm ? 'text' : 'password'} type={setupShowConfirm ? 'text' : 'password'}
bind:value={setupConfirm} bind:value={setupConfirm}
onkeydown={onSetupPwKey} onkeydown={onSetupPwKey}
@@ -592,6 +651,7 @@
type="button" type="button"
class="auth-pw-toggle" class="auth-pw-toggle"
aria-pressed={setupShowConfirm} aria-pressed={setupShowConfirm}
data-testid="login-setup-confirm-toggle-btn"
aria-label={t('auth.toggle_password', 'Show password')} aria-label={t('auth.toggle_password', 'Show password')}
onclick={() => (setupShowConfirm = !setupShowConfirm)} onclick={() => (setupShowConfirm = !setupShowConfirm)}
></button> ></button>
@@ -609,14 +669,24 @@
{/if} {/if}
</div> </div>
<button class="auth-button" type="submit" disabled={busy} aria-busy={busy}> <button
class="auth-button"
type="submit"
data-testid="login-setup-submit-btn"
disabled={busy}
aria-busy={busy}
>
{t('auth.create_admin', 'Create administrator')} {t('auth.create_admin', 'Create administrator')}
</button> </button>
</form> </form>
<div class="auth-toggle"> <div class="auth-toggle">
{t('auth.back_to_login', 'Already configured?')} {t('auth.back_to_login', 'Already configured?')}
<button class="auth-toggle-link" onclick={() => (mode = 'login')}> <button
class="auth-toggle-link"
data-testid="login-setup-to-login-btn"
onclick={() => (mode = 'login')}
>
{t('auth.sign_in', 'Sign in')} {t('auth.sign_in', 'Sign in')}
</button> </button>
</div> </div>
@@ -626,6 +696,7 @@
<div class="auth-lang"> <div class="auth-lang">
<select <select
aria-label={t('settings.language', 'Language')} aria-label={t('settings.language', 'Language')}
data-testid="login-language-select"
value={i18n.locale} value={i18n.locale}
onchange={(e) => setLocale(e.currentTarget.value as Locale)} onchange={(e) => setLocale(e.currentTarget.value as Locale)}
> >
+157
View File
@@ -0,0 +1,157 @@
import { it, expect, vi, beforeEach } from 'vitest';
import { render, screen, fireEvent, waitFor } from '@testing-library/svelte';
const { goto, pageState, session } = vi.hoisted(() => ({
goto: vi.fn(),
pageState: { url: new URL('http://localhost/login') } as { url: URL },
session: { user: null } as { user: unknown }
}));
vi.mock('$app/navigation', () => ({ goto }));
vi.mock('$app/state', () => ({ page: pageState }));
vi.mock('$lib/stores/session.svelte', () => ({ session }));
vi.mock('$lib/api/endpoints/auth', () => ({
exchangeOidcCode: vi.fn(),
fetchMe: vi.fn(),
getOidcProviders: vi.fn(),
getAuthStatus: vi.fn(),
login: vi.fn(),
register: vi.fn(),
sendMagicLink: vi.fn(),
setupAdmin: vi.fn()
}));
import * as auth from '$lib/api/endpoints/auth';
import LoginPage from './+page.svelte';
const m = (fn: unknown) => fn as ReturnType<typeof vi.fn>;
beforeEach(() => {
vi.clearAllMocks();
pageState.url = new URL('http://localhost/login');
session.user = null;
m(auth.fetchMe).mockResolvedValue(null);
m(auth.getOidcProviders).mockResolvedValue({ providers: [] });
m(auth.getAuthStatus).mockResolvedValue({ initialized: true });
});
it('logs in and redirects', async () => {
m(auth.login).mockResolvedValue({ user: { id: '1' } });
render(LoginPage);
await screen.findByTestId('login-form');
await fireEvent.input(screen.getByTestId('login-username-input'), { target: { value: 'admin' } });
await fireEvent.input(screen.getByTestId('login-password-input'), { target: { value: 'pw' } });
await fireEvent.click(screen.getByTestId('login-submit-btn'));
await waitFor(() => expect(auth.login).toHaveBeenCalled());
});
it('exchanges an oidc code on mount and redirects', async () => {
pageState.url = new URL('http://localhost/login?oidc_code=abc');
m(auth.exchangeOidcCode).mockResolvedValue({ id: '1' });
render(LoginPage);
await waitFor(() => expect(auth.exchangeOidcCode).toHaveBeenCalledWith('abc'));
await waitFor(() => expect(goto).toHaveBeenCalled());
});
it('skips the form when already authenticated', async () => {
m(auth.fetchMe).mockResolvedValue({ id: '1' });
render(LoginPage);
await waitFor(() => expect(goto).toHaveBeenCalled());
});
it('enters setup mode on a fresh install', async () => {
m(auth.getAuthStatus).mockResolvedValue({ initialized: false });
render(LoginPage);
await screen.findByTestId('login-setup-form');
});
it('sends a magic link', async () => {
m(auth.sendMagicLink).mockResolvedValue('sent');
render(LoginPage);
await screen.findByTestId('login-form');
await fireEvent.click(screen.getByTestId('login-magic-toggle-btn'));
await fireEvent.input(screen.getByTestId('login-magic-email-input'), {
target: { value: 'a@b.test' }
});
await fireEvent.click(screen.getByTestId('login-magic-send-btn'));
await waitFor(() => expect(auth.sendMagicLink).toHaveBeenCalledWith('a@b.test'));
});
it('registers a new account', async () => {
m(auth.register).mockResolvedValue(undefined);
render(LoginPage);
await screen.findByTestId('login-form');
await fireEvent.click(screen.getByTestId('login-to-register-btn'));
await fireEvent.input(screen.getByTestId('login-register-username-input'), {
target: { value: 'u' }
});
await fireEvent.input(screen.getByTestId('login-register-email-input'), {
target: { value: 'u@b.test' }
});
await fireEvent.input(screen.getByTestId('login-register-password-input'), {
target: { value: 'TestPassword1!' }
});
await fireEvent.input(screen.getByTestId('login-register-confirm-input'), {
target: { value: 'TestPassword1!' }
});
await fireEvent.click(screen.getByTestId('login-register-submit-btn'));
await waitFor(() => expect(auth.register).toHaveBeenCalled());
});
it('shows an error message when login fails', async () => {
m(auth.login).mockRejectedValue(new Error('bad credentials'));
render(LoginPage);
await screen.findByTestId('login-form');
await fireEvent.input(screen.getByTestId('login-username-input'), { target: { value: 'admin' } });
await fireEvent.input(screen.getByTestId('login-password-input'), { target: { value: 'wrong' } });
await fireEvent.click(screen.getByTestId('login-submit-btn'));
await waitFor(() => expect(screen.getByText('bad credentials')).toBeTruthy());
});
it('rejects a registration with mismatched passwords without calling the API', async () => {
render(LoginPage);
await screen.findByTestId('login-form');
await fireEvent.click(screen.getByTestId('login-to-register-btn'));
await fireEvent.input(screen.getByTestId('login-register-username-input'), {
target: { value: 'u' }
});
await fireEvent.input(screen.getByTestId('login-register-password-input'), {
target: { value: 'TestPassword1!' }
});
await fireEvent.input(screen.getByTestId('login-register-confirm-input'), {
target: { value: 'Different1!' }
});
await fireEvent.click(screen.getByTestId('login-register-submit-btn'));
expect(auth.register).not.toHaveBeenCalled();
});
it('creates the first administrator in setup mode', async () => {
m(auth.getAuthStatus).mockResolvedValue({ initialized: false });
m(auth.setupAdmin).mockResolvedValue(undefined);
render(LoginPage);
await screen.findByTestId('login-setup-form');
await fireEvent.input(screen.getByTestId('login-setup-email-input'), {
target: { value: 'admin@x.test' }
});
await fireEvent.input(screen.getByTestId('login-setup-password-input'), {
target: { value: 'TestPassword1!' }
});
await fireEvent.input(screen.getByTestId('login-setup-confirm-input'), {
target: { value: 'TestPassword1!' }
});
await fireEvent.click(screen.getByTestId('login-setup-submit-btn'));
await waitFor(() =>
expect(auth.setupAdmin).toHaveBeenCalledWith('admin@x.test', 'TestPassword1!')
);
});
it('renders an SSO sign-in link when an OIDC provider is configured', async () => {
m(auth.getOidcProviders).mockResolvedValue({
enabled: true,
authorize_endpoint: 'https://idp.test/auth',
provider_name: 'Acme SSO',
password_login_enabled: true
});
render(LoginPage);
const sso = await screen.findByTestId('login-oidc-btn');
expect(sso.getAttribute('href')).toBe('https://idp.test/auth');
});
+72 -8
View File
@@ -512,7 +512,11 @@
<p class="music-empty-state-desc"> <p class="music-empty-state-desc">
{t('music.empty_hint', 'Create a playlist to start collecting your tracks.')} {t('music.empty_hint', 'Create a playlist to start collecting your tracks.')}
</p> </p>
<button class="btn btn-primary" onclick={onCreate}> <button
class="btn btn-primary"
data-testid="music-create-playlist-empty-btn"
onclick={onCreate}
>
<Icon name="plus" /> <Icon name="plus" />
<span>{t('music.create_playlist', 'Create playlist')}</span> <span>{t('music.create_playlist', 'Create playlist')}</span>
</button> </button>
@@ -526,6 +530,7 @@
class="music-sidebar-add-btn" class="music-sidebar-add-btn"
title={t('music.create_playlist', 'Create playlist')} title={t('music.create_playlist', 'Create playlist')}
aria-label={t('music.create_playlist', 'Create playlist')} aria-label={t('music.create_playlist', 'Create playlist')}
data-testid="music-create-playlist-btn"
onclick={onCreate} onclick={onCreate}
> >
<Icon name="plus" /> <Icon name="plus" />
@@ -538,6 +543,7 @@
<div <div
class="music-playlist-item" class="music-playlist-item"
class:active={current?.id === p.id} class:active={current?.id === p.id}
data-testid={p.name}
onclick={() => select(p)} onclick={() => select(p)}
> >
<div class="music-playlist-icon"><Icon name="music" /></div> <div class="music-playlist-icon"><Icon name="music" /></div>
@@ -561,6 +567,7 @@
class="music-playlist-cover" class="music-playlist-cover"
title={t('music.set_cover', 'Set cover')} title={t('music.set_cover', 'Set cover')}
aria-label={t('music.set_cover', 'Set cover')} aria-label={t('music.set_cover', 'Set cover')}
data-testid="music-set-cover-btn"
onclick={pickCover} onclick={pickCover}
> >
{#if coverUrl(current)} {#if coverUrl(current)}
@@ -585,12 +592,18 @@
</div> </div>
<div class="music-playlist-actions"> <div class="music-playlist-actions">
<button class="btn btn-secondary" onclick={playAll} disabled={tracks.length === 0}> <button
class="btn btn-secondary"
data-testid="music-play-all-btn"
onclick={playAll}
disabled={tracks.length === 0}
>
<Icon name="play" /> <Icon name="play" />
<span>{t('music.play_all', 'Play all')}</span> <span>{t('music.play_all', 'Play all')}</span>
</button> </button>
<button <button
class="btn btn-secondary" class="btn btn-secondary"
data-testid="music-shuffle-play-btn"
onclick={shufflePlay} onclick={shufflePlay}
disabled={tracks.length === 0} disabled={tracks.length === 0}
title={t('music.shuffle', 'Shuffle')} title={t('music.shuffle', 'Shuffle')}
@@ -598,12 +611,17 @@
> >
<Icon name="shuffle" /> <Icon name="shuffle" />
</button> </button>
<button class="btn btn-secondary" onclick={openAdd}> <button
class="btn btn-secondary"
data-testid="music-add-tracks-btn"
onclick={openAdd}
>
<Icon name="plus" /> <Icon name="plus" />
<span>{t('music.add_tracks', 'Add tracks')}</span> <span>{t('music.add_tracks', 'Add tracks')}</span>
</button> </button>
<button <button
class="btn btn-secondary" class="btn btn-secondary"
data-testid="music-rename-playlist-btn"
onclick={onRenamePlaylist} onclick={onRenamePlaylist}
title={t('common.rename', 'Rename')} title={t('common.rename', 'Rename')}
aria-label={t('common.rename', 'Rename')} aria-label={t('common.rename', 'Rename')}
@@ -612,6 +630,7 @@
</button> </button>
<button <button
class="btn btn-secondary" class="btn btn-secondary"
data-testid="music-edit-description-btn"
onclick={onEditDescription} onclick={onEditDescription}
title={t('music.edit_description', 'Edit description')} title={t('music.edit_description', 'Edit description')}
aria-label={t('music.edit_description', 'Edit description')} aria-label={t('music.edit_description', 'Edit description')}
@@ -620,6 +639,7 @@
</button> </button>
<button <button
class="btn btn-secondary" class="btn btn-secondary"
data-testid="music-manage-shares-btn"
onclick={openShares} onclick={openShares}
title={t('music.manage_shares', 'Manage shares')} title={t('music.manage_shares', 'Manage shares')}
aria-label={t('music.manage_shares', 'Manage shares')} aria-label={t('music.manage_shares', 'Manage shares')}
@@ -629,6 +649,7 @@
<button <button
class="btn btn-secondary" class="btn btn-secondary"
class:active={current.is_public} class:active={current.is_public}
data-testid="music-toggle-public-btn"
onclick={onTogglePublic} onclick={onTogglePublic}
title={current.is_public title={current.is_public
? t('music.make_private', 'Make private') ? t('music.make_private', 'Make private')
@@ -641,6 +662,7 @@
</button> </button>
<button <button
class="btn btn-secondary" class="btn btn-secondary"
data-testid="music-delete-playlist-btn"
onclick={() => onDelete(current!)} onclick={() => onDelete(current!)}
title={t('common.delete', 'Delete')} title={t('common.delete', 'Delete')}
aria-label={t('common.delete', 'Delete')} aria-label={t('common.delete', 'Delete')}
@@ -673,6 +695,7 @@
<div <div
class="music-track" class="music-track"
class:playing={currentTrack?.id === track.id && playing} class:playing={currentTrack?.id === track.id && playing}
data-testid={trackLabel(track)}
draggable="true" draggable="true"
ondblclick={() => playFromTracks(i)} ondblclick={() => playFromTracks(i)}
ondragstart={() => onDragStart(i)} ondragstart={() => onDragStart(i)}
@@ -687,6 +710,7 @@
<!-- svelte-ignore a11y_click_events_have_key_events --> <!-- svelte-ignore a11y_click_events_have_key_events -->
<!-- svelte-ignore a11y_no_static_element_interactions --> <!-- svelte-ignore a11y_no_static_element_interactions -->
<span <span
data-testid={`music-track-play-${track.id}`}
onclick={(e) => { onclick={(e) => {
e.stopPropagation(); e.stopPropagation();
if (currentTrack?.id === track.id) togglePlay(); if (currentTrack?.id === track.id) togglePlay();
@@ -714,6 +738,7 @@
class="music-track-remove-btn" class="music-track-remove-btn"
title={t('common.remove', 'Remove')} title={t('common.remove', 'Remove')}
aria-label={t('common.remove', 'Remove')} aria-label={t('common.remove', 'Remove')}
data-testid={`music-track-remove-${track.id}`}
onclick={(e) => { onclick={(e) => {
e.stopPropagation(); e.stopPropagation();
onRemoveTrack(track); onRemoveTrack(track);
@@ -763,6 +788,7 @@
class:active={shuffle} class:active={shuffle}
title={t('music.shuffle', 'Shuffle')} title={t('music.shuffle', 'Shuffle')}
aria-label={t('music.shuffle', 'Shuffle')} aria-label={t('music.shuffle', 'Shuffle')}
data-testid="music-player-shuffle-btn"
onclick={() => (shuffle = !shuffle)} onclick={() => (shuffle = !shuffle)}
> >
<Icon name="shuffle" /> <Icon name="shuffle" />
@@ -771,6 +797,7 @@
class="player-btn" class="player-btn"
title={t('music.prev', 'Previous')} title={t('music.prev', 'Previous')}
aria-label={t('music.prev', 'Previous')} aria-label={t('music.prev', 'Previous')}
data-testid="music-player-prev-btn"
onclick={prev} onclick={prev}
> >
<Icon name="backward" /> <Icon name="backward" />
@@ -779,6 +806,7 @@
class="player-btn player-btn-main" class="player-btn player-btn-main"
title={t('music.play', 'Play')} title={t('music.play', 'Play')}
aria-label={t('music.play', 'Play')} aria-label={t('music.play', 'Play')}
data-testid="music-player-play-btn"
onclick={togglePlay} onclick={togglePlay}
> >
<Icon name={playing ? 'pause' : 'play'} /> <Icon name={playing ? 'pause' : 'play'} />
@@ -787,6 +815,7 @@
class="player-btn" class="player-btn"
title={t('music.next', 'Next')} title={t('music.next', 'Next')}
aria-label={t('music.next', 'Next')} aria-label={t('music.next', 'Next')}
data-testid="music-player-next-btn"
onclick={next} onclick={next}
> >
<Icon name="forward" /> <Icon name="forward" />
@@ -797,6 +826,7 @@
class:repeat-one={repeat === 'one'} class:repeat-one={repeat === 'one'}
title={t('music.repeat', 'Repeat')} title={t('music.repeat', 'Repeat')}
aria-label={t('music.repeat', 'Repeat')} aria-label={t('music.repeat', 'Repeat')}
data-testid="music-player-repeat-btn"
onclick={cycleRepeat} onclick={cycleRepeat}
> >
<Icon name="repeat" /> <Icon name="repeat" />
@@ -813,6 +843,7 @@
value={currentTime} value={currentTime}
oninput={seek} oninput={seek}
aria-label={t('music.seek', 'Seek')} aria-label={t('music.seek', 'Seek')}
data-testid="music-player-seek-input"
/> />
<span class="player-time player-time-total">{fmtTime(duration)}</span> <span class="player-time player-time-total">{fmtTime(duration)}</span>
</div> </div>
@@ -824,6 +855,7 @@
class:active={queueOpen} class:active={queueOpen}
title={t('music.queue', 'Queue')} title={t('music.queue', 'Queue')}
aria-label={t('music.queue', 'Queue')} aria-label={t('music.queue', 'Queue')}
data-testid="music-player-queue-toggle-btn"
onclick={() => (queueOpen = !queueOpen)} onclick={() => (queueOpen = !queueOpen)}
> >
<Icon name="list" /> <Icon name="list" />
@@ -832,6 +864,7 @@
class="player-btn player-btn-small" class="player-btn player-btn-small"
title={t('music.mute', 'Mute')} title={t('music.mute', 'Mute')}
aria-label={t('music.mute', 'Mute')} aria-label={t('music.mute', 'Mute')}
data-testid="music-player-mute-btn"
onclick={toggleMute} onclick={toggleMute}
> >
<Icon name={volumeIcon} /> <Icon name={volumeIcon} />
@@ -846,17 +879,19 @@
value={muted ? 0 : volume} value={muted ? 0 : volume}
oninput={setVolume} oninput={setVolume}
aria-label={t('music.volume', 'Volume')} aria-label={t('music.volume', 'Volume')}
data-testid="music-player-volume-input"
/> />
</div> </div>
</div> </div>
</div> </div>
{#if queueOpen} {#if queueOpen}
<div class="player-queue"> <div class="player-queue" data-testid="music-queue-panel">
<div class="player-queue-header"> <div class="player-queue-header">
<h3>{t('music.queue', 'Queue')}</h3> <h3>{t('music.queue', 'Queue')}</h3>
<button <button
class="player-btn player-btn-small" class="player-btn player-btn-small"
data-testid="music-queue-close-btn"
onclick={() => (queueOpen = false)} onclick={() => (queueOpen = false)}
aria-label={t('common.close', 'Close')} aria-label={t('common.close', 'Close')}
> >
@@ -876,6 +911,7 @@
<div <div
class="player-queue-item" class="player-queue-item"
class:active={i === currentIndex} class:active={i === currentIndex}
data-testid={trackLabel(qt)}
onclick={() => jumpQueue(i)} onclick={() => jumpQueue(i)}
> >
<span class="queue-item-num">{i + 1}</span> <span class="queue-item-num">{i + 1}</span>
@@ -887,6 +923,7 @@
<button <button
class="queue-item-remove" class="queue-item-remove"
aria-label={t('common.remove', 'Remove')} aria-label={t('common.remove', 'Remove')}
data-testid={`music-queue-remove-${qt.id}`}
onclick={(e) => { onclick={(e) => {
e.stopPropagation(); e.stopPropagation();
removeFromQueue(i); removeFromQueue(i);
@@ -917,6 +954,7 @@
type="file" type="file"
accept="image/*" accept="image/*"
class="hidden-input" class="hidden-input"
data-testid="music-cover-input"
onchange={onCoverChosen} onchange={onCoverChosen}
/> />
@@ -925,6 +963,7 @@
<!-- svelte-ignore a11y_no_static_element_interactions --> <!-- svelte-ignore a11y_no_static_element_interactions -->
<div <div
class="music-picker-overlay active" class="music-picker-overlay active"
data-testid="music-add-tracks-dialog"
onclick={(e) => { onclick={(e) => {
if (e.target === e.currentTarget) addOpen = false; if (e.target === e.currentTarget) addOpen = false;
}} }}
@@ -935,6 +974,7 @@
<button <button
class="music-picker-close" class="music-picker-close"
aria-label={t('common.close', 'Close')} aria-label={t('common.close', 'Close')}
data-testid="music-add-tracks-close-btn"
onclick={() => (addOpen = false)}>&times;</button onclick={() => (addOpen = false)}>&times;</button
> >
</div> </div>
@@ -948,6 +988,7 @@
oninput={onAddQueryInput} oninput={onAddQueryInput}
autocomplete="off" autocomplete="off"
autofocus autofocus
data-testid="music-add-tracks-search-input"
/> />
</div> </div>
<div class="music-picker-list"> <div class="music-picker-list">
@@ -968,6 +1009,7 @@
type="checkbox" type="checkbox"
checked={addSelected.has(f.id)} checked={addSelected.has(f.id)}
onchange={() => addSelected.toggle(f.id)} onchange={() => addSelected.toggle(f.id)}
data-testid={f.name}
/> />
<Icon name="file-audio" /> <Icon name="file-audio" />
<span class="music-picker-name" title={f.name}>{f.name}</span> <span class="music-picker-name" title={f.name}>{f.name}</span>
@@ -980,10 +1022,19 @@
{t('music.selected_count', { n: addSelected.size }, '{{n}} selected')} {t('music.selected_count', { n: addSelected.size }, '{{n}} selected')}
</span> </span>
<div class="music-picker-actions"> <div class="music-picker-actions">
<button class="btn btn-secondary" onclick={() => (addOpen = false)}> <button
class="btn btn-secondary"
data-testid="music-add-tracks-cancel-btn"
onclick={() => (addOpen = false)}
>
{t('common.cancel', 'Cancel')} {t('common.cancel', 'Cancel')}
</button> </button>
<button class="btn btn-primary" disabled={addSelected.size === 0} onclick={confirmAdd}> <button
class="btn btn-primary"
disabled={addSelected.size === 0}
data-testid="music-add-tracks-confirm-btn"
onclick={confirmAdd}
>
<Icon name="plus" /> <Icon name="plus" />
{t('music.add_selected', 'Add selected')} {t('music.add_selected', 'Add selected')}
</button> </button>
@@ -998,6 +1049,7 @@
<!-- svelte-ignore a11y_no_static_element_interactions --> <!-- svelte-ignore a11y_no_static_element_interactions -->
<div <div
class="music-shares-overlay" class="music-shares-overlay"
data-testid="music-shares-dialog"
onclick={(e) => { onclick={(e) => {
if (e.target === e.currentTarget) sharesOpen = false; if (e.target === e.currentTarget) sharesOpen = false;
}} }}
@@ -1008,6 +1060,7 @@
<button <button
class="music-shares-close-btn" class="music-shares-close-btn"
aria-label={t('common.close', 'Close')} aria-label={t('common.close', 'Close')}
data-testid="music-shares-close-btn"
onclick={() => (sharesOpen = false)} onclick={() => (sharesOpen = false)}
> >
<Icon name="times" /> <Icon name="times" />
@@ -1029,6 +1082,7 @@
class="music-share-remove-btn" class="music-share-remove-btn"
title={t('music.remove_share', 'Remove')} title={t('music.remove_share', 'Remove')}
aria-label={t('music.remove_share', 'Remove')} aria-label={t('music.remove_share', 'Remove')}
data-testid={`music-share-remove-${s.user_id}`}
onclick={() => onRemoveShare(s.user_id)} onclick={() => onRemoveShare(s.user_id)}
> >
<Icon name="times" /> <Icon name="times" />
@@ -1044,12 +1098,22 @@
placeholder={t('music.share_with_user', 'User ID or email')} placeholder={t('music.share_with_user', 'User ID or email')}
bind:value={shareUser} bind:value={shareUser}
autocomplete="off" autocomplete="off"
data-testid="music-share-user-input"
/> />
<label class="music-shares-write-label"> <label class="music-shares-write-label">
<input type="checkbox" bind:checked={shareCanWrite} /> <input
type="checkbox"
bind:checked={shareCanWrite}
data-testid="music-share-can-write-checkbox"
/>
{t('music.can_write', 'Can edit')} {t('music.can_write', 'Can edit')}
</label> </label>
<button class="btn btn-primary btn-sm" disabled={!shareUser.trim()} onclick={onAddShare}> <button
class="btn btn-primary btn-sm"
disabled={!shareUser.trim()}
data-testid="music-share-add-btn"
onclick={onAddShare}
>
<Icon name="plus" /> <Icon name="plus" />
{t('music.share', 'Share')} {t('music.share', 'Share')}
</button> </button>
+119
View File
@@ -0,0 +1,119 @@
import { it, expect, vi, beforeEach } from 'vitest';
import { render, screen, fireEvent, waitFor } from '@testing-library/svelte';
const { ui, promptDialog, confirmDialog } = vi.hoisted(() => ({
ui: { notify: vi.fn() },
promptDialog: vi.fn(),
confirmDialog: vi.fn()
}));
vi.mock('$lib/stores/ui.svelte', () => ({ ui }));
vi.mock('$lib/stores/dialogs.svelte', () => ({ promptDialog, confirmDialog }));
vi.mock('$lib/api/endpoints/files', () => ({ fileInlineUrl: () => '/in' }));
vi.mock('$lib/api/endpoints/search', () => ({ searchFiles: vi.fn(async () => ({ items: [] })) }));
vi.mock('$lib/api/endpoints/music', () => ({
addTracks: vi.fn(),
createPlaylist: vi.fn(),
deletePlaylist: vi.fn(),
listPlaylists: vi.fn(),
listShares: vi.fn(async () => []),
listTracks: vi.fn(async () => []),
removeShare: vi.fn(),
removeTrack: vi.fn(),
renamePlaylist: vi.fn(),
reorderTracks: vi.fn(),
sharePlaylist: vi.fn(),
updatePlaylist: vi.fn(),
uploadCoverImage: vi.fn()
}));
import {
listPlaylists,
listTracks,
createPlaylist,
renamePlaylist,
deletePlaylist
} from '$lib/api/endpoints/music';
import MusicPage from './+page.svelte';
const m = (fn: unknown) => fn as ReturnType<typeof vi.fn>;
const playlist = {
id: 'p1',
name: 'Roadtrip',
description: 'songs',
owner_id: 'me',
is_public: false,
cover_file_id: null,
track_count: 1,
total_duration_secs: 180,
created_at: 0,
updated_at: 0
};
const track = {
id: 't1',
playlist_id: 'p1',
file_id: 'f1',
position: 0,
added_at: 0,
file_name: 'song.mp3',
file_size: 100,
mime_type: 'audio/mpeg',
title: 'Song',
artist: 'Artist',
album: 'Album',
duration_secs: 180
};
beforeEach(() => {
vi.clearAllMocks();
m(listTracks).mockResolvedValue([track]);
});
it('renders an empty state when there are no playlists', async () => {
m(listPlaylists).mockResolvedValue([]);
render(MusicPage);
await waitFor(() => expect(listPlaylists).toHaveBeenCalled());
await screen.findByTestId('music-create-playlist-empty-btn');
});
it('selects the first playlist and loads its tracks', async () => {
m(listPlaylists).mockResolvedValue([playlist]);
render(MusicPage);
await waitFor(() => expect(listTracks).toHaveBeenCalledWith('p1'));
await waitFor(() => expect(screen.getAllByText('Roadtrip').length).toBeGreaterThan(0));
});
it('creates a playlist from the header button', async () => {
m(listPlaylists).mockResolvedValue([playlist]);
promptDialog.mockResolvedValue('Chill');
m(createPlaylist).mockResolvedValue({ ...playlist, id: 'p2', name: 'Chill' });
render(MusicPage);
await screen.findByTestId('music-create-playlist-btn');
await fireEvent.click(screen.getByTestId('music-create-playlist-btn'));
await waitFor(() => expect(createPlaylist).toHaveBeenCalledWith('Chill'));
});
it('surfaces a load error', async () => {
m(listPlaylists).mockRejectedValue(new Error('boom'));
render(MusicPage);
await waitFor(() => expect(listPlaylists).toHaveBeenCalled());
await waitFor(() => expect(screen.getByText('boom')).toBeTruthy());
});
it('renames the current playlist', async () => {
m(listPlaylists).mockResolvedValue([playlist]);
promptDialog.mockResolvedValue('Renamed');
m(renamePlaylist).mockResolvedValue(undefined);
render(MusicPage);
await fireEvent.click(await screen.findByTestId('music-rename-playlist-btn'));
await waitFor(() => expect(renamePlaylist).toHaveBeenCalledWith('p1', 'Renamed'));
});
it('deletes the current playlist after confirmation', async () => {
m(listPlaylists).mockResolvedValue([playlist]);
confirmDialog.mockResolvedValue(true);
m(deletePlaylist).mockResolvedValue(undefined);
render(MusicPage);
await fireEvent.click(await screen.findByTestId('music-delete-playlist-btn'));
await waitFor(() => expect(deletePlaylist).toHaveBeenCalledWith('p1'));
});
@@ -70,7 +70,12 @@
<Icon name="ban" class="nc-status__icon nc-status__icon--err" /> <Icon name="ban" class="nc-status__icon nc-status__icon--err" />
<h1>{view.title}</h1> <h1>{view.title}</h1>
<p>{view.message}</p> <p>{view.message}</p>
<button type="button" class="nc-status__action" onclick={onAction}>{view.actionLabel}</button> <button
type="button"
class="nc-status__action"
data-testid="nextcloud-error-action-btn"
onclick={onAction}>{view.actionLabel}</button
>
</main> </main>
<style> <style>
@@ -52,7 +52,12 @@
</div> </div>
{:else} {:else}
{#if passwordLoginEnabled} {#if passwordLoginEnabled}
<form class="auth-form" method="post" action={formAction}> <form
class="auth-form"
data-testid="nextcloud-login-form"
method="post"
action={formAction}
>
<div class="auth-input-group"> <div class="auth-input-group">
<label class="auth-label" for="nc-user">{t('auth.username', 'Username or email')}</label <label class="auth-label" for="nc-user">{t('auth.username', 'Username or email')}</label
> >
@@ -60,6 +65,7 @@
<input <input
id="nc-user" id="nc-user"
class="auth-input" class="auth-input"
data-testid="nextcloud-login-user-input"
name="user" name="user"
type="text" type="text"
autocomplete="username" autocomplete="username"
@@ -73,6 +79,7 @@
<input <input
id="nc-password" id="nc-password"
class="auth-input" class="auth-input"
data-testid="nextcloud-login-password-input"
name="password" name="password"
type="password" type="password"
autocomplete="current-password" autocomplete="current-password"
@@ -80,7 +87,9 @@
/> />
</div> </div>
</div> </div>
<button class="auth-button" type="submit">{t('nextcloud.grant', 'Grant access')}</button> <button class="auth-button" data-testid="nextcloud-login-grant-btn" type="submit"
>{t('nextcloud.grant', 'Grant access')}</button
>
</form> </form>
{/if} {/if}
@@ -89,7 +98,12 @@
<div class="auth-divider"><span>{t('auth.or', 'or')}</span></div> <div class="auth-divider"><span>{t('auth.or', 'or')}</span></div>
{/if} {/if}
<!-- Backend Nextcloud Login Flow v2 OIDC handshake (not a SvelteKit route). --> <!-- Backend Nextcloud Login Flow v2 OIDC handshake (not a SvelteKit route). -->
<a class="auth-button auth-button-sso" href={`/login/v2/flow/${token}/oidc`} rel="external"> <a
class="auth-button auth-button-sso"
data-testid="nextcloud-login-sso-link"
href={`/login/v2/flow/${token}/oidc`}
rel="external"
>
{t('nextcloud.sign_in_with', { provider: oidcProvider }, 'Sign in with {{provider}}')} {t('nextcloud.sign_in_with', { provider: oidcProvider }, 'Sign in with {{provider}}')}
</a> </a>
{/if} {/if}
@@ -22,7 +22,12 @@
<Icon name="check" class="nc-status__icon nc-status__icon--ok" /> <Icon name="check" class="nc-status__icon nc-status__icon--ok" />
<h1>{t('nextcloud.success_title', 'Access granted')}</h1> <h1>{t('nextcloud.success_title', 'Access granted')}</h1>
<p>{t('nextcloud.success_body', 'You can now return to your application — it is connected.')}</p> <p>{t('nextcloud.success_body', 'You can now return to your application — it is connected.')}</p>
<button type="button" class="nc-status__action" onclick={closeWindow}> <button
type="button"
class="nc-status__action"
data-testid="nextcloud-success-close-btn"
onclick={closeWindow}
>
{t('nextcloud.close_window', 'Close Window')} {t('nextcloud.close_window', 'Close Window')}
</button> </button>
</main> </main>
+21 -5
View File
@@ -328,6 +328,7 @@
class:active={tab === 'moments'} class:active={tab === 'moments'}
role="tab" role="tab"
aria-selected={tab === 'moments'} aria-selected={tab === 'moments'}
data-testid="photos-tab-moments"
onclick={() => (tab = 'moments')} onclick={() => (tab = 'moments')}
> >
{t('photos.tab_moments', 'Moments')} {t('photos.tab_moments', 'Moments')}
@@ -337,6 +338,7 @@
class:active={tab === 'places'} class:active={tab === 'places'}
role="tab" role="tab"
aria-selected={tab === 'places'} aria-selected={tab === 'places'}
data-testid="photos-tab-places"
onclick={() => (tab = 'places')} onclick={() => (tab = 'places')}
> >
{t('photos.tab_places', 'Places')} {t('photos.tab_places', 'Places')}
@@ -347,6 +349,7 @@
class:active={tab === 'people'} class:active={tab === 'people'}
role="tab" role="tab"
aria-selected={tab === 'people'} aria-selected={tab === 'people'}
data-testid="photos-tab-people"
onclick={() => (tab = 'people')} onclick={() => (tab = 'people')}
> >
{t('photos.tab_people', 'People')} {t('photos.tab_people', 'People')}
@@ -370,6 +373,7 @@
class:active={layoutMode === 'square'} class:active={layoutMode === 'square'}
title={t('photos.layout_square', 'Grid')} title={t('photos.layout_square', 'Grid')}
aria-label={t('photos.layout_square', 'Grid')} aria-label={t('photos.layout_square', 'Grid')}
data-testid="photos-layout-square-btn"
onclick={() => setLayoutMode('square')}><Icon name="th" /></button onclick={() => setLayoutMode('square')}><Icon name="th" /></button
> >
<button <button
@@ -377,18 +381,25 @@
class:active={layoutMode === 'justified'} class:active={layoutMode === 'justified'}
title={t('photos.layout_justified', 'Justified')} title={t('photos.layout_justified', 'Justified')}
aria-label={t('photos.layout_justified', 'Justified')} aria-label={t('photos.layout_justified', 'Justified')}
data-testid="photos-layout-justified-btn"
onclick={() => setLayoutMode('justified')}><Icon name="layer-group" /></button onclick={() => setLayoutMode('justified')}><Icon name="layer-group" /></button
> >
</div> </div>
</div> </div>
{#if selected.size > 0} {#if selected.size > 0}
<div class="batch-bar"> <div class="batch-bar" data-testid="photos-batch-bar">
<span>{t('files.selected_count', { count: selected.size }, '{{count}} selected')}</span> <span>{t('files.selected_count', { count: selected.size }, '{{count}} selected')}</span>
<div class="batch-bar__actions"> <div class="batch-bar__actions">
<Button onclick={downloadSelected}>{t('common.download', 'Download')}</Button> <Button data-testid="photos-batch-download-btn" onclick={downloadSelected}
<Button onclick={() => selected.clear()}>{t('common.clear', 'Clear')}</Button> >{t('common.download', 'Download')}</Button
<Button variant="danger" onclick={trashSelected}>{t('common.delete', 'Delete')}</Button> >
<Button data-testid="photos-batch-clear-btn" onclick={() => selected.clear()}
>{t('common.clear', 'Clear')}</Button
>
<Button data-testid="photos-batch-delete-btn" variant="danger" onclick={trashSelected}
>{t('common.delete', 'Delete')}</Button
>
</div> </div>
</div> </div>
{/if} {/if}
@@ -449,7 +460,11 @@
{#snippet tile(photo: PhotoItem, sizeStyle?: string)} {#snippet tile(photo: PhotoItem, sizeStyle?: string)}
<div class="photo-tile" class:selected={selected.has(photo.id)} style={sizeStyle}> <div class="photo-tile" class:selected={selected.has(photo.id)} style={sizeStyle}>
<button class="photo-tile__open" onclick={() => onTileClick(photo)}> <button
class="photo-tile__open"
data-testid={`photo-tile-${photo.id}`}
onclick={() => onTileClick(photo)}
>
<!-- Always-present placeholder: the thumbnail <img> overlays it and, when <!-- Always-present placeholder: the thumbnail <img> overlays it and, when
it can't load (no server thumbnail, e.g. SVG), hides itself to reveal it can't load (no server thumbnail, e.g. SVG), hides itself to reveal
this default rather than the browser's broken-image glyph. --> this default rather than the browser's broken-image glyph. -->
@@ -471,6 +486,7 @@
class="photo-tile__check" class="photo-tile__check"
class:on={selected.has(photo.id)} class:on={selected.has(photo.id)}
aria-label={t('common.select', 'Select')} aria-label={t('common.select', 'Select')}
data-testid={`photo-tile-check-${photo.id}`}
onclick={() => selected.toggle(photo.id)} onclick={() => selected.toggle(photo.id)}
> >
<Icon name="check" /> <Icon name="check" />
+88
View File
@@ -0,0 +1,88 @@
import { it, expect, vi, beforeEach } from 'vitest';
import { render, screen, waitFor } from '@testing-library/svelte';
const { ui, confirmDialog } = vi.hoisted(() => ({
ui: { notify: vi.fn() },
confirmDialog: vi.fn()
}));
vi.mock('$lib/stores/ui.svelte', () => ({ ui }));
vi.mock('$lib/stores/dialogs.svelte', () => ({ confirmDialog }));
vi.mock('$lib/api/endpoints/photos', () => ({
batchTrash: vi.fn(),
fetchPhotos: vi.fn(),
uploadThumbnail: vi.fn()
}));
vi.mock('$lib/api/endpoints/people', () => ({ peopleEnabled: vi.fn() }));
vi.mock('$lib/api/endpoints/files', () => ({
fileDownloadUrl: () => '/dl',
fileThumbnailUrl: () => '/thumb'
}));
import { fetchPhotos } from '$lib/api/endpoints/photos';
import { peopleEnabled } from '$lib/api/endpoints/people';
import PhotosPage from './+page.svelte';
const m = (fn: unknown) => fn as ReturnType<typeof vi.fn>;
function photo(id: string) {
return {
category: 'Image',
created_at: 0,
icon_class: 'fa-image',
icon_special_class: '',
id,
mime_type: 'image/jpeg',
modified_at: 0,
name: id + '.jpg',
owner_id: 'me',
folder_id: 'home',
path: '/' + id + '.jpg',
size: 100,
size_formatted: '100 B',
sort_date: 1_700_000_000,
etag: 'e',
content_hash: 'h',
width: 100,
height: 100
};
}
beforeEach(() => {
vi.clearAllMocks();
m(peopleEnabled).mockResolvedValue(false);
});
it('loads the first page of photos on mount', async () => {
m(fetchPhotos).mockResolvedValue({ items: [photo('a')], nextCursor: null });
render(PhotosPage);
await waitFor(() => expect(fetchPhotos).toHaveBeenCalled());
expect(m(fetchPhotos).mock.calls[0][0]).toBe(60);
});
it('shows an empty state when there are no photos', async () => {
m(fetchPhotos).mockResolvedValue({ items: [], nextCursor: null });
render(PhotosPage);
await waitFor(() => expect(fetchPhotos).toHaveBeenCalled());
await waitFor(() => expect(screen.getByTestId('photos-tab-moments')).toBeTruthy());
});
it('surfaces a load error', async () => {
m(fetchPhotos).mockRejectedValue(new Error('photos boom'));
render(PhotosPage);
await waitFor(() => expect(screen.getByText('photos boom')).toBeTruthy());
});
it('hides the People tab when face detection is disabled', async () => {
m(fetchPhotos).mockResolvedValue({ items: [], nextCursor: null });
m(peopleEnabled).mockResolvedValue(false);
render(PhotosPage);
await waitFor(() => expect(peopleEnabled).toHaveBeenCalled());
expect(screen.queryByTestId('photos-tab-people')).toBeNull();
});
it('shows the People tab when face detection is enabled', async () => {
m(fetchPhotos).mockResolvedValue({ items: [], nextCursor: null });
m(peopleEnabled).mockResolvedValue(true);
render(PhotosPage);
await waitFor(() => expect(screen.getByTestId('photos-tab-people')).toBeTruthy());
});
+86 -19
View File
@@ -318,6 +318,7 @@
{#if canEditImage} {#if canEditImage}
<button <button
class="btn btn-secondary avatar-edit-btn" class="btn btn-secondary avatar-edit-btn"
data-testid="profile-avatar-edit-btn"
title={t('profile.edit_photo', 'Edit photo')} title={t('profile.edit_photo', 'Edit photo')}
onclick={openAvatarEdit} onclick={openAvatarEdit}
> >
@@ -327,10 +328,11 @@
</div> </div>
{#if canEditImage && avatarEditOpen} {#if canEditImage && avatarEditOpen}
<div class="avatar-edit"> <div class="avatar-edit" data-testid="profile-avatar-edit-panel">
<div class="avatar-tabs"> <div class="avatar-tabs">
<button <button
class="avatar-tab" class="avatar-tab"
data-testid="profile-avatar-url-tab"
class:avatar-tab--active={avatarTab === 'url'} class:avatar-tab--active={avatarTab === 'url'}
onclick={() => (avatarTab = 'url')} onclick={() => (avatarTab = 'url')}
> >
@@ -338,6 +340,7 @@
</button> </button>
<button <button
class="avatar-tab" class="avatar-tab"
data-testid="profile-avatar-upload-tab"
class:avatar-tab--active={avatarTab === 'upload'} class:avatar-tab--active={avatarTab === 'upload'}
onclick={() => (avatarTab = 'upload')} onclick={() => (avatarTab = 'upload')}
> >
@@ -346,7 +349,12 @@
</div> </div>
{#if avatarTab === 'url'} {#if avatarTab === 'url'}
<input type="url" bind:value={avatarUrl} placeholder="https://example.com/photo.jpg" /> <input
type="url"
data-testid="profile-avatar-url-input"
bind:value={avatarUrl}
placeholder="https://example.com/photo.jpg"
/>
<small class="muted"> <small class="muted">
{t('profile.photo_url_hint', 'https://, http://, or data:image/…;base64,… accepted')} {t('profile.photo_url_hint', 'https://, http://, or data:image/…;base64,… accepted')}
</small> </small>
@@ -356,6 +364,7 @@
<span>{t('profile.photo_choose_file', 'Choose a photo (PNG, JPEG, WebP)')}</span> <span>{t('profile.photo_choose_file', 'Choose a photo (PNG, JPEG, WebP)')}</span>
<input <input
type="file" type="file"
data-testid="profile-avatar-file-input"
accept="image/png,image/jpeg,image/webp" accept="image/png,image/jpeg,image/webp"
hidden hidden
onchange={onAvatarFile} onchange={onAvatarFile}
@@ -373,19 +382,30 @@
{/if} {/if}
<div class="avatar-edit-actions"> <div class="avatar-edit-actions">
<button class="btn btn-primary" disabled={avatarBusy} onclick={saveAvatar}> <button
class="btn btn-primary"
data-testid="profile-avatar-save-btn"
disabled={avatarBusy}
onclick={saveAvatar}
>
{t('profile.photo_save', 'Save')} {t('profile.photo_save', 'Save')}
</button> </button>
{#if session.user.image} {#if session.user.image}
<button <button
class="btn link-btn link-btn--danger" class="btn link-btn link-btn--danger"
data-testid="profile-avatar-remove-btn"
disabled={avatarBusy} disabled={avatarBusy}
onclick={() => commitAvatar(null)} onclick={() => commitAvatar(null)}
> >
{t('profile.photo_remove', 'Remove photo')} {t('profile.photo_remove', 'Remove photo')}
</button> </button>
{/if} {/if}
<button class="btn btn-secondary" disabled={avatarBusy} onclick={closeAvatarEdit}> <button
class="btn btn-secondary"
data-testid="profile-avatar-cancel-btn"
disabled={avatarBusy}
onclick={closeAvatarEdit}
>
{t('common.cancel', 'Cancel')} {t('common.cancel', 'Cancel')}
</button> </button>
</div> </div>
@@ -463,10 +483,11 @@
</span> </span>
</div> </div>
{:else} {:else}
<form onsubmit={saveProfile}> <form data-testid="profile-edit-form" onsubmit={saveProfile}>
<label> <label>
<span>{t('profile.username', 'Username')}</span> <span>{t('profile.username', 'Username')}</span>
<input <input
data-testid="profile-username-input"
bind:value={username} bind:value={username}
maxlength="64" maxlength="64"
autocomplete="username" autocomplete="username"
@@ -483,26 +504,42 @@
</label> </label>
<label> <label>
<span>{t('profile.given_name', 'First name')}</span> <span>{t('profile.given_name', 'First name')}</span>
<input bind:value={givenName} maxlength="128" autocomplete="given-name" /> <input
data-testid="profile-given-name-input"
bind:value={givenName}
maxlength="128"
autocomplete="given-name"
/>
</label> </label>
<label> <label>
<span>{t('profile.family_name', 'Last name')}</span> <span>{t('profile.family_name', 'Last name')}</span>
<input bind:value={familyName} maxlength="128" autocomplete="family-name" /> <input
data-testid="profile-family-name-input"
bind:value={familyName}
maxlength="128"
autocomplete="family-name"
/>
</label> </label>
<label> <label>
<span>{t('profile.language', 'Language')}</span> <span>{t('profile.language', 'Language')}</span>
<select bind:value={preferredLocale}> <select data-testid="profile-language-select" bind:value={preferredLocale}>
<option value="">{t('profile.language_auto', 'Automatic')}</option> <option value="" data-testid="profile-language-auto-option"
>{t('profile.language_auto', 'Automatic')}</option
>
{#each SUPPORTED_LOCALES as loc (loc)} {#each SUPPORTED_LOCALES as loc (loc)}
<option value={loc}>{loc}</option> <option value={loc} data-testid={`profile-language-option-${loc}`}>{loc}</option>
{/each} {/each}
</select> </select>
</label> </label>
<label class="checkbox"> <label class="checkbox">
<input type="checkbox" bind:checked={notifyOnShare} /> <input
type="checkbox"
data-testid="profile-notify-on-share-checkbox"
bind:checked={notifyOnShare}
/>
<span>{t('profile.notify_on_share', 'Email me when someone shares with me')}</span> <span>{t('profile.notify_on_share', 'Email me when someone shares with me')}</span>
</label> </label>
<button type="submit" disabled={savingProfile} <button type="submit" data-testid="profile-save-btn" disabled={savingProfile}
>{t('profile.save_profile', 'Save changes')}</button >{t('profile.save_profile', 'Save changes')}</button
> >
</form> </form>
@@ -522,11 +559,17 @@
<div class="app-pw-create"> <div class="app-pw-create">
<input <input
data-testid="profile-app-pw-label-input"
bind:value={newLabel} bind:value={newLabel}
maxlength="128" maxlength="128"
placeholder={t('profile.app_pw_label_placeholder', 'Label (e.g. Thunderbird, macOS)')} placeholder={t('profile.app_pw_label_placeholder', 'Label (e.g. Thunderbird, macOS)')}
/> />
<button class="btn btn-primary" disabled={creatingPw} onclick={createPw}> <button
class="btn btn-primary"
data-testid="profile-app-pw-generate-btn"
disabled={creatingPw}
onclick={createPw}
>
<Icon name="user-plus" /> <Icon name="user-plus" />
{t('profile.generate', 'Generate')} {t('profile.generate', 'Generate')}
</button> </button>
@@ -542,6 +585,7 @@
<code>{generated.password}</code> <code>{generated.password}</code>
<button <button
class="btn-action" class="btn-action"
data-testid="profile-app-pw-copy-btn"
title={t('profile.copy_to_clipboard', 'Copy to clipboard')} title={t('profile.copy_to_clipboard', 'Copy to clipboard')}
onclick={copyGenerated} onclick={copyGenerated}
> >
@@ -587,6 +631,7 @@
{#if p.active !== false} {#if p.active !== false}
<button <button
class="btn-action btn-action--danger" class="btn-action btn-action--danger"
data-testid={`profile-app-pw-revoke-${p.id}`}
title={t('profile.revoke_title', 'Revoke')} title={t('profile.revoke_title', 'Revoke')}
onclick={() => revokePw(p)} onclick={() => revokePw(p)}
> >
@@ -602,7 +647,11 @@
{#if autoPasswords.length > 0} {#if autoPasswords.length > 0}
<div class="app-pw-auto"> <div class="app-pw-auto">
<button class="app-pw-auto__toggle" onclick={() => (autoExpanded = !autoExpanded)}> <button
class="app-pw-auto__toggle"
data-testid="profile-app-pw-auto-toggle-btn"
onclick={() => (autoExpanded = !autoExpanded)}
>
<Icon name={autoExpanded ? 'chevron-down' : 'chevron-right'} /> <Icon name={autoExpanded ? 'chevron-down' : 'chevron-right'} />
<span>{t('profile.client_sessions', 'Client sessions')}</span> <span>{t('profile.client_sessions', 'Client sessions')}</span>
<span class="badge badge--count">{autoPasswords.length}</span> <span class="badge badge--count">{autoPasswords.length}</span>
@@ -637,6 +686,7 @@
{#if p.active !== false} {#if p.active !== false}
<button <button
class="btn-action btn-action--danger" class="btn-action btn-action--danger"
data-testid={`profile-app-pw-auto-revoke-${p.id}`}
title={t('profile.revoke_title', 'Revoke')} title={t('profile.revoke_title', 'Revoke')}
onclick={() => revokePw(p)} onclick={() => revokePw(p)}
> >
@@ -656,22 +706,39 @@
<!-- Change password --> <!-- Change password -->
{#if showPasswordCard} {#if showPasswordCard}
<form class="card" onsubmit={savePassword}> <form class="card" data-testid="profile-password-form" onsubmit={savePassword}>
<h2><Icon name="key" /> {t('profile.change_password', 'Change Password')}</h2> <h2><Icon name="key" /> {t('profile.change_password', 'Change Password')}</h2>
<label> <label>
<span>{t('profile.current_password', 'Current Password')}</span> <span>{t('profile.current_password', 'Current Password')}</span>
<input type="password" bind:value={currentPw} autocomplete="current-password" /> <input
type="password"
data-testid="profile-current-password-input"
bind:value={currentPw}
autocomplete="current-password"
/>
</label> </label>
<label> <label>
<span>{t('profile.new_password', 'New Password')}</span> <span>{t('profile.new_password', 'New Password')}</span>
<input type="password" bind:value={newPw} minlength="8" autocomplete="new-password" /> <input
type="password"
data-testid="profile-new-password-input"
bind:value={newPw}
minlength="8"
autocomplete="new-password"
/>
<small class="muted">{t('profile.min_8_chars', 'At least 8 characters')}</small> <small class="muted">{t('profile.min_8_chars', 'At least 8 characters')}</small>
</label> </label>
<label> <label>
<span>{t('profile.confirm_password', 'Confirm New Password')}</span> <span>{t('profile.confirm_password', 'Confirm New Password')}</span>
<input type="password" bind:value={confirmPw} minlength="8" autocomplete="new-password" /> <input
type="password"
data-testid="profile-confirm-password-input"
bind:value={confirmPw}
minlength="8"
autocomplete="new-password"
/>
</label> </label>
<button type="submit" disabled={savingPassword}> <button type="submit" data-testid="profile-update-password-btn" disabled={savingPassword}>
{t('profile.update_password', 'Update Password')} {t('profile.update_password', 'Update Password')}
</button> </button>
</form> </form>
+128
View File
@@ -0,0 +1,128 @@
import { it, expect, vi, beforeEach } from 'vitest';
import { render, screen, fireEvent, waitFor } from '@testing-library/svelte';
const { session, ui } = vi.hoisted(() => ({
session: {
loaded: true,
load: vi.fn(),
user: {
id: '1',
username: 'admin',
email: 'a@x.test',
given_name: 'A',
family_name: 'B',
role: 'admin',
storage_used_bytes: 100,
storage_quota_bytes: 1000,
is_external: false
}
},
ui: { notify: vi.fn() }
}));
vi.mock('$lib/stores/session.svelte', () => ({ session }));
vi.mock('$lib/stores/ui.svelte', () => ({ ui }));
vi.mock('$lib/stores/dialogs.svelte', () => ({ confirmDialog: vi.fn() }));
vi.mock('$lib/utils/errors', () => ({ errorToast: vi.fn() }));
vi.mock('$lib/api/endpoints/auth', () => ({ getOidcProviders: vi.fn() }));
vi.mock('$lib/api/endpoints/profile', () => ({
changePassword: vi.fn(),
createAppPassword: vi.fn(),
isAutoAppPassword: () => false,
listAppPasswords: vi.fn(),
revokeAppPassword: vi.fn(),
updateAvatar: vi.fn(),
updateProfile: vi.fn()
}));
import * as profile from '$lib/api/endpoints/profile';
import { getOidcProviders } from '$lib/api/endpoints/auth';
import { confirmDialog } from '$lib/stores/dialogs.svelte';
import ProfilePage from './+page.svelte';
const m = (fn: unknown) => fn as ReturnType<typeof vi.fn>;
beforeEach(() => {
vi.clearAllMocks();
// Reset the shared session each test (handlers may mutate session.user).
session.loaded = true;
session.user = {
id: '1',
username: 'admin',
email: 'a@x.test',
given_name: 'A',
family_name: 'B',
role: 'admin',
storage_used_bytes: 100,
storage_quota_bytes: 1000,
is_external: false
};
m(profile.listAppPasswords).mockResolvedValue([]);
m(profile.updateProfile).mockResolvedValue(undefined);
m(getOidcProviders).mockResolvedValue({ password_login_enabled: true });
});
it('renders and saves the profile form', async () => {
m(profile.updateProfile).mockResolvedValue(undefined);
render(ProfilePage);
await screen.findByTestId('profile-edit-form');
await fireEvent.input(screen.getByTestId('profile-given-name-input'), {
target: { value: 'New' }
});
await fireEvent.click(screen.getByTestId('profile-save-btn'));
await waitFor(() => expect(profile.updateProfile).toHaveBeenCalled());
});
it('generates an app password', async () => {
m(profile.createAppPassword).mockResolvedValue({ id: 'ap1', secret: 'xyz', label: 'tok' });
render(ProfilePage);
await screen.findByTestId('profile-app-pw-label-input');
await fireEvent.input(screen.getByTestId('profile-app-pw-label-input'), {
target: { value: 'tok' }
});
await fireEvent.click(screen.getByTestId('profile-app-pw-generate-btn'));
await waitFor(() => expect(profile.createAppPassword).toHaveBeenCalledWith('tok'));
});
it('rejects a mismatched password change without calling the API', async () => {
render(ProfilePage);
await screen.findByTestId('profile-password-form');
await fireEvent.input(screen.getByTestId('profile-current-password-input'), {
target: { value: 'old' }
});
await fireEvent.input(screen.getByTestId('profile-new-password-input'), {
target: { value: 'new1' }
});
await fireEvent.input(screen.getByTestId('profile-confirm-password-input'), {
target: { value: 'new2' }
});
await fireEvent.click(screen.getByTestId('profile-update-password-btn'));
expect(profile.changePassword).not.toHaveBeenCalled();
});
it('changes the password when the confirmation matches', async () => {
m(profile.changePassword).mockResolvedValue(undefined);
render(ProfilePage);
await screen.findByTestId('profile-password-form');
await fireEvent.input(screen.getByTestId('profile-current-password-input'), {
target: { value: 'OldPassword1!' }
});
await fireEvent.input(screen.getByTestId('profile-new-password-input'), {
target: { value: 'NewPassword1!' }
});
await fireEvent.input(screen.getByTestId('profile-confirm-password-input'), {
target: { value: 'NewPassword1!' }
});
await fireEvent.click(screen.getByTestId('profile-update-password-btn'));
await waitFor(() => expect(profile.changePassword).toHaveBeenCalled());
});
it('revokes an existing app password after confirmation', async () => {
m(profile.listAppPasswords).mockResolvedValue([
{ id: 'ap1', label: 'CLI token', created_at: '2024-01-01T00:00:00Z' }
]);
m(confirmDialog).mockResolvedValue(true);
m(profile.revokeAppPassword).mockResolvedValue(undefined);
render(ProfilePage);
await fireEvent.click(await screen.findByTestId('profile-app-pw-revoke-ap1'));
await waitFor(() => expect(profile.revokeAppPassword).toHaveBeenCalledWith('ap1'));
});
+12 -4
View File
@@ -341,21 +341,29 @@
> >
{#snippet toolbar()} {#snippet toolbar()}
{#if entries.length > 0} {#if entries.length > 0}
<Button icon="broom" onclick={clearAll}>{t('recent.clear', 'Clear recent')}</Button> <Button icon="broom" data-testid="recent-clear-btn" onclick={clearAll}
>{t('recent.clear', 'Clear recent')}</Button
>
{/if} {/if}
{/snippet} {/snippet}
{#snippet batchToolbar()} {#snippet batchToolbar()}
<Button icon="download" onclick={batchDownload}>{t('common.download', 'Download')}</Button> <Button icon="download" data-testid="recent-batch-download-btn" onclick={batchDownload}
>{t('common.download', 'Download')}</Button
>
<Button <Button
icon="arrows-alt" icon="arrows-alt"
data-testid="recent-batch-move-btn"
onclick={() => { onclick={() => {
moveTarget = null; moveTarget = null;
moveItems = batchTargets(); moveItems = batchTargets();
moveOpen = true; moveOpen = true;
}}>{t('files.move', 'Move')}</Button }}>{t('files.move', 'Move')}</Button
> >
<Button variant="danger" icon="trash" onclick={batchDelete} <Button
>{t('common.delete', 'Delete')}</Button variant="danger"
icon="trash"
data-testid="recent-batch-delete-btn"
onclick={batchDelete}>{t('common.delete', 'Delete')}</Button
> >
{/snippet} {/snippet}
</ResourceList> </ResourceList>
+105
View File
@@ -0,0 +1,105 @@
import { it, expect, vi, beforeEach } from 'vitest';
import { render, screen, fireEvent, waitFor } from '@testing-library/svelte';
const { confirmDialog, promptDialog } = vi.hoisted(() => ({
confirmDialog: vi.fn(),
promptDialog: vi.fn()
}));
vi.mock('$lib/api/endpoints/recent', () => ({ clearRecent: vi.fn(), fetchRecentPage: vi.fn() }));
vi.mock('$lib/api/endpoints/favorites', () => ({
addFavorite: vi.fn(),
dateBucket: () => 'Today',
fetchFavoritesPage: vi.fn(async () => ({ items: [], next_cursor: null })),
removeFavorite: vi.fn(),
resolveOwnerName: vi.fn(async () => 'me'),
sizeBucket: () => 'Small',
typeLabel: () => 'File'
}));
vi.mock('$lib/api/endpoints/files', () => ({
fileDownloadUrl: () => '/dl',
renameFile: vi.fn(),
deleteFile: vi.fn()
}));
vi.mock('$lib/api/endpoints/folders', () => ({ renameFolder: vi.fn(), deleteFolder: vi.fn() }));
vi.mock('$lib/stores/dialogs.svelte', () => ({ confirmDialog, promptDialog }));
import { fetchRecentPage, clearRecent } from '$lib/api/endpoints/recent';
import { addFavorite } from '$lib/api/endpoints/favorites';
import { deleteFile } from '$lib/api/endpoints/files';
import RecentPage from './+page.svelte';
const m = (fn: unknown) => fn as ReturnType<typeof vi.fn>;
function withOneFile() {
m(fetchRecentPage).mockResolvedValue({
items: [
{
resource_type: 'file',
accessed_at: '2024-01-01T00:00:00Z',
resource: {
category: 'Document',
created_at: 0,
icon_class: 'fa-file',
icon_special_class: '',
id: 'r1',
mime_type: 'text/plain',
modified_at: 0,
name: 'notes.txt',
owner_id: 'me',
folder_id: 'root',
path: '/notes.txt',
size: 4,
size_formatted: '4 B',
sort_date: 0,
etag: 'e',
content_hash: 'h'
}
}
],
next_cursor: null
});
}
beforeEach(() => vi.clearAllMocks());
it('renders recent items returned by the API', async () => {
withOneFile();
render(RecentPage);
await waitFor(() => expect(fetchRecentPage).toHaveBeenCalled());
await waitFor(() => expect(screen.getByText('notes.txt')).toBeTruthy());
});
it('clears recent activity after confirmation', async () => {
withOneFile();
confirmDialog.mockResolvedValue(true);
m(clearRecent).mockResolvedValue(undefined);
render(RecentPage);
await fireEvent.click(await screen.findByTestId('recent-clear-btn'));
await waitFor(() => expect(clearRecent).toHaveBeenCalled());
});
it('favorites a recent row via the star button', async () => {
withOneFile();
m(addFavorite).mockResolvedValue(undefined);
render(RecentPage);
await screen.findByText('notes.txt');
await fireEvent.click(screen.getByTestId('resource-list-favorite-r1-btn'));
await waitFor(() => expect(addFavorite).toHaveBeenCalledWith('file', 'r1'));
});
it('batch-deletes selected recent items after confirmation', async () => {
withOneFile();
confirmDialog.mockResolvedValue(true);
m(deleteFile).mockResolvedValue(undefined);
render(RecentPage);
await screen.findByText('notes.txt');
await fireEvent.click(screen.getByTestId('resource-list-select-r1-checkbox'));
await fireEvent.click(await screen.findByTestId('recent-batch-delete-btn'));
await waitFor(() => expect(deleteFile).toHaveBeenCalledWith('r1'));
});
it('renders an empty state when there is no recent activity', async () => {
m(fetchRecentPage).mockResolvedValue({ items: [], next_cursor: null });
render(RecentPage);
await waitFor(() => expect(fetchRecentPage).toHaveBeenCalled());
});
+37 -6
View File
@@ -258,23 +258,32 @@
<p>{t('share.expired', 'This share link is no longer available.')}</p> <p>{t('share.expired', 'This share link is no longer available.')}</p>
</div> </div>
{:else if view === 'password'} {:else if view === 'password'}
<form class="share__pw" onsubmit={submitPassword}> <form class="share__pw" data-testid="public-share-password-form" onsubmit={submitPassword}>
<h1>{t('share.password_title', 'Password required')}</h1> <h1>{t('share.password_title', 'Password required')}</h1>
<input <input
type="password" type="password"
data-testid="public-share-password-input"
bind:value={pwInput} bind:value={pwInput}
placeholder={t('share.password', 'Password')} placeholder={t('share.password', 'Password')}
disabled={busy} disabled={busy}
autocomplete="off" autocomplete="off"
/> />
{#if pwError}<p class="share__error" role="alert">{pwError}</p>{/if} {#if pwError}<p class="share__error" role="alert">{pwError}</p>{/if}
<button type="submit" disabled={busy}>{t('share.unlock', 'Unlock')}</button> <button type="submit" data-testid="public-share-unlock-btn" disabled={busy}
>{t('share.unlock', 'Unlock')}</button
>
</form> </form>
{:else if view === 'file'} {:else if view === 'file'}
<div class="share__center"> <div class="share__center">
<Icon name="file" class="share__big-icon" /> <Icon name="file" class="share__big-icon" />
<h1>{meta?.item_name}</h1> <h1>{meta?.item_name}</h1>
<a class="share__btn" href={shareDownloadUrl(token)} download rel="external"> <a
class="share__btn"
data-testid="public-share-download-btn"
href={shareDownloadUrl(token)}
download
rel="external"
>
{t('share.download', 'Download')} {t('share.download', 'Download')}
</a> </a>
</div> </div>
@@ -286,7 +295,11 @@
{#if i === crumbs.length - 1} {#if i === crumbs.length - 1}
<span class="breadcrumb__current">{c.name}</span> <span class="breadcrumb__current">{c.name}</span>
{:else} {:else}
<button class="breadcrumb__link" onclick={() => gotoCrumb(i)}>{c.name}</button> <button
class="breadcrumb__link"
data-testid={`public-share-breadcrumb-${i}`}
onclick={() => gotoCrumb(i)}>{c.name}</button
>
{/if} {/if}
{/each} {/each}
</nav> </nav>
@@ -296,6 +309,7 @@
type="button" type="button"
aria-pressed={viewMode === 'grid'} aria-pressed={viewMode === 'grid'}
class:active={viewMode === 'grid'} class:active={viewMode === 'grid'}
data-testid="public-share-view-grid-btn"
title={t('files.grid', 'Grid')} title={t('files.grid', 'Grid')}
onclick={() => setViewMode('grid')}><Icon name="th" /></button onclick={() => setViewMode('grid')}><Icon name="th" /></button
> >
@@ -303,11 +317,18 @@
type="button" type="button"
aria-pressed={viewMode === 'list'} aria-pressed={viewMode === 'list'}
class:active={viewMode === 'list'} class:active={viewMode === 'list'}
data-testid="public-share-view-list-btn"
title={t('files.list', 'List')} title={t('files.list', 'List')}
onclick={() => setViewMode('list')}><Icon name="bars" /></button onclick={() => setViewMode('list')}><Icon name="bars" /></button
> >
</div> </div>
<a class="share__btn" href={shareZipUrl(token, folderId)} download rel="external"> <a
class="share__btn"
data-testid="public-share-download-zip-btn"
href={shareZipUrl(token, folderId)}
download
rel="external"
>
<Icon name="file-archive" /> <Icon name="file-archive" />
{t('share.download_zip', 'Download ZIP')} {t('share.download_zip', 'Download ZIP')}
</a> </a>
@@ -323,7 +344,11 @@
<ul class="share__grid" class:share__grid--list={viewMode === 'list'}> <ul class="share__grid" class:share__grid--list={viewMode === 'list'}>
{#each listing.folders as f (f.id)} {#each listing.folders as f (f.id)}
<li> <li>
<button class="card" onclick={() => openFolder(f.id, { id: f.id, name: f.name }, true)}> <button
class="card"
data-testid={f.name}
onclick={() => openFolder(f.id, { id: f.id, name: f.name }, true)}
>
<span class="card__thumb"><Icon name="folder" class="card__icon" /></span> <span class="card__thumb"><Icon name="folder" class="card__icon" /></span>
<span class="card__name">{f.name}</span> <span class="card__name">{f.name}</span>
</button> </button>
@@ -341,6 +366,7 @@
<li> <li>
<button <button
class="card" class="card"
data-testid={f.name}
onclick={() => (lightbox = mediaFiles.findIndex((m) => m.id === f.id))} onclick={() => (lightbox = mediaFiles.findIndex((m) => m.id === f.id))}
> >
<span class="card__thumb"> <span class="card__thumb">
@@ -369,6 +395,7 @@
<li> <li>
<a <a
class="card" class="card"
data-testid={f.name}
href={shareFileUrl(token, f.id)} href={shareFileUrl(token, f.id)}
target="_blank" target="_blank"
rel="external noreferrer" rel="external noreferrer"
@@ -390,6 +417,7 @@
<div <div
class="lb" class="lb"
role="dialog" role="dialog"
data-testid="public-share-lightbox-dialog"
aria-modal="true" aria-modal="true"
aria-label={m.name} aria-label={m.name}
tabindex="-1" tabindex="-1"
@@ -397,11 +425,13 @@
> >
<button <button
class="lb__close" class="lb__close"
data-testid="public-share-lightbox-close-btn"
aria-label={t('common.close', 'Close')} aria-label={t('common.close', 'Close')}
onclick={() => (lightbox = -1)}>×</button onclick={() => (lightbox = -1)}>×</button
> >
<button <button
class="lb__nav lb__nav--prev" class="lb__nav lb__nav--prev"
data-testid="public-share-lightbox-prev-btn"
aria-label={t('common.previous', 'Previous')} aria-label={t('common.previous', 'Previous')}
disabled={lightbox === 0} disabled={lightbox === 0}
onclick={(e) => { onclick={(e) => {
@@ -417,6 +447,7 @@
{/if} {/if}
<button <button
class="lb__nav lb__nav--next" class="lb__nav lb__nav--next"
data-testid="public-share-lightbox-next-btn"
aria-label={t('common.next', 'Next')} aria-label={t('common.next', 'Next')}
disabled={lightbox === mediaFiles.length - 1} disabled={lightbox === mediaFiles.length - 1}
onclick={(e) => { onclick={(e) => {
+29 -7
View File
@@ -201,12 +201,18 @@
<div class="search-controls"> <div class="search-controls">
{#if filesStore.currentFolder} {#if filesStore.currentFolder}
<div class="seg" role="group" aria-label={t('search.scope', 'Scope')}> <div class="seg" role="group" aria-label={t('search.scope', 'Scope')}>
<button class="seg__btn" class:active={scope === 'all'} onclick={() => (scope = 'all')}> <button
class="seg__btn"
class:active={scope === 'all'}
data-testid="search-scope-all-btn"
onclick={() => (scope = 'all')}
>
{t('search.everywhere', 'Everywhere')} {t('search.everywhere', 'Everywhere')}
</button> </button>
<button <button
class="seg__btn" class="seg__btn"
class:active={scope === 'folder'} class:active={scope === 'folder'}
data-testid="search-scope-folder-btn"
onclick={() => (scope = 'folder')} onclick={() => (scope = 'folder')}
> >
{t('search.this_folder', 'This folder')} {t('search.this_folder', 'This folder')}
@@ -217,28 +223,40 @@
class="sort-select" class="sort-select"
bind:value={typeFilter} bind:value={typeFilter}
aria-label={t('search.type_label', 'Type')} aria-label={t('search.type_label', 'Type')}
data-testid="search-type-filter-select"
> >
{#each TYPES as o (o.v)}<option value={o.v}>{o.l}</option>{/each} {#each TYPES as o (o.v)}<option value={o.v} data-testid={`search-type-${o.v}`}>{o.l}</option
>{/each}
</select> </select>
<select <select
class="sort-select" class="sort-select"
bind:value={sizeFilter} bind:value={sizeFilter}
aria-label={t('search.size_label', 'Size')} aria-label={t('search.size_label', 'Size')}
data-testid="search-size-filter-select"
> >
{#each SIZES as o (o.v)}<option value={o.v}>{o.l}</option>{/each} {#each SIZES as o (o.v)}<option value={o.v} data-testid={`search-size-${o.v}`}>{o.l}</option
>{/each}
</select> </select>
<select <select
class="sort-select" class="sort-select"
bind:value={dateFilter} bind:value={dateFilter}
aria-label={t('search.date_label', 'Date')} aria-label={t('search.date_label', 'Date')}
data-testid="search-date-filter-select"
> >
{#each DATES as o (o.v)}<option value={o.v}>{o.l}</option>{/each} {#each DATES as o (o.v)}<option value={o.v} data-testid={`search-date-${o.v}`}>{o.l}</option
>{/each}
</select> </select>
<select class="sort-select" bind:value={sortBy} aria-label={t('search.sort_by', 'Sort by')}> <select
{#each SORTS as s (s.v)}<option value={s.v}>{s.l}</option>{/each} class="sort-select"
bind:value={sortBy}
aria-label={t('search.sort_by', 'Sort by')}
data-testid="search-sort-select"
>
{#each SORTS as s (s.v)}<option value={s.v} data-testid={`search-sort-${s.v}`}>{s.l}</option
>{/each}
</select> </select>
{#if hasFilters} {#if hasFilters}
<button class="clear-filters" onclick={clearFilters}> <button class="clear-filters" data-testid="search-clear-filters-btn" onclick={clearFilters}>
<Icon name="times" /> <Icon name="times" />
{t('search.clear_filters', 'Clear filters')} {t('search.clear_filters', 'Clear filters')}
</button> </button>
@@ -281,6 +299,8 @@
class="file-item" class="file-item"
role="button" role="button"
tabindex="0" tabindex="0"
aria-label={e.folder.name}
data-testid={e.folder.name}
onclick={() => openFolder(e.folder)} onclick={() => openFolder(e.folder)}
onkeydown={(ev) => ev.key === 'Enter' && openFolder(e.folder)} onkeydown={(ev) => ev.key === 'Enter' && openFolder(e.folder)}
> >
@@ -297,6 +317,8 @@
class="file-item" class="file-item"
role="button" role="button"
tabindex="0" tabindex="0"
aria-label={e.file.name}
data-testid={e.file.name}
onclick={() => openFile(e.file)} onclick={() => openFile(e.file)}
onkeydown={(ev) => ev.key === 'Enter' && openFile(e.file)} onkeydown={(ev) => ev.key === 'Enter' && openFile(e.file)}
> >
+43
View File
@@ -0,0 +1,43 @@
import { it, expect, vi, beforeEach } from 'vitest';
import { render, screen, waitFor } from '@testing-library/svelte';
const { goto, pageState } = vi.hoisted(() => ({
goto: vi.fn(),
pageState: { url: new URL('http://localhost/search?q=report') }
}));
vi.mock('$app/navigation', () => ({ goto }));
vi.mock('$app/state', () => ({ page: pageState }));
vi.mock('$lib/api/endpoints/search', () => ({ searchFiles: vi.fn() }));
vi.mock('$lib/api/endpoints/files', () => ({ fileInlineUrl: () => '/in' }));
import { searchFiles } from '$lib/api/endpoints/search';
import SearchPage from './+page.svelte';
const m = (fn: unknown) => fn as ReturnType<typeof vi.fn>;
beforeEach(() => {
vi.clearAllMocks();
pageState.url = new URL('http://localhost/search?q=report');
m(searchFiles).mockResolvedValue({ files: [], folders: [], total: 0 });
});
it('runs a search from the q query parameter on mount', async () => {
render(SearchPage);
await waitFor(() => expect(searchFiles).toHaveBeenCalled());
expect(m(searchFiles).mock.calls[0][0]).toBe('report');
});
it('does not search when there is no query', async () => {
pageState.url = new URL('http://localhost/search');
render(SearchPage);
// Give the reactive effect a tick to (not) fire.
await Promise.resolve();
expect(searchFiles).not.toHaveBeenCalled();
});
it('surfaces a search error', async () => {
m(searchFiles).mockRejectedValue(new Error('search boom'));
render(SearchPage);
await waitFor(() => expect(searchFiles).toHaveBeenCalled());
await waitFor(() => expect(screen.getByText('search boom')).toBeTruthy());
});
+47 -7
View File
@@ -391,11 +391,19 @@
<header class="ms-lane__header"> <header class="ms-lane__header">
{#if lane.header.kind === 'resource'} {#if lane.header.kind === 'resource'}
{@const laneItem = lane.header.item} {@const laneItem = lane.header.item}
<button class="ms-lane__resource" onclick={() => openResource(laneItem)}> <button
class="ms-lane__resource"
data-testid={`shared-lane-open-${laneItem.resource.id}`}
onclick={() => openResource(laneItem)}
>
<Icon name={resourceIcon(laneItem)} /> <Icon name={resourceIcon(laneItem)} />
<span class="ms-lane__name">{laneItem.resource.name}</span> <span class="ms-lane__name">{laneItem.resource.name}</span>
</button> </button>
<button class="btn btn-secondary ms-lane__edit" onclick={() => editSharing(laneItem)}> <button
class="btn btn-secondary ms-lane__edit"
data-testid={`shared-edit-sharing-${laneItem.resource.id}`}
onclick={() => editSharing(laneItem)}
>
<Icon name="pencil-alt" /> <Icon name="pencil-alt" />
{t('myshares.editSharing', 'Edit sharing')} {t('myshares.editSharing', 'Edit sharing')}
</button> </button>
@@ -427,7 +435,11 @@
<!-- Identity --> <!-- Identity -->
<span class="ms-row__identity"> <span class="ms-row__identity">
{#if (grant.subject_type === 'user' || grant.subject_type === 'group') && groupBy === 'sharedWith'} {#if (grant.subject_type === 'user' || grant.subject_type === 'group') && groupBy === 'sharedWith'}
<button class="ms-link-btn" onclick={() => openResource(item)}> <button
class="ms-link-btn"
data-testid={`shared-row-open-${grant.grant_id}`}
onclick={() => openResource(item)}
>
<Icon name={resourceIcon(item)} /> <Icon name={resourceIcon(item)} />
<span class="ms-row__name">{item.resource.name}</span> <span class="ms-row__name">{item.resource.name}</span>
</button> </button>
@@ -443,6 +455,7 @@
<button <button
class="ms-chip ms-chip--link" class="ms-chip ms-chip--link"
class:ms-chip--locked={grant.has_password} class:ms-chip--locked={grant.has_password}
data-testid={`shared-copy-link-${grant.grant_id}`}
onclick={() => copyLink(grant)} onclick={() => copyLink(grant)}
title={t('share.copyLink', 'Copy link')} title={t('share.copyLink', 'Copy link')}
> >
@@ -451,7 +464,11 @@
</button> </button>
{#if groupBy === 'sharedWith'} {#if groupBy === 'sharedWith'}
<span class="ms-arrow">→</span> <span class="ms-arrow">→</span>
<button class="ms-link-btn" onclick={() => openResource(item)}> <button
class="ms-link-btn"
data-testid={`shared-link-open-${grant.grant_id}`}
onclick={() => openResource(item)}
>
<Icon name={resourceIcon(item)} /> <Icon name={resourceIcon(item)} />
<span>{item.resource.name}</span> <span>{item.resource.name}</span>
</button> </button>
@@ -480,6 +497,7 @@
aria-label={t('myshares.manageAccess', 'Manage access')} aria-label={t('myshares.manageAccess', 'Manage access')}
aria-haspopup="menu" aria-haspopup="menu"
aria-expanded={menuFor === grant.grant_id} aria-expanded={menuFor === grant.grant_id}
data-testid={`shared-kebab-${grant.grant_id}`}
onclick={(e) => { onclick={(e) => {
e.stopPropagation(); e.stopPropagation();
toggleMenu(grant.grant_id); toggleMenu(grant.grant_id);
@@ -490,11 +508,17 @@
class="ms-menu" class="ms-menu"
role="menu" role="menu"
tabindex="-1" tabindex="-1"
data-testid={`shared-menu-${grant.grant_id}`}
onclick={(e) => e.stopPropagation()} onclick={(e) => e.stopPropagation()}
onkeydown={(e) => e.key === 'Escape' && closeMenu()} onkeydown={(e) => e.key === 'Escape' && closeMenu()}
> >
{#if grant.subject_type === 'user' || grant.subject_type === 'group'} {#if grant.subject_type === 'user' || grant.subject_type === 'group'}
<button class="ms-menu__item" role="menuitem" onclick={() => notify(grant)}> <button
class="ms-menu__item"
role="menuitem"
data-testid={`shared-notify-${grant.grant_id}`}
onclick={() => notify(grant)}
>
<Icon name="paper-plane" /> <Icon name="paper-plane" />
{grant.subject_type === 'group' {grant.subject_type === 'group'
? t('myshares.notifyGroupMembers', 'Notify group members') ? t('myshares.notifyGroupMembers', 'Notify group members')
@@ -508,6 +532,7 @@
class="ms-menu__item" class="ms-menu__item"
class:ms-menu__item--current={grant.role === r.v} class:ms-menu__item--current={grant.role === r.v}
role="menuitem" role="menuitem"
data-testid={`shared-role-${r.v}-${grant.grant_id}`}
onclick={() => changeRole(grant, item, r.v)} onclick={() => changeRole(grant, item, r.v)}
> >
<Icon name={grant.role === r.v ? 'check' : r.icon} /> <Icon name={grant.role === r.v ? 'check' : r.icon} />
@@ -520,6 +545,7 @@
<input <input
type="date" type="date"
class="ms-menu__date" class="ms-menu__date"
data-testid={`shared-expiry-${grant.grant_id}-input`}
value={isoToDate(grant.expires_at)} value={isoToDate(grant.expires_at)}
onchange={(e) => onchange={(e) =>
changeExpiry(grant, item, (e.currentTarget as HTMLInputElement).value)} changeExpiry(grant, item, (e.currentTarget as HTMLInputElement).value)}
@@ -529,13 +555,19 @@
<button <button
class="ms-menu__item ms-menu__item--danger" class="ms-menu__item ms-menu__item--danger"
role="menuitem" role="menuitem"
data-testid={`shared-remove-access-${grant.grant_id}`}
onclick={() => removeAccess(grant)} onclick={() => removeAccess(grant)}
> >
<Icon name="user-xmark" /> <Icon name="user-xmark" />
{t('myshares.removeAccess', 'Remove access')} {t('myshares.removeAccess', 'Remove access')}
</button> </button>
{:else} {:else}
<button class="ms-menu__item" role="menuitem" onclick={() => copyLink(grant)}> <button
class="ms-menu__item"
role="menuitem"
data-testid={`shared-menu-copy-link-${grant.grant_id}`}
onclick={() => copyLink(grant)}
>
<Icon name="copy" /> <Icon name="copy" />
{t('myshares.copyLink', 'Copy link')} {t('myshares.copyLink', 'Copy link')}
</button> </button>
@@ -545,6 +577,7 @@
<input <input
type="date" type="date"
class="ms-menu__date" class="ms-menu__date"
data-testid={`shared-link-expiry-${grant.grant_id}-input`}
value={isoToDate(grant.expires_at)} value={isoToDate(grant.expires_at)}
onchange={(e) => onchange={(e) =>
changeLinkExpiry(grant, (e.currentTarget as HTMLInputElement).value)} changeLinkExpiry(grant, (e.currentTarget as HTMLInputElement).value)}
@@ -553,6 +586,7 @@
<button <button
class="ms-menu__item" class="ms-menu__item"
role="menuitem" role="menuitem"
data-testid={`shared-edit-password-${grant.grant_id}`}
onclick={() => editLinkPassword(grant)} onclick={() => editLinkPassword(grant)}
> >
<Icon name={grant.has_password ? 'lock' : 'lock-open'} /> <Icon name={grant.has_password ? 'lock' : 'lock-open'} />
@@ -564,6 +598,7 @@
<button <button
class="ms-menu__item ms-menu__item--danger" class="ms-menu__item ms-menu__item--danger"
role="menuitem" role="menuitem"
data-testid={`shared-delete-link-${grant.grant_id}`}
onclick={() => deleteLink(grant)} onclick={() => deleteLink(grant)}
> >
<Icon name="trash" /> <Icon name="trash" />
@@ -580,7 +615,12 @@
{/each} {/each}
{#if cursor} {#if cursor}
<button class="btn btn-secondary ms-more" onclick={() => load(false)} disabled={loading}> <button
class="btn btn-secondary ms-more"
data-testid="shared-load-more-btn"
onclick={() => load(false)}
disabled={loading}
>
{loading ? t('common.loading', 'Loading…') : t('common.load_more', 'Load more')} {loading ? t('common.loading', 'Loading…') : t('common.load_more', 'Load more')}
</button> </button>
{/if} {/if}
+112
View File
@@ -0,0 +1,112 @@
import { it, expect, vi, beforeEach } from 'vitest';
import { render, screen, fireEvent, waitFor } from '@testing-library/svelte';
const { ui, goto } = vi.hoisted(() => ({ ui: { notify: vi.fn() }, goto: vi.fn() }));
vi.mock('$app/navigation', () => ({ goto }));
vi.mock('$lib/stores/ui.svelte', () => ({ ui }));
vi.mock('$lib/utils/errors', () => ({
errorMessage: (e: Error) => e.message,
errorToast: vi.fn()
}));
vi.mock('$lib/api/endpoints/grants', () => ({
displayRole: (r: string) => r,
expiryToIso: (v: string | null) => v,
fetchMyShares: vi.fn(),
notifyGrantRecipient: vi.fn(async () => ({ outcomes: [] })),
revokeGrant: vi.fn(),
updateGrantRole: vi.fn()
}));
vi.mock('$lib/api/endpoints/recipients', () => ({
ensureResolvers: vi.fn(),
resolveLabel: (_t: string, id: string) => id
}));
vi.mock('$lib/api/endpoints/shares', () => ({
copyShareLink: vi.fn(),
deleteShare: vi.fn(),
getShareById: vi.fn(),
updateShare: vi.fn()
}));
vi.mock('$lib/api/endpoints/files', () => ({ fileInlineUrl: () => '/in' }));
import { fetchMyShares, updateGrantRole, revokeGrant } from '$lib/api/endpoints/grants';
import { ensureResolvers } from '$lib/api/endpoints/recipients';
import SharedPage from './+page.svelte';
const m = (fn: unknown) => fn as ReturnType<typeof vi.fn>;
function grantItem() {
return {
resource_type: 'folder' as const,
resource: {
category: 'Folder',
created_at: 0,
icon_class: 'fa-folder',
icon_special_class: '',
id: 'r1',
is_root: false,
modified_at: 0,
name: 'Docs',
owner_id: 'me',
parent_id: null,
path: '/Docs',
etag: 'e'
},
first_shared_at: '2024-01-01T00:00:00Z',
grants: [
{
grant_id: 'g1',
subject_type: 'user' as const,
subject_id: 'u1',
subject_display: 'Bob',
role: 'viewer' as const,
granted_at: '2024-01-01T00:00:00Z',
expires_at: null,
has_password: false,
is_external: false
}
]
};
}
beforeEach(() => {
vi.clearAllMocks();
m(ensureResolvers).mockResolvedValue(undefined);
});
it('renders a swimlane for each shared resource', async () => {
m(fetchMyShares).mockResolvedValue({ items: [grantItem()], next_cursor: null });
render(SharedPage);
await waitFor(() => expect(fetchMyShares).toHaveBeenCalled());
await screen.findByTestId('shared-kebab-g1');
});
it('changes a grant role from the kebab menu', async () => {
m(fetchMyShares).mockResolvedValue({ items: [grantItem()], next_cursor: null });
m(updateGrantRole).mockResolvedValue(undefined);
render(SharedPage);
await fireEvent.click(await screen.findByTestId('shared-kebab-g1'));
await fireEvent.click(await screen.findByTestId('shared-role-editor-g1'));
await waitFor(() =>
expect(updateGrantRole).toHaveBeenCalledWith(
{ type: 'user', id: 'u1' },
{ type: 'folder', id: 'r1' },
'editor',
null
)
);
});
it('revokes access from the kebab menu', async () => {
m(fetchMyShares).mockResolvedValue({ items: [grantItem()], next_cursor: null });
m(revokeGrant).mockResolvedValue(undefined);
render(SharedPage);
await fireEvent.click(await screen.findByTestId('shared-kebab-g1'));
await fireEvent.click(await screen.findByTestId('shared-remove-access-g1'));
await waitFor(() => expect(revokeGrant).toHaveBeenCalledWith('g1'));
});
it('surfaces a load error without crashing', async () => {
m(fetchMyShares).mockRejectedValue(new Error('boom'));
render(SharedPage);
await waitFor(() => expect(fetchMyShares).toHaveBeenCalled());
});
+8 -2
View File
@@ -174,7 +174,7 @@
> >
{#snippet toolbar()} {#snippet toolbar()}
{#if entries.length > 0} {#if entries.length > 0}
<button class="btn btn-danger" onclick={purgeAll}> <button class="btn btn-danger" data-testid="trash-empty-btn" onclick={purgeAll}>
<Icon name="trash" /> <Icon name="trash" />
{t('trash.empty_action', 'Empty trash')} {t('trash.empty_action', 'Empty trash')}
</button> </button>
@@ -188,11 +188,17 @@
</span> </span>
{/snippet} {/snippet}
{#snippet actions(entry)} {#snippet actions(entry)}
<button class="btn-action" title={t('trash.restore', 'Restore')} onclick={() => restore(entry)}> <button
class="btn-action"
data-testid={`trash-restore-btn-${entry.id}`}
title={t('trash.restore', 'Restore')}
onclick={() => restore(entry)}
>
<Icon name="undo" /> <Icon name="undo" />
</button> </button>
<button <button
class="btn-action btn-action--delete" class="btn-action btn-action--delete"
data-testid={`trash-delete-btn-${entry.id}`}
title={t('trash.delete', 'Delete permanently')} title={t('trash.delete', 'Delete permanently')}
onclick={() => purge(entry)} onclick={() => purge(entry)}
> >
+119
View File
@@ -0,0 +1,119 @@
/**
* Svelte markup preprocessor that strips test-only `data-testid` attributes at
* compile time for production builds.
*
* The attribute name, value, and any `{expression}` are removed from the
* template *before* Svelte compiles it, so production output carries no trace —
* neither in the rendered DOM nor in the JS bundle (no `'data-testid'` string
* and no attribute-setting call survive). A runtime guard could only blank the
* value; this removes the attribute outright.
*
* The attribute is kept intact when:
* - `VITE_E2E=1` is set (the e2e image / `just fe-build-e2e` build), or
* - the build is not a production build (the `vite dev` server),
* so Playwright and `playwright codegen` can rely on `getByTestId`.
*
* Only the markup *between* `<script>`/`<style>` blocks is scanned, so the token
* `data-testid` appearing inside component logic or styles is never touched.
*/
const ATTR = 'data-testid';
/** Strip only for production builds that didn't opt into test ids. */
function shouldStrip() {
if (process.env.VITE_E2E === '1') return false;
return process.env.NODE_ENV === 'production';
}
/** @returns {import('svelte/compiler').PreprocessorGroup} */
export function stripTestId() {
return {
name: 'strip-testid',
markup({ content }) {
if (!shouldStrip() || !content.includes(ATTR)) return;
return { code: stripOutsideBlocks(content) };
}
};
}
/** Run the attribute removal on markup only, leaving `<script>`/`<style>` verbatim. */
function stripOutsideBlocks(source) {
const blockRe = /<(script|style)\b[^>]*>[\s\S]*?<\/\1>/gi;
let result = '';
let last = 0;
let m;
while ((m = blockRe.exec(source)) !== null) {
result += removeAttr(source.slice(last, m.index));
result += m[0];
last = m.index + m[0].length;
}
result += removeAttr(source.slice(last));
return result;
}
/**
* Remove every `data-testid` attribute occurrence from a markup fragment,
* handling `="literal"`, `='literal'`, `={balanced expression}` (respecting
* nested braces and string literals), unquoted values, and the bare boolean
* form. One preceding space is consumed so no double-space is left behind.
*/
function removeAttr(markup) {
let out = '';
let i = 0;
while (i < markup.length) {
const idx = markup.indexOf(ATTR, i);
if (idx === -1) {
out += markup.slice(i);
break;
}
const prev = markup[idx - 1];
const after = markup[idx + ATTR.length];
const boundaryBefore = prev === undefined || /\s/.test(prev);
const boundaryAfter = after === undefined || after === '=' || /[\s/>]/.test(after);
if (!boundaryBefore || !boundaryAfter) {
out += markup.slice(i, idx + ATTR.length);
i = idx + ATTR.length;
continue;
}
// Emit up to the attribute, dropping one leading whitespace char if present.
const cut = prev !== undefined && /\s/.test(prev) ? idx - 1 : idx;
out += markup.slice(i, cut);
// Advance past the attribute and its value (if any).
let j = idx + ATTR.length;
if (markup[j] === '=') {
j++;
const q = markup[j];
if (q === '"' || q === "'") {
j++;
while (j < markup.length && markup[j] !== q) j++;
j++; // consume the closing quote
} else if (q === '{') {
let depth = 0;
while (j < markup.length) {
const c = markup[j];
if (c === '"' || c === "'" || c === '`') {
const sq = c;
j++;
while (j < markup.length && markup[j] !== sq) {
if (markup[j] === '\\') j++;
j++;
}
} else if (c === '{') {
depth++;
} else if (c === '}') {
depth--;
if (depth === 0) {
j++;
break;
}
}
j++;
}
} else {
while (j < markup.length && !/[\s/>]/.test(markup[j])) j++;
}
}
i = j;
}
return out;
}
+73 -1
View File
@@ -1,5 +1,41 @@
import adapter from '@sveltejs/adapter-static'; import adapter from '@sveltejs/adapter-static';
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte'; import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';
import { stripTestId } from './strip-testid.js';
import { createHash } from 'node:crypto';
import { readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { parse } from 'parse5';
// SvelteKit's `kit.csp` hash mode only hashes the inline scripts SvelteKit
// itself generates (its bootstrap) — NOT inline scripts authored in app.html.
// So we compute the SHA-256 of our anti-FOUC theme-init <script> here, at
// config load, and feed it into `script-src`. This keeps the script INLINE
// (zero extra request) while auto-managing its hash: edit the script and the
// hash regenerates on the next build — the CSP can't drift out of step.
//
// We parse app.html with parse5 (WHATWG-compliant) and target the <script> by
// id, so the lookup is exact. parse5 returns a raw-text element's content
// byte-for-byte, and SvelteKit emits app.html verbatim (only %sveltekit.*%
// substitution, no minification of the shell), so the bytes we hash equal what
// the browser parses. Throws if the id is missing — failing the build loudly
// rather than shipping a CSP that silently blocks the script.
function inlineScriptHash(htmlPath, id) {
const stack = [parse(readFileSync(htmlPath, 'utf-8'))];
while (stack.length) {
const node = stack.pop();
for (const child of node.childNodes ?? []) stack.push(child);
if (node.tagName === 'script' && node.attrs?.some((a) => a.name === 'id' && a.value === id)) {
const body = (node.childNodes ?? []).map((c) => c.value ?? '').join('');
return `sha256-${createHash('sha256').update(body, 'utf8').digest('base64')}`;
}
}
throw new Error(`svelte.config.js: no inline <script id="${id}"> found in ${htmlPath}`);
}
const themeInitHash = inlineScriptHash(
fileURLToPath(new URL('./src/app.html', import.meta.url)),
'theme-init'
);
/** /**
* SvelteKit config — pure SPA via adapter-static. * SvelteKit config — pure SPA via adapter-static.
@@ -15,7 +51,9 @@ import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';
* @type {import('@sveltejs/kit').Config} * @type {import('@sveltejs/kit').Config}
*/ */
const config = { const config = {
preprocess: vitePreprocess(), // `stripTestId` removes `data-testid` attributes from production builds; it
// runs after vitePreprocess and only scans markup outside <script>/<style>.
preprocess: [vitePreprocess(), stripTestId()],
kit: { kit: {
adapter: adapter({ adapter: adapter({
// Cutover: emit the SPA into the repo-root `static-dist/` that the Rust // Cutover: emit the SPA into the repo-root `static-dist/` that the Rust
@@ -36,6 +74,40 @@ const config = {
// code after a rebuild (the classic "my fix isn't applied" trap). // code after a rebuild (the classic "my fix isn't applied" trap).
version: { version: {
pollInterval: 60000 pollInterval: 60000
},
// Content-Security-Policy for the SPA document.
//
// The Rust server deliberately does NOT send a CSP *header* on text/html
// responses (see `content_security_policy` in src/main.rs); this <meta>
// policy is the sole, strict authority for the app shell. `mode: 'hash'`
// auto-emits the SHA-256 of SvelteKit's inline bootstrap; `themeInitHash`
// (computed above from app.html) covers the inline theme-init script. Net
// result: strict script-src with no `'unsafe-inline'`. Directives mirror
// the server's header policy for every other response. (The shell has no
// inline <style>, so `style-src 'unsafe-inline'` stays effective for
// runtime element.style.)
csp: {
mode: 'hash',
directives: {
'default-src': ['self'],
'script-src': ['self', themeInitHash],
'worker-src': ['self'],
'style-src': ['self', 'unsafe-inline'],
'img-src': ['self', 'data:', 'blob:', 'https:'],
'media-src': ['self', 'blob:'],
'connect-src': ['self'],
'font-src': ['self', 'data:'],
'frame-src': ['*', 'blob:'],
'frame-ancestors': ['none'],
'base-uri': ['self'],
// 'https:' (beyond 'self') so the in-app WOPI office editor works: the
// modal POSTs a hidden token form to the editor's action URL, which is
// a cross-origin, admin-configured Collabora/OnlyOffice host (the same
// host 'frame-src *' already lets us iframe). Without this the browser
// refuses the submit and the editor never loads. Mirrors the server
// header in src/main.rs.
'form-action': ['self', 'https:']
}
} }
} }
}; };
+27 -2
View File
@@ -1,10 +1,17 @@
import { sveltekit } from '@sveltejs/kit/vite'; import { sveltekit } from '@sveltejs/kit/vite';
import { defineConfig } from 'vitest/config'; import { defineConfig } from 'vitest/config';
import istanbul from 'vite-plugin-istanbul';
import { svelteTesting } from '@testing-library/svelte/vite';
// Backend dev server (cargo run) — the Vite dev server proxies API/protocol // Backend dev server (cargo run) — the Vite dev server proxies API/protocol
// traffic here so cookies, CSRF, and the auth-refresh flow are same-origin. // traffic here so cookies, CSRF, and the auth-refresh flow are same-origin.
const BACKEND = process.env.OXICLOUD_BACKEND ?? 'http://localhost:8086'; const BACKEND = process.env.OXICLOUD_BACKEND ?? 'http://localhost:8086';
// When COVERAGE=1, instrument the app source with Istanbul so Playwright e2e
// runs can read `window.__coverage__` and report SvelteKit code coverage. Off
// by default so normal dev/release builds carry no instrumentation overhead.
const COVERAGE = process.env.COVERAGE === '1';
const proxy = { const proxy = {
'/api': { target: BACKEND, changeOrigin: true }, '/api': { target: BACKEND, changeOrigin: true },
'/locales': { target: BACKEND, changeOrigin: true }, '/locales': { target: BACKEND, changeOrigin: true },
@@ -20,14 +27,32 @@ const proxy = {
}; };
export default defineConfig({ export default defineConfig({
plugins: [sveltekit()], plugins: [
sveltekit(),
// Compiles Svelte components in client mode for Vitest component tests
// (so onMount etc. run); a no-op outside the test runner.
svelteTesting(),
...(COVERAGE
? [
istanbul({
include: 'src/**/*.{ts,svelte}',
exclude: ['node_modules', 'src/**/*.{test,spec}.{js,ts}'],
extension: ['.ts', '.svelte'],
requireEnv: false,
forceBuildInstrument: true
})
]
: [])
],
server: { server: {
port: 5173, port: 5173,
proxy proxy
}, },
test: { test: {
environment: 'jsdom', environment: 'jsdom',
setupFiles: ['./vitest-setup.ts'], // vitest-coverage.ts is a no-op unless COVERAGE=1; it collects Istanbul
// coverage into tests/e2e/.nyc_output_unit for the combined report.
setupFiles: ['./vitest-setup.ts', './vitest-coverage.ts'],
include: ['src/**/*.{test,spec}.{js,ts}'], include: ['src/**/*.{test,spec}.{js,ts}'],
globals: true globals: true
} }
+25
View File
@@ -0,0 +1,25 @@
/**
* Combined-coverage collector for the unit tests.
*
* When `COVERAGE=1`, `vite-plugin-istanbul` (wired in vite.config.ts) instruments
* `src/` during Vitest's transform — the SAME instrumenter the Playwright e2e
* build uses — so the two coverage sets are mergeable. After every test file we
* dump the accumulated `globalThis.__coverage__` into `tests/e2e/.nyc_output_unit`
* (separate from the e2e `.nyc_output`). `tests/e2e/coverage-report.cjs` can then
* report unit-only, e2e-only, or the merge of both.
*
* Off unless `COVERAGE=1`, so the normal `npm run test:unit` is unaffected.
*/
import { afterAll } from 'vitest';
afterAll(async () => {
if (process.env.COVERAGE !== '1') return;
const cov = (globalThis as Record<string, unknown>).__coverage__;
if (!cov) return;
const fs = await import('node:fs');
const path = await import('node:path');
const dir = path.resolve(process.cwd(), '../tests/e2e/.nyc_output_unit');
fs.mkdirSync(dir, { recursive: true });
const id = `${process.pid}-${Math.random().toString(36).slice(2)}`;
fs.writeFileSync(path.join(dir, `unit-${id}.json`), JSON.stringify(cov));
});
+55
View File
@@ -1 +1,56 @@
import '@testing-library/jest-dom/vitest'; import '@testing-library/jest-dom/vitest';
// jsdom lacks ResizeObserver / IntersectionObserver, which several list and
// virtualization components (ResourceList, VirtualList, photos grid) construct
// on mount. Provide inert stubs so component tests can render them.
class StubObserver {
observe(): void {}
unobserve(): void {}
disconnect(): void {}
takeRecords(): [] {
return [];
}
}
const g = globalThis as Record<string, unknown>;
if (!g.ResizeObserver) g.ResizeObserver = StubObserver;
if (!g.IntersectionObserver) g.IntersectionObserver = StubObserver;
if (!g.scrollTo) g.scrollTo = () => {};
// Node 24+ ships a native global `localStorage`/`sessionStorage` (Web Storage
// API) that is unusable without a backing file and shadows jsdom's storage in
// bare-global access — so `localStorage` reads as undefined in some test files
// on newer Node. Install a deterministic in-memory implementation so storage
// behaves identically across Node versions and is fresh for every test file.
class MemoryStorage {
private store = new Map<string, string>();
get length(): number {
return this.store.size;
}
clear(): void {
this.store.clear();
}
getItem(key: string): string | null {
return this.store.has(key) ? (this.store.get(key) as string) : null;
}
key(index: number): string | null {
return [...this.store.keys()][index] ?? null;
}
removeItem(key: string): void {
this.store.delete(key);
}
setItem(key: string, value: string): void {
this.store.set(key, String(value));
}
}
for (const name of ['localStorage', 'sessionStorage']) {
try {
Object.defineProperty(globalThis, name, {
configurable: true,
writable: true,
value: new MemoryStorage() as unknown as Storage
});
} catch {
g[name] = new MemoryStorage() as unknown as Storage;
}
}

Some files were not shown because too many files have changed in this diff Show More