Skip to content

Commit 4a3363c

Browse files
authored
Merge pull request #174 from PeterOche/test/etl-integration-tests
Test/etl integration tests
2 parents c92d804 + b436d07 commit 4a3363c

File tree

11 files changed

+467
-110
lines changed

11 files changed

+467
-110
lines changed

pytest.ini

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
11
[pytest]
22
minversion = 7.0
3-
addopts = -ra -q
3+
addopts = -ra -q -m "not integration"
44
testpaths = tests
5+
markers =
6+
integration: integration tests requiring a live database

src/analytics/service.py

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -305,6 +305,30 @@ def get_recent_scans(self, event_id: str, limit: int = 100) -> List[Dict[str, An
305305
raise
306306
finally:
307307
session.close()
308+
309+
def get_scans_by_ticket_id(self, ticket_id: str, limit: int = 100) -> List[Dict[str, Any]]:
310+
"""Get scan records for a specific ticket identifier."""
311+
try:
312+
session = get_session()
313+
scans = session.query(TicketScan).filter(
314+
TicketScan.ticket_id == ticket_id
315+
).order_by(desc(TicketScan.scan_timestamp)).limit(limit).all()
316+
return [{
317+
"id": scan.id,
318+
"ticket_id": scan.ticket_id,
319+
"event_id": scan.event_id,
320+
"scan_timestamp": scan.scan_timestamp.isoformat(),
321+
"is_valid": scan.is_valid,
322+
"location": scan.location
323+
} for scan in scans]
324+
except Exception as e:
325+
log_error("Failed to get scans by ticket_id", {
326+
"ticket_id": ticket_id,
327+
"error": str(e)
328+
})
329+
raise
330+
finally:
331+
session.close()
308332

309333
def get_recent_transfers(self, event_id: str, limit: int = 100) -> List[Dict[str, Any]]:
310334
"""Get recent transfer records for an event."""

src/config.py

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -42,9 +42,6 @@ class Settings(BaseSettings):
4242
"owerri,warri,uyo,akure,ilorin,sokoto,zaria,maiduguri,asaba,nnewi"
4343
)
4444

45-
class Config:
46-
env_file = ".env"
47-
4845
settings = Settings()
4946

5047

src/fraud.py

Lines changed: 32 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ def check_fraud_rules(events: List[Dict[str, Any]]) -> List[str]:
3535
triggered.add("duplicate_ticket_transfer")
3636

3737
# Rule 3: Excessive purchases by same user in a day (>5)
38-
from datetime import date as date_type # noqa: PLC0415 – local import to avoid shadowing
38+
from datetime import date as date_type # noqa: PLC0415
3939
purchases_by_user_day: Dict[tuple[str, date_type], int] = {}
4040
for event in events:
4141
if event.get("type") == "purchase":
@@ -47,6 +47,31 @@ def check_fraud_rules(events: List[Dict[str, Any]]) -> List[str]:
4747
if count > 5:
4848
triggered.add("excessive_purchases_user_day")
4949

50+
# Rule 4: Impossible travel (different locations within 30 min)
51+
scans_by_ticket: Dict[str, List[Dict[str, Any]]] = {}
52+
for event in events:
53+
if event.get("type") == "scan":
54+
tid = str(event.get("ticket_id", ""))
55+
scans_by_ticket.setdefault(tid, []).append(event)
56+
for _tid, scans in scans_by_ticket.items():
57+
scans.sort(key=lambda x: datetime.fromisoformat(str(x.get("timestamp", ""))))
58+
for i in range(len(scans) - 1):
59+
t1 = datetime.fromisoformat(str(scans[i].get("timestamp", "")))
60+
t2 = datetime.fromisoformat(str(scans[i + 1].get("timestamp", "")))
61+
loc1 = scans[i].get("location")
62+
loc2 = scans[i + 1].get("location")
63+
if loc1 != loc2 and (t2 - t1).total_seconds() <= 1800:
64+
triggered.add("impossible_travel_scan")
65+
break
66+
67+
# Rule 5: Bulk allocation (20% or more of event capacity)
68+
for event in events:
69+
if event.get("type") == "purchase":
70+
qty = float(event.get("qty", 1))
71+
capacity = float(event.get("capacity", 1000000))
72+
if capacity > 0 and (qty / capacity) >= 0.2:
73+
triggered.add("bulk_allocation_purchase")
74+
5075
return list(triggered)
5176

5277

@@ -58,7 +83,12 @@ def determine_severity(triggered_rules: List[str]) -> str:
5883
if not triggered_rules:
5984
return "none"
6085

61-
HIGH_RULES = {"too_many_purchases_same_ip", "excessive_purchases_user_day"}
86+
HIGH_RULES = {
87+
"too_many_purchases_same_ip",
88+
"excessive_purchases_user_day",
89+
"impossible_travel_scan",
90+
"bulk_allocation_purchase",
91+
}
6292
MEDIUM_RULES = {"duplicate_ticket_transfer"}
6393

6494
s = set(triggered_rules)

src/main.py

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -265,7 +265,7 @@ def generate_qr(payload: TicketRequest) -> Any:
265265
encoded = base64.b64encode(buffer.read()).decode("utf-8")
266266
QR_GENERATIONS_TOTAL.inc()
267267
log_info("QR code generated successfully")
268-
return QRResponse(qr_base64=encoded)
268+
return QRResponse(qr_base64=encoded, token=json.dumps(data, separators=(",", ":")))
269269

270270

271271
@app.post("/validate-qr", response_model=QRValidateResponse)
@@ -283,15 +283,30 @@ def validate_qr(payload: QRValidateRequest) -> QRValidateResponse:
283283
if hmac.compare_digest(provided_sig, expected_sig):
284284
QR_VALIDATIONS_TOTAL.labels(result="valid").inc()
285285
log_info("QR validation successful", {"ticket_id": unsigned.get("ticket_id")})
286+
analytics_service.log_ticket_scan(
287+
ticket_id=str(unsigned.get("ticket_id") or "unknown"),
288+
event_id=str(unsigned.get("event") or "unknown"),
289+
is_valid=True
290+
)
286291
return QRValidateResponse(isValid=True, metadata=unsigned)
287292
log_warning("Invalid QR signature", {"metadata": unsigned})
288293
QR_VALIDATIONS_TOTAL.labels(result="invalid").inc()
294+
analytics_service.log_ticket_scan(
295+
ticket_id=str(unsigned.get("ticket_id") or "unknown"),
296+
event_id=str(unsigned.get("event") or "unknown"),
297+
is_valid=False
298+
)
289299
return QRValidateResponse(isValid=False)
290300
except Exception as exc:
291301
log_warning("Invalid QR validation attempt", {"error": str(exc)})
292302
QR_VALIDATIONS_TOTAL.labels(result="error").inc()
293303
return QRValidateResponse(isValid=False)
294304

305+
@app.get("/qr/scan-log/{ticket_id}")
306+
def get_qr_scan_log(ticket_id: str) -> List[Dict[str, Any]]:
307+
"""Returns the scan audit log for a specific ticket."""
308+
return analytics_service.get_scans_by_ticket_id(ticket_id)
309+
295310

296311
# ---------------------------------------------------------------------------
297312
# Analytics endpoints

src/types_custom.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@ class TicketRequest(BaseModel):
5252
class QRResponse(BaseModel):
5353
model_config = ConfigDict(extra="forbid")
5454
qr_base64: str
55+
token: str
5556

5657

5758
class QRValidateRequest(BaseModel):

tests/conftest.py

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,36 @@
11
import os
2+
import pytest
3+
from sqlalchemy import create_engine, text
4+
from src.config import get_settings
25

36
# Provide a non-default test key so startup validation passes in test environments.
47
os.environ.setdefault("QR_SIGNING_KEY", "a" * 32)
8+
# Force model training to skip in test environments
9+
os.environ.setdefault("SKIP_MODEL_TRAINING", "true")
10+
11+
@pytest.fixture(scope="session")
12+
def db_engine():
13+
"""Provides a database engine for integration tests."""
14+
settings = get_settings()
15+
engine = create_engine(settings.DATABASE_URL)
16+
yield engine
17+
engine.dispose()
18+
19+
@pytest.fixture
20+
def clean_test_db(db_engine):
21+
"""Truncates all tables before/after integration tests."""
22+
tables = ["event_sales_summary", "daily_ticket_sales", "etl_run_log"]
23+
with db_engine.begin() as conn:
24+
for table in tables:
25+
try:
26+
conn.execute(text(f"TRUNCATE TABLE {table} RESTART IDENTITY CASCADE"))
27+
except Exception:
28+
# Tables might not exist yet if it's the first run
29+
pass
30+
yield
31+
with db_engine.begin() as conn:
32+
for table in tables:
33+
try:
34+
conn.execute(text(f"TRUNCATE TABLE {table} RESTART IDENTITY CASCADE"))
35+
except Exception:
36+
pass

tests/e2e/test_qr_flow.py

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
import base64
2+
import json
3+
import pytest
4+
from fastapi.testclient import TestClient
5+
from prometheus_client import REGISTRY
6+
7+
from src.main import app
8+
from src.config import get_settings
9+
10+
@pytest.fixture
11+
def client(monkeypatch):
12+
"""Fixture to provide a TestClient with a fixed signing key and cleared settings cache."""
13+
# Requirement: All tests must set QR_SIGNING_KEY to a 32-character test string
14+
monkeypatch.setenv("QR_SIGNING_KEY", "q" * 32)
15+
get_settings.cache_clear()
16+
return TestClient(app)
17+
18+
def test_qr_generate_validate_audit_flow(client):
19+
"""
20+
End-to-end test for the QR lifecycle:
21+
1. Generate a signed QR token.
22+
2. Validate the token successfully.
23+
3. Validate a tampered token (invalid signature).
24+
4. Validate a token with extra fields (signature mismatch).
25+
5. Verify the audit log contains valid and invalid entries.
26+
6. Verify Prometheus metrics.
27+
"""
28+
ticket_id = "E2E-TEST-001"
29+
event_name = "E2E-Festival"
30+
31+
# --- Step 1: Generate QR ---
32+
gen_payload = {
33+
"ticket_id": ticket_id,
34+
"event": event_name,
35+
"user": "tester@example.com"
36+
}
37+
resp = client.post("/generate-qr", json=gen_payload)
38+
39+
# Assertions 1 & 2: Status 200, valid PNG, and token presence
40+
assert resp.status_code == 200
41+
data = resp.json()
42+
assert "qr_base64" in data
43+
assert "token" in data # Extracted signed token requirement
44+
45+
qr_content = base64.b64decode(data["qr_base64"])
46+
assert qr_content.startswith(b"\x89PNG"), "QR code must be a PNG image"
47+
48+
token_str = data["token"]
49+
token_obj = json.loads(token_str)
50+
51+
# Store initial metrics
52+
def get_metric(res):
53+
return REGISTRY.get_sample_value("qr_validations_total", {"result": res}) or 0
54+
55+
m_valid_start = get_metric("valid")
56+
m_invalid_start = get_metric("invalid")
57+
58+
# --- Step 2: Validate (Successful) ---
59+
val_resp = client.post("/validate-qr", json={"qr_text": token_str})
60+
61+
# Assertion 3: Valid scan returns True and correct metadata
62+
assert val_resp.status_code == 200
63+
val_data = val_resp.json()
64+
assert val_data["isValid"] is True
65+
assert val_data["metadata"]["ticket_id"] == ticket_id
66+
assert val_data["metadata"]["event"] == event_name
67+
68+
# --- Step 3: Validate (Tampered signature) ---
69+
tampered_token = token_obj.copy()
70+
tampered_token["sig"] = "invalid_signature_string"
71+
resp_tampered = client.post("/validate-qr", json={"qr_text": json.dumps(tampered_token)})
72+
73+
# Assertion 4: Tampered signature returns False
74+
assert resp_tampered.status_code == 200
75+
assert resp_tampered.json()["isValid"] is False
76+
77+
# --- Step 4: Validate (Extra field / Tampered payload) ---
78+
extra_field_token = token_obj.copy()
79+
extra_field_token["fraud"] = "injected"
80+
resp_extra = client.post("/validate-qr", json={"qr_text": json.dumps(extra_field_token)})
81+
82+
# Assertion 5: Extra field (tampering) returns False
83+
assert resp_extra.status_code == 200
84+
assert resp_extra.json()["isValid"] is False
85+
86+
# --- Step 5: Audit Log ---
87+
log_resp = client.get(f"/qr/scan-log/{ticket_id}")
88+
89+
# Assertion 6: Audit log contains valid and invalid entries
90+
assert log_resp.status_code == 200
91+
logs = log_resp.json()
92+
assert len(logs) >= 2, "Should have at least one valid and one invalid log entry"
93+
94+
has_valid = any(l["is_valid"] is True for l in logs)
95+
has_invalid = any(l["is_valid"] is False for l in logs)
96+
assert has_valid and has_invalid, "Audit log must contain both valid and invalid attempts"
97+
98+
# --- Step 6: Prometheus Metrics ---
99+
# Assertion 7: Valid scan incremented counter
100+
assert get_metric("valid") == m_valid_start + 1
101+
102+
# Assertion 8: Invalid scans incremented counter (2 invalid attempts in steps 3-4)
103+
assert get_metric("invalid") >= m_invalid_start + 2

0 commit comments

Comments
 (0)