Files
minstrel/internal/dbtest/reset.go
T
bvandeusenandClaude Opus 5.5 598abeb8cf
release / govulncheck (push) Successful in 14s
release / web (push) Successful in 1m20s
release / go (push) Successful in 1m44s
release / android (push) Successful in 4m54s
release / Build signed APK (push) Successful in 5m10s
release / integration (push) Successful in 5m19s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 15s
release / Verify release artifacts (tag releases only) (push) Skipped
fix: a fresh app install syncs the whole library, not the last 30 days of changes (#6778)
The change feed dropped every row older than 30 days, and an app syncing
from cursor 0 builds its library from the feed, so a fresh install, cleared
data or a 410 reset got only what had changed lately.

The compactor now keeps each entity's latest change however old. It drops a
row once a newer one for the same entity exists, and a delete once it is 30
days old, raising a stored mark (library_changes_trimmed) past it; an app
whose cursor is below the mark is sent back to 0, as before. Migration 0101
adds the index the compactor needs, seeds the mark where the old compactor
left off, and gives every artist, album and track whose latest row is not an
upsert a fresh one, so the rows the old compactor dropped come back. The
backup carries the mark with the feed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 22:07:42 -05:00

222 lines
9.0 KiB
Go

// Package dbtest provides shared helpers for integration tests that need
// a clean Postgres state without disturbing the developer's admin user.
//
// When integration tests run against the same Postgres a developer uses
// for local dev, a TRUNCATE that includes the users table wipes their admin
// login on every test run. To avoid that, tests should:
//
// 1. Call ResetDB instead of issuing TRUNCATE statements directly. It
// truncates every data table EXCEPT users, then deletes only those
// users whose username starts with TestUserPrefix.
// 2. Always create test user rows with a username that begins with
// TestUserPrefix; otherwise those rows survive across runs and
// leak into other tests.
//
// One test legitimately needs to wipe the entire users table:
// internal/auth/bootstrap_test.go, which exercises first-time admin
// bootstrap. That file does its own TRUNCATE and does not use this
// helper.
package dbtest
import (
"context"
"strings"
"testing"
"github.com/jackc/pgx/v5/pgxpool"
)
// TestUserPrefix is the required username prefix for any user row
// created by an integration test. ResetDB removes every user matching
// this prefix and leaves all others intact.
const TestUserPrefix = "test-"
// dataTables is the union of every non-users table touched by any
// integration test in the repo. Truncating the union is harmless for
// callers that only care about a subset; truncating tables that don't
// exist would error, but every name here corresponds to a migration
// that has shipped.
var dataTables = []string{
"artist_similarity",
"track_similarity",
"artist_similarity_unmatched",
"listenbrainz_similar_recordings",
"track_similarity_fetches",
"artist_similarity_fetches",
"scrobble_queue",
"contextual_likes",
"general_likes_albums",
"general_likes_artists",
"general_likes",
"play_events",
"skip_events",
"play_sessions",
"sessions",
"quarantine_actions",
"quarantine",
// Keyed by (user_id, candidate_mbid) with no FK to artists —
// candidates are out-of-library — so the CASCADE from artists/users
// does NOT reach it for a leftover row whose user survived. Truncate
// explicitly or a stale snooze silently hides a candidate from the
// next test's suggestion assertions.
"suggestion_snoozes",
// Same reasoning: keyed by candidate MBID with no FK anywhere,
// so nothing cascades to them. A leftover tag row would make a
// candidate look enriched to the next test, and a leftover state row
// would make it look already-settled and thus ineligible.
"candidate_artist_tags",
"candidate_artist_tag_state",
"playlist_tracks",
"playlists",
"library_changes", // reset so each test's sync cursor starts clean
// SettingsService.reconcile() idempotently re-UpsertProviderSettings
// for every registered provider at boot, so truncating this is the
// correct per-test reset (clears test-modified enabled/api_key rows).
// cover_art_sources_meta is NOT truncated — boot only READS it
// (never recreates the singleton, seeded once by 0018); ResetDB
// resets its counter via UPDATE below instead.
"cover_art_provider_settings",
// Same reasoning for the tag-enrichment settings: truncate the
// per-provider rows (reconcile re-seeds registered ones), and reset the
// tag_sources_meta counter via UPDATE below rather than truncating it.
"tag_provider_settings",
// recsettings.New reconciles shipped defaults on every construction,
// so truncating gives each test pristine tuning values.
"recommendation_weight_profiles",
"taste_tuning",
// Same reasoning as taste_tuning above: recsettings.New re-seeds
// shipped defaults on every construction, so truncating gives each test
// pristine Discover knobs rather than whatever a previous test tuned.
"discover_tuning",
"recommendation_tuning_audit",
"duplicate_group_members",
"duplicate_groups",
"duplicate_sweeps",
"track_fingerprints",
"track_loudness",
"album_loudness",
"user_normalization_prefs",
// Both cascade from users, but the developer's own admin row is
// never deleted, so rows a test wrote for it would otherwise survive.
"user_notifications",
"user_notification_prefs",
"user_notification_email_state",
"acquisition_services",
"mb_release_tracks",
"mb_releases",
"mb_artist_release_groups",
"mb_release_groups",
"mb_artists",
"requests",
"download_interventions",
"library_album_matches",
"release_blocks",
"grabs",
"want_candidates",
"wants",
"follow_seen_groups",
"follows",
"tracks",
"albums",
"artists",
}
// ResetDB clears all data tables and removes any user whose username
// begins with TestUserPrefix. Users without that prefix (notably the
// developer's admin row) are left alone. Calls t.Fatalf on error.
func ResetDB(t *testing.T, pool *pgxpool.Pool) {
t.Helper()
ctx := context.Background()
stmt := "TRUNCATE " + strings.Join(dataTables, ", ") + " RESTART IDENTITY CASCADE"
if _, err := pool.Exec(ctx, stmt); err != nil {
t.Fatalf("dbtest.ResetDB truncate: %v", err)
}
if _, err := pool.Exec(ctx,
"DELETE FROM users WHERE username LIKE $1",
TestUserPrefix+"%",
); err != nil {
t.Fatalf("dbtest.ResetDB delete test users: %v", err)
}
// Reset the monotonic cover-art source-version counter to its
// post-migration seeded value. Truncating the row would break
// SettingsService boot, which reads (never recreates) this
// singleton; an UPDATE keeps the row while clearing cross-test
// version accumulation (CurrentVersion=4 want 1, key-only-bump).
if _, err := pool.Exec(ctx,
"UPDATE cover_art_sources_meta SET current_version = 1",
); err != nil {
t.Fatalf("dbtest.ResetDB reset cover-art version: %v", err)
}
// Same for the tag-sources counter — clears cross-test version
// accumulation that would spuriously report version_bumped on a
// key-only change.
if _, err := pool.Exec(ctx,
"UPDATE tag_sources_meta SET current_version = 1, last_registered_providers_hash = ''",
); err != nil {
t.Fatalf("dbtest.ResetDB reset tag-sources version: %v", err)
}
// Fingerprinting settings, a singleton like the counters above.
// Every column goes back to its migration default rather than to literals
// written here, so a test can pin the Go defaults to the migration's.
if _, err := pool.Exec(ctx, `
UPDATE fingerprint_settings
SET enabled = DEFAULT, chromaprint_length_sec = DEFAULT,
acoustic_max_bit_error_rate = DEFAULT, backfill_concurrency = DEFAULT,
sweep_interval_hours = DEFAULT, updated_at = DEFAULT`,
); err != nil {
t.Fatalf("dbtest.ResetDB reset fingerprint settings: %v", err)
}
// Loudness analysis settings, reset the same way.
if _, err := pool.Exec(ctx, `
UPDATE loudness_settings
SET enabled = DEFAULT, backfill_concurrency = DEFAULT, updated_at = DEFAULT`,
); err != nil {
t.Fatalf("dbtest.ResetDB reset loudness settings: %v", err)
}
// Release-type profiles: migration 0081 seeds "Standard" as
// the default. Tests add and edit profiles, so put back exactly the
// seeded row rather than truncating it away.
if _, err := pool.Exec(ctx, `
DELETE FROM release_type_profiles WHERE name <> 'Standard';
UPDATE release_type_profiles
SET primary_types = ARRAY['Album', 'EP', 'Single'], secondary_types = '{}', is_default = true
WHERE name = 'Standard'`,
); err != nil {
t.Fatalf("dbtest.ResetDB reset release-type profiles: %v", err)
}
// The change log's trimmed mark, a singleton: nothing has been trimmed.
if _, err := pool.Exec(ctx, "UPDATE library_changes_trimmed SET through = 0"); err != nil {
t.Fatalf("dbtest.ResetDB reset library_changes_trimmed: %v", err)
}
// The server's own backup: no runs, and settings back to off.
if _, err := pool.Exec(ctx, `
DELETE FROM backup_runs;
DELETE FROM backup_music_files;
UPDATE backup_settings
SET enabled = DEFAULT, destination = DEFAULT, folder = DEFAULT, endpoint = DEFAULT,
bucket = DEFAULT, access_key = DEFAULT, secret_key = DEFAULT, run_hour = DEFAULT,
upload_limit_kib = DEFAULT, include_music = DEFAULT, updated_at = DEFAULT`,
); err != nil {
t.Fatalf("dbtest.ResetDB reset backup settings: %v", err)
}
// Search and grab settings, a singleton reset the same way.
// The library listing reads as done, so a test's albums are not
// put on the wanted list behind its back; its own tests clear it.
if _, err := pool.Exec(ctx, `
UPDATE acquisition_settings
SET auto_grab = DEFAULT, search_interval_hours = DEFAULT, allowed_qualities = DEFAULT, import_root = DEFAULT,
seed_ratio = DEFAULT, seed_days = DEFAULT, stall_hours = DEFAULT, upgrade_until = DEFAULT,
library_listing_started_at = now(), library_listed_at = now(), library_unlisted = 0,
last_pass_at = NULL, last_pass = DEFAULT, updated_at = DEFAULT`,
); err != nil {
t.Fatalf("dbtest.ResetDB reset acquisition settings: %v", err)
}
// Notification email settings, reset the same way.
if _, err := pool.Exec(ctx, `
UPDATE notification_email_settings
SET summary_hour = DEFAULT, batch_window_minutes = DEFAULT, updated_at = DEFAULT`,
); err != nil {
t.Fatalf("dbtest.ResetDB reset notification email settings: %v", err)
}
}