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

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 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)

  1. test_smoke.py - Mount/unmount lifecycle
  2. test_nested_bug.py - Verify readdir, open are callable (will fail, exposing bug)

Phase 3: Core Tests (Day 2)

  1. test_readdir.py - Directory listing
  2. test_read.py - Metadata overlay verification (critical)
  3. test_stat.py - File/directory attributes

Phase 4: Write & Errors (Day 3)

  1. test_write.py - Metadata modification, DB persistence
  2. test_error_handling.py - ENOENT, EOPNOTSUPP

Phase 5: Edge Cases (Day 3-4)

  1. test_edge_cases.py - Unicode, concurrent opens, special chars
  2. 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

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

  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:

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)