refactor(backend): select() and an inline now everywhere, held by a test

CLAUDE.md's canonical table names SQLAlchemy 2.0 select() and an inline
datetime.now(timezone.utc), and both had drifted: five db.query(...) calls
remained in routes/auth.py and routes/me.py (seven before the demo went),
and four private now helpers stood in sync/videos.py, utils.py (imported
as _now by explore and maintenance), scheduler.py (_now_iso) and the e2e
seed. All are the canonical form now.

Ruff cannot ban a method call on an instance or a function by name, so
tests/test_canonical_patterns.py reads every app module's AST and fails on
a .query(...) call or a now/_now/now_utc helper — seen red on a planted file.
This commit is contained in:
2026-10-05 00:19:39 +02:00
parent ab76166878
commit 24c47d12bd
10 changed files with 111 additions and 59 deletions
+12 -4
View File
@@ -399,7 +399,7 @@ async def callback(
return RedirectResponse(url=_access_notice_url("requested", email, req.invite)) return RedirectResponse(url=_access_notice_url("requested", email, req.invite))
return RedirectResponse(url=_access_notice_url("denied")) return RedirectResponse(url=_access_notice_url("denied"))
user = db.query(User).filter(User.google_sub == sub).one_or_none() user = db.execute(select(User).where(User.google_sub == sub)).scalar_one_or_none()
if user is None: if user is None:
# No account carries this Google identity yet. If one already exists for this MAILBOX — # No account carries this Google identity yet. If one already exists for this MAILBOX —
# e.g. an email+password registration — adopt the Google identity onto it instead of # e.g. an email+password registration — adopt the Google identity onto it instead of
@@ -412,7 +412,11 @@ async def callback(
# (ids 13 and 14). Exact spelling still wins when both exist, so an account that already # (ids 13 and 14). Exact spelling still wins when both exist, so an account that already
# matches literally is never passed over for a canonical sibling. # matches literally is never passed over for a canonical sibling.
canon = canonical_email(email) canon = canonical_email(email)
candidates = db.query(User).filter(User.email_canonical == canon).order_by(User.id).all() candidates = (
db.execute(select(User).where(User.email_canonical == canon).order_by(User.id))
.scalars()
.all()
)
existing = next((u for u in candidates if u.email == email), None) or next( existing = next((u for u in candidates if u.email == email), None) or next(
iter(candidates), None iter(candidates), None
) )
@@ -516,7 +520,9 @@ def _sync_google_email(db: Session, user: User, email: str) -> None:
return return
if canonical_email(user.email) == canonical_email(email): if canonical_email(user.email) == canonical_email(email):
return # same mailbox, their spelling — leave it alone return # same mailbox, their spelling — leave it alone
clash = db.query(User).filter(User.email == email, User.id != user.id).one_or_none() clash = db.execute(
select(User).where(User.email == email, User.id != user.id)
).scalar_one_or_none()
if clash is None: if clash is None:
user.email = email user.email = email
@@ -575,7 +581,9 @@ def _complete_link(
user = db.get(User, link_uid) user = db.get(User, link_uid)
if user is None: if user is None:
return RedirectResponse(url="/?link=error") return RedirectResponse(url="/?link=error")
other = db.query(User).filter(User.google_sub == sub, User.id != user.id).one_or_none() other = db.execute(
select(User).where(User.google_sub == sub, User.id != user.id)
).scalar_one_or_none()
if other is not None: if other is not None:
return RedirectResponse(url="/?link=conflict") return RedirectResponse(url="/?link=conflict")
if user.google_sub is not None and user.google_sub != sub: if user.google_sub is not None and user.google_sub != sub:
+1 -1
View File
@@ -40,7 +40,7 @@ def my_accounts(
"""The accounts that have authenticated in this browser session (switchable without a """The accounts that have authenticated in this browser session (switchable without a
new Google sign-in). The active one is flagged.""" new Google sign-in). The active one is flagged."""
ids = request.session.get("account_ids") or [user.id] ids = request.session.get("account_ids") or [user.id]
rows = {u.id: u for u in db.query(User).filter(User.id.in_(ids)).all()} rows = {u.id: u for u in db.execute(select(User).where(User.id.in_(ids))).scalars()}
out = [] out = []
for uid in ids: # preserve recency order from the session for uid in ids: # preserve recency order from the session
u = rows.get(uid) u = rows.get(uid)
+4 -8
View File
@@ -106,10 +106,6 @@ def apply_interval(db, job_id: str, minutes: int) -> int:
return minutes return minutes
def _now_iso() -> str:
return datetime.now(timezone.utc).isoformat()
def _record(name: str, **fields) -> None: def _record(name: str, **fields) -> None:
with _activity_lock: with _activity_lock:
_activity.setdefault(name, {}).update(fields) _activity.setdefault(name, {}).update(fields)
@@ -187,7 +183,7 @@ def _job(name: str, fn) -> None:
db = SessionLocal() db = SessionLocal()
actor_id = _manual_actor.get() # set only for a manual "run now" trigger actor_id = _manual_actor.get() # set only for a manual "run now" trigger
_record( _record(
name, last_started=_now_iso(), last_error=None, progress=None name, last_started=datetime.now(timezone.utc).isoformat(), last_error=None, progress=None
) # running already claimed ) # running already claimed
def sink(current, total, phase): def sink(current, total, phase):
@@ -199,7 +195,7 @@ def _job(name: str, fn) -> None:
_record( _record(
name, name,
status="skipped", status="skipped",
last_finished=_now_iso(), last_finished=datetime.now(timezone.utc).isoformat(),
last_result="paused", last_result="paused",
result=_PAUSED, result=_PAUSED,
progress=None, progress=None,
@@ -216,7 +212,7 @@ def _job(name: str, fn) -> None:
_record( _record(
name, name,
status="ok", status="ok",
last_finished=_now_iso(), last_finished=datetime.now(timezone.utc).isoformat(),
last_result=summary, last_result=summary,
result=data, result=data,
progress=None, progress=None,
@@ -236,7 +232,7 @@ def _job(name: str, fn) -> None:
_record( _record(
name, name,
status="error", status="error",
last_finished=_now_iso(), last_finished=datetime.now(timezone.utc).isoformat(),
last_error=err, last_error=err,
result=failure, result=failure,
progress=None, progress=None,
+4 -5
View File
@@ -8,7 +8,7 @@ stay private to users who explored (or subscribe to) the channel — the feed qu
otherwise the cleanup job hard-deletes the channel and its untouched ephemeral videos after a otherwise the cleanup job hard-deletes the channel and its untouched ephemeral videos after a
grace period (mirrors the maintenance job's grace-then-delete safety).""" grace period (mirrors the maintenance job's grace-then-delete safety)."""
from datetime import timedelta from datetime import datetime, timedelta, timezone
from sqlalchemy import delete, or_, select from sqlalchemy import delete, or_, select
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
@@ -24,7 +24,6 @@ from app.models import (
VideoState, VideoState,
) )
from app.sync.videos import insert_stubs, stub_from_playlist_item, apply_video_details from app.sync.videos import insert_stubs, stub_from_playlist_item, apply_video_details
from app.utils import now_utc as _now
from app.youtube.client import YouTubeClient, YouTubeError, is_playlist_gone from app.youtube.client import YouTubeClient, YouTubeError, is_playlist_gone
log = logs.get_logger(__name__) log = logs.get_logger(__name__)
@@ -59,7 +58,7 @@ def _enrich_new(db: Session, yt: YouTubeClient, video_ids: list[str]) -> int:
if not videos: if not videos:
return 0 return 0
items = {it["id"]: it for it in yt.get_videos([v.id for v in videos])} items = {it["id"]: it for it in yt.get_videos([v.id for v in videos])}
now = _now() now = datetime.now(timezone.utc)
for video in videos: for video in videos:
item = items.get(video.id) item = items.get(video.id)
if item is None: if item is None:
@@ -115,7 +114,7 @@ def purge_unkept_explored(db: Session) -> dict:
or surfaced by the user's own search (SearchFind) — these survive even if the channel goes. or surfaced by the user's own search (SearchFind) — these survive even if the channel goes.
A channel row is dropped only once it holds no videos at all. 0 quota.""" A channel row is dropped only once it holds no videos at all. 0 quota."""
grace_days = sysconfig.get_int(db, "explore_grace_days") grace_days = sysconfig.get_int(db, "explore_grace_days")
cutoff = _now() - timedelta(days=grace_days) cutoff = datetime.now(timezone.utc) - timedelta(days=grace_days)
expired = db.execute( expired = db.execute(
delete(ExploredChannel).where(ExploredChannel.created_at < cutoff) delete(ExploredChannel).where(ExploredChannel.created_at < cutoff)
@@ -166,7 +165,7 @@ def purge_unkept_search(db: Session, grace_days: int | None = None) -> dict:
SearchFinds. `grace_days=0` purges immediately (the admin "purge now" path); None uses config. SearchFinds. `grace_days=0` purges immediately (the admin "purge now" path); None uses config.
0 quota.""" 0 quota."""
grace = sysconfig.get_int(db, "search_grace_days") if grace_days is None else grace_days grace = sysconfig.get_int(db, "search_grace_days") if grace_days is None else grace_days
cutoff = _now() - timedelta(days=grace) cutoff = datetime.now(timezone.utc) - timedelta(days=grace)
state_exists = select(VideoState.id).where(VideoState.video_id == Video.id).exists() state_exists = select(VideoState.id).where(VideoState.video_id == Video.id).exists()
pl_exists = select(PlaylistItem.id).where(PlaylistItem.video_id == Video.id).exists() pl_exists = select(PlaylistItem.id).where(PlaylistItem.video_id == Video.id).exists()
sub_exists = select(Subscription.id).where(Subscription.channel_id == Video.channel_id).exists() sub_exists = select(Subscription.id).where(Subscription.channel_id == Video.channel_id).exists()
+3 -4
View File
@@ -20,7 +20,7 @@ Quota: phase 1 costs ~(flagged / 50) units, phase 2 ~(batch / 50). Both respect
backfill reserve as the other jobs so interactive syncs never starve. backfill reserve as the other jobs so interactive syncs never starve.
""" """
from datetime import datetime, timedelta from datetime import datetime, timedelta, timezone
from sqlalchemy import or_, select from sqlalchemy import or_, select
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
@@ -31,7 +31,6 @@ from app.models import Playlist, PlaylistItem, Video
from app.notifications import create_notification from app.notifications import create_notification
from app.sync.runner import get_service_user from app.sync.runner import get_service_user
from app.sync.videos import apply_video_details, parse_dt from app.sync.videos import apply_video_details, parse_dt
from app.utils import now_utc as _now
from app.youtube.client import YouTubeClient from app.youtube.client import YouTubeClient
log = logs.get_logger(__name__) log = logs.get_logger(__name__)
@@ -60,7 +59,7 @@ def _is_abandoned_upcoming(item: dict) -> bool:
scheduled = parse_dt(live.get("scheduledStartTime")) scheduled = parse_dt(live.get("scheduledStartTime"))
if scheduled is None: if scheduled is None:
return False return False
return _now() - scheduled > timedelta(days=2) return datetime.now(timezone.utc) - scheduled > timedelta(days=2)
def _evaluate(video: Video, item: dict | None) -> str | None: def _evaluate(video: Video, item: dict | None) -> str | None:
@@ -225,7 +224,7 @@ def run_maintenance(db: Session) -> dict:
return {"reason": "no service user"} return {"reason": "no service user"}
# One `now` for the whole run: each phase taking its own let a video phase 1 had just recovered # One `now` for the whole run: each phase taking its own let a video phase 1 had just recovered
# (stamped with phase 1's) pass phase 2's "not checked this run" test and be fetched twice. # (stamped with phase 1's) pass phase 2's "not checked this run" test and be fetched twice.
now = _now() now = datetime.now(timezone.utc)
with YouTubeClient(db, user) as yt: with YouTubeClient(db, user) as yt:
phase1 = _recheck_flagged(db, yt, now) phase1 = _recheck_flagged(db, yt, now)
phase2 = ( phase2 = (
+12 -12
View File
@@ -22,10 +22,6 @@ log = logs.get_logger(__name__)
_DURATION_RE = re.compile(r"P(?:(\d+)D)?T(?:(\d+)H)?(?:(\d+)M)?(?:(\d+)S)?", re.IGNORECASE) _DURATION_RE = re.compile(r"P(?:(\d+)D)?T(?:(\d+)H)?(?:(\d+)M)?(?:(\d+)S)?", re.IGNORECASE)
def _now() -> datetime:
return datetime.now(timezone.utc)
def parse_iso8601_duration(value: str | None) -> int | None: def parse_iso8601_duration(value: str | None) -> int | None:
if not value: if not value:
return None return None
@@ -80,7 +76,7 @@ def apply_rss_feed(db: Session, channel: Channel, entries: list[dict]) -> int:
time. DB-only — must run on the session's own thread; the network fetch time. DB-only — must run on the session's own thread; the network fetch
(fetch_channel_feed) is kept separate so callers like run_rss_poll can parallelise it.""" (fetch_channel_feed) is kept separate so callers like run_rss_poll can parallelise it."""
inserted = insert_stubs(db, entries) inserted = insert_stubs(db, entries)
channel.last_rss_at = _now() channel.last_rss_at = datetime.now(timezone.utc)
db.add(channel) db.add(channel)
db.commit() db.commit()
return inserted return inserted
@@ -96,7 +92,9 @@ def stub_from_playlist_item(item: dict, channel_id: str) -> dict | None:
# published_at is NOT NULL (migration 0068); fall back to now() for the rare item that carries no # published_at is NOT NULL (migration 0068); fall back to now() for the rare item that carries no
# date at all, so the bulk stub insert never writes a NULL. The enrich pass replaces it if the # date at all, so the bulk stub insert never writes a NULL. The enrich pass replaces it if the
# video's own snippet later yields a real publish date. # video's own snippet later yields a real publish date.
published = parse_dt(content.get("videoPublishedAt") or snippet.get("publishedAt")) or _now() published = parse_dt(
content.get("videoPublishedAt") or snippet.get("publishedAt")
) or datetime.now(timezone.utc)
return { return {
"id": video_id, "id": video_id,
"channel_id": channel_id, "channel_id": channel_id,
@@ -124,12 +122,14 @@ def backfill_channel_recent(db: Session, yt: YouTubeClient, channel: Channel) ->
"""First pass: newest videos up to the configured count or age cutoff. Records a """First pass: newest videos up to the configured count or age cutoff. Records a
resume cursor for the later deep backfill.""" resume cursor for the later deep backfill."""
if not channel.uploads_playlist_id: if not channel.uploads_playlist_id:
channel.recent_synced_at = _now() channel.recent_synced_at = datetime.now(timezone.utc)
db.add(channel) db.add(channel)
db.commit() db.commit()
return 0 return 0
cutoff = _now() - timedelta(days=sysconfig.get_int(db, "backfill_recent_max_days")) cutoff = datetime.now(timezone.utc) - timedelta(
days=sysconfig.get_int(db, "backfill_recent_max_days")
)
collected: list[dict] = [] collected: list[dict] = []
page_token: str | None = None page_token: str | None = None
next_cursor: str | None = None next_cursor: str | None = None
@@ -164,7 +164,7 @@ def backfill_channel_recent(db: Session, yt: YouTubeClient, channel: Channel) ->
break break
next_cursor = page_token # whole page consumed, more pages remain next_cursor = page_token # whole page consumed, more pages remain
channel.recent_synced_at = _now() channel.recent_synced_at = datetime.now(timezone.utc)
channel.backfill_cursor = next_cursor channel.backfill_cursor = next_cursor
channel.backfill_done = done channel.backfill_done = done
inserted = insert_stubs(db, collected) inserted = insert_stubs(db, collected)
@@ -264,7 +264,7 @@ def apply_video_details(video: Video, item: dict) -> None:
if snippet_published: if snippet_published:
video.published_at = snippet_published video.published_at = snippet_published
elif not video.published_at: elif not video.published_at:
video.published_at = _now() video.published_at = datetime.now(timezone.utc)
video.thumbnail_url = best_thumbnail(snippet.get("thumbnails")) or video.thumbnail_url video.thumbnail_url = best_thumbnail(snippet.get("thumbnails")) or video.thumbnail_url
video.duration_seconds = parse_iso8601_duration(content.get("duration")) video.duration_seconds = parse_iso8601_duration(content.get("duration"))
video.view_count = int(stats["viewCount"]) if stats.get("viewCount") else None video.view_count = int(stats["viewCount"]) if stats.get("viewCount") else None
@@ -296,7 +296,7 @@ def _apply_video_batch(
if not videos: if not videos:
return 0 return 0
items = {it["id"]: it for it in yt.get_videos([v.id for v in videos])} items = {it["id"]: it for it in yt.get_videos([v.id for v in videos])}
now = _now() now = datetime.now(timezone.utc)
applied = 0 applied = 0
for video in videos: for video in videos:
item = items.get(video.id) item = items.get(video.id)
@@ -312,7 +312,7 @@ def _apply_video_batch(
def _mark_enriched(video: Video) -> None: def _mark_enriched(video: Video) -> None:
# Unavailable (deleted/private): stamp so we don't keep retrying. # Unavailable (deleted/private): stamp so we don't keep retrying.
video.enriched_at = _now() video.enriched_at = datetime.now(timezone.utc)
def enrich_pending(db: Session, yt: YouTubeClient, limit: int | None = None) -> int: def enrich_pending(db: Session, yt: YouTubeClient, limit: int | None = None) -> int:
+1 -6
View File
@@ -1,7 +1,7 @@
"""Small cross-cutting helpers shared across modules (kept dependency-light on purpose).""" """Small cross-cutting helpers shared across modules (kept dependency-light on purpose)."""
import re import re
from datetime import date, datetime, timezone from datetime import date
def iso(dt: date | None) -> str | None: def iso(dt: date | None) -> str | None:
@@ -55,11 +55,6 @@ def canonical_email(value: str | None) -> str:
return f"{local}@gmail.com" if local else email return f"{local}@gmail.com" if local else email
def now_utc() -> datetime:
"""Current time as a timezone-aware UTC datetime."""
return datetime.now(timezone.utc)
# The shortest last word that is prefix-matched. A 1–2 letter prefix matches a large share of the # The shortest last word that is prefix-matched. A 1–2 letter prefix matches a large share of the
# library and ts_rank then scores every match: "m" took ~5–8 s on prod against ~100 ms for a whole # library and ts_rank then scores every match: "m" took ~5–8 s on prod against ~100 ms for a whole
# word (E31 S4e). A shorter last word is matched whole instead ("tv", "4k" still find themselves). # word (E31 S4e). A shorter last word is matched whole instead ("tv", "4k" still find themselves).
+52
View File
@@ -0,0 +1,52 @@
"""Two rows of CLAUDE.md's canonical-patterns table, held in code (E35 S4).
Both had drifted while the table said otherwise: seven `db.query(...)` calls stood beside the
`select()` the table names, and four modules carried a private `_now` / `now_utc` helper (nine uses)
where the table says inline `datetime.now(timezone.utc)`. A rule only prose holds drifts; ruff cannot
ban a method call on an instance or a function's name, so this reads the AST of every app module.
Pure-logic: no DB.
"""
import ast
import pathlib
import re
import app
APP = pathlib.Path(app.__file__).resolve().parent
NOW_HELPER = re.compile(r"_?now(_\w+)?|now_utc|utcnow")
def _modules() -> list[tuple[str, ast.Module]]:
return [
(p.relative_to(APP.parent).as_posix(), ast.parse(p.read_text(encoding="utf-8")))
for p in sorted(APP.rglob("*.py"))
]
def test_queries_use_select_not_the_legacy_query_api():
found = [
f"{path}:{node.lineno}"
for path, tree in _modules()
for node in ast.walk(tree)
if isinstance(node, ast.Call)
and isinstance(node.func, ast.Attribute)
and node.func.attr == "query"
]
assert found == [], "use db.execute(select(...)) — SQLAlchemy 2.0 — not db.query(...)"
def test_the_current_time_is_inline_not_a_helper():
found = [
f"{path}:{node.lineno} {node.name}"
for path, tree in _modules()
for node in ast.walk(tree)
if isinstance(node, ast.FunctionDef | ast.AsyncFunctionDef)
and NOW_HELPER.fullmatch(node.name)
]
assert found == [], "write datetime.now(timezone.utc) inline, not a now()/_now() helper"
def test_the_scan_sees_the_app():
# a scan that reads nothing is green over nothing
assert len(_modules()) > 50
+15 -12
View File
@@ -7,14 +7,13 @@ trigger, and an upcoming premiere that never went live is. YouTube is a fake tha
videos.list from a dict. videos.list from a dict.
""" """
from datetime import timedelta from datetime import datetime, timedelta, timezone
import pytest import pytest
from sqlalchemy import select from sqlalchemy import select
from app.models import Channel, Notification, Playlist, PlaylistItem, Video from app.models import Channel, Notification, Playlist, PlaylistItem, Video
from app.sync import maintenance from app.sync import maintenance
from app.utils import now_utc
class _YT: class _YT:
@@ -41,11 +40,14 @@ def channel(db):
def test_a_flagged_video_that_answers_again_is_unhidden(db, channel): def test_a_flagged_video_that_answers_again_is_unhidden(db, channel):
v = Video( v = Video(
id="v1", channel_id=channel.id, unavailable_since=now_utc(), unavailable_reason="removed" id="v1",
channel_id=channel.id,
unavailable_since=datetime.now(timezone.utc),
unavailable_reason="removed",
) )
db.add(v) db.add(v)
db.commit() db.commit()
out = maintenance._recheck_flagged(db, _YT({"v1": _item("v1")}), now_utc()) out = maintenance._recheck_flagged(db, _YT({"v1": _item("v1")}), datetime.now(timezone.utc))
db.refresh(v) db.refresh(v)
assert v.unavailable_since is None and v.unavailable_reason is None assert v.unavailable_since is None and v.unavailable_reason is None
assert out == {"checked": 1, "recovered": 1, "deleted": 0, "notified": 0} assert out == {"checked": 1, "recovered": 1, "deleted": 0, "notified": 0}
@@ -55,12 +57,13 @@ def test_a_video_still_missing_inside_its_grace_period_is_kept_hidden(db, channe
v = Video( v = Video(
id="v1", id="v1",
channel_id=channel.id, channel_id=channel.id,
unavailable_since=now_utc() - timedelta(days=maintenance.DEFAULT_GRACE_DAYS - 1), unavailable_since=datetime.now(timezone.utc)
- timedelta(days=maintenance.DEFAULT_GRACE_DAYS - 1),
unavailable_reason="removed", unavailable_reason="removed",
) )
db.add(v) db.add(v)
db.commit() db.commit()
out = maintenance._recheck_flagged(db, _YT({}), now_utc()) out = maintenance._recheck_flagged(db, _YT({}), datetime.now(timezone.utc))
assert db.get(Video, "v1") is not None assert db.get(Video, "v1") is not None
assert out["deleted"] == 0 assert out["deleted"] == 0
@@ -69,7 +72,7 @@ def test_past_the_grace_period_it_is_deleted_and_each_user_who_kept_it_told_once
db, channel, make_user db, channel, make_user
): ):
a, b = make_user(), make_user() a, b = make_user(), make_user()
gone_since = now_utc() - timedelta(days=maintenance.DEFAULT_GRACE_DAYS + 1) gone_since = datetime.now(timezone.utc) - timedelta(days=maintenance.DEFAULT_GRACE_DAYS + 1)
for vid in ("v1", "v2"): for vid in ("v1", "v2"):
db.add( db.add(
Video( Video(
@@ -92,7 +95,7 @@ def test_past_the_grace_period_it_is_deleted_and_each_user_who_kept_it_told_once
] ]
) )
db.commit() db.commit()
out = maintenance._recheck_flagged(db, _YT({}), now_utc()) out = maintenance._recheck_flagged(db, _YT({}), datetime.now(timezone.utc))
db.expire_all() db.expire_all()
assert db.get(Video, "v1") is None and db.get(Video, "v2") is None assert db.get(Video, "v1") is None and db.get(Video, "v2") is None
assert db.execute(select(PlaylistItem)).scalars().all() == [] # the cascade assert db.execute(select(PlaylistItem)).scalars().all() == [] # the cascade
@@ -118,7 +121,7 @@ def test_rolling_revalidation_flags_the_missing_and_keeps_a_live_stream(db, chan
) )
db.commit() db.commit()
yt = _YT({"ok": _item("ok"), "live": _item("live", liveBroadcastContent="live")}) yt = _YT({"ok": _item("ok"), "live": _item("live", liveBroadcastContent="live")})
out = maintenance._revalidate_rolling(db, yt, now_utc()) out = maintenance._revalidate_rolling(db, yt, datetime.now(timezone.utc))
db.expire_all() db.expire_all()
assert db.get(Video, "gone").unavailable_reason == "removed" assert db.get(Video, "gone").unavailable_reason == "removed"
assert db.get(Video, "ok").unavailable_since is None assert db.get(Video, "ok").unavailable_since is None
@@ -150,7 +153,7 @@ def test_a_run_asks_youtube_about_each_video_once(db, channel, monkeypatch, make
Video( Video(
id="v1", id="v1",
channel_id=channel.id, channel_id=channel.id,
unavailable_since=now_utc(), unavailable_since=datetime.now(timezone.utc),
unavailable_reason="removed", unavailable_reason="removed",
), ),
Video(id="v2", channel_id=channel.id), Video(id="v2", channel_id=channel.id),
@@ -163,8 +166,8 @@ def test_a_run_asks_youtube_about_each_video_once(db, channel, monkeypatch, make
def test_an_upcoming_premiere_counts_as_abandoned_only_when_it_never_went_live(): def test_an_upcoming_premiere_counts_as_abandoned_only_when_it_never_went_live():
past = (now_utc() - timedelta(days=3)).isoformat() past = (datetime.now(timezone.utc) - timedelta(days=3)).isoformat()
soon = (now_utc() - timedelta(days=1)).isoformat() soon = (datetime.now(timezone.utc) - timedelta(days=1)).isoformat()
def item(**live): def item(**live):
return {"snippet": {"liveBroadcastContent": "upcoming"}, "liveStreamingDetails": live} return {"snippet": {"liveBroadcastContent": "upcoming"}, "liveStreamingDetails": live}
+7 -7
View File
@@ -45,10 +45,6 @@ if not PASSWORD:
N_SUBS = 3 N_SUBS = 3
def _now() -> datetime:
return datetime.now(timezone.utc)
def main() -> None: def main() -> None:
db = SessionLocal() db = SessionLocal()
try: try:
@@ -99,7 +95,11 @@ def main() -> None:
db.execute(delete(Subscription).where(Subscription.user_id == user.id)) db.execute(delete(Subscription).where(Subscription.user_id == user.id))
for cid in channel_ids: for cid in channel_ids:
db.add(Subscription(user_id=user.id, channel_id=cid, subscribed_at=_now())) db.add(
Subscription(
user_id=user.id, channel_id=cid, subscribed_at=datetime.now(timezone.utc)
)
)
db.commit() db.commit()
# 3. Deterministic watch state (reset each run): # 3. Deterministic watch state (reset each run):
@@ -133,7 +133,7 @@ def main() -> None:
video_id=vid.id, video_id=vid.id,
status="new", status="new",
position_seconds=42, position_seconds=42,
progress_updated_at=_now(), progress_updated_at=datetime.now(timezone.utc),
) )
) )
in_progress = vid.id in_progress = vid.id
@@ -146,7 +146,7 @@ def main() -> None:
user_id=user.id, user_id=user.id,
video_id=wv.id, video_id=wv.id,
status="watched", status="watched",
watched_at=_now(), watched_at=datetime.now(timezone.utc),
) )
) )
watched = wv.id watched = wv.id