Archive milestone artifacts: - milestones/v1.0-ROADMAP.md (full roadmap archive) - milestones/v1.0-REQUIREMENTS.md (26/26 requirements complete) - milestones/v1.0-phases/ (8 phase directories with plans, summaries, verifications) Updated: - PROJECT.md: full evolution review, all consolidation requirements validated - ROADMAP.md: collapsed to milestone summary with archive link - STATE.md: reset for next milestone - MILESTONES.md: created with stats and accomplishments - RETROSPECTIVE.md: created with lessons learned Deleted: - REQUIREMENTS.md (archived, fresh for next milestone) 8 phases, 17 plans, 34 tasks, 84 tests added, 6 days
14 KiB
phase, plan, type, wave, depends_on, files_modified, autonomous, requirements, must_haves
| phase | plan | type | wave | depends_on | files_modified | autonomous | requirements | must_haves | |||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 05-database-library-tests | 01 | execute | 1 |
|
true |
|
|
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.
<execution_context> @/home/caleb/.config/opencode/get-shit-done/workflows/execute-plan.md @/home/caleb/.config/opencode/get-shit-done/templates/summary.md </execution_context>
@.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.mdFrom backend/database/database.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:
func NewTestDB(t *testing.T) *DB
From backend/database/search.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):
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:
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:
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:
CREATE TABLE IF NOT EXISTS artist_credit (id INTEGER PRIMARY KEY, text TEXT NOT NULL UNIQUE);
SQL schema — release_groups:
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:
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):
// 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,
)
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):
-
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
- Simple word: "hello" →
-
TestBuildFTSQuery— table-driven subtests:- Single word: "queen" →
"\"queen\"" - Multi-word: "bohemian rhapsody" →
"\"bohemian\" \"rhapsody\"" - Empty string returns the original (empty)
- Single word: "queen" →
-
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.
FTS5 Search tests (use seedSearchData + NewTestDB):
-
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. -
TestSearchFTS_EmptyQuery— search for "", verify returns nil (not an error). Also test whitespace-only " ". -
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. -
TestSearchFTS_MultiWord— search for "bohemian rhapsody", verify returns the Queen track as top result. Multi-word queries use implicit AND. -
TestSearchFTS_Diacritics— search for "Beyonce" (no accent), verify returns the Beyoncé track. This testsunicode61 remove_diacritics 2tokeniser config. -
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. -
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. -
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:
-
TestInsertAndDeleteSearchIndex— insert a search_index entry, verify SearchFTS finds it, delete it, verify SearchFTS no longer finds it. -
TestRebuildSearchIndex— seed audio_files + recordings + artist_credit + release_groups + release_group_recordings (without search_index entries), call RebuildSearchIndex(), verify SearchFTS now returns results. -
TestClearSearchIndex— seed search data, call ClearSearchIndex(), verify SearchFTS returns empty.
Migration test:
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.
Verify test count is in target range (10-15)
cd backend && go test ./database/ -v -count=1 2>&1 | grep -c "=== RUN"
</verification>
<success_criteria>
- 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`
</success_criteria>
<output>
After completion, create `.planning/phases/05-database-library-tests/05-01-SUMMARY.md`
</output>