July 3rd - Review and optimize codes
This commit is contained in:
@@ -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/<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_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_templates` | `/api/v1` | `/templates`, `/templates/<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 |
|
||||
| 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
|
||||
→ 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. |
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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 ─────────────────────────────────────────
|
||||
|
||||
+5
-2
@@ -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)
|
||||
@@ -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})
|
||||
@@ -6,4 +6,3 @@ 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
|
||||
@@ -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'))
|
||||
@@ -0,0 +1,6 @@
|
||||
[pytest]
|
||||
testpaths = tests
|
||||
python_files = test_*.py
|
||||
python_classes = Test*
|
||||
python_functions = test_*
|
||||
addopts = -q
|
||||
@@ -0,0 +1,4 @@
|
||||
# Development / test dependencies (not installed in production).
|
||||
# pip install -r requirements-dev.txt
|
||||
-r requirements.txt
|
||||
pytest
|
||||
@@ -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`.
|
||||
@@ -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
|
||||
@@ -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': {}}})
|
||||
@@ -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
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user