Files
silo-server/internal/api/handlers/libraries_match_test.go
22fec4ed2d feat(metadata): add resilient match queue diagnostics (#463)
* feat(metadata): add resilient match queue diagnostics

* fix(metadata): harden match queue lifecycle

---------

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

465 lines
15 KiB
Go

package handlers
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/go-chi/chi/v5"
"github.com/Silo-Server/silo-server/internal/metadata"
"github.com/Silo-Server/silo-server/internal/models"
"github.com/Silo-Server/silo-server/internal/scanner"
)
// TestLibraryMatchUnmatchedItems_NilPool verifies that the unmatched-items
// endpoint returns a clear error when the database pool is not configured.
func TestLibraryMatchUnmatchedItems_NilPool(t *testing.T) {
h := &LibraryHandler{}
r := chi.NewRouter()
r.Get("/libraries/unmatched-items", h.HandleListUnmatchedItems)
req := httptest.NewRequest(http.MethodGet, "/libraries/unmatched-items", nil)
rec := httptest.NewRecorder()
r.ServeHTTP(rec, req)
if rec.Code != http.StatusInternalServerError {
t.Fatalf("expected 500 for nil pool, got %d: %s", rec.Code, rec.Body.String())
}
var resp errorResponse
if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil {
t.Fatalf("decoding error response: %v", err)
}
if resp.Error != "internal_error" {
t.Errorf("expected error code 'internal_error', got %q", resp.Error)
}
}
// TestLibraryMatchUnmatchedItems_ResponseShape verifies that the
// unmatchedItemResponse type has the expected JSON field tags for the
// admin maintenance page. This is a compile-time structure test.
func TestLibraryMatchUnmatchedItems_ResponseShape(t *testing.T) {
item := unmatchedItemResponse{
ContentID: "abc-123",
Title: "Test Movie",
Year: 2024,
ContentType: "movie",
LibraryID: 1,
LibraryName: "Movies",
Status: "unmatched",
}
data, err := json.Marshal(item)
if err != nil {
t.Fatalf("marshal: %v", err)
}
var m map[string]any
if err := json.Unmarshal(data, &m); err != nil {
t.Fatalf("unmarshal: %v", err)
}
expectedKeys := []string{
"content_id", "title", "year", "content_type",
"library_id", "library_name", "status",
}
for _, key := range expectedKeys {
if _, ok := m[key]; !ok {
t.Errorf("expected JSON key %q in response, got keys: %v", key, keys(m))
}
}
}
// TestLibraryMatchStaleIDs_NilRepo verifies that the stale-IDs endpoint
// returns an empty array when the repository is nil, confirming backward
// compatibility after the new match endpoints are introduced.
func TestLibraryMatchStaleIDs_NilRepo(t *testing.T) {
h := &LibraryHandler{} // StaleIDRepo is nil
r := chi.NewRouter()
r.Get("/libraries/stale-ids", h.HandleListStaleIDs)
req := httptest.NewRequest(http.MethodGet, "/libraries/stale-ids", nil)
rec := httptest.NewRecorder()
r.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected 200 for nil StaleIDRepo, got %d: %s", rec.Code, rec.Body.String())
}
var resp []staleMediaIDResponse
if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil {
t.Fatalf("decoding response: %v", err)
}
if len(resp) != 0 {
t.Errorf("expected empty stale IDs list, got %d entries", len(resp))
}
}
// TestLibraryMatchStaleIDs_ResponseShape verifies the stale media ID response
// JSON structure still contains the expected fields.
func TestLibraryMatchStaleIDs_ResponseShape(t *testing.T) {
item := staleMediaIDResponse{
ContentID: "content-1",
LibraryID: 2,
LibraryName: "TV Shows",
Title: "Test Show",
Year: 2023,
ContentType: "series",
Provider: "tvdb",
ProviderID: "12345",
FirstSeenAt: "2023-01-01T00:00:00Z",
LastSeenAt: "2023-06-01T00:00:00Z",
}
data, err := json.Marshal(item)
if err != nil {
t.Fatalf("marshal: %v", err)
}
var m map[string]any
if err := json.Unmarshal(data, &m); err != nil {
t.Fatalf("unmarshal: %v", err)
}
expectedKeys := []string{
"content_id", "library_id", "library_name", "title",
"year", "content_type", "provider", "provider_id",
"first_seen_at", "last_seen_at",
}
for _, key := range expectedKeys {
if _, ok := m[key]; !ok {
t.Errorf("expected JSON key %q in response, got keys: %v", key, keys(m))
}
}
}
// TestLibraryMatchRematch_DeprecatedStillRoutes verifies that the deprecated
// HandleRematchStaleID handler method still exists and can be registered as a
// route alongside the new match endpoints. This is a compile-time routing test.
func TestLibraryMatchRematch_DeprecatedStillRoutes(t *testing.T) {
h := &LibraryHandler{}
r := chi.NewRouter()
// Register both old and new endpoints to prove they coexist.
r.Post("/libraries/stale-ids/{contentID}/rematch", h.HandleRematchStaleID)
r.Get("/libraries/unmatched-items", h.HandleListUnmatchedItems)
r.Get("/libraries/stale-ids", h.HandleListStaleIDs)
// Verify the routes are registered by checking that a walk finds them.
found := map[string]bool{}
chi.Walk(r, func(method, route string, _ http.Handler, _ ...func(http.Handler) http.Handler) error {
found[method+" "+route] = true
return nil
})
expectedRoutes := []string{
"POST /libraries/stale-ids/{contentID}/rematch",
"GET /libraries/unmatched-items",
"GET /libraries/stale-ids",
}
for _, route := range expectedRoutes {
if !found[route] {
t.Errorf("expected route %q to be registered, registered routes: %v", route, found)
}
}
}
func TestLibraryMetadataMatchQueueHandlers_NilFolderRepo(t *testing.T) {
h := &LibraryHandler{MovieMatchQueueRepo: noopMovieMatchQueue{}}
tests := []struct {
name string
method string
path string
handler http.HandlerFunc
}{
{
name: "get",
method: http.MethodGet,
path: "/libraries/1/metadata-match-queue",
handler: h.HandleGetMetadataMatchQueue,
},
{
name: "retry",
method: http.MethodPost,
path: "/libraries/1/metadata-match-queue/retry",
handler: h.HandleRetryMetadataMatchQueue,
},
{
name: "cancel",
method: http.MethodDelete,
path: "/libraries/1/metadata-match-queue",
handler: h.HandleCancelMetadataMatchQueue,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
r := chi.NewRouter()
r.Method(tt.method, "/libraries/{id}/metadata-match-queue", tt.handler)
r.Method(tt.method, "/libraries/{id}/metadata-match-queue/retry", tt.handler)
req := httptest.NewRequest(tt.method, tt.path, nil)
rec := httptest.NewRecorder()
r.ServeHTTP(rec, req)
if rec.Code != http.StatusServiceUnavailable {
t.Fatalf("expected 503 for nil folderRepo, got %d: %s", rec.Code, rec.Body.String())
}
var resp errorResponse
if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil {
t.Fatalf("decoding error response: %v", err)
}
if resp.Error != "unavailable" {
t.Errorf("expected error code 'unavailable', got %q", resp.Error)
}
})
}
}
type noopMovieMatchQueue struct{}
func (noopMovieMatchQueue) SyncForFolder(context.Context, int) error {
return nil
}
func (noopMovieMatchQueue) DeleteByFolder(context.Context, int) (int, error) {
return 0, nil
}
func (noopMovieMatchQueue) CountStatesByFolder(context.Context, int) (int, int, error) {
return 0, 0, nil
}
func (noopMovieMatchQueue) CountStatesByFolders(context.Context, []int) (map[int]metadata.MatchQueueStateCounts, error) {
return map[int]metadata.MatchQueueStateCounts{}, nil
}
func (noopMovieMatchQueue) ListByFolder(context.Context, int, int, int) ([]models.MovieMatchQueueEntry, int, error) {
return nil, 0, nil
}
func (noopMovieMatchQueue) RetryNowByFolder(context.Context, int) (int, error) {
return 0, nil
}
type fakeMovieMatchQueue struct {
pending int
parked int
entries []models.MovieMatchQueueEntry
retryFolders []int
bulkCalls int
}
func (f *fakeMovieMatchQueue) SyncForFolder(context.Context, int) error { return nil }
func (f *fakeMovieMatchQueue) DeleteByFolder(context.Context, int) (int, error) {
return 0, nil
}
func (f *fakeMovieMatchQueue) CountStatesByFolder(context.Context, int) (int, int, error) {
return f.pending, f.parked, nil
}
func (f *fakeMovieMatchQueue) CountStatesByFolders(_ context.Context, folderIDs []int) (map[int]metadata.MatchQueueStateCounts, error) {
f.bulkCalls++
counts := make(map[int]metadata.MatchQueueStateCounts, len(folderIDs))
for _, folderID := range folderIDs {
counts[folderID] = metadata.MatchQueueStateCounts{Pending: f.pending, Parked: f.parked}
}
return counts, nil
}
func (f *fakeMovieMatchQueue) ListByFolder(context.Context, int, int, int) ([]models.MovieMatchQueueEntry, int, error) {
return f.entries, len(f.entries), nil
}
func (f *fakeMovieMatchQueue) RetryNowByFolder(_ context.Context, folderID int) (int, error) {
f.retryFolders = append(f.retryFolders, folderID)
return len(f.retryFolders), nil
}
type fakeSeriesMatchQueue struct {
pending int
parked int
entries []models.SeriesRootMatchQueueEntry
retryFolders []int
bulkCalls int
}
func (f *fakeSeriesMatchQueue) SyncForFolder(context.Context, int) error { return nil }
func (f *fakeSeriesMatchQueue) DeleteByFolder(context.Context, int) (int, error) {
return 0, nil
}
func (f *fakeSeriesMatchQueue) CountStatesByFolder(context.Context, int) (int, int, error) {
return f.pending, f.parked, nil
}
func (f *fakeSeriesMatchQueue) CountStatesByFolders(_ context.Context, folderIDs []int) (map[int]metadata.MatchQueueStateCounts, error) {
f.bulkCalls++
counts := make(map[int]metadata.MatchQueueStateCounts, len(folderIDs))
for _, folderID := range folderIDs {
counts[folderID] = metadata.MatchQueueStateCounts{Pending: f.pending, Parked: f.parked}
}
return counts, nil
}
func (f *fakeSeriesMatchQueue) ListByFolder(context.Context, int, int, int) ([]models.SeriesRootMatchQueueEntry, int, error) {
return f.entries, len(f.entries), nil
}
func (f *fakeSeriesMatchQueue) RetryNowByFolder(_ context.Context, folderID int) (int, error) {
f.retryFolders = append(f.retryFolders, folderID)
return len(f.retryFolders), nil
}
type fakeRawMatchBacklog struct {
count int
bulkCalls int
}
func (f *fakeRawMatchBacklog) CountUnmatchedMatchBacklogByFolder(context.Context, int, scanner.RawMatchBacklogMode) (int, error) {
return f.count, nil
}
func (f *fakeRawMatchBacklog) CountUnmatchedMatchBacklogByFolders(_ context.Context, folderIDs []int, _ scanner.RawMatchBacklogMode) (map[int]int, error) {
f.bulkCalls++
counts := make(map[int]int, len(folderIDs))
for _, folderID := range folderIDs {
counts[folderID] = f.count
}
return counts, nil
}
func (f *fakeRawMatchBacklog) ListUnmatchedMatchBacklogByFolder(context.Context, int, scanner.RawMatchBacklogMode, int, int) ([]*models.MediaFile, int, error) {
return nil, 0, nil
}
func (f *fakeRawMatchBacklog) SuppressUnmatchedMatchBacklogByFolder(context.Context, int, scanner.RawMatchBacklogMode) (int, error) {
return 0, nil
}
func (f *fakeRawMatchBacklog) RetryUnmatchedMatchBacklogByFolder(context.Context, int, scanner.RawMatchBacklogMode) (int, error) {
return 0, nil
}
// TestMetadataMatchQueueStatus_AggregatesStates verifies the pending/parked
// aggregation rules the admin UI depends on: parked rows stay out of
// pending_count, raw-backlog files count as pending, and the per-queue totals
// still equal pending + parked so movie_count/series_count semantics are
// unchanged from the pre-state API.
func TestMetadataMatchQueueStatus_AggregatesStates(t *testing.T) {
h := &LibraryHandler{
MovieMatchQueueRepo: &fakeMovieMatchQueue{pending: 2, parked: 1},
SeriesMatchQueueRepo: &fakeSeriesMatchQueue{pending: 3, parked: 2},
RawMatchBacklogRepo: &fakeRawMatchBacklog{count: 4},
}
status, err := h.metadataMatchQueueStatus(context.Background(), 7)
if err != nil {
t.Fatalf("metadataMatchQueueStatus() error = %v", err)
}
if status.LibraryID != 7 {
t.Errorf("LibraryID = %d, want 7", status.LibraryID)
}
if status.MovieCount != 3 || status.SeriesCount != 5 || status.RawFileCount != 4 {
t.Errorf("per-queue counts = %d/%d/%d, want 3/5/4", status.MovieCount, status.SeriesCount, status.RawFileCount)
}
if status.PendingCount != 9 {
t.Errorf("PendingCount = %d, want 9 (2+3 pending + 4 raw)", status.PendingCount)
}
if status.ParkedCount != 3 {
t.Errorf("ParkedCount = %d, want 3", status.ParkedCount)
}
if status.TotalCount != 12 {
t.Errorf("TotalCount = %d, want 12", status.TotalCount)
}
}
func TestMetadataMatchQueueStatusesUsesOneAggregateCallPerQueue(t *testing.T) {
movie := &fakeMovieMatchQueue{pending: 1}
series := &fakeSeriesMatchQueue{parked: 1}
raw := &fakeRawMatchBacklog{count: 1}
h := &LibraryHandler{
MovieMatchQueueRepo: movie,
SeriesMatchQueueRepo: series,
RawMatchBacklogRepo: raw,
}
statuses, err := h.metadataMatchQueueStatuses(context.Background(), []int{3, 7, 11})
if err != nil {
t.Fatalf("metadataMatchQueueStatuses() error = %v", err)
}
if len(statuses) != 3 {
t.Fatalf("status count = %d, want 3", len(statuses))
}
if movie.bulkCalls != 1 || series.bulkCalls != 1 || raw.bulkCalls != 1 {
t.Fatalf("bulk calls = movie:%d series:%d raw:%d, want one each", movie.bulkCalls, series.bulkCalls, raw.bulkCalls)
}
}
// TestWakeMetadataMatcher_RetriesBothQueues verifies the provider-chain-change
// wake path resets parked work in both queue repos for the changed library.
func TestWakeMetadataMatcher_RetriesBothQueues(t *testing.T) {
movie := &fakeMovieMatchQueue{}
series := &fakeSeriesMatchQueue{}
h := &LibraryHandler{MovieMatchQueueRepo: movie, SeriesMatchQueueRepo: series}
h.wakeMetadataMatcher(context.Background(), 42)
if len(movie.retryFolders) != 1 || movie.retryFolders[0] != 42 {
t.Errorf("movie retry folders = %v, want [42]", movie.retryFolders)
}
if len(series.retryFolders) != 1 || series.retryFolders[0] != 42 {
t.Errorf("series retry folders = %v, want [42]", series.retryFolders)
}
}
// TestMovieMatchQueueEntryResponse_SerializesStateFields pins the JSON contract
// for the queue-state fields the admin UI reads. Additive-only API rule: these
// names must never change once shipped.
func TestMovieMatchQueueEntryResponse_SerializesStateFields(t *testing.T) {
parkedAt := time.Date(2026, 7, 21, 12, 0, 0, 0, time.UTC)
entry := libraryMovieMatchQueueEntryResponse{
MediaFileID: 11,
MediaFolderID: 7,
FilePath: "/movies/a.mkv",
State: "parked",
FailureKind: "candidate_rejected",
FailureDetail: json.RawMessage(`{"message":"below threshold"}`),
DeterministicAttemptCount: 3,
MatcherRevision: 8,
ParkedAt: &parkedAt,
}
data, err := json.Marshal(entry)
if err != nil {
t.Fatalf("marshal: %v", err)
}
var m map[string]any
if err := json.Unmarshal(data, &m); err != nil {
t.Fatalf("unmarshal: %v", err)
}
for key, want := range map[string]any{
"state": "parked", //nolint:goconst // JSON contract key.
"failure_kind": "candidate_rejected",
"deterministic_attempt_count": float64(3),
"matcher_revision": float64(8),
} {
if m[key] != want {
t.Errorf("JSON %q = %v, want %v", key, m[key], want)
}
}
if _, ok := m["parked_at"]; !ok {
t.Error("expected parked_at in JSON output")
}
if _, ok := m["failure_detail"]; !ok {
t.Error("expected failure_detail in JSON output")
}
}
func keys(m map[string]any) []string {
out := make([]string, 0, len(m))
for k := range m {
out = append(out, k)
}
return out
}