Files
siftlode/backend/tests/test_search_scrape_ingest.py
peter b04b51c909 fix(search): every related and live-search request takes a slot, and a failed one gives it back
A related list's cursor cost nothing, and a cursor is a free string: a replayed one read and
enriched without end. Every request now takes a slot of the user's daily cap — taken under a lock
before the work (`quota.claim_action`), so requests sent together cannot all pass the last free
one, and given back when the request fails outright (`quota.refund_action`). The live search's
scrape source takes its slots the same way.

A limit of 0 turns the feature off: a distinct 409 `related_disabled` / `search_disabled`, and
`/api/me` says `related_enabled` / `search_enabled` so the UI can leave it out.

A videos.list that fails no longer reads as an empty page: `ingest_candidates` raises, and the
search and the related list answer the refusal (the shared budget spent says "quota") instead of
"no results". The two routes' page loops are one (`_gather`).

`clear` deletes only what the caller found: a related list's rows carry no find, and any user
could name them.
2026-10-25 00:32:26 +02:00

177 lines
6.7 KiB
Python

"""Regression: live YouTube search ingest must survive the scrape source carrying no publish date.
`videos.published_at` is NOT NULL (migration 0068, PERF-15). The scrape source exposes only a
relative time ("3 years ago"), so its stub has `published_at=None`; the ingest used to pass that
None straight through, which the NOT NULL constraint rejected with an IntegrityError. That error
is raised OUTSIDE the route's page-fetch try/except, so the whole `/api/search/youtube` request
500'd and the UI showed the generic "YouTube search failed." — every scrape search was broken.
The stub now falls back to now() (a non-null placeholder), and the enrich overwrites it with the
video's real snippet date. These DB-lane tests lock both halves: no crash on a date-less stub, and
a placeholder never becomes the video's permanent date.
"""
from datetime import datetime, timezone
import pytest
from app import sysconfig
from app.models import User
from app.catalog.models import Video
from app.catalog.search import ingest_candidates
from app.youtube.client import YouTubeError
class _FakeYT:
"""Offline stand-in for YouTubeClient. Channel enrich is a no-op (the from_search stub survives);
video enrich returns a real publishedAt plus a long duration so Shorts classification finalises
without the youtube.com/shorts probe (no network in the DB lane)."""
def __init__(self, enrich: dict[str, dict]):
self._enrich = enrich
def get_channels(self, ids):
return []
def get_videos(self, ids):
return [self._enrich[i] for i in ids if i in self._enrich]
def _candidate(vid: str, ch: str) -> dict:
# The scrape shape (see search_scrape._video_item): published_at is ALWAYS None.
return {
"id": vid,
"channel_id": ch,
"channel_title": f"Chan {ch}",
"title": f"Title {vid}",
"published_at": None,
"thumbnail_url": None,
"live_broadcast": "none",
}
def _enrich_item(vid: str, published_iso: str) -> dict:
# PT30M keeps the video well above shorts_probe_max_seconds so no probe fires.
return {
"id": vid,
"snippet": {"title": f"Title {vid}", "publishedAt": published_iso},
"contentDetails": {"duration": "PT30M"},
"statistics": {},
"status": {},
}
def test_scrape_ingest_dateless_stub_ingests_and_takes_real_enrich_date(db):
# Small cap so the 1800s enrich duration keeps the shorts step offline.
sysconfig.set_value(db, "shorts_probe_max_seconds", 60)
user = User(email="searcher@example.com")
db.add(user)
db.commit()
candidates = [_candidate("vid_aaaaaaa", "UC_chan_a"), _candidate("vid_bbbbbbb", "UC_chan_a")]
enrich = {
"vid_aaaaaaa": _enrich_item("vid_aaaaaaa", "2021-02-12T19:00:07Z"),
"vid_bbbbbbb": _enrich_item("vid_bbbbbbb", "2023-10-30T17:00:08Z"),
}
kept = ingest_candidates(db, _FakeYT(enrich), user, candidates)
# No IntegrityError, and both non-Short/non-live videos are kept in input order.
assert kept == ["vid_aaaaaaa", "vid_bbbbbbb"]
a = db.get(Video, "vid_aaaaaaa")
# The now() placeholder was replaced by the video's real snippet date, not left as "now".
assert a.published_at == datetime(2021, 2, 12, 19, 0, 7, tzinfo=timezone.utc)
assert a.via_search is True
assert a.enriched_at is not None
def test_scrape_ingest_enrich_miss_keeps_nonnull_placeholder(db):
# If enrich yields nothing (proxy blip / omitted row), the stub must STILL satisfy the NOT NULL
# constraint (a non-null placeholder) — it simply isn't kept (enriched_at stays None).
user = User(email="searcher2@example.com")
db.add(user)
db.commit()
kept = ingest_candidates(db, _FakeYT({}), user, [_candidate("vid_ccccccc", "UC_chan_c")])
assert kept == []
c = db.get(Video, "vid_ccccccc")
assert c.published_at is not None # placeholder wrote a real value → no crash
assert c.enriched_at is None
class _FailingYT(_FakeYT):
def get_videos(self, ids):
raise YouTubeError("videos -> 403", reason="quotaExceeded", path="videos")
def test_a_videos_list_failure_raises_and_keeps_the_stubs(db):
# Swallowed, it answered as a page with nothing on it — "no results" for a search whose lookup
# never ran. The stubs stay: the FK targets, backfilled later.
user = User(email="searcher3@example.com")
db.add(user)
db.commit()
with pytest.raises(YouTubeError):
ingest_candidates(db, _FailingYT({}), user, [_candidate("vid_ddddddd", "UC_chan_d")])
db.rollback() # what the caller does: the stubs were committed before the lookup
assert db.get(Video, "vid_ddddddd") is not None
def test_search_youtube_route_returns_422_when_ingest_fails(db, monkeypatch, override_dep):
# Route-level guard (B-core-1): an ingest failure with nothing yet collected must surface as a 422
# + message on this JSON endpoint (the FE hides 5xx behind a generic "connection lost" + auto-retry,
# burning a second quota-costed search), and the session is rolled back. Here the page fetch succeeds
# (one candidate) and ingest_candidates raises → the endpoint returns 422.
from fastapi.testclient import TestClient
import app.state as state
from app.auth import current_user
from app.db import get_db
from app.main import app
from app.routes import search as search_mod
user = User(email="route422@example.com")
db.add(user)
db.commit()
monkeypatch.setattr(state, "_configured_cache", True)
monkeypatch.setattr(
search_mod.sysconfig, "get_str", lambda d, k: "scrape" if k == "search_source" else ""
)
monkeypatch.setattr(search_mod.sysconfig, "get_int", lambda d, k: 100) # daily cap not reached
monkeypatch.setattr(search_mod.quota, "actions_today", lambda d, uid, action: 0)
class _FakeYTCM:
def __init__(self, *a, **k):
pass
def __enter__(self):
return self
def __exit__(self, *a):
return False
monkeypatch.setattr(search_mod, "YouTubeClient", _FakeYTCM)
monkeypatch.setattr(
search_mod.search_scrape,
"search",
lambda term, continuation=None: {
"items": [_candidate("vid_route12", "UC_route_a")],
"next_page_token": None,
},
)
def _boom(db_, yt, user_, candidates):
raise RuntimeError("ingest exploded")
monkeypatch.setattr(search_mod.catalog_search, "ingest_candidates", _boom)
override_dep(get_db, db.info["request"])
override_dep(current_user, lambda: user)
resp = TestClient(app).get("/api/search/youtube?q=test+term")
assert resp.status_code == 422, resp.text
assert resp.json()["detail"] == {"code": "youtube", "reason": "unavailable"}