diff --git a/tests/test_sync_to_ipod.py b/tests/test_sync_to_ipod.py index c0b4e82..b95d392 100644 --- a/tests/test_sync_to_ipod.py +++ b/tests/test_sync_to_ipod.py @@ -271,3 +271,67 @@ def test_the_scan_reads_from_the_mirror_not_the_device(): assert 'ln -s "$mirror"' in script assert 'cd "$scratch"' in script + + +def can_bind_mount(): + """User namespaces let an unprivileged process bind mount. Not everywhere, + notably not inside some containers, so the test that needs it skips.""" + return ( + subprocess.run( + ["unshare", "-Umr", "true"], capture_output=True, check=False + ).returncode + == 0 + ) + + +@pytest.mark.skipif(not can_bind_mount(), reason="needs unprivileged user namespaces") +def test_the_database_lands_at_the_device_root_not_the_music_folder(tmp_path): + """The two tools disagree about where the root is. rsync copies artist + folders into /Music; the database tool must run one level up, where + .rockbox lives, and must record /Music/... paths while reading the bytes + from the mirror. device_prefix is what reconciles them. + """ + mirror = tmp_path / "mirror" / "Pendulum" / "Immersion" + mirror.mkdir(parents=True) + (mirror / "01.mp3").write_bytes(b"not really an mp3") + card = tmp_path / "card" + (card / ".rockbox").mkdir(parents=True) + (card / "Music").mkdir() + device = tmp_path / "device" + device.mkdir() + + tool = tmp_path / "fake-database" + # Records where it was run and what it could see, which is the whole + # question; producing a real database needs Rockbox's builder. + tool.write_text( + "#!/bin/sh\n" + "printf '%s\\n' \"$PWD\" > .rockbox/where.txt\n" + "ls Music/ > .rockbox/saw.txt\n" + "echo db > .rockbox/database_0.tcd\n" + ) + tool.chmod(0o755) + + script = ( + f"mount --bind {card} {device} && " + f"XDG_CACHE_HOME={tmp_path / 'cache'} MUSIC_MIRROR_DATABASE_TOOL={tool} " + f"bash {SCRIPT} -f -S -U -Q {tmp_path / 'mirror'} {device / 'Music'}" + ) + result = subprocess.run( + ["unshare", "-Umr", "sh", "-c", script], capture_output=True, text=True + ) + + assert result.returncode == 0, result.stderr + # The database lands beside the device root, not inside Music. + assert (card / ".rockbox" / "database_0.tcd").is_file() + # Only *.tcd is copied across, so the markers stay in the scratch root -- + # which is itself the point: nothing else is written to the device. + scratch = tmp_path / "cache" / "music-mirror" / "database" / ".rockbox" + assert not (card / ".rockbox" / "where.txt").exists() + + # It ran in the scratch root, not on the card. + where = (scratch / "where.txt").read_text().strip() + assert where.endswith("music-mirror/database"), where + # ...and could walk into the mirror through a symlink named for the device + # prefix, which is how the paths come out as /Music/... while the bytes are + # read from somewhere else entirely. + assert "Pendulum" in (scratch / "saw.txt").read_text()