Files
silo-server/internal/catalog/item_alias_repo_test.go
383973ec22 feat(metadata): improve match accuracy and localized titles (#461)
* feat(metadata): improve match accuracy and localized titles

* fix(metadata): address matching review findings

* test(catalog): align empty alias snapshot scope

---------

Co-authored-by: Quick104 <31828688+Quick104@users.noreply.github.com>
2026-07-24 12:02:52 -04:00

460 lines
19 KiB
Go

package catalog
import (
"context"
"fmt"
"slices"
"testing"
"time"
"github.com/Silo-Server/silo-server/internal/models"
)
const (
catalogTestContentTypeSeries = "series"
catalogTestProviderTMDB = "tmdb"
catalogTestProviderTVDB = "tvdb"
)
func TestItemAliasRepositoryReplacesOnlySuccessfulProviderAndCascades(t *testing.T) {
pool := newSemanticCoverageTestPool(t)
ctx := context.Background()
var aliasTable *string
if err := pool.QueryRow(ctx, `SELECT to_regclass('public.media_item_aliases')::text`).Scan(&aliasTable); err != nil {
t.Fatalf("check media_item_aliases table: %v", err)
}
if aliasTable == nil || *aliasTable == "" {
t.Skip("test database has not applied media item aliases migration")
}
contentID := fmt.Sprintf("alias-repo-%d", time.Now().UnixNano())
seedSemanticCoverageMediaItem(t, pool, contentID, catalogTestContentTypeSeries, "matched")
t.Cleanup(func() { _, _ = pool.Exec(ctx, `DELETE FROM media_items WHERE content_id = $1`, contentID) })
repo := NewItemAliasRepository(pool)
if err := repo.ReplaceProvider(ctx, contentID, catalogTestProviderTVDB, []models.MediaItemAlias{
{Title: "10 Tokyo Warriors", Language: "en", Kind: itemAliasKindAlternate},
}); err != nil {
t.Fatalf("seed TVDB aliases: %v", err)
}
if err := repo.ReplaceProvider(ctx, contentID, catalogTestProviderTMDB, []models.MediaItemAlias{
{Title: "Old TMDB Title", Language: "en", Kind: itemAliasKindLocalized},
}); err != nil {
t.Fatalf("seed TMDB aliases: %v", err)
}
if err := repo.ReplaceProvider(ctx, contentID, catalogTestProviderTMDB, []models.MediaItemAlias{
{Title: "New TMDB Title", Language: "en", Kind: itemAliasKindLocalized},
{Title: "New TMDB Title", Language: "en", Kind: itemAliasKindLocalized},
}); err != nil {
t.Fatalf("replace TMDB aliases: %v", err)
}
aliasesByID, err := repo.ListByContentIDs(ctx, []string{contentID})
if err != nil {
t.Fatalf("ListByContentIDs(): %v", err)
}
titles := make([]string, 0, len(aliasesByID[contentID]))
for _, alias := range aliasesByID[contentID] {
titles = append(titles, alias.Title)
}
if !slices.Contains(titles, "10 Tokyo Warriors") || !slices.Contains(titles, "New TMDB Title") || slices.Contains(titles, "Old TMDB Title") {
t.Fatalf("titles = %v", titles)
}
if len(titles) != 2 {
t.Fatalf("deduplicated titles = %v, want 2", titles)
}
if _, err := pool.Exec(ctx, `DELETE FROM media_items WHERE content_id = $1`, contentID); err != nil {
t.Fatalf("delete media item: %v", err)
}
var count int
if err := pool.QueryRow(ctx, `SELECT count(*) FROM media_item_aliases WHERE content_id = $1`, contentID).Scan(&count); err != nil {
t.Fatalf("count cascaded aliases: %v", err)
}
if count != 0 {
t.Fatalf("alias count after item delete = %d, want 0", count)
}
}
func TestItemAliasRepositoryLanguageRefreshPreservesOtherLibraryLanguages(t *testing.T) {
pool := newSemanticCoverageTestPool(t)
ctx := context.Background()
var aliasTable *string
if err := pool.QueryRow(ctx, `SELECT to_regclass('public.media_item_aliases')::text`).Scan(&aliasTable); err != nil {
t.Fatalf("check media_item_aliases table: %v", err)
}
if aliasTable == nil || *aliasTable == "" {
t.Skip("test database has not applied media item aliases migration")
}
contentID := fmt.Sprintf("alias-language-%d", time.Now().UnixNano())
seedSemanticCoverageMediaItem(t, pool, contentID, catalogTestContentTypeSeries, "matched")
t.Cleanup(func() { _, _ = pool.Exec(ctx, `DELETE FROM media_items WHERE content_id = $1`, contentID) })
repo := NewItemAliasRepository(pool)
if err := repo.RefreshProviderLanguage(ctx, contentID, catalogTestProviderTVDB, "ja", []models.MediaItemAlias{
{Title: "日本語", Language: "ja", Kind: itemAliasKindLocalized},
}, true); err != nil {
t.Fatalf("seed Japanese aliases: %v", err)
}
if err := repo.RefreshProviderLanguage(ctx, contentID, catalogTestProviderTVDB, "en", []models.MediaItemAlias{
{Title: "Old English", Language: "en", Kind: itemAliasKindLocalized},
{Title: "Old Untagged", Kind: itemAliasKindAlternate},
}, true); err != nil {
t.Fatalf("seed English aliases: %v", err)
}
if err := repo.ReplaceProviderLanguage(ctx, contentID, catalogTestProviderTVDB, "en-US", []models.MediaItemAlias{
{Title: "New English", Language: "en", Kind: itemAliasKindLocalized},
{Title: "日本語原題", Language: "ja", Kind: itemAliasKindOriginal},
{Title: "New Untagged", Kind: itemAliasKindAlternate},
}); err != nil {
t.Fatalf("refresh English aliases: %v", err)
}
aliasesByID, err := repo.ListByContentIDs(ctx, []string{contentID})
if err != nil {
t.Fatalf("ListByContentIDs(): %v", err)
}
titles := make([]string, 0, len(aliasesByID[contentID]))
for _, alias := range aliasesByID[contentID] {
titles = append(titles, alias.Title)
}
for _, want := range []string{"New English", "日本語", "日本語原題", "New Untagged"} {
if !slices.Contains(titles, want) {
t.Fatalf("titles = %v, want preserved/refreshed %q", titles, want)
}
}
for _, stale := range []string{"Old English", "Old Untagged"} {
if slices.Contains(titles, stale) {
t.Fatalf("titles = %v, stale alias %q was not replaced", titles, stale)
}
}
}
func TestItemAliasRepositoryCompleteSnapshotReplacesCrossLanguageAliasesInItsScope(t *testing.T) {
pool := newSemanticCoverageTestPool(t)
ctx := context.Background()
contentID := fmt.Sprintf("alias-cross-language-%d", time.Now().UnixNano())
seedSemanticCoverageMediaItem(t, pool, contentID, catalogTestContentTypeSeries, "matched")
t.Cleanup(func() { _, _ = pool.Exec(ctx, `DELETE FROM media_items WHERE content_id = $1`, contentID) })
repo := NewItemAliasRepository(pool)
if err := repo.RefreshProviderLanguage(ctx, contentID, catalogTestProviderTVDB, "ja", []models.MediaItemAlias{
{Title: "日本語ライブラリ", Language: "ja", Kind: itemAliasKindLocalized},
}, true); err != nil {
t.Fatalf("seed Japanese snapshot: %v", err)
}
if err := repo.RefreshProviderLanguage(ctx, contentID, catalogTestProviderTVDB, "en", []models.MediaItemAlias{
{Title: "English Title", Language: "en", Kind: itemAliasKindLocalized},
{Title: "Ancienne française", Language: "fr", Kind: itemAliasKindAlternate},
}, true); err != nil {
t.Fatalf("seed multilingual English snapshot: %v", err)
}
if err := repo.RefreshProviderLanguage(ctx, contentID, catalogTestProviderTVDB, "en", []models.MediaItemAlias{
{Title: "Updated English Title", Language: "en", Kind: itemAliasKindLocalized},
}, true); err != nil {
t.Fatalf("replace English snapshot: %v", err)
}
aliasesByID, err := repo.ListByContentIDs(ctx, []string{contentID})
if err != nil {
t.Fatalf("ListByContentIDs(): %v", err)
}
titles := make([]string, 0, len(aliasesByID[contentID]))
for _, alias := range aliasesByID[contentID] {
titles = append(titles, alias.Title)
}
for _, want := range []string{"日本語ライブラリ", "Updated English Title"} {
if !slices.Contains(titles, want) {
t.Fatalf("titles = %v, want %q", titles, want)
}
}
for _, stale := range []string{"English Title", "Ancienne française"} {
if slices.Contains(titles, stale) {
t.Fatalf("titles = %v, stale cross-language alias %q survived", titles, stale)
}
}
}
func TestItemAliasRepositoryListDeduplicatesAliasesAcrossSnapshotScopes(t *testing.T) {
pool := newSemanticCoverageTestPool(t)
ctx := context.Background()
contentID := fmt.Sprintf("alias-snapshot-dedup-%d", time.Now().UnixNano())
seedSemanticCoverageMediaItem(t, pool, contentID, catalogTestContentTypeSeries, "matched")
t.Cleanup(func() { _, _ = pool.Exec(ctx, `DELETE FROM media_items WHERE content_id = $1`, contentID) })
repo := NewItemAliasRepository(pool)
if err := repo.RefreshProviderLanguage(ctx, contentID, catalogTestProviderTVDB, "en", []models.MediaItemAlias{
{Title: "The Office", Language: "en", Kind: itemAliasKindAlternate},
}, true); err != nil {
t.Fatalf("seed English snapshot: %v", err)
}
if err := repo.RefreshProviderLanguage(ctx, contentID, catalogTestProviderTVDB, "ja", []models.MediaItemAlias{
{Title: "the office", Language: "en", Kind: itemAliasKindAlternate},
}, true); err != nil {
t.Fatalf("seed Japanese snapshot: %v", err)
}
aliasesByID, err := repo.ListByContentIDs(ctx, []string{contentID})
if err != nil {
t.Fatalf("ListByContentIDs(): %v", err)
}
aliases := aliasesByID[contentID]
if len(aliases) != 1 {
t.Fatalf("aliases = %#v, want one normalized alias", aliases)
}
if aliases[0].Title != "the office" {
t.Fatalf("alias title = %q, want newest spelling", aliases[0].Title)
}
}
func TestItemAliasRepositoryScopedRefreshAdoptsLegacyWithoutErasingProviderWideSnapshot(t *testing.T) {
pool := newSemanticCoverageTestPool(t)
ctx := context.Background()
contentID := fmt.Sprintf("alias-scope-provenance-%d", time.Now().UnixNano())
seedSemanticCoverageMediaItem(t, pool, contentID, "movie", "matched")
t.Cleanup(func() { _, _ = pool.Exec(ctx, `DELETE FROM media_items WHERE content_id = $1`, contentID) })
repo := NewItemAliasRepository(pool)
if err := repo.ReplaceProvider(ctx, contentID, catalogTestProviderTMDB, []models.MediaItemAlias{
{Title: "Provider-wide title", Kind: itemAliasKindAlternate},
}); err != nil {
t.Fatalf("seed provider-wide snapshot: %v", err)
}
if _, err := pool.Exec(ctx, `
INSERT INTO media_item_aliases (
content_id, title, language, kind, provider, snapshot_language
) VALUES
($1, 'Legacy unscoped title', '', 'alternate', $2, NULL),
($1, '従来の日本語タイトル', 'ja', 'localized', $2, NULL)
`, contentID, catalogTestProviderTMDB); err != nil {
t.Fatalf("seed legacy alias: %v", err)
}
if err := repo.RefreshProviderLanguage(ctx, contentID, catalogTestProviderTMDB, "en", []models.MediaItemAlias{
{Title: "Current English title", Language: "en", Kind: itemAliasKindLocalized},
}, true); err != nil {
t.Fatalf("refresh English snapshot: %v", err)
}
aliasesByID, err := repo.ListByContentIDs(ctx, []string{contentID})
if err != nil {
t.Fatalf("ListByContentIDs(): %v", err)
}
titles := make([]string, 0, len(aliasesByID[contentID]))
for _, alias := range aliasesByID[contentID] {
titles = append(titles, alias.Title)
}
for _, want := range []string{"Provider-wide title", "Current English title", "従来の日本語タイトル"} {
if !slices.Contains(titles, want) {
t.Fatalf("titles = %v, want preserved/refreshed %q", titles, want)
}
}
if slices.Contains(titles, "Legacy unscoped title") {
t.Fatalf("titles = %v, legacy alias was not adopted by complete refresh", titles)
}
}
func TestItemAliasRepositoryPartialRefreshMergesWithoutErasing(t *testing.T) {
pool := newSemanticCoverageTestPool(t)
ctx := context.Background()
contentID := fmt.Sprintf("alias-partial-%d", time.Now().UnixNano())
seedSemanticCoverageMediaItem(t, pool, contentID, "movie", "matched")
t.Cleanup(func() { _, _ = pool.Exec(ctx, `DELETE FROM media_items WHERE content_id = $1`, contentID) })
repo := NewItemAliasRepository(pool)
if err := repo.RefreshProviderLanguage(ctx, contentID, catalogTestProviderTMDB, "en", []models.MediaItemAlias{
{Title: "Authoritative English", Language: "en", Kind: itemAliasKindLocalized},
{Title: "Provider Alternate", Language: "en", Kind: itemAliasKindAlternate},
}, true); err != nil {
t.Fatalf("seed complete aliases: %v", err)
}
if err := repo.RefreshProviderLanguage(ctx, contentID, catalogTestProviderTMDB, "en", []models.MediaItemAlias{
{Title: "Legacy Primary Title", Language: "en", Kind: itemAliasKindLocalized},
}, false); err != nil {
t.Fatalf("merge partial aliases: %v", err)
}
aliasesByID, err := repo.ListByContentIDs(ctx, []string{contentID})
if err != nil {
t.Fatalf("ListByContentIDs(): %v", err)
}
titles := make([]string, 0, len(aliasesByID[contentID]))
for _, alias := range aliasesByID[contentID] {
titles = append(titles, alias.Title)
}
for _, want := range []string{"Authoritative English", "Provider Alternate", "Legacy Primary Title"} {
if !slices.Contains(titles, want) {
t.Fatalf("titles = %v, partial refresh erased %q", titles, want)
}
}
}
func TestItemAliasRepositoryEmptySnapshotHonorsCompleteness(t *testing.T) {
pool := newSemanticCoverageTestPool(t)
ctx := context.Background()
contentID := fmt.Sprintf("alias-empty-%d", time.Now().UnixNano())
seedSemanticCoverageMediaItem(t, pool, contentID, "movie", "matched")
t.Cleanup(func() { _, _ = pool.Exec(ctx, `DELETE FROM media_items WHERE content_id = $1`, contentID) })
repo := NewItemAliasRepository(pool)
if err := repo.RefreshProviderLanguage(ctx, contentID, catalogTestProviderTMDB, "en", []models.MediaItemAlias{
{Title: "Stale English", Language: "en", Kind: itemAliasKindLocalized},
}, true); err != nil {
t.Fatalf("seed TMDB aliases: %v", err)
}
if err := repo.RefreshProviderLanguage(ctx, contentID, catalogTestProviderTVDB, "en", []models.MediaItemAlias{
{Title: "Keep TVDB", Language: "en", Kind: itemAliasKindAlternate},
}, true); err != nil {
t.Fatalf("seed TVDB aliases: %v", err)
}
if err := repo.RefreshProviderLanguage(ctx, contentID, catalogTestProviderTMDB, "en", nil, false); err != nil {
t.Fatalf("merge empty partial snapshot: %v", err)
}
aliasesByID, err := repo.ListByContentIDs(ctx, []string{contentID})
if err != nil {
t.Fatalf("list aliases after partial snapshot: %v", err)
}
if !slices.ContainsFunc(aliasesByID[contentID], func(alias models.MediaItemAlias) bool {
return alias.Title == "Stale English"
}) {
t.Fatalf("partial empty snapshot removed aliases: %+v", aliasesByID[contentID])
}
if err := repo.RefreshProviderLanguage(ctx, contentID, catalogTestProviderTMDB, "en", nil, true); err != nil {
t.Fatalf("replace with empty complete snapshot: %v", err)
}
aliasesByID, err = repo.ListByContentIDs(ctx, []string{contentID})
if err != nil {
t.Fatalf("list aliases after complete snapshot: %v", err)
}
for _, alias := range aliasesByID[contentID] {
if alias.Provider == catalogTestProviderTMDB {
t.Fatalf("complete empty snapshot retained TMDB alias: %+v", alias)
}
}
if !slices.ContainsFunc(aliasesByID[contentID], func(alias models.MediaItemAlias) bool {
return alias.Title == "Keep TVDB" && alias.Provider == catalogTestProviderTVDB
}) {
t.Fatalf("complete TMDB snapshot erased another provider: %+v", aliasesByID[contentID])
}
}
func TestItemAliasRepositoryBackfillResumesAndEnqueuesIndexEvents(t *testing.T) {
pool := newSemanticCoverageTestPool(t)
ctx := context.Background()
var stateTable *string
if err := pool.QueryRow(ctx, `SELECT to_regclass('public.media_item_alias_backfill_state')::text`).Scan(&stateTable); err != nil {
t.Fatalf("check alias backfill state table: %v", err)
}
if stateTable == nil || *stateTable == "" {
t.Skip("test database has not applied alias backfill state migration")
}
prefix := fmt.Sprintf("zz-alias-backfill-%d-", time.Now().UnixNano())
firstID, secondID := prefix+"a", prefix+"b"
for _, contentID := range []string{firstID, secondID} {
seedSemanticCoverageMediaItem(t, pool, contentID, catalogTestContentTypeSeries, "matched")
if _, err := pool.Exec(ctx, `
UPDATE media_items SET original_title = '倒凶十将伝', original_language = 'jpn'
WHERE content_id = $1
`, contentID); err != nil {
t.Fatalf("seed original title: %v", err)
}
if _, err := pool.Exec(ctx, `
INSERT INTO media_item_localizations (content_id, language, title)
VALUES ($1, 'en_US', '10 Tokyo Warriors')
`, contentID); err != nil {
t.Fatalf("seed localization: %v", err)
}
}
t.Cleanup(func() {
_, _ = pool.Exec(ctx, `DELETE FROM catalog_search_index_events WHERE content_id = ANY($1)`, []string{firstID, secondID})
_, _ = pool.Exec(ctx, `DELETE FROM media_items WHERE content_id = ANY($1)`, []string{firstID, secondID})
_, _ = pool.Exec(ctx, `DELETE FROM media_item_alias_backfill_state WHERE task_key = 'media_item_aliases_v1'`)
})
if _, err := pool.Exec(ctx, `
INSERT INTO media_item_alias_backfill_state (task_key, last_content_id, completed)
VALUES ('media_item_aliases_v1', $1, false)
ON CONFLICT (task_key) DO UPDATE
SET last_content_id = EXCLUDED.last_content_id, completed = false
`, prefix); err != nil {
t.Fatalf("seed backfill cursor: %v", err)
}
repo := NewItemAliasRepository(pool)
repo.events.WithActiveProvider(SearchProviderMeilisearch)
firstCursor, processed, err := repo.BackfillBatch(ctx, "", 1)
if err != nil || processed != 1 || firstCursor != firstID {
t.Fatalf("first BackfillBatch() = (%q, %d, %v), want (%q, 1, nil)", firstCursor, processed, err, firstID)
}
secondCursor, processed, err := repo.BackfillBatch(ctx, "", 1)
if err != nil || processed != 1 || secondCursor != secondID {
t.Fatalf("resumed BackfillBatch() = (%q, %d, %v), want (%q, 1, nil)", secondCursor, processed, err, secondID)
}
for _, contentID := range []string{firstID, secondID} {
aliases, err := repo.ListByContentIDs(ctx, []string{contentID})
if err != nil {
t.Fatalf("list aliases for %s: %v", contentID, err)
}
languages := make([]string, 0, len(aliases[contentID]))
for _, alias := range aliases[contentID] {
languages = append(languages, alias.Language)
}
if !slices.Contains(languages, "ja") || !slices.Contains(languages, "en") {
t.Fatalf("alias languages for %s = %v, want canonical ja and en", contentID, languages)
}
var eventCount int
if err := pool.QueryRow(ctx, `
SELECT count(*) FROM catalog_search_index_events
WHERE content_id = $1 AND action = 'upsert'
`, contentID).Scan(&eventCount); err != nil {
t.Fatalf("count index events for %s: %v", contentID, err)
}
if eventCount == 0 {
t.Fatalf("no search-index upsert event for %s", contentID)
}
}
}
func TestItemRepositorySearchesPersistedAliasesAcrossExactFTSAndFuzzyPaths(t *testing.T) {
pool := newSemanticCoverageTestPool(t)
ctx := context.Background()
var aliasTable *string
if err := pool.QueryRow(ctx, `SELECT to_regclass('public.media_item_aliases')::text`).Scan(&aliasTable); err != nil {
t.Fatalf("check media_item_aliases table: %v", err)
}
if aliasTable == nil || *aliasTable == "" {
t.Skip("test database has not applied media item aliases migration")
}
contentID := fmt.Sprintf("alias-search-%d", time.Now().UnixNano())
seedSemanticCoverageMediaItem(t, pool, contentID, catalogTestContentTypeSeries, "matched")
t.Cleanup(func() { _, _ = pool.Exec(ctx, `DELETE FROM media_items WHERE content_id = $1`, contentID) })
if _, err := pool.Exec(ctx, `
UPDATE media_items SET title = '倒凶十将伝', original_title = '倒凶十将伝' WHERE content_id = $1
`, contentID); err != nil {
t.Fatalf("seed native title: %v", err)
}
if _, err := pool.Exec(ctx, `
INSERT INTO media_item_aliases (content_id, title, language, kind, provider)
VALUES ($1, '10 Tokyo Warriors', 'en', 'alternate', 'tvdb')
`, contentID); err != nil {
t.Fatalf("seed searchable alias: %v", err)
}
repo := NewItemRepository(pool)
// The final query deliberately verifies fuzzy matching for a misspelling.
for _, query := range []string{"10 Tokyo Warriors", "Tokyo Warriors", "10 Tokyo Warriros"} { //nolint:misspell
items, _, err := repo.Search(ctx, query, []string{catalogTestContentTypeSeries}, 20, 0, AccessFilter{})
if err != nil {
t.Fatalf("Search(%q): %v", query, err)
}
found := false
for _, item := range items {
found = found || item.ContentID == contentID
}
if !found {
t.Fatalf("Search(%q) did not return alias-backed item %s", query, contentID)
}
}
}