fix(files): review round 1, the server — the tray's window, the GC's locks, a file's origin, the upgrade path
- The queue keeps a done job by when it became done (ready_at), not by its last write: renaming, keeping or moving an old file, or the GC's expiry warning, no longer brings it back as just finished. - GC expiry and eviction re-read each file under the asset→job locks the user routes take, and commit one file at a time: a delete, a re-download or a keep made while the GC runs is honoured, never a second release that frees a file another user still holds. - A file records the origin it was placed under (download_jobs.source_key, migration 0105): an edit whose parent expired still goes back to its folder, not to Uploads. - A folder a file is being put in is held (FOR SHARE) until it lands; a delete of it waits and takes that file home too, instead of failing on the foreign key. - The hash route reads at most two files at once (429 past that) and holds no connection meanwhile. - 0104's backfill dates old files by their request date only: no notice prod wrote names its job. - The old DOWNLOAD_ROOT still finds the files; a files root that is not a mounted volume stops the worker and is logged by the API; the volume-copy recipe looks the project-prefixed names up. - Stale comments about the /downloads mount and the Downloads page.
This commit is contained in:
@@ -15,6 +15,7 @@ from app import sysconfig
|
||||
from app.config import settings
|
||||
from app.downloads import folders, formats, service
|
||||
from app.downloads import gc as gcmod
|
||||
from app.db import SessionLocal
|
||||
from app.downloads.status import JobStatus
|
||||
from app.models import DownloadJob, FileFolder, MediaAsset, Notification
|
||||
|
||||
@@ -123,6 +124,68 @@ def test_an_edit_whose_parent_is_gone_follows_the_parents_asset(db, make_user):
|
||||
assert folders.source_key_for(db, edit) == "site:vimeo.com"
|
||||
|
||||
|
||||
def test_an_edit_keeps_its_origin_after_its_parent_and_its_file_are_gone(db, make_user):
|
||||
# The parent download expires, its file goes with its last holder, and both of the edit's
|
||||
# pointers to it are cleared: the edit must still go back to the YouTube folder it came from.
|
||||
uid = make_user().id
|
||||
parent = _job(db, uid, _asset(db))
|
||||
edit = _job(
|
||||
db, uid, _asset(db, "edit", "e1"), kind="edit", source_job_id=parent.id,
|
||||
source_asset_id=parent.asset_id,
|
||||
) # fmt: skip
|
||||
home = _folder(db, edit)
|
||||
own = FileFolder(user_id=uid, kind=folders.FolderKind.OWN, name="Mine")
|
||||
db.add(own)
|
||||
db.flush()
|
||||
folders.move(db, edit, own)
|
||||
edit.source_job_id = edit.source_asset_id = None # what ON DELETE SET NULL leaves
|
||||
db.delete(parent)
|
||||
db.commit()
|
||||
folders.move(db, edit, home)
|
||||
assert edit.folder_id == home.id
|
||||
folders.move(db, edit, own)
|
||||
folders.delete_own(db, own)
|
||||
assert edit.folder_id == home.id
|
||||
|
||||
|
||||
def test_a_folder_deleted_while_a_file_goes_in_takes_that_file_home_too(db, make_user):
|
||||
# Before, the delete missed the file still on its way in and failed on the foreign key (a 500),
|
||||
# or the file's insert did after the folder went.
|
||||
import threading
|
||||
|
||||
uid = make_user().id
|
||||
own = FileFolder(user_id=uid, kind=folders.FolderKind.OWN, name="Mine")
|
||||
db.add(own)
|
||||
db.commit()
|
||||
own_id = own.id
|
||||
job = _job(db, uid, _asset(db))
|
||||
home = job.folder_id
|
||||
target = folders.own_folder(db, uid, own_id) # held from the check until this commits
|
||||
outcome: list[object] = []
|
||||
|
||||
def delete_it():
|
||||
other = SessionLocal()
|
||||
try:
|
||||
outcome.append(folders.delete_own(other, other.get(FileFolder, own_id)))
|
||||
other.commit()
|
||||
except Exception as e: # noqa: BLE001 — the assertion below names what went wrong
|
||||
outcome.append(e)
|
||||
finally:
|
||||
other.close()
|
||||
|
||||
t = threading.Thread(target=delete_it)
|
||||
t.start()
|
||||
t.join(timeout=1)
|
||||
assert t.is_alive() # the delete waits for the file, even before it is written
|
||||
folders.move(db, job, target)
|
||||
db.commit()
|
||||
t.join(timeout=10)
|
||||
assert outcome == [1]
|
||||
db.expire_all()
|
||||
assert db.get(DownloadJob, job.id).folder_id == home
|
||||
assert db.get(FileFolder, own_id) is None
|
||||
|
||||
|
||||
def test_folders_are_per_user(db, make_user):
|
||||
a, b = make_user().id, make_user().id
|
||||
asset = _asset(db)
|
||||
@@ -201,6 +264,30 @@ def test_the_gc_warns_once_then_takes_the_file_out_of_the_library(db, make_user,
|
||||
assert db.execute(select(func.count()).select_from(MediaAsset)).scalar() == 0
|
||||
|
||||
|
||||
@pytest.mark.parametrize("restart", ["unkeep", "move", "retention"])
|
||||
def test_a_file_whose_clock_starts_again_is_warned_again(db, make_user, _root, restart):
|
||||
# The GC warns once per expiry; a file whose expiry was moved must be warned before the new one.
|
||||
(_root / "a.mp4").write_bytes(b"x")
|
||||
uid = make_user().id
|
||||
job = _job(db, uid, _asset(db), expires_at=NOW + timedelta(hours=12))
|
||||
_gc(db)
|
||||
assert job.expiry_warned
|
||||
if restart == "unkeep":
|
||||
folders.set_kept(db, job, True)
|
||||
folders.set_kept(db, job, False)
|
||||
elif restart == "move":
|
||||
home = _folder(db, job)
|
||||
own = FileFolder(user_id=uid, kind=folders.FolderKind.OWN, name="Mine")
|
||||
db.add(own)
|
||||
db.flush()
|
||||
folders.move(db, job, own)
|
||||
folders.move(db, job, home)
|
||||
else:
|
||||
folders.set_retention(db, _folder(db, job), 90)
|
||||
db.commit()
|
||||
assert not job.expiry_warned
|
||||
|
||||
|
||||
def test_the_gc_never_takes_a_kept_file(db, make_user, _root):
|
||||
(_root / "a.mp4").write_bytes(b"x")
|
||||
uid = make_user().id
|
||||
@@ -221,6 +308,52 @@ def test_one_users_expiry_leaves_another_users_copy(db, make_user, _root):
|
||||
assert (_root / "a.mp4").exists()
|
||||
|
||||
|
||||
def _meanwhile(monkeypatch, act):
|
||||
"""Run `act` in another session (another request) between the GC's expiry SELECT and its
|
||||
handling of each file it found — the window a user's click can land in."""
|
||||
real = gcmod._relock_expired
|
||||
|
||||
def relock(db, job_id, asset_id, now):
|
||||
other = SessionLocal()
|
||||
try:
|
||||
act(other, job_id)
|
||||
other.commit()
|
||||
finally:
|
||||
other.close()
|
||||
return real(db, job_id, asset_id, now)
|
||||
|
||||
monkeypatch.setattr(gcmod, "_relock_expired", relock)
|
||||
|
||||
|
||||
def test_a_file_kept_while_the_gc_runs_stays(db, make_user, _root, monkeypatch):
|
||||
(_root / "a.mp4").write_bytes(b"x")
|
||||
job = _job(db, make_user().id, _asset(db), expires_at=NOW - timedelta(minutes=1))
|
||||
_meanwhile(monkeypatch, lambda other, jid: setattr(other.get(DownloadJob, jid), "kept", True))
|
||||
assert _gc(db)["expired"] == 0
|
||||
db.expire_all()
|
||||
assert db.get(DownloadJob, job.id) is not None
|
||||
assert (_root / "a.mp4").exists()
|
||||
|
||||
|
||||
def test_a_file_deleted_while_the_gc_runs_is_released_once(db, make_user, _root, monkeypatch):
|
||||
# Released twice, the shared file would go from under the other user's copy.
|
||||
(_root / "a.mp4").write_bytes(b"x")
|
||||
a, b = make_user().id, make_user().id
|
||||
asset = _asset(db)
|
||||
_job(db, a, asset, expires_at=NOW - timedelta(minutes=1))
|
||||
stays = _job(db, b, asset, expires_at=NOW + timedelta(days=20))
|
||||
|
||||
def user_deletes(other, jid):
|
||||
service.delete(other, other.get(DownloadJob, jid))
|
||||
|
||||
_meanwhile(monkeypatch, user_deletes)
|
||||
_gc(db)
|
||||
db.expire_all()
|
||||
assert db.get(DownloadJob, stays.id).asset_id == asset.id
|
||||
assert db.get(MediaAsset, asset.id).ref_count == 1
|
||||
assert (_root / "a.mp4").exists()
|
||||
|
||||
|
||||
def test_the_cache_cap_never_evicts_a_kept_file_and_an_evicted_one_leaves_every_library(
|
||||
db, make_user, _root
|
||||
):
|
||||
|
||||
@@ -421,6 +421,21 @@ def test_a_deleted_file_leaves_the_library_and_the_log_says_so(db, make_user, as
|
||||
assert (row.actor_id, row.target_id) == (alice.id, str(job.id))
|
||||
|
||||
|
||||
def test_a_file_shared_with_someone_is_theirs_to_watch_not_to_change(db, make_user, as_user, _root):
|
||||
# The read routes let a share's recipient in; the write routes must not.
|
||||
alice, friend = make_user(), make_user()
|
||||
job = _file(db, alice.id, title="Mine")
|
||||
db.add(DownloadShare(job_id=job.id, from_user_id=alice.id, to_user_id=friend.id))
|
||||
db.commit()
|
||||
c = as_user(friend)
|
||||
assert c.patch(f"/api/files/{job.id}", json={"name": "Theirs"}).status_code == 404
|
||||
assert c.patch(f"/api/files/{job.id}", json={"kept": True}).status_code == 404
|
||||
assert c.delete(f"/api/files/{job.id}").status_code == 404
|
||||
db.expire_all()
|
||||
still = db.get(DownloadJob, job.id)
|
||||
assert still is not None and still.display_name is None and not still.kept
|
||||
|
||||
|
||||
# --- upload, the file, its hash ------------------------------------------------------------
|
||||
|
||||
|
||||
@@ -532,6 +547,26 @@ def test_the_file_carries_its_hash_once_known_and_the_hash_is_taken_on_first_ask
|
||||
assert as_user(make_user()).get(f"/api/files/{job.id}/sha256").status_code == 404
|
||||
|
||||
|
||||
def test_only_a_few_files_are_hashed_at_once(db, make_user, as_user, _root):
|
||||
# Each hash holds a thread of the pool every sync route shares for the whole read; a caller
|
||||
# past the few that may run is told to come back, not queued behind them.
|
||||
from app.routes import files as filesroute
|
||||
|
||||
alice = make_user()
|
||||
job = _file(db, alice.id, rel="v.mp4")
|
||||
(_root / "v.mp4").write_bytes(b"bytes")
|
||||
held = [filesroute._HASHING.acquire(blocking=False) for _ in range(filesroute._HASHERS)]
|
||||
try:
|
||||
assert all(held)
|
||||
busy = as_user(alice).get(f"/api/files/{job.id}/sha256")
|
||||
finally:
|
||||
for _ in held:
|
||||
filesroute._HASHING.release()
|
||||
assert busy.status_code == 429
|
||||
assert busy.json()["detail"]["reason"] == "file_hash_busy"
|
||||
assert as_user(alice).get(f"/api/files/{job.id}/sha256").status_code == 200
|
||||
|
||||
|
||||
def test_a_new_file_at_an_assets_path_forgets_the_old_hash(db, make_user):
|
||||
job = _file(db, make_user().id)
|
||||
asset = db.get(MediaAsset, job.asset_id)
|
||||
@@ -562,7 +597,7 @@ def test_the_queue_is_what_is_in_flight_and_what_just_finished_not_the_library(
|
||||
db.execute(
|
||||
update(DownloadJob)
|
||||
.where(DownloadJob.id == job.id)
|
||||
.values(updated_at=NOW - timedelta(hours=1))
|
||||
.values(updated_at=NOW - timedelta(hours=1), ready_at=NOW - timedelta(hours=1))
|
||||
)
|
||||
db.commit()
|
||||
rows = as_user(user).get("/api/downloads").json()
|
||||
@@ -570,3 +605,22 @@ def test_the_queue_is_what_is_in_flight_and_what_just_finished_not_the_library(
|
||||
assert {row["id"] for row in rows} == {fresh.id, waiting.id, stuck.id, paused.id, stopped.id}
|
||||
# The tray names where each came from, as the library does.
|
||||
assert {row["source_key"] for row in rows} == {"youtube"}
|
||||
|
||||
|
||||
def test_an_old_file_written_to_does_not_come_back_as_just_finished(db, make_user, as_user):
|
||||
# Renaming, keeping or moving touches the row; the tray would announce the file as ready again.
|
||||
from sqlalchemy import update
|
||||
|
||||
user = make_user()
|
||||
old = _file(db, user.id, title="Old")
|
||||
db.execute(
|
||||
update(DownloadJob)
|
||||
.where(DownloadJob.id == old.id)
|
||||
.values(updated_at=NOW - timedelta(days=30), ready_at=NOW - timedelta(days=30))
|
||||
)
|
||||
db.commit()
|
||||
c = as_user(user)
|
||||
assert (
|
||||
c.patch(f"/api/files/{old.id}", json={"name": "Renamed", "kept": True}).status_code == 200
|
||||
)
|
||||
assert [row["id"] for row in c.get("/api/downloads").json()] == []
|
||||
|
||||
@@ -71,3 +71,71 @@ def test_existing_jobs_are_placed_and_keep_their_expiry(db, make_user):
|
||||
}
|
||||
# alice: youtube, tiktok, upload, plex; bob: youtube.
|
||||
assert folders == 5
|
||||
|
||||
|
||||
def test_the_admins_retention_is_kept_and_the_downgrade_gives_the_asset_back_its_expiry(
|
||||
db, make_user
|
||||
):
|
||||
alice, bob = make_user().id, make_user().id
|
||||
cfg = Config("alembic.ini")
|
||||
command.downgrade(cfg, "0101_plex_text_plain")
|
||||
try:
|
||||
with engine.begin() as conn:
|
||||
conn.execute(
|
||||
text(
|
||||
"INSERT INTO system_config (key, value) VALUES ('download_retention_days', '7')"
|
||||
" ON CONFLICT (key) DO UPDATE SET value = '7'"
|
||||
)
|
||||
)
|
||||
yt = conn.execute(
|
||||
text(
|
||||
"INSERT INTO media_assets (source_kind, source_ref, format_sig, status,"
|
||||
" ref_count, expires_at) VALUES ('youtube', 'abcdefghijk', 'sig', 'ready', 2,"
|
||||
" '2030-01-02T00:00:00+00:00') RETURNING id"
|
||||
)
|
||||
).scalar_one()
|
||||
for user in (alice, bob):
|
||||
conn.execute(
|
||||
text(
|
||||
"INSERT INTO download_jobs (user_id, asset_id, source_kind, source_ref,"
|
||||
" profile_snapshot, status) VALUES (:u, :a, 'youtube', 'abcdefghijk',"
|
||||
" '{}', 'done')"
|
||||
),
|
||||
{"u": user, "a": yt},
|
||||
)
|
||||
command.upgrade(cfg, "head")
|
||||
with engine.connect() as conn:
|
||||
retention = conn.execute(
|
||||
text(
|
||||
"SELECT DISTINCT retention_days FROM file_folders WHERE source_key = 'youtube'"
|
||||
)
|
||||
).all()
|
||||
|
||||
def asset_expiry_after_downgrade(alice_at, bob_at):
|
||||
with engine.begin() as conn:
|
||||
for user, at in ((alice, alice_at), (bob, bob_at)):
|
||||
conn.execute(
|
||||
text(
|
||||
"UPDATE download_jobs SET expires_at = CAST(:at AS timestamptz)"
|
||||
" WHERE asset_id = :a AND user_id = :u"
|
||||
),
|
||||
{"at": at, "a": yt, "u": user},
|
||||
)
|
||||
command.downgrade(cfg, "0101_plex_text_plain")
|
||||
with engine.connect() as conn:
|
||||
got = conn.execute(
|
||||
text("SELECT expires_at::date FROM media_assets WHERE id = :a"), {"a": yt}
|
||||
).scalar()
|
||||
command.upgrade(cfg, "head")
|
||||
return str(got) if got else None
|
||||
|
||||
# The shared file goes when its last holder's copy would; one holder keeping it keeps it.
|
||||
latest = asset_expiry_after_downgrade(
|
||||
"2030-02-01T00:00:00+00:00", "2030-03-01T00:00:00+00:00"
|
||||
)
|
||||
kept_by_one = asset_expiry_after_downgrade("2030-02-01T00:00:00+00:00", None)
|
||||
finally:
|
||||
command.upgrade(cfg, "head")
|
||||
assert retention == [(7,)]
|
||||
assert latest == "2030-03-01"
|
||||
assert kept_by_one is None
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
"""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."""
|
||||
"""Migration 0104's data step, DB-lane: a done file from before is dated by when it was asked for
|
||||
(where it already sat); a job not done has no date."""
|
||||
|
||||
from alembic import command
|
||||
from alembic.config import Config
|
||||
@@ -8,7 +8,7 @@ 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):
|
||||
def test_a_done_file_takes_its_request_time(db, make_user):
|
||||
uid = make_user().id
|
||||
cfg = Config("alembic.ini")
|
||||
command.downgrade(cfg, "0103_files_sha256")
|
||||
@@ -25,17 +25,8 @@ def test_a_done_file_takes_its_notice_time_else_its_request_time(db, make_user):
|
||||
{"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")
|
||||
done = 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")
|
||||
|
||||
@@ -47,6 +38,5 @@ def test_a_done_file_takes_its_notice_time_else_its_request_time(db, make_user):
|
||||
)
|
||||
).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[done] == "10-02 10"
|
||||
assert rows[failed] is None
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
"""Migration 0105's data step, DB-lane: a file in a source folder records that folder's key as its
|
||||
origin; a file in an own folder is left to be worked out, as before."""
|
||||
|
||||
from alembic import command
|
||||
from alembic.config import Config
|
||||
from sqlalchemy import text
|
||||
|
||||
from app.db import engine
|
||||
|
||||
|
||||
def test_a_file_in_a_source_folder_records_its_origin(db, make_user):
|
||||
uid = make_user().id
|
||||
cfg = Config("alembic.ini")
|
||||
command.downgrade(cfg, "0104_job_ready_at")
|
||||
try:
|
||||
with engine.begin() as conn:
|
||||
|
||||
def folder(kind, key, name):
|
||||
return conn.execute(
|
||||
text(
|
||||
"INSERT INTO file_folders (user_id, kind, source_key, name) VALUES"
|
||||
" (:u, :k, :s, :n) RETURNING id"
|
||||
),
|
||||
{"u": uid, "k": kind, "s": key, "n": name},
|
||||
).scalar_one()
|
||||
|
||||
def job(folder_id):
|
||||
return conn.execute(
|
||||
text(
|
||||
"INSERT INTO download_jobs (user_id, source_kind, source_ref,"
|
||||
" profile_snapshot, status, folder_id) VALUES (:u, 'url', 'x', '{}',"
|
||||
" 'queued', :f) RETURNING id"
|
||||
),
|
||||
{"u": uid, "f": folder_id},
|
||||
).scalar_one()
|
||||
|
||||
in_source = job(folder("source", "site:vimeo.com", None))
|
||||
in_own = job(folder("own", None, "Mine"))
|
||||
finally:
|
||||
command.upgrade(cfg, "head")
|
||||
|
||||
with engine.connect() as conn:
|
||||
rows = dict(conn.execute(text("SELECT id, source_key FROM download_jobs")).all())
|
||||
assert rows[in_source] == "site:vimeo.com"
|
||||
assert rows[in_own] is None
|
||||
@@ -6,6 +6,7 @@ from datetime import date
|
||||
from app.downloads.storage import (
|
||||
MediaMeta,
|
||||
download_filename,
|
||||
files_root_problem,
|
||||
rel_path,
|
||||
safe_abs_path,
|
||||
sanitize,
|
||||
@@ -174,3 +175,24 @@ def test_sidecars_keep_the_full_basename_when_the_title_has_a_dot(tmp_path: Path
|
||||
assert f"{stem}.nfo" in names, names
|
||||
assert f"{stem}.jpg" in names, names
|
||||
assert "Chan_-_2026-08-12_-_Sonka_Hazilag.nfo" not in names # the old truncated orphan
|
||||
|
||||
|
||||
def test_a_files_root_on_the_containers_own_disk_is_refused(tmp_path):
|
||||
# The image's empty /files, left unmounted by an old compose file: every file would 404 and
|
||||
# every download would go with the container.
|
||||
assert "not a mounted volume" in files_root_problem(
|
||||
str(tmp_path), system_root=str(tmp_path.parent)
|
||||
)
|
||||
assert "does not exist" in files_root_problem(str(tmp_path / "gone"))
|
||||
# On another filesystem than the container's own, and writable: the volume.
|
||||
assert files_root_problem(str(tmp_path), system_root="/proc") is None
|
||||
|
||||
|
||||
def test_the_old_name_of_the_files_root_still_finds_the_files(monkeypatch):
|
||||
from app.config import Settings
|
||||
|
||||
monkeypatch.delenv("FILES_ROOT", raising=False)
|
||||
monkeypatch.setenv("DOWNLOAD_ROOT", "/downloads")
|
||||
assert Settings().files_root == "/downloads"
|
||||
monkeypatch.setenv("FILES_ROOT", "/files")
|
||||
assert Settings().files_root == "/files"
|
||||
|
||||
@@ -67,6 +67,7 @@ def test_worker_boots_with_the_stored_concurrency_and_log_level(monkeypatch, sto
|
||||
monkeypatch.setattr(worker, "_wait_for_schema", lambda: True)
|
||||
monkeypatch.setattr(worker.logsink, "start", lambda: None)
|
||||
monkeypatch.setattr(worker, "_recover_orphans", lambda: None)
|
||||
monkeypatch.setattr(worker.storage, "files_root_problem", lambda _root: None)
|
||||
|
||||
threading.Timer(0.5, worker._stop.set).start()
|
||||
worker.main()
|
||||
@@ -107,3 +108,17 @@ def test_an_unreadable_database_keeps_what_runs(monkeypatch, levels, loops):
|
||||
assert worker._target == 2
|
||||
assert sorted(threads) == [0, 1] # the loops still run on the boot value
|
||||
assert levels == []
|
||||
|
||||
|
||||
def test_the_worker_will_not_start_on_a_files_root_that_is_not_the_volume(monkeypatch, loops):
|
||||
# Files downloaded into the container's own empty /files go with the container: refuse, loudly.
|
||||
monkeypatch.setattr(worker.settings, "worker_enabled", True)
|
||||
monkeypatch.setattr(worker.logs, "configure_logging", lambda _source: None)
|
||||
monkeypatch.setattr(worker.signal, "signal", lambda *_a: None)
|
||||
monkeypatch.setattr(worker, "_wait_for_schema", lambda: True)
|
||||
monkeypatch.setattr(worker.logsink, "start", lambda: None)
|
||||
monkeypatch.setattr(worker.storage, "files_root_problem", lambda _root: "not a mounted volume")
|
||||
with pytest.raises(SystemExit) as exit_:
|
||||
worker.main()
|
||||
assert exit_.value.code == 1
|
||||
assert loops == []
|
||||
|
||||
Reference in New Issue
Block a user