* 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>
193 lines
6.5 KiB
Go
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")
|
|
}
|
|
}
|