Files is the one library: every done file, the ones shared with me, or one folder's, picked in a side rail (sources, own folders, the storage meter) and mirrored to ?folder=. One table with every kind of value in its own column; Copy link, Share and Save in sight, every action on a right click: play, cut, rename in the row, move to an own folder or back home, keep, details, re-download, delete. The Downloads page keeps only the queue until the tray replaces it. The old /api/downloads library routes are gone: list shared, upload, the details PATCH, delete, the file and the usage now live under /api/files, and a listed file says which source it came from. The job's "expired" flag is gone with the state it described.
97 lines
3.6 KiB
Python
97 lines
3.6 KiB
Python
"""Characterization net — download job state transitions.
|
|
|
|
pause / resume / cancel / delete / redownload currently live inline in the routes, each hand-rolling
|
|
the asset bookkeeping (release, error-reset, fresh-asset rebuild). This pins their OBSERVABLE HTTP
|
|
behaviour so the logic can move into `service.*` methods (with the routes as thin callers) provably
|
|
behaviour-equivalently. DB lane (`db` fixture) — skipped without Postgres.
|
|
"""
|
|
|
|
from sqlalchemy import text
|
|
|
|
from app.models import DownloadJob, MediaAsset, User
|
|
|
|
|
|
def _seed(db, *, job_status, asset_status="ready", ref_count=1, rel_path=None):
|
|
u = User(email=f"tr-{job_status}-{asset_status}-{ref_count}@example.com")
|
|
db.add(u)
|
|
db.flush()
|
|
asset = MediaAsset(
|
|
source_kind="url",
|
|
source_ref=f"r-{u.id}",
|
|
format_sig="s",
|
|
status=asset_status,
|
|
ref_count=ref_count,
|
|
rel_path=rel_path,
|
|
)
|
|
db.add(asset)
|
|
db.flush()
|
|
job = DownloadJob(
|
|
user_id=u.id,
|
|
asset_id=asset.id,
|
|
source_kind="url",
|
|
source_ref="r",
|
|
status=job_status,
|
|
queue_pos=1,
|
|
profile_snapshot={},
|
|
)
|
|
db.add(job)
|
|
db.commit()
|
|
return u, asset, job
|
|
|
|
|
|
def _ref(db, asset_id):
|
|
return db.execute(
|
|
text("SELECT ref_count FROM media_assets WHERE id = :id"), {"id": asset_id}
|
|
).scalar()
|
|
|
|
|
|
def test_cancel_releases_the_asset_and_marks_canceled(db, as_user):
|
|
u, asset, job = _seed(db, job_status="running", asset_status="downloading", ref_count=1)
|
|
r = as_user(u).post(f"/api/downloads/{job.id}/cancel")
|
|
assert r.status_code == 200, r.text
|
|
db.expire_all()
|
|
assert db.get(DownloadJob, job.id).status == "canceled"
|
|
assert _ref(db, asset.id) == 0 # the hold was released while the job still counted as holding
|
|
|
|
|
|
def test_pause_then_resume_round_trips_queued(db, as_user):
|
|
u, asset, job = _seed(db, job_status="queued", asset_status="pending")
|
|
c = as_user(u)
|
|
assert c.post(f"/api/downloads/{job.id}/pause").status_code == 200
|
|
db.expire_all()
|
|
assert db.get(DownloadJob, job.id).status == "paused"
|
|
assert c.post(f"/api/downloads/{job.id}/resume").status_code == 200
|
|
db.expire_all()
|
|
assert db.get(DownloadJob, job.id).status == "queued"
|
|
|
|
|
|
def test_resume_resets_a_failed_shared_asset_to_pending(db, as_user):
|
|
u, asset, job = _seed(db, job_status="error", asset_status="error")
|
|
r = as_user(u).post(f"/api/downloads/{job.id}/resume")
|
|
assert r.status_code == 200, r.text
|
|
db.expire_all()
|
|
assert db.get(DownloadJob, job.id).status == "queued"
|
|
# A failed shared asset is reset so the worker actually re-downloads instead of re-inheriting the error.
|
|
assert db.get(MediaAsset, asset.id).status == "pending"
|
|
|
|
|
|
def test_delete_releases_and_removes_the_job(db, as_user):
|
|
u, asset, job = _seed(db, job_status="done", asset_status="ready", ref_count=1, rel_path=None)
|
|
r = as_user(u).request("DELETE", f"/api/files/{job.id}")
|
|
assert r.status_code == 200, r.text
|
|
db.expire_all()
|
|
assert db.get(DownloadJob, job.id) is None
|
|
assert db.get(MediaAsset, asset.id) is None # last holder gone → ready asset freed
|
|
|
|
|
|
def test_redownload_rebuilds_into_a_fresh_asset(db, as_user):
|
|
u, asset, job = _seed(db, job_status="done", asset_status="ready", ref_count=1, rel_path=None)
|
|
old_asset_id = asset.id
|
|
r = as_user(u).post(f"/api/downloads/{job.id}/redownload")
|
|
assert r.status_code == 200, r.text
|
|
db.expire_all()
|
|
j = db.get(DownloadJob, job.id)
|
|
assert j.status == "queued"
|
|
assert j.progress == 0
|
|
assert j.asset_id != old_asset_id # a fresh private asset, not the old (now-released) one
|