Spaces:
Sleeping
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
- batch_import.py +0 -146
- main.py +21 -12
- requirements-dev.txt +2 -0
- test_pipeline.py +500 -0
- verify_pipeline.py +0 -745
|
@@ -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())
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@@ -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
|
|
|
|
|
|
|
|
|
|
| 261 |
if len(embeddings) <= 1:
|
| 262 |
return [0] * len(embeddings)
|
| 263 |
|
| 264 |
try:
|
| 265 |
-
from sklearn.cluster import
|
|
|
|
| 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 |
-
|
| 274 |
-
|
| 275 |
-
|
| 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/
|
| 673 |
-
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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,
|
|
@@ -0,0 +1,2 @@
|
|
|
|
|
|
|
|
|
|
| 1 |
+
-r requirements.txt
|
| 2 |
+
pytest>=8
|
|
@@ -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)
|
|
@@ -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)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|