"""Capture's service and queries, one use case each, below the HTTP layer. The routes are thin callers now, and their own tests stay the contract a client sees. These hold what each write does and leaves on its own: that it flushes and leaves the commit to its caller, that a row of someone else's reads as a missing one, and the rule each write carries (a hash kept once, a password change that retires the old grants, a quota floored). DB-backed (real Postgres). """ import pytest from sqlalchemy import select from app.audit import AuditAction from app.downloads import folders, links, queries, quota, service from app.downloads.models import ( DownloadJob, DownloadLink, DownloadQuota, DownloadShare, FileFolder, LibraryState, MediaAsset, ) from app.models import AuditLog, Notification from app.userscope import owned_by, owned_row def _file(db, user, *, sha256=None) -> DownloadJob: asset = MediaAsset( source_kind="url", source_ref=f"https://example.com/{user.id}.mp4", format_sig=f"sig-{user.id}", status="ready", rel_path=f"f-{user.id}.mp4", ref_count=1, media_kind="video", container="mp4", sha256=sha256, ) db.add(asset) db.flush() job = DownloadJob( user_id=user.id, asset_id=asset.id, source_kind="url", source_ref=asset.source_ref, status="done", profile_snapshot={}, display_name="Clip", ) db.add(job) db.commit() return job def _uncommitted(db): """What the caller still has to commit: the session holds pending work.""" return bool(db.new or db.dirty or db.deleted) or db.in_transaction() # --- whose a row is ------------------------------------------------------------------------ def test_a_row_of_someone_elses_reads_as_a_missing_one(db, make_user): owner, stranger = make_user(), make_user() job = _file(db, owner) assert owned_row(db, DownloadJob, job.id, owner) is job assert owned_row(db, DownloadJob, job.id, stranger) is None assert owned_row(db, DownloadJob, job.id + 1000, owner) is None mine = db.execute(select(DownloadJob.id).where(owned_by(DownloadJob.user_id, owner.id))) assert mine.scalars().all() == [job.id] def test_a_file_is_readable_by_its_owner_and_whoever_it_was_shared_with(db, make_user): owner, friend, stranger = make_user(), make_user(), make_user() job = _file(db, owner) assert service.share(db, job, owner, friend) is True db.commit() assert queries.accessible_job(db, owner, job.id) is job assert queries.accessible_job(db, friend, job.id) is job assert queries.accessible_job(db, stranger, job.id) is None assert queries.own_job(db, friend, job.id) is None # shared is not owned def test_the_tray_and_the_library_list_only_the_callers_own_files(db, make_user): from datetime import datetime, timedelta, timezone mine, theirs = make_user(), make_user() own, other = _file(db, mine), _file(db, theirs) since = datetime.now(timezone.utc) - timedelta(days=1) for job in (own, other): job.ready_at = datetime.now(timezone.utc) db.commit() assert [j.id for j in queries.queue(db, mine, since)] == [own.id] library = queries.library( db, mine, folder_id=None, listable=None, shared=False, term="", sort="added", order="desc", lang="en", ) assert [j.id for j in library] == [own.id] assert [r for r, _ in queries.statuses_by_source(db, mine, ["done"])] == [own.source_ref] # --- locks ----------------------------------------------------------------------------------- def test_the_job_lock_answers_none_for_a_job_not_the_callers(db, make_user): owner, stranger = make_user(), make_user() job = _file(db, owner) assert service.lock_own_job(db, stranger, job.id) is None assert service.lock_own_job(db, owner, job.id) is job assert service.hold_for_share(db, stranger, job.id) is None assert service.hold_for_share(db, owner, job.id) is job # --- profiles -------------------------------------------------------------------------------- def test_a_profile_is_made_after_the_users_others_and_only_theirs_to_change(db, make_user): user, other = make_user(), make_user() first = service.create_profile(db, user, "x" * 100, {"kind": "video"}) second = service.create_profile(db, user, "Audio", {"kind": "audio"}) assert _uncommitted(db) db.commit() assert first.name == "x" * 80 # names are capped assert second.sort_order == first.sort_order + 1 assert queries.own_profile(db, other, first.id) is None service.update_profile(first, name="Video", spec=None) db.commit() assert (first.name, first.spec) == ("Video", {"kind": "video"}) service.delete_profile(db, first) db.commit() assert [p.id for p in queries.profiles_for(db, user)] == [second.id] # --- resume points --------------------------------------------------------------------------- def test_a_resume_point_is_one_row_per_user_and_file_and_a_zero_clears_it(db, make_user): user = make_user() job = _file(db, user) service.set_position(db, user, job.id, 40) service.set_position(db, user, job.id, 90) db.commit() rows = db.execute(select(LibraryState.position_seconds)).scalars().all() assert rows == [90] assert queries.positions_for(db, user, [job]) == {job.id: 90} service.set_position(db, user, job.id, 0) service.set_position(db, user, job.id, 0) # the beacon's second clear deletes nothing db.commit() assert db.execute(select(LibraryState)).first() is None # --- shares ---------------------------------------------------------------------------------- def test_a_share_is_made_once_with_one_notice_and_undone_by_either_side(db, make_user): owner, friend = make_user(), make_user() job = _file(db, owner) assert service.share(db, job, owner, friend) is True assert service.share(db, job, owner, friend) is False assert _uncommitted(db) db.commit() notices = db.execute(select(Notification).where(Notification.user_id == friend.id)).scalars() assert [n.data["job_id"] for n in notices] == [job.id] assert service.unshare(db, job.id, friend) is True assert service.unshare(db, job.id, friend) is False db.commit() assert db.execute(select(DownloadShare)).first() is None # --- a file's display fields, its access clock, its hash ------------------------------------ def test_only_display_fields_can_be_set_on_a_file(db, make_user): job = _file(db, make_user()) service.set_display(job, display_name="New", extra_links=None) assert job.display_name == "New" with pytest.raises(ValueError): service.set_display(job, status="error") assert job.status == "done" def test_a_hash_is_kept_once_and_never_overwritten(db, make_user): job = _file(db, make_user()) service.store_sha256(db, job.asset_id, "a" * 64) service.store_sha256(db, job.asset_id, "b" * 64) db.commit() assert db.get(MediaAsset, job.asset_id).sha256 == "a" * 64 def test_serving_a_file_restarts_its_idle_clock(db, make_user): job = _file(db, make_user()) asset = queries.asset_of(db, job) before = asset.last_access_at service.touch(asset) assert asset.last_access_at is not None and asset.last_access_at != before # --- links ----------------------------------------------------------------------------------- def _actions(db) -> list[str]: return db.execute(select(AuditLog.action).order_by(AuditLog.id)).scalars().all() def test_a_link_is_made_changed_and_revoked_with_an_audit_row_each(db, make_user): user = make_user() job = _file(db, user) link, token = links.create( db, user, password="pw", allow_download=False, expires_days=3, job_id=job.id ) db.commit() assert links.find(db, token) is link assert link.expires_at is not None and link.password_hash is not None version = link.password_version links.update(db, user, link, password="other", sent=frozenset({"password"})) db.commit() assert link.password_version == version + 1 # the old grants stop working links.update(db, user, link, allow_download=True, sent=frozenset()) # nothing was sent assert link.allow_download is False assert [link.id for link in queries.links_to(db, job_id=job.id)] == [link.id] links.revoke(db, user, link) db.commit() assert db.execute(select(DownloadLink)).first() is None assert _actions(db) == [ AuditAction.DOWNLOAD_LINK_CREATE, AuditAction.DOWNLOAD_LINK_UPDATE, AuditAction.DOWNLOAD_LINK_REVOKE, ] def test_a_link_is_the_callers_only_through_a_file_or_folder_of_theirs(db, make_user): owner, stranger = make_user(), make_user() job = _file(db, owner) link, _ = links.create( db, owner, password="", allow_download=True, expires_days=None, job_id=job.id ) db.commit() assert queries.own_link(db, owner, link.id) is link assert queries.own_link(db, stranger, link.id) is None def test_a_view_is_counted_in_sql(db, make_user): user = make_user() job = _file(db, user) link, _ = links.create( db, user, password="", allow_download=True, expires_days=None, job_id=job.id ) db.commit() links.count_view(db, link) links.count_view(db, link) db.commit() db.expire_all() assert db.get(DownloadLink, link.id).view_count == 2 # --- folders --------------------------------------------------------------------------------- def test_a_folder_name_is_one_per_user_and_a_source_folder_keeps_its_own(db, make_user): user, other = make_user(), make_user() mine = folders.create_own(db, user.id, "Trips") with pytest.raises(folders.FolderError, match="file_folder_name_taken"): folders.create_own(db, user.id, "trips") folders.create_own(db, other.id, "Trips") # another user's namesake is theirs db.commit() source = folders.source_folder(db, user.id, folders.SourceKey.UPLOAD) with pytest.raises(folders.FolderError, match="file_folder_not_own"): folders.rename(db, source, "Mine now") folders.rename(db, mine, "Holidays") db.commit() assert db.get(FileFolder, mine.id).name == "Holidays" assert queries.own_folder(db, other, mine.id) is None # --- quota ----------------------------------------------------------------------------------- def test_a_custom_quota_starts_from_what_applies_and_floors_each_value(db, make_user): user = make_user() defaults = quota.resolve(db, user.id) quota.set_custom(db, user.id, max_concurrent=0, max_bytes=-5) db.commit() row = queries.custom_quota(db, user.id) assert (row.max_concurrent, row.max_bytes, row.max_jobs) == (1, 0, defaults.max_jobs) assert quota.reset_custom(db, user.id) is True assert quota.reset_custom(db, user.id) is False db.commit() assert db.get(DownloadQuota, user.id) is None