1374084135
docs/v1/ - Original beetfs documentation:
- analysis.md, components.md, data-flow.md, drawbacks.md
- features.md, modernization.md, rust-migration.md
- benchmark-plan.md, benchmark-results.md, e2e-test-plan.md
- README.md
docs/v2/ - New MusicFS architecture:
- requirements.md: Full requirements spec (FR-1 to FR-25, NFR-1 to NFR-14)
- P0: Multi-origin, plugins, CAS, control API
- P1: Search, album art, prefetch, metadata sources
- P3: HA, 10M+ files scalability
- architecture.md: Google BlueDoc style design document
- PlantUML diagrams for all components
- Design requirements with quantitative targets
- Alternatives considered, implementation plan
494 lines
18 KiB
Markdown
494 lines
18 KiB
Markdown
# beetfs E2E Test Plan
|
|
|
|
> **Reviewed by Oracle** - Critical bug discovered, plan updated accordingly
|
|
|
|
## Test Results (Latest Run)
|
|
|
|
```
|
|
Tests run: 74
|
|
Passed: 12
|
|
Failures: 56
|
|
Errors: 3
|
|
Skipped: 3
|
|
Duration: ~103 seconds
|
|
```
|
|
|
|
### Bugs Detected by Tests
|
|
|
|
| Bug | Tests Affected | Description |
|
|
|-----|----------------|-------------|
|
|
| **Nested Methods** | 56 | Lines 758-1144 indented inside `access()` - FUSE operations unreachable |
|
|
| **Directory Tree Building** | 3 | `KeyError` in `FSNode.getnode()` when adding files |
|
|
| **Unmount** | 1 | Filesystem not unmounting cleanly |
|
|
|
|
### Passing Tests (12)
|
|
|
|
- `test_fuse_available` - FUSE/fusermount detected
|
|
- `test_library_fixture_created` - SQLite DB and music dir created
|
|
- `test_temp_directory_created` - Temp dirs set up correctly
|
|
- `test_mount_empty_library` - **Mount works with empty library!**
|
|
- `test_list_empty_root` - Empty root returns empty list
|
|
- `test_list_root_returns_list` - Returns list type
|
|
- `test_access_empty_path` - Handles empty path
|
|
- Plus 5 nested bug detection tests (confirming bug exists)
|
|
|
|
## 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
|
|
|
|
---
|
|
|
|
## Findings from Test Execution
|
|
|
|
### Bug #1: Nested Methods (CRITICAL)
|
|
|
|
**Location**: `beetFs.py` lines 758-1144
|
|
|
|
**Problem**: All FUSE operation methods are indented inside the `access()` method, making them local functions instead of class methods.
|
|
|
|
**Evidence**:
|
|
```python
|
|
def access(self, path, flags): # Line 723 - correct class method
|
|
...
|
|
return 0
|
|
|
|
def readdir(self, path, ...): # Line 931 - WRONG! Nested inside access()
|
|
...
|
|
def open(self, path, flags): # Line 988 - Also nested
|
|
...
|
|
def read(self, path, ...): # Line 1077 - Also nested
|
|
...
|
|
```
|
|
|
|
**Symptom**: `os.listdir()` returns `OSError: [Errno 38] Function not implemented`
|
|
|
|
**Fix Required**: Dedent lines 758-1144 by 8 spaces to make them class methods.
|
|
|
|
### Bug #2: Directory Tree Building
|
|
|
|
**Location**: `beetFs.py` lines 403-414 (`FSNode.getnode()` and `FSNode.adddir()`)
|
|
|
|
**Problem**: When adding files to the directory structure, the code assumes parent directories already exist.
|
|
|
|
**Evidence**:
|
|
```
|
|
KeyError: u'Test Artist'
|
|
File "beetFs.py", line 403, in getnode
|
|
return self.getnode(elements, root=root.dirs[topdir])
|
|
```
|
|
|
|
**Symptom**: Mount fails when library contains tracks.
|
|
|
|
### Bug #3: Unmount Not Clean
|
|
|
|
**Problem**: After unmounting, `os.path.ismount()` still returns `True`.
|
|
|
|
**Likely Cause**: FUSE process not terminating properly, or lazy unmount not completing.
|
|
|
|
---
|
|
|
|
## 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)
|