Initial commit
This commit is contained in:
439
scripts/smoke.py
Executable file
439
scripts/smoke.py
Executable file
@@ -0,0 +1,439 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Run the first-slice contract against the real local Go API and PostgreSQL."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import http.cookiejar
|
||||
import json
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
import uuid
|
||||
|
||||
|
||||
class Client:
|
||||
def __init__(self, base_url: str) -> None:
|
||||
self.base_url = base_url.rstrip("/")
|
||||
self.opener = urllib.request.build_opener(
|
||||
urllib.request.HTTPCookieProcessor(http.cookiejar.CookieJar())
|
||||
)
|
||||
|
||||
def request(
|
||||
self,
|
||||
method: str,
|
||||
path: str,
|
||||
body: dict | None = None,
|
||||
*,
|
||||
key: str | None = None,
|
||||
expected: int = 200,
|
||||
) -> dict:
|
||||
data = json.dumps(body).encode() if body is not None else None
|
||||
headers = {"Accept": "application/json"}
|
||||
if data is not None:
|
||||
headers["Content-Type"] = "application/json"
|
||||
if key:
|
||||
headers["Idempotency-Key"] = key
|
||||
request = urllib.request.Request(
|
||||
self.base_url + path, data=data, headers=headers, method=method
|
||||
)
|
||||
try:
|
||||
response = self.opener.open(request, timeout=10)
|
||||
status = response.status
|
||||
payload = json.loads(response.read() or b"{}")
|
||||
except urllib.error.HTTPError as error:
|
||||
status = error.code
|
||||
payload = json.loads(error.read() or b"{}")
|
||||
if status != expected:
|
||||
raise AssertionError(
|
||||
f"{method} {path}: expected {expected}, got {status}: {payload}"
|
||||
)
|
||||
return payload
|
||||
|
||||
|
||||
def error_code(payload: dict) -> str | None:
|
||||
error = payload.get("error")
|
||||
return error.get("code") if isinstance(error, dict) else None
|
||||
|
||||
|
||||
def mapping_keys(value: object) -> set[str]:
|
||||
"""Collect every JSON object key recursively for privacy-boundary checks."""
|
||||
if isinstance(value, dict):
|
||||
keys = set(value)
|
||||
for child in value.values():
|
||||
keys.update(mapping_keys(child))
|
||||
return keys
|
||||
if isinstance(value, list):
|
||||
keys: set[str] = set()
|
||||
for child in value:
|
||||
keys.update(mapping_keys(child))
|
||||
return keys
|
||||
return set()
|
||||
|
||||
|
||||
def ticket_with_number(
|
||||
tickets: object,
|
||||
ticket_number: str,
|
||||
source: str,
|
||||
project_id: str | None = None,
|
||||
) -> dict:
|
||||
assert isinstance(tickets, list), f"{source} must be a ticket list"
|
||||
matches = [
|
||||
ticket
|
||||
for ticket in tickets
|
||||
if isinstance(ticket, dict)
|
||||
and ticket.get("ticket_number") == ticket_number
|
||||
and (project_id is None or ticket.get("project_id") == project_id)
|
||||
]
|
||||
assert len(matches) == 1, (
|
||||
f"{source} must contain exactly one ticket {ticket_number}, got {len(matches)}"
|
||||
)
|
||||
return matches[0]
|
||||
|
||||
|
||||
def assert_personal_continuity(
|
||||
ticket: object,
|
||||
*,
|
||||
phone: str,
|
||||
last_name: str,
|
||||
honorific: str,
|
||||
source: str,
|
||||
) -> dict:
|
||||
assert isinstance(ticket, dict), f"{source} must be a ticket object"
|
||||
assert ticket.get("phone") == phone, f"{source} changed the ticket phone"
|
||||
assert ticket.get("last_name") == last_name, (
|
||||
f"{source} changed the ticket last name"
|
||||
)
|
||||
assert ticket.get("honorific") == honorific, (
|
||||
f"{source} changed the ticket honorific"
|
||||
)
|
||||
return ticket
|
||||
|
||||
|
||||
def assert_public_phone_projection(payload: dict, phone: str, source: str) -> None:
|
||||
assert payload.get("phone_last4") == phone[-4:]
|
||||
phone_keys = {
|
||||
key for key in mapping_keys(payload) if "phone" in key.lower()
|
||||
}
|
||||
assert phone_keys == {"phone_last4"}, (
|
||||
f"{source} may expose only phone_last4, got {phone_keys}"
|
||||
)
|
||||
assert phone not in json.dumps(payload, ensure_ascii=False), (
|
||||
f"{source} leaked the complete phone"
|
||||
)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--base-url", default="http://127.0.0.1:8080")
|
||||
parser.add_argument("--username", default="staff")
|
||||
parser.add_argument("--admin-username", default="admin")
|
||||
parser.add_argument("--password", default="ChangeMe123!")
|
||||
parser.add_argument("--display-token", required=True)
|
||||
args = parser.parse_args()
|
||||
|
||||
client = Client(args.base_url)
|
||||
health = client.request("GET", "/healthz")
|
||||
assert health["status"] == "ok"
|
||||
|
||||
client.request(
|
||||
"POST",
|
||||
"/api/staff/auth/login",
|
||||
{"username": args.username, "password": args.password},
|
||||
)
|
||||
me = client.request("GET", "/api/staff/auth/me")
|
||||
project_id = me["projects"][0]["id"]
|
||||
|
||||
snapshot = client.request("GET", f"/api/staff/projects/{project_id}/queue")
|
||||
assert snapshot.get("current_batch") is None, (
|
||||
"smoke requires a project with no active call batch; finish or miss the "
|
||||
"current batch before running it"
|
||||
)
|
||||
assert snapshot.get("waiting") == [], (
|
||||
"smoke requires a freshly rebuilt, dedicated database with no waiting "
|
||||
"tickets; rebuild and seed the smoke database before running it"
|
||||
)
|
||||
phone = "139" + str(int(time.time() * 1000))[-8:]
|
||||
last_name = "烟测"
|
||||
honorific = "游客"
|
||||
ticket_body = {
|
||||
"phone": phone,
|
||||
"last_name": last_name,
|
||||
"honorific": honorific,
|
||||
"allow_duplicate": False,
|
||||
}
|
||||
first_key = str(uuid.uuid4())
|
||||
first = client.request(
|
||||
"POST",
|
||||
f"/api/staff/projects/{project_id}/tickets",
|
||||
ticket_body,
|
||||
key=first_key,
|
||||
expected=201,
|
||||
)
|
||||
first_ticket = first["ticket"]
|
||||
assert first_ticket["ticket_number"]
|
||||
assert first_ticket["public_token"]
|
||||
assert_personal_continuity(
|
||||
first_ticket,
|
||||
phone=phone,
|
||||
last_name=last_name,
|
||||
honorific=honorific,
|
||||
source="create response",
|
||||
)
|
||||
|
||||
replay = client.request(
|
||||
"POST",
|
||||
f"/api/staff/projects/{project_id}/tickets",
|
||||
ticket_body,
|
||||
key=first_key,
|
||||
expected=201,
|
||||
)
|
||||
assert replay["ticket"]["id"] == first_ticket["id"]
|
||||
assert_personal_continuity(
|
||||
replay["ticket"],
|
||||
phone=phone,
|
||||
last_name=last_name,
|
||||
honorific=honorific,
|
||||
source="create idempotency replay",
|
||||
)
|
||||
|
||||
# Staff and admin are authenticated operational surfaces, so they must
|
||||
# expose the complete phone beside the exact ticket number.
|
||||
staff_snapshot = client.request(
|
||||
"GET", f"/api/staff/projects/{project_id}/queue"
|
||||
)
|
||||
staff_ticket = ticket_with_number(
|
||||
staff_snapshot["waiting"], first_ticket["ticket_number"], "staff queue"
|
||||
)
|
||||
assert_personal_continuity(
|
||||
staff_ticket,
|
||||
phone=phone,
|
||||
last_name=last_name,
|
||||
honorific=honorific,
|
||||
source="staff waiting queue",
|
||||
)
|
||||
|
||||
public = client.request(
|
||||
"GET", f"/api/public/status/{first_ticket['public_token']}"
|
||||
)
|
||||
assert public["ticket_number"] == first_ticket["ticket_number"]
|
||||
assert_public_phone_projection(public, phone, "public status")
|
||||
|
||||
client.request(
|
||||
"POST",
|
||||
"/api/admin/auth/login",
|
||||
{"username": args.admin_username, "password": args.password},
|
||||
)
|
||||
overview = client.request("GET", "/api/admin/overview")
|
||||
admin_ticket = ticket_with_number(
|
||||
overview.get("active_tickets"),
|
||||
first_ticket["ticket_number"],
|
||||
"admin active_tickets",
|
||||
project_id,
|
||||
)
|
||||
assert_personal_continuity(
|
||||
admin_ticket,
|
||||
phone=phone,
|
||||
last_name=last_name,
|
||||
honorific=honorific,
|
||||
source="admin active_tickets",
|
||||
)
|
||||
|
||||
duplicate = client.request(
|
||||
"POST",
|
||||
f"/api/staff/projects/{project_id}/tickets",
|
||||
ticket_body,
|
||||
key=str(uuid.uuid4()),
|
||||
expected=409,
|
||||
)
|
||||
assert error_code(duplicate) == "DUPLICATE_PHONE"
|
||||
|
||||
confirmed_body = {**ticket_body, "allow_duplicate": True}
|
||||
confirmed = client.request(
|
||||
"POST",
|
||||
f"/api/staff/projects/{project_id}/tickets",
|
||||
confirmed_body,
|
||||
key=str(uuid.uuid4()),
|
||||
expected=201,
|
||||
)
|
||||
confirmed_ticket = confirmed["ticket"]
|
||||
assert confirmed_ticket["id"] != first_ticket["id"]
|
||||
assert_personal_continuity(
|
||||
confirmed_ticket,
|
||||
phone=phone,
|
||||
last_name=last_name,
|
||||
honorific=honorific,
|
||||
source="confirmed duplicate create response",
|
||||
)
|
||||
|
||||
snapshot = client.request("GET", f"/api/staff/projects/{project_id}/queue")
|
||||
project = snapshot["project"]
|
||||
batch_size = int(
|
||||
project.get("call_batch_size") or project.get("batch_size") or 0
|
||||
)
|
||||
assert batch_size > 0, "project call batch size must be positive"
|
||||
created_tickets = [first_ticket, confirmed_ticket]
|
||||
while len(created_tickets) < batch_size + 1:
|
||||
extra = client.request(
|
||||
"POST",
|
||||
f"/api/staff/projects/{project_id}/tickets",
|
||||
confirmed_body,
|
||||
key=str(uuid.uuid4()),
|
||||
expected=201,
|
||||
)
|
||||
created_tickets.append(extra["ticket"])
|
||||
snapshot = client.request("GET", f"/api/staff/projects/{project_id}/queue")
|
||||
waiting_before_call = snapshot["waiting"]
|
||||
waiting_ids = {ticket["id"] for ticket in waiting_before_call}
|
||||
assert waiting_ids == {ticket["id"] for ticket in created_tickets}, (
|
||||
"dedicated smoke queue contains unexpected tickets"
|
||||
)
|
||||
for ticket in waiting_before_call:
|
||||
assert_personal_continuity(
|
||||
ticket,
|
||||
phone=phone,
|
||||
last_name=last_name,
|
||||
honorific=honorific,
|
||||
source="staff waiting queue before call-next",
|
||||
)
|
||||
stale_call = client.request(
|
||||
"POST",
|
||||
f"/api/staff/projects/{project_id}/call-next",
|
||||
{"expected_revision": max(0, int(snapshot["revision"]) - 1), "count": batch_size},
|
||||
key=str(uuid.uuid4()),
|
||||
expected=409,
|
||||
)
|
||||
assert error_code(stale_call) == "REVISION_CONFLICT"
|
||||
|
||||
expected_called_ids = [
|
||||
ticket["id"] for ticket in waiting_before_call[:batch_size]
|
||||
]
|
||||
call_key = str(uuid.uuid4())
|
||||
call_body = {"expected_revision": int(snapshot["revision"]), "count": batch_size}
|
||||
called = client.request(
|
||||
"POST",
|
||||
f"/api/staff/projects/{project_id}/call-next",
|
||||
call_body,
|
||||
key=call_key,
|
||||
)
|
||||
called_tickets = called["batch"]["tickets"]
|
||||
assert [ticket["id"] for ticket in called_tickets] == expected_called_ids
|
||||
assert len(called_tickets) == min(batch_size, len(waiting_before_call))
|
||||
assert called_tickets, "call-next must call at least one waiting ticket"
|
||||
for ticket in called_tickets:
|
||||
assert_personal_continuity(
|
||||
ticket,
|
||||
phone=phone,
|
||||
last_name=last_name,
|
||||
honorific=honorific,
|
||||
source="call-next response",
|
||||
)
|
||||
assert called["device_results"][0]["status"] == "SUCCESS"
|
||||
|
||||
call_replay = client.request(
|
||||
"POST",
|
||||
f"/api/staff/projects/{project_id}/call-next",
|
||||
call_body,
|
||||
key=call_key,
|
||||
)
|
||||
assert call_replay["batch"]["id"] == called["batch"]["id"]
|
||||
for ticket in call_replay["batch"]["tickets"]:
|
||||
assert_personal_continuity(
|
||||
ticket,
|
||||
phone=phone,
|
||||
last_name=last_name,
|
||||
honorific=honorific,
|
||||
source="call-next idempotency replay",
|
||||
)
|
||||
|
||||
active_snapshot = client.request(
|
||||
"GET", f"/api/staff/projects/{project_id}/queue"
|
||||
)
|
||||
current_batch = active_snapshot.get("current_batch")
|
||||
assert isinstance(current_batch, dict)
|
||||
assert current_batch["id"] == called["batch"]["id"]
|
||||
assert [ticket["id"] for ticket in current_batch["tickets"]] == (
|
||||
expected_called_ids
|
||||
)
|
||||
for ticket in current_batch["tickets"]:
|
||||
assert_personal_continuity(
|
||||
ticket,
|
||||
phone=phone,
|
||||
last_name=last_name,
|
||||
honorific=honorific,
|
||||
source="staff current_batch",
|
||||
)
|
||||
|
||||
second_called = client.request(
|
||||
"POST",
|
||||
f"/api/staff/projects/{project_id}/call-next",
|
||||
{"expected_revision": int(called["revision"]), "count": 1},
|
||||
key=str(uuid.uuid4()),
|
||||
)
|
||||
assert second_called["batch"]["id"] != called["batch"]["id"]
|
||||
assert len(second_called["batch"]["tickets"]) == 1
|
||||
final_staff_snapshot = client.request(
|
||||
"GET", f"/api/staff/projects/{project_id}/queue"
|
||||
)
|
||||
assert final_staff_snapshot["current_batch"]["id"] == second_called["batch"]["id"]
|
||||
assert final_staff_snapshot["metrics"]["waiting_count"] == 0
|
||||
completed_public = client.request(
|
||||
"GET", f"/api/public/status/{first_ticket['public_token']}"
|
||||
)
|
||||
assert completed_public["status"] == "COMPLETED"
|
||||
assert_public_phone_projection(completed_public, phone, "auto-completed public status")
|
||||
display = client.request(
|
||||
"GET", f"/api/display/{args.display_token}/snapshot"
|
||||
)
|
||||
serialized_display = json.dumps(display, ensure_ascii=False)
|
||||
display_keys = mapping_keys(display)
|
||||
assert "honorific" not in display_keys
|
||||
display_phone_keys = {
|
||||
key for key in display_keys if "phone" in key.lower()
|
||||
}
|
||||
forbidden_display_phone_keys = {"phone", "phone_last4", "phone_masked"}
|
||||
assert display_phone_keys.isdisjoint(forbidden_display_phone_keys)
|
||||
assert not display_phone_keys, (
|
||||
f"display leaked phone fields: {sorted(display_phone_keys)}"
|
||||
)
|
||||
assert phone not in serialized_display
|
||||
current_numbers = {
|
||||
ticket["ticket_number"]
|
||||
for ticket in (display.get("current_batch") or {}).get("tickets", [])
|
||||
}
|
||||
assert first_ticket["ticket_number"] not in current_numbers
|
||||
|
||||
overview = client.request("GET", "/api/admin/overview")
|
||||
project = next(item for item in overview["projects"] if item["id"] == project_id)
|
||||
assert project["waiting_count"] == 0
|
||||
current_admin_ticket = ticket_with_number(
|
||||
overview.get("active_tickets"),
|
||||
second_called["batch"]["tickets"][0]["ticket_number"],
|
||||
"admin active_tickets after automatic rotation",
|
||||
project_id,
|
||||
)
|
||||
assert_personal_continuity(
|
||||
current_admin_ticket,
|
||||
phone=phone,
|
||||
last_name=last_name,
|
||||
honorific=honorific,
|
||||
source="admin active_tickets after automatic rotation",
|
||||
)
|
||||
|
||||
print(
|
||||
json.dumps(
|
||||
{
|
||||
"status": "ok",
|
||||
"project_id": project_id,
|
||||
"first_ticket": first_ticket["ticket_number"],
|
||||
"first_batch_id": called["batch"]["id"],
|
||||
"second_batch_id": second_called["batch"]["id"],
|
||||
},
|
||||
ensure_ascii=False,
|
||||
indent=2,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user