Files
silo-server/internal/api/handlers/admin_match_test.go
T

308 lines
9.4 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 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_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
}
func (c *capturingMetadataService) SearchAndNormalize(ctx context.Context, query metadata.SearchQuery, folderID int) ([]metadata.MatchCandidate, error) {
c.lastQuery = query
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)
}