harden dashboard API contracts and proxy errors
This commit is contained in:
@@ -839,10 +839,11 @@ class ApiHandler(BaseHTTPRequestHandler):
|
||||
return self.send_json(200,{"business_id":bid,"status":"unknown","reason":"not_configured","provider_configured":False,"items":[{"domain":d,"status":"unknown","reason":"not_configured"} for d in domains]})
|
||||
|
||||
def list_jobs(self, db, org, query):
|
||||
try: limit=max(1,min(int(query.get("page_size",[50])[0]),JOB_PAGE_SIZE)); offset=max(0,int(query.get("offset",[0])[0]))
|
||||
try:
|
||||
limit=max(1,min(int(query.get("page_size",[50])[0]),JOB_PAGE_SIZE)); page=max(1,int(query.get("page",[1])[0])); offset=max(0,int(query.get("offset",[0])[0]))+(page-1)*limit
|
||||
except (ValueError, TypeError): return self.send_json(400,{"error":"invalid_pagination"})
|
||||
rows=db.execute("SELECT * FROM jobs WHERE organization_id=? ORDER BY id DESC LIMIT ? OFFSET ?",(org,limit+1,offset)).fetchall(); more=len(rows)>limit
|
||||
return self.send_json(200,{"organization_id":org,"items":[job_json(r) for r in rows[:limit]],"limit":limit,"offset":offset,"has_more":more})
|
||||
return self.send_json(200,{"organization_id":org,"items":[job_json(r) for r in rows[:limit]],"limit":limit,"offset":offset,"page":page,"page_size":limit,"has_more":more,"has_next":more,"next_page":page+1 if more else None})
|
||||
|
||||
def _discovery_run_json(self, row):
|
||||
item = row_json(row)
|
||||
@@ -1038,7 +1039,7 @@ class ApiHandler(BaseHTTPRequestHandler):
|
||||
if stage: where.append("EXISTS (SELECT 1 FROM pipeline_entries p WHERE p.business_id=b.id AND p.organization_id=b.organization_id AND p.stage=?)"); params.append(stage)
|
||||
offset=(number("cursor",0) or 0)+(page-1)*size
|
||||
rows=db.execute("SELECT b.* FROM businesses b WHERE "+" AND ".join(where)+" ORDER BY b.score DESC,b.id LIMIT ? OFFSET ?",params+[size+1,offset]).fetchall(); more=len(rows)>size; rows=rows[:size]
|
||||
return self.send_json(200,{"organization_id":org,"items":[row_json(r) for r in rows],"page":page,"page_size":size,"next_cursor":str(offset+size) if more else None})
|
||||
return self.send_json(200,{"organization_id":org,"items":[row_json(r) for r in rows],"page":page,"page_size":size,"has_next":more,"next_page":page+1 if more else None,"next_cursor":str(offset+size) if more else None})
|
||||
def bulk_review(self, payload, db, user):
|
||||
ids = payload.get("ids", payload.get("business_ids")); action = str(payload.get("action", "")).strip().lower()
|
||||
if not isinstance(ids, list) or not ids or len(ids) > 100 or any(not isinstance(i, int) or i < 1 for i in ids) or len(set(ids)) != len(ids): return self.send_json(400, {"error": "invalid_bulk_ids"})
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
import json
|
||||
import os
|
||||
import threading
|
||||
import unittest
|
||||
from http.client import HTTPConnection
|
||||
from tempfile import TemporaryDirectory
|
||||
|
||||
from app.main import create_server
|
||||
|
||||
|
||||
class DashboardBootstrapContractTests(unittest.TestCase):
|
||||
"""Exercise the exact independent GETs issued by the dashboard bootstrap."""
|
||||
|
||||
BOOTSTRAP_GETS = (
|
||||
("/api/v1/businesses?page=1&page_size=10", ("items", "page", "page_size", "next_cursor")),
|
||||
("/api/v1/dashboard/summary", ("businesses", "counts", "clickable_filters")),
|
||||
("/api/v1/jobs", ("items", "has_more", "limit", "offset")),
|
||||
("/api/v1/sources", ("items",)),
|
||||
("/api/v1/sources/adapters", ("items",)),
|
||||
("/api/v1/discovery-runs?page_size=50", ("items",)),
|
||||
("/api/v1/scoring/summary", ("businesses", "bands")),
|
||||
("/api/v1/score-rules", ("items",)),
|
||||
("/api/v1/saved-filters", ("items",)),
|
||||
("/api/v1/review-queue?page=1&page_size=100", ("items", "has_more", "limit", "offset")),
|
||||
("/api/v1/pipeline-entries", ("items",)),
|
||||
("/api/v1/reports/pipeline", ("items",)),
|
||||
("/api/v1/reports/outcomes", ("items",)),
|
||||
("/api/v1/reports/activity", ("items",)),
|
||||
("/api/v1/suppressions", ("items",)),
|
||||
("/api/v1/outreach/provider-config", ("enabled", "policy")),
|
||||
("/api/v1/ai/provider-config", ("status",)),
|
||||
)
|
||||
|
||||
def setUp(self):
|
||||
self.tmp = TemporaryDirectory()
|
||||
os.environ["BOOTSTRAP_ADMIN_EMAIL"] = "owner@example.test"
|
||||
os.environ["BOOTSTRAP_ADMIN_PASSWORD"] = "development-password"
|
||||
self.server = create_server("127.0.0.1", 0, self.tmp.name + "/dashboard.db")
|
||||
self.thread = threading.Thread(target=self.server.serve_forever, daemon=True)
|
||||
self.thread.start()
|
||||
self.conn = HTTPConnection("127.0.0.1", self.server.server_port, timeout=3)
|
||||
self.cookie = None
|
||||
status, body = self.request("POST", "/api/v1/auth/login", {"email": "owner@example.test", "password": "development-password"})
|
||||
self.assertEqual(status, 200, body)
|
||||
|
||||
def tearDown(self):
|
||||
self.server.shutdown()
|
||||
self.server.server_close()
|
||||
self.thread.join(timeout=2)
|
||||
self.tmp.cleanup()
|
||||
|
||||
def request(self, method, path, payload=None, authenticated=True):
|
||||
body = json.dumps(payload).encode() if payload is not None else None
|
||||
headers = {"Content-Type": "application/json"} if body else {}
|
||||
if authenticated and self.cookie:
|
||||
headers["Cookie"] = self.cookie
|
||||
self.conn.request(method, path, body, headers)
|
||||
response = self.conn.getresponse()
|
||||
set_cookie = response.getheader("Set-Cookie")
|
||||
if set_cookie and "session=" in set_cookie:
|
||||
self.cookie = set_cookie.split(";", 1)[0]
|
||||
raw = response.read()
|
||||
return response.status, json.loads(raw or b"{}")
|
||||
|
||||
def test_every_bootstrap_get_is_authenticated_json_and_shape_compatible(self):
|
||||
failures = []
|
||||
for path, required in self.BOOTSTRAP_GETS:
|
||||
status, body = self.request("GET", path)
|
||||
if status != 200:
|
||||
failures.append(f"{path}: HTTP {status} {body}")
|
||||
elif not all(key in body for key in required):
|
||||
failures.append(f"{path}: missing {sorted(set(required) - set(body))} in {body}")
|
||||
self.assertEqual(failures, [])
|
||||
status, body = self.request("GET", "/api/v1/businesses?page=1&page_size=10")
|
||||
self.assertEqual(status, 200)
|
||||
self.assertIn("has_next", body)
|
||||
self.assertIn("next_page", body)
|
||||
status, body = self.request("GET", "/api/v1/jobs?page=1&page_size=10")
|
||||
self.assertEqual(status, 200)
|
||||
self.assertIn("page", body)
|
||||
self.assertIn("page_size", body)
|
||||
|
||||
status, body = self.request("GET", "/api/v1/businesses?page_size=0")
|
||||
self.assertEqual(status, 400)
|
||||
self.assertEqual(body["error"], "invalid_pagination")
|
||||
status, body = self.request("GET", "/api/v1/health/live", authenticated=False)
|
||||
self.assertEqual(status, 200)
|
||||
self.assertEqual(body["status"], "ok")
|
||||
status, body = self.request("GET", "/api/v1/health/ready", authenticated=False)
|
||||
self.assertEqual(status, 200)
|
||||
self.assertTrue(body["ready"])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user