From 4c275fc0e555a8fee0b04cfd2a611250d60678d7 Mon Sep 17 00:00:00 2001 From: Nguyen Ngo Date: Fri, 3 Jul 2026 14:38:49 -0400 Subject: [PATCH] July 3rd - Review and optimize codes --- CLAUDE.md | 53 ++++++++++-- MULTI_TENANT_PLAN.md | 8 +- app/__init__.py | 2 - app/api/__init__.py | 7 +- app/api/devices.py | 95 ---------------------- app/models/__init__.py | 3 +- app/models/device_registration.py | 24 ------ pytest.ini | 6 ++ requirements-dev.txt | 4 + {app => scripts}/add_form_schema.py | 0 {app => scripts}/scratch_bootstrap_test.py | 0 tests/README.md | 44 ++++++++++ tests/conftest.py | 38 +++++++++ tests/test_billing_webhooks.py | 81 ++++++++++++++++++ tests/test_sla.py | 84 +++++++++++++++++++ tests/test_tenant_routing.py | 40 +++++++++ 16 files changed, 353 insertions(+), 136 deletions(-) delete mode 100644 app/api/devices.py delete mode 100644 app/models/device_registration.py create mode 100644 pytest.ini create mode 100644 requirements-dev.txt rename {app => scripts}/add_form_schema.py (100%) rename {app => scripts}/scratch_bootstrap_test.py (100%) create mode 100644 tests/README.md create mode 100644 tests/conftest.py create mode 100644 tests/test_billing_webhooks.py create mode 100644 tests/test_sla.py create mode 100644 tests/test_tenant_routing.py diff --git a/CLAUDE.md b/CLAUDE.md index 90c9f09..10d5b30 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -2,7 +2,7 @@ > **Audience:** AI assistants and developers working on this codebase. > **Purpose:** Authoritative reference for architecture, conventions, gotchas, and decisions. -> **Last reviewed:** June 2026 (Phase 19 complete + mobile API gap-fill Phases A–E + customer UI refinements + Phase 22 comment visibility + Phase 23 support chat/tickets + inspector performance Excel export + inspection list filters + customer issue logging + AI chatbot + dashboard grouped sections + issues/inspections PDF export + date/ID filters + Reports expansion Phases R1–R4 + Phase 24 issue_created notify defaults + Phase 25 inspection GPS + Phase 26 issue vendor fields + Phase 27 facility score alerts + **MT-0 through MT-8 complete; self-service signup; trial enforcement; billing emails; invoice history; superadmin billing controls; per-tenant backup CLI; health dashboard; fail2ban brute-force protection; welcome email on signup; dunning sequence day-3/7/14; MT-9 iOS pending**) +> **Last reviewed:** July 2026 (doc-reconciliation pass — verified against code on disk. Adds previously-undocumented phase28 notify-fix, phase29 broadcasts, phase30–32 device registry; `broadcast` + `devices` + `api_devices` blueprints; Broadcast + DeviceRegistration models; corrected MT-8 billing status to DONE; resolved the device-registration collision (rule 84 — removed duplicate `api_devices` blueprint + `DeviceRegistration` model, consolidated on `DeviceToken`). Prior: Phase 19 + mobile API Phases A–E + Phase 22 comment visibility + Phase 23 support chat/tickets + inspector Excel export + inspection list filters + customer issue logging + AI chatbot + dashboard grouped sections + issues/inspections PDF export + Reports R1–R4 + Phase 24 notify defaults + Phase 25 GPS + Phase 26 vendor fields + Phase 27 score alerts + **MT-0 through MT-8 complete; self-service signup; trial enforcement; billing emails; invoice history; superadmin billing controls; per-tenant backup CLI; health dashboard; fail2ban; welcome email; dunning day-3/7/14; MT-9 iOS pending**) --- @@ -400,10 +400,26 @@ audit_logs: id, user_id (nullable), username (snapshot), user_role (snapshot), ``` api_refresh_tokens: id, user_id, token_hash (SHA-256, unique), device_id, device_name, created_at, expires_at, revoked -api_device_tokens: id, user_id, device_id, apns_token, device_name, app_version, registered_at +api_device_tokens: id, user_id, device_id, apns_token, device_name, app_version, registered_at, + ios_version (phase31/32), last_seen_at (phase31/32) UniqueConstraint(user_id, device_id) ``` +`api_device_tokens` (model `DeviceToken`) is the canonical device registry read by the admin Devices page (`/admin/devices`). + +### Broadcast (phase29) + +``` +broadcasts: id, title VARCHAR(255), body TEXT, target_roles JSON (list[str]), + sent_by_id (FK→users SET NULL), sent_at DATETIME, recipient_count INT +``` + +Admin composes a message at `/admin/broadcast`; `broadcast.send` writes one `Notification` row per active user in the targeted roles (`event_type='admin_broadcast'`) plus one `broadcasts` audit row. iOS picks the notifications up on its existing `GET /api/v1/notifications?since=` poll — no APNs/FCM required. + +### ~~DeviceRegistration~~ (phase30 — REMOVED, see rule 84) + +The `DeviceRegistration` model and the duplicate `api_devices` blueprint were **deleted** (rule 84 resolution). Device tracking is consolidated on `DeviceToken` / `api_device_tokens` via the canonical `POST /api/v1/devices/register` handler in `app/api/auth.py`. The `device_registrations` **table is dropped** by phase31/32 on existing tenants; the baseline (`0003_add_user_active`) still creates it, so freshly-bootstrapped tenants carry a harmless empty orphan table that nothing reads or writes. + --- ## 6. Role & Permission Matrix @@ -457,8 +473,10 @@ api_device_tokens: id, user_id, device_id, apns_token, device_name, app_version | `reports` | `/reports` | index, facility report, scorecard, CSV/PDF/Excel export, issues-aging, sla-compliance, followup-closure, facility summary PDF | | `scheduled_reports` | `/scheduled-reports` | CRUD + manual trigger (accessible via Reports sub-nav) | | `support` | `/support` | `GET /chat`, `POST /chat/message` (AJAX→Groq), `POST /tickets`, `GET /my-tickets`, `GET/POST /my-tickets/`, `GET /admin/tickets`, `GET/POST /admin/tickets/` | +| `broadcast` | `/admin/broadcast` | `GET /` (compose + history), `POST /send` — admin-only push to iOS via Notification rows (phase29) | +| `devices` | `/admin/devices` | `GET /` (registered device list), `POST /notify` — notify users on outdated app versions (reads `api_device_tokens`) | | `api` | `/api/v1` | parent blueprint | -| `api_auth` | `/api/v1` | `/auth/login`, `/auth/refresh`, `/auth/logout`, `/auth/me`, `/devices/register` | +| `api_auth` | `/api/v1` | `/auth/login`, `/auth/refresh`, `/auth/logout`, `/auth/me`, `/devices/register` (device tracking + APNs token → `api_device_tokens`) | | `api_facilities` | `/api/v1` | `/facilities`, `/facilities//areas` | | `api_templates` | `/api/v1` | `/templates`, `/templates/` | | `api_inspections` | `/api/v1` | `GET /inspections`, `POST /inspections`, `PATCH /inspections/` | @@ -677,9 +695,9 @@ EVENT_SCORE_ALERT = 'score_alert' ← Phase 27 | critical | 4h | 3h | | high | 24h | 18h | | medium | 72h | 54h | -| low | 168h | 126h | +| low | 120h | 90h | -`issue.sla_notified` prevents duplicate cron notifications. +At-risk is computed as `AT_RISK_THRESHOLD` (0.75) × the window in `app/utils/sla.py` — it is not a stored constant. Source of truth is `SLA_HOURS` in [app/utils/sla.py](app/utils/sla.py) (`critical=4, high=24, medium=72, low=120`). `issue.sla_notified` prevents duplicate cron notifications. --- @@ -744,10 +762,30 @@ limiter = Limiter( → phase25_inspection_gps → phase26_issue_vendor → phase27_score_alerts - → ... → phase32_device_token_columns - → phase33_tenant_settings ← HEAD + → phase28_fix_inspection_notify + → phase29_broadcasts + → phase30_device_registry + → phase31_device_registry + → phase32_device_token_columns + → phase33_tenant_settings ← HEAD ``` +### phase28_fix_inspection_notify + +Data-only. Resets `notification_matrix` rows for `('inspection_completed', role)` where role ∈ `('admin','director','customer')` back to `enabled=1` (they had been inadvertently disabled). No schema change; `UPDATE` on missing rows is a no-op. No `downgrade`. + +### phase29_broadcasts + +Creates the `broadcasts` table (admin-composed push messages to iOS). Uses `CREATE TABLE IF NOT EXISTS` — safe to re-run. See §5 "Broadcast" model and the `broadcast` blueprint. + +### phase30/31/32 — device registry (⚠ inconsistent, see rule 84) + +- **phase30** created a `device_registrations` table. +- **phase31** reversed course: added `ios_version` + `last_seen_at` columns to the existing `api_device_tokens` table (phase7) and **dropped** `device_registrations`. Its `ADD COLUMN IF NOT EXISTS` never actually executed on the LT box (MySQL recorded the revision without applying the DDL). +- **phase32** re-applies the `api_device_tokens` column adds using proper `INFORMATION_SCHEMA` existence checks, and again drops `device_registrations` if present. + +**Net end-state:** device tracking lives on `api_device_tokens` (model `DeviceToken`), which the admin Devices page reads and the single `POST /api/v1/devices/register` handler (`app/api/auth.py`) writes. The `device_registrations` table / `DeviceRegistration` model was removed in the rule 84 resolution; the baseline still creates the (now-unused) table, so fresh tenants carry a harmless empty orphan. See rule 84. + ### Fresh DB provisioning (multi-tenant) **Never use `flask db upgrade` on an empty database.** Fourteen of the phase migrations are not idempotent (no `INFORMATION_SCHEMA` guards) and will fail on a fresh DB that already has the baseline schema. Use the provisioner instead: @@ -1196,6 +1234,7 @@ set -a; . /etc/jqc/control.env; set +a | 74 | **`bootstrap_tenant()` for fresh DBs; `upgrade_tenant()` for incremental** | Fourteen phase migrations are unguarded. Running the full chain on an empty DB (from the baseline) causes duplicate-column errors. `bootstrap_tenant()` runs the baseline to HEAD then stamps — phases skipped. `upgrade_tenant()` is for phase33+ incremental upgrades on already-provisioned DBs. | | 75 | **`delete_tenant()` + `_add_domains()` are retry-safe** | `_add_domains` uses `_upsert_domain()` (delete-then-insert) to handle orphan rows from failed partial runs. `delete_tenant()` also purges by derived domain string, not only by `tenant_id`, catching orphans whose parent tenant row was rolled back. | | 76 | **`register-tenant-zero` never bootstraps and never drops the DB** | `register_tenant_zero()` only reads the existing head, inserts control rows, and maps domains. `delete_tenant()` on tenant-zero must never use `--drop-db` — the guard checks `db_name == db_name_for(slug)` and refuses non-provisioner-named DBs (LT's DB name is `jqc_lt`, not `jqc_lts`). | +| 84 | **Device registration is consolidated on `DeviceToken` / `api_device_tokens` — one handler only** | RESOLVED. There is exactly one `POST /api/v1/devices/register`, in `app/api/auth.py` (blueprint `api_auth`); it upserts `DeviceToken` (device_id, device_name, app_version, ios_version, apns_token, last_seen_at) which the admin Devices page reads. The former duplicate `api_devices` blueprint (`app/api/devices.py`) and the orphaned `DeviceRegistration` model / `device_registrations` table were **deleted** — that path wrote to a table phase31/32 drop. Do not reintroduce a second `/devices/register` route or a `device_registrations`-backed model. | --- diff --git a/MULTI_TENANT_PLAN.md b/MULTI_TENANT_PLAN.md index 9e9abac..b9788d0 100644 --- a/MULTI_TENANT_PLAN.md +++ b/MULTI_TENANT_PLAN.md @@ -2,7 +2,7 @@ > **Audience:** AI assistants and developers extending JQC into a multi-tenant SaaS. > **Companion to:** `CLAUDE.md` (single-tenant architecture reference). -> **Status:** MT-0 through MT-7 complete and deployed. MT-8 flag-gated (future). MT-9 (iOS) pending. +> **Status:** MT-0 through MT-8 complete and deployed (MT-8 billing is flag-gated behind `BILLING_ENABLED`, default off). MT-9 (iOS) server-side endpoints exist; iOS client pending. --- @@ -115,7 +115,7 @@ db = SQLAlchemy(session_options={'class_': RoutingSession}) Two independent Alembic chains: 1. **Tenant schema** — existing chain (HEAD: **`phase33_tenant_settings`**). Runs per-tenant DB. New tenant features continue as `phase34_…` per existing naming. -2. **Control schema** — chain `control{N}_…`, runs once against `jqc_control`. HEAD: `control0001_init`. +2. **Control schema** — chain `control{N}_…`, runs once against `jqc_control`. HEAD: `control0004_dunning_tracking` (`control0001_init → control0002_billing → control0003_trial_reminder_sent → control0004_dunning_tracking`). **CLI (always source env first):** ```bash @@ -218,8 +218,8 @@ Self-service features: - **Plan & Usage**: read-only plan info + live quota progress bars (reads from control DB + tenant DB live counts). "Request upgrade" mailto link. - **Domains**: lists all `TenantDomain` rows. Admin can request a custom domain (creates unverified row, shows TXT/CNAME DNS instructions). Delete unverified custom domains. Superadmin does final verification via control panel. -**MT-8 — Billing. 🔲 FUTURE — flag-gated.** -Stripe per-plan subscription. Lifecycle: trial → active → past_due → suspended → cancelled. Dunning emails. All behind a `BILLING_ENABLED` feature flag. No code written. +**MT-8 — Billing. ✅ DONE — flag-gated behind `BILLING_ENABLED` (default off).** +Stripe per-plan subscription, fully implemented in `app/billing/` (`routes.py`, `webhooks.py`, `emails.py`, `stripe_client.py`; blueprint registered + CSRF-exempted in `app/__init__.py`). Lifecycle: trial → active → past_due → suspended → cancelled. Billing gate (`_billing_gate()` in `app/tenancy/middleware.py`) enforces trial expiry + cancellation on every request. Dunning sequence (day 3/7/14) via cron `POST /notifications/dunning-reminders`, tracked by three `Tenant` columns (`past_due_since`, `dunning_stage`, `dunning_sent_at`; migration `control0004_dunning_tracking`). HTML + plain-text lifecycle emails, invoice history on `/settings/plan`, superadmin billing controls on the panel, and public self-service signup (`/signup`) with 14-day trial + welcome email. See CLAUDE.md §23 for the full reference. **MT-9 — iOS multi-tenant. 🔲 PENDING.** Server side: `GET /api/v1/discover?subdomain=acme` and `GET /api/v1/tenant` public endpoints — exempt from tenant middleware via `MULTI_TENANT_EXEMPT_PATHS`. iOS side: pending (web-first priority). diff --git a/app/__init__.py b/app/__init__.py index 5b8b2d2..7874c03 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -247,7 +247,6 @@ def create_app(config_name='default'): from app.api.notifications import bp as _api_notifications_bp from app.api.stats import bp as _api_stats_bp from app.api.comments import bp as _api_comments_bp - from app.api.devices import bp as _api_devices_bp csrf.exempt(_api_auth_bp) csrf.exempt(_api_facilities_bp) csrf.exempt(_api_templates_bp) @@ -257,7 +256,6 @@ def create_app(config_name='default'): csrf.exempt(_api_notifications_bp) csrf.exempt(_api_stats_bp) csrf.exempt(_api_comments_bp) - csrf.exempt(_api_devices_bp) register_api(app) # ── Security response headers ───────────────────────────────────────── diff --git a/app/api/__init__.py b/app/api/__init__.py index e59e773..53b5441 100644 --- a/app/api/__init__.py +++ b/app/api/__init__.py @@ -50,7 +50,10 @@ def register_api(app): from app.api.comments import bp as comments_bp api_bp.register_blueprint(comments_bp) - from app.api.devices import bp as devices_bp - api_bp.register_blueprint(devices_bp) + # NOTE: device registration lives on the auth blueprint + # (POST /api/v1/devices/register in app/api/auth.py) and writes to the + # canonical api_device_tokens table (model DeviceToken). A former duplicate + # `api_devices` blueprint wrote to the orphaned device_registrations table + # and was removed — see CLAUDE.md rule 84. app.register_blueprint(api_bp) \ No newline at end of file diff --git a/app/api/devices.py b/app/api/devices.py deleted file mode 100644 index 04434b8..0000000 --- a/app/api/devices.py +++ /dev/null @@ -1,95 +0,0 @@ -""" -app/api/devices.py ------------------- -Mobile API endpoint for device registration. - -POST /api/v1/devices/register - Upserts a device record for the authenticated user. - Called on every app foreground (active scenePhase) so last_seen_at - stays current and the admin can identify stale / outdated installs. - - Request JSON - ------------ - { - "device_id": "stable-uuid-from-keychain", // required - "device_name": "Nguyen's iPad", // UIDevice.current.name - "app_version": "1.2.0", // CFBundleShortVersionString - "ios_version": "18.3.1" // UIDevice.current.systemVersion - } - - Response 200 - ------------ - { "ok": true, "data": { "registered": true } } -""" - -import logging - -from flask import Blueprint, request, g -from app import db -from app.models.device_registration import DeviceRegistration -from app.api.errors import api_ok, api_error -from app.api.decorators import jwt_required -from app.utils.audit import log_action, ACTION_UPDATE, ACTION_CREATE -from app.utils.time_utils import now_eastern - -logger = logging.getLogger(__name__) - -bp = Blueprint('api_devices', __name__) - -_ALLOWED_ROLES = {'admin', 'director', 'inspector', 'project_manager'} - - -@bp.route('/devices/register', methods=['POST']) -@jwt_required -def register_device(): - user = g.api_user - if user.role not in _ALLOWED_ROLES: - return api_error('Access denied', 403) - - data = request.get_json(silent=True) or {} - - device_id = (data.get('device_id') or '').strip() - device_name = (data.get('device_name') or '').strip()[:255] - app_version = (data.get('app_version') or '').strip()[:32] - ios_version = (data.get('ios_version') or '').strip()[:32] - - if not device_id: - return api_error('device_id is required', 400) - if len(device_id) > 64: - return api_error('device_id too long', 400) - - now = now_eastern() - - existing = DeviceRegistration.query.filter_by(device_id=device_id).first() - if existing: - # Update — always refresh last_seen_at and app/ios version - existing.user_id = user.id # re-bind if different user logs in same device - existing.device_name = device_name or existing.device_name - existing.app_version = app_version or existing.app_version - existing.ios_version = ios_version or existing.ios_version - existing.last_seen_at = now - db.session.commit() - log_action(ACTION_UPDATE, 'DeviceRegistration', existing.id, - f'{device_name} v{app_version}', - f'user={user.username}; ios={ios_version}') - logger.info('API DEVICES | updated | device_id=%s | user=%s | app=%s', - device_id[:8], user.username, app_version) - else: - reg = DeviceRegistration( - device_id = device_id, - user_id = user.id, - device_name = device_name, - app_version = app_version, - ios_version = ios_version, - registered_at = now, - last_seen_at = now, - ) - db.session.add(reg) - db.session.commit() - log_action(ACTION_CREATE, 'DeviceRegistration', reg.id, - f'{device_name} v{app_version}', - f'user={user.username}; ios={ios_version}') - logger.info('API DEVICES | registered | device_id=%s | user=%s | app=%s', - device_id[:8], user.username, app_version) - - return api_ok({'registered': True}) diff --git a/app/models/__init__.py b/app/models/__init__.py index 1e43a69..dd90aff 100644 --- a/app/models/__init__.py +++ b/app/models/__init__.py @@ -5,5 +5,4 @@ from app.models.inspection import (InspectionTemplate, ChecklistItem, from app.models.issue import Issue from app.models.project import Project, CustomerAssignment from app.models.api_token import RefreshToken, DeviceToken -from app.models.notification_matrix import NotificationMatrix -from app.models.device_registration import DeviceRegistration \ No newline at end of file +from app.models.notification_matrix import NotificationMatrix \ No newline at end of file diff --git a/app/models/device_registration.py b/app/models/device_registration.py deleted file mode 100644 index 31238ea..0000000 --- a/app/models/device_registration.py +++ /dev/null @@ -1,24 +0,0 @@ -# app/models/device_registration.py -# ----------------------------------- -# Tracks iOS devices that have registered with the server. -# One row per physical device — upserted on every app foreground. - -from app import db -from app.utils.time_utils import now_eastern - - -class DeviceRegistration(db.Model): - __tablename__ = 'device_registrations' - - id = db.Column(db.Integer, primary_key=True) - # Stable UUID generated on first launch and stored in iOS Keychain. - # Unique across all devices; survives app restarts but not device wipes. - device_id = db.Column(db.String(64), nullable=False, unique=True, index=True) - user_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False, index=True) - device_name = db.Column(db.String(255), nullable=False, default='') - app_version = db.Column(db.String(32), nullable=False, default='') - ios_version = db.Column(db.String(32), nullable=False, default='') - registered_at = db.Column(db.DateTime, nullable=False, default=now_eastern) - last_seen_at = db.Column(db.DateTime, nullable=False, default=now_eastern) - - user = db.relationship('User', backref=db.backref('devices', lazy='dynamic')) diff --git a/pytest.ini b/pytest.ini new file mode 100644 index 0000000..ef7b5d9 --- /dev/null +++ b/pytest.ini @@ -0,0 +1,6 @@ +[pytest] +testpaths = tests +python_files = test_*.py +python_classes = Test* +python_functions = test_* +addopts = -q diff --git a/requirements-dev.txt b/requirements-dev.txt new file mode 100644 index 0000000..ac05e57 --- /dev/null +++ b/requirements-dev.txt @@ -0,0 +1,4 @@ +# Development / test dependencies (not installed in production). +# pip install -r requirements-dev.txt +-r requirements.txt +pytest diff --git a/app/add_form_schema.py b/scripts/add_form_schema.py similarity index 100% rename from app/add_form_schema.py rename to scripts/add_form_schema.py diff --git a/app/scratch_bootstrap_test.py b/scripts/scratch_bootstrap_test.py similarity index 100% rename from app/scratch_bootstrap_test.py rename to scripts/scratch_bootstrap_test.py diff --git a/tests/README.md b/tests/README.md new file mode 100644 index 0000000..2549e9c --- /dev/null +++ b/tests/README.md @@ -0,0 +1,44 @@ +# Tests + +The first automated tests for JQC. Kept deliberately small and targeted at the +places where a bug is **catastrophic and non-obvious** — the money path +(Stripe webhooks), the SLA state machine, and the multi-tenant routing +invariant. + +## Running + +```bash +pip install -r requirements-dev.txt +pytest +``` + +No MySQL, Redis, or Stripe account is required. `tests/conftest.py` sets +throwaway `SECRET_KEY` / `DATABASE_URL` (in-memory SQLite) at import time so the +`app` package imports cleanly, and every test either exercises pure functions or +uses the in-memory engine. + +## What's covered + +| File | Scope | Infra needed | +|---|---|---| +| `test_sla.py` | SLA thresholds + `ok`/`at_risk`/`breached`/`None` boundaries | none | +| `test_billing_webhooks.py` | `_ts_to_dt`, status normalization, `handle_event` dispatch + error-swallowing | none | +| `test_tenant_routing.py` | Routing is inert with no tenant; resolved `g.tenant_engine` wins | in-memory SQLite | + +## Not yet covered (needs a live control DB + ≥2 tenant DBs) + +These are the highest-value **integration** tests to add next. They require a +throwaway MySQL control DB plus two provisioned tenant DBs, so they live outside +this fast unit suite for now: + +1. **Cross-tenant isolation** — resolve tenant A, write a row, resolve tenant B, + assert the row is invisible. Proves `RoutingSession` never leaks across + tenants. This is the single most important test the project can have. +2. **Webhook handlers end-to-end** — `_on_payment_failed` sets `past_due_since` + and `subscription_status='past_due'`; `_on_payment_succeeded` clears the + dunning columns. Assert against a real `Tenant` row. +3. **`bootstrap_tenant` vs `upgrade_tenant`** — a freshly bootstrapped tenant DB + ends stamped at chain head with the full schema (the logic + `scripts/scratch_bootstrap_test.py` proves manually today). +4. **Billing gate state machine** — trial-expired redirects to + `/billing/subscribe`; `cancelled` redirects to `/billing/suspended`. diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..ae457e9 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,38 @@ +""" +tests/conftest.py +----------------- +Shared pytest fixtures + import-time environment setup. + +`config.py` calls `_require_env('SECRET_KEY')` and `_require_env('DATABASE_URL')` +at *import* time, so those must be present in os.environ before `app` is +imported anywhere. We set safe, throwaway values here (SQLite in-memory) so the +whole suite can run with no MySQL, no Redis, no Stripe, and no real secrets. + +The pure-logic tests (SLA math, webhook helpers) never touch the DB. The +routing-inertness test uses the in-memory SQLite engine only. +""" + +import os + +# ── Import-time env (must be set BEFORE `import app`) ──────────────────────── +os.environ.setdefault('SECRET_KEY', 'test-secret-key') +os.environ.setdefault('DATABASE_URL', 'sqlite:///:memory:') +os.environ.setdefault('MULTI_TENANT_ENABLED', 'false') +os.environ.setdefault('BILLING_ENABLED', 'false') + +import pytest # noqa: E402 + + +@pytest.fixture(scope='session') +def app(): + """A minimal single-tenant app on in-memory SQLite (multi-tenancy inert).""" + from app import create_app + application = create_app('default') + application.config.update(TESTING=True, WTF_CSRF_ENABLED=False, SQLALCHEMY_ECHO=False) + return application + + +@pytest.fixture +def app_ctx(app): + with app.app_context(): + yield app diff --git a/tests/test_billing_webhooks.py b/tests/test_billing_webhooks.py new file mode 100644 index 0000000..a9042db --- /dev/null +++ b/tests/test_billing_webhooks.py @@ -0,0 +1,81 @@ +""" +Unit tests for the Stripe webhook dispatcher (app/billing/webhooks.py). + +Covers the pure, DB-free surface: + - _ts_to_dt timestamp conversion + - _STRIPE_STATUS_MAP status normalization (the 'canceled'/'cancelled' trap) + - handle_event dispatch: unknown types are a no-op; known types call the right + handler; a handler that raises is swallowed (one bad event must not abort the + rest of a delivery batch). + +The individual _on_* handlers open a control_session and are covered separately +by integration tests against a live control DB (not included here — see +tests/README.md). +""" + +from datetime import datetime + +import app.billing.webhooks as wh + + +# ── _ts_to_dt ──────────────────────────────────────────────────────────────── + +def test_ts_to_dt_none_returns_none(): + assert wh._ts_to_dt(None) is None + + +def test_ts_to_dt_epoch(): + # 0 → 1970-01-01T00:00:00, naive (tzinfo stripped) + dt = wh._ts_to_dt(0) + assert dt == datetime(1970, 1, 1, 0, 0, 0) + assert dt.tzinfo is None + + +def test_ts_to_dt_known_value(): + # 2021-01-01T00:00:00Z == 1609459200 + assert wh._ts_to_dt(1609459200) == datetime(2021, 1, 1, 0, 0, 0) + + +# ── _STRIPE_STATUS_MAP ─────────────────────────────────────────────────────── + +def test_status_map_both_cancel_spellings(): + # Stripe emits 'canceled' (one L); we must normalize to our 'cancelled'. + assert wh._STRIPE_STATUS_MAP['canceled'] == 'cancelled' + assert wh._STRIPE_STATUS_MAP['cancelled'] == 'cancelled' + + +def test_status_map_trial_and_pastdue(): + assert wh._STRIPE_STATUS_MAP['trialing'] == 'trial' + assert wh._STRIPE_STATUS_MAP['past_due'] == 'past_due' + assert wh._STRIPE_STATUS_MAP['unpaid'] == 'past_due' + + +# ── handle_event dispatch ──────────────────────────────────────────────────── + +def test_unknown_event_type_is_noop(): + # Should simply return without raising and without touching any handler. + wh.handle_event({'type': 'ping.unhandled', 'data': {'object': {}}}) + + +def test_known_event_dispatches_to_handler(monkeypatch): + called = {} + + def fake_handler(obj): + called['obj'] = obj + + monkeypatch.setattr(wh, '_on_payment_failed', fake_handler) + + payload = {'customer': 'cus_123'} + wh.handle_event({'type': 'invoice.payment_failed', 'data': {'object': payload}}) + + assert called.get('obj') == payload + + +def test_handler_exception_is_swallowed(monkeypatch): + def boom(obj): + raise RuntimeError('stripe handler blew up') + + monkeypatch.setattr(wh, '_on_subscription_deleted', boom) + + # Must NOT propagate — a single malformed event cannot abort the batch. + wh.handle_event({'type': 'customer.subscription.deleted', 'data': {'object': {}}}) diff --git a/tests/test_sla.py b/tests/test_sla.py new file mode 100644 index 0000000..617077a --- /dev/null +++ b/tests/test_sla.py @@ -0,0 +1,84 @@ +""" +Unit tests for the SLA engine (app/utils/sla.py). + +These are pure-logic tests — they build a lightweight fake issue object with the +three attributes `sla_status`/`sla_deadline`/`sla_hours_remaining` read +(`severity`, `status`, `reported_at`) and assert the boundary behaviour of the +SLA state machine. No database, no app context required. +""" + +from datetime import timedelta + +from app.utils.sla import ( + sla_status, sla_deadline, sla_hours_remaining, SLA_HOURS, AT_RISK_THRESHOLD, +) +from app.utils.time_utils import now_eastern + + +class FakeIssue: + """Minimal stand-in for the Issue ORM object used by the SLA helpers.""" + def __init__(self, severity, status='open', hours_ago=0.0): + self.severity = severity + self.status = status + self.reported_at = now_eastern() - timedelta(hours=hours_ago) + + +# ── sla_deadline ───────────────────────────────────────────────────────────── + +def test_deadline_matches_window(): + issue = FakeIssue('high', hours_ago=0) + expected = issue.reported_at + timedelta(hours=SLA_HOURS['high']) + assert sla_deadline(issue) == expected + + +def test_deadline_none_for_unknown_severity(): + assert sla_deadline(FakeIssue('bogus')) is None + + +# ── sla_status boundaries ──────────────────────────────────────────────────── + +def test_status_ok_when_fresh(): + # critical window is 4h; reported 1h ago → well inside → ok + assert sla_status(FakeIssue('critical', hours_ago=1)) == 'ok' + + +def test_status_at_risk_past_threshold(): + # critical window 4h, at-risk at 3h (0.75 * 4). Reported 3.5h ago → at_risk. + assert AT_RISK_THRESHOLD == 0.75 + assert sla_status(FakeIssue('critical', hours_ago=3.5)) == 'at_risk' + + +def test_status_breached_past_window(): + # critical window 4h; reported 5h ago → breached + assert sla_status(FakeIssue('critical', hours_ago=5)) == 'breached' + + +def test_status_none_when_resolved(): + # A resolved issue has no SLA regardless of how old it is. + assert sla_status(FakeIssue('critical', status='resolved', hours_ago=99)) is None + + +def test_status_none_for_unknown_severity(): + assert sla_status(FakeIssue('bogus', hours_ago=1)) is None + + +def test_low_severity_window_is_120h_not_168h(): + """Regression guard: CLAUDE.md once documented low=168h; code says 120h.""" + assert SLA_HOURS['low'] == 120 + + +# ── sla_hours_remaining ────────────────────────────────────────────────────── + +def test_hours_remaining_positive_when_fresh(): + issue = FakeIssue('medium', hours_ago=0) # window 72h + remaining = sla_hours_remaining(issue) + assert 71.0 <= remaining <= 72.0 + + +def test_hours_remaining_negative_when_breached(): + issue = FakeIssue('critical', hours_ago=6) # window 4h → ~-2h + assert sla_hours_remaining(issue) < 0 + + +def test_hours_remaining_none_when_resolved(): + assert sla_hours_remaining(FakeIssue('high', status='resolved')) is None diff --git a/tests/test_tenant_routing.py b/tests/test_tenant_routing.py new file mode 100644 index 0000000..2a30f52 --- /dev/null +++ b/tests/test_tenant_routing.py @@ -0,0 +1,40 @@ +""" +Tests for the tenant routing layer (app/tenancy/routing.py). + +The crown-jewel invariant of the multi-tenant design is data isolation: a +request bound to tenant A must never touch tenant B's database. Full isolation +proof requires two live tenant DBs and belongs in an integration suite (see +tests/README.md). What we CAN verify cheaply and deterministically here is the +other half of the contract: + + "The routing layer is completely inert until a tenant is resolved." + +If routing were NOT inert when MULTI_TENANT_ENABLED=false, the existing +single-tenant deployment would break. This test locks that guarantee in. +""" + +from flask import g + + +def test_routing_is_inert_without_a_resolved_tenant(app): + """With no g.tenant_engine set, db.session binds to the default engine.""" + from app import db + with app.test_request_context('/'): + # Multi-tenancy disabled → the resolver never sets g.tenant_engine. + assert g.get('tenant_engine') is None + # …so get_bind() must fall through to the app's default engine. + assert db.session.get_bind() is db.engine + + +def test_resolved_tenant_engine_wins(app, monkeypatch): + """When g.tenant_engine IS set, get_bind() returns it, not the default.""" + from app import db + + class _SentinelEngine: + """Stand-in object; get_bind should return it verbatim when present.""" + + sentinel = _SentinelEngine() + + with app.test_request_context('/'): + g.tenant_engine = sentinel + assert db.session.get_bind() is sentinel