test: the rewritten write handlers commit, and three more owner filters hold

Each handler that now calls a service and commits is driven through HTTP once.
A second account's watch state, Watch later list and private download profile
are never read or changed. The autotag job's orphan prune is seen committed.
This commit is contained in:
2026-10-20 00:45:22 +02:00
parent 3a5212cd8a
commit b04b2cbd62
3 changed files with 483 additions and 0 deletions
+14
View File
@@ -8,6 +8,8 @@ drops an unknown id, so the view silently showed the whole feed under its old na
from sqlalchemy import select from sqlalchemy import select
from app.catalog.models import Channel from app.catalog.models import Channel
from app.db import SessionLocal
from app.sync.autotag import run_autotag_all
from app.reader.models import ChannelTag, SavedView, Tag from app.reader.models import ChannelTag, SavedView, Tag
from app.reader import service as reader_service from app.reader import service as reader_service
@@ -66,3 +68,15 @@ def test_a_view_kept_tag_list_larger_than_a_bind_parameter_limit_still_cleans_up
db.commit() db.commit()
assert reader_service.prune_orphan_system_tags(db) == 1 assert reader_service.prune_orphan_system_tags(db) == 1
db.commit() db.commit()
def test_the_job_commits_its_cleanup(db):
"""The prune only flushes; the job ends its own transaction. Read from a session of its own,
the orphan is gone only once the job committed — the job's session would see its own delete
either way."""
orphan = Tag(user_id=None, name="Cooking", category="topic")
db.add(orphan)
db.commit()
assert run_autotag_all(db, only_missing=True)["orphan_tags_removed"] == 1
with SessionLocal() as fresh:
assert fresh.execute(select(Tag).where(Tag.name == "Cooking")).first() is None
+113
View File
@@ -10,7 +10,10 @@ from datetime import datetime, timedelta, timezone
from sqlalchemy import func, select from sqlalchemy import func, select
from app import debugsession, notifications, quota from app import debugsession, notifications, quota
from app.catalog.models import Channel, Video
from app.downloads.models import DownloadJob, DownloadProfile
from app.models import DebugScreenshot, DebugSession, Notification, QuotaEvent from app.models import DebugScreenshot, DebugSession, Notification, QuotaEvent
from app.reader.models import Playlist, PlaylistItem, VideoState
from app.routes import tokens as tokens_routes from app.routes import tokens as tokens_routes
from app.routes.auth import RESET_TTL, _issue_token from app.routes.auth import RESET_TTL, _issue_token
@@ -133,3 +136,113 @@ def test_screenshot_storage_counts_only_mine(db, make_user):
) )
db.commit() db.commit()
assert debugsession.stored_screenshot_bytes(db, me.id) == 10 assert debugsession.stored_screenshot_bytes(db, me.id) == 10
# --- the units' owner filters, through their routes --------------------------------------------
# The same rule one layer up: a second account acting on the SAME video, list or profile id must
# reach only its own row. Each test holds the first account's row unchanged afterwards.
def _videos(db, *ids):
db.add(Channel(id="UCo", title="O"))
db.flush()
for vid in ids:
db.add(
Video(
id=vid,
channel_id="UCo",
published_at=datetime.now(timezone.utc),
duration_seconds=3600,
)
)
db.commit()
def _video_state(db, user, vid="v1"):
db.expire_all()
row = db.execute(
select(VideoState).where(VideoState.user_id == user.id, VideoState.video_id == vid)
).scalar_one_or_none()
return None if row is None else (row.status, row.position_seconds)
def test_unmarking_a_video_leaves_another_users_state(db, make_user, as_user):
me, other = make_user(), make_user()
_videos(db, "v1")
db.add(VideoState(user_id=other.id, video_id="v1", status="watched"))
db.commit()
r = as_user(me).post("/api/videos/v1/state", json={"status": "new"})
assert r.status_code == 200, r.text
assert _video_state(db, other) == ("watched", 0)
def test_clearing_a_video_leaves_another_users_state(db, make_user, as_user):
me, other = make_user(), make_user()
_videos(db, "v1")
db.add(VideoState(user_id=other.id, video_id="v1", status="hidden", position_seconds=90))
db.commit()
assert as_user(me).delete("/api/videos/v1/state").status_code == 200
assert _video_state(db, other) == ("hidden", 90)
def test_a_resume_point_is_set_and_cleared_on_my_own_row_only(db, make_user, as_user):
me, other = make_user(), make_user()
_videos(db, "v1")
db.add(VideoState(user_id=other.id, video_id="v1", status="hidden", position_seconds=90))
db.commit()
client = as_user(me)
body = {"position_seconds": 600, "duration_seconds": 3600}
assert client.post("/api/videos/v1/progress", json=body).status_code == 200
assert (_video_state(db, me), _video_state(db, other)) == (("new", 600), ("hidden", 90))
body = {"position_seconds": 0, "duration_seconds": 3600}
assert client.post("/api/videos/v1/progress", json=body).status_code == 200
assert (_video_state(db, me), _video_state(db, other)) == (None, ("hidden", 90))
def _watch_later_ids(db, user):
db.expire_all()
return sorted(
db.execute(
select(PlaylistItem.video_id)
.join(Playlist, Playlist.id == PlaylistItem.playlist_id)
.where(Playlist.user_id == user.id, Playlist.kind == "watch_later")
).scalars()
)
def test_watch_later_adds_and_removes_use_my_own_list(db, make_user, as_user):
me, other = make_user(), make_user()
_videos(db, "v1", "v2")
theirs = as_user(other).post("/api/playlists/watch-later", json={"video_id": "v1"}).json()
client = as_user(me)
mine = client.post("/api/playlists/watch-later", json={"video_id": "v2"}).json()
assert mine["playlist_id"] != theirs["playlist_id"]
# Their saved video is not on my list, so removing it from mine leaves theirs alone.
assert client.delete("/api/playlists/watch-later/v1").status_code == 200
assert (_watch_later_ids(db, me), _watch_later_ids(db, other)) == (["v2"], ["v1"])
def test_another_users_private_profile_is_refused_like_a_missing_one(db, make_user, as_user):
me, other = make_user(), make_user()
theirs = DownloadProfile(user_id=other.id, name="Theirs", spec={"format": "best"})
builtin = DownloadProfile(user_id=None, name="Best", spec={"format": "best"}, is_builtin=True)
db.add_all([theirs, builtin])
db.commit()
client = as_user(me)
def enqueue(profile_id):
return client.post(
"/api/downloads", json={"source": "dQw4w9WgXcQ", "profile_id": profile_id}
)
missing = enqueue(theirs.id + builtin.id + 1)
refused = enqueue(theirs.id)
assert (refused.status_code, refused.json()) == (404, missing.json())
assert refused.json()["detail"] == {"code": "not_found", "reason": "profile"}
db.expire_all()
assert db.execute(select(DownloadJob)).first() is None
# A built-in is everyone's to use.
r = enqueue(builtin.id)
assert r.status_code == 200, r.text
db.expire_all()
assert db.execute(select(DownloadJob.profile_id)).scalars().all() == [builtin.id]
+356
View File
@@ -0,0 +1,356 @@
"""Writes driven through HTTP, each read back: the handler called its unit and committed.
A route's helper only flushes; the handler commits. The `db` fixture fails a test whose request left
a write uncommitted, so every test here drives one successful write through its route and then
reads the result back — a handler that lost its commit fails on both counts. The services' own
tests hold what each write does; these hold that the route finishes it. Nothing here reaches
YouTube or Plex: the YouTube client is a stub and no user has a Plex link. Needs the DB.
"""
from datetime import datetime, timezone
import pytest
from sqlalchemy import select
from app import quota
from app.auth import require_write_scope
from app.catalog.models import Channel, ExploredChannel, SearchFind, Subscription, Video
from app.downloads.models import DownloadJob, DownloadProfile, DownloadShare
from app.plex.models import PlexItem, PlexLibrary, PlexSeason, PlexShow, PlexState
from app.models import Notification
from app.reader.models import SavedView, Tag, VideoState
from app.routes import channels
NOW = datetime.now(timezone.utc)
def _video(db, vid="v1", **kw):
if db.get(Channel, "UCr") is None:
db.add(Channel(id="UCr", title="R"))
db.flush()
db.add(Video(id=vid, channel_id="UCr", published_at=NOW, duration_seconds=60, **kw))
db.commit()
# --- tags ------------------------------------------------------------------------------------
def test_a_tag_made_through_the_route_is_listed_back(db, make_user, as_user):
user = make_user()
client = as_user(user)
r = client.post("/api/tags", json={"name": "Science"})
assert r.status_code == 200, r.text
assert [t["name"] for t in client.get("/api/tags").json()] == ["Science"]
def test_a_tag_renamed_through_the_route_is_listed_back(db, make_user, as_user):
user = make_user()
tag = Tag(user_id=user.id, name="Old")
db.add(tag)
db.commit()
client = as_user(user)
assert client.patch(f"/api/tags/{tag.id}", json={"name": "New"}).status_code == 200
assert [t["name"] for t in client.get("/api/tags").json()] == ["New"]
def test_a_tag_deleted_through_the_route_is_gone(db, make_user, as_user):
user = make_user()
tag = Tag(user_id=user.id, name="Gone")
db.add(tag)
db.commit()
client = as_user(user)
assert client.delete(f"/api/tags/{tag.id}").json() == {"deleted": tag.id}
assert client.get("/api/tags").json() == []
# --- watch state ------------------------------------------------------------------------------
def _state(db, user, vid="v1"):
db.expire_all()
return db.execute(
select(VideoState).where(VideoState.user_id == user.id, VideoState.video_id == vid)
).scalar_one_or_none()
def test_a_video_marked_watched_through_the_route_stays_watched(db, make_user, as_user):
user = make_user()
_video(db)
r = as_user(user).post("/api/videos/v1/state", json={"status": "watched"})
assert r.status_code == 200, r.text
assert _state(db, user).status == "watched"
def test_a_video_unmarked_through_the_route_stays_unmarked(db, make_user, as_user):
# "new" is its own branch of the handler, with its own commit.
user = make_user()
_video(db)
db.add(VideoState(user_id=user.id, video_id="v1", status="watched"))
db.commit()
r = as_user(user).post("/api/videos/v1/state", json={"status": "new"})
assert r.status_code == 200, r.text
assert _state(db, user) is None
def test_a_video_state_cleared_through_the_route_is_gone(db, make_user, as_user):
user = make_user()
_video(db)
db.add(VideoState(user_id=user.id, video_id="v1", status="hidden", position_seconds=90))
db.commit()
assert as_user(user).delete("/api/videos/v1/state").status_code == 200
assert _state(db, user) is None
# --- saved views ------------------------------------------------------------------------------
def test_views_reordered_through_the_route_list_back_in_that_order(db, make_user, as_user):
user = make_user()
a = SavedView(user_id=user.id, source="feed", name="A", filters={}, position=0)
b = SavedView(user_id=user.id, source="feed", name="B", filters={}, position=1)
db.add_all([a, b])
db.commit()
client = as_user(user)
assert client.post("/api/saved-views/reorder", json={"ids": [b.id, a.id]}).status_code == 200
listed = client.get("/api/saved-views").json()
assert [(v["name"], v["position"]) for v in listed] == [("B", 0), ("A", 1)]
# --- download profiles and shares -------------------------------------------------------------
_SPEC = {"format": "best"}
def _profile(db, user, name="Mine"):
p = DownloadProfile(user_id=user.id, name=name, spec=_SPEC, is_builtin=False, sort_order=101)
db.add(p)
db.commit()
return p
def _profile_names(client):
return [p["name"] for p in client.get("/api/downloads/profiles").json()]
def test_a_profile_made_through_the_route_is_listed_back(db, make_user, as_user):
user = make_user()
client = as_user(user)
r = client.post("/api/downloads/profiles", json={"name": "Phone", "spec": _SPEC})
assert r.status_code == 200, r.text
assert _profile_names(client) == ["Phone"]
def test_a_profile_renamed_through_the_route_is_listed_back(db, make_user, as_user):
user = make_user()
p = _profile(db, user)
client = as_user(user)
r = client.patch(f"/api/downloads/profiles/{p.id}", json={"name": "Renamed"})
assert r.status_code == 200, r.text
assert _profile_names(client) == ["Renamed"]
def test_a_profile_deleted_through_the_route_is_gone(db, make_user, as_user):
user = make_user()
p = _profile(db, user)
client = as_user(user)
assert client.delete(f"/api/downloads/profiles/{p.id}").json() == {"deleted": p.id}
assert _profile_names(client) == []
def test_a_share_taken_back_through_the_route_is_gone(db, make_user, as_user):
owner, friend = make_user(), make_user()
job = DownloadJob(
user_id=owner.id, source_kind="youtube", source_ref="dQw4w9WgXcQ", profile_snapshot={}
)
db.add(job)
db.flush()
db.add(DownloadShare(job_id=job.id, from_user_id=owner.id, to_user_id=friend.id))
db.commit()
r = as_user(owner).delete(f"/api/downloads/{job.id}/share/{friend.email}")
assert r.json() == {"ok": True}
db.expire_all()
assert db.execute(select(DownloadShare)).first() is None
# --- channels ---------------------------------------------------------------------------------
class _YouTube:
"""The YouTube client, answering every write the channel routes make."""
def __init__(self, db, user):
pass
def __enter__(self):
return self
def __exit__(self, *exc):
return False
def insert_subscription(self, channel_id):
return "yt-sub-1"
def delete_subscription(self, sub_id):
return None
def get_channels(self, ids):
return []
@pytest.fixture
def youtube(monkeypatch):
monkeypatch.setattr(channels, "YouTubeClient", _YouTube)
monkeypatch.setattr(quota, "backfill_reserve_reached", lambda: False)
def _channel(db, cid="UCc", **kw):
ch = Channel(id=cid, title="C", details_synced_at=NOW, **kw)
db.add(ch)
db.commit()
return ch
def _subscription(db, user, cid="UCc"):
db.expire_all()
return db.execute(
select(Subscription).where(Subscription.user_id == user.id, Subscription.channel_id == cid)
).scalar_one_or_none()
def test_a_subscribe_through_the_route_is_recorded(db, make_user, as_user, override_dep, youtube):
user = make_user()
_channel(db)
client = as_user(user)
override_dep(require_write_scope, lambda: user)
assert client.post("/api/channels/UCc/subscribe").json() == {"ok": True, "subscribed": "UCc"}
assert _subscription(db, user).yt_subscription_id == "yt-sub-1"
def test_an_explore_through_the_route_is_recorded(db, make_user, as_user, youtube, monkeypatch):
user = make_user()
_channel(db)
# The ingest pages YouTube; what the route itself records is the exploration.
monkeypatch.setattr(channels, "details_stale", lambda ch, now: False)
monkeypatch.setattr(
channels,
"explore_ingest_page",
lambda db, yt, ch, token: {"ingested": 0, "next_page_token": None},
)
r = as_user(user).post("/api/channels/UCc/explore")
assert r.status_code == 200, r.text
db.expire_all()
assert db.execute(select(ExploredChannel.user_id)).scalars().all() == [user.id]
assert db.get(Channel, "UCc").from_explore is True
def test_an_unsubscribe_through_the_route_drops_the_subscription(
db, make_user, as_user, override_dep, youtube
):
user = make_user()
_channel(db)
db.add(Subscription(user_id=user.id, channel_id="UCc", yt_subscription_id="yt-sub-1"))
db.commit()
client = as_user(user)
override_dep(require_write_scope, lambda: user)
r = client.delete("/api/channels/UCc/subscription")
assert r.json() == {"ok": True, "unsubscribed": "UCc"}
assert _subscription(db, user) is None
def test_a_backfill_reset_through_the_route_is_kept(db, make_user, as_user, monkeypatch):
admin, reader = make_user(role="admin"), make_user()
_channel(db, backfill_done=True, backfill_cursor="page-9", recent_synced_at=NOW)
db.add(Subscription(user_id=reader.id, channel_id="UCc", deep_requested=False))
db.commit()
# The recent pull afterwards reaches YouTube; the reset is the route's own write.
monkeypatch.setattr(channels, "run_recent_backfill", lambda *a, **kw: None)
r = as_user(admin).post("/api/channels/UCc/reset-backfill")
assert r.status_code == 200, r.text
db.expire_all()
ch = db.get(Channel, "UCc")
assert (ch.backfill_done, ch.backfill_cursor, ch.recent_synced_at) == (False, None, None)
assert _subscription(db, reader).deep_requested is True
# --- live search ------------------------------------------------------------------------------
def test_search_results_cleared_through_the_route_are_gone(db, make_user, as_user):
user = make_user()
_video(db, "s1", via_search=True)
db.add(SearchFind(user_id=user.id, video_id="s1"))
db.commit()
r = as_user(user).post("/api/search/clear", json={"video_ids": ["s1"]})
assert r.json() == {"ok": True, "removed": 1}
db.expire_all()
assert db.get(Video, "s1") is None
assert db.execute(select(SearchFind)).first() is None
# --- Plex show and season marks ---------------------------------------------------------------
def _show(db):
lib = PlexLibrary(plex_key="2", title="Shows", kind="show", enabled=True)
db.add(lib)
db.flush()
sh = PlexShow(rating_key="sh1", library_id=lib.id, title="Show")
db.add(sh)
db.flush()
se = PlexSeason(rating_key="se1", show_id=sh.id, season_number=1)
db.add(se)
db.flush()
for n in (1, 2):
db.add(
PlexItem(
rating_key=f"e{n}",
library_id=lib.id,
kind="episode",
title=f"E{n}",
show_id=sh.id,
season_id=se.id,
season_number=1,
episode_number=n,
)
)
db.commit()
def _plex_marks(db, user):
db.expire_all()
return sorted(
db.execute(
select(PlexItem.rating_key, PlexState.status)
.join(PlexState, PlexState.item_id == PlexItem.id)
.where(PlexState.user_id == user.id)
).all()
)
def test_a_show_marked_watched_through_the_route_stays_watched(db, make_user, as_user):
user = make_user()
_show(db)
r = as_user(user).post("/api/plex/show/sh1/state", json={"watched": True})
assert r.json() == {"ok": True, "changed": 2, "watched": True}
assert _plex_marks(db, user) == [("e1", "watched"), ("e2", "watched")]
def test_a_season_marked_watched_through_the_route_stays_watched(db, make_user, as_user):
user = make_user()
_show(db)
r = as_user(user).post("/api/plex/season/se1/state", json={"watched": True})
assert r.json() == {"ok": True, "changed": 2, "watched": True}
assert _plex_marks(db, user) == [("e1", "watched"), ("e2", "watched")]
def test_reading_one_notice_is_committed_with_its_trim(db, make_user, as_user):
"""Marking a notice read and trimming the read ones past the cap are one write the handler
commits — the trim no longer commits on its own."""
user = make_user()
n = Notification(user_id=user.id, type="t", title="x")
db.add(n)
db.commit()
r = as_user(user).post(f"/api/me/notifications/{n.id}/read")
assert r.json() == {"ok": True}
db.expire_all()
assert db.get(Notification, n.id).read is True