308 lines
9.4 KiB
Go
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)
|
|
}
|