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

133 lines
4.7 KiB
Go

//nolint:goconst // Repeated canonical IDs are the evidence under test.
package metadata
import (
"context"
"testing"
"github.com/Silo-Server/silo-server/internal/models"
)
type idConsensusPipelineProvider struct{}
func (p *idConsensusPipelineProvider) Slug() string { return "tmdb" }
func (p *idConsensusPipelineProvider) Name() string { return "TMDB" }
func (p *idConsensusPipelineProvider) ForTypes() []string { return []string{"series"} }
func (p *idConsensusPipelineProvider) Search(context.Context, SearchQuery) ([]SearchResult, error) {
return []SearchResult{
{
Name: "A Teacher", Year: 2020, Provider: "tvdb",
ProviderIDs: map[string]string{
"imdb": "tt10680614", "tmdb": "103992", "tvdb": "352440",
},
},
{
Name: "A Teacher", Year: 2020, Provider: "tmdb",
ProviderIDs: map[string]string{
"imdb": "tt10680614", "tmdb": "103992", "tvdb": "473725",
},
},
}, nil
}
func (p *idConsensusPipelineProvider) GetMetadata(_ context.Context, req MetadataRequest) (*MetadataResult, error) {
if req.ProviderIDs["tvdb"] != "352440" {
return nil, errUnexpectedQuarantinedProviderID
}
return &MetadataResult{
HasMetadata: true,
Title: "A Teacher",
Year: 2020,
// A provider detail response may repeat its stale cross-reference. The
// pipeline must not let it replace the native provider's confirmed ID.
ProviderIDs: map[string]string{
"imdb": "tt10680614", "tmdb": "103992", "tvdb": "473725",
},
}, nil
}
var errUnexpectedQuarantinedProviderID = &unexpectedQuarantinedProviderIDError{}
type unexpectedQuarantinedProviderIDError struct{}
func (*unexpectedQuarantinedProviderIDError) Error() string {
return "quarantined provider ID reached metadata phase"
}
func TestInitialMatchPipelineResolvesConflictingConsensusIDFromOwningProvider(t *testing.T) {
t.Parallel()
harness := newTestHarness()
provider := &idConsensusPipelineProvider{}
result, err := harness.service.ProcessWithProviders(context.Background(), ProcessRequest{
Hints: &MatchHints{Title: "A Teacher", Type: "series"},
Mode: ModeInitialMatch,
}, []Provider{provider})
if err != nil {
t.Fatalf("ProcessWithProviders() error = %v", err)
}
if result == nil || !result.Updated || result.Decision == nil || result.Decision.Outcome != "matched" {
t.Fatalf("result = %#v, want matched consensus", result)
}
item, err := harness.itemRepo.GetByID(context.Background(), result.ContentID)
if err != nil {
t.Fatalf("load matched item: %v", err)
}
if item.ImdbID != "tt10680614" || item.TmdbID != "103992" {
t.Fatalf("persisted consensus IDs = imdb:%q tmdb:%q", item.ImdbID, item.TmdbID)
}
if item.TvdbID != "352440" {
t.Fatalf("native TVDB ID = %q, want 352440", item.TvdbID)
}
}
func TestRefreshPipelinesReplaceStoredCrossReferenceWithOwningProviderID(t *testing.T) {
for name, mode := range map[string]RefreshMode{"scheduled": ModeScheduledRefresh, "manual": ModeManualRefresh} {
mode := mode
t.Run(name, func(t *testing.T) {
t.Parallel()
harness := newTestHarness()
const contentID = "series-refresh-consensus"
if err := harness.itemRepo.Upsert(context.Background(), &models.MediaItem{
ContentID: contentID, Type: "series", Title: "A Teacher", Year: 2020, Status: "matched",
ImdbID: "tt10680614", TmdbID: "103992", TvdbID: "473725",
Studios: []string{}, Networks: []string{}, Countries: []string{}, Genres: []string{},
}); err != nil {
t.Fatalf("seed item: %v", err)
}
result, err := harness.service.ProcessWithProviders(context.Background(), ProcessRequest{
ContentID: contentID,
Language: "en",
Mode: mode,
}, []Provider{&idConsensusPipelineProvider{}})
if err != nil {
t.Fatalf("ProcessWithProviders() error = %v", err)
}
if result == nil || !result.Updated {
t.Fatalf("result = %#v, want updated refresh", result)
}
item, err := harness.itemRepo.GetByID(context.Background(), contentID)
if err != nil {
t.Fatalf("load refreshed item: %v", err)
}
if item.ImdbID != "tt10680614" || item.TmdbID != "103992" || item.TvdbID != "352440" {
t.Fatalf("refreshed IDs = imdb:%q tmdb:%q tvdb:%q, want native TVDB ID 352440", item.ImdbID, item.TmdbID, item.TvdbID)
}
})
}
}
func TestApplyCandidateProviderIDConsensusRemovesDisputedStoredID(t *testing.T) {
ids := map[string]string{"imdb": "tt10680614", "tmdb": "103992", "tvdb": "473725"}
winner := &MatchCandidate{
ProviderIDs: map[string]string{"imdb": "tt10680614", "tmdb": "103992", "tvdb": "473725"},
ConflictingProviderIDKeys: []string{"tvdb"},
}
applyCandidateProviderIDConsensus(ids, winner, nil)
if ids["tvdb"] != "" || ids["imdb"] != "tt10680614" || ids["tmdb"] != "103992" {
t.Fatalf("consensus IDs = %#v", ids)
}
}