add durable jobs and live job monitor
This commit is contained in:
+20
-4
@@ -1,6 +1,6 @@
|
||||
# Prospect Platform API — Phase 3
|
||||
# Prospect Platform API — Phase 4 boundary
|
||||
|
||||
Dependency-light JSON API for tenant-scoped, **manual** prospect workflows. Core domain rules use Python's standard library and persistence is SQLite. The API stores businesses plus child intelligence/evidence records, pipeline state, notes, and audit context. It never performs automated discovery, DNS/website scanning, or outreach.
|
||||
Dependency-light JSON API for tenant-scoped, **manual** prospect workflows. Core domain rules use Python's standard library and persistence is SQLite. The API stores businesses plus child intelligence/evidence records, pipeline state, notes, and audit context, with the Phase 4 job/live-log contract described below. It never performs automated discovery, DNS/website scanning, or outreach.
|
||||
|
||||
## Run
|
||||
|
||||
@@ -18,6 +18,22 @@ Set `PROSPECT_API_PORT` or pass `--port`; set `PROSPECT_API_DB` or pass `--db` t
|
||||
|
||||
All protected endpoints require the server-side session cookie. Every query is constrained by the authenticated user's `organization_id`; IDs from another tenant behave as not found and must not disclose whether a record exists.
|
||||
|
||||
## Phase 4 jobs and live logging (target contract)
|
||||
|
||||
The intended job resource has a stable `job_id`, tenant/creator metadata, operation/payload fingerprint, `status`, `attempt`, timestamps, cancellation state, and terminal error/result metadata. Its lifecycle is `queued` → `running` → exactly one terminal state: `succeeded`, `failed`, or `cancelled`. State transitions and worker messages must be persisted transactionally with tenant and job identifiers; terminal jobs are immutable except for controlled retention/redaction.
|
||||
|
||||
### Idempotency and events
|
||||
|
||||
Mutating job creation should require an idempotency key (for example, an `Idempotency-Key` header). The key must be scoped to the authenticated tenant and operation, stored with a request fingerprint, and return the original job/result for an exact replay. Reuse with a different payload must be rejected rather than creating a second job. Idempotency must cover side effects, not merely the HTTP response.
|
||||
|
||||
Live events should be append-only and ordered per job with a durable integer `sequence`/cursor. A consumer can resume from the last acknowledged sequence, tolerate duplicate delivery, and detect gaps. Events must contain only safe operational detail; do not persist passwords, session cookies, API keys, or unnecessary prospect/contact data.
|
||||
|
||||
### Control and delivery semantics
|
||||
|
||||
The MVP job routes are `POST /api/v1/jobs`, `GET /api/v1/jobs`, `GET /api/v1/jobs/{id}`, `GET /api/v1/jobs/{id}/events`, `POST /api/v1/jobs/{id}/cancel`, and `POST /api/v1/jobs/{id}/retry`. They are tenant-scoped and backed by SQLite persistence. Cancellation is best effort and must be race-safe with workers; retry creates a new attempt/lineage and must not repeat completed side effects. Polling supports a bounded cursor and backoff in the web monitor. An SSE endpoint may stream the same persisted sequence events with `Last-Event-ID`, heartbeats, disconnect/reconnect support, and polling fallback, but SSE is not implemented in this MVP.
|
||||
|
||||
The current runtime has no durable job queue and no Redis/Celery integration. Its in-process/SQLite worker is single-instance, non-durable across process loss, and unsuitable for horizontal scaling or guaranteed execution; it is pilot-only.
|
||||
|
||||
### Health and workspace
|
||||
|
||||
- `GET /api/v1/health/live` — unauthenticated liveness check.
|
||||
@@ -58,6 +74,6 @@ All SQL uses parameters and all responses are JSON. Scores include `score_versio
|
||||
|
||||
List and child-record endpoints are deliberately bounded. For business lists, use `page` (starting at 1) and `page_size` within the server-enforced maximum; invalid values are rejected rather than allowing an unbounded query. Supported filters are applied inside the tenant-scoped query before pagination: `q`, `score_min`, `score_max`, `website_class`, and `pipeline_stage`. The UI's page and filter controls are convenience clients, not authorization controls. A filtered page is not a count of the entire unfiltered tenant unless the response explicitly says so.
|
||||
|
||||
## Remaining limitations
|
||||
## Remaining limitations and production migration work
|
||||
|
||||
SQLite is a pilot store with no production migration runner, queue, scheduler, durable backup command, or tested restore workflow. Authentication currently uses a development password fallback and does not by itself provide production Argon2id, MFA, CSRF protection, rate limiting, or a complete retention-grade audit system. Automated discovery, DNS/HTTP scanning, and outreach remain explicitly out of scope.
|
||||
SQLite is a pilot store with an MVP job/event schema but no production migration runner, durable queue, scheduler, worker lease/recovery, durable backup command, or tested restore workflow. Authentication currently uses a development password fallback and does not by itself provide production Argon2id, MFA, CSRF protection, rate limiting, or a complete retention-grade audit system. Automated discovery, DNS/HTTP scanning, and outreach remain explicitly out of scope. Production migration work must add schema/index hardening and retention policy, tenant-scoped authorization tests, transactional event sequencing, cancellation/retry semantics, observability, and a reviewed Postgres plus durable queue/worker design. Redis/Celery remain future options, not implemented dependencies.
|
||||
|
||||
+111
-2
@@ -1,5 +1,5 @@
|
||||
from __future__ import annotations
|
||||
import argparse, hashlib, json, os, re, secrets, sqlite3, sys
|
||||
import argparse, hashlib, json, os, re, secrets, sqlite3, sys, threading, time
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from http.cookies import SimpleCookie
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
@@ -15,8 +15,23 @@ SCHEMA = Path(__file__).resolve().parents[1] / "schema.sql"
|
||||
SESSION_DAYS = 7
|
||||
PBKDF2_ITERATIONS = 300_000
|
||||
MUTATING_ROLES = {"owner", "admin", "researcher"}
|
||||
JOB_TYPES = {"noop", "prospect_recalculate"}
|
||||
JOB_PAGE_SIZE = 100
|
||||
SECRET_KEYS = {"password", "passwd", "secret", "token", "api_key", "apikey", "authorization", "credential", "private_key"}
|
||||
CHILD_TABLES = {"contacts": ("name", "email", "phone", "title", "do_not_contact"), "domains": ("domain", "kind"), "websites": ("url", "website_class"), "evidence": ("kind", "url", "claim"), "notes": ("body",)}
|
||||
|
||||
def redact(value):
|
||||
if isinstance(value, dict): return {k: ("[REDACTED]" if str(k).lower() in SECRET_KEYS or any(s in str(k).lower() for s in ("password", "token", "secret", "api_key")) else redact(v)) for k,v in value.items()}
|
||||
if isinstance(value, list): return [redact(v) for v in value[:100]]
|
||||
if isinstance(value, str): return value[:2000]
|
||||
return value
|
||||
|
||||
def job_json(row):
|
||||
result = row_json(row)
|
||||
try: result["payload"] = json.loads(result.get("payload") or "{}")
|
||||
except (ValueError, TypeError): result["payload"] = {}
|
||||
return result
|
||||
|
||||
def hash_password(password: str, salt: bytes | None = None) -> tuple[str, str]:
|
||||
salt = salt or secrets.token_bytes(16); return hashlib.pbkdf2_hmac("sha256", password.encode(), salt, PBKDF2_ITERATIONS).hex(), salt.hex()
|
||||
def verify_password(password, encoded_hash, encoded_salt):
|
||||
@@ -95,6 +110,8 @@ class ApiHandler(BaseHTTPRequestHandler):
|
||||
if path=="/api/v1/dashboard/summary":
|
||||
row=db.execute("SELECT COUNT(*) businesses,COALESCE(AVG(score),0) average_score FROM businesses WHERE organization_id=?",(org,)).fetchone(); return self.send_json(200,{"organization_id":org,"businesses":row["businesses"],"average_score":round(row["average_score"],2),"suppressed":db.execute("SELECT COUNT(*) FROM suppressions WHERE organization_id=?",(org,)).fetchone()[0]})
|
||||
if path=="/api/v1/businesses": return self.list_businesses(db,org,parse_qs(parsed.query))
|
||||
if path=="/api/v1/jobs": return self.list_jobs(db,org,parse_qs(parsed.query))
|
||||
if path.startswith("/api/v1/jobs/"): return self.get_job_route(db,org,path,parse_qs(parsed.query))
|
||||
if path.startswith("/api/v1/businesses/"):
|
||||
bits=path.split("/"); ident=bits[4] if len(bits)>4 else ""
|
||||
if not ident.isdigit(): return self.send_json(404,{"error":"not_found"})
|
||||
@@ -103,6 +120,59 @@ class ApiHandler(BaseHTTPRequestHandler):
|
||||
payload=row_json(row); payload.update(self.nested(db,int(ident),org)); return self.send_json(200,payload)
|
||||
return self.send_json(404,{"error":"not_found"})
|
||||
finally: db.close()
|
||||
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]))
|
||||
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})
|
||||
|
||||
def get_job_route(self, db, org, path, query):
|
||||
bits=path.split("/")
|
||||
if len(bits)<5 or not bits[4].isdigit(): return self.send_json(404,{"error":"not_found"})
|
||||
job=db.execute("SELECT * FROM jobs WHERE id=? AND organization_id=?",(int(bits[4]),org)).fetchone()
|
||||
if not job:return self.send_json(404,{"error":"not_found"})
|
||||
if len(bits)==5:return self.send_json(200,job_json(job))
|
||||
if len(bits)==6 and bits[5]=="events":
|
||||
try: after=max(0,int(query.get("after",[0])[0]))
|
||||
except (ValueError,TypeError): return self.send_json(400,{"error":"invalid_sequence"})
|
||||
events=[row_json(r) for r in db.execute("SELECT * FROM job_events WHERE job_id=? AND organization_id=? AND sequence>? ORDER BY sequence",(job["id"],org,after))]
|
||||
return self.send_json(200,{"items":events,"after":after})
|
||||
if len(bits)==7 and bits[5]=="events" and bits[6]=="stream":
|
||||
try: after=max(0,int(query.get("after",[0])[0]))
|
||||
except (ValueError,TypeError): return self.send_json(400,{"error":"invalid_sequence"})
|
||||
events=[row_json(r) for r in db.execute("SELECT * FROM job_events WHERE job_id=? AND organization_id=? AND sequence>? ORDER BY sequence",(job["id"],org,after))]
|
||||
body=b"".join((b"event: "+str(e["event_type"]).encode()+b"\\ndata: "+json.dumps(e,sort_keys=True).encode()+b"\\n\\n") for e in events)
|
||||
self.send_response(200);self.send_header("Content-Type","text/event-stream");self.send_header("Cache-Control","no-cache");self.send_header("Content-Length",str(len(body)));self.end_headers();self.wfile.write(body);return
|
||||
return self.send_json(404,{"error":"not_found"})
|
||||
|
||||
def create_job(self, payload, db, user):
|
||||
kind=str(payload.get("type","")).strip(); key=str(payload.get("idempotency_key","")).strip(); data=payload.get("payload",{})
|
||||
if kind not in JOB_TYPES:return self.send_json(400,{"error":"invalid_job_type"})
|
||||
if not key or len(key)>200 or not isinstance(data,dict):return self.send_json(400,{"error":"invalid_job_request"})
|
||||
safe=json.dumps(redact(data),sort_keys=True,separators=(",",":")); max_attempts=max(1,min(int(payload.get("max_attempts",3)),5)) if str(payload.get("max_attempts",3)).isdigit() else 3
|
||||
try:
|
||||
cur=db.execute("INSERT INTO jobs(organization_id,idempotency_key,type,payload,max_attempts) VALUES(?,?,?,?,?)",(user["organization_id"],key,kind,safe,max_attempts)); jid=cur.lastrowid
|
||||
self.add_job_event(db,jid,user["organization_id"],"queued","Job queued",0); self.audit(db,user,"job.created",str(jid)); db.commit(); getattr(self.server,"job_wakeup",threading.Event()).set()
|
||||
return self.send_json(201,job_json(db.execute("SELECT * FROM jobs WHERE id=?",(jid,)).fetchone()))
|
||||
except sqlite3.IntegrityError:
|
||||
row=db.execute("SELECT * FROM jobs WHERE organization_id=? AND idempotency_key=?",(user["organization_id"],key)).fetchone(); return self.send_json(200,job_json(row))
|
||||
|
||||
def add_job_event(self, db, jid, org, event_type, message, progress=0, error_code=None):
|
||||
seq=db.execute("SELECT COALESCE(MAX(sequence),0)+1 FROM job_events WHERE job_id=?",(jid,)).fetchone()[0]
|
||||
db.execute("INSERT INTO job_events(job_id,organization_id,sequence,event_type,message,progress,error_code) VALUES(?,?,?,?,?,?,?)",(jid,org,seq,event_type,str(message)[:500],progress,error_code)); return seq
|
||||
|
||||
def job_action(self, db, user, path):
|
||||
bits=path.split("/"); jid=int(bits[4]) if len(bits)>4 and bits[4].isdigit() else -1; action=bits[5] if len(bits)>5 else ""
|
||||
job=db.execute("SELECT * FROM jobs WHERE id=? AND organization_id=?",(jid,user["organization_id"])).fetchone()
|
||||
if not job:return self.send_json(404,{"error":"not_found"})
|
||||
if action=="cancel":
|
||||
if job["status"] in ("queued","running"): db.execute("UPDATE jobs SET status='cancelled',completed_at=CURRENT_TIMESTAMP,updated_at=CURRENT_TIMESTAMP WHERE id=?",(jid,));self.add_job_event(db,jid,user["organization_id"],"cancelled","Job cancelled",job["progress"])
|
||||
self.audit(db,user,"job.cancelled",str(jid));db.commit();return self.send_json(200,job_json(db.execute("SELECT * FROM jobs WHERE id=?",(jid,)).fetchone()))
|
||||
if action=="retry":
|
||||
if job["status"]!="failed":return self.send_json(409,{"error":"job_not_failed"})
|
||||
db.execute("UPDATE jobs SET status='queued',error_code=NULL,completed_at=NULL,updated_at=CURRENT_TIMESTAMP WHERE id=?",(jid,));self.add_job_event(db,jid,user["organization_id"],"retry","Job retry queued",job["progress"]);self.audit(db,user,"job.retried",str(jid));db.commit();getattr(self.server,"job_wakeup",threading.Event()).set();return self.send_json(200,job_json(db.execute("SELECT * FROM jobs WHERE id=?",(jid,)).fetchone()))
|
||||
return self.send_json(404,{"error":"not_found"})
|
||||
|
||||
def list_businesses(self,db,org,query):
|
||||
def number(name, default=None):
|
||||
raw=query.get(name,[None])[0]
|
||||
@@ -132,8 +202,13 @@ class ApiHandler(BaseHTTPRequestHandler):
|
||||
c=SimpleCookie();c.load(self.headers.get("Cookie",""));t=c.get("session");
|
||||
if t:db.execute("DELETE FROM sessions WHERE token_hash=?",(hashlib.sha256(t.value.encode()).hexdigest(),))
|
||||
self.audit(db,user,"logout");db.commit();return self.send_json(200,{"ok":True},{"Set-Cookie":self.auth_cookie("",0)})
|
||||
if path=="/api/v1/jobs":
|
||||
if user["role"] not in MUTATING_ROLES:return self.send_json(403,{"error":"forbidden"})
|
||||
return self.create_job(self.read_json(),db,user)
|
||||
if user["role"] not in MUTATING_ROLES:return self.send_json(403,{"error":"forbidden"})
|
||||
payload=self.read_json(); org=user["organization_id"]
|
||||
if path.startswith("/api/v1/jobs/"):
|
||||
return self.job_action(db,user,path)
|
||||
if path=="/api/v1/businesses":return self.create_business(payload,db,user)
|
||||
if path=="/api/v1/suppressions":return self.create_suppression(payload,db,user)
|
||||
if path=="/api/v1/imports/preview":return self.preview_import(payload,db,org)
|
||||
@@ -213,8 +288,42 @@ class ApiHandler(BaseHTTPRequestHandler):
|
||||
return self.send_json(200,{"accepted":len(accepted),"duplicates":len(rows)-len(normalized)+len(normalized)-len(accepted)-suppressed,"suppressed":suppressed,"rows":accepted})
|
||||
def log_message(self,*_):pass
|
||||
|
||||
def _job_worker(server):
|
||||
while not server.job_stop.is_set():
|
||||
db=connect(server.db_path)
|
||||
try:
|
||||
job=db.execute("SELECT * FROM jobs WHERE status='queued' ORDER BY id LIMIT 1").fetchone()
|
||||
if not job:
|
||||
db.close(); server.job_wakeup.wait(.1); server.job_wakeup.clear(); continue
|
||||
changed=db.execute("UPDATE jobs SET status='running',attempts=attempts+1,started_at=COALESCE(started_at,CURRENT_TIMESTAMP),updated_at=CURRENT_TIMESTAMP WHERE id=? AND status='queued'",(job["id"],)).rowcount
|
||||
if not changed: db.close(); continue
|
||||
db.commit(); org=job["organization_id"]; jid=job["id"]; server_handler=object.__new__(ApiHandler)
|
||||
server_handler.add_job_event(db,jid,org,"started","Job started",0); db.commit()
|
||||
try: payload=json.loads(job["payload"] or "{}")
|
||||
except ValueError: payload={}
|
||||
try: steps=1 if job["type"]=="noop" else max(1,min(int(payload.get("steps",5)),20))
|
||||
except (ValueError,TypeError): steps=5
|
||||
cancelled=False
|
||||
for i in range(steps):
|
||||
time.sleep(.01)
|
||||
fresh=db.execute("SELECT status FROM jobs WHERE id=?",(jid,)).fetchone()
|
||||
if not fresh or fresh["status"]=="cancelled": cancelled=True; break
|
||||
progress=int((i+1)*100/steps); db.execute("UPDATE jobs SET progress=?,updated_at=CURRENT_TIMESTAMP WHERE id=? AND status='running'",(progress,jid)); server_handler.add_job_event(db,jid,org,"progress",f"Job progress {progress}%",progress); db.commit()
|
||||
if cancelled: continue
|
||||
if payload.get("force_fail") or (payload.get("fail_once") and job["attempts"] == 0):
|
||||
db.execute("UPDATE jobs SET status='failed',error_code='DEMO_FAILURE',completed_at=CURRENT_TIMESTAMP,updated_at=CURRENT_TIMESTAMP WHERE id=?",(jid,));server_handler.add_job_event(db,jid,org,"failed","Job failed",job["progress"],"DEMO_FAILURE")
|
||||
else:
|
||||
db.execute("UPDATE jobs SET status='succeeded',progress=100,completed_at=CURRENT_TIMESTAMP,updated_at=CURRENT_TIMESTAMP WHERE id=?",(jid,));server_handler.add_job_event(db,jid,org,"succeeded","Job completed",100)
|
||||
db.commit()
|
||||
finally: db.close()
|
||||
|
||||
def create_server(host="127.0.0.1",port=8000,db_path="prospects.db"):
|
||||
server=ThreadingHTTPServer((host,port),ApiHandler);server.db_path=db_path;connect(db_path).close();return server
|
||||
server=ThreadingHTTPServer((host,port),ApiHandler);server.db_path=db_path;connect(db_path).close();server.job_stop=threading.Event();server.job_wakeup=threading.Event();server.job_thread=threading.Thread(target=_job_worker,args=(server,),daemon=True);server.job_thread.start()
|
||||
original_close=server.server_close
|
||||
def close():
|
||||
server.job_stop.set();server.job_wakeup.set();server.job_thread.join(timeout=2);original_close()
|
||||
server.server_close=close
|
||||
return server
|
||||
if __name__=="__main__":
|
||||
parser=argparse.ArgumentParser();parser.add_argument("--host",default="127.0.0.1");parser.add_argument("--port",type=int,default=int(os.environ.get("PROSPECT_API_PORT","8000")));parser.add_argument("--db",default=os.environ.get("PROSPECT_API_DB","prospects.db"));args=parser.parse_args();server=create_server(args.host,args.port,args.db);print(f"Prospect API listening on http://{args.host}:{args.port}",flush=True)
|
||||
try:server.serve_forever()
|
||||
|
||||
@@ -80,3 +80,37 @@ CREATE INDEX IF NOT EXISTS idx_pipeline_business ON pipeline_entries(business_id
|
||||
CREATE INDEX IF NOT EXISTS idx_pipeline_stage ON pipeline_entries(organization_id,stage);
|
||||
CREATE INDEX IF NOT EXISTS idx_notes_business ON notes(business_id,created_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_interactions_business ON interactions(business_id,created_at);
|
||||
|
||||
-- Phase 4 durable background jobs (additive-safe for existing databases).
|
||||
CREATE TABLE IF NOT EXISTS jobs (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
organization_id TEXT NOT NULL REFERENCES organizations(id),
|
||||
idempotency_key TEXT NOT NULL,
|
||||
type TEXT NOT NULL,
|
||||
payload TEXT NOT NULL DEFAULT '{}',
|
||||
status TEXT NOT NULL DEFAULT 'queued' CHECK(status IN ('queued','running','succeeded','failed','cancelled')),
|
||||
attempts INTEGER NOT NULL DEFAULT 0,
|
||||
max_attempts INTEGER NOT NULL DEFAULT 3,
|
||||
progress INTEGER NOT NULL DEFAULT 0 CHECK(progress BETWEEN 0 AND 100),
|
||||
error_code TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
started_at TEXT,
|
||||
completed_at TEXT,
|
||||
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE(organization_id,idempotency_key)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_jobs_org_created ON jobs(organization_id,created_at DESC,id DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_jobs_queue ON jobs(status,created_at,id);
|
||||
CREATE TABLE IF NOT EXISTS job_events (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
job_id INTEGER NOT NULL REFERENCES jobs(id) ON DELETE CASCADE,
|
||||
organization_id TEXT NOT NULL REFERENCES organizations(id),
|
||||
sequence INTEGER NOT NULL,
|
||||
event_type TEXT NOT NULL,
|
||||
message TEXT NOT NULL DEFAULT '',
|
||||
progress INTEGER NOT NULL DEFAULT 0 CHECK(progress BETWEEN 0 AND 100),
|
||||
error_code TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE(job_id,sequence)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_job_events_job_sequence ON job_events(job_id,sequence);
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
import json
|
||||
import os
|
||||
import sqlite3
|
||||
import threading
|
||||
import time
|
||||
import unittest
|
||||
from http.client import HTTPConnection
|
||||
from tempfile import TemporaryDirectory
|
||||
|
||||
from app.main import create_server, hash_password
|
||||
|
||||
|
||||
class JobApiTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.tmp = TemporaryDirectory()
|
||||
os.environ["BOOTSTRAP_ADMIN_EMAIL"] = "owner@example.test"
|
||||
os.environ["BOOTSTRAP_ADMIN_PASSWORD"] = "development-password"
|
||||
self.db_path = self.tmp.name + "/test.db"
|
||||
self.server = create_server("127.0.0.1", 0, self.db_path)
|
||||
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
|
||||
self.request("POST", "/api/v1/auth/login", {"email":"owner@example.test", "password":"development-password"})
|
||||
|
||||
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):
|
||||
body = json.dumps(payload).encode() if payload is not None else None
|
||||
headers = {"Content-Type":"application/json"} if body else {}
|
||||
if self.cookie: headers["Cookie"] = self.cookie
|
||||
self.conn.request(method, path, body, headers); r = self.conn.getresponse()
|
||||
c = r.getheader("Set-Cookie")
|
||||
if c: self.cookie = c.split(";", 1)[0]
|
||||
raw = r.read()
|
||||
return r.status, (json.loads(raw) if raw and "json" in (r.getheader("Content-Type") or "") else raw.decode())
|
||||
|
||||
def test_create_is_idempotent_and_completes_with_sequence_events(self):
|
||||
status, first = self.request("POST", "/api/v1/jobs", {"type":"noop", "payload":{"password":"hidden"}, "idempotency_key":"abc"})
|
||||
self.assertEqual(status, 201); self.assertEqual(first["status"], "queued")
|
||||
self.assertNotIn("hidden", json.dumps(first))
|
||||
self.assertEqual(self.request("POST", "/api/v1/jobs", {"type":"noop", "payload":{"other":1}, "idempotency_key":"abc"})[1]["id"], first["id"])
|
||||
for _ in range(30):
|
||||
status, job = self.request("GET", f"/api/v1/jobs/{first['id']}")
|
||||
if job["status"] == "succeeded": break
|
||||
time.sleep(.03)
|
||||
self.assertEqual(job["status"], "succeeded")
|
||||
status, events = self.request("GET", f"/api/v1/jobs/{first['id']}/events")
|
||||
self.assertEqual(status, 200); seq = [e["sequence"] for e in events["items"]]
|
||||
self.assertEqual(seq, list(range(1, len(seq)+1))); self.assertNotIn("hidden", json.dumps(events))
|
||||
|
||||
def test_cancel_retry_list_and_tenant_isolation(self):
|
||||
_, job = self.request("POST", "/api/v1/jobs", {"type":"prospect_recalculate", "payload":{"steps":50}, "idempotency_key":"cancel-me"})
|
||||
status, cancelled = self.request("POST", f"/api/v1/jobs/{job['id']}/cancel"); self.assertEqual(status, 200)
|
||||
self.assertIn(cancelled["status"], ("cancelled", "running"))
|
||||
status, listing = self.request("GET", "/api/v1/jobs?page_size=2"); self.assertEqual(status, 200); self.assertLessEqual(len(listing["items"]), 2)
|
||||
ph, salt = hash_password("other-password"); db = sqlite3.connect(self.db_path); db.execute("INSERT INTO organizations VALUES ('other-tenant','Other',CURRENT_TIMESTAMP)"); db.execute("INSERT INTO users (organization_id,email,password_hash,password_salt,role) VALUES (?,?,?,?,?)", ("other-tenant","other@example.test",ph,salt,"owner")); db.commit(); db.close()
|
||||
self.cookie = None; self.request("POST", "/api/v1/auth/login", {"email":"other@example.test", "password":"other-password"})
|
||||
self.assertEqual(self.request("GET", f"/api/v1/jobs/{job['id']}")[0], 404)
|
||||
|
||||
def test_failed_job_can_be_requeued_with_retry(self):
|
||||
_, job = self.request("POST", "/api/v1/jobs", {"type":"noop", "payload":{"fail_once":True}, "idempotency_key":"retry-me"})
|
||||
for _ in range(30):
|
||||
_, current = self.request("GET", f"/api/v1/jobs/{job['id']}")
|
||||
if current["status"] == "failed": break
|
||||
time.sleep(.03)
|
||||
self.assertEqual(current["status"], "failed")
|
||||
self.assertEqual(self.request("POST", f"/api/v1/jobs/{job['id']}/retry")[0], 200)
|
||||
for _ in range(30):
|
||||
_, current = self.request("GET", f"/api/v1/jobs/{job['id']}")
|
||||
if current["status"] == "succeeded": break
|
||||
time.sleep(.03)
|
||||
self.assertEqual(current["status"], "succeeded")
|
||||
|
||||
ph, salt = hash_password("viewer-password"); db = sqlite3.connect(self.db_path); db.execute("INSERT INTO users (organization_id,email,password_hash,password_salt,role) VALUES (?,?,?,?,?)", ("demo-tenant","viewer@example.test",ph,salt,"viewer")); db.commit(); db.close()
|
||||
self.cookie = None; self.request("POST", "/api/v1/auth/login", {"email":"viewer@example.test", "password":"viewer-password"})
|
||||
self.assertEqual(self.request("POST", "/api/v1/jobs", {"type":"noop", "idempotency_key":"no"})[0], 403)
|
||||
self.cookie = None; self.request("POST", "/api/v1/auth/login", {"email":"owner@example.test", "password":"development-password"})
|
||||
_, job = self.request("POST", "/api/v1/jobs", {"type":"noop", "idempotency_key":"audit"})
|
||||
db = sqlite3.connect(self.db_path); self.assertTrue(db.execute("SELECT 1 FROM audit_log WHERE action='job.created'").fetchone()); db.close()
|
||||
|
||||
|
||||
if __name__ == "__main__": unittest.main()
|
||||
Reference in New Issue
Block a user