Files
yellowjacket/backend/database/migrations_test.go
T
yonluandClaude Sonnet 5 cbd82a5a74
Build & publish Arch package / arch-package (push) Successful in 2m2s
Search index maintenance / maintain-index (push) Successful in 7s
feat: autotag mixed-bag splitting, search relevance fixes, and multi-library download imports
Autotag: detect "junk drawer" folders with no artist/album consensus
and split them into synthetic per-cluster groups instead of forcing
one match on an unrelated pile of tracks; repair tagging_items rows
left behind by a prior scan orphan-cleanup gap.

Explore: fix an exact artist-name search being drowned out by its own
catalog entries in intent-prior scoring, and prune stale in_library
bookkeeping left behind when a referenced library row is deleted.

Download: fix a multi-library regression where every import failed
with "no library root configured" — the importer resolved the
library root from a legacy single-library config field that nothing
populates in the current multi-library model. It now resolves the
destination library per-request from the request's own library_id.
Also widen the Soulseek search window (12s -> 20s), measured against
real request history to be missing available peers on live queries.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y2Agd9af5hE7qzti2ackiS
2026-08-10 11:52:26 -04:00

197 lines
6.1 KiB
Go

package database
import (
"database/sql"
"testing"
)
// oldTaggingItemsDDL is a frozen snapshot of tagging_items exactly as
// it read before sql/migrations/0001_tagging_items_synthetic.sql —
// i.e. what a real user's existing database looks like today, before
// upgrading to a build that includes that migration.
const oldTaggingItemsDDL = `
CREATE TABLE IF NOT EXISTS tagging_items (
group_key TEXT PRIMARY KEY,
library_id INTEGER NOT NULL,
track_count INTEGER NOT NULL DEFAULT 0,
album_name TEXT NOT NULL DEFAULT '',
album_artist TEXT NOT NULL DEFAULT '',
disc_number INTEGER NOT NULL DEFAULT 0,
best_match_release_mbid TEXT,
score REAL,
last_checked_at DATETIME,
status TEXT NOT NULL DEFAULT 'pending'
CHECK(status IN ('pending', 'matched', 'confirmed', 'skipped')),
cleared_at DATETIME,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY(library_id) REFERENCES libraries(id)
);
CREATE INDEX IF NOT EXISTS idx_tagging_items_library_status
ON tagging_items(library_id, status);
CREATE INDEX IF NOT EXISTS idx_tagging_items_status_pending
ON tagging_items(library_id) WHERE status = 'pending';
`
// tableColumns returns the column names of a table in on-disk
// (positional) order, via PRAGMA table_info — the order sqlc's
// generated `SELECT *` scans bind to positionally.
func tableColumns(t *testing.T, db *sql.DB, table string) []string {
t.Helper()
rows, err := db.QueryContext(t.Context(), "PRAGMA table_info("+table+")")
if err != nil {
t.Fatalf("PRAGMA table_info(%s): %v", table, err)
}
defer func() { _ = rows.Close() }()
var cols []string
for rows.Next() {
var (
cid int
name string
ctype string
notnull int
dfltValue sql.NullString
primaryKey int
)
if err := rows.Scan(&cid, &name, &ctype, &notnull, &dfltValue, &primaryKey); err != nil {
t.Fatalf("scan table_info row: %v", err)
}
cols = append(cols, name)
}
if err := rows.Err(); err != nil {
t.Fatalf("iterate table_info: %v", err)
}
return cols
}
func openMemDB(t *testing.T) *sql.DB {
t.Helper()
db, err := sql.Open("sqlite", ":memory:?_busy_timeout=5000&_journal_mode=WAL")
if err != nil {
t.Fatalf("open in-memory db: %v", err)
}
db.SetMaxOpenConns(1)
t.Cleanup(func() { _ = db.Close() })
if err := applyPRAGMAs(t.Context(), db); err != nil {
t.Fatalf("apply pragmas: %v", err)
}
return db
}
// TestMigrations_ColumnOrderMatchesFreshInstall is the regression
// test for the exact failure mode that got the old 48-step migration
// chain torn out (see .planning/NOTES.md, "No migration chain"):
// sql/schemas drifting from what migrations actually produce, so
// sqlc-generated code silently reads the wrong thing.
//
// A fresh install takes tagging_items straight from sql/schemas
// (CREATE TABLE, columns in file order). An existing database takes
// it from sql/schemas (the base shape, unchanged since the table
// already existed) plus sql/migrations/0001 (`ALTER TABLE ADD
// COLUMN`, which SQLite always appends at the END of the column
// list, regardless of where the column sits in the CREATE TABLE
// statement). If sql/schemas ever declares a migrated column
// somewhere other than last, the two paths produce tables with the
// SAME columns in a DIFFERENT order — invisible until a `SELECT *`
// (e.g. GetTaggingItem) silently binds a value to the wrong field.
func TestMigrations_ColumnOrderMatchesFreshInstall(t *testing.T) {
t.Parallel()
fresh := openMemDB(t)
if err := applySchema(t.Context(), fresh); err != nil {
t.Fatalf("apply schema (fresh): %v", err)
}
upgraded := openMemDB(t)
librariesDDL, err := schemas.ReadFile("sql/schemas/libraries.sql")
if err != nil {
t.Fatalf("read libraries schema: %v", err)
}
if _, err := upgraded.ExecContext(t.Context(), string(librariesDDL)); err != nil {
t.Fatalf("create libraries table: %v", err)
}
if _, err := upgraded.ExecContext(t.Context(), oldTaggingItemsDDL); err != nil {
t.Fatalf("create pre-migration tagging_items: %v", err)
}
// sql/schemas no-ops on the pre-existing tagging_items (IF NOT
// EXISTS), then sql/migrations/0001's ALTER TABLE statements
// actually add the missing columns for real this time.
if err := applySchema(t.Context(), upgraded); err != nil {
t.Fatalf("apply schema (upgrade path): %v", err)
}
freshCols := tableColumns(t, fresh, "tagging_items")
upgradedCols := tableColumns(t, upgraded, "tagging_items")
if len(freshCols) != len(upgradedCols) {
t.Fatalf(
"column count mismatch: fresh install has %d (%v), upgraded has %d (%v)",
len(freshCols), freshCols, len(upgradedCols), upgradedCols,
)
}
for i := range freshCols {
if freshCols[i] != upgradedCols[i] {
t.Errorf(
"column order mismatch at position %d: fresh install has %q, upgraded has %q\nfresh: %v\nupgraded: %v",
i,
freshCols[i],
upgradedCols[i],
freshCols,
upgradedCols,
)
}
}
}
// TestMigrations_FreshDatabaseStillRecordsAndGetsIndex confirms a
// brand-new database runs migration 0001 (tolerating "duplicate
// column name" from its ALTER TABLE statements, since sql/schemas
// already declared those columns), records it applied, AND still
// gets the trailing CREATE INDEX statement sql/schemas deliberately
// omits for migrated columns.
func TestMigrations_FreshDatabaseStillRecordsAndGetsIndex(t *testing.T) {
t.Parallel()
fresh := openMemDB(t)
if err := applySchema(t.Context(), fresh); err != nil {
t.Fatalf("apply schema: %v", err)
}
var version int
err := fresh.QueryRowContext(
t.Context(), "SELECT version FROM schema_migrations WHERE version = 1",
).Scan(&version)
if err != nil {
t.Fatalf("expected migration 1 to be recorded as applied on a fresh db: %v", err)
}
var indexName string
err = fresh.QueryRowContext(
t.Context(),
"SELECT name FROM sqlite_master WHERE type = 'index' AND name = 'idx_tagging_items_parent_group_key'",
).Scan(&indexName)
if err != nil {
t.Fatalf("expected idx_tagging_items_parent_group_key to exist on a fresh db: %v", err)
}
}