Files
MusicFS/docs/v1/e2e-test-plan.md
T
Alexander 1374084135 Reorganize docs into v1 (beetfs) and v2 (new architecture)
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
2026-05-12 16:46:37 +02:00

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)