WhoIsLuwah commited on
Commit
f39e6ee
·
1 Parent(s): 9a7ec08

fix(backend): balanced clustering, retry failed transcriptions, pytest

Browse files

- Clustering: switch from average-linkage agglomerative to k-means on
normalized embeddings. On the real 740-reel library the old method put
96% of reels into two clusters (404/306) and left singletons; k-means
gives 12 groups of 33-106.
- Worker: a failed transcription now raises so the reel is retried with
backoff; only the final attempt settles for a caption-only extraction.
Previously any Whisper error silently produced a caption-only "done" row.
- Tests: verify_pipeline.py's hand-rolled runner becomes pytest
(backend/test_pipeline.py, monkeypatch fixtures). Drops two
hasattr-only tests; adds clustering balance and transcription retry tests.
Dev dependencies live in requirements-dev.txt.
- Remove batch_import.py, superseded by POST /extract/batch.

Claude-Session: https://claude.ai/code/session_0166W7S7XvHg5P2uyJtP4GiC

Files changed (5) hide show
  1. batch_import.py +0 -146
  2. main.py +21 -12
  3. requirements-dev.txt +2 -0
  4. test_pipeline.py +500 -0
  5. verify_pipeline.py +0 -745
batch_import.py DELETED
@@ -1,146 +0,0 @@
1
- """
2
- Batch import saved Instagram reels into the Transcriber pipeline.
3
-
4
- Reads an Instagram "Download Your Information" export (saved_posts.json),
5
- filters to /reel/ URLs, and POSTs each to the running backend's /extract/url.
6
-
7
- Resumable: progress is tracked in batch_progress.json so re-running skips
8
- already-done URLs. The backend also caches by URL, so re-processing is cheap
9
- either way.
10
-
11
- Usage (backend must be running, default http://localhost:8000):
12
- python batch_import.py path/to/saved_posts.json
13
- python batch_import.py saved_posts.json --delay 3 --caption-fallback
14
- python batch_import.py saved_posts.json --limit 10 # smoke test
15
- python batch_import.py saved_posts.json --retry-failed # only retry prior failures
16
- """
17
- import argparse
18
- import json
19
- import os
20
- import sys
21
- import time
22
-
23
- import requests
24
-
25
- PROGRESS_PATH = os.path.join(os.path.dirname(os.path.abspath(__file__)), "batch_progress.json")
26
-
27
-
28
- from saved_parser import parse_saved_posts
29
-
30
-
31
- def parse_saved(json_path):
32
- """Return list of {url, caption, title} for every /reel/ URL in the export."""
33
- with open(json_path, encoding="utf-8") as f:
34
- data = json.load(f)
35
- return parse_saved_posts(data)
36
-
37
-
38
- def load_progress():
39
- if os.path.exists(PROGRESS_PATH):
40
- with open(PROGRESS_PATH, encoding="utf-8") as f:
41
- return json.load(f)
42
- return {}
43
-
44
-
45
- def save_progress(progress):
46
- with open(PROGRESS_PATH, "w", encoding="utf-8") as f:
47
- json.dump(progress, f, indent=2)
48
-
49
-
50
- def post_with_retry(url, payload, retries=3, backoff=5):
51
- """POST with retry on 429/5xx. Returns (ok, status_code, detail)."""
52
- for attempt in range(retries):
53
- try:
54
- r = requests.post(url, json=payload, timeout=300)
55
- if r.status_code == 200:
56
- return True, 200, ""
57
- # silent-hook / no audio = 400, permanent for this reel — don't retry
58
- if r.status_code == 400:
59
- return False, 400, r.json().get("detail", r.text[:200])
60
- if r.status_code == 429 or r.status_code >= 500:
61
- wait = backoff * (attempt + 1)
62
- print(f" {r.status_code}, retry in {wait}s ({attempt + 1}/{retries})")
63
- time.sleep(wait)
64
- continue
65
- return False, r.status_code, r.text[:200]
66
- except requests.RequestException as e:
67
- wait = backoff * (attempt + 1)
68
- print(f" network error {e}, retry in {wait}s ({attempt + 1}/{retries})")
69
- time.sleep(wait)
70
- return False, 0, "exhausted retries"
71
-
72
-
73
- def main():
74
- ap = argparse.ArgumentParser()
75
- ap.add_argument("json_path", help="Path to saved_posts.json from the IG export")
76
- ap.add_argument("--api", default="http://localhost:8000", help="Backend base URL")
77
- ap.add_argument("--delay", type=float, default=2.0, help="Seconds between reels")
78
- ap.add_argument("--limit", type=int, default=0, help="Process at most N new reels (0 = all)")
79
- ap.add_argument("--caption-fallback", action="store_true",
80
- help="On download failure, extract from export caption (DB row loses its URL)")
81
- ap.add_argument("--retry-failed", action="store_true",
82
- help="Only re-attempt URLs marked failed in progress file")
83
- args = ap.parse_args()
84
-
85
- items = parse_saved(args.json_path)
86
- progress = load_progress()
87
- print(f"Found {len(items)} reels in export. Progress file has {len(progress)} records.\n")
88
-
89
- queue = []
90
- for it in items:
91
- status = progress.get(it["url"], {}).get("status")
92
- if args.retry_failed:
93
- if status == "failed":
94
- queue.append(it)
95
- elif status not in ("ok", "caption"):
96
- queue.append(it)
97
-
98
- if args.limit:
99
- queue = queue[: args.limit]
100
- print(f"Queue: {len(queue)} reels to process.\n")
101
-
102
- done = ok = capt = fail = 0
103
- for it in queue:
104
- done += 1
105
- url = it["url"]
106
- print(f"[{done}/{len(queue)}] {url}")
107
- success, code, detail = post_with_retry(f"{args.api}/extract/url", {"url": url})
108
-
109
- if success:
110
- progress[url] = {"status": "ok"}
111
- ok += 1
112
- print(" ok")
113
- elif code == 400:
114
- # silent hook / no spoken content — permanent
115
- progress[url] = {"status": "failed", "code": 400, "detail": detail}
116
- fail += 1
117
- print(f" skip (400): {detail}")
118
- elif args.caption_fallback and it["caption"]:
119
- s2, c2, d2 = post_with_retry(
120
- f"{args.api}/extract/text",
121
- {"transcript": "", "caption": it["caption"],
122
- "title": it["title"] or url},
123
- )
124
- if s2:
125
- progress[url] = {"status": "caption", "note": "url not stored in DB"}
126
- capt += 1
127
- print(" ok (caption fallback)")
128
- else:
129
- progress[url] = {"status": "failed", "code": code, "detail": detail}
130
- fail += 1
131
- print(f" failed: {detail}")
132
- else:
133
- progress[url] = {"status": "failed", "code": code, "detail": detail}
134
- fail += 1
135
- print(f" failed ({code}): {detail}")
136
-
137
- save_progress(progress)
138
- time.sleep(args.delay)
139
-
140
- print(f"\nDone. ok={ok} caption={capt} failed={fail}")
141
- if fail:
142
- print("Re-run with --retry-failed to retry failures (download throttling is often transient).")
143
-
144
-
145
- if __name__ == "__main__":
146
- sys.exit(main())
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
main.py CHANGED
@@ -257,12 +257,16 @@ def _target_cluster_count(item_count: int) -> int:
257
 
258
 
259
  def _semantic_cluster_labels(embeddings: List) -> List[int]:
260
- """Group embeddings with cosine-distance agglomerative clustering."""
 
 
 
261
  if len(embeddings) <= 1:
262
  return [0] * len(embeddings)
263
 
264
  try:
265
- from sklearn.cluster import AgglomerativeClustering
 
266
  except ImportError as e:
267
  raise HTTPException(
268
  status_code=500,
@@ -270,12 +274,9 @@ def _semantic_cluster_labels(embeddings: List) -> List[int]:
270
  "Install backend/requirements.txt.",
271
  ) from e
272
 
273
- clusterer = AgglomerativeClustering(
274
- n_clusters=_target_cluster_count(len(embeddings)),
275
- metric="cosine",
276
- linkage="average",
277
- )
278
- return [int(label) for label in clusterer.fit_predict(embeddings)]
279
 
280
 
281
  def _name_semantic_clusters(items: List[dict], labels: List[int]) -> dict:
@@ -664,13 +665,15 @@ def get_recompute_status():
664
  """Poll endpoint to check recomputation status."""
665
  return CLUSTER_JOB
666
 
667
- def _run_pipeline(url: str) -> tuple[str, str, str, ReelExtraction]:
668
  """Download → transcribe → silent-hook guard → LLM extract for one reel.
669
 
670
  Owns the temp audio file (always cleaned up). Returns
671
  (title, raw_transcript, post_caption, extracted). Raises HTTPException(400)
672
- on silent-hook reels and HTTPException(500) on download/transcription/LLM
673
- failures. Callers handle persistence."""
 
 
674
  mp3_path = None
675
  try:
676
  mp3_path, post_caption, title = download_and_extract_audio(url)
@@ -678,6 +681,8 @@ def _run_pipeline(url: str) -> tuple[str, str, str, ReelExtraction]:
678
  try:
679
  raw_transcript = transcribe_audio(mp3_path)
680
  except Exception as e:
 
 
681
  logger.warning(f"Transcription failed: {str(e)}. Proceeding using metadata/caption only.")
682
  guard_silent_hook(raw_transcript, post_caption)
683
  extracted = extract_structured_json(raw_transcript, post_caption)
@@ -744,7 +749,11 @@ def process_pending_reel(row: dict) -> None:
744
  db.mark_failed(reel_id, f"Invalid Instagram Reel URL format: {url}")
745
  return
746
  try:
747
- title, raw_transcript, post_caption, extracted = _run_pipeline(clean_url)
 
 
 
 
748
  db.update_reel_result(
749
  reel_id=reel_id,
750
  title=title,
 
257
 
258
 
259
  def _semantic_cluster_labels(embeddings: List) -> List[int]:
260
+ """Group embeddings with k-means on unit-length vectors (i.e. by cosine similarity).
261
+
262
+ Average-linkage agglomerative clustering was used before; on a 740-reel
263
+ library it put 96% of the reels into two clusters and left singletons."""
264
  if len(embeddings) <= 1:
265
  return [0] * len(embeddings)
266
 
267
  try:
268
+ from sklearn.cluster import KMeans
269
+ from sklearn.preprocessing import normalize
270
  except ImportError as e:
271
  raise HTTPException(
272
  status_code=500,
 
274
  "Install backend/requirements.txt.",
275
  ) from e
276
 
277
+ # Fixed seed: recomputing an unchanged library yields the same grouping.
278
+ clusterer = KMeans(n_clusters=_target_cluster_count(len(embeddings)), n_init=10, random_state=0)
279
+ return [int(label) for label in clusterer.fit_predict(normalize(embeddings))]
 
 
 
280
 
281
 
282
  def _name_semantic_clusters(items: List[dict], labels: List[int]) -> dict:
 
665
  """Poll endpoint to check recomputation status."""
666
  return CLUSTER_JOB
667
 
668
+ def _run_pipeline(url: str, allow_caption_fallback: bool = True) -> tuple[str, str, str, ReelExtraction]:
669
  """Download → transcribe → silent-hook guard → LLM extract for one reel.
670
 
671
  Owns the temp audio file (always cleaned up). Returns
672
  (title, raw_transcript, post_caption, extracted). Raises HTTPException(400)
673
+ on silent-hook reels and HTTPException(500) on download/LLM failures. A
674
+ failed transcription falls back to the caption alone unless
675
+ allow_caption_fallback is False, in which case it raises too. Callers
676
+ handle persistence."""
677
  mp3_path = None
678
  try:
679
  mp3_path, post_caption, title = download_and_extract_audio(url)
 
681
  try:
682
  raw_transcript = transcribe_audio(mp3_path)
683
  except Exception as e:
684
+ if not allow_caption_fallback:
685
+ raise
686
  logger.warning(f"Transcription failed: {str(e)}. Proceeding using metadata/caption only.")
687
  guard_silent_hook(raw_transcript, post_caption)
688
  extracted = extract_structured_json(raw_transcript, post_caption)
 
749
  db.mark_failed(reel_id, f"Invalid Instagram Reel URL format: {url}")
750
  return
751
  try:
752
+ # A failed transcription is retried like any transient error; only the
753
+ # last attempt settles for a caption-only extraction.
754
+ is_final_attempt = int(row.get("attempt_count") or 0) >= MAX_WORKER_ATTEMPTS
755
+ title, raw_transcript, post_caption, extracted = _run_pipeline(
756
+ clean_url, allow_caption_fallback=is_final_attempt)
757
  db.update_reel_result(
758
  reel_id=reel_id,
759
  title=title,
requirements-dev.txt ADDED
@@ -0,0 +1,2 @@
 
 
 
1
+ -r requirements.txt
2
+ pytest>=8
test_pipeline.py ADDED
@@ -0,0 +1,500 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Pipeline tests. Groq, yt-dlp and Supabase are mocked: no keys or server needed.
2
+
3
+ Run from the repo root: python -m pytest backend
4
+ """
5
+ import io
6
+ import os
7
+ import tempfile
8
+ from datetime import datetime, timezone
9
+
10
+ os.environ.setdefault("GROQ_API_KEY", "mock_groq_key_for_testing")
11
+ os.environ["ENABLE_WORKER"] = "0" # never spawn the background worker thread
12
+
13
+ import httpx
14
+ import pytest
15
+ from fastapi import HTTPException
16
+ from fastapi.testclient import TestClient
17
+
18
+ import db
19
+ import main
20
+ from main import ReelExtraction
21
+ from saved_parser import normalize_instagram_url, parse_saved_posts
22
+
23
+ client = TestClient(main.app)
24
+
25
+ REEL_URL = "https://www.instagram.com/reel/Z/"
26
+ LONG_TRANSCRIPT = " ".join(["word"] * 30) # clears the 15-word silent-hook guard
27
+ EXTRACTION = ReelExtraction(
28
+ core_topic="Topic", key_takeaway="Takeaway", action_items=["a"], tools_or_resources=["b"])
29
+
30
+
31
+ def fake_insert_reel(url, title, raw_transcript, post_caption, extracted_json):
32
+ return {"id": "id-1", "url": url, "title": title, "raw_transcript": raw_transcript,
33
+ "post_caption": post_caption, "extracted_json": extracted_json,
34
+ "created_at": "2026-06-06T00:00:00Z"}
35
+
36
+
37
+ def raiser(exc):
38
+ def _raise(*args, **kwargs):
39
+ raise exc
40
+ return _raise
41
+
42
+
43
+ def fake_client(table):
44
+ """A db.get_client replacement whose every table() is the given fake."""
45
+ return lambda: type("FakeClient", (), {"table": lambda self, name: table})()
46
+
47
+
48
+ class FakeTable:
49
+ """Stand-in for a Supabase table query: filters with in_() and records inserts."""
50
+ def __init__(self, rows=None):
51
+ self.data = rows or []
52
+ self.inserted = []
53
+
54
+ def select(self, *args, **kwargs):
55
+ return self
56
+
57
+ def in_(self, col, vals):
58
+ return FakeTable([row for row in self.data if row.get(col) in vals])
59
+
60
+ def insert(self, rows):
61
+ self.inserted.extend(rows)
62
+ return self
63
+
64
+ def execute(self):
65
+ return type("Result", (), {"data": self.data})()
66
+
67
+
68
+ @pytest.fixture
69
+ def pipeline(monkeypatch):
70
+ """Mock download, transcription and extraction so the pipeline runs offline."""
71
+ monkeypatch.setattr(main, "download_and_extract_audio", lambda url: ("/tmp/does_not_exist.mp3", "cap", "Title"))
72
+ monkeypatch.setattr(main, "transcribe_audio", lambda path: LONG_TRANSCRIPT)
73
+ monkeypatch.setattr(main, "extract_structured_json", lambda transcript, caption: EXTRACTION)
74
+
75
+
76
+ @pytest.fixture
77
+ def batch_job():
78
+ """Restore the in-memory batch tracker after the test."""
79
+ saved = dict(main.BATCH_JOB)
80
+ yield main.BATCH_JOB
81
+ main.BATCH_JOB.clear()
82
+ main.BATCH_JOB.update(saved)
83
+
84
+
85
+ # ---------------------------------------------------------------- endpoints
86
+
87
+ def test_health():
88
+ response = client.get("/health")
89
+ assert response.status_code == 200
90
+ assert response.json()["status"] == "ok"
91
+
92
+
93
+ def test_cors_no_credentials():
94
+ r = client.options("/health", headers={
95
+ "Origin": "https://reels-transcriber.vercel.app",
96
+ "Access-Control-Request-Method": "GET",
97
+ })
98
+ # With allow_credentials=False, Starlette must not advertise credentials for a wildcard origin.
99
+ assert r.headers.get("access-control-allow-credentials") != "true", dict(r.headers)
100
+
101
+
102
+ def test_extract_text(monkeypatch):
103
+ monkeypatch.setattr(main, "extract_structured_json", lambda transcript, caption: EXTRACTION)
104
+ monkeypatch.setattr(main.db, "insert_reel", fake_insert_reel)
105
+ response = client.post("/extract/text", json={
106
+ "title": "Test AI Video",
107
+ "transcript": "Let's build a transcription app with Python and React.",
108
+ "caption": "#coding #ai #python",
109
+ })
110
+ assert response.status_code == 200
111
+ data = response.json()
112
+ assert data["title"] == "Test AI Video"
113
+ assert data["extracted_json"]["core_topic"] == "Topic"
114
+
115
+
116
+ def test_extract_url(monkeypatch, pipeline):
117
+ monkeypatch.setattr(main.db, "get_reel_by_url", lambda url: None)
118
+ monkeypatch.setattr(main.db, "insert_reel", fake_insert_reel)
119
+ r = client.post("/extract/url", json={"url": "https://www.instagram.com/reel/REGRESSION1/"})
120
+ assert r.status_code == 200, r.text
121
+ assert r.json()["extracted_json"]["core_topic"] == "Topic"
122
+ assert r.json()["status"] == "done"
123
+
124
+
125
+ @pytest.mark.parametrize("status, title, error", [
126
+ ("pending", None, None), # title is NULL for rows the iPhone Shortcut inserts over REST
127
+ ("failed", "Reel (Q)", "Meta blocking request"),
128
+ ])
129
+ def test_extract_url_returns_unfinished_row(monkeypatch, status, title, error):
130
+ """A URL that is already queued or failed comes back as-is instead of a 500."""
131
+ monkeypatch.setattr(main.db, "get_reel_by_url", lambda url: {
132
+ "id": "row-q", "url": url, "title": title, "raw_transcript": "", "post_caption": None,
133
+ "extracted_json": {}, "created_at": "2026-10-10T00:00:00Z", "cluster": "Unclustered",
134
+ "status": status, "error": error})
135
+ r = client.post("/extract/url", json={"url": "https://www.instagram.com/reel/QUEUED1/"})
136
+ assert r.status_code == 200, r.text
137
+ assert r.json()["status"] == status
138
+ assert r.json()["error"] == error
139
+
140
+
141
+ def test_extract_url_rejects_invalid_url():
142
+ response = client.post("/extract/url", json={"url": "https://google.com"})
143
+ assert response.status_code == 400
144
+ assert "Invalid Instagram Reel URL" in response.json()["detail"]
145
+
146
+
147
+ def test_url_validation_helpers():
148
+ assert main.is_valid_instagram_reel("https://www.instagram.com/reel/C7xY9/")
149
+ assert main.is_valid_instagram_reel("http://instagram.com/reel/abc-123_XYZ/?query=1")
150
+ assert main.is_valid_instagram_reel("https://instagram.com/reels/abc-123/")
151
+ assert main.is_valid_instagram_reel("https://instagram.com/p/abc/")
152
+ assert not main.is_valid_instagram_reel("https://google.com")
153
+ assert not main.is_valid_instagram_reel("invalid-url")
154
+ assert not main.is_valid_instagram_reel("")
155
+
156
+ assert normalize_instagram_url("https://www.instagram.com/reel/C7xY9/?igsh=123&foo=bar") == "https://www.instagram.com/reel/C7xY9/"
157
+ assert normalize_instagram_url("https://instagram.com/reels/abc-123?igsi=abc==") == "https://www.instagram.com/reel/abc-123/"
158
+ assert normalize_instagram_url("https://www.instagram.com/p/ABC123_/?img_index=2") == "https://www.instagram.com/p/ABC123_/"
159
+
160
+
161
+ def test_api_auth_token(monkeypatch):
162
+ monkeypatch.setattr(main, "API_AUTH_TOKEN", "super_secret_test_key")
163
+ monkeypatch.setattr(main, "extract_structured_json", lambda transcript, caption: EXTRACTION)
164
+ monkeypatch.setattr(main.db, "insert_reel", fake_insert_reel)
165
+ payload = {"title": "Test", "transcript": "a", "caption": "b"}
166
+
167
+ res = client.post("/extract/text", json=payload)
168
+ assert res.status_code == 401, res.text
169
+ assert "Unauthorized" in res.json()["detail"]
170
+
171
+ res = client.post("/extract/text", json=payload, headers={"X-API-Key": "wrong"})
172
+ assert res.status_code == 401, res.text
173
+
174
+ res = client.post("/extract/text", json=payload, headers={"X-API-Key": "super_secret_test_key"})
175
+ assert res.status_code == 200, res.text
176
+ assert res.json()["title"] == "Test"
177
+
178
+
179
+ def test_delete_reel(monkeypatch):
180
+ calls = []
181
+ # First call: row exists (True); second: already gone (False -> 404)
182
+ monkeypatch.setattr(main.db, "delete_reel", lambda rid: (calls.append(rid), len(calls) == 1)[1])
183
+ r = client.delete("/reels/abc")
184
+ assert r.status_code == 200, r.text
185
+ assert r.json()["deleted"] == "abc"
186
+ assert client.delete("/reels/abc").status_code == 404
187
+
188
+
189
+ def test_get_reel_details(monkeypatch):
190
+ monkeypatch.setattr(main.db, "get_reel_details",
191
+ lambda rid: {"raw_transcript": "t", "post_caption": "c"} if rid == "abc" else None)
192
+ r = client.get("/reels/abc/details")
193
+ assert r.status_code == 200, r.text
194
+ assert r.json() == {"raw_transcript": "t", "post_caption": "c"}
195
+ assert client.get("/reels/missing/details").status_code == 404
196
+
197
+
198
+ def test_reels_include_cluster(monkeypatch):
199
+ monkeypatch.setattr(main.db, "list_reels", lambda limit=20: [{
200
+ "id": "x", "url": None, "title": "t", "raw_transcript": None,
201
+ "post_caption": None, "extracted_json": {"core_topic": "c"},
202
+ "created_at": "2026-06-06T00:00:00Z", "cluster": "Unclustered"}])
203
+ r = client.get("/reels")
204
+ assert r.status_code == 200, r.text
205
+ assert "cluster" in r.json()[0]
206
+
207
+
208
+ def test_recompute_clusters(monkeypatch):
209
+ rows = [
210
+ {"id": "a", "extracted_json": {"core_topic": "AI email tools"}},
211
+ {"id": "b", "extracted_json": {"core_topic": "Marathon training"}},
212
+ {"id": "c", "extracted_json": {}}, # queued/failed reel: nothing to cluster
213
+ ]
214
+ applied = []
215
+ monkeypatch.setattr(main.db, "reels_for_clustering", lambda: rows)
216
+ monkeypatch.setattr(main.db, "set_cluster",
217
+ lambda reel_ids, cluster: applied.extend((rid, cluster) for rid in reel_ids))
218
+ monkeypatch.setattr(main, "cluster_topics_semantically", lambda items: [
219
+ {"id": "a", "cluster": "Productivity"}, {"id": "b", "cluster": "Fitness"}])
220
+
221
+ r = client.post("/clusters/recompute")
222
+ assert r.status_code == 200, r.text
223
+ assert r.json()["status"] == "started"
224
+
225
+ job = client.get("/clusters/recompute/status").json()
226
+ assert job["status"] == "done", job
227
+ assert job["assigned"] == 2, job
228
+ assert ("a", "Productivity") in applied
229
+ assert ("c", None) in applied, "unextracted reel should be reset to Unclustered"
230
+
231
+
232
+ def test_cluster_labels_are_balanced():
233
+ """Well-separated groups must not collapse into one giant cluster plus singletons."""
234
+ np = pytest.importorskip("numpy")
235
+ pytest.importorskip("sklearn")
236
+ rng = np.random.default_rng(0)
237
+ centers = np.eye(8, 16) * 10 # 8 distinct directions
238
+ points = [center + rng.normal(scale=0.1, size=16) for center in centers for _ in range(8)] # 64 points -> 8 clusters
239
+ labels = main._semantic_cluster_labels(points)
240
+ sizes = sorted(labels.count(label) for label in set(labels))
241
+ assert sizes == [8] * 8, sizes
242
+
243
+
244
+ # ---------------------------------------------------------------- worker
245
+
246
+ def test_worker_tick_success(monkeypatch, pipeline):
247
+ saved = {}
248
+ monkeypatch.setattr(main.db, "claim_next_pending", lambda: {"id": "row1", "url": REEL_URL})
249
+ monkeypatch.setattr(main.db, "update_reel_result",
250
+ lambda reel_id, title, raw_transcript, post_caption, extracted_json:
251
+ saved.update(id=reel_id, title=title))
252
+ monkeypatch.setattr(main.db, "mark_failed", raiser(AssertionError("should not be marked failed")))
253
+ assert main.worker_tick() is True
254
+ assert saved == {"id": "row1", "title": "Title"}
255
+
256
+
257
+ def test_worker_tick_failure(monkeypatch):
258
+ failed = {}
259
+ monkeypatch.setattr(main.db, "claim_next_pending", lambda: {"id": "row2", "url": "https://www.instagram.com/reel/F/"})
260
+ monkeypatch.setattr(main, "download_and_extract_audio",
261
+ raiser(HTTPException(status_code=400, detail="Meta blocking request")))
262
+ monkeypatch.setattr(main.db, "mark_failed",
263
+ lambda reel_id, error, status="failed": failed.update(id=reel_id, error=str(error), status=status))
264
+ assert main.worker_tick() is True
265
+ assert failed["id"] == "row2"
266
+ assert "Meta blocking" in failed["error"]
267
+
268
+
269
+ def test_worker_tick_invalid_url(monkeypatch):
270
+ failed = {}
271
+ monkeypatch.setattr(main.db, "claim_next_pending", lambda: {"id": "row_invalid", "url": "https://malicious-ssrf.com"})
272
+ monkeypatch.setattr(main.db, "mark_failed", lambda reel_id, error: failed.update(id=reel_id, error=str(error)))
273
+ assert main.worker_tick() is True
274
+ assert failed["id"] == "row_invalid"
275
+ assert "Invalid Instagram Reel URL" in failed["error"]
276
+
277
+
278
+ def test_worker_tick_empty(monkeypatch):
279
+ monkeypatch.setattr(main.db, "claim_next_pending", lambda: None)
280
+ assert main.worker_tick() is False
281
+
282
+
283
+ def test_worker_retries_failed_transcription(monkeypatch, pipeline):
284
+ """A transcription error is retried, not silently replaced by a caption-only extraction."""
285
+ events = []
286
+ monkeypatch.setattr(main.db, "claim_next_pending", lambda: {"id": "row1", "url": REEL_URL, "attempt_count": 1})
287
+ monkeypatch.setattr(main, "transcribe_audio",
288
+ raiser(HTTPException(status_code=500, detail="Audio transcription failed: 429")))
289
+ monkeypatch.setattr(main.db, "schedule_retry", lambda reel_id, error, when: events.append(("retry", reel_id)))
290
+ monkeypatch.setattr(main.db, "update_reel_result", lambda **kwargs: events.append(("done", kwargs["reel_id"])))
291
+ monkeypatch.setattr(main.db, "mark_failed", lambda *args, **kwargs: events.append(("failed", args[0])))
292
+ assert main.worker_tick() is True
293
+ assert events == [("retry", "row1")]
294
+
295
+
296
+ def test_worker_falls_back_to_caption_on_final_attempt(monkeypatch, pipeline):
297
+ saved = {}
298
+ monkeypatch.setattr(main.db, "claim_next_pending",
299
+ lambda: {"id": "row1", "url": REEL_URL, "attempt_count": main.MAX_WORKER_ATTEMPTS})
300
+ monkeypatch.setattr(main, "transcribe_audio",
301
+ raiser(HTTPException(status_code=500, detail="Audio transcription failed: 413")))
302
+ monkeypatch.setattr(main.db, "update_reel_result",
303
+ lambda **kwargs: saved.update(id=kwargs["reel_id"], transcript=kwargs["raw_transcript"]))
304
+ assert main.worker_tick() is True
305
+ assert saved == {"id": "row1", "transcript": ""}
306
+
307
+
308
+ def test_run_worker_drain(monkeypatch):
309
+ """Drain mode stops at the first empty tick and survives a failed stale-claim recovery."""
310
+ ticks = iter([True, True, False])
311
+ monkeypatch.setattr(main.db, "recover_stale_processing", raiser(RuntimeError("db offline")))
312
+ monkeypatch.setattr(main, "worker_tick", lambda: next(ticks))
313
+ main.run_worker(drain=True)
314
+ assert next(ticks, None) is None
315
+
316
+
317
+ def test_worker_retry_policy(monkeypatch):
318
+ assert main.is_retryable_worker_error(HTTPException(status_code=429))
319
+ assert main.is_retryable_worker_error(HTTPException(status_code=503))
320
+ assert main.is_retryable_worker_error(httpx.ConnectError("offline"))
321
+ assert not main.is_retryable_worker_error(HTTPException(status_code=400))
322
+ assert not main.is_retryable_worker_error(HTTPException(status_code=403))
323
+ assert not main.is_retryable_worker_error(HTTPException(status_code=415))
324
+ assert 27 <= main.retry_delay_seconds(1, .9) <= 33
325
+ assert 108 <= main.retry_delay_seconds(2, .9) <= 132
326
+
327
+ scheduled = []
328
+ monkeypatch.setattr(main.db, "schedule_retry", lambda reel_id, error, when: scheduled.append(reel_id))
329
+ assert main._schedule_worker_retry({"id": "a", "attempt_count": 1}, "temporary")
330
+ assert main._schedule_worker_retry({"id": "b", "attempt_count": 2}, "temporary")
331
+ assert not main._schedule_worker_retry({"id": "c", "attempt_count": 3}, "temporary")
332
+ assert scheduled == ["a", "b"]
333
+
334
+
335
+ def test_download_cleanup_on_failure(monkeypatch):
336
+ reel_id = "test_cleanup_fail_123"
337
+ leftovers = [os.path.join(tempfile.gettempdir(), f"video_{reel_id}.{ext}") for ext in ("mp4", "part")]
338
+ for path in leftovers:
339
+ with open(path, "w") as f:
340
+ f.write("partial download")
341
+
342
+ class FailingYDL:
343
+ def __init__(self, *args, **kwargs):
344
+ pass
345
+
346
+ def __enter__(self):
347
+ return self
348
+
349
+ def __exit__(self, *exc):
350
+ pass
351
+
352
+ def extract_info(self, *args, **kwargs):
353
+ raise Exception("Mock download error")
354
+
355
+ monkeypatch.setattr(main.yt_dlp, "YoutubeDL", FailingYDL)
356
+ try:
357
+ with pytest.raises(HTTPException) as excinfo:
358
+ main.download_and_extract_audio(f"https://www.instagram.com/reel/{reel_id}/")
359
+ assert excinfo.value.status_code == 500
360
+ assert "Mock download error" in excinfo.value.detail
361
+ assert not any(os.path.exists(path) for path in leftovers)
362
+ finally:
363
+ for path in leftovers:
364
+ if os.path.exists(path):
365
+ os.remove(path)
366
+
367
+
368
+ # ---------------------------------------------------------------- bulk import
369
+
370
+ def test_parse_saved_posts():
371
+ sample = [
372
+ {"label_values": [
373
+ {"label": "URL", "value": "https://www.instagram.com/reel/AAA/"},
374
+ {"label": "Caption", "value": "hello"},
375
+ {"label": "Title", "value": "T1"}]},
376
+ {"label_values": [
377
+ {"label": "URL", "value": "https://www.instagram.com/p/BBB/"},
378
+ {"label": "Caption", "value": "photo"}]},
379
+ ]
380
+ out = parse_saved_posts(sample)
381
+ assert len(out) == 1, out # the /p/ photo post is dropped
382
+ assert out[0] == {"url": "https://www.instagram.com/reel/AAA/", "caption": "hello", "title": "T1"}
383
+
384
+
385
+ def test_batch_status_shape():
386
+ body = client.get("/extract/batch/status").json()
387
+ for key in ("status", "total", "done", "ok", "failed", "current", "errors"):
388
+ assert key in body, f"missing {key} in {body}"
389
+
390
+
391
+ def test_batch_guard_409(monkeypatch, batch_job):
392
+ batch_job["status"] = "running"
393
+ monkeypatch.setattr(main, "update_batch_job_status", lambda: None)
394
+ files = {"file": ("saved_posts.json", io.BytesIO(b"[]"), "application/json")}
395
+ assert client.post("/extract/batch", files=files).status_code == 409
396
+
397
+
398
+ def test_extract_batch(monkeypatch, batch_job):
399
+ table = FakeTable([{"url": "https://www.instagram.com/reel/BBB/"}]) # BBB already saved
400
+ monkeypatch.setattr(main.db, "get_client", fake_client(table))
401
+ upload = (
402
+ '[{"label_values": [{"label": "URL", "value": "https://www.instagram.com/reel/AAA/"}]},'
403
+ ' {"label_values": [{"label": "URL", "value": "https://www.instagram.com/reel/BBB/"}]}]'
404
+ )
405
+ files = {"file": ("saved_posts.json", io.BytesIO(upload.encode()), "application/json")}
406
+
407
+ r = client.post("/extract/batch", files=files)
408
+ assert r.status_code == 200, r.text
409
+ assert r.json() == {"started": True, "total": 2, "enqueued": 1, "existing": 1}
410
+ assert len(table.inserted) == 1
411
+ assert table.inserted[0]["url"] == "https://www.instagram.com/reel/AAA/"
412
+ assert table.inserted[0]["status"] == "pending"
413
+ assert table.inserted[0]["source"] == "batch"
414
+
415
+ status = client.get("/extract/batch/status").json()
416
+ assert (status["status"], status["total"], status["done"]) == ("running", 2, 1)
417
+
418
+ table.data.append({"url": "https://www.instagram.com/reel/AAA/", "status": "done"}) # worker finishes AAA
419
+ status = client.get("/extract/batch/status").json()
420
+ assert (status["status"], status["done"], status["ok"]) == ("done", 2, 2)
421
+
422
+
423
+ @pytest.mark.parametrize("status", ["cookies_expired", "unsupported_format", "mystery"])
424
+ def test_batch_terminal_statuses(monkeypatch, batch_job, status):
425
+ table = FakeTable()
426
+ table.in_ = lambda col, vals: FakeTable([{"url": "u", "status": status, "error": "boom"}])
427
+ monkeypatch.setattr(main.db, "get_client", fake_client(table))
428
+ batch_job.update({"status": "running", "urls": ["u"], "finished_at": None})
429
+
430
+ main.update_batch_job_status()
431
+ assert batch_job["status"] == "done"
432
+ assert batch_job["failed"] == 1
433
+ if status == "mystery":
434
+ assert "Unknown queue status: mystery" in batch_job["errors"][0]["detail"]
435
+
436
+
437
+ # ---------------------------------------------------------------- data layer
438
+
439
+ def test_row_to_record_handles_bad_json():
440
+ assert db.row_to_record({"id": "1", "extracted_json": "{not valid json"})["extracted_json"] == {}
441
+ assert db.row_to_record({"id": "2", "extracted_json": '{"core_topic": "x"}'})["extracted_json"] == {"core_topic": "x"}
442
+
443
+
444
+ def test_chunked_helper():
445
+ assert list(db._chunked([1, 2, 3, 4, 5], 2)) == [[1, 2], [3, 4], [5]]
446
+ assert list(db._chunked([], 3)) == []
447
+ assert list(db._chunked([1, 2], 5)) == [[1, 2]]
448
+
449
+
450
+ def test_db_queue_metadata_contract(monkeypatch):
451
+ class Query:
452
+ def __init__(self):
453
+ self.calls = []
454
+ self.updated = None
455
+
456
+ def select(self, *args):
457
+ self.calls.append(("select", args))
458
+ return self
459
+
460
+ def update(self, payload):
461
+ self.updated = payload
462
+ return self
463
+
464
+ def eq(self, *args):
465
+ self.calls.append(("eq", args))
466
+ return self
467
+
468
+ def or_(self, value):
469
+ self.calls.append(("or", value))
470
+ return self
471
+
472
+ def order(self, *args, **kwargs):
473
+ return self
474
+
475
+ def limit(self, value):
476
+ return self
477
+
478
+ def lt(self, *args):
479
+ self.calls.append(("lt", args))
480
+ return self
481
+
482
+ def execute(self):
483
+ rows = [{"id": "r1", "attempt_count": 1}] if self.updated is None else [{"id": "r1"}]
484
+ return type("Result", (), {"data": rows})()
485
+
486
+ query = Query()
487
+ monkeypatch.setattr(db, "get_client", fake_client(query))
488
+
489
+ claimed = db.claim_next_pending()
490
+ assert claimed["attempt_count"] == 2
491
+ assert any(kind == "or" and "next_attempt_at.is.null" in value and "next_attempt_at.lte." in value
492
+ for kind, value in query.calls)
493
+ assert query.updated["status"] == "processing"
494
+ assert query.updated["processing_started_at"]
495
+ assert query.updated["next_attempt_at"] is None
496
+
497
+ query.calls.clear()
498
+ assert db.recover_stale_processing(datetime.now(timezone.utc)) == 1
499
+ assert ("eq", ("status", "processing")) in query.calls
500
+ assert any(kind == "lt" and args[0] == "processing_started_at" for kind, args in query.calls)
verify_pipeline.py DELETED
@@ -1,745 +0,0 @@
1
- import os
2
- import sys
3
-
4
- # Ensure backend directory is in path
5
- sys.path.append(os.path.dirname(os.path.abspath(__file__)))
6
-
7
- # Set mock env variables for testing if they are not defined
8
- if not os.environ.get("GROQ_API_KEY"):
9
- os.environ["GROQ_API_KEY"] = "mock_groq_key_for_testing"
10
-
11
- # Never spawn the background worker thread during tests
12
- os.environ["ENABLE_WORKER"] = "0"
13
-
14
- try:
15
- from fastapi.testclient import TestClient
16
- except ImportError:
17
- print("Installing 'httpx' for TestClient support...")
18
- import subprocess
19
- subprocess.check_call([sys.executable, "-m", "pip", "install", "httpx"])
20
- from fastapi.testclient import TestClient
21
-
22
- from main import app, ReelExtraction
23
-
24
- client = TestClient(app)
25
-
26
- def test_health():
27
- print("Testing GET /health...")
28
- response = client.get("/health")
29
- assert response.status_code == 200
30
- assert response.json()["status"] == "ok"
31
- print("[OK] Health check passed!")
32
-
33
- def test_extract_text_mock():
34
- print("Testing POST /extract/text with mock data...")
35
- # Monkeypatch the extract_structured_json function in main to prevent external calls
36
- import main
37
-
38
- mock_extracted = ReelExtraction(
39
- core_topic="AI Video Automation",
40
- key_takeaway="You can automate your video transcription easily.",
41
- action_items=["Get a URL", "Extract audio", "Run Whisper"],
42
- tools_or_resources=["FastAPI", "Groq", "Supabase"]
43
- )
44
-
45
- original_extract = main.extract_structured_json
46
- main.extract_structured_json = lambda t, c: mock_extracted
47
-
48
- # Mock saving to database to prevent real database call
49
- original_save = main.db.insert_reel
50
- main.db.insert_reel = lambda url, title, raw_transcript, post_caption, extracted_json: {
51
- "id": "123e4567-e89b-12d3-a456-426614174000",
52
- "url": url,
53
- "title": title,
54
- "raw_transcript": raw_transcript,
55
- "post_caption": post_caption,
56
- "extracted_json": extracted_json,
57
- "created_at": "2026-06-05T00:00:00Z"
58
- }
59
-
60
- try:
61
- response = client.post("/extract/text", json={
62
- "title": "Test AI Video",
63
- "transcript": "Let's build a transcription app with Python and React.",
64
- "caption": "#coding #ai #python"
65
- })
66
- assert response.status_code == 200
67
- data = response.json()
68
- assert data["title"] == "Test AI Video"
69
- assert data["extracted_json"]["core_topic"] == "AI Video Automation"
70
- assert len(data["extracted_json"]["action_items"]) == 3
71
- print("[OK] Text extraction pipeline endpoint passed (mocked)!")
72
- finally:
73
- # Restore functions
74
- main.extract_structured_json = original_extract
75
- main.db.insert_reel = original_save
76
-
77
- def test_recompute_clusters_mock():
78
- print("Testing POST /clusters/recompute (db + llm mocked)...")
79
- import main
80
- fake_rows = [
81
- {"id": "a", "extracted_json": {"core_topic": "AI email tools"}},
82
- {"id": "b", "extracted_json": {"core_topic": "Marathon training"}},
83
- {"id": "c", "extracted_json": {}}, # queued/failed reel: nothing to cluster
84
- ]
85
- applied = []
86
- orig_for = main.db.reels_for_clustering
87
- orig_set = main.db.set_cluster
88
- orig_llm = main.cluster_topics_semantically
89
- main.db.reels_for_clustering = lambda: fake_rows
90
- main.db.set_cluster = lambda reel_ids, cluster: applied.extend((rid, cluster) for rid in reel_ids)
91
- main.cluster_topics_semantically = lambda items: [
92
- {"id": "a", "cluster": "Productivity"}, {"id": "b", "cluster": "Fitness"}]
93
- try:
94
- r = client.post("/clusters/recompute")
95
- assert r.status_code == 200, r.text
96
- assert r.json()["status"] == "started", r.text
97
-
98
- status_resp = client.get("/clusters/recompute/status")
99
- assert status_resp.status_code == 200
100
- job = status_resp.json()
101
- assert job["status"] == "done", job
102
- assert job["assigned"] == 2, job
103
- assert ("a", "Productivity") in applied
104
- assert ("c", None) in applied, "unextracted reel should be reset to Unclustered"
105
- print("[OK] recompute clusters passed!")
106
- finally:
107
- main.db.reels_for_clustering = orig_for
108
- main.db.set_cluster = orig_set
109
- main.cluster_topics_semantically = orig_llm
110
-
111
- def test_row_to_record_handles_bad_json():
112
- print("Testing row_to_record tolerates malformed extracted_json...")
113
- import db
114
- rec = db.row_to_record({"id": "1", "extracted_json": "{not valid json"})
115
- assert rec["extracted_json"] == {}, rec
116
- rec2 = db.row_to_record({"id": "2", "extracted_json": '{"core_topic": "x"}'})
117
- assert rec2["extracted_json"]["core_topic"] == "x", rec2
118
- print("[OK] row_to_record bad-json guard passed!")
119
-
120
-
121
- def test_db_module_surface():
122
- print("Testing db.py exposes the expected data-layer functions...")
123
- import db
124
- for fn in ("get_reel_by_url", "insert_reel", "list_reels",
125
- "reels_for_clustering", "set_cluster",
126
- "row_to_record"):
127
- assert hasattr(db, fn), f"db.{fn} missing"
128
- print("[OK] db module surface passed!")
129
-
130
-
131
- def test_db_queue_surface():
132
- print("Testing db.py exposes queue functions...")
133
- import db
134
- for fn in ("claim_next_pending", "update_reel_result", "mark_failed"):
135
- assert hasattr(db, fn), f"db.{fn} missing"
136
- print("[OK] db queue surface passed!")
137
-
138
-
139
- def test_extract_url_regression():
140
- print("Testing POST /extract/url via process_reel_url (mocked)...")
141
- import main
142
- long_transcript = " ".join(["word"] * 30) # clears the 15-word silent-hook guard
143
- mocked = ReelExtraction(
144
- core_topic="Topic", key_takeaway="Takeaway",
145
- action_items=["a"], tools_or_resources=["b"])
146
- orig_dl = main.download_and_extract_audio
147
- orig_tr = main.transcribe_audio
148
- orig_ex = main.extract_structured_json
149
- orig_save = main.db.insert_reel
150
- orig_cache = main.db.get_reel_by_url
151
- main.db.get_reel_by_url = lambda url: None
152
- main.download_and_extract_audio = lambda url: ("/tmp/does_not_exist.mp3", "cap", "Title")
153
- main.transcribe_audio = lambda p: long_transcript
154
- main.extract_structured_json = lambda t, c: mocked
155
- main.db.insert_reel = lambda url, title, raw_transcript, post_caption, extracted_json: {
156
- "id": "id-1", "url": url, "title": title,
157
- "raw_transcript": raw_transcript, "post_caption": post_caption,
158
- "extracted_json": extracted_json, "created_at": "2026-06-06T00:00:00Z"}
159
- try:
160
- r = client.post("/extract/url", json={"url": "https://www.instagram.com/reel/REGRESSION1/"})
161
- assert r.status_code == 200, r.text
162
- assert r.json()["extracted_json"]["core_topic"] == "Topic"
163
- print("[OK] extract_url regression passed!")
164
- finally:
165
- main.download_and_extract_audio = orig_dl
166
- main.transcribe_audio = orig_tr
167
- main.extract_structured_json = orig_ex
168
- main.db.insert_reel = orig_save
169
- main.db.get_reel_by_url = orig_cache
170
-
171
-
172
- def test_extract_url_returns_unfinished_row():
173
- print("Testing POST /extract/url returns an already-queued or failed row instead of 500...")
174
- import main
175
- orig = main.db.get_reel_by_url
176
- try:
177
- # title is None for rows the iPhone Shortcut inserts over REST
178
- for status, title, error in (("pending", None, None), ("failed", "Reel (Q)", "Meta blocking request")):
179
- main.db.get_reel_by_url = lambda url: {
180
- "id": "row-q", "url": url, "title": title, "raw_transcript": "", "post_caption": None,
181
- "extracted_json": {}, "created_at": "2026-10-10T00:00:00Z", "cluster": "Unclustered",
182
- "status": status, "error": error}
183
- r = client.post("/extract/url", json={"url": "https://www.instagram.com/reel/QUEUED1/"})
184
- assert r.status_code == 200, r.text
185
- assert r.json()["status"] == status, r.json()
186
- assert r.json()["error"] == error, r.json()
187
- print("[OK] extract_url unfinished-row passthrough passed!")
188
- finally:
189
- main.db.get_reel_by_url = orig
190
-
191
-
192
- def test_worker_tick_success():
193
- print("Testing worker_tick processes a pending reel (mocked)...")
194
- import main
195
- long_transcript = " ".join(["word"] * 30)
196
- mocked = ReelExtraction(core_topic="T", key_takeaway="K",
197
- action_items=["a"], tools_or_resources=["b"])
198
- captured = {}
199
- orig = (main.db.claim_next_pending, main.download_and_extract_audio,
200
- main.transcribe_audio, main.extract_structured_json,
201
- main.db.update_reel_result, main.db.mark_failed)
202
- main.db.claim_next_pending = lambda: {"id": "row1", "url": "https://www.instagram.com/reel/Z/"}
203
- main.download_and_extract_audio = lambda url: ("/tmp/nope.mp3", "cap", "Title")
204
- main.transcribe_audio = lambda p: long_transcript
205
- main.extract_structured_json = lambda t, c: mocked
206
- main.db.update_reel_result = lambda reel_id, title, raw_transcript, post_caption, extracted_json, status="done": captured.update(
207
- {"id": reel_id, "status": status, "title": title})
208
- main.db.mark_failed = lambda reel_id, error: captured.update({"failed": reel_id})
209
- try:
210
- did = main.worker_tick()
211
- assert did is True
212
- assert captured["id"] == "row1", captured
213
- assert captured["status"] == "done", captured
214
- assert "failed" not in captured
215
- print("[OK] worker_tick success passed!")
216
- finally:
217
- (main.db.claim_next_pending, main.download_and_extract_audio,
218
- main.transcribe_audio, main.extract_structured_json,
219
- main.db.update_reel_result, main.db.mark_failed) = orig
220
-
221
-
222
- def test_worker_tick_failure():
223
- print("Testing worker_tick marks a failed download (mocked)...")
224
- import main
225
- from fastapi import HTTPException
226
- captured = {}
227
- orig = (main.db.claim_next_pending, main.download_and_extract_audio, main.db.mark_failed)
228
- main.db.claim_next_pending = lambda: {"id": "row2", "url": "https://www.instagram.com/reel/F/"}
229
- def _boom(url):
230
- raise HTTPException(status_code=400, detail="Meta blocking request")
231
- main.download_and_extract_audio = _boom
232
-
233
- main.db.mark_failed = lambda reel_id, error, status="failed": captured.update({"id": reel_id, "error": str(error), "status": status})
234
- try:
235
- did = main.worker_tick()
236
- assert did is True
237
- assert captured["id"] == "row2", captured
238
- assert "Meta blocking" in captured["error"]
239
- print("[OK] worker_tick failure passed!")
240
- finally:
241
- (main.db.claim_next_pending, main.download_and_extract_audio, main.db.mark_failed) = orig
242
-
243
-
244
- def test_worker_tick_empty():
245
- print("Testing worker_tick no-op on empty queue...")
246
- import main
247
- orig = main.db.claim_next_pending
248
- main.db.claim_next_pending = lambda: None
249
- try:
250
- assert main.worker_tick() is False
251
- print("[OK] worker_tick empty passed!")
252
- finally:
253
- main.db.claim_next_pending = orig
254
-
255
-
256
- def test_run_worker_drain():
257
- print("Testing run_worker drains the queue and survives a failed recovery...")
258
- import main
259
- ticks = iter([True, True, False])
260
- orig = (main.db.recover_stale_processing, main.worker_tick)
261
- def _boom(stale_before):
262
- raise RuntimeError("db offline")
263
- main.db.recover_stale_processing = _boom
264
- main.worker_tick = lambda: next(ticks)
265
- try:
266
- main.run_worker(drain=True)
267
- assert next(ticks, None) is None, "drain should stop at the first empty tick"
268
- print("[OK] run_worker drain passed!")
269
- finally:
270
- main.db.recover_stale_processing, main.worker_tick = orig
271
-
272
-
273
- def test_delete_reel():
274
- print("Testing DELETE /reels/{id} (db mocked)...")
275
- import main
276
- calls = []
277
- orig = main.db.delete_reel
278
- # First call: row exists (returns True); second: missing (False -> 404)
279
- main.db.delete_reel = lambda rid: (calls.append(rid), len(calls) == 1)[1]
280
- try:
281
- r = client.delete("/reels/abc")
282
- assert r.status_code == 200, r.text
283
- assert r.json()["deleted"] == "abc"
284
- assert client.delete("/reels/abc").status_code == 404
285
- print("[OK] delete reel passed!")
286
- finally:
287
- main.db.delete_reel = orig
288
-
289
-
290
- def test_get_reel_details():
291
- print("Testing GET /reels/{id}/details (db mocked)...")
292
- import main
293
- orig = main.db.get_reel_details
294
- main.db.get_reel_details = lambda rid: {"raw_transcript": "t", "post_caption": "c"} if rid == "abc" else None
295
- try:
296
- r = client.get("/reels/abc/details")
297
- assert r.status_code == 200, r.text
298
- assert r.json()["raw_transcript"] == "t"
299
- assert r.json()["post_caption"] == "c"
300
-
301
- assert client.get("/reels/missing/details").status_code == 404
302
- print("[OK] get reel details passed!")
303
- finally:
304
- main.db.get_reel_details = orig
305
-
306
-
307
- def test_reels_include_cluster():
308
- print("Testing GET /reels includes cluster (db mocked)...")
309
- import main
310
- orig = main.db.list_reels
311
- main.db.list_reels = lambda limit=20: [{
312
- "id": "x", "url": None, "title": "t", "raw_transcript": None,
313
- "post_caption": None, "extracted_json": {"core_topic": "c"},
314
- "created_at": "2026-06-06T00:00:00Z", "cluster": "Unclustered"}]
315
- try:
316
- r = client.get("/reels")
317
- assert r.status_code == 200, r.text
318
- assert "cluster" in r.json()[0]
319
- print("[OK] reels include cluster passed!")
320
- finally:
321
- main.db.list_reels = orig
322
-
323
-
324
- def test_cors_no_credentials():
325
- print("Testing CORS does not advertise credentials with wildcard origin...")
326
- r = client.options(
327
- "/health",
328
- headers={
329
- "Origin": "https://reels-transcriber.vercel.app",
330
- "Access-Control-Request-Method": "GET",
331
- },
332
- )
333
- # With allow_credentials=False, Starlette must NOT emit this header as true.
334
- assert r.headers.get("access-control-allow-credentials") != "true", dict(r.headers)
335
- print("[OK] CORS no-credentials passed!")
336
-
337
-
338
- def test_chunked_helper():
339
- print("Testing _chunked splits sequences into fixed-size lists...")
340
- from db import _chunked
341
- assert list(_chunked([1, 2, 3, 4, 5], 2)) == [[1, 2], [3, 4], [5]]
342
- assert list(_chunked([], 3)) == []
343
- assert list(_chunked([1, 2], 5)) == [[1, 2]]
344
- print("[OK] _chunked helper passed!")
345
-
346
-
347
- def test_parse_saved_posts():
348
- print("Testing parse_saved_posts filters reels...")
349
- from saved_parser import parse_saved_posts
350
- sample = [
351
- {"label_values": [
352
- {"label": "URL", "value": "https://www.instagram.com/reel/AAA/"},
353
- {"label": "Caption", "value": "hello"},
354
- {"label": "Title", "value": "T1"}]},
355
- {"label_values": [
356
- {"label": "URL", "value": "https://www.instagram.com/p/BBB/"},
357
- {"label": "Caption", "value": "photo"}]},
358
- ]
359
- out = parse_saved_posts(sample)
360
- assert len(out) == 1, out
361
- assert out[0]["url"].endswith("/reel/AAA/")
362
- assert out[0]["caption"] == "hello"
363
- assert out[0]["title"] == "T1"
364
- print("[OK] parse_saved_posts passed!")
365
-
366
-
367
- def test_batch_status_initial():
368
- print("Testing GET /extract/batch/status shape...")
369
- r = client.get("/extract/batch/status")
370
- assert r.status_code == 200, r.text
371
- body = r.json()
372
- for key in ("status", "total", "done", "ok", "failed", "current", "errors"):
373
- assert key in body, f"missing {key} in {body}"
374
- print("[OK] batch status shape passed!")
375
-
376
-
377
- def test_batch_guard_409():
378
- print("Testing POST /extract/batch returns 409 while running...")
379
- import main, io
380
- prev = dict(main.BATCH_JOB)
381
- main.BATCH_JOB["status"] = "running"
382
- def fake_update():
383
- pass
384
- orig_update = main.update_batch_job_status
385
- main.update_batch_job_status = fake_update
386
- try:
387
- files = {"file": ("saved_posts.json", io.BytesIO(b"[]"), "application/json")}
388
- r = client.post("/extract/batch", files=files)
389
- assert r.status_code == 409, r.text
390
- print("[OK] batch 409 guard passed!")
391
- finally:
392
- main.BATCH_JOB.update(prev)
393
- main.update_batch_job_status = orig_update
394
-
395
-
396
-
397
- def test_extract_batch_supabase():
398
- print("Testing POST /extract/batch enqueuing (db mocked)...")
399
- import main, io
400
- # Mock supabase client and database calls
401
- class FakeTable:
402
- def __init__(self, data=None):
403
- self.data = data or []
404
- self.inserted = []
405
-
406
- def select(self, *args, **kwargs):
407
- return self
408
-
409
- def in_(self, col, vals):
410
- # simulate existing URLs: say BBB already exists, AAA is new
411
- matching = [d for d in self.data if d.get(col) in vals]
412
- return FakeTable(matching)
413
-
414
- def insert(self, rows):
415
- if isinstance(rows, list):
416
- self.inserted.extend(rows)
417
- else:
418
- self.inserted.append(rows)
419
- return self
420
-
421
- def execute(self):
422
- class Res:
423
- def __init__(self, data):
424
- self.data = data
425
- return Res(self.data)
426
-
427
- fake_db = [{"url": "https://www.instagram.com/reel/BBB/"}]
428
- fake_table = FakeTable(fake_db)
429
-
430
- orig_client = main.db.get_client
431
- main.db.get_client = lambda: type('FakeClient', (), {'table': lambda self, name: fake_table})()
432
-
433
- sample_json = (
434
- '['
435
- ' {"label_values": [{"label": "URL", "value": "https://www.instagram.com/reel/AAA/"}]},'
436
- ' {"label_values": [{"label": "URL", "value": "https://www.instagram.com/reel/BBB/"}]}'
437
- ']'
438
- )
439
-
440
- try:
441
- files = {"file": ("saved_posts.json", io.BytesIO(sample_json.encode('utf-8')), "application/json")}
442
- r = client.post("/extract/batch", files=files)
443
- assert r.status_code == 200, r.text
444
- res_json = r.json()
445
- assert res_json["total"] == 2
446
- assert res_json["enqueued"] == 1
447
- assert res_json["existing"] == 1
448
-
449
- # AAA should have been inserted
450
- assert len(fake_table.inserted) == 1
451
- assert fake_table.inserted[0]["url"] == "https://www.instagram.com/reel/AAA/"
452
- assert fake_table.inserted[0]["status"] == "pending"
453
- assert fake_table.inserted[0]["source"] == "batch"
454
-
455
- # Poll status
456
- # status should show running, total=2, done=1, ok=1
457
- r_status = client.get("/extract/batch/status")
458
- assert r_status.status_code == 200
459
- status_data = r_status.json()
460
- assert status_data["status"] == "running"
461
- assert status_data["total"] == 2
462
- assert status_data["done"] == 1
463
-
464
- # Simulate processing AAA -> done
465
- fake_table.data.append({"url": "https://www.instagram.com/reel/AAA/", "status": "done"})
466
- r_status2 = client.get("/extract/batch/status")
467
- assert r_status2.json()["status"] == "done"
468
- assert r_status2.json()["done"] == 2
469
- assert r_status2.json()["ok"] == 2
470
-
471
- print("[OK] extract batch supabase mocked test passed!")
472
- finally:
473
- main.db.get_client = orig_client
474
- main.BATCH_JOB["status"] = "idle"
475
- main.BATCH_JOB["urls"] = []
476
-
477
-
478
- def test_url_validation_helper():
479
- print("Testing is_valid_instagram_reel and normalize_instagram_url helpers...")
480
- from main import is_valid_instagram_reel
481
- from saved_parser import normalize_instagram_url
482
- assert is_valid_instagram_reel("https://www.instagram.com/reel/C7xY9/") is True
483
- assert is_valid_instagram_reel("http://instagram.com/reel/abc-123_XYZ/?query=1") is True
484
- assert is_valid_instagram_reel("https://instagram.com/reels/abc-123/") is True
485
- assert is_valid_instagram_reel("https://instagram.com/p/abc/") is True
486
- assert is_valid_instagram_reel("https://google.com") is False
487
- assert is_valid_instagram_reel("invalid-url") is False
488
- assert is_valid_instagram_reel("") is False
489
-
490
- assert normalize_instagram_url("https://www.instagram.com/reel/C7xY9/?igsh=123&foo=bar") == "https://www.instagram.com/reel/C7xY9/"
491
- assert normalize_instagram_url("https://instagram.com/reels/abc-123?igsi=abc==") == "https://www.instagram.com/reel/abc-123/"
492
- assert normalize_instagram_url("https://www.instagram.com/p/ABC123_/?img_index=2") == "https://www.instagram.com/p/ABC123_/"
493
- print("[OK] URL validation and normalization helpers passed!")
494
-
495
- def test_extract_url_validation_error():
496
- print("Testing POST /extract/url blocks invalid URLs...")
497
- response = client.post("/extract/url", json={"url": "https://google.com"})
498
- assert response.status_code == 400
499
- assert "Invalid Instagram Reel URL" in response.json()["detail"]
500
- print("[OK] extract_url validation error passed!")
501
-
502
- def test_worker_tick_invalid_url():
503
- print("Testing worker_tick marks invalid URLs as failed...")
504
- import main
505
- captured = {}
506
- orig = (main.db.claim_next_pending, main.db.mark_failed)
507
- main.db.claim_next_pending = lambda: {"id": "row_invalid", "url": "https://malicious-ssrf.com"}
508
- main.db.mark_failed = lambda reel_id, error: captured.update({"id": reel_id, "error": str(error)})
509
- try:
510
- did = main.worker_tick()
511
- assert did is True
512
- assert captured["id"] == "row_invalid"
513
- assert "Invalid Instagram Reel URL" in captured["error"]
514
- print("[OK] worker_tick invalid URL failure passed!")
515
- finally:
516
- main.db.claim_next_pending, main.db.mark_failed = orig
517
-
518
-
519
- def test_download_cleanup_on_failure():
520
- print("Testing download_and_extract_audio cleans up files on failure...")
521
- import tempfile
522
- import os
523
- import main
524
-
525
- # Create fake files in temp dir
526
- temp_dir = tempfile.gettempdir()
527
- reel_id = "test_cleanup_fail_123"
528
- url = f"https://www.instagram.com/reel/{reel_id}/"
529
-
530
- fake_mp4 = os.path.join(temp_dir, f"video_{reel_id}.mp4")
531
- fake_part = os.path.join(temp_dir, f"video_{reel_id}.part")
532
-
533
- # Write empty files
534
- with open(fake_mp4, "w") as f:
535
- f.write("mp4")
536
- with open(fake_part, "w") as f:
537
- f.write("part")
538
-
539
- assert os.path.exists(fake_mp4) is True
540
- assert os.path.exists(fake_part) is True
541
-
542
- # Mock YoutubeDL to throw an Exception
543
- class FakeYDLThrow:
544
- def __init__(self, *args, **kwargs):
545
- pass
546
- def __enter__(self):
547
- return self
548
- def __exit__(self, exc_type, exc_val, exc_tb):
549
- pass
550
- def extract_info(self, *args, **kwargs):
551
- raise Exception("Mock download error")
552
-
553
- orig_ydl = main.yt_dlp.YoutubeDL
554
- main.yt_dlp.YoutubeDL = FakeYDLThrow
555
-
556
- try:
557
- from fastapi import HTTPException
558
- try:
559
- main.download_and_extract_audio(url)
560
- assert False, "Should have raised HTTPException"
561
- except HTTPException as he:
562
- assert he.status_code == 500
563
- assert "Mock download error" in he.detail
564
-
565
- # Check that the files were deleted
566
- assert os.path.exists(fake_mp4) is False, f"fake_mp4 still exists at {fake_mp4}"
567
- assert os.path.exists(fake_part) is False, f"fake_part still exists at {fake_part}"
568
- print("[OK] download_and_extract_audio failure cleanup passed!")
569
- finally:
570
- main.yt_dlp.YoutubeDL = orig_ydl
571
- # Clean up files in case they were not deleted to avoid side effects
572
- if os.path.exists(fake_mp4):
573
- os.remove(fake_mp4)
574
- if os.path.exists(fake_part):
575
- os.remove(fake_part)
576
-
577
-
578
- def test_api_auth_token_check():
579
- print("Testing API Access Gate authentication check (API_AUTH_TOKEN set)...")
580
- import main
581
- orig_token = main.API_AUTH_TOKEN
582
- main.API_AUTH_TOKEN = "super_secret_test_key"
583
- try:
584
- # Request without header -> 401
585
- res = client.post("/extract/text", json={"title": "Test", "transcript": "a", "caption": "b"})
586
- assert res.status_code == 401, res.text
587
- assert "Unauthorized" in res.json()["detail"]
588
-
589
- # Request with incorrect header -> 401
590
- res = client.post("/extract/text", json={"title": "Test", "transcript": "a", "caption": "b"}, headers={"X-API-Key": "wrong"})
591
- assert res.status_code == 401, res.text
592
-
593
- # Request with correct header -> mock output to pass
594
- mock_extracted = ReelExtraction(
595
- core_topic="Topic", key_takeaway="Takeaway", action_items=["a"], tools_or_resources=["b"]
596
- )
597
- orig_extract = main.extract_structured_json
598
- main.extract_structured_json = lambda t, c: mock_extracted
599
- orig_save = main.db.insert_reel
600
- main.db.insert_reel = lambda url, title, raw_transcript, post_caption, extracted_json: {
601
- "id": "1", "title": title, "extracted_json": extracted_json, "created_at": "2026-06-06T00:00:00Z"
602
- }
603
- try:
604
- res = client.post("/extract/text", json={"title": "Test", "transcript": "a", "caption": "b"}, headers={"X-API-Key": "super_secret_test_key"})
605
- assert res.status_code == 200, res.text
606
- assert res.json()["title"] == "Test"
607
- finally:
608
- main.extract_structured_json = orig_extract
609
- main.db.insert_reel = orig_save
610
-
611
- print("[OK] API Access Gate authentication check passed!")
612
- finally:
613
- main.API_AUTH_TOKEN = orig_token
614
-
615
-
616
-
617
- def test_batch_terminal_statuses():
618
- print("Testing all terminal and unknown batch statuses...")
619
- import main
620
- original_client = main.db.get_client
621
- previous = dict(main.BATCH_JOB)
622
-
623
- class Query:
624
- def select(self, *args): return self
625
- def in_(self, column, values): return self
626
- def execute(self):
627
- return type("Result", (), {"data": [{"url": "u", "status": self.status, "error": "boom"}]})()
628
-
629
- try:
630
- for status in ("cookies_expired", "unsupported_format", "mystery"):
631
- query = Query()
632
- query.status = status
633
- main.db.get_client = lambda q=query: type("Client", (), {"table": lambda self, name: q})()
634
- main.BATCH_JOB.update({"status": "running", "urls": ["u"], "finished_at": None})
635
- main.update_batch_job_status()
636
- assert main.BATCH_JOB["status"] == "done"
637
- assert main.BATCH_JOB["failed"] == 1
638
- if status == "mystery":
639
- assert "Unknown queue status: mystery" in main.BATCH_JOB["errors"][0]["detail"]
640
- finally:
641
- main.db.get_client = original_client
642
- main.BATCH_JOB.clear()
643
- main.BATCH_JOB.update(previous)
644
- print("[OK] terminal batch statuses passed!")
645
-
646
-
647
- def test_worker_retry_policy():
648
- print("Testing bounded worker retry policy...")
649
- import main, httpx
650
- from fastapi import HTTPException
651
- assert main.is_retryable_worker_error(HTTPException(status_code=429))
652
- assert main.is_retryable_worker_error(HTTPException(status_code=503))
653
- assert main.is_retryable_worker_error(httpx.ConnectError("offline"))
654
- assert not main.is_retryable_worker_error(HTTPException(status_code=400))
655
- assert not main.is_retryable_worker_error(HTTPException(status_code=403))
656
- assert not main.is_retryable_worker_error(HTTPException(status_code=415))
657
- assert 27 <= main.retry_delay_seconds(1, .9) <= 33
658
- assert 108 <= main.retry_delay_seconds(2, .9) <= 132
659
- calls = []
660
- original = main.db.schedule_retry
661
- main.db.schedule_retry = lambda reel_id, error, when: calls.append((reel_id, error, when))
662
- try:
663
- assert main._schedule_worker_retry({"id": "a", "attempt_count": 1}, "temporary")
664
- assert main._schedule_worker_retry({"id": "b", "attempt_count": 2}, "temporary")
665
- assert not main._schedule_worker_retry({"id": "c", "attempt_count": 3}, "temporary")
666
- assert [call[0] for call in calls] == ["a", "b"]
667
- finally:
668
- main.db.schedule_retry = original
669
- print("[OK] bounded retry policy passed!")
670
-
671
-
672
- def test_db_queue_metadata_contract():
673
- print("Testing queue metadata data-layer contract...")
674
- import db
675
- from datetime import datetime, timezone
676
-
677
- class Result:
678
- def __init__(self, data): self.data = data
679
- class Query:
680
- def __init__(self):
681
- self.calls = []
682
- self.selected_rows = [{"id": "r1", "attempt_count": 1}]
683
- self.updated = None
684
- def select(self, *args): self.calls.append(("select", args)); return self
685
- def update(self, payload): self.updated = payload; self.calls.append(("update", payload)); return self
686
- def eq(self, *args): self.calls.append(("eq", args)); return self
687
- def or_(self, value): self.calls.append(("or", value)); return self
688
- def order(self, *args, **kwargs): return self
689
- def limit(self, value): return self
690
- def lt(self, *args): self.calls.append(("lt", args)); return self
691
- def execute(self):
692
- return Result(self.selected_rows if self.updated is None else [{"id": "r1"}])
693
-
694
- query = Query()
695
- original = db.get_client
696
- db.get_client = lambda: type("Client", (), {"table": lambda self, name: query})()
697
- try:
698
- claimed = db.claim_next_pending()
699
- assert claimed["attempt_count"] == 2
700
- assert any(call[0] == "or" and "next_attempt_at.is.null" in call[1] and "next_attempt_at.lte." in call[1] for call in query.calls)
701
- assert query.updated["status"] == "processing"
702
- assert query.updated["processing_started_at"]
703
- assert query.updated["next_attempt_at"] is None
704
- query.calls.clear()
705
- assert db.recover_stale_processing(datetime.now(timezone.utc)) == 1
706
- assert ("eq", ("status", "processing")) in query.calls
707
- assert any(call[0] == "lt" and call[1][0] == "processing_started_at" for call in query.calls)
708
- finally:
709
- db.get_client = original
710
- print("[OK] queue metadata contract passed!")
711
-
712
-
713
- if __name__ == "__main__":
714
- print("--- Starting Transcriber Pipeline Test ---")
715
- test_health()
716
- test_extract_text_mock()
717
- test_recompute_clusters_mock()
718
- test_row_to_record_handles_bad_json()
719
- test_db_module_surface()
720
- test_db_queue_surface()
721
- test_worker_tick_success()
722
- test_worker_tick_failure()
723
- test_worker_tick_empty()
724
- test_run_worker_drain()
725
- test_extract_url_regression()
726
- test_extract_url_returns_unfinished_row()
727
- test_delete_reel()
728
- test_get_reel_details()
729
- test_reels_include_cluster()
730
- test_cors_no_credentials()
731
- test_chunked_helper()
732
- test_parse_saved_posts()
733
- test_batch_status_initial()
734
- test_batch_guard_409()
735
- test_extract_batch_supabase()
736
- test_url_validation_helper()
737
- test_extract_url_validation_error()
738
- test_worker_tick_invalid_url()
739
- test_download_cleanup_on_failure()
740
- test_api_auth_token_check()
741
- test_batch_terminal_statuses()
742
- test_worker_retry_policy()
743
- test_db_queue_metadata_contract()
744
- print("--- All tests completed successfully! ---")
745
- sys.exit(0)