July 3rd - Review and optimize codes
This commit is contained in:
@@ -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