Test Results (74 tests): - 12 passed, 56 failures, 3 errors, 3 skipped Bugs Detected: 1. Nested methods bug: lines 758-1144 indented inside access() - FUSE operations (readdir, open, read, write) unreachable - os.listdir() returns ENOSYS (Function not implemented) 2. Directory tree building: KeyError in FSNode.getnode() - Mount fails when library contains tracks 3. Unmount not clean: filesystem not releasing properly Changes: - Fix conftest.py: inline sanitization (no module-level sanitize fn) - Add test findings to e2e-test-plan.md - Add .gitignore for .pyc and test artifacts
18 KiB
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 detectedtest_library_fixture_created- SQLite DB and music dir createdtest_temp_directory_created- Temp dirs set up correctlytest_mount_empty_library- Mount works with empty library!test_list_empty_root- Empty root returns empty listtest_list_root_returns_list- Returns list typetest_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)
- Create
tests/directory structure - Implement
BeetFSTestCasebase class with:- Subprocess timeout via
threading.Timer(Py2.7 compatible) - Mount wait polling (
os.path.ismount()) - Proper cleanup (
fusermount -u)
- Subprocess timeout via
- Create synthetic FLAC generator using ffmpeg + flac CLI
- Setup isolated beets config and library
Phase 2: Bug Detection (Day 1 PM)
test_smoke.py- Mount/unmount lifecycletest_nested_bug.py- Verifyreaddir,openare callable (will fail, exposing bug)
Phase 3: Core Tests (Day 2)
test_readdir.py- Directory listingtest_read.py- Metadata overlay verification (critical)test_stat.py- File/directory attributes
Phase 4: Write & Errors (Day 3)
test_write.py- Metadata modification, DB persistencetest_error_handling.py- ENOENT, EOPNOTSUPP
Phase 5: Edge Cases (Day 3-4)
test_edge_cases.py- Unicode, concurrent opens, special charstest_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
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)
# 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
# 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
# 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
- All smoke tests pass - beetfs mounts and unmounts cleanly
- Nested bug exposed and fixed - All FUSE methods callable
- Metadata overlay verified - Reads return DB metadata, not file metadata
- Writes update DB - Metadata changes persist
- Errors handled gracefully - Correct errno for unsupported ops
- 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:
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
- MP3 is not "readonly" - metadata overlay is disabled (
bound=0), but reads still work - Write returns None for MP3 - no explicit return in MP3 path (falls through)
- Path format is hardcoded - tests must match
$artist/$album ($year) [$format_upper]/$track - $artist - $title.$format - basestring vs str - use
isinstance(x, basestring)for Py2.7 string checks - Global variables -
library,directory_structuremust be reset between tests (use subprocesses)