Files
silo-server/internal/metadata/identify_stale_id_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

311 lines
9.6 KiB
Go

package metadata
import (
"context"
"errors"
"strings"
"sync"
"testing"
"github.com/Silo-Server/silo-server/internal/models"
)
// notFoundMetadataProvider simulates a provider whose recorded external ID no
// longer exists upstream: any metadata fetch that reaches it 404s.
type notFoundMetadataProvider struct {
slug string
mu sync.Mutex
called bool
reqIDs map[string]string
}
func (p *notFoundMetadataProvider) Slug() string { return p.slug }
func (p *notFoundMetadataProvider) Name() string { return p.slug }
func (p *notFoundMetadataProvider) ForTypes() []string { return []string{"movie", "series"} }
func (p *notFoundMetadataProvider) GetMetadata(_ context.Context, req MetadataRequest) (*MetadataResult, error) {
p.mu.Lock()
p.called = true
p.reqIDs = copyMap(req.ProviderIDs)
p.mu.Unlock()
return nil, errors.New(p.slug + ": HTTP 404: not found")
}
// TestProcess_IdentifyDoesNotResurrectRecordedStaleProviderID reproduces
// issue #268: an item has a durable tmdb ID already recorded as stale; the
// admin rematches it to a different provider via the Apply Match flow
// (ModeIdentify). The stale tmdb ID must not be re-injected into the identify
// request. The rejected value remains a non-actionable negative-cache entry so
// a later detail response cannot resurrect it.
func TestProcess_IdentifyDoesNotResurrectRecordedStaleProviderID(t *testing.T) {
h := newTestHarness()
ctx := context.Background()
if err := h.itemRepo.Upsert(ctx, &models.MediaItem{
ContentID: "existing-1",
Type: "series",
Title: "Formula 1",
Year: 2016,
Status: "matched",
Studios: []string{},
Networks: []string{},
Countries: []string{},
Genres: []string{},
}); err != nil {
t.Fatalf("upsert existing item: %v", err)
}
providerRepo := newFakeProviderIDRepo()
providerRepo.set("existing-1", &models.MediaItemProviderID{
ContentID: "existing-1",
ItemType: "series",
Provider: "tmdb",
ProviderID: "324880",
})
h.service.providerIDRepo = providerRepo
staleRepo := newFakeStaleIDRepo()
staleRepo.set("existing-1", &models.StaleMediaID{
ContentID: "existing-1",
Provider: "tmdb",
ProviderID: "324880",
})
h.service.staleIDRepo = staleRepo
tmdb := &notFoundMetadataProvider{slug: "tmdb"}
tvdb := &capturingMetadataProvider{
response: &MetadataResult{
HasMetadata: true,
Title: "Formula 1: Drive to Survive",
// A detail response may repeat the known-dead cross-reference; it
// must not undo the stale suppression while bootstrapping new IDs.
ProviderIDs: map[string]string{testTVDBProvider: "417585", testTMDBProvider: "324880"},
},
}
result, err := h.service.ProcessWithProviders(ctx, ProcessRequest{
ContentID: "existing-1",
ProviderIDs: map[string]string{"tvdb": "417585"},
Language: "en",
Mode: ModeIdentify,
}, []Provider{tmdb, tvdb})
if err != nil {
t.Fatalf("ProcessWithProviders: %v", err)
}
if result == nil || !result.Updated {
t.Fatalf("result = %#v, want Updated=true", result)
}
req := tvdb.lastRequest()
if got := req.ProviderIDs["tmdb"]; got != "" {
t.Errorf("identify request tmdb id = %q, want empty (stale durable id must not be re-injected)", got)
}
if got := req.ProviderIDs["tvdb"]; got != "417585" {
t.Errorf("identify request tvdb id = %q, want 417585", got)
}
providerRepo.mu.Lock()
persisted := providerRepo.lastReplace["existing-1"]
providerRepo.mu.Unlock()
if persisted["tmdb"] != "" {
t.Errorf("known-dead detail cross-reference was restored: %#v", persisted)
}
stale, err := staleRepo.GetByContentID(ctx, "existing-1")
if err != nil {
t.Fatalf("get stale rows: %v", err)
}
if len(stale) != 1 || stale[0].Provider != "tmdb" || stale[0].ProviderID != "324880" {
t.Errorf("stale rows after successful rematch = %#v, want retained tmdb=324880 negative cache", stale)
}
}
func TestProcess_IdentifyPreservesProviderAnchoredContentID(t *testing.T) {
const (
contentID = "movie-tmdb-777"
correctedTitle = "Corrected Movie"
)
h := newTestHarness()
ctx := context.Background()
if err := h.itemRepo.Upsert(ctx, &models.MediaItem{
ContentID: contentID, Type: "movie", Title: correctedTitle, Year: 2020,
Status: "matched", TmdbID: "777",
Studios: []string{}, Networks: []string{}, Countries: []string{}, Genres: []string{},
}); err != nil {
t.Fatalf("upsert existing item: %v", err)
}
providerRepo := newFakeProviderIDRepo()
providerRepo.set(contentID, &models.MediaItemProviderID{
ContentID: contentID, ItemType: "movie", Provider: "tmdb", ProviderID: "777",
})
h.service.providerIDRepo = providerRepo
staleRepo := newFakeStaleIDRepo()
staleRepo.set(contentID, &models.StaleMediaID{
ContentID: contentID, Provider: "tmdb", ProviderID: "777",
})
h.service.staleIDRepo = staleRepo
provider := &capturingMetadataProvider{response: &MetadataResult{
HasMetadata: true, Title: correctedTitle,
ProviderIDs: map[string]string{testIMDBProvider: "tt0000777"},
}}
result, err := h.service.ProcessWithProviders(ctx, ProcessRequest{
ContentID: contentID,
ProviderIDs: map[string]string{testIMDBProvider: "tt0000777"},
Language: "en",
Mode: ModeIdentify,
}, []Provider{provider})
if err != nil {
t.Fatalf("ProcessWithProviders: %v", err)
}
if result == nil || result.ContentID != contentID {
t.Fatalf("result content id = %#v, want %s preserved", result, contentID)
}
}
// TestProcess_IdentifySuppressesStaleIDDespiteKeyCasing guards the
// normalization layer: the stale row is recorded with the canonical
// lower-case provider slug, but the durable row (and therefore the injected
// provider-id map key) arrives with different casing and padding ("TMDB ").
// Suppression must still match the two and drop the stale ID.
func TestProcess_IdentifySuppressesStaleIDDespiteKeyCasing(t *testing.T) {
h := newTestHarness()
ctx := context.Background()
if err := h.itemRepo.Upsert(ctx, &models.MediaItem{
ContentID: "existing-1",
Type: "series",
Title: "Formula 1",
Year: 2016,
Status: "matched",
Studios: []string{},
Networks: []string{},
Countries: []string{},
Genres: []string{},
}); err != nil {
t.Fatalf("upsert existing item: %v", err)
}
providerRepo := newFakeProviderIDRepo()
providerRepo.set("existing-1", &models.MediaItemProviderID{
ContentID: "existing-1",
ItemType: "series",
Provider: "TMDB ",
ProviderID: "324880",
})
h.service.providerIDRepo = providerRepo
staleRepo := newFakeStaleIDRepo()
staleRepo.set("existing-1", &models.StaleMediaID{
ContentID: "existing-1",
Provider: "tmdb",
ProviderID: "324880",
})
h.service.staleIDRepo = staleRepo
tmdb := &notFoundMetadataProvider{slug: "tmdb"}
tvdb := &capturingMetadataProvider{
response: &MetadataResult{
HasMetadata: true,
Title: "Formula 1: Drive to Survive",
ProviderIDs: map[string]string{"tvdb": "417585"},
},
}
result, err := h.service.ProcessWithProviders(ctx, ProcessRequest{
ContentID: "existing-1",
ProviderIDs: map[string]string{"tvdb": "417585"},
Language: "en",
Mode: ModeIdentify,
}, []Provider{tmdb, tvdb})
if err != nil {
t.Fatalf("ProcessWithProviders: %v", err)
}
if result == nil || !result.Updated {
t.Fatalf("result = %#v, want Updated=true", result)
}
req := tvdb.lastRequest()
for key, value := range req.ProviderIDs {
if strings.EqualFold(strings.TrimSpace(key), "tmdb") {
t.Errorf("identify request still carries %q=%q, want stale tmdb id suppressed despite key casing", key, value)
}
}
if got := req.ProviderIDs["tvdb"]; got != "417585" {
t.Errorf("identify request tvdb id = %q, want 417585", got)
}
}
// TestProcess_IdentifyKeepsUserSuppliedIDEvenIfRecordedStale documents the
// deliberate exception: when the admin explicitly re-selects the very ID that
// was recorded stale (e.g. the provider has since fixed it), identify must
// retry that ID instead of silently suppressing it.
func TestProcess_IdentifyKeepsUserSuppliedIDEvenIfRecordedStale(t *testing.T) {
h := newTestHarness()
ctx := context.Background()
if err := h.itemRepo.Upsert(ctx, &models.MediaItem{
ContentID: "existing-1",
Type: "series",
Title: "Formula 1",
Year: 2016,
Status: "matched",
Studios: []string{},
Networks: []string{},
Countries: []string{},
Genres: []string{},
}); err != nil {
t.Fatalf("upsert existing item: %v", err)
}
staleRepo := newFakeStaleIDRepo()
staleRepo.set("existing-1", &models.StaleMediaID{
ContentID: "existing-1",
Provider: "tmdb",
ProviderID: "324880",
})
h.service.staleIDRepo = staleRepo
provider := &capturingMetadataProvider{
response: &MetadataResult{
HasMetadata: true,
Title: "Formula 1",
ProviderIDs: map[string]string{"tmdb": "324880"},
},
}
result, err := h.service.ProcessWithProviders(ctx, ProcessRequest{
ContentID: "existing-1",
ProviderIDs: map[string]string{"tmdb": "324880"},
Language: "en",
Mode: ModeIdentify,
}, []Provider{provider})
if err != nil {
t.Fatalf("ProcessWithProviders: %v", err)
}
if result == nil || !result.Updated {
t.Fatalf("result = %#v, want Updated=true", result)
}
req := provider.lastRequest()
if got := req.ProviderIDs["tmdb"]; got != "324880" {
t.Errorf("identify request tmdb id = %q, want 324880 (user-supplied id must survive)", got)
}
item, err := h.itemRepo.GetByID(ctx, "existing-1")
if err != nil {
t.Fatalf("load identified item: %v", err)
}
if item.TmdbID != "324880" {
t.Errorf("persisted tmdb id = %q, want explicitly retried 324880", item.TmdbID)
}
staleRows, err := staleRepo.GetByContentID(ctx, "existing-1")
if err != nil {
t.Fatalf("load stale rows: %v", err)
}
if len(staleRows) != 0 {
t.Errorf("stale rows after successful explicit retry = %#v, want none", staleRows)
}
}