* fix(metadata): stop manual rematch from resurrecting recorded stale IDs The Apply Match flow (ModeIdentify) re-injected durable provider IDs into the identify request without checking stale_media_ids, so a known-dead tmdb ID rode along, 404ed again during the Phase-2 fetch, and was re-recorded with a fresh last_seen_at — the item never left the Stale External IDs list and jumped back to the top after every rematch. Filter recorded-stale IDs out of the injected durable set in prepareProcessRequest. Caller-supplied IDs are untouched, so an admin deliberately re-selecting a previously-stale ID still retries it (which is also why the ModeIdentify suppression guard in processInternal stays). Fixes #268 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(metadata): normalize provider-id keys so stale-ID suppression can't be bypassed by casing Review on PR #276 flagged that suppressRecordedStaleProviderIDs lowercases and trims the stored stale row's provider before looking it up in the incoming map, while the map keys are used verbatim, and that HandleApplyItemMatch passes req.ProviderIDs from the JSON body straight into metadata.Process without the normalization the search endpoint applies. A caller-supplied key like "TMDB" or " tmdb " therefore defeated the suppression. The same normalization gap was previously flagged on PR #182. Fix both layers: - HandleApplyItemMatch now runs req.ProviderIDs through normalizeMatchProviderIDs (same semantics as the search endpoint) and returns 400 when no non-blank entries remain, mirroring the existing empty-map rejection. - suppressRecordedStaleProviderIDs now indexes the incoming map by normalized key and deletes the matching original keys, so suppression is robust regardless of caller casing or padding. Adds regression tests at both layers: a metadata-level case where the durable row arrives as "TMDB " while the stale row records "tmdb", and handler-level cases asserting apply normalizes keys/values and rejects all-blank provider-id maps. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com> Co-authored-by: Quick <31828688+Quick104@users.noreply.github.com>
423 lines
13 KiB
Go
423 lines
13 KiB
Go
package handlers
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"testing"
|
|
|
|
"github.com/go-chi/chi/v5"
|
|
|
|
"github.com/Silo-Server/silo-server/internal/metadata"
|
|
"github.com/Silo-Server/silo-server/internal/models"
|
|
)
|
|
|
|
// --- Fakes ---
|
|
|
|
type fakeMatchItemLookup struct {
|
|
items map[string]*models.MediaItem
|
|
}
|
|
|
|
func (f *fakeMatchItemLookup) GetByID(_ context.Context, contentID string) (*models.MediaItem, error) {
|
|
if item, ok := f.items[contentID]; ok {
|
|
return item, nil
|
|
}
|
|
return nil, fmt.Errorf("item not found: %s", contentID)
|
|
}
|
|
|
|
type fakeMatchFolderLookup struct {
|
|
folders map[string]int
|
|
folderIDs map[string][]int
|
|
}
|
|
|
|
func (f *fakeMatchFolderLookup) GetFolderIDForItem(_ context.Context, contentID string) (int, error) {
|
|
if fid, ok := f.folders[contentID]; ok {
|
|
return fid, nil
|
|
}
|
|
return 0, fmt.Errorf("no folder for %s", contentID)
|
|
}
|
|
|
|
func (f *fakeMatchFolderLookup) GetFolderIDsForItem(_ context.Context, contentID string) ([]int, error) {
|
|
if ids, ok := f.folderIDs[contentID]; ok {
|
|
return ids, nil
|
|
}
|
|
if fid, ok := f.folders[contentID]; ok {
|
|
return []int{fid}, nil
|
|
}
|
|
return nil, fmt.Errorf("no folders for %s", contentID)
|
|
}
|
|
|
|
type fakeMatchMetadataService struct {
|
|
searchResults []metadata.MatchCandidate
|
|
searchErr error
|
|
processResult *metadata.ProcessResult
|
|
processErr error
|
|
lastProcess metadata.ProcessRequest
|
|
}
|
|
|
|
func (f *fakeMatchMetadataService) SearchAndNormalize(_ context.Context, _ metadata.SearchQuery, _ int) ([]metadata.MatchCandidate, error) {
|
|
return f.searchResults, f.searchErr
|
|
}
|
|
|
|
func (f *fakeMatchMetadataService) Process(_ context.Context, req metadata.ProcessRequest) (*metadata.ProcessResult, error) {
|
|
f.lastProcess = req
|
|
return f.processResult, f.processErr
|
|
}
|
|
|
|
// --- Helpers ---
|
|
|
|
func buildMatchRouter(h *AdminMatchHandler) *chi.Mux {
|
|
r := chi.NewRouter()
|
|
r.Post("/admin/items/{id}/match/search", h.HandleSearchItemMatchCandidates)
|
|
r.Post("/admin/items/{id}/match/apply", h.HandleApplyItemMatch)
|
|
return r
|
|
}
|
|
|
|
// --- Tests ---
|
|
|
|
func TestAdminMatchSearch_ReturnsCandidatesArray(t *testing.T) {
|
|
items := &fakeMatchItemLookup{
|
|
items: map[string]*models.MediaItem{
|
|
"item-1": {ContentID: "item-1", Title: "The Matrix", Year: 1999, Type: "movie"},
|
|
},
|
|
}
|
|
folders := &fakeMatchFolderLookup{
|
|
folders: map[string]int{"item-1": 10},
|
|
}
|
|
metaSvc := &fakeMatchMetadataService{
|
|
searchResults: []metadata.MatchCandidate{
|
|
{
|
|
Title: "The Matrix",
|
|
Year: 1999,
|
|
ContentType: "movie",
|
|
ProviderIDs: map[string]string{"tmdb": "603", "imdb": "tt0133093"},
|
|
Sources: []string{"tmdb"},
|
|
AgreementHints: []string{"agreed_by_tmdb_and_tvdb"},
|
|
},
|
|
{
|
|
Title: "The Matrix Reloaded",
|
|
Year: 2003,
|
|
ContentType: "movie",
|
|
ProviderIDs: map[string]string{"tmdb": "604"},
|
|
Sources: []string{"tmdb"},
|
|
},
|
|
},
|
|
}
|
|
|
|
h := NewAdminMatchHandler(items, folders, metaSvc)
|
|
router := buildMatchRouter(h)
|
|
|
|
body, _ := json.Marshal(matchSearchRequest{Title: "The Matrix"})
|
|
req := httptest.NewRequest(http.MethodPost, "/admin/items/item-1/match/search", bytes.NewReader(body))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
rec := httptest.NewRecorder()
|
|
|
|
router.ServeHTTP(rec, req)
|
|
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("expected 200, got %d: %s", rec.Code, rec.Body.String())
|
|
}
|
|
|
|
var resp matchSearchResponse
|
|
if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil {
|
|
t.Fatalf("decoding response: %v", err)
|
|
}
|
|
|
|
if len(resp.Candidates) != 2 {
|
|
t.Fatalf("expected 2 candidates, got %d", len(resp.Candidates))
|
|
}
|
|
|
|
// Verify candidate structure.
|
|
c0 := resp.Candidates[0]
|
|
if c0.Title != "The Matrix" {
|
|
t.Errorf("expected title 'The Matrix', got %q", c0.Title)
|
|
}
|
|
if c0.ProviderIDs["tmdb"] != "603" {
|
|
t.Errorf("expected tmdb=603, got %q", c0.ProviderIDs["tmdb"])
|
|
}
|
|
if len(c0.AgreementHints) != 1 || c0.AgreementHints[0] != "agreed_by_tmdb_and_tvdb" {
|
|
t.Errorf("expected agreement_hints, got %v", c0.AgreementHints)
|
|
}
|
|
}
|
|
|
|
func TestAdminMatchSearch_ItemNotFound(t *testing.T) {
|
|
items := &fakeMatchItemLookup{items: map[string]*models.MediaItem{}}
|
|
h := NewAdminMatchHandler(items, nil, &fakeMatchMetadataService{})
|
|
router := buildMatchRouter(h)
|
|
|
|
body, _ := json.Marshal(matchSearchRequest{Title: "foo"})
|
|
req := httptest.NewRequest(http.MethodPost, "/admin/items/nonexistent/match/search", bytes.NewReader(body))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
rec := httptest.NewRecorder()
|
|
|
|
router.ServeHTTP(rec, req)
|
|
|
|
if rec.Code != http.StatusNotFound {
|
|
t.Fatalf("expected 404, got %d: %s", rec.Code, rec.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestAdminMatchSearch_FallsBackToItemMetadata(t *testing.T) {
|
|
items := &fakeMatchItemLookup{
|
|
items: map[string]*models.MediaItem{
|
|
"item-2": {ContentID: "item-2", Title: "Inception", Year: 2010, Type: "movie"},
|
|
},
|
|
}
|
|
var capturedQuery metadata.SearchQuery
|
|
metaSvc := &fakeMatchMetadataService{
|
|
searchResults: []metadata.MatchCandidate{},
|
|
}
|
|
// Wrap to capture the query.
|
|
captureSvc := &capturingMetadataService{
|
|
inner: metaSvc,
|
|
}
|
|
|
|
h := NewAdminMatchHandler(items, nil, captureSvc)
|
|
router := buildMatchRouter(h)
|
|
|
|
// Send empty title/year -- handler should use item's metadata.
|
|
body, _ := json.Marshal(matchSearchRequest{})
|
|
req := httptest.NewRequest(http.MethodPost, "/admin/items/item-2/match/search", bytes.NewReader(body))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
rec := httptest.NewRecorder()
|
|
|
|
router.ServeHTTP(rec, req)
|
|
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("expected 200, got %d: %s", rec.Code, rec.Body.String())
|
|
}
|
|
|
|
capturedQuery = captureSvc.lastQuery
|
|
if capturedQuery.Title != "Inception" {
|
|
t.Errorf("expected title fallback to 'Inception', got %q", capturedQuery.Title)
|
|
}
|
|
if capturedQuery.Year != 2010 {
|
|
t.Errorf("expected year fallback to 2010, got %d", capturedQuery.Year)
|
|
}
|
|
}
|
|
|
|
func TestAdminMatchSearch_AcceptsGenericProviderIDsAndLibraryID(t *testing.T) {
|
|
items := &fakeMatchItemLookup{
|
|
items: map[string]*models.MediaItem{
|
|
"ebook-1": {ContentID: "ebook-1", Title: "Example Book", Year: 2024, Type: "ebook"},
|
|
},
|
|
}
|
|
folders := &fakeMatchFolderLookup{
|
|
folderIDs: map[string][]int{"ebook-1": {7, 8}},
|
|
}
|
|
captureSvc := &capturingMetadataService{
|
|
inner: &fakeMatchMetadataService{searchResults: []metadata.MatchCandidate{}},
|
|
}
|
|
|
|
h := NewAdminMatchHandler(items, folders, captureSvc)
|
|
router := buildMatchRouter(h)
|
|
|
|
libraryID := 8
|
|
body, _ := json.Marshal(matchSearchRequest{
|
|
ProviderIDs: map[string]string{
|
|
" ISBN ": " 9780000000001 ",
|
|
"goodreads": " ",
|
|
},
|
|
TmdbID: "12345",
|
|
LibraryID: &libraryID,
|
|
})
|
|
req := httptest.NewRequest(http.MethodPost, "/admin/items/ebook-1/match/search", bytes.NewReader(body))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
rec := httptest.NewRecorder()
|
|
|
|
router.ServeHTTP(rec, req)
|
|
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("expected 200, got %d: %s", rec.Code, rec.Body.String())
|
|
}
|
|
|
|
if captureSvc.lastFolderID != 8 {
|
|
t.Fatalf("folderID = %d, want 8", captureSvc.lastFolderID)
|
|
}
|
|
if captureSvc.lastQuery.ContentType != "ebook" {
|
|
t.Fatalf("ContentType = %q, want ebook", captureSvc.lastQuery.ContentType)
|
|
}
|
|
if got := captureSvc.lastQuery.ProviderIDs["isbn"]; got != "9780000000001" {
|
|
t.Fatalf("ProviderIDs[isbn] = %q, want 9780000000001", got)
|
|
}
|
|
if got := captureSvc.lastQuery.ProviderIDs["tmdb"]; got != "12345" {
|
|
t.Fatalf("ProviderIDs[tmdb] = %q, want 12345", got)
|
|
}
|
|
if _, ok := captureSvc.lastQuery.ProviderIDs["goodreads"]; ok {
|
|
t.Fatalf("blank provider IDs should be ignored, got %v", captureSvc.lastQuery.ProviderIDs)
|
|
}
|
|
}
|
|
|
|
func TestAdminMatchApply_PreservesContentID(t *testing.T) {
|
|
items := &fakeMatchItemLookup{
|
|
items: map[string]*models.MediaItem{
|
|
"item-3": {ContentID: "item-3", Title: "Interstellar", Year: 2014, Type: "movie"},
|
|
},
|
|
}
|
|
metaSvc := &fakeMatchMetadataService{
|
|
processResult: &metadata.ProcessResult{
|
|
ContentID: "item-3",
|
|
Updated: true,
|
|
},
|
|
}
|
|
|
|
h := NewAdminMatchHandler(items, nil, metaSvc)
|
|
router := buildMatchRouter(h)
|
|
|
|
body, _ := json.Marshal(matchApplyRequest{
|
|
ProviderIDs: map[string]string{"tmdb": "157336"},
|
|
})
|
|
req := httptest.NewRequest(http.MethodPost, "/admin/items/item-3/match/apply", bytes.NewReader(body))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
rec := httptest.NewRecorder()
|
|
|
|
router.ServeHTTP(rec, req)
|
|
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("expected 200, got %d: %s", rec.Code, rec.Body.String())
|
|
}
|
|
|
|
var resp matchApplyResponse
|
|
if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil {
|
|
t.Fatalf("decoding response: %v", err)
|
|
}
|
|
|
|
// The original content_id must be preserved (not replaced with a new ID).
|
|
if resp.ContentID != "item-3" {
|
|
t.Errorf("expected content_id 'item-3' preserved, got %q", resp.ContentID)
|
|
}
|
|
if !resp.Updated {
|
|
t.Error("expected updated=true")
|
|
}
|
|
|
|
// Verify the Process call used ModeIdentify with the original content_id.
|
|
if metaSvc.lastProcess.ContentID != "item-3" {
|
|
t.Errorf("expected Process called with content_id 'item-3', got %q", metaSvc.lastProcess.ContentID)
|
|
}
|
|
if metaSvc.lastProcess.Mode != metadata.ModeIdentify {
|
|
t.Errorf("expected ModeIdentify, got %d", metaSvc.lastProcess.Mode)
|
|
}
|
|
if metaSvc.lastProcess.ProviderIDs["tmdb"] != "157336" {
|
|
t.Errorf("expected provider_ids tmdb=157336, got %v", metaSvc.lastProcess.ProviderIDs)
|
|
}
|
|
}
|
|
|
|
func TestAdminMatchApply_RequiresProviderIDs(t *testing.T) {
|
|
items := &fakeMatchItemLookup{
|
|
items: map[string]*models.MediaItem{
|
|
"item-4": {ContentID: "item-4", Title: "Test", Type: "movie"},
|
|
},
|
|
}
|
|
h := NewAdminMatchHandler(items, nil, &fakeMatchMetadataService{})
|
|
router := buildMatchRouter(h)
|
|
|
|
body, _ := json.Marshal(matchApplyRequest{ProviderIDs: map[string]string{}})
|
|
req := httptest.NewRequest(http.MethodPost, "/admin/items/item-4/match/apply", bytes.NewReader(body))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
rec := httptest.NewRecorder()
|
|
|
|
router.ServeHTTP(rec, req)
|
|
|
|
if rec.Code != http.StatusBadRequest {
|
|
t.Fatalf("expected 400 for empty provider_ids, got %d: %s", rec.Code, rec.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestAdminMatchApply_NormalizesProviderIDKeys(t *testing.T) {
|
|
items := &fakeMatchItemLookup{
|
|
items: map[string]*models.MediaItem{
|
|
"item-5": {ContentID: "item-5", Title: "Test", Type: "movie"},
|
|
},
|
|
}
|
|
metaSvc := &fakeMatchMetadataService{
|
|
processResult: &metadata.ProcessResult{ContentID: "item-5", Updated: true},
|
|
}
|
|
h := NewAdminMatchHandler(items, nil, metaSvc)
|
|
router := buildMatchRouter(h)
|
|
|
|
body, _ := json.Marshal(matchApplyRequest{
|
|
ProviderIDs: map[string]string{
|
|
" TMDB ": " 157336 ",
|
|
"blank": " ",
|
|
},
|
|
})
|
|
req := httptest.NewRequest(http.MethodPost, "/admin/items/item-5/match/apply", bytes.NewReader(body))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
rec := httptest.NewRecorder()
|
|
|
|
router.ServeHTTP(rec, req)
|
|
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("expected 200, got %d: %s", rec.Code, rec.Body.String())
|
|
}
|
|
if got := metaSvc.lastProcess.ProviderIDs["tmdb"]; got != "157336" {
|
|
t.Errorf("Process provider_ids[tmdb] = %q, want 157336 (keys/values must be normalized)", got)
|
|
}
|
|
if _, ok := metaSvc.lastProcess.ProviderIDs[" TMDB "]; ok {
|
|
t.Errorf("Process received raw key %q, want normalized keys only: %v", " TMDB ", metaSvc.lastProcess.ProviderIDs)
|
|
}
|
|
if _, ok := metaSvc.lastProcess.ProviderIDs["blank"]; ok {
|
|
t.Errorf("blank provider IDs should be dropped, got %v", metaSvc.lastProcess.ProviderIDs)
|
|
}
|
|
}
|
|
|
|
func TestAdminMatchApply_RejectsAllBlankProviderIDs(t *testing.T) {
|
|
items := &fakeMatchItemLookup{
|
|
items: map[string]*models.MediaItem{
|
|
"item-6": {ContentID: "item-6", Title: "Test", Type: "movie"},
|
|
},
|
|
}
|
|
h := NewAdminMatchHandler(items, nil, &fakeMatchMetadataService{})
|
|
router := buildMatchRouter(h)
|
|
|
|
body, _ := json.Marshal(matchApplyRequest{
|
|
ProviderIDs: map[string]string{"tmdb": " ", " ": "123"},
|
|
})
|
|
req := httptest.NewRequest(http.MethodPost, "/admin/items/item-6/match/apply", bytes.NewReader(body))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
rec := httptest.NewRecorder()
|
|
|
|
router.ServeHTTP(rec, req)
|
|
|
|
if rec.Code != http.StatusBadRequest {
|
|
t.Fatalf("expected 400 for all-blank provider_ids, got %d: %s", rec.Code, rec.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestAdminMatchApply_ItemNotFound(t *testing.T) {
|
|
items := &fakeMatchItemLookup{items: map[string]*models.MediaItem{}}
|
|
h := NewAdminMatchHandler(items, nil, &fakeMatchMetadataService{})
|
|
router := buildMatchRouter(h)
|
|
|
|
body, _ := json.Marshal(matchApplyRequest{ProviderIDs: map[string]string{"tmdb": "123"}})
|
|
req := httptest.NewRequest(http.MethodPost, "/admin/items/nonexistent/match/apply", bytes.NewReader(body))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
rec := httptest.NewRecorder()
|
|
|
|
router.ServeHTTP(rec, req)
|
|
|
|
if rec.Code != http.StatusNotFound {
|
|
t.Fatalf("expected 404, got %d: %s", rec.Code, rec.Body.String())
|
|
}
|
|
}
|
|
|
|
// capturingMetadataService wraps a fake to capture the query passed to SearchAndNormalize.
|
|
type capturingMetadataService struct {
|
|
inner *fakeMatchMetadataService
|
|
lastQuery metadata.SearchQuery
|
|
lastFolderID int
|
|
}
|
|
|
|
func (c *capturingMetadataService) SearchAndNormalize(ctx context.Context, query metadata.SearchQuery, folderID int) ([]metadata.MatchCandidate, error) {
|
|
c.lastQuery = query
|
|
c.lastFolderID = folderID
|
|
return c.inner.SearchAndNormalize(ctx, query, folderID)
|
|
}
|
|
|
|
func (c *capturingMetadataService) Process(ctx context.Context, req metadata.ProcessRequest) (*metadata.ProcessResult, error) {
|
|
return c.inner.Process(ctx, req)
|
|
}
|