Merge branch 'main' into copilot/ensure-full-test-coverage
This commit is contained in:
+13
-13
@@ -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:
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
+160
@@ -7,6 +7,75 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
<!-- version list -->
|
||||
|
||||
## 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).
|
||||
|
||||
@@ -1,284 +1,106 @@
|
||||
# InboxConverge
|
||||
|
||||
[](https://github.com/christianlouis/inboxconverge/actions/workflows/test.yml)
|
||||
[](https://github.com/christianlouis/inboxconverge/actions/workflows/lint.yml)
|
||||
[](https://github.com/christianlouis/inboxconverge/actions/workflows/security.yml)
|
||||
[](https://github.com/christianlouis/inboxconverge/actions/workflows/docker-build.yml)
|
||||
[](https://github.com/christianlouis/InboxConverge/actions/workflows/ci.yml)
|
||||
[](https://github.com/christianlouis/InboxConverge/releases/latest)
|
||||
[](https://opensource.org/licenses/MIT)
|
||||
[](https://www.python.org/downloads/)
|
||||
[](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).
|
||||
|
||||
+20
-20
@@ -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
|
||||
|
||||
@@ -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"]
|
||||
@@ -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
|
||||
@@ -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"]
|
||||
@@ -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 <john.doe@example.com>")
|
||||
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("<user@example.com>")
|
||||
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" <alice@example.com>')
|
||||
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(" <admin@site.org>")
|
||||
# No display name → returns masked email only
|
||||
assert "@" in result
|
||||
assert "***" in result
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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()`.
|
||||
|
||||
@@ -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;
|
||||
|
||||
Generated
+415
-604
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user