Merge pull request #494 from BCNelson/bcn/e2eTesting
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