add durable jobs and live job monitor

This commit is contained in:
Marco0300
2026-09-02 18:12:21 +02:00
parent bc33b03075
commit cf034288e6
12 changed files with 342 additions and 18 deletions
+20 -4
View File
@@ -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
View File
@@ -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()
+34
View File
@@ -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);
+82
View File
@@ -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()