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