July 3rd - Review and optimize codes

This commit is contained in:
2026-07-03 14:38:49 -04:00
parent d0b1b7ae23
commit 4c275fc0e5
16 changed files with 353 additions and 136 deletions
+45 -6
View File
@@ -2,7 +2,7 @@
> **Audience:** AI assistants and developers working on this codebase. > **Audience:** AI assistants and developers working on this codebase.
> **Purpose:** Authoritative reference for architecture, conventions, gotchas, and decisions. > **Purpose:** Authoritative reference for architecture, conventions, gotchas, and decisions.
> **Last reviewed:** June 2026 (Phase 19 complete + mobile API gap-fill Phases AE + 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 R1R4 + 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, phase3032 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 AE + 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 R1R4 + 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, api_refresh_tokens: id, user_id, token_hash (SHA-256, unique), device_id, device_name,
created_at, expires_at, revoked 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) 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 ## 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 | | `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) | | `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/<id>`, `GET /admin/tickets`, `GET/POST /admin/tickets/<id>` | | `support` | `/support` | `GET /chat`, `POST /chat/message` (AJAX→Groq), `POST /tickets`, `GET /my-tickets`, `GET/POST /my-tickets/<id>`, `GET /admin/tickets`, `GET/POST /admin/tickets/<id>` |
| `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` | `/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/<id>/areas` | | `api_facilities` | `/api/v1` | `/facilities`, `/facilities/<id>/areas` |
| `api_templates` | `/api/v1` | `/templates`, `/templates/<id>` | | `api_templates` | `/api/v1` | `/templates`, `/templates/<id>` |
| `api_inspections` | `/api/v1` | `GET /inspections`, `POST /inspections`, `PATCH /inspections/<id>` | | `api_inspections` | `/api/v1` | `GET /inspections`, `POST /inspections`, `PATCH /inspections/<id>` |
@@ -677,9 +695,9 @@ EVENT_SCORE_ALERT = 'score_alert' ← Phase 27
| critical | 4h | 3h | | critical | 4h | 3h |
| high | 24h | 18h | | high | 24h | 18h |
| medium | 72h | 54h | | 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 → phase25_inspection_gps
→ phase26_issue_vendor → phase26_issue_vendor
→ phase27_score_alerts → phase27_score_alerts
... → phase32_device_token_columns phase28_fix_inspection_notify
→ phase29_broadcasts
→ phase30_device_registry
→ phase31_device_registry
→ phase32_device_token_columns
→ phase33_tenant_settings ← HEAD → 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) ### 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: **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. | | 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. | | 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`). | | 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. |
--- ---
+4 -4
View File
@@ -2,7 +2,7 @@
> **Audience:** AI assistants and developers extending JQC into a multi-tenant SaaS. > **Audience:** AI assistants and developers extending JQC into a multi-tenant SaaS.
> **Companion to:** `CLAUDE.md` (single-tenant architecture reference). > **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: 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. 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):** **CLI (always source env first):**
```bash ```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. - **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. - **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.** **MT-8 — Billing. ✅ DONE — flag-gated behind `BILLING_ENABLED` (default off).**
Stripe per-plan subscription. Lifecycle: trial → active → past_due → suspended → cancelled. Dunning emails. All behind a `BILLING_ENABLED` feature flag. No code written. 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.** **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). 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).
-2
View File
@@ -247,7 +247,6 @@ def create_app(config_name='default'):
from app.api.notifications import bp as _api_notifications_bp from app.api.notifications import bp as _api_notifications_bp
from app.api.stats import bp as _api_stats_bp from app.api.stats import bp as _api_stats_bp
from app.api.comments import bp as _api_comments_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_auth_bp)
csrf.exempt(_api_facilities_bp) csrf.exempt(_api_facilities_bp)
csrf.exempt(_api_templates_bp) csrf.exempt(_api_templates_bp)
@@ -257,7 +256,6 @@ def create_app(config_name='default'):
csrf.exempt(_api_notifications_bp) csrf.exempt(_api_notifications_bp)
csrf.exempt(_api_stats_bp) csrf.exempt(_api_stats_bp)
csrf.exempt(_api_comments_bp) csrf.exempt(_api_comments_bp)
csrf.exempt(_api_devices_bp)
register_api(app) register_api(app)
# ── Security response headers ───────────────────────────────────────── # ── Security response headers ─────────────────────────────────────────
+5 -2
View File
@@ -50,7 +50,10 @@ def register_api(app):
from app.api.comments import bp as comments_bp from app.api.comments import bp as comments_bp
api_bp.register_blueprint(comments_bp) api_bp.register_blueprint(comments_bp)
from app.api.devices import bp as devices_bp # NOTE: device registration lives on the auth blueprint
api_bp.register_blueprint(devices_bp) # (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) app.register_blueprint(api_bp)
-95
View File
@@ -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})
-1
View File
@@ -6,4 +6,3 @@ from app.models.issue import Issue
from app.models.project import Project, CustomerAssignment from app.models.project import Project, CustomerAssignment
from app.models.api_token import RefreshToken, DeviceToken from app.models.api_token import RefreshToken, DeviceToken
from app.models.notification_matrix import NotificationMatrix from app.models.notification_matrix import NotificationMatrix
from app.models.device_registration import DeviceRegistration
-24
View File
@@ -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'))
+6
View File
@@ -0,0 +1,6 @@
[pytest]
testpaths = tests
python_files = test_*.py
python_classes = Test*
python_functions = test_*
addopts = -q
+4
View File
@@ -0,0 +1,4 @@
# Development / test dependencies (not installed in production).
# pip install -r requirements-dev.txt
-r requirements.txt
pytest
+44
View File
@@ -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`.
+38
View File
@@ -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
+81
View File
@@ -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': {}}})
+84
View File
@@ -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
+40
View File
@@ -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