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:
@@ -0,0 +1 @@
|
||||
{"sessionId":"82cd2c6b-7874-4cfa-9d00-297f91d81b98","pid":2450899,"procStart":"26101142","acquiredAt":1781931933331}
|
||||
@@ -1,6 +1,27 @@
|
||||
# Build artifacts
|
||||
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/
|
||||
.gitignore
|
||||
|
||||
@@ -64,7 +64,7 @@ jobs:
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 26.3.1
|
||||
node-version: 26.3.0
|
||||
cache: npm
|
||||
cache-dependency-path: frontend/package-lock.json
|
||||
|
||||
@@ -240,7 +240,7 @@ jobs:
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 26.3.1
|
||||
node-version: 26.3.0
|
||||
cache: npm
|
||||
cache-dependency-path: frontend/package-lock.json
|
||||
- name: Build SPA (Vite -> static-dist/)
|
||||
@@ -324,12 +324,20 @@ jobs:
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: lts/*
|
||||
node-version: 26.3.0
|
||||
|
||||
- name: Install Node dependencies
|
||||
working-directory: tests/e2e
|
||||
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
|
||||
working-directory: tests/e2e
|
||||
run: npx playwright install --with-deps
|
||||
@@ -340,6 +348,10 @@ jobs:
|
||||
env:
|
||||
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
|
||||
if: ${{ !cancelled() }}
|
||||
with:
|
||||
|
||||
+63
-4
@@ -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) ────────────────
|
||||
FROM rust:1.96-alpine3.24 AS base
|
||||
# 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
|
||||
WORKDIR /frontend
|
||||
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/ ./
|
||||
# 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
|
||||
|
||||
# ─── 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).
|
||||
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 ──────────────────────────────────────────
|
||||
FROM alpine:3.24.0
|
||||
|
||||
@@ -76,19 +135,19 @@ RUN apk --no-cache upgrade && \
|
||||
adduser -u 1001 -S oxicloud -G oxicloud
|
||||
|
||||
# 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
|
||||
# operators can run it inside the container without a separate Rust
|
||||
# toolchain — `docker exec <container> migrate-nfc-filenames --dry-run`
|
||||
# to preview, drop `--dry-run` to execute. One-shot tool, safe to
|
||||
# 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
|
||||
RUN sed -i 's/\r//' /usr/local/bin/entrypoint.sh && \
|
||||
chmod 755 /usr/local/bin/entrypoint.sh
|
||||
|
||||
# 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
|
||||
RUN mkdir -p /app/storage && chown -R oxicloud:oxicloud /app/storage
|
||||
|
||||
|
||||
+7
-2
@@ -21,8 +21,11 @@
|
||||
just
|
||||
cargo-audit
|
||||
|
||||
# frontend tooling (no root package.json — these are expected as global bins)
|
||||
nodejs_24
|
||||
# frontend tooling (no root package.json — these are expected as global bins).
|
||||
# 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
|
||||
typescript # provides `tsc`
|
||||
stylelint
|
||||
@@ -39,6 +42,8 @@
|
||||
# (postgres://postgres:postgres@localhost:5432/oxicloud).
|
||||
services.postgres = {
|
||||
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";
|
||||
port = 5432;
|
||||
initialDatabases = [ { name = "oxicloud"; } ];
|
||||
|
||||
@@ -32,6 +32,14 @@ services:
|
||||
build:
|
||||
context: .
|
||||
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:
|
||||
- "8086:8086"
|
||||
networks:
|
||||
|
||||
@@ -7,3 +7,6 @@ node_modules/
|
||||
*.local
|
||||
vite.config.ts.timestamp-*
|
||||
vite.config.js.timestamp-*
|
||||
|
||||
# Vitest/istanbul coverage output
|
||||
/coverage/
|
||||
|
||||
@@ -16,3 +16,4 @@ static/vendors/
|
||||
static/workers/
|
||||
static/basemaps/
|
||||
static/geo/
|
||||
coverage/
|
||||
|
||||
Generated
+745
-8
@@ -20,6 +20,7 @@
|
||||
"eslint-plugin-svelte": "^3.19.0",
|
||||
"globals": "^17.6.0",
|
||||
"jsdom": "^29.1.1",
|
||||
"parse5": "^7.3.0",
|
||||
"postcss-html": "^1.8.1",
|
||||
"prettier": "^3.8.4",
|
||||
"prettier-plugin-svelte": "^4.1.1",
|
||||
@@ -30,6 +31,7 @@
|
||||
"typescript": "^6.0.3",
|
||||
"typescript-eslint": "^8.61.1",
|
||||
"vite": "^6.4.3",
|
||||
"vite-plugin-istanbul": "^8.0.0",
|
||||
"vitest": "^4.1.9"
|
||||
},
|
||||
"engines": {
|
||||
@@ -109,6 +111,163 @@
|
||||
"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": {
|
||||
"version": "7.29.7",
|
||||
"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_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": {
|
||||
"version": "7.29.7",
|
||||
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz",
|
||||
@@ -129,6 +328,54 @@
|
||||
"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": {
|
||||
"version": "2.4.2",
|
||||
"resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz",
|
||||
@@ -676,6 +923,123 @@
|
||||
"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": {
|
||||
"version": "0.3.13",
|
||||
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
|
||||
@@ -1364,6 +1728,16 @@
|
||||
"dev": true,
|
||||
"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": {
|
||||
"version": "5.2.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz",
|
||||
@@ -1893,6 +2267,19 @@
|
||||
"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": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz",
|
||||
@@ -1929,6 +2316,40 @@
|
||||
"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": {
|
||||
"version": "2.3.5",
|
||||
"resolved": "https://registry.npmjs.org/cacheable/-/cacheable-2.3.5.tgz",
|
||||
@@ -1963,6 +2384,37 @@
|
||||
"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": {
|
||||
"version": "6.2.2",
|
||||
"resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz",
|
||||
@@ -2281,6 +2733,13 @@
|
||||
"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": {
|
||||
"version": "8.0.0",
|
||||
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
|
||||
@@ -2289,13 +2748,13 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/entities": {
|
||||
"version": "8.0.0",
|
||||
"resolved": "https://registry.npmjs.org/entities/-/entities-8.0.0.tgz",
|
||||
"integrity": "sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==",
|
||||
"version": "6.0.1",
|
||||
"resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz",
|
||||
"integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==",
|
||||
"dev": true,
|
||||
"license": "BSD-2-Clause",
|
||||
"engines": {
|
||||
"node": ">=20.19.0"
|
||||
"node": ">=0.12"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/fb55/entities?sponsor=1"
|
||||
@@ -2328,6 +2787,16 @@
|
||||
"dev": true,
|
||||
"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": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz",
|
||||
@@ -2547,6 +3016,20 @@
|
||||
"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": {
|
||||
"version": "1.7.0",
|
||||
"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_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": {
|
||||
"version": "1.6.0",
|
||||
"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"
|
||||
}
|
||||
},
|
||||
"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": {
|
||||
"version": "6.0.2",
|
||||
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz",
|
||||
@@ -3160,6 +3681,33 @@
|
||||
"dev": true,
|
||||
"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": {
|
||||
"version": "4.0.0",
|
||||
"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": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz",
|
||||
@@ -3259,6 +3846,19 @@
|
||||
"dev": true,
|
||||
"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": {
|
||||
"version": "4.5.4",
|
||||
"resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
|
||||
@@ -3468,6 +4068,16 @@
|
||||
"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": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/mri/-/mri-1.2.0.tgz",
|
||||
@@ -3521,6 +4131,16 @@
|
||||
"dev": true,
|
||||
"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": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz",
|
||||
@@ -3595,6 +4215,16 @@
|
||||
"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": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz",
|
||||
@@ -3628,13 +4258,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/parse5": {
|
||||
"version": "8.0.1",
|
||||
"resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz",
|
||||
"integrity": "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==",
|
||||
"version": "7.3.0",
|
||||
"resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz",
|
||||
"integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"entities": "^8.0.0"
|
||||
"entities": "^6.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/inikulin/parse5?sponsor=1"
|
||||
@@ -3660,6 +4290,23 @@
|
||||
"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": {
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz",
|
||||
@@ -4233,6 +4880,16 @@
|
||||
"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": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
|
||||
@@ -4243,6 +4900,13 @@
|
||||
"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": {
|
||||
"version": "0.0.2",
|
||||
"resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz",
|
||||
@@ -4771,6 +5435,21 @@
|
||||
"dev": true,
|
||||
"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": {
|
||||
"version": "2.9.0",
|
||||
"resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz",
|
||||
@@ -5009,6 +5688,37 @@
|
||||
"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": {
|
||||
"version": "4.4.1",
|
||||
"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": {
|
||||
"version": "0.25.12",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz",
|
||||
@@ -5809,6 +6539,13 @@
|
||||
"dev": true,
|
||||
"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": {
|
||||
"version": "2.9.0",
|
||||
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz",
|
||||
|
||||
@@ -15,7 +15,8 @@
|
||||
"lint": "eslint .",
|
||||
"format": "prettier --write .",
|
||||
"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": {
|
||||
"@eslint/js": "^10.0.1",
|
||||
@@ -30,6 +31,7 @@
|
||||
"eslint-plugin-svelte": "^3.19.0",
|
||||
"globals": "^17.6.0",
|
||||
"jsdom": "^29.1.1",
|
||||
"parse5": "^7.3.0",
|
||||
"postcss-html": "^1.8.1",
|
||||
"prettier": "^3.8.4",
|
||||
"prettier-plugin-svelte": "^4.1.1",
|
||||
@@ -40,6 +42,7 @@
|
||||
"typescript": "^6.0.3",
|
||||
"typescript-eslint": "^8.61.1",
|
||||
"vite": "^6.4.3",
|
||||
"vite-plugin-istanbul": "^8.0.0",
|
||||
"vitest": "^4.1.9"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,13 +5,20 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<link rel="icon" href="%sveltekit.assets%/favicon.ico" />
|
||||
<meta name="color-scheme" content="light dark" />
|
||||
%sveltekit.head%
|
||||
<!--
|
||||
Anti-FOUC theme init — runs synchronously before first paint.
|
||||
Ported from static/js/core/theme-init.js. Keeps the legacy
|
||||
`oxicloud_theme` localStorage key and `data-color-scheme` attribute
|
||||
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 () {
|
||||
try {
|
||||
var s = localStorage.getItem('oxicloud_theme');
|
||||
@@ -64,7 +71,6 @@
|
||||
}
|
||||
}
|
||||
</style>
|
||||
%sveltekit.head%
|
||||
</head>
|
||||
<body data-sveltekit-preload-data="hover">
|
||||
<div id="app-splash" role="status" aria-label="Loading">
|
||||
|
||||
@@ -128,3 +128,16 @@ describe('createApiFetch — 401 refresh/retry parity', () => {
|
||||
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.
|
||||
const { blake3Mock, byHashMock, batchMock } = vi.hoisted(() => ({
|
||||
@@ -13,7 +15,96 @@ vi.mock('$lib/api/endpoints/files', () => ({
|
||||
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 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);
|
||||
});
|
||||
@@ -26,7 +26,9 @@ export function searchFiles(query: string, opts: SearchOptions = {}): Promise<Se
|
||||
params.append('query', query);
|
||||
if (opts.folderId) params.append('folder_id', opts.folderId);
|
||||
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.maxSize != null) params.append('max_size', String(opts.maxSize));
|
||||
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);
|
||||
});
|
||||
@@ -268,7 +268,7 @@
|
||||
></div>
|
||||
|
||||
<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">
|
||||
<svg viewBox="95 67 320 320" aria-hidden="true">
|
||||
<path
|
||||
@@ -286,6 +286,7 @@
|
||||
class:active={active(link.href)}
|
||||
href={resolve(link.href)}
|
||||
data-section={link.section}
|
||||
data-testid={`appshell-nav-${link.href.replace(/^\//, '')}-link`}
|
||||
onclick={() => (sidebarOpen = false)}
|
||||
>
|
||||
<Icon name={link.icon} />
|
||||
@@ -324,6 +325,7 @@
|
||||
class="sidebar-toggle"
|
||||
aria-label={t('nav.toggle', 'Toggle navigation menu')}
|
||||
aria-expanded={sidebarOpen}
|
||||
data-testid="appshell-sidebar-toggle-btn"
|
||||
onclick={() => (sidebarOpen = !sidebarOpen)}
|
||||
>
|
||||
<Icon name="bars" />
|
||||
@@ -334,6 +336,7 @@
|
||||
class="search-toggle-btn"
|
||||
id="search-toggle-btn"
|
||||
aria-label={t('actions.search_btn', 'Search')}
|
||||
data-testid="appshell-search-toggle-btn"
|
||||
onclick={openMobileSearch}
|
||||
>
|
||||
<Icon name="search" />
|
||||
@@ -343,6 +346,7 @@
|
||||
<button
|
||||
class="search-back-btn"
|
||||
aria-label={t('common.close', 'Close')}
|
||||
data-testid="appshell-search-back-btn"
|
||||
onclick={closeMobileSearch}
|
||||
>
|
||||
<Icon name="arrow-left" />
|
||||
@@ -355,6 +359,7 @@
|
||||
type="text"
|
||||
bind:this={searchInputEl}
|
||||
bind:value={searchQuery}
|
||||
data-testid="appshell-search-input"
|
||||
oninput={onSearchInput}
|
||||
onfocus={() => (suggestOpen = suggestions.length > 0)}
|
||||
onblur={() => setTimeout(() => (suggestOpen = false), 150)}
|
||||
@@ -367,6 +372,7 @@
|
||||
type="button"
|
||||
title={t('common.clear', 'Clear')}
|
||||
aria-label={t('common.clear', 'Clear')}
|
||||
data-testid="appshell-search-clear-btn"
|
||||
onclick={clearSearch}
|
||||
>
|
||||
<Icon name="times" />
|
||||
@@ -377,6 +383,7 @@
|
||||
type="submit"
|
||||
title={t('actions.search_btn', 'Search')}
|
||||
aria-label={t('actions.search_btn', 'Search')}
|
||||
data-testid="appshell-search-submit-btn"
|
||||
>
|
||||
<Icon name="search" />
|
||||
</button>
|
||||
@@ -385,7 +392,12 @@
|
||||
<ul class="suggest">
|
||||
{#each suggestions as s (s.kind + s.item.id)}
|
||||
<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">
|
||||
{#if s.kind === 'folder'}
|
||||
<Icon name="folder" />
|
||||
@@ -398,7 +410,12 @@
|
||||
</li>
|
||||
{/each}
|
||||
<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')}
|
||||
</button>
|
||||
</li>
|
||||
@@ -411,13 +428,14 @@
|
||||
|
||||
<div class="user-controls">
|
||||
<!-- Notifications -->
|
||||
<div class="notif-wrapper" class:open={notifOpen}>
|
||||
<div class="notif-wrapper" class:open={notifOpen} data-testid="appshell-notif-menu">
|
||||
<button
|
||||
class="notif-bell-btn"
|
||||
class:active={notifOpen}
|
||||
class:ring={bellRinging}
|
||||
aria-label={t('notifications.title', 'Notifications')}
|
||||
aria-haspopup="true"
|
||||
data-testid="appshell-notif-bell-btn"
|
||||
onclick={(e) => {
|
||||
e.stopPropagation();
|
||||
notifOpen = !notifOpen;
|
||||
@@ -436,6 +454,7 @@
|
||||
class="notif-clear-btn"
|
||||
title={t('notifications.clear', 'Clear all')}
|
||||
aria-label={t('notifications.clear', 'Clear all')}
|
||||
data-testid="appshell-notif-clear-btn"
|
||||
onclick={(e) => {
|
||||
e.stopPropagation();
|
||||
ui.clearNotifications();
|
||||
@@ -494,11 +513,12 @@
|
||||
</div>
|
||||
|
||||
<!-- User menu -->
|
||||
<div class="user-menu-wrapper" class:open={menuOpen}>
|
||||
<div class="user-menu-wrapper" class:open={menuOpen} data-testid="appshell-user-menu">
|
||||
<button
|
||||
class="user-avatar-btn"
|
||||
aria-label={t('user_menu.title', 'User menu')}
|
||||
aria-haspopup="true"
|
||||
data-testid="appshell-user-menu-btn"
|
||||
onclick={(e) => {
|
||||
e.stopPropagation();
|
||||
menuOpen = !menuOpen;
|
||||
@@ -555,15 +575,30 @@
|
||||
<div class="user-menu-divider"></div>
|
||||
|
||||
{#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>
|
||||
</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" />
|
||||
<span>{t('user_menu.manage_groups', 'Manage groups')}</span>
|
||||
</a>
|
||||
{/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>
|
||||
</a>
|
||||
|
||||
@@ -572,12 +607,17 @@
|
||||
<div class="user-menu-item user-menu-item--lang">
|
||||
<Icon name="globe" />
|
||||
<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
|
||||
type="button"
|
||||
class="lang-selector__toggle"
|
||||
aria-haspopup="listbox"
|
||||
aria-expanded={langOpen}
|
||||
data-testid="appshell-lang-toggle-btn"
|
||||
onclick={(e) => {
|
||||
e.stopPropagation();
|
||||
langOpen = !langOpen;
|
||||
@@ -597,6 +637,7 @@
|
||||
class:lang-option--active={lang.code === i18n.locale}
|
||||
role="option"
|
||||
aria-selected={lang.code === i18n.locale}
|
||||
data-testid={`appshell-lang-${lang.code}-option`}
|
||||
onclick={(e) => {
|
||||
e.stopPropagation();
|
||||
chooseLocale(lang.code);
|
||||
@@ -622,6 +663,7 @@
|
||||
class="theme-segmented"
|
||||
role="radiogroup"
|
||||
aria-label={t('user_menu.appearance', 'Appearance')}
|
||||
data-testid="appshell-theme-toggle"
|
||||
>
|
||||
{#each THEMES as th (th.mode)}
|
||||
<button
|
||||
@@ -632,6 +674,7 @@
|
||||
aria-checked={theme.current === th.mode}
|
||||
title={th.label}
|
||||
aria-label={th.label}
|
||||
data-testid={`appshell-theme-${th.mode}-option`}
|
||||
onclick={(e) => {
|
||||
e.stopPropagation();
|
||||
theme.set(th.mode);
|
||||
@@ -643,13 +686,21 @@
|
||||
</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>
|
||||
</button>
|
||||
|
||||
<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>
|
||||
</button>
|
||||
</div>
|
||||
@@ -708,6 +759,7 @@
|
||||
href="https://github.com/AtalayaLabs/OxiCloud/"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
data-testid="appshell-about-github-link"
|
||||
>
|
||||
<Icon name="github" /> GitHub
|
||||
</a>
|
||||
@@ -716,12 +768,17 @@
|
||||
href="https://github.com/AtalayaLabs/OxiCloud/blob/main/LICENSE"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
data-testid="appshell-about-license-link"
|
||||
>
|
||||
<Icon name="file-alt" />
|
||||
{t('user_menu.mit_license', 'MIT License')}
|
||||
</a>
|
||||
</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')}
|
||||
</button>
|
||||
</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'));
|
||||
});
|
||||
@@ -17,6 +17,8 @@
|
||||
onclick?: (e: MouseEvent) => void;
|
||||
/** Extra classes appended after the base `.btn` classes. */
|
||||
class?: string;
|
||||
/** e2e test hook forwarded to the underlying `<button>`. */
|
||||
'data-testid'?: string;
|
||||
children?: Snippet;
|
||||
}
|
||||
|
||||
@@ -29,6 +31,7 @@
|
||||
title,
|
||||
onclick,
|
||||
class: cls = '',
|
||||
'data-testid': testid,
|
||||
children
|
||||
}: Props = $props();
|
||||
|
||||
@@ -37,7 +40,7 @@
|
||||
);
|
||||
</script>
|
||||
|
||||
<button class={className} {type} {disabled} {title} {onclick}>
|
||||
<button class={className} {type} {disabled} {title} data-testid={testid} {onclick}>
|
||||
{#if icon}<Icon name={icon} />{/if}
|
||||
{@render children?.()}
|
||||
</button>
|
||||
|
||||
@@ -277,6 +277,7 @@
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={t('cmdk.title', 'Command palette')}
|
||||
data-testid="command-palette-panel"
|
||||
>
|
||||
<div class="cmdk__search">
|
||||
<Icon name="search" />
|
||||
@@ -288,6 +289,7 @@
|
||||
placeholder={t('cmdk.placeholder', 'Type a command or search…')}
|
||||
autocomplete="off"
|
||||
autofocus
|
||||
data-testid="command-palette-input"
|
||||
/>
|
||||
</div>
|
||||
{#if filtered.length === 0}
|
||||
@@ -301,6 +303,7 @@
|
||||
class:active={i === index}
|
||||
role="option"
|
||||
aria-selected={i === index}
|
||||
data-testid={`command-palette-${cmd.id}-item`}
|
||||
onmouseenter={() => (index = i)}
|
||||
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');
|
||||
});
|
||||
@@ -53,6 +53,7 @@
|
||||
{#if c.opts.message}<p class="dlg-msg">{c.opts.message}</p>{/if}
|
||||
<input
|
||||
class="dlg-input"
|
||||
data-testid="dialog-host-prompt-input"
|
||||
type="text"
|
||||
bind:this={inputEl}
|
||||
bind:value
|
||||
@@ -70,11 +71,22 @@
|
||||
{/if}
|
||||
|
||||
{#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')}
|
||||
</button>
|
||||
{#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
|
||||
? t('common.loading', 'Loading…')
|
||||
: (c.opts.confirmText ?? t('common.ok', 'OK'))}
|
||||
@@ -82,6 +94,7 @@
|
||||
{:else}
|
||||
<button
|
||||
class="btn {c.opts.danger ? 'btn-danger' : 'btn-primary'}"
|
||||
data-testid="dialog-host-confirm-btn"
|
||||
disabled={dialogs.busy}
|
||||
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();
|
||||
});
|
||||
@@ -153,6 +153,7 @@
|
||||
<div
|
||||
class="fv"
|
||||
role="dialog"
|
||||
data-testid="file-viewer-dialog"
|
||||
aria-modal="true"
|
||||
aria-label={file.name}
|
||||
tabindex="-1"
|
||||
@@ -166,6 +167,7 @@
|
||||
<div class="fv__zoom" role="group" aria-label={t('viewer.zoom', 'Zoom')}>
|
||||
<button
|
||||
class="fv__zoom-btn"
|
||||
data-testid="file-viewer-zoom-out-btn"
|
||||
title={t('viewer.zoom_out', 'Zoom out')}
|
||||
aria-label={t('viewer.zoom_out', 'Zoom out')}
|
||||
onclick={() => zoomBy(0.8)}
|
||||
@@ -174,6 +176,7 @@
|
||||
</button>
|
||||
<button
|
||||
class="fv__zoom-btn"
|
||||
data-testid="file-viewer-zoom-reset-btn"
|
||||
title={t('viewer.zoom_reset', 'Reset zoom')}
|
||||
aria-label={t('viewer.zoom_reset', 'Reset zoom')}
|
||||
onclick={resetZoom}
|
||||
@@ -182,6 +185,7 @@
|
||||
</button>
|
||||
<button
|
||||
class="fv__zoom-btn"
|
||||
data-testid="file-viewer-zoom-in-btn"
|
||||
title={t('viewer.zoom_in', 'Zoom in')}
|
||||
aria-label={t('viewer.zoom_in', 'Zoom in')}
|
||||
onclick={() => zoomBy(1.2)}
|
||||
@@ -191,13 +195,18 @@
|
||||
</div>
|
||||
{/if}
|
||||
{#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" />
|
||||
{t('files.edit', 'Edit')}
|
||||
</button>
|
||||
{/if}
|
||||
<a
|
||||
class="btn btn-secondary btn-sm"
|
||||
data-testid="file-viewer-download-link"
|
||||
href={fileDownloadUrl(file.id)}
|
||||
download
|
||||
rel="external"
|
||||
@@ -207,13 +216,19 @@
|
||||
</a>
|
||||
<a
|
||||
class="btn btn-secondary btn-sm"
|
||||
data-testid="file-viewer-open-new-tab-link"
|
||||
href={fileInlineUrl(file.id)}
|
||||
target="_blank"
|
||||
rel="external noreferrer"
|
||||
>
|
||||
<Icon name="external-link-alt" />
|
||||
</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" />
|
||||
</button>
|
||||
</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}
|
||||
<div class="view-toggle" role="group" aria-label={t('view.label', 'View options')}>
|
||||
{#if groups?.length}
|
||||
<div class="group-by-selector">
|
||||
<div class="group-by-selector" data-testid="list-toolbar-groupby-menu">
|
||||
<button
|
||||
class="toggle-btn group-by-btn active"
|
||||
title={t('groupby.title', 'Group by')}
|
||||
aria-haspopup="true"
|
||||
aria-expanded={menuOpen}
|
||||
data-testid="list-toolbar-groupby-btn"
|
||||
onclick={() => (menuOpen = !menuOpen)}
|
||||
>
|
||||
<Icon name={active?.icon ?? 'layer-group'} />
|
||||
@@ -83,6 +84,7 @@
|
||||
class:active={reversed}
|
||||
title={t('sortdir.title', 'Sort direction')}
|
||||
aria-label={t('sort.direction', 'Sort direction')}
|
||||
data-testid="list-toolbar-sort-direction-btn"
|
||||
onclick={() => ondirection?.()}
|
||||
>
|
||||
<Icon name="arrow-up" />
|
||||
@@ -93,6 +95,7 @@
|
||||
<button
|
||||
class="group-by-option"
|
||||
class:active={groupBy === g.key}
|
||||
data-testid={`list-toolbar-groupby-${g.key}-item`}
|
||||
onclick={() => pick(g.key)}
|
||||
>
|
||||
<Icon name={g.icon ?? 'layer-group'} />
|
||||
@@ -110,6 +113,7 @@
|
||||
class:active={filesStore.viewMode === 'grid'}
|
||||
title={t('view.grid', 'Grid view')}
|
||||
aria-pressed={filesStore.viewMode === 'grid'}
|
||||
data-testid="list-toolbar-view-grid-btn"
|
||||
onclick={() => filesStore.setViewMode('grid')}><Icon name="th" /></button
|
||||
>
|
||||
<button
|
||||
@@ -117,6 +121,7 @@
|
||||
class:active={filesStore.viewMode === 'list'}
|
||||
title={t('view.list', 'List view')}
|
||||
aria-pressed={filesStore.viewMode === 'list'}
|
||||
data-testid="list-toolbar-view-list-btn"
|
||||
onclick={() => filesStore.setViewMode('list')}><Icon name="list" /></button
|
||||
>
|
||||
{/if}
|
||||
|
||||
@@ -86,13 +86,17 @@
|
||||
aria-modal="true"
|
||||
aria-label={title}
|
||||
tabindex="-1"
|
||||
data-testid="modal"
|
||||
bind:this={dialogEl}
|
||||
>
|
||||
{#if title}
|
||||
<header class="modal__header">
|
||||
<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>
|
||||
{/if}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
@@ -123,51 +123,73 @@
|
||||
</script>
|
||||
|
||||
<Modal bind:open title={moveTitle}>
|
||||
<div class="mv-nav">
|
||||
<button
|
||||
class="mv-nav-btn"
|
||||
title={t('breadcrumb.home', 'Home')}
|
||||
aria-label={t('breadcrumb.home', 'Home')}
|
||||
disabled={atHome}
|
||||
onclick={goHome}><Icon name="home" /></button
|
||||
>
|
||||
<button
|
||||
class="mv-nav-btn"
|
||||
title={t('dialogs.go_to_parent', 'Go to parent')}
|
||||
aria-label={t('dialogs.go_to_parent', 'Go to parent')}
|
||||
disabled={atHome}
|
||||
onclick={goParent}><Icon name="level-up-alt" /></button
|
||||
>
|
||||
<nav class="mv-crumbs" aria-label="Breadcrumb">
|
||||
{#each crumbs as c, i (c.id)}
|
||||
{#if i > 0}<span class="mv-sep">/</span>{/if}
|
||||
<button class="mv-crumb" onclick={() => gotoCrumb(i)}>{c.name}</button>
|
||||
{/each}
|
||||
</nav>
|
||||
<div data-testid="move-dialog">
|
||||
<div class="mv-nav">
|
||||
<button
|
||||
class="mv-nav-btn"
|
||||
data-testid="move-dialog-home-btn"
|
||||
title={t('breadcrumb.home', 'Home')}
|
||||
aria-label={t('breadcrumb.home', 'Home')}
|
||||
disabled={atHome}
|
||||
onclick={goHome}><Icon name="home" /></button
|
||||
>
|
||||
<button
|
||||
class="mv-nav-btn"
|
||||
data-testid="move-dialog-parent-btn"
|
||||
title={t('dialogs.go_to_parent', 'Go to parent')}
|
||||
aria-label={t('dialogs.go_to_parent', 'Go to parent')}
|
||||
disabled={atHome}
|
||||
onclick={goParent}><Icon name="level-up-alt" /></button
|
||||
>
|
||||
<nav class="mv-crumbs" aria-label="Breadcrumb">
|
||||
{#each crumbs as c, i (c.id)}
|
||||
{#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>
|
||||
|
||||
{#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()}
|
||||
<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')}
|
||||
</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')}
|
||||
</button>
|
||||
{/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-label={item.name}
|
||||
tabindex="-1"
|
||||
data-testid="photo-lightbox"
|
||||
onclick={(e) => e.target === e.currentTarget && close()}
|
||||
>
|
||||
<div class="lb__info">
|
||||
@@ -187,12 +188,18 @@
|
||||
<div class="lb__meta">{meta}</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
|
||||
class="lb__nav lb__nav--prev"
|
||||
aria-label={t('common.previous', 'Previous')}
|
||||
disabled={index === 0}
|
||||
data-testid="photo-lightbox-prev-btn"
|
||||
onclick={(e) => {
|
||||
e.stopPropagation();
|
||||
prev();
|
||||
@@ -221,6 +228,7 @@
|
||||
class="lb__nav lb__nav--next"
|
||||
aria-label={t('common.next', 'Next')}
|
||||
disabled={index === items.length - 1}
|
||||
data-testid="photo-lightbox-next-btn"
|
||||
onclick={(e) => {
|
||||
e.stopPropagation();
|
||||
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)}
|
||||
role={onopen ? 'button' : undefined}
|
||||
tabindex={onopen ? 0 : undefined}
|
||||
aria-label={onopen ? entry.name : undefined}
|
||||
data-testid={entry.name}
|
||||
title={showOwner ? ownerTitle(entry) : undefined}
|
||||
onclick={onopen ? () => onopen(entry) : undefined}
|
||||
onkeydown={onopen ? (e) => e.key === 'Enter' && onopen(entry) : undefined}
|
||||
@@ -319,6 +321,7 @@
|
||||
<input
|
||||
type="checkbox"
|
||||
aria-label={t('common.select', 'Select')}
|
||||
data-testid={`resource-list-select-${entry.id}-checkbox`}
|
||||
checked={selected.has(entry.id)}
|
||||
onchange={() => toggleSelected(entry.id)}
|
||||
/>
|
||||
@@ -360,6 +363,7 @@
|
||||
<button
|
||||
class="rl-star"
|
||||
class:rl-star--on={entry.isFavorite}
|
||||
data-testid={`resource-list-favorite-${entry.id}-btn`}
|
||||
title={entry.isFavorite
|
||||
? t('files.unfavorite', 'Remove favorite')
|
||||
: t('files.favorite', 'Add favorite')}
|
||||
@@ -393,8 +397,18 @@
|
||||
</div>
|
||||
|
||||
{#if selectable && selected.size > 0 && batchToolbar}
|
||||
<div class="rl-batch" role="region" aria-label={t('files.selection', 'Selection')}>
|
||||
<button class="rl-batch__close" title={t('common.clear', 'Clear')} onclick={clearSelection}>
|
||||
<div
|
||||
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" />
|
||||
</button>
|
||||
<span class="rl-batch__count"
|
||||
@@ -454,7 +468,12 @@
|
||||
{/if}
|
||||
|
||||
{#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')}
|
||||
</button>
|
||||
{/if}
|
||||
@@ -470,6 +489,7 @@
|
||||
<input
|
||||
type="checkbox"
|
||||
aria-label={t('common.select_all', 'Select all')}
|
||||
data-testid="resource-list-select-all-checkbox"
|
||||
checked={allSelected}
|
||||
onchange={toggleSelectAll}
|
||||
/>
|
||||
@@ -492,12 +512,19 @@
|
||||
onclick={closeContext}
|
||||
oncontextmenu={(e) => e.preventDefault()}
|
||||
></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)}
|
||||
<button
|
||||
class="rl-ctx-item"
|
||||
class:rl-ctx-item--danger={action.danger}
|
||||
role="menuitem"
|
||||
data-testid={`resource-list-context-${action.key}-item`}
|
||||
onclick={() => {
|
||||
const e = ctxEntry!;
|
||||
closeContext();
|
||||
|
||||
@@ -377,194 +377,249 @@
|
||||
{/snippet}
|
||||
|
||||
<Modal bind:open title={t('share.dialog_title', { name: item?.name ?? '' }, 'Share “{{name}}”')}>
|
||||
<div class="tabs" role="tablist">
|
||||
<button role="tab" aria-selected={tab === 'people'} onclick={() => (tab = 'people')}>
|
||||
{t('share.people', 'People')}
|
||||
</button>
|
||||
<button role="tab" aria-selected={tab === 'link'} onclick={() => (tab = 'link')}>
|
||||
{t('share.public_link', 'Public link')}
|
||||
</button>
|
||||
</div>
|
||||
<div data-testid="share-dialog">
|
||||
<div class="tabs" role="tablist">
|
||||
<button
|
||||
role="tab"
|
||||
data-testid="share-dialog-people-tab"
|
||||
aria-selected={tab === 'people'}
|
||||
onclick={() => (tab = 'people')}
|
||||
>
|
||||
{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 !directoryAvailable && !grantsLoading}
|
||||
<p class="status status--note">
|
||||
{t('share.directoryUnavailable', 'User directory unavailable')}
|
||||
</p>
|
||||
{:else}
|
||||
<div class="add-row">
|
||||
<div class="search">
|
||||
<input
|
||||
placeholder={t('share.add_people', 'Add people, groups, or email…')}
|
||||
bind:value={query}
|
||||
oninput={onQueryInput}
|
||||
autocomplete="off"
|
||||
/>
|
||||
{#if results.length > 0}
|
||||
<ul class="results">
|
||||
{#each results as r (r.type + r.id)}
|
||||
<li>
|
||||
<button class="result" onclick={() => addRecipient(r)}>
|
||||
<Icon
|
||||
name={r.type === 'group'
|
||||
? 'user-group'
|
||||
: r.type === 'email'
|
||||
? 'envelope'
|
||||
: 'user'}
|
||||
{#if tab === 'people'}
|
||||
{#if !directoryAvailable && !grantsLoading}
|
||||
<p class="status status--note">
|
||||
{t('share.directoryUnavailable', 'User directory unavailable')}
|
||||
</p>
|
||||
{:else}
|
||||
<div class="add-row">
|
||||
<div class="search">
|
||||
<input
|
||||
data-testid="share-dialog-search-input"
|
||||
placeholder={t('share.add_people', 'Add people, groups, or email…')}
|
||||
bind:value={query}
|
||||
oninput={onQueryInput}
|
||||
autocomplete="off"
|
||||
/>
|
||||
{#if results.length > 0}
|
||||
<ul class="results">
|
||||
{#each results as r (r.type + r.id)}
|
||||
<li>
|
||||
<button
|
||||
class="result"
|
||||
data-testid={`share-dialog-result-${r.type}-${r.id}`}
|
||||
onclick={() => addRecipient(r)}
|
||||
>
|
||||
<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>
|
||||
{#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>
|
||||
{: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"
|
||||
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>
|
||||
{/each}
|
||||
</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>
|
||||
<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}
|
||||
</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}
|
||||
</div>
|
||||
|
||||
{#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')}
|
||||
</button>
|
||||
{/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();
|
||||
});
|
||||
@@ -10,10 +10,11 @@
|
||||
aria-label={t('notifications.title', 'Notifications')}
|
||||
>
|
||||
{#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>
|
||||
<button
|
||||
class="toast__close"
|
||||
data-testid={`toaster-dismiss-btn-${toast.id}`}
|
||||
aria-label={t('common.dismiss', 'Dismiss')}
|
||||
onclick={() => ui.dismiss(toast.id)}
|
||||
>
|
||||
|
||||
@@ -78,10 +78,21 @@
|
||||
<svelte:window onkeydown={onKeydown} onmessage={onMessage} />
|
||||
|
||||
{#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">
|
||||
<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" />
|
||||
</button>
|
||||
</header>
|
||||
@@ -97,8 +108,18 @@
|
||||
target="wopi_frame"
|
||||
class="wopi__form"
|
||||
>
|
||||
<input type="hidden" name="access_token" value={token} />
|
||||
<input type="hidden" name="access_token_ttl" value={tokenTtl} />
|
||||
<input
|
||||
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>
|
||||
{/if}
|
||||
<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();
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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('');
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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)
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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/);
|
||||
});
|
||||
});
|
||||
@@ -914,27 +914,57 @@
|
||||
<h1>{t('admin.title', 'Admin')}</h1>
|
||||
|
||||
<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" />
|
||||
{t('admin.dashboard', 'Dashboard')}
|
||||
</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" />
|
||||
{t('admin.users', 'Users')}
|
||||
</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" />
|
||||
{t('admin.oidc', 'OIDC / SSO')}
|
||||
</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" />
|
||||
{t('admin.storage_tab', 'Storage')}
|
||||
</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" />
|
||||
{t('admin.smtp', 'Email (SMTP)')}
|
||||
</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" />
|
||||
{t('admin.plugins', 'Plugins')}
|
||||
</button>
|
||||
@@ -1031,6 +1061,7 @@
|
||||
<label class="checkbox">
|
||||
<input
|
||||
type="checkbox"
|
||||
data-testid="admin-dashboard-registration-checkbox"
|
||||
checked={dashboard.registration_enabled}
|
||||
onchange={(e) => toggleRegistration(e.currentTarget.checked)}
|
||||
/>
|
||||
@@ -1106,9 +1137,17 @@
|
||||
{#if !oidc}
|
||||
<p class="status">{t('common.loading', 'Loading…')}</p>
|
||||
{: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">
|
||||
<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>
|
||||
</label>
|
||||
<label
|
||||
@@ -1119,11 +1158,17 @@
|
||||
>
|
||||
<input
|
||||
bind:value={oidc.issuer_url}
|
||||
data-testid="admin-oidc-issuer-input"
|
||||
placeholder="https://idp.example.com"
|
||||
disabled={isEnvLocked(oidc.env_overrides, 'issuer_url')}
|
||||
/></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" />
|
||||
{t('admin.oidc_discover', 'Test / discover')}
|
||||
</button>
|
||||
@@ -1155,6 +1200,7 @@
|
||||
>
|
||||
<input
|
||||
bind:value={oidc.client_id}
|
||||
data-testid="admin-oidc-client-id-input"
|
||||
disabled={isEnvLocked(oidc.env_overrides, 'client_id')}
|
||||
/></label
|
||||
>
|
||||
@@ -1166,6 +1212,7 @@
|
||||
>
|
||||
<input
|
||||
type="password"
|
||||
data-testid="admin-oidc-client-secret-input"
|
||||
bind:value={oidc.client_secret}
|
||||
disabled={isEnvLocked(oidc.env_overrides, 'client_secret')}
|
||||
placeholder={oidc.client_secret_set
|
||||
@@ -1187,6 +1234,7 @@
|
||||
>
|
||||
<input
|
||||
bind:value={oidc.scopes}
|
||||
data-testid="admin-oidc-scopes-input"
|
||||
placeholder="openid profile email"
|
||||
disabled={isEnvLocked(oidc.env_overrides, 'scopes')}
|
||||
/></label
|
||||
@@ -1199,6 +1247,7 @@
|
||||
>
|
||||
<input
|
||||
bind:value={oidc.provider_name}
|
||||
data-testid="admin-oidc-provider-name-input"
|
||||
disabled={isEnvLocked(oidc.env_overrides, 'provider_name')}
|
||||
/></label
|
||||
>
|
||||
@@ -1210,15 +1259,24 @@
|
||||
>
|
||||
<input
|
||||
bind:value={oidc.admin_groups}
|
||||
data-testid="admin-oidc-admin-groups-input"
|
||||
disabled={isEnvLocked(oidc.env_overrides, 'admin_groups')}
|
||||
/></label
|
||||
>
|
||||
<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>
|
||||
</label>
|
||||
<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>
|
||||
</label>
|
||||
{#if oidc.callback_url}
|
||||
@@ -1227,6 +1285,7 @@
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-sm btn-secondary"
|
||||
data-testid="admin-oidc-callback-copy-btn"
|
||||
onclick={() => copyText(oidc?.callback_url ?? '')}
|
||||
>
|
||||
<Icon name="copy" />
|
||||
@@ -1237,7 +1296,12 @@
|
||||
{#if oidcMsg}<p class={oidcMsg.ok ? 'status--ok' : 'status--error'}>
|
||||
{oidcMsg.text}
|
||||
</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')}
|
||||
</button>
|
||||
</form>
|
||||
@@ -1249,10 +1313,14 @@
|
||||
{#if !storage}
|
||||
<p class="status">{t('common.loading', 'Loading…')}</p>
|
||||
{:else}
|
||||
<form class="form" onsubmit={(e) => (e.preventDefault(), doSaveStorage())}>
|
||||
<form
|
||||
class="form"
|
||||
data-testid="admin-storage-form"
|
||||
onsubmit={(e) => (e.preventDefault(), doSaveStorage())}
|
||||
>
|
||||
<label
|
||||
><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="s3">S3</option>
|
||||
</select></label
|
||||
@@ -1260,7 +1328,11 @@
|
||||
{#if sForm.backend === 's3'}
|
||||
<label
|
||||
><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}
|
||||
</select></label
|
||||
>
|
||||
@@ -1272,6 +1344,7 @@
|
||||
>
|
||||
<input
|
||||
bind:value={sForm.endpoint}
|
||||
data-testid="admin-storage-endpoint-input"
|
||||
disabled={isEnvLocked(storage.env_overrides, 's3_endpoint_url')}
|
||||
/></label
|
||||
>
|
||||
@@ -1283,6 +1356,7 @@
|
||||
>
|
||||
<input
|
||||
bind:value={sForm.bucket}
|
||||
data-testid="admin-storage-bucket-input"
|
||||
disabled={isEnvLocked(storage.env_overrides, 's3_bucket')}
|
||||
/></label
|
||||
>
|
||||
@@ -1294,6 +1368,7 @@
|
||||
>
|
||||
<input
|
||||
bind:value={sForm.region}
|
||||
data-testid="admin-storage-region-input"
|
||||
disabled={isEnvLocked(storage.env_overrides, 's3_region')}
|
||||
/></label
|
||||
>
|
||||
@@ -1305,6 +1380,7 @@
|
||||
>
|
||||
<input
|
||||
bind:value={sForm.accessKey}
|
||||
data-testid="admin-storage-access-key-input"
|
||||
disabled={isEnvLocked(storage.env_overrides, 's3_access_key')}
|
||||
placeholder={storage.s3_access_key_set
|
||||
? t('admin.unchanged', 'Leave blank to keep current')
|
||||
@@ -1319,6 +1395,7 @@
|
||||
>
|
||||
<input
|
||||
type="password"
|
||||
data-testid="admin-storage-secret-key-input"
|
||||
bind:value={sForm.secretKey}
|
||||
disabled={isEnvLocked(storage.env_overrides, 's3_secret_key')}
|
||||
placeholder={storage.s3_secret_key_set
|
||||
@@ -1327,7 +1404,11 @@
|
||||
/></label
|
||||
>
|
||||
<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>
|
||||
</label>
|
||||
{/if}
|
||||
@@ -1335,13 +1416,17 @@
|
||||
{storageMsg.text}
|
||||
</p>{/if}
|
||||
<div class="smtp-test">
|
||||
<button class="btn btn-primary" type="submit" disabled={storageBusy}
|
||||
>{t('common.save', 'Save')}</button
|
||||
<button
|
||||
class="btn btn-primary"
|
||||
type="submit"
|
||||
data-testid="admin-storage-save-btn"
|
||||
disabled={storageBusy}>{t('common.save', 'Save')}</button
|
||||
>
|
||||
{#if sForm.backend === 's3'}
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-secondary"
|
||||
data-testid="admin-storage-test-btn"
|
||||
disabled={storageBusy}
|
||||
onclick={doTestStorage}
|
||||
>
|
||||
@@ -1401,30 +1486,43 @@
|
||||
<div class="smtp-test">
|
||||
<!-- Start: only when no migration is active (running/paused) or completed. -->
|
||||
{#if migration.status !== 'running' && migration.status !== 'paused' && migration.status !== 'completed'}
|
||||
<button class="btn btn-primary" onclick={() => doMigration('start')}
|
||||
>{t('admin.mig_start', 'Start')}</button
|
||||
<button
|
||||
class="btn btn-primary"
|
||||
data-testid="admin-migration-start-btn"
|
||||
onclick={() => doMigration('start')}>{t('admin.mig_start', 'Start')}</button
|
||||
>
|
||||
{/if}
|
||||
{#if migration.status === 'running'}
|
||||
<button class="btn btn-secondary" onclick={() => doMigration('pause')}
|
||||
>{t('admin.mig_pause', 'Pause')}</button
|
||||
<button
|
||||
class="btn btn-secondary"
|
||||
data-testid="admin-migration-pause-btn"
|
||||
onclick={() => doMigration('pause')}>{t('admin.mig_pause', 'Pause')}</button
|
||||
>
|
||||
{/if}
|
||||
{#if migration.status === 'paused'}
|
||||
<button class="btn btn-primary" onclick={() => doMigration('resume')}
|
||||
>{t('admin.mig_resume', 'Resume')}</button
|
||||
<button
|
||||
class="btn btn-primary"
|
||||
data-testid="admin-migration-resume-btn"
|
||||
onclick={() => doMigration('resume')}>{t('admin.mig_resume', 'Resume')}</button
|
||||
>
|
||||
{/if}
|
||||
<!-- Verify + Finalize: only once the copy phase has 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" />
|
||||
{verifying
|
||||
? t('admin.mig_verifying', 'Verifying…')
|
||||
: t('admin.mig_verify', 'Verify integrity')}
|
||||
</button>
|
||||
<button class="btn btn-secondary" onclick={() => doMigration('complete')}
|
||||
>{t('admin.mig_complete', 'Finalize')}</button
|
||||
<button
|
||||
class="btn btn-secondary"
|
||||
data-testid="admin-migration-complete-btn"
|
||||
onclick={() => doMigration('complete')}>{t('admin.mig_complete', 'Finalize')}</button
|
||||
>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -1540,10 +1638,16 @@
|
||||
<div class="smtp-test">
|
||||
<input
|
||||
type="email"
|
||||
data-testid="admin-smtp-to-input"
|
||||
bind:value={smtpTo}
|
||||
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" />
|
||||
{smtpSending ? t('admin.smtp_sending', 'Sending…') : t('admin.smtp_send', 'Send')}
|
||||
</button>
|
||||
@@ -1567,7 +1671,11 @@
|
||||
</div>
|
||||
{:else if tab === 'users'}
|
||||
<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" />
|
||||
{t('admin.create_user', 'Create user')}
|
||||
</button>
|
||||
@@ -1644,6 +1752,7 @@
|
||||
<td class="actions">
|
||||
<button
|
||||
class="icon-btn"
|
||||
data-testid={`admin-user-quota-${u.id}`}
|
||||
title={t('admin.edit_quota_title', 'Edit quota')}
|
||||
aria-label={t('admin.edit_quota_title', 'Edit quota')}
|
||||
onclick={() => openQuota(u)}
|
||||
@@ -1653,6 +1762,7 @@
|
||||
{#if !isOidcUser(u)}
|
||||
<button
|
||||
class="icon-btn"
|
||||
data-testid={`admin-user-reset-password-${u.id}`}
|
||||
title={t('admin.reset_password_title', 'Reset password')}
|
||||
aria-label={t('admin.reset_password_title', 'Reset password')}
|
||||
onclick={() => openReset(u)}
|
||||
@@ -1662,6 +1772,7 @@
|
||||
{/if}
|
||||
<button
|
||||
class="icon-btn"
|
||||
data-testid={`admin-user-toggle-role-${u.id}`}
|
||||
title={t('admin.toggle_role_title', 'Toggle admin role')}
|
||||
aria-label={t('admin.toggle_role_title', 'Toggle admin role')}
|
||||
disabled={isSelf(u)}
|
||||
@@ -1671,6 +1782,7 @@
|
||||
</button>
|
||||
<button
|
||||
class="icon-btn {u.active ? 'icon-btn--danger' : 'icon-btn--success'}"
|
||||
data-testid={`admin-user-toggle-active-${u.id}`}
|
||||
title={u.active
|
||||
? t('admin.deactivate_title', 'Deactivate')
|
||||
: t('admin.activate_title', 'Activate')}
|
||||
@@ -1684,6 +1796,7 @@
|
||||
</button>
|
||||
<button
|
||||
class="icon-btn icon-btn--danger"
|
||||
data-testid={`admin-user-delete-${u.id}`}
|
||||
title={t('admin.delete_title', 'Delete user')}
|
||||
aria-label={t('admin.delete_title', 'Delete user')}
|
||||
disabled={isSelf(u)}
|
||||
@@ -1697,10 +1810,16 @@
|
||||
</tbody>
|
||||
</table>
|
||||
<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>
|
||||
<button
|
||||
class="btn"
|
||||
data-testid="admin-users-pager-next-btn"
|
||||
disabled={(pageIndex + 1) * PAGE_SIZE >= total}
|
||||
onclick={() => changePage(1)}>›</button
|
||||
>
|
||||
@@ -1725,6 +1844,7 @@
|
||||
: t('admin.plugins_upload', 'Upload .zip')}
|
||||
<input
|
||||
type="file"
|
||||
data-testid="admin-plugins-install-input"
|
||||
accept=".zip,application/zip"
|
||||
hidden
|
||||
disabled={installing}
|
||||
@@ -1775,6 +1895,7 @@
|
||||
<td class="actions">
|
||||
<button
|
||||
class="icon-btn"
|
||||
data-testid={`admin-plugin-details-${p.id}`}
|
||||
title={t('admin.plugins_details', 'Logs & details')}
|
||||
aria-label={t('admin.plugins_details', 'Logs & details')}
|
||||
onclick={() => openLogs(p)}
|
||||
@@ -1783,6 +1904,7 @@
|
||||
</button>
|
||||
<button
|
||||
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')}
|
||||
aria-label={p.enabled
|
||||
? t('admin.disable', 'Disable')
|
||||
@@ -1793,6 +1915,7 @@
|
||||
</button>
|
||||
<button
|
||||
class="icon-btn icon-btn--danger"
|
||||
data-testid={`admin-plugin-delete-${p.id}`}
|
||||
title={t('common.delete', 'Delete')}
|
||||
aria-label={t('common.delete', 'Delete')}
|
||||
onclick={() => removePlugin(p)}
|
||||
@@ -1809,10 +1932,20 @@
|
||||
</main>
|
||||
|
||||
<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
|
||||
><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
|
||||
><span
|
||||
@@ -1821,17 +1954,24 @@
|
||||
>
|
||||
<input
|
||||
type="email"
|
||||
data-testid="admin-create-user-email-input"
|
||||
bind:value={newUser.email}
|
||||
placeholder={t('admin.email_auto', 'Auto-generated if left blank')}
|
||||
/></label
|
||||
>
|
||||
<label
|
||||
><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
|
||||
><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="admin">admin</option>
|
||||
</select></label
|
||||
@@ -1839,8 +1979,14 @@
|
||||
<label
|
||||
><span>{t('admin.quota', 'Quota')}</span>
|
||||
<div class="quota-input">
|
||||
<input type="number" min="0" step="0.1" bind:value={newUser.quotaValue} />
|
||||
<select bind:value={newUser.quotaUnit}>
|
||||
<input
|
||||
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}
|
||||
</select>
|
||||
@@ -1850,8 +1996,18 @@
|
||||
{#if createError}<p class="status--error">{createError}</p>{/if}
|
||||
</form>
|
||||
{#snippet footer()}
|
||||
<button class="btn" onclick={() => (createOpen = false)}>{t('common.cancel', 'Cancel')}</button>
|
||||
<button class="btn btn--primary" type="submit" form="create-user-form" disabled={creating}>
|
||||
<button
|
||||
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')}
|
||||
</button>
|
||||
{/snippet}
|
||||
@@ -1867,6 +2023,7 @@
|
||||
<form
|
||||
id="quota-form"
|
||||
class="form"
|
||||
data-testid="admin-quota-form"
|
||||
onsubmit={(e) => {
|
||||
e.preventDefault();
|
||||
void saveQuota();
|
||||
@@ -1878,8 +2035,14 @@
|
||||
<label
|
||||
><span>{t('admin.quota', 'Quota')}</span>
|
||||
<div class="quota-input">
|
||||
<input type="number" min="0" step="0.1" bind:value={quotaModal.value} />
|
||||
<select bind:value={quotaModal.unit}>
|
||||
<input
|
||||
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}
|
||||
</select>
|
||||
@@ -1889,8 +2052,15 @@
|
||||
</form>
|
||||
{/if}
|
||||
{#snippet footer()}
|
||||
<button class="btn" onclick={() => (quotaModal = null)}>{t('common.cancel', 'Cancel')}</button>
|
||||
<button class="btn btn--primary" type="submit" form="quota-form">
|
||||
<button class="btn" data-testid="admin-quota-cancel-btn" onclick={() => (quotaModal = null)}
|
||||
>{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')}
|
||||
</button>
|
||||
{/snippet}
|
||||
@@ -1903,20 +2073,41 @@
|
||||
onclose={() => (resetModal = null)}
|
||||
>
|
||||
{#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">
|
||||
{t('admin.reset_pw_for', 'New password for')} <strong>{resetModal.username}</strong>
|
||||
</p>
|
||||
<label
|
||||
><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}
|
||||
</form>
|
||||
{/if}
|
||||
{#snippet footer()}
|
||||
<button class="btn" onclick={() => (resetModal = null)}>{t('common.cancel', 'Cancel')}</button>
|
||||
<button class="btn btn--primary" type="submit" form="reset-pw-form" disabled={resetting}>
|
||||
<button
|
||||
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')}
|
||||
</button>
|
||||
{/snippet}
|
||||
@@ -1930,9 +2121,14 @@
|
||||
>
|
||||
<p>{confirmState?.message}</p>
|
||||
{#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')}
|
||||
</button>
|
||||
{/snippet}
|
||||
@@ -1972,25 +2168,43 @@
|
||||
{/if}
|
||||
|
||||
{#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>
|
||||
<label
|
||||
><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
|
||||
><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}
|
||||
<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
|
||||
>
|
||||
</form>
|
||||
{/if}
|
||||
|
||||
<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="info">info</option>
|
||||
<option value="warn">warn</option>
|
||||
@@ -1998,14 +2212,22 @@
|
||||
</select>
|
||||
<input
|
||||
placeholder={t('admin.logs_search', 'Search…')}
|
||||
data-testid="admin-plugin-logs-search-input"
|
||||
bind:value={logsSearch}
|
||||
onkeydown={(e) => e.key === 'Enter' && reloadLogsFromStart()}
|
||||
/>
|
||||
<button class="btn btn-secondary" onclick={reloadLogsFromStart}
|
||||
>{t('common.search', 'Search')}</button
|
||||
<button
|
||||
class="btn btn-secondary"
|
||||
data-testid="admin-plugin-logs-search-btn"
|
||||
onclick={reloadLogsFromStart}>{t('common.search', 'Search')}</button
|
||||
>
|
||||
<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>
|
||||
</label>
|
||||
</div>
|
||||
@@ -2044,7 +2266,12 @@
|
||||
</div>
|
||||
{/if}
|
||||
<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>
|
||||
{#if logsTotal === 0}
|
||||
{t('admin.logs_empty', 'No log entries.')}
|
||||
@@ -2060,15 +2287,18 @@
|
||||
)}
|
||||
{/if}
|
||||
</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>
|
||||
{#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
|
||||
>
|
||||
<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')}
|
||||
</button>
|
||||
{/snippet}
|
||||
|
||||
@@ -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'));
|
||||
});
|
||||
@@ -123,11 +123,12 @@
|
||||
<h1>{t('device.title', 'Device verification')}</h1>
|
||||
|
||||
{#if step === 'code'}
|
||||
<form onsubmit={lookup}>
|
||||
<form data-testid="device-code-form" onsubmit={lookup}>
|
||||
<label class="device__field">
|
||||
<span>{t('device.enter_code', 'Enter the code shown on your device')}</span>
|
||||
<input
|
||||
bind:this={codeInput}
|
||||
data-testid="device-code-input"
|
||||
value={code}
|
||||
oninput={onCodeInput}
|
||||
autocomplete="off"
|
||||
@@ -137,7 +138,9 @@
|
||||
maxlength={FULL_CODE_LENGTH}
|
||||
/>
|
||||
</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>
|
||||
{:else if step === 'loading'}
|
||||
<p>{t('common.loading', 'Loading…')}</p>
|
||||
@@ -149,10 +152,20 @@
|
||||
<dd>{info?.scopes || 'all'}</dd>
|
||||
</dl>
|
||||
<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')}
|
||||
</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')}
|
||||
</button>
|
||||
</div>
|
||||
@@ -164,7 +177,9 @@
|
||||
<p>{t('device.denied', 'Device access denied.')}</p>
|
||||
{:else if step === 'error'}
|
||||
<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}
|
||||
</div>
|
||||
</main>
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
@@ -300,17 +300,23 @@
|
||||
onselectionchange={(ids) => (selectedIds = ids)}
|
||||
>
|
||||
{#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
|
||||
icon="arrows-alt"
|
||||
data-testid="favorites-batch-move-btn"
|
||||
onclick={() => {
|
||||
moveTarget = null;
|
||||
moveItems = batchTargets();
|
||||
moveOpen = true;
|
||||
}}>{t('files.move', 'Move')}</Button
|
||||
>
|
||||
<Button variant="danger" icon="trash" onclick={batchDelete}
|
||||
>{t('common.delete', 'Delete')}</Button
|
||||
<Button
|
||||
variant="danger"
|
||||
icon="trash"
|
||||
data-testid="favorites-batch-delete-btn"
|
||||
onclick={batchDelete}>{t('common.delete', 'Delete')}</Button
|
||||
>
|
||||
{/snippet}
|
||||
</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">
|
||||
<!-- 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
|
||||
bind:this={folderInput}
|
||||
type="file"
|
||||
multiple
|
||||
hidden
|
||||
webkitdirectory
|
||||
data-testid="files-upload-folder-input"
|
||||
onchange={onUploadFolder}
|
||||
/>
|
||||
|
||||
@@ -1474,12 +1482,13 @@
|
||||
>
|
||||
{#snippet start()}
|
||||
{#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">
|
||||
<button
|
||||
class="batch-bar-close"
|
||||
title={t('files.cancel_selection', 'Cancel selection')}
|
||||
aria-label={t('files.cancel_selection', 'Cancel selection')}
|
||||
data-testid="files-batch-cancel-btn"
|
||||
onclick={clearSelection}
|
||||
>
|
||||
<Icon name="times" />
|
||||
@@ -1493,22 +1502,34 @@
|
||||
<button
|
||||
class="batch-btn"
|
||||
title={t('files.add_favorites', 'Add to favorites')}
|
||||
data-testid="files-batch-favorite-btn"
|
||||
onclick={() => void batchFavorites()}
|
||||
>
|
||||
<Icon name="star" />
|
||||
<span>{t('files.add_favorites', 'Add to favorites')}</span>
|
||||
</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" />
|
||||
<span>{t('files.move', 'Move')}</span>
|
||||
</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" />
|
||||
<span>{t('files.copy', 'Copy')}</span>
|
||||
</button>
|
||||
<button
|
||||
class="batch-btn"
|
||||
title={t('common.download', 'Download')}
|
||||
data-testid="files-batch-download-btn"
|
||||
onclick={() => void batchDownload()}
|
||||
>
|
||||
<Icon name="download" />
|
||||
@@ -1517,6 +1538,7 @@
|
||||
<button
|
||||
class="batch-btn batch-btn-danger"
|
||||
title={t('common.delete', 'Delete')}
|
||||
data-testid="files-batch-delete-btn"
|
||||
onclick={batchDelete}
|
||||
>
|
||||
<Icon name="trash" />
|
||||
@@ -1527,9 +1549,10 @@
|
||||
</div>
|
||||
{:else}
|
||||
<div class="action-buttons">
|
||||
<div class="upload-dropdown">
|
||||
<div class="upload-dropdown" data-testid="files-upload-dropdown">
|
||||
<button
|
||||
class="btn btn-primary"
|
||||
data-testid="files-upload-btn"
|
||||
onclick={() => (uploadMenuOpen = !uploadMenuOpen)}
|
||||
disabled={uploading}
|
||||
aria-haspopup="true"
|
||||
@@ -1544,9 +1567,10 @@
|
||||
<Icon name="caret-down" class="upload-caret" />
|
||||
</button>
|
||||
{#if uploadMenuOpen}
|
||||
<div class="upload-dropdown-menu">
|
||||
<div class="upload-dropdown-menu" data-testid="files-upload-menu">
|
||||
<button
|
||||
class="upload-dropdown-item"
|
||||
data-testid="files-upload-files-item"
|
||||
onclick={() => {
|
||||
uploadMenuOpen = false;
|
||||
fileInput?.click();
|
||||
@@ -1557,6 +1581,7 @@
|
||||
</button>
|
||||
<button
|
||||
class="upload-dropdown-item"
|
||||
data-testid="files-upload-folder-item"
|
||||
onclick={() => {
|
||||
uploadMenuOpen = false;
|
||||
folderInput?.click();
|
||||
@@ -1568,7 +1593,11 @@
|
||||
</div>
|
||||
{/if}
|
||||
</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" />
|
||||
<span>{t('actions.new_folder', 'New folder')}</span>
|
||||
</button>
|
||||
@@ -1593,6 +1622,7 @@
|
||||
class="breadcrumb-item breadcrumb-link"
|
||||
class:breadcrumb-home={i === 0}
|
||||
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()}
|
||||
ondrop={(e) => onCrumbDrop(e, c.id)}
|
||||
>
|
||||
@@ -1655,6 +1685,7 @@
|
||||
<input
|
||||
type="checkbox"
|
||||
aria-label={t('files.select_all', 'Select all')}
|
||||
data-testid="files-select-all-checkbox"
|
||||
checked={selectedCount > 0 && selectedCount === totalCount}
|
||||
indeterminate={selectedCount > 0 && selectedCount < totalCount}
|
||||
onchange={toggleSelectAll}
|
||||
@@ -1668,6 +1699,7 @@
|
||||
class="list-header-sort"
|
||||
class:is-active={sortField === col.f}
|
||||
data-sort-field={col.f}
|
||||
data-testid={`files-sort-${col.f}-btn`}
|
||||
onclick={() => toggleSort(col.f as SortField)}
|
||||
>
|
||||
{col.l}
|
||||
@@ -1700,6 +1732,8 @@
|
||||
role="button"
|
||||
tabindex="0"
|
||||
draggable="true"
|
||||
aria-label={folder.name}
|
||||
data-testid={folder.name}
|
||||
ondragstart={(e) => onItemDragStart(e, 'folder', folder.id, folder.name)}
|
||||
ondragover={(e) => {
|
||||
if (e.dataTransfer?.types.includes(DRAG_TYPE)) {
|
||||
@@ -1723,6 +1757,7 @@
|
||||
type="checkbox"
|
||||
checked={selected.has(folder.id)}
|
||||
aria-label={folder.name}
|
||||
data-testid={`files-folder-checkbox-${folder.id}`}
|
||||
onclick={(e) => {
|
||||
e.stopPropagation();
|
||||
toggleSelected(folder.id);
|
||||
@@ -1760,6 +1795,7 @@
|
||||
? t('files.unfavorite', 'Remove favorite')
|
||||
: t('files.favorite', 'Add favorite')}
|
||||
aria-pressed={favoriteIds.has(folder.id)}
|
||||
data-testid={`files-folder-favorite-${folder.id}`}
|
||||
onclick={(e) => {
|
||||
e.stopPropagation();
|
||||
void toggleFavorite('folder', folder.id);
|
||||
@@ -1768,6 +1804,7 @@
|
||||
<button
|
||||
class="btn-action"
|
||||
title={t('files.share', 'Share')}
|
||||
data-testid={`files-folder-share-${folder.id}`}
|
||||
onclick={(e) => {
|
||||
e.stopPropagation();
|
||||
openShare('folder', folder.id, folder.name);
|
||||
@@ -1776,6 +1813,7 @@
|
||||
<button
|
||||
class="btn-action"
|
||||
title={t('files.move', 'Move')}
|
||||
data-testid={`files-folder-move-${folder.id}`}
|
||||
onclick={(e) => {
|
||||
e.stopPropagation();
|
||||
openMove('folder', folder.id, folder.name);
|
||||
@@ -1784,6 +1822,7 @@
|
||||
<button
|
||||
class="btn-action"
|
||||
title={t('common.rename', 'Rename')}
|
||||
data-testid={`files-folder-rename-${folder.id}`}
|
||||
onclick={(e) => {
|
||||
e.stopPropagation();
|
||||
renameItem('folder', folder.id, folder.name);
|
||||
@@ -1792,6 +1831,7 @@
|
||||
<button
|
||||
class="btn-action btn-action--delete"
|
||||
title={t('common.delete', 'Delete')}
|
||||
data-testid={`files-folder-delete-${folder.id}`}
|
||||
onclick={(e) => {
|
||||
e.stopPropagation();
|
||||
deleteItem('folder', folder.id, folder.name);
|
||||
@@ -1802,6 +1842,7 @@
|
||||
title={t('files.more_actions', 'More actions')}
|
||||
aria-label={t('files.more_actions', 'More actions')}
|
||||
aria-haspopup="menu"
|
||||
data-testid={`files-folder-more-${folder.id}`}
|
||||
onclick={(e) => openContext(e, 'folder', folder.id, folder.name)}
|
||||
><Icon name="ellipsis-v" /></button
|
||||
>
|
||||
@@ -1817,6 +1858,8 @@
|
||||
role="button"
|
||||
tabindex="0"
|
||||
draggable="true"
|
||||
aria-label={file.name}
|
||||
data-testid={file.name}
|
||||
ondragstart={(e) => onItemDragStart(e, 'file', file.id, file.name)}
|
||||
ondblclick={() => openFile(file)}
|
||||
onclick={(e) => {
|
||||
@@ -1830,6 +1873,7 @@
|
||||
type="checkbox"
|
||||
checked={selected.has(file.id)}
|
||||
aria-label={file.name}
|
||||
data-testid={`files-file-checkbox-${file.id}`}
|
||||
onclick={(e) => {
|
||||
e.stopPropagation();
|
||||
toggleSelected(file.id);
|
||||
@@ -1881,6 +1925,7 @@
|
||||
? t('files.unfavorite', 'Remove favorite')
|
||||
: t('files.favorite', 'Add favorite')}
|
||||
aria-pressed={favoriteIds.has(file.id)}
|
||||
data-testid={`files-file-favorite-${file.id}`}
|
||||
onclick={(e) => {
|
||||
e.stopPropagation();
|
||||
void toggleFavorite('file', file.id);
|
||||
@@ -1889,6 +1934,7 @@
|
||||
<button
|
||||
class="btn-action"
|
||||
title={t('files.share', 'Share')}
|
||||
data-testid={`files-file-share-${file.id}`}
|
||||
onclick={(e) => {
|
||||
e.stopPropagation();
|
||||
openShare('file', file.id, file.name);
|
||||
@@ -1897,6 +1943,7 @@
|
||||
<button
|
||||
class="btn-action"
|
||||
title={t('files.move', 'Move')}
|
||||
data-testid={`files-file-move-${file.id}`}
|
||||
onclick={(e) => {
|
||||
e.stopPropagation();
|
||||
openMove('file', file.id, file.name);
|
||||
@@ -1908,11 +1955,13 @@
|
||||
rel="external"
|
||||
download
|
||||
title={t('common.download', 'Download')}
|
||||
data-testid={`files-file-download-${file.id}`}
|
||||
onclick={(e) => e.stopPropagation()}><Icon name="download" /></a
|
||||
>
|
||||
<button
|
||||
class="btn-action"
|
||||
title={t('common.rename', 'Rename')}
|
||||
data-testid={`files-file-rename-${file.id}`}
|
||||
onclick={(e) => {
|
||||
e.stopPropagation();
|
||||
renameItem('file', file.id, file.name);
|
||||
@@ -1921,6 +1970,7 @@
|
||||
<button
|
||||
class="btn-action btn-action--delete"
|
||||
title={t('common.delete', 'Delete')}
|
||||
data-testid={`files-file-delete-${file.id}`}
|
||||
onclick={(e) => {
|
||||
e.stopPropagation();
|
||||
deleteItem('file', file.id, file.name);
|
||||
@@ -1931,6 +1981,7 @@
|
||||
title={t('files.more_actions', 'More actions')}
|
||||
aria-label={t('files.more_actions', 'More actions')}
|
||||
aria-haspopup="menu"
|
||||
data-testid={`files-file-more-${file.id}`}
|
||||
onclick={(e) => openContext(e, 'file', file.id, file.name)}
|
||||
><Icon name="ellipsis-v" /></button
|
||||
>
|
||||
@@ -1977,14 +2028,22 @@
|
||||
<div
|
||||
class="ctx-scrim"
|
||||
role="presentation"
|
||||
data-testid="files-context-menu-scrim"
|
||||
onclick={closeContext}
|
||||
oncontextmenu={(e) => e.preventDefault()}
|
||||
></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'}
|
||||
<button
|
||||
class="ctx-item"
|
||||
role="menuitem"
|
||||
data-testid="files-ctx-folder-open-item"
|
||||
onclick={() => {
|
||||
const id = ctxTarget!.id;
|
||||
closeContext();
|
||||
@@ -1994,6 +2053,7 @@
|
||||
<button
|
||||
class="ctx-item"
|
||||
role="menuitem"
|
||||
data-testid="files-ctx-download-zip-item"
|
||||
onclick={() => {
|
||||
const tg = ctxTarget!;
|
||||
closeContext();
|
||||
@@ -2004,6 +2064,7 @@
|
||||
<button
|
||||
class="ctx-item"
|
||||
role="menuitem"
|
||||
data-testid="files-ctx-file-open-item"
|
||||
onclick={() => {
|
||||
const f = listing.files.find((x) => x.id === ctxTarget!.id);
|
||||
closeContext();
|
||||
@@ -2014,6 +2075,7 @@
|
||||
<button
|
||||
class="ctx-item"
|
||||
role="menuitem"
|
||||
data-testid="files-ctx-edit-item"
|
||||
onclick={() => {
|
||||
const tg = ctxTarget!;
|
||||
closeContext();
|
||||
@@ -2023,6 +2085,7 @@
|
||||
<button
|
||||
class="ctx-item"
|
||||
role="menuitem"
|
||||
data-testid="files-ctx-edit-new-tab-item"
|
||||
onclick={() => {
|
||||
const tg = ctxTarget!;
|
||||
closeContext();
|
||||
@@ -2036,11 +2099,13 @@
|
||||
href={fileDownloadUrl(ctxTarget.id)}
|
||||
rel="external"
|
||||
download
|
||||
data-testid="files-ctx-download-item"
|
||||
onclick={closeContext}><Icon name="download" /> {t('common.download', 'Download')}</a
|
||||
>
|
||||
<button
|
||||
class="ctx-item"
|
||||
role="menuitem"
|
||||
data-testid="files-ctx-open-parent-item"
|
||||
onclick={() => {
|
||||
const f = listing.files.find((x) => x.id === ctxTarget!.id);
|
||||
closeContext();
|
||||
@@ -2051,6 +2116,7 @@
|
||||
<button
|
||||
class="ctx-item"
|
||||
role="menuitem"
|
||||
data-testid="files-ctx-add-playlist-item"
|
||||
onclick={() => {
|
||||
const f = listing.files.find((x) => x.id === ctxTarget!.id);
|
||||
closeContext();
|
||||
@@ -2062,6 +2128,7 @@
|
||||
<button
|
||||
class="ctx-item"
|
||||
role="menuitem"
|
||||
data-testid="files-ctx-share-item"
|
||||
onclick={() => {
|
||||
const tg = ctxTarget!;
|
||||
closeContext();
|
||||
@@ -2071,6 +2138,7 @@
|
||||
<button
|
||||
class="ctx-item"
|
||||
role="menuitem"
|
||||
data-testid="files-ctx-move-item"
|
||||
onclick={() => {
|
||||
const tg = ctxTarget!;
|
||||
closeContext();
|
||||
@@ -2080,6 +2148,7 @@
|
||||
<button
|
||||
class="ctx-item"
|
||||
role="menuitem"
|
||||
data-testid="files-ctx-copy-item"
|
||||
onclick={() => {
|
||||
const tg = ctxTarget!;
|
||||
closeContext();
|
||||
@@ -2089,6 +2158,7 @@
|
||||
<button
|
||||
class="ctx-item"
|
||||
role="menuitem"
|
||||
data-testid="files-ctx-favorite-item"
|
||||
onclick={() => {
|
||||
const tg = ctxTarget!;
|
||||
closeContext();
|
||||
@@ -2103,6 +2173,7 @@
|
||||
<button
|
||||
class="ctx-item"
|
||||
role="menuitem"
|
||||
data-testid="files-ctx-rename-item"
|
||||
onclick={() => {
|
||||
const tg = ctxTarget!;
|
||||
closeContext();
|
||||
@@ -2112,6 +2183,7 @@
|
||||
<button
|
||||
class="ctx-item ctx-item--danger"
|
||||
role="menuitem"
|
||||
data-testid="files-ctx-delete-item"
|
||||
onclick={() => {
|
||||
const tg = ctxTarget!;
|
||||
closeContext();
|
||||
|
||||
@@ -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' })
|
||||
)
|
||||
);
|
||||
});
|
||||
@@ -231,7 +231,8 @@
|
||||
<main class="groups">
|
||||
<header class="groups__head">
|
||||
<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>
|
||||
|
||||
@@ -247,7 +248,11 @@
|
||||
{@const description = groupDescription(g)}
|
||||
<li class="group">
|
||||
<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="group__text">
|
||||
<span class="group__title">
|
||||
@@ -264,10 +269,16 @@
|
||||
</button>
|
||||
{#if g.can_manage !== false && !g.is_virtual}
|
||||
<div class="group__actions">
|
||||
<button class="link-btn" onclick={() => onRename(g)}
|
||||
>{t('common.rename', 'Rename')}</button
|
||||
<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')}
|
||||
</button>
|
||||
</div>
|
||||
@@ -275,7 +286,7 @@
|
||||
</div>
|
||||
|
||||
{#if expandedId === g.id}
|
||||
<div class="members">
|
||||
<div class="members" data-testid={`groups-members-panel-${g.id}`}>
|
||||
<div class="members__head">
|
||||
<h2>{t('groups.members', 'Members')}</h2>
|
||||
</div>
|
||||
@@ -284,6 +295,7 @@
|
||||
<div 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…')}
|
||||
bind:value={addQuery}
|
||||
oninput={onAddQuery}
|
||||
@@ -291,10 +303,14 @@
|
||||
{#if addBusy}
|
||||
<p class="muted">{t('common.loading', 'Loading…')}</p>
|
||||
{: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)}
|
||||
<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">
|
||||
<Icon name={r.type === 'group' ? 'user-group' : 'user'} />
|
||||
</span>
|
||||
@@ -338,6 +354,7 @@
|
||||
{#if g.can_manage !== false && !g.is_virtual}
|
||||
<button
|
||||
class="link-btn link-btn--danger"
|
||||
data-testid={`groups-member-remove-${m.kind}-${m.id}`}
|
||||
onclick={() => onRemoveMember(g.id, m)}
|
||||
>
|
||||
{t('common.remove', 'Remove')}
|
||||
@@ -354,7 +371,12 @@
|
||||
</ul>
|
||||
|
||||
{#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')}
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
@@ -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'));
|
||||
});
|
||||
@@ -271,7 +271,7 @@
|
||||
{#if mode === 'login'}
|
||||
{#if passwordLoginEnabled}
|
||||
{#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">
|
||||
<label class="auth-label" for="login-username">
|
||||
{t('auth.username', 'Username or email')}
|
||||
@@ -280,6 +280,7 @@
|
||||
<input
|
||||
id="login-username"
|
||||
class="auth-input"
|
||||
data-testid="login-username-input"
|
||||
type="text"
|
||||
bind:value={username}
|
||||
autocomplete="username"
|
||||
@@ -296,6 +297,7 @@
|
||||
<input
|
||||
id="login-password"
|
||||
class="auth-input"
|
||||
data-testid="login-password-input"
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
bind:value={password}
|
||||
onkeydown={onPwKey}
|
||||
@@ -308,6 +310,7 @@
|
||||
type="button"
|
||||
class="auth-pw-toggle"
|
||||
aria-pressed={showPassword}
|
||||
data-testid="login-password-toggle-btn"
|
||||
aria-label={t('auth.toggle_password', 'Show password')}
|
||||
onclick={() => (showPassword = !showPassword)}
|
||||
></button>
|
||||
@@ -317,12 +320,22 @@
|
||||
{/if}
|
||||
</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')}
|
||||
</button>
|
||||
</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')}
|
||||
</button>
|
||||
{#if magicOpen}
|
||||
@@ -333,7 +346,7 @@
|
||||
"No password? Enter your email and we'll send you a one-time sign-in link."
|
||||
)}
|
||||
</p>
|
||||
<form class="auth-form" onsubmit={onMagicLink}>
|
||||
<form class="auth-form" data-testid="login-magic-form" onsubmit={onMagicLink}>
|
||||
<div class="auth-input-group">
|
||||
<label class="auth-label" for="magic-email">
|
||||
{t('auth.magic_email_label', 'Email address')}
|
||||
@@ -342,6 +355,7 @@
|
||||
<input
|
||||
id="magic-email"
|
||||
class="auth-input"
|
||||
data-testid="login-magic-email-input"
|
||||
type="email"
|
||||
bind:value={magicEmail}
|
||||
autocomplete="email"
|
||||
@@ -349,7 +363,12 @@
|
||||
/>
|
||||
</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')}
|
||||
</button>
|
||||
</form>
|
||||
@@ -371,7 +390,12 @@
|
||||
<div class="auth-divider"><span>{t('auth.or', 'or')}</span></div>
|
||||
{/if}
|
||||
<!-- 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(
|
||||
'auth.sso_login_provider',
|
||||
{ provider: oidc.provider_name ?? 'SSO' },
|
||||
@@ -383,7 +407,11 @@
|
||||
{#if passwordLoginEnabled}
|
||||
<div class="auth-toggle">
|
||||
{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')}
|
||||
</button>
|
||||
</div>
|
||||
@@ -392,7 +420,11 @@
|
||||
{#if setupAvailable}
|
||||
<div class="auth-toggle">
|
||||
{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')}
|
||||
</button>
|
||||
</div>
|
||||
@@ -402,12 +434,13 @@
|
||||
{regError}
|
||||
</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">
|
||||
<label class="auth-label" for="reg-username">{t('auth.username', 'Username')}</label>
|
||||
<input
|
||||
id="reg-username"
|
||||
class="auth-input"
|
||||
data-testid="login-register-username-input"
|
||||
bind:value={regUsername}
|
||||
required
|
||||
disabled={busy}
|
||||
@@ -418,6 +451,7 @@
|
||||
<input
|
||||
id="reg-email"
|
||||
class="auth-input"
|
||||
data-testid="login-register-email-input"
|
||||
type="email"
|
||||
bind:value={regEmail}
|
||||
required
|
||||
@@ -430,6 +464,7 @@
|
||||
<input
|
||||
id="reg-password"
|
||||
class="auth-input"
|
||||
data-testid="login-register-password-input"
|
||||
type={regShowPassword ? 'text' : 'password'}
|
||||
bind:value={regPassword}
|
||||
onkeydown={onRegPwKey}
|
||||
@@ -442,6 +477,7 @@
|
||||
type="button"
|
||||
class="auth-pw-toggle"
|
||||
aria-pressed={regShowPassword}
|
||||
data-testid="login-register-password-toggle-btn"
|
||||
aria-label={t('auth.toggle_password', 'Show password')}
|
||||
onclick={() => (regShowPassword = !regShowPassword)}
|
||||
></button>
|
||||
@@ -458,6 +494,7 @@
|
||||
<input
|
||||
id="reg-confirm"
|
||||
class="auth-input"
|
||||
data-testid="login-register-confirm-input"
|
||||
type={regShowConfirm ? 'text' : 'password'}
|
||||
bind:value={regConfirm}
|
||||
onkeydown={onRegPwKey}
|
||||
@@ -470,6 +507,7 @@
|
||||
type="button"
|
||||
class="auth-pw-toggle"
|
||||
aria-pressed={regShowConfirm}
|
||||
data-testid="login-register-confirm-toggle-btn"
|
||||
aria-label={t('auth.toggle_password', 'Show password')}
|
||||
onclick={() => (regShowConfirm = !regShowConfirm)}
|
||||
></button>
|
||||
@@ -484,13 +522,23 @@
|
||||
</div>
|
||||
{/if}
|
||||
</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')}
|
||||
</button>
|
||||
</form>
|
||||
<div class="auth-toggle">
|
||||
{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')}
|
||||
</button>
|
||||
</div>
|
||||
@@ -515,13 +563,20 @@
|
||||
</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">
|
||||
<label class="auth-label" for="setup-username">
|
||||
{t('auth.admin_username', 'Administrator username')}
|
||||
</label>
|
||||
<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>
|
||||
|
||||
@@ -533,6 +588,7 @@
|
||||
<input
|
||||
id="setup-email"
|
||||
class="auth-input"
|
||||
data-testid="login-setup-email-input"
|
||||
type="email"
|
||||
bind:value={setupEmail}
|
||||
autocomplete="email"
|
||||
@@ -550,6 +606,7 @@
|
||||
<input
|
||||
id="setup-password"
|
||||
class="auth-input"
|
||||
data-testid="login-setup-password-input"
|
||||
type={setupShowPassword ? 'text' : 'password'}
|
||||
bind:value={setupPassword}
|
||||
onkeydown={onSetupPwKey}
|
||||
@@ -563,6 +620,7 @@
|
||||
type="button"
|
||||
class="auth-pw-toggle"
|
||||
aria-pressed={setupShowPassword}
|
||||
data-testid="login-setup-password-toggle-btn"
|
||||
aria-label={t('auth.toggle_password', 'Show password')}
|
||||
onclick={() => (setupShowPassword = !setupShowPassword)}
|
||||
></button>
|
||||
@@ -580,6 +638,7 @@
|
||||
<input
|
||||
id="setup-confirm"
|
||||
class="auth-input"
|
||||
data-testid="login-setup-confirm-input"
|
||||
type={setupShowConfirm ? 'text' : 'password'}
|
||||
bind:value={setupConfirm}
|
||||
onkeydown={onSetupPwKey}
|
||||
@@ -592,6 +651,7 @@
|
||||
type="button"
|
||||
class="auth-pw-toggle"
|
||||
aria-pressed={setupShowConfirm}
|
||||
data-testid="login-setup-confirm-toggle-btn"
|
||||
aria-label={t('auth.toggle_password', 'Show password')}
|
||||
onclick={() => (setupShowConfirm = !setupShowConfirm)}
|
||||
></button>
|
||||
@@ -609,14 +669,24 @@
|
||||
{/if}
|
||||
</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')}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div class="auth-toggle">
|
||||
{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')}
|
||||
</button>
|
||||
</div>
|
||||
@@ -626,6 +696,7 @@
|
||||
<div class="auth-lang">
|
||||
<select
|
||||
aria-label={t('settings.language', 'Language')}
|
||||
data-testid="login-language-select"
|
||||
value={i18n.locale}
|
||||
onchange={(e) => setLocale(e.currentTarget.value as Locale)}
|
||||
>
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
@@ -512,7 +512,11 @@
|
||||
<p class="music-empty-state-desc">
|
||||
{t('music.empty_hint', 'Create a playlist to start collecting your tracks.')}
|
||||
</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" />
|
||||
<span>{t('music.create_playlist', 'Create playlist')}</span>
|
||||
</button>
|
||||
@@ -526,6 +530,7 @@
|
||||
class="music-sidebar-add-btn"
|
||||
title={t('music.create_playlist', 'Create playlist')}
|
||||
aria-label={t('music.create_playlist', 'Create playlist')}
|
||||
data-testid="music-create-playlist-btn"
|
||||
onclick={onCreate}
|
||||
>
|
||||
<Icon name="plus" />
|
||||
@@ -538,6 +543,7 @@
|
||||
<div
|
||||
class="music-playlist-item"
|
||||
class:active={current?.id === p.id}
|
||||
data-testid={p.name}
|
||||
onclick={() => select(p)}
|
||||
>
|
||||
<div class="music-playlist-icon"><Icon name="music" /></div>
|
||||
@@ -561,6 +567,7 @@
|
||||
class="music-playlist-cover"
|
||||
title={t('music.set_cover', 'Set cover')}
|
||||
aria-label={t('music.set_cover', 'Set cover')}
|
||||
data-testid="music-set-cover-btn"
|
||||
onclick={pickCover}
|
||||
>
|
||||
{#if coverUrl(current)}
|
||||
@@ -585,12 +592,18 @@
|
||||
</div>
|
||||
|
||||
<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" />
|
||||
<span>{t('music.play_all', 'Play all')}</span>
|
||||
</button>
|
||||
<button
|
||||
class="btn btn-secondary"
|
||||
data-testid="music-shuffle-play-btn"
|
||||
onclick={shufflePlay}
|
||||
disabled={tracks.length === 0}
|
||||
title={t('music.shuffle', 'Shuffle')}
|
||||
@@ -598,12 +611,17 @@
|
||||
>
|
||||
<Icon name="shuffle" />
|
||||
</button>
|
||||
<button class="btn btn-secondary" onclick={openAdd}>
|
||||
<button
|
||||
class="btn btn-secondary"
|
||||
data-testid="music-add-tracks-btn"
|
||||
onclick={openAdd}
|
||||
>
|
||||
<Icon name="plus" />
|
||||
<span>{t('music.add_tracks', 'Add tracks')}</span>
|
||||
</button>
|
||||
<button
|
||||
class="btn btn-secondary"
|
||||
data-testid="music-rename-playlist-btn"
|
||||
onclick={onRenamePlaylist}
|
||||
title={t('common.rename', 'Rename')}
|
||||
aria-label={t('common.rename', 'Rename')}
|
||||
@@ -612,6 +630,7 @@
|
||||
</button>
|
||||
<button
|
||||
class="btn btn-secondary"
|
||||
data-testid="music-edit-description-btn"
|
||||
onclick={onEditDescription}
|
||||
title={t('music.edit_description', 'Edit description')}
|
||||
aria-label={t('music.edit_description', 'Edit description')}
|
||||
@@ -620,6 +639,7 @@
|
||||
</button>
|
||||
<button
|
||||
class="btn btn-secondary"
|
||||
data-testid="music-manage-shares-btn"
|
||||
onclick={openShares}
|
||||
title={t('music.manage_shares', 'Manage shares')}
|
||||
aria-label={t('music.manage_shares', 'Manage shares')}
|
||||
@@ -629,6 +649,7 @@
|
||||
<button
|
||||
class="btn btn-secondary"
|
||||
class:active={current.is_public}
|
||||
data-testid="music-toggle-public-btn"
|
||||
onclick={onTogglePublic}
|
||||
title={current.is_public
|
||||
? t('music.make_private', 'Make private')
|
||||
@@ -641,6 +662,7 @@
|
||||
</button>
|
||||
<button
|
||||
class="btn btn-secondary"
|
||||
data-testid="music-delete-playlist-btn"
|
||||
onclick={() => onDelete(current!)}
|
||||
title={t('common.delete', 'Delete')}
|
||||
aria-label={t('common.delete', 'Delete')}
|
||||
@@ -673,6 +695,7 @@
|
||||
<div
|
||||
class="music-track"
|
||||
class:playing={currentTrack?.id === track.id && playing}
|
||||
data-testid={trackLabel(track)}
|
||||
draggable="true"
|
||||
ondblclick={() => playFromTracks(i)}
|
||||
ondragstart={() => onDragStart(i)}
|
||||
@@ -687,6 +710,7 @@
|
||||
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<span
|
||||
data-testid={`music-track-play-${track.id}`}
|
||||
onclick={(e) => {
|
||||
e.stopPropagation();
|
||||
if (currentTrack?.id === track.id) togglePlay();
|
||||
@@ -714,6 +738,7 @@
|
||||
class="music-track-remove-btn"
|
||||
title={t('common.remove', 'Remove')}
|
||||
aria-label={t('common.remove', 'Remove')}
|
||||
data-testid={`music-track-remove-${track.id}`}
|
||||
onclick={(e) => {
|
||||
e.stopPropagation();
|
||||
onRemoveTrack(track);
|
||||
@@ -763,6 +788,7 @@
|
||||
class:active={shuffle}
|
||||
title={t('music.shuffle', 'Shuffle')}
|
||||
aria-label={t('music.shuffle', 'Shuffle')}
|
||||
data-testid="music-player-shuffle-btn"
|
||||
onclick={() => (shuffle = !shuffle)}
|
||||
>
|
||||
<Icon name="shuffle" />
|
||||
@@ -771,6 +797,7 @@
|
||||
class="player-btn"
|
||||
title={t('music.prev', 'Previous')}
|
||||
aria-label={t('music.prev', 'Previous')}
|
||||
data-testid="music-player-prev-btn"
|
||||
onclick={prev}
|
||||
>
|
||||
<Icon name="backward" />
|
||||
@@ -779,6 +806,7 @@
|
||||
class="player-btn player-btn-main"
|
||||
title={t('music.play', 'Play')}
|
||||
aria-label={t('music.play', 'Play')}
|
||||
data-testid="music-player-play-btn"
|
||||
onclick={togglePlay}
|
||||
>
|
||||
<Icon name={playing ? 'pause' : 'play'} />
|
||||
@@ -787,6 +815,7 @@
|
||||
class="player-btn"
|
||||
title={t('music.next', 'Next')}
|
||||
aria-label={t('music.next', 'Next')}
|
||||
data-testid="music-player-next-btn"
|
||||
onclick={next}
|
||||
>
|
||||
<Icon name="forward" />
|
||||
@@ -797,6 +826,7 @@
|
||||
class:repeat-one={repeat === 'one'}
|
||||
title={t('music.repeat', 'Repeat')}
|
||||
aria-label={t('music.repeat', 'Repeat')}
|
||||
data-testid="music-player-repeat-btn"
|
||||
onclick={cycleRepeat}
|
||||
>
|
||||
<Icon name="repeat" />
|
||||
@@ -813,6 +843,7 @@
|
||||
value={currentTime}
|
||||
oninput={seek}
|
||||
aria-label={t('music.seek', 'Seek')}
|
||||
data-testid="music-player-seek-input"
|
||||
/>
|
||||
<span class="player-time player-time-total">{fmtTime(duration)}</span>
|
||||
</div>
|
||||
@@ -824,6 +855,7 @@
|
||||
class:active={queueOpen}
|
||||
title={t('music.queue', 'Queue')}
|
||||
aria-label={t('music.queue', 'Queue')}
|
||||
data-testid="music-player-queue-toggle-btn"
|
||||
onclick={() => (queueOpen = !queueOpen)}
|
||||
>
|
||||
<Icon name="list" />
|
||||
@@ -832,6 +864,7 @@
|
||||
class="player-btn player-btn-small"
|
||||
title={t('music.mute', 'Mute')}
|
||||
aria-label={t('music.mute', 'Mute')}
|
||||
data-testid="music-player-mute-btn"
|
||||
onclick={toggleMute}
|
||||
>
|
||||
<Icon name={volumeIcon} />
|
||||
@@ -846,17 +879,19 @@
|
||||
value={muted ? 0 : volume}
|
||||
oninput={setVolume}
|
||||
aria-label={t('music.volume', 'Volume')}
|
||||
data-testid="music-player-volume-input"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if queueOpen}
|
||||
<div class="player-queue">
|
||||
<div class="player-queue" data-testid="music-queue-panel">
|
||||
<div class="player-queue-header">
|
||||
<h3>{t('music.queue', 'Queue')}</h3>
|
||||
<button
|
||||
class="player-btn player-btn-small"
|
||||
data-testid="music-queue-close-btn"
|
||||
onclick={() => (queueOpen = false)}
|
||||
aria-label={t('common.close', 'Close')}
|
||||
>
|
||||
@@ -876,6 +911,7 @@
|
||||
<div
|
||||
class="player-queue-item"
|
||||
class:active={i === currentIndex}
|
||||
data-testid={trackLabel(qt)}
|
||||
onclick={() => jumpQueue(i)}
|
||||
>
|
||||
<span class="queue-item-num">{i + 1}</span>
|
||||
@@ -887,6 +923,7 @@
|
||||
<button
|
||||
class="queue-item-remove"
|
||||
aria-label={t('common.remove', 'Remove')}
|
||||
data-testid={`music-queue-remove-${qt.id}`}
|
||||
onclick={(e) => {
|
||||
e.stopPropagation();
|
||||
removeFromQueue(i);
|
||||
@@ -917,6 +954,7 @@
|
||||
type="file"
|
||||
accept="image/*"
|
||||
class="hidden-input"
|
||||
data-testid="music-cover-input"
|
||||
onchange={onCoverChosen}
|
||||
/>
|
||||
|
||||
@@ -925,6 +963,7 @@
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div
|
||||
class="music-picker-overlay active"
|
||||
data-testid="music-add-tracks-dialog"
|
||||
onclick={(e) => {
|
||||
if (e.target === e.currentTarget) addOpen = false;
|
||||
}}
|
||||
@@ -935,6 +974,7 @@
|
||||
<button
|
||||
class="music-picker-close"
|
||||
aria-label={t('common.close', 'Close')}
|
||||
data-testid="music-add-tracks-close-btn"
|
||||
onclick={() => (addOpen = false)}>×</button
|
||||
>
|
||||
</div>
|
||||
@@ -948,6 +988,7 @@
|
||||
oninput={onAddQueryInput}
|
||||
autocomplete="off"
|
||||
autofocus
|
||||
data-testid="music-add-tracks-search-input"
|
||||
/>
|
||||
</div>
|
||||
<div class="music-picker-list">
|
||||
@@ -968,6 +1009,7 @@
|
||||
type="checkbox"
|
||||
checked={addSelected.has(f.id)}
|
||||
onchange={() => addSelected.toggle(f.id)}
|
||||
data-testid={f.name}
|
||||
/>
|
||||
<Icon name="file-audio" />
|
||||
<span class="music-picker-name" title={f.name}>{f.name}</span>
|
||||
@@ -980,10 +1022,19 @@
|
||||
{t('music.selected_count', { n: addSelected.size }, '{{n}} selected')}
|
||||
</span>
|
||||
<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')}
|
||||
</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" />
|
||||
{t('music.add_selected', 'Add selected')}
|
||||
</button>
|
||||
@@ -998,6 +1049,7 @@
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div
|
||||
class="music-shares-overlay"
|
||||
data-testid="music-shares-dialog"
|
||||
onclick={(e) => {
|
||||
if (e.target === e.currentTarget) sharesOpen = false;
|
||||
}}
|
||||
@@ -1008,6 +1060,7 @@
|
||||
<button
|
||||
class="music-shares-close-btn"
|
||||
aria-label={t('common.close', 'Close')}
|
||||
data-testid="music-shares-close-btn"
|
||||
onclick={() => (sharesOpen = false)}
|
||||
>
|
||||
<Icon name="times" />
|
||||
@@ -1029,6 +1082,7 @@
|
||||
class="music-share-remove-btn"
|
||||
title={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)}
|
||||
>
|
||||
<Icon name="times" />
|
||||
@@ -1044,12 +1098,22 @@
|
||||
placeholder={t('music.share_with_user', 'User ID or email')}
|
||||
bind:value={shareUser}
|
||||
autocomplete="off"
|
||||
data-testid="music-share-user-input"
|
||||
/>
|
||||
<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')}
|
||||
</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" />
|
||||
{t('music.share', 'Share')}
|
||||
</button>
|
||||
|
||||
@@ -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" />
|
||||
<h1>{view.title}</h1>
|
||||
<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>
|
||||
|
||||
<style>
|
||||
|
||||
@@ -52,7 +52,12 @@
|
||||
</div>
|
||||
{:else}
|
||||
{#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">
|
||||
<label class="auth-label" for="nc-user">{t('auth.username', 'Username or email')}</label
|
||||
>
|
||||
@@ -60,6 +65,7 @@
|
||||
<input
|
||||
id="nc-user"
|
||||
class="auth-input"
|
||||
data-testid="nextcloud-login-user-input"
|
||||
name="user"
|
||||
type="text"
|
||||
autocomplete="username"
|
||||
@@ -73,6 +79,7 @@
|
||||
<input
|
||||
id="nc-password"
|
||||
class="auth-input"
|
||||
data-testid="nextcloud-login-password-input"
|
||||
name="password"
|
||||
type="password"
|
||||
autocomplete="current-password"
|
||||
@@ -80,7 +87,9 @@
|
||||
/>
|
||||
</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>
|
||||
{/if}
|
||||
|
||||
@@ -89,7 +98,12 @@
|
||||
<div class="auth-divider"><span>{t('auth.or', 'or')}</span></div>
|
||||
{/if}
|
||||
<!-- 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}}')}
|
||||
</a>
|
||||
{/if}
|
||||
|
||||
@@ -22,7 +22,12 @@
|
||||
<Icon name="check" class="nc-status__icon nc-status__icon--ok" />
|
||||
<h1>{t('nextcloud.success_title', 'Access granted')}</h1>
|
||||
<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')}
|
||||
</button>
|
||||
</main>
|
||||
|
||||
@@ -328,6 +328,7 @@
|
||||
class:active={tab === 'moments'}
|
||||
role="tab"
|
||||
aria-selected={tab === 'moments'}
|
||||
data-testid="photos-tab-moments"
|
||||
onclick={() => (tab = 'moments')}
|
||||
>
|
||||
{t('photos.tab_moments', 'Moments')}
|
||||
@@ -337,6 +338,7 @@
|
||||
class:active={tab === 'places'}
|
||||
role="tab"
|
||||
aria-selected={tab === 'places'}
|
||||
data-testid="photos-tab-places"
|
||||
onclick={() => (tab = 'places')}
|
||||
>
|
||||
{t('photos.tab_places', 'Places')}
|
||||
@@ -347,6 +349,7 @@
|
||||
class:active={tab === 'people'}
|
||||
role="tab"
|
||||
aria-selected={tab === 'people'}
|
||||
data-testid="photos-tab-people"
|
||||
onclick={() => (tab = 'people')}
|
||||
>
|
||||
{t('photos.tab_people', 'People')}
|
||||
@@ -370,6 +373,7 @@
|
||||
class:active={layoutMode === 'square'}
|
||||
title={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
|
||||
>
|
||||
<button
|
||||
@@ -377,18 +381,25 @@
|
||||
class:active={layoutMode === 'justified'}
|
||||
title={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
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#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>
|
||||
<div class="batch-bar__actions">
|
||||
<Button onclick={downloadSelected}>{t('common.download', 'Download')}</Button>
|
||||
<Button onclick={() => selected.clear()}>{t('common.clear', 'Clear')}</Button>
|
||||
<Button variant="danger" onclick={trashSelected}>{t('common.delete', 'Delete')}</Button>
|
||||
<Button data-testid="photos-batch-download-btn" onclick={downloadSelected}
|
||||
>{t('common.download', 'Download')}</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>
|
||||
{/if}
|
||||
@@ -449,7 +460,11 @@
|
||||
|
||||
{#snippet tile(photo: PhotoItem, sizeStyle?: string)}
|
||||
<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
|
||||
it can't load (no server thumbnail, e.g. SVG), hides itself to reveal
|
||||
this default rather than the browser's broken-image glyph. -->
|
||||
@@ -471,6 +486,7 @@
|
||||
class="photo-tile__check"
|
||||
class:on={selected.has(photo.id)}
|
||||
aria-label={t('common.select', 'Select')}
|
||||
data-testid={`photo-tile-check-${photo.id}`}
|
||||
onclick={() => selected.toggle(photo.id)}
|
||||
>
|
||||
<Icon name="check" />
|
||||
|
||||
@@ -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());
|
||||
});
|
||||
@@ -318,6 +318,7 @@
|
||||
{#if canEditImage}
|
||||
<button
|
||||
class="btn btn-secondary avatar-edit-btn"
|
||||
data-testid="profile-avatar-edit-btn"
|
||||
title={t('profile.edit_photo', 'Edit photo')}
|
||||
onclick={openAvatarEdit}
|
||||
>
|
||||
@@ -327,10 +328,11 @@
|
||||
</div>
|
||||
|
||||
{#if canEditImage && avatarEditOpen}
|
||||
<div class="avatar-edit">
|
||||
<div class="avatar-edit" data-testid="profile-avatar-edit-panel">
|
||||
<div class="avatar-tabs">
|
||||
<button
|
||||
class="avatar-tab"
|
||||
data-testid="profile-avatar-url-tab"
|
||||
class:avatar-tab--active={avatarTab === 'url'}
|
||||
onclick={() => (avatarTab = 'url')}
|
||||
>
|
||||
@@ -338,6 +340,7 @@
|
||||
</button>
|
||||
<button
|
||||
class="avatar-tab"
|
||||
data-testid="profile-avatar-upload-tab"
|
||||
class:avatar-tab--active={avatarTab === 'upload'}
|
||||
onclick={() => (avatarTab = 'upload')}
|
||||
>
|
||||
@@ -346,7 +349,12 @@
|
||||
</div>
|
||||
|
||||
{#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">
|
||||
{t('profile.photo_url_hint', 'https://, http://, or data:image/…;base64,… accepted')}
|
||||
</small>
|
||||
@@ -356,6 +364,7 @@
|
||||
<span>{t('profile.photo_choose_file', 'Choose a photo (PNG, JPEG, WebP)')}</span>
|
||||
<input
|
||||
type="file"
|
||||
data-testid="profile-avatar-file-input"
|
||||
accept="image/png,image/jpeg,image/webp"
|
||||
hidden
|
||||
onchange={onAvatarFile}
|
||||
@@ -373,19 +382,30 @@
|
||||
{/if}
|
||||
|
||||
<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')}
|
||||
</button>
|
||||
{#if session.user.image}
|
||||
<button
|
||||
class="btn link-btn link-btn--danger"
|
||||
data-testid="profile-avatar-remove-btn"
|
||||
disabled={avatarBusy}
|
||||
onclick={() => commitAvatar(null)}
|
||||
>
|
||||
{t('profile.photo_remove', 'Remove photo')}
|
||||
</button>
|
||||
{/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')}
|
||||
</button>
|
||||
</div>
|
||||
@@ -463,10 +483,11 @@
|
||||
</span>
|
||||
</div>
|
||||
{:else}
|
||||
<form onsubmit={saveProfile}>
|
||||
<form data-testid="profile-edit-form" onsubmit={saveProfile}>
|
||||
<label>
|
||||
<span>{t('profile.username', 'Username')}</span>
|
||||
<input
|
||||
data-testid="profile-username-input"
|
||||
bind:value={username}
|
||||
maxlength="64"
|
||||
autocomplete="username"
|
||||
@@ -483,26 +504,42 @@
|
||||
</label>
|
||||
<label>
|
||||
<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>
|
||||
<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>
|
||||
<span>{t('profile.language', 'Language')}</span>
|
||||
<select bind:value={preferredLocale}>
|
||||
<option value="">{t('profile.language_auto', 'Automatic')}</option>
|
||||
<select data-testid="profile-language-select" bind:value={preferredLocale}>
|
||||
<option value="" data-testid="profile-language-auto-option"
|
||||
>{t('profile.language_auto', 'Automatic')}</option
|
||||
>
|
||||
{#each SUPPORTED_LOCALES as loc (loc)}
|
||||
<option value={loc}>{loc}</option>
|
||||
<option value={loc} data-testid={`profile-language-option-${loc}`}>{loc}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</label>
|
||||
<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>
|
||||
</label>
|
||||
<button type="submit" disabled={savingProfile}
|
||||
<button type="submit" data-testid="profile-save-btn" disabled={savingProfile}
|
||||
>{t('profile.save_profile', 'Save changes')}</button
|
||||
>
|
||||
</form>
|
||||
@@ -522,11 +559,17 @@
|
||||
|
||||
<div class="app-pw-create">
|
||||
<input
|
||||
data-testid="profile-app-pw-label-input"
|
||||
bind:value={newLabel}
|
||||
maxlength="128"
|
||||
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" />
|
||||
{t('profile.generate', 'Generate')}
|
||||
</button>
|
||||
@@ -542,6 +585,7 @@
|
||||
<code>{generated.password}</code>
|
||||
<button
|
||||
class="btn-action"
|
||||
data-testid="profile-app-pw-copy-btn"
|
||||
title={t('profile.copy_to_clipboard', 'Copy to clipboard')}
|
||||
onclick={copyGenerated}
|
||||
>
|
||||
@@ -587,6 +631,7 @@
|
||||
{#if p.active !== false}
|
||||
<button
|
||||
class="btn-action btn-action--danger"
|
||||
data-testid={`profile-app-pw-revoke-${p.id}`}
|
||||
title={t('profile.revoke_title', 'Revoke')}
|
||||
onclick={() => revokePw(p)}
|
||||
>
|
||||
@@ -602,7 +647,11 @@
|
||||
|
||||
{#if autoPasswords.length > 0}
|
||||
<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'} />
|
||||
<span>{t('profile.client_sessions', 'Client sessions')}</span>
|
||||
<span class="badge badge--count">{autoPasswords.length}</span>
|
||||
@@ -637,6 +686,7 @@
|
||||
{#if p.active !== false}
|
||||
<button
|
||||
class="btn-action btn-action--danger"
|
||||
data-testid={`profile-app-pw-auto-revoke-${p.id}`}
|
||||
title={t('profile.revoke_title', 'Revoke')}
|
||||
onclick={() => revokePw(p)}
|
||||
>
|
||||
@@ -656,22 +706,39 @@
|
||||
|
||||
<!-- Change password -->
|
||||
{#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>
|
||||
<label>
|
||||
<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>
|
||||
<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>
|
||||
</label>
|
||||
<label>
|
||||
<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>
|
||||
<button type="submit" disabled={savingPassword}>
|
||||
<button type="submit" data-testid="profile-update-password-btn" disabled={savingPassword}>
|
||||
{t('profile.update_password', 'Update Password')}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
@@ -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'));
|
||||
});
|
||||
@@ -341,21 +341,29 @@
|
||||
>
|
||||
{#snippet toolbar()}
|
||||
{#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}
|
||||
{/snippet}
|
||||
{#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
|
||||
icon="arrows-alt"
|
||||
data-testid="recent-batch-move-btn"
|
||||
onclick={() => {
|
||||
moveTarget = null;
|
||||
moveItems = batchTargets();
|
||||
moveOpen = true;
|
||||
}}>{t('files.move', 'Move')}</Button
|
||||
>
|
||||
<Button variant="danger" icon="trash" onclick={batchDelete}
|
||||
>{t('common.delete', 'Delete')}</Button
|
||||
<Button
|
||||
variant="danger"
|
||||
icon="trash"
|
||||
data-testid="recent-batch-delete-btn"
|
||||
onclick={batchDelete}>{t('common.delete', 'Delete')}</Button
|
||||
>
|
||||
{/snippet}
|
||||
</ResourceList>
|
||||
|
||||
@@ -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());
|
||||
});
|
||||
@@ -258,23 +258,32 @@
|
||||
<p>{t('share.expired', 'This share link is no longer available.')}</p>
|
||||
</div>
|
||||
{: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>
|
||||
<input
|
||||
type="password"
|
||||
data-testid="public-share-password-input"
|
||||
bind:value={pwInput}
|
||||
placeholder={t('share.password', 'Password')}
|
||||
disabled={busy}
|
||||
autocomplete="off"
|
||||
/>
|
||||
{#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>
|
||||
{:else if view === 'file'}
|
||||
<div class="share__center">
|
||||
<Icon name="file" class="share__big-icon" />
|
||||
<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')}
|
||||
</a>
|
||||
</div>
|
||||
@@ -286,7 +295,11 @@
|
||||
{#if i === crumbs.length - 1}
|
||||
<span class="breadcrumb__current">{c.name}</span>
|
||||
{: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}
|
||||
{/each}
|
||||
</nav>
|
||||
@@ -296,6 +309,7 @@
|
||||
type="button"
|
||||
aria-pressed={viewMode === 'grid'}
|
||||
class:active={viewMode === 'grid'}
|
||||
data-testid="public-share-view-grid-btn"
|
||||
title={t('files.grid', 'Grid')}
|
||||
onclick={() => setViewMode('grid')}><Icon name="th" /></button
|
||||
>
|
||||
@@ -303,11 +317,18 @@
|
||||
type="button"
|
||||
aria-pressed={viewMode === 'list'}
|
||||
class:active={viewMode === 'list'}
|
||||
data-testid="public-share-view-list-btn"
|
||||
title={t('files.list', 'List')}
|
||||
onclick={() => setViewMode('list')}><Icon name="bars" /></button
|
||||
>
|
||||
</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" />
|
||||
{t('share.download_zip', 'Download ZIP')}
|
||||
</a>
|
||||
@@ -323,7 +344,11 @@
|
||||
<ul class="share__grid" class:share__grid--list={viewMode === 'list'}>
|
||||
{#each listing.folders as f (f.id)}
|
||||
<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__name">{f.name}</span>
|
||||
</button>
|
||||
@@ -341,6 +366,7 @@
|
||||
<li>
|
||||
<button
|
||||
class="card"
|
||||
data-testid={f.name}
|
||||
onclick={() => (lightbox = mediaFiles.findIndex((m) => m.id === f.id))}
|
||||
>
|
||||
<span class="card__thumb">
|
||||
@@ -369,6 +395,7 @@
|
||||
<li>
|
||||
<a
|
||||
class="card"
|
||||
data-testid={f.name}
|
||||
href={shareFileUrl(token, f.id)}
|
||||
target="_blank"
|
||||
rel="external noreferrer"
|
||||
@@ -390,6 +417,7 @@
|
||||
<div
|
||||
class="lb"
|
||||
role="dialog"
|
||||
data-testid="public-share-lightbox-dialog"
|
||||
aria-modal="true"
|
||||
aria-label={m.name}
|
||||
tabindex="-1"
|
||||
@@ -397,11 +425,13 @@
|
||||
>
|
||||
<button
|
||||
class="lb__close"
|
||||
data-testid="public-share-lightbox-close-btn"
|
||||
aria-label={t('common.close', 'Close')}
|
||||
onclick={() => (lightbox = -1)}>×</button
|
||||
>
|
||||
<button
|
||||
class="lb__nav lb__nav--prev"
|
||||
data-testid="public-share-lightbox-prev-btn"
|
||||
aria-label={t('common.previous', 'Previous')}
|
||||
disabled={lightbox === 0}
|
||||
onclick={(e) => {
|
||||
@@ -417,6 +447,7 @@
|
||||
{/if}
|
||||
<button
|
||||
class="lb__nav lb__nav--next"
|
||||
data-testid="public-share-lightbox-next-btn"
|
||||
aria-label={t('common.next', 'Next')}
|
||||
disabled={lightbox === mediaFiles.length - 1}
|
||||
onclick={(e) => {
|
||||
|
||||
@@ -201,12 +201,18 @@
|
||||
<div class="search-controls">
|
||||
{#if filesStore.currentFolder}
|
||||
<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')}
|
||||
</button>
|
||||
<button
|
||||
class="seg__btn"
|
||||
class:active={scope === 'folder'}
|
||||
data-testid="search-scope-folder-btn"
|
||||
onclick={() => (scope = 'folder')}
|
||||
>
|
||||
{t('search.this_folder', 'This folder')}
|
||||
@@ -217,28 +223,40 @@
|
||||
class="sort-select"
|
||||
bind:value={typeFilter}
|
||||
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
|
||||
class="sort-select"
|
||||
bind:value={sizeFilter}
|
||||
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
|
||||
class="sort-select"
|
||||
bind:value={dateFilter}
|
||||
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 class="sort-select" bind:value={sortBy} aria-label={t('search.sort_by', 'Sort by')}>
|
||||
{#each SORTS as s (s.v)}<option value={s.v}>{s.l}</option>{/each}
|
||||
<select
|
||||
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>
|
||||
{#if hasFilters}
|
||||
<button class="clear-filters" onclick={clearFilters}>
|
||||
<button class="clear-filters" data-testid="search-clear-filters-btn" onclick={clearFilters}>
|
||||
<Icon name="times" />
|
||||
{t('search.clear_filters', 'Clear filters')}
|
||||
</button>
|
||||
@@ -281,6 +299,8 @@
|
||||
class="file-item"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
aria-label={e.folder.name}
|
||||
data-testid={e.folder.name}
|
||||
onclick={() => openFolder(e.folder)}
|
||||
onkeydown={(ev) => ev.key === 'Enter' && openFolder(e.folder)}
|
||||
>
|
||||
@@ -297,6 +317,8 @@
|
||||
class="file-item"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
aria-label={e.file.name}
|
||||
data-testid={e.file.name}
|
||||
onclick={() => openFile(e.file)}
|
||||
onkeydown={(ev) => ev.key === 'Enter' && openFile(e.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());
|
||||
});
|
||||
@@ -391,11 +391,19 @@
|
||||
<header class="ms-lane__header">
|
||||
{#if lane.header.kind === 'resource'}
|
||||
{@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)} />
|
||||
<span class="ms-lane__name">{laneItem.resource.name}</span>
|
||||
</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" />
|
||||
{t('myshares.editSharing', 'Edit sharing')}
|
||||
</button>
|
||||
@@ -427,7 +435,11 @@
|
||||
<!-- Identity -->
|
||||
<span class="ms-row__identity">
|
||||
{#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)} />
|
||||
<span class="ms-row__name">{item.resource.name}</span>
|
||||
</button>
|
||||
@@ -443,6 +455,7 @@
|
||||
<button
|
||||
class="ms-chip ms-chip--link"
|
||||
class:ms-chip--locked={grant.has_password}
|
||||
data-testid={`shared-copy-link-${grant.grant_id}`}
|
||||
onclick={() => copyLink(grant)}
|
||||
title={t('share.copyLink', 'Copy link')}
|
||||
>
|
||||
@@ -451,7 +464,11 @@
|
||||
</button>
|
||||
{#if groupBy === 'sharedWith'}
|
||||
<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)} />
|
||||
<span>{item.resource.name}</span>
|
||||
</button>
|
||||
@@ -480,6 +497,7 @@
|
||||
aria-label={t('myshares.manageAccess', 'Manage access')}
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={menuFor === grant.grant_id}
|
||||
data-testid={`shared-kebab-${grant.grant_id}`}
|
||||
onclick={(e) => {
|
||||
e.stopPropagation();
|
||||
toggleMenu(grant.grant_id);
|
||||
@@ -490,11 +508,17 @@
|
||||
class="ms-menu"
|
||||
role="menu"
|
||||
tabindex="-1"
|
||||
data-testid={`shared-menu-${grant.grant_id}`}
|
||||
onclick={(e) => e.stopPropagation()}
|
||||
onkeydown={(e) => e.key === 'Escape' && closeMenu()}
|
||||
>
|
||||
{#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" />
|
||||
{grant.subject_type === 'group'
|
||||
? t('myshares.notifyGroupMembers', 'Notify group members')
|
||||
@@ -508,6 +532,7 @@
|
||||
class="ms-menu__item"
|
||||
class:ms-menu__item--current={grant.role === r.v}
|
||||
role="menuitem"
|
||||
data-testid={`shared-role-${r.v}-${grant.grant_id}`}
|
||||
onclick={() => changeRole(grant, item, r.v)}
|
||||
>
|
||||
<Icon name={grant.role === r.v ? 'check' : r.icon} />
|
||||
@@ -520,6 +545,7 @@
|
||||
<input
|
||||
type="date"
|
||||
class="ms-menu__date"
|
||||
data-testid={`shared-expiry-${grant.grant_id}-input`}
|
||||
value={isoToDate(grant.expires_at)}
|
||||
onchange={(e) =>
|
||||
changeExpiry(grant, item, (e.currentTarget as HTMLInputElement).value)}
|
||||
@@ -529,13 +555,19 @@
|
||||
<button
|
||||
class="ms-menu__item ms-menu__item--danger"
|
||||
role="menuitem"
|
||||
data-testid={`shared-remove-access-${grant.grant_id}`}
|
||||
onclick={() => removeAccess(grant)}
|
||||
>
|
||||
<Icon name="user-xmark" />
|
||||
{t('myshares.removeAccess', 'Remove access')}
|
||||
</button>
|
||||
{: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" />
|
||||
{t('myshares.copyLink', 'Copy link')}
|
||||
</button>
|
||||
@@ -545,6 +577,7 @@
|
||||
<input
|
||||
type="date"
|
||||
class="ms-menu__date"
|
||||
data-testid={`shared-link-expiry-${grant.grant_id}-input`}
|
||||
value={isoToDate(grant.expires_at)}
|
||||
onchange={(e) =>
|
||||
changeLinkExpiry(grant, (e.currentTarget as HTMLInputElement).value)}
|
||||
@@ -553,6 +586,7 @@
|
||||
<button
|
||||
class="ms-menu__item"
|
||||
role="menuitem"
|
||||
data-testid={`shared-edit-password-${grant.grant_id}`}
|
||||
onclick={() => editLinkPassword(grant)}
|
||||
>
|
||||
<Icon name={grant.has_password ? 'lock' : 'lock-open'} />
|
||||
@@ -564,6 +598,7 @@
|
||||
<button
|
||||
class="ms-menu__item ms-menu__item--danger"
|
||||
role="menuitem"
|
||||
data-testid={`shared-delete-link-${grant.grant_id}`}
|
||||
onclick={() => deleteLink(grant)}
|
||||
>
|
||||
<Icon name="trash" />
|
||||
@@ -580,7 +615,12 @@
|
||||
{/each}
|
||||
|
||||
{#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')}
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
@@ -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());
|
||||
});
|
||||
@@ -174,7 +174,7 @@
|
||||
>
|
||||
{#snippet toolbar()}
|
||||
{#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" />
|
||||
{t('trash.empty_action', 'Empty trash')}
|
||||
</button>
|
||||
@@ -188,11 +188,17 @@
|
||||
</span>
|
||||
{/snippet}
|
||||
{#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" />
|
||||
</button>
|
||||
<button
|
||||
class="btn-action btn-action--delete"
|
||||
data-testid={`trash-delete-btn-${entry.id}`}
|
||||
title={t('trash.delete', 'Delete permanently')}
|
||||
onclick={() => purge(entry)}
|
||||
>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -1,5 +1,41 @@
|
||||
import adapter from '@sveltejs/adapter-static';
|
||||
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.
|
||||
@@ -15,7 +51,9 @@ import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';
|
||||
* @type {import('@sveltejs/kit').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: {
|
||||
adapter: adapter({
|
||||
// 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).
|
||||
version: {
|
||||
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
@@ -1,10 +1,17 @@
|
||||
import { sveltekit } from '@sveltejs/kit/vite';
|
||||
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
|
||||
// traffic here so cookies, CSRF, and the auth-refresh flow are same-origin.
|
||||
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 = {
|
||||
'/api': { target: BACKEND, changeOrigin: true },
|
||||
'/locales': { target: BACKEND, changeOrigin: true },
|
||||
@@ -20,14 +27,32 @@ const proxy = {
|
||||
};
|
||||
|
||||
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: {
|
||||
port: 5173,
|
||||
proxy
|
||||
},
|
||||
test: {
|
||||
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}'],
|
||||
globals: true
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
});
|
||||
@@ -1 +1,56 @@
|
||||
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
Reference in New Issue
Block a user