Files
silo-server/internal/adminjob/library_refresh_language_test.go
99d205676f fix(metadata): prevent stale cross-provider IDs (#480)
* fix(metadata): prevent stale cross-provider IDs

* fix(metadata): address stale ID review findings

* fix(migrations): build the stale-ID primary key concurrently

ALTER TABLE ... ADD PRIMARY KEY builds the index under ACCESS EXCLUSIVE,
blocking reads and writes on stale_media_ids for the whole build. Create the
wider unique index with CREATE UNIQUE INDEX CONCURRENTLY and attach it with
ADD CONSTRAINT ... PRIMARY KEY USING INDEX instead; all three key columns are
already NOT NULL, so the attach is metadata-only. Same treatment on the
rollback path, plus the repo's INVALID-remnant cleanup so a failed concurrent
build is not silently accepted by IF NOT EXISTS.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Quick <31828688+Quick104@users.noreply.github.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 11:19:33 -04:00

193 lines
6.5 KiB
Go

package adminjob
import (
"context"
"fmt"
"os"
"testing"
"time"
"github.com/jackc/pgx/v5/pgxpool"
)
// TestQuickRefreshListsLanguageMismatchedItems covers the listing half of
// issue #211: quick-mode library refresh must include items whose stamped
// default_metadata_language differs from the library's configured metadata
// language, even when the item is otherwise complete (overview, poster,
// backdrop all present) — otherwise a library language change never revisits
// already-complete items.
func TestQuickRefreshListsLanguageMismatchedItems(t *testing.T) {
dsn := os.Getenv("SILO_TEST_DATABASE_URL")
if dsn == "" {
t.Skip("SILO_TEST_DATABASE_URL is not set")
}
ctx := context.Background()
pool, err := pgxpool.New(ctx, dsn)
if err != nil {
t.Fatalf("connect test database: %v", err)
}
t.Cleanup(pool.Close)
suffix := time.Now().UnixNano()
mismatchID := fmt.Sprintf("lang-mismatch-%d", suffix)
matchedID := fmt.Sprintf("lang-matched-%d", suffix)
var folderID int
if err := pool.QueryRow(ctx, `
INSERT INTO media_folders (type, name, enabled, metadata_language)
VALUES ('movies', 'Lang Refresh Test', true, 'da')
RETURNING id
`).Scan(&folderID); err != nil {
t.Fatalf("seed folder: %v", err)
}
t.Cleanup(func() {
_, _ = pool.Exec(ctx, `DELETE FROM media_items WHERE content_id = ANY($1)`, []string{mismatchID, matchedID})
_, _ = pool.Exec(ctx, `DELETE FROM media_folders WHERE id = $1`, folderID)
})
for _, row := range []struct {
id, lang string
}{
{mismatchID, "zh"},
{matchedID, "da"},
} {
if _, err := pool.Exec(ctx, `
INSERT INTO media_items (
content_id, type, title, status, genres, tmdb_id,
default_metadata_language, overview, poster_path, backdrop_path,
last_refreshed, refresh_failures, episode_metadata_incomplete
) VALUES ($1, 'movie', 'Complete Item', 'matched', '{}'::text[], '42',
$2, 'An overview', '/p.jpg', '/b.jpg', NOW(), 0, FALSE)
`, row.id, row.lang); err != nil {
t.Fatalf("seed media item %s: %v", row.id, err)
}
if _, err := pool.Exec(ctx, `
INSERT INTO media_item_libraries (content_id, media_folder_id)
VALUES ($1, $2)
`, row.id, folderID); err != nil {
t.Fatalf("link media item %s: %v", row.id, err)
}
}
lister := NewPGLibraryRefreshItemLister(pool)
items, err := lister.ListLibraryItems(ctx, folderID, LibraryRefreshModeQuick)
if err != nil {
t.Fatalf("ListLibraryItems: %v", err)
}
var sawMismatch, sawMatched bool
for _, item := range items {
switch item.ContentID {
case mismatchID:
sawMismatch = true
case matchedID:
sawMatched = true
}
}
if !sawMismatch {
t.Errorf("quick refresh must include complete item with stamped language differing from the library language")
}
if sawMatched {
t.Errorf("quick refresh must not include complete item whose stamped language matches the library language")
}
}
func TestQuickRefreshIgnoresHistoricalSecondaryStaleIDs(t *testing.T) {
dsn := os.Getenv("SILO_TEST_DATABASE_URL")
if dsn == "" {
t.Skip("SILO_TEST_DATABASE_URL is not set")
}
ctx := context.Background()
pool, err := pgxpool.New(ctx, dsn)
if err != nil {
t.Fatalf("connect test database: %v", err)
}
t.Cleanup(pool.Close)
suffix := fmt.Sprintf("%d", time.Now().UnixNano())
historicalID := "movie-tmdb-" + suffix
actionableProviderID := suffix + "1"
actionableID := "movie-tmdb-" + actionableProviderID
rejectedSiblingID := "series-tvdb-" + suffix
var folderID int
if err := pool.QueryRow(ctx, `
INSERT INTO media_folders (type, name, enabled, metadata_language)
VALUES ('movies', 'Stale Refresh Test', true, 'en')
RETURNING id
`).Scan(&folderID); err != nil {
t.Fatalf("seed folder: %v", err)
}
t.Cleanup(func() {
_, _ = pool.Exec(ctx, `DELETE FROM media_items WHERE content_id = ANY($1)`, []string{historicalID, actionableID, rejectedSiblingID})
_, _ = pool.Exec(ctx, `DELETE FROM media_folders WHERE id = $1`, folderID)
})
for _, row := range []struct {
contentID, tmdbID, staleProvider, staleProviderID string
}{
{historicalID, suffix, "tvdb", "999999"},
{actionableID, actionableProviderID, "tmdb", actionableProviderID},
} {
if _, err := pool.Exec(ctx, `
INSERT INTO media_items (
content_id, type, title, status, genres, tmdb_id,
default_metadata_language, overview, poster_path, backdrop_path,
last_refreshed, refresh_failures, episode_metadata_incomplete
) VALUES ($1, 'movie', 'Complete Item', 'matched', '{}'::text[], $2,
'en', 'An overview', '/p.jpg', '/b.jpg', NOW(), 0, FALSE)
`, row.contentID, row.tmdbID); err != nil {
t.Fatalf("seed media item %s: %v", row.contentID, err)
}
if _, err := pool.Exec(ctx, `
INSERT INTO media_item_libraries (content_id, media_folder_id) VALUES ($1, $2)
`, row.contentID, folderID); err != nil {
t.Fatalf("link media item %s: %v", row.contentID, err)
}
if _, err := pool.Exec(ctx, `
INSERT INTO stale_media_ids (content_id, provider, provider_id)
VALUES ($1, $2, $3)
`, row.contentID, row.staleProvider, row.staleProviderID); err != nil {
t.Fatalf("seed stale id for %s: %v", row.contentID, err)
}
}
if _, err := pool.Exec(ctx, `
INSERT INTO media_items (
content_id, type, title, status, genres, tvdb_id,
default_metadata_language, overview, poster_path, backdrop_path,
last_refreshed, refresh_failures, episode_metadata_incomplete
) VALUES ($1, 'series', 'Complete TVDB Item', 'matched', '{}'::text[], $2,
'en', 'An overview', '/p.jpg', '/b.jpg', NOW(), 0, FALSE)
`, rejectedSiblingID, suffix); err != nil {
t.Fatalf("seed TVDB-only media item: %v", err)
}
if _, err := pool.Exec(ctx, `
INSERT INTO media_item_libraries (content_id, media_folder_id) VALUES ($1, $2)
`, rejectedSiblingID, folderID); err != nil {
t.Fatalf("link TVDB-only media item: %v", err)
}
if _, err := pool.Exec(ctx, `
INSERT INTO stale_media_ids (content_id, provider, provider_id)
VALUES ($1, 'tmdb', $2)
`, rejectedSiblingID, suffix+"2"); err != nil {
t.Fatalf("seed rejected TMDB sibling: %v", err)
}
items, err := NewPGLibraryRefreshItemLister(pool).ListLibraryItems(ctx, folderID, LibraryRefreshModeQuick)
if err != nil {
t.Fatalf("ListLibraryItems: %v", err)
}
listed := make(map[string]bool, len(items))
for _, item := range items {
listed[item.ContentID] = true
}
if listed[historicalID] {
t.Fatal("historical secondary stale ID must not keep a complete item in quick refresh")
}
if !listed[actionableID] {
t.Fatal("current canonical stale ID must remain in quick refresh")
}
if listed[rejectedSiblingID] {
t.Fatal("a rejected TMDB sibling must not keep provider-ID enrichment debt active")
}
}