Build and publish container / build (pull_request) Successful in 2m27s
The script required the destination to be its own mount point, while the documentation and its own usage text both told the user to pass /media/IPOD/Music. The documented invocation was rejected. A subdirectory is the better target, so the guard was what was wrong. --delete is confined to it, and the device path budget is now derived from it -- the part of the destination below its mount point -- rather than configured, so the budget cannot disagree with where the files are actually going. The check that matters is that the destination sits on a FAT filesystem, which is also what catches an unmounted device: /media/IPOD/Music then resolves to the host's own root filesystem, and emptying that is the outcome all of these guards exist to prevent. Three further faults found by testing the guards rather than reasoning about them: Stripping the trailing slash from "/" left an empty string, so the guard refusing the host root never fired and the user got "destination is not a directory" instead. die() printed only its first argument, so the second half of the non-FAT message -- the half saying to check whether the device is mounted -- was silently dropped. --help was not handled at all. Only -h reached the usage text, and it exited 2 to stderr, which is right for misuse and wrong for someone asking a question. Help now goes to stdout and exits zero, and carries the guidance rather than leaving it to the README, since the question it answers is asked at a terminal. The test stage installs bash, rsync and findmnt, none of which are in the base image, and the tests skip rather than fail where they are absent -- a machine without rsync is not a machine that would run this script.
177 lines
5.4 KiB
Python
177 lines
5.4 KiB
Python
"""The guards on sync-to-ipod.sh, which are the substance of the script.
|
|
|
|
rsync --delete is being aimed at a whole filesystem, so every refusal here is
|
|
protecting against emptying the wrong directory -- a mistake that does not
|
|
announce itself.
|
|
"""
|
|
|
|
import shutil
|
|
import subprocess
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
SCRIPT = Path(__file__).resolve().parent.parent / "tools" / "sync-to-ipod.sh"
|
|
|
|
# Skipped rather than failed where the tools are absent: this is a host-side
|
|
# script, and a machine without rsync is not a machine that would run it.
|
|
REQUIRED = ("bash", "rsync", "findmnt")
|
|
pytestmark = pytest.mark.skipif(
|
|
not all(shutil.which(tool) for tool in REQUIRED),
|
|
reason=f"needs {', '.join(REQUIRED)} on PATH",
|
|
)
|
|
|
|
|
|
def run(*arguments):
|
|
return subprocess.run(
|
|
["bash", str(SCRIPT), *arguments], capture_output=True, text=True
|
|
)
|
|
|
|
|
|
@pytest.fixture
|
|
def mirror(tmp_path):
|
|
source = tmp_path / "mirror"
|
|
(source / "Album").mkdir(parents=True)
|
|
(source / "Album" / "track.mp3").write_bytes(b"x")
|
|
return source
|
|
|
|
|
|
def test_the_host_root_is_refused(mirror):
|
|
"""Stripping the trailing slash from "/" leaves an empty string, and an
|
|
earlier version then reported it as "not a directory" instead."""
|
|
result = run(str(mirror), "/")
|
|
|
|
assert result.returncode == 1
|
|
assert "refusing to sync onto /" in result.stderr
|
|
|
|
|
|
def test_an_empty_mirror_is_refused(tmp_path):
|
|
"""Mirroring nothing onto the device would delete everything on it."""
|
|
empty = tmp_path / "empty"
|
|
empty.mkdir()
|
|
destination = tmp_path / "dest"
|
|
destination.mkdir()
|
|
|
|
result = run(str(empty), str(destination))
|
|
|
|
assert result.returncode == 1
|
|
assert "refusing to mirror nothing" in result.stderr
|
|
|
|
|
|
def test_syncing_a_directory_onto_itself_is_refused(mirror):
|
|
result = run(str(mirror), str(mirror))
|
|
|
|
assert result.returncode == 1
|
|
assert "same directory" in result.stderr
|
|
|
|
|
|
def test_a_non_fat_destination_is_refused(mirror, tmp_path):
|
|
"""Which is also how an unmounted device is caught: /media/IPOD/Music then
|
|
resolves to the host's own root filesystem."""
|
|
destination = tmp_path / "dest"
|
|
destination.mkdir()
|
|
|
|
result = run(str(mirror), str(destination))
|
|
|
|
assert result.returncode == 1
|
|
assert "not FAT" in result.stderr
|
|
assert "Is the device mounted?" in result.stderr
|
|
|
|
|
|
def test_a_missing_destination_is_refused(mirror, tmp_path):
|
|
result = run(str(mirror), str(tmp_path / "nowhere"))
|
|
|
|
assert result.returncode == 1
|
|
assert "not a directory" in result.stderr
|
|
|
|
|
|
def test_a_subdirectory_of_the_device_is_a_valid_target(mirror, tmp_path):
|
|
"""The better target, in fact: --delete is confined to it."""
|
|
destination = tmp_path / "dest" / "Music"
|
|
destination.mkdir(parents=True)
|
|
|
|
result = run("-f", "-n", str(mirror), str(destination))
|
|
|
|
assert result.returncode == 0, result.stderr
|
|
assert "dry run, nothing was written" in result.stderr
|
|
|
|
|
|
def test_the_device_prefix_is_derived_from_the_destination(mirror, tmp_path):
|
|
"""Derived rather than configured, so it cannot disagree with where the
|
|
files are actually going -- and the device's path limit applies to it."""
|
|
destination = tmp_path / "dest" / "Music"
|
|
destination.mkdir(parents=True)
|
|
|
|
result = run("-f", "-n", str(mirror), str(destination))
|
|
|
|
assert "the device will see this as /" in result.stderr
|
|
|
|
|
|
def test_a_dry_run_writes_nothing(mirror, tmp_path):
|
|
destination = tmp_path / "dest"
|
|
destination.mkdir()
|
|
|
|
run("-f", "-n", str(mirror), str(destination))
|
|
|
|
assert list(destination.iterdir()) == []
|
|
|
|
|
|
def test_rockbox_is_never_deleted(mirror, tmp_path):
|
|
"""A sync to the card root would otherwise remove the Rockbox install,
|
|
since the mirror does not contain it."""
|
|
destination = tmp_path / "dest"
|
|
destination.mkdir()
|
|
(destination / ".rockbox").mkdir()
|
|
(destination / ".rockbox" / "rockbox.ipod").write_bytes(b"firmware")
|
|
(destination / ".scrobbler.log").write_bytes(b"#AUDIOSCROBBLER/1.1\n")
|
|
(destination / "Stale.mp3").write_bytes(b"old")
|
|
|
|
result = run("-f", "-S", "-U", str(mirror), str(destination))
|
|
|
|
assert result.returncode == 0, result.stderr
|
|
assert (destination / ".rockbox" / "rockbox.ipod").is_file()
|
|
assert (destination / ".scrobbler.log").is_file()
|
|
# But a track whose source has gone is still removed. That is the point.
|
|
assert not (destination / "Stale.mp3").exists()
|
|
assert (destination / "Album" / "track.mp3").is_file()
|
|
|
|
|
|
def test_help_goes_to_stdout_and_exits_clean():
|
|
"""Asking for help is not an error; getting the arguments wrong is."""
|
|
result = run("--help")
|
|
|
|
assert result.returncode == 0
|
|
assert result.stdout.startswith("usage:")
|
|
assert result.stderr == ""
|
|
|
|
|
|
def test_short_help_behaves_the_same():
|
|
result = run("-h")
|
|
|
|
assert result.returncode == 0
|
|
assert result.stdout.startswith("usage:")
|
|
|
|
|
|
def test_misuse_goes_to_stderr_and_does_not():
|
|
result = run("only-one-argument")
|
|
|
|
assert result.returncode == 2
|
|
assert result.stderr.startswith("usage:")
|
|
assert result.stdout == ""
|
|
|
|
|
|
def test_the_help_explains_what_the_destination_should_be():
|
|
"""The question this script actually gets asked."""
|
|
help_text = run("--help").stdout
|
|
|
|
assert "/media/IPOD/Music" in help_text
|
|
assert "artist folders" in help_text
|
|
assert ".rockbox" in help_text
|
|
|
|
|
|
def test_the_help_says_how_to_reach_and_leave_disk_mode():
|
|
help_text = run("--help").stdout
|
|
|
|
assert "Menu+Select" in help_text
|
|
assert "holding Play" in help_text
|