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
+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()