Files
yellowjacket/backend/database/explorefts_test.go
T
yonluandClaude Sonnet 5 e190fd75b9
Build & publish Arch package / arch-package (push) Successful in 2m12s
Search index maintenance / maintain-index (push) Successful in 2h22m28s
feat: data lifecycle rewrite, download clients, wanted list, and central catalog index
Ships the fresh-start schema cleanup: rebuilt explore catalog index
pipeline (dump import, artifact fetch/build, incremental listen-count
refresh), a new download subsystem (Lidarr/Prowlarr/qBittorrent/SABnzbd/
slskd/yt-dlp providers, staging, reconciliation, wanted list), and the
supporting schema/query/store changes across backend and frontend.

Also includes two smaller follow-ups: bump the central index's
rebuild-after cadence from 90 to 180 days, and remove the Explore
"library only" online/offline toggle entirely (frontend-only, no
backend counterpart) rather than carry unused UI/state.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y2Agd9af5hE7qzti2ackiS
2026-08-06 17:12:01 -04:00

160 lines
4.2 KiB
Go

package database
import (
"testing"
)
// seedExploreRow inserts one explore_index row.
func seedExploreRow(t *testing.T, db *DB, mbid, title, artist string) {
t.Helper()
if _, err := db.ExecContext(`
INSERT INTO explore_index (entity_type, mbid, title, artist_name, artist_mbid)
VALUES ('recording', ?, ?, ?, '')
`, mbid, title, artist); err != nil {
t.Fatalf("seed %s: %v", mbid, err)
}
}
// ftsMatches returns how many FTS rows match a query.
func ftsMatches(t *testing.T, db *DB, query string) int {
t.Helper()
rows, err := db.QueryContext(
"SELECT COUNT(*) FROM explore_index_fts WHERE explore_index_fts MATCH ?", query,
)
if err != nil {
t.Fatalf("fts query %q: %v", query, err)
}
defer func() { _ = rows.Close() }()
n := 0
if rows.Next() {
if err := rows.Scan(&n); err != nil {
t.Fatalf("scan fts count: %v", err)
}
}
if err := rows.Err(); err != nil {
t.Fatalf("fts rows: %v", err)
}
return n
}
// Rows written while FTS sync is suspended are invisible to search
// until the window closes — and fully searchable afterwards. This is
// the contract the dump import's bulk-load path depends on.
func TestExploreFTSSuspendResumeIndexesBulkRows(t *testing.T) {
db := NewTestDB(t)
seedExploreRow(t, db, "mbid-before", "Before Suspend", "Artist One")
if got := ftsMatches(t, db, "Before"); got != 1 {
t.Fatalf("matches for pre-suspend row = %d, want 1", got)
}
if err := db.SuspendExploreIndexFTS(); err != nil {
t.Fatalf("suspend: %v", err)
}
seedExploreRow(t, db, "mbid-during", "During Suspend", "Artist Two")
if got := ftsMatches(t, db, "During"); got != 0 {
t.Errorf("matches while suspended = %d, want 0 (triggers should be off)", got)
}
if err := db.ResumeExploreIndexFTS(); err != nil {
t.Fatalf("resume: %v", err)
}
if got := ftsMatches(t, db, "During"); got != 1 {
t.Errorf("matches for bulk-loaded row after resume = %d, want 1", got)
}
if got := ftsMatches(t, db, "Before"); got != 1 {
t.Errorf("matches for pre-suspend row after resume = %d, want 1", got)
}
}
// The import wipes explore_index before reassembling it. With the
// triggers suspended that DELETE writes no FTS delete-markers, so the
// rebuild must be what clears the old rows out of search.
func TestExploreFTSResumeDropsDeletedRows(t *testing.T) {
db := NewTestDB(t)
seedExploreRow(t, db, "mbid-stale", "Stale Recording", "Old Artist")
if err := db.SuspendExploreIndexFTS(); err != nil {
t.Fatalf("suspend: %v", err)
}
if _, err := db.ExecContext("DELETE FROM explore_index"); err != nil {
t.Fatalf("wipe: %v", err)
}
seedExploreRow(t, db, "mbid-fresh", "Fresh Recording", "New Artist")
if err := db.ResumeExploreIndexFTS(); err != nil {
t.Fatalf("resume: %v", err)
}
if got := ftsMatches(t, db, "Stale"); got != 0 {
t.Errorf("matches for wiped row = %d, want 0", got)
}
if got := ftsMatches(t, db, "Fresh"); got != 1 {
t.Errorf("matches for reassembled row = %d, want 1", got)
}
}
// resumeFTS runs from a defer as well as at its natural point in the
// pipeline, so a second call must be harmless.
func TestExploreFTSResumeIsIdempotent(t *testing.T) {
db := NewTestDB(t)
if err := db.SuspendExploreIndexFTS(); err != nil {
t.Fatalf("suspend: %v", err)
}
seedExploreRow(t, db, "mbid-a", "Repeatable Resume", "Artist")
if err := db.ResumeExploreIndexFTS(); err != nil {
t.Fatalf("first resume: %v", err)
}
if err := db.ResumeExploreIndexFTS(); err != nil {
t.Fatalf("second resume: %v", err)
}
if got := ftsMatches(t, db, "Repeatable"); got != 1 {
t.Errorf("matches after repeated resume = %d, want 1", got)
}
// Triggers must still be live for ordinary writes after the window.
seedExploreRow(t, db, "mbid-b", "Postwindow Row", "Artist")
if got := ftsMatches(t, db, "Postwindow"); got != 1 {
t.Errorf("matches for row written after resume = %d, want 1", got)
}
}
// Suspending twice must not fail — the triggers are simply already gone.
func TestExploreFTSSuspendIsIdempotent(t *testing.T) {
db := NewTestDB(t)
if err := db.SuspendExploreIndexFTS(); err != nil {
t.Fatalf("first suspend: %v", err)
}
if err := db.SuspendExploreIndexFTS(); err != nil {
t.Fatalf("second suspend: %v", err)
}
if err := db.ResumeExploreIndexFTS(); err != nil {
t.Fatalf("resume: %v", err)
}
}