add durable jobs and live job monitor
This commit is contained in:
@@ -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