"""Unit tests for symlink utilities in voice_mode.utils.symlinks."""
import os
from pathlib import Path
from unittest.mock import patch, MagicMock
import pytest
@pytest.fixture(autouse=True)
def patch_audio_dir(isolate_home_directory, monkeypatch):
"""Ensure AUDIO_DIR points to the current test's home directory.
AUDIO_DIR is computed at module import time from Path.home(), so it gets
baked in from whichever test first triggers the import. This fixture
patches it to use the current test's isolated home directory.
"""
correct_audio_dir = isolate_home_directory / ".voicemode" / "audio"
monkeypatch.setattr("voice_mode.config.AUDIO_DIR", correct_audio_dir)
monkeypatch.setattr("voice_mode.utils.symlinks.AUDIO_DIR", correct_audio_dir)
class TestUpdateLatestSymlinks:
"""Tests for update_latest_symlinks function."""
def test_creates_stt_and_latest_symlinks_for_wav(self, isolate_home_directory):
"""Test that symlinks are created correctly for .wav STT files."""
from voice_mode.utils.symlinks import update_latest_symlinks
from voice_mode.config import AUDIO_DIR
# Create test audio file
AUDIO_DIR.mkdir(parents=True, exist_ok=True)
year_month = AUDIO_DIR / "2026" / "02"
year_month.mkdir(parents=True, exist_ok=True)
test_file = year_month / "123456_conv1_stt.wav"
test_file.write_bytes(b"fake audio data")
# Call the function
type_symlink, latest_symlink = update_latest_symlinks(test_file, "stt")
# Verify symlinks were created
assert type_symlink is not None
assert latest_symlink is not None
assert type_symlink.name == "latest-STT.wav"
assert latest_symlink.name == "latest.wav"
# Verify symlinks point to correct file
assert type_symlink.is_symlink()
assert latest_symlink.is_symlink()
assert type_symlink.resolve() == test_file.resolve()
assert latest_symlink.resolve() == test_file.resolve()
def test_creates_tts_and_latest_symlinks_for_mp3(self, isolate_home_directory):
"""Test that symlinks are created correctly for .mp3 TTS files."""
from voice_mode.utils.symlinks import update_latest_symlinks
from voice_mode.config import AUDIO_DIR
# Create test audio file
AUDIO_DIR.mkdir(parents=True, exist_ok=True)
year_month = AUDIO_DIR / "2026" / "02"
year_month.mkdir(parents=True, exist_ok=True)
test_file = year_month / "123456_conv1_tts.mp3"
test_file.write_bytes(b"fake mp3 data")
# Call the function
type_symlink, latest_symlink = update_latest_symlinks(test_file, "tts")
# Verify symlinks were created
assert type_symlink is not None
assert latest_symlink is not None
assert type_symlink.name == "latest-TTS.mp3"
assert latest_symlink.name == "latest.mp3"
# Verify symlinks point to correct file
assert type_symlink.resolve() == test_file.resolve()
assert latest_symlink.resolve() == test_file.resolve()
def test_creates_symlinks_for_various_extensions(self, isolate_home_directory):
"""Test symlink creation works for multiple audio formats."""
from voice_mode.utils.symlinks import update_latest_symlinks
from voice_mode.config import AUDIO_DIR
AUDIO_DIR.mkdir(parents=True, exist_ok=True)
extensions = [".wav", ".mp3", ".flac", ".aac", ".opus", ".ogg"]
for ext in extensions:
test_file = AUDIO_DIR / f"test_audio{ext}"
test_file.write_bytes(b"fake audio data")
type_symlink, latest_symlink = update_latest_symlinks(test_file, "stt")
assert type_symlink is not None, f"Failed for extension {ext}"
assert latest_symlink is not None, f"Failed for extension {ext}"
assert type_symlink.name == f"latest-STT{ext}"
assert latest_symlink.name == f"latest{ext}"
# Clean up for next iteration
test_file.unlink()
type_symlink.unlink()
latest_symlink.unlink()
def test_uses_relative_paths_for_symlinks(self, isolate_home_directory):
"""Test that symlinks use relative paths when file is under AUDIO_DIR."""
from voice_mode.utils.symlinks import update_latest_symlinks
from voice_mode.config import AUDIO_DIR
# Create test audio file in subdirectory
AUDIO_DIR.mkdir(parents=True, exist_ok=True)
year_month = AUDIO_DIR / "2026" / "02"
year_month.mkdir(parents=True, exist_ok=True)
test_file = year_month / "test_relative.wav"
test_file.write_bytes(b"fake audio data")
type_symlink, latest_symlink = update_latest_symlinks(test_file, "stt")
# Read the symlink target (without resolving)
stt_target = os.readlink(type_symlink)
latest_target = os.readlink(latest_symlink)
# Should be relative path like "2026/02/test_relative.wav"
assert not os.path.isabs(stt_target), "STT symlink should use relative path"
assert not os.path.isabs(latest_target), "latest symlink should use relative path"
assert stt_target == "2026/02/test_relative.wav"
assert latest_target == "2026/02/test_relative.wav"
class TestSymlinkExtensionChanges:
"""Tests for symlink updates when file extension changes."""
def test_removes_old_stt_symlink_when_extension_changes(self, isolate_home_directory):
"""Test that old STT symlink is removed when a new file has different extension."""
from voice_mode.utils.symlinks import update_latest_symlinks
from voice_mode.config import AUDIO_DIR
AUDIO_DIR.mkdir(parents=True, exist_ok=True)
# Create first file with .wav extension
wav_file = AUDIO_DIR / "first_stt.wav"
wav_file.write_bytes(b"wav data")
update_latest_symlinks(wav_file, "stt")
# Verify initial symlinks exist
old_stt_symlink = AUDIO_DIR / "latest-STT.wav"
old_latest_symlink = AUDIO_DIR / "latest.wav"
assert old_stt_symlink.is_symlink()
assert old_latest_symlink.is_symlink()
# Create second file with .mp3 extension
mp3_file = AUDIO_DIR / "second_stt.mp3"
mp3_file.write_bytes(b"mp3 data")
update_latest_symlinks(mp3_file, "stt")
# Old symlinks should be removed
assert not old_stt_symlink.exists(), "Old .wav STT symlink should be removed"
assert not old_latest_symlink.exists(), "Old .wav latest symlink should be removed"
# New symlinks should exist
new_stt_symlink = AUDIO_DIR / "latest-STT.mp3"
new_latest_symlink = AUDIO_DIR / "latest.mp3"
assert new_stt_symlink.is_symlink()
assert new_latest_symlink.is_symlink()
assert new_stt_symlink.resolve() == mp3_file.resolve()
assert new_latest_symlink.resolve() == mp3_file.resolve()
def test_removes_old_tts_symlink_when_extension_changes(self, isolate_home_directory):
"""Test that old TTS symlink is removed when a new file has different extension."""
from voice_mode.utils.symlinks import update_latest_symlinks
from voice_mode.config import AUDIO_DIR
AUDIO_DIR.mkdir(parents=True, exist_ok=True)
# Create first file with .mp3 extension
mp3_file = AUDIO_DIR / "first_tts.mp3"
mp3_file.write_bytes(b"mp3 data")
update_latest_symlinks(mp3_file, "tts")
# Create second file with .flac extension
flac_file = AUDIO_DIR / "second_tts.flac"
flac_file.write_bytes(b"flac data")
update_latest_symlinks(flac_file, "tts")
# Old symlinks should be removed
assert not (AUDIO_DIR / "latest-TTS.mp3").exists()
assert not (AUDIO_DIR / "latest.mp3").exists()
# New symlinks should exist
assert (AUDIO_DIR / "latest-TTS.flac").is_symlink()
assert (AUDIO_DIR / "latest.flac").is_symlink()
def test_latest_symlink_updated_by_both_stt_and_tts(self, isolate_home_directory):
"""Test that 'latest' symlink is updated by both STT and TTS."""
from voice_mode.utils.symlinks import update_latest_symlinks
from voice_mode.config import AUDIO_DIR
AUDIO_DIR.mkdir(parents=True, exist_ok=True)
# Create STT file
stt_file = AUDIO_DIR / "recording.wav"
stt_file.write_bytes(b"stt data")
update_latest_symlinks(stt_file, "stt")
assert (AUDIO_DIR / "latest.wav").resolve() == stt_file.resolve()
# Create TTS file (should update latest)
tts_file = AUDIO_DIR / "speech.mp3"
tts_file.write_bytes(b"tts data")
update_latest_symlinks(tts_file, "tts")
# latest should now point to TTS file
assert (AUDIO_DIR / "latest.mp3").resolve() == tts_file.resolve()
assert not (AUDIO_DIR / "latest.wav").exists()
# STT-specific symlink should still point to STT file
assert (AUDIO_DIR / "latest-STT.wav").resolve() == stt_file.resolve()
# TTS-specific symlink should point to TTS file
assert (AUDIO_DIR / "latest-TTS.mp3").resolve() == tts_file.resolve()
class TestSymlinkCleanup:
"""Tests for cleanup of old symlinks."""
def test_removes_only_symlinks_not_regular_files(self, isolate_home_directory, tmp_path):
"""Test that cleanup only removes symlinks, not regular files."""
from voice_mode.utils.symlinks import _remove_old_symlinks
# Use a fresh directory to avoid conflicts with other tests
test_dir = tmp_path / "symlink_cleanup_test"
test_dir.mkdir(parents=True, exist_ok=True)
# Create a regular file that matches the pattern
regular_file = test_dir / "latest-STT.txt"
regular_file.write_text("not a symlink")
# Create a symlink that matches the pattern
symlink_target = test_dir / "target.wav"
symlink_target.write_bytes(b"target")
symlink = test_dir / "latest-STT.wav"
symlink.symlink_to(symlink_target)
# Run cleanup
_remove_old_symlinks(test_dir, "latest-STT")
# Regular file should still exist
assert regular_file.exists(), "Regular file should not be removed"
# Symlink should be removed
assert not symlink.exists(), "Symlink should be removed"
def test_cleanup_removes_symlink_with_different_extension(self, isolate_home_directory, tmp_path):
"""Test that cleanup removes symlinks with various extensions."""
from voice_mode.utils.symlinks import _remove_old_symlinks
# Use a fresh directory
test_dir = tmp_path / "symlink_ext_test"
test_dir.mkdir(parents=True, exist_ok=True)
# Create target file
target = test_dir / "target.wav"
target.write_bytes(b"target")
# Create symlinks with different extensions
symlink_wav = test_dir / "latest-STT.wav"
symlink_mp3 = test_dir / "latest-STT.mp3"
symlink_wav.symlink_to(target)
symlink_mp3.symlink_to(target)
# Run cleanup
_remove_old_symlinks(test_dir, "latest-STT")
# Both symlinks should be removed
assert not symlink_wav.exists()
assert not symlink_mp3.exists()
class TestSymlinkErrorHandling:
"""Tests for error handling in symlink operations."""
def test_returns_none_for_nonexistent_file(self, isolate_home_directory):
"""Test that function returns (None, None) for nonexistent source file."""
from voice_mode.utils.symlinks import update_latest_symlinks
from voice_mode.config import AUDIO_DIR
AUDIO_DIR.mkdir(parents=True, exist_ok=True)
nonexistent = AUDIO_DIR / "does_not_exist.wav"
type_symlink, latest_symlink = update_latest_symlinks(nonexistent, "stt")
assert type_symlink is None
assert latest_symlink is None
def test_returns_none_for_file_without_extension(self, isolate_home_directory):
"""Test that function returns (None, None) for file without extension."""
from voice_mode.utils.symlinks import update_latest_symlinks
from voice_mode.config import AUDIO_DIR
AUDIO_DIR.mkdir(parents=True, exist_ok=True)
no_ext_file = AUDIO_DIR / "no_extension"
no_ext_file.write_bytes(b"data")
type_symlink, latest_symlink = update_latest_symlinks(no_ext_file, "stt")
assert type_symlink is None
assert latest_symlink is None
def test_handles_permission_error_gracefully(self, isolate_home_directory):
"""Test that permission errors are handled and return (None, None)."""
from voice_mode.utils.symlinks import update_latest_symlinks
from voice_mode.config import AUDIO_DIR
AUDIO_DIR.mkdir(parents=True, exist_ok=True)
test_file = AUDIO_DIR / "test.wav"
test_file.write_bytes(b"data")
# Mock symlink_to to raise permission error
with patch.object(Path, "symlink_to", side_effect=OSError("Permission denied")):
type_symlink, latest_symlink = update_latest_symlinks(test_file, "stt")
assert type_symlink is None
assert latest_symlink is None
def test_handles_string_path_input(self, isolate_home_directory):
"""Test that function accepts string paths in addition to Path objects."""
from voice_mode.utils.symlinks import update_latest_symlinks
from voice_mode.config import AUDIO_DIR
AUDIO_DIR.mkdir(parents=True, exist_ok=True)
test_file = AUDIO_DIR / "test_string_path.wav"
test_file.write_bytes(b"data")
# Pass string path instead of Path object
type_symlink, latest_symlink = update_latest_symlinks(str(test_file), "stt")
assert type_symlink is not None
assert latest_symlink is not None
assert type_symlink.resolve() == test_file.resolve()
def test_handles_file_outside_audio_dir(self, isolate_home_directory, tmp_path):
"""Test handling of files outside AUDIO_DIR (uses absolute path)."""
from voice_mode.utils.symlinks import update_latest_symlinks
from voice_mode.config import AUDIO_DIR
AUDIO_DIR.mkdir(parents=True, exist_ok=True)
# Create file outside AUDIO_DIR
external_file = tmp_path / "external_audio.wav"
external_file.write_bytes(b"external data")
type_symlink, latest_symlink = update_latest_symlinks(external_file, "stt")
# Should still create symlinks
assert type_symlink is not None
assert latest_symlink is not None
# Symlink should use absolute path since file is outside AUDIO_DIR
stt_target = os.readlink(type_symlink)
assert os.path.isabs(stt_target) or str(external_file) in stt_target
class TestRemoveOldSymlinksFunction:
"""Tests specifically for _remove_old_symlinks helper."""
def test_handles_empty_directory(self, isolate_home_directory):
"""Test that cleanup handles empty directory without errors."""
from voice_mode.utils.symlinks import _remove_old_symlinks
from voice_mode.config import AUDIO_DIR
AUDIO_DIR.mkdir(parents=True, exist_ok=True)
# Should not raise any errors
_remove_old_symlinks(AUDIO_DIR, "latest")
_remove_old_symlinks(AUDIO_DIR, "latest-STT")
_remove_old_symlinks(AUDIO_DIR, "latest-TTS")
def test_handles_nonexistent_directory(self, isolate_home_directory, tmp_path):
"""Test that cleanup handles nonexistent directory without errors."""
from voice_mode.utils.symlinks import _remove_old_symlinks
nonexistent_dir = tmp_path / "nonexistent"
# Should not raise any errors (glob returns empty for nonexistent paths)
_remove_old_symlinks(nonexistent_dir, "latest")
def test_removes_multiple_matching_symlinks(self, isolate_home_directory):
"""Test that all matching symlinks are removed."""
from voice_mode.utils.symlinks import _remove_old_symlinks
from voice_mode.config import AUDIO_DIR
AUDIO_DIR.mkdir(parents=True, exist_ok=True)
# Create target
target = AUDIO_DIR / "target.wav"
target.write_bytes(b"target")
# Create multiple symlinks matching pattern
symlinks = [
AUDIO_DIR / "latest.wav",
AUDIO_DIR / "latest.mp3",
AUDIO_DIR / "latest.flac",
]
for s in symlinks:
s.symlink_to(target)
# Run cleanup
_remove_old_symlinks(AUDIO_DIR, "latest")
# All should be removed
for s in symlinks:
assert not s.exists(), f"{s.name} should be removed"