fix(files): a file is added when it is done, not when it was asked for
A download that failed for days and then went through sat days down the library, under its request date, and read as missing. A job now records when it became done (`ready_at`), stamped where the expiry already is (`folders.stamp_done`, `finalize_if_running`) and on an insert that is already done; a check constraint holds every path to done to it. The library sorts and dates its "Added" by it; the queue keeps the request order. The migration dates a file from before by its "ready to watch" notice where the inbox still holds one, else by its request date, where it already sat.
This commit is contained in:
@@ -374,7 +374,7 @@ def test_a_clip_is_not_redownloadable(db, make_user, as_user, yt_video):
|
||||
c = as_user(make_user())
|
||||
job = _clip(c, source="youtube", ref=yt_video).json()
|
||||
row = db.get(DownloadJob, job["id"])
|
||||
row.status = "done"
|
||||
row.status, row.ready_at = "done", row.created_at
|
||||
db.commit()
|
||||
r = c.post(f"/api/downloads/{job['id']}/redownload")
|
||||
assert (r.status_code, r.json()["detail"]["reason"]) == (409, "not_redownloadable")
|
||||
|
||||
@@ -59,7 +59,7 @@ def _file(db, user_id, *, kind="youtube", ref=None, title="A title", size=10, re
|
||||
db.add(job)
|
||||
db.flush()
|
||||
folders.place(db, job)
|
||||
folders.stamp_expiry(db, job)
|
||||
folders.stamp_done(db, job)
|
||||
service.incr_ref_count(db, asset.id)
|
||||
db.commit()
|
||||
return job
|
||||
@@ -245,6 +245,38 @@ def test_the_list_sorts_by_name_size_or_when_added(db, make_user, as_user):
|
||||
assert ids(sort="size", order="desc") == [b.id, z.id, a.id]
|
||||
|
||||
|
||||
def test_a_file_is_added_when_it_is_done_not_when_it_was_asked_for(db, make_user, as_user):
|
||||
"""A download that failed for days and then went through is new: it lists first, dated today."""
|
||||
alice = make_user()
|
||||
early = _file(db, alice.id, created_at=NOW - timedelta(days=1))
|
||||
early.ready_at = NOW - timedelta(days=1)
|
||||
late = _file(db, alice.id, status=JobStatus.RUNNING, created_at=NOW - timedelta(days=5))
|
||||
late.ready_at = None
|
||||
db.commit()
|
||||
assert service.finalize_if_running(db, late.id, status=JobStatus.DONE)
|
||||
db.commit()
|
||||
db.expire_all() # the finalize is a Core UPDATE; the route reads this same session
|
||||
got = as_user(alice).get("/api/files").json()
|
||||
assert [f["id"] for f in got] == [late.id, early.id]
|
||||
ready = datetime.fromisoformat(got[0]["ready_at"])
|
||||
assert ready > NOW - timedelta(minutes=5)
|
||||
assert datetime.fromisoformat(got[0]["created_at"]) < NOW - timedelta(days=4)
|
||||
|
||||
|
||||
def test_a_job_made_done_is_ready_as_it_is_made(db, make_user):
|
||||
# An upload kept as it came is inserted done; the database refuses a done job without the moment.
|
||||
job = DownloadJob(
|
||||
user_id=make_user().id,
|
||||
source_kind="upload",
|
||||
source_ref="r",
|
||||
profile_snapshot={},
|
||||
status=JobStatus.DONE,
|
||||
)
|
||||
db.add(job)
|
||||
db.commit()
|
||||
assert job.ready_at is not None
|
||||
|
||||
|
||||
def test_shared_with_me_lists_what_others_shared_and_nothing_else(db, make_user, as_user):
|
||||
alice, bob, carol = make_user(), make_user(), make_user()
|
||||
theirs = _file(db, bob.id)
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
"""Migration 0104's data step, DB-lane: a done file from before is dated by when it finished where
|
||||
its "ready to watch" notice says so, else by when it was asked for; a job not done has no date."""
|
||||
|
||||
from alembic import command
|
||||
from alembic.config import Config
|
||||
from sqlalchemy import text
|
||||
|
||||
from app.db import engine
|
||||
|
||||
|
||||
def test_a_done_file_takes_its_notice_time_else_its_request_time(db, make_user):
|
||||
uid = make_user().id
|
||||
cfg = Config("alembic.ini")
|
||||
command.downgrade(cfg, "0103_files_sha256")
|
||||
try:
|
||||
with engine.begin() as conn:
|
||||
|
||||
def job(status, created):
|
||||
return conn.execute(
|
||||
text(
|
||||
"INSERT INTO download_jobs (user_id, source_kind, source_ref,"
|
||||
" profile_snapshot, status, created_at) VALUES (:u, 'url', 'x', '{}', :s,"
|
||||
" CAST(:c AS timestamptz)) RETURNING id"
|
||||
),
|
||||
{"u": uid, "s": status, "c": created},
|
||||
).scalar_one()
|
||||
|
||||
retried = job("done", "2026-10-01T10:00:00+00:00")
|
||||
quiet = job("done", "2026-10-02T10:00:00+00:00")
|
||||
failed = job("error", "2026-10-03T10:00:00+00:00")
|
||||
for at in ("2026-10-05T09:00:00+00:00", "2026-10-06T09:00:00+00:00"):
|
||||
conn.execute(
|
||||
text(
|
||||
"INSERT INTO notifications (user_id, type, title, data, created_at) VALUES"
|
||||
" (:u, 'download_done', 't', CAST(:d AS json), CAST(:c AS timestamptz))"
|
||||
),
|
||||
{"u": uid, "d": f'{{"job_id": {retried}}}', "c": at},
|
||||
)
|
||||
finally:
|
||||
command.upgrade(cfg, "head")
|
||||
|
||||
with engine.connect() as conn:
|
||||
rows = dict(
|
||||
conn.execute(
|
||||
text(
|
||||
"SELECT id, to_char(ready_at AT TIME ZONE 'UTC', 'MM-DD HH24') FROM download_jobs"
|
||||
)
|
||||
).all()
|
||||
)
|
||||
assert rows[retried] == "10-06 09" # its last finish
|
||||
assert rows[quiet] == "10-02 10" # no notice left: where it already sat
|
||||
assert rows[failed] is None
|
||||
@@ -320,7 +320,7 @@ def test_upload_route_media_html_extension_not_served_as_html(
|
||||
assert job["status"] == "queued"
|
||||
row = db.get(DownloadJob, job["id"])
|
||||
db.get(MediaAsset, row.asset_id).status = "ready"
|
||||
row.status = "done"
|
||||
row.status, row.ready_at = "done", row.created_at
|
||||
db.commit()
|
||||
|
||||
# Authenticated endpoint: never text/html.
|
||||
|
||||
@@ -407,7 +407,7 @@ def test_the_sweep_keeps_an_aged_upload_file_its_row_names_in_any_state(
|
||||
_probe(monkeypatch)
|
||||
job, asset = _ingest(db, _root, make_user().id)
|
||||
path = storage.abs_path(str(_root), asset.rel_path)
|
||||
job.status, asset.status = job_status, asset_status
|
||||
job.status, asset.status, job.ready_at = job_status, asset_status, job.created_at
|
||||
db.commit()
|
||||
_aged(path)
|
||||
|
||||
@@ -427,7 +427,11 @@ def _route_moved(db, job, asset, job_status: str, ref_count: int) -> None:
|
||||
"""What the cancel/pause/delete route leaves behind, written as SQL: the session's copy of
|
||||
`ref_count` is stale (the routes count it in SQL), so an ORM assignment of the same value would
|
||||
write nothing."""
|
||||
db.execute(update(DownloadJob).where(DownloadJob.id == job.id).values(status=job_status))
|
||||
db.execute(
|
||||
update(DownloadJob)
|
||||
.where(DownloadJob.id == job.id)
|
||||
.values(status=job_status, ready_at=DownloadJob.created_at)
|
||||
)
|
||||
db.execute(update(MediaAsset).where(MediaAsset.id == asset.id).values(ref_count=ref_count))
|
||||
db.commit()
|
||||
|
||||
@@ -609,6 +613,7 @@ def test_the_admin_storage_view_names_each_users_quota_and_files(
|
||||
done, _ = _ingest(db, _root, u.id, size=10)
|
||||
waiting, _ = _ingest(db, _root, u.id, name="b.mov", size=5)
|
||||
done.status, waiting.status = "done", "queued"
|
||||
done.ready_at = done.created_at
|
||||
db.commit()
|
||||
admin = as_user(make_user(role="admin"))
|
||||
put = admin.put(f"/api/admin/downloads/quota/{u.id}", json={"max_bytes": 1000})
|
||||
|
||||
Reference in New Issue
Block a user