--- phase: 05-database-library-tests plan: 01 type: execute wave: 1 depends_on: [] files_modified: - backend/database/search_test.go autonomous: true requirements: [TEST-03] must_haves: truths: - "SearchFTS returns correct results for basic term queries" - "SearchFTS returns nil for empty queries" - "SearchFTS handles special characters (quotes, slashes like AC/DC) without error" - "SearchFTS multi-word queries match across title/artist/album columns" - "SearchFTSByFilename scopes search to file_path column only" - "SearchFTSTracks returns full 16-column track metadata" - "FTS5 search ranking produces consistent BM25 ordering for known data" - "Diacritics search works (Beyonce finds Beyoncé)" - "RebuildSearchIndex repopulates the index from audio_files data" - "tokeniseForFTS and buildFTSQuery produce correct FTS5 query syntax" - "Schema migrations run successfully on a fresh database" - "All tests pass with -race flag" artifacts: - path: "backend/database/search_test.go" provides: "FTS5 search tests, pure helper tests, migration tests, rebuild tests" min_lines: 300 key_links: - from: "backend/database/search_test.go" to: "backend/database/search.go" via: "direct function calls (same package)" pattern: "SearchFTS|SearchFTSByFilename|SearchFTSTracks|tokeniseForFTS|buildFTSQuery|stripExtForSearch" - from: "backend/database/search_test.go" to: "backend/database/testhelper.go" via: "NewTestDB(t)" pattern: "NewTestDB" --- Write unit tests for the database package covering FTS5 search queries (SearchFTS, SearchFTSByFilename, SearchFTSTracks), pure helper functions (tokeniseForFTS, buildFTSQuery, stripExtForSearch), search index operations (InsertSearchIndex, DeleteSearchIndex, ClearSearchIndex, RebuildSearchIndex), and schema migration verification. Purpose: Lock down FTS5 search behavior before Phase 6's VIEW consolidation — these tests become the safety net that proves the VIEW doesn't break search ranking or result mapping. Output: backend/database/search_test.go with ~12-15 tests, all passing with `-race`. @/home/caleb/.config/opencode/get-shit-done/workflows/execute-plan.md @/home/caleb/.config/opencode/get-shit-done/templates/summary.md @.planning/PROJECT.md @.planning/ROADMAP.md @.planning/STATE.md @.planning/phases/05-database-library-tests/05-CONTEXT.md @.planning/phases/03-test-infrastructure/03-01-SUMMARY.md @.planning/phases/04-queue-config-player-tests/04-01-SUMMARY.md From backend/database/database.go: ```go type DB struct { db *sql.DB Ctx context.Context Queries *sqlcgen.Queries logger *slog.Logger } func (d *DB) ExecContext(query string, args ...any) (sql.Result, error) func (d *DB) QueryContext(query string, args ...any) (*sql.Rows, error) func (d *DB) BeginTx() (*sql.Tx, error) ``` From backend/database/testhelper.go: ```go func NewTestDB(t *testing.T) *DB ``` From backend/database/search.go: ```go type SearchRow struct { FilePath string LengthMilliseconds int64 Title string Artist string Album string } type SearchTrackRow struct { FilePath string LengthMilliseconds int64 Title string ArtistName string TrackNumber sql.NullInt64 DiscNumber sql.NullInt64 Album string Genre string Year int64 Composer string FileType string SampleRate int64 BitDepth int64 Channels int64 Bitrate int64 FileSize int64 } func (d *DB) SearchFTS(query string, limit int) ([]SearchRow, error) func (d *DB) SearchFTSByFilename(basename string, limit int) ([]SearchRow, error) func (d *DB) SearchFTSTracks(query string, limit int) ([]SearchTrackRow, error) func (d *DB) InsertSearchIndex(rowid int64, filePath, title, artist, album string) error func (d *DB) DeleteSearchIndex(rowid int64) error func (d *DB) ClearSearchIndex() error func (d *DB) RebuildSearchIndex() error // Unexported (same package, accessible in tests): func buildFTSQuery(query string) string func tokeniseForFTS(s string) []string func stripExtForSearch(s string) string ``` SQL schema — search_index (FTS5 contentless table): ```sql CREATE VIRTUAL TABLE IF NOT EXISTS search_index USING fts5( file_path, title, artist, album, content='', tokenize='unicode61 remove_diacritics 2' ); ``` SQL schema — audio_files: ```sql CREATE TABLE IF NOT EXISTS audio_files ( id integer PRIMARY KEY, file_path text NOT NULL UNIQUE, length_milliseconds int NOT NULL, file_type_id int NOT NULL, recording_id int NOT NULL, sample_rate int NOT NULL DEFAULT 0, bit_depth int NOT NULL DEFAULT 0, channels int NOT NULL DEFAULT 0, bitrate int NOT NULL DEFAULT 0, file_size int NOT NULL DEFAULT 0, basename text NOT NULL DEFAULT '', FOREIGN KEY(file_type_id) REFERENCES file_types(id), FOREIGN KEY(recording_id) REFERENCES recordings(id) ); ``` SQL schema — recordings: ```sql CREATE TABLE IF NOT EXISTS recordings ( id INTEGER PRIMARY KEY, name TEXT NOT NULL, artist_credit_id INTEGER NOT NULL, track_number INTEGER, disc_number INTEGER, year INTEGER, genre TEXT, composer TEXT, lyrics TEXT, comment TEXT, FOREIGN KEY(artist_credit_id) REFERENCES artist_credit(id) ); ``` SQL schema — artist_credit: ```sql CREATE TABLE IF NOT EXISTS artist_credit (id INTEGER PRIMARY KEY, text TEXT NOT NULL UNIQUE); ``` SQL schema — release_groups: ```sql CREATE TABLE IF NOT EXISTS release_groups ( id INTEGER PRIMARY KEY, name TEXT NOT NULL UNIQUE, cover_art_id INTEGER, album_artist_credit_id INTEGER, year INTEGER, total_tracks INTEGER, total_discs INTEGER, FOREIGN KEY(cover_art_id) REFERENCES cover_art(id), FOREIGN KEY(album_artist_credit_id) REFERENCES artist_credit(id) ); ``` SQL schema — release_group_recordings: ```sql CREATE TABLE IF NOT EXISTS release_group_recordings ( id INTEGER PRIMARY KEY, release_group_id INTEGER NOT NULL, recording_id INTEGER NOT NULL, track_number INTEGER, disc_number INTEGER, FOREIGN KEY(release_group_id) REFERENCES release_groups(id), FOREIGN KEY(recording_id) REFERENCES recordings(id) ); ``` Existing test pattern from queue package (seedAudioFiles): ```go // Creates FK chain: artist_credit → recordings → audio_files _, err := db.ExecContext( "INSERT OR IGNORE INTO artist_credit (id, text) VALUES (1, 'Test Artist')", ) _, err = db.ExecContext( "INSERT OR IGNORE INTO recordings (id, name, artist_credit_id) VALUES (?, ?, 1)", recID, fmt.Sprintf("Track %d", i+1), ) _, err = db.ExecContext( "INSERT OR IGNORE INTO audio_files (id, file_path, length_milliseconds, file_type_id, recording_id) VALUES (?, ?, 180000, 0, ?)", afID, fp, recID, ) ``` Task 1: Pure helper function tests + seed helper backend/database/search_test.go Create `backend/database/search_test.go` (package database — internal tests, access unexported functions). **Seed helper function:** Create `seedSearchData(t *testing.T, db *DB)` that inserts ~6-8 tracks with the full FK chain needed for FTS5 search: - artist_credit rows (e.g., "Queen", "Beyoncé", "AC/DC", "Pink Floyd") - recordings with varied metadata (title, track_number, disc_number, year, genre, composer) - audio_files with file_path, length_milliseconds, file_type_id=0, recording_id - release_groups with album names (e.g., "A Night at the Opera", "Lemonade", "Back in Black", "The Dark Side of the Moon") - release_group_recordings linking recordings to release_groups - search_index entries via `InsertSearchIndex()` for each audio file (rowid must match audio_files.id) Use realistic music metadata per CONTEXT.md decision: "Bohemian Rhapsody" by "Queen" on "A Night at the Opera", "Halo" by "Beyoncé" on "Lemonade", "Back in Black" by "AC/DC" on "Back in Black", "Comfortably Numb" by "Pink Floyd" on "The Dark Side of the Moon", "Another One Bites the Dust" by "Queen" on "The Game", etc. **Pure helper tests (no DB needed):** 1. `TestTokeniseForFTS` — table-driven subtests: - Simple word: "hello" → `["\"hello\""]` - Multiple words: "hello world" → `["\"hello\"" "\"world\""]` - Hyphens split: "rock-pop" → `["\"rock\"" "\"pop\""]` - Slashes split: "AC/DC" → `["\"AC\"" "\"DC\""]` - Dots split: "01.track" → `["\"01\"" "\"track\""]` - Underscores split: "my_song" → `["\"my\"" "\"song\""]` - Double quotes escaped: `he"llo` → `["\"he\"\"llo\""]` - Empty string: "" → nil or empty slice - Only separators: "---" → nil or empty slice 2. `TestBuildFTSQuery` — table-driven subtests: - Single word: "queen" → `"\"queen\""` - Multi-word: "bohemian rhapsody" → `"\"bohemian\" \"rhapsody\""` - Empty string returns the original (empty) 3. `TestStripExtForSearch` — table-driven subtests: - "song.mp3" → "song" - "my.song.flac" → "my.song" - "noextension" → "noextension" - ".hidden" → ".hidden" (dot at position 0 is not stripped) Follow established patterns: `t.Parallel()`, `t.Run()` subtests, standard library testing (no testify), `TestFunctionName_Scenario` naming convention. cd backend && go test -race -run "TestTokeniseForFTS|TestBuildFTSQuery|TestStripExtForSearch|seedSearchData" ./database/ -v -count=1 Pure helper tests pass: tokeniseForFTS handles all separator types and quote escaping, buildFTSQuery produces correct FTS5 syntax, stripExtForSearch handles edge cases. seedSearchData helper function creates full entity graph for search tests. Task 2: FTS5 search + index operation + migration tests backend/database/search_test.go Add to the existing `backend/database/search_test.go` file created in Task 1. **FTS5 Search tests (use seedSearchData + NewTestDB):** 4. `TestSearchFTS_BasicTerm` — search for "queen", verify returns "Bohemian Rhapsody" and "Another One Bites the Dust" (both Queen tracks). Assert len >= 2, check FilePath and Title fields. 5. `TestSearchFTS_EmptyQuery` — search for "", verify returns nil (not an error). Also test whitespace-only " ". 6. `TestSearchFTS_SpecialCharacters` — search for "AC/DC", verify returns the AC/DC track. The tokeniser splits on `/`, so "AC" and "DC" both match. Also test a query with double quotes. 7. `TestSearchFTS_MultiWord` — search for "bohemian rhapsody", verify returns the Queen track as top result. Multi-word queries use implicit AND. 8. `TestSearchFTS_Diacritics` — search for "Beyonce" (no accent), verify returns the Beyoncé track. This tests `unicode61 remove_diacritics 2` tokeniser config. 9. `TestSearchFTS_Ranking` — seed data with specific artist/title combos where one track should rank higher. Search a term that appears in both title and artist of one track vs. only artist of another. Assert the more-relevant result comes first (lower BM25 rank = first). Use exact result ordering assertion per CONTEXT.md decision. 10. `TestSearchFTSByFilename` — search by basename "bohemian_rhapsody.mp3", verify matches. The search strips extension and scopes to file_path column. Also test empty basename returns nil. 11. `TestSearchFTSTracks` — search for "queen", verify returns SearchTrackRow with all 16 fields populated (FilePath, LengthMilliseconds, Title, ArtistName, TrackNumber, DiscNumber, Album, Genre, Year, Composer, FileType, SampleRate, BitDepth, Channels, Bitrate, FileSize). This is the safety net for the full-metadata search path. **Search index operation tests:** 12. `TestInsertAndDeleteSearchIndex` — insert a search_index entry, verify SearchFTS finds it, delete it, verify SearchFTS no longer finds it. 13. `TestRebuildSearchIndex` — seed audio_files + recordings + artist_credit + release_groups + release_group_recordings (without search_index entries), call RebuildSearchIndex(), verify SearchFTS now returns results. 14. `TestClearSearchIndex` — seed search data, call ClearSearchIndex(), verify SearchFTS returns empty. **Migration test:** 15. `TestMigrationsApplied` — call NewTestDB(t), verify user_version PRAGMA is >= 3 (all 3 migrations applied). Verify the artist_credit_artist UNIQUE index exists by attempting a duplicate insert and checking for UNIQUE violation error. Each test gets its own `NewTestDB(t)` call + `seedSearchData(t, db)` where needed. Use `t.Parallel()` for all tests. Follow established Phase 4 patterns (table-driven subtests where appropriate, descriptive assertions with `t.Errorf`). cd backend && go test -race ./database/ -v -count=1 12+ database tests pass with -race: FTS5 search works for basic terms, empty queries, special characters (AC/DC), multi-word, diacritics (Beyonce→Beyoncé), ranking order is deterministic. SearchFTSByFilename scopes to file_path column. SearchFTSTracks returns full 16-column metadata. Insert/Delete/Clear/Rebuild index operations work correctly. Migrations verified applied. ```bash # All database package tests pass with race detector cd backend && go test -race ./database/ -v -count=1 # Verify test count is in target range (10-15) cd backend && go test ./database/ -v -count=1 2>&1 | grep -c "=== RUN" ``` - backend/database/search_test.go exists with 12-15 tests - All search functions tested independently: SearchFTS, SearchFTSByFilename, SearchFTSTracks - Pure helpers tested: tokeniseForFTS, buildFTSQuery, stripExtForSearch - Index operations tested: InsertSearchIndex, DeleteSearchIndex, ClearSearchIndex, RebuildSearchIndex - Diacritics behavior verified (Beyonce → Beyoncé) - Special characters handled (AC/DC, quotes) - Search ranking produces consistent ordering - Migrations verified (user_version >= 3, UNIQUE index works) - All tests pass with `go test -race` After completion, create `.planning/phases/05-database-library-tests/05-01-SUMMARY.md`