diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b286d20c..cd9c646f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -75,7 +75,7 @@ jobs: - name: Set up Python uses: actions/setup-python@v6 with: - python-version: "3.12" + python-version: "3.13" - name: Set up uv uses: astral-sh/setup-uv@v7 @@ -101,7 +101,7 @@ jobs: - name: Set up Python uses: actions/setup-python@v6 with: - python-version: "3.12" + python-version: "3.13" - name: Set up uv uses: astral-sh/setup-uv@v7 @@ -125,7 +125,7 @@ jobs: - name: Set up Python uses: actions/setup-python@v6 with: - python-version: "3.12" + python-version: "3.13" - name: Set up uv uses: astral-sh/setup-uv@v7 @@ -148,6 +148,34 @@ jobs: --ignore=tests/unittests/test_controller/test_scan/test_remote_scanner.py --ignore=tests/unittests/test_common/test_multiprocessing_logger.py + python-integration-test: + name: Python Integration Tests + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Set up Python + uses: actions/setup-python@v6 + with: + python-version: "3.13" + + - name: Set up uv + uses: astral-sh/setup-uv@v7 + + - name: Install dependencies + working-directory: src/python + run: uv pip install --system -r pyproject.toml --group test + + - name: Run web handler integration tests + working-directory: src/python + env: + PYTHONPATH: . + run: >- + pytest tests/integration/test_web -v --tb=short + --timeout=30 + angular-build: name: Build Angular if: startsWith(github.ref, 'refs/tags/v') || (github.ref == 'refs/heads/develop' && github.event_name == 'push') || github.event_name == 'workflow_dispatch' @@ -180,7 +208,7 @@ jobs: build-test: name: Build and Test (amd64) - if: ${{ !(startsWith(github.ref, 'refs/tags/v') || (github.ref == 'refs/heads/develop' && github.event_name == 'push') || github.event_name == 'workflow_dispatch') }} + if: ${{ !(startsWith(github.ref, 'refs/tags/v') || github.event_name == 'workflow_dispatch') }} runs-on: ubuntu-latest steps: - name: Checkout @@ -211,20 +239,17 @@ jobs: done docker logs test-container - - name: Set up Node.js - uses: actions/setup-node@v6 - with: - node-version: 22 - - - name: Install Playwright - working-directory: src/e2e-playwright + - name: Run E2E tests in Playwright container run: | - npm install - npx playwright install --with-deps chromium - - - name: Run E2E tests - working-directory: src/e2e-playwright - run: npx playwright test + # Match the @playwright/test version pinned in the lockfile so the + # container's chromium build is the one the package expects. + PW_VERSION=$(jq -r '.packages["node_modules/@playwright/test"].version' src/e2e-playwright/package-lock.json) + docker run --rm \ + --network host \ + -v "$GITHUB_WORKSPACE":/workspace \ + -w /workspace/src/e2e-playwright \ + "mcr.microsoft.com/playwright:v${PW_VERSION}-noble" \ + sh -c "npm ci && npx playwright test" - name: Upload Playwright report if: failure() @@ -392,9 +417,10 @@ jobs: && needs.python-lint.result == 'success' && needs.python-typecheck.result == 'success' && needs.python-test.result == 'success' + && needs.python-integration-test.result == 'success' && needs.build-amd64.result == 'success' && needs.build-arm64.result == 'success' - needs: [unit-test, angular-lint, python-lint, python-typecheck, python-test, build-amd64, build-arm64] + needs: [unit-test, angular-lint, python-lint, python-typecheck, python-test, python-integration-test, build-amd64, build-arm64] runs-on: ubuntu-latest permissions: contents: read diff --git a/.gitignore b/.gitignore index b835059a..6d89bb3a 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,7 @@ package-lock.json !src/angular/package-lock.json !website/package-lock.json +!src/e2e-playwright/package-lock.json src/python/build src/python/site dev-config/ diff --git a/CHANGELOG.md b/CHANGELOG.md index c16a5692..de70995e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,49 @@ # Changelog +## [0.18.0] - 2026-05-04 + +### Changed + +- **Image size reduced from 114 MB to 64 MB** — `RUN --mount=from=ghcr.io/astral-sh/uv` keeps `uv` out of the runtime image; build artifacts no longer persist (#437) +- **Dockerfile collapsed to 2 stages on Python 3.13-alpine** — Removes an intermediate stage and the legacy Buster build path (#436) +- **Test image rewritten as Alpine with Python 3.13** (#435) +- **Removed stale Docker test infrastructure** — Old Compose files and fixture data carried over from the Protractor era (#434) +- **Playwright E2E migrated to the official `mcr.microsoft.com/playwright` container** — Eliminates host-side browser install and `apt install` of system deps; image tag auto-pinned to the `@playwright/test` version from `package-lock.json` (#456) +- **Build and Test (amd64) runs on develop pushes** — Populates the GHA cache so PRs against develop hit a warm cache instead of paying the install cost on every run (#456) +- **Playwright browser cache + npm cache wired through `actions/cache`** (#452, #453) +- **Python 3.12 → 3.13** in CI and Dockerfile (#451) +- **README refreshed** to cover features through v0.17.0 — Notifications, Sonarr/Radarr, integrity verification, staging, API key (#454) +- **Test count badges** added to the README (#451) +- **Dependency updates** — Angular group (10 packages), typescript-eslint, jsdom, eslint, Docusaurus 3.10.0 → 3.10.1 across 5 packages (#458, #459, #460, #461, #463, #464, #465, #466) + +### Added + +- **LFTP hot-reload** — Connection-related settings (parallel connections, max total connections, socket buffer size, bandwidth limit) apply without a container restart. Settings UI distinguishes between options that take effect immediately and those that require restart (#433) +- **Atomic config writes** — `Config.to_file()` now flushes to disk via temp file + `os.rename` so a process kill mid-write can't truncate the config (#433) +- **Expanded unit and E2E test coverage:** + - 47 security middleware unit tests (#439) + - 36 controller core unit tests (#444) + - 28 FileOptions / Integrations / Option component tests (#448) + - 25 AutoQueueService and PathPairsService tests (#445) + - 20 HeaderComponent and VersionCheckService tests (#443) + - 18 ViewFileFilterService tests (#440) + - E2E for integrations CRUD (#441) + - E2E for File Actions & Error States (#438) + - E2E Settings coverage expansion (#442) + - Web App Job & Context Python tests (#446) + - Handler integration test expansion (#447) + - Python integration tests added to CI (#449) + +### Fixed + +- **Hash Algorithm select test flake** — Test waited on the wrong config field; the algorithm select's disable gate is `validate.enabled`, not `validate.xfer_verify`. Test now sets the correct field and waits for the SSE-delivered model value before asserting (#455) +- **StreamHandler leaks in test setUp methods** — Tests added a handler to the shared root logger but never removed it. Loggers are singletons, so by test N the logger had N handlers and each log line printed N times. Fixed via `self.addCleanup(logger.removeHandler, handler)` across 8 test files / 10 setUp methods (#450, #457) +- **Multiprocessing resource leak in `test_active_scanner.py`** — `scanner.close()` now registered with `addCleanup` so resources release even if assertions raise + +### Security + +- **`apk` and its package database are stripped from the runtime image** — `/sbin/apk`, `/etc/apk`, `/var/cache/apk`, `/lib/apk`, and the `libapk` shared libraries are removed in the runtime stage to keep the image under 64 MB (#437). This means in-image vulnerability scanners (Trivy, Grype) can't enumerate Alpine packages directly from a running container; consumers should scan the published `ghcr.io/nitrobass24/seedsync` image via SBOM or image-history tooling. Affected runtime packages: `lftp`, `openssh-client`, `ca-certificates`, `setpriv`, `libstdc++`. Derived images that need `apk add` should base on Alpine and reinstall what they need rather than extending this image. + ## [0.17.0] - 2026-04-30 ### Added diff --git a/README.md b/README.md index 50efc94e..807e0d15 100644 --- a/README.md +++ b/README.md @@ -25,8 +25,12 @@ Documentation Angular 21 - Python 3.12 + Python 3.13 Platform +
+ Python Tests: 828 + Angular Tests: 412 + E2E Tests: 95

SeedSync is a tool to sync files from a remote Linux server (like your seedbox) to your local machine. @@ -36,16 +40,19 @@ It uses LFTP to transfer files fast! ## Features -* Built on top of [LFTP](http://lftp.tech/), the fastest file transfer program -* Web UI — track and control your transfers from anywhere -* **Multiple path pairs** — sync from multiple remote directories independently -* **Exclude patterns** — filter out unwanted files with glob patterns -* **Multi-select** — select multiple files for bulk queue/stop/delete -* Auto-Queue — only sync the files you want based on pattern matching -* Automatically extract your files after sync -* **Webhook notifications** — HTTP POST on download/extract events -* Delete local and remote files easily -* Dark mode, staging directory, bandwidth limiting, and more +* Built on top of [LFTP](http://lftp.tech/) — the fastest file transfer tool around +* Web UI — track and control transfers from any browser +* **Multiple path pairs** — sync from independent remote/local directory pairs, each with its own settings +* **Auto-Queue** — automatically queue files matching your patterns +* **Exclude patterns** — filter out unwanted files (`*.nfo`, `Sample/`, etc.) per path pair +* **Multi-select bulk actions** — queue, stop, or delete multiple files at once +* **Auto-extract** — unpack RAR, ZIP, 7z, tar, gz, bz2, xz archives after download +* **File integrity verification** — inline checksum during download plus optional post-download validation +* **Notifications** — Discord, Telegram, or generic webhook on download/extract events +* **Sonarr/Radarr integration** — trigger imports with multiple named instances, attached per path pair +* **Staging directory** — land downloads on fast storage, then move to the final location +* **Optional API key auth** with CSRF protection and rate limiting +* **Dark mode**, bandwidth limiting, virtual scrolling for large libraries, and more * **Lightweight Docker image** — ~45 MB Alpine-based, multi-arch (amd64/arm64) * Fully open source! @@ -121,6 +128,20 @@ SeedSync is available as a Community Application on Unraid. > **Note**: PUID/PGID default to `99`/`100` (Unraid's `nobody`/`users`), which is correct for most Unraid setups. +## Recommended Workflow + +The best way to use SeedSync is with **hard links** and a dedicated completion directory: + +1. **Configure your torrent client** (qBittorrent, ruTorrent, etc.) to hard link completed downloads into a separate folder (e.g., `/downloads/complete`). Hard links don't use extra disk space — your originals stay intact for seeding. +2. **Point SeedSync** at the completion directory. +3. **Enable Auto-Queue** and turn on **"Delete remote file after syncing"** in Settings. + +This way, each file is downloaded exactly once. After SeedSync syncs it, the hard link is removed from the completion directory, so it's never re-downloaded — even after a container restart. Your originals remain untouched for seeding. + +> **Note**: Both directories must be on the same filesystem for hard links to work. + +See the [full setup guide](https://nitrobass24.github.io/seedsync/usage#recommended-setup) in the docs for directory layout examples and torrent client configuration. + ## Configuration On first run, access the web UI and configure: @@ -149,19 +170,33 @@ You can limit download speed in Settings under the **Connections** section. The - Values with suffixes: `K` for KB/s, `M` for MB/s (e.g., `500K`, `2M`) - `0` or empty for unlimited -## Recommended Workflow +### Notifications -The best way to use SeedSync is with **hard links** and a dedicated completion directory: +SeedSync can notify you when downloads or extractions finish. Configure destinations in **Settings → Notifications**: -1. **Configure your torrent client** (qBittorrent, ruTorrent, etc.) to hard link completed downloads into a separate folder (e.g., `/downloads/complete`). Hard links don't use extra disk space — your originals stay intact for seeding. -2. **Point SeedSync** at the completion directory. -3. **Enable Auto-Queue** and turn on **"Delete remote file after syncing"** in Settings. +- **Discord** — paste a webhook URL; events arrive as color-coded embeds +- **Telegram** — provide a bot token and chat ID +- **Generic webhook** — POST a JSON payload to any HTTP(S) URL -This way, each file is downloaded exactly once. After SeedSync syncs it, the hard link is removed from the completion directory, so it's never re-downloaded — even after a container restart. Your originals remain untouched for seeding. +Each destination has a **Test** button to verify it's working. -> **Note**: Both directories must be on the same filesystem for hard links to work. +### Sonarr / Radarr -See the [full setup guide](https://nitrobass24.github.io/seedsync/usage#recommended-setup) in the docs for directory layout examples and torrent client configuration. +To trigger automatic imports after downloads complete, configure **Settings → Integrations**: + +1. Click **Add instance**, choose Sonarr or Radarr, and enter the base URL and API key +2. Use **Test connection** to verify credentials +3. In the **Path Pairs** card, attach instances to the path pair(s) where imports should fire + +You can mix and match — for example, attach a 4K path pair only to your 4K Radarr instance. + +### Staging Directory + +For setups with fast scratch storage (NVMe/SSD) and slower bulk storage, enable **Staging** in Settings. Downloads complete on the staging volume first, then move to the final location. Mount your staging path at `/staging` in the container. + +### API Key + +To require authentication on the web UI and API, set an **API Key** in **Settings → Other Settings**. When set, browser clients prompt for the key on first load and API clients send it via the `X-API-Key` header. Leave it blank to run without auth (fine behind a reverse proxy or on a trusted network). ## Building from Source @@ -192,6 +227,7 @@ make logs |------|-------------| | `/config` | Configuration and state files | | `/downloads` | Download destination directory | +| `/staging` | Staging directory (optional, mount when staging is enabled) | | `/home/seedsync/.ssh/id_rsa` | SSH private key (optional, for key-based auth) | ## Ports diff --git a/src/angular/package-lock.json b/src/angular/package-lock.json index d00736e5..c7b5700e 100644 --- a/src/angular/package-lock.json +++ b/src/angular/package-lock.json @@ -1,20 +1,20 @@ { "name": "seedsync", - "version": "0.14.4", + "version": "0.17.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "seedsync", - "version": "0.14.4", - "dependencies": { - "@angular/cdk": "^21.2.8", - "@angular/common": "^21.2.10", - "@angular/compiler": "^21.2.10", - "@angular/core": "^21.2.10", - "@angular/forms": "^21.2.10", - "@angular/platform-browser": "^21.2.10", - "@angular/router": "^21.2.10", + "version": "0.17.0", + "dependencies": { + "@angular/cdk": "^21.2.9", + "@angular/common": "^21.2.11", + "@angular/compiler": "^21.2.11", + "@angular/core": "^21.2.11", + "@angular/forms": "^21.2.11", + "@angular/platform-browser": "^21.2.11", + "@angular/router": "^21.2.11", "@fortawesome/fontawesome-free": "^7.1.0", "bootstrap": "^5.3.8", "compare-versions": "^6.1.1", @@ -22,15 +22,15 @@ "tslib": "^2.3.0" }, "devDependencies": { - "@angular/build": "^21.2.8", - "@angular/cli": "^21.2.8", - "@angular/compiler-cli": "^21.2.10", + "@angular/build": "^21.2.9", + "@angular/cli": "^21.2.9", + "@angular/compiler-cli": "^21.2.11", "@eslint/js": "^10.0.1", "angular-eslint": "21.3.1", - "eslint": "^10.0.3", - "jsdom": "^29.0.1", + "eslint": "^10.3.0", + "jsdom": "^29.1.1", "typescript": "~5.9.2", - "typescript-eslint": "8.59.0", + "typescript-eslint": "8.59.1", "vitest": "^4.1.5" } }, @@ -258,13 +258,13 @@ } }, "node_modules/@angular-devkit/architect": { - "version": "0.2102.8", - "resolved": "https://registry.npmjs.org/@angular-devkit/architect/-/architect-0.2102.8.tgz", - "integrity": "sha512-b7su7AHIO5F2I6InEu/Bx/oXvGjdCP7kos2tGX73he/lPrTuizooils62OgAzgJ2UeKscyRNUjBPieFCy6XvHQ==", + "version": "0.2102.9", + "resolved": "https://registry.npmjs.org/@angular-devkit/architect/-/architect-0.2102.9.tgz", + "integrity": "sha512-OlPEtd5pPZSFdkXEIyZ93jsfBrkvUrVPb3xs4z2WPRnBRk9jyey40eKnmql86KRHfdn4WjHpmde4NDgtDpZRxQ==", "dev": true, "license": "MIT", "dependencies": { - "@angular-devkit/core": "21.2.8", + "@angular-devkit/core": "21.2.9", "rxjs": "7.8.2" }, "bin": { @@ -277,9 +277,9 @@ } }, "node_modules/@angular-devkit/core": { - "version": "21.2.8", - "resolved": "https://registry.npmjs.org/@angular-devkit/core/-/core-21.2.8.tgz", - "integrity": "sha512-DyxCILaaic/hfcfiBjAC/SdKE1ybSQIrU62/K5Msn3gZtThZj/T7cG0VHfbmpEFcgYkrQ9caUt6MCg8OoOVDzw==", + "version": "21.2.9", + "resolved": "https://registry.npmjs.org/@angular-devkit/core/-/core-21.2.9.tgz", + "integrity": "sha512-04rdOGEzjLWFHlyAwqtuikginFeQ2jfXS5HqqKNP0VtG6Uu9NUDAEW5UDvXgqkEMfCDwGZbmg2iRHxp3AmAKVw==", "dev": true, "license": "MIT", "dependencies": { @@ -305,13 +305,13 @@ } }, "node_modules/@angular-devkit/schematics": { - "version": "21.2.8", - "resolved": "https://registry.npmjs.org/@angular-devkit/schematics/-/schematics-21.2.8.tgz", - "integrity": "sha512-UTEMM1JXzzxufLsTGDsWth2E7+8e9PaFT7nbjUvJ2qevltACkiqAbHEpiD2ISzrSRIO3OirJ+cZtnzXO0FyoBQ==", + "version": "21.2.9", + "resolved": "https://registry.npmjs.org/@angular-devkit/schematics/-/schematics-21.2.9.tgz", + "integrity": "sha512-Gyyuq2Vet70AMkbC+e0L6rjzjZWjSOyKTlOJvd99GjjyWQf6eezjd8IcF17ppKJsML6YUagO2I6AlWROq5yJmg==", "dev": true, "license": "MIT", "dependencies": { - "@angular-devkit/core": "21.2.8", + "@angular-devkit/core": "21.2.9", "jsonc-parser": "3.3.1", "magic-string": "0.30.21", "ora": "9.3.0", @@ -433,14 +433,14 @@ } }, "node_modules/@angular/build": { - "version": "21.2.8", - "resolved": "https://registry.npmjs.org/@angular/build/-/build-21.2.8.tgz", - "integrity": "sha512-t0PHT7ONDMLwcjC9GaClNF+gsUKN78ofBikw4huiu6np5Rwmxp8KKCrdoRx20lOiibSolXgjZ2Ny0xxjNdNdQA==", + "version": "21.2.9", + "resolved": "https://registry.npmjs.org/@angular/build/-/build-21.2.9.tgz", + "integrity": "sha512-XYP5ALB56NWvcQisznmvQdVU6WJdUCAuCAEN2eDZNVd9X1IqRNfewQfFH6FyHo7SrK4GHDReqm6xWW6rs0+weQ==", "dev": true, "license": "MIT", "dependencies": { "@ampproject/remapping": "2.3.0", - "@angular-devkit/architect": "0.2102.8", + "@angular-devkit/architect": "0.2102.9", "@babel/core": "7.29.0", "@babel/helper-annotate-as-pure": "7.27.3", "@babel/helper-split-export-declaration": "7.24.7", @@ -483,7 +483,7 @@ "@angular/platform-browser": "^21.0.0", "@angular/platform-server": "^21.0.0", "@angular/service-worker": "^21.0.0", - "@angular/ssr": "^21.2.8", + "@angular/ssr": "^21.2.9", "karma": "^6.4.0", "less": "^4.2.0", "ng-packagr": "^21.0.0", @@ -533,9 +533,9 @@ } }, "node_modules/@angular/cdk": { - "version": "21.2.8", - "resolved": "https://registry.npmjs.org/@angular/cdk/-/cdk-21.2.8.tgz", - "integrity": "sha512-WdvMLpuFcRgDWLDyin3sw5a65PQYdI0Y+4BxiMxOkesoZ2RZTBAlLKIfQ9Nz5CY3LamUTO3Qel2T8ZhJ+Cqfuw==", + "version": "21.2.9", + "resolved": "https://registry.npmjs.org/@angular/cdk/-/cdk-21.2.9.tgz", + "integrity": "sha512-0JXsr8f7xjV2815esTSq4+zGqWMa0CyNT/DV1F7lYS6qkYXcFdYUzGcd/WjNL05VKkajkSkWmTi6uyVsOpYdGA==", "license": "MIT", "dependencies": { "parse5": "^8.0.0", @@ -549,19 +549,19 @@ } }, "node_modules/@angular/cli": { - "version": "21.2.8", - "resolved": "https://registry.npmjs.org/@angular/cli/-/cli-21.2.8.tgz", - "integrity": "sha512-Y+/US12o+7X2774oeKPsEfHeeYM2SxwnyoXfcaLR8vrMn0zxUrhHebmlz9h83th4EJEuex1Qk0JtF7j5vcwrqQ==", + "version": "21.2.9", + "resolved": "https://registry.npmjs.org/@angular/cli/-/cli-21.2.9.tgz", + "integrity": "sha512-KldNb7vCEVOeyEUK57dguP3dTjYeikBmAohjAouu8JLtY8OOI+tf/TA31Gco/rxZ3nGqBwkvrqpD4rcDf5AhUA==", "dev": true, "license": "MIT", "dependencies": { - "@angular-devkit/architect": "0.2102.8", - "@angular-devkit/core": "21.2.8", - "@angular-devkit/schematics": "21.2.8", + "@angular-devkit/architect": "0.2102.9", + "@angular-devkit/core": "21.2.9", + "@angular-devkit/schematics": "21.2.9", "@inquirer/prompts": "7.10.1", "@listr2/prompt-adapter-inquirer": "3.0.5", "@modelcontextprotocol/sdk": "1.26.0", - "@schematics/angular": "21.2.8", + "@schematics/angular": "21.2.9", "@yarnpkg/lockfile": "1.1.0", "algoliasearch": "5.48.1", "ini": "6.0.0", @@ -584,9 +584,9 @@ } }, "node_modules/@angular/common": { - "version": "21.2.10", - "resolved": "https://registry.npmjs.org/@angular/common/-/common-21.2.10.tgz", - "integrity": "sha512-WLyi/CRLtgALg2mmaqIuKuPnE4i+8PGt/uuz26pVqx+ASh28/TWr5KSCAMomgxEc8kt4OE7lopoQsTihrQCfEw==", + "version": "21.2.11", + "resolved": "https://registry.npmjs.org/@angular/common/-/common-21.2.11.tgz", + "integrity": "sha512-3Z3SABXpzM6fkX21WCRP6IwrjxNQVHM/3Fk2OXScExOAzpaOpS2bDgS4NB6rtCbmzKL/NFSp7ZPIZigfdqnWGw==", "license": "MIT", "dependencies": { "tslib": "^2.3.0" @@ -595,14 +595,14 @@ "node": "^20.19.0 || ^22.12.0 || >=24.0.0" }, "peerDependencies": { - "@angular/core": "21.2.10", + "@angular/core": "21.2.11", "rxjs": "^6.5.3 || ^7.4.0" } }, "node_modules/@angular/compiler": { - "version": "21.2.10", - "resolved": "https://registry.npmjs.org/@angular/compiler/-/compiler-21.2.10.tgz", - "integrity": "sha512-IrgdFuzzD7NTK3WQaSfowjAPxPbnTqsgR92NsOs5ZaWu3RgLl21dHThNc0BK1KwVwppLUSWmD4qePbcLW71VzQ==", + "version": "21.2.11", + "resolved": "https://registry.npmjs.org/@angular/compiler/-/compiler-21.2.11.tgz", + "integrity": "sha512-/KdE0kPQr24K/aNsdIDS2or555+8CrQxyRB5MxPKy3/8d6EvilEY/UN7pB7A5xgRQtUPMea08ZzLFJVp1qNbDA==", "license": "MIT", "dependencies": { "tslib": "^2.3.0" @@ -612,9 +612,9 @@ } }, "node_modules/@angular/compiler-cli": { - "version": "21.2.10", - "resolved": "https://registry.npmjs.org/@angular/compiler-cli/-/compiler-cli-21.2.10.tgz", - "integrity": "sha512-FDcnj3ogRmnTca4m2GbKP2khFOCtoVvWDZyfw2ZCPAf+zsQlKTyscKvx4GpTFo+KHrYXpawUpDIWHORFpuqFEA==", + "version": "21.2.11", + "resolved": "https://registry.npmjs.org/@angular/compiler-cli/-/compiler-cli-21.2.11.tgz", + "integrity": "sha512-qp/LgptDYJvpEHVVdwBEtkcbybre/ftanu0qJMpH3mu5FC4HEEOChl+9m7UVrmL4jC1ZkoZcgtzsGKAQr8mw2g==", "dev": true, "license": "MIT", "dependencies": { @@ -635,7 +635,7 @@ "node": "^20.19.0 || ^22.12.0 || >=24.0.0" }, "peerDependencies": { - "@angular/compiler": "21.2.10", + "@angular/compiler": "21.2.11", "typescript": ">=5.9 <6.1" }, "peerDependenciesMeta": { @@ -645,9 +645,9 @@ } }, "node_modules/@angular/core": { - "version": "21.2.10", - "resolved": "https://registry.npmjs.org/@angular/core/-/core-21.2.10.tgz", - "integrity": "sha512-uxH+mbPiCE7rInWKYOPe9Ytas97+mFM6FhFORoN234yBK3b8he+iDuxX6dsbhEFCxhRmfS6hLxe7BdLY6U6kIA==", + "version": "21.2.11", + "resolved": "https://registry.npmjs.org/@angular/core/-/core-21.2.11.tgz", + "integrity": "sha512-EULAfQ0m/I9hZJes74OFlrnfDWqlfV0esE0CkHehO5IEF9rd769+dfuGEAJAzrz+/6Q3PhS0bWDYiT68z1H8Ag==", "license": "MIT", "dependencies": { "tslib": "^2.3.0" @@ -656,7 +656,7 @@ "node": "^20.19.0 || ^22.12.0 || >=24.0.0" }, "peerDependencies": { - "@angular/compiler": "21.2.10", + "@angular/compiler": "21.2.11", "rxjs": "^6.5.3 || ^7.4.0", "zone.js": "~0.15.0 || ~0.16.0" }, @@ -670,9 +670,9 @@ } }, "node_modules/@angular/forms": { - "version": "21.2.10", - "resolved": "https://registry.npmjs.org/@angular/forms/-/forms-21.2.10.tgz", - "integrity": "sha512-XOo9qkuBqCLzSBXmyga9ke2tSulxWl+E7Y9Uwqgz8sJtQUlyP/0GYJfu60jiC3NAYobk9K/6h6MsU8zftQKdaA==", + "version": "21.2.11", + "resolved": "https://registry.npmjs.org/@angular/forms/-/forms-21.2.11.tgz", + "integrity": "sha512-F67V612wHxPXHrbp825VirYfGPKBUM8PvL9atN2Ku1fsdGSFPU3hTxu1HU8fKYLLBpKYVVuqFqzaU/qIpTXGYA==", "license": "MIT", "dependencies": { "@standard-schema/spec": "^1.0.0", @@ -682,16 +682,16 @@ "node": "^20.19.0 || ^22.12.0 || >=24.0.0" }, "peerDependencies": { - "@angular/common": "21.2.10", - "@angular/core": "21.2.10", - "@angular/platform-browser": "21.2.10", + "@angular/common": "21.2.11", + "@angular/core": "21.2.11", + "@angular/platform-browser": "21.2.11", "rxjs": "^6.5.3 || ^7.4.0" } }, "node_modules/@angular/platform-browser": { - "version": "21.2.10", - "resolved": "https://registry.npmjs.org/@angular/platform-browser/-/platform-browser-21.2.10.tgz", - "integrity": "sha512-5WMoHGU8BOV3eO9h3vGMIUDPf+3SHis7+X2dHKMtKfFBUtiO8m/lq2x3PzkkKj1782i7KYt92EqPHuADd/eWOw==", + "version": "21.2.11", + "resolved": "https://registry.npmjs.org/@angular/platform-browser/-/platform-browser-21.2.11.tgz", + "integrity": "sha512-Uz/KwGjSEvbE8J9kNSSetzxhBWjCXv9OuxH1w2WkW6jLNU3vgvzuKX7SXDyUys6KJv5TqkClJ9BLeU11QbmJdw==", "license": "MIT", "dependencies": { "tslib": "^2.3.0" @@ -700,9 +700,9 @@ "node": "^20.19.0 || ^22.12.0 || >=24.0.0" }, "peerDependencies": { - "@angular/animations": "21.2.10", - "@angular/common": "21.2.10", - "@angular/core": "21.2.10" + "@angular/animations": "21.2.11", + "@angular/common": "21.2.11", + "@angular/core": "21.2.11" }, "peerDependenciesMeta": { "@angular/animations": { @@ -711,9 +711,9 @@ } }, "node_modules/@angular/router": { - "version": "21.2.10", - "resolved": "https://registry.npmjs.org/@angular/router/-/router-21.2.10.tgz", - "integrity": "sha512-4cHHwewIhFEAAaRgJ80371EOtNlydFHbjj/UENLZitjU0azal0mfFCBdkaEdVehd7+mH5xO7MRjy6eFTcTYR5Q==", + "version": "21.2.11", + "resolved": "https://registry.npmjs.org/@angular/router/-/router-21.2.11.tgz", + "integrity": "sha512-IB7/KuRDsxAjCOxYNccq2LdCTKuu59cx5MmOhrt+TarvkNE/xdlFkP7vtrCl44DJt0q7/tveWvsn5oqTw7rN7A==", "license": "MIT", "dependencies": { "tslib": "^2.3.0" @@ -722,21 +722,22 @@ "node": "^20.19.0 || ^22.12.0 || >=24.0.0" }, "peerDependencies": { - "@angular/common": "21.2.10", - "@angular/core": "21.2.10", - "@angular/platform-browser": "21.2.10", + "@angular/common": "21.2.11", + "@angular/core": "21.2.11", + "@angular/platform-browser": "21.2.11", "rxjs": "^6.5.3 || ^7.4.0" } }, "node_modules/@asamuzakjp/css-color": { - "version": "5.1.8", - "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-5.1.8.tgz", - "integrity": "sha512-OISPR9c2uPo23rUdvfEQiLPjoMLOpEeLNnP5iGkxr6tDDxJd3NjD+6fxY0mdaMbIPUjFGL4HFOJqLvow5q4aqQ==", + "version": "5.1.11", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-5.1.11.tgz", + "integrity": "sha512-KVw6qIiCTUQhByfTd78h2yD1/00waTmm9uy/R7Ck/ctUyAPj+AEDLkQIdJW0T8+qGgj3j5bpNKK7Q3G+LedJWg==", "dev": true, "license": "MIT", "dependencies": { - "@csstools/css-calc": "^3.1.1", - "@csstools/css-color-parser": "^4.0.2", + "@asamuzakjp/generational-cache": "^1.0.1", + "@csstools/css-calc": "^3.2.0", + "@csstools/css-color-parser": "^4.1.0", "@csstools/css-parser-algorithms": "^4.0.0", "@csstools/css-tokenizer": "^4.0.0" }, @@ -745,12 +746,13 @@ } }, "node_modules/@asamuzakjp/dom-selector": { - "version": "7.0.8", - "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-7.0.8.tgz", - "integrity": "sha512-erMO6FgtM02dC24NGm0xufMzWz5OF0wXKR7BpvGD973bq/GbmR8/DbxNZbj0YevQ5hlToJaWSVK/G9/NDgGEVw==", + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-7.1.1.tgz", + "integrity": "sha512-67RZDnYRc8H/8MLDgQCDE//zoqVFwajkepHZgmXrbwybzXOEwOWGPYGmALYl9J2DOLfFPPs6kKCqmbzV895hTQ==", "dev": true, "license": "MIT", "dependencies": { + "@asamuzakjp/generational-cache": "^1.0.1", "@asamuzakjp/nwsapi": "^2.3.9", "bidi-js": "^1.0.3", "css-tree": "^3.2.1", @@ -760,6 +762,16 @@ "node": "^20.19.0 || ^22.12.0 || >=24.0.0" } }, + "node_modules/@asamuzakjp/generational-cache": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@asamuzakjp/generational-cache/-/generational-cache-1.0.1.tgz", + "integrity": "sha512-wajfB8KqzMCN2KGNFdLkReeHncd0AslUSrvHVvvYWuU8ghncRJoA50kT3zP9MVL0+9g4/67H+cdvBskj9THPzg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, "node_modules/@asamuzakjp/nwsapi": { "version": "2.3.9", "resolved": "https://registry.npmjs.org/@asamuzakjp/nwsapi/-/nwsapi-2.3.9.tgz", @@ -1094,9 +1106,9 @@ } }, "node_modules/@csstools/css-calc": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.1.1.tgz", - "integrity": "sha512-HJ26Z/vmsZQqs/o3a6bgKslXGFAungXGbinULZO3eMsOyNJHeBBZfup5FiZInOghgoM4Hwnmw+OgbJCNg1wwUQ==", + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.2.0.tgz", + "integrity": "sha512-bR9e6o2BDB12jzN/gIbjHa5wLJ4UjD1CB9pM7ehlc0ddk6EBz+yYS1EV2MF55/HUxrHcB/hehAyt5vhsA3hx7w==", "dev": true, "funding": [ { @@ -1118,9 +1130,9 @@ } }, "node_modules/@csstools/css-color-parser": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.0.2.tgz", - "integrity": "sha512-0GEfbBLmTFf0dJlpsNU7zwxRIH0/BGEMuXLTCvFYxuL1tNhqzTbtnFICyJLTNK4a+RechKP75e7w42ClXSnJQw==", + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.1.0.tgz", + "integrity": "sha512-U0KhLYmy2GVj6q4T3WaAe6NPuFYCPQoE3b0dRGxejWDgcPp8TP7S5rVdM5ZrFaqu4N67X8YaPBw14dQSYx3IyQ==", "dev": true, "funding": [ { @@ -1135,7 +1147,7 @@ "license": "MIT", "dependencies": { "@csstools/color-helpers": "^6.0.2", - "@csstools/css-calc": "^3.1.1" + "@csstools/css-calc": "^3.2.0" }, "engines": { "node": ">=20.19.0" @@ -1169,9 +1181,9 @@ } }, "node_modules/@csstools/css-syntax-patches-for-csstree": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.1.tgz", - "integrity": "sha512-BvqN0AMWNAnLk9G8jnUT77D+mUbY/H2b3uDTvg2isJkHaOufUE2R3AOwxWo7VBQKT1lOdwdvorddo2B/lk64+w==", + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.3.tgz", + "integrity": "sha512-SH60bMfrRCJF3morcdk57WklujF4Jr/EsQUzqkarfHXEFcAR1gg7fS/chAE922Sehgzc1/+Tz5H3Ypa1HiEKrg==", "dev": true, "funding": [ { @@ -4060,14 +4072,14 @@ ] }, "node_modules/@schematics/angular": { - "version": "21.2.8", - "resolved": "https://registry.npmjs.org/@schematics/angular/-/angular-21.2.8.tgz", - "integrity": "sha512-Kx3PmuZIXhwQqAqoERAXqDCORHFbKTMd+eflXwZfpKkrbWJTVPqKpL4R9RVdEr2E6/VEXDFrdL1whIvGd1xmDg==", + "version": "21.2.9", + "resolved": "https://registry.npmjs.org/@schematics/angular/-/angular-21.2.9.tgz", + "integrity": "sha512-1renEbBZz9Yw3A0GUOJ6x6E1jd2Vu/fX5tEGiFNbIoWaNwa71SlFTvKKqaYxiYQkrpc7oexVJ2ymuvOfgTbI1w==", "dev": true, "license": "MIT", "dependencies": { - "@angular-devkit/core": "21.2.8", - "@angular-devkit/schematics": "21.2.8", + "@angular-devkit/core": "21.2.9", + "@angular-devkit/schematics": "21.2.9", "jsonc-parser": "3.3.1" }, "engines": { @@ -4237,17 +4249,17 @@ "license": "MIT" }, "node_modules/@typescript-eslint/eslint-plugin": { - "version": "8.59.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.59.0.tgz", - "integrity": "sha512-HyAZtpdkgZwpq8Sz3FSUvCR4c+ScbuWa9AksK2Jweub7w4M3yTz4O11AqVJzLYjy/B9ZWPyc81I+mOdJU/bDQw==", + "version": "8.59.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.59.1.tgz", + "integrity": "sha512-BOziFIfE+6osHO9FoJG4zjoHUcvI7fTNBSpdAwrNH0/TLvzjsk2oo8XSSOT2HhqUyhZPfHv4UOffoJ9oEEQ7Ag==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/regexpp": "^4.12.2", - "@typescript-eslint/scope-manager": "8.59.0", - "@typescript-eslint/type-utils": "8.59.0", - "@typescript-eslint/utils": "8.59.0", - "@typescript-eslint/visitor-keys": "8.59.0", + "@typescript-eslint/scope-manager": "8.59.1", + "@typescript-eslint/type-utils": "8.59.1", + "@typescript-eslint/utils": "8.59.1", + "@typescript-eslint/visitor-keys": "8.59.1", "ignore": "^7.0.5", "natural-compare": "^1.4.0", "ts-api-utils": "^2.5.0" @@ -4260,22 +4272,22 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "@typescript-eslint/parser": "^8.59.0", + "@typescript-eslint/parser": "^8.59.1", "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "node_modules/@typescript-eslint/parser": { - "version": "8.59.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.59.0.tgz", - "integrity": "sha512-TI1XGwKbDpo9tRW8UDIXCOeLk55qe9ZFGs8MTKU6/M08HWTw52DD/IYhfQtOEhEdPhLMT26Ka/x7p70nd3dzDg==", + "version": "8.59.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.59.1.tgz", + "integrity": "sha512-HDQH9O/47Dxi1ceDhBXdaldtf/WV9yRYMjbjCuNk3qnaTD564qwv61Y7+gTxwxRKzSrgO5uhtw584igXVuuZkA==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/scope-manager": "8.59.0", - "@typescript-eslint/types": "8.59.0", - "@typescript-eslint/typescript-estree": "8.59.0", - "@typescript-eslint/visitor-keys": "8.59.0", + "@typescript-eslint/scope-manager": "8.59.1", + "@typescript-eslint/types": "8.59.1", + "@typescript-eslint/typescript-estree": "8.59.1", + "@typescript-eslint/visitor-keys": "8.59.1", "debug": "^4.4.3" }, "engines": { @@ -4291,14 +4303,14 @@ } }, "node_modules/@typescript-eslint/project-service": { - "version": "8.59.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.59.0.tgz", - "integrity": "sha512-Lw5ITrR5s5TbC19YSvlr63ZfLaJoU6vtKTHyB0GQOpX0W7d5/Ir6vUahWi/8Sps/nOukZQ0IB3SmlxZnjaKVnw==", + "version": "8.59.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.59.1.tgz", + "integrity": "sha512-+MuHQlHiEr00Of/IQbE/MmEoi44znZHbR/Pz7Opq4HryUOlRi+/44dro9Ycy8Fyo+/024IWtw8m4JUMCGTYxDg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/tsconfig-utils": "^8.59.0", - "@typescript-eslint/types": "^8.59.0", + "@typescript-eslint/tsconfig-utils": "^8.59.1", + "@typescript-eslint/types": "^8.59.1", "debug": "^4.4.3" }, "engines": { @@ -4313,14 +4325,14 @@ } }, "node_modules/@typescript-eslint/scope-manager": { - "version": "8.59.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.59.0.tgz", - "integrity": "sha512-UzR16Ut8IpA3Mc4DbgAShlPPkVm8xXMWafXxB0BocaVRHs8ZGakAxGRskF7FId3sdk9lgGD73GSFaWmWFDE4dg==", + "version": "8.59.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.59.1.tgz", + "integrity": "sha512-LwuHQI4pDOYVKvmH2dkaJo6YZCSgouVgnS/z7yBPKBMvgtBvyLqiLy9Z6b7+m/TRcX1NFYUqZetI5Y+aT4GEfg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.59.0", - "@typescript-eslint/visitor-keys": "8.59.0" + "@typescript-eslint/types": "8.59.1", + "@typescript-eslint/visitor-keys": "8.59.1" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -4331,9 +4343,9 @@ } }, "node_modules/@typescript-eslint/tsconfig-utils": { - "version": "8.59.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.59.0.tgz", - "integrity": "sha512-91Sbl3s4Kb3SybliIY6muFBmHVv+pYXfybC4Oolp3dvk8BvIE3wOPc+403CWIT7mJNkfQRGtdqghzs2+Z91Tqg==", + "version": "8.59.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.59.1.tgz", + "integrity": "sha512-/0nEyPbX7gRsk0Uwfe4ALwwgxuA66d/l2mhRDNlAvaj4U3juhUtJNq0DsY8M2AYwwb9rEq2hrC3IcIcEt++iJA==", "dev": true, "license": "MIT", "engines": { @@ -4348,15 +4360,15 @@ } }, "node_modules/@typescript-eslint/type-utils": { - "version": "8.59.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.59.0.tgz", - "integrity": "sha512-3TRiZaQSltGqGeNrJzzr1+8YcEobKH9rHnqIp/1psfKFmhRQDNMGP5hBufanYTGznwShzVLs3Mz+gDN7HkWfXg==", + "version": "8.59.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.59.1.tgz", + "integrity": "sha512-klWPBR2ciQHS3f++ug/mVnWKPjBUo7icEL3FAO1lhAR1Z1i5NQYZ1EannMSRYcq5qCv5wNALlXr6fksRHyYl7w==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.59.0", - "@typescript-eslint/typescript-estree": "8.59.0", - "@typescript-eslint/utils": "8.59.0", + "@typescript-eslint/types": "8.59.1", + "@typescript-eslint/typescript-estree": "8.59.1", + "@typescript-eslint/utils": "8.59.1", "debug": "^4.4.3", "ts-api-utils": "^2.5.0" }, @@ -4373,9 +4385,9 @@ } }, "node_modules/@typescript-eslint/types": { - "version": "8.59.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.59.0.tgz", - "integrity": "sha512-nLzdsT1gdOgFxxxwrlNVUBzSNBEEHJ86bblmk4QAS6stfig7rcJzWKqCyxFy3YRRHXDWEkb2NralA1nOYkkm/A==", + "version": "8.59.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.59.1.tgz", + "integrity": "sha512-ZDCjgccSdYPw5Bxh+my4Z0lJU96ZDN7jbBzvmEn0FZx3RtU1C7VWl6NbDx94bwY3V5YsgwRzJPOgeY2Q/nLG8A==", "dev": true, "license": "MIT", "engines": { @@ -4387,16 +4399,16 @@ } }, "node_modules/@typescript-eslint/typescript-estree": { - "version": "8.59.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.59.0.tgz", - "integrity": "sha512-O9Re9P1BmBLFJyikRbQpLku/QA3/AueZNO9WePLBwQrvkixTmDe8u76B6CYUAITRl/rHawggEqUGn5QIkVRLMw==", + "version": "8.59.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.59.1.tgz", + "integrity": "sha512-OUd+vJS05sSkOip+BkZ/2NS8RMxrAAJemsC6vU3kmfLyeaJT0TftHkV9mcx2107MmsBVXXexhVu4F0TZXyMl4g==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/project-service": "8.59.0", - "@typescript-eslint/tsconfig-utils": "8.59.0", - "@typescript-eslint/types": "8.59.0", - "@typescript-eslint/visitor-keys": "8.59.0", + "@typescript-eslint/project-service": "8.59.1", + "@typescript-eslint/tsconfig-utils": "8.59.1", + "@typescript-eslint/types": "8.59.1", + "@typescript-eslint/visitor-keys": "8.59.1", "debug": "^4.4.3", "minimatch": "^10.2.2", "semver": "^7.7.3", @@ -4415,16 +4427,16 @@ } }, "node_modules/@typescript-eslint/utils": { - "version": "8.59.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.59.0.tgz", - "integrity": "sha512-I1R/K7V07XsMJ12Oaxg/O9GfrysGTmCRhvZJBv0RE0NcULMzjqVpR5kRRQjHsz3J/bElU7HwCO7zkqL+MSUz+g==", + "version": "8.59.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.59.1.tgz", + "integrity": "sha512-3pIeoXhCeYH9FSCBI8P3iNwJlGuzPlYKkTlen2O9T1DSeeg8UG8jstq6BLk+Mda0qup7mgk4z4XL4OzRaxZ8LA==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/eslint-utils": "^4.9.1", - "@typescript-eslint/scope-manager": "8.59.0", - "@typescript-eslint/types": "8.59.0", - "@typescript-eslint/typescript-estree": "8.59.0" + "@typescript-eslint/scope-manager": "8.59.1", + "@typescript-eslint/types": "8.59.1", + "@typescript-eslint/typescript-estree": "8.59.1" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -4439,13 +4451,13 @@ } }, "node_modules/@typescript-eslint/visitor-keys": { - "version": "8.59.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.59.0.tgz", - "integrity": "sha512-/uejZt4dSere1bx12WLlPfv8GktzcaDtuJ7s42/HEZ5zGj9oxRaD4bj7qwSunXkf+pbAhFt2zjpHYUiT5lHf0Q==", + "version": "8.59.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.59.1.tgz", + "integrity": "sha512-LdDNl6C5iJExcM0Yh0PwAIBb9PrSiCsWamF/JyEZawm3kFDnRoaq3LGE4bpyRao/fWeGKKyw7icx0YxrLFC5Cg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.59.0", + "@typescript-eslint/types": "8.59.1", "eslint-visitor-keys": "^5.0.0" }, "engines": { @@ -5729,9 +5741,9 @@ } }, "node_modules/eslint": { - "version": "10.2.1", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.2.1.tgz", - "integrity": "sha512-wiyGaKsDgqXvF40P8mDwiUp/KQjE1FdrIEJsM8PZ3XCiniTMXS3OHWWUe5FI5agoCnr8x4xPrTDZuxsBlNHl+Q==", + "version": "10.3.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.3.0.tgz", + "integrity": "sha512-XbEXaRva5cF0ZQB8w6MluHA0kZZfV2DuCMJ3ozyEOHLwDpZX2Lmm/7Pp0xdJmI0GL1W05VH5VwIFHEm1Vcw2gw==", "dev": true, "license": "MIT", "dependencies": { @@ -6773,28 +6785,28 @@ "license": "MIT" }, "node_modules/jsdom": { - "version": "29.0.2", - "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-29.0.2.tgz", - "integrity": "sha512-9VnGEBosc/ZpwyOsJBCQ/3I5p7Q5ngOY14a9bf5btenAORmZfDse1ZEheMiWcJ3h81+Fv7HmJFdS0szo/waF2w==", + "version": "29.1.1", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-29.1.1.tgz", + "integrity": "sha512-ECi4Fi2f7BdJtUKTflYRTiaMxIB0O6zfR1fX0GXpUrf6flp8QIYn1UT20YQqdSOfk2dfkCwS8LAFoJDEppNK5Q==", "dev": true, "license": "MIT", "dependencies": { - "@asamuzakjp/css-color": "^5.1.5", - "@asamuzakjp/dom-selector": "^7.0.6", + "@asamuzakjp/css-color": "^5.1.11", + "@asamuzakjp/dom-selector": "^7.1.1", "@bramus/specificity": "^2.4.2", - "@csstools/css-syntax-patches-for-csstree": "^1.1.1", + "@csstools/css-syntax-patches-for-csstree": "^1.1.3", "@exodus/bytes": "^1.15.0", "css-tree": "^3.2.1", "data-urls": "^7.0.0", "decimal.js": "^10.6.0", "html-encoding-sniffer": "^6.0.0", "is-potential-custom-element-name": "^1.0.1", - "lru-cache": "^11.2.7", - "parse5": "^8.0.0", + "lru-cache": "^11.3.5", + "parse5": "^8.0.1", "saxes": "^6.0.0", "symbol-tree": "^3.2.4", "tough-cookie": "^6.0.1", - "undici": "^7.24.5", + "undici": "^7.25.0", "w3c-xmlserializer": "^5.0.0", "webidl-conversions": "^8.0.1", "whatwg-mimetype": "^5.0.0", @@ -6814,9 +6826,9 @@ } }, "node_modules/jsdom/node_modules/lru-cache": { - "version": "11.2.7", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.7.tgz", - "integrity": "sha512-aY/R+aEsRelme17KGQa/1ZSIpLpNYYrhcrepKTZgE+W3WM16YMCaPwOHLHsmopZHELU0Ojin1lPVxKR0MihncA==", + "version": "11.3.5", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.3.5.tgz", + "integrity": "sha512-NxVFwLAnrd9i7KUBxC4DrUhmgjzOs+1Qm50D3oF1/oL+r1NpZ4gA7xvG0/zJ8evR7zIKn4vLf7qTNduWFtCrRw==", "dev": true, "license": "BlueOak-1.0.0", "engines": { @@ -6824,9 +6836,9 @@ } }, "node_modules/jsdom/node_modules/undici": { - "version": "7.24.5", - "resolved": "https://registry.npmjs.org/undici/-/undici-7.24.5.tgz", - "integrity": "sha512-3IWdCpjgxp15CbJnsi/Y9TCDE7HWVN19j1hmzVhoAkY/+CJx449tVxT5wZc1Gwg8J+P0LWvzlBzxYRnHJ+1i7Q==", + "version": "7.25.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.25.0.tgz", + "integrity": "sha512-xXnp4kTyor2Zq+J1FfPI6Eq3ew5h6Vl0F/8d9XU5zZQf1tX9s2Su1/3PiMmUANFULpmksxkClamIZcaUqryHsQ==", "dev": true, "license": "MIT", "engines": { @@ -7892,12 +7904,12 @@ } }, "node_modules/parse5": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.0.tgz", - "integrity": "sha512-9m4m5GSgXjL4AjumKzq1Fgfp3Z8rsvjRNbnkVwfu2ImRqE5D0LnY2QfDen18FSY9C573YU5XxSapdHZTZ2WolA==", + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz", + "integrity": "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==", "license": "MIT", "dependencies": { - "entities": "^6.0.0" + "entities": "^8.0.0" }, "funding": { "url": "https://github.com/inikulin/parse5?sponsor=1" @@ -7945,12 +7957,12 @@ } }, "node_modules/parse5/node_modules/entities": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", - "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-8.0.0.tgz", + "integrity": "sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==", "license": "BSD-2-Clause", "engines": { - "node": ">=0.12" + "node": ">=20.19.0" }, "funding": { "url": "https://github.com/fb55/entities?sponsor=1" @@ -9121,16 +9133,16 @@ } }, "node_modules/typescript-eslint": { - "version": "8.59.0", - "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.59.0.tgz", - "integrity": "sha512-BU3ONW9X+v90EcCH9ZS6LMackcVtxRLlI3XrYyqZIwVSHIk7Qf7bFw1z0M9Q0IUxhTMZCf8piY9hTYaNEIASrw==", + "version": "8.59.1", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.59.1.tgz", + "integrity": "sha512-xqDcFVBmlrltH64lklOVp1wYxgJr6LVdg3NamBgH2OOQDLFdTKfIZXF5PfghrnXQKXZGTQs8tr1vL7fJvq8CTQ==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/eslint-plugin": "8.59.0", - "@typescript-eslint/parser": "8.59.0", - "@typescript-eslint/typescript-estree": "8.59.0", - "@typescript-eslint/utils": "8.59.0" + "@typescript-eslint/eslint-plugin": "8.59.1", + "@typescript-eslint/parser": "8.59.1", + "@typescript-eslint/typescript-estree": "8.59.1", + "@typescript-eslint/utils": "8.59.1" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" diff --git a/src/angular/package.json b/src/angular/package.json index 68b044e4..1020f5f8 100644 --- a/src/angular/package.json +++ b/src/angular/package.json @@ -1,6 +1,6 @@ { "name": "seedsync", - "version": "0.17.0", + "version": "0.18.0", "scripts": { "ng": "ng", "start": "ng serve", @@ -24,13 +24,13 @@ "private": true, "packageManager": "npm@10.9.2", "dependencies": { - "@angular/cdk": "^21.2.8", - "@angular/common": "^21.2.10", - "@angular/compiler": "^21.2.10", - "@angular/core": "^21.2.10", - "@angular/forms": "^21.2.10", - "@angular/platform-browser": "^21.2.10", - "@angular/router": "^21.2.10", + "@angular/cdk": "^21.2.9", + "@angular/common": "^21.2.11", + "@angular/compiler": "^21.2.11", + "@angular/core": "^21.2.11", + "@angular/forms": "^21.2.11", + "@angular/platform-browser": "^21.2.11", + "@angular/router": "^21.2.11", "@fortawesome/fontawesome-free": "^7.1.0", "bootstrap": "^5.3.8", "compare-versions": "^6.1.1", @@ -38,15 +38,15 @@ "tslib": "^2.3.0" }, "devDependencies": { - "@angular/build": "^21.2.8", - "@angular/cli": "^21.2.8", - "@angular/compiler-cli": "^21.2.10", + "@angular/build": "^21.2.9", + "@angular/cli": "^21.2.9", + "@angular/compiler-cli": "^21.2.11", "@eslint/js": "^10.0.1", "angular-eslint": "21.3.1", - "eslint": "^10.0.3", - "jsdom": "^29.0.1", + "eslint": "^10.3.0", + "jsdom": "^29.1.1", "typescript": "~5.9.2", - "typescript-eslint": "8.59.0", + "typescript-eslint": "8.59.1", "vitest": "^4.1.5" } } diff --git a/src/angular/src/app/pages/files/file-options.component.spec.ts b/src/angular/src/app/pages/files/file-options.component.spec.ts new file mode 100644 index 00000000..ff1139fa --- /dev/null +++ b/src/angular/src/app/pages/files/file-options.component.spec.ts @@ -0,0 +1,156 @@ +import '@angular/compiler'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { TestBed } from '@angular/core/testing'; +import { BehaviorSubject, of } from 'rxjs'; + +import { FileOptionsComponent } from './file-options.component'; +import { ViewFileOptionsService } from '../../services/files/view-file-options.service'; +import { ViewFileService } from '../../services/files/view-file.service'; +import { DomService } from '../../services/utils/dom.service'; +import { ViewFile, ViewFileStatus } from '../../models/view-file'; +import { ViewFileOptions, SortMethod } from '../../models/view-file-options'; + +function makeViewFile(overrides: Partial = {}): ViewFile { + return { + name: 'TestFile.txt', + pairId: null, + pairName: null, + isDir: false, + localSize: 0, + remoteSize: 100, + percentDownloaded: 0, + status: ViewFileStatus.DEFAULT, + downloadingSpeed: 0, + eta: 0, + fullPath: '/TestFile.txt', + isArchive: false, + isSelected: false, + isChecked: false, + isQueueable: true, + isStoppable: false, + isExtractable: false, + isLocallyDeletable: false, + isRemotelyDeletable: true, + isValidatable: false, + validateTooltip: null, + localCreatedTimestamp: null, + localModifiedTimestamp: null, + remoteCreatedTimestamp: null, + remoteModifiedTimestamp: null, + ...overrides, + }; +} + +function makeOptions(overrides: Partial = {}): ViewFileOptions { + return { + showDetails: false, + sortMethod: SortMethod.STATUS, + selectedStatusFilter: null, + nameFilter: '', + pinFilter: false, + ...overrides, + }; +} + +describe('FileOptionsComponent', () => { + let component: FileOptionsComponent; + let filesSubject: BehaviorSubject; + let optionsSubject: BehaviorSubject; + let mockViewFileOptionsService: { + options$: ReturnType; + setNameFilter: ReturnType; + setSelectedStatusFilter: ReturnType; + setSortMethod: ReturnType; + setShowDetails: ReturnType; + setPinFilter: ReturnType; + }; + + beforeEach(() => { + filesSubject = new BehaviorSubject([]); + optionsSubject = new BehaviorSubject(makeOptions()); + + mockViewFileOptionsService = { + options$: optionsSubject.asObservable(), + setNameFilter: vi.fn(), + setSelectedStatusFilter: vi.fn(), + setSortMethod: vi.fn(), + setShowDetails: vi.fn(), + setPinFilter: vi.fn(), + }; + + TestBed.configureTestingModule({ + providers: [ + { provide: ViewFileOptionsService, useValue: mockViewFileOptionsService }, + { provide: ViewFileService, useValue: { files$: filesSubject.asObservable() } }, + { provide: DomService, useValue: { headerHeight$: of(0) } }, + ], + }); + + const fixture = TestBed.createComponent(FileOptionsComponent); + component = fixture.componentInstance; + component.ngOnInit(); + }); + + // --- Status filter enablement --- + + it('should disable all status filters when file list is empty', () => { + filesSubject.next([]); + expect(component.isDownloadingStatusEnabled).toBe(false); + expect(component.isDownloadedStatusEnabled).toBe(false); + expect(component.isQueuedStatusEnabled).toBe(false); + expect(component.isStoppedStatusEnabled).toBe(false); + expect(component.isExtractedStatusEnabled).toBe(false); + expect(component.isExtractingStatusEnabled).toBe(false); + }); + + it('should enable DOWNLOADING status filter when a downloading file exists', () => { + filesSubject.next([makeViewFile({ status: ViewFileStatus.DOWNLOADING })]); + expect(component.isDownloadingStatusEnabled).toBe(true); + expect(component.isDownloadedStatusEnabled).toBe(false); + }); + + it('should enable multiple status filters based on file list contents', () => { + filesSubject.next([ + makeViewFile({ name: 'a', status: ViewFileStatus.DOWNLOADING }), + makeViewFile({ name: 'b', status: ViewFileStatus.QUEUED }), + makeViewFile({ name: 'c', status: ViewFileStatus.EXTRACTED }), + ]); + expect(component.isDownloadingStatusEnabled).toBe(true); + expect(component.isQueuedStatusEnabled).toBe(true); + expect(component.isExtractedStatusEnabled).toBe(true); + expect(component.isDownloadedStatusEnabled).toBe(false); + expect(component.isStoppedStatusEnabled).toBe(false); + }); + + it('should update enablement reactively when file list changes', () => { + filesSubject.next([makeViewFile({ status: ViewFileStatus.DOWNLOADING })]); + expect(component.isDownloadingStatusEnabled).toBe(true); + + filesSubject.next([makeViewFile({ status: ViewFileStatus.QUEUED })]); + expect(component.isDownloadingStatusEnabled).toBe(false); + expect(component.isQueuedStatusEnabled).toBe(true); + }); + + // --- Delegation to ViewFileOptionsService --- + + it('should delegate name filter changes to ViewFileOptionsService', () => { + component.onFilterByName('test'); + expect(mockViewFileOptionsService.setNameFilter).toHaveBeenCalledWith('test'); + }); + + it('should delegate status filter changes to ViewFileOptionsService', () => { + component.onFilterByStatus(ViewFileStatus.DOWNLOADING); + expect(mockViewFileOptionsService.setSelectedStatusFilter).toHaveBeenCalledWith(ViewFileStatus.DOWNLOADING); + }); + + it('should delegate sort method changes to ViewFileOptionsService', () => { + component.onSort(SortMethod.NAME_ASC); + expect(mockViewFileOptionsService.setSortMethod).toHaveBeenCalledWith(SortMethod.NAME_ASC); + }); + + it('should toggle showDetails via ViewFileOptionsService', () => { + // Initial showDetails is false (from makeOptions default) + component.onToggleShowDetails(); + expect(mockViewFileOptionsService.setShowDetails).toHaveBeenCalledWith(true); + }); +}); diff --git a/src/angular/src/app/pages/main/header.component.spec.ts b/src/angular/src/app/pages/main/header.component.spec.ts new file mode 100644 index 00000000..0954a3a9 --- /dev/null +++ b/src/angular/src/app/pages/main/header.component.spec.ts @@ -0,0 +1,242 @@ +import '@angular/compiler'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { TestBed } from '@angular/core/testing'; +import { BehaviorSubject } from 'rxjs'; + +import { HeaderComponent } from './header.component'; +import { ServerStatusService } from '../../services/server/server-status.service'; +import { NotificationService } from '../../services/utils/notification.service'; +import { LoggerService } from '../../services/utils/logger.service'; +import { ServerStatus } from '../../models/server-status'; +import { Notification, NotificationLevel } from '../../models/notification'; +import { Localization } from '../../models/localization'; + +function makeStatus(overrides: Partial<{ + server: Partial; + controller: Partial; +}> = {}): ServerStatus { + return { + server: { + up: true, + errorMessage: null, + ...overrides.server, + }, + controller: { + latestLocalScanTime: null, + latestRemoteScanTime: new Date(), + latestRemoteScanFailed: false, + latestRemoteScanError: null, + noEnabledPairs: false, + ...overrides.controller, + }, + }; +} + +describe('HeaderComponent', () => { + let component: HeaderComponent; + let statusSubject: BehaviorSubject; + let notificationService: NotificationService; + + beforeEach(() => { + statusSubject = new BehaviorSubject(makeStatus()); + + TestBed.configureTestingModule({ + providers: [ + { provide: ServerStatusService, useValue: { status$: statusSubject.asObservable() } }, + NotificationService, + { provide: LoggerService, useValue: { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() } }, + ], + }); + + notificationService = TestBed.inject(NotificationService); + const fixture = TestBed.createComponent(HeaderComponent); + component = fixture.componentInstance; + component.ngOnInit(); + }); + + // --- Server down --- + + it('should show DANGER notification when server is down', () => { + let notifications: Notification[] = []; + notificationService.notifications$.subscribe(n => notifications = n); + + statusSubject.next(makeStatus({ server: { up: false, errorMessage: 'Connection refused' } })); + + expect(notifications.length).toBeGreaterThanOrEqual(1); + const danger = notifications.find(n => n.level === NotificationLevel.DANGER); + expect(danger).toBeDefined(); + expect(danger!.text).toBe('Connection refused'); + }); + + it('should hide server notification when server recovers', () => { + let notifications: Notification[] = []; + notificationService.notifications$.subscribe(n => notifications = n); + + statusSubject.next(makeStatus({ server: { up: false, errorMessage: 'Down' } })); + expect(notifications.some(n => n.level === NotificationLevel.DANGER)).toBe(true); + + statusSubject.next(makeStatus({ server: { up: true, errorMessage: null } })); + expect(notifications.some(n => n.level === NotificationLevel.DANGER)).toBe(false); + }); + + // --- Waiting for remote scan --- + + it('should show INFO notification when no remote scan yet and pairs exist', () => { + let notifications: Notification[] = []; + notificationService.notifications$.subscribe(n => notifications = n); + + statusSubject.next(makeStatus({ + server: { up: true }, + controller: { latestRemoteScanTime: null, noEnabledPairs: false }, + })); + + const info = notifications.find(n => n.level === NotificationLevel.INFO); + expect(info).toBeDefined(); + expect(info!.text).toBe(Localization.Notification.STATUS_REMOTE_SCAN_WAITING); + }); + + // --- Remote scan failed --- + + it('should show WARNING notification when remote scan fails', () => { + let notifications: Notification[] = []; + notificationService.notifications$.subscribe(n => notifications = n); + + statusSubject.next(makeStatus({ + server: { up: true }, + controller: { + latestRemoteScanFailed: true, + latestRemoteScanError: 'Timeout', + noEnabledPairs: false, + }, + })); + + const warning = notifications.find(n => n.level === NotificationLevel.WARNING); + expect(warning).toBeDefined(); + expect(warning!.text).toContain('Timeout'); + }); + + // --- No enabled pairs --- + + it('should show WARNING notification when no pairs are enabled', () => { + let notifications: Notification[] = []; + notificationService.notifications$.subscribe(n => notifications = n); + + statusSubject.next(makeStatus({ + server: { up: true }, + controller: { noEnabledPairs: true }, + })); + + const warning = notifications.find(n => n.text === Localization.Notification.STATUS_NO_ENABLED_PAIRS); + expect(warning).toBeDefined(); + expect(warning!.level).toBe(NotificationLevel.WARNING); + }); + + // --- Precedence / coexistence --- + + it('should show server-down notification with highest priority (DANGER sorts first)', () => { + let notifications: Notification[] = []; + notificationService.notifications$.subscribe(n => notifications = n); + + statusSubject.next(makeStatus({ server: { up: false, errorMessage: 'Server down' } })); + + // DANGER should be first in the sorted list + expect(notifications[0].level).toBe(NotificationLevel.DANGER); + }); + + it('should replace old notification text when status text changes', () => { + let notifications: Notification[] = []; + notificationService.notifications$.subscribe(n => notifications = n); + + statusSubject.next(makeStatus({ server: { up: false, errorMessage: 'Error A' } })); + expect(notifications.some(n => n.text === 'Error A')).toBe(true); + + statusSubject.next(makeStatus({ server: { up: false, errorMessage: 'Error B' } })); + expect(notifications.some(n => n.text === 'Error B')).toBe(true); + expect(notifications.some(n => n.text === 'Error A')).toBe(false); + }); + + it('should not create duplicate notification when same text is emitted again', () => { + let notifications: Notification[] = []; + notificationService.notifications$.subscribe(n => notifications = n); + + statusSubject.next(makeStatus({ server: { up: false, errorMessage: 'Same error' } })); + const countBefore = notifications.length; + + statusSubject.next(makeStatus({ server: { up: false, errorMessage: 'Same error' } })); + expect(notifications.length).toBe(countBefore); + }); + + it('should handle multiple rules coexisting independently', () => { + let notifications: Notification[] = []; + notificationService.notifications$.subscribe(n => notifications = n); + + // Trigger both "waiting for scan" and "no enabled pairs" are mutually exclusive + // (noEnabledPairs suppresses waitingForRemoteScan), but remoteServerError + noEnabledPairs + // are also exclusive. Let's verify server-down + noEnabledPairs can't coexist + // because server.up is false suppresses the noEnabledPairs check. + // Instead, let's verify that changing from one rule to another cleans up properly. + + // First: no enabled pairs + statusSubject.next(makeStatus({ + server: { up: true }, + controller: { noEnabledPairs: true, latestRemoteScanTime: new Date() }, + })); + expect(notifications.some(n => n.text === Localization.Notification.STATUS_NO_ENABLED_PAIRS)).toBe(true); + + // Then: server goes down + statusSubject.next(makeStatus({ + server: { up: false, errorMessage: 'Down' }, + controller: { noEnabledPairs: true }, + })); + // noEnabledPairs rule should be hidden (server.up is false) + expect(notifications.some(n => n.text === Localization.Notification.STATUS_NO_ENABLED_PAIRS)).toBe(false); + expect(notifications.some(n => n.level === NotificationLevel.DANGER)).toBe(true); + }); + + it('should not show "waiting for remote scan" when no pairs are enabled', () => { + let notifications: Notification[] = []; + notificationService.notifications$.subscribe(n => notifications = n); + + statusSubject.next(makeStatus({ + server: { up: true }, + controller: { latestRemoteScanTime: null, noEnabledPairs: true }, + })); + + // "waitingForRemoteScan" should NOT show because noEnabledPairs is true + expect(notifications.some(n => n.text === Localization.Notification.STATUS_REMOTE_SCAN_WAITING)).toBe(false); + // But "noEnabledPairs" should show + expect(notifications.some(n => n.text === Localization.Notification.STATUS_NO_ENABLED_PAIRS)).toBe(true); + }); + + it('should not show remote scan error when no pairs are enabled', () => { + let notifications: Notification[] = []; + notificationService.notifications$.subscribe(n => notifications = n); + + statusSubject.next(makeStatus({ + server: { up: true }, + controller: { + latestRemoteScanFailed: true, + latestRemoteScanError: 'Timeout', + noEnabledPairs: true, + }, + })); + + expect(notifications.some(n => n.text.includes('Timeout'))).toBe(false); + }); + + it('should clean up all notifications when server recovers from complex state', () => { + let notifications: Notification[] = []; + notificationService.notifications$.subscribe(n => notifications = n); + + // Server down + statusSubject.next(makeStatus({ server: { up: false, errorMessage: 'Error' } })); + expect(notifications.length).toBeGreaterThan(0); + + // Server recovers to healthy state + statusSubject.next(makeStatus({ + server: { up: true }, + controller: { latestRemoteScanTime: new Date(), noEnabledPairs: false }, + })); + expect(notifications.length).toBe(0); + }); +}); diff --git a/src/angular/src/app/pages/settings/integrations.component.spec.ts b/src/angular/src/app/pages/settings/integrations.component.spec.ts new file mode 100644 index 00000000..3ed64af9 --- /dev/null +++ b/src/angular/src/app/pages/settings/integrations.component.spec.ts @@ -0,0 +1,182 @@ +import '@angular/compiler'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { TestBed } from '@angular/core/testing'; +import { BehaviorSubject, of, throwError } from 'rxjs'; + +import { IntegrationsComponent } from './integrations.component'; +import { IntegrationsService } from '../../services/settings/integrations.service'; +import { ArrInstance } from '../../models/arr-instance'; + +function makeInstance(overrides: Partial = {}): ArrInstance { + return { + id: 'inst-1', + name: 'Sonarr — TV', + kind: 'sonarr', + url: 'http://sonarr:8989', + api_key: '********', + enabled: true, + ...overrides, + }; +} + +describe('IntegrationsComponent', () => { + let component: IntegrationsComponent; + let instancesSubject: BehaviorSubject; + let mockIntegrationsService: { + instances$: ReturnType; + create: ReturnType; + update: ReturnType; + remove: ReturnType; + refresh: ReturnType; + test: ReturnType; + }; + + beforeEach(() => { + vi.useFakeTimers(); + instancesSubject = new BehaviorSubject([]); + + mockIntegrationsService = { + instances$: instancesSubject.asObservable(), + create: vi.fn(), + update: vi.fn(), + remove: vi.fn(), + refresh: vi.fn(), + test: vi.fn(), + }; + + TestBed.configureTestingModule({ + providers: [ + { provide: IntegrationsService, useValue: mockIntegrationsService }, + ], + }); + + const fixture = TestBed.createComponent(IntegrationsComponent); + component = fixture.componentInstance; + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + // --- Add form --- + + it('should open add form with kind pre-selected and cancel should reset', () => { + component.onStartAdd('radarr'); + expect(component.adding).toBe(true); + expect(component.addForm.kind).toBe('radarr'); + expect(component.addForm.name).toBe(''); + + component.onCancelAdd(); + expect(component.adding).toBe(false); + expect(component.addForm.name).toBe(''); + }); + + it('should set error when saving add with empty name', () => { + component.onStartAdd('sonarr'); + component.addForm.name = ' '; + component.onSaveAdd(); + + expect(component.errorMessage).toBe('Name is required.'); + expect(mockIntegrationsService.create).not.toHaveBeenCalled(); + }); + + it('should call service create with valid data', () => { + const created = makeInstance({ id: 'new-id', name: 'My Sonarr' }); + mockIntegrationsService.create.mockReturnValue(of(created)); + + component.onStartAdd('sonarr'); + component.addForm.name = 'My Sonarr'; + component.addForm.url = 'http://sonarr:8989'; + component.addForm.api_key = 'key123'; + component.onSaveAdd(); + + expect(mockIntegrationsService.create).toHaveBeenCalledWith({ + name: 'My Sonarr', + kind: 'sonarr', + url: 'http://sonarr:8989', + api_key: 'key123', + enabled: true, + }); + expect(component.adding).toBe(false); + }); + + // --- Edit form --- + + it('should populate edit form from instance', () => { + const inst = makeInstance({ name: 'Radarr — Movies', kind: 'radarr' }); + component.onStartEdit(inst); + + expect(component.editingId).toBe('inst-1'); + expect(component.editForm.name).toBe('Radarr — Movies'); + expect(component.editForm.kind).toBe('radarr'); + }); + + it('should cancel edit and clear form', () => { + component.onStartEdit(makeInstance()); + expect(component.editingId).not.toBeNull(); + + component.onCancelEdit(); + expect(component.editingId).toBeNull(); + expect(component.editForm.name).toBe(''); + }); + + // --- Delete double-click --- + + it('should enter confirmation state on first delete click', () => { + component.onDelete('inst-1'); + expect(component.confirmingDeleteId).toBe('inst-1'); + expect(mockIntegrationsService.remove).not.toHaveBeenCalled(); + }); + + it('should call service remove on second delete click (confirmation)', () => { + mockIntegrationsService.remove.mockReturnValue(of(true)); + + component.onDelete('inst-1'); // first click -> confirm + component.onDelete('inst-1'); // second click -> delete + + expect(mockIntegrationsService.remove).toHaveBeenCalledWith('inst-1'); + expect(component.confirmingDeleteId).toBeNull(); + }); + + it('should reset confirmation state after 3 seconds', () => { + component.onDelete('inst-1'); + expect(component.confirmingDeleteId).toBe('inst-1'); + + vi.advanceTimersByTime(3000); + expect(component.confirmingDeleteId).toBeNull(); + }); + + it('should clear confirmation timer on destroy', () => { + component.onDelete('inst-1'); + expect(component.confirmingDeleteId).toBe('inst-1'); + + component.ngOnDestroy(); + // Advancing timers after destroy should not cause issues + vi.advanceTimersByTime(5000); + // Timer was cleared, so it doesn't auto-reset (already cleared in ngOnDestroy) + }); + + // --- Error handling --- + + it('should set error when create returns null (server error)', () => { + mockIntegrationsService.create.mockReturnValue(of(null)); + + component.onStartAdd('sonarr'); + component.addForm.name = 'Test'; + component.onSaveAdd(); + + expect(component.errorMessage).toContain('Failed to create'); + }); + + it('should set error when create throws (409 conflict)', () => { + mockIntegrationsService.create.mockReturnValue( + throwError(() => ({ status: 409 })), + ); + + component.onStartAdd('sonarr'); + component.addForm.name = 'Duplicate'; + component.onSaveAdd(); + + expect(component.errorMessage).toContain('already exists'); + }); +}); diff --git a/src/angular/src/app/pages/settings/option.component.spec.ts b/src/angular/src/app/pages/settings/option.component.spec.ts new file mode 100644 index 00000000..e5373ece --- /dev/null +++ b/src/angular/src/app/pages/settings/option.component.spec.ts @@ -0,0 +1,141 @@ +import '@angular/compiler'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { Subject, Subscription } from 'rxjs'; +import { debounceTime, distinctUntilChanged } from 'rxjs/operators'; +import { TestBed } from '@angular/core/testing'; +import { ComponentFixture } from '@angular/core/testing'; + +import { OptionComponent, OptionType, OptionValue, DEBOUNCE_TIME_MS } from './option.component'; +import { REDACTED_SENTINEL } from '../../models/config'; + +/** + * Test the debounce/distinctUntilChanged pipe logic directly using the same + * operators as OptionComponent, avoiding Angular component lifecycle timing issues. + */ +describe('OptionComponent — debounce pipe logic', () => { + let newValue: Subject; + let emitted: OptionValue[]; + let subscription: Subscription; + + beforeEach(() => { + vi.useFakeTimers(); + newValue = new Subject(); + emitted = []; + subscription = newValue + .pipe(debounceTime(DEBOUNCE_TIME_MS), distinctUntilChanged()) + .subscribe((val) => emitted.push(val)); + }); + + afterEach(() => { + subscription.unsubscribe(); + vi.useRealTimers(); + }); + + it('should emit value after the full debounce window, not immediately', () => { + newValue.next('hello'); + expect(emitted).toEqual([]); + + vi.advanceTimersByTime(DEBOUNCE_TIME_MS); + expect(emitted).toEqual(['hello']); + }); + + it('should emit only the final value when rapid changes occur', () => { + newValue.next('a'); + vi.advanceTimersByTime(200); + newValue.next('b'); + vi.advanceTimersByTime(200); + newValue.next('c'); + + expect(emitted).toEqual([]); + + vi.advanceTimersByTime(DEBOUNCE_TIME_MS); + expect(emitted).toEqual(['c']); + }); + + it('should not re-emit same value after full debounce period (distinctUntilChanged)', () => { + newValue.next('same'); + vi.advanceTimersByTime(DEBOUNCE_TIME_MS); + expect(emitted).toEqual(['same']); + + newValue.next('same'); + vi.advanceTimersByTime(DEBOUNCE_TIME_MS); + expect(emitted).toEqual(['same']); + }); + + it('should emit different values after each debounce period', () => { + newValue.next('first'); + vi.advanceTimersByTime(DEBOUNCE_TIME_MS); + newValue.next('second'); + vi.advanceTimersByTime(DEBOUNCE_TIME_MS); + + expect(emitted).toEqual(['first', 'second']); + }); +}); + +/** + * Test the onChange guard logic (password REDACTED_SENTINEL suppression) + * and effectiveChoices computed signal. + */ +describe('OptionComponent — onChange and effectiveChoices', () => { + let component: OptionComponent; + let fixture: ComponentFixture; + + beforeEach(() => { + TestBed.configureTestingModule({}); + fixture = TestBed.createComponent(OptionComponent); + component = fixture.componentInstance; + }); + + // Restore real timers after every test so a failing assertion inside a + // useFakeTimers() block can't leak fake timers into the next test. + afterEach(() => { + vi.useRealTimers(); + }); + + it('should suppress REDACTED_SENTINEL for password type', () => { + fixture.componentRef.setInput('type', OptionType.Password); + fixture.detectChanges(); + + // Spy on the internal Subject to verify nothing gets pushed. + // No fake timers needed — onChange() pushes to the Subject synchronously + // before any debounceTime delay; the assertion observes the next() call, + // not the downstream debounced emission. + const nextSpy = vi.spyOn((component as unknown as { newValue: Subject }).newValue, 'next'); + component.onChange(REDACTED_SENTINEL); + expect(nextSpy).not.toHaveBeenCalled(); + }); + + it('should pass real password value through to Subject', () => { + fixture.componentRef.setInput('type', OptionType.Password); + fixture.detectChanges(); + + const nextSpy = vi.spyOn((component as unknown as { newValue: Subject }).newValue, 'next'); + component.onChange('real-password'); + expect(nextSpy).toHaveBeenCalledWith('real-password'); + }); + + it('should not suppress REDACTED_SENTINEL for non-password types', () => { + fixture.componentRef.setInput('type', OptionType.Text); + fixture.detectChanges(); + + const nextSpy = vi.spyOn((component as unknown as { newValue: Subject }).newValue, 'next'); + component.onChange(REDACTED_SENTINEL); + expect(nextSpy).toHaveBeenCalledWith(REDACTED_SENTINEL); + }); + + it('should include current value in choices if not in predefined list', () => { + fixture.componentRef.setInput('choices', ['opt1', 'opt2']); + fixture.componentRef.setInput('value', 'custom'); + fixture.detectChanges(); + + expect(component.effectiveChoices()).toEqual(['custom', 'opt1', 'opt2']); + }); + + it('should not duplicate current value if already in choices', () => { + fixture.componentRef.setInput('choices', ['opt1', 'opt2']); + fixture.componentRef.setInput('value', 'opt1'); + fixture.detectChanges(); + + expect(component.effectiveChoices()).toEqual(['opt1', 'opt2']); + }); +}); diff --git a/src/angular/src/app/pages/settings/option.component.ts b/src/angular/src/app/pages/settings/option.component.ts index 2bcc3ce1..f2e76b93 100644 --- a/src/angular/src/app/pages/settings/option.component.ts +++ b/src/angular/src/app/pages/settings/option.component.ts @@ -13,6 +13,12 @@ export enum OptionType { /** Value emitted by OptionComponent — matches the possible config value types. */ export type OptionValue = string | number | boolean | null; +/** + * Debounce window applied to onChange before the change event is emitted. + * Exported so tests can reference the same value without re-declaring it. + */ +export const DEBOUNCE_TIME_MS = 1000; + @Component({ selector: 'app-option', standalone: true, @@ -43,13 +49,12 @@ export class OptionComponent implements OnInit, OnDestroy { return c; }); - private readonly DEBOUNCE_TIME_MS = 1000; private readonly newValue = new Subject(); private subscription?: Subscription; ngOnInit(): void { this.subscription = this.newValue - .pipe(debounceTime(this.DEBOUNCE_TIME_MS), distinctUntilChanged()) + .pipe(debounceTime(DEBOUNCE_TIME_MS), distinctUntilChanged()) .subscribe({ next: (val) => this.changeEvent.emit(val) }); } diff --git a/src/angular/src/app/pages/settings/options-list.ts b/src/angular/src/app/pages/settings/options-list.ts index 2bc8cecd..c53cf1fc 100644 --- a/src/angular/src/app/pages/settings/options-list.ts +++ b/src/angular/src/app/pages/settings/options-list.ts @@ -7,6 +7,7 @@ export interface IOption { description: string | null; disabled?: boolean; choices?: string[]; + requiresRestart?: boolean; } export interface IOptionsContext { @@ -24,48 +25,56 @@ export const OPTIONS_CONTEXT_SERVER: IOptionsContext = { label: 'Server Address', valuePath: ['lftp', 'remote_address'], description: null, + requiresRestart: true, }, { type: OptionType.Text, label: 'Server User', valuePath: ['lftp', 'remote_username'], description: null, + requiresRestart: true, }, { type: OptionType.Password, label: 'Server Password', valuePath: ['lftp', 'remote_password'], description: 'Required unless SSH key authentication is enabled', + requiresRestart: true, }, { type: OptionType.Checkbox, label: 'Use password-less key-based authentication', valuePath: ['lftp', 'use_ssh_key'], description: null, + requiresRestart: true, }, { type: OptionType.Text, label: 'Server Directory', valuePath: ['lftp', 'remote_path'], description: 'Path to your files on the remote server', + requiresRestart: true, }, { type: OptionType.Text, label: 'Local Directory', valuePath: ['lftp', 'local_path'], description: 'Downloaded files are placed here', + requiresRestart: true, }, { type: OptionType.Text, label: 'Remote SSH Port', valuePath: ['lftp', 'remote_port'], description: null, + requiresRestart: true, }, { type: OptionType.Text, label: 'Server Script Path', valuePath: ['lftp', 'remote_path_to_scan_script'], description: 'Where to install scanner script on remote server', + requiresRestart: true, }, { type: OptionType.Text, @@ -74,6 +83,7 @@ export const OPTIONS_CONTEXT_SERVER: IOptionsContext = { description: 'Path to Python 3 on the remote server. Leave empty to use the default "python3". ' + 'Set this if your seedbox has a custom Python install (e.g. "~/python3/bin/python3").', + requiresRestart: true, }, ], }; @@ -95,18 +105,21 @@ export const OPTIONS_CONTEXT_DISCOVERY: IOptionsContext = { label: 'Remote Scan Interval (ms)', valuePath: ['controller', 'interval_ms_remote_scan'], description: 'How often the remote server is scanned for new files', + requiresRestart: true, }, { type: OptionType.Text, label: 'Local Scan Interval (ms)', valuePath: ['controller', 'interval_ms_local_scan'], description: 'How often the local directory is scanned', + requiresRestart: true, }, { type: OptionType.Text, label: 'Downloading Scan Interval (ms)', valuePath: ['controller', 'interval_ms_downloading_scan'], description: 'How often the downloading information is updated', + requiresRestart: true, }, ], }; @@ -180,12 +193,14 @@ export const OPTIONS_CONTEXT_OTHER: IOptionsContext = { label: 'Web GUI Port', valuePath: ['web', 'port'], description: null, + requiresRestart: true, }, { type: OptionType.Password, label: 'API Key', valuePath: ['web', 'api_key'], description: 'Require this key for API access. Leave empty to disable.', + requiresRestart: true, }, ], }; @@ -199,18 +214,21 @@ export const OPTIONS_CONTEXT_AUTOQUEUE: IOptionsContext = { label: 'Enable AutoQueue', valuePath: ['autoqueue', 'enabled'], description: null, + requiresRestart: true, }, { type: OptionType.Checkbox, label: 'Restrict to patterns', valuePath: ['autoqueue', 'patterns_only'], description: 'Only autoqueue files that match a pattern', + requiresRestart: true, }, { type: OptionType.Checkbox, label: 'Enable auto extraction', valuePath: ['autoqueue', 'auto_extract'], description: 'Automatically extract files', + requiresRestart: true, }, { type: OptionType.Checkbox, @@ -231,12 +249,14 @@ export const OPTIONS_CONTEXT_STAGING: IOptionsContext = { label: 'Use staging directory', valuePath: ['controller', 'use_staging'], description: 'Download files to a staging directory before moving to the final location', + requiresRestart: true, }, { type: OptionType.Text, label: 'Staging Path', valuePath: ['controller', 'staging_path'], description: 'Temporary directory where files are downloaded before being moved', + requiresRestart: true, }, ], }; @@ -315,6 +335,7 @@ export const OPTIONS_CONTEXT_LOGGING: IOptionsContext = { valuePath: ['general', 'log_level'], description: 'Controls which log messages are recorded', choices: ['DEBUG', 'INFO', 'WARNING', 'ERROR', 'CRITICAL'], + requiresRestart: true, }, { type: OptionType.Checkbox, @@ -328,6 +349,7 @@ export const OPTIONS_CONTEXT_LOGGING: IOptionsContext = { valuePath: ['logging', 'log_format'], description: 'Log output format', choices: ['standard', 'json'], + requiresRestart: true, }, ], }; @@ -433,6 +455,7 @@ export const OPTIONS_CONTEXT_EXTRACT: IOptionsContext = { label: 'Extract archives in the downloads directory', valuePath: ['controller', 'use_local_path_as_extract_path'], description: null, + requiresRestart: true, }, { type: OptionType.Text, @@ -440,6 +463,7 @@ export const OPTIONS_CONTEXT_EXTRACT: IOptionsContext = { valuePath: ['controller', 'extract_path'], description: 'When option above is disabled, extract archives to this directory', + requiresRestart: true, }, ], }; diff --git a/src/angular/src/app/pages/settings/settings-page.component.html b/src/angular/src/app/pages/settings/settings-page.component.html index cf846026..a8742843 100644 --- a/src/angular/src/app/pages/settings/settings-page.component.html +++ b/src/angular/src/app/pages/settings/settings-page.component.html @@ -55,7 +55,7 @@ [description]="option.description" [choices]="option.choices || []" [value]="getOptionValue(config$ | async, option.valuePath)" - (changeEvent)="onSetConfig(option.valuePath[0], option.valuePath[1], $event)"> + (changeEvent)="onSetConfig(option.valuePath[0], option.valuePath[1], $event, option.requiresRestart)"> } @@ -83,7 +83,7 @@

Notifications

[disabled]="!!option.disabled" [choices]="option.choices || []" [value]="getOptionValue(config$ | async, option.valuePath)" - (changeEvent)="onSetConfig(option.valuePath[0], option.valuePath[1], $event)"> + (changeEvent)="onSetConfig(option.valuePath[0], option.valuePath[1], $event, option.requiresRestart)"> } @@ -152,7 +152,7 @@

{{ header }}

[disabled]="!!option.disabled" [choices]="option.choices || []" [value]="getOptionValue(config$ | async, option.valuePath)" - (changeEvent)="onSetConfig(option.valuePath[0], option.valuePath[1], $event)"> + (changeEvent)="onSetConfig(option.valuePath[0], option.valuePath[1], $event, option.requiresRestart)"> } diff --git a/src/angular/src/app/pages/settings/settings-page.component.ts b/src/angular/src/app/pages/settings/settings-page.component.ts index a79721b9..2363b8c2 100644 --- a/src/angular/src/app/pages/settings/settings-page.component.ts +++ b/src/angular/src/app/pages/settings/settings-page.component.ts @@ -164,7 +164,7 @@ export class SettingsPageComponent implements OnInit { return section[valuePath[1]] ?? null; } - onSetConfig(section: string, option: string, value: OptionValue): void { + onSetConfig(section: string, option: string, value: OptionValue, requiresRestart?: boolean): void { this.configService.set(section, option, value).subscribe({ next: (reaction) => { const notifKey = section + '.' + option; @@ -176,7 +176,9 @@ export class SettingsPageComponent implements OnInit { this.badValueNotifs.delete(notifKey); } - this.notifService.show(this.configRestartNotif); + if (requiresRestart) { + this.notifService.show(this.configRestartNotif); + } } else { const notif = createNotification( NotificationLevel.DANGER, diff --git a/src/angular/src/app/services/autoqueue/autoqueue.service.spec.ts b/src/angular/src/app/services/autoqueue/autoqueue.service.spec.ts new file mode 100644 index 00000000..7b1eb48e --- /dev/null +++ b/src/angular/src/app/services/autoqueue/autoqueue.service.spec.ts @@ -0,0 +1,216 @@ +import '@angular/compiler'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { TestBed } from '@angular/core/testing'; +import { BehaviorSubject, of } from 'rxjs'; + +import { AutoQueueService } from './autoqueue.service'; +import { ConnectedService } from '../utils/connected.service'; +import { RestService, WebReaction } from '../utils/rest.service'; +import { LoggerService } from '../utils/logger.service'; +import { AutoQueuePattern } from '../../models/autoqueue-pattern'; +import { Localization } from '../../models/localization'; + +function makeReaction(overrides: Partial = {}): WebReaction { + return { success: true, data: null, errorMessage: null, ...overrides }; +} + +describe('AutoQueueService', () => { + let service: AutoQueueService; + let connectedSubject: BehaviorSubject; + let mockRestService: { sendRequest: ReturnType }; + + function snapshot(): AutoQueuePattern[] { + let result: AutoQueuePattern[] = []; + service.patterns$.subscribe(p => result = p); + return result; + } + + beforeEach(() => { + connectedSubject = new BehaviorSubject(false); + mockRestService = { sendRequest: vi.fn() }; + + TestBed.configureTestingModule({ + providers: [ + AutoQueueService, + { provide: ConnectedService, useValue: { connected$: connectedSubject.asObservable() } }, + { provide: RestService, useValue: mockRestService }, + { provide: LoggerService, useValue: { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() } }, + ], + }); + service = TestBed.inject(AutoQueueService); + }); + + // --- Connect / disconnect --- + + it('should fetch patterns when connected', () => { + mockRestService.sendRequest.mockReturnValue( + of(makeReaction({ success: true, data: JSON.stringify([{ pattern: '*.mkv' }]) })), + ); + + connectedSubject.next(true); + + expect(mockRestService.sendRequest).toHaveBeenCalledWith('/server/autoqueue/get'); + expect(snapshot()).toEqual([{ pattern: '*.mkv' }]); + }); + + it('should clear patterns when disconnected', () => { + mockRestService.sendRequest.mockReturnValue( + of(makeReaction({ success: true, data: JSON.stringify([{ pattern: '*.mkv' }]) })), + ); + connectedSubject.next(true); + expect(snapshot().length).toBe(1); + + connectedSubject.next(false); + expect(snapshot()).toEqual([]); + }); + + // --- add() --- + + it('should reject empty pattern with error message', () => { + let result: WebReaction | undefined; + service.add('').subscribe(r => result = r); + + expect(result!.success).toBe(false); + expect(result!.errorMessage).toBe(Localization.Notification.AUTOQUEUE_PATTERN_EMPTY); + }); + + it('should reject whitespace-only pattern', () => { + let result: WebReaction | undefined; + service.add(' ').subscribe(r => result = r); + + expect(result!.success).toBe(false); + expect(result!.errorMessage).toBe(Localization.Notification.AUTOQUEUE_PATTERN_EMPTY); + }); + + it('should reject duplicate pattern locally', () => { + // Pre-load a pattern + mockRestService.sendRequest.mockReturnValue( + of(makeReaction({ success: true, data: JSON.stringify([{ pattern: '*.mkv' }]) })), + ); + connectedSubject.next(true); + + let result: WebReaction | undefined; + service.add('*.mkv').subscribe(r => result = r); + + expect(result!.success).toBe(false); + expect(result!.errorMessage).toContain('already exists'); + }); + + it('should append pattern to local list on successful add', () => { + // Start with empty connected state + mockRestService.sendRequest.mockReturnValueOnce( + of(makeReaction({ success: true, data: JSON.stringify([]) })), + ); + connectedSubject.next(true); + expect(snapshot()).toEqual([]); + + // Add returns success + mockRestService.sendRequest.mockReturnValue( + of(makeReaction({ success: true })), + ); + + service.add('*.mkv'); + expect(snapshot()).toEqual([{ pattern: '*.mkv' }]); + }); + + it('should send double-encoded URL for add', () => { + mockRestService.sendRequest.mockReturnValueOnce( + of(makeReaction({ success: true, data: JSON.stringify([]) })), + ); + connectedSubject.next(true); + + mockRestService.sendRequest.mockReturnValue( + of(makeReaction({ success: true })), + ); + + service.add('my pattern'); + + const encoded = encodeURIComponent(encodeURIComponent('my pattern')); + expect(mockRestService.sendRequest).toHaveBeenCalledWith(`/server/autoqueue/add/${encoded}`); + }); + + it('should not update local state when server rejects add', () => { + mockRestService.sendRequest.mockReturnValueOnce( + of(makeReaction({ success: true, data: JSON.stringify([]) })), + ); + connectedSubject.next(true); + + mockRestService.sendRequest.mockReturnValue( + of(makeReaction({ success: false, errorMessage: 'Server error' })), + ); + + service.add('*.mkv'); + expect(snapshot()).toEqual([]); + }); + + it('should double-encode special characters in pattern', () => { + mockRestService.sendRequest.mockReturnValueOnce( + of(makeReaction({ success: true, data: JSON.stringify([]) })), + ); + connectedSubject.next(true); + + mockRestService.sendRequest.mockReturnValue( + of(makeReaction({ success: true })), + ); + + service.add('test/path&name'); + + const encoded = encodeURIComponent(encodeURIComponent('test/path&name')); + expect(mockRestService.sendRequest).toHaveBeenCalledWith(`/server/autoqueue/add/${encoded}`); + }); + + // --- remove() --- + + it('should filter pattern from local list on successful remove', () => { + mockRestService.sendRequest.mockReturnValueOnce( + of(makeReaction({ success: true, data: JSON.stringify([{ pattern: '*.mkv' }, { pattern: '*.avi' }]) })), + ); + connectedSubject.next(true); + expect(snapshot().length).toBe(2); + + mockRestService.sendRequest.mockReturnValue( + of(makeReaction({ success: true })), + ); + + service.remove('*.mkv'); + expect(snapshot()).toEqual([{ pattern: '*.avi' }]); + }); + + it('should not update local state when server rejects remove', () => { + mockRestService.sendRequest.mockReturnValueOnce( + of(makeReaction({ success: true, data: JSON.stringify([{ pattern: '*.mkv' }]) })), + ); + connectedSubject.next(true); + + mockRestService.sendRequest.mockReturnValue( + of(makeReaction({ success: false, errorMessage: 'Server error' })), + ); + + service.remove('*.mkv'); + expect(snapshot()).toEqual([{ pattern: '*.mkv' }]); + }); + + it('should return error when removing a pattern that does not exist', () => { + mockRestService.sendRequest.mockReturnValueOnce( + of(makeReaction({ success: true, data: JSON.stringify([]) })), + ); + connectedSubject.next(true); + + let result: WebReaction | undefined; + service.remove('nonexistent').subscribe(r => result = r); + + expect(result!.success).toBe(false); + expect(result!.errorMessage).toContain('not found'); + }); + + // --- GET failure --- + + it('should set empty array when GET fails (graceful degradation)', () => { + mockRestService.sendRequest.mockReturnValue( + of(makeReaction({ success: false, errorMessage: 'Network error' })), + ); + + connectedSubject.next(true); + expect(snapshot()).toEqual([]); + }); +}); diff --git a/src/angular/src/app/services/files/view-file-filter.service.spec.ts b/src/angular/src/app/services/files/view-file-filter.service.spec.ts new file mode 100644 index 00000000..72e68c4a --- /dev/null +++ b/src/angular/src/app/services/files/view-file-filter.service.spec.ts @@ -0,0 +1,275 @@ +import '@angular/compiler'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { TestBed } from '@angular/core/testing'; +import { BehaviorSubject } from 'rxjs'; + +import { ViewFileFilterService } from './view-file-filter.service'; +import { ViewFileService, ViewFileFilterCriteria } from './view-file.service'; +import { ViewFileOptionsService } from './view-file-options.service'; +import { LoggerService } from '../utils/logger.service'; +import { ViewFile, ViewFileStatus } from '../../models/view-file'; +import { ViewFileOptions, SortMethod } from '../../models/view-file-options'; + +function makeViewFile(overrides: Partial = {}): ViewFile { + return { + name: 'TestFile.txt', + pairId: null, + pairName: null, + isDir: false, + localSize: 0, + remoteSize: 100, + percentDownloaded: 0, + status: ViewFileStatus.DEFAULT, + downloadingSpeed: 0, + eta: 0, + fullPath: '/TestFile.txt', + isArchive: false, + isSelected: false, + isChecked: false, + isQueueable: true, + isStoppable: false, + isExtractable: false, + isLocallyDeletable: false, + isRemotelyDeletable: true, + isValidatable: false, + validateTooltip: null, + localCreatedTimestamp: null, + localModifiedTimestamp: null, + remoteCreatedTimestamp: null, + remoteModifiedTimestamp: null, + ...overrides, + }; +} + +function makeOptions(overrides: Partial = {}): ViewFileOptions { + return { + showDetails: false, + sortMethod: SortMethod.STATUS, + selectedStatusFilter: null, + nameFilter: '', + pinFilter: false, + ...overrides, + }; +} + +// ─── NameFilterCriteria (tested via service) ────────────────────────── + +describe('ViewFileFilterService — NameFilterCriteria', () => { + let optionsSubject: BehaviorSubject; + let capturedCriteria: ViewFileFilterCriteria | null; + let mockViewFileService: { setFilterCriteria: ReturnType }; + + beforeEach(() => { + capturedCriteria = null; + optionsSubject = new BehaviorSubject(makeOptions()); + mockViewFileService = { + setFilterCriteria: vi.fn((c: ViewFileFilterCriteria | null) => { + capturedCriteria = c; + }), + }; + + TestBed.configureTestingModule({ + providers: [ + ViewFileFilterService, + { provide: ViewFileOptionsService, useValue: { options$: optionsSubject.asObservable() } }, + { provide: ViewFileService, useValue: mockViewFileService }, + { provide: LoggerService, useValue: { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() } }, + ], + }); + // Instantiate to trigger the constructor subscription + TestBed.inject(ViewFileFilterService); + }); + + it('should match all files when name filter is null', () => { + optionsSubject.next(makeOptions({ nameFilter: null as unknown as string })); + const file = makeViewFile({ name: 'Anything.mkv' }); + expect(capturedCriteria!.meetsCriteria(file)).toBe(true); + }); + + it('should match all files when name filter is empty string', () => { + optionsSubject.next(makeOptions({ nameFilter: '' })); + const file = makeViewFile({ name: 'Anything.mkv' }); + expect(capturedCriteria!.meetsCriteria(file)).toBe(true); + }); + + it('should match substring case-insensitively', () => { + optionsSubject.next(makeOptions({ nameFilter: 'show' })); + expect(capturedCriteria!.meetsCriteria(makeViewFile({ name: 'My.Show.S01E01.mkv' }))).toBe(true); + expect(capturedCriteria!.meetsCriteria(makeViewFile({ name: 'MY.SHOW.S01E01.mkv' }))).toBe(true); + expect(capturedCriteria!.meetsCriteria(makeViewFile({ name: 'other-file.txt' }))).toBe(false); + }); + + it('should treat spaces as dots for fuzzy matching ("my show" matches "My.Show.S01E01")', () => { + optionsSubject.next(makeOptions({ nameFilter: 'my show' })); + expect(capturedCriteria!.meetsCriteria(makeViewFile({ name: 'My.Show.S01E01.mkv' }))).toBe(true); + }); + + it('should treat dots as spaces for fuzzy matching ("my.show" matches "My Show S01E01")', () => { + optionsSubject.next(makeOptions({ nameFilter: 'my.show' })); + expect(capturedCriteria!.meetsCriteria(makeViewFile({ name: 'My Show S01E01.mkv' }))).toBe(true); + }); + + it('should return false for all files when no name matches', () => { + optionsSubject.next(makeOptions({ nameFilter: 'nonexistent' })); + expect(capturedCriteria!.meetsCriteria(makeViewFile({ name: 'Something.Else.mkv' }))).toBe(false); + expect(capturedCriteria!.meetsCriteria(makeViewFile({ name: 'Another.File.txt' }))).toBe(false); + }); + + it('should match when query contains mixed dots and spaces', () => { + optionsSubject.next(makeOptions({ nameFilter: 'my.show s01' })); + // The original query lowercased: "my.show s01" + // space→dot variant: "my.show.s01" + // dot→space variant: "my show s01" + expect(capturedCriteria!.meetsCriteria(makeViewFile({ name: 'My.Show.S01E01.mkv' }))).toBe(true); + }); + + it('should match only on the file name, not other fields', () => { + optionsSubject.next(makeOptions({ nameFilter: 'download' })); + // Name doesn't contain "download" but status is DOWNLOADING + const file = makeViewFile({ name: 'SomeFile.txt', status: ViewFileStatus.DOWNLOADING }); + expect(capturedCriteria!.meetsCriteria(file)).toBe(false); + }); +}); + +// ─── StatusFilterCriteria (tested via service) ──────────────────────── + +describe('ViewFileFilterService — StatusFilterCriteria', () => { + let optionsSubject: BehaviorSubject; + let capturedCriteria: ViewFileFilterCriteria | null; + let mockViewFileService: { setFilterCriteria: ReturnType }; + + beforeEach(() => { + capturedCriteria = null; + optionsSubject = new BehaviorSubject(makeOptions()); + mockViewFileService = { + setFilterCriteria: vi.fn((c: ViewFileFilterCriteria | null) => { + capturedCriteria = c; + }), + }; + + TestBed.configureTestingModule({ + providers: [ + ViewFileFilterService, + { provide: ViewFileOptionsService, useValue: { options$: optionsSubject.asObservable() } }, + { provide: ViewFileService, useValue: mockViewFileService }, + { provide: LoggerService, useValue: { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() } }, + ], + }); + TestBed.inject(ViewFileFilterService); + }); + + it('should match all files when status filter is null', () => { + optionsSubject.next(makeOptions({ selectedStatusFilter: null })); + expect(capturedCriteria!.meetsCriteria(makeViewFile({ status: ViewFileStatus.DEFAULT }))).toBe(true); + expect(capturedCriteria!.meetsCriteria(makeViewFile({ status: ViewFileStatus.DOWNLOADING }))).toBe(true); + expect(capturedCriteria!.meetsCriteria(makeViewFile({ status: ViewFileStatus.QUEUED }))).toBe(true); + }); + + it('should match only files with the specified status', () => { + optionsSubject.next(makeOptions({ selectedStatusFilter: ViewFileStatus.DOWNLOADING })); + expect(capturedCriteria!.meetsCriteria(makeViewFile({ status: ViewFileStatus.DOWNLOADING }))).toBe(true); + expect(capturedCriteria!.meetsCriteria(makeViewFile({ status: ViewFileStatus.DEFAULT }))).toBe(false); + expect(capturedCriteria!.meetsCriteria(makeViewFile({ status: ViewFileStatus.QUEUED }))).toBe(false); + }); + + it('should match each status enum value correctly', () => { + optionsSubject.next(makeOptions({ selectedStatusFilter: ViewFileStatus.EXTRACTED })); + expect(capturedCriteria!.meetsCriteria(makeViewFile({ status: ViewFileStatus.EXTRACTED }))).toBe(true); + expect(capturedCriteria!.meetsCriteria(makeViewFile({ status: ViewFileStatus.EXTRACTING }))).toBe(false); + }); +}); + +// ─── AndFilterCriteria (combined name + status) ─────────────────────── + +describe('ViewFileFilterService — AndFilterCriteria', () => { + let optionsSubject: BehaviorSubject; + let capturedCriteria: ViewFileFilterCriteria | null; + let mockViewFileService: { setFilterCriteria: ReturnType }; + + beforeEach(() => { + capturedCriteria = null; + optionsSubject = new BehaviorSubject(makeOptions()); + mockViewFileService = { + setFilterCriteria: vi.fn((c: ViewFileFilterCriteria | null) => { + capturedCriteria = c; + }), + }; + + TestBed.configureTestingModule({ + providers: [ + ViewFileFilterService, + { provide: ViewFileOptionsService, useValue: { options$: optionsSubject.asObservable() } }, + { provide: ViewFileService, useValue: mockViewFileService }, + { provide: LoggerService, useValue: { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() } }, + ], + }); + TestBed.inject(ViewFileFilterService); + }); + + it('should pass when both name and status match', () => { + optionsSubject.next(makeOptions({ nameFilter: 'show', selectedStatusFilter: ViewFileStatus.DOWNLOADING })); + const file = makeViewFile({ name: 'My.Show.S01E01.mkv', status: ViewFileStatus.DOWNLOADING }); + expect(capturedCriteria!.meetsCriteria(file)).toBe(true); + }); + + it('should reject when name matches but status does not', () => { + optionsSubject.next(makeOptions({ nameFilter: 'show', selectedStatusFilter: ViewFileStatus.DOWNLOADING })); + const file = makeViewFile({ name: 'My.Show.S01E01.mkv', status: ViewFileStatus.DEFAULT }); + expect(capturedCriteria!.meetsCriteria(file)).toBe(false); + }); + + it('should reject when status matches but name does not', () => { + optionsSubject.next(makeOptions({ nameFilter: 'show', selectedStatusFilter: ViewFileStatus.DOWNLOADING })); + const file = makeViewFile({ name: 'Other.File.txt', status: ViewFileStatus.DOWNLOADING }); + expect(capturedCriteria!.meetsCriteria(file)).toBe(false); + }); +}); + +// ─── Service integration ────────────────────────────────────────────── + +describe('ViewFileFilterService — service integration', () => { + let optionsSubject: BehaviorSubject; + let mockViewFileService: { setFilterCriteria: ReturnType }; + + beforeEach(() => { + optionsSubject = new BehaviorSubject(makeOptions()); + mockViewFileService = { setFilterCriteria: vi.fn() }; + + TestBed.configureTestingModule({ + providers: [ + ViewFileFilterService, + { provide: ViewFileOptionsService, useValue: { options$: optionsSubject.asObservable() } }, + { provide: ViewFileService, useValue: mockViewFileService }, + { provide: LoggerService, useValue: { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() } }, + ], + }); + TestBed.inject(ViewFileFilterService); + }); + + it('should call setFilterCriteria when status filter changes', () => { + mockViewFileService.setFilterCriteria.mockClear(); + optionsSubject.next(makeOptions({ selectedStatusFilter: ViewFileStatus.QUEUED })); + expect(mockViewFileService.setFilterCriteria).toHaveBeenCalledTimes(1); + }); + + it('should call setFilterCriteria when name filter changes', () => { + mockViewFileService.setFilterCriteria.mockClear(); + optionsSubject.next(makeOptions({ nameFilter: 'test' })); + expect(mockViewFileService.setFilterCriteria).toHaveBeenCalledTimes(1); + }); + + it('should not call setFilterCriteria when options re-emit with same filter values', () => { + // Initial emission happened during construction with defaults (null status, '' name) + mockViewFileService.setFilterCriteria.mockClear(); + + // Re-emit with identical values — should NOT trigger a redundant update + optionsSubject.next(makeOptions({ selectedStatusFilter: null, nameFilter: '' })); + expect(mockViewFileService.setFilterCriteria).not.toHaveBeenCalled(); + }); + + it('should call setFilterCriteria only once when both filters change simultaneously', () => { + mockViewFileService.setFilterCriteria.mockClear(); + optionsSubject.next(makeOptions({ selectedStatusFilter: ViewFileStatus.DOWNLOADED, nameFilter: 'movie' })); + expect(mockViewFileService.setFilterCriteria).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/angular/src/app/services/settings/path-pairs.service.spec.ts b/src/angular/src/app/services/settings/path-pairs.service.spec.ts new file mode 100644 index 00000000..f4d0d183 --- /dev/null +++ b/src/angular/src/app/services/settings/path-pairs.service.spec.ts @@ -0,0 +1,230 @@ +import '@angular/compiler'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { TestBed } from '@angular/core/testing'; +import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing'; +import { provideHttpClient } from '@angular/common/http'; +import { BehaviorSubject } from 'rxjs'; +import { take } from 'rxjs/operators'; + +import { PathPairsService } from './path-pairs.service'; +import { ConnectedService } from '../utils/connected.service'; +import { LoggerService } from '../utils/logger.service'; +import { PathPair } from '../../models/path-pair'; + +function makePair(overrides: Partial = {}): PathPair { + return { + id: 'pair-1', + name: 'Default', + remote_path: '/remote', + local_path: '/local', + enabled: true, + auto_queue: false, + arr_target_ids: [], + ...overrides, + }; +} + +describe('PathPairsService', () => { + let service: PathPairsService; + let httpMock: HttpTestingController; + let connectedSubject: BehaviorSubject; + + function snapshot(): PathPair[] { + // take(1) auto-completes the observable after the first emission, so + // each snapshot() call doesn't accumulate a lingering subscription + // on service.pairs$. + let result: PathPair[] = []; + service.pairs$.pipe(take(1)).subscribe(p => result = p); + return result; + } + + beforeEach(() => { + connectedSubject = new BehaviorSubject(false); + + TestBed.configureTestingModule({ + providers: [ + provideHttpClient(), + provideHttpClientTesting(), + PathPairsService, + { provide: ConnectedService, useValue: { connected$: connectedSubject.asObservable() } }, + { provide: LoggerService, useValue: { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() } }, + ], + }); + + service = TestBed.inject(PathPairsService); + httpMock = TestBed.inject(HttpTestingController); + }); + + afterEach(() => { + httpMock.verify(); + }); + + // --- Connect / disconnect --- + + it('should fetch pairs when connected', () => { + connectedSubject.next(true); + const req = httpMock.expectOne('/server/pathpairs'); + expect(req.request.method).toBe('GET'); + req.flush([makePair()]); + + expect(snapshot().length).toBe(1); + expect(snapshot()[0].name).toBe('Default'); + }); + + it('should clear pairs when disconnected', () => { + connectedSubject.next(true); + httpMock.expectOne('/server/pathpairs').flush([makePair()]); + expect(snapshot().length).toBe(1); + + connectedSubject.next(false); + expect(snapshot()).toEqual([]); + }); + + // --- create() --- + + it('should send POST and append returned pair', () => { + const created = makePair({ id: 'new-pair', name: 'New' }); + + let result: PathPair | null | undefined; + service.create({ + name: 'New', + remote_path: '/r', + local_path: '/l', + enabled: true, + auto_queue: false, + arr_target_ids: [], + }).subscribe(r => result = r); + + const req = httpMock.expectOne('/server/pathpairs'); + expect(req.request.method).toBe('POST'); + req.flush(created); + + expect(result!.id).toBe('new-pair'); + expect(snapshot().map(p => p.id)).toEqual(['new-pair']); + }); + + it('should rethrow 409 conflict from create()', () => { + let result: PathPair | null | undefined; + let errorStatus: number | undefined; + service.create({ + name: 'dup', + remote_path: '/r', + local_path: '/l', + enabled: true, + auto_queue: false, + arr_target_ids: [], + }).subscribe({ + next: r => result = r, + error: err => errorStatus = err.status, + }); + + httpMock.expectOne('/server/pathpairs').flush('conflict', { status: 409, statusText: 'Conflict' }); + expect(result).toBeUndefined(); + expect(errorStatus).toBe(409); + }); + + it('should return null on non-409 create errors', () => { + let result: PathPair | null | undefined; + service.create({ + name: 'X', + remote_path: '/r', + local_path: '/l', + enabled: true, + auto_queue: false, + arr_target_ids: [], + }).subscribe(r => result = r); + + httpMock.expectOne('/server/pathpairs').flush('error', { status: 500, statusText: 'Error' }); + expect(result).toBeNull(); + }); + + // --- update() --- + + it('should send PUT and replace pair in list by ID', () => { + connectedSubject.next(true); + httpMock.expectOne('/server/pathpairs').flush([makePair()]); + + const updated = makePair({ name: 'Renamed' }); + + let result: PathPair | null | undefined; + service.update(updated).subscribe(r => result = r); + + const req = httpMock.expectOne('/server/pathpairs/pair-1'); + expect(req.request.method).toBe('PUT'); + req.flush(updated); + + expect(result!.name).toBe('Renamed'); + expect(snapshot()[0].name).toBe('Renamed'); + }); + + it('should rethrow 409 conflict from update()', () => { + connectedSubject.next(true); + httpMock.expectOne('/server/pathpairs').flush([makePair()]); + + let errorStatus: number | undefined; + service.update(makePair({ name: 'dup' })).subscribe({ + error: err => errorStatus = err.status, + }); + + httpMock.expectOne('/server/pathpairs/pair-1').flush('conflict', { status: 409, statusText: 'Conflict' }); + expect(errorStatus).toBe(409); + }); + + it('should return null on non-409 error from update()', () => { + // Mirrors the create() non-409 coverage. update() rethrows 409 (caller + // displays the conflict message) but falls back to of(null) on any other + // HTTP error so subscribers can detect failure without an unhandled throw. + connectedSubject.next(true); + httpMock.expectOne('/server/pathpairs').flush([makePair()]); + + let result: PathPair | null | undefined; + service.update(makePair({ name: 'other' })).subscribe(r => result = r); + + httpMock.expectOne('/server/pathpairs/pair-1').flush('error', { status: 500, statusText: 'Server Error' }); + expect(result).toBeNull(); + }); + + // --- remove() --- + + it('should send DELETE and filter pair out of list', () => { + connectedSubject.next(true); + httpMock.expectOne('/server/pathpairs').flush([makePair()]); + expect(snapshot().length).toBe(1); + + let success: boolean | undefined; + service.remove('pair-1').subscribe(r => success = r); + + const req = httpMock.expectOne('/server/pathpairs/pair-1'); + expect(req.request.method).toBe('DELETE'); + req.flush('', { status: 204, statusText: 'No Content' }); + + expect(success).toBe(true); + expect(snapshot()).toEqual([]); + }); + + it('should return false on remove() error', () => { + let success: boolean | undefined; + service.remove('pair-1').subscribe(r => success = r); + + httpMock.expectOne('/server/pathpairs/pair-1').flush('error', { status: 500, statusText: 'Error' }); + expect(success).toBe(false); + }); + + // --- Failed refresh --- + + it('should clear pairs when refresh fails (catchError emits empty array)', () => { + connectedSubject.next(true); + httpMock.expectOne('/server/pathpairs').flush([makePair()]); + expect(snapshot().length).toBe(1); + + // Trigger another refresh that fails. The service's catchError(of([])) + // turns the error into an empty list, which pairsSubject.next([]) emits + // to subscribers — the previously cached list is dropped. + service.refresh(); + httpMock.expectOne('/server/pathpairs').error(new ProgressEvent('error')); + + // Use the snapshot() helper (which pipes through take(1)) so this + // assertion doesn't leave a live subscriber on service.pairs$. + expect(snapshot()).toEqual([]); + }); +}); diff --git a/src/angular/src/app/services/utils/version-check.service.spec.ts b/src/angular/src/app/services/utils/version-check.service.spec.ts new file mode 100644 index 00000000..6af87b00 --- /dev/null +++ b/src/angular/src/app/services/utils/version-check.service.spec.ts @@ -0,0 +1,188 @@ +import '@angular/compiler'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { TestBed } from '@angular/core/testing'; +import { of } from 'rxjs'; + +import { VersionCheckService } from './version-check.service'; +import { RestService, WebReaction } from './rest.service'; +import { NotificationService } from './notification.service'; +import { LoggerService } from './logger.service'; +import { Notification, NotificationLevel } from '../../models/notification'; +import packageJson from '../../../../package.json'; + +function makeReaction(overrides: Partial = {}): WebReaction { + return { + success: true, + data: null, + errorMessage: null, + ...overrides, + }; +} + +function makeGithubResponse(tagName: string, htmlUrl = 'https://github.com/nitrobass24/seedsync/releases/latest'): string { + return JSON.stringify({ tag_name: tagName, html_url: htmlUrl }); +} + +describe('VersionCheckService', () => { + let mockRestService: { sendRequest: ReturnType }; + let notificationService: NotificationService; + let mockLogger: { debug: ReturnType; info: ReturnType; warn: ReturnType; error: ReturnType }; + + function createService(): VersionCheckService { + return TestBed.inject(VersionCheckService); + } + + beforeEach(() => { + mockRestService = { sendRequest: vi.fn() }; + mockLogger = { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() }; + + TestBed.configureTestingModule({ + providers: [ + VersionCheckService, + NotificationService, + { provide: RestService, useValue: mockRestService }, + { provide: LoggerService, useValue: mockLogger }, + ], + }); + + notificationService = TestBed.inject(NotificationService); + }); + + it('should show notification when a newer release is available', () => { + // 99.0.0 is chosen because it's always newer than the current package.json + // version, regardless of how that version evolves. + mockRestService.sendRequest.mockReturnValue( + of(makeReaction({ success: true, data: makeGithubResponse('v99.0.0') })), + ); + + let notifications: Notification[] = []; + notificationService.notifications$.subscribe(n => notifications = n); + + createService(); + + expect(notifications.length).toBe(1); + expect(notifications[0].level).toBe(NotificationLevel.INFO); + expect(notifications[0].text).toContain('new version'); + }); + + it('should not show notification when release is same version', () => { + // Use the live package.json version so this test stays correct + // across version bumps. + mockRestService.sendRequest.mockReturnValue( + of(makeReaction({ success: true, data: makeGithubResponse('v' + packageJson.version) })), + ); + + let notifications: Notification[] = []; + notificationService.notifications$.subscribe(n => notifications = n); + + createService(); + + expect(notifications.length).toBe(0); + }); + + it('should not show notification when release is older', () => { + mockRestService.sendRequest.mockReturnValue( + of(makeReaction({ success: true, data: makeGithubResponse('v0.1.0') })), + ); + + let notifications: Notification[] = []; + notificationService.notifications$.subscribe(n => notifications = n); + + createService(); + + expect(notifications.length).toBe(0); + }); + + it('should treat prefixed and non-prefixed releases identically', () => { + // Verify v-prefix stripping by running the same scenario twice — once + // with "v99.0.0", once with "99.0.0" — and asserting identical results. + // The TestBed has to be reset between runs because VersionCheckService + // is a singleton and the version check fires on construction. + function notificationsFor(tag: string): Notification[] { + TestBed.resetTestingModule(); + mockRestService = { sendRequest: vi.fn() }; + mockLogger = { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() }; + mockRestService.sendRequest.mockReturnValue( + of(makeReaction({ success: true, data: makeGithubResponse(tag) })), + ); + TestBed.configureTestingModule({ + providers: [ + VersionCheckService, + NotificationService, + { provide: RestService, useValue: mockRestService }, + { provide: LoggerService, useValue: mockLogger }, + ], + }); + const ns = TestBed.inject(NotificationService); + let captured: Notification[] = []; + ns.notifications$.subscribe(n => captured = n); + createService(); + return captured; + } + + const withPrefix = notificationsFor('v99.0.0'); + const withoutPrefix = notificationsFor('99.0.0'); + + expect(withPrefix).toHaveLength(1); + expect(withoutPrefix).toHaveLength(1); + expect(withoutPrefix[0].text).toBe(withPrefix[0].text); + expect(withoutPrefix[0].level).toBe(withPrefix[0].level); + }); + + it('should log warning and not show notification on network error', () => { + mockRestService.sendRequest.mockReturnValue( + of(makeReaction({ success: false, errorMessage: 'Network error' })), + ); + + let notifications: Notification[] = []; + notificationService.notifications$.subscribe(n => notifications = n); + + createService(); + + expect(notifications.length).toBe(0); + expect(mockLogger.warn).toHaveBeenCalled(); + }); + + it('should log error and not crash on malformed JSON response', () => { + mockRestService.sendRequest.mockReturnValue( + of(makeReaction({ success: true, data: 'not valid json {{{' })), + ); + + let notifications: Notification[] = []; + notificationService.notifications$.subscribe(n => notifications = n); + + createService(); + + expect(notifications.length).toBe(0); + expect(mockLogger.error).toHaveBeenCalled(); + }); + + it('should log error and not crash on unexpected JSON structure', () => { + mockRestService.sendRequest.mockReturnValue( + of(makeReaction({ success: true, data: JSON.stringify({ foo: 'bar' }) })), + ); + + let notifications: Notification[] = []; + notificationService.notifications$.subscribe(n => notifications = n); + + createService(); + + expect(notifications.length).toBe(0); + expect(mockLogger.error).toHaveBeenCalled(); + }); + + it('should include the release URL in the notification text', () => { + const url = 'https://github.com/nitrobass24/seedsync/releases/tag/v99.0.0'; + mockRestService.sendRequest.mockReturnValue( + of(makeReaction({ success: true, data: makeGithubResponse('v99.0.0', url) })), + ); + + let notifications: Notification[] = []; + notificationService.notifications$.subscribe(n => notifications = n); + + createService(); + + expect(notifications).toHaveLength(1); + expect(notifications[0].text).toContain(url); + }); +}); diff --git a/src/docker/build/docker-image/Dockerfile b/src/docker/build/docker-image/Dockerfile index ddc3e5f8..f4049747 100644 --- a/src/docker/build/docker-image/Dockerfile +++ b/src/docker/build/docker-image/Dockerfile @@ -12,56 +12,80 @@ COPY src/angular/ ./ RUN npx ng build --configuration=production --output-path /build # ============================================================================== -# Stage 2: Prepare Python runtime (install deps) +# Stage 2: Prepare Python runtime (install deps, strip stdlib) # ============================================================================== -FROM python:3.12-alpine AS python-deps - -COPY --from=ghcr.io/astral-sh/uv:0.11 /uv /usr/local/bin/uv +# Pin to the same Alpine minor as the runtime stage (FROM alpine:3.23 +# below) so Python + C extensions in site-packages are compiled against +# the same musl ABI they'll run on. The floating "python:3.13-alpine" +# tag could otherwise resolve to a newer Alpine and silently introduce +# a musl mismatch when /usr/local is copied into the runtime stage. +FROM python:3.13-alpine3.23 AS python-deps COPY src/python/pyproject.toml src/python/uv.lock /tmp/ -RUN uv pip install --system --no-cache --strict \ - -r /tmp/pyproject.toml \ - && rm -f /usr/local/bin/uv \ - && pip uninstall -y pip setuptools \ - && rm -rf /root/.cache /tmp/pyproject.toml /tmp/uv.lock \ +# `uv pip install -r pyproject.toml` re-resolves dependencies on every build +# and ignores uv.lock — transitive versions can drift between identical +# rebuilds. Export the lockfile to a frozen requirements list first so we +# install the exact versions in uv.lock. +WORKDIR /tmp +RUN --mount=from=ghcr.io/astral-sh/uv:0.11,source=/uv,target=/tmp/uv \ + /tmp/uv export --frozen --no-dev --no-emit-project -o /tmp/requirements.txt \ + && /tmp/uv pip install --system --no-cache --strict \ + -r /tmp/requirements.txt \ + && rm -rf /usr/local/lib/python3.13/site-packages/pip* \ + /usr/local/lib/python3.13/site-packages/setuptools* \ + /usr/local/bin/pip* \ + /usr/local/bin/wheel* \ + && rm -rf /root/.cache /tmp/pyproject.toml /tmp/uv.lock /tmp/requirements.txt \ && rm -rf \ - /usr/local/lib/python3.12/ensurepip \ - /usr/local/lib/python3.12/idlelib \ - /usr/local/lib/python3.12/turtle.py \ - /usr/local/lib/python3.12/turtledemo \ - /usr/local/lib/python3.12/tkinter \ - /usr/local/lib/python3.12/test \ - /usr/local/lib/python3.12/unittest \ - /usr/local/lib/python3.12/pydoc.py \ - /usr/local/lib/python3.12/pydoc_data \ - /usr/local/lib/python3.12/doctest.py \ - /usr/local/lib/python3.12/lib2to3 \ - /usr/local/lib/python3.12/distutils \ - /usr/local/lib/python3.12/lib-dynload/_test*.so \ - /usr/local/lib/python3.12/lib-dynload/_codecs_jp*.so \ - /usr/local/lib/python3.12/lib-dynload/_codecs_kr*.so \ - /usr/local/lib/python3.12/lib-dynload/_codecs_hk*.so \ - /usr/local/lib/python3.12/lib-dynload/_codecs_cn*.so \ - /usr/local/lib/python3.12/lib-dynload/_codecs_tw*.so \ - /usr/local/lib/python3.12/lib-dynload/_codecs_iso*.so \ - /usr/local/lib/python3.12/lib-dynload/_curses*.so \ - /usr/local/lib/python3.12/lib-dynload/_sqlite3*.so \ - /usr/local/lib/python3.12/lib-dynload/_lzma*.so \ - /usr/local/lib/python3.12/lib-dynload/_bz2*.so \ - /usr/local/lib/python3.12/lib-dynload/_crypt*.so \ - /usr/local/lib/python3.12/lib-dynload/_dbm*.so \ - /usr/local/lib/python3.12/lib-dynload/_gdbm*.so \ - /usr/local/lib/python3.12/lib-dynload/audioop*.so \ - /usr/local/lib/python3.12/lib-dynload/ossaudiodev*.so \ - /usr/local/lib/python3.12/lib-dynload/nis*.so \ - /usr/local/lib/python3.12/lib-dynload/readline*.so \ - /usr/local/lib/python3.12/lib-dynload/_ctypes_test*.so \ - && find /usr/local/lib/python3.12 -type d -name __pycache__ -exec rm -rf {} + 2>/dev/null || true + /usr/local/lib/python3.13/ensurepip \ + /usr/local/lib/python3.13/idlelib \ + /usr/local/lib/python3.13/turtle.py \ + /usr/local/lib/python3.13/turtledemo \ + /usr/local/lib/python3.13/tkinter \ + /usr/local/lib/python3.13/test \ + /usr/local/lib/python3.13/unittest \ + /usr/local/lib/python3.13/pydoc.py \ + /usr/local/lib/python3.13/pydoc_data \ + /usr/local/lib/python3.13/doctest.py \ + /usr/local/lib/python3.13/lib-dynload/_test*.so \ + /usr/local/lib/python3.13/lib-dynload/_codecs_jp*.so \ + /usr/local/lib/python3.13/lib-dynload/_codecs_kr*.so \ + /usr/local/lib/python3.13/lib-dynload/_codecs_hk*.so \ + /usr/local/lib/python3.13/lib-dynload/_codecs_cn*.so \ + /usr/local/lib/python3.13/lib-dynload/_codecs_tw*.so \ + /usr/local/lib/python3.13/lib-dynload/_codecs_iso*.so \ + /usr/local/lib/python3.13/lib-dynload/_curses*.so \ + /usr/local/lib/python3.13/lib-dynload/_sqlite3*.so \ + /usr/local/lib/python3.13/lib-dynload/_lzma*.so \ + /usr/local/lib/python3.13/lib-dynload/_bz2*.so \ + /usr/local/lib/python3.13/lib-dynload/_dbm*.so \ + /usr/local/lib/python3.13/lib-dynload/_gdbm*.so \ + /usr/local/lib/python3.13/lib-dynload/readline*.so \ + /usr/local/lib/python3.13/lib-dynload/_ctypes_test*.so \ + /usr/local/lib/python3.13/lib-dynload/xx*.so \ + /usr/local/lib/python3.13/lib-dynload/_tkinter*.so \ + /usr/local/lib/python3.13/lib-dynload/_xxtestfuzz*.so \ + /usr/local/lib/python3.13/lib-dynload/_lsprof*.so \ + /usr/local/lib/python3.13/lib-dynload/_interp*.so \ + /usr/local/lib/python3.13/_pyrepl \ + /usr/local/lib/python3.13/_pydecimal.py \ + /usr/local/lib/python3.13/venv \ + /usr/local/lib/python3.13/config-3.13-* \ + /usr/local/include \ + && find /usr/local/lib/python3.13/encodings -name '*.py' \ + ! -name '__init__.py' ! -name 'aliases.py' \ + ! -name 'ascii.py' ! -name 'latin_1.py' \ + ! -name 'utf_8.py' ! -name 'utf_8_sig.py' \ + ! -name 'utf_16*.py' ! -name 'utf_32*.py' \ + ! -name 'unicode_escape.py' ! -name 'raw_unicode_escape.py' \ + ! -name 'charmap.py' ! -name 'idna.py' \ + -delete \ + && find /usr/local/lib/python3.13 -type d -name __pycache__ -exec rm -rf {} + 2>/dev/null || true # ============================================================================== # Stage 3: Final Runtime Image # ============================================================================== -FROM alpine:3.21 AS runtime +FROM alpine:3.23 AS runtime LABEL maintainer="nitrobass24" \ description="SeedSync - Seedbox file synchronization tool" @@ -82,7 +106,14 @@ RUN apk add --no-cache \ openssh-client \ ca-certificates \ setpriv \ - libstdc++ + libstdc++ \ + && rm -rf /sbin/apk /etc/apk /usr/share/apk /lib/apk /var/cache/apk \ + /lib/libapk.so* /usr/lib/libapk.so* /usr/bin/scanelf \ + /usr/bin/ssh-keyscan /usr/bin/ssh-add /usr/bin/ssh-agent \ + /usr/bin/ssh-pkcs11-helper /usr/bin/ssh-copy-id \ + /usr/bin/findssl.sh /usr/bin/c_rehash \ + /usr/bin/iconv /usr/bin/getconf /usr/bin/ssl_client \ + /etc/ssh/moduli ENV LC_ALL=C.UTF-8 \ LANG=C.UTF-8 \ @@ -92,8 +123,9 @@ ENV LC_ALL=C.UTF-8 \ # Create directory structure RUN mkdir -p /app/python /app/html /config /downloads /staging -# Copy application source +# Copy application source (remove dev config files not needed at runtime) COPY src/python/ /app/python/ +RUN rm -f /app/python/pyproject.toml /app/python/uv.lock # Copy built artifacts from previous stages COPY --from=angular-builder /build/browser /app/html diff --git a/src/docker/build/docker-image/Dockerfile.dockerignore b/src/docker/build/docker-image/Dockerfile.dockerignore index bff773b7..67f23b6e 100644 --- a/src/docker/build/docker-image/Dockerfile.dockerignore +++ b/src/docker/build/docker-image/Dockerfile.dockerignore @@ -2,9 +2,13 @@ **/__pycache__ **/node_modules **/.venv +**/.ruff_cache +**/.pytest_cache +**/pyrightconfig.json .git .idea build src/angular/dist src/python/tests src/python/build +src/python/typings diff --git a/src/docker/build/test-image/Dockerfile b/src/docker/build/test-image/Dockerfile index 65fa7d4e..385475da 100644 --- a/src/docker/build/test-image/Dockerfile +++ b/src/docker/build/test-image/Dockerfile @@ -1,16 +1,16 @@ -FROM python:3.12-slim-bookworm -RUN apt-get update && apt-get install -y --no-install-recommends lftp openssh-client \ - && rm -rf /var/lib/apt/lists/* +FROM python:3.13-alpine -COPY --from=ghcr.io/astral-sh/uv:0.11 /uv /usr/local/bin/uv +RUN apk add --no-cache lftp openssh-client COPY src/python/pyproject.toml src/python/uv.lock /tmp/ -RUN uv pip install --system --no-cache --strict \ +RUN --mount=from=ghcr.io/astral-sh/uv:0.11,source=/uv,target=/tmp/uv \ + /tmp/uv pip install --system --no-cache --strict \ -r /tmp/pyproject.toml --group test \ - && rm -f /usr/local/bin/uv \ && rm /tmp/pyproject.toml /tmp/uv.lock -RUN groupadd -r testuser && useradd -r -g testuser -d /app/python -s /sbin/nologin testuser \ + +RUN addgroup -S testuser && adduser -S -G testuser -h /app/python -s /sbin/nologin testuser \ && mkdir -p /app/python && chown testuser:testuser /app/python + WORKDIR /app/python ENV PYTHONPATH=/app/python USER testuser diff --git a/src/docker/test/angular/Dockerfile b/src/docker/test/angular/Dockerfile deleted file mode 100644 index 0f1442e5..00000000 --- a/src/docker/test/angular/Dockerfile +++ /dev/null @@ -1,27 +0,0 @@ -FROM node:20-bookworm-slim AS seedsync_test_angular - -RUN apt-get update && apt-get install -y --no-install-recommends \ - wget \ - gnupg \ - && wget -nv -O /tmp/chrome.deb https://dl.google.com/linux/direct/google-chrome-stable_current_amd64.deb \ - && dpkg -i /tmp/chrome.deb || apt-get -fy install > /dev/null \ - && rm -rf /var/lib/apt/lists/* - -WORKDIR /app - -# Copy Angular 17 configuration files -COPY src/angular/package*.json ./ -RUN npm install --include=dev --silent - -COPY \ - src/angular/tsconfig.json \ - src/angular/tsconfig.app.json \ - src/angular/tsconfig.spec.json \ - src/angular/angular.json \ - /app/ - -COPY src/angular/src /app/src - -CMD ["./node_modules/.bin/ng", "test", \ - "--browsers", "ChromeHeadless", \ - "--watch=false"] diff --git a/src/docker/test/angular/compose.yml b/src/docker/test/angular/compose.yml deleted file mode 100644 index 17f79f6f..00000000 --- a/src/docker/test/angular/compose.yml +++ /dev/null @@ -1,15 +0,0 @@ -version: "3.4" -services: - tests: - image: seedsync/test/angular - container_name: seedsync_test_angular - tty: true - build: - context: ../../../../ - dockerfile: src/docker/test/angular/Dockerfile - target: seedsync_test_angular - volumes: - - type: bind - source: ../../../angular/src - target: /app/src - read_only: true diff --git a/src/docker/test/python/Dockerfile b/src/docker/test/python/Dockerfile deleted file mode 100644 index c207663b..00000000 --- a/src/docker/test/python/Dockerfile +++ /dev/null @@ -1,40 +0,0 @@ -FROM seedsync/run/python/devenv as seedsync_test_python - -RUN ls -l /app/python - -# Install dependencies -RUN apt-get install -y software-properties-common && \ - apt-add-repository non-free && \ - apt-get update && \ - apt-get install -y \ - openssh-server \ - rar - -ADD src/docker/test/python/entrypoint.sh /app/ - -# setup sshd -RUN mkdir /var/run/sshd -RUN ssh-keygen -t rsa -N "" -f /root/.ssh/id_rsa && \ - cat /root/.ssh/id_rsa.pub >> /root/.ssh/authorized_keys -# Disable the known hosts prompt -RUN echo "StrictHostKeyChecking no\nUserKnownHostsFile /dev/null\nLogLevel=quiet" > /root/.ssh/config - -# create the seedsynctest user, add root's public key to seedsynctest -RUN useradd --create-home -s /bin/bash seedsynctest && \ - echo "seedsynctest:seedsyncpass" | chpasswd -RUN usermod -a -G root seedsynctest -RUN cp /root/.ssh/id_rsa.pub /home/seedsynctest/ && \ - chown seedsynctest:seedsynctest /home/seedsynctest/id_rsa.pub -USER seedsynctest -RUN mkdir -p /home/seedsynctest/.ssh && \ - cat /home/seedsynctest/id_rsa.pub >> /home/seedsynctest/.ssh/authorized_keys -USER root - -EXPOSE 22 - -# src needs to be mounted on /src/ -WORKDIR /src/ -ENV PYTHONPATH=/src - -ENTRYPOINT ["/app/entrypoint.sh"] -CMD ["pytest", "-v"] diff --git a/src/docker/test/python/compose.yml b/src/docker/test/python/compose.yml deleted file mode 100644 index f2eb014a..00000000 --- a/src/docker/test/python/compose.yml +++ /dev/null @@ -1,14 +0,0 @@ -version: "3.4" -services: - tests: - image: seedsync/test/python - container_name: seedsync_test_python - build: - context: ../../../../ - dockerfile: src/docker/test/python/Dockerfile - target: seedsync_test_python - volumes: - - type: bind - source: ../../../python - target: /src - read_only: true diff --git a/src/docker/test/python/entrypoint.sh b/src/docker/test/python/entrypoint.sh deleted file mode 100755 index c49b5bff..00000000 --- a/src/docker/test/python/entrypoint.sh +++ /dev/null @@ -1,11 +0,0 @@ -#!/bin/bash - -# exit on first error -set -e - -echo "Running sshd" -/usr/sbin/sshd -D & - -echo "Continuing entrypoint" -echo "$@" -exec $@ diff --git a/src/e2e-playwright/package-lock.json b/src/e2e-playwright/package-lock.json new file mode 100644 index 00000000..f190de2e --- /dev/null +++ b/src/e2e-playwright/package-lock.json @@ -0,0 +1,76 @@ +{ + "name": "seedsync-e2e", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "seedsync-e2e", + "devDependencies": { + "@playwright/test": "^1.52.0" + } + }, + "node_modules/@playwright/test": { + "version": "1.59.1", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.59.1.tgz", + "integrity": "sha512-PG6q63nQg5c9rIi4/Z5lR5IVF7yU5MqmKaPOe0HSc0O2cX1fPi96sUQu5j7eo4gKCkB2AnNGoWt7y4/Xx3Kcqg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.59.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/playwright": { + "version": "1.59.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.59.1.tgz", + "integrity": "sha512-C8oWjPR3F81yljW9o5OxcWzfh6avkVwDD2VYdwIGqTkl+OGFISgypqzfu7dOe4QNLL2aqcWBmI3PMtLIK233lw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.59.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.59.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.59.1.tgz", + "integrity": "sha512-HBV/RJg81z5BiiZ9yPzIiClYV/QMsDCKUyogwH9p3MCP6IYjUFu/MActgYAvK0oWyV9NlwM3GLBjADyWgydVyg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + } + } +} diff --git a/src/e2e-playwright/playwright.config.ts b/src/e2e-playwright/playwright.config.ts index 91a67c63..b22cf169 100644 --- a/src/e2e-playwright/playwright.config.ts +++ b/src/e2e-playwright/playwright.config.ts @@ -5,6 +5,11 @@ export default defineConfig({ timeout: 30_000, expect: { timeout: 5_000 }, fullyParallel: false, // tests share a Docker container per file + // ubuntu-latest has 4 vCPUs. Each worker runs its own chromium and + // shares the host's CPU pool with the SeedSync container and runner + // overhead — pushing past 4 oversubscribes and makes the suite + // slower overall. + workers: 4, retries: 0, reporter: [["html", { open: "never" }], ["list"]], use: { diff --git a/src/e2e-playwright/tests/error-states.spec.ts b/src/e2e-playwright/tests/error-states.spec.ts new file mode 100644 index 00000000..2d111c03 --- /dev/null +++ b/src/e2e-playwright/tests/error-states.spec.ts @@ -0,0 +1,268 @@ +import { test, expect } from "./fixtures"; + +test.describe("Error States — Header Notifications", () => { + // The noEnabledPairs notification only appears when the server is fully + // configured and running (status.server.up === true). CI containers start + // with incomplete config so the server reports "not up" and the controller + // never evaluates pair state. Skip these tests when the server is down. + + /** + * Helper: disable all path pairs and ensure at least one disabled pair exists. + * + * The backend only sets `no_enabled_pairs = true` when there are path pairs + * configured but none are enabled. If no pairs exist at all, it falls through + * to the legacy single-path mode. So every test that expects the + * "no enabled pairs" notification must guarantee a disabled pair exists. + * + * Returns cleanup info so the caller can restore state in a `finally` block. + */ + async function disableAllPairs(apiFetch: (path: string, init?: RequestInit) => Promise) { + const res = await apiFetch("/server/pathpairs"); + expect(res.ok, `GET /server/pathpairs failed: ${res.status}`).toBe(true); + const pairs = await res.json(); + const originalStates: { id: string; enabled: boolean }[] = []; + + for (const pair of pairs) { + originalStates.push({ id: pair.id, enabled: pair.enabled }); + if (pair.enabled) { + const updateRes = await apiFetch(`/server/pathpairs/${pair.id}`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ enabled: false }), + }); + expect(updateRes.ok, `PUT pathpairs/${pair.id} failed: ${updateRes.status}`).toBe(true); + } + } + + // If no pairs exist at all, create a disabled one so the backend reports + // noEnabledPairs=true rather than falling through to legacy single-path mode. + let tempPairId: string | null = null; + if (pairs.length === 0) { + const createRes = await apiFetch("/server/pathpairs", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + name: "e2e-disabled-pair", + remote_path: "/remote/e2e", + local_path: "/local/e2e", + enabled: false, + }), + }); + expect(createRes.ok, `POST /server/pathpairs failed: ${createRes.status}`).toBe(true); + const created = await createRes.json(); + tempPairId = created.id; + } + + return { originalStates, tempPairId }; + } + + /** + * Helper: restore original pair states and clean up any temp pair. + */ + async function restorePairs( + apiFetch: (path: string, init?: RequestInit) => Promise, + originalStates: { id: string; enabled: boolean }[], + tempPairId: string | null, + ) { + if (tempPairId) { + await apiFetch(`/server/pathpairs/${tempPairId}`, { method: "DELETE" }); + } + for (const state of originalStates) { + if (state.enabled) { + await apiFetch(`/server/pathpairs/${state.id}`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ enabled: true }), + }); + } + } + } + + test("no enabled pairs shows warning notification", async ({ + page, + apiGet, + apiFetch, + waitForStream, + }) => { + const status = await apiGet("/server/status"); + test.skip(!status.server.up, "Server is not fully configured — noEnabledPairs notification requires server.up"); + + const { originalStates, tempPairId } = await disableAllPairs(apiFetch); + + try { + await page.goto("/dashboard"); + await waitForStream(page); + + const notification = page.locator("#header .alert", { + hasText: /path pairs are disabled/i, + }); + await expect(notification).toBeVisible({ timeout: 15_000 }); + await expect(notification).toHaveClass(/alert-warning/); + } finally { + await restorePairs(apiFetch, originalStates, tempPairId); + } + }); + + test("no enabled pairs notification text matches expected string", async ({ + page, + apiGet, + apiFetch, + waitForStream, + }) => { + const status = await apiGet("/server/status"); + test.skip(!status.server.up, "Server is not fully configured — noEnabledPairs notification requires server.up"); + + const { originalStates, tempPairId } = await disableAllPairs(apiFetch); + + try { + await page.goto("/dashboard"); + await waitForStream(page); + + const notification = page.locator("#header .alert", { + hasText: /path pairs are disabled/i, + }); + await expect(notification).toBeVisible({ timeout: 15_000 }); + await expect(notification).toContainText( + "Enable a pair in Settings to start syncing" + ); + } finally { + await restorePairs(apiFetch, originalStates, tempPairId); + } + }); + + test("restart notification appears after config change on settings page", async ({ + page, + waitForStream, + apiGet, + apiSetConfig, + }) => { + await page.goto("/settings"); + await waitForStream(page); + + const configBefore = await apiGet("/server/config/get"); + const originalAddress = configBefore.lftp.remote_address; + + try { + const field = page + .locator("app-option", { hasText: "Server Address" }) + .locator("input[type='text'], input[type='password']"); + await field.clear(); + await field.fill("trigger-restart-" + Date.now()); + + // The restart notification should appear in the header + const notification = page.locator("#header .alert", { + hasText: /restart/i, + }); + await expect(notification).toBeVisible({ timeout: 5000 }); + } finally { + await apiSetConfig("lftp", "remote_address", originalAddress ?? ""); + } + }); + + test("notification has correct alert level styling", async ({ + page, + apiGet, + apiFetch, + waitForStream, + }) => { + const status = await apiGet("/server/status"); + test.skip(!status.server.up, "Server is not fully configured — noEnabledPairs notification requires server.up"); + + const { originalStates, tempPairId } = await disableAllPairs(apiFetch); + + try { + await page.goto("/dashboard"); + await waitForStream(page); + + // The no-enabled-pairs notification should be a warning (not danger or info) + const notification = page.locator("#header .alert", { + hasText: /path pairs are disabled/i, + }); + await expect(notification).toBeVisible({ timeout: 10_000 }); + await expect(notification).toHaveClass(/alert-warning/); + await expect(notification).not.toHaveClass(/alert-danger/); + await expect(notification).not.toHaveClass(/alert-info/); + } finally { + await restorePairs(apiFetch, originalStates, tempPairId); + } + }); + + test("re-enabling a pair clears the no-enabled-pairs warning", async ({ + page, + apiGet, + apiFetch, + waitForStream, + }) => { + const status = await apiGet("/server/status"); + test.skip(!status.server.up, "Server is not fully configured — noEnabledPairs notification requires server.up"); + // Create a disabled pair for this test + const pairName = `e2e-reenable-${Date.now()}`; + const createRes = await apiFetch("/server/pathpairs", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + name: pairName, + remote_path: "/remote/reenable", + local_path: "/local/reenable", + enabled: false, + }), + }); + expect(createRes.ok, `POST /server/pathpairs failed: ${createRes.status}`).toBe(true); + const createdPair = await createRes.json(); + + // Also disable any other enabled pairs + const listRes = await apiFetch("/server/pathpairs"); + expect(listRes.ok, `GET /server/pathpairs failed: ${listRes.status}`).toBe(true); + const allPairs = await listRes.json(); + const originalStates: { id: string; enabled: boolean }[] = []; + for (const pair of allPairs) { + if (pair.id !== createdPair.id) { + originalStates.push({ id: pair.id, enabled: pair.enabled }); + if (pair.enabled) { + const updateRes = await apiFetch(`/server/pathpairs/${pair.id}`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ enabled: false }), + }); + expect(updateRes.ok, `PUT pathpairs/${pair.id} failed: ${updateRes.status}`).toBe(true); + } + } + } + + try { + await page.goto("/dashboard"); + await waitForStream(page); + + // Warning should be visible + const notification = page.locator("#header .alert", { + hasText: /path pairs are disabled/i, + }); + await expect(notification).toBeVisible({ timeout: 10_000 }); + + // Re-enable the pair via API + const enableRes = await apiFetch(`/server/pathpairs/${createdPair.id}`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ enabled: true }), + }); + expect(enableRes.ok, `PUT pathpairs/${createdPair.id} failed: ${enableRes.status}`).toBe(true); + + // The warning should disappear (SSE pushes status updates) + await expect(notification).not.toBeVisible({ timeout: 10_000 }); + } finally { + // Clean up: delete the test pair and restore others + await apiFetch(`/server/pathpairs/${createdPair.id}`, { + method: "DELETE", + }); + for (const state of originalStates) { + if (state.enabled) { + await apiFetch(`/server/pathpairs/${state.id}`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ enabled: true }), + }); + } + } + } + }); +}); diff --git a/src/e2e-playwright/tests/file-actions.spec.ts b/src/e2e-playwright/tests/file-actions.spec.ts new file mode 100644 index 00000000..71fb9b9a --- /dev/null +++ b/src/e2e-playwright/tests/file-actions.spec.ts @@ -0,0 +1,164 @@ +import { test, expect } from "./fixtures"; +import { DashboardPage } from "./pages/dashboard.page"; + +test.describe("File Actions", () => { + let dashboard: DashboardPage; + let fileCount: number; + + test.beforeEach(async ({ page, waitForStream }) => { + dashboard = new DashboardPage(page); + await dashboard.goto(); + await waitForStream(page); + fileCount = await dashboard.getFileRows().count(); + }); + + test("file row click reveals action buttons with correct labels", async () => { + test.skip(fileCount === 0, "No files present on the remote seedbox"); + + const row = dashboard.getFileRows().first(); + await row.click(); + + // All 6 action buttons should be present (some may be disabled) + await expect(dashboard.getActionButton(row, "Queue")).toBeVisible(); + await expect(dashboard.getActionButton(row, "Stop")).toBeVisible(); + await expect(dashboard.getActionButton(row, "Extract")).toBeVisible(); + await expect(dashboard.getActionButton(row, "Validate")).toBeVisible(); + await expect(dashboard.getActionButton(row, "Delete Local")).toBeVisible(); + await expect(dashboard.getActionButton(row, "Delete Remote")).toBeVisible(); + }); + + test("action buttons have correct disabled state based on file status", async () => { + test.skip(fileCount === 0, "No files present on the remote seedbox"); + + const row = dashboard.getFileRows().first(); + await row.click(); + + // At least one action button should exist and have a disabled attribute (either true or false) + const buttons = row.locator(".actions button"); + const count = await buttons.count(); + expect(count).toBe(6); + + // Each button should have a deterministic disabled state (not missing the attribute) + for (let i = 0; i < count; i++) { + const btn = buttons.nth(i); + const isDisabled = await btn.isDisabled(); + expect(typeof isDisabled).toBe("boolean"); + } + }); + + test("Delete Local button requires confirmation (double-click pattern)", async () => { + test.skip(fileCount === 0, "No files present on the remote seedbox"); + + const row = dashboard.getFileRows().first(); + await row.click(); + + const deleteLocalBtn = dashboard.getActionButton(row, "Delete Local"); + const isDisabled = await deleteLocalBtn.isDisabled(); + test.skip(isDisabled, "Delete Local is disabled for this file (no local copy)"); + + // First click should change text to "Confirm?" + await deleteLocalBtn.click(); + await expect(deleteLocalBtn).toContainText("Confirm?"); + }); + + test("Delete Remote button requires confirmation (double-click pattern)", async () => { + test.skip(fileCount === 0, "No files present on the remote seedbox"); + + const row = dashboard.getFileRows().first(); + await row.click(); + + const deleteRemoteBtn = dashboard.getActionButton(row, "Delete Remote"); + const isDisabled = await deleteRemoteBtn.isDisabled(); + test.skip(isDisabled, "Delete Remote is disabled for this file"); + + // First click should change text to "Confirm?" + await deleteRemoteBtn.click(); + await expect(deleteRemoteBtn).toContainText("Confirm?"); + }); + + test("clicking a different file row deselects the previous one", async () => { + test.skip(fileCount < 2, "Need at least 2 files to test selection switching"); + + const rows = dashboard.getFileRows(); + const firstRow = rows.nth(0); + const secondRow = rows.nth(1); + + // Click first row to select it + await firstRow.click(); + await expect(firstRow).toHaveClass(/selected/); + + // Click second row — first should deselect + await secondRow.click(); + await expect(secondRow).toHaveClass(/selected/); + await expect(firstRow).not.toHaveClass(/selected/); + }); + + test("re-clicking selected file row deselects it", async () => { + test.skip(fileCount === 0, "No files present on the remote seedbox"); + + const row = dashboard.getFileRows().first(); + + // Click to select + await row.click(); + await expect(row.locator(".actions")).toBeVisible(); + + // Click again to deselect (actions should hide) + await row.click(); + // After deselecting, the actions div should not be visible + // (actions are only shown when selected via CSS) + await expect(row).not.toHaveClass(/selected/); + await expect(row.locator(".actions")).not.toBeVisible(); + }); +}); + +test.describe("File Actions — Bulk operations", () => { + let dashboard: DashboardPage; + let fileCount: number; + + test.beforeEach(async ({ page, waitForStream }) => { + dashboard = new DashboardPage(page); + await dashboard.goto(); + await waitForStream(page); + fileCount = await dashboard.getFileRows().count(); + }); + + test("bulk Queue button is present when files are checked", async () => { + test.skip(fileCount < 1, "Need at least 1 file for bulk action"); + + const rows = dashboard.getFileRows(); + await dashboard.getCheckbox(rows.first()).check(); + await expect(dashboard.bulkActionBar).toBeVisible(); + await expect(dashboard.getBulkButton("Queue")).toBeVisible(); + }); + + test("bulk Stop button is present when files are checked", async () => { + test.skip(fileCount < 1, "Need at least 1 file for bulk action"); + + const rows = dashboard.getFileRows(); + await dashboard.getCheckbox(rows.first()).check(); + await expect(dashboard.bulkActionBar).toBeVisible(); + await expect(dashboard.getBulkButton("Stop")).toBeVisible(); + }); + + test("bulk Delete Local and Delete Remote buttons are present", async () => { + test.skip(fileCount < 1, "Need at least 1 file for bulk action"); + + const rows = dashboard.getFileRows(); + await dashboard.getCheckbox(rows.first()).check(); + await expect(dashboard.getBulkButton("Delete Local")).toBeVisible(); + await expect(dashboard.getBulkButton("Delete Remote")).toBeVisible(); + }); + + test("unchecking all files hides the bulk action bar", async () => { + test.skip(fileCount < 1, "Need at least 1 file for this test"); + + const rows = dashboard.getFileRows(); + const checkbox = dashboard.getCheckbox(rows.first()); + + await checkbox.check(); + await expect(dashboard.bulkActionBar).toBeVisible(); + + await checkbox.uncheck(); + await expect(dashboard.bulkActionBar).not.toBeVisible(); + }); +}); diff --git a/src/e2e-playwright/tests/integrations.spec.ts b/src/e2e-playwright/tests/integrations.spec.ts new file mode 100644 index 00000000..fe2109f7 --- /dev/null +++ b/src/e2e-playwright/tests/integrations.spec.ts @@ -0,0 +1,383 @@ +import { test, expect } from "./fixtures"; +import { IntegrationsPage } from "./pages/integrations.page"; + +// Wrap a fixture-setup API call so failures surface as "fixture creation +// failed" instead of cascading into confusing UI assertion failures. +async function expectFixtureOk(res: Response, label: string): Promise { + if (!res.ok) { + throw new Error(`fixture creation failed: ${label} → ${res.status} ${res.statusText}`); + } +} + +test.describe("Integrations CRUD", () => { + let integrations: IntegrationsPage; + + // Clean up all integrations before each test for a clean slate. Fail fast + // on any API error so the test stops with a clear message instead of + // proceeding against stale or unknown state. + test.beforeEach(async ({ page, apiFetch }) => { + const res = await apiFetch("/server/integrations"); + if (!res.ok) { + throw new Error(`GET /server/integrations failed: ${res.status} ${res.statusText}`); + } + const instances = await res.json(); + for (const inst of instances) { + const del = await apiFetch(`/server/integrations/${inst.id}`, { + method: "DELETE", + }); + if (!del.ok) { + throw new Error( + `DELETE /server/integrations/${inst.id} failed: ${del.status} ${del.statusText}`, + ); + } + } + + integrations = new IntegrationsPage(page); + await integrations.goto(); + }); + + // Clean up after each test. Same fail-fast contract as beforeEach so + // teardown failures surface as test failures instead of being swallowed. + test.afterEach(async ({ apiFetch }) => { + const res = await apiFetch("/server/integrations"); + if (!res.ok) { + throw new Error(`GET /server/integrations failed during cleanup: ${res.status} ${res.statusText}`); + } + const instances = await res.json(); + for (const inst of instances) { + const del = await apiFetch(`/server/integrations/${inst.id}`, { + method: "DELETE", + }); + if (!del.ok) { + throw new Error( + `DELETE /server/integrations/${inst.id} failed during cleanup: ${del.status} ${del.statusText}`, + ); + } + } + }); + + test("empty state shows 'No integrations' message and add buttons", async () => { + await expect(integrations.emptyState).toBeVisible(); + await expect(integrations.emptyState).toContainText("No integrations"); + await expect(integrations.addSonarrButton).toBeVisible(); + await expect(integrations.addRadarrButton).toBeVisible(); + }); + + test("clicking + Sonarr opens add form with Sonarr pre-selected", async () => { + await integrations.addSonarrButton.click(); + await expect(integrations.instanceForm).toBeVisible(); + + const kindSelect = integrations.instanceForm.locator("select"); + await expect(kindSelect).toHaveValue("sonarr"); + }); + + test("clicking + Radarr opens add form with Radarr pre-selected", async () => { + await integrations.addRadarrButton.click(); + await expect(integrations.instanceForm).toBeVisible(); + + const kindSelect = integrations.instanceForm.locator("select"); + await expect(kindSelect).toHaveValue("radarr"); + }); + + test("save with empty name shows validation error", async () => { + await integrations.addSonarrButton.click(); + await integrations.fillForm({ + url: "http://localhost:8989", + apiKey: "test-key", + }); + await integrations.clickSave(); + + await expect(integrations.errorMessage).toBeVisible(); + await expect(integrations.errorMessage).toContainText("Name is required"); + }); + + test("fill and save creates integration visible in list and API", async ({ + apiFetch, + }) => { + await integrations.addSonarrButton.click(); + await integrations.fillForm({ + name: "E2E Sonarr", + url: "http://localhost:8989", + apiKey: "test-api-key-123", + }); + await integrations.clickSave(); + + // Form should close + await expect(integrations.instanceForm).not.toBeVisible({ + timeout: 10_000, + }); + + // Instance should appear in the list + const row = integrations.getInstanceByName("E2E Sonarr"); + await expect(row).toBeVisible({ timeout: 10_000 }); + + // Verify via API + await expect + .poll( + async () => { + const res = await apiFetch("/server/integrations"); + if (!res.ok) { + throw new Error(`GET /server/integrations failed: ${res.status} ${res.statusText}`); + } + const instances = await res.json(); + return instances.find( + (i: { name: string }) => i.name === "E2E Sonarr" + ); + }, + { timeout: 5000 } + ) + .toEqual( + expect.objectContaining({ + name: "E2E Sonarr", + kind: "sonarr", + url: "http://localhost:8989", + }) + ); + }); + + test("edit populates form and save updates the instance", async ({ + apiFetch, + }) => { + // Create an integration via API + const created = await apiFetch("/server/integrations", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + name: "Edit Me", + kind: "sonarr", + url: "http://localhost:8989", + api_key: "old-key", + enabled: true, + }), + }); + await expectFixtureOk(created, 'POST /server/integrations (Edit Me)'); + + await integrations.goto(); + + const row = integrations.getInstanceByName("Edit Me"); + await row.waitFor({ timeout: 10_000 }); + await integrations.getEditButton(row).click(); + + // Form should be visible with existing values + await expect(integrations.instanceForm).toBeVisible(); + const nameInput = integrations.instanceForm.locator( + 'label:has-text("Name") input' + ); + await expect(nameInput).toHaveValue("Edit Me"); + + // Update the URL + await integrations.fillForm({ + url: "http://localhost:7878", + }); + await integrations.clickSave(); + + // Verify via API + await expect + .poll( + async () => { + const res = await apiFetch("/server/integrations"); + if (!res.ok) { + throw new Error(`GET /server/integrations failed: ${res.status} ${res.statusText}`); + } + const instances = await res.json(); + return instances.find( + (i: { name: string }) => i.name === "Edit Me" + ); + }, + { timeout: 5000 } + ) + .toEqual( + expect.objectContaining({ + url: "http://localhost:7878", + }) + ); + }); + + test("single-click delete shows confirmation state", async ({ + apiFetch, + }) => { + // Create an integration via API + const created = await apiFetch("/server/integrations", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + name: "Confirm Delete", + kind: "radarr", + url: "http://localhost:7878", + api_key: "key", + enabled: true, + }), + }); + await expectFixtureOk(created, 'POST /server/integrations (Confirm Delete)'); + + await integrations.goto(); + + const row = integrations.getInstanceByName("Confirm Delete"); + await row.waitFor({ timeout: 10_000 }); + const deleteBtn = integrations.getDeleteButton(row); + + // First click — should enter confirmation state + await deleteBtn.click(); + await expect(deleteBtn).toContainText("Confirm?"); + await expect(deleteBtn).toHaveClass(/confirming/); + }); + + test("double-click delete removes the integration", async ({ + apiFetch, + }) => { + // Create an integration via API + const created = await apiFetch("/server/integrations", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + name: "Delete Me", + kind: "sonarr", + url: "http://localhost:8989", + api_key: "key", + enabled: true, + }), + }); + await expectFixtureOk(created, 'POST /server/integrations (Delete Me)'); + + await integrations.goto(); + + const row = integrations.getInstanceByName("Delete Me"); + await row.waitFor({ timeout: 10_000 }); + const deleteBtn = integrations.getDeleteButton(row); + + // First click — confirmation + await deleteBtn.click(); + await expect(deleteBtn).toContainText("Confirm?"); + + // Second click — actual delete + await deleteBtn.click(); + + // Row should disappear + await expect(row).not.toBeVisible({ timeout: 5000 }); + + // Verify via API. Throw on a non-OK response so the poll keeps + // retrying — returning undefined here would falsely satisfy + // toBeUndefined() and mask a real API failure. + await expect + .poll( + async () => { + const res = await apiFetch("/server/integrations"); + if (!res.ok) { + throw new Error(`GET /server/integrations failed: ${res.status} ${res.statusText}`); + } + const instances = await res.json(); + return instances.find( + (i: { name: string }) => i.name === "Delete Me" + ); + }, + { timeout: 5000 } + ) + .toBeUndefined(); + }); + + test("Test Connection button shows result", async ({ apiFetch }) => { + // Create an integration with a URL that will likely fail connection + const created = await apiFetch("/server/integrations", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + name: "Test Conn", + kind: "sonarr", + url: "http://localhost:19999", + api_key: "bad-key", + enabled: true, + }), + }); + await expectFixtureOk(created, 'POST /server/integrations (Test Conn)'); + + await integrations.goto(); + + const row = integrations.getInstanceByName("Test Conn"); + await row.waitFor({ timeout: 10_000 }); + + const testBtn = integrations.getTestButton(row); + await testBtn.click(); + + // Button should show "Testing..." while in progress + // Then a result should appear (success or failure) + const result = integrations.getTestResult(row); + await expect(result).toBeVisible({ timeout: 15_000 }); + // The result should have either success or failure styling + const resultText = await result.textContent(); + expect(resultText?.trim().length).toBeGreaterThan(0); + }); + + test("enable/disable toggle updates via API", async ({ apiFetch }) => { + // Create an enabled integration + const created = await apiFetch("/server/integrations", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + name: "Toggle Me", + kind: "sonarr", + url: "http://localhost:8989", + api_key: "key", + enabled: true, + }), + }); + await expectFixtureOk(created, 'POST /server/integrations (Toggle Me)'); + + await integrations.goto(); + + const row = integrations.getInstanceByName("Toggle Me"); + await row.waitFor({ timeout: 10_000 }); + const toggle = integrations.getEnabledToggle(row); + + // Should start checked (enabled) + await expect(toggle).toBeChecked(); + + // Toggle off + await toggle.click(); + + // Poll API until disabled + await expect + .poll( + async () => { + const res = await apiFetch("/server/integrations"); + if (!res.ok) { + throw new Error(`GET /server/integrations failed: ${res.status} ${res.statusText}`); + } + const instances = await res.json(); + const inst = instances.find( + (i: { name: string }) => i.name === "Toggle Me" + ); + return inst?.enabled; + }, + { timeout: 5000 } + ) + .toBe(false); + }); + + test("cancel button closes the add form without creating", async ({ + apiFetch, + }) => { + await integrations.addSonarrButton.click(); + await expect(integrations.instanceForm).toBeVisible(); + + await integrations.fillForm({ + name: "Should Not Exist", + url: "http://localhost:8989", + }); + await integrations.clickCancel(); + + // Form should close + await expect(integrations.instanceForm).not.toBeVisible(); + + // Verify nothing was created. Fail loudly if the GET itself fails so + // we don't get a false-positive (an empty list always satisfies the + // "Should Not Exist" check). + const res = await apiFetch("/server/integrations"); + if (!res.ok) { + throw new Error(`GET /server/integrations failed: ${res.status} ${res.statusText}`); + } + const instances = await res.json(); + expect( + instances.find((i: { name: string }) => i.name === "Should Not Exist") + ).toBeUndefined(); + }); +}); diff --git a/src/e2e-playwright/tests/pages/integrations.page.ts b/src/e2e-playwright/tests/pages/integrations.page.ts new file mode 100644 index 00000000..a924e5f5 --- /dev/null +++ b/src/e2e-playwright/tests/pages/integrations.page.ts @@ -0,0 +1,101 @@ +import { type Page, type Locator } from "@playwright/test"; + +export class IntegrationsPage { + readonly page: Page; + readonly container: Locator; + readonly addSonarrButton: Locator; + readonly addRadarrButton: Locator; + readonly emptyState: Locator; + readonly instanceForm: Locator; + readonly errorMessage: Locator; + + constructor(page: Page) { + this.page = page; + // Scope all selectors to .integrations to avoid collisions with .path-pairs + this.container = page.locator(".integrations"); + this.addSonarrButton = this.container.locator("button.btn-add", { + hasText: "Sonarr", + }); + this.addRadarrButton = this.container.locator("button.btn-add", { + hasText: "Radarr", + }); + this.emptyState = this.container.locator(".empty-state"); + this.instanceForm = this.container.locator(".instance-form"); + this.errorMessage = this.container.locator(".error-message"); + } + + async goto() { + await this.page.goto("/settings"); + await this.page.waitForURL("**/settings", { timeout: 10_000 }); + await this.page.waitForSelector('a[href="/dashboard"]', { + timeout: 10_000, + }); + } + + /** Fill the add/edit instance form */ + async fillForm(fields: { + name?: string; + url?: string; + apiKey?: string; + kind?: string; + }) { + const form = this.instanceForm; + if (fields.kind !== undefined) { + const select = form.locator("select"); + await select.selectOption(fields.kind); + } + if (fields.name !== undefined) { + const nameInput = form.locator('label:has-text("Name") input'); + await nameInput.fill(fields.name); + } + if (fields.url !== undefined) { + const urlInput = form.locator('label:has-text("URL") input'); + await urlInput.fill(fields.url); + } + if (fields.apiKey !== undefined) { + const apiKeyInput = form.locator('label:has-text("API Key") input'); + await apiKeyInput.fill(fields.apiKey); + } + } + + async clickSave() { + await this.instanceForm.locator("button.btn-save").click(); + } + + async clickCancel() { + await this.instanceForm.locator("button.btn-cancel").click(); + } + + getInstanceRows() { + return this.container.locator(".instance-row"); + } + + getInstanceByName(name: string) { + const escaped = name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + return this.container.locator(".instance-row").filter({ + has: this.page.locator(".instance-name", { + hasText: new RegExp(escaped), + }), + }); + } + + getEditButton(row: Locator) { + return row.locator("button.btn-edit"); + } + + getDeleteButton(row: Locator) { + return row.locator("button.btn-delete"); + } + + getTestButton(row: Locator) { + return row.locator("button", { hasText: /Test Connection|Testing/i }); + } + + getTestResult(row: Locator) { + return row.locator(".test-result"); + } + + getEnabledToggle(row: Locator) { + return row.locator('input[type="checkbox"]'); + } +} diff --git a/src/e2e-playwright/tests/pages/settings.page.ts b/src/e2e-playwright/tests/pages/settings.page.ts index aac4253f..fa80645a 100644 --- a/src/e2e-playwright/tests/pages/settings.page.ts +++ b/src/e2e-playwright/tests/pages/settings.page.ts @@ -60,9 +60,9 @@ export class SettingsPage { } } - /** Get the restart notification if visible */ + /** Get the restart notification if visible (scoped to header to avoid matching unrelated alerts) */ getRestartNotification() { - return this.page.locator(".alert", { + return this.page.locator("#header .alert", { hasText: /restart/i, }); } diff --git a/src/e2e-playwright/tests/path-pairs.spec.ts b/src/e2e-playwright/tests/path-pairs.spec.ts index a7411170..b2ab1bf5 100644 --- a/src/e2e-playwright/tests/path-pairs.spec.ts +++ b/src/e2e-playwright/tests/path-pairs.spec.ts @@ -1,5 +1,6 @@ import { test, expect } from "./fixtures"; import { PathPairsPage } from "./pages/path-pairs.page"; +import { SettingsPage } from "./pages/settings.page"; test.describe("Path Pairs", () => { let pathPairs: PathPairsPage; @@ -306,4 +307,30 @@ test.describe("Path Pairs", () => { ) .toBe(false); }); + + test("Server Directory field on Settings page is disabled when a path pair exists", async ({ + page, + apiFetch, + }) => { + // Lives in this file (not settings.spec.ts) so it shares the same + // beforeEach/afterEach pair cleanup and doesn't race with other specs + // running in parallel — pair creation/deletion across files is the + // root cause of the cross-file races we saw at workers > 2. + const res = await apiFetch("/server/pathpairs", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + name: "e2e-server-dir-test", + remote_path: "/remote/test", + local_path: "/local/test", + enabled: true, + }), + }); + expect(res.ok).toBe(true); + + const settings = new SettingsPage(page); + await settings.goto(); + const serverDir = settings.getTextInput("Server Directory"); + await expect(serverDir).toBeDisabled(); + }); }); diff --git a/src/e2e-playwright/tests/settings.spec.ts b/src/e2e-playwright/tests/settings.spec.ts index 51245d80..df2ed166 100644 --- a/src/e2e-playwright/tests/settings.spec.ts +++ b/src/e2e-playwright/tests/settings.spec.ts @@ -39,18 +39,25 @@ test.describe("Settings Page", () => { try { const field = settings.getTextInput("Server Address"); - await field.clear(); + // Wait for the field to be enabled (config loaded via SSE) + await expect(field).toBeEnabled({ timeout: 5000 }); const testValue = "e2e-test-server"; + // fill() already clears the input before typing — calling clear() + // separately can race with Angular's signal-driven re-render of the + // ngModel binding and reset the field before fill() runs. await field.fill(testValue); + // Blur triggers any pending change events and ensures Angular + // processes the final value through the debounce pipeline. + await field.blur(); - // Poll the API until the value is saved + // Poll the API until the value is saved (debounce is 1 s) await expect .poll( async () => { const config = await apiGet("/server/config/get"); return config.lftp.remote_address; }, - { timeout: 5000 } + { timeout: 8000 } ) .toBe(testValue); } finally { @@ -105,6 +112,8 @@ test.describe("Settings Page", () => { const originalFormat = configBefore.logging.log_format; const select = settings.getSelect("Log Format"); + // Wait for SSE-delivered config — same gate as the other select tests. + await expect(select).toBeEnabled({ timeout: 10_000 }); try { await select.selectOption("json"); @@ -166,45 +175,18 @@ test.describe("Settings Page", () => { await expect(options).toHaveCount(7); }); - test("Server Directory field is disabled when path pairs exist", async ({ - apiFetch, - }) => { - // Create a path pair via API - const pairName = `temp-pair-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; - const res = await apiFetch("/server/pathpairs", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - name: pairName, - remote_path: "/remote/test", - local_path: "/local/test", - enabled: true, - }), - }); - expect(res.ok).toBe(true); - const pair = await res.json(); - - try { - await settings.goto(); - const serverDir = settings.getTextInput("Server Directory"); - await expect(serverDir).toBeDisabled(); - } finally { - // Always clean up the pair - if (pair?.id) { - const del = await apiFetch(`/server/pathpairs/${pair.id}`, { method: "DELETE" }); - expect(del.ok, `Failed to delete temp pair ${pair.id}: ${del.status}`).toBe(true); - } - } - }); - test("restart notification appears after config change", async ({ apiGet, apiSetConfig }) => { const configBefore = await apiGet("/server/config/get"); const originalAddress = configBefore.lftp.remote_address; try { const field = settings.getTextInput("Server Address"); - await field.clear(); + // fill() already clears before typing — calling clear() separately + // races with Angular's signal-driven re-render and can reset the + // field before fill() runs. Wait for SSE-delivered config first. + await expect(field).toBeEnabled({ timeout: 5000 }); await field.fill("trigger-restart-notice-" + Date.now()); + await field.blur(); const notification = settings.getRestartNotification(); await expect(notification).toBeVisible({ timeout: 5000 }); @@ -228,3 +210,328 @@ test.describe("Settings Page", () => { await expect(field).toHaveAttribute("type", "password"); }); }); + +test.describe("Settings — Staging Directory", () => { + let settings: SettingsPage; + + test.beforeEach(async ({ page }) => { + settings = new SettingsPage(page); + await settings.goto(); + }); + + test("staging path text field saves to backend", async ({ + apiGet, + apiSetConfig, + }) => { + const configBefore = await apiGet("/server/config/get"); + const originalPath = configBefore.controller.staging_path; + + try { + const field = settings.getTextInput("Staging Path"); + await expect(field).toBeEnabled({ timeout: 5000 }); + const testValue = "/tmp/e2e-staging-" + Date.now(); + await field.fill(testValue); + await field.blur(); + + await expect + .poll( + async () => { + const config = await apiGet("/server/config/get"); + return config.controller.staging_path; + }, + { timeout: 5000 } + ) + .toBe(testValue); + } finally { + await apiSetConfig("controller", "staging_path", originalPath ?? ""); + } + }); + + test("use staging directory checkbox toggles and saves", async ({ + apiGet, + }) => { + const checkbox = settings.getCheckbox("Use staging directory"); + const wasBefore = await checkbox.isChecked(); + const expected = !wasBefore; + + await checkbox.click(); + + try { + await expect + .poll( + async () => { + const config = await apiGet("/server/config/get"); + return config.controller.use_staging; + }, + { timeout: 5000 } + ) + .toBe(expected); + } finally { + await checkbox.click(); + await expect + .poll( + async () => { + const config = await apiGet("/server/config/get"); + return config.controller.use_staging; + }, + { timeout: 5000 } + ) + .toBe(wasBefore); + } + }); +}); + +test.describe("Settings — Archive Extraction", () => { + let settings: SettingsPage; + + test.beforeEach(async ({ page }) => { + settings = new SettingsPage(page); + await settings.goto(); + }); + + test("extract in downloads directory checkbox toggles and saves", async ({ + apiGet, + }) => { + const checkbox = settings.getCheckbox( + "Extract archives in the downloads directory" + ); + const wasBefore = await checkbox.isChecked(); + const expected = !wasBefore; + + await checkbox.click(); + + try { + await expect + .poll( + async () => { + const config = await apiGet("/server/config/get"); + return config.controller.use_local_path_as_extract_path; + }, + { timeout: 5000 } + ) + .toBe(expected); + } finally { + await checkbox.click(); + await expect + .poll( + async () => { + const config = await apiGet("/server/config/get"); + return config.controller.use_local_path_as_extract_path; + }, + { timeout: 5000 } + ) + .toBe(wasBefore); + } + }); +}); + +test.describe("Settings — Integrity Check", () => { + let settings: SettingsPage; + + test.beforeEach(async ({ page }) => { + settings = new SettingsPage(page); + await settings.goto(); + }); + + test("verify transfers checkbox and algorithm select are present", async () => { + const section = settings.getSection("Integrity Check"); + await expect(section).toBeVisible(); + + const verifyCheckbox = settings.getCheckbox( + "Verify transfers inline" + ); + await expect(verifyCheckbox).toBeVisible(); + + const algorithmSelect = settings.getSelect("Hash Algorithm"); + await expect(algorithmSelect).toBeVisible(); + }); + + test("hash algorithm select changes and saves to backend", async ({ + page, + apiGet, + apiSetConfig, + waitForStream, + }) => { + // The Hash Algorithm select is disabled by buildValidateContext() unless + // validate.enabled is true (see settings-page.component.ts). Enable + // post-download validation here, then restore in the finally block. + const configBefore = await apiGet("/server/config/get"); + const originalAlgorithm = configBefore.validate.algorithm; + const originalValidateEnabled = configBefore.validate.enabled; + + try { + // All state mutation lives inside try so the finally block always + // restores it — even if the apiSetConfig itself or any subsequent + // setup step throws. + if (!originalValidateEnabled) { + await apiSetConfig("validate", "enabled", "True"); + } + + // beforeEach already constructed `settings` and navigated to /settings, + // but the page may have loaded before validate.enabled was applied. + // Reload so the buildValidateContext() rebuild observes the new value, + // then wait for the SSE stream before interacting with the select. + await settings.goto(); + await waitForStream(page); + + const select = settings.getSelect("Hash Algorithm"); + // Wait for the SSE config to arrive AND for buildValidateContext to + // unlock the select (the disable rule needs validate.enabled === true). + await expect(select).toBeEnabled({ timeout: 10_000 }); + await expect(select).toHaveValue(/^(md5|sha1|sha256)$/); + + // Pick a different algorithm than current + const newValue = originalAlgorithm === "sha256" ? "md5" : "sha256"; + + await select.selectOption(newValue); + + await expect + .poll( + async () => { + const config = await apiGet("/server/config/get"); + return config.validate.algorithm; + }, + { timeout: 5000 } + ) + .toBe(newValue); + } finally { + await apiSetConfig("validate", "algorithm", String(originalAlgorithm)); + if (!originalValidateEnabled) { + await apiSetConfig("validate", "enabled", "False"); + } + } + }); +}); + +test.describe("Settings — Connections", () => { + let settings: SettingsPage; + + test.beforeEach(async ({ page }) => { + settings = new SettingsPage(page); + await settings.goto(); + }); + + test("Max Parallel Downloads field saves to backend", async ({ + apiGet, + apiSetConfig, + }) => { + const configBefore = await apiGet("/server/config/get"); + const originalValue = configBefore.lftp.num_max_parallel_downloads; + + try { + const field = settings.getTextInput("Max Parallel Downloads"); + await expect(field).toBeEnabled({ timeout: 5000 }); + await field.fill("7"); + await field.blur(); + + await expect + .poll( + async () => { + const config = await apiGet("/server/config/get"); + return String(config.lftp.num_max_parallel_downloads); + }, + { timeout: 5000 } + ) + .toBe("7"); + } finally { + await apiSetConfig( + "lftp", + "num_max_parallel_downloads", + String(originalValue) + ); + } + }); +}); + +test.describe("Settings — Notifications", () => { + let settings: SettingsPage; + + test.beforeEach(async ({ page }) => { + settings = new SettingsPage(page); + await settings.goto(); + }); + + test("Discord and Telegram webhook fields are present", async () => { + const section = settings.getSection("Notifications"); + await expect(section).toBeVisible(); + + // Discord Webhook URL is a password field — assert masked input type, + // not just visibility, so an accidental switch to type="text" is caught. + const discordField = section + .locator("app-option", { hasText: "Discord Webhook URL" }) + .locator("input[type='text'], input[type='password']"); + await expect(discordField).toBeVisible(); + await expect(discordField).toHaveAttribute("type", "password"); + + // Telegram Bot Token is a password field — same masking assertion. + const telegramTokenField = section + .locator("app-option", { hasText: "Telegram Bot Token" }) + .locator("input[type='text'], input[type='password']"); + await expect(telegramTokenField).toBeVisible(); + await expect(telegramTokenField).toHaveAttribute("type", "password"); + + // Telegram Chat ID is a text field + const telegramChatField = section + .locator("app-option", { hasText: "Telegram Chat ID" }) + .locator("input[type='text'], input[type='password']"); + await expect(telegramChatField).toBeVisible(); + }); +}); + +test.describe("Settings — Logging", () => { + let settings: SettingsPage; + + test.beforeEach(async ({ page }) => { + settings = new SettingsPage(page); + await settings.goto(); + }); + + test("Log Level dropdown persists selected value", async ({ + apiGet, + }) => { + const configBefore = await apiGet("/server/config/get"); + const originalLevel = configBefore.general.log_level; + const select = settings.getSelect("Log Level"); + + try { + // Wait for SSE-delivered config and pick the new value inside the + // try so any throw before the mutation still hits the finally — + // same pattern as the Hash Algorithm test. + await expect(select).toBeEnabled({ timeout: 10_000 }); + const newValue = originalLevel === "DEBUG" ? "WARNING" : "DEBUG"; + + await select.selectOption(newValue); + + await expect + .poll( + async () => { + const config = await apiGet("/server/config/get"); + return config.general.log_level; + }, + { timeout: 5000 } + ) + .toBe(newValue); + + // Reload the page and verify the value persists. settings.goto() + // only waits for Angular bootstrap (sidebar render), not for SSE + // config delivery, so wait for the select to become enabled — its + // disabled gate is `value() === null || value() === undefined`, so + // an enabled select means the model has received the SSE value. + await settings.goto(); + const reloadedSelect = settings.getSelect("Log Level"); + await expect(reloadedSelect).toBeEnabled({ timeout: 10_000 }); + await expect(reloadedSelect).toHaveValue(newValue); + } finally { + await select.selectOption(String(originalLevel)); + await expect + .poll( + async () => { + const config = await apiGet("/server/config/get"); + return config.general.log_level; + }, + { timeout: 5000 } + ) + .toBe(originalLevel); + } + }); +}); diff --git a/src/python/common/context.py b/src/python/common/context.py index 5fdc30f1..545d8990 100644 --- a/src/python/common/context.py +++ b/src/python/common/context.py @@ -49,6 +49,11 @@ def __init__( status: Status, path_pairs_config: PathPairsConfig | None = None, integrations_config: IntegrationsConfig | None = None, + config_path: str | None = None, + path_pairs_path: str | None = None, + integrations_path: str | None = None, + auto_queue_persist_path: str | None = None, + controller_persist_path: str | None = None, ): """ Primary constructor to construct the top-level context @@ -62,6 +67,13 @@ def __init__( self.path_pairs_config = path_pairs_config or PathPairsConfig() self.integrations_config = integrations_config or IntegrationsConfig() + # File paths for flush-on-write + self.config_path = config_path + self.path_pairs_path = path_pairs_path + self.integrations_path = integrations_path + self.auto_queue_persist_path = auto_queue_persist_path + self.controller_persist_path = controller_persist_path + def create_child_context(self, context_name: str) -> "Context": child_context = copy.copy(self) child_context.logger = self.logger.getChild(context_name) diff --git a/src/python/controller/controller.py b/src/python/controller/controller.py index 32d17288..975e25c9 100644 --- a/src/python/controller/controller.py +++ b/src/python/controller/controller.py @@ -3,6 +3,7 @@ from __future__ import annotations import os +import threading from abc import ABC, abstractmethod from collections.abc import Callable from enum import Enum @@ -146,6 +147,9 @@ def __init__(self, context: Context, persist: ControllerPersist): # Seed each builder with filtered persist state self.__updater.sync_persist_to_all_builders() + # Flag for hot-reloading LFTP tuning settings (set from REST thread) + self.__needs_lftp_reconfigure = threading.Event() + self.__started = False def _validate_config(self) -> None: @@ -314,6 +318,13 @@ def start(self): self.__mp_logger.start() self.__started = True + def request_lftp_reconfigure(self) -> None: + """Signal that LFTP tuning settings have changed and should be reapplied. + + Thread-safe: called from the REST handler thread. + """ + self.__needs_lftp_reconfigure.set() + def process(self): """ Advance the controller state @@ -322,6 +333,11 @@ def process(self): """ if not self.__started: raise ControllerError("Cannot process, controller is not started") + if self.__needs_lftp_reconfigure.is_set(): + self.__needs_lftp_reconfigure.clear() + for pc in self.__pair_contexts: + self._configure_lftp(pc.lftp) + self.logger.info("Reapplied LFTP tuning settings") self.__pipeline.propagate_exceptions() self.__pipeline.cleanup() self.__pipeline.step() diff --git a/src/python/seedsync.py b/src/python/seedsync.py index 28d6d4e7..cdf180eb 100644 --- a/src/python/seedsync.py +++ b/src/python/seedsync.py @@ -148,6 +148,9 @@ def __init__(self): status=status, path_pairs_config=path_pairs_config, integrations_config=integrations_config, + config_path=self.config_path, + path_pairs_path=self.path_pairs_path, + integrations_path=self.integrations_path, ) # Register the signal handlers @@ -164,6 +167,10 @@ def __init__(self): self.auto_queue_persist_path = os.path.join(args.config_dir, Seedsync.__FILE_AUTO_QUEUE_PERSIST) self.auto_queue_persist = self._load_persist(AutoQueuePersist, self.auto_queue_persist_path) + # Set persist paths on context (these are determined after context creation) + self.context.controller_persist_path = self.controller_persist_path + self.context.auto_queue_persist_path = self.auto_queue_persist_path + def run(self): self.context.logger.info("Starting SeedSync") self.context.logger.info(f"Platform: {platform.machine()}") diff --git a/src/python/tests/integration/test_controller/test_controller.py b/src/python/tests/integration/test_controller/test_controller.py index 9da2bb49..56815496 100644 --- a/src/python/tests/integration/test_controller/test_controller.py +++ b/src/python/tests/integration/test_controller/test_controller.py @@ -316,6 +316,7 @@ def setUp(self): logger = logging.getLogger(TestController.__name__) handler = logging.StreamHandler(sys.stdout) logger.addHandler(handler) + self.addCleanup(logger.removeHandler, handler) logger.setLevel(logging.DEBUG) formatter = logging.Formatter("%(asctime)s - %(levelname)s - %(name)s - %(message)s") handler.setFormatter(formatter) diff --git a/src/python/tests/integration/test_lftp/test_lftp.py b/src/python/tests/integration/test_lftp/test_lftp.py index b964e3c2..43edca0d 100644 --- a/src/python/tests/integration/test_lftp/test_lftp.py +++ b/src/python/tests/integration/test_lftp/test_lftp.py @@ -44,6 +44,7 @@ def setUp(self): formatter = logging.Formatter("%(asctime)s - %(levelname)s - %(name)s - %(message)s") handler.setFormatter(formatter) logger.addHandler(handler) + self.addCleanup(logger.removeHandler, handler) self.lftp.set_base_logger(logger) # Verbose logging diff --git a/src/python/tests/integration/test_web/test_handler/test_auto_queue.py b/src/python/tests/integration/test_web/test_handler/test_auto_queue.py index c8493d22..a387ec39 100644 --- a/src/python/tests/integration/test_web/test_handler/test_auto_queue.py +++ b/src/python/tests/integration/test_web/test_handler/test_auto_queue.py @@ -1,6 +1,5 @@ # Copyright 2017, Inderpreet Singh, All rights reserved. -import json from urllib.parse import quote from controller import AutoQueuePattern @@ -16,7 +15,7 @@ def test_get(self): self.auto_queue_persist.add_pattern(AutoQueuePattern(pattern="fi%ve")) resp = self.test_app.get("/server/autoqueue/get") self.assertEqual(200, resp.status_int) - json_list = json.loads(str(resp.html)) + json_list = resp.json self.assertEqual(5, len(json_list)) self.assertIn({"pattern": "one"}, json_list) self.assertIn({"pattern": "t wo"}, json_list) @@ -32,7 +31,7 @@ def test_get_is_ordered(self): self.auto_queue_persist.add_pattern(AutoQueuePattern(pattern="e")) resp = self.test_app.get("/server/autoqueue/get") self.assertEqual(200, resp.status_int) - json_list = json.loads(str(resp.html)) + json_list = resp.json self.assertEqual(5, len(json_list)) self.assertEqual( [{"pattern": "a"}, {"pattern": "b"}, {"pattern": "c"}, {"pattern": "d"}, {"pattern": "e"}], json_list @@ -73,7 +72,7 @@ def test_add_double(self): self.assertEqual(200, resp.status_int) resp = self.test_app.get("/server/autoqueue/add/one", expect_errors=True) self.assertEqual(400, resp.status_int) - self.assertEqual("Auto-queue pattern 'one' already exists.", str(resp.html)) + self.assertEqual("Auto-queue pattern 'one' already exists.", resp.text) def test_add_empty_value(self): uri = quote(quote(" ", safe=""), safe="") @@ -124,13 +123,13 @@ def test_remove_good(self): def test_remove_non_existing(self): resp = self.test_app.get("/server/autoqueue/remove/one", expect_errors=True) self.assertEqual(400, resp.status_int) - self.assertEqual("Auto-queue pattern 'one' doesn't exist.", str(resp.html)) + self.assertEqual("Auto-queue pattern 'one' doesn't exist.", resp.text) def test_remove_empty_value(self): uri = quote(quote(" ", safe=""), safe="") resp = self.test_app.get("/server/autoqueue/remove/" + uri, expect_errors=True) self.assertEqual(400, resp.status_int) - self.assertEqual("Auto-queue pattern ' ' doesn't exist.", str(resp.html)) + self.assertEqual("Auto-queue pattern ' ' doesn't exist.", resp.text) self.assertEqual(0, len(self.auto_queue_persist.patterns)) resp = self.test_app.get("/server/autoqueue/remove/", expect_errors=True) diff --git a/src/python/tests/integration/test_web/test_handler/test_server.py b/src/python/tests/integration/test_web/test_handler/test_server.py index cdfd5c22..59c7da75 100644 --- a/src/python/tests/integration/test_web/test_handler/test_server.py +++ b/src/python/tests/integration/test_web/test_handler/test_server.py @@ -10,3 +10,35 @@ def test_restart(self): self.assertTrue(self.web_app_builder.server_handler.is_restart_requested()) print(self.test_app.get("/server/command/restart")) self.assertTrue(self.web_app_builder.server_handler.is_restart_requested()) + + def test_restart_response_body(self): + """Response body is 'Requested restart'.""" + resp = self.test_app.get("/server/command/restart") + self.assertIn("Requested restart", resp.text) + + def test_restart_response_is_200(self): + """Restart endpoint returns 200.""" + resp = self.test_app.get("/server/command/restart") + self.assertEqual(200, resp.status_int) + + def test_state_transition_false_to_true(self): + """is_restart_requested() transitions from False to True after restart call.""" + handler = self.web_app_builder.server_handler + self.assertFalse(handler.is_restart_requested()) + self.test_app.get("/server/command/restart") + self.assertTrue(handler.is_restart_requested()) + + def test_restart_idempotent(self): + """Calling restart twice still returns 200 (idempotent).""" + resp1 = self.test_app.get("/server/command/restart") + resp2 = self.test_app.get("/server/command/restart") + self.assertEqual(200, resp1.status_int) + self.assertEqual(200, resp2.status_int) + + def test_restart_state_stays_true(self): + """Once restart is requested, state remains True.""" + handler = self.web_app_builder.server_handler + self.test_app.get("/server/command/restart") + self.assertTrue(handler.is_restart_requested()) + self.test_app.get("/server/command/restart") + self.assertTrue(handler.is_restart_requested()) diff --git a/src/python/tests/integration/test_web/test_handler/test_status.py b/src/python/tests/integration/test_web/test_handler/test_status.py index eafa323d..bd704804 100644 --- a/src/python/tests/integration/test_web/test_handler/test_status.py +++ b/src/python/tests/integration/test_web/test_handler/test_status.py @@ -11,3 +11,45 @@ def test_status(self): self.assertEqual(200, resp.status_int) json_dict = json.loads(str(resp.html)) self.assertEqual(True, json_dict["server"]["up"]) + + def test_full_response_structure(self): + """Response body contains both 'server' and 'controller' sections.""" + resp = self.test_app.get("/server/status") + json_dict = json.loads(str(resp.html)) + self.assertIn("server", json_dict) + self.assertIn("controller", json_dict) + # Server section + self.assertIn("up", json_dict["server"]) + self.assertIn("error_msg", json_dict["server"]) + # Controller section + self.assertIn("latest_local_scan_time", json_dict["controller"]) + self.assertIn("latest_remote_scan_time", json_dict["controller"]) + self.assertIn("no_enabled_pairs", json_dict["controller"]) + self.assertIn("latest_remote_scan_failed", json_dict["controller"]) + self.assertIn("latest_remote_scan_error", json_dict["controller"]) + + def test_remote_scan_time_null_initially(self): + """controller.latest_remote_scan_time is null initially.""" + resp = self.test_app.get("/server/status") + json_dict = json.loads(str(resp.html)) + self.assertIsNone(json_dict["controller"]["latest_remote_scan_time"]) + + def test_local_scan_time_null_initially(self): + """controller.latest_local_scan_time is null initially.""" + resp = self.test_app.get("/server/status") + json_dict = json.loads(str(resp.html)) + self.assertIsNone(json_dict["controller"]["latest_local_scan_time"]) + + def test_no_enabled_pairs_reflects_state(self): + """controller.no_enabled_pairs reflects actual state.""" + resp = self.test_app.get("/server/status") + json_dict = json.loads(str(resp.html)) + # Default status has no_enabled_pairs = False + self.assertFalse(json_dict["controller"]["no_enabled_pairs"]) + + def test_no_enabled_pairs_true_when_set(self): + """controller.no_enabled_pairs reflects True when set.""" + self.context.status.controller.no_enabled_pairs = True + resp = self.test_app.get("/server/status") + json_dict = json.loads(str(resp.html)) + self.assertTrue(json_dict["controller"]["no_enabled_pairs"]) diff --git a/src/python/tests/integration/test_web/test_handler/test_stream_log.py b/src/python/tests/integration/test_web/test_handler/test_stream_log.py index ef29d64e..25cd41b2 100644 --- a/src/python/tests/integration/test_web/test_handler/test_stream_log.py +++ b/src/python/tests/integration/test_web/test_handler/test_stream_log.py @@ -5,13 +5,14 @@ from unittest.mock import patch from tests.integration.test_web.test_web_app import BaseTestWebApp +from web.handler.stream_log import CachedQueueLogHandler, QueueLogHandler class TestLogStreamHandler(BaseTestWebApp): @patch("web.handler.stream_log.SerializeLogRecord") def test_stream_log_serializes_record(self, mock_serialize_log_record_cls): # Schedule server stop - Timer(0.5, self.web_app.stop).start() + Timer(2.0, self.web_app.stop).start() # Schedule status update def issue_logs(): @@ -41,3 +42,78 @@ def issue_logs(): record4 = call4[0][0] self.assertEqual("Error msg", record4.msg) self.assertEqual(logging.ERROR, record4.levelno) + + +class TestLogStreamHandlerCleanup(BaseTestWebApp): + """Tests for handler attachment and cleanup.""" + + def test_queue_handler_attach_remove(self): + """QueueLogHandler can be attached and removed from logger.""" + logger = self.context.logger + handler = QueueLogHandler() + initial_count = len(logger.handlers) + + logger.addHandler(handler) + self.assertEqual(len(logger.handlers), initial_count + 1) + + logger.removeHandler(handler) + self.assertEqual(len(logger.handlers), initial_count) + + def test_multiple_handlers_independent(self): + """Multiple QueueLogHandlers attach/remove independently.""" + logger = self.context.logger + h1 = QueueLogHandler() + h2 = QueueLogHandler() + + logger.addHandler(h1) + logger.addHandler(h2) + + logger.info("test message") + + # Both handlers should have received the message + r1 = h1.get_next_event() + r2 = h2.get_next_event() + self.assertIsNotNone(r1) + self.assertIsNotNone(r2) + self.assertEqual(r1.msg, "test message") + self.assertEqual(r2.msg, "test message") + + # Remove h1, h2 still works + logger.removeHandler(h1) + logger.info("second message") + + r1 = h1.get_next_event() + r2 = h2.get_next_event() + self.assertIsNone(r1) + self.assertIsNotNone(r2) + self.assertEqual(r2.msg, "second message") + + logger.removeHandler(h2) + + def test_cached_handler_delivers_history(self): + """CachedQueueLogHandler stores records and returns them via get_cached_records().""" + logger = self.context.logger + cache = CachedQueueLogHandler(history_size_in_ms=5000) + logger.addHandler(cache) + + # Log something before the queue handler connects + logger.info("before connection") + + # Get cached records + cached = cache.get_cached_records() + self.assertTrue(any(r.msg == "before connection" for r in cached)) + + logger.removeHandler(cache) + + def test_cache_zero_sends_no_history(self): + """CachedQueueLogHandler with history_size_in_ms=0 sends no historical records.""" + logger = self.context.logger + cache = CachedQueueLogHandler(history_size_in_ms=0) + logger.addHandler(cache) + + logger.info("should not be cached") + + cached = cache.get_cached_records() + self.assertEqual(len(cached), 0) + + logger.removeHandler(cache) diff --git a/src/python/tests/integration/test_web/test_handler/test_stream_model.py b/src/python/tests/integration/test_web/test_handler/test_stream_model.py index 92020266..85d34e8c 100644 --- a/src/python/tests/integration/test_web/test_handler/test_stream_model.py +++ b/src/python/tests/integration/test_web/test_handler/test_stream_model.py @@ -12,14 +12,14 @@ class TestModelStreamHandler(BaseTestWebApp): def test_stream_model_fetches_model_and_adds_listener(self): # Schedule server stop - Timer(0.5, self.web_app.stop).start() + Timer(2.0, self.web_app.stop).start() self.test_app.get("/server/stream") self.controller.get_model_files_and_add_listener.assert_called_once_with(unittest.mock.ANY) def test_stream_model_removes_listener(self): # Schedule server stop - Timer(0.5, self.web_app.stop).start() + Timer(2.0, self.web_app.stop).start() self.test_app.get("/server/stream") self.controller.remove_model_listener.assert_called_once_with(self.model_listener) @@ -27,7 +27,7 @@ def test_stream_model_removes_listener(self): @patch("web.handler.stream_model.SerializeModel") def test_stream_model_serializes_initial_model(self, mock_serialize_model_cls): # Schedule server stop - Timer(0.5, self.web_app.stop).start() + Timer(2.0, self.web_app.stop).start() # Setup mock serialize instance mock_serialize = mock_serialize_model_cls.return_value diff --git a/src/python/tests/integration/test_web/test_handler/test_stream_status.py b/src/python/tests/integration/test_web/test_handler/test_stream_status.py index ee1a2853..e3bbb368 100644 --- a/src/python/tests/integration/test_web/test_handler/test_stream_status.py +++ b/src/python/tests/integration/test_web/test_handler/test_stream_status.py @@ -10,7 +10,7 @@ class TestStatusStreamHandler(BaseTestWebApp): @patch("web.handler.stream_status.SerializeStatus") def test_stream_status_serializes_initial_status(self, mock_serialize_status_cls): # Schedule server stop - Timer(0.5, self.web_app.stop).start() + Timer(2.0, self.web_app.stop).start() # Setup mock serialize instance mock_serialize = mock_serialize_status_cls.return_value @@ -26,7 +26,7 @@ def test_stream_status_serializes_initial_status(self, mock_serialize_status_cls @patch("web.handler.stream_status.SerializeStatus") def test_stream_status_serializes_new_status(self, mock_serialize_status_cls): # Schedule server stop - Timer(0.5, self.web_app.stop).start() + Timer(2.0, self.web_app.stop).start() # Schedule status update def update_status(): diff --git a/src/python/tests/integration/test_web/test_web_app.py b/src/python/tests/integration/test_web/test_web_app.py index 368f3217..2bbd372c 100644 --- a/src/python/tests/integration/test_web/test_web_app.py +++ b/src/python/tests/integration/test_web/test_web_app.py @@ -1,7 +1,10 @@ # Copyright 2017, Inderpreet Singh, All rights reserved. import logging +import os +import shutil import sys +import tempfile import unittest from unittest.mock import MagicMock @@ -27,6 +30,7 @@ def setUp(self): logger = logging.getLogger() handler = logging.StreamHandler(sys.stdout) logger.addHandler(handler) + self.addCleanup(logger.removeHandler, handler) logger.setLevel(logging.DEBUG) formatter = logging.Formatter("%(asctime)s - %(levelname)s - %(name)s - %(message)s") handler.setFormatter(formatter) @@ -48,6 +52,14 @@ def setUp(self): # Real auto-queue persist self.auto_queue_persist = AutoQueuePersist() + # Temp directory for flush-on-write file paths + self._test_tmpdir = tempfile.mkdtemp() + self.context.config_path = os.path.join(self._test_tmpdir, "settings.cfg") + self.context.path_pairs_path = os.path.join(self._test_tmpdir, "path_pairs.json") + self.context.integrations_path = os.path.join(self._test_tmpdir, "integrations.json") + self.context.auto_queue_persist_path = os.path.join(self._test_tmpdir, "autoqueue.persist") + self.context.controller_persist_path = os.path.join(self._test_tmpdir, "controller.persist") + # Capture the model listener def capture_listener(listener): self.model_listener = listener @@ -63,6 +75,10 @@ def capture_listener(listener): self.web_app = self.web_app_builder.build() self.test_app = TestApp(self.web_app) + @overrides(unittest.TestCase) + def tearDown(self): + shutil.rmtree(self._test_tmpdir, ignore_errors=True) + class TestWebApp(BaseTestWebApp): def test_process(self): diff --git a/src/python/tests/unittests/test_common/test_app_process.py b/src/python/tests/unittests/test_common/test_app_process.py index ed8f33c5..ce3dad7d 100644 --- a/src/python/tests/unittests/test_common/test_app_process.py +++ b/src/python/tests/unittests/test_common/test_app_process.py @@ -99,6 +99,7 @@ def setUp(self): logger = logging.getLogger() handler = logging.StreamHandler(sys.stdout) logger.addHandler(handler) + self.addCleanup(logger.removeHandler, handler) logger.setLevel(logging.DEBUG) formatter = logging.Formatter("%(asctime)s - %(levelname)s - %(name)s - %(message)s") handler.setFormatter(formatter) @@ -172,6 +173,7 @@ def setUp(self): logger = logging.getLogger() handler = logging.StreamHandler(sys.stdout) logger.addHandler(handler) + self.addCleanup(logger.removeHandler, handler) logger.setLevel(logging.DEBUG) formatter = logging.Formatter("%(asctime)s - %(levelname)s - %(name)s - %(message)s") handler.setFormatter(formatter) diff --git a/src/python/tests/unittests/test_common/test_context.py b/src/python/tests/unittests/test_common/test_context.py new file mode 100644 index 00000000..50545a9e --- /dev/null +++ b/src/python/tests/unittests/test_common/test_context.py @@ -0,0 +1,111 @@ +# Copyright 2017, Inderpreet Singh, All rights reserved. + +import logging +import unittest + +from common import Args, Config, Context, PathPairsConfig, Status +from common.path_pairs_config import PathPair + + +def _make_context(): + """Create a basic Context for testing.""" + # Tests use self.assertLogs(...) which installs its own capture handler, + # so we don't need to attach a StreamHandler here. Attaching one would + # leak across tests because loggers are singletons. + logger = logging.getLogger("test_context") + logger.setLevel(logging.DEBUG) + web_logger = logging.getLogger("test_context.web") + config = Config() + args = Args() + status = Status() + return Context(logger=logger, web_access_logger=web_logger, config=config, args=args, status=status) + + +class TestCreateChildContext(unittest.TestCase): + """Tests for Context.create_child_context.""" + + def test_returns_copy_with_child_logger(self): + ctx = _make_context() + child = ctx.create_child_context("child1") + self.assertEqual(child.logger.name, "test_context.child1") + # Parent logger unchanged + self.assertEqual(ctx.logger.name, "test_context") + + def test_child_shares_config_reference(self): + ctx = _make_context() + child = ctx.create_child_context("child2") + self.assertIs(child.config, ctx.config) + self.assertIs(child.status, ctx.status) + self.assertIs(child.args, ctx.args) + + +class TestPrintToLog(unittest.TestCase): + """Tests for Context.print_to_log.""" + + def test_redacts_sensitive_fields(self): + """Sensitive fields (passwords) are redacted in log output.""" + ctx = _make_context() + ctx.config.lftp.remote_password = "supersecret" + + with self.assertLogs("test_context", level="DEBUG") as log_ctx: + ctx.print_to_log() + + all_output = "\n".join(log_ctx.output) + self.assertNotIn("supersecret", all_output) + self.assertIn("********", all_output) + + def test_handles_no_path_pairs(self): + """print_to_log handles absent path pairs without error.""" + ctx = _make_context() + ctx.path_pairs_config = PathPairsConfig() + + with self.assertLogs("test_context", level="DEBUG") as log_ctx: + ctx.print_to_log() + + all_output = "\n".join(log_ctx.output) + self.assertIn("Path Pairs: (none)", all_output) + + def test_handles_present_path_pairs(self): + """print_to_log logs path pair details when pairs are present.""" + ctx = _make_context() + ppc = PathPairsConfig() + pair = PathPair(remote_path="/remote/test", local_path="/local/test") + pair.name = "TestPair" + pair.enabled = True + pair.auto_queue = True + # Use the public property setter so PathPairsConfig's lock is + # acquired, instead of touching the private _pairs attribute. + ppc.pairs = [pair] + ctx.path_pairs_config = ppc + + with self.assertLogs("test_context", level="DEBUG") as log_ctx: + ctx.print_to_log() + + all_output = "\n".join(log_ctx.output) + self.assertIn("TestPair", all_output) + self.assertIn("/remote/test", all_output) + + +class TestArgsAsDict(unittest.TestCase): + """Tests for Args.as_dict.""" + + def test_serializes_all_fields(self): + args = Args() + args.local_path_to_scanfs = "/scan" + args.html_path = "/html" + args.debug = True + args.exit = False + args.logdir = "/logs" + + d = args.as_dict() + self.assertEqual(d["local_path_to_scanfs"], "/scan") + self.assertEqual(d["html_path"], "/html") + self.assertEqual(d["debug"], "True") + self.assertEqual(d["exit"], "False") + self.assertEqual(d["logdir"], "/logs") + + def test_none_values_serialized_as_string(self): + args = Args() + d = args.as_dict() + self.assertEqual(d["local_path_to_scanfs"], "None") + self.assertEqual(d["html_path"], "None") diff --git a/src/python/tests/unittests/test_controller/test_delete/__init__.py b/src/python/tests/unittests/test_controller/test_delete/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/python/tests/unittests/test_controller/test_delete/test_delete_process.py b/src/python/tests/unittests/test_controller/test_delete/test_delete_process.py new file mode 100644 index 00000000..47d6f512 --- /dev/null +++ b/src/python/tests/unittests/test_controller/test_delete/test_delete_process.py @@ -0,0 +1,167 @@ +# Copyright 2017, Inderpreet Singh, All rights reserved. + +import logging +import sys +import unittest +from unittest.mock import patch + +from controller.delete.delete_process import DeleteLocalProcess, DeleteRemoteProcess + + +class TestDeleteLocalProcess(unittest.TestCase): + """Tests for DeleteLocalProcess.run_once().""" + + def setUp(self): + logger = logging.getLogger() + handler = logging.StreamHandler(sys.stdout) + logger.addHandler(handler) + self.addCleanup(logger.removeHandler, handler) + logger.setLevel(logging.DEBUG) + + @patch("controller.delete.delete_process.shutil.rmtree") + @patch("controller.delete.delete_process.os.path.isfile", return_value=False) + @patch("controller.delete.delete_process.os.path.exists", return_value=True) + @patch("controller.delete.delete_process.os.path.realpath") + def test_directory_uses_rmtree(self, mock_realpath, mock_exists, mock_isfile, mock_rmtree): + """Directory deletion uses shutil.rmtree.""" + mock_realpath.side_effect = lambda p: p + proc = DeleteLocalProcess("/base", "mydir") + proc.run_once() + mock_rmtree.assert_called_once_with("/base/mydir", ignore_errors=True) + + @patch("controller.delete.delete_process.os.remove") + @patch("controller.delete.delete_process.os.path.isfile", return_value=True) + @patch("controller.delete.delete_process.os.path.exists", return_value=True) + @patch("controller.delete.delete_process.os.path.realpath") + def test_regular_file_uses_os_remove(self, mock_realpath, mock_exists, mock_isfile, mock_remove): + """Regular file deletion uses os.remove.""" + mock_realpath.side_effect = lambda p: p + proc = DeleteLocalProcess("/base", "myfile.txt") + proc.run_once() + mock_remove.assert_called_once_with("/base/myfile.txt") + + @patch("controller.delete.delete_process.os.path.realpath") + def test_symlink_escaping_base_blocked(self, mock_realpath): + """Symlink escaping base directory is blocked and logged.""" + mock_realpath.side_effect = lambda p: "/etc/passwd" if "evil" in p else p + proc = DeleteLocalProcess("/base", "evil_symlink") + + with self.assertLogs(level="ERROR") as log_ctx: + proc.run_once() + + self.assertTrue(any("Path traversal blocked" in msg for msg in log_ctx.output)) + + @patch("controller.delete.delete_process.os.path.realpath") + def test_path_traversal_blocked(self, mock_realpath): + """../../etc/passwd style paths are blocked.""" + mock_realpath.side_effect = lambda p: "/etc/passwd" if "etc" in p else "/base" + proc = DeleteLocalProcess("/base", "../../etc/passwd") + + with self.assertLogs(level="ERROR") as log_ctx: + proc.run_once() + + self.assertTrue(any("Path traversal blocked" in msg for msg in log_ctx.output)) + + @patch("controller.delete.delete_process.os.path.exists", return_value=False) + @patch("controller.delete.delete_process.os.path.realpath") + def test_nonexistent_file_logs_error(self, mock_realpath, mock_exists): + """Non-existing file logs error, no crash.""" + mock_realpath.side_effect = lambda p: p + proc = DeleteLocalProcess("/base", "gone.txt") + + with self.assertLogs(level="ERROR") as log_ctx: + proc.run_once() + + self.assertTrue(any("non-existing" in msg for msg in log_ctx.output)) + + +class TestDeleteRemoteProcess(unittest.TestCase): + """Tests for DeleteRemoteProcess.run_once().""" + + def setUp(self): + logger = logging.getLogger() + handler = logging.StreamHandler(sys.stdout) + logger.addHandler(handler) + self.addCleanup(logger.removeHandler, handler) + logger.setLevel(logging.DEBUG) + + @patch("controller.delete.delete_process.Sshcp") + def test_constructs_correct_ssh_command(self, mock_sshcp_cls): + """Remote delete constructs correct SSH rm -rf command.""" + mock_ssh = mock_sshcp_cls.return_value + mock_ssh.shell.return_value = b"" + + proc = DeleteRemoteProcess( + remote_address="host", + remote_username="user", + remote_password="pass", + remote_port=22, + remote_path="/remote", + file_name="myfile.txt", + ) + proc.run_once() + + mock_ssh.shell.assert_called_once() + cmd = mock_ssh.shell.call_args[0][0] + self.assertIn("rm -rf", cmd) + self.assertIn("myfile.txt", cmd) + + @patch("controller.delete.delete_process.Sshcp") + def test_remote_path_starting_with_dotdot_blocked(self, mock_sshcp_cls): + """Remote paths starting with .. are blocked.""" + mock_ssh = mock_sshcp_cls.return_value + + proc = DeleteRemoteProcess( + remote_address="host", + remote_username="user", + remote_password="pass", + remote_port=22, + remote_path="/remote", + file_name="../etc/passwd", + ) + + with self.assertLogs(level="ERROR") as log_ctx: + proc.run_once() + + self.assertTrue(any("Path traversal blocked" in msg for msg in log_ctx.output)) + mock_ssh.shell.assert_not_called() + + @patch("controller.delete.delete_process.Sshcp") + def test_remote_absolute_path_blocked(self, mock_sshcp_cls): + """Remote file names with absolute paths are blocked.""" + mock_ssh = mock_sshcp_cls.return_value + + proc = DeleteRemoteProcess( + remote_address="host", + remote_username="user", + remote_password="pass", + remote_port=22, + remote_path="/remote", + file_name="/etc/passwd", + ) + + with self.assertLogs(level="ERROR") as log_ctx: + proc.run_once() + + self.assertTrue(any("Path traversal blocked" in msg for msg in log_ctx.output)) + mock_ssh.shell.assert_not_called() + + @patch("controller.delete.delete_process.Sshcp") + def test_tilde_path_uses_double_escape(self, mock_sshcp_cls): + """Paths starting with ~ use double-quote escaping.""" + mock_ssh = mock_sshcp_cls.return_value + mock_ssh.shell.return_value = b"" + + proc = DeleteRemoteProcess( + remote_address="host", + remote_username="user", + remote_password="pass", + remote_port=22, + remote_path="~/downloads", + file_name="myfile.txt", + ) + proc.run_once() + + cmd = mock_ssh.shell.call_args[0][0] + # Tilde paths use double-quote escaping (escape_remote_path_double) + self.assertIn('"', cmd) diff --git a/src/python/tests/unittests/test_controller/test_pair_context.py b/src/python/tests/unittests/test_controller/test_pair_context.py new file mode 100644 index 00000000..a418707f --- /dev/null +++ b/src/python/tests/unittests/test_controller/test_pair_context.py @@ -0,0 +1,272 @@ +# Copyright 2017, Inderpreet Singh, All rights reserved. + +import unittest +from unittest.mock import MagicMock + +from common import Args, Config, Constants, Context, Status +from controller.pair_context import ( + ControllerError, + PairContext, + configure_lftp, + validate_config, +) + + +class TestPairContextInit(unittest.TestCase): + """PairContext initializes with empty tracking state.""" + + def test_tracking_state_initialized_empty(self): + pc = PairContext( + pair_id="test-id", + name="test-pair", + remote_path="/remote", + local_path="/local", + effective_local_path="/local", + lftp=MagicMock(), + active_scanner=MagicMock(), + local_scanner=MagicMock(), + remote_scanner=MagicMock(), + active_scan_process=MagicMock(), + local_scan_process=MagicMock(), + remote_scan_process=MagicMock(), + model_builder=MagicMock(), + ) + self.assertEqual(pc.active_downloading_file_names, []) + self.assertEqual(pc.active_extracting_file_names, []) + self.assertEqual(pc.prev_downloading_file_names, set()) + self.assertEqual(pc.pending_completion, set()) + self.assertFalse(pc.remote_scan_received) + self.assertFalse(pc.local_scan_received) + self.assertIsNone(pc.latest_remote_scan) + self.assertIsNone(pc.latest_local_scan) + + def test_stores_constructor_args(self): + mock_lftp = MagicMock() + pc = PairContext( + pair_id="id-1", + name="my-pair", + remote_path="/r", + local_path="/l", + effective_local_path="/eff", + lftp=mock_lftp, + active_scanner=MagicMock(), + local_scanner=MagicMock(), + remote_scanner=MagicMock(), + active_scan_process=MagicMock(), + local_scan_process=MagicMock(), + remote_scan_process=MagicMock(), + model_builder=MagicMock(), + ) + self.assertEqual(pc.pair_id, "id-1") + self.assertEqual(pc.name, "my-pair") + self.assertEqual(pc.remote_path, "/r") + self.assertEqual(pc.local_path, "/l") + self.assertEqual(pc.effective_local_path, "/eff") + self.assertIs(pc.lftp, mock_lftp) + + +def _make_valid_context() -> Context: + """Create a Context with all required fields populated.""" + config = Config() + # Lftp required + config.lftp.remote_address = "host" + config.lftp.remote_username = "user" + config.lftp.remote_port = 22 + config.lftp.remote_path_to_scan_script = "/scan" + config.lftp.use_ssh_key = True + config.lftp.use_temp_file = True + config.lftp.num_max_parallel_downloads = 2 + config.lftp.num_max_parallel_files_per_download = 3 + config.lftp.num_max_connections_per_root_file = 4 + config.lftp.num_max_connections_per_dir_file = 5 + config.lftp.num_max_total_connections = 10 + # Controller required + config.controller.interval_ms_remote_scan = 5000 + config.controller.interval_ms_local_scan = 3000 + config.controller.interval_ms_downloading_scan = 1000 + config.controller.use_local_path_as_extract_path = True + # General + config.general.verbose = True + # AutoQueue + config.autoqueue.auto_delete_remote = False + + args = Args() + args.local_path_to_scanfs = "/scanfs" + + import logging + + logger = logging.getLogger("test") + web_logger = logging.getLogger("test.web") + status = Status() + return Context(logger=logger, web_access_logger=web_logger, config=config, args=args, status=status) + + +class TestValidateConfig(unittest.TestCase): + """Tests for validate_config.""" + + def test_fully_populated_config_passes(self): + ctx = _make_valid_context() + # Should not raise + validate_config(ctx) + + def test_missing_lftp_field_raises(self): + ctx = _make_valid_context() + # Bypass property checker by setting internal attribute directly + setattr(ctx.config.lftp, "__remote_address", None) + with self.assertRaises(ControllerError) as cm: + validate_config(ctx) + self.assertIn("Lftp.remote_address", str(cm.exception)) + + def test_missing_multiple_fields_lists_all(self): + ctx = _make_valid_context() + setattr(ctx.config.lftp, "__remote_address", None) + setattr(ctx.config.lftp, "__remote_port", None) + setattr(ctx.config.controller, "__interval_ms_remote_scan", None) + with self.assertRaises(ControllerError) as cm: + validate_config(ctx) + msg = str(cm.exception) + self.assertIn("Lftp.remote_address", msg) + self.assertIn("Lftp.remote_port", msg) + self.assertIn("Controller.interval_ms_remote_scan", msg) + + def test_extract_path_required_when_not_using_local(self): + ctx = _make_valid_context() + ctx.config.controller.use_local_path_as_extract_path = False + ctx.config.controller.extract_path = None + with self.assertRaises(ControllerError) as cm: + validate_config(ctx) + self.assertIn("Controller.extract_path", str(cm.exception)) + + def test_extract_path_not_required_when_using_local(self): + ctx = _make_valid_context() + ctx.config.controller.use_local_path_as_extract_path = True + ctx.config.controller.extract_path = None + # Should not raise + validate_config(ctx) + + def test_missing_scanfs_arg_raises(self): + ctx = _make_valid_context() + ctx.args.local_path_to_scanfs = None + with self.assertRaises(ControllerError) as cm: + validate_config(ctx) + self.assertIn("Args.local_path_to_scanfs", str(cm.exception)) + + def test_missing_verbose_raises(self): + ctx = _make_valid_context() + ctx.config.general.verbose = None + with self.assertRaises(ControllerError) as cm: + validate_config(ctx) + self.assertIn("General.verbose", str(cm.exception)) + + def test_missing_auto_delete_remote_raises(self): + ctx = _make_valid_context() + ctx.config.autoqueue.auto_delete_remote = None + with self.assertRaises(ControllerError) as cm: + validate_config(ctx) + self.assertIn("AutoQueue.auto_delete_remote", str(cm.exception)) + + +class TestConfigureLftp(unittest.TestCase): + """Tests for configure_lftp.""" + + def test_mandatory_settings_applied(self): + lftp = MagicMock() + config = Config() + config.lftp.num_max_parallel_downloads = 2 + config.lftp.num_max_parallel_files_per_download = 3 + config.lftp.num_max_connections_per_root_file = 4 + config.lftp.num_max_connections_per_dir_file = 5 + config.lftp.num_max_total_connections = 10 + config.lftp.use_temp_file = True + + configure_lftp(lftp, config) + + self.assertEqual(lftp.num_parallel_jobs, 2) + self.assertEqual(lftp.num_parallel_files, 3) + self.assertEqual(lftp.num_connections_per_root_file, 4) + self.assertEqual(lftp.num_connections_per_dir_file, 5) + self.assertEqual(lftp.num_max_total_connections, 10) + self.assertTrue(lftp.use_temp_file) + self.assertEqual(lftp.temp_file_name, "*" + Constants.LFTP_TEMP_FILE_SUFFIX) + + def test_optional_settings_applied_when_truthy(self): + lftp = MagicMock() + config = Config() + config.lftp.num_max_parallel_downloads = 1 + config.lftp.num_max_parallel_files_per_download = 1 + config.lftp.num_max_connections_per_root_file = 1 + config.lftp.num_max_connections_per_dir_file = 1 + config.lftp.num_max_total_connections = 1 + config.lftp.use_temp_file = True + config.lftp.net_limit_rate = "1M" + config.lftp.net_socket_buffer = "65536" + config.lftp.pget_min_chunk_size = "100k" + + configure_lftp(lftp, config) + + self.assertEqual(lftp.rate_limit, "1M") + self.assertEqual(lftp.net_socket_buffer, "65536") + self.assertEqual(lftp.min_chunk_size, "100k") + + def test_optional_settings_skipped_when_falsy(self): + """Optional LFTP settings are not applied when their config values are falsy.""" + + class TrackingMock: + """Mock that tracks which attributes were set.""" + + def __init__(self): + self._set_attrs: list[str] = [] + + def __setattr__(self, name, value): + if not name.startswith("_"): + self._set_attrs.append(name) + super().__setattr__(name, value) + + lftp = TrackingMock() + config = Config() + config.lftp.num_max_parallel_downloads = 1 + config.lftp.num_max_parallel_files_per_download = 1 + config.lftp.num_max_connections_per_root_file = 1 + config.lftp.num_max_connections_per_dir_file = 1 + config.lftp.num_max_total_connections = 1 + config.lftp.use_temp_file = True + config.lftp.net_limit_rate = "" + # net_socket_buffer and pget_min_chunk_size are None by default + + configure_lftp(lftp, config) + + self.assertNotIn("rate_limit", lftp._set_attrs) + self.assertNotIn("net_socket_buffer", lftp._set_attrs) + self.assertNotIn("min_chunk_size", lftp._set_attrs) + + def test_xfer_verify_enabled(self): + lftp = MagicMock() + config = Config() + config.lftp.num_max_parallel_downloads = 1 + config.lftp.num_max_parallel_files_per_download = 1 + config.lftp.num_max_connections_per_root_file = 1 + config.lftp.num_max_connections_per_dir_file = 1 + config.lftp.num_max_total_connections = 1 + config.lftp.use_temp_file = True + config.validate.xfer_verify = True + config.validate.algorithm = "sha256" + + configure_lftp(lftp, config) + + self.assertTrue(lftp.xfer_verify) + self.assertEqual(lftp.xfer_verify_command, "sha256sum") + + def test_xfer_verify_disabled(self): + lftp = MagicMock() + config = Config() + config.lftp.num_max_parallel_downloads = 1 + config.lftp.num_max_parallel_files_per_download = 1 + config.lftp.num_max_connections_per_root_file = 1 + config.lftp.num_max_connections_per_dir_file = 1 + config.lftp.num_max_total_connections = 1 + config.lftp.use_temp_file = True + config.validate.xfer_verify = False + + configure_lftp(lftp, config) + + self.assertFalse(lftp.xfer_verify) diff --git a/src/python/tests/unittests/test_controller/test_scan/test_active_scanner.py b/src/python/tests/unittests/test_controller/test_scan/test_active_scanner.py new file mode 100644 index 00000000..aed6bc6a --- /dev/null +++ b/src/python/tests/unittests/test_controller/test_scan/test_active_scanner.py @@ -0,0 +1,126 @@ +# Copyright 2017, Inderpreet Singh, All rights reserved. + +import logging +import sys +import time +import unittest +from unittest.mock import patch + +from controller.scan.active_scanner import ActiveScanner +from system import SystemFile, SystemScannerError + + +class TestActiveScanner(unittest.TestCase): + """Tests for ActiveScanner.""" + + def setUp(self): + logger = logging.getLogger("test_active_scanner") + handler = logging.StreamHandler(sys.stdout) + logger.addHandler(handler) + self.addCleanup(logger.removeHandler, handler) + logger.setLevel(logging.DEBUG) + + @patch("controller.scan.active_scanner.SystemScanner") + def test_empty_active_files_returns_empty(self, mock_scanner_cls): + """With no active files set, scan returns empty.""" + scanner = ActiveScanner("/local") + self.addCleanup(scanner.close) + result = scanner.scan() + self.assertEqual(result, []) + + @patch("controller.scan.active_scanner.SystemScanner") + def test_scan_returns_files_after_set_active(self, mock_scanner_cls): + """After set_active_files, scan returns SystemFile objects for those files.""" + mock_scanner = mock_scanner_cls.return_value + file_a = SystemFile("fileA", 100, False) + file_b = SystemFile("fileB", 200, True) + mock_scanner.scan_single.side_effect = [file_a, file_b] + + scanner = ActiveScanner("/local") + self.addCleanup(scanner.close) + scanner.set_active_files(["fileA", "fileB"]) + # multiprocessing.Queue.put() uses a background thread; brief pause + # ensures the data is available for the non-blocking get() in scan() + time.sleep(0.05) + result = scanner.scan() + + self.assertEqual(len(result), 2) + self.assertEqual(result[0].name, "fileA") + self.assertEqual(result[1].name, "fileB") + + @patch("controller.scan.active_scanner.SystemScanner") + def test_latest_list_wins_when_multiple_set_before_scan(self, mock_scanner_cls): + """Multiple set_active_files before scan: latest list wins (queue draining).""" + mock_scanner = mock_scanner_cls.return_value + file_c = SystemFile("fileC", 300, False) + mock_scanner.scan_single.return_value = file_c + + scanner = ActiveScanner("/local") + self.addCleanup(scanner.close) + scanner.set_active_files(["fileA", "fileB"]) + scanner.set_active_files(["fileC"]) + time.sleep(0.05) + result = scanner.scan() + + # Only the latest list should be used + self.assertEqual(len(result), 1) + self.assertEqual(result[0].name, "fileC") + + @patch("controller.scan.active_scanner.SystemScanner") + def test_missing_file_logged_at_debug(self, mock_scanner_cls): + """Missing file during download logged at DEBUG, not ERROR.""" + mock_scanner = mock_scanner_cls.return_value + mock_scanner.scan_single.side_effect = SystemScannerError("file does not exist: /local/missing") + + scanner = ActiveScanner("/local") + self.addCleanup(scanner.close) + scanner.set_active_files(["missing"]) + time.sleep(0.05) + + with self.assertLogs("ActiveScanner", level="DEBUG") as log_ctx: + result = scanner.scan() + + self.assertEqual(result, []) + # Enforce the DEBUG level explicitly — assertLogs(level="DEBUG") only + # captures records >= DEBUG, so a higher-level log would also satisfy + # a plain message-text check. log_ctx.output entries are formatted + # "LEVEL:logger:message", so the DEBUG: prefix pins the level. + self.assertTrue(any(o.startswith("DEBUG:ActiveScanner:") and "does not exist" in o for o in log_ctx.output)) + + @patch("controller.scan.active_scanner.SystemScanner") + def test_unexpected_error_logged_at_warning(self, mock_scanner_cls): + """Unexpected SystemScannerError logged at WARNING.""" + mock_scanner = mock_scanner_cls.return_value + mock_scanner.scan_single.side_effect = SystemScannerError("permission denied") + + scanner = ActiveScanner("/local") + self.addCleanup(scanner.close) + scanner.set_active_files(["restricted"]) + time.sleep(0.05) + + with self.assertLogs("ActiveScanner", level="WARNING") as log_ctx: + result = scanner.scan() + + self.assertEqual(result, []) + # Pin the level explicitly via the "WARNING:ActiveScanner:" prefix — + # mirrors the DEBUG-level check in test_missing_file_logged_at_debug. + self.assertTrue( + any(o.startswith("WARNING:ActiveScanner:") and "Unexpected scan error" in o for o in log_ctx.output) + ) + + @patch("controller.scan.active_scanner.SystemScanner") + def test_set_base_logger(self, mock_scanner_cls): + """set_base_logger creates a child logger.""" + scanner = ActiveScanner("/local") + self.addCleanup(scanner.close) + parent_logger = logging.getLogger("parent") + scanner.set_base_logger(parent_logger) + self.assertEqual(scanner.logger.name, "parent.ActiveScanner") + + @patch("controller.scan.active_scanner.SystemScanner") + def test_lftp_temp_suffix_forwarded(self, mock_scanner_cls): + """lftp_temp_suffix is forwarded to SystemScanner.""" + mock_scanner = mock_scanner_cls.return_value + scanner = ActiveScanner("/local", lftp_temp_suffix=".partial") + self.addCleanup(scanner.close) + mock_scanner.set_lftp_temp_suffix.assert_called_once_with(".partial") diff --git a/src/python/tests/unittests/test_controller/test_scan/test_local_scanner.py b/src/python/tests/unittests/test_controller/test_scan/test_local_scanner.py new file mode 100644 index 00000000..eb0ac2ce --- /dev/null +++ b/src/python/tests/unittests/test_controller/test_scan/test_local_scanner.py @@ -0,0 +1,67 @@ +# Copyright 2017, Inderpreet Singh, All rights reserved. + +import logging +import unittest +from unittest.mock import patch + +from controller.scan.local_scanner import LocalScanner +from controller.scan.scanner_process import ScannerError +from system import SystemFile, SystemScannerError + + +class TestLocalScanner(unittest.TestCase): + """Tests for LocalScanner.""" + + @patch("controller.scan.local_scanner.os.path.isdir", return_value=False) + @patch("controller.scan.local_scanner.SystemScanner") + def test_missing_path_returns_empty_with_warning(self, mock_scanner_cls, mock_isdir): + """Missing scan path returns empty list + warning log.""" + scanner = LocalScanner("/nonexistent", use_temp_file=False) + + with self.assertLogs("LocalScanner", level="WARNING") as log_ctx: + result = scanner.scan() + + self.assertEqual(result, []) + self.assertTrue(any("does not exist" in msg for msg in log_ctx.output)) + + @patch("controller.scan.local_scanner.os.path.isdir", return_value=True) + @patch("controller.scan.local_scanner.SystemScanner") + def test_successful_scan_returns_results(self, mock_scanner_cls, mock_isdir): + """Successful scan returns SystemScanner results.""" + mock_scanner = mock_scanner_cls.return_value + files = [SystemFile("a", 10, False), SystemFile("b", 20, True)] + mock_scanner.scan.return_value = files + + scanner = LocalScanner("/exists", use_temp_file=False) + result = scanner.scan() + + self.assertEqual(len(result), 2) + self.assertEqual(result[0].name, "a") + self.assertEqual(result[1].name, "b") + + @patch("controller.scan.local_scanner.os.path.isdir", return_value=True) + @patch("controller.scan.local_scanner.SystemScanner") + def test_scanner_error_raises_localized(self, mock_scanner_cls, mock_isdir): + """SystemScannerError raises ScannerError with localized message.""" + mock_scanner = mock_scanner_cls.return_value + mock_scanner.scan.side_effect = SystemScannerError("disk failure") + + scanner = LocalScanner("/exists", use_temp_file=False) + + with self.assertRaises(ScannerError): + scanner.scan() + + @patch("controller.scan.local_scanner.SystemScanner") + def test_set_base_logger(self, mock_scanner_cls): + """set_base_logger creates a child logger.""" + scanner = LocalScanner("/local", use_temp_file=False) + parent_logger = logging.getLogger("parent") + scanner.set_base_logger(parent_logger) + self.assertEqual(scanner.logger.name, "parent.LocalScanner") + + @patch("controller.scan.local_scanner.SystemScanner") + def test_temp_file_suffix_set(self, mock_scanner_cls): + """When use_temp_file is True, lftp temp suffix is set on SystemScanner.""" + mock_scanner = mock_scanner_cls.return_value + LocalScanner("/local", use_temp_file=True) + mock_scanner.set_lftp_temp_suffix.assert_called_once() diff --git a/src/python/tests/unittests/test_lftp/test_lftp.py b/src/python/tests/unittests/test_lftp/test_lftp.py index 744aab79..c5c12d3a 100644 --- a/src/python/tests/unittests/test_lftp/test_lftp.py +++ b/src/python/tests/unittests/test_lftp/test_lftp.py @@ -114,6 +114,7 @@ def setUp(self): formatter = logging.Formatter("%(asctime)s - %(levelname)s - %(name)s - %(message)s") handler.setFormatter(formatter) logger.addHandler(handler) + self.addCleanup(logger.removeHandler, handler) def tearDown(self): self.lftp.raise_pending_error() diff --git a/src/python/tests/unittests/test_ssh/test_sshcp.py b/src/python/tests/unittests/test_ssh/test_sshcp.py index 747c8ab5..2646fa83 100644 --- a/src/python/tests/unittests/test_ssh/test_sshcp.py +++ b/src/python/tests/unittests/test_ssh/test_sshcp.py @@ -45,6 +45,7 @@ def setUp(self): logger = logging.getLogger() handler = logging.StreamHandler(sys.stdout) logger.addHandler(handler) + self.addCleanup(logger.removeHandler, handler) logger.setLevel(logging.DEBUG) formatter = logging.Formatter("%(asctime)s - %(levelname)s - %(name)s - %(message)s") handler.setFormatter(formatter) diff --git a/src/python/tests/unittests/test_web/test_security.py b/src/python/tests/unittests/test_web/test_security.py new file mode 100644 index 00000000..430324f6 --- /dev/null +++ b/src/python/tests/unittests/test_web/test_security.py @@ -0,0 +1,526 @@ +# Copyright 2017, Inderpreet Singh, All rights reserved. + +import unittest + +import bottle +from webtest import TestApp + +from web.security import ( + _origin_tuple, + _RateLimiter, + install_api_key_auth, + install_csrf_protection, + install_rate_limiting, + install_security_headers, +) + + +def _make_app_with_route(): + """Create a minimal Bottle app with a single POST/GET route for testing.""" + app = bottle.Bottle() + + @app.route("/test", method=["GET", "POST", "PUT", "DELETE", "PATCH", "HEAD", "OPTIONS"]) + def test_route(): + return "ok" + + @app.route("/server/stream", method="GET") + def stream_route(): + return "stream" + + @app.route("/server/config/get", method=["GET", "POST"]) + def config_get_route(): + return "config" + + @app.route("/server/protected", method=["GET", "POST"]) + def protected_route(): + return "protected" + + return app + + +# --------------------------------------------------------------------------- +# CSRF Protection Tests +# --------------------------------------------------------------------------- + + +class TestCsrfSafeMethodsExempt(unittest.TestCase): + """Safe methods (GET, HEAD, OPTIONS) should pass without Origin check.""" + + def setUp(self): + self.app = _make_app_with_route() + install_csrf_protection(self.app) + self.test_app = TestApp(self.app) + + def test_get_without_origin_passes(self): + resp = self.test_app.get("/test") + self.assertEqual(resp.status_int, 200) + + def test_head_without_origin_passes(self): + resp = self.test_app.head("/test") + self.assertEqual(resp.status_int, 200) + + def test_options_without_origin_passes(self): + resp = self.test_app.options("/test") + self.assertEqual(resp.status_int, 200) + + +class TestCsrfLoopbackExemption(unittest.TestCase): + """POST from loopback without proxy headers is exempt.""" + + def setUp(self): + self.app = _make_app_with_route() + install_csrf_protection(self.app) + self.test_app = TestApp(self.app) + + def test_post_from_localhost_without_proxy_headers_passes(self): + """Loopback POST without X-Forwarded-For is exempt.""" + resp = self.test_app.post( + "/test", + extra_environ={"REMOTE_ADDR": "127.0.0.1"}, + ) + self.assertEqual(resp.status_int, 200) + + def test_post_from_ipv6_loopback_without_proxy_headers_passes(self): + """IPv6 loopback POST is also exempt.""" + resp = self.test_app.post( + "/test", + extra_environ={"REMOTE_ADDR": "::1"}, + ) + self.assertEqual(resp.status_int, 200) + + def test_post_from_localhost_string_without_proxy_headers_passes(self): + """'localhost' string also counts as loopback.""" + resp = self.test_app.post( + "/test", + extra_environ={"REMOTE_ADDR": "localhost"}, + ) + self.assertEqual(resp.status_int, 200) + + def test_post_from_loopback_with_x_forwarded_for_is_checked(self): + """Proxied traffic through loopback IS checked for CSRF.""" + resp = self.test_app.post( + "/test", + headers={"X-Forwarded-For": "10.0.0.1"}, + extra_environ={"REMOTE_ADDR": "127.0.0.1"}, + expect_errors=True, + ) + self.assertEqual(resp.status_int, 403) + + def test_post_from_loopback_with_forwarded_header_is_checked(self): + """Forwarded header also triggers CSRF check on loopback.""" + resp = self.test_app.post( + "/test", + headers={"Forwarded": "for=10.0.0.1"}, + extra_environ={"REMOTE_ADDR": "127.0.0.1"}, + expect_errors=True, + ) + self.assertEqual(resp.status_int, 403) + + +class TestCsrfOriginMatching(unittest.TestCase): + """POST with matching/mismatched Origin.""" + + def setUp(self): + self.app = _make_app_with_route() + install_csrf_protection(self.app) + self.test_app = TestApp(self.app) + + def test_matching_origin_passes(self): + resp = self.test_app.post( + "/test", + headers={ + "Origin": "http://example.com", + "Host": "example.com", + }, + extra_environ={"REMOTE_ADDR": "10.0.0.1"}, + ) + self.assertEqual(resp.status_int, 200) + + def test_mismatched_origin_returns_403(self): + resp = self.test_app.post( + "/test", + headers={ + "Origin": "http://evil.com", + "Host": "example.com", + }, + extra_environ={"REMOTE_ADDR": "10.0.0.1"}, + expect_errors=True, + ) + self.assertEqual(resp.status_int, 403) + + def test_referer_fallback_when_origin_absent(self): + """When Origin is missing, Referer is used for CSRF check.""" + resp = self.test_app.post( + "/test", + headers={ + "Referer": "http://example.com/page", + "Host": "example.com", + }, + extra_environ={"REMOTE_ADDR": "10.0.0.1"}, + ) + self.assertEqual(resp.status_int, 200) + + def test_mismatched_referer_returns_403(self): + resp = self.test_app.post( + "/test", + headers={ + "Referer": "http://evil.com/page", + "Host": "example.com", + }, + extra_environ={"REMOTE_ADDR": "10.0.0.1"}, + expect_errors=True, + ) + self.assertEqual(resp.status_int, 403) + + def test_neither_origin_nor_referer_returns_403(self): + resp = self.test_app.post( + "/test", + headers={"Host": "example.com"}, + extra_environ={"REMOTE_ADDR": "10.0.0.1"}, + expect_errors=True, + ) + self.assertEqual(resp.status_int, 403) + self.assertIn("missing Origin/Referer", resp.text) + + +class TestCsrfPortNormalization(unittest.TestCase): + """Port normalization: default ports (80/443) are treated as equivalent.""" + + def setUp(self): + self.app = _make_app_with_route() + install_csrf_protection(self.app) + self.test_app = TestApp(self.app) + + def test_explicit_port_80_matches_implicit(self): + """http://example.com:80 should match http://example.com.""" + resp = self.test_app.post( + "/test", + headers={ + "Origin": "http://example.com:80", + "Host": "example.com", + }, + extra_environ={"REMOTE_ADDR": "10.0.0.1"}, + ) + self.assertEqual(resp.status_int, 200) + + def test_explicit_port_443_matches_implicit_https(self): + """https://example.com:443 should match https://example.com.""" + resp = self.test_app.post( + "/test", + headers={ + "Origin": "https://example.com:443", + "Host": "example.com", + }, + extra_environ={"REMOTE_ADDR": "10.0.0.1"}, + ) + self.assertEqual(resp.status_int, 200) + + +class TestOriginTupleHelper(unittest.TestCase): + """Tests for the _origin_tuple parsing helper.""" + + def test_normal_url(self): + result = _origin_tuple("http://example.com") + self.assertEqual(result, ("http", "example.com", 80)) + + def test_url_with_port(self): + result = _origin_tuple("http://example.com:8080") + self.assertEqual(result, ("http", "example.com", 8080)) + + def test_https_default_port(self): + result = _origin_tuple("https://secure.example.com") + self.assertEqual(result, ("https", "secure.example.com", 443)) + + def test_none_input(self): + result = _origin_tuple(None) + self.assertIsNone(result) + + def test_empty_string(self): + result = _origin_tuple("") + self.assertIsNone(result) + + def test_malformed_url_returns_none(self): + result = _origin_tuple("not-a-url") + self.assertIsNone(result) + + def test_scheme_only_returns_none(self): + result = _origin_tuple("http://") + self.assertIsNone(result) + + +# --------------------------------------------------------------------------- +# Rate Limiting Tests +# --------------------------------------------------------------------------- + + +class TestRateLimiterUnit(unittest.TestCase): + """Direct tests on the _RateLimiter class.""" + + def test_below_limit_requests_allowed(self): + limiter = _RateLimiter(max_requests=5, window_seconds=60) + for _ in range(5): + self.assertTrue(limiter.is_allowed("10.0.0.1")) + + def test_exceeding_limit_rejected(self): + limiter = _RateLimiter(max_requests=3, window_seconds=60) + for _ in range(3): + self.assertTrue(limiter.is_allowed("10.0.0.1")) + self.assertFalse(limiter.is_allowed("10.0.0.1")) + + def test_per_ip_independence(self): + limiter = _RateLimiter(max_requests=2, window_seconds=60) + self.assertTrue(limiter.is_allowed("10.0.0.1")) + self.assertTrue(limiter.is_allowed("10.0.0.1")) + self.assertFalse(limiter.is_allowed("10.0.0.1")) + # Different IP should still be allowed + self.assertTrue(limiter.is_allowed("10.0.0.2")) + self.assertTrue(limiter.is_allowed("10.0.0.2")) + + def test_retry_after_returns_positive_value(self): + limiter = _RateLimiter(max_requests=1, window_seconds=60) + limiter.is_allowed("10.0.0.1") + limiter.is_allowed("10.0.0.1") # rejected + retry = limiter.retry_after("10.0.0.1") + self.assertGreater(retry, 0) + + def test_retry_after_returns_zero_for_unknown_ip(self): + limiter = _RateLimiter(max_requests=5, window_seconds=60) + # No requests yet for this IP + retry = limiter.retry_after("10.0.0.99") + self.assertEqual(retry, 0) + + def test_stale_entry_sweep(self): + """Stale entries should be swept after sweep_interval.""" + limiter = _RateLimiter(max_requests=2, window_seconds=1, sweep_interval=0) + limiter.is_allowed("10.0.0.1") + limiter.is_allowed("10.0.0.1") + # Manually expire all timestamps + with limiter._lock: + limiter._hits["10.0.0.1"] = [0.0] # ancient timestamp + limiter._last_sweep = 0.0 # force sweep on next call + # Next call triggers sweep and allows the request + self.assertTrue(limiter.is_allowed("10.0.0.1")) + + +class TestRateLimitingMiddleware(unittest.TestCase): + """Tests for the rate limiting Bottle middleware.""" + + def test_sse_stream_endpoint_exempt(self): + app = _make_app_with_route() + install_rate_limiting(app) + test_app = TestApp(app) + # SSE stream should never be rate limited + for _ in range(200): + resp = test_app.get("/server/stream") + self.assertEqual(resp.status_int, 200) + + def test_disable_flag_skips_limiting(self): + app = _make_app_with_route() + install_rate_limiting(app, disable=True) + test_app = TestApp(app) + # With disable=True, no rate limiting should apply at all + for _ in range(200): + resp = test_app.get("/test") + self.assertEqual(resp.status_int, 200) + + def test_rate_limit_returns_429_with_retry_after(self): + app = _make_app_with_route() + limiter = _RateLimiter(max_requests=2, window_seconds=60) + + @app.hook("before_request") + def _rate_limit(): + ip = bottle.request.environ.get("REMOTE_ADDR", "127.0.0.1") + if not limiter.is_allowed(ip): + retry = limiter.retry_after(ip) + resp = bottle.HTTPError(429, "Rate limit exceeded") + resp.headers["Retry-After"] = str(retry) + raise resp + + test_app = TestApp(app) + test_app.get("/test", extra_environ={"REMOTE_ADDR": "10.0.0.1"}) + test_app.get("/test", extra_environ={"REMOTE_ADDR": "10.0.0.1"}) + resp = test_app.get( + "/test", + extra_environ={"REMOTE_ADDR": "10.0.0.1"}, + expect_errors=True, + ) + self.assertEqual(resp.status_int, 429) + self.assertIn("Retry-After", resp.headers) + + def test_x_forwarded_for_used_when_trusted(self): + app = _make_app_with_route() + limiter = _RateLimiter(max_requests=2, window_seconds=60) + + @app.hook("before_request") + def _rate_limit(): + ip = None + forwarded = bottle.request.get_header("X-Forwarded-For") + if forwarded: + ip = forwarded.split(",")[0].strip() + if not ip: + ip = bottle.request.environ.get("REMOTE_ADDR", "127.0.0.1") + if not limiter.is_allowed(ip): + raise bottle.HTTPError(429, "Rate limit exceeded") + + test_app = TestApp(app) + + # Requests with X-Forwarded-For should use the forwarded IP + test_app.get( + "/test", + headers={"X-Forwarded-For": "192.168.1.100"}, + extra_environ={"REMOTE_ADDR": "127.0.0.1"}, + ) + test_app.get( + "/test", + headers={"X-Forwarded-For": "192.168.1.100"}, + extra_environ={"REMOTE_ADDR": "127.0.0.1"}, + ) + # Third request from same forwarded IP should be rejected + resp = test_app.get( + "/test", + headers={"X-Forwarded-For": "192.168.1.100"}, + extra_environ={"REMOTE_ADDR": "127.0.0.1"}, + expect_errors=True, + ) + self.assertEqual(resp.status_int, 429) + + # But a different forwarded IP should still be allowed + resp = test_app.get( + "/test", + headers={"X-Forwarded-For": "192.168.1.200"}, + extra_environ={"REMOTE_ADDR": "127.0.0.1"}, + ) + self.assertEqual(resp.status_int, 200) + + +# --------------------------------------------------------------------------- +# Security Headers Tests +# --------------------------------------------------------------------------- + + +class TestSecurityHeaders(unittest.TestCase): + """Every response should include security headers.""" + + def setUp(self): + self.app = _make_app_with_route() + install_security_headers(self.app) + self.test_app = TestApp(self.app) + + def test_nosniff_header_present(self): + resp = self.test_app.get("/test") + self.assertEqual(resp.headers["X-Content-Type-Options"], "nosniff") + + def test_x_frame_options_header_present(self): + resp = self.test_app.get("/test") + self.assertEqual(resp.headers["X-Frame-Options"], "DENY") + + def test_csp_header_present(self): + resp = self.test_app.get("/test") + csp = resp.headers["Content-Security-Policy"] + self.assertIn("default-src 'self'", csp) + self.assertIn("script-src 'self'", csp) + + def test_referrer_policy_header_present(self): + resp = self.test_app.get("/test") + self.assertEqual( + resp.headers["Referrer-Policy"], + "strict-origin-when-cross-origin", + ) + + def test_headers_on_post_response(self): + """Security headers should be added to POST responses too.""" + # Need CSRF to pass — use loopback + install_csrf_protection(self.app) + test_app = TestApp(self.app) + resp = test_app.post( + "/test", + extra_environ={"REMOTE_ADDR": "127.0.0.1"}, + ) + self.assertEqual(resp.headers["X-Content-Type-Options"], "nosniff") + self.assertEqual(resp.headers["X-Frame-Options"], "DENY") + + +# --------------------------------------------------------------------------- +# API Key Authentication Tests +# --------------------------------------------------------------------------- + + +class TestApiKeyAuthDisabled(unittest.TestCase): + """When API key is empty, auth is disabled.""" + + def setUp(self): + self.app = _make_app_with_route() + install_api_key_auth(self.app, get_api_key=lambda: "") + self.test_app = TestApp(self.app) + + def test_empty_key_disables_auth(self): + resp = self.test_app.get("/server/protected") + self.assertEqual(resp.status_int, 200) + + def test_empty_key_allows_any_server_route(self): + resp = self.test_app.get("/server/stream") + self.assertEqual(resp.status_int, 200) + + +class TestApiKeyAuthEnabled(unittest.TestCase): + """When API key is set, protected routes require it.""" + + def setUp(self): + self.api_key = "test-secret-key-12345" + self.app = _make_app_with_route() + install_api_key_auth(self.app, get_api_key=lambda: self.api_key) + self.test_app = TestApp(self.app) + + def test_valid_key_passes(self): + resp = self.test_app.get( + "/server/protected", + headers={"X-Api-Key": self.api_key}, + ) + self.assertEqual(resp.status_int, 200) + + def test_invalid_key_returns_401(self): + resp = self.test_app.get( + "/server/protected", + headers={"X-Api-Key": "wrong-key"}, + expect_errors=True, + ) + self.assertEqual(resp.status_int, 401) + self.assertIn("Invalid API key", resp.text) + + def test_missing_key_returns_401(self): + resp = self.test_app.get( + "/server/protected", + expect_errors=True, + ) + self.assertEqual(resp.status_int, 401) + self.assertIn("API key required", resp.text) + + def test_non_server_paths_unprotected(self): + """Paths not starting with /server/ don't require API key.""" + resp = self.test_app.get("/test") + self.assertEqual(resp.status_int, 200) + + def test_config_get_exempt(self): + """/server/config/get is exempt for frontend bootstrapping.""" + resp = self.test_app.get("/server/config/get") + self.assertEqual(resp.status_int, 200) + + def test_sse_accepts_query_param(self): + """SSE stream endpoint accepts api_key as query parameter.""" + resp = self.test_app.get(f"/server/stream?api_key={self.api_key}") + self.assertEqual(resp.status_int, 200) + + def test_sse_rejects_invalid_query_param(self): + resp = self.test_app.get( + "/server/stream?api_key=wrong", + expect_errors=True, + ) + self.assertEqual(resp.status_int, 401) + + def test_sse_header_also_works(self): + """SSE stream also accepts the key via X-Api-Key header.""" + resp = self.test_app.get( + "/server/stream", + headers={"X-Api-Key": self.api_key}, + ) + self.assertEqual(resp.status_int, 200) diff --git a/src/python/tests/unittests/test_web/test_web_app_job.py b/src/python/tests/unittests/test_web/test_web_app_job.py new file mode 100644 index 00000000..8a2137d5 --- /dev/null +++ b/src/python/tests/unittests/test_web/test_web_app_job.py @@ -0,0 +1,152 @@ +# Copyright 2017, Inderpreet Singh, All rights reserved. + +import logging +import unittest +from unittest.mock import MagicMock, patch + +from web.web_app_job import MyWSGIRefServer, WebAppJob, _RequestLoggingMiddleware + + +class _WebAppJobBase(unittest.TestCase): + """Shared context fixture for the lifecycle test classes below.""" + + def _make_context(self): + context = MagicMock() + # Don't attach a StreamHandler — assertLogs() in tests installs its + # own capture handler, and adding one here would leak across tests + # (loggers are singletons). + logger = logging.getLogger("test_web_app_job") + logger.setLevel(logging.DEBUG) + context.logger = logger + context.web_access_logger = logger + context.config.web.port = 8080 + context.args.debug = False + return context + + +class TestWebAppJobSetup(_WebAppJobBase): + """Tests for WebAppJob.setup() — server creation and thread start.""" + + @patch("web.web_app_job.Thread") + @patch("web.web_app_job.MyWSGIRefServer") + def test_setup_creates_server_and_starts_thread(self, mock_server_cls, mock_thread_cls): + """setup() creates server on configured port and starts thread.""" + context = self._make_context() + web_app = MagicMock() + + job = WebAppJob(context, web_app) + job.setup() + + mock_server_cls.assert_called_once_with(context.web_access_logger, host="0.0.0.0", port=8080) + mock_thread_cls.assert_called_once() + mock_thread_cls.return_value.start.assert_called_once() + + +class TestWebAppJobExecute(_WebAppJobBase): + """Tests for WebAppJob.execute() — invokes the web app's process loop.""" + + def test_execute_calls_process(self): + """execute() calls web_app.process().""" + context = self._make_context() + web_app = MagicMock() + + job = WebAppJob(context, web_app) + job.execute() + + web_app.process.assert_called_once() + + +class TestWebAppJobCleanup(_WebAppJobBase): + """Tests for WebAppJob.cleanup() — server stop and thread join.""" + + @patch("web.web_app_job.Thread") + @patch("web.web_app_job.MyWSGIRefServer") + def test_cleanup_stops_server_and_joins_thread(self, mock_server_cls, mock_thread_cls): + """cleanup() stops server and joins thread without hanging.""" + context = self._make_context() + web_app = MagicMock() + mock_thread = mock_thread_cls.return_value + + job = WebAppJob(context, web_app) + job.setup() + job.cleanup() + + web_app.stop.assert_called_once() + mock_server_cls.return_value.stop.assert_called_once() + mock_thread.join.assert_called_once() + + +class TestMyWSGIRefServer(unittest.TestCase): + """Tests for MyWSGIRefServer.""" + + def test_stop_on_never_initialized_server_logs_warning(self): + """Stop on never-initialized server logs warning, doesn't crash.""" + logger = logging.getLogger("test_wsgi_server") + server = MyWSGIRefServer(logger, host="0.0.0.0", port=8080) + + with self.assertLogs("test_wsgi_server", level="WARNING") as log_ctx: + server.stop() + + self.assertTrue(any("never initialized" in msg for msg in log_ctx.output)) + + def test_quiet_flag_is_true(self): + """Server has quiet=True to suppress stdout logging.""" + logger = logging.getLogger("test_wsgi_server") + server = MyWSGIRefServer(logger, host="0.0.0.0", port=8080) + self.assertTrue(server.quiet) + + +class TestRequestLoggingMiddleware(unittest.TestCase): + """Tests for _RequestLoggingMiddleware.""" + + def test_logs_method_path_status_duration(self): + """Middleware logs method, path, status, duration.""" + logger = logging.getLogger("test_request_logging") + + def mock_app(environ, start_response): + start_response("200 OK", []) + return [b"ok"] + + middleware = _RequestLoggingMiddleware(mock_app, logger) + + environ = { + "REQUEST_METHOD": "GET", + "PATH_INFO": "/test/path", + } + + def start_response(status, headers, *args): + pass + + with self.assertLogs("test_request_logging", level="DEBUG") as log_ctx: + result = list(middleware(environ, start_response)) + + self.assertEqual(result, [b"ok"]) + # Some log line should contain method, path, and status. Don't index + # log_ctx.output[0] — other loggers might emit lines first under load. + self.assertTrue(any("GET" in o and "/test/path" in o and "200" in o for o in log_ctx.output)) + + def test_logs_even_on_app_error(self): + """Duration is logged even when the app raises.""" + logger = logging.getLogger("test_request_logging_error") + + def failing_app(environ, start_response): + start_response("500 Internal Server Error", []) + raise RuntimeError("boom") + + middleware = _RequestLoggingMiddleware(failing_app, logger) + + environ = { + "REQUEST_METHOD": "POST", + "PATH_INFO": "/fail", + } + + def start_response(status, headers, *args): + pass + + with self.assertLogs("test_request_logging_error", level="DEBUG") as log_ctx: + with self.assertRaises(RuntimeError): + list(middleware(environ, start_response)) + + # Mirror the happy-path assertion: the error branch should still log + # the method, path, and status that start_response saw before the raise. + self.assertTrue(any("POST" in o and "/fail" in o and "500" in o for o in log_ctx.output)) diff --git a/src/python/web/handler/auto_queue.py b/src/python/web/handler/auto_queue.py index 478d6f3f..d9c105ee 100644 --- a/src/python/web/handler/auto_queue.py +++ b/src/python/web/handler/auto_queue.py @@ -14,8 +14,9 @@ class AutoQueueHandler(IHandler): _NOSNIFF_HEADERS = {"X-Content-Type-Options": "nosniff"} - def __init__(self, auto_queue_persist: AutoQueuePersist): + def __init__(self, auto_queue_persist: AutoQueuePersist, persist_path: str): self.__auto_queue_persist = auto_queue_persist + self.__persist_path = persist_path @overrides(IHandler) def add_routes(self, web_app: WebApp): @@ -44,6 +45,7 @@ def __handle_add_autoqueue(self, pattern: str): ) try: self.__auto_queue_persist.add_pattern(aqp) + self.__auto_queue_persist.to_file(self.__persist_path) return HTTPResponse( body=f"Added auto-queue pattern '{pattern}'.", content_type="text/plain", @@ -71,6 +73,7 @@ def __handle_remove_autoqueue(self, pattern: str): headers=self._NOSNIFF_HEADERS, ) self.__auto_queue_persist.remove_pattern(aqp) + self.__auto_queue_persist.to_file(self.__persist_path) return HTTPResponse( body=f"Removed auto-queue pattern '{pattern}'.", content_type="text/plain", diff --git a/src/python/web/handler/config.py b/src/python/web/handler/config.py index 09bc6523..52171414 100644 --- a/src/python/web/handler/config.py +++ b/src/python/web/handler/config.py @@ -1,5 +1,6 @@ # Copyright 2017, Inderpreet Singh, All rights reserved. +from collections.abc import Callable from urllib.parse import unquote from bottle import HTTPResponse @@ -9,10 +10,41 @@ from ..serialize import SerializeConfig from ..web_app import IHandler, WebApp +# (section, key) pairs for settings that can be hot-reloaded into the +# running LFTP process without a full restart. +_LFTP_TUNING_KEYS: frozenset[tuple[str, str]] = frozenset( + { + ("lftp", "num_max_parallel_downloads"), + ("lftp", "num_max_parallel_files_per_download"), + ("lftp", "num_max_connections_per_root_file"), + ("lftp", "num_max_connections_per_dir_file"), + ("lftp", "num_max_total_connections"), + ("lftp", "use_temp_file"), + ("lftp", "net_limit_rate"), + ("lftp", "net_socket_buffer"), + ("lftp", "pget_min_chunk_size"), + ("lftp", "mirror_parallel_directories"), + ("lftp", "net_timeout"), + ("lftp", "net_max_retries"), + ("lftp", "net_reconnect_interval_base"), + ("lftp", "net_reconnect_interval_multiplier"), + ("general", "verbose"), + ("validate", "xfer_verify"), + ("validate", "algorithm"), + } +) + class ConfigHandler(IHandler): - def __init__(self, config: Config): + def __init__( + self, + config: Config, + config_path: str, + on_lftp_config_change: Callable[[], None] | None = None, + ): self.__config = config + self.__config_path = config_path + self.__on_lftp_config_change = on_lftp_config_change @overrides(IHandler) def add_routes(self, web_app: WebApp): @@ -42,6 +74,9 @@ def __handle_set_config(self, section: str, key: str, value: str): return HTTPResponse(body="Cannot set sensitive field to redacted value", status=400) try: inner_config.set_property(key, value) + self.__config.to_file(self.__config_path) + if (section, key) in _LFTP_TUNING_KEYS and self.__on_lftp_config_change: + self.__on_lftp_config_change() if Config.is_sensitive(section, key): return HTTPResponse(body=f"{section}.{key} updated") return HTTPResponse(body=f"{section}.{key} set to {value}") diff --git a/src/python/web/handler/integrations.py b/src/python/web/handler/integrations.py index 563ece36..c50bedef 100644 --- a/src/python/web/handler/integrations.py +++ b/src/python/web/handler/integrations.py @@ -17,9 +17,17 @@ class IntegrationsHandler(IHandler): """REST endpoints for managing *arr (Sonarr/Radarr) instances.""" - def __init__(self, integrations_config: IntegrationsConfig, path_pairs_config: PathPairsConfig): + def __init__( + self, + integrations_config: IntegrationsConfig, + path_pairs_config: PathPairsConfig, + integrations_path: str, + path_pairs_path: str, + ): self.__config = integrations_config self.__path_pairs_config = path_pairs_config + self.__integrations_path = integrations_path + self.__path_pairs_path = path_pairs_path self._logger = logging.getLogger(self.__class__.__name__) @overrides(IHandler) @@ -49,6 +57,7 @@ def __handle_create(self): self.__config.add_instance(instance) except ValueError as e: return HTTPResponse(body=str(e), status=409) + self.__config.to_file(self.__integrations_path) return HTTPResponse( body=json.dumps(self._redact(instance.to_dict())), status=201, @@ -90,6 +99,7 @@ def __handle_update(self, instance_id: str): if "not found" in msg: return HTTPResponse(body="Integration not found", status=404) return HTTPResponse(body=msg, status=400) + self.__config.to_file(self.__integrations_path) return HTTPResponse( body=json.dumps(self._redact(updated.to_dict())), headers={"Content-Type": "application/json"}, @@ -102,6 +112,8 @@ def __handle_delete(self, instance_id: str): return HTTPResponse(body="Integration not found", status=404) # Detach from any path pair that referenced it so we don't leave dangling pointers. self.__path_pairs_config.detach_arr_target(instance_id) + self.__config.to_file(self.__integrations_path) + self.__path_pairs_config.to_file(self.__path_pairs_path) return HTTPResponse(status=204) def __handle_test(self, instance_id: str): diff --git a/src/python/web/handler/path_pairs.py b/src/python/web/handler/path_pairs.py index c798acb7..55d6028d 100644 --- a/src/python/web/handler/path_pairs.py +++ b/src/python/web/handler/path_pairs.py @@ -11,9 +11,12 @@ class PathPairsHandler(IHandler): - def __init__(self, path_pairs_config: PathPairsConfig, integrations_config: IntegrationsConfig): + def __init__( + self, path_pairs_config: PathPairsConfig, integrations_config: IntegrationsConfig, path_pairs_path: str + ): self.__config = path_pairs_config self.__integrations_config = integrations_config + self.__path_pairs_path = path_pairs_path @overrides(IHandler) def add_routes(self, web_app: WebApp): @@ -111,6 +114,7 @@ def __handle_create(self): if "name" in str(e) and "already exists" in str(e): return HTTPResponse(body=str(e), status=409) raise + self.__config.to_file(self.__path_pairs_path) return HTTPResponse(body=json.dumps(pair.to_dict()), status=201, headers={"Content-Type": "application/json"}) def __handle_update(self, pair_id: str): @@ -150,6 +154,7 @@ def __handle_update(self, pair_id: str): if "name" in str(e) and "already exists" in str(e): return HTTPResponse(body=str(e), status=409) return HTTPResponse(body="Path pair not found", status=404) + self.__config.to_file(self.__path_pairs_path) return HTTPResponse(body=json.dumps(updated.to_dict()), headers={"Content-Type": "application/json"}) def __handle_delete(self, pair_id: str): @@ -157,4 +162,5 @@ def __handle_delete(self, pair_id: str): self.__config.remove_pair(pair_id) except ValueError: return HTTPResponse(body="Path pair not found", status=404) + self.__config.to_file(self.__path_pairs_path) return HTTPResponse(status=204) diff --git a/src/python/web/web_app_builder.py b/src/python/web/web_app_builder.py index 80e5c065..f767044a 100644 --- a/src/python/web/web_app_builder.py +++ b/src/python/web/web_app_builder.py @@ -30,14 +30,29 @@ def __init__(self, context: Context, controller: Controller, auto_queue_persist: self.__context = context self.__controller = controller + if context.config_path is None: + raise RuntimeError("Context.config_path must be set before building WebApp") + if context.path_pairs_path is None: + raise RuntimeError("Context.path_pairs_path must be set before building WebApp") + if context.integrations_path is None: + raise RuntimeError("Context.integrations_path must be set before building WebApp") + if context.auto_queue_persist_path is None: + raise RuntimeError("Context.auto_queue_persist_path must be set before building WebApp") + self.controller_handler = ControllerHandler(controller) self.server_handler = ServerHandler(context) - self.config_handler = ConfigHandler(context.config) - self.auto_queue_handler = AutoQueueHandler(auto_queue_persist) + self.config_handler = ConfigHandler( + context.config, context.config_path, on_lftp_config_change=controller.request_lftp_reconfigure + ) + self.auto_queue_handler = AutoQueueHandler(auto_queue_persist, context.auto_queue_persist_path) self.status_handler = StatusHandler(context.status) self.logs_handler = LogsHandler(logdir=context.args.logdir, service_name=Constants.SERVICE_NAME) - self.path_pairs_handler = PathPairsHandler(context.path_pairs_config, context.integrations_config) - self.integrations_handler = IntegrationsHandler(context.integrations_config, context.path_pairs_config) + self.path_pairs_handler = PathPairsHandler( + context.path_pairs_config, context.integrations_config, context.path_pairs_path + ) + self.integrations_handler = IntegrationsHandler( + context.integrations_config, context.path_pairs_config, context.integrations_path, context.path_pairs_path + ) self.notifications_handler = NotificationsHandler(context.config) def build(self) -> WebApp: diff --git a/website/package-lock.json b/website/package-lock.json index 0d47ec19..6f738623 100644 --- a/website/package-lock.json +++ b/website/package-lock.json @@ -8,8 +8,8 @@ "name": "seedsync-website", "version": "0.0.0", "dependencies": { - "@docusaurus/core": "3.10.0", - "@docusaurus/preset-classic": "3.10.0", + "@docusaurus/core": "3.10.1", + "@docusaurus/preset-classic": "3.10.1", "@mdx-js/react": "^3.0.0", "clsx": "^2.0.0", "prism-react-renderer": "^2.3.0", @@ -17,24 +17,24 @@ "react-dom": "^19.2.5" }, "devDependencies": { - "@docusaurus/faster": "3.10.0", - "@docusaurus/module-type-aliases": "3.10.0", - "@docusaurus/types": "3.10.0" + "@docusaurus/faster": "3.10.1", + "@docusaurus/module-type-aliases": "3.10.1", + "@docusaurus/types": "3.10.1" }, "engines": { "node": ">=20.0" } }, "node_modules/@algolia/abtesting": { - "version": "1.16.1", - "resolved": "https://registry.npmjs.org/@algolia/abtesting/-/abtesting-1.16.1.tgz", - "integrity": "sha512-Xxk4l00pYI+jE0PNw8y0MvsQWh5278WRtZQav8/BMMi3HKi2xmeuqe11WJ3y8/6nuBHdv39w76OpJb09TMfAVQ==", + "version": "1.18.0", + "resolved": "https://registry.npmjs.org/@algolia/abtesting/-/abtesting-1.18.0.tgz", + "integrity": "sha512-8siuLG+FIns1AjZ/g2SDVwHz9S+ObacDQISEJvS8XsNei1zl3FXqfqQrBpmrG7ACWCyesXHbicMJtvRbg00FEw==", "license": "MIT", "dependencies": { - "@algolia/client-common": "5.50.1", - "@algolia/requester-browser-xhr": "5.50.1", - "@algolia/requester-fetch": "5.50.1", - "@algolia/requester-node-http": "5.50.1" + "@algolia/client-common": "5.52.0", + "@algolia/requester-browser-xhr": "5.52.0", + "@algolia/requester-fetch": "5.52.0", + "@algolia/requester-node-http": "5.52.0" }, "engines": { "node": ">= 14.0.0" @@ -73,99 +73,99 @@ } }, "node_modules/@algolia/client-abtesting": { - "version": "5.50.1", - "resolved": "https://registry.npmjs.org/@algolia/client-abtesting/-/client-abtesting-5.50.1.tgz", - "integrity": "sha512-4peZlPXMwTOey9q1rQKMdCnwZb/E95/1e+7KujXpLLSh0FawJzg//U2NM+r4AiJy4+naT2MTBhj0K30yshnVTA==", + "version": "5.52.0", + "resolved": "https://registry.npmjs.org/@algolia/client-abtesting/-/client-abtesting-5.52.0.tgz", + "integrity": "sha512-wtwPgyPmO7b7sQPVgoK29c1VpfS08DnnJCmxX/oU1pV2DlMRJCzQcLN7JSloYpodyKHwM8+9wOzlAM0co3TDmA==", "license": "MIT", "dependencies": { - "@algolia/client-common": "5.50.1", - "@algolia/requester-browser-xhr": "5.50.1", - "@algolia/requester-fetch": "5.50.1", - "@algolia/requester-node-http": "5.50.1" + "@algolia/client-common": "5.52.0", + "@algolia/requester-browser-xhr": "5.52.0", + "@algolia/requester-fetch": "5.52.0", + "@algolia/requester-node-http": "5.52.0" }, "engines": { "node": ">= 14.0.0" } }, "node_modules/@algolia/client-analytics": { - "version": "5.50.1", - "resolved": "https://registry.npmjs.org/@algolia/client-analytics/-/client-analytics-5.50.1.tgz", - "integrity": "sha512-i+aWHHG8NZvGFHtPeMZkxL2Loc6Fm7iaRo15lYSMx8gFL+at9vgdWxhka7mD1fqxkrxXsQstUBCIsSY8FvkEOw==", + "version": "5.52.0", + "resolved": "https://registry.npmjs.org/@algolia/client-analytics/-/client-analytics-5.52.0.tgz", + "integrity": "sha512-9KY36bRl4AH7RjqSeDDOKnjsz4IxQFBEOB8/fWmEbdQe+Isbs5jGzVJu9NEPQ1Tgwxlf8Uf07Swj3jZyMNUZ2g==", "license": "MIT", "dependencies": { - "@algolia/client-common": "5.50.1", - "@algolia/requester-browser-xhr": "5.50.1", - "@algolia/requester-fetch": "5.50.1", - "@algolia/requester-node-http": "5.50.1" + "@algolia/client-common": "5.52.0", + "@algolia/requester-browser-xhr": "5.52.0", + "@algolia/requester-fetch": "5.52.0", + "@algolia/requester-node-http": "5.52.0" }, "engines": { "node": ">= 14.0.0" } }, "node_modules/@algolia/client-common": { - "version": "5.50.1", - "resolved": "https://registry.npmjs.org/@algolia/client-common/-/client-common-5.50.1.tgz", - "integrity": "sha512-Hw52Fwapyk/7hMSV/fI4+s3H9MGZEUcRh4VphyXLAk2oLYdndVUkc6KBi0zwHSzwPAr+ZBwFPe2x6naUt9mZGw==", + "version": "5.52.0", + "resolved": "https://registry.npmjs.org/@algolia/client-common/-/client-common-5.52.0.tgz", + "integrity": "sha512-3a/qM3dzJqqfTx7Yrw7uGQ98I3Q0rDfb4Vkv0wEzko96l7YQMxfBVz/VbLq2N+c59GweYv6Vhp8mPeqnWJSITw==", "license": "MIT", "engines": { "node": ">= 14.0.0" } }, "node_modules/@algolia/client-insights": { - "version": "5.50.1", - "resolved": "https://registry.npmjs.org/@algolia/client-insights/-/client-insights-5.50.1.tgz", - "integrity": "sha512-Bn/wtwhJ7p1OD/6pY+Zzn+zlu2N/SJnH46md/PAbvqIzmjVuwjNwD4y0vV5Ov8naeukXdd7UU9v550+v8+mtlg==", + "version": "5.52.0", + "resolved": "https://registry.npmjs.org/@algolia/client-insights/-/client-insights-5.52.0.tgz", + "integrity": "sha512-Rki7ACbMcvbQW0BuM84x9dkGHY47ABmv4jU6tYssat2k02p3mIUms2YOLUAMeknhmnFsj6lb6ZzOXdMWMyc1sA==", "license": "MIT", "dependencies": { - "@algolia/client-common": "5.50.1", - "@algolia/requester-browser-xhr": "5.50.1", - "@algolia/requester-fetch": "5.50.1", - "@algolia/requester-node-http": "5.50.1" + "@algolia/client-common": "5.52.0", + "@algolia/requester-browser-xhr": "5.52.0", + "@algolia/requester-fetch": "5.52.0", + "@algolia/requester-node-http": "5.52.0" }, "engines": { "node": ">= 14.0.0" } }, "node_modules/@algolia/client-personalization": { - "version": "5.50.1", - "resolved": "https://registry.npmjs.org/@algolia/client-personalization/-/client-personalization-5.50.1.tgz", - "integrity": "sha512-0V4Tu0RWR8YxkgI9EPVOZHGE4K5pEIhkLNN0CTkP/rnPsqaaSQpNMYW3/mGWdiKOWbX0iVmwLB9QESk3H0jS5g==", + "version": "5.52.0", + "resolved": "https://registry.npmjs.org/@algolia/client-personalization/-/client-personalization-5.52.0.tgz", + "integrity": "sha512-96s4Uzc3kk+/f4jJXIVVGWP5XlngOGNQ1x6hW9AT59pOixHlOs5tqJg+ZUS/GQ6h/iYP0ceQcmxDQeLyCLTaDQ==", "license": "MIT", "dependencies": { - "@algolia/client-common": "5.50.1", - "@algolia/requester-browser-xhr": "5.50.1", - "@algolia/requester-fetch": "5.50.1", - "@algolia/requester-node-http": "5.50.1" + "@algolia/client-common": "5.52.0", + "@algolia/requester-browser-xhr": "5.52.0", + "@algolia/requester-fetch": "5.52.0", + "@algolia/requester-node-http": "5.52.0" }, "engines": { "node": ">= 14.0.0" } }, "node_modules/@algolia/client-query-suggestions": { - "version": "5.50.1", - "resolved": "https://registry.npmjs.org/@algolia/client-query-suggestions/-/client-query-suggestions-5.50.1.tgz", - "integrity": "sha512-jofcWNYMXJDDr87Z2eivlWY6o71Zn7F7aOvQCXSDAo9QTlyf7BhXEsZymLUvF0O1yU9Q9wvrjAWn8uVHYnAvgw==", + "version": "5.52.0", + "resolved": "https://registry.npmjs.org/@algolia/client-query-suggestions/-/client-query-suggestions-5.52.0.tgz", + "integrity": "sha512-lqeycNpSPe5Qa0OUWpejVvYQjQWV5nQuLT0a4aq7XzRAvCxprV/6Lf841EygdD2nrFnuS58ok7Au1uOtXzpnkg==", "license": "MIT", "dependencies": { - "@algolia/client-common": "5.50.1", - "@algolia/requester-browser-xhr": "5.50.1", - "@algolia/requester-fetch": "5.50.1", - "@algolia/requester-node-http": "5.50.1" + "@algolia/client-common": "5.52.0", + "@algolia/requester-browser-xhr": "5.52.0", + "@algolia/requester-fetch": "5.52.0", + "@algolia/requester-node-http": "5.52.0" }, "engines": { "node": ">= 14.0.0" } }, "node_modules/@algolia/client-search": { - "version": "5.50.1", - "resolved": "https://registry.npmjs.org/@algolia/client-search/-/client-search-5.50.1.tgz", - "integrity": "sha512-OteRb8WubcmEvU0YlMJwCXs3Q6xrdkb0v50/qZBJP1TF0CvujFZQM++9BjEkTER/Jr9wbPHvjSFKnbMta0b4dQ==", + "version": "5.52.0", + "resolved": "https://registry.npmjs.org/@algolia/client-search/-/client-search-5.52.0.tgz", + "integrity": "sha512-ly1wETVGRo30cx61O7fetESN+ElL9c9K+bD/AVgnT1ar4c6v+/Yqjrhdtu6Fm4D0s4NZP081Isf6tunH1wUXHg==", "license": "MIT", "dependencies": { - "@algolia/client-common": "5.50.1", - "@algolia/requester-browser-xhr": "5.50.1", - "@algolia/requester-fetch": "5.50.1", - "@algolia/requester-node-http": "5.50.1" + "@algolia/client-common": "5.52.0", + "@algolia/requester-browser-xhr": "5.52.0", + "@algolia/requester-fetch": "5.52.0", + "@algolia/requester-node-http": "5.52.0" }, "engines": { "node": ">= 14.0.0" @@ -178,81 +178,81 @@ "license": "MIT" }, "node_modules/@algolia/ingestion": { - "version": "1.50.1", - "resolved": "https://registry.npmjs.org/@algolia/ingestion/-/ingestion-1.50.1.tgz", - "integrity": "sha512-0GmfSgDQK6oiIVXnJvGxtNFOfosBspRTR7csCOYCTL1P8QtxX2vDCIKwTM7xdSAEbJaZ43QlWg25q0Qdsndz8Q==", + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/@algolia/ingestion/-/ingestion-1.52.0.tgz", + "integrity": "sha512-U4EeTvgmluRjj39ykZSAd5X+a6LD5m7/mcOWDmB7hqm1R6QY0yT8jLxpNVEjYhzgEN5hcDGW6X67EWQY8KiYGQ==", "license": "MIT", "dependencies": { - "@algolia/client-common": "5.50.1", - "@algolia/requester-browser-xhr": "5.50.1", - "@algolia/requester-fetch": "5.50.1", - "@algolia/requester-node-http": "5.50.1" + "@algolia/client-common": "5.52.0", + "@algolia/requester-browser-xhr": "5.52.0", + "@algolia/requester-fetch": "5.52.0", + "@algolia/requester-node-http": "5.52.0" }, "engines": { "node": ">= 14.0.0" } }, "node_modules/@algolia/monitoring": { - "version": "1.50.1", - "resolved": "https://registry.npmjs.org/@algolia/monitoring/-/monitoring-1.50.1.tgz", - "integrity": "sha512-ySuigKEe4YjYV3si8NVk9BHQpFj/1B+ON7DhhvTvbrZJseHQQloxzq0yHwKmznSdlO6C956fx4pcfOKkZClsyg==", + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/@algolia/monitoring/-/monitoring-1.52.0.tgz", + "integrity": "sha512-FCPnDcILfpTE94u7BVlV4DmnSV5wE3+j25EEF+3dYPrVzkVCSoAHs318oWDGxnxsAgiL4HpL12Jc4XHmw9shpA==", "license": "MIT", "dependencies": { - "@algolia/client-common": "5.50.1", - "@algolia/requester-browser-xhr": "5.50.1", - "@algolia/requester-fetch": "5.50.1", - "@algolia/requester-node-http": "5.50.1" + "@algolia/client-common": "5.52.0", + "@algolia/requester-browser-xhr": "5.52.0", + "@algolia/requester-fetch": "5.52.0", + "@algolia/requester-node-http": "5.52.0" }, "engines": { "node": ">= 14.0.0" } }, "node_modules/@algolia/recommend": { - "version": "5.50.1", - "resolved": "https://registry.npmjs.org/@algolia/recommend/-/recommend-5.50.1.tgz", - "integrity": "sha512-Cp8T/B0gVmjFlzzp6eP47hwKh5FGyeqQp1N48/ANDdvdiQkPqLyFHQVDwLBH0LddfIPQE+yqmZIgmKc82haF4A==", + "version": "5.52.0", + "resolved": "https://registry.npmjs.org/@algolia/recommend/-/recommend-5.52.0.tgz", + "integrity": "sha512-br3DO7n4N8CXwTRbZS0MnB4WQ9YHfNjCwkCEzVR/wek/qNTDQKDb0nROmkFaNZ8ucUqUVKZi074dbwMwRDlK8Q==", "license": "MIT", "dependencies": { - "@algolia/client-common": "5.50.1", - "@algolia/requester-browser-xhr": "5.50.1", - "@algolia/requester-fetch": "5.50.1", - "@algolia/requester-node-http": "5.50.1" + "@algolia/client-common": "5.52.0", + "@algolia/requester-browser-xhr": "5.52.0", + "@algolia/requester-fetch": "5.52.0", + "@algolia/requester-node-http": "5.52.0" }, "engines": { "node": ">= 14.0.0" } }, "node_modules/@algolia/requester-browser-xhr": { - "version": "5.50.1", - "resolved": "https://registry.npmjs.org/@algolia/requester-browser-xhr/-/requester-browser-xhr-5.50.1.tgz", - "integrity": "sha512-XKdGGLikfrlK66ZSXh/vWcXZZ8Vg3byDFbJD8pwEvN1FoBRGxhxya476IY2ohoTymLa4qB5LBRlIa+2TLHx3Uw==", + "version": "5.52.0", + "resolved": "https://registry.npmjs.org/@algolia/requester-browser-xhr/-/requester-browser-xhr-5.52.0.tgz", + "integrity": "sha512-b0T/Ca2c9KyEslKsVrGZvbe1UrrKKSdfXhBZ2pbpKahFUzJfziRZ0urbOm7V65O0tO/jwU+Lo/+bIiiyhzGt8w==", "license": "MIT", "dependencies": { - "@algolia/client-common": "5.50.1" + "@algolia/client-common": "5.52.0" }, "engines": { "node": ">= 14.0.0" } }, "node_modules/@algolia/requester-fetch": { - "version": "5.50.1", - "resolved": "https://registry.npmjs.org/@algolia/requester-fetch/-/requester-fetch-5.50.1.tgz", - "integrity": "sha512-mBAU6WyVsDwhHyGM+nodt1/oebHxgvuLlOAoMGbj/1i6LygDHZWDgL1t5JEs37x9Aywv7ZGhqbM1GsfZ54sU6g==", + "version": "5.52.0", + "resolved": "https://registry.npmjs.org/@algolia/requester-fetch/-/requester-fetch-5.52.0.tgz", + "integrity": "sha512-ozBT8J/mtD4H4IAojw8QPirlcL2gHrI1BGuZ4/ZXXO/rTE1yQ4VIPJj4mTTbwo4FbkS1MoJsD/DsrqLzhnc4/g==", "license": "MIT", "dependencies": { - "@algolia/client-common": "5.50.1" + "@algolia/client-common": "5.52.0" }, "engines": { "node": ">= 14.0.0" } }, "node_modules/@algolia/requester-node-http": { - "version": "5.50.1", - "resolved": "https://registry.npmjs.org/@algolia/requester-node-http/-/requester-node-http-5.50.1.tgz", - "integrity": "sha512-qmo1LXrNKLHvJE6mdQbLnsZAoZvj7VyF2ft4xmbSGWI2WWm87fx/CjUX4kEExt4y0a6T6nEts6ofpUfH5TEE1A==", + "version": "5.52.0", + "resolved": "https://registry.npmjs.org/@algolia/requester-node-http/-/requester-node-http-5.52.0.tgz", + "integrity": "sha512-gyyWcLD22tnabmoit4iukCXuoRc5HYJuUjPSEa8a0D/f/NlRafpWi52AlAaa4Uu/rsl7saHsJFTNjTptWbu2+A==", "license": "MIT", "dependencies": { - "@algolia/client-common": "5.50.1" + "@algolia/client-common": "5.52.0" }, "engines": { "node": ">= 14.0.0" @@ -3295,9 +3295,9 @@ } }, "node_modules/@docsearch/core": { - "version": "4.6.2", - "resolved": "https://registry.npmjs.org/@docsearch/core/-/core-4.6.2.tgz", - "integrity": "sha512-/S0e6Dj7Zcm8m9Rru49YEX49dhU11be68c+S/BCyN8zQsTTgkKzXlhRbVL5mV6lOLC2+ZRRryaTdcm070Ug2oA==", + "version": "4.6.3", + "resolved": "https://registry.npmjs.org/@docsearch/core/-/core-4.6.3.tgz", + "integrity": "sha512-rUOujwIpxJRgD7+kicVsI3D5sqBvdiRTquzWBpTEXZs8ZXfGbfzpus5HqumaNYTppN2HvH8E2yNuRwYdHJeOlA==", "license": "MIT", "peerDependencies": { "@types/react": ">= 16.8.0 < 20.0.0", @@ -3317,20 +3317,20 @@ } }, "node_modules/@docsearch/css": { - "version": "4.6.2", - "resolved": "https://registry.npmjs.org/@docsearch/css/-/css-4.6.2.tgz", - "integrity": "sha512-fH/cn8BjEEdM2nJdjNMHIvOVYupG6AIDtFVDgIZrNzdCSj4KXr9kd+hsehqsNGYjpUjObeKYKvgy/IwCb1jZYQ==", + "version": "4.6.3", + "resolved": "https://registry.npmjs.org/@docsearch/css/-/css-4.6.3.tgz", + "integrity": "sha512-nlOwcXcsNAptQl4vlL4MA78qNJKO0Qlds5GuBjCoePgkebTXLSf8Qt1oyZ3YBshYupKXG9VRGEsk1zr23d+bzQ==", "license": "MIT" }, "node_modules/@docsearch/react": { - "version": "4.6.2", - "resolved": "https://registry.npmjs.org/@docsearch/react/-/react-4.6.2.tgz", - "integrity": "sha512-/BbtGFtqVOGwZx0dw/UfhN/0/DmMQYnulY4iv0tPRhC2JCXv0ka/+izwt3Jzo1ZxXS/2eMvv9zHsBJOK1I9f/w==", + "version": "4.6.3", + "resolved": "https://registry.npmjs.org/@docsearch/react/-/react-4.6.3.tgz", + "integrity": "sha512-Bg2wdDsoQVlNCcEKuEJAU04tvHCqgx8rIu+uIoM4pRtcx3TBKJuXutJik3LTA8LRc9YEyHkrYUrmcC0D7BYf+g==", "license": "MIT", "dependencies": { "@algolia/autocomplete-core": "1.19.2", - "@docsearch/core": "4.6.2", - "@docsearch/css": "4.6.2" + "@docsearch/core": "4.6.3", + "@docsearch/css": "4.6.3" }, "peerDependencies": { "@types/react": ">= 16.8.0 < 20.0.0", @@ -3386,9 +3386,9 @@ } }, "node_modules/@docusaurus/babel": { - "version": "3.10.0", - "resolved": "https://registry.npmjs.org/@docusaurus/babel/-/babel-3.10.0.tgz", - "integrity": "sha512-mqCJhCZNZUDg0zgDEaPTM4DnRsisa24HdqTy/qn/MQlbwhTb4WVaZg6ZyX6yIVKqTz8fS1hBMgM+98z+BeJJDg==", + "version": "3.10.1", + "resolved": "https://registry.npmjs.org/@docusaurus/babel/-/babel-3.10.1.tgz", + "integrity": "sha512-DZzFO1K3v/GoEt1fx1DiYHF4en+PuhtQf1AkQJa5zu3CoeKSpr5cpQRUlz3jr0m44wyzmSXu9bVpfir+N4+8bg==", "license": "MIT", "dependencies": { "@babel/core": "^7.25.9", @@ -3400,8 +3400,8 @@ "@babel/preset-typescript": "^7.25.9", "@babel/runtime": "^7.25.9", "@babel/traverse": "^7.25.9", - "@docusaurus/logger": "3.10.0", - "@docusaurus/utils": "3.10.0", + "@docusaurus/logger": "3.10.1", + "@docusaurus/utils": "3.10.1", "babel-plugin-dynamic-import-node": "^2.3.3", "fs-extra": "^11.1.1", "tslib": "^2.6.0" @@ -3411,17 +3411,17 @@ } }, "node_modules/@docusaurus/bundler": { - "version": "3.10.0", - "resolved": "https://registry.npmjs.org/@docusaurus/bundler/-/bundler-3.10.0.tgz", - "integrity": "sha512-iONUGZGgp+lAkw/cJZH6irONcF4p8+278IsdRlq8lYhxGjkoNUs0w7F4gVXBYSNChq5KG5/JleTSsdJySShxow==", + "version": "3.10.1", + "resolved": "https://registry.npmjs.org/@docusaurus/bundler/-/bundler-3.10.1.tgz", + "integrity": "sha512-HIqQPvbqnnQRe4NsBd1774KRarjXqS6wHsWELtyuSs1gCfvixJO2jUGH/OEBtr1Gvzpw+ze5CjGMvSJ8UE1KUw==", "license": "MIT", "dependencies": { "@babel/core": "^7.25.9", - "@docusaurus/babel": "3.10.0", - "@docusaurus/cssnano-preset": "3.10.0", - "@docusaurus/logger": "3.10.0", - "@docusaurus/types": "3.10.0", - "@docusaurus/utils": "3.10.0", + "@docusaurus/babel": "3.10.1", + "@docusaurus/cssnano-preset": "3.10.1", + "@docusaurus/logger": "3.10.1", + "@docusaurus/types": "3.10.1", + "@docusaurus/utils": "3.10.1", "babel-loader": "^9.2.1", "clean-css": "^5.3.3", "copy-webpack-plugin": "^11.0.0", @@ -3439,7 +3439,7 @@ "tslib": "^2.6.0", "url-loader": "^4.1.1", "webpack": "^5.95.0", - "webpackbar": "^6.0.1" + "webpackbar": "^7.0.0" }, "engines": { "node": ">=20.0" @@ -3454,18 +3454,18 @@ } }, "node_modules/@docusaurus/core": { - "version": "3.10.0", - "resolved": "https://registry.npmjs.org/@docusaurus/core/-/core-3.10.0.tgz", - "integrity": "sha512-mgLdQsO8xppnQZc3LPi+Mf+PkPeyxJeIx11AXAq/14fsaMefInQiMEZUUmrc7J+956G/f7MwE7tn8KZgi3iRcA==", - "license": "MIT", - "dependencies": { - "@docusaurus/babel": "3.10.0", - "@docusaurus/bundler": "3.10.0", - "@docusaurus/logger": "3.10.0", - "@docusaurus/mdx-loader": "3.10.0", - "@docusaurus/utils": "3.10.0", - "@docusaurus/utils-common": "3.10.0", - "@docusaurus/utils-validation": "3.10.0", + "version": "3.10.1", + "resolved": "https://registry.npmjs.org/@docusaurus/core/-/core-3.10.1.tgz", + "integrity": "sha512-3pf2fXXw0eVk8WnC3T4LIigRDupcpvngpKo9Vy7mYyBhuddc0klDUuZAIfzMoK6z05pdlk6EFC/vBSX43+1O5w==", + "license": "MIT", + "dependencies": { + "@docusaurus/babel": "3.10.1", + "@docusaurus/bundler": "3.10.1", + "@docusaurus/logger": "3.10.1", + "@docusaurus/mdx-loader": "3.10.1", + "@docusaurus/utils": "3.10.1", + "@docusaurus/utils-common": "3.10.1", + "@docusaurus/utils-validation": "3.10.1", "boxen": "^6.2.1", "chalk": "^4.1.2", "chokidar": "^3.5.3", @@ -3521,9 +3521,9 @@ } }, "node_modules/@docusaurus/cssnano-preset": { - "version": "3.10.0", - "resolved": "https://registry.npmjs.org/@docusaurus/cssnano-preset/-/cssnano-preset-3.10.0.tgz", - "integrity": "sha512-qzSshTO1DB3TYW+dPUal5KHM7XPc5YQfzF3Kdb2NDACJUyGbNcFtw3tGkCJlYwhNCRKbZcmwraKUS1i5dcHdGg==", + "version": "3.10.1", + "resolved": "https://registry.npmjs.org/@docusaurus/cssnano-preset/-/cssnano-preset-3.10.1.tgz", + "integrity": "sha512-eNfHGcTKCSq6xmcavAkX3RRclHaE2xRCMParlDXLdXVP01/a2e/jKXMj/0ULnLFQSNwwuI62L0Ge8J+nZsR7UQ==", "license": "MIT", "dependencies": { "cssnano-preset-advanced": "^6.1.2", @@ -3536,13 +3536,13 @@ } }, "node_modules/@docusaurus/faster": { - "version": "3.10.0", - "resolved": "https://registry.npmjs.org/@docusaurus/faster/-/faster-3.10.0.tgz", - "integrity": "sha512-GNPtVH14ISjHfSwnHu3KiFGf86ICmJSQDeSv/QaanpBgiZGOtgZaslnC5q8WiguxM1EVkwcGxPuD8BXF4eggKw==", + "version": "3.10.1", + "resolved": "https://registry.npmjs.org/@docusaurus/faster/-/faster-3.10.1.tgz", + "integrity": "sha512-XTZhE5C1gZ/DaYYMlSk02dwP5vhpQON5QHVz1s3892mSESAywgWanURpXEDAvt4GvGuq7s+XP8rTWHZvfaJmdQ==", "devOptional": true, "license": "MIT", "dependencies": { - "@docusaurus/types": "3.10.0", + "@docusaurus/types": "3.10.1", "@rspack/core": "^1.7.10", "@swc/core": "^1.7.39", "@swc/html": "^1.13.5", @@ -3561,9 +3561,9 @@ } }, "node_modules/@docusaurus/logger": { - "version": "3.10.0", - "resolved": "https://registry.npmjs.org/@docusaurus/logger/-/logger-3.10.0.tgz", - "integrity": "sha512-9jrZzFuBH1LDRlZ7cznAhCLmAZ3HSDqgwdrSSZdGHq9SPUOQgXXu8mnxe2ZRB9NS1PCpMTIOVUqDtZPIhMafZg==", + "version": "3.10.1", + "resolved": "https://registry.npmjs.org/@docusaurus/logger/-/logger-3.10.1.tgz", + "integrity": "sha512-oPjNFnfJsRCkePVjkGrxWGq4MvJKRQT0r9jOP0eRBTZ7Wr9FAbzdP/Gjs0I2Ss6YRkPoEgygKG112OkE6skvJw==", "license": "MIT", "dependencies": { "chalk": "^4.1.2", @@ -3574,14 +3574,14 @@ } }, "node_modules/@docusaurus/mdx-loader": { - "version": "3.10.0", - "resolved": "https://registry.npmjs.org/@docusaurus/mdx-loader/-/mdx-loader-3.10.0.tgz", - "integrity": "sha512-mQQV97080AH4PYNs087l202NMDqRopZA4mg5W76ZZyTFrmWhJ3mHg+8A+drJVENxw5/Q+wHMHLgsx+9z1nEs0A==", + "version": "3.10.1", + "resolved": "https://registry.npmjs.org/@docusaurus/mdx-loader/-/mdx-loader-3.10.1.tgz", + "integrity": "sha512-GRmeb/wQ+iXRrFwcHBfgQhrJxGElgCsoTWZYDhccjsZVne1p8MK/EpQVIloXttz76TCe78kKD5AEG9n1xc1oxQ==", "license": "MIT", "dependencies": { - "@docusaurus/logger": "3.10.0", - "@docusaurus/utils": "3.10.0", - "@docusaurus/utils-validation": "3.10.0", + "@docusaurus/logger": "3.10.1", + "@docusaurus/utils": "3.10.1", + "@docusaurus/utils-validation": "3.10.1", "@mdx-js/mdx": "^3.0.0", "@slorber/remark-comment": "^1.0.0", "escape-html": "^1.0.3", @@ -3613,12 +3613,12 @@ } }, "node_modules/@docusaurus/module-type-aliases": { - "version": "3.10.0", - "resolved": "https://registry.npmjs.org/@docusaurus/module-type-aliases/-/module-type-aliases-3.10.0.tgz", - "integrity": "sha512-/1O0Zg8w3DFrYX/I6Fbss7OJrtZw1QoyjDhegiFNHVi9A9Y0gQ3jUAytVxF6ywpAWpLyLxch8nN8H/V3XfzdJQ==", + "version": "3.10.1", + "resolved": "https://registry.npmjs.org/@docusaurus/module-type-aliases/-/module-type-aliases-3.10.1.tgz", + "integrity": "sha512-YoOZKUdGlp8xSYhuAkGdSo5Ydkbq4V4eK3sD8v0a2hloxCWdQbNBhkc+Ko9QyjpESc0BYcIGM5iHVAy5hdFV6w==", "license": "MIT", "dependencies": { - "@docusaurus/types": "3.10.0", + "@docusaurus/types": "3.10.1", "@types/history": "^4.7.11", "@types/react": "*", "@types/react-router-config": "*", @@ -3632,19 +3632,19 @@ } }, "node_modules/@docusaurus/plugin-content-blog": { - "version": "3.10.0", - "resolved": "https://registry.npmjs.org/@docusaurus/plugin-content-blog/-/plugin-content-blog-3.10.0.tgz", - "integrity": "sha512-RuTz68DhB7CL96QO5UsFbciD7GPYq6QV+YMfF9V0+N4ZgLhJIBgpVAr8GobrKF6NRe5cyWWETU5z5T834piG9g==", - "license": "MIT", - "dependencies": { - "@docusaurus/core": "3.10.0", - "@docusaurus/logger": "3.10.0", - "@docusaurus/mdx-loader": "3.10.0", - "@docusaurus/theme-common": "3.10.0", - "@docusaurus/types": "3.10.0", - "@docusaurus/utils": "3.10.0", - "@docusaurus/utils-common": "3.10.0", - "@docusaurus/utils-validation": "3.10.0", + "version": "3.10.1", + "resolved": "https://registry.npmjs.org/@docusaurus/plugin-content-blog/-/plugin-content-blog-3.10.1.tgz", + "integrity": "sha512-mmkgE6Q2+K74tnkou7tXlpDLvoCU/qkSa2GSQ3XUiHWvcebCoDQzS670RR3tO8PmaWlIyWWISYWzZLuMfxunRA==", + "license": "MIT", + "dependencies": { + "@docusaurus/core": "3.10.1", + "@docusaurus/logger": "3.10.1", + "@docusaurus/mdx-loader": "3.10.1", + "@docusaurus/theme-common": "3.10.1", + "@docusaurus/types": "3.10.1", + "@docusaurus/utils": "3.10.1", + "@docusaurus/utils-common": "3.10.1", + "@docusaurus/utils-validation": "3.10.1", "cheerio": "1.0.0-rc.12", "combine-promises": "^1.1.0", "feed": "^4.2.2", @@ -3667,20 +3667,20 @@ } }, "node_modules/@docusaurus/plugin-content-docs": { - "version": "3.10.0", - "resolved": "https://registry.npmjs.org/@docusaurus/plugin-content-docs/-/plugin-content-docs-3.10.0.tgz", - "integrity": "sha512-9BjHhf15ct8Z7TThTC0xRndKDVvMKmVsAGAN7W9FpNRzfMdScOGcXtLmcCWtJGvAezjOJIm6CxOYCy3Io5+RnQ==", - "license": "MIT", - "dependencies": { - "@docusaurus/core": "3.10.0", - "@docusaurus/logger": "3.10.0", - "@docusaurus/mdx-loader": "3.10.0", - "@docusaurus/module-type-aliases": "3.10.0", - "@docusaurus/theme-common": "3.10.0", - "@docusaurus/types": "3.10.0", - "@docusaurus/utils": "3.10.0", - "@docusaurus/utils-common": "3.10.0", - "@docusaurus/utils-validation": "3.10.0", + "version": "3.10.1", + "resolved": "https://registry.npmjs.org/@docusaurus/plugin-content-docs/-/plugin-content-docs-3.10.1.tgz", + "integrity": "sha512-2jRVrtzjf8LClGTHQlwlwuD3wQXRx3WEoF7XUarJ8Ou+0onV+SLtejsyfY9JLpfUh9hPhXM4pbBGkyAY4Bi3HQ==", + "license": "MIT", + "dependencies": { + "@docusaurus/core": "3.10.1", + "@docusaurus/logger": "3.10.1", + "@docusaurus/mdx-loader": "3.10.1", + "@docusaurus/module-type-aliases": "3.10.1", + "@docusaurus/theme-common": "3.10.1", + "@docusaurus/types": "3.10.1", + "@docusaurus/utils": "3.10.1", + "@docusaurus/utils-common": "3.10.1", + "@docusaurus/utils-validation": "3.10.1", "@types/react-router-config": "^5.0.7", "combine-promises": "^1.1.0", "fs-extra": "^11.1.1", @@ -3700,16 +3700,16 @@ } }, "node_modules/@docusaurus/plugin-content-pages": { - "version": "3.10.0", - "resolved": "https://registry.npmjs.org/@docusaurus/plugin-content-pages/-/plugin-content-pages-3.10.0.tgz", - "integrity": "sha512-5amX8kEJI+nIGtuLVjYk59Y5utEJ3CHETFOPEE4cooIRLA4xM4iBsA6zFgu4ljcopeYwvBzFEWf5g2I6Yb9SkA==", + "version": "3.10.1", + "resolved": "https://registry.npmjs.org/@docusaurus/plugin-content-pages/-/plugin-content-pages-3.10.1.tgz", + "integrity": "sha512-huJpaRPMl42nsFwuCXvV8bVDj2MazuwRJIUylI/RSlmZeJssVoZXeCjVf1y+1Drtpa9SKcdGn8yoJ76IRJijtw==", "license": "MIT", "dependencies": { - "@docusaurus/core": "3.10.0", - "@docusaurus/mdx-loader": "3.10.0", - "@docusaurus/types": "3.10.0", - "@docusaurus/utils": "3.10.0", - "@docusaurus/utils-validation": "3.10.0", + "@docusaurus/core": "3.10.1", + "@docusaurus/mdx-loader": "3.10.1", + "@docusaurus/types": "3.10.1", + "@docusaurus/utils": "3.10.1", + "@docusaurus/utils-validation": "3.10.1", "fs-extra": "^11.1.1", "tslib": "^2.6.0", "webpack": "^5.88.1" @@ -3723,15 +3723,15 @@ } }, "node_modules/@docusaurus/plugin-css-cascade-layers": { - "version": "3.10.0", - "resolved": "https://registry.npmjs.org/@docusaurus/plugin-css-cascade-layers/-/plugin-css-cascade-layers-3.10.0.tgz", - "integrity": "sha512-6q1vtt5FJcg5osgkHeM1euErECNqEZ5Z1j69yiNx2luEBIso+nxCkS9nqj8w+MK5X7rvKEToGhFfOFWncs51pQ==", + "version": "3.10.1", + "resolved": "https://registry.npmjs.org/@docusaurus/plugin-css-cascade-layers/-/plugin-css-cascade-layers-3.10.1.tgz", + "integrity": "sha512-r//fn+MNHkE1wCof8T29VAQezt1enGCpsFxoziBbvLgBM4JfXN2P3rxrBaavHmvLvm7lYkpJeitcDthwnmWCTw==", "license": "MIT", "dependencies": { - "@docusaurus/core": "3.10.0", - "@docusaurus/types": "3.10.0", - "@docusaurus/utils": "3.10.0", - "@docusaurus/utils-validation": "3.10.0", + "@docusaurus/core": "3.10.1", + "@docusaurus/types": "3.10.1", + "@docusaurus/utils": "3.10.1", + "@docusaurus/utils-validation": "3.10.1", "tslib": "^2.6.0" }, "engines": { @@ -3739,14 +3739,14 @@ } }, "node_modules/@docusaurus/plugin-debug": { - "version": "3.10.0", - "resolved": "https://registry.npmjs.org/@docusaurus/plugin-debug/-/plugin-debug-3.10.0.tgz", - "integrity": "sha512-XcljKN+G+nmmK69uQA1d9BlYU3ZftG3T3zpK8/7Hf/wrOlV7TA4Ampdrdwkg0jElKdKAoSnPhCO0/U3bQGsVQQ==", + "version": "3.10.1", + "resolved": "https://registry.npmjs.org/@docusaurus/plugin-debug/-/plugin-debug-3.10.1.tgz", + "integrity": "sha512-9KqOpKNfAyqGZykRb9LhIT/vyRF6sm/ykhjj/39JvaJahDS+jZJE0Z1Wfz9q3DUNDTMNN0Q7u/kk4rKKU+IJuA==", "license": "MIT", "dependencies": { - "@docusaurus/core": "3.10.0", - "@docusaurus/types": "3.10.0", - "@docusaurus/utils": "3.10.0", + "@docusaurus/core": "3.10.1", + "@docusaurus/types": "3.10.1", + "@docusaurus/utils": "3.10.1", "fs-extra": "^11.1.1", "react-json-view-lite": "^2.3.0", "tslib": "^2.6.0" @@ -3760,14 +3760,14 @@ } }, "node_modules/@docusaurus/plugin-google-analytics": { - "version": "3.10.0", - "resolved": "https://registry.npmjs.org/@docusaurus/plugin-google-analytics/-/plugin-google-analytics-3.10.0.tgz", - "integrity": "sha512-hTEoodatpBZnUat5nFExbuTGA1lhWGy7vZGuTew5Q3QDtGKFpSJLYmZJhdTjvCFwv1+qQ67hgAVlKdJOB8TXow==", + "version": "3.10.1", + "resolved": "https://registry.npmjs.org/@docusaurus/plugin-google-analytics/-/plugin-google-analytics-3.10.1.tgz", + "integrity": "sha512-8o0P1KtmgdYQHH+oInitPpRWI0Of5XednAX4+DMhQNSmGSRNrsEEHg1ebv35m9AgRClfAytCJ5jA9KvcASTyuA==", "license": "MIT", "dependencies": { - "@docusaurus/core": "3.10.0", - "@docusaurus/types": "3.10.0", - "@docusaurus/utils-validation": "3.10.0", + "@docusaurus/core": "3.10.1", + "@docusaurus/types": "3.10.1", + "@docusaurus/utils-validation": "3.10.1", "tslib": "^2.6.0" }, "engines": { @@ -3779,14 +3779,14 @@ } }, "node_modules/@docusaurus/plugin-google-gtag": { - "version": "3.10.0", - "resolved": "https://registry.npmjs.org/@docusaurus/plugin-google-gtag/-/plugin-google-gtag-3.10.0.tgz", - "integrity": "sha512-iB/Zzjv/eelJRbdULZqzWCbgMgJ7ht4ONVjXtN3+BI/muil6S87gQ1OJyPwlXD+ELdKkitC7bWv5eJdYOZLhrQ==", + "version": "3.10.1", + "resolved": "https://registry.npmjs.org/@docusaurus/plugin-google-gtag/-/plugin-google-gtag-3.10.1.tgz", + "integrity": "sha512-pu3xIUo5o/zCMLfUY9BO5KOwSH0zIsAGyFRPvXHayFSA5XIhCU/SFuB0g0ZNjFn9niZLCaNvoeAuOGFJZq0fdw==", "license": "MIT", "dependencies": { - "@docusaurus/core": "3.10.0", - "@docusaurus/types": "3.10.0", - "@docusaurus/utils-validation": "3.10.0", + "@docusaurus/core": "3.10.1", + "@docusaurus/types": "3.10.1", + "@docusaurus/utils-validation": "3.10.1", "@types/gtag.js": "^0.0.20", "tslib": "^2.6.0" }, @@ -3799,14 +3799,14 @@ } }, "node_modules/@docusaurus/plugin-google-tag-manager": { - "version": "3.10.0", - "resolved": "https://registry.npmjs.org/@docusaurus/plugin-google-tag-manager/-/plugin-google-tag-manager-3.10.0.tgz", - "integrity": "sha512-FEjZxqKgLHa+Wez/EgKxRwvArNCWIScfyEQD95rot7jkxp6nonjI5XIbGfO/iYhM5Qinwe8aIEQHP2KZtpqVuA==", + "version": "3.10.1", + "resolved": "https://registry.npmjs.org/@docusaurus/plugin-google-tag-manager/-/plugin-google-tag-manager-3.10.1.tgz", + "integrity": "sha512-f6fyGHiCm7kJHBtAisGQS5oNBnpnMTYQZxDXeVrnw/3zWU+LMA22pr6UHGYkBKDbN+qPC5QHG3NuOfzQLq3+Lw==", "license": "MIT", "dependencies": { - "@docusaurus/core": "3.10.0", - "@docusaurus/types": "3.10.0", - "@docusaurus/utils-validation": "3.10.0", + "@docusaurus/core": "3.10.1", + "@docusaurus/types": "3.10.1", + "@docusaurus/utils-validation": "3.10.1", "tslib": "^2.6.0" }, "engines": { @@ -3818,17 +3818,17 @@ } }, "node_modules/@docusaurus/plugin-sitemap": { - "version": "3.10.0", - "resolved": "https://registry.npmjs.org/@docusaurus/plugin-sitemap/-/plugin-sitemap-3.10.0.tgz", - "integrity": "sha512-DVTSLjB97hIjmayGnGcBfognCeI7ZuUKgEnU7Oz81JYqXtVg94mVTthDjq3QHTylYNeCUbkaW8VF0FDLcc8pPw==", + "version": "3.10.1", + "resolved": "https://registry.npmjs.org/@docusaurus/plugin-sitemap/-/plugin-sitemap-3.10.1.tgz", + "integrity": "sha512-C26MbmmqgdjkDq1htaZ3aD7LzEDKFWXfpyQpt0EOUThuq5nV77zDaedV20yHcVo9p+3ey9aZ4pbHA0D3QcZTzg==", "license": "MIT", "dependencies": { - "@docusaurus/core": "3.10.0", - "@docusaurus/logger": "3.10.0", - "@docusaurus/types": "3.10.0", - "@docusaurus/utils": "3.10.0", - "@docusaurus/utils-common": "3.10.0", - "@docusaurus/utils-validation": "3.10.0", + "@docusaurus/core": "3.10.1", + "@docusaurus/logger": "3.10.1", + "@docusaurus/types": "3.10.1", + "@docusaurus/utils": "3.10.1", + "@docusaurus/utils-common": "3.10.1", + "@docusaurus/utils-validation": "3.10.1", "fs-extra": "^11.1.1", "sitemap": "^7.1.1", "tslib": "^2.6.0" @@ -3842,15 +3842,15 @@ } }, "node_modules/@docusaurus/plugin-svgr": { - "version": "3.10.0", - "resolved": "https://registry.npmjs.org/@docusaurus/plugin-svgr/-/plugin-svgr-3.10.0.tgz", - "integrity": "sha512-lNljBESaETZqVBMPqkrGchr+UPT1eZzEPLmJhz8I76BxbjqgsUnRvrq6lQJ9sYjgmgX52KB7kkgczqd2yzoswQ==", + "version": "3.10.1", + "resolved": "https://registry.npmjs.org/@docusaurus/plugin-svgr/-/plugin-svgr-3.10.1.tgz", + "integrity": "sha512-6SFxsmjWFkVLDmBUvFK6i72QjUwqyQFe4Ovz+SUJophJjOyVG3ZZG5IQpBC/kX/Gfv1yWeU9nWauH6F6Q7QX/Q==", "license": "MIT", "dependencies": { - "@docusaurus/core": "3.10.0", - "@docusaurus/types": "3.10.0", - "@docusaurus/utils": "3.10.0", - "@docusaurus/utils-validation": "3.10.0", + "@docusaurus/core": "3.10.1", + "@docusaurus/types": "3.10.1", + "@docusaurus/utils": "3.10.1", + "@docusaurus/utils-validation": "3.10.1", "@svgr/core": "8.1.0", "@svgr/webpack": "^8.1.0", "tslib": "^2.6.0", @@ -3865,26 +3865,26 @@ } }, "node_modules/@docusaurus/preset-classic": { - "version": "3.10.0", - "resolved": "https://registry.npmjs.org/@docusaurus/preset-classic/-/preset-classic-3.10.0.tgz", - "integrity": "sha512-kw/Ye02Hc6xP1OdTswy8yxQEHg0fdPpyWAQRxr5b2x3h7LlG2Zgbb5BDFROnXDDMpUxB7YejlocJIE5HIEfpNA==", - "license": "MIT", - "dependencies": { - "@docusaurus/core": "3.10.0", - "@docusaurus/plugin-content-blog": "3.10.0", - "@docusaurus/plugin-content-docs": "3.10.0", - "@docusaurus/plugin-content-pages": "3.10.0", - "@docusaurus/plugin-css-cascade-layers": "3.10.0", - "@docusaurus/plugin-debug": "3.10.0", - "@docusaurus/plugin-google-analytics": "3.10.0", - "@docusaurus/plugin-google-gtag": "3.10.0", - "@docusaurus/plugin-google-tag-manager": "3.10.0", - "@docusaurus/plugin-sitemap": "3.10.0", - "@docusaurus/plugin-svgr": "3.10.0", - "@docusaurus/theme-classic": "3.10.0", - "@docusaurus/theme-common": "3.10.0", - "@docusaurus/theme-search-algolia": "3.10.0", - "@docusaurus/types": "3.10.0" + "version": "3.10.1", + "resolved": "https://registry.npmjs.org/@docusaurus/preset-classic/-/preset-classic-3.10.1.tgz", + "integrity": "sha512-YO/FL8v1zmbxoTso6mjMz/RDjhaTJxb1UpFFTDdY5847LLDCeyYiYlrhyTbgN1RIN3xnkLKZ9Lj1x8hUzI4JOg==", + "license": "MIT", + "dependencies": { + "@docusaurus/core": "3.10.1", + "@docusaurus/plugin-content-blog": "3.10.1", + "@docusaurus/plugin-content-docs": "3.10.1", + "@docusaurus/plugin-content-pages": "3.10.1", + "@docusaurus/plugin-css-cascade-layers": "3.10.1", + "@docusaurus/plugin-debug": "3.10.1", + "@docusaurus/plugin-google-analytics": "3.10.1", + "@docusaurus/plugin-google-gtag": "3.10.1", + "@docusaurus/plugin-google-tag-manager": "3.10.1", + "@docusaurus/plugin-sitemap": "3.10.1", + "@docusaurus/plugin-svgr": "3.10.1", + "@docusaurus/theme-classic": "3.10.1", + "@docusaurus/theme-common": "3.10.1", + "@docusaurus/theme-search-algolia": "3.10.1", + "@docusaurus/types": "3.10.1" }, "engines": { "node": ">=20.0" @@ -3895,24 +3895,24 @@ } }, "node_modules/@docusaurus/theme-classic": { - "version": "3.10.0", - "resolved": "https://registry.npmjs.org/@docusaurus/theme-classic/-/theme-classic-3.10.0.tgz", - "integrity": "sha512-9msCAsRdN+UG+RwPwCFb0uKy4tGoPh5YfBozXeGUtIeAgsMdn6f3G/oY861luZ3t8S2ET8S9Y/1GnpJAGWytww==", - "license": "MIT", - "dependencies": { - "@docusaurus/core": "3.10.0", - "@docusaurus/logger": "3.10.0", - "@docusaurus/mdx-loader": "3.10.0", - "@docusaurus/module-type-aliases": "3.10.0", - "@docusaurus/plugin-content-blog": "3.10.0", - "@docusaurus/plugin-content-docs": "3.10.0", - "@docusaurus/plugin-content-pages": "3.10.0", - "@docusaurus/theme-common": "3.10.0", - "@docusaurus/theme-translations": "3.10.0", - "@docusaurus/types": "3.10.0", - "@docusaurus/utils": "3.10.0", - "@docusaurus/utils-common": "3.10.0", - "@docusaurus/utils-validation": "3.10.0", + "version": "3.10.1", + "resolved": "https://registry.npmjs.org/@docusaurus/theme-classic/-/theme-classic-3.10.1.tgz", + "integrity": "sha512-VU1RK0qb2pab0si4r7HFK37cYco8VzqLj3u1PspVipSr/z/GPVKHO4/HXbnePqHoWDk8urjyGSeatH0NIMBM1A==", + "license": "MIT", + "dependencies": { + "@docusaurus/core": "3.10.1", + "@docusaurus/logger": "3.10.1", + "@docusaurus/mdx-loader": "3.10.1", + "@docusaurus/module-type-aliases": "3.10.1", + "@docusaurus/plugin-content-blog": "3.10.1", + "@docusaurus/plugin-content-docs": "3.10.1", + "@docusaurus/plugin-content-pages": "3.10.1", + "@docusaurus/theme-common": "3.10.1", + "@docusaurus/theme-translations": "3.10.1", + "@docusaurus/types": "3.10.1", + "@docusaurus/utils": "3.10.1", + "@docusaurus/utils-common": "3.10.1", + "@docusaurus/utils-validation": "3.10.1", "@mdx-js/react": "^3.0.0", "clsx": "^2.0.0", "copy-text-to-clipboard": "^3.2.0", @@ -3936,15 +3936,15 @@ } }, "node_modules/@docusaurus/theme-common": { - "version": "3.10.0", - "resolved": "https://registry.npmjs.org/@docusaurus/theme-common/-/theme-common-3.10.0.tgz", - "integrity": "sha512-Dkp1YXKn16ByCJAdIjbDIOpVb4Z66MsVD694/ilX1vAAHaVEMrVsf/NPd9VgreyFx08rJ9GqV1MtzsbTcU73Kg==", + "version": "3.10.1", + "resolved": "https://registry.npmjs.org/@docusaurus/theme-common/-/theme-common-3.10.1.tgz", + "integrity": "sha512-0YtmIeoNo1fIw65LO8+/1dPgmDV86UmhMkow37gzjytuiCSQm9xob6PJy0L4kuQEMTLfUOGvkXvZr7GPrHquMA==", "license": "MIT", "dependencies": { - "@docusaurus/mdx-loader": "3.10.0", - "@docusaurus/module-type-aliases": "3.10.0", - "@docusaurus/utils": "3.10.0", - "@docusaurus/utils-common": "3.10.0", + "@docusaurus/mdx-loader": "3.10.1", + "@docusaurus/module-type-aliases": "3.10.1", + "@docusaurus/utils": "3.10.1", + "@docusaurus/utils-common": "3.10.1", "@types/history": "^4.7.11", "@types/react": "*", "@types/react-router-config": "*", @@ -3964,20 +3964,20 @@ } }, "node_modules/@docusaurus/theme-search-algolia": { - "version": "3.10.0", - "resolved": "https://registry.npmjs.org/@docusaurus/theme-search-algolia/-/theme-search-algolia-3.10.0.tgz", - "integrity": "sha512-f5FPKI08e3JRG63vR/o4qeuUVHUHzFzM0nnF+AkB67soAZgNsKJRf2qmUZvlQkGwlV+QFkKe4D0ANMh1jToU3g==", + "version": "3.10.1", + "resolved": "https://registry.npmjs.org/@docusaurus/theme-search-algolia/-/theme-search-algolia-3.10.1.tgz", + "integrity": "sha512-OTaARARVZj2GvkJQjB+1jOIxntRaXea+G+fMsNqrZBAU1O1vJKDW22R7kECOHW27oJCLFN9HKaZeRrfAUyviug==", "license": "MIT", "dependencies": { "@algolia/autocomplete-core": "^1.19.2", "@docsearch/react": "^3.9.0 || ^4.3.2", - "@docusaurus/core": "3.10.0", - "@docusaurus/logger": "3.10.0", - "@docusaurus/plugin-content-docs": "3.10.0", - "@docusaurus/theme-common": "3.10.0", - "@docusaurus/theme-translations": "3.10.0", - "@docusaurus/utils": "3.10.0", - "@docusaurus/utils-validation": "3.10.0", + "@docusaurus/core": "3.10.1", + "@docusaurus/logger": "3.10.1", + "@docusaurus/plugin-content-docs": "3.10.1", + "@docusaurus/theme-common": "3.10.1", + "@docusaurus/theme-translations": "3.10.1", + "@docusaurus/utils": "3.10.1", + "@docusaurus/utils-validation": "3.10.1", "algoliasearch": "^5.37.0", "algoliasearch-helper": "^3.26.0", "clsx": "^2.0.0", @@ -3996,9 +3996,9 @@ } }, "node_modules/@docusaurus/theme-translations": { - "version": "3.10.0", - "resolved": "https://registry.npmjs.org/@docusaurus/theme-translations/-/theme-translations-3.10.0.tgz", - "integrity": "sha512-L9IbFLwTc5+XdgH45iQYufLn0SVZd6BUNelDbKIFlH+E4hhjuj/XHWAFMX/w2K59rfy8wak9McOaei7BSUfRPA==", + "version": "3.10.1", + "resolved": "https://registry.npmjs.org/@docusaurus/theme-translations/-/theme-translations-3.10.1.tgz", + "integrity": "sha512-cLMyaKivjBVWKMJuWqyFVVgtqe8DPJNPkog0bn8W1MDVAKcPdxRFycBfC1We1RaNp7Rdk513bmtW78RR6OBxBw==", "license": "MIT", "dependencies": { "fs-extra": "^11.1.1", @@ -4009,9 +4009,9 @@ } }, "node_modules/@docusaurus/types": { - "version": "3.10.0", - "resolved": "https://registry.npmjs.org/@docusaurus/types/-/types-3.10.0.tgz", - "integrity": "sha512-F0dOt3FOoO20rRaFK7whGFQZ3ggyrWEdQc/c8/UiRuzhtg4y1w9FspXH5zpCT07uMnJKBPGh+qNazbNlCQqvSw==", + "version": "3.10.1", + "resolved": "https://registry.npmjs.org/@docusaurus/types/-/types-3.10.1.tgz", + "integrity": "sha512-XYMK8k1szDCFMw2V+Xyen0g7Kee1sP3dtFnl7vkGkZOkeAJ/oPDQPL8iz4HBKOo/cwU8QeV6onVjMqtP+tFzsw==", "license": "MIT", "dependencies": { "@mdx-js/mdx": "^3.0.0", @@ -4045,14 +4045,14 @@ } }, "node_modules/@docusaurus/utils": { - "version": "3.10.0", - "resolved": "https://registry.npmjs.org/@docusaurus/utils/-/utils-3.10.0.tgz", - "integrity": "sha512-T3B0WTigsIthe0D4LQa2k+7bJY+c3WS+Wq2JhcznOSpn1lSN64yNtHQXboCj3QnUs1EuAZszQG1SHKu5w5ZrlA==", + "version": "3.10.1", + "resolved": "https://registry.npmjs.org/@docusaurus/utils/-/utils-3.10.1.tgz", + "integrity": "sha512-3ojeJry9xBYdJO6qoyyzqeJFSJBVx2mXhyDzSdjwL2+URFQMf+h25gG38iswGImicK0ELjTd1EL2xzk8hf3QPw==", "license": "MIT", "dependencies": { - "@docusaurus/logger": "3.10.0", - "@docusaurus/types": "3.10.0", - "@docusaurus/utils-common": "3.10.0", + "@docusaurus/logger": "3.10.1", + "@docusaurus/types": "3.10.1", + "@docusaurus/utils-common": "3.10.1", "escape-string-regexp": "^4.0.0", "execa": "^5.1.1", "file-loader": "^6.2.0", @@ -4077,12 +4077,12 @@ } }, "node_modules/@docusaurus/utils-common": { - "version": "3.10.0", - "resolved": "https://registry.npmjs.org/@docusaurus/utils-common/-/utils-common-3.10.0.tgz", - "integrity": "sha512-JyL7sb9QVDgYvudIS81Dv0lsWm7le0vGZSDwsztxWam1SPBqrnkvBy9UYL/amh6pbybkyYTd3CMTkO24oMlCSw==", + "version": "3.10.1", + "resolved": "https://registry.npmjs.org/@docusaurus/utils-common/-/utils-common-3.10.1.tgz", + "integrity": "sha512-5mFSgEADtnFxFH7RLw02QA5MpU5JVUCj0MPeIvi/aF4Fi45tQRIuTwXoXDqJ+1VfQJuYJGz3SI63wmGz4HvXzA==", "license": "MIT", "dependencies": { - "@docusaurus/types": "3.10.0", + "@docusaurus/types": "3.10.1", "tslib": "^2.6.0" }, "engines": { @@ -4090,14 +4090,14 @@ } }, "node_modules/@docusaurus/utils-validation": { - "version": "3.10.0", - "resolved": "https://registry.npmjs.org/@docusaurus/utils-validation/-/utils-validation-3.10.0.tgz", - "integrity": "sha512-c+6n2+ZPOJtWWc8Bb/EYdpSDfjYEScdCu9fB/SNjOmSCf1IdVnGf2T53o0tsz0gDRtCL90tifTL0JE/oMuP1Mw==", + "version": "3.10.1", + "resolved": "https://registry.npmjs.org/@docusaurus/utils-validation/-/utils-validation-3.10.1.tgz", + "integrity": "sha512-cRv1X69jwaWv47waglllgZVWzeBFLhl53XT/XED/83BerVBTC5FTP8WTcVl8Z6sZOegDSwitu/wpCSPCDOT6lg==", "license": "MIT", "dependencies": { - "@docusaurus/logger": "3.10.0", - "@docusaurus/utils": "3.10.0", - "@docusaurus/utils-common": "3.10.0", + "@docusaurus/logger": "3.10.1", + "@docusaurus/utils": "3.10.1", + "@docusaurus/utils-common": "3.10.1", "fs-extra": "^11.2.0", "joi": "^17.9.2", "js-yaml": "^4.1.0", @@ -4112,7 +4112,6 @@ "version": "1.10.0", "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", "integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==", - "dev": true, "license": "MIT", "optional": true, "dependencies": { @@ -4124,7 +4123,6 @@ "version": "1.10.0", "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", - "dev": true, "license": "MIT", "optional": true, "dependencies": { @@ -4135,7 +4133,6 @@ "version": "1.2.1", "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==", - "dev": true, "license": "MIT", "optional": true, "dependencies": { @@ -4773,7 +4770,6 @@ "version": "1.0.7", "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.0.7.tgz", "integrity": "sha512-SeDnOO0Tk7Okiq6DbXmmBODgOAb9dp9gjlphokTUxmt8U3liIP1ZsozBahH69j/RJv+Rfs6IwUKHTgQYJ/HBAw==", - "dev": true, "license": "MIT", "optional": true, "dependencies": { @@ -5050,7 +5046,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -5064,7 +5059,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -5078,7 +5072,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -5092,7 +5085,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -5106,7 +5098,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -5120,7 +5111,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -5134,7 +5124,6 @@ "cpu": [ "wasm32" ], - "dev": true, "license": "MIT", "optional": true, "dependencies": { @@ -5148,7 +5137,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -5162,7 +5150,6 @@ "cpu": [ "ia32" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -5176,7 +5163,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -5568,7 +5554,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "Apache-2.0 AND MIT", "optional": true, "os": [ @@ -5585,7 +5570,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "Apache-2.0 AND MIT", "optional": true, "os": [ @@ -5602,7 +5586,6 @@ "cpu": [ "arm" ], - "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -5619,7 +5602,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "Apache-2.0 AND MIT", "optional": true, "os": [ @@ -5636,7 +5618,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "Apache-2.0 AND MIT", "optional": true, "os": [ @@ -5653,7 +5634,6 @@ "cpu": [ "ppc64" ], - "dev": true, "license": "Apache-2.0 AND MIT", "optional": true, "os": [ @@ -5670,7 +5650,6 @@ "cpu": [ "s390x" ], - "dev": true, "license": "Apache-2.0 AND MIT", "optional": true, "os": [ @@ -5687,7 +5666,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "Apache-2.0 AND MIT", "optional": true, "os": [ @@ -5704,7 +5682,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "Apache-2.0 AND MIT", "optional": true, "os": [ @@ -5721,7 +5698,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "Apache-2.0 AND MIT", "optional": true, "os": [ @@ -5738,7 +5714,6 @@ "cpu": [ "ia32" ], - "dev": true, "license": "Apache-2.0 AND MIT", "optional": true, "os": [ @@ -5755,7 +5730,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "Apache-2.0 AND MIT", "optional": true, "os": [ @@ -5806,7 +5780,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "Apache-2.0 AND MIT", "optional": true, "os": [ @@ -5823,7 +5796,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "Apache-2.0 AND MIT", "optional": true, "os": [ @@ -5840,7 +5812,6 @@ "cpu": [ "arm" ], - "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -5857,7 +5828,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "Apache-2.0 AND MIT", "optional": true, "os": [ @@ -5874,7 +5844,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "Apache-2.0 AND MIT", "optional": true, "os": [ @@ -5891,7 +5860,6 @@ "cpu": [ "ppc64" ], - "dev": true, "license": "Apache-2.0 AND MIT", "optional": true, "os": [ @@ -5908,7 +5876,6 @@ "cpu": [ "s390x" ], - "dev": true, "license": "Apache-2.0 AND MIT", "optional": true, "os": [ @@ -5925,7 +5892,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "Apache-2.0 AND MIT", "optional": true, "os": [ @@ -5942,7 +5908,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "Apache-2.0 AND MIT", "optional": true, "os": [ @@ -5959,7 +5924,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "Apache-2.0 AND MIT", "optional": true, "os": [ @@ -5976,7 +5940,6 @@ "cpu": [ "ia32" ], - "dev": true, "license": "Apache-2.0 AND MIT", "optional": true, "os": [ @@ -5993,7 +5956,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "Apache-2.0 AND MIT", "optional": true, "os": [ @@ -6029,7 +5991,6 @@ "version": "0.10.1", "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz", "integrity": "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==", - "dev": true, "license": "MIT", "optional": true, "dependencies": { @@ -6728,34 +6689,34 @@ } }, "node_modules/algoliasearch": { - "version": "5.50.1", - "resolved": "https://registry.npmjs.org/algoliasearch/-/algoliasearch-5.50.1.tgz", - "integrity": "sha512-/bwdue1/8LWELn/DBalGRfuLsXBLXULJo/yOeavJtDu8rBwxIzC6/Rz9Jg19S21VkJvRuZO1k8CZXBMS73mYbA==", - "license": "MIT", - "dependencies": { - "@algolia/abtesting": "1.16.1", - "@algolia/client-abtesting": "5.50.1", - "@algolia/client-analytics": "5.50.1", - "@algolia/client-common": "5.50.1", - "@algolia/client-insights": "5.50.1", - "@algolia/client-personalization": "5.50.1", - "@algolia/client-query-suggestions": "5.50.1", - "@algolia/client-search": "5.50.1", - "@algolia/ingestion": "1.50.1", - "@algolia/monitoring": "1.50.1", - "@algolia/recommend": "5.50.1", - "@algolia/requester-browser-xhr": "5.50.1", - "@algolia/requester-fetch": "5.50.1", - "@algolia/requester-node-http": "5.50.1" + "version": "5.52.0", + "resolved": "https://registry.npmjs.org/algoliasearch/-/algoliasearch-5.52.0.tgz", + "integrity": "sha512-0ZzY9mjqV7gop/AH8pIBiAS8giXP7WcSiUfoFYIzYAK9QC5c37E4SIVtJVBMwlURc0/uNt2o4RcNRvdHa4CJ5w==", + "license": "MIT", + "dependencies": { + "@algolia/abtesting": "1.18.0", + "@algolia/client-abtesting": "5.52.0", + "@algolia/client-analytics": "5.52.0", + "@algolia/client-common": "5.52.0", + "@algolia/client-insights": "5.52.0", + "@algolia/client-personalization": "5.52.0", + "@algolia/client-query-suggestions": "5.52.0", + "@algolia/client-search": "5.52.0", + "@algolia/ingestion": "1.52.0", + "@algolia/monitoring": "1.52.0", + "@algolia/recommend": "5.52.0", + "@algolia/requester-browser-xhr": "5.52.0", + "@algolia/requester-fetch": "5.52.0", + "@algolia/requester-node-http": "5.52.0" }, "engines": { "node": ">= 14.0.0" } }, "node_modules/algoliasearch-helper": { - "version": "3.28.1", - "resolved": "https://registry.npmjs.org/algoliasearch-helper/-/algoliasearch-helper-3.28.1.tgz", - "integrity": "sha512-6iXpbkkrAI5HFpCWXlNmIDSBuoN/U1XnEvb2yJAoWfqrZ+DrybI7MQ5P5mthFaprmocq+zbi6HxnR28xnZAYBw==", + "version": "3.28.2", + "resolved": "https://registry.npmjs.org/algoliasearch-helper/-/algoliasearch-helper-3.28.2.tgz", + "integrity": "sha512-sexVcXLHrJN54+S0wXD52xV3ySeGZA5T6HMDkb84wT+3UcXCd8af/k2vU5qJTbHv7DoBb4mISJHdyQ2JOo3Aig==", "license": "MIT", "dependencies": { "@algolia/events": "^4.0.1" @@ -6793,33 +6754,6 @@ "node": ">=8" } }, - "node_modules/ansi-escapes": { - "version": "4.3.2", - "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-4.3.2.tgz", - "integrity": "sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ==", - "license": "MIT", - "dependencies": { - "type-fest": "^0.21.3" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/ansi-escapes/node_modules/type-fest": { - "version": "0.21.3", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.21.3.tgz", - "integrity": "sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w==", - "license": "(MIT OR CC0-1.0)", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/ansi-html-community": { "version": "0.0.8", "resolved": "https://registry.npmjs.org/ansi-html-community/-/ansi-html-community-0.0.8.tgz", @@ -6856,6 +6790,15 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, + "node_modules/ansis": { + "version": "3.17.0", + "resolved": "https://registry.npmjs.org/ansis/-/ansis-3.17.0.tgz", + "integrity": "sha512-0qWUglt9JEqLFr3w1I1pbrChn1grhaiAR2ocX1PP/flRmxgtwTzPFFFnfIlD6aMOLQZgSuCRlidD70lvx8yhzg==", + "license": "ISC", + "engines": { + "node": ">=14" + } + }, "node_modules/anymatch": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", @@ -6920,9 +6863,9 @@ } }, "node_modules/autoprefixer": { - "version": "10.4.27", - "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.27.tgz", - "integrity": "sha512-NP9APE+tO+LuJGn7/9+cohklunJsXWiaWEfV3si4Gi/XHDwVNgkwr1J3RQYFIvPy76GmJ9/bW8vyoU1LcxwKHA==", + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.5.0.tgz", + "integrity": "sha512-FMhOoZV4+qR6aTUALKX2rEqGG+oyATvwBt9IIzVR5rMa2HRWPkxf+P+PAJLD1I/H5/II+HuZcBJYEFBpq39ong==", "funding": [ { "type": "opencollective", @@ -6939,8 +6882,8 @@ ], "license": "MIT", "dependencies": { - "browserslist": "^4.28.1", - "caniuse-lite": "^1.0.30001774", + "browserslist": "^4.28.2", + "caniuse-lite": "^1.0.30001787", "fraction.js": "^5.3.4", "picocolors": "^1.1.1", "postcss-value-parser": "^4.2.0" @@ -7046,9 +6989,9 @@ "license": "MIT" }, "node_modules/baseline-browser-mapping": { - "version": "2.10.0", - "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.0.tgz", - "integrity": "sha512-lIyg0szRfYbiy67j9KN8IyeD7q7hcmqnJ1ddWmNt19ItGpNN64mnllmxUNFIOdOm6by97jlL6wfpTTJrmnjWAA==", + "version": "2.10.27", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.27.tgz", + "integrity": "sha512-zEs/ufmZoUd7WftKpKyXaT6RFxpQ5Qm9xytKRHvJfxFV9DFJkZph9RvJ1LcOUi0Z1ZVijMte65JbILeV+8QQEA==", "license": "Apache-2.0", "bin": { "baseline-browser-mapping": "dist/cli.cjs" @@ -7193,9 +7136,9 @@ } }, "node_modules/browserslist": { - "version": "4.28.1", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz", - "integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==", + "version": "4.28.2", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz", + "integrity": "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==", "funding": [ { "type": "opencollective", @@ -7212,11 +7155,11 @@ ], "license": "MIT", "dependencies": { - "baseline-browser-mapping": "^2.9.0", - "caniuse-lite": "^1.0.30001759", - "electron-to-chromium": "^1.5.263", - "node-releases": "^2.0.27", - "update-browserslist-db": "^1.2.0" + "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" @@ -7382,9 +7325,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001774", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001774.tgz", - "integrity": "sha512-DDdwPGz99nmIEv216hKSgLD+D4ikHQHjBC/seF98N9CPqRX4M5mSxT9eTV6oyisnJcuzxtZy4n17yKKQYmYQOA==", + "version": "1.0.30001791", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001791.tgz", + "integrity": "sha512-yk0l/YSrOnFZk3UROpDLQD9+kC1l4meK/wed583AXrzoarMGJcbRi2Q4RaUYbKxYAsZ8sWmaSa/DsLmdBeI1vQ==", "funding": [ { "type": "opencollective", @@ -8881,9 +8824,9 @@ "license": "MIT" }, "node_modules/electron-to-chromium": { - "version": "1.5.302", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.302.tgz", - "integrity": "sha512-sM6HAN2LyK82IyPBpznDRqlTQAtuSaO+ShzFiWTvoMJLHyZ+Y39r8VMfHzwbU8MVBzQ4Wdn85+wlZl2TLGIlwg==", + "version": "1.5.349", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.349.tgz", + "integrity": "sha512-QsWVGyRuY07Aqb234QytTfwd5d9AJlfNIQ5wIOl1L+PZDzI9d9+Fn0FRale/QYlFxt/bUnB0/nLd1jFPGxGK1A==", "license": "ISC" }, "node_modules/emoji-regex": { @@ -9502,30 +9445,6 @@ "node": ">=0.4.0" } }, - "node_modules/figures": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/figures/-/figures-3.2.0.tgz", - "integrity": "sha512-yaduQFRKLXYOGgEn6AZau90j3ggSOyiqXU0F9JZfeXYhNa+Jk4X+s45A2zg5jns87GAFa34BBm2kXw4XpNcbdg==", - "license": "MIT", - "dependencies": { - "escape-string-regexp": "^1.0.5" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/figures/node_modules/escape-string-regexp": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", - "integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==", - "license": "MIT", - "engines": { - "node": ">=0.8.0" - } - }, "node_modules/file-loader": { "version": "6.2.0", "resolved": "https://registry.npmjs.org/file-loader/-/file-loader-6.2.0.tgz", @@ -11306,7 +11225,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -11327,7 +11245,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -11348,7 +11265,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -11369,7 +11285,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -11390,7 +11305,6 @@ "cpu": [ "arm" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -11411,7 +11325,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -11432,7 +11345,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -11453,7 +11365,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -11474,7 +11385,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -11495,7 +11405,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -11516,7 +11425,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -14161,9 +14069,9 @@ } }, "node_modules/node-releases": { - "version": "2.0.27", - "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz", - "integrity": "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==", + "version": "2.0.38", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.38.tgz", + "integrity": "sha512-3qT/88Y3FbH/Kx4szpQQ4HzUbVrHPKTLVpVocKiLfoYvw9XSGOX2FmD2d6DrXbVYyAQTF2HeF6My8jmzx7/CRw==", "license": "MIT" }, "node_modules/normalize-path": { @@ -14238,9 +14146,9 @@ } }, "node_modules/null-loader/node_modules/ajv": { - "version": "6.14.0", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz", - "integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==", + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", + "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", "license": "MIT", "dependencies": { "fast-deep-equal": "^3.1.1", @@ -17068,15 +16976,6 @@ "entities": "^2.0.0" } }, - "node_modules/repeat-string": { - "version": "1.6.1", - "resolved": "https://registry.npmjs.org/repeat-string/-/repeat-string-1.6.1.tgz", - "integrity": "sha512-PV0dzCYDNfRi1jCDbJzpW7jNNDRuCOG/jI5ctQcGKt/clZD+YcPS3yIlWuTJMmESC8aevCFmWJy5wjAFgNqN6w==", - "license": "MIT", - "engines": { - "node": ">=0.10" - } - }, "node_modules/require-from-string": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", @@ -19250,75 +19149,30 @@ } }, "node_modules/webpackbar": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/webpackbar/-/webpackbar-6.0.1.tgz", - "integrity": "sha512-TnErZpmuKdwWBdMoexjio3KKX6ZtoKHRVvLIU0A47R0VVBDtx3ZyOJDktgYixhoJokZTYTt1Z37OkO9pnGJa9Q==", + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/webpackbar/-/webpackbar-7.0.0.tgz", + "integrity": "sha512-aS9soqSO2iCHgqHoCrj4LbfGQUboDCYJPSFOAchEK+9psIjNrfSWW4Y0YEz67MKURNvMmfo0ycOg9d/+OOf9/Q==", "license": "MIT", "dependencies": { - "ansi-escapes": "^4.3.2", - "chalk": "^4.1.2", + "ansis": "^3.2.0", "consola": "^3.2.3", - "figures": "^3.2.0", - "markdown-table": "^2.0.0", "pretty-time": "^1.1.0", - "std-env": "^3.7.0", - "wrap-ansi": "^7.0.0" + "std-env": "^3.7.0" }, "engines": { "node": ">=14.21.3" }, "peerDependencies": { + "@rspack/core": "*", "webpack": "3 || 4 || 5" - } - }, - "node_modules/webpackbar/node_modules/emoji-regex": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", - "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", - "license": "MIT" - }, - "node_modules/webpackbar/node_modules/markdown-table": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/markdown-table/-/markdown-table-2.0.0.tgz", - "integrity": "sha512-Ezda85ToJUBhM6WGaG6veasyym+Tbs3cMAw/ZhOPqXiYsr0jgocBV3j3nx+4lk47plLlIqjwuTm/ywVI+zjJ/A==", - "license": "MIT", - "dependencies": { - "repeat-string": "^1.0.0" }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/webpackbar/node_modules/string-width": { - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", - "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "license": "MIT", - "dependencies": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/webpackbar/node_modules/wrap-ansi": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", - "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", - "license": "MIT", - "dependencies": { - "ansi-styles": "^4.0.0", - "string-width": "^4.1.0", - "strip-ansi": "^6.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + "peerDependenciesMeta": { + "@rspack/core": { + "optional": true + }, + "webpack": { + "optional": true + } } }, "node_modules/websocket-driver": { diff --git a/website/package.json b/website/package.json index 4856d9d2..1caac683 100644 --- a/website/package.json +++ b/website/package.json @@ -14,8 +14,8 @@ "write-heading-ids": "docusaurus write-heading-ids" }, "dependencies": { - "@docusaurus/core": "3.10.0", - "@docusaurus/preset-classic": "3.10.0", + "@docusaurus/core": "3.10.1", + "@docusaurus/preset-classic": "3.10.1", "@mdx-js/react": "^3.0.0", "clsx": "^2.0.0", "prism-react-renderer": "^2.3.0", @@ -23,9 +23,9 @@ "react-dom": "^19.2.5" }, "devDependencies": { - "@docusaurus/faster": "3.10.0", - "@docusaurus/module-type-aliases": "3.10.0", - "@docusaurus/types": "3.10.0" + "@docusaurus/faster": "3.10.1", + "@docusaurus/module-type-aliases": "3.10.1", + "@docusaurus/types": "3.10.1" }, "overrides": { "serialize-javascript": "^7.0.5"