fix(catalog): preserve normalized TMDB ownership

This commit is contained in:
Quick
2026-07-29 09:37:50 -04:00
parent 9f4f9a3bd8
commit 824d7bdf43
2 changed files with 83 additions and 5 deletions
+24 -4
View File
@@ -118,14 +118,34 @@ func (r *ProviderIDRepository) AttachTMDBID(ctx context.Context, contentID, item
}
var existingOwnerContentID string
err = tx.QueryRow(ctx, `
ownerQuery := `
SELECT content_id
FROM media_items
WHERE type = $1
AND tmdb_id = $2
WHERE tmdb_id >= $2
AND type = $1
AND content_id <> $3
AND (tmdb_id = $2 OR tmdb_id LIKE $2 || '-%')
LIMIT 1
`, itemType, tmdbText, contentID).Scan(&existingOwnerContentID)
`
ownerArgs := []any{itemType, tmdbText, contentID}
// Bound ordinary slug candidates with the existing tmdb_id index. A run of
// nines has no same-width numeric successor, so that rare boundary uses the
// lower-bounded predicate above rather than an empty lexical range.
tmdbUpper := strconv.FormatUint(uint64(tmdbID)+1, 10)
if len(tmdbUpper) == len(tmdbText) {
ownerQuery = `
SELECT content_id
FROM media_items
WHERE tmdb_id >= $1
AND tmdb_id < $2
AND type = $3
AND content_id <> $4
AND (tmdb_id = $1 OR tmdb_id LIKE $1 || '-%')
LIMIT 1
`
ownerArgs = []any{tmdbText, tmdbUpper, itemType, contentID}
}
err = tx.QueryRow(ctx, ownerQuery, ownerArgs...).Scan(&existingOwnerContentID)
if err != nil && !errors.Is(err, pgx.ErrNoRows) {
return fmt.Errorf("checking tmdb id owner: %w", err)
}
@@ -1,6 +1,15 @@
package catalog
import "testing"
import (
"context"
"fmt"
"os"
"strings"
"testing"
"time"
"github.com/jackc/pgx/v5/pgxpool"
)
// Real values seen rejected in production: media_items.tmdb_id held TMDB's
// "id-slug" URL form while the presence lookup attached the bare numeric id, so
@@ -39,3 +48,52 @@ func TestNormalizeTMDBIDLeavesNonIdentifiersAlone(t *testing.T) {
t.Errorf("normalizeTMDBID(%q) = %q", "", got)
}
}
func TestAttachTMDBIDRejectsEquivalentSlugOwner(t *testing.T) {
dsn := strings.TrimSpace(os.Getenv("SILO_TEST_DATABASE_URL"))
if dsn == "" {
t.Skip("SILO_TEST_DATABASE_URL is not set")
}
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
pool, err := pgxpool.New(ctx, dsn)
if err != nil {
t.Fatalf("connect to test database: %v", err)
}
t.Cleanup(pool.Close)
suffix := time.Now().UnixNano()
// A run of nines guards the lexical range bound used by the owner lookup:
// incrementing the numeric id would produce a shorter, invalid upper bound.
tmdbID := 999_999_999
ownerContentID := fmt.Sprintf("test-tmdb-slug-owner-%d", suffix)
targetContentID := fmt.Sprintf("test-tmdb-slug-target-%d", suffix)
if _, err := pool.Exec(ctx, `
INSERT INTO media_items (content_id, type, title, tmdb_id)
VALUES ($1, 'series', 'TMDB slug owner test', $3),
($2, 'series', 'TMDB slug target test', '')
`, ownerContentID, targetContentID, fmt.Sprintf("%d-some-title", tmdbID)); err != nil {
t.Fatalf("insert test media items: %v", err)
}
t.Cleanup(func() {
cleanupCtx, cleanupCancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cleanupCancel()
if _, err := pool.Exec(cleanupCtx, `DELETE FROM media_items WHERE content_id = ANY($1)`, []string{ownerContentID, targetContentID}); err != nil {
t.Errorf("clean up test media items: %v", err)
}
})
err = NewProviderIDRepository(pool).AttachTMDBID(ctx, targetContentID, "series", tmdbID)
if err == nil || !strings.Contains(err.Error(), "already belongs to content_id") {
t.Fatalf("AttachTMDBID() error = %v, want existing-owner conflict", err)
}
var targetTMDBID string
if err := pool.QueryRow(ctx, `SELECT tmdb_id FROM media_items WHERE content_id = $1`, targetContentID).Scan(&targetTMDBID); err != nil {
t.Fatalf("load target tmdb id: %v", err)
}
if targetTMDBID != "" {
t.Fatalf("target tmdb_id = %q, want unchanged", targetTMDBID)
}
}