Files
romm/backend/tests/handler/filesystem/test_sync_handler.py
Claude 26bdc11e13 refactor(filesystem): lazy-init launchbox + sync handlers, drop tolerate_missing_base
Apply the same lazy-factory pattern to FSLaunchboxHandler and FSSyncHandler
that ssh_sync_handler now uses. With both opt-in features deferred to
first-use, the tolerate_missing_base escape hatch on FSHandler is no longer
needed — every handler now fails loudly on mkdir failure, which is the
right behavior for the always-on core paths (assets, library, resources).

Touched call sites:
  - resources_handler._resolve_local_file_uri (launchbox)
  - sync_watcher.py, endpoints/device.py, tasks/manual/sync_folder_scan.py
    (fs_sync)

Net effect:
  - Default installs never poke /romm/launchbox or /romm/sync at startup.
  - Misconfigured opt-in users get a clear, actionable PermissionError at
    the call site instead of a silent warning followed by mystery failures.
  - tolerate_missing_base, its tests, and one stale log import are gone.
2026-05-24 14:59:03 +00:00

157 lines
5.9 KiB
Python

"""Tests for filesystem sync handler."""
import errno
import os
import shutil
import tempfile
from pathlib import Path
from unittest.mock import patch
import pytest
from handler.filesystem.sync_handler import FSSyncHandler, get_fs_sync_handler
class TestFSSyncHandler:
@pytest.fixture
def temp_dir(self):
temp_dir = tempfile.mkdtemp()
yield temp_dir
shutil.rmtree(temp_dir, ignore_errors=True)
@pytest.fixture
def handler(self):
return FSSyncHandler.__new__(FSSyncHandler)
@pytest.fixture(autouse=True)
def patch_base_path(self, handler: FSSyncHandler, temp_dir):
handler.base_path = Path(temp_dir)
def test_build_incoming_path(self, handler: FSSyncHandler):
path = handler.build_incoming_path("device-1")
assert path == os.path.join("device-1", "incoming")
def test_build_incoming_path_with_platform(self, handler):
path = handler.build_incoming_path("device-1", "gba")
assert path == os.path.join("device-1", "incoming", "gba")
def test_build_outgoing_path(self, handler: FSSyncHandler):
path = handler.build_outgoing_path("device-1")
assert path == os.path.join("device-1", "outgoing")
def test_build_outgoing_path_with_platform(self, handler: FSSyncHandler):
path = handler.build_outgoing_path("device-1", "snes")
assert path == os.path.join("device-1", "outgoing", "snes")
def test_build_conflicts_path(self, handler: FSSyncHandler):
path = handler.build_conflicts_path("device-1", "gba")
assert path == os.path.join("device-1", "conflicts", "gba")
def test_ensure_device_directories(self, handler: FSSyncHandler, temp_dir):
handler.ensure_device_directories("test-device")
incoming = handler.base_path / handler.build_incoming_path("test-device")
outgoing = handler.base_path / handler.build_outgoing_path("test-device")
assert os.path.isdir(incoming)
assert os.path.isdir(outgoing)
def test_list_incoming_files_empty(self, handler: FSSyncHandler):
result = handler.list_incoming_files("nonexistent-device")
assert result == []
def test_list_incoming_files(self, handler: FSSyncHandler, temp_dir):
handler.ensure_device_directories("dev-1")
incoming_path = str(
handler.base_path / handler.build_incoming_path("dev-1", "gba")
)
os.makedirs(incoming_path, exist_ok=True)
test_file = os.path.join(incoming_path, "save.sav")
with open(test_file, "wb") as f:
f.write(b"test save content")
result = handler.list_incoming_files("dev-1")
assert len(result) == 1
assert result[0]["platform_slug"] == "gba"
assert result[0]["file_name"] == "save.sav"
assert result[0]["file_size"] == 17
def test_compute_file_hash(self, handler: FSSyncHandler, temp_dir):
test_file = os.path.join(temp_dir, "test.bin")
with open(test_file, "wb") as f:
f.write(b"hello world")
hash1 = handler.compute_file_hash(test_file)
hash2 = handler.compute_file_hash(test_file)
assert hash1 == hash2
assert len(hash1) == 32 # MD5 hex length
def test_compute_file_hash_different_content(
self, handler: FSSyncHandler, temp_dir
):
file_a = os.path.join(temp_dir, "a.bin")
file_b = os.path.join(temp_dir, "b.bin")
with open(file_a, "wb") as f:
f.write(b"content a")
with open(file_b, "wb") as f:
f.write(b"content b")
assert handler.compute_file_hash(file_a) != handler.compute_file_hash(file_b)
def test_write_outgoing_file(self, handler: FSSyncHandler, temp_dir):
path = handler.write_outgoing_file(
device_id="dev-1",
platform_slug="gba",
file_name="save.sav",
data=b"outgoing save data",
)
assert os.path.isfile(path)
with open(path, "rb") as f:
assert f.read() == b"outgoing save data"
def test_remove_incoming_file(self, handler: FSSyncHandler, temp_dir):
handler.ensure_device_directories("dev-1")
incoming = str(handler.base_path / handler.build_incoming_path("dev-1", "gba"))
os.makedirs(incoming, exist_ok=True)
test_file = os.path.join(incoming, "to_remove.sav")
with open(test_file, "wb") as f:
f.write(b"data")
assert os.path.exists(test_file)
handler.remove_incoming_file(test_file)
assert not os.path.exists(test_file)
def test_remove_incoming_file_outside_base_raises(
self, handler: FSSyncHandler, temp_dir
):
outside_file = os.path.join(tempfile.gettempdir(), "outside.txt")
with open(outside_file, "w") as f:
f.write("should not be deleted")
with pytest.raises(ValueError, match="outside the sync base directory"):
handler.remove_incoming_file(outside_file)
# Cleanup
os.unlink(outside_file)
def test_remove_incoming_file_nonexistent(self, handler: FSSyncHandler):
# Should not raise for nonexistent files
handler.remove_incoming_file("/nonexistent/path/file.sav")
class TestFSSyncHandlerLazyFactory:
"""Sync is an optional feature: if /romm/sync isn't writable (bad mount,
wrong ownership), the app must still boot. The factory defers construction
until first use so the error surfaces at the call site, not at startup."""
def test_factory_raises_at_call_time_when_unwritable(self):
"""Regression for the PermissionError on /romm/sync at boot: the
failure must surface from the factory call, not from module import."""
get_fs_sync_handler.cache_clear()
try:
with patch.object(
Path, "mkdir", side_effect=PermissionError(errno.EACCES, "denied")
):
with pytest.raises(PermissionError):
get_fs_sync_handler()
finally:
get_fs_sync_handler.cache_clear()