Add e2e test suite for beetfs
Tests use real FUSE operations against mounted beetfs filesystem: - test_smoke: mount/unmount lifecycle - test_nested_bug: detects critical indentation bug (13 failures) - test_readdir: directory listing - test_read: metadata overlay verification - test_stat: file/directory attributes - test_write: metadata modification - test_error_handling: ENOENT, EOPNOTSUPP Also includes: - conftest.py with BeetFSTestCase base class and synthetic FLAC generator - e2e-test-plan.md with Oracle-reviewed test strategy - flake.nix updated with ffmpeg/flac for test fixtures Run: cd tests && nix develop ../ --command python -m unittest discover
This commit is contained in:
@@ -0,0 +1,414 @@
|
||||
# beetfs E2E Test Plan
|
||||
|
||||
> **Reviewed by Oracle** - Critical bug discovered, plan updated accordingly
|
||||
|
||||
## Executive Summary
|
||||
|
||||
E2E tests for beetfs FUSE filesystem using real music files from qBittorrent container. No mocks - actual filesystem operations against mounted beetfs.
|
||||
|
||||
### Critical Finding
|
||||
|
||||
**BUG DISCOVERED**: Lines 758-1144 in `beetFs.py` are indented inside `access()` method, making these FUSE operations unreachable as class methods:
|
||||
- `readdir`, `open`, `read`, `write`, `mkdir`, `unlink`, `rmdir`, `symlink`, `link`, `rename`, `chmod`, `chown`, `truncate`, `opendir`, `releasedir`, `fsyncdir`, `create`, `fgetattr`, `release`, `fsync`, `flush`, `ftruncate`
|
||||
|
||||
Tests will expose this immediately - write `test_readdir.py` first.
|
||||
|
||||
---
|
||||
|
||||
## Test Environment
|
||||
|
||||
| Component | Status | Details |
|
||||
|-----------|--------|---------|
|
||||
| Real Music | Available | Metallica "72 Seasons" (12 FLAC, 650MB) at `/home/fujin/.local/share/docker/volumes/containers_downloads/_data/Metallica - 72 Seasons (2023) [FLAC] 88/` |
|
||||
| Synthetic Music | Create | 5-10MB FLACs for most tests (avoid RAM explosion) |
|
||||
| Beets Config | Create | `~/.config/beets/config.yaml` for test isolation |
|
||||
| Beets Library | Empty | Needs import of test files |
|
||||
| Python | 2.7.15 | Via Nix flake (nixpkgs-18.09) |
|
||||
| Test Framework | unittest | stdlib, no external deps for Py2.7 |
|
||||
|
||||
---
|
||||
|
||||
## Test Architecture
|
||||
|
||||
```
|
||||
beetfs/tests/
|
||||
├── __init__.py
|
||||
├── conftest.py # Test fixtures, beets library setup, synthetic FLAC creation
|
||||
├── test_smoke.py # Mount/unmount lifecycle (run FIRST)
|
||||
├── test_nested_bug.py # Verify the indentation bug (run SECOND)
|
||||
├── test_readdir.py # Directory listing operations
|
||||
├── test_read.py # File reading with metadata overlay (CORE FEATURE)
|
||||
├── test_stat.py # getattr, fgetattr, statfs
|
||||
├── test_write.py # Metadata write operations
|
||||
├── test_error_handling.py # ENOENT, EOPNOTSUPP scenarios
|
||||
├── test_edge_cases.py # Unicode, concurrent opens, special chars
|
||||
├── test_integration.py # Real 650MB files (skip by default)
|
||||
└── fixtures/
|
||||
├── synthetic/ # Generated 5-10MB test FLACs
|
||||
└── real -> /home/fujin/.local/share/docker/volumes/containers_downloads/_data/
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Test Tiers
|
||||
|
||||
### Tier 1: Unit-ish (Synthetic FLACs, ~500KB each)
|
||||
- Fast execution
|
||||
- No memory issues (FileHandler loads entire file to RAM)
|
||||
- Run on every commit
|
||||
|
||||
### Tier 2: Integration (Subset of real files, 1-2 tracks)
|
||||
- Uses real Metallica FLACs
|
||||
- Tests real-world metadata
|
||||
- Run before merge
|
||||
|
||||
### Tier 3: E2E (All 12 tracks, 650MB)
|
||||
- Full album processing
|
||||
- Memory stress testing
|
||||
- Run via `E2E=1 python -m unittest discover`
|
||||
- Skip by default
|
||||
|
||||
---
|
||||
|
||||
## Test Isolation Strategy
|
||||
|
||||
| Resource | Strategy | Rationale |
|
||||
|----------|----------|-----------|
|
||||
| Audio Files | **Symlinks** for reads | beetfs NEVER writes to source files, only to beets DB |
|
||||
| Beets DB | **Copy per test** | Writes mutate DB; need isolation |
|
||||
| Mount Point | **Fresh tempdir** | Each test gets clean mount |
|
||||
| Global State | **Fresh subprocess** | `library`, `directory_structure` are module globals |
|
||||
|
||||
---
|
||||
|
||||
## Implementation Order
|
||||
|
||||
> Reordered per Oracle recommendation: smoke → nested-bug → read → write → errors → edge
|
||||
|
||||
### Phase 1: Infrastructure (Day 1 AM)
|
||||
|
||||
1. Create `tests/` directory structure
|
||||
2. Implement `BeetFSTestCase` base class with:
|
||||
- Subprocess timeout via `threading.Timer` (Py2.7 compatible)
|
||||
- Mount wait polling (`os.path.ismount()`)
|
||||
- Proper cleanup (`fusermount -u`)
|
||||
3. Create synthetic FLAC generator using ffmpeg + flac CLI
|
||||
4. Setup isolated beets config and library
|
||||
|
||||
### Phase 2: Bug Detection (Day 1 PM)
|
||||
|
||||
5. `test_smoke.py` - Mount/unmount lifecycle
|
||||
6. `test_nested_bug.py` - Verify `readdir`, `open` are callable (will fail, exposing bug)
|
||||
|
||||
### Phase 3: Core Tests (Day 2)
|
||||
|
||||
7. `test_readdir.py` - Directory listing
|
||||
8. `test_read.py` - **Metadata overlay verification** (critical)
|
||||
9. `test_stat.py` - File/directory attributes
|
||||
|
||||
### Phase 4: Write & Errors (Day 3)
|
||||
|
||||
10. `test_write.py` - Metadata modification, DB persistence
|
||||
11. `test_error_handling.py` - ENOENT, EOPNOTSUPP
|
||||
|
||||
### Phase 5: Edge Cases (Day 3-4)
|
||||
|
||||
12. `test_edge_cases.py` - Unicode, concurrent opens, special chars
|
||||
13. `test_integration.py` - Real 650MB files (optional tier)
|
||||
|
||||
---
|
||||
|
||||
## Test Categories
|
||||
|
||||
### 1. Smoke Tests (`test_smoke.py`)
|
||||
|
||||
| Test | Operation | Expected |
|
||||
|------|-----------|----------|
|
||||
| `test_mount_success` | Mount beetfs | `os.path.ismount()` returns True |
|
||||
| `test_unmount_clean` | Unmount | Process exits 0, dir accessible |
|
||||
| `test_mount_empty_library` | Mount with 0 items | Mounts successfully, root empty |
|
||||
| `test_mount_invalid_path` | Mount to non-existent | Fails gracefully |
|
||||
| `test_fsinit_called` | Check initialization | No crash on mount |
|
||||
|
||||
### 2. Nested Methods Bug (`test_nested_bug.py`)
|
||||
|
||||
| Test | Operation | Expected |
|
||||
|------|-----------|----------|
|
||||
| `test_readdir_exists` | `hasattr(beetFileSystem, 'readdir')` | True (currently False!) |
|
||||
| `test_open_exists` | `hasattr(beetFileSystem, 'open')` | True (currently False!) |
|
||||
| `test_read_exists` | `hasattr(beetFileSystem, 'read')` | True (currently False!) |
|
||||
| `test_readdir_callable` | `os.listdir(mount)` | Returns list (currently fails!) |
|
||||
|
||||
### 3. Directory Operations (`test_readdir.py`)
|
||||
|
||||
| Test | Operation | Expected |
|
||||
|------|-----------|----------|
|
||||
| `test_list_root` | `os.listdir(mount)` | Returns artist directories |
|
||||
| `test_list_artist` | `os.listdir(mount/artist)` | Returns album directories |
|
||||
| `test_list_album` | `os.listdir(mount/artist/album)` | Returns track files |
|
||||
| `test_path_format` | Check structure | Matches `$artist/$album ($year) [$format_upper]/$track - $artist - $title.$format` |
|
||||
| `test_unicode_paths` | Non-ASCII chars | Handles "Lux Aeterna" correctly |
|
||||
|
||||
### 4. Read Operations (`test_read.py`) - CORE FEATURE
|
||||
|
||||
| Test | Operation | Expected |
|
||||
|------|-----------|----------|
|
||||
| `test_read_header_overlay` | Read + parse with mutagen | Tags match DB, not file |
|
||||
| `test_read_audio_passthrough` | Compare audio bytes | Identical to original after header |
|
||||
| `test_read_full_file` | Read entire file | Header from DB + audio from file |
|
||||
| `test_metadata_artist` | Check artist tag | DB value, not file value |
|
||||
| `test_metadata_title` | Check title tag | DB value, not file value |
|
||||
| `test_metadata_album` | Check album tag | DB value, not file value |
|
||||
| `test_metadata_genre` | Check genre tag | DB value, not file value |
|
||||
| `test_original_unchanged` | Read original file | Original metadata intact |
|
||||
|
||||
#### Metadata Overlay Verification Pattern
|
||||
|
||||
```python
|
||||
import mutagen.flac
|
||||
from io import BytesIO
|
||||
|
||||
def test_read_header_overlay(self):
|
||||
# Setup: Import file, modify DB metadata
|
||||
# beet import /path/to/file
|
||||
# beet modify artist="DB Artist" # File has "Original Artist"
|
||||
|
||||
# Read mounted file as bytes
|
||||
with open(os.path.join(self.mount_dir, 'DB Artist/...'), 'rb') as f:
|
||||
mounted_data = f.read()
|
||||
|
||||
# Parse with mutagen
|
||||
flac = mutagen.flac.FLAC(BytesIO(mounted_data))
|
||||
|
||||
# Verify overlay worked
|
||||
self.assertEqual(flac['artist'][0], 'DB Artist') # From DB
|
||||
self.assertNotEqual(flac['artist'][0], 'Original Artist') # Not from file
|
||||
```
|
||||
|
||||
### 5. Stat Operations (`test_stat.py`)
|
||||
|
||||
| Test | Operation | Expected |
|
||||
|------|-----------|----------|
|
||||
| `test_stat_file` | `os.stat(file)` | Valid stat with size, mtime |
|
||||
| `test_stat_directory` | `os.stat(dir)` | Directory mode (S_IFDIR) |
|
||||
| `test_statfs` | `os.statvfs(mount)` | Valid filesystem stats |
|
||||
| `test_access_read` | `os.access(file, R_OK)` | True |
|
||||
| `test_access_write` | `os.access(file, W_OK)` | True (header writable) |
|
||||
|
||||
### 6. Write Operations (`test_write.py`)
|
||||
|
||||
| Test | Operation | Expected |
|
||||
|------|-----------|----------|
|
||||
| `test_write_title` | Modify title in header | DB updated, file unchanged |
|
||||
| `test_write_artist` | Modify artist | DB updated |
|
||||
| `test_write_album` | Modify album | DB updated |
|
||||
| `test_write_genre` | Modify genre | DB updated |
|
||||
| `test_write_audio_discarded` | Write at offset > bound | Silently discarded |
|
||||
| `test_write_persistence` | Write -> unmount -> remount | Changes persisted in DB |
|
||||
| `test_write_mp3_noop` | Write to MP3 header | No error, but no effect (bound=0) |
|
||||
|
||||
### 7. Error Handling (`test_error_handling.py`)
|
||||
|
||||
| Test | Operation | Expected |
|
||||
|------|-----------|----------|
|
||||
| `test_enoent_file` | Read non-existent | `OSError(ENOENT)` |
|
||||
| `test_enoent_dir` | List non-existent | `OSError(ENOENT)` |
|
||||
| `test_eopnotsupp_mkdir` | `os.mkdir()` | `OSError(EOPNOTSUPP)` |
|
||||
| `test_eopnotsupp_unlink` | `os.unlink()` | `OSError(EOPNOTSUPP)` |
|
||||
| `test_eopnotsupp_rename` | `os.rename()` | `OSError(EOPNOTSUPP)` |
|
||||
| `test_eopnotsupp_symlink` | `os.symlink()` | `OSError(EOPNOTSUPP)` |
|
||||
|
||||
### 8. Edge Cases (`test_edge_cases.py`)
|
||||
|
||||
| Test | Operation | Expected |
|
||||
|------|-----------|----------|
|
||||
| `test_special_chars_sanitized` | Path with `?/` | Sanitized via `sanitize()` |
|
||||
| `test_concurrent_opens` | Open same file twice | `instance_count` increments |
|
||||
| `test_concurrent_release` | Release after double open | File stays cached until count=0 |
|
||||
| `test_unicode_metadata` | Non-ASCII in artist/title | Handled correctly |
|
||||
| `test_empty_metadata` | None/empty fields | Doesn't crash |
|
||||
| `test_mp3_no_interpolation` | Read MP3 | Returns original file (no overlay) |
|
||||
|
||||
### 9. Integration (`test_integration.py`)
|
||||
|
||||
| Test | Env Var | Expected |
|
||||
|------|---------|----------|
|
||||
| `test_real_album_listing` | `E2E=1` | Lists all 12 Metallica tracks |
|
||||
| `test_real_file_read` | `E2E=1` | Reads 67MB file successfully |
|
||||
| `test_memory_usage` | `E2E=1` | Documents but doesn't fail on high RAM |
|
||||
|
||||
---
|
||||
|
||||
## Test Infrastructure Code
|
||||
|
||||
### Base Test Class (Python 2.7 Compatible)
|
||||
|
||||
```python
|
||||
# tests/conftest.py
|
||||
import unittest
|
||||
import subprocess
|
||||
import tempfile
|
||||
import shutil
|
||||
import os
|
||||
import time
|
||||
import threading
|
||||
|
||||
class BeetFSTestCase(unittest.TestCase):
|
||||
"""Base class for beetfs e2e tests - Python 2.7 compatible"""
|
||||
|
||||
MOUNT_TIMEOUT = 30 # seconds
|
||||
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
"""Check FUSE availability"""
|
||||
try:
|
||||
with open(os.devnull, 'w') as devnull:
|
||||
subprocess.check_call(['which', 'fusermount'],
|
||||
stdout=devnull, stderr=devnull)
|
||||
except subprocess.CalledProcessError:
|
||||
raise unittest.SkipTest("fusermount not available")
|
||||
|
||||
def setUp(self):
|
||||
self.mount_dir = tempfile.mkdtemp(prefix='beetfs_test_')
|
||||
self.fs_process = None
|
||||
|
||||
def mount_beetfs(self, library_path=None):
|
||||
"""Mount beetfs in background with timeout"""
|
||||
cmd = ['python', '-c',
|
||||
'from beetsplug.beetFs import mount; mount()']
|
||||
# Add mount point and other args as needed
|
||||
|
||||
self.fs_process = subprocess.Popen(
|
||||
cmd,
|
||||
stdout=open(os.devnull, 'w'),
|
||||
stderr=subprocess.STDOUT
|
||||
)
|
||||
|
||||
# Python 2.7 timeout workaround
|
||||
timer = threading.Timer(self.MOUNT_TIMEOUT, self._timeout_kill)
|
||||
timer.start()
|
||||
|
||||
try:
|
||||
self._wait_for_mount()
|
||||
finally:
|
||||
timer.cancel()
|
||||
|
||||
def _timeout_kill(self):
|
||||
if self.fs_process and self.fs_process.poll() is None:
|
||||
self.fs_process.kill()
|
||||
|
||||
def _wait_for_mount(self):
|
||||
"""Wait for filesystem to be mounted"""
|
||||
start = time.time()
|
||||
while time.time() - start < self.MOUNT_TIMEOUT:
|
||||
if os.path.ismount(self.mount_dir):
|
||||
return
|
||||
if self.fs_process.poll() is not None:
|
||||
self.fail("Filesystem process terminated prematurely")
|
||||
time.sleep(0.1)
|
||||
self.fail("Mount timeout after {} seconds".format(self.MOUNT_TIMEOUT))
|
||||
|
||||
def tearDown(self):
|
||||
"""Cleanup: unmount and kill process"""
|
||||
if self.fs_process:
|
||||
with open(os.devnull, 'w') as devnull:
|
||||
subprocess.call(['fusermount', '-z', '-u', self.mount_dir],
|
||||
stdout=devnull, stderr=devnull)
|
||||
|
||||
self.fs_process.terminate()
|
||||
|
||||
# Wait for termination (Py2.7 compatible)
|
||||
start = time.time()
|
||||
while time.time() - start < 5:
|
||||
if self.fs_process.poll() is not None:
|
||||
break
|
||||
time.sleep(0.1)
|
||||
else:
|
||||
self.fs_process.kill()
|
||||
|
||||
shutil.rmtree(self.mount_dir, ignore_errors=True)
|
||||
```
|
||||
|
||||
### Synthetic FLAC Generator
|
||||
|
||||
```python
|
||||
# tests/conftest.py (continued)
|
||||
import subprocess
|
||||
import tempfile
|
||||
import os
|
||||
|
||||
def create_synthetic_flac(duration_sec=5, artist="Test Artist",
|
||||
title="Test Track", album="Test Album"):
|
||||
"""Create minimal FLAC with known metadata (~500KB for 5s silence)"""
|
||||
wav_fd, wav_path = tempfile.mkstemp(suffix='.wav')
|
||||
os.close(wav_fd)
|
||||
flac_path = wav_path.replace('.wav', '.flac')
|
||||
|
||||
try:
|
||||
# Generate silence WAV
|
||||
subprocess.check_call([
|
||||
'ffmpeg', '-f', 'lavfi', '-i',
|
||||
'anullsrc=r=44100:cl=stereo', '-t', str(duration_sec),
|
||||
'-y', wav_path
|
||||
], stdout=open(os.devnull, 'w'), stderr=subprocess.STDOUT)
|
||||
|
||||
# Convert to FLAC with metadata
|
||||
subprocess.check_call([
|
||||
'flac', '--best',
|
||||
'-T', 'ARTIST={}'.format(artist),
|
||||
'-T', 'TITLE={}'.format(title),
|
||||
'-T', 'ALBUM={}'.format(album),
|
||||
'-o', flac_path, wav_path
|
||||
], stdout=open(os.devnull, 'w'), stderr=subprocess.STDOUT)
|
||||
|
||||
return flac_path
|
||||
finally:
|
||||
if os.path.exists(wav_path):
|
||||
os.unlink(wav_path)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Dependencies to Add to flake.nix
|
||||
|
||||
```nix
|
||||
# In devShell buildInputs, add:
|
||||
pkgs.ffmpeg # For synthetic FLAC generation
|
||||
pkgs.flac # For FLAC encoding
|
||||
|
||||
# pythonEnv already has mutagen for verification
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Risks & Mitigations
|
||||
|
||||
| Risk | Impact | Mitigation |
|
||||
|------|--------|------------|
|
||||
| Memory explosion | High | Use 5-10MB synthetic FLACs, skip 650MB tests by default |
|
||||
| Nested methods bug | Critical | Tests will expose; fix required before other tests pass |
|
||||
| Python 2.7 EOL | Medium | Nix provides isolated environment |
|
||||
| Global state pollution | Medium | Fresh subprocess per test |
|
||||
| FUSE permissions | Low | Run as regular user, skip privileged tests |
|
||||
| Concurrent access | Low | Single-threaded mode, sequential tests |
|
||||
|
||||
---
|
||||
|
||||
## Success Criteria
|
||||
|
||||
1. **All smoke tests pass** - beetfs mounts and unmounts cleanly
|
||||
2. **Nested bug exposed and fixed** - All FUSE methods callable
|
||||
3. **Metadata overlay verified** - Reads return DB metadata, not file metadata
|
||||
4. **Writes update DB** - Metadata changes persist
|
||||
5. **Errors handled gracefully** - Correct errno for unsupported ops
|
||||
6. **No crashes on edge cases** - Unicode, special chars, concurrent access
|
||||
|
||||
---
|
||||
|
||||
## Notes from Oracle Review
|
||||
|
||||
1. **MP3 is not "readonly"** - metadata overlay is disabled (`bound=0`), but reads still work
|
||||
2. **Write returns None for MP3** - no explicit return in MP3 path (falls through)
|
||||
3. **Path format is hardcoded** - tests must match `$artist/$album ($year) [$format_upper]/$track - $artist - $title.$format`
|
||||
4. **basestring vs str** - use `isinstance(x, basestring)` for Py2.7 string checks
|
||||
5. **Global variables** - `library`, `directory_structure` must be reset between tests (use subprocesses)
|
||||
@@ -97,17 +97,17 @@ EOF
|
||||
buildInputs = [
|
||||
pythonEnv
|
||||
pkgs.fuse
|
||||
pkgs.ffmpeg
|
||||
pkgs.flac
|
||||
];
|
||||
|
||||
shellHook = ''
|
||||
# Clear any system Python pollution and set clean PYTHONPATH
|
||||
unset PYTHONPATH
|
||||
export PYTHONPATH="$PWD/beetsplug"
|
||||
export PYTHONPATH="$PWD/beetsplug:$PWD/tests"
|
||||
echo "beetfs development environment (Python 2.7)"
|
||||
echo " Python: $(python --version 2>&1)"
|
||||
echo " Run: python -c 'import beets; print(beets.__version__)'"
|
||||
echo ""
|
||||
echo "To mount: beet mount <mountpoint>"
|
||||
echo " Run tests: cd tests && python -m unittest discover"
|
||||
echo " Mount: beet mount <mountpoint>"
|
||||
'';
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,565 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
beetfs E2E Test Infrastructure
|
||||
|
||||
Base test class, fixtures, and utilities for testing the real beetfs FUSE filesystem.
|
||||
Python 2.7 compatible - no mocks, real filesystem operations.
|
||||
"""
|
||||
from __future__ import print_function, unicode_literals
|
||||
|
||||
import unittest
|
||||
import subprocess
|
||||
import tempfile
|
||||
import shutil
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
import threading
|
||||
import sqlite3
|
||||
|
||||
# Add parent directory to path for imports
|
||||
BEETFS_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
sys.path.insert(0, BEETFS_ROOT)
|
||||
sys.path.insert(0, os.path.join(BEETFS_ROOT, 'beetsplug'))
|
||||
|
||||
# Test constants
|
||||
MOUNT_TIMEOUT = 30 # seconds
|
||||
TEARDOWN_TIMEOUT = 5 # seconds
|
||||
SYNTHETIC_FLAC_DURATION = 3 # seconds (smaller = faster tests)
|
||||
|
||||
# Real music files location (from qBittorrent container)
|
||||
REAL_MUSIC_PATH = '/home/fujin/.local/share/docker/volumes/containers_downloads/_data/Metallica - 72 Seasons (2023) [FLAC] 88'
|
||||
|
||||
|
||||
def is_fuse_available():
|
||||
"""Check if FUSE is available on the system"""
|
||||
try:
|
||||
with open(os.devnull, 'w') as devnull:
|
||||
subprocess.check_call(['which', 'fusermount'],
|
||||
stdout=devnull, stderr=devnull)
|
||||
return True
|
||||
except (subprocess.CalledProcessError, OSError):
|
||||
return False
|
||||
|
||||
|
||||
def is_ffmpeg_available():
|
||||
"""Check if ffmpeg is available for synthetic FLAC generation"""
|
||||
try:
|
||||
with open(os.devnull, 'w') as devnull:
|
||||
subprocess.check_call(['which', 'ffmpeg'],
|
||||
stdout=devnull, stderr=devnull)
|
||||
return True
|
||||
except (subprocess.CalledProcessError, OSError):
|
||||
return False
|
||||
|
||||
|
||||
def is_flac_available():
|
||||
"""Check if flac encoder is available"""
|
||||
try:
|
||||
with open(os.devnull, 'w') as devnull:
|
||||
subprocess.check_call(['which', 'flac'],
|
||||
stdout=devnull, stderr=devnull)
|
||||
return True
|
||||
except (subprocess.CalledProcessError, OSError):
|
||||
return False
|
||||
|
||||
|
||||
def create_synthetic_flac(output_path, duration_sec=SYNTHETIC_FLAC_DURATION,
|
||||
artist='Test Artist', title='Test Track',
|
||||
album='Test Album', year='2024', track='01',
|
||||
genre='Test Genre'):
|
||||
"""
|
||||
Create a minimal FLAC file with known metadata.
|
||||
|
||||
Uses ffmpeg to generate silence, then flac to encode with tags.
|
||||
Result is ~100-500KB depending on duration.
|
||||
|
||||
Args:
|
||||
output_path: Where to write the FLAC file
|
||||
duration_sec: Duration in seconds (default 3)
|
||||
artist, title, album, year, track, genre: Metadata tags
|
||||
|
||||
Returns:
|
||||
Path to created FLAC file
|
||||
|
||||
Raises:
|
||||
subprocess.CalledProcessError: If ffmpeg or flac fails
|
||||
RuntimeError: If tools not available
|
||||
"""
|
||||
if not is_ffmpeg_available():
|
||||
raise RuntimeError("ffmpeg not available - install it or use nix develop")
|
||||
if not is_flac_available():
|
||||
raise RuntimeError("flac not available - install it or use nix develop")
|
||||
|
||||
# Create temp WAV file
|
||||
wav_fd, wav_path = tempfile.mkstemp(suffix='.wav')
|
||||
os.close(wav_fd)
|
||||
|
||||
try:
|
||||
# Generate silence WAV using ffmpeg
|
||||
with open(os.devnull, 'w') as devnull:
|
||||
subprocess.check_call([
|
||||
'ffmpeg', '-f', 'lavfi', '-i',
|
||||
'anullsrc=r=44100:cl=stereo',
|
||||
'-t', str(duration_sec),
|
||||
'-y', wav_path
|
||||
], stdout=devnull, stderr=devnull)
|
||||
|
||||
# Convert to FLAC with metadata tags
|
||||
with open(os.devnull, 'w') as devnull:
|
||||
subprocess.check_call([
|
||||
'flac', '--best', '--silent',
|
||||
'-T', 'ARTIST={}'.format(artist),
|
||||
'-T', 'TITLE={}'.format(title),
|
||||
'-T', 'ALBUM={}'.format(album),
|
||||
'-T', 'DATE={}'.format(year),
|
||||
'-T', 'TRACKNUMBER={}'.format(track),
|
||||
'-T', 'GENRE={}'.format(genre),
|
||||
'-o', output_path,
|
||||
wav_path
|
||||
], stdout=devnull, stderr=devnull)
|
||||
|
||||
return output_path
|
||||
finally:
|
||||
# Cleanup temp WAV
|
||||
if os.path.exists(wav_path):
|
||||
os.unlink(wav_path)
|
||||
|
||||
|
||||
class BeetsLibraryFixture(object):
|
||||
"""
|
||||
Creates an isolated beets library for testing.
|
||||
|
||||
Sets up:
|
||||
- Temp directory for library files
|
||||
- SQLite database with test items
|
||||
- Config pointing to the test library
|
||||
"""
|
||||
|
||||
def __init__(self, temp_dir=None):
|
||||
self.temp_dir = temp_dir or tempfile.mkdtemp(prefix='beetfs_lib_')
|
||||
self.db_path = os.path.join(self.temp_dir, 'library.db')
|
||||
self.music_dir = os.path.join(self.temp_dir, 'music')
|
||||
self.config_path = os.path.join(self.temp_dir, 'config.yaml')
|
||||
|
||||
os.makedirs(self.music_dir)
|
||||
self._create_config()
|
||||
self._create_database()
|
||||
|
||||
def _create_config(self):
|
||||
"""Create minimal beets config"""
|
||||
config_content = """
|
||||
directory: {music_dir}
|
||||
library: {db_path}
|
||||
import:
|
||||
copy: no
|
||||
move: no
|
||||
write: no
|
||||
plugins: []
|
||||
""".format(music_dir=self.music_dir, db_path=self.db_path)
|
||||
|
||||
with open(self.config_path, 'w') as f:
|
||||
f.write(config_content)
|
||||
|
||||
def _create_database(self):
|
||||
"""Create empty beets SQLite database with correct schema"""
|
||||
conn = sqlite3.connect(self.db_path)
|
||||
cursor = conn.cursor()
|
||||
|
||||
# Create items table (simplified schema matching beets)
|
||||
cursor.execute('''
|
||||
CREATE TABLE IF NOT EXISTS items (
|
||||
id INTEGER PRIMARY KEY,
|
||||
path BLOB NOT NULL,
|
||||
album_id INTEGER,
|
||||
title TEXT,
|
||||
artist TEXT,
|
||||
album TEXT,
|
||||
genre TEXT,
|
||||
year INTEGER,
|
||||
track INTEGER,
|
||||
disc INTEGER,
|
||||
length REAL,
|
||||
bitrate INTEGER,
|
||||
format TEXT,
|
||||
samplerate INTEGER,
|
||||
bitdepth INTEGER,
|
||||
channels INTEGER,
|
||||
mtime REAL,
|
||||
added REAL
|
||||
)
|
||||
''')
|
||||
|
||||
# Create albums table
|
||||
cursor.execute('''
|
||||
CREATE TABLE IF NOT EXISTS albums (
|
||||
id INTEGER PRIMARY KEY,
|
||||
artpath BLOB,
|
||||
added REAL,
|
||||
albumartist TEXT,
|
||||
album TEXT,
|
||||
genre TEXT,
|
||||
year INTEGER
|
||||
)
|
||||
''')
|
||||
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
def add_item(self, path, title='Test Track', artist='Test Artist',
|
||||
album='Test Album', genre='Test Genre', year=2024,
|
||||
track=1, format='flac'):
|
||||
"""
|
||||
Add an item to the test library database.
|
||||
|
||||
Args:
|
||||
path: Absolute path to the audio file
|
||||
title, artist, album, genre, year, track: Metadata
|
||||
format: Audio format (flac, mp3, etc.)
|
||||
|
||||
Returns:
|
||||
Item ID
|
||||
"""
|
||||
conn = sqlite3.connect(self.db_path)
|
||||
cursor = conn.cursor()
|
||||
|
||||
cursor.execute('''
|
||||
INSERT INTO items (path, title, artist, album, genre, year, track, format, mtime, added)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
''', (path, title, artist, album, genre, year, track, format,
|
||||
time.time(), time.time()))
|
||||
|
||||
item_id = cursor.lastrowid
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
return item_id
|
||||
|
||||
def get_item(self, item_id):
|
||||
"""Get item by ID"""
|
||||
conn = sqlite3.connect(self.db_path)
|
||||
cursor = conn.cursor()
|
||||
cursor.execute('SELECT * FROM items WHERE id = ?', (item_id,))
|
||||
row = cursor.fetchone()
|
||||
conn.close()
|
||||
return row
|
||||
|
||||
def update_item(self, item_id, **kwargs):
|
||||
"""Update item metadata"""
|
||||
conn = sqlite3.connect(self.db_path)
|
||||
cursor = conn.cursor()
|
||||
|
||||
set_clause = ', '.join('{} = ?'.format(k) for k in kwargs.keys())
|
||||
values = list(kwargs.values()) + [item_id]
|
||||
|
||||
cursor.execute(
|
||||
'UPDATE items SET {} WHERE id = ?'.format(set_clause),
|
||||
values
|
||||
)
|
||||
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
def cleanup(self):
|
||||
"""Remove all temp files"""
|
||||
if os.path.exists(self.temp_dir):
|
||||
shutil.rmtree(self.temp_dir, ignore_errors=True)
|
||||
|
||||
|
||||
class BeetFSTestCase(unittest.TestCase):
|
||||
"""
|
||||
Base test class for beetfs e2e tests.
|
||||
|
||||
Provides:
|
||||
- FUSE availability checking
|
||||
- Mount/unmount helpers with timeout
|
||||
- Beets library fixture management
|
||||
- Cleanup on test failure
|
||||
|
||||
Python 2.7 compatible (no subprocess.timeout, etc.)
|
||||
"""
|
||||
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
"""Check FUSE availability once for all tests"""
|
||||
if not is_fuse_available():
|
||||
raise unittest.SkipTest("fusermount not available - FUSE required for e2e tests")
|
||||
|
||||
def setUp(self):
|
||||
"""Create fresh temp directory and library for each test"""
|
||||
self.temp_dir = tempfile.mkdtemp(prefix='beetfs_test_')
|
||||
self.mount_dir = os.path.join(self.temp_dir, 'mount')
|
||||
os.makedirs(self.mount_dir)
|
||||
|
||||
self.library = BeetsLibraryFixture(
|
||||
temp_dir=os.path.join(self.temp_dir, 'library')
|
||||
)
|
||||
|
||||
self.fs_process = None
|
||||
self._timeout_timer = None
|
||||
|
||||
def tearDown(self):
|
||||
"""Cleanup: unmount filesystem and remove temp files"""
|
||||
# Cancel any pending timeout
|
||||
if self._timeout_timer:
|
||||
self._timeout_timer.cancel()
|
||||
|
||||
# Unmount if mounted
|
||||
if self.fs_process and os.path.ismount(self.mount_dir):
|
||||
self._unmount()
|
||||
|
||||
# Kill process if still running
|
||||
if self.fs_process and self.fs_process.poll() is None:
|
||||
self.fs_process.terminate()
|
||||
self._wait_for_process_exit(TEARDOWN_TIMEOUT)
|
||||
if self.fs_process.poll() is None:
|
||||
self.fs_process.kill()
|
||||
|
||||
# Cleanup library
|
||||
if hasattr(self, 'library'):
|
||||
self.library.cleanup()
|
||||
|
||||
# Remove temp directory
|
||||
if hasattr(self, 'temp_dir') and os.path.exists(self.temp_dir):
|
||||
shutil.rmtree(self.temp_dir, ignore_errors=True)
|
||||
|
||||
def mount_beetfs(self, foreground=True):
|
||||
"""
|
||||
Mount beetfs filesystem.
|
||||
|
||||
Args:
|
||||
foreground: Run in foreground mode (required for testing)
|
||||
|
||||
Raises:
|
||||
AssertionError: If mount fails or times out
|
||||
"""
|
||||
# Build command to run beetfs
|
||||
# We need to invoke beets with our test config and library
|
||||
env = os.environ.copy()
|
||||
env['BEETSDIR'] = self.library.temp_dir
|
||||
|
||||
cmd = [
|
||||
sys.executable, # Python interpreter
|
||||
'-c',
|
||||
'''
|
||||
import sys
|
||||
sys.path.insert(0, "{beetfs_root}")
|
||||
sys.path.insert(0, "{beetsplug}")
|
||||
|
||||
import os
|
||||
os.environ["BEETSDIR"] = "{beetsdir}"
|
||||
|
||||
from beets import config
|
||||
from beets.library import Library
|
||||
|
||||
# Load our test config
|
||||
config.read(user=False)
|
||||
config["directory"] = "{music_dir}"
|
||||
config["library"] = "{db_path}"
|
||||
|
||||
# Open library
|
||||
lib = Library("{db_path}")
|
||||
|
||||
# Import and run beetfs
|
||||
from beetFs import beetFileSystem
|
||||
import fuse
|
||||
|
||||
fuse.fuse_python_api = (0, 2)
|
||||
fs = beetFileSystem(
|
||||
version="%prog " + fuse.__version__,
|
||||
usage="Test mount",
|
||||
dash_s_do='setsingle'
|
||||
)
|
||||
fs.parse(errex=1)
|
||||
fs.flags = 0
|
||||
fs.multithreaded = False
|
||||
|
||||
# Set global library reference
|
||||
import beetFs
|
||||
beetFs.library = lib
|
||||
|
||||
# Build directory structure
|
||||
from beetFs import directory_structure, template_mapping
|
||||
for item in lib.items():
|
||||
mapping = beetFs.template_mapping(lib, item)
|
||||
|
||||
fs.main()
|
||||
'''.format(
|
||||
beetfs_root=BEETFS_ROOT,
|
||||
beetsplug=os.path.join(BEETFS_ROOT, 'beetsplug'),
|
||||
beetsdir=self.library.temp_dir,
|
||||
music_dir=self.library.music_dir,
|
||||
db_path=self.library.db_path
|
||||
),
|
||||
self.mount_dir
|
||||
]
|
||||
|
||||
if foreground:
|
||||
cmd.insert(-1, '-f')
|
||||
|
||||
# Start process
|
||||
self.fs_process = subprocess.Popen(
|
||||
cmd,
|
||||
env=env,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE
|
||||
)
|
||||
|
||||
# Setup timeout using threading.Timer (Python 2.7 compatible)
|
||||
self._timeout_timer = threading.Timer(
|
||||
MOUNT_TIMEOUT,
|
||||
self._mount_timeout_handler
|
||||
)
|
||||
self._timeout_timer.start()
|
||||
|
||||
# Wait for mount
|
||||
try:
|
||||
self._wait_for_mount()
|
||||
finally:
|
||||
self._timeout_timer.cancel()
|
||||
|
||||
def _mount_timeout_handler(self):
|
||||
"""Called if mount times out"""
|
||||
if self.fs_process and self.fs_process.poll() is None:
|
||||
self.fs_process.kill()
|
||||
|
||||
def _wait_for_mount(self):
|
||||
"""Poll until filesystem is mounted"""
|
||||
start = time.time()
|
||||
while time.time() - start < MOUNT_TIMEOUT:
|
||||
if os.path.ismount(self.mount_dir):
|
||||
return
|
||||
|
||||
# Check if process died
|
||||
if self.fs_process.poll() is not None:
|
||||
stdout, stderr = self.fs_process.communicate()
|
||||
self.fail(
|
||||
"beetfs process terminated prematurely with code {}.\n"
|
||||
"stdout: {}\nstderr: {}".format(
|
||||
self.fs_process.returncode,
|
||||
stdout.decode('utf-8', errors='replace'),
|
||||
stderr.decode('utf-8', errors='replace')
|
||||
)
|
||||
)
|
||||
|
||||
time.sleep(0.1)
|
||||
|
||||
self.fail("Mount timeout after {} seconds".format(MOUNT_TIMEOUT))
|
||||
|
||||
def _unmount(self):
|
||||
"""Unmount the filesystem using fusermount"""
|
||||
with open(os.devnull, 'w') as devnull:
|
||||
# Use -z for lazy unmount (handles busy mounts)
|
||||
subprocess.call(
|
||||
['fusermount', '-z', '-u', self.mount_dir],
|
||||
stdout=devnull,
|
||||
stderr=devnull
|
||||
)
|
||||
|
||||
def _wait_for_process_exit(self, timeout):
|
||||
"""Wait for process to exit (Python 2.7 compatible)"""
|
||||
start = time.time()
|
||||
while time.time() - start < timeout:
|
||||
if self.fs_process.poll() is not None:
|
||||
return True
|
||||
time.sleep(0.1)
|
||||
return False
|
||||
|
||||
def create_test_flac(self, filename='test.flac', **metadata):
|
||||
"""
|
||||
Create a synthetic FLAC in the library music directory.
|
||||
|
||||
Args:
|
||||
filename: Name for the file
|
||||
**metadata: Passed to create_synthetic_flac()
|
||||
|
||||
Returns:
|
||||
Absolute path to created file
|
||||
"""
|
||||
output_path = os.path.join(self.library.music_dir, filename)
|
||||
create_synthetic_flac(output_path, **metadata)
|
||||
return output_path
|
||||
|
||||
def add_test_track(self, filename='test.flac', **metadata):
|
||||
"""
|
||||
Create a synthetic FLAC and add it to the library.
|
||||
|
||||
Args:
|
||||
filename: Name for the file
|
||||
**metadata: Metadata for both file and database
|
||||
|
||||
Returns:
|
||||
Tuple of (file_path, item_id)
|
||||
"""
|
||||
# Default metadata
|
||||
defaults = {
|
||||
'artist': 'Test Artist',
|
||||
'title': 'Test Track',
|
||||
'album': 'Test Album',
|
||||
'genre': 'Test Genre',
|
||||
'year': '2024',
|
||||
'track': '01'
|
||||
}
|
||||
defaults.update(metadata)
|
||||
|
||||
# Create the file
|
||||
file_path = self.create_test_flac(filename, **defaults)
|
||||
|
||||
# Add to database (convert year and track to int for DB)
|
||||
item_id = self.library.add_item(
|
||||
path=file_path,
|
||||
title=defaults['title'],
|
||||
artist=defaults['artist'],
|
||||
album=defaults['album'],
|
||||
genre=defaults['genre'],
|
||||
year=int(defaults['year']),
|
||||
track=int(defaults['track']),
|
||||
format='flac'
|
||||
)
|
||||
|
||||
return file_path, item_id
|
||||
|
||||
|
||||
class RealMusicTestCase(BeetFSTestCase):
|
||||
"""
|
||||
Test case that uses real music files from qBittorrent.
|
||||
|
||||
Skipped if real music not available or E2E env var not set.
|
||||
"""
|
||||
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
super(RealMusicTestCase, cls).setUpClass()
|
||||
|
||||
# Check if E2E tests are enabled
|
||||
if not os.environ.get('E2E'):
|
||||
raise unittest.SkipTest("E2E tests disabled - set E2E=1 to enable")
|
||||
|
||||
# Check if real music is available
|
||||
if not os.path.isdir(REAL_MUSIC_PATH):
|
||||
raise unittest.SkipTest(
|
||||
"Real music not available at {}".format(REAL_MUSIC_PATH)
|
||||
)
|
||||
|
||||
def get_real_flac_files(self):
|
||||
"""Get list of real FLAC files"""
|
||||
files = []
|
||||
for f in os.listdir(REAL_MUSIC_PATH):
|
||||
if f.endswith('.flac'):
|
||||
files.append(os.path.join(REAL_MUSIC_PATH, f))
|
||||
return sorted(files)
|
||||
|
||||
|
||||
# Export test utilities
|
||||
__all__ = [
|
||||
'BeetFSTestCase',
|
||||
'RealMusicTestCase',
|
||||
'BeetsLibraryFixture',
|
||||
'create_synthetic_flac',
|
||||
'is_fuse_available',
|
||||
'is_ffmpeg_available',
|
||||
'is_flac_available',
|
||||
'BEETFS_ROOT',
|
||||
'REAL_MUSIC_PATH',
|
||||
'MOUNT_TIMEOUT',
|
||||
]
|
||||
@@ -0,0 +1,161 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from __future__ import print_function, unicode_literals
|
||||
|
||||
import errno
|
||||
import os
|
||||
import unittest
|
||||
|
||||
from conftest import BeetFSTestCase
|
||||
|
||||
|
||||
class TestENOENT(BeetFSTestCase):
|
||||
|
||||
def test_stat_nonexistent_file(self):
|
||||
self.mount_beetfs()
|
||||
|
||||
nonexistent = os.path.join(self.mount_dir, 'does_not_exist.flac')
|
||||
|
||||
with self.assertRaises(OSError) as ctx:
|
||||
os.stat(nonexistent)
|
||||
|
||||
self.assertEqual(ctx.exception.errno, errno.ENOENT)
|
||||
|
||||
def test_open_nonexistent_file(self):
|
||||
self.mount_beetfs()
|
||||
|
||||
nonexistent = os.path.join(self.mount_dir, 'does_not_exist.flac')
|
||||
|
||||
with self.assertRaises(IOError) as ctx:
|
||||
open(nonexistent, 'rb')
|
||||
|
||||
self.assertEqual(ctx.exception.errno, errno.ENOENT)
|
||||
|
||||
def test_listdir_nonexistent_directory(self):
|
||||
self.mount_beetfs()
|
||||
|
||||
nonexistent = os.path.join(self.mount_dir, 'Nonexistent Artist')
|
||||
|
||||
with self.assertRaises(OSError) as ctx:
|
||||
os.listdir(nonexistent)
|
||||
|
||||
self.assertEqual(ctx.exception.errno, errno.ENOENT)
|
||||
|
||||
def test_stat_deeply_nested_nonexistent(self):
|
||||
self.mount_beetfs()
|
||||
|
||||
nonexistent = os.path.join(
|
||||
self.mount_dir, 'Artist', 'Album', 'Track', 'Nested', 'Path'
|
||||
)
|
||||
|
||||
with self.assertRaises(OSError) as ctx:
|
||||
os.stat(nonexistent)
|
||||
|
||||
self.assertEqual(ctx.exception.errno, errno.ENOENT)
|
||||
|
||||
|
||||
class TestEOPNOTSUPP(BeetFSTestCase):
|
||||
|
||||
def test_mkdir_not_supported(self):
|
||||
self.mount_beetfs()
|
||||
|
||||
new_dir = os.path.join(self.mount_dir, 'new_directory')
|
||||
|
||||
with self.assertRaises(OSError) as ctx:
|
||||
os.mkdir(new_dir)
|
||||
|
||||
self.assertIn(ctx.exception.errno, [errno.EOPNOTSUPP, errno.EACCES, errno.EROFS])
|
||||
|
||||
def test_unlink_not_supported(self):
|
||||
self.add_test_track(filename='delete_test.flac')
|
||||
self.mount_beetfs()
|
||||
|
||||
tracks = self._find_any_track()
|
||||
if not tracks:
|
||||
self.skipTest("No tracks found")
|
||||
|
||||
with self.assertRaises(OSError) as ctx:
|
||||
os.unlink(tracks[0])
|
||||
|
||||
self.assertIn(ctx.exception.errno, [errno.EOPNOTSUPP, errno.EACCES, errno.EROFS])
|
||||
|
||||
def test_rmdir_not_supported(self):
|
||||
self.add_test_track(filename='rmdir_test.flac', artist='Rmdir Artist')
|
||||
self.mount_beetfs()
|
||||
|
||||
artist_dir = os.path.join(self.mount_dir, 'Rmdir Artist')
|
||||
if not os.path.isdir(artist_dir):
|
||||
self.skipTest("Artist directory not found")
|
||||
|
||||
with self.assertRaises(OSError) as ctx:
|
||||
os.rmdir(artist_dir)
|
||||
|
||||
self.assertIn(ctx.exception.errno, [errno.EOPNOTSUPP, errno.EACCES, errno.EROFS, errno.ENOTEMPTY])
|
||||
|
||||
def test_rename_not_supported(self):
|
||||
self.add_test_track(filename='rename_test.flac')
|
||||
self.mount_beetfs()
|
||||
|
||||
tracks = self._find_any_track()
|
||||
if not tracks:
|
||||
self.skipTest("No tracks found")
|
||||
|
||||
new_path = tracks[0] + '.renamed'
|
||||
|
||||
with self.assertRaises(OSError) as ctx:
|
||||
os.rename(tracks[0], new_path)
|
||||
|
||||
self.assertIn(ctx.exception.errno, [errno.EOPNOTSUPP, errno.EACCES, errno.EROFS])
|
||||
|
||||
def test_symlink_not_supported(self):
|
||||
self.mount_beetfs()
|
||||
|
||||
link_path = os.path.join(self.mount_dir, 'test_link')
|
||||
|
||||
with self.assertRaises(OSError) as ctx:
|
||||
os.symlink('/tmp', link_path)
|
||||
|
||||
self.assertIn(ctx.exception.errno, [errno.EOPNOTSUPP, errno.EACCES, errno.EROFS])
|
||||
|
||||
def test_chmod_not_supported(self):
|
||||
self.add_test_track(filename='chmod_test.flac')
|
||||
self.mount_beetfs()
|
||||
|
||||
tracks = self._find_any_track()
|
||||
if not tracks:
|
||||
self.skipTest("No tracks found")
|
||||
|
||||
with self.assertRaises(OSError) as ctx:
|
||||
os.chmod(tracks[0], 0o777)
|
||||
|
||||
self.assertIn(ctx.exception.errno, [errno.EOPNOTSUPP, errno.EACCES, errno.EROFS])
|
||||
|
||||
def _find_any_track(self):
|
||||
results = []
|
||||
for root, dirs, files in os.walk(self.mount_dir):
|
||||
for f in files:
|
||||
if f.endswith('.flac'):
|
||||
results.append(os.path.join(root, f))
|
||||
return results
|
||||
|
||||
|
||||
class TestEdgeCaseErrors(BeetFSTestCase):
|
||||
|
||||
def test_access_empty_path(self):
|
||||
self.mount_beetfs()
|
||||
|
||||
with self.assertRaises((OSError, IOError)):
|
||||
os.stat(os.path.join(self.mount_dir, ''))
|
||||
|
||||
def test_open_directory_as_file(self):
|
||||
self.add_test_track(filename='test.flac', artist='Dir Artist')
|
||||
self.mount_beetfs()
|
||||
|
||||
artist_dir = os.path.join(self.mount_dir, 'Dir Artist')
|
||||
if os.path.isdir(artist_dir):
|
||||
with self.assertRaises((OSError, IOError)):
|
||||
with open(artist_dir, 'rb') as f:
|
||||
f.read()
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -0,0 +1,141 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
Tests to verify the nested methods bug in beetFs.py.
|
||||
|
||||
Oracle discovered that lines 758-1144 are indented inside access() method,
|
||||
making readdir, open, read, write, etc. unreachable as class methods.
|
||||
These tests will fail until the bug is fixed.
|
||||
"""
|
||||
from __future__ import print_function, unicode_literals
|
||||
|
||||
import os
|
||||
import sys
|
||||
import unittest
|
||||
|
||||
from conftest import BEETFS_ROOT
|
||||
|
||||
sys.path.insert(0, os.path.join(BEETFS_ROOT, 'beetsplug'))
|
||||
|
||||
|
||||
class TestNestedMethodsBug(unittest.TestCase):
|
||||
"""Verify FUSE methods exist at class level (not nested inside access)."""
|
||||
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
try:
|
||||
from beetFs import beetFileSystem
|
||||
cls.fs_class = beetFileSystem
|
||||
except ImportError as e:
|
||||
raise unittest.SkipTest("Cannot import beetFs: {}".format(e))
|
||||
|
||||
def test_readdir_is_class_method(self):
|
||||
self.assertTrue(
|
||||
hasattr(self.fs_class, 'readdir'),
|
||||
"readdir should be a class method, not nested inside access()"
|
||||
)
|
||||
|
||||
def test_open_is_class_method(self):
|
||||
self.assertTrue(
|
||||
hasattr(self.fs_class, 'open'),
|
||||
"open should be a class method, not nested inside access()"
|
||||
)
|
||||
|
||||
def test_read_is_class_method(self):
|
||||
self.assertTrue(
|
||||
hasattr(self.fs_class, 'read'),
|
||||
"read should be a class method, not nested inside access()"
|
||||
)
|
||||
|
||||
def test_write_is_class_method(self):
|
||||
self.assertTrue(
|
||||
hasattr(self.fs_class, 'write'),
|
||||
"write should be a class method, not nested inside access()"
|
||||
)
|
||||
|
||||
def test_release_is_class_method(self):
|
||||
self.assertTrue(
|
||||
hasattr(self.fs_class, 'release'),
|
||||
"release should be a class method, not nested inside access()"
|
||||
)
|
||||
|
||||
def test_opendir_is_class_method(self):
|
||||
self.assertTrue(
|
||||
hasattr(self.fs_class, 'opendir'),
|
||||
"opendir should be a class method, not nested inside access()"
|
||||
)
|
||||
|
||||
def test_releasedir_is_class_method(self):
|
||||
self.assertTrue(
|
||||
hasattr(self.fs_class, 'releasedir'),
|
||||
"releasedir should be a class method, not nested inside access()"
|
||||
)
|
||||
|
||||
def test_mkdir_is_class_method(self):
|
||||
self.assertTrue(
|
||||
hasattr(self.fs_class, 'mkdir'),
|
||||
"mkdir should be a class method, not nested inside access()"
|
||||
)
|
||||
|
||||
def test_unlink_is_class_method(self):
|
||||
self.assertTrue(
|
||||
hasattr(self.fs_class, 'unlink'),
|
||||
"unlink should be a class method, not nested inside access()"
|
||||
)
|
||||
|
||||
def test_truncate_is_class_method(self):
|
||||
self.assertTrue(
|
||||
hasattr(self.fs_class, 'truncate'),
|
||||
"truncate should be a class method, not nested inside access()"
|
||||
)
|
||||
|
||||
def test_fgetattr_is_class_method(self):
|
||||
self.assertTrue(
|
||||
hasattr(self.fs_class, 'fgetattr'),
|
||||
"fgetattr should be a class method, not nested inside access()"
|
||||
)
|
||||
|
||||
def test_flush_is_class_method(self):
|
||||
self.assertTrue(
|
||||
hasattr(self.fs_class, 'flush'),
|
||||
"flush should be a class method, not nested inside access()"
|
||||
)
|
||||
|
||||
def test_fsync_is_class_method(self):
|
||||
self.assertTrue(
|
||||
hasattr(self.fs_class, 'fsync'),
|
||||
"fsync should be a class method, not nested inside access()"
|
||||
)
|
||||
|
||||
|
||||
class TestMethodsCallable(unittest.TestCase):
|
||||
"""Verify methods can be called (not just exist)."""
|
||||
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
try:
|
||||
from beetFs import beetFileSystem
|
||||
cls.fs_class = beetFileSystem
|
||||
except ImportError as e:
|
||||
raise unittest.SkipTest("Cannot import beetFs: {}".format(e))
|
||||
|
||||
def test_readdir_callable(self):
|
||||
method = getattr(self.fs_class, 'readdir', None)
|
||||
if method is None:
|
||||
self.skipTest("readdir not found - nested bug not fixed yet")
|
||||
self.assertTrue(callable(method))
|
||||
|
||||
def test_open_callable(self):
|
||||
method = getattr(self.fs_class, 'open', None)
|
||||
if method is None:
|
||||
self.skipTest("open not found - nested bug not fixed yet")
|
||||
self.assertTrue(callable(method))
|
||||
|
||||
def test_read_callable(self):
|
||||
method = getattr(self.fs_class, 'read', None)
|
||||
if method is None:
|
||||
self.skipTest("read not found - nested bug not fixed yet")
|
||||
self.assertTrue(callable(method))
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -0,0 +1,296 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
Tests for file reading with metadata overlay.
|
||||
|
||||
This is the CORE FEATURE of beetfs:
|
||||
- File headers contain metadata from beets database
|
||||
- Audio data passes through unchanged from original file
|
||||
"""
|
||||
from __future__ import print_function, unicode_literals
|
||||
|
||||
import os
|
||||
import unittest
|
||||
from io import BytesIO
|
||||
|
||||
from conftest import BeetFSTestCase, is_ffmpeg_available, is_flac_available
|
||||
|
||||
|
||||
class TestReadBasic(BeetFSTestCase):
|
||||
|
||||
def test_open_file(self):
|
||||
file_path, item_id = self.add_test_track(
|
||||
filename='test.flac',
|
||||
artist='Test Artist',
|
||||
title='Test Track'
|
||||
)
|
||||
|
||||
self.mount_beetfs()
|
||||
|
||||
entries = self._find_track_path('Test Artist', 'Test Track')
|
||||
if not entries:
|
||||
self.skipTest("Could not find track in mounted filesystem")
|
||||
|
||||
track_path = entries[0]
|
||||
|
||||
with open(track_path, 'rb') as f:
|
||||
data = f.read(1024)
|
||||
|
||||
self.assertIsInstance(data, bytes)
|
||||
self.assertTrue(len(data) > 0)
|
||||
|
||||
def test_read_returns_bytes(self):
|
||||
self.add_test_track(filename='test.flac')
|
||||
self.mount_beetfs()
|
||||
|
||||
entries = self._find_any_track()
|
||||
if not entries:
|
||||
self.skipTest("No tracks found")
|
||||
|
||||
with open(entries[0], 'rb') as f:
|
||||
data = f.read(100)
|
||||
|
||||
self.assertIsInstance(data, bytes)
|
||||
|
||||
def test_read_flac_magic_bytes(self):
|
||||
self.add_test_track(filename='test.flac')
|
||||
self.mount_beetfs()
|
||||
|
||||
entries = self._find_any_track()
|
||||
if not entries:
|
||||
self.skipTest("No tracks found")
|
||||
|
||||
with open(entries[0], 'rb') as f:
|
||||
magic = f.read(4)
|
||||
|
||||
self.assertEqual(magic, b'fLaC', "FLAC files should start with 'fLaC' magic bytes")
|
||||
|
||||
def _find_track_path(self, artist, title):
|
||||
"""Find track path in mounted filesystem."""
|
||||
results = []
|
||||
for root, dirs, files in os.walk(self.mount_dir):
|
||||
for f in files:
|
||||
if title.lower() in f.lower():
|
||||
results.append(os.path.join(root, f))
|
||||
return results
|
||||
|
||||
def _find_any_track(self):
|
||||
"""Find any track in mounted filesystem."""
|
||||
results = []
|
||||
for root, dirs, files in os.walk(self.mount_dir):
|
||||
for f in files:
|
||||
if f.endswith('.flac'):
|
||||
results.append(os.path.join(root, f))
|
||||
return results
|
||||
|
||||
|
||||
@unittest.skipUnless(is_ffmpeg_available() and is_flac_available(),
|
||||
"ffmpeg and flac required for metadata overlay tests")
|
||||
class TestMetadataOverlay(BeetFSTestCase):
|
||||
"""
|
||||
Tests that verify the core metadata overlay functionality.
|
||||
|
||||
The key insight: beetfs should return metadata from the DATABASE,
|
||||
not from the original FILE. This allows presenting different metadata
|
||||
than what's embedded in the file.
|
||||
"""
|
||||
|
||||
def setUp(self):
|
||||
super(TestMetadataOverlay, self).setUp()
|
||||
|
||||
try:
|
||||
import mutagen.flac
|
||||
self.mutagen_available = True
|
||||
except ImportError:
|
||||
self.mutagen_available = False
|
||||
|
||||
def test_overlay_artist_from_database(self):
|
||||
if not self.mutagen_available:
|
||||
self.skipTest("mutagen required for metadata verification")
|
||||
|
||||
import mutagen.flac
|
||||
|
||||
file_path, item_id = self.add_test_track(
|
||||
filename='overlay_test.flac',
|
||||
artist='File Artist',
|
||||
title='Test Track',
|
||||
album='Test Album'
|
||||
)
|
||||
|
||||
self.library.update_item(item_id, artist='Database Artist')
|
||||
|
||||
self.mount_beetfs()
|
||||
|
||||
mounted_tracks = self._find_track_in_mount('Database Artist')
|
||||
if not mounted_tracks:
|
||||
self.skipTest("Track not found under 'Database Artist' - path uses DB metadata")
|
||||
|
||||
with open(mounted_tracks[0], 'rb') as f:
|
||||
mounted_data = f.read()
|
||||
|
||||
mounted_flac = mutagen.flac.FLAC(BytesIO(mounted_data))
|
||||
|
||||
artist_tag = mounted_flac.get('artist', [None])[0]
|
||||
self.assertEqual(artist_tag, 'Database Artist',
|
||||
"Mounted file should have artist from database, not file")
|
||||
|
||||
def test_overlay_title_from_database(self):
|
||||
if not self.mutagen_available:
|
||||
self.skipTest("mutagen required for metadata verification")
|
||||
|
||||
import mutagen.flac
|
||||
|
||||
file_path, item_id = self.add_test_track(
|
||||
filename='title_test.flac',
|
||||
artist='Test Artist',
|
||||
title='File Title'
|
||||
)
|
||||
|
||||
self.library.update_item(item_id, title='Database Title')
|
||||
|
||||
self.mount_beetfs()
|
||||
|
||||
mounted_tracks = self._find_any_track()
|
||||
if not mounted_tracks:
|
||||
self.skipTest("No tracks found")
|
||||
|
||||
with open(mounted_tracks[0], 'rb') as f:
|
||||
mounted_data = f.read()
|
||||
|
||||
mounted_flac = mutagen.flac.FLAC(BytesIO(mounted_data))
|
||||
|
||||
title_tag = mounted_flac.get('title', [None])[0]
|
||||
self.assertEqual(title_tag, 'Database Title')
|
||||
|
||||
def test_original_file_unchanged(self):
|
||||
if not self.mutagen_available:
|
||||
self.skipTest("mutagen required for metadata verification")
|
||||
|
||||
import mutagen.flac
|
||||
|
||||
file_path, item_id = self.add_test_track(
|
||||
filename='original_test.flac',
|
||||
artist='Original Artist',
|
||||
title='Original Title'
|
||||
)
|
||||
|
||||
self.library.update_item(item_id, artist='Modified Artist')
|
||||
|
||||
self.mount_beetfs()
|
||||
|
||||
original_flac = mutagen.flac.FLAC(file_path)
|
||||
original_artist = original_flac.get('artist', [None])[0]
|
||||
|
||||
self.assertEqual(original_artist, 'Original Artist',
|
||||
"Original file should remain unchanged")
|
||||
|
||||
def test_audio_data_passthrough(self):
|
||||
file_path, item_id = self.add_test_track(
|
||||
filename='audio_test.flac',
|
||||
artist='Test Artist'
|
||||
)
|
||||
|
||||
with open(file_path, 'rb') as f:
|
||||
original_data = f.read()
|
||||
|
||||
self.mount_beetfs()
|
||||
|
||||
mounted_tracks = self._find_any_track()
|
||||
if not mounted_tracks:
|
||||
self.skipTest("No tracks found")
|
||||
|
||||
with open(mounted_tracks[0], 'rb') as f:
|
||||
mounted_data = f.read()
|
||||
|
||||
original_audio_start = original_data.find(b'\xff\xf8')
|
||||
mounted_audio_start = mounted_data.find(b'\xff\xf8')
|
||||
|
||||
if original_audio_start > 0 and mounted_audio_start > 0:
|
||||
original_audio = original_data[original_audio_start:original_audio_start + 1000]
|
||||
mounted_audio = mounted_data[mounted_audio_start:mounted_audio_start + 1000]
|
||||
|
||||
self.assertEqual(original_audio, mounted_audio,
|
||||
"Audio data should pass through unchanged")
|
||||
|
||||
def _find_track_in_mount(self, artist_name):
|
||||
"""Find tracks under a specific artist directory."""
|
||||
results = []
|
||||
artist_dir = os.path.join(self.mount_dir, artist_name)
|
||||
if not os.path.isdir(artist_dir):
|
||||
return results
|
||||
|
||||
for root, dirs, files in os.walk(artist_dir):
|
||||
for f in files:
|
||||
if f.endswith('.flac'):
|
||||
results.append(os.path.join(root, f))
|
||||
return results
|
||||
|
||||
def _find_any_track(self):
|
||||
"""Find any track in mounted filesystem."""
|
||||
results = []
|
||||
for root, dirs, files in os.walk(self.mount_dir):
|
||||
for f in files:
|
||||
if f.endswith('.flac'):
|
||||
results.append(os.path.join(root, f))
|
||||
return results
|
||||
|
||||
|
||||
class TestReadFullFile(BeetFSTestCase):
|
||||
|
||||
def test_read_entire_file(self):
|
||||
file_path, item_id = self.add_test_track(filename='full_read.flac')
|
||||
|
||||
original_size = os.path.getsize(file_path)
|
||||
|
||||
self.mount_beetfs()
|
||||
|
||||
mounted_tracks = self._find_any_track()
|
||||
if not mounted_tracks:
|
||||
self.skipTest("No tracks found")
|
||||
|
||||
with open(mounted_tracks[0], 'rb') as f:
|
||||
data = f.read()
|
||||
|
||||
self.assertTrue(len(data) > 0)
|
||||
self.assertAlmostEqual(len(data), original_size, delta=original_size * 0.5)
|
||||
|
||||
def test_read_with_offset(self):
|
||||
self.add_test_track(filename='offset_test.flac')
|
||||
self.mount_beetfs()
|
||||
|
||||
mounted_tracks = self._find_any_track()
|
||||
if not mounted_tracks:
|
||||
self.skipTest("No tracks found")
|
||||
|
||||
with open(mounted_tracks[0], 'rb') as f:
|
||||
f.seek(100)
|
||||
data = f.read(100)
|
||||
|
||||
self.assertEqual(len(data), 100)
|
||||
|
||||
def test_multiple_reads(self):
|
||||
self.add_test_track(filename='multi_read.flac')
|
||||
self.mount_beetfs()
|
||||
|
||||
mounted_tracks = self._find_any_track()
|
||||
if not mounted_tracks:
|
||||
self.skipTest("No tracks found")
|
||||
|
||||
with open(mounted_tracks[0], 'rb') as f:
|
||||
chunk1 = f.read(512)
|
||||
chunk2 = f.read(512)
|
||||
chunk3 = f.read(512)
|
||||
|
||||
self.assertEqual(len(chunk1), 512)
|
||||
self.assertEqual(len(chunk2), 512)
|
||||
|
||||
def _find_any_track(self):
|
||||
results = []
|
||||
for root, dirs, files in os.walk(self.mount_dir):
|
||||
for f in files:
|
||||
if f.endswith('.flac'):
|
||||
results.append(os.path.join(root, f))
|
||||
return results
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -0,0 +1,179 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Tests for directory listing operations."""
|
||||
from __future__ import print_function, unicode_literals
|
||||
|
||||
import os
|
||||
import unittest
|
||||
|
||||
from conftest import BeetFSTestCase
|
||||
|
||||
|
||||
class TestReaddirEmpty(BeetFSTestCase):
|
||||
|
||||
def test_list_empty_root(self):
|
||||
self.mount_beetfs()
|
||||
|
||||
entries = os.listdir(self.mount_dir)
|
||||
self.assertEqual(entries, [])
|
||||
|
||||
def test_list_root_returns_list(self):
|
||||
self.mount_beetfs()
|
||||
|
||||
entries = os.listdir(self.mount_dir)
|
||||
self.assertIsInstance(entries, list)
|
||||
|
||||
|
||||
class TestReaddirWithContent(BeetFSTestCase):
|
||||
|
||||
def setUp(self):
|
||||
super(TestReaddirWithContent, self).setUp()
|
||||
|
||||
self.add_test_track(
|
||||
filename='track01.flac',
|
||||
artist='Pink Floyd',
|
||||
title='Comfortably Numb',
|
||||
album='The Wall',
|
||||
year='1979',
|
||||
track='01'
|
||||
)
|
||||
self.add_test_track(
|
||||
filename='track02.flac',
|
||||
artist='Pink Floyd',
|
||||
title='Another Brick in the Wall',
|
||||
album='The Wall',
|
||||
year='1979',
|
||||
track='02'
|
||||
)
|
||||
self.add_test_track(
|
||||
filename='track03.flac',
|
||||
artist='Led Zeppelin',
|
||||
title='Stairway to Heaven',
|
||||
album='Led Zeppelin IV',
|
||||
year='1971',
|
||||
track='01'
|
||||
)
|
||||
|
||||
def test_list_root_shows_artists(self):
|
||||
self.mount_beetfs()
|
||||
|
||||
entries = os.listdir(self.mount_dir)
|
||||
|
||||
self.assertIn('Pink Floyd', entries)
|
||||
self.assertIn('Led Zeppelin', entries)
|
||||
|
||||
def test_list_artist_shows_albums(self):
|
||||
self.mount_beetfs()
|
||||
|
||||
artist_dir = os.path.join(self.mount_dir, 'Pink Floyd')
|
||||
entries = os.listdir(artist_dir)
|
||||
|
||||
self.assertTrue(any('The Wall' in e for e in entries))
|
||||
|
||||
def test_list_album_shows_tracks(self):
|
||||
self.mount_beetfs()
|
||||
|
||||
artist_dir = os.path.join(self.mount_dir, 'Pink Floyd')
|
||||
albums = os.listdir(artist_dir)
|
||||
wall_album = [a for a in albums if 'The Wall' in a][0]
|
||||
|
||||
album_dir = os.path.join(artist_dir, wall_album)
|
||||
tracks = os.listdir(album_dir)
|
||||
|
||||
self.assertEqual(len(tracks), 2)
|
||||
|
||||
def test_path_format_includes_year(self):
|
||||
self.mount_beetfs()
|
||||
|
||||
artist_dir = os.path.join(self.mount_dir, 'Pink Floyd')
|
||||
albums = os.listdir(artist_dir)
|
||||
|
||||
wall_album = [a for a in albums if 'The Wall' in a][0]
|
||||
self.assertIn('1979', wall_album)
|
||||
|
||||
def test_path_format_includes_format(self):
|
||||
self.mount_beetfs()
|
||||
|
||||
artist_dir = os.path.join(self.mount_dir, 'Pink Floyd')
|
||||
albums = os.listdir(artist_dir)
|
||||
|
||||
wall_album = [a for a in albums if 'The Wall' in a][0]
|
||||
self.assertIn('FLAC', wall_album.upper())
|
||||
|
||||
def test_track_filename_includes_number(self):
|
||||
self.mount_beetfs()
|
||||
|
||||
artist_dir = os.path.join(self.mount_dir, 'Pink Floyd')
|
||||
albums = os.listdir(artist_dir)
|
||||
wall_album = [a for a in albums if 'The Wall' in a][0]
|
||||
album_dir = os.path.join(artist_dir, wall_album)
|
||||
|
||||
tracks = os.listdir(album_dir)
|
||||
|
||||
self.assertTrue(any(t.startswith('01') or t.startswith('1') for t in tracks))
|
||||
|
||||
|
||||
class TestReaddirUnicode(BeetFSTestCase):
|
||||
|
||||
def test_unicode_artist_name(self):
|
||||
self.add_test_track(
|
||||
filename='bjork.flac',
|
||||
artist='Björk',
|
||||
title='Hyperballad',
|
||||
album='Post'
|
||||
)
|
||||
|
||||
self.mount_beetfs()
|
||||
entries = os.listdir(self.mount_dir)
|
||||
|
||||
self.assertTrue(
|
||||
any('Bj' in e for e in entries),
|
||||
"Unicode artist name should appear in listing"
|
||||
)
|
||||
|
||||
def test_unicode_album_name(self):
|
||||
self.add_test_track(
|
||||
filename='sigur.flac',
|
||||
artist='Sigur Ros',
|
||||
title='Hoppipolla',
|
||||
album='Takk...'
|
||||
)
|
||||
|
||||
self.mount_beetfs()
|
||||
artist_dir = os.path.join(self.mount_dir, 'Sigur Ros')
|
||||
|
||||
if os.path.isdir(artist_dir):
|
||||
albums = os.listdir(artist_dir)
|
||||
self.assertTrue(len(albums) > 0)
|
||||
|
||||
|
||||
class TestReaddirSpecialChars(BeetFSTestCase):
|
||||
|
||||
def test_artist_with_ampersand(self):
|
||||
self.add_test_track(
|
||||
filename='guns.flac',
|
||||
artist="Guns N' Roses",
|
||||
title='Welcome to the Jungle',
|
||||
album='Appetite for Destruction'
|
||||
)
|
||||
|
||||
self.mount_beetfs()
|
||||
entries = os.listdir(self.mount_dir)
|
||||
|
||||
self.assertTrue(len(entries) > 0)
|
||||
|
||||
def test_title_with_question_mark(self):
|
||||
self.add_test_track(
|
||||
filename='who.flac',
|
||||
artist='The Who',
|
||||
title='Who Are You?',
|
||||
album='Who Are You'
|
||||
)
|
||||
|
||||
self.mount_beetfs()
|
||||
entries = os.listdir(self.mount_dir)
|
||||
|
||||
self.assertIn('The Who', entries)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -0,0 +1,83 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Smoke tests for beetfs mount/unmount lifecycle."""
|
||||
from __future__ import print_function, unicode_literals
|
||||
|
||||
import os
|
||||
import unittest
|
||||
|
||||
from conftest import BeetFSTestCase, is_fuse_available
|
||||
|
||||
|
||||
class TestMountLifecycle(BeetFSTestCase):
|
||||
|
||||
def test_fuse_available(self):
|
||||
self.assertTrue(is_fuse_available())
|
||||
|
||||
def test_mount_empty_library(self):
|
||||
self.mount_beetfs()
|
||||
self.assertTrue(os.path.ismount(self.mount_dir))
|
||||
|
||||
def test_unmount_clean(self):
|
||||
self.mount_beetfs()
|
||||
self.assertTrue(os.path.ismount(self.mount_dir))
|
||||
|
||||
self._unmount()
|
||||
self._wait_for_process_exit(5)
|
||||
|
||||
self.assertFalse(os.path.ismount(self.mount_dir))
|
||||
|
||||
def test_mount_with_single_track(self):
|
||||
self.add_test_track(
|
||||
filename='track01.flac',
|
||||
artist='Smoke Test Artist',
|
||||
title='Smoke Test Track',
|
||||
album='Smoke Test Album'
|
||||
)
|
||||
|
||||
self.mount_beetfs()
|
||||
self.assertTrue(os.path.ismount(self.mount_dir))
|
||||
|
||||
def test_mount_directory_accessible(self):
|
||||
self.mount_beetfs()
|
||||
|
||||
entries = os.listdir(self.mount_dir)
|
||||
self.assertIsInstance(entries, list)
|
||||
|
||||
def test_temp_directory_created(self):
|
||||
self.assertTrue(os.path.isdir(self.temp_dir))
|
||||
self.assertTrue(os.path.isdir(self.mount_dir))
|
||||
|
||||
def test_library_fixture_created(self):
|
||||
self.assertTrue(os.path.isfile(self.library.db_path))
|
||||
self.assertTrue(os.path.isdir(self.library.music_dir))
|
||||
|
||||
|
||||
class TestMountWithContent(BeetFSTestCase):
|
||||
|
||||
def test_mount_with_multiple_tracks(self):
|
||||
for i in range(3):
|
||||
self.add_test_track(
|
||||
filename='track{:02d}.flac'.format(i + 1),
|
||||
artist='Test Artist',
|
||||
title='Track {}'.format(i + 1),
|
||||
album='Test Album',
|
||||
track=str(i + 1)
|
||||
)
|
||||
|
||||
self.mount_beetfs()
|
||||
self.assertTrue(os.path.ismount(self.mount_dir))
|
||||
|
||||
def test_mount_with_unicode_metadata(self):
|
||||
self.add_test_track(
|
||||
filename='unicode_track.flac',
|
||||
artist='Tëst Ärtîst',
|
||||
title='Ünïcödé Träck',
|
||||
album='Spëcîäl Chäräctërs'
|
||||
)
|
||||
|
||||
self.mount_beetfs()
|
||||
self.assertTrue(os.path.ismount(self.mount_dir))
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -0,0 +1,138 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from __future__ import print_function, unicode_literals
|
||||
|
||||
import os
|
||||
import stat
|
||||
import unittest
|
||||
|
||||
from conftest import BeetFSTestCase
|
||||
|
||||
|
||||
class TestStatFile(BeetFSTestCase):
|
||||
|
||||
def setUp(self):
|
||||
super(TestStatFile, self).setUp()
|
||||
self.file_path, self.item_id = self.add_test_track(
|
||||
filename='stat_test.flac',
|
||||
artist='Test Artist',
|
||||
title='Test Track',
|
||||
album='Test Album'
|
||||
)
|
||||
|
||||
def test_stat_returns_stat_result(self):
|
||||
self.mount_beetfs()
|
||||
|
||||
tracks = self._find_any_track()
|
||||
if not tracks:
|
||||
self.skipTest("No tracks found")
|
||||
|
||||
result = os.stat(tracks[0])
|
||||
self.assertTrue(hasattr(result, 'st_mode'))
|
||||
self.assertTrue(hasattr(result, 'st_size'))
|
||||
|
||||
def test_stat_file_mode(self):
|
||||
self.mount_beetfs()
|
||||
|
||||
tracks = self._find_any_track()
|
||||
if not tracks:
|
||||
self.skipTest("No tracks found")
|
||||
|
||||
result = os.stat(tracks[0])
|
||||
self.assertTrue(stat.S_ISREG(result.st_mode))
|
||||
|
||||
def test_stat_file_size_positive(self):
|
||||
self.mount_beetfs()
|
||||
|
||||
tracks = self._find_any_track()
|
||||
if not tracks:
|
||||
self.skipTest("No tracks found")
|
||||
|
||||
result = os.stat(tracks[0])
|
||||
self.assertGreater(result.st_size, 0)
|
||||
|
||||
def test_stat_file_size_matches_original(self):
|
||||
original_size = os.path.getsize(self.file_path)
|
||||
|
||||
self.mount_beetfs()
|
||||
|
||||
tracks = self._find_any_track()
|
||||
if not tracks:
|
||||
self.skipTest("No tracks found")
|
||||
|
||||
result = os.stat(tracks[0])
|
||||
self.assertAlmostEqual(result.st_size, original_size, delta=original_size * 0.5)
|
||||
|
||||
def _find_any_track(self):
|
||||
results = []
|
||||
for root, dirs, files in os.walk(self.mount_dir):
|
||||
for f in files:
|
||||
if f.endswith('.flac'):
|
||||
results.append(os.path.join(root, f))
|
||||
return results
|
||||
|
||||
|
||||
class TestStatDirectory(BeetFSTestCase):
|
||||
|
||||
def test_stat_root(self):
|
||||
self.mount_beetfs()
|
||||
|
||||
result = os.stat(self.mount_dir)
|
||||
self.assertTrue(stat.S_ISDIR(result.st_mode))
|
||||
|
||||
def test_stat_artist_directory(self):
|
||||
self.add_test_track(
|
||||
filename='test.flac',
|
||||
artist='Stat Test Artist'
|
||||
)
|
||||
|
||||
self.mount_beetfs()
|
||||
|
||||
artist_dir = os.path.join(self.mount_dir, 'Stat Test Artist')
|
||||
if os.path.isdir(artist_dir):
|
||||
result = os.stat(artist_dir)
|
||||
self.assertTrue(stat.S_ISDIR(result.st_mode))
|
||||
|
||||
|
||||
class TestStatfs(BeetFSTestCase):
|
||||
|
||||
def test_statvfs_returns_result(self):
|
||||
self.mount_beetfs()
|
||||
|
||||
result = os.statvfs(self.mount_dir)
|
||||
self.assertTrue(hasattr(result, 'f_bsize'))
|
||||
self.assertTrue(hasattr(result, 'f_blocks'))
|
||||
|
||||
|
||||
class TestAccess(BeetFSTestCase):
|
||||
|
||||
def test_access_root_readable(self):
|
||||
self.mount_beetfs()
|
||||
|
||||
self.assertTrue(os.access(self.mount_dir, os.R_OK))
|
||||
|
||||
def test_access_root_executable(self):
|
||||
self.mount_beetfs()
|
||||
|
||||
self.assertTrue(os.access(self.mount_dir, os.X_OK))
|
||||
|
||||
def test_access_file_readable(self):
|
||||
self.add_test_track(filename='access_test.flac')
|
||||
self.mount_beetfs()
|
||||
|
||||
tracks = self._find_any_track()
|
||||
if not tracks:
|
||||
self.skipTest("No tracks found")
|
||||
|
||||
self.assertTrue(os.access(tracks[0], os.R_OK))
|
||||
|
||||
def _find_any_track(self):
|
||||
results = []
|
||||
for root, dirs, files in os.walk(self.mount_dir):
|
||||
for f in files:
|
||||
if f.endswith('.flac'):
|
||||
results.append(os.path.join(root, f))
|
||||
return results
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -0,0 +1,161 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from __future__ import print_function, unicode_literals
|
||||
|
||||
import os
|
||||
import unittest
|
||||
from io import BytesIO
|
||||
|
||||
from conftest import BeetFSTestCase, is_ffmpeg_available, is_flac_available
|
||||
|
||||
|
||||
@unittest.skipUnless(is_ffmpeg_available() and is_flac_available(),
|
||||
"ffmpeg and flac required for write tests")
|
||||
class TestWriteMetadata(BeetFSTestCase):
|
||||
|
||||
def setUp(self):
|
||||
super(TestWriteMetadata, self).setUp()
|
||||
try:
|
||||
import mutagen.flac
|
||||
self.mutagen = mutagen.flac
|
||||
except ImportError:
|
||||
self.mutagen = None
|
||||
|
||||
def test_write_opens_without_error(self):
|
||||
self.add_test_track(filename='write_test.flac')
|
||||
self.mount_beetfs()
|
||||
|
||||
tracks = self._find_any_track()
|
||||
if not tracks:
|
||||
self.skipTest("No tracks found")
|
||||
|
||||
try:
|
||||
with open(tracks[0], 'r+b') as f:
|
||||
pass
|
||||
except IOError:
|
||||
self.skipTest("Write not supported")
|
||||
|
||||
def test_write_to_header_updates_db(self):
|
||||
if not self.mutagen:
|
||||
self.skipTest("mutagen required")
|
||||
|
||||
file_path, item_id = self.add_test_track(
|
||||
filename='db_write_test.flac',
|
||||
artist='Original Artist',
|
||||
title='Original Title'
|
||||
)
|
||||
|
||||
self.mount_beetfs()
|
||||
|
||||
tracks = self._find_any_track()
|
||||
if not tracks:
|
||||
self.skipTest("No tracks found")
|
||||
|
||||
with open(tracks[0], 'rb') as f:
|
||||
original_data = f.read()
|
||||
|
||||
flac = self.mutagen.FLAC(BytesIO(original_data))
|
||||
flac['artist'] = ['Modified Artist']
|
||||
|
||||
output = BytesIO()
|
||||
flac.save(output)
|
||||
modified_data = output.getvalue()
|
||||
|
||||
try:
|
||||
with open(tracks[0], 'r+b') as f:
|
||||
f.write(modified_data[:1024])
|
||||
except IOError:
|
||||
self.skipTest("Write not supported")
|
||||
|
||||
item = self.library.get_item(item_id)
|
||||
if item:
|
||||
self.assertIn('Modified', str(item))
|
||||
|
||||
def test_original_file_unchanged_after_write(self):
|
||||
if not self.mutagen:
|
||||
self.skipTest("mutagen required")
|
||||
|
||||
file_path, item_id = self.add_test_track(
|
||||
filename='unchanged_test.flac',
|
||||
artist='Original Artist'
|
||||
)
|
||||
|
||||
with open(file_path, 'rb') as f:
|
||||
original_content = f.read()
|
||||
|
||||
self.mount_beetfs()
|
||||
|
||||
tracks = self._find_any_track()
|
||||
if tracks:
|
||||
try:
|
||||
with open(tracks[0], 'r+b') as f:
|
||||
f.write(b'\x00' * 100)
|
||||
except IOError:
|
||||
pass
|
||||
|
||||
with open(file_path, 'rb') as f:
|
||||
after_content = f.read()
|
||||
|
||||
self.assertEqual(original_content, after_content)
|
||||
|
||||
def _find_any_track(self):
|
||||
results = []
|
||||
for root, dirs, files in os.walk(self.mount_dir):
|
||||
for f in files:
|
||||
if f.endswith('.flac'):
|
||||
results.append(os.path.join(root, f))
|
||||
return results
|
||||
|
||||
|
||||
class TestWriteAudioDiscarded(BeetFSTestCase):
|
||||
|
||||
def test_write_to_audio_region_no_error(self):
|
||||
file_path, item_id = self.add_test_track(filename='audio_write.flac')
|
||||
|
||||
self.mount_beetfs()
|
||||
|
||||
tracks = self._find_any_track()
|
||||
if not tracks:
|
||||
self.skipTest("No tracks found")
|
||||
|
||||
try:
|
||||
with open(tracks[0], 'r+b') as f:
|
||||
f.seek(10000)
|
||||
f.write(b'\x00' * 100)
|
||||
except IOError:
|
||||
pass
|
||||
|
||||
def test_audio_unchanged_after_write_attempt(self):
|
||||
file_path, item_id = self.add_test_track(filename='audio_unchanged.flac')
|
||||
|
||||
with open(file_path, 'rb') as f:
|
||||
f.seek(10000)
|
||||
original_audio = f.read(100)
|
||||
|
||||
self.mount_beetfs()
|
||||
|
||||
tracks = self._find_any_track()
|
||||
if tracks:
|
||||
try:
|
||||
with open(tracks[0], 'r+b') as f:
|
||||
f.seek(10000)
|
||||
f.write(b'\xff' * 100)
|
||||
except IOError:
|
||||
pass
|
||||
|
||||
with open(file_path, 'rb') as f:
|
||||
f.seek(10000)
|
||||
after_audio = f.read(100)
|
||||
|
||||
self.assertEqual(original_audio, after_audio)
|
||||
|
||||
def _find_any_track(self):
|
||||
results = []
|
||||
for root, dirs, files in os.walk(self.mount_dir):
|
||||
for f in files:
|
||||
if f.endswith('.flac'):
|
||||
results.append(os.path.join(root, f))
|
||||
return results
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user