From bc151891b50e59e41da2e00dbfafbecaad11b4ac Mon Sep 17 00:00:00 2001 From: Caleb Allen Date: Mon, 9 Mar 2026 09:50:14 -0400 Subject: [PATCH] feat(10-02): add migration 6 integration tests and NewTestDBWithLibrary helper - Add NewTestDBWithLibrary helper for tests needing a pre-created library - TestMigration6FreshDB: verify all tables, columns, phantom cols, VIEW, and user_version - TestMigration6LibraryQueries: verify CRUD operations and unique path constraint - TestMigration6PhantomPlaylistTracks: verify SET NULL FK preserves phantom metadata - TestMigration6AudioFilesLibraryFK: verify FK enforcement on library_id - TestMigration6TrackMetadataViewHasLibraryID: verify VIEW includes library_id --- backend/database/database_test.go | 588 ++++++++++++++++++++++++++++++ backend/database/testhelper.go | 24 ++ 2 files changed, 612 insertions(+) create mode 100644 backend/database/database_test.go diff --git a/backend/database/database_test.go b/backend/database/database_test.go new file mode 100644 index 0000000..0459cba --- /dev/null +++ b/backend/database/database_test.go @@ -0,0 +1,588 @@ +package database + +import ( + "database/sql" + "strings" + "testing" + + "yellowjacket/backend/database/sql/sqlcgen" +) + +// --------------------------------------------------------------------------- +// Migration 6 integration tests +// --------------------------------------------------------------------------- + +func TestMigration6FreshDB(t *testing.T) { + t.Parallel() + + db := NewTestDB(t) + + // Verify libraries table exists. + var tableCount int64 + + rows, err := db.QueryContext( + "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='libraries'", + ) + if err != nil { + t.Fatalf("query sqlite_master: %v", err) + } + + if !rows.Next() { + _ = rows.Close() + + t.Fatal("no row returned from sqlite_master query") + } + + if err := rows.Scan(&tableCount); err != nil { + _ = rows.Close() + + t.Fatalf("scan table count: %v", err) + } + + _ = rows.Close() + + if tableCount != 1 { + t.Errorf("libraries table count = %d, want 1", tableCount) + } + + // Verify audio_files has library_id column. + hasLibraryID := false + + afRows, err := db.QueryContext("PRAGMA table_info(audio_files)") + if err != nil { + t.Fatalf("PRAGMA table_info(audio_files): %v", err) + } + + for afRows.Next() { + var ( + cid int64 + name string + colType string + notNull int64 + dfltValue sql.NullString + pk int64 + ) + + if err := afRows.Scan( + &cid, &name, &colType, ¬Null, &dfltValue, &pk, + ); err != nil { + _ = afRows.Close() + + t.Fatalf("scan table_info row: %v", err) + } + + if name == "library_id" { + hasLibraryID = true + } + } + + _ = afRows.Close() + + if !hasLibraryID { + t.Error("audio_files missing library_id column") + } + + // Verify playlist_tracks has phantom columns and nullable + // audio_file_id. + phantomCols := map[string]bool{ + "phantom_title": false, + "phantom_artist": false, + "phantom_album": false, + "phantom_duration_ms": false, + "phantom_genre": false, + "phantom_cover_art_path": false, + } + + audioFileIDNullable := false + + ptRows, err := db.QueryContext( + "PRAGMA table_info(playlist_tracks)", + ) + if err != nil { + t.Fatalf("PRAGMA table_info(playlist_tracks): %v", err) + } + + for ptRows.Next() { + var ( + cid int64 + name string + colType string + notNull int64 + dfltValue sql.NullString + pk int64 + ) + + if err := ptRows.Scan( + &cid, &name, &colType, ¬Null, &dfltValue, &pk, + ); err != nil { + _ = ptRows.Close() + + t.Fatalf("scan playlist_tracks table_info: %v", err) + } + + if _, ok := phantomCols[name]; ok { + phantomCols[name] = true + } + + if name == "audio_file_id" && notNull == 0 { + audioFileIDNullable = true + } + } + + _ = ptRows.Close() + + for col, found := range phantomCols { + if !found { + t.Errorf("playlist_tracks missing column: %s", col) + } + } + + if !audioFileIDNullable { + t.Error( + "playlist_tracks.audio_file_id should be nullable", + ) + } + + // Verify track_metadata VIEW includes library_id. + viewRows, err := db.QueryContext( + "SELECT sql FROM sqlite_master WHERE name='track_metadata'", + ) + if err != nil { + t.Fatalf("query track_metadata view: %v", err) + } + + if !viewRows.Next() { + _ = viewRows.Close() + + t.Fatal("track_metadata view not found") + } + + var viewSQL string + + if err := viewRows.Scan(&viewSQL); err != nil { + _ = viewRows.Close() + + t.Fatalf("scan view sql: %v", err) + } + + _ = viewRows.Close() + + if !strings.Contains(viewSQL, "library_id") { + t.Error("track_metadata VIEW does not contain library_id") + } + + // Verify user_version >= 6. + var version int + + verRows, err := db.QueryContext("PRAGMA user_version") + if err != nil { + t.Fatalf("PRAGMA user_version: %v", err) + } + + if !verRows.Next() { + _ = verRows.Close() + + t.Fatal("PRAGMA user_version: no row returned") + } + + if err := verRows.Scan(&version); err != nil { + _ = verRows.Close() + + t.Fatalf("scan user_version: %v", err) + } + + _ = verRows.Close() + + if version < 6 { + t.Errorf("user_version = %d, want >= 6", version) + } + + // Verify libraries table has only the sentinel row on fresh DB. + count, err := db.Queries.CountLibraries(db.Ctx) + if err != nil { + t.Fatalf("CountLibraries: %v", err) + } + + // Sentinel library at id=0 is inserted by NewTestDB. + if count != 1 { + t.Errorf( + "CountLibraries on fresh DB = %d, want 1 (sentinel only)", + count, + ) + } +} + +func TestMigration6LibraryQueries(t *testing.T) { + t.Parallel() + + db := NewTestDB(t) + + // Create a library. + lib, err := db.Queries.CreateLibrary( + db.Ctx, + sqlcgen.CreateLibraryParams{ + Name: "Music", + Path: "/home/user/Music", + }, + ) + if err != nil { + t.Fatalf("CreateLibrary: %v", err) + } + + if lib.Name != "Music" { + t.Errorf("Name = %q, want %q", lib.Name, "Music") + } + + if lib.Path != "/home/user/Music" { + t.Errorf("Path = %q, want %q", lib.Path, "/home/user/Music") + } + + if lib.ID <= 0 { + t.Errorf("ID = %d, want > 0", lib.ID) + } + + // Get by ID. + got, err := db.Queries.GetLibrary(db.Ctx, lib.ID) + if err != nil { + t.Fatalf("GetLibrary: %v", err) + } + + if got.Name != lib.Name || got.Path != lib.Path { + t.Errorf( + "GetLibrary = {%q, %q}, want {%q, %q}", + got.Name, got.Path, lib.Name, lib.Path, + ) + } + + // Get by path. + gotByPath, err := db.Queries.GetLibraryByPath( + db.Ctx, "/home/user/Music", + ) + if err != nil { + t.Fatalf("GetLibraryByPath: %v", err) + } + + if gotByPath.ID != lib.ID { + t.Errorf( + "GetLibraryByPath ID = %d, want %d", + gotByPath.ID, lib.ID, + ) + } + + // Unique path constraint. + _, err = db.Queries.CreateLibrary( + db.Ctx, + sqlcgen.CreateLibraryParams{ + Name: "Duplicate", + Path: "/home/user/Music", + }, + ) + + if !IsUniqueViolation(err) { + t.Errorf( + "duplicate path insert: got %v, want unique violation", + err, + ) + } + + // List libraries (sentinel + Music). + libs, err := db.Queries.GetAllLibraries(db.Ctx) + if err != nil { + t.Fatalf("GetAllLibraries: %v", err) + } + + if len(libs) != 2 { + t.Errorf("GetAllLibraries len = %d, want 2", len(libs)) + } + + // Update name. + err = db.Queries.UpdateLibraryName( + db.Ctx, + sqlcgen.UpdateLibraryNameParams{ + Name: "My Music", + ID: lib.ID, + }, + ) + if err != nil { + t.Fatalf("UpdateLibraryName: %v", err) + } + + updated, _ := db.Queries.GetLibrary(db.Ctx, lib.ID) + + if updated.Name != "My Music" { + t.Errorf( + "after update Name = %q, want %q", + updated.Name, "My Music", + ) + } + + // Delete. + err = db.Queries.DeleteLibrary(db.Ctx, lib.ID) + if err != nil { + t.Fatalf("DeleteLibrary: %v", err) + } + + count, _ := db.Queries.CountLibraries(db.Ctx) + + // Only sentinel remains. + if count != 1 { + t.Errorf("after delete CountLibraries = %d, want 1", count) + } +} + +func TestMigration6PhantomPlaylistTracks(t *testing.T) { + t.Parallel() + + db, libID := NewTestDBWithLibrary(t, "Test", "/test/music") + + // Create prerequisite data: artist_credit, recording, + // audio_file. + _, err := db.ExecContext( + "INSERT INTO artist_credit (id, text) VALUES (1, 'Test Artist')", + ) + if err != nil { + t.Fatalf("insert artist_credit: %v", err) + } + + _, err = db.ExecContext( + "INSERT INTO recordings (id, name, artist_credit_id) " + + "VALUES (1, 'Test Song', 1)", + ) + if err != nil { + t.Fatalf("insert recording: %v", err) + } + + _, err = db.ExecContext( + "INSERT INTO audio_files "+ + "(id, file_path, length_milliseconds, file_type_id, "+ + "recording_id, library_id) "+ + "VALUES (1, '/test/music/song.mp3', 180000, 0, 1, ?)", + libID, + ) + if err != nil { + t.Fatalf("insert audio_file: %v", err) + } + + // Create playlist. + playlist, err := db.Queries.CreatePlaylist( + db.Ctx, "Test Playlist", + ) + if err != nil { + t.Fatalf("CreatePlaylist: %v", err) + } + + // Add track with phantom metadata (eager population). + track, err := db.Queries.AddPlaylistTrack( + db.Ctx, + sqlcgen.AddPlaylistTrackParams{ + PlaylistID: playlist.ID, + AudioFileID: sql.NullInt64{ + Int64: 1, + Valid: true, + }, + Position: 0, + PhantomTitle: sql.NullString{ + String: "Test Song", + Valid: true, + }, + PhantomArtist: sql.NullString{ + String: "Test Artist", + Valid: true, + }, + PhantomAlbum: sql.NullString{ + String: "Test Album", + Valid: true, + }, + PhantomDurationMs: sql.NullInt64{ + Int64: 180000, + Valid: true, + }, + PhantomGenre: sql.NullString{ + String: "Rock", + Valid: true, + }, + }, + ) + if err != nil { + t.Fatalf("AddPlaylistTrack: %v", err) + } + + if track.ID <= 0 { + t.Errorf("track ID = %d, want > 0", track.ID) + } + + // Delete the audio file — FK ON DELETE SET NULL should set + // audio_file_id to NULL without deleting the playlist_track. + _, err = db.ExecContext( + "DELETE FROM audio_files WHERE id = 1", + ) + if err != nil { + t.Fatalf("delete audio_file: %v", err) + } + + // Verify playlist track still exists with phantom data. + tracks, err := db.Queries.GetPlaylistTracksWithMetadata( + db.Ctx, playlist.ID, + ) + if err != nil { + t.Fatalf("GetPlaylistTracksWithMetadata: %v", err) + } + + if len(tracks) != 1 { + t.Fatalf( + "tracks after delete = %d, want 1", len(tracks), + ) + } + + row := tracks[0] + + if row.AudioFileID.Valid { + t.Error( + "audio_file_id should be NULL after delete", + ) + } + + if row.Title != "Test Song" { + t.Errorf( + "phantom Title = %q, want %q", + row.Title, "Test Song", + ) + } + + if row.Artist != "Test Artist" { + t.Errorf( + "phantom Artist = %q, want %q", + row.Artist, "Test Artist", + ) + } + + if row.IsPhantom != 1 { + t.Errorf("IsPhantom = %d, want 1", row.IsPhantom) + } +} + +func TestMigration6AudioFilesLibraryFK(t *testing.T) { + t.Parallel() + + db, libID := NewTestDBWithLibrary(t, "Test", "/test/fk-lib") + + // Insert prerequisite recording. + _, err := db.ExecContext( + "INSERT INTO artist_credit (id, text) VALUES (1, 'Test')", + ) + if err != nil { + t.Fatalf("insert artist_credit: %v", err) + } + + _, err = db.ExecContext( + "INSERT INTO recordings (id, name, artist_credit_id) " + + "VALUES (1, 'Track', 1)", + ) + if err != nil { + t.Fatalf("insert recording: %v", err) + } + + // Insert audio file with valid library_id — should succeed. + _, err = db.ExecContext( + "INSERT INTO audio_files "+ + "(id, file_path, length_milliseconds, file_type_id, "+ + "recording_id, library_id) "+ + "VALUES (1, '/test/song.mp3', 180000, 0, 1, ?)", + libID, + ) + if err != nil { + t.Fatalf("insert audio_file with valid library: %v", err) + } + + // Insert audio file with invalid library_id — should fail FK. + _, err = db.ExecContext( + "INSERT INTO audio_files " + + "(id, file_path, length_milliseconds, file_type_id, " + + "recording_id, library_id) " + + "VALUES (2, '/test/song2.mp3', 200000, 0, 1, 999)", + ) + if err == nil { + t.Error( + "insert with invalid library_id should fail FK check", + ) + } + + // Count files by library. + count, err := db.Queries.CountAudioFilesByLibrary( + db.Ctx, libID, + ) + if err != nil { + t.Fatalf("CountAudioFilesByLibrary: %v", err) + } + + if count != 1 { + t.Errorf( + "CountAudioFilesByLibrary = %d, want 1", count, + ) + } +} + +func TestMigration6TrackMetadataViewHasLibraryID(t *testing.T) { + t.Parallel() + + db, libID := NewTestDBWithLibrary(t, "Test", "/test/view-lib") + + // Insert prerequisites. + _, err := db.ExecContext( + "INSERT INTO artist_credit (id, text) VALUES (1, 'View Artist')", + ) + if err != nil { + t.Fatalf("insert artist_credit: %v", err) + } + + _, err = db.ExecContext( + "INSERT INTO recordings (id, name, artist_credit_id) " + + "VALUES (1, 'View Track', 1)", + ) + if err != nil { + t.Fatalf("insert recording: %v", err) + } + + _, err = db.ExecContext( + "INSERT INTO audio_files "+ + "(id, file_path, length_milliseconds, file_type_id, "+ + "recording_id, library_id) "+ + "VALUES (1, '/test/view.mp3', 200000, 0, 1, ?)", + libID, + ) + if err != nil { + t.Fatalf("insert audio_file: %v", err) + } + + // Query track_metadata VIEW and verify library_id is present + // with the correct value. + viewRows, err := db.QueryContext( + "SELECT library_id FROM track_metadata WHERE id = 1", + ) + if err != nil { + t.Fatalf("query track_metadata: %v", err) + } + + if !viewRows.Next() { + _ = viewRows.Close() + + t.Fatal("track_metadata: no row returned") + } + + var viewLibID int64 + + if err := viewRows.Scan(&viewLibID); err != nil { + _ = viewRows.Close() + + t.Fatalf("scan library_id from view: %v", err) + } + + _ = viewRows.Close() + + if viewLibID != libID { + t.Errorf( + "track_metadata.library_id = %d, want %d", + viewLibID, libID, + ) + } +} diff --git a/backend/database/testhelper.go b/backend/database/testhelper.go index 358a24c..9af2dd7 100644 --- a/backend/database/testhelper.go +++ b/backend/database/testhelper.go @@ -81,3 +81,27 @@ func NewTestDB(t *testing.T) *DB { logger: slog.Default(), } } + +// NewTestDBWithLibrary returns a test DB with a library row +// pre-inserted. Returns the DB and the library ID. +func NewTestDBWithLibrary( + t *testing.T, + name, libPath string, +) (*DB, int64) { + t.Helper() + + db := NewTestDB(t) + + lib, err := db.Queries.CreateLibrary( + db.Ctx, + sqlcgen.CreateLibraryParams{ + Name: name, + Path: libPath, + }, + ) + if err != nil { + t.Fatalf("could not create test library: %v", err) + } + + return db, lib.ID +}