diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 08d68e8..ce820b0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -26,7 +26,7 @@ jobs: contents: read steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - name: Set up Python uses: actions/setup-python@v6 @@ -99,7 +99,7 @@ jobs: - 6379:6379 steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - name: Set up Python uses: actions/setup-python@v6 @@ -139,7 +139,7 @@ jobs: working-directory: frontend - name: Upload backend coverage to Codecov - uses: codecov/codecov-action@v5 + uses: codecov/codecov-action@v6 with: token: ${{ secrets.CODECOV_TOKEN }} files: ./backend/coverage.xml @@ -148,7 +148,7 @@ jobs: fail_ci_if_error: false - name: Upload frontend coverage to Codecov - uses: codecov/codecov-action@v5 + uses: codecov/codecov-action@v6 with: token: ${{ secrets.CODECOV_TOKEN }} files: ./frontend/coverage/lcov.info @@ -165,7 +165,7 @@ jobs: contents: read steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - name: Set up Python uses: actions/setup-python@v6 @@ -206,13 +206,13 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@v6 - name: Set up Docker Buildx uses: docker/setup-buildx-action@v4 - name: Log in to GitHub Container Registry - uses: docker/login-action@v3 + uses: docker/login-action@v4 with: registry: ${{ env.GHCR_REGISTRY }} username: ${{ github.actor }} @@ -220,7 +220,7 @@ jobs: # Requires PRIVATE_REGISTRY_USERNAME and PRIVATE_REGISTRY_PASSWORD secrets - name: Log in to private registry - uses: docker/login-action@v3 + uses: docker/login-action@v4 with: registry: ${{ env.PRIVATE_REGISTRY }} username: ${{ secrets.PRIVATE_REGISTRY_USERNAME }} @@ -228,7 +228,7 @@ jobs: - name: Extract metadata (tags, labels) id: meta - uses: docker/metadata-action@v5 + uses: docker/metadata-action@v6 with: images: | ${{ env.GHCR_REGISTRY }}/${{ github.repository_owner }}/${{ matrix.image_name }} @@ -242,7 +242,7 @@ jobs: - name: Build and push Docker image id: build-push - uses: docker/build-push-action@v5 + uses: docker/build-push-action@v7 with: context: ${{ matrix.context }} push: true @@ -294,7 +294,7 @@ jobs: - name: Checkout k8s-cluster-state if: steps.pat-check.outputs.available == 'true' - uses: actions/checkout@v4 + uses: actions/checkout@v6 with: repository: ${{ env.K8S_STATE_REPO }} token: ${{ secrets.GH_PAT }} @@ -303,7 +303,7 @@ jobs: - name: Update backend image tag in preprod manifest if: steps.pat-check.outputs.available == 'true' - uses: mikefarah/yq@v4.44.6 + uses: mikefarah/yq@v4.52.5 env: IMAGE: ${{ steps.tag.outputs.backend_image }} with: @@ -313,7 +313,7 @@ jobs: - name: Update frontend image tag in preprod manifest if: steps.pat-check.outputs.available == 'true' - uses: mikefarah/yq@v4.44.6 + uses: mikefarah/yq@v4.52.5 env: IMAGE: ${{ steps.tag.outputs.frontend_image }} with: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index a08bfdf..1b27e4c 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -23,7 +23,7 @@ jobs: steps: - name: Checkout Code - uses: actions/checkout@v4 + uses: actions/checkout@v6 with: fetch-depth: 0 token: ${{ secrets.GITHUB_TOKEN }} diff --git a/CHANGELOG.md b/CHANGELOG.md index 5bf6593..fde5620 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,75 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 +## v0.6.3 (2026-04-07) + +### Bug Fixes + +- **lint**: Specify explicit React version to fix ESLint 10 compatibility + ([`efa553c`](https://github.com/christianlouis/InboxConverge/commit/efa553c8d52b2e7595318b5e4897baf13b4d847d)) + +### Chores + +- **ci**: Bump docker/login-action from 3 to 4 + ([`6fe2f9a`](https://github.com/christianlouis/InboxConverge/commit/6fe2f9ad951a75d7c43bbb596cce9af552b82ece)) + +- **deps**: Bump @tanstack/react-query in /frontend + ([`466e852`](https://github.com/christianlouis/InboxConverge/commit/466e852c8bb587ed9f98198c8ef7a570e4cb1aec)) + +- **deps**: Bump @types/node from 25.5.0 to 25.5.2 in /frontend + ([`cc1edd8`](https://github.com/christianlouis/InboxConverge/commit/cc1edd8f5d1893eeecc83afb7795983fe5181b71)) + +- **deps**: Bump aiosmtplib from 3.0.1 to 5.1.0 in /backend + ([`284fbba`](https://github.com/christianlouis/InboxConverge/commit/284fbba47f1082cc52a3d23ec82ef24d443f1c17)) + +- **deps**: Bump alembic from 1.13.1 to 1.18.4 in /backend + ([`bd51a5b`](https://github.com/christianlouis/InboxConverge/commit/bd51a5b541db60610976d784e3518b00b04d9b65)) + +- **deps**: Bump email-validator from 2.1.0.post1 to 2.3.0 in /backend + ([`6f414e1`](https://github.com/christianlouis/InboxConverge/commit/6f414e175a117aa1faa57844797be33b258ac380)) + +- **deps**: Bump eslint from 9.39.4 to 10.2.0 in /frontend + ([`4bb92ac`](https://github.com/christianlouis/InboxConverge/commit/4bb92ac9d030aef180b2b358f4e824068a6c5c74)) + +- **deps**: Bump eslint-config-next from 16.2.1 to 16.2.2 in /frontend + ([`9d53b11`](https://github.com/christianlouis/InboxConverge/commit/9d53b117d4c10282c1525312e048a509fba4aa00)) + +- **deps**: Bump faker from 22.6.0 to 40.12.0 in /backend + ([`9553fae`](https://github.com/christianlouis/InboxConverge/commit/9553fae6b7a06a1780672a8c3ecd1d98b945731d)) + +- **deps**: Bump google-auth-httplib2 from 0.3.0 to 0.3.1 in /backend + ([`233595e`](https://github.com/christianlouis/InboxConverge/commit/233595e5ddf3caf135ff36ab15a702955868b131)) + +- **deps**: Bump google-auth-oauthlib from 1.2.0 to 1.3.1 in /backend + ([`3bb7a7c`](https://github.com/christianlouis/InboxConverge/commit/3bb7a7cd460d01356c89914681d3bb1344231dae)) + +- **deps**: Bump lucide-react from 0.577.0 to 1.7.0 in /frontend + ([`bfaa639`](https://github.com/christianlouis/InboxConverge/commit/bfaa639eedf97df6e81620590b1a21e6e780b1e3)) + +- **deps**: Bump next from 16.2.1 to 16.2.2 in /frontend + ([`a5d8700`](https://github.com/christianlouis/InboxConverge/commit/a5d87001194b9f0764875975e0d792b85e073ceb)) + +- **deps**: Bump python-multipart from 0.0.22 to 0.0.24 in /backend + ([`f479974`](https://github.com/christianlouis/InboxConverge/commit/f479974a5a432183de01a5c58264ff9e35376d7d)) + +- **deps**: Bump sqlalchemy from 2.0.48 to 2.0.49 in /backend + ([`6283d1c`](https://github.com/christianlouis/InboxConverge/commit/6283d1c4b4a46afb43cbafbfbb570e7be23cdef8)) + +- **deps**: Bump stripe from 14.4.1 to 15.0.1 in /backend + ([`582311a`](https://github.com/christianlouis/InboxConverge/commit/582311a18e6ec02ef90b6d049c709ef0057bb837)) + +- **deps**: Bump uvicorn from 0.42.0 to 0.43.0 in /backend + ([`b2d044d`](https://github.com/christianlouis/InboxConverge/commit/b2d044d8c209853f5ee2e58ab7e6b701ba462015)) + + +## v0.6.2 (2026-04-05) + +### Code Style + +- Reformat test_gdpr.py and test_gmail_labels.py to fix black CI check + ([`7afa33f`](https://github.com/christianlouis/InboxConverge/commit/7afa33f3758d289bb0971e3f83bcc30b39e436f1)) + + ## [Unreleased] ### Added @@ -17,6 +86,93 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Unit tests for provider endpoints (`test_providers.py`): 23 tests covering provider presets, Gmail credential CRUD, import labels, authorize URL, debug email, and OAuth callback - Unit tests for mail account endpoints (`test_mail_accounts.py`): 30 tests covering CRUD, toggle, pull-now, test connection, auto-detect, processing runs, and processing logs - Unit tests for authentication endpoints (`test_auth.py`): 22 tests covering register, login, Google OAuth, authorize-url, and helper functions +- **Backend test coverage expanded** (+150 tests, 361 → 511 total): added `test_gdpr.py` (GDPR masking utilities), `test_gmail_labels.py` (Gmail label helpers), `test_notification_service.py` (Apprise notification service), `test_auth_service.py` (OAuth service token exchange and token creation), `test_version_endpoint.py`, `test_auth_endpoints.py` (register, login, Google OAuth, authorize-url, domain helpers), `test_users_endpoints.py` (profile CRUD, SMTP config upsert/delete), `test_notifications_endpoints.py` (full CRUD + test-send), `test_logs_endpoints.py` (processing-runs pagination/filtering + run log retrieval), and `test_app_settings_endpoints.py` (list, upsert, delete, seed-defaults with bootstrap key guards). +## v0.6.1 (2026-04-05) + +### Bug Fixes + +- Upgrade pytest-asyncio to 1.3.0 for pytest 9.x compatibility + ([`39052af`](https://github.com/christianlouis/InboxConverge/commit/39052af4e2a5df1dd29062b7dcced9b1f2f17baf)) + +### Chores + +- **ci**: Bump actions/checkout from 4 to 6 + ([`68a6163`](https://github.com/christianlouis/InboxConverge/commit/68a6163fef8e81e250d6959d94be2174bf9fba31)) + +- **ci**: Bump codecov/codecov-action from 5 to 6 + ([`594e380`](https://github.com/christianlouis/InboxConverge/commit/594e3803013363c0360d32fc329e9b427fa62835)) + +- **ci**: Bump docker/build-push-action from 5 to 7 + ([`2c26c15`](https://github.com/christianlouis/InboxConverge/commit/2c26c156f63cc7335cf7799a443c63d734c047f7)) + +- **ci**: Bump docker/metadata-action from 5 to 6 + ([`0e87da8`](https://github.com/christianlouis/InboxConverge/commit/0e87da85a61b9c1a764313231b69882b9aa55137)) + +- **ci**: Bump mikefarah/yq from 4.44.6 to 4.52.5 + ([`f4c8e4a`](https://github.com/christianlouis/InboxConverge/commit/f4c8e4a1dc98d221eabd137bb71459ebec9de254)) + +- **deps**: Bump @tanstack/react-query in /frontend + ([`348c347`](https://github.com/christianlouis/InboxConverge/commit/348c3477dcbb0c504681cc71df6d7d098ae378df)) + +- **deps**: Bump @types/node from 20.19.30 to 25.5.0 in /frontend + ([`917dacc`](https://github.com/christianlouis/InboxConverge/commit/917dacc7cb9f1fa35a8dc2a72664b7d022039f2c)) + +- **deps**: Bump aiohttp from 3.13.3 to 3.13.4 in /backend + ([`d919939`](https://github.com/christianlouis/InboxConverge/commit/d91993964783c6bfde48b6f115abb6d02b0eec5c)) + +- **deps**: Bump apprise from 1.7.1 to 1.9.9 in /backend + ([`094b1e1`](https://github.com/christianlouis/InboxConverge/commit/094b1e177fcefc77b417a6715446fa13527c1a16)) + +- **deps**: Bump axios from 1.13.6 to 1.14.0 in /frontend + ([`3b580c3`](https://github.com/christianlouis/InboxConverge/commit/3b580c36c751d962e4c164596ce65ef4e7ce5048)) + +- **deps**: Bump bcrypt from 4.3.0 to 5.0.0 in /backend + ([`f0213a0`](https://github.com/christianlouis/InboxConverge/commit/f0213a0b03b05195329323d68a870a51e0a97150)) + +- **deps**: Bump celery from 5.6.2 to 5.6.3 in /backend + ([`5992a9e`](https://github.com/christianlouis/InboxConverge/commit/5992a9e802686227675867a259771dcb3ecf41bb)) + +- **deps**: Bump httpx from 0.26.0 to 0.28.1 in /backend + ([`c50cf6e`](https://github.com/christianlouis/InboxConverge/commit/c50cf6e0b32f2d115e9345054b777d3ab8a63f2b)) + +- **deps**: Bump next from 16.1.7 to 16.2.1 in /frontend + ([`ec90cd6`](https://github.com/christianlouis/InboxConverge/commit/ec90cd600b03fd0aea64a9c395b11b7faaa0d52a)) + +- **deps**: Bump prometheus-client from 0.19.0 to 0.24.1 in /backend + ([`77c8426`](https://github.com/christianlouis/InboxConverge/commit/77c84266fd5ea93f9c59e4ef3d9b37990f232883)) + +- **deps**: Bump pytest from 7.4.4 to 9.0.2 in /backend + ([`4a6f93e`](https://github.com/christianlouis/InboxConverge/commit/4a6f93e5acf5a2f7766e25444a20e11a5918a2b6)) + +- **deps**: Bump python-dotenv from 1.0.0 to 1.2.2 in /backend + ([`4dbe30b`](https://github.com/christianlouis/InboxConverge/commit/4dbe30b0eadb829e0d112b2c9ce877353b9d903e)) + +- **deps**: Bump redis from 7.3.0 to 7.4.0 in /backend + ([`b914ac0`](https://github.com/christianlouis/InboxConverge/commit/b914ac0ecd8a14bb1c3a96a232de330622c93ac7)) + +- **deps**: Bump typescript from 5.9.3 to 6.0.2 in /frontend + ([`726e089`](https://github.com/christianlouis/InboxConverge/commit/726e089c464b81e33ee872cd200b346cb996dcf6)) + +- **deps**: Bump uvicorn from 0.27.0 to 0.42.0 in /backend + ([`ac6a837`](https://github.com/christianlouis/InboxConverge/commit/ac6a837522c1dcfee3fdc9b6e9dca2f94460053f)) + +### Documentation + +- Fix CHANGELOG entry to only reference pytest-asyncio bump + ([`0521c97`](https://github.com/christianlouis/InboxConverge/commit/0521c97394ff3f4e909f9b463bd15958ac06b76f)) + +- Fix secret key generation syntax in README quickstart + ([`9c12555`](https://github.com/christianlouis/InboxConverge/commit/9c12555a8116e1db605d3fc3042de48d292b2be2)) + +- Rewrite README focused on Google 2026 POP/Gmailify shutdown replacement + ([`06efeed`](https://github.com/christianlouis/InboxConverge/commit/06efeed7be70a2d1da3a9e55a63cb8c9556b8c31)) + + +## [Unreleased] + +### Changed + +- Bumped `pytest-asyncio` from 0.23.3 to 1.3.0 to restore compatibility with pytest 9.0.2 in the backend test suite. ## v0.6.0 (2026-03-29) @@ -45,6 +201,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Changed + +- **README rewrite**: Replaced README with a focused, marketing-oriented page targeting users affected by Google's 2026 shutdown of Gmail POP import ("Check mail from other accounts") and Gmailify. Fixed broken CI badge links (pointed to non-existent workflow files; now correctly references `ci.yml`). Removed verbose technical implementation sections in favour of a clear problem statement, comparison table, 5-minute quick-start, and concise delivery-method summary. + ### Added - **Celery tasks test coverage**: Added 40 unit tests for `backend/app/workers/tasks.py`, raising coverage from 9.22% to 96%. Tests cover `_as_utc` helper, `process_mail_account` (Gmail API and SMTP forwarding, credential revocation, empty-email detection, error handling), `process_all_enabled_accounts` (stale-run cleanup, interval checking), and `cleanup_old_logs` (data retention, stale-run recovery). diff --git a/README.md b/README.md index ccf931f..1fe2e76 100644 --- a/README.md +++ b/README.md @@ -1,284 +1,106 @@ # InboxConverge -[![CI Tests](https://github.com/christianlouis/inboxconverge/actions/workflows/test.yml/badge.svg)](https://github.com/christianlouis/inboxconverge/actions/workflows/test.yml) -[![Lint](https://github.com/christianlouis/inboxconverge/actions/workflows/lint.yml/badge.svg)](https://github.com/christianlouis/inboxconverge/actions/workflows/lint.yml) -[![Security Scan](https://github.com/christianlouis/inboxconverge/actions/workflows/security.yml/badge.svg)](https://github.com/christianlouis/inboxconverge/actions/workflows/security.yml) -[![Docker Build](https://github.com/christianlouis/inboxconverge/actions/workflows/docker-build.yml/badge.svg)](https://github.com/christianlouis/inboxconverge/actions/workflows/docker-build.yml) +[![CI](https://github.com/christianlouis/InboxConverge/actions/workflows/ci.yml/badge.svg)](https://github.com/christianlouis/InboxConverge/actions/workflows/ci.yml) [![GitHub Release](https://img.shields.io/github/v/release/christianlouis/InboxConverge?sort=semver&label=release)](https://github.com/christianlouis/InboxConverge/releases/latest) [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) -[![Python 3.11+](https://img.shields.io/badge/python-3.11+-blue.svg)](https://www.python.org/downloads/) [![Docker](https://img.shields.io/badge/docker-ready-blue.svg)](https://www.docker.com/) -A Docker-based solution that automatically fetches emails from POP3 mailboxes and forwards them to Gmail, replacing Google's discontinued POP3 import feature. +**Google is removing "Check mail from other accounts" (POP) and Gmailify in 2026. InboxConverge is the self-hosted replacement.** -## Features +Gmail's built-in POP fetcher and Gmailify are being shut down imminently. Google's suggested alternatives — asking your old provider to configure outbound forwarding, or reading mail in the Gmail mobile app over IMAP — don't replicate the seamless, automatic consolidation you had. InboxConverge does: it polls your POP3/IMAP mailboxes on a schedule and injects new messages directly into your Gmail inbox, exactly like the old feature, running on your own infrastructure. -- **Multiple POP3 Accounts** — support for unlimited POP3 mailboxes -- **Dual Delivery** — inject emails via **Gmail API** (preferred) or forward via **SMTP** -- **Hybrid Configuration** — configure via environment variables, `.env` files, **or** the database -- **Smart Throttling** — configurable rate limiting to stay within Gmail quotas -- **Error Reporting** — multi-channel notifications (Apprise: email, Telegram, Slack, Discord, webhooks) -- **Scheduled Polling** — configurable check intervals (default: every 5 minutes) -- **Docker Ready** — fully containerized with Docker Compose support -- **Secure** — runs as non-root user, SSL/TLS connections, encrypted credential storage +> **Google's own announcement:** *"Gmail no longer supports fetching email from third-party accounts via POP. The 'Check mail from other accounts' option is no longer available in Gmail."* +> +> InboxConverge puts that option back — permanently, on your own terms. -### SaaS Platform (in development) +## Who this is for -The repository also includes a multi-tenant SaaS backend built with FastAPI, PostgreSQL, Redis, and Celery. It adds multi-user support, OAuth2 authentication, POP3/IMAP protocol support, encrypted credential storage, and background job processing. See the [SaaS README](docs/README_SAAS.md) for details. +- You used Gmail's "Check mail from other accounts" and it's going away +- You have one or more external mailboxes (work, old ISP, custom domain) that you want consolidated into Gmail automatically +- You don't want to rely on your old provider supporting outbound forwarding +- You want email delivered cleanly into Gmail without headers being mangled or spam filters misfiring -## Quick Start +## What you get -### Using a Pre-built Docker Image (Recommended) +| | Google POP Import (shutting down 2026) | InboxConverge | +|---|---|---| +| Still works? | ❌ Shutting down 2026 | ✅ | +| Multiple source accounts | Limited | ✅ Unlimited | +| Automatic, scheduled polling | ✅ | ✅ Every 5 min (configurable) | +| Original headers preserved | ❌ | ✅ Via Gmail API injection | +| Counts against sending quota | ❌ N/A | ✅ No (Gmail API) / ⚠️ Yes (SMTP) | +| Alerts when something breaks | ❌ | ✅ Email, Slack, Telegram, Discord… | +| Self-hosted, no third-party dependency | ❌ | ✅ Docker, runs anywhere | +| Open source | ❌ | ✅ MIT | + +## Get started in 5 minutes ```bash -# Pull and configure -curl -O https://raw.githubusercontent.com/christianlouis/inboxconverge/main/docker-compose.yml -curl -o .env https://raw.githubusercontent.com/christianlouis/inboxconverge/main/.env.example +# 1. Grab the config files +curl -O https://raw.githubusercontent.com/christianlouis/InboxConverge/main/docker-compose.yml +curl -o .env https://raw.githubusercontent.com/christianlouis/InboxConverge/main/.env.example -# Edit .env with your credentials +# 2. Fill in your credentials nano .env -# Start +# 3. Launch docker-compose up -d ``` -### Building from Source +Minimum `.env` to get going: -```bash -git clone https://github.com/christianlouis/inboxconverge.git -cd inboxconverge -cp .env.example .env # then edit .env -docker-compose up -d +```dotenv +# Source mailbox — add _2_, _3_, … for additional accounts +POP3_ACCOUNT_1_HOST=pop.yourprovider.com +POP3_ACCOUNT_1_USER=you@yourprovider.com +POP3_ACCOUNT_1_PASSWORD=your-pop3-password + +# Destination — Gmail App Password (quickest way to start) +SMTP_USER=you@gmail.com +SMTP_PASSWORD=xxxx-xxxx-xxxx-xxxx + +# Required internal secrets — run each command below and paste the output +SECRET_KEY= # python -c 'import secrets; print(secrets.token_urlsafe(32))' +ENCRYPTION_KEY= # python -c 'import secrets; print(secrets.token_urlsafe(32))' +DATABASE_URL=postgresql+asyncpg://user:password@localhost:5432/inboxconverge ``` -See the [Quick Start Guide](docs/QUICKSTART.md) for detailed instructions. +See the [Quick Start Guide](docs/QUICKSTART.md) for a full walkthrough, including the recommended Gmail API setup which preserves headers and avoids sending-quota limits. -## Configuration +## Two ways to deliver mail into Gmail -### Hybrid Configuration (Environment + Database) +**Gmail API injection (recommended)** — messages land in your inbox with original `From`, `Reply-To`, and `Message-ID` intact, don't count against your sending quota, and bypass the spam-filter penalties that forwarded mail often triggers. Requires a one-time Google OAuth2 authorisation. -The application supports a **hybrid configuration model**: +**SMTP with App Password (zero-setup fallback)** — works immediately with a [Gmail App Password](https://myaccount.google.com/apppasswords). Forwarded messages may be re-wrapped and count toward your 500-message/day free-tier limit. Good for getting started quickly; upgrade to the API later. -| Source | Priority | Use For | -|--------|----------|---------| -| **Database** (`app_settings` table) | Highest | SMTP, processing, Gmail API, notifications | -| **Environment variables / `.env`** | Fallback | All settings; required for bootstrap settings | -| **Built-in defaults** | Lowest | Sensible defaults for all non-bootstrap settings | +## Key settings -**Bootstrap settings** (`DATABASE_URL`, `SECRET_KEY`, `ENCRYPTION_KEY`) always come from environment variables because the database connection depends on them. +| Variable | Default | What it does | +|---|---|---| +| `CHECK_INTERVAL_MINUTES` | `5` | How often mailboxes are polled | +| `MAX_EMAILS_PER_RUN` | `50` | Maximum messages fetched per account per run | +| `THROTTLE_EMAILS_PER_MINUTE` | `10` | Rate cap toward Gmail | -All other settings (SMTP, processing intervals, Gmail API, etc.) can be managed via the admin API at `/api/v1/settings` and are stored in the PostgreSQL database. When a database setting exists, it takes priority over the corresponding environment variable. - -### POP3 Accounts - -Add multiple POP3 accounts by incrementing the account number in your `.env`: - -```bash -POP3_ACCOUNT_1_HOST=pop.provider1.com -POP3_ACCOUNT_1_USER=user1@provider1.com -POP3_ACCOUNT_1_PASSWORD=password1 - -POP3_ACCOUNT_2_HOST=pop.provider2.com -POP3_ACCOUNT_2_USER=user2@provider2.com -POP3_ACCOUNT_2_PASSWORD=password2 -``` - -### Email Delivery Methods - -The forwarder supports two delivery methods for getting emails into Gmail: - -#### Gmail API Injection (Preferred) - -Emails are injected directly into your Gmail account using Google's `users.messages.insert()` API. This is the **recommended method** because it: - -- Preserves original email headers and metadata exactly as-is -- Does not modify `From`, `Reply-To`, or `Message-ID` headers -- Applies Gmail labels (e.g., `INBOX`) on injection -- Does not count against Gmail's SMTP sending quotas -- Does not require an SMTP App Password - -**Setup:** - -1. Configure Google OAuth2 credentials (`GOOGLE_CLIENT_ID`, `GOOGLE_CLIENT_SECRET`) -2. Authenticate via the SaaS web UI or API (`POST /api/v1/providers/gmail-credential`) -3. Set `delivery_method` to `gmail_api` when creating mail accounts - -**Required OAuth2 Scopes:** -- `https://www.googleapis.com/auth/gmail.insert` -- `https://www.googleapis.com/auth/gmail.labels` - -#### SMTP Forwarding (Fallback) - -Emails are forwarded to Gmail via SMTP. This is the legacy method and is used as a fallback when Gmail API credentials are not available. - -**Limitations vs Gmail API:** -- Modifies email headers (adds `Received`, may rewrite `From`) -- Counts against Gmail's SMTP sending quota (500/day for free accounts) -- Requires a Gmail App Password (see below) -- May trigger spam filters for forwarded mail - -**Setup:** - -1. Go to your [Google Account Security](https://myaccount.google.com/security) -2. Under "Signing in to Google," select **App Passwords** -3. Generate a new app password for "Mail" -4. Set `SMTP_PASSWORD` in your environment or database settings - -### Environment Variables - -> **Note:** All settings marked ★ can also be managed via the database -> through the admin API (`/api/v1/settings`). Database values take precedence. - -#### Bootstrap Settings (env only) - -| Variable | Required | Default | Description | -|----------|----------|---------|-------------| -| `DATABASE_URL` | Yes | `postgresql+asyncpg://...` | PostgreSQL connection string | -| `SECRET_KEY` | Yes | — | JWT signing key (min 32 chars) | -| `ENCRYPTION_KEY` | Yes | — | Credential encryption key (min 32 chars) | - -#### POP3/IMAP Accounts (env only — or via API) - -| Variable | Required | Default | Description | -|----------|----------|---------|-------------| -| `POP3_ACCOUNT_N_HOST` | Yes | — | POP3 server hostname | -| `POP3_ACCOUNT_N_PORT` | No | `995` | POP3 server port | -| `POP3_ACCOUNT_N_USER` | Yes | — | POP3 username | -| `POP3_ACCOUNT_N_PASSWORD` | Yes | — | POP3 password | -| `POP3_ACCOUNT_N_USE_SSL` | No | `true` | Use SSL/TLS | - -#### SMTP Settings (★ database-configurable) - -| Variable | Required | Default | Description | -|----------|----------|---------|-------------| -| `SMTP_HOST` | No | `smtp.gmail.com` | SMTP server | -| `SMTP_PORT` | No | `587` | SMTP port | -| `SMTP_USER` | For SMTP | — | SMTP username | -| `SMTP_PASSWORD` | For SMTP | — | SMTP password (App Password) | -| `SMTP_USE_TLS` | No | `true` | Use STARTTLS | - -#### Gmail API Settings (★ database-configurable) - -| Variable | Required | Default | Description | -|----------|----------|---------|-------------| -| `GOOGLE_CLIENT_ID` | For Gmail API | — | Google OAuth2 client ID | -| `GOOGLE_CLIENT_SECRET` | For Gmail API | — | Google OAuth2 client secret | -| `GMAIL_API_ENABLED` | No | `true` | Enable Gmail API delivery | - -#### Processing Settings (★ database-configurable) - -| Variable | Required | Default | Description | -|----------|----------|---------|-------------| -| `CHECK_INTERVAL_MINUTES` | No | `5` | Polling interval | -| `MAX_EMAILS_PER_RUN` | No | `50` | Max emails per account per run | -| `THROTTLE_EMAILS_PER_MINUTE` | No | `10` | Rate limit | -| `LOG_LEVEL` | No | `INFO` | Logging level | - -## How It Works - -``` -┌─────────────────┐ -│ POP3 Server 1 │ -└────────┬────────┘ - │ (Fetch emails) - ▼ -┌─────────────────┐ ┌──────────────────┐ ┌──────────────────┐ -│ POP3 Server 2 │─────▶│ Forwarder │─────▶│ Gmail API │ -└─────────────────┘ │ Container │ │ (Preferred) │ - │ │ │ └──────────────────┘ -┌────────▼────────┐ │ Config from: │ ┌──────────────────┐ -│ POP3 Server N │ │ • Database │─────▶│ Gmail SMTP │ -└─────────────────┘ │ • Environment │ │ (Fallback) │ - └──────┬───────────┘ └──────────────────┘ - │ (Notifications) - ▼ - ┌─────────────────┐ - │ Apprise │ - │ (Email, Slack, │ - │ Telegram ...) │ - └─────────────────┘ -``` - -1. **Polling** — checks POP3/IMAP mailboxes at the configured interval -2. **Fetching** — retrieves new emails from each account -3. **Delivery** — injects into Gmail via API (preferred) or forwards via SMTP (fallback) -4. **Cleanup** — deletes from source after successful delivery -5. **Throttling** — respects rate limits to avoid quota issues -6. **Notifications** — sends alerts via Apprise (email, Telegram, Slack, Discord, webhooks) - -## Development - -```bash -# Install dependencies -make install-dev - -# Run linting & formatting -make lint -make format - -# Run tests -make test - -# Start backend in dev mode -make run-dev -``` - -See the [Testing Guide](docs/TESTING_GUIDE.md) for the full test workflow. +All settings except the three bootstrap secrets can be changed at runtime in the admin web UI — no restart needed. ## Documentation -Detailed documentation lives in the [`docs/`](docs/) directory: - -| Document | Description | -|----------|-------------| -| [Architecture](docs/ARCHITECTURE.md) | System design and component overview | -| [Quick Start](docs/QUICKSTART.md) | Step-by-step setup guide | -| [Migration Guide](docs/MIGRATION_GUIDE.md) | Upgrading from v1 to v2 | -| [Deployment Checklist](docs/DEPLOYMENT_CHECKLIST.md) | Production deployment guide | -| [Roadmap](docs/ROADMAP.md) | Planned features and milestones | -| [Testing Guide](docs/TESTING_GUIDE.md) | How to run and write tests | -| [Coding Patterns](docs/CODING_PATTERNS.md) | Code style and conventions | -| [SaaS README](docs/README_SAAS.md) | Multi-tenant SaaS platform details | - -## Releases - -This project uses [Semantic Versioning](https://semver.org/) and -[Conventional Commits](https://www.conventionalcommits.org/) to automate -release numbering. Every push to `main` triggers the -[Semantic Release workflow](.github/workflows/release.yml), which: - -1. Inspects commit messages since the last release. -2. Determines the next version number (`patch`, `minor`, or `major`) based on - the commit prefixes (`fix:`, `feat:`, `feat!:` / `BREAKING CHANGE`). -3. Creates a git tag (`vX.Y.Z`), updates the `version` field in - `pyproject.toml`, generates a GitHub Release with release notes, and - updates `CHANGELOG.md` — all automatically. - -| Commit prefix | Version bump | -|---------------|-------------| -| `fix:`, `perf:` | Patch (`0.0.x`) | -| `feat:` | Minor (`0.x.0`) | -| `feat!:` / `BREAKING CHANGE` | Major (`x.0.0`) | - -Browse all releases on the [Releases page](https://github.com/christianlouis/InboxConverge/releases). +| | | +|---|---| +| [Quick Start](docs/QUICKSTART.md) | Step-by-step setup, Gmail API & SMTP | +| [Deployment Checklist](docs/DEPLOYMENT_CHECKLIST.md) | Production hardening guide | +| [Architecture](docs/ARCHITECTURE.md) | How the pieces fit together | +| [Migration Guide](docs/MIGRATION_GUIDE.md) | Upgrading from an older version | +| [Roadmap](docs/ROADMAP.md) | What's coming next | ## Contributing -Contributions are welcome! Please read [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines on: - -- Reporting bugs and suggesting features -- Development setup and code style -- Pull request process +Bug reports, feature requests, and pull requests are welcome — see [CONTRIBUTING.md](CONTRIBUTING.md). ## Security -To report a vulnerability, please see [SECURITY.md](SECURITY.md). **Do not open public issues for security concerns.** +Please report vulnerabilities privately via [SECURITY.md](SECURITY.md) rather than opening a public issue. ## License -This project is licensed under the MIT License — see [LICENSE](LICENSE) for details. - -## Support - -- [Issue Tracker](https://github.com/christianlouis/inboxconverge/issues) -- [Discussions](https://github.com/christianlouis/inboxconverge/discussions) +MIT — see [LICENSE](LICENSE). diff --git a/backend/requirements.txt b/backend/requirements.txt index 4b07356..8afda28 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -1,59 +1,59 @@ # Core Framework fastapi==0.135.2 # Updated: Fixed starlette DoS vulnerabilities (was 0.109.1); pulls in starlette>=1.0.0 -uvicorn[standard]==0.27.0 +uvicorn[standard]==0.43.0 pydantic==2.12.5 pydantic-settings==2.13.1 # Database -sqlalchemy==2.0.48 # Updated: Fixed Python 3.14 incompatibility with TypingOnly assertion (was 2.0.25) -alembic==1.13.1 +sqlalchemy==2.0.49 # Updated: Fixed Python 3.14 incompatibility with TypingOnly assertion (was 2.0.25) +alembic==1.18.4 psycopg2-binary==2.9.11 asyncpg==0.31.0 # Authentication python-jose[cryptography]==3.5.0 # Updated: Fixed algorithm confusion with OpenSSH ECDSA keys (was 3.3.0) -bcrypt==4.3.0 -python-multipart==0.0.22 # Updated: Fixed multiple vulnerabilities (was 0.0.6) +bcrypt==5.0.0 +python-multipart==0.0.24 # Updated: Fixed multiple vulnerabilities (was 0.0.6) authlib==1.6.9 # Updated: Fixed OIDC hash binding, JWE RSA1_5 padding oracle, alg:none bypass, JWK header injection (was 1.6.6) -httpx==0.26.0 +httpx==0.28.1 # Payment Processing -stripe==14.4.1 +stripe==15.0.1 # Email & Mail Processing -aiosmtplib==3.0.1 -aiohttp==3.13.3 # Updated: Fixed zip bomb, DoS, and directory traversal vulnerabilities (was 3.9.1) +aiosmtplib==5.1.0 +aiohttp==3.13.4 # Updated: Fixed zip bomb, DoS, and directory traversal vulnerabilities (was 3.9.1) aioimaplib==2.0.1 -email-validator==2.1.0.post1 +email-validator==2.3.0 # Gmail API (for direct email injection) google-api-python-client==2.193.0 google-auth==2.49.1 -google-auth-oauthlib==1.2.0 -google-auth-httplib2==0.3.0 +google-auth-oauthlib==1.3.1 +google-auth-httplib2==0.3.1 # Job Queue & Cache -celery==5.6.2 -redis==7.3.0 +celery==5.6.3 +redis==7.4.0 # Security & Encryption cryptography==46.0.6 # Updated: Fixed NULL pointer dereference (was 42.0.0) # Notifications -apprise==1.7.1 +apprise==1.9.9 # Monitoring & Logging -prometheus-client==0.19.0 +prometheus-client==0.24.1 python-json-logger==2.0.7 # Development & Testing -pytest==7.4.4 -pytest-asyncio==0.23.3 +pytest==9.0.2 +pytest-asyncio==1.3.0 pytest-cov==4.1.0 -faker==22.6.0 +faker==40.12.0 # Utilities -python-dotenv==1.0.0 +python-dotenv==1.2.2 schedule==1.2.0 tenacity==9.1.4 diff --git a/backend/tests/unit/test_app_settings_endpoints.py b/backend/tests/unit/test_app_settings_endpoints.py new file mode 100644 index 0000000..51c244a --- /dev/null +++ b/backend/tests/unit/test_app_settings_endpoints.py @@ -0,0 +1,241 @@ +""" +Unit tests for app settings endpoints (api/v1/endpoints/app_settings.py). + +All database interactions and auth dependencies are mocked. +""" + +import pytest +from datetime import datetime, timezone +from unittest.mock import AsyncMock, MagicMock, patch + +from httpx import AsyncClient, ASGITransport + +from app.main import create_application +from app.core.database import get_db +from app.core.deps import get_current_superuser, get_current_user +from app.models.database_models import User, SubscriptionTier + +# ── helpers ────────────────────────────────────────────────────────────────── + + +def _make_admin_user(**overrides) -> MagicMock: + defaults = dict( + id=1, + email="admin@example.com", + full_name="Admin", + is_active=True, + is_superuser=True, + subscription_tier=SubscriptionTier.FREE, + subscription_status="active", + google_id=None, + oauth_provider=None, + last_login_at=None, + created_at=datetime.now(timezone.utc), + updated_at=datetime.now(timezone.utc), + stripe_customer_id=None, + stripe_subscription_id=None, + subscription_expires_at=None, + ) + defaults.update(overrides) + u = MagicMock(spec=User) + for k, v in defaults.items(): + setattr(u, k, v) + return u + + +def _make_setting(**overrides) -> MagicMock: + defaults = dict( + id=1, + key="SOME_SETTING", + value="some_value", + value_type="string", + description="A setting", + is_secret=False, + category="general", + ) + defaults.update(overrides) + s = MagicMock() + for k, v in defaults.items(): + setattr(s, k, v) + return s + + +@pytest.fixture +def app(): + return create_application() + + +@pytest.fixture +def mock_db(): + db = AsyncMock() + db.commit = AsyncMock() + db.refresh = AsyncMock() + db.add = MagicMock() + db.delete = AsyncMock() + return db + + +@pytest.fixture +def admin_user(): + return _make_admin_user() + + +@pytest.fixture +async def admin_client(app, admin_user, mock_db): + async def _override_superuser(): + return admin_user + + async def _override_db(): + yield mock_db + + app.dependency_overrides[get_current_superuser] = _override_superuser + app.dependency_overrides[get_current_user] = _override_superuser + app.dependency_overrides[get_db] = _override_db + + transport = ASGITransport(app=app) + async with AsyncClient(transport=transport, base_url="http://test") as client: + yield client + + app.dependency_overrides.clear() + + +# ── GET /app-settings ───────────────────────────────────────────────────────── + + +class TestListSettings: + async def test_returns_list(self, admin_client): + setting = _make_setting() + with patch( + "app.api.v1.endpoints.app_settings.ConfigService.list_all", + new=AsyncMock(return_value=[setting]), + ): + response = await admin_client.get("/api/v1/settings") + assert response.status_code == 200 + data = response.json() + assert isinstance(data, list) + assert len(data) == 1 + assert data[0]["key"] == "SOME_SETTING" + assert data[0]["value"] == "some_value" + + async def test_secret_value_masked(self, admin_client): + setting = _make_setting(key="SECRET_KEY", value="supersecret", is_secret=True) + with patch( + "app.api.v1.endpoints.app_settings.ConfigService.list_all", + new=AsyncMock(return_value=[setting]), + ): + response = await admin_client.get("/api/v1/settings") + assert response.status_code == 200 + data = response.json() + assert data[0]["value"] == "********" + + async def test_filters_by_category(self, admin_client): + with patch( + "app.api.v1.endpoints.app_settings.ConfigService.list_all", + new=AsyncMock(return_value=[]), + ): + response = await admin_client.get("/api/v1/settings?category=general") + assert response.status_code == 200 + + async def test_unauthenticated_401(self, app): + transport = ASGITransport(app=app) + async with AsyncClient(transport=transport, base_url="http://test") as client: + response = await client.get("/api/v1/settings") + assert response.status_code == 401 + + +# ── PUT /app-settings/{key} ─────────────────────────────────────────────────── + + +class TestUpsertSetting: + async def test_upsert_creates_or_updates(self, admin_client): + setting = _make_setting(key="MY_KEY", value="myval") + with patch( + "app.api.v1.endpoints.app_settings.ConfigService.set", + new=AsyncMock(return_value=setting), + ): + response = await admin_client.put( + "/api/v1/settings/MY_KEY", + json={ + "key": "MY_KEY", + "value": "myval", + "value_type": "string", + "is_secret": False, + }, + ) + assert response.status_code == 200 + data = response.json() + assert data["key"] == "MY_KEY" + assert data["value"] == "myval" + + async def test_bootstrap_key_rejected_400(self, admin_client): + response = await admin_client.put( + "/api/v1/settings/SECRET_KEY", + json={"key": "SECRET_KEY", "value": "new_secret"}, + ) + assert response.status_code == 400 + assert "bootstrap" in response.json()["detail"].lower() + + async def test_database_url_rejected(self, admin_client): + response = await admin_client.put( + "/api/v1/settings/DATABASE_URL", + json={"key": "DATABASE_URL", "value": "postgresql://..."}, + ) + assert response.status_code == 400 + + async def test_secret_value_masked_in_response(self, admin_client): + setting = _make_setting(key="API_KEY", value="secret!", is_secret=True) + with patch( + "app.api.v1.endpoints.app_settings.ConfigService.set", + new=AsyncMock(return_value=setting), + ): + response = await admin_client.put( + "/api/v1/settings/API_KEY", + json={ + "key": "API_KEY", + "value": "secret!", + "is_secret": True, + }, + ) + assert response.status_code == 200 + assert response.json()["value"] == "********" + + +# ── DELETE /app-settings/{key} ──────────────────────────────────────────────── + + +class TestDeleteSetting: + async def test_deletes_existing_setting(self, admin_client): + with patch( + "app.api.v1.endpoints.app_settings.ConfigService.delete", + new=AsyncMock(return_value=True), + ): + response = await admin_client.delete("/api/v1/settings/MY_KEY") + assert response.status_code == 204 + + async def test_404_when_not_found(self, admin_client): + with patch( + "app.api.v1.endpoints.app_settings.ConfigService.delete", + new=AsyncMock(return_value=False), + ): + response = await admin_client.delete("/api/v1/settings/MISSING_KEY") + assert response.status_code == 404 + + async def test_bootstrap_key_rejected_400(self, admin_client): + response = await admin_client.delete("/api/v1/settings/SECRET_KEY") + assert response.status_code == 400 + + +# ── POST /app-settings/seed-defaults ───────────────────────────────────────── + + +class TestSeedDefaultSettings: + async def test_seeds_defaults(self, admin_client): + with patch( + "app.api.v1.endpoints.app_settings.ConfigService.seed_defaults", + new=AsyncMock(return_value=5), + ): + response = await admin_client.post("/api/v1/settings/seed-defaults") + assert response.status_code == 200 + data = response.json() + assert data["created"] == 5 + assert "Seeded 5" in data["message"] diff --git a/backend/tests/unit/test_auth_endpoints.py b/backend/tests/unit/test_auth_endpoints.py new file mode 100644 index 0000000..0627134 --- /dev/null +++ b/backend/tests/unit/test_auth_endpoints.py @@ -0,0 +1,371 @@ +""" +Unit tests for authentication endpoints (api/v1/endpoints/auth.py). + +All database interactions and the oauth_service are mocked so no real +PostgreSQL instance or Google credentials are needed. +""" + +import pytest +from datetime import datetime, timezone +from unittest.mock import AsyncMock, MagicMock, patch + +from httpx import AsyncClient, ASGITransport + +from app.main import create_application +from app.core.database import get_db +from app.models.database_models import User, SubscriptionTier + +# ── helpers ────────────────────────────────────────────────────────────────── + + +def _make_user(**overrides) -> MagicMock: + defaults = dict( + id=1, + email="user@example.com", + hashed_password=None, + full_name="Test User", + is_active=True, + is_superuser=False, + subscription_tier=SubscriptionTier.FREE, + subscription_status="active", + google_id=None, + oauth_provider=None, + last_login_at=None, + created_at=datetime.now(timezone.utc), + updated_at=datetime.now(timezone.utc), + stripe_customer_id=None, + stripe_subscription_id=None, + subscription_expires_at=None, + ) + defaults.update(overrides) + u = MagicMock(spec=User) + for k, v in defaults.items(): + setattr(u, k, v) + return u + + +def _scalar_one_or_none(value): + r = MagicMock() + r.scalar_one_or_none.return_value = value + return r + + +@pytest.fixture +def app(): + return create_application() + + +@pytest.fixture +def mock_db(): + db = AsyncMock() + db.commit = AsyncMock() + db.refresh = AsyncMock() + db.add = MagicMock() + return db + + +@pytest.fixture +async def anon_client(app, mock_db): + """Client with no auth (db mocked).""" + + async def _override_db(): + yield mock_db + + app.dependency_overrides[get_db] = _override_db + transport = ASGITransport(app=app) + async with AsyncClient(transport=transport, base_url="http://test") as client: + yield client + app.dependency_overrides.clear() + + +# ── /register ───────────────────────────────────────────────────────────────── + + +class TestRegisterEndpoint: + async def test_register_new_user_201(self, anon_client, mock_db): + # DB returns no existing user + mock_db.execute = AsyncMock(return_value=_scalar_one_or_none(None)) + + # db.refresh will be called with the new User object; we simulate it + # by setting the required response fields on that object. + from datetime import datetime, timezone + from app.models.database_models import SubscriptionTier + + async def _refresh(obj): + obj.id = 99 + obj.email = "new@example.com" + obj.full_name = None + obj.is_active = True + obj.subscription_tier = SubscriptionTier.FREE + obj.subscription_status = "active" + obj.created_at = datetime.now(timezone.utc) + obj.updated_at = datetime.now(timezone.utc) + + mock_db.refresh = AsyncMock(side_effect=_refresh) + + response = await anon_client.post( + "/api/v1/auth/register", + json={"email": "new@example.com", "password": "secretpassword"}, + ) + assert response.status_code == 201 + + async def test_register_existing_user_400(self, anon_client, mock_db): + existing = _make_user(email="taken@example.com") + mock_db.execute = AsyncMock(return_value=_scalar_one_or_none(existing)) + + response = await anon_client.post( + "/api/v1/auth/register", + json={"email": "taken@example.com", "password": "pass"}, + ) + assert response.status_code == 400 + assert "already registered" in response.json()["detail"] + + async def test_register_blocked_domain_403(self, app, mock_db): + """When ALLOWED_DOMAINS is set, unknown domains should get 403.""" + + async def _override_db(): + yield mock_db + + mock_db.execute = AsyncMock(return_value=_scalar_one_or_none(None)) + app.dependency_overrides[get_db] = _override_db + + transport = ASGITransport(app=app) + with patch("app.api.v1.endpoints.auth.settings") as mock_settings: + mock_settings.ALLOWED_DOMAINS = ["allowed.com"] + mock_settings.DEFAULT_USER_TIER = "free" + mock_settings.ADMIN_EMAIL = None + async with AsyncClient( + transport=transport, base_url="http://test" + ) as client: + response = await client.post( + "/api/v1/auth/register", + json={"email": "user@blocked.com", "password": "pass"}, + ) + app.dependency_overrides.clear() + assert response.status_code == 403 + + +# ── /login ───────────────────────────────────────────────────────────────────── + + +class TestLoginEndpoint: + async def test_login_success_returns_tokens(self, anon_client, mock_db): + from app.core.security import get_password_hash + + hashed = get_password_hash("correctpassword") + user = _make_user(email="user@example.com", hashed_password=hashed) + mock_db.execute = AsyncMock(return_value=_scalar_one_or_none(user)) + + response = await anon_client.post( + "/api/v1/auth/login", + data={"username": "user@example.com", "password": "correctpassword"}, + ) + assert response.status_code == 200 + data = response.json() + assert "access_token" in data + assert "refresh_token" in data + assert data["token_type"] == "bearer" + + async def test_login_wrong_password_401(self, anon_client, mock_db): + from app.core.security import get_password_hash + + hashed = get_password_hash("correctpassword") + user = _make_user(email="user@example.com", hashed_password=hashed) + mock_db.execute = AsyncMock(return_value=_scalar_one_or_none(user)) + + response = await anon_client.post( + "/api/v1/auth/login", + data={"username": "user@example.com", "password": "wrongpassword"}, + ) + assert response.status_code == 401 + + async def test_login_user_not_found_401(self, anon_client, mock_db): + mock_db.execute = AsyncMock(return_value=_scalar_one_or_none(None)) + + response = await anon_client.post( + "/api/v1/auth/login", + data={"username": "unknown@example.com", "password": "pass"}, + ) + assert response.status_code == 401 + + async def test_login_inactive_user_403(self, anon_client, mock_db): + from app.core.security import get_password_hash + + hashed = get_password_hash("password") + user = _make_user( + email="user@example.com", hashed_password=hashed, is_active=False + ) + mock_db.execute = AsyncMock(return_value=_scalar_one_or_none(user)) + + response = await anon_client.post( + "/api/v1/auth/login", + data={"username": "user@example.com", "password": "password"}, + ) + assert response.status_code == 403 + + async def test_login_no_password_hash_401(self, anon_client, mock_db): + """OAuth-only users have no hashed_password – login should fail.""" + user = _make_user(hashed_password=None) + mock_db.execute = AsyncMock(return_value=_scalar_one_or_none(user)) + + response = await anon_client.post( + "/api/v1/auth/login", + data={"username": "user@example.com", "password": "pass"}, + ) + assert response.status_code == 401 + + +# ── /google ──────────────────────────────────────────────────────────────────── + + +class TestGoogleOAuthEndpoint: + async def test_google_oauth_unverified_email_400(self, anon_client, mock_db): + with patch( + "app.api.v1.endpoints.auth.oauth_service.get_google_user_info", + new=AsyncMock( + return_value={ + "email": "user@gmail.com", + "google_id": "g123", + "full_name": "Test", + "verified_email": False, + } + ), + ): + response = await anon_client.post( + "/api/v1/auth/google", + json={"code": "code", "redirect_uri": "http://localhost"}, + ) + assert response.status_code == 400 + + async def test_google_oauth_new_user_created(self, anon_client, mock_db): + mock_db.execute = AsyncMock(return_value=_scalar_one_or_none(None)) + mock_db.refresh = AsyncMock(side_effect=lambda obj: None) + + with ( + patch( + "app.api.v1.endpoints.auth.oauth_service.get_google_user_info", + new=AsyncMock( + return_value={ + "email": "google@example.com", + "google_id": "g123", + "full_name": "Google User", + "verified_email": True, + } + ), + ), + patch( + "app.api.v1.endpoints.auth.oauth_service.create_tokens_for_user", + return_value={ + "access_token": "tok", + "refresh_token": "ref", + "token_type": "bearer", + }, + ), + ): + response = await anon_client.post( + "/api/v1/auth/google", + json={"code": "code", "redirect_uri": "http://localhost"}, + ) + assert response.status_code == 200 + assert response.json()["access_token"] == "tok" + + async def test_google_oauth_existing_user_logs_in(self, anon_client, mock_db): + existing = _make_user(email="google@example.com", google_id="g123") + mock_db.execute = AsyncMock(return_value=_scalar_one_or_none(existing)) + mock_db.refresh = AsyncMock(side_effect=lambda obj: None) + + with ( + patch( + "app.api.v1.endpoints.auth.oauth_service.get_google_user_info", + new=AsyncMock( + return_value={ + "email": "google@example.com", + "google_id": "g123", + "full_name": "Google User", + "verified_email": True, + } + ), + ), + patch( + "app.api.v1.endpoints.auth.oauth_service.create_tokens_for_user", + return_value={ + "access_token": "tok2", + "refresh_token": "ref2", + "token_type": "bearer", + }, + ), + ): + response = await anon_client.post( + "/api/v1/auth/google", + json={"code": "code", "redirect_uri": "http://localhost"}, + ) + assert response.status_code == 200 + + +# ── /google/authorize-url ────────────────────────────────────────────────────── + + +class TestGoogleAuthorizeUrl: + async def test_returns_authorization_url(self, anon_client): + response = await anon_client.get( + "/api/v1/auth/google/authorize-url", + params={"redirect_uri": "http://localhost/callback"}, + ) + assert response.status_code == 200 + data = response.json() + assert "authorization_url" in data + assert data["authorization_url"].startswith( + "https://accounts.google.com/o/oauth2/v2/auth" + ) + + async def test_url_contains_redirect_uri(self, anon_client): + redirect = "http://myapp.example.com/callback" + response = await anon_client.get( + "/api/v1/auth/google/authorize-url", + params={"redirect_uri": redirect}, + ) + assert response.status_code == 200 + + +# ── helper functions (domain checks, tier, admin email) ─────────────────────── + + +class TestAuthHelpers: + def test_domain_of(self): + from app.api.v1.endpoints.auth import _domain_of + + assert _domain_of("user@Example.COM") == "example.com" + assert _domain_of("a@b.de") == "b.de" + + def test_default_tier_fallback(self): + from app.api.v1.endpoints.auth import _default_tier + from app.models.database_models import SubscriptionTier + + with patch("app.api.v1.endpoints.auth.settings") as ms: + ms.DEFAULT_USER_TIER = "invalid_tier" + tier = _default_tier() + assert tier == SubscriptionTier.FREE + + def test_default_tier_valid(self): + from app.api.v1.endpoints.auth import _default_tier + from app.models.database_models import SubscriptionTier + + with patch("app.api.v1.endpoints.auth.settings") as ms: + ms.DEFAULT_USER_TIER = "pro" + tier = _default_tier() + assert tier == SubscriptionTier.PRO + + def test_is_admin_email_match(self): + from app.api.v1.endpoints.auth import _is_admin_email + + with patch("app.api.v1.endpoints.auth.settings") as ms: + ms.ADMIN_EMAIL = "admin@example.com" + assert _is_admin_email("ADMIN@EXAMPLE.COM") is True + assert _is_admin_email("other@example.com") is False + + def test_is_admin_email_none_config(self): + from app.api.v1.endpoints.auth import _is_admin_email + + with patch("app.api.v1.endpoints.auth.settings") as ms: + ms.ADMIN_EMAIL = None + assert _is_admin_email("admin@example.com") is False diff --git a/backend/tests/unit/test_auth_service.py b/backend/tests/unit/test_auth_service.py new file mode 100644 index 0000000..71270ee --- /dev/null +++ b/backend/tests/unit/test_auth_service.py @@ -0,0 +1,192 @@ +""" +Unit tests for the OAuth / auth service (services/auth_service.py). + +External HTTP calls are mocked via httpx. No real network or DB needed. +""" + +import pytest +from unittest.mock import AsyncMock, MagicMock, patch + +from fastapi import HTTPException + +from app.services.auth_service import OAuthService +from app.models.database_models import User + +# ── helpers ────────────────────────────────────────────────────────────────── + + +def _make_user(id: int = 1, email: str = "user@example.com") -> MagicMock: + u = MagicMock(spec=User) + u.id = id + u.email = email + return u + + +def _mock_token_response( + access_token: str = "access123", refresh_token: str = "refresh456" +): + resp = MagicMock() + resp.status_code = 200 + resp.json.return_value = { + "access_token": access_token, + "refresh_token": refresh_token, + "expires_in": 3600, + "scope": "openid email profile", + } + return resp + + +def _mock_user_info_response( + email: str = "user@google.com", + name: str = "Test User", + google_id: str = "g123", + verified: bool = True, +): + resp = MagicMock() + resp.status_code = 200 + resp.json.return_value = { + "email": email, + "name": name, + "id": google_id, + "picture": "https://example.com/pic.jpg", + "verified_email": verified, + } + return resp + + +# ── OAuthService.get_google_user_info ──────────────────────────────────────── + + +class TestGetGoogleUserInfo: + async def test_success(self): + svc = OAuthService() + token_resp = _mock_token_response() + user_info_resp = _mock_user_info_response() + + mock_client = AsyncMock() + mock_client.__aenter__ = AsyncMock(return_value=mock_client) + mock_client.__aexit__ = AsyncMock(return_value=False) + mock_client.post = AsyncMock(return_value=token_resp) + mock_client.get = AsyncMock(return_value=user_info_resp) + + with patch( + "app.services.auth_service.httpx.AsyncClient", return_value=mock_client + ): + result = await svc.get_google_user_info( + code="authcode", redirect_uri="http://localhost/callback" + ) + + assert result["email"] == "user@google.com" + assert result["google_id"] == "g123" + assert result["verified_email"] is True + assert result["access_token"] == "access123" + + async def test_token_exchange_fails(self): + svc = OAuthService() + bad_resp = MagicMock() + bad_resp.status_code = 400 + bad_resp.text = "bad_request" + + mock_client = AsyncMock() + mock_client.__aenter__ = AsyncMock(return_value=mock_client) + mock_client.__aexit__ = AsyncMock(return_value=False) + mock_client.post = AsyncMock(return_value=bad_resp) + + with patch( + "app.services.auth_service.httpx.AsyncClient", return_value=mock_client + ): + with pytest.raises(HTTPException) as exc_info: + await svc.get_google_user_info( + code="bad", redirect_uri="http://localhost" + ) + assert exc_info.value.status_code == 400 + + async def test_no_access_token_in_response(self): + svc = OAuthService() + resp = MagicMock() + resp.status_code = 200 + resp.json.return_value = {} # no access_token + + mock_client = AsyncMock() + mock_client.__aenter__ = AsyncMock(return_value=mock_client) + mock_client.__aexit__ = AsyncMock(return_value=False) + mock_client.post = AsyncMock(return_value=resp) + + with patch( + "app.services.auth_service.httpx.AsyncClient", return_value=mock_client + ): + with pytest.raises(HTTPException) as exc_info: + await svc.get_google_user_info( + code="c", redirect_uri="http://localhost" + ) + assert exc_info.value.status_code == 400 + + async def test_user_info_fetch_fails(self): + svc = OAuthService() + token_resp = _mock_token_response() + bad_info = MagicMock() + bad_info.status_code = 500 + bad_info.text = "server error" + + mock_client = AsyncMock() + mock_client.__aenter__ = AsyncMock(return_value=mock_client) + mock_client.__aexit__ = AsyncMock(return_value=False) + mock_client.post = AsyncMock(return_value=token_resp) + mock_client.get = AsyncMock(return_value=bad_info) + + with patch( + "app.services.auth_service.httpx.AsyncClient", return_value=mock_client + ): + with pytest.raises(HTTPException) as exc_info: + await svc.get_google_user_info( + code="c", redirect_uri="http://localhost" + ) + assert exc_info.value.status_code == 400 + + async def test_unexpected_exception_becomes_500(self): + svc = OAuthService() + + mock_client = AsyncMock() + mock_client.__aenter__ = AsyncMock(return_value=mock_client) + mock_client.__aexit__ = AsyncMock(return_value=False) + mock_client.post = AsyncMock(side_effect=RuntimeError("network down")) + + with patch( + "app.services.auth_service.httpx.AsyncClient", return_value=mock_client + ): + with pytest.raises(HTTPException) as exc_info: + await svc.get_google_user_info( + code="c", redirect_uri="http://localhost" + ) + assert exc_info.value.status_code == 500 + + +# ── OAuthService.create_tokens_for_user ────────────────────────────────────── + + +class TestCreateTokensForUser: + def test_returns_all_fields(self): + user = _make_user(id=7) + result = OAuthService.create_tokens_for_user(user) + assert "access_token" in result + assert "refresh_token" in result + assert result["token_type"] == "bearer" + + def test_access_token_is_string(self): + user = _make_user(id=3) + result = OAuthService.create_tokens_for_user(user) + assert isinstance(result["access_token"], str) + assert len(result["access_token"]) > 0 + + def test_refresh_token_is_string(self): + user = _make_user(id=5) + result = OAuthService.create_tokens_for_user(user) + assert isinstance(result["refresh_token"], str) + assert len(result["refresh_token"]) > 0 + + def test_different_users_get_different_tokens(self): + u1 = _make_user(id=1) + u2 = _make_user(id=2) + tokens1 = OAuthService.create_tokens_for_user(u1) + tokens2 = OAuthService.create_tokens_for_user(u2) + assert tokens1["access_token"] != tokens2["access_token"] diff --git a/backend/tests/unit/test_gdpr.py b/backend/tests/unit/test_gdpr.py new file mode 100644 index 0000000..9b85341 --- /dev/null +++ b/backend/tests/unit/test_gdpr.py @@ -0,0 +1,101 @@ +""" +Unit tests for GDPR data masking utilities (core/gdpr.py). +No database or HTTP layer required – pure function tests. +""" + +from app.core.gdpr import mask_email, mask_name, mask_from_header + + +class TestMaskEmail: + def test_typical_address(self): + assert mask_email("john.doe@example.com") == "jo***@e***.com" + + def test_short_local(self): + # local part has only 1 char; still returns that char + *** + result = mask_email("a@b.de") + assert result == "a***@b***.de" + + def test_two_char_local(self): + result = mask_email("ab@x.io") + assert result == "ab***@x***.io" + + def test_domain_with_subdomain_tld(self): + # rsplit('.', 1) splits on the last dot only + result = mask_email("user@mail.example.org") + assert result == "us***@m***.org" + + def test_empty_string(self): + assert mask_email("") == "***" + + def test_no_at_sign(self): + assert mask_email("notanemail") == "***" + + def test_preserves_tld(self): + result = mask_email("hello@world.co.uk") + assert result.endswith(".uk") + + def test_single_char_sld(self): + result = mask_email("user@x.com") + assert result == "us***@x***.com" + + +class TestMaskName: + def test_single_word(self): + assert mask_name("Alice") == "Al***" + + def test_two_words(self): + assert mask_name("John Doe") == "Jo*** Do***" + + def test_single_char_word(self): + result = mask_name("X") + assert result == "X***" + + def test_empty_string(self): + assert mask_name("") == "***" + + def test_three_words(self): + result = mask_name("Jean-Luc Picard") + # Two words separated by space + parts = result.split(" ") + assert len(parts) == 2 + assert all(p.endswith("***") for p in parts) + + def test_long_name(self): + result = mask_name("Alexander") + assert result == "Al***" + + +class TestMaskFromHeader: + def test_display_name_with_angle_email(self): + result = mask_from_header("John Doe ") + assert "<" in result + assert "jo***" in result + assert "Jo***" in result + + def test_plain_email(self): + result = mask_from_header("john.doe@example.com") + assert result == "jo***@e***.com" + + def test_empty_string(self): + assert mask_from_header("") == "***" + + def test_no_display_name_angle_email(self): + result = mask_from_header("") + assert result == "us***@e***.com" + + def test_fallback_no_email_pattern(self): + # String with no recognisable email → falls back to mask_name + result = mask_from_header("JustAName") + assert result == "Ju***" + + def test_quoted_display_name(self): + result = mask_from_header('"Alice Smith" ') + assert "Al***" in result + assert "al***" in result + + def test_angle_email_no_display(self): + # Edge: angle brackets but empty display part + result = mask_from_header(" ") + # No display name → returns masked email only + assert "@" in result + assert "***" in result diff --git a/backend/tests/unit/test_gmail_labels.py b/backend/tests/unit/test_gmail_labels.py new file mode 100644 index 0000000..d7d6c97 --- /dev/null +++ b/backend/tests/unit/test_gmail_labels.py @@ -0,0 +1,183 @@ +""" +Unit tests for Gmail label utilities (utils/gmail_labels.py). +Pure function tests – no database or HTTP layer required. +""" + +from app.utils.gmail_labels import ( + DEFAULT_IMPORT_LABEL_TEMPLATES, + MAX_IMPORT_LABELS, + _normalize_string_list, + normalize_import_label_templates, + extract_granted_scopes, + extract_import_label_templates, + build_gmail_credential_scopes, + render_import_labels, +) + + +class TestNormalizeStringList: + def test_basic_dedup(self): + result = _normalize_string_list(["a", "A", "b"]) + assert result == ["a", "b"] + + def test_strips_whitespace(self): + result = _normalize_string_list([" hello ", "world"]) + assert result == ["hello", "world"] + + def test_empty_strings_filtered(self): + result = _normalize_string_list(["", " ", "real"]) + assert result == ["real"] + + def test_none_returns_empty(self): + assert _normalize_string_list(None) == [] + + def test_preserves_case_in_output(self): + # Case-insensitive dedup but preserves original casing + result = _normalize_string_list(["Hello", "hello"]) + assert result == ["Hello"] + + def test_order_preserved(self): + items = ["c", "a", "b"] + assert _normalize_string_list(items) == ["c", "a", "b"] + + +class TestNormalizeImportLabelTemplates: + def test_none_returns_defaults(self): + result = normalize_import_label_templates(None) + assert result == DEFAULT_IMPORT_LABEL_TEMPLATES + + def test_empty_list_returns_defaults(self): + result = normalize_import_label_templates([]) + assert result == DEFAULT_IMPORT_LABEL_TEMPLATES + + def test_custom_templates(self): + result = normalize_import_label_templates(["archive", "inbox"]) + assert result == ["archive", "inbox"] + + def test_deduplicates(self): + result = normalize_import_label_templates(["tag", "TAG"]) + assert result == ["tag"] + + def test_returns_copy_of_defaults(self): + result = normalize_import_label_templates(None) + result.append("extra") + assert "extra" not in DEFAULT_IMPORT_LABEL_TEMPLATES + + +class TestExtractGrantedScopes: + def test_list_format(self): + result = extract_granted_scopes(["scope1", "scope2"]) + assert result == ["scope1", "scope2"] + + def test_list_filters_non_strings(self): + result = extract_granted_scopes(["valid", 42, None, "other"]) + assert result == ["valid", "other"] + + def test_dict_format(self): + data = {"granted_scopes": ["https://mail.google.com/", "openid"]} + result = extract_granted_scopes(data) + assert result == ["https://mail.google.com/", "openid"] + + def test_dict_missing_granted_scopes(self): + result = extract_granted_scopes({}) + assert result == [] + + def test_dict_non_list_granted_scopes(self): + result = extract_granted_scopes({"granted_scopes": "not-a-list"}) + assert result == [] + + def test_unrecognized_type(self): + assert extract_granted_scopes(None) == [] + assert extract_granted_scopes(42) == [] + assert extract_granted_scopes("string") == [] + + +class TestExtractImportLabelTemplates: + def test_dict_with_templates(self): + data = {"import_label_templates": ["archive", "imported"]} + result = extract_import_label_templates(data) + assert result == ["archive", "imported"] + + def test_dict_missing_key(self): + result = extract_import_label_templates({}) + assert result == DEFAULT_IMPORT_LABEL_TEMPLATES + + def test_non_dict(self): + assert extract_import_label_templates(None) == DEFAULT_IMPORT_LABEL_TEMPLATES + assert extract_import_label_templates([]) == DEFAULT_IMPORT_LABEL_TEMPLATES + + def test_empty_templates_falls_back_to_defaults(self): + result = extract_import_label_templates({"import_label_templates": []}) + assert result == DEFAULT_IMPORT_LABEL_TEMPLATES + + def test_filters_non_strings(self): + data = {"import_label_templates": ["valid", 123, None]} + result = extract_import_label_templates(data) + assert result == ["valid"] + + +class TestBuildGmailCredentialScopes: + def test_basic(self): + result = build_gmail_credential_scopes( + ["https://mail.google.com/"], ["{{source_email}}", "imported"] + ) + assert "granted_scopes" in result + assert "import_label_templates" in result + assert result["granted_scopes"] == ["https://mail.google.com/"] + + def test_none_granted_scopes(self): + result = build_gmail_credential_scopes(None) + assert result["granted_scopes"] == [] + assert result["import_label_templates"] == DEFAULT_IMPORT_LABEL_TEMPLATES + + def test_deduplication(self): + result = build_gmail_credential_scopes(["scope", "SCOPE"]) + assert result["granted_scopes"] == ["scope"] + + +class TestRenderImportLabels: + def test_source_email_substitution(self): + result = render_import_labels(["{{source_email}}"], "user@example.com") + assert result == ["user@example.com"] + + def test_literal_template(self): + result = render_import_labels(["imported"], "user@example.com") + assert result == ["imported"] + + def test_mixed_templates(self): + result = render_import_labels( + ["{{source_email}}", "imported"], "user@example.com" + ) + assert result == ["user@example.com", "imported"] + + def test_deduplication(self): + result = render_import_labels(["tag", "TAG"], "user@example.com") + assert result == ["tag"] + + def test_none_source_email(self): + # Template containing source_email placeholder with no email → empty string, + # gets stripped, filtered out. + result = render_import_labels(["{{source_email}}"], None) + assert result == [] + + def test_empty_source_email(self): + result = render_import_labels(["{{source_email}}"], "") + assert result == [] + + def test_none_templates_uses_defaults(self): + result = render_import_labels(None, "user@example.com") + assert "user@example.com" in result + assert "imported" in result + + def test_whitespace_only_template_filtered(self): + # Whitespace-only entries are stripped to empty strings and then filtered + # by normalize_import_label_templates. When no valid templates remain, + # defaults are returned. The source_email template then renders to the + # source email, and "imported" is included too. + result = render_import_labels([" "], "user@example.com") + # normalize_import_label_templates falls back to defaults → includes + # {{source_email}} (renders to "user@example.com") and "imported" + assert result == ["user@example.com", "imported"] + + def test_max_labels_constant(self): + assert MAX_IMPORT_LABELS == 10 diff --git a/backend/tests/unit/test_logs_endpoints.py b/backend/tests/unit/test_logs_endpoints.py new file mode 100644 index 0000000..c68a186 --- /dev/null +++ b/backend/tests/unit/test_logs_endpoints.py @@ -0,0 +1,335 @@ +""" +Unit tests for processing logs / runs endpoints (api/v1/endpoints/logs.py). + +All database interactions and auth dependencies are mocked. +""" + +import pytest +from datetime import datetime, timezone +from unittest.mock import AsyncMock, MagicMock + +from httpx import AsyncClient, ASGITransport + +from app.main import create_application +from app.core.database import get_db +from app.core.deps import get_current_active_user +from app.models.database_models import ( + User, + ProcessingRun, + ProcessingLog, + SubscriptionTier, +) + +# ── helpers ────────────────────────────────────────────────────────────────── + + +def _make_user(**overrides) -> MagicMock: + defaults = dict( + id=1, + email="user@example.com", + full_name="Test User", + is_active=True, + is_superuser=False, + subscription_tier=SubscriptionTier.FREE, + subscription_status="active", + google_id=None, + oauth_provider=None, + last_login_at=None, + created_at=datetime.now(timezone.utc), + updated_at=datetime.now(timezone.utc), + stripe_customer_id=None, + stripe_subscription_id=None, + subscription_expires_at=None, + ) + defaults.update(overrides) + u = MagicMock(spec=User) + for k, v in defaults.items(): + setattr(u, k, v) + return u + + +def _make_run(**overrides) -> MagicMock: + defaults = dict( + id=1, + mail_account_id=10, + started_at=datetime.now(timezone.utc), + completed_at=datetime.now(timezone.utc), + duration_seconds=1.5, + emails_fetched=3, + emails_forwarded=3, + emails_failed=0, + status="completed", + error_message=None, + ) + defaults.update(overrides) + run = MagicMock(spec=ProcessingRun) + for k, v in defaults.items(): + setattr(run, k, v) + return run + + +def _make_log(**overrides) -> MagicMock: + defaults = dict( + id=1, + timestamp=datetime.now(timezone.utc), + level="INFO", + message="processed", + email_subject="Hello", + email_from="sender@example.com", + success=True, + mail_account_id=10, + processing_run_id=1, + email_size_bytes=1024, + error_details=None, + ) + defaults.update(overrides) + log = MagicMock(spec=ProcessingLog) + for k, v in defaults.items(): + setattr(log, k, v) + return log + + +def _scalar_one(value): + r = MagicMock() + r.scalar_one.return_value = value + return r + + +def _scalar_one_or_none(value): + r = MagicMock() + r.scalar_one_or_none.return_value = value + return r + + +def _rows_all(rows): + r = MagicMock() + r.all.return_value = rows + return r + + +def _scalars_all(values): + r = MagicMock() + scalars = MagicMock() + scalars.all.return_value = values + r.scalars.return_value = scalars + return r + + +@pytest.fixture +def app(): + return create_application() + + +@pytest.fixture +def mock_db(): + db = AsyncMock() + return db + + +@pytest.fixture +def current_user(): + return _make_user() + + +@pytest.fixture +async def auth_client(app, current_user, mock_db): + async def _override_user(): + return current_user + + async def _override_db(): + yield mock_db + + app.dependency_overrides[get_current_active_user] = _override_user + app.dependency_overrides[get_db] = _override_db + + transport = ASGITransport(app=app) + async with AsyncClient(transport=transport, base_url="http://test") as client: + yield client + + app.dependency_overrides.clear() + + +# ── GET /logs ───────────────────────────────────────────────────────────────── + + +class TestListProcessingRuns: + async def test_returns_paginated_list(self, auth_client, mock_db): + run = _make_run() + # The endpoint queries total count then rows + # First execute → count, second execute → rows with joined columns + row = MagicMock() + row.ProcessingRun = run + row.name = "My Account" + row.email_address = "me@example.com" + + mock_db.execute = AsyncMock( + side_effect=[ + _scalar_one(1), # count query + _rows_all([row]), # data query + ] + ) + response = await auth_client.get("/api/v1/processing-runs") + assert response.status_code == 200 + data = response.json() + assert "items" in data + assert "total" in data + assert data["total"] == 1 + assert len(data["items"]) == 1 + + async def test_empty_result(self, auth_client, mock_db): + mock_db.execute = AsyncMock( + side_effect=[ + _scalar_one(0), + _rows_all([]), + ] + ) + response = await auth_client.get("/api/v1/processing-runs") + assert response.status_code == 200 + data = response.json() + assert data["items"] == [] + assert data["total"] == 0 + + async def test_unauthenticated_401(self, app): + transport = ASGITransport(app=app) + async with AsyncClient(transport=transport, base_url="http://test") as client: + response = await client.get("/api/v1/processing-runs") + assert response.status_code == 401 + + async def test_pagination_params(self, auth_client, mock_db): + mock_db.execute = AsyncMock( + side_effect=[ + _scalar_one(0), + _rows_all([]), + ] + ) + response = await auth_client.get("/api/v1/processing-runs?page=2&page_size=5") + assert response.status_code == 200 + data = response.json() + assert data["page"] == 2 + assert data["page_size"] == 5 + + async def test_filter_by_account_id(self, auth_client, mock_db): + mock_db.execute = AsyncMock( + side_effect=[ + _scalar_one(0), + _rows_all([]), + ] + ) + response = await auth_client.get("/api/v1/processing-runs?account_id=5") + assert response.status_code == 200 + + async def test_filter_by_status(self, auth_client, mock_db): + mock_db.execute = AsyncMock( + side_effect=[ + _scalar_one(0), + _rows_all([]), + ] + ) + response = await auth_client.get("/api/v1/processing-runs?status=completed") + assert response.status_code == 200 + + async def test_filter_has_emails(self, auth_client, mock_db): + mock_db.execute = AsyncMock( + side_effect=[ + _scalar_one(0), + _rows_all([]), + ] + ) + response = await auth_client.get("/api/v1/processing-runs?has_emails=true") + assert response.status_code == 200 + + +# ── GET /logs/{run_id} ──────────────────────────────────────────────────────── + + +class TestGetProcessingRun: + async def test_returns_run(self, auth_client, mock_db): + run = _make_run(id=42) + row = MagicMock() + row.ProcessingRun = run + row.name = "Account" + row.email_address = "me@example.com" + + result = MagicMock() + result.one_or_none.return_value = row + mock_db.execute = AsyncMock(return_value=result) + + response = await auth_client.get("/api/v1/processing-runs/42") + assert response.status_code == 200 + data = response.json() + assert data["id"] == 42 + + async def test_404_when_not_found(self, auth_client, mock_db): + result = MagicMock() + result.one_or_none.return_value = None + mock_db.execute = AsyncMock(return_value=result) + + response = await auth_client.get("/api/v1/processing-runs/999") + assert response.status_code == 404 + + +# ── GET /logs/{run_id}/logs ─────────────────────────────────────────────────── + + +class TestGetRunLogs: + async def test_returns_log_entries(self, auth_client, mock_db): + run = _make_run(id=1) + log_entry = _make_log(processing_run_id=1) + + mock_db.execute = AsyncMock( + side_effect=[ + _scalar_one_or_none(run), # ownership check + _scalar_one(1), # count + _scalars_all([log_entry]), # log entries + ] + ) + response = await auth_client.get("/api/v1/processing-runs/1/logs") + assert response.status_code == 200 + data = response.json() + assert "items" in data + assert len(data["items"]) == 1 + + async def test_404_when_run_not_found(self, auth_client, mock_db): + mock_db.execute = AsyncMock(return_value=_scalar_one_or_none(None)) + response = await auth_client.get("/api/v1/processing-runs/999/logs") + assert response.status_code == 404 + + async def test_empty_log_entries(self, auth_client, mock_db): + run = _make_run(id=1) + mock_db.execute = AsyncMock( + side_effect=[ + _scalar_one_or_none(run), + _scalar_one(0), + _scalars_all([]), + ] + ) + response = await auth_client.get("/api/v1/processing-runs/1/logs") + assert response.status_code == 200 + data = response.json() + assert data["items"] == [] + assert data["total"] == 0 + + +# ── helper: _paginate ───────────────────────────────────────────────────────── + + +class TestPaginateHelper: + def test_single_page(self): + from app.api.v1.endpoints.logs import _paginate + + result = _paginate(total=10, page=1, page_size=20) + assert result["total"] == 10 + assert result["pages"] == 1 + + def test_multiple_pages(self): + from app.api.v1.endpoints.logs import _paginate + + result = _paginate(total=25, page=2, page_size=10) + assert result["pages"] == 3 + + def test_zero_total(self): + from app.api.v1.endpoints.logs import _paginate + + result = _paginate(total=0, page=1, page_size=20) + assert result["pages"] == 1 + assert result["total"] == 0 diff --git a/backend/tests/unit/test_notification_service.py b/backend/tests/unit/test_notification_service.py new file mode 100644 index 0000000..6d1d72c --- /dev/null +++ b/backend/tests/unit/test_notification_service.py @@ -0,0 +1,215 @@ +""" +Unit tests for the notification service (services/notification_service.py). + +All external dependencies (database, Apprise) are mocked so no real +infrastructure is needed. +""" + +from unittest.mock import AsyncMock, MagicMock, patch + +from app.services.notification_service import ( + send_user_notification, + send_admin_notification, + test_notification as _test_notification, + _send_apprise, +) + +# ── helpers ────────────────────────────────────────────────────────────────── + + +def _make_notification_config( + *, + id: int = 1, + user_id: int = 42, + apprise_url: str = "json://localhost", + is_enabled: bool = True, + notify_on_errors: bool = True, + notify_on_success: bool = False, +): + cfg = MagicMock() + cfg.id = id + cfg.user_id = user_id + cfg.apprise_url = apprise_url + cfg.is_enabled = is_enabled + cfg.notify_on_errors = notify_on_errors + cfg.notify_on_success = notify_on_success + return cfg + + +def _make_db(configs=None): + """Return an AsyncMock db with execute returning the given config list.""" + db = AsyncMock() + result = MagicMock() + scalars = MagicMock() + scalars.all.return_value = configs or [] + result.scalars.return_value = scalars + db.execute = AsyncMock(return_value=result) + return db + + +# ── send_user_notification ──────────────────────────────────────────────────── + + +class TestSendUserNotification: + async def test_returns_zero_when_no_configs(self): + db = _make_db(configs=[]) + count = await send_user_notification(db, user_id=1, title="T", body="B") + assert count == 0 + + async def test_sends_to_error_channel(self): + cfg = _make_notification_config(notify_on_errors=True, notify_on_success=False) + db = _make_db(configs=[cfg]) + with patch( + "app.services.notification_service._send_apprise", + new=AsyncMock(return_value=True), + ) as mock_send: + count = await send_user_notification( + db, user_id=42, title="Err", body="msg", notify_on_error=True + ) + assert count == 1 + mock_send.assert_awaited_once() + + async def test_skips_error_channel_for_success_notification(self): + cfg = _make_notification_config(notify_on_errors=True, notify_on_success=False) + db = _make_db(configs=[cfg]) + with patch( + "app.services.notification_service._send_apprise", + new=AsyncMock(return_value=True), + ) as mock_send: + count = await send_user_notification( + db, user_id=42, title="Ok", body="msg", notify_on_error=False + ) + assert count == 0 + mock_send.assert_not_awaited() + + async def test_sends_to_success_channel(self): + cfg = _make_notification_config(notify_on_errors=False, notify_on_success=True) + db = _make_db(configs=[cfg]) + with patch( + "app.services.notification_service._send_apprise", + new=AsyncMock(return_value=True), + ) as mock_send: + count = await send_user_notification( + db, user_id=42, title="Ok", body="msg", notify_on_error=False + ) + assert count == 1 + mock_send.assert_awaited_once() + + async def test_failed_apprise_not_counted(self): + cfg = _make_notification_config(notify_on_errors=True) + db = _make_db(configs=[cfg]) + with patch( + "app.services.notification_service._send_apprise", + new=AsyncMock(return_value=False), + ): + count = await send_user_notification(db, user_id=42, title="T", body="B") + assert count == 0 + + async def test_exception_in_apprise_swallowed(self): + cfg = _make_notification_config(notify_on_errors=True) + db = _make_db(configs=[cfg]) + with patch( + "app.services.notification_service._send_apprise", + new=AsyncMock(side_effect=Exception("boom")), + ): + count = await send_user_notification(db, user_id=42, title="T", body="B") + assert count == 0 + + async def test_multiple_channels_counted_individually(self): + cfg1 = _make_notification_config(id=1, notify_on_errors=True) + cfg2 = _make_notification_config(id=2, notify_on_errors=True) + db = _make_db(configs=[cfg1, cfg2]) + with patch( + "app.services.notification_service._send_apprise", + new=AsyncMock(return_value=True), + ): + count = await send_user_notification(db, user_id=42, title="T", body="B") + assert count == 2 + + +# ── send_admin_notification ─────────────────────────────────────────────────── + + +class TestSendAdminNotification: + async def test_returns_zero_when_no_configs(self): + db = _make_db(configs=[]) + count = await send_admin_notification(db, title="T", body="B") + assert count == 0 + + async def test_sends_to_enabled_channel(self): + cfg = MagicMock() + cfg.id = 1 + cfg.apprise_url = "json://localhost" + db = _make_db(configs=[cfg]) + with patch( + "app.services.notification_service._send_apprise", + new=AsyncMock(return_value=True), + ): + count = await send_admin_notification(db, title="T", body="B") + assert count == 1 + + async def test_exception_in_channel_swallowed(self): + cfg = MagicMock() + cfg.id = 1 + cfg.apprise_url = "json://localhost" + db = _make_db(configs=[cfg]) + with patch( + "app.services.notification_service._send_apprise", + new=AsyncMock(side_effect=RuntimeError("oops")), + ): + count = await send_admin_notification(db, title="T", body="B") + assert count == 0 + + +# ── test_notification ───────────────────────────────────────────────────────── + + +class TestTestNotification: + async def test_success(self): + with patch( + "app.services.notification_service._send_apprise", + new=AsyncMock(return_value=True), + ): + ok, msg = await _test_notification("json://localhost") + assert ok is True + assert "success" in msg.lower() + + async def test_failure_from_apprise(self): + with patch( + "app.services.notification_service._send_apprise", + new=AsyncMock(return_value=False), + ): + ok, msg = await _test_notification("json://localhost") + assert ok is False + + async def test_exception_returns_false(self): + with patch( + "app.services.notification_service._send_apprise", + new=AsyncMock(side_effect=Exception("network error")), + ): + ok, msg = await _test_notification("json://localhost") + assert ok is False + assert "error" in msg.lower() + + +# ── _send_apprise internal helper ───────────────────────────────────────────── + + +class TestSendApprise: + async def test_invalid_url_returns_false(self): + # Apprise.add() returns False for unrecognised schemes + with patch("app.services.notification_service.apprise") as mock_apprise_module: + ap_instance = MagicMock() + ap_instance.add.return_value = False + mock_apprise_module.Apprise.return_value = ap_instance + result = await _send_apprise("not-a-valid-url://", "T", "B") + assert result is False + + async def test_valid_url_returns_true(self): + with patch("app.services.notification_service.apprise") as mock_apprise_module: + ap_instance = MagicMock() + ap_instance.add.return_value = True + ap_instance.async_notify = AsyncMock(return_value=True) + mock_apprise_module.Apprise.return_value = ap_instance + result = await _send_apprise("json://localhost", "T", "B") + assert result is True diff --git a/backend/tests/unit/test_notifications_endpoints.py b/backend/tests/unit/test_notifications_endpoints.py new file mode 100644 index 0000000..5075a1d --- /dev/null +++ b/backend/tests/unit/test_notifications_endpoints.py @@ -0,0 +1,278 @@ +""" +Unit tests for notification config endpoints (api/v1/endpoints/notifications.py). + +All database interactions and auth dependencies are mocked. +""" + +import pytest +from datetime import datetime, timezone +from unittest.mock import AsyncMock, MagicMock, patch + +from httpx import AsyncClient, ASGITransport + +from app.main import create_application +from app.core.database import get_db +from app.core.deps import get_current_active_user +from app.models.database_models import ( + User, + NotificationConfig, + NotificationChannel, + SubscriptionTier, +) + +# ── helpers ────────────────────────────────────────────────────────────────── + + +def _make_user(**overrides) -> MagicMock: + defaults = dict( + id=1, + email="user@example.com", + full_name="Test User", + is_active=True, + is_superuser=False, + subscription_tier=SubscriptionTier.FREE, + subscription_status="active", + google_id=None, + oauth_provider=None, + last_login_at=None, + created_at=datetime.now(timezone.utc), + updated_at=datetime.now(timezone.utc), + stripe_customer_id=None, + stripe_subscription_id=None, + subscription_expires_at=None, + ) + defaults.update(overrides) + u = MagicMock(spec=User) + for k, v in defaults.items(): + setattr(u, k, v) + return u + + +def _make_notification_config(**overrides) -> MagicMock: + defaults = dict( + id=1, + user_id=1, + name="Test Notification", + apprise_url="json://localhost", + channel=NotificationChannel.WEBHOOK, + is_enabled=True, + config={}, + notify_on_errors=True, + notify_on_success=False, + notify_threshold=3, + created_at=datetime.now(timezone.utc), + updated_at=datetime.now(timezone.utc), + ) + defaults.update(overrides) + cfg = MagicMock(spec=NotificationConfig) + for k, v in defaults.items(): + setattr(cfg, k, v) + return cfg + + +def _scalar_one_or_none(value): + r = MagicMock() + r.scalar_one_or_none.return_value = value + return r + + +def _scalars_all(values): + r = MagicMock() + scalars = MagicMock() + scalars.all.return_value = values + r.scalars.return_value = scalars + return r + + +@pytest.fixture +def app(): + return create_application() + + +@pytest.fixture +def mock_db(): + db = AsyncMock() + db.commit = AsyncMock() + db.refresh = AsyncMock() + db.add = MagicMock() + db.delete = AsyncMock() + return db + + +@pytest.fixture +def current_user(): + return _make_user() + + +@pytest.fixture +async def auth_client(app, current_user, mock_db): + async def _override_user(): + return current_user + + async def _override_db(): + yield mock_db + + app.dependency_overrides[get_current_active_user] = _override_user + app.dependency_overrides[get_db] = _override_db + + transport = ASGITransport(app=app) + async with AsyncClient(transport=transport, base_url="http://test") as client: + yield client + + app.dependency_overrides.clear() + + +# ── POST /notifications ─────────────────────────────────────────────────────── + + +class TestCreateNotificationConfig: + async def test_creates_config_201(self, auth_client, mock_db, current_user): + created_cfg = _make_notification_config() + + # db.refresh must populate the object returned from the endpoint + async def _refresh(obj): + for k, v in vars(created_cfg).items(): + if not k.startswith("_"): + try: + setattr(obj, k, v) + except AttributeError: + pass + + mock_db.refresh = AsyncMock(side_effect=_refresh) + + response = await auth_client.post( + "/api/v1/notifications", + json={ + "name": "My Webhook", + "apprise_url": "json://localhost", + "channel": "webhook", + "is_enabled": True, + "config": {}, + "notify_on_errors": True, + "notify_on_success": False, + "notify_threshold": 3, + }, + ) + assert response.status_code == 201 + + async def test_unauthenticated_401(self, app): + transport = ASGITransport(app=app) + async with AsyncClient(transport=transport, base_url="http://test") as client: + response = await client.post( + "/api/v1/notifications", + json={ + "name": "x", + "apprise_url": "json://localhost", + "channel": "webhook", + "config": {}, + }, + ) + assert response.status_code == 401 + + +# ── GET /notifications ──────────────────────────────────────────────────────── + + +class TestListNotificationConfigs: + async def test_returns_list(self, auth_client, mock_db): + cfg1 = _make_notification_config(id=1) + cfg2 = _make_notification_config(id=2) + mock_db.execute = AsyncMock(return_value=_scalars_all([cfg1, cfg2])) + response = await auth_client.get("/api/v1/notifications") + assert response.status_code == 200 + assert isinstance(response.json(), list) + assert len(response.json()) == 2 + + async def test_returns_empty_list(self, auth_client, mock_db): + mock_db.execute = AsyncMock(return_value=_scalars_all([])) + response = await auth_client.get("/api/v1/notifications") + assert response.status_code == 200 + assert response.json() == [] + + +# ── GET /notifications/{id} ─────────────────────────────────────────────────── + + +class TestGetNotificationConfig: + async def test_returns_config(self, auth_client, mock_db): + cfg = _make_notification_config(id=5) + mock_db.execute = AsyncMock(return_value=_scalar_one_or_none(cfg)) + response = await auth_client.get("/api/v1/notifications/5") + assert response.status_code == 200 + + async def test_404_when_not_found(self, auth_client, mock_db): + mock_db.execute = AsyncMock(return_value=_scalar_one_or_none(None)) + response = await auth_client.get("/api/v1/notifications/999") + assert response.status_code == 404 + + +# ── PUT /notifications/{id} ─────────────────────────────────────────────────── + + +class TestUpdateNotificationConfig: + async def test_updates_config(self, auth_client, mock_db): + cfg = _make_notification_config(id=5) + mock_db.execute = AsyncMock(return_value=_scalar_one_or_none(cfg)) + mock_db.refresh = AsyncMock(side_effect=lambda obj: None) + + response = await auth_client.put( + "/api/v1/notifications/5", + json={"name": "Updated Name"}, + ) + assert response.status_code == 200 + + async def test_404_when_not_found(self, auth_client, mock_db): + mock_db.execute = AsyncMock(return_value=_scalar_one_or_none(None)) + response = await auth_client.put( + "/api/v1/notifications/999", + json={"name": "Updated"}, + ) + assert response.status_code == 404 + + +# ── DELETE /notifications/{id} ──────────────────────────────────────────────── + + +class TestDeleteNotificationConfig: + async def test_deletes_config_204(self, auth_client, mock_db): + cfg = _make_notification_config(id=5) + mock_db.execute = AsyncMock(return_value=_scalar_one_or_none(cfg)) + response = await auth_client.delete("/api/v1/notifications/5") + assert response.status_code == 204 + mock_db.delete.assert_awaited_once_with(cfg) + + async def test_404_when_not_found(self, auth_client, mock_db): + mock_db.execute = AsyncMock(return_value=_scalar_one_or_none(None)) + response = await auth_client.delete("/api/v1/notifications/999") + assert response.status_code == 404 + + +# ── POST /notifications/test ────────────────────────────────────────────────── + + +class TestTestNotificationConfig: + async def test_test_success(self, auth_client): + with patch( + "app.api.v1.endpoints.notifications.test_notification", + new=AsyncMock(return_value=(True, "sent successfully")), + ): + response = await auth_client.post( + "/api/v1/notifications/test", + json={"apprise_url": "json://localhost"}, + ) + assert response.status_code == 200 + data = response.json() + assert data["success"] is True + + async def test_test_failure(self, auth_client): + with patch( + "app.api.v1.endpoints.notifications.test_notification", + new=AsyncMock(return_value=(False, "delivery failed")), + ): + response = await auth_client.post( + "/api/v1/notifications/test", + json={"apprise_url": "invalid://url"}, + ) + assert response.status_code == 200 + data = response.json() + assert data["success"] is False diff --git a/backend/tests/unit/test_users_endpoints.py b/backend/tests/unit/test_users_endpoints.py new file mode 100644 index 0000000..edf2dde --- /dev/null +++ b/backend/tests/unit/test_users_endpoints.py @@ -0,0 +1,247 @@ +""" +Unit tests for user profile and SMTP config endpoints (api/v1/endpoints/users.py). + +All database interactions and auth dependencies are mocked. +""" + +import pytest +from datetime import datetime, timezone +from unittest.mock import AsyncMock, MagicMock + +from httpx import AsyncClient, ASGITransport + +from app.main import create_application +from app.core.database import get_db +from app.core.deps import get_current_active_user +from app.models.database_models import User, UserSmtpConfig, SubscriptionTier + +# ── helpers ────────────────────────────────────────────────────────────────── + + +def _make_user(**overrides) -> MagicMock: + defaults = dict( + id=1, + email="user@example.com", + full_name="Test User", + is_active=True, + is_superuser=False, + subscription_tier=SubscriptionTier.FREE, + subscription_status="active", + google_id=None, + oauth_provider=None, + last_login_at=None, + created_at=datetime.now(timezone.utc), + updated_at=datetime.now(timezone.utc), + stripe_customer_id=None, + stripe_subscription_id=None, + subscription_expires_at=None, + ) + defaults.update(overrides) + u = MagicMock(spec=User) + for k, v in defaults.items(): + setattr(u, k, v) + return u + + +def _make_smtp_config(**overrides) -> MagicMock: + defaults = dict( + id=1, + user_id=1, + host="smtp.example.com", + port=587, + username="user@example.com", + encrypted_password="encrypted", + use_tls=True, + created_at=datetime.now(timezone.utc), + updated_at=datetime.now(timezone.utc), + ) + defaults.update(overrides) + cfg = MagicMock(spec=UserSmtpConfig) + for k, v in defaults.items(): + setattr(cfg, k, v) + return cfg + + +def _scalar_one_or_none(value): + r = MagicMock() + r.scalar_one_or_none.return_value = value + return r + + +@pytest.fixture +def app(): + return create_application() + + +@pytest.fixture +def mock_db(): + db = AsyncMock() + db.commit = AsyncMock() + db.refresh = AsyncMock() + db.add = MagicMock() + db.delete = AsyncMock() + return db + + +@pytest.fixture +def current_user(): + return _make_user() + + +@pytest.fixture +async def auth_client(app, current_user, mock_db): + async def _override_user(): + return current_user + + async def _override_db(): + yield mock_db + + app.dependency_overrides[get_current_active_user] = _override_user + app.dependency_overrides[get_db] = _override_db + + transport = ASGITransport(app=app) + async with AsyncClient(transport=transport, base_url="http://test") as client: + yield client + + app.dependency_overrides.clear() + + +# ── GET /me ─────────────────────────────────────────────────────────────────── + + +class TestGetMe: + async def test_returns_user_data(self, auth_client, current_user): + response = await auth_client.get("/api/v1/users/me") + assert response.status_code == 200 + data = response.json() + assert data["email"] == current_user.email + + async def test_unauthenticated_401(self, app): + transport = ASGITransport(app=app) + async with AsyncClient(transport=transport, base_url="http://test") as client: + response = await client.get("/api/v1/users/me") + assert response.status_code == 401 + + +# ── PUT /me ─────────────────────────────────────────────────────────────────── + + +class TestUpdateMe: + async def test_update_full_name(self, auth_client, mock_db, current_user): + mock_db.refresh = AsyncMock( + side_effect=lambda obj: setattr(obj, "full_name", "Updated Name") + ) + response = await auth_client.put( + "/api/v1/users/me", json={"full_name": "Updated Name"} + ) + assert response.status_code == 200 + + async def test_update_email(self, auth_client, mock_db, current_user): + mock_db.refresh = AsyncMock( + side_effect=lambda obj: setattr(obj, "email", "new@example.com") + ) + response = await auth_client.put( + "/api/v1/users/me", json={"email": "new@example.com"} + ) + assert response.status_code == 200 + + +# ── GET /smtp-config ────────────────────────────────────────────────────────── + + +class TestGetSmtpConfig: + async def test_returns_config_when_exists(self, auth_client, mock_db): + cfg = _make_smtp_config() + mock_db.execute = AsyncMock(return_value=_scalar_one_or_none(cfg)) + response = await auth_client.get("/api/v1/users/smtp-config") + assert response.status_code == 200 + data = response.json() + assert data["host"] == "smtp.example.com" + assert data["port"] == 587 + assert "has_password" in data + # Password must not be exposed + assert "encrypted_password" not in data + + async def test_returns_404_when_no_config(self, auth_client, mock_db): + mock_db.execute = AsyncMock(return_value=_scalar_one_or_none(None)) + response = await auth_client.get("/api/v1/users/smtp-config") + assert response.status_code == 404 + + +# ── PUT /smtp-config ────────────────────────────────────────────────────────── + + +class TestUpsertSmtpConfig: + async def test_creates_new_config(self, auth_client, mock_db): + mock_db.execute = AsyncMock(return_value=_scalar_one_or_none(None)) + + # db.refresh populates the new config object with required fields + async def _refresh(obj): + obj.id = 1 + obj.user_id = 1 + obj.host = "smtp.example.com" + obj.port = 587 + obj.username = "user@example.com" + obj.encrypted_password = "encrypted" + obj.use_tls = True + obj.created_at = datetime.now(timezone.utc) + obj.updated_at = datetime.now(timezone.utc) + + mock_db.refresh = AsyncMock(side_effect=_refresh) + + from unittest.mock import patch + + with patch( + "app.api.v1.endpoints.users.encrypt_credential", return_value="encrypted" + ): + response = await auth_client.put( + "/api/v1/users/smtp-config", + json={ + "host": "smtp.example.com", + "port": 587, + "username": "user@example.com", + "password": "secret", + "use_tls": True, + }, + ) + assert response.status_code == 200 + + async def test_updates_existing_config(self, auth_client, mock_db): + existing = _make_smtp_config() + mock_db.execute = AsyncMock(return_value=_scalar_one_or_none(existing)) + mock_db.refresh = AsyncMock(side_effect=lambda obj: None) + + from unittest.mock import patch + + with patch( + "app.api.v1.endpoints.users.encrypt_credential", return_value="encrypted" + ): + response = await auth_client.put( + "/api/v1/users/smtp-config", + json={ + "host": "newsmtp.example.com", + "port": 465, + "username": "newuser@example.com", + "password": "newpass", + "use_tls": False, + }, + ) + assert response.status_code == 200 + + +# ── DELETE /smtp-config ─────────────────────────────────────────────────────── + + +class TestDeleteSmtpConfig: + async def test_deletes_existing_config(self, auth_client, mock_db): + existing = _make_smtp_config() + mock_db.execute = AsyncMock(return_value=_scalar_one_or_none(existing)) + response = await auth_client.delete("/api/v1/users/smtp-config") + assert response.status_code == 204 + mock_db.delete.assert_awaited_once_with(existing) + + async def test_no_config_is_noop(self, auth_client, mock_db): + mock_db.execute = AsyncMock(return_value=_scalar_one_or_none(None)) + response = await auth_client.delete("/api/v1/users/smtp-config") + assert response.status_code == 204 + mock_db.delete.assert_not_awaited() diff --git a/backend/tests/unit/test_version_endpoint.py b/backend/tests/unit/test_version_endpoint.py new file mode 100644 index 0000000..633aef5 --- /dev/null +++ b/backend/tests/unit/test_version_endpoint.py @@ -0,0 +1,42 @@ +""" +Unit tests for the version endpoint (api/v1/endpoints/version.py). +""" + +import pytest +from httpx import AsyncClient, ASGITransport + +from app.main import create_application + + +@pytest.fixture +def app(): + return create_application() + + +class TestVersionEndpoint: + async def test_get_version_returns_200(self, app): + transport = ASGITransport(app=app) + async with AsyncClient(transport=transport, base_url="http://test") as client: + response = await client.get("/api/v1/version") + assert response.status_code == 200 + + async def test_get_version_has_version_key(self, app): + transport = ASGITransport(app=app) + async with AsyncClient(transport=transport, base_url="http://test") as client: + response = await client.get("/api/v1/version") + data = response.json() + assert "version" in data + + async def test_get_version_has_build_date_key(self, app): + transport = ASGITransport(app=app) + async with AsyncClient(transport=transport, base_url="http://test") as client: + response = await client.get("/api/v1/version") + data = response.json() + assert "build_date" in data + + async def test_version_is_string_or_none(self, app): + transport = ASGITransport(app=app) + async with AsyncClient(transport=transport, base_url="http://test") as client: + response = await client.get("/api/v1/version") + data = response.json() + assert isinstance(data["version"], str) or data["version"] is None diff --git a/docs/TODO.md b/docs/TODO.md index b212c02..bb89f42 100644 --- a/docs/TODO.md +++ b/docs/TODO.md @@ -4,6 +4,8 @@ Comprehensive task breakdown for repository improvements and production readines ## ✅ Recently Completed +- [x] **Expanded backend test coverage**: Added 150 new unit tests across 10 new test files, increasing the backend test count from 361 to 511. New coverage includes `core/gdpr.py`, `utils/gmail_labels.py`, `services/notification_service.py`, `services/auth_service.py`, and API endpoints for auth, users, notifications, processing-runs/logs, app-settings, and version. + - [x] **Improved test coverage for `mail_processor.py`**: Added 46 new unit tests covering POP3 connection testing, POP3 email fetching, IMAP edge cases, email forwarding (STARTTLS/SSL), and `fetch_emails`/`test_connection` routing. Coverage increased from ~42% to 98%. - [x] **Log noise reduction**: Suppressed `ignored untagged response` INFO messages from `aioimaplib` in Celery workers (set logger to WARNING). Eliminated repeated `file_cache is only supported with oauth2client<4.0.0` warnings from the Gmail API client by passing `cache_discovery=False` to `googleapiclient.discovery.build()`. diff --git a/frontend/eslint.config.mjs b/frontend/eslint.config.mjs index 05e726d..41b28c4 100644 --- a/frontend/eslint.config.mjs +++ b/frontend/eslint.config.mjs @@ -13,6 +13,15 @@ const eslintConfig = defineConfig([ "build/**", "next-env.d.ts", ]), + // Specify explicit React version to avoid eslint-plugin-react calling the + // removed context.getFilename() API when using version: 'detect' with ESLint 10. + { + settings: { + react: { + version: "19", + }, + }, + }, ]); export default eslintConfig; diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 4a7a9cc..8175b20 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -8,10 +8,10 @@ "name": "frontend", "version": "0.1.0", "dependencies": { - "@tanstack/react-query": "^5.95.0", - "axios": "^1.13.6", - "lucide-react": "^0.577.0", - "next": "16.1.7", + "@tanstack/react-query": "^5.96.2", + "axios": "^1.14.0", + "lucide-react": "^1.7.0", + "next": "16.2.2", "react": "19.2.4", "react-dom": "19.2.4", "zustand": "^5.0.12" @@ -22,15 +22,15 @@ "@testing-library/react": "^16.3.2", "@testing-library/user-event": "^14.6.1", "@types/jest": "^30.0.0", - "@types/node": "^20", + "@types/node": "^25", "@types/react": "^19", "@types/react-dom": "^19", - "eslint": "^9", - "eslint-config-next": "16.2.1", + "eslint": "^10", + "eslint-config-next": "16.2.2", "jest": "^30.3.0", "jest-environment-jsdom": "^30.3.0", "tailwindcss": "^4", - "typescript": "^5" + "typescript": "^6" } }, "node_modules/@adobe/css-tools": { @@ -784,167 +784,68 @@ } }, "node_modules/@eslint/config-array": { - "version": "0.21.2", - "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.2.tgz", - "integrity": "sha512-nJl2KGTlrf9GjLimgIru+V/mzgSK0ABCDQRvxw5BjURL7WfH5uoWmizbH7QB6MmnMBd8cIC9uceWnezL1VZWWw==", + "version": "0.23.4", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.23.4.tgz", + "integrity": "sha512-lf19F24LSMfF8weXvW5QEtnLqW70u7kgit5e9PSx0MsHAFclGd1T9ynvWEMDT1w5J4Qt54tomGeAhdoAku1Xow==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@eslint/object-schema": "^2.1.7", + "@eslint/object-schema": "^3.0.4", "debug": "^4.3.1", - "minimatch": "^3.1.5" + "minimatch": "^10.2.4" }, "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - } - }, - "node_modules/@eslint/config-array/node_modules/balanced-match": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", - "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", - "dev": true, - "license": "MIT" - }, - "node_modules/@eslint/config-array/node_modules/brace-expansion": { - "version": "1.1.13", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.13.tgz", - "integrity": "sha512-9ZLprWS6EENmhEOpjCYW2c8VkmOvckIJZfkr7rBW6dObmfgJ/L1GpSYW5Hpo9lDz4D1+n0Ckz8rU7FwHDQiG/w==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" - } - }, - "node_modules/@eslint/config-array/node_modules/minimatch": { - "version": "3.1.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", - "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", - "dev": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^1.1.7" - }, - "engines": { - "node": "*" + "node": "^20.19.0 || ^22.13.0 || >=24" } }, "node_modules/@eslint/config-helpers": { - "version": "0.4.2", - "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.4.2.tgz", - "integrity": "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==", + "version": "0.5.4", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.5.4.tgz", + "integrity": "sha512-jJhqiY3wPMlWWO3370M86CPJ7pt8GmEwSLglMfQhjXal07RCvhmU0as4IuUEW5SJeunfItiEetHmSxCCe9lDBg==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@eslint/core": "^0.17.0" + "@eslint/core": "^1.2.0" }, "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": "^20.19.0 || ^22.13.0 || >=24" } }, "node_modules/@eslint/core": { - "version": "0.17.0", - "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.17.0.tgz", - "integrity": "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==", + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-1.2.0.tgz", + "integrity": "sha512-8FTGbNzTvmSlc4cZBaShkC6YvFMG0riksYWRFKXztqVdXaQbcZLXlFbSpC05s70sGEsXAw0qwhx69JiW7hQS7A==", "dev": true, "license": "Apache-2.0", "dependencies": { "@types/json-schema": "^7.0.15" }, "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - } - }, - "node_modules/@eslint/eslintrc": { - "version": "3.3.5", - "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.5.tgz", - "integrity": "sha512-4IlJx0X0qftVsN5E+/vGujTRIFtwuLbNsVUe7TO6zYPDR1O6nFwvwhIKEKSrl6dZchmYBITazxKoUYOjdtjlRg==", - "dev": true, - "license": "MIT", - "dependencies": { - "ajv": "^6.14.0", - "debug": "^4.3.2", - "espree": "^10.0.1", - "globals": "^14.0.0", - "ignore": "^5.2.0", - "import-fresh": "^3.2.1", - "js-yaml": "^4.1.1", - "minimatch": "^3.1.5", - "strip-json-comments": "^3.1.1" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/@eslint/eslintrc/node_modules/balanced-match": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", - "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", - "dev": true, - "license": "MIT" - }, - "node_modules/@eslint/eslintrc/node_modules/brace-expansion": { - "version": "1.1.13", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.13.tgz", - "integrity": "sha512-9ZLprWS6EENmhEOpjCYW2c8VkmOvckIJZfkr7rBW6dObmfgJ/L1GpSYW5Hpo9lDz4D1+n0Ckz8rU7FwHDQiG/w==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" - } - }, - "node_modules/@eslint/eslintrc/node_modules/minimatch": { - "version": "3.1.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", - "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", - "dev": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^1.1.7" - }, - "engines": { - "node": "*" - } - }, - "node_modules/@eslint/js": { - "version": "9.39.4", - "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.4.tgz", - "integrity": "sha512-nE7DEIchvtiFTwBw4Lfbu59PG+kCofhjsKaCWzxTpt4lfRjRMqG6uMBzKXuEcyXhOHoUp9riAm7/aWYGhXZ9cw==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "url": "https://eslint.org/donate" + "node": "^20.19.0 || ^22.13.0 || >=24" } }, "node_modules/@eslint/object-schema": { - "version": "2.1.7", - "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.7.tgz", - "integrity": "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==", + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-3.0.4.tgz", + "integrity": "sha512-55lO/7+Yp0ISKRP0PsPtNTeNGapXaO085aELZmWCVc5SH3jfrqpuU6YgOdIxMS99ZHkQN1cXKE+cdIqwww9ptw==", "dev": true, "license": "Apache-2.0", "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": "^20.19.0 || ^22.13.0 || >=24" } }, "node_modules/@eslint/plugin-kit": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.4.1.tgz", - "integrity": "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==", + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.7.0.tgz", + "integrity": "sha512-ejvBr8MQCbVsWNZnCwDXjUKq40MDmHalq7cJ6e9s/qzTUFIIo/afzt1Vui9T97FM/V/pN4YsFVoed5NIa96RDg==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@eslint/core": "^0.17.0", + "@eslint/core": "^1.2.0", "levn": "^0.4.1" }, "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": "^20.19.0 || ^22.13.0 || >=24" } }, "node_modules/@humanfs/core": { @@ -2083,15 +1984,15 @@ } }, "node_modules/@next/env": { - "version": "16.1.7", - "resolved": "https://registry.npmjs.org/@next/env/-/env-16.1.7.tgz", - "integrity": "sha512-rJJbIdJB/RQr2F1nylZr/PJzamvNNhfr3brdKP6s/GW850jbtR70QlSfFselvIBbcPUOlQwBakexjFzqLzF6pg==", + "version": "16.2.2", + "resolved": "https://registry.npmjs.org/@next/env/-/env-16.2.2.tgz", + "integrity": "sha512-LqSGz5+xGk9EL/iBDr2yo/CgNQV6cFsNhRR2xhSXYh7B/hb4nePCxlmDvGEKG30NMHDFf0raqSyOZiQrO7BkHQ==", "license": "MIT" }, "node_modules/@next/eslint-plugin-next": { - "version": "16.2.1", - "resolved": "https://registry.npmjs.org/@next/eslint-plugin-next/-/eslint-plugin-next-16.2.1.tgz", - "integrity": "sha512-r0epZGo24eT4g08jJlg2OEryBphXqO8aL18oajoTKLzHJ6jVr6P6FI58DLMug04MwD3j8Fj0YK0slyzneKVyzA==", + "version": "16.2.2", + "resolved": "https://registry.npmjs.org/@next/eslint-plugin-next/-/eslint-plugin-next-16.2.2.tgz", + "integrity": "sha512-IOPbWzDQ+76AtjZioaCjpIY72xNSDMnarZ2GMQ4wjNLvnJEJHqxQwGFhgnIWLV9klb4g/+amg88Tk5OXVpyLTw==", "dev": true, "license": "MIT", "dependencies": { @@ -2099,9 +2000,9 @@ } }, "node_modules/@next/swc-darwin-arm64": { - "version": "16.1.7", - "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.1.7.tgz", - "integrity": "sha512-b2wWIE8sABdyafc4IM8r5Y/dS6kD80JRtOGrUiKTsACFQfWWgUQ2NwoUX1yjFMXVsAwcQeNpnucF2ZrujsBBPg==", + "version": "16.2.2", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.2.2.tgz", + "integrity": "sha512-B92G3ulrwmkDSEJEp9+XzGLex5wC1knrmCSIylyVeiAtCIfvEJYiN3v5kXPlYt5R4RFlsfO/v++aKV63Acrugg==", "cpu": [ "arm64" ], @@ -2115,9 +2016,9 @@ } }, "node_modules/@next/swc-darwin-x64": { - "version": "16.1.7", - "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.1.7.tgz", - "integrity": "sha512-zcnVaaZulS1WL0Ss38R5Q6D2gz7MtBu8GZLPfK+73D/hp4GFMrC2sudLky1QibfV7h6RJBJs/gOFvYP0X7UVlQ==", + "version": "16.2.2", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.2.2.tgz", + "integrity": "sha512-7ZwSgNKJNQiwW0CKhNm9B1WS2L1Olc4B2XY0hPYCAL3epFnugMhuw5TMWzMilQ3QCZcCHoYm9NGWTHbr5REFxw==", "cpu": [ "x64" ], @@ -2131,9 +2032,9 @@ } }, "node_modules/@next/swc-linux-arm64-gnu": { - "version": "16.1.7", - "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.1.7.tgz", - "integrity": "sha512-2ant89Lux/Q3VyC8vNVg7uBaFVP9SwoK2jJOOR0L8TQnX8CAYnh4uctAScy2Hwj2dgjVHqHLORQZJ2wH6VxhSQ==", + "version": "16.2.2", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.2.2.tgz", + "integrity": "sha512-c3m8kBHMziMgo2fICOP/cd/5YlrxDU5YYjAJeQLyFsCqVF8xjOTH/QYG4a2u48CvvZZSj1eHQfBCbyh7kBr30Q==", "cpu": [ "arm64" ], @@ -2147,9 +2048,9 @@ } }, "node_modules/@next/swc-linux-arm64-musl": { - "version": "16.1.7", - "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.1.7.tgz", - "integrity": "sha512-uufcze7LYv0FQg9GnNeZ3/whYfo+1Q3HnQpm16o6Uyi0OVzLlk2ZWoY7j07KADZFY8qwDbsmFnMQP3p3+Ftprw==", + "version": "16.2.2", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.2.2.tgz", + "integrity": "sha512-VKLuscm0P/mIfzt+SDdn2+8TNNJ7f0qfEkA+az7OqQbjzKdBxAHs0UvuiVoCtbwX+dqMEL9U54b5wQ/aN3dHeg==", "cpu": [ "arm64" ], @@ -2163,9 +2064,9 @@ } }, "node_modules/@next/swc-linux-x64-gnu": { - "version": "16.1.7", - "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.1.7.tgz", - "integrity": "sha512-KWVf2gxYvHtvuT+c4MBOGxuse5TD7DsMFYSxVxRBnOzok/xryNeQSjXgxSv9QpIVlaGzEn/pIuI6Koosx8CGWA==", + "version": "16.2.2", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.2.2.tgz", + "integrity": "sha512-kU3OPHJq6sBUjOk7wc5zJ7/lipn8yGldMoAv4z67j6ov6Xo/JvzA7L7LCsyzzsXmgLEhk3Qkpwqaq/1+XpNR3g==", "cpu": [ "x64" ], @@ -2179,9 +2080,9 @@ } }, "node_modules/@next/swc-linux-x64-musl": { - "version": "16.1.7", - "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.1.7.tgz", - "integrity": "sha512-HguhaGwsGr1YAGs68uRKc4aGWxLET+NevJskOcCAwXbwj0fYX0RgZW2gsOCzr9S11CSQPIkxmoSbuVaBp4Z3dA==", + "version": "16.2.2", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.2.2.tgz", + "integrity": "sha512-CKXRILyErMtUftp+coGcZ38ZwE/Aqq45VMCcRLr2I4OXKrgxIBDXHnBgeX/UMil0S09i2JXaDL3Q+TN8D/cKmg==", "cpu": [ "x64" ], @@ -2195,9 +2096,9 @@ } }, "node_modules/@next/swc-win32-arm64-msvc": { - "version": "16.1.7", - "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.1.7.tgz", - "integrity": "sha512-S0n3KrDJokKTeFyM/vGGGR8+pCmXYrjNTk2ZozOL1C/JFdfUIL9O1ATaJOl5r2POe56iRChbsszrjMAdWSv7kQ==", + "version": "16.2.2", + "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.2.2.tgz", + "integrity": "sha512-sS/jSk5VUoShUqINJFvNjVT7JfR5ORYj/+/ZpOYbbIohv/lQfduWnGAycq2wlknbOql2xOR0DoV0s6Xfcy49+g==", "cpu": [ "arm64" ], @@ -2211,9 +2112,9 @@ } }, "node_modules/@next/swc-win32-x64-msvc": { - "version": "16.1.7", - "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.1.7.tgz", - "integrity": "sha512-mwgtg8CNZGYm06LeEd+bNnOUfwOyNem/rOiP14Lsz+AnUY92Zq/LXwtebtUiaeVkhbroRCQ0c8GlR4UT1U+0yg==", + "version": "16.2.2", + "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.2.2.tgz", + "integrity": "sha512-aHaKceJgdySReT7qeck5oShucxWRiiEuwCGK8HHALe6yZga8uyFpLkPgaRw3kkF04U7ROogL/suYCNt/+CuXGA==", "cpu": [ "x64" ], @@ -2673,9 +2574,9 @@ } }, "node_modules/@tanstack/query-core": { - "version": "5.95.0", - "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.95.0.tgz", - "integrity": "sha512-H1/CWCe8tGL3YIVeo770Z6kPbt0B3M1d/iQXIIK1qlFiFt6G2neYdkHgLapOC8uMYNt9DmHjmGukEKgdMk1P+A==", + "version": "5.96.2", + "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.96.2.tgz", + "integrity": "sha512-hzI6cTVh4KNRk8UtoIBS7Lv9g6BnJPXvBKsvYH1aGWvv0347jT3BnSvztOE+kD76XGvZnRC/t6qdW1CaIfwCeA==", "license": "MIT", "funding": { "type": "github", @@ -2683,12 +2584,12 @@ } }, "node_modules/@tanstack/react-query": { - "version": "5.95.0", - "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.95.0.tgz", - "integrity": "sha512-EMP8B+BK9zvnAemT8M/y3z/WO0NjZ7fIUY3T3wnHYK6AA3qK/k33i7tPgCXCejhX0cd4I6bJIXN2GmjrHjDBzg==", + "version": "5.96.2", + "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.96.2.tgz", + "integrity": "sha512-sYyzzJT4G0g02azzJ8o55VFFV31XvFpdUpG+unxS0vSaYsJnSPKGoI6WdPwUucJL1wpgGfwfmntNX/Ub1uOViA==", "license": "MIT", "dependencies": { - "@tanstack/query-core": "5.95.0" + "@tanstack/query-core": "5.96.2" }, "funding": { "type": "github", @@ -2912,6 +2813,13 @@ "@babel/types": "^7.28.2" } }, + "node_modules/@types/esrecurse": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz", + "integrity": "sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/estree": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", @@ -2984,13 +2892,13 @@ "license": "MIT" }, "node_modules/@types/node": { - "version": "20.19.30", - "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.30.tgz", - "integrity": "sha512-WJtwWJu7UdlvzEAUm484QNg5eAoq5QR08KDNx7g45Usrs2NtOPiX8ugDqmKdXkyL03rBqU5dYNYVQetEpBHq2g==", + "version": "25.5.2", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.5.2.tgz", + "integrity": "sha512-tO4ZIRKNC+MDWV4qKVZe3Ql/woTnmHDr5JD8UI5hn2pwBrHEwOEMZK7WlNb5RKB6EoJ02gwmQS9OrjuFnZYdpg==", "dev": true, "license": "MIT", "dependencies": { - "undici-types": "~6.21.0" + "undici-types": "~7.18.0" } }, "node_modules/@types/react": { @@ -3044,101 +2952,15 @@ "dev": true, "license": "MIT" }, - "node_modules/@typescript-eslint/eslint-plugin": { - "version": "8.57.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.57.1.tgz", - "integrity": "sha512-Gn3aqnvNl4NGc6x3/Bqk1AOn0thyTU9bqDRhiRnUWezgvr2OnhYCWCgC8zXXRVqBsIL1pSDt7T9nJUe0oM0kDQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@eslint-community/regexpp": "^4.12.2", - "@typescript-eslint/scope-manager": "8.57.1", - "@typescript-eslint/type-utils": "8.57.1", - "@typescript-eslint/utils": "8.57.1", - "@typescript-eslint/visitor-keys": "8.57.1", - "ignore": "^7.0.5", - "natural-compare": "^1.4.0", - "ts-api-utils": "^2.4.0" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "@typescript-eslint/parser": "^8.57.1", - "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", - "typescript": ">=4.8.4 <6.0.0" - } - }, - "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { - "version": "7.0.5", - "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", - "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 4" - } - }, - "node_modules/@typescript-eslint/parser": { - "version": "8.57.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.57.1.tgz", - "integrity": "sha512-k4eNDan0EIMTT/dUKc/g+rsJ6wcHYhNPdY19VoX/EOtaAG8DLtKCykhrUnuHPYvinn5jhAPgD2Qw9hXBwrahsw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@typescript-eslint/scope-manager": "8.57.1", - "@typescript-eslint/types": "8.57.1", - "@typescript-eslint/typescript-estree": "8.57.1", - "@typescript-eslint/visitor-keys": "8.57.1", - "debug": "^4.4.3" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", - "typescript": ">=4.8.4 <6.0.0" - } - }, - "node_modules/@typescript-eslint/project-service": { - "version": "8.57.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.57.1.tgz", - "integrity": "sha512-vx1F37BRO1OftsYlmG9xay1TqnjNVlqALymwWVuYTdo18XuKxtBpCj1QlzNIEHlvlB27osvXFWptYiEWsVdYsg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@typescript-eslint/tsconfig-utils": "^8.57.1", - "@typescript-eslint/types": "^8.57.1", - "debug": "^4.4.3" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "typescript": ">=4.8.4 <6.0.0" - } - }, "node_modules/@typescript-eslint/scope-manager": { - "version": "8.57.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.57.1.tgz", - "integrity": "sha512-hs/QcpCwlwT2L5S+3fT6gp0PabyGk4Q0Rv2doJXA0435/OpnSR3VRgvrp8Xdoc3UAYSg9cyUjTeFXZEPg/3OKg==", + "version": "8.57.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.57.2.tgz", + "integrity": "sha512-snZKH+W4WbWkrBqj4gUNRIGb/jipDW3qMqVJ4C9rzdFc+wLwruxk+2a5D+uoFcKPAqyqEnSb4l2ULuZf95eSkw==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.57.1", - "@typescript-eslint/visitor-keys": "8.57.1" + "@typescript-eslint/types": "8.57.2", + "@typescript-eslint/visitor-keys": "8.57.2" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -3148,52 +2970,10 @@ "url": "https://opencollective.com/typescript-eslint" } }, - "node_modules/@typescript-eslint/tsconfig-utils": { - "version": "8.57.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.57.1.tgz", - "integrity": "sha512-0lgOZB8cl19fHO4eI46YUx2EceQqhgkPSuCGLlGi79L2jwYY1cxeYc1Nae8Aw1xjgW3PKVDLlr3YJ6Bxx8HkWg==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "typescript": ">=4.8.4 <6.0.0" - } - }, - "node_modules/@typescript-eslint/type-utils": { - "version": "8.57.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.57.1.tgz", - "integrity": "sha512-+Bwwm0ScukFdyoJsh2u6pp4S9ktegF98pYUU0hkphOOqdMB+1sNQhIz8y5E9+4pOioZijrkfNO/HUJVAFFfPKA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@typescript-eslint/types": "8.57.1", - "@typescript-eslint/typescript-estree": "8.57.1", - "@typescript-eslint/utils": "8.57.1", - "debug": "^4.4.3", - "ts-api-utils": "^2.4.0" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", - "typescript": ">=4.8.4 <6.0.0" - } - }, "node_modules/@typescript-eslint/types": { - "version": "8.57.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.57.1.tgz", - "integrity": "sha512-S29BOBPJSFUiblEl6RzPPjJt6w25A6XsBqRVDt53tA/tlL8q7ceQNZHTjPeONt/3S7KRI4quk+yP9jK2WjBiPQ==", + "version": "8.57.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.57.2.tgz", + "integrity": "sha512-/iZM6FnM4tnx9csuTxspMW4BOSegshwX5oBDznJ7S4WggL7Vczz5d2W11ecc4vRrQMQHXRSxzrCsyG5EsPPTbA==", "dev": true, "license": "MIT", "engines": { @@ -3204,79 +2984,14 @@ "url": "https://opencollective.com/typescript-eslint" } }, - "node_modules/@typescript-eslint/typescript-estree": { - "version": "8.57.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.57.1.tgz", - "integrity": "sha512-ybe2hS9G6pXpqGtPli9Gx9quNV0TWLOmh58ADlmZe9DguLq0tiAKVjirSbtM1szG6+QH6rVXyU6GTLQbWnMY+g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@typescript-eslint/project-service": "8.57.1", - "@typescript-eslint/tsconfig-utils": "8.57.1", - "@typescript-eslint/types": "8.57.1", - "@typescript-eslint/visitor-keys": "8.57.1", - "debug": "^4.4.3", - "minimatch": "^10.2.2", - "semver": "^7.7.3", - "tinyglobby": "^0.2.15", - "ts-api-utils": "^2.4.0" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "typescript": ">=4.8.4 <6.0.0" - } - }, - "node_modules/@typescript-eslint/typescript-estree/node_modules/semver": { - "version": "7.7.4", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", - "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/@typescript-eslint/utils": { - "version": "8.57.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.57.1.tgz", - "integrity": "sha512-XUNSJ/lEVFttPMMoDVA2r2bwrl8/oPx8cURtczkSEswY5T3AeLmCy+EKWQNdL4u0MmAHOjcWrqJp2cdvgjn8dQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@eslint-community/eslint-utils": "^4.9.1", - "@typescript-eslint/scope-manager": "8.57.1", - "@typescript-eslint/types": "8.57.1", - "@typescript-eslint/typescript-estree": "8.57.1" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", - "typescript": ">=4.8.4 <6.0.0" - } - }, "node_modules/@typescript-eslint/visitor-keys": { - "version": "8.57.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.57.1.tgz", - "integrity": "sha512-YWnmJkXbofiz9KbnbbwuA2rpGkFPLbAIetcCNO6mJ8gdhdZ/v7WDXsoGFAJuM6ikUFKTlSQnjWnVO4ux+UzS6A==", + "version": "8.57.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.57.2.tgz", + "integrity": "sha512-zhahknjobV2FiD6Ee9iLbS7OV9zi10rG26odsQdfBO/hjSzUQbkIYgda+iNKK1zNiW2ey+Lf8MU5btN17V3dUw==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.57.1", + "@typescript-eslint/types": "8.57.2", "eslint-visitor-keys": "^5.0.0" }, "engines": { @@ -3672,13 +3387,6 @@ "node": ">= 8" } }, - "node_modules/argparse": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", - "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", - "dev": true, - "license": "Python-2.0" - }, "node_modules/aria-query": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.2.tgz", @@ -3899,14 +3607,14 @@ } }, "node_modules/axios": { - "version": "1.13.6", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.6.tgz", - "integrity": "sha512-ChTCHMouEe2kn713WHbQGcuYrr6fXTBiu460OTwWrWob16g1bXn4vtz07Ope7ewMozJAnEquLk5lWQWtBig9DQ==", + "version": "1.14.0", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.14.0.tgz", + "integrity": "sha512-3Y8yrqLSwjuzpXuZ0oIYZ/XGgLwUIBU3uLvbcpb0pidD9ctpShJd43KSlEEkVQg6DS0G9NKyzOvBfUtDKEyHvQ==", "license": "MIT", "dependencies": { "follow-redirects": "^1.15.11", "form-data": "^4.0.5", - "proxy-from-env": "^1.1.0" + "proxy-from-env": "^2.1.0" } }, "node_modules/axobject-query": { @@ -4956,33 +4664,30 @@ } }, "node_modules/eslint": { - "version": "9.39.4", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.4.tgz", - "integrity": "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==", + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.2.0.tgz", + "integrity": "sha512-+L0vBFYGIpSNIt/KWTpFonPrqYvgKw1eUI5Vn7mEogrQcWtWYtNQ7dNqC+px/J0idT3BAkiWrhfS7k+Tum8TUA==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", - "@eslint-community/regexpp": "^4.12.1", - "@eslint/config-array": "^0.21.2", - "@eslint/config-helpers": "^0.4.2", - "@eslint/core": "^0.17.0", - "@eslint/eslintrc": "^3.3.5", - "@eslint/js": "9.39.4", - "@eslint/plugin-kit": "^0.4.1", + "@eslint-community/regexpp": "^4.12.2", + "@eslint/config-array": "^0.23.4", + "@eslint/config-helpers": "^0.5.4", + "@eslint/core": "^1.2.0", + "@eslint/plugin-kit": "^0.7.0", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/retry": "^0.4.2", "@types/estree": "^1.0.6", "ajv": "^6.14.0", - "chalk": "^4.0.0", "cross-spawn": "^7.0.6", "debug": "^4.3.2", "escape-string-regexp": "^4.0.0", - "eslint-scope": "^8.4.0", - "eslint-visitor-keys": "^4.2.1", - "espree": "^10.4.0", - "esquery": "^1.5.0", + "eslint-scope": "^9.1.2", + "eslint-visitor-keys": "^5.0.1", + "espree": "^11.2.0", + "esquery": "^1.7.0", "esutils": "^2.0.2", "fast-deep-equal": "^3.1.3", "file-entry-cache": "^8.0.0", @@ -4992,8 +4697,7 @@ "imurmurhash": "^0.1.4", "is-glob": "^4.0.0", "json-stable-stringify-without-jsonify": "^1.0.1", - "lodash.merge": "^4.6.2", - "minimatch": "^3.1.5", + "minimatch": "^10.2.4", "natural-compare": "^1.4.0", "optionator": "^0.9.3" }, @@ -5001,7 +4705,7 @@ "eslint": "bin/eslint.js" }, "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": "^20.19.0 || ^22.13.0 || >=24" }, "funding": { "url": "https://eslint.org/donate" @@ -5016,13 +4720,13 @@ } }, "node_modules/eslint-config-next": { - "version": "16.2.1", - "resolved": "https://registry.npmjs.org/eslint-config-next/-/eslint-config-next-16.2.1.tgz", - "integrity": "sha512-qhabwjQZ1Mk53XzXvmogf8KQ0tG0CQXF0CZ56+2/lVhmObgmaqj7x5A1DSrWdZd3kwI7GTPGUjFne+krRxYmFg==", + "version": "16.2.2", + "resolved": "https://registry.npmjs.org/eslint-config-next/-/eslint-config-next-16.2.2.tgz", + "integrity": "sha512-6VlvEhwoug2JpVgjZDhyXrJXUEuPY++TddzIpTaIRvlvlXXFgvQUtm3+Zr84IjFm0lXtJt73w19JA08tOaZVwg==", "dev": true, "license": "MIT", "dependencies": { - "@next/eslint-plugin-next": "16.2.1", + "@next/eslint-plugin-next": "16.2.2", "eslint-import-resolver-node": "^0.3.6", "eslint-import-resolver-typescript": "^3.5.2", "eslint-plugin-import": "^2.32.0", @@ -5235,6 +4939,16 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/eslint-config-next/node_modules/ignore": { + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", + "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, "node_modules/eslint-config-next/node_modules/minimatch": { "version": "3.1.5", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", @@ -5272,6 +4986,252 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/eslint-config-next/node_modules/typescript-eslint": { + "version": "8.57.2", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.57.2.tgz", + "integrity": "sha512-VEPQ0iPgWO/sBaZOU1xo4nuNdODVOajPnTIbog2GKYr31nIlZ0fWPoCQgGfF3ETyBl1vn63F/p50Um9Z4J8O8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/eslint-plugin": "8.57.2", + "@typescript-eslint/parser": "8.57.2", + "@typescript-eslint/typescript-estree": "8.57.2", + "@typescript-eslint/utils": "8.57.2" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/eslint-config-next/node_modules/typescript-eslint/node_modules/@typescript-eslint/eslint-plugin": { + "version": "8.57.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.57.2.tgz", + "integrity": "sha512-NZZgp0Fm2IkD+La5PR81sd+g+8oS6JwJje+aRWsDocxHkjyRw0J5L5ZTlN3LI1LlOcGL7ph3eaIUmTXMIjLk0w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/regexpp": "^4.12.2", + "@typescript-eslint/scope-manager": "8.57.2", + "@typescript-eslint/type-utils": "8.57.2", + "@typescript-eslint/utils": "8.57.2", + "@typescript-eslint/visitor-keys": "8.57.2", + "ignore": "^7.0.5", + "natural-compare": "^1.4.0", + "ts-api-utils": "^2.4.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^8.57.2", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/eslint-config-next/node_modules/typescript-eslint/node_modules/@typescript-eslint/eslint-plugin/node_modules/@typescript-eslint/type-utils": { + "version": "8.57.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.57.2.tgz", + "integrity": "sha512-Co6ZCShm6kIbAM/s+oYVpKFfW7LBc6FXoPXjTRQ449PPNBY8U0KZXuevz5IFuuUj2H9ss40atTaf9dlGLzbWZg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.57.2", + "@typescript-eslint/typescript-estree": "8.57.2", + "@typescript-eslint/utils": "8.57.2", + "debug": "^4.4.3", + "ts-api-utils": "^2.4.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/eslint-config-next/node_modules/typescript-eslint/node_modules/@typescript-eslint/parser": { + "version": "8.57.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.57.2.tgz", + "integrity": "sha512-30ScMRHIAD33JJQkgfGW1t8CURZtjc2JpTrq5n2HFhOefbAhb7ucc7xJwdWcrEtqUIYJ73Nybpsggii6GtAHjA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/scope-manager": "8.57.2", + "@typescript-eslint/types": "8.57.2", + "@typescript-eslint/typescript-estree": "8.57.2", + "@typescript-eslint/visitor-keys": "8.57.2", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/eslint-config-next/node_modules/typescript-eslint/node_modules/@typescript-eslint/typescript-estree": { + "version": "8.57.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.57.2.tgz", + "integrity": "sha512-2MKM+I6g8tJxfSmFKOnHv2t8Sk3T6rF20A1Puk0svLK+uVapDZB/4pfAeB7nE83uAZrU6OxW+HmOd5wHVdXwXA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/project-service": "8.57.2", + "@typescript-eslint/tsconfig-utils": "8.57.2", + "@typescript-eslint/types": "8.57.2", + "@typescript-eslint/visitor-keys": "8.57.2", + "debug": "^4.4.3", + "minimatch": "^10.2.2", + "semver": "^7.7.3", + "tinyglobby": "^0.2.15", + "ts-api-utils": "^2.4.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/eslint-config-next/node_modules/typescript-eslint/node_modules/@typescript-eslint/typescript-estree/node_modules/@typescript-eslint/project-service": { + "version": "8.57.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.57.2.tgz", + "integrity": "sha512-FuH0wipFywXRTHf+bTTjNyuNQQsQC3qh/dYzaM4I4W0jrCqjCVuUh99+xd9KamUfmCGPvbO8NDngo/vsnNVqgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/tsconfig-utils": "^8.57.2", + "@typescript-eslint/types": "^8.57.2", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/eslint-config-next/node_modules/typescript-eslint/node_modules/@typescript-eslint/typescript-estree/node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.57.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.57.2.tgz", + "integrity": "sha512-3Lm5DSM+DCowsUOJC+YqHHnKEfFh5CoGkj5Z31NQSNF4l5wdOwqGn99wmwN/LImhfY3KJnmordBq/4+VDe2eKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/eslint-config-next/node_modules/typescript-eslint/node_modules/@typescript-eslint/utils": { + "version": "8.57.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.57.2.tgz", + "integrity": "sha512-krRIbvPK1ju1WBKIefiX+bngPs+odIQUtR7kymzPfo1POVw3jlF+nLkmexdSSd4UCbDcQn+wMBATOOmpBbqgKg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.9.1", + "@typescript-eslint/scope-manager": "8.57.2", + "@typescript-eslint/types": "8.57.2", + "@typescript-eslint/typescript-estree": "8.57.2" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/eslint-config-next/node_modules/typescript-eslint/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/eslint-config-next/node_modules/typescript-eslint/node_modules/brace-expansion": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz", + "integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/eslint-config-next/node_modules/typescript-eslint/node_modules/minimatch": { + "version": "10.2.4", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.4.tgz", + "integrity": "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/eslint-config-next/node_modules/typescript-eslint/node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/eslint-import-resolver-node": { "version": "0.3.9", "resolved": "https://registry.npmjs.org/eslint-import-resolver-node/-/eslint-import-resolver-node-0.3.9.tgz", @@ -5323,17 +5283,19 @@ } }, "node_modules/eslint-scope": { - "version": "8.4.0", - "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz", - "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==", + "version": "9.1.2", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-9.1.2.tgz", + "integrity": "sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ==", "dev": true, "license": "BSD-2-Clause", "dependencies": { + "@types/esrecurse": "^4.3.1", + "@types/estree": "^1.0.8", "esrecurse": "^4.3.0", "estraverse": "^5.2.0" }, "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": "^20.19.0 || ^22.13.0 || >=24" }, "funding": { "url": "https://opencollective.com/eslint" @@ -5352,76 +5314,19 @@ "url": "https://opencollective.com/eslint" } }, - "node_modules/eslint/node_modules/balanced-match": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", - "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", - "dev": true, - "license": "MIT" - }, - "node_modules/eslint/node_modules/brace-expansion": { - "version": "1.1.13", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.13.tgz", - "integrity": "sha512-9ZLprWS6EENmhEOpjCYW2c8VkmOvckIJZfkr7rBW6dObmfgJ/L1GpSYW5Hpo9lDz4D1+n0Ckz8rU7FwHDQiG/w==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" - } - }, - "node_modules/eslint/node_modules/eslint-visitor-keys": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", - "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/eslint/node_modules/minimatch": { - "version": "3.1.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", - "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", - "dev": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^1.1.7" - }, - "engines": { - "node": "*" - } - }, "node_modules/espree": { - "version": "10.4.0", - "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", - "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==", + "version": "11.2.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-11.2.0.tgz", + "integrity": "sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw==", "dev": true, "license": "BSD-2-Clause", "dependencies": { - "acorn": "^8.15.0", + "acorn": "^8.16.0", "acorn-jsx": "^5.3.2", - "eslint-visitor-keys": "^4.2.1" + "eslint-visitor-keys": "^5.0.1" }, "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/espree/node_modules/eslint-visitor-keys": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", - "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": "^20.19.0 || ^22.13.0 || >=24" }, "funding": { "url": "https://opencollective.com/eslint" @@ -6001,19 +5906,6 @@ "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/globals": { - "version": "14.0.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", - "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/globalthis": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/globalthis/-/globalthis-1.0.4.tgz", @@ -6239,23 +6131,6 @@ "node": ">= 4" } }, - "node_modules/import-fresh": { - "version": "3.3.1", - "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", - "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "parent-module": "^1.0.0", - "resolve-from": "^4.0.0" - }, - "engines": { - "node": ">=6" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/import-local": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/import-local/-/import-local-3.2.0.tgz", @@ -7605,19 +7480,6 @@ "dev": true, "license": "MIT" }, - "node_modules/js-yaml": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", - "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", - "dev": true, - "license": "MIT", - "dependencies": { - "argparse": "^2.0.1" - }, - "bin": { - "js-yaml": "bin/js-yaml.js" - } - }, "node_modules/jsdom": { "version": "26.1.0", "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-26.1.0.tgz", @@ -8066,13 +7928,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/lodash.merge": { - "version": "4.6.2", - "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", - "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", - "dev": true, - "license": "MIT" - }, "node_modules/loose-envify": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", @@ -8097,9 +7952,9 @@ } }, "node_modules/lucide-react": { - "version": "0.577.0", - "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.577.0.tgz", - "integrity": "sha512-4LjoFv2eEPwYDPg/CUdBJQSDfPyzXCRrVW1X7jrx/trgxnxkHFjnVZINbzvzxjN70dxychOfg+FTYwBiS3pQ5A==", + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.7.0.tgz", + "integrity": "sha512-yI7BeItCLZJTXikmK4KNUGCKoGzSvbKlfCvw44bU4fXAL6v3gYS4uHD1jzsLkfwODYwI6Drw5Tu9Z5ulDe0TSg==", "license": "ISC", "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" @@ -8247,13 +8102,13 @@ } }, "node_modules/minimatch": { - "version": "10.2.4", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.4.tgz", - "integrity": "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==", + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", "dev": true, "license": "BlueOak-1.0.0", "dependencies": { - "brace-expansion": "^5.0.2" + "brace-expansion": "^5.0.5" }, "engines": { "node": "18 || 20 || >=22" @@ -8331,12 +8186,12 @@ "license": "MIT" }, "node_modules/next": { - "version": "16.1.7", - "resolved": "https://registry.npmjs.org/next/-/next-16.1.7.tgz", - "integrity": "sha512-WM0L7WrSvKwoLegLYr6V+mz+RIofqQgVAfHhMp9a88ms0cFX8iX9ew+snpWlSBwpkURJOUdvCEt3uLl3NNzvWg==", + "version": "16.2.2", + "resolved": "https://registry.npmjs.org/next/-/next-16.2.2.tgz", + "integrity": "sha512-i6AJdyVa4oQjyvX/6GeER8dpY/xlIV+4NMv/svykcLtURJSy/WzDnnUk/TM4d0uewFHK7xSQz4TbIwPgjky+3A==", "license": "MIT", "dependencies": { - "@next/env": "16.1.7", + "@next/env": "16.2.2", "@swc/helpers": "0.5.15", "baseline-browser-mapping": "^2.9.19", "caniuse-lite": "^1.0.30001579", @@ -8350,15 +8205,15 @@ "node": ">=20.9.0" }, "optionalDependencies": { - "@next/swc-darwin-arm64": "16.1.7", - "@next/swc-darwin-x64": "16.1.7", - "@next/swc-linux-arm64-gnu": "16.1.7", - "@next/swc-linux-arm64-musl": "16.1.7", - "@next/swc-linux-x64-gnu": "16.1.7", - "@next/swc-linux-x64-musl": "16.1.7", - "@next/swc-win32-arm64-msvc": "16.1.7", - "@next/swc-win32-x64-msvc": "16.1.7", - "sharp": "^0.34.4" + "@next/swc-darwin-arm64": "16.2.2", + "@next/swc-darwin-x64": "16.2.2", + "@next/swc-linux-arm64-gnu": "16.2.2", + "@next/swc-linux-arm64-musl": "16.2.2", + "@next/swc-linux-x64-gnu": "16.2.2", + "@next/swc-linux-x64-musl": "16.2.2", + "@next/swc-win32-arm64-msvc": "16.2.2", + "@next/swc-win32-x64-msvc": "16.2.2", + "sharp": "^0.34.5" }, "peerDependencies": { "@opentelemetry/api": "^1.1.0", @@ -8708,19 +8563,6 @@ "dev": true, "license": "BlueOak-1.0.0" }, - "node_modules/parent-module": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", - "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", - "dev": true, - "license": "MIT", - "dependencies": { - "callsites": "^3.0.0" - }, - "engines": { - "node": ">=6" - } - }, "node_modules/parse-json": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz", @@ -9009,10 +8851,13 @@ } }, "node_modules/proxy-from-env": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", - "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", - "license": "MIT" + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz", + "integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==", + "license": "MIT", + "engines": { + "node": ">=10" + } }, "node_modules/punycode": { "version": "2.3.1", @@ -9202,16 +9047,6 @@ "node": ">=8" } }, - "node_modules/resolve-from": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", - "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=4" - } - }, "node_modules/resolve-pkg-maps": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", @@ -10387,9 +10222,9 @@ } }, "node_modules/typescript": { - "version": "5.9.3", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", - "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.2.tgz", + "integrity": "sha512-bGdAIrZ0wiGDo5l8c++HWtbaNCWTS4UTv7RaTH/ThVIgjkveJt83m74bBHMJkuCbslY8ixgLBVZJIOiQlQTjfQ==", "dev": true, "license": "Apache-2.0", "bin": { @@ -10400,30 +10235,6 @@ "node": ">=14.17" } }, - "node_modules/typescript-eslint": { - "version": "8.57.1", - "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.57.1.tgz", - "integrity": "sha512-fLvZWf+cAGw3tqMCYzGIU6yR8K+Y9NT2z23RwOjlNFF2HwSB3KhdEFI5lSBv8tNmFkkBShSjsCjzx1vahZfISA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@typescript-eslint/eslint-plugin": "8.57.1", - "@typescript-eslint/parser": "8.57.1", - "@typescript-eslint/typescript-estree": "8.57.1", - "@typescript-eslint/utils": "8.57.1" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", - "typescript": ">=4.8.4 <6.0.0" - } - }, "node_modules/unbox-primitive": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.1.0.tgz", @@ -10444,9 +10255,9 @@ } }, "node_modules/undici-types": { - "version": "6.21.0", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", - "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "version": "7.18.2", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", + "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", "dev": true, "license": "MIT" }, diff --git a/frontend/package.json b/frontend/package.json index 94c5b76..5c70447 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -11,10 +11,10 @@ "test:ci": "jest --coverage --coverageReporters=lcov --passWithNoTests" }, "dependencies": { - "@tanstack/react-query": "^5.95.0", - "axios": "^1.13.6", - "lucide-react": "^0.577.0", - "next": "16.1.7", + "@tanstack/react-query": "^5.96.2", + "axios": "^1.14.0", + "lucide-react": "^1.7.0", + "next": "16.2.2", "react": "19.2.4", "react-dom": "19.2.4", "zustand": "^5.0.12" @@ -25,14 +25,14 @@ "@testing-library/react": "^16.3.2", "@testing-library/user-event": "^14.6.1", "@types/jest": "^30.0.0", - "@types/node": "^20", + "@types/node": "^25", "@types/react": "^19", "@types/react-dom": "^19", - "eslint": "^9", - "eslint-config-next": "16.2.1", + "eslint": "^10", + "eslint-config-next": "16.2.2", "jest": "^30.3.0", "jest-environment-jsdom": "^30.3.0", "tailwindcss": "^4", - "typescript": "^5" + "typescript": "^6" } } diff --git a/pyproject.toml b/pyproject.toml index 3c51c90..d828ddd 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "inboxconverge" -version = "0.6.0" +version = "0.6.3" description = "Multi-account email forwarding and processing service" readme = "README.md" requires-python = ">=3.12"