diff --git a/internal/api/abs_config_coverage_test.go b/internal/api/abs_config_coverage_test.go new file mode 100644 index 000000000..417f23c96 --- /dev/null +++ b/internal/api/abs_config_coverage_test.go @@ -0,0 +1,65 @@ +package api + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +// TestABSSetConfigCoverage covers the refusals (bad body, bad base URL, a key +// with control characters; none may write) and the defaults on a valid save: +// a blank label falls back to "Audiobookshelf" and a single libraryId becomes +// the one-entry library list. +func TestABSSetConfigCoverage(t *testing.T) { + h, repo, ctx := absFixture(t, nil) + put := func(body string) *httptest.ResponseRecorder { + rec := httptest.NewRecorder() + h.SetConfig(rec, httptest.NewRequest(http.MethodPut, "/api/v1/abs/config", strings.NewReader(body))) + return rec + } + for _, c := range []struct { + name, body, wantErr string + }{ + {"bad body", `{"baseUrl":`, "invalid request body"}, + {"schemeless url", `{"baseUrl":"abs.local:13378"}`, "missing a scheme"}, + {"wrong scheme", `{"baseUrl":"ftp://abs.local"}`, "must use http or https"}, + {"control chars in key", `{"baseUrl":"http://abs.local","apiKey":"ab\u0001cd"}`, "invalid control characters"}, + } { + t.Run(c.name, func(t *testing.T) { + rec := put(c.body) + if rec.Code != http.StatusBadRequest || !strings.Contains(rec.Body.String(), c.wantErr) { + t.Fatalf("%d %s, want 400 %q", rec.Code, rec.Body.String(), c.wantErr) + } + if s, _ := repo.Get(ctx, SettingABSBaseURL); s != nil && s.Value != "" { + t.Fatalf("a refused save stored base url %q", s.Value) + } + }) + } + + rec := put(`{"baseUrl":"http://abs.local:13378/","label":" ","libraryId":" lib-1 ","apiKey":"tok","enabled":true}`) + if rec.Code != http.StatusOK { + t.Fatalf("save: %d %s", rec.Code, rec.Body.String()) + } + if strings.Contains(rec.Body.String(), `"tok"`) { + t.Fatalf("response leaked the api key: %s", rec.Body.String()) + } + var got ABSConfigResponse + if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil { + t.Fatal(err) + } + if got.Label != "Audiobookshelf" || got.LibraryID != "lib-1" || len(got.LibraryIDs) != 1 || got.LibraryIDs[0] != "lib-1" || !got.APIKeyConfigured || !got.Enabled { + t.Fatalf("response = %+v", got) + } + assertSettingValue(t, repo, ctx, SettingABSLabel, "Audiobookshelf") + assertSettingValue(t, repo, ctx, SettingABSLibraryID, "lib-1") + assertSettingValue(t, repo, ctx, SettingABSAPIKey, "tok") + + // Omitting the key on a later save keeps the stored one. + if rec := put(`{"label":"Shelf"}`); rec.Code != http.StatusOK { + t.Fatalf("relabel: %d %s", rec.Code, rec.Body.String()) + } + assertSettingValue(t, repo, ctx, SettingABSAPIKey, "tok") + assertSettingValue(t, repo, ctx, SettingABSLabel, "Shelf") +} diff --git a/internal/api/abs_coverage_test.go b/internal/api/abs_coverage_test.go new file mode 100644 index 000000000..60c27807a --- /dev/null +++ b/internal/api/abs_coverage_test.go @@ -0,0 +1,593 @@ +package api + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "strconv" + "strings" + "testing" + "time" + + "github.com/go-chi/chi/v5" + + "github.com/vavallee/bindery/internal/abs" + "github.com/vavallee/bindery/internal/db" + "github.com/vavallee/bindery/internal/models" +) + +// covSyChiReq builds a request carrying chi URL params, for the ABS handlers +// that read {id} or {runID}. +func covSyChiReq(method, target, body string, params map[string]string) *http.Request { + var req *http.Request + if body == "" { + req = httptest.NewRequest(method, target, nil) + } else { + req = httptest.NewRequest(method, target, strings.NewReader(body)) + } + rctx := chi.NewRouteContext() + for k, v := range params { + rctx.URLParams.Add(k, v) + } + return req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, rctx)) +} + +func covSyErrorBody(t *testing.T, rec *httptest.ResponseRecorder) string { + t.Helper() + var out map[string]any + if err := json.Unmarshal(rec.Body.Bytes(), &out); err != nil { + t.Fatalf("decode error body %q: %v", rec.Body.String(), err) + } + s, _ := out["error"].(string) + return s +} + +// --- ABS conflicts ----------------------------------------------------------- + +func covSySeedConflict(t *testing.T, conflicts *db.ABSMetadataConflictRepo, c *models.ABSMetadataConflict) *models.ABSMetadataConflict { + t.Helper() + if c.SourceID == "" { + c.SourceID = "default" + } + if c.LibraryID == "" { + c.LibraryID = "lib-books" + } + if c.AppliedSource == "" { + c.AppliedSource = abs.MetadataSourceUpstream + } + if c.ResolutionStatus == "" { + c.ResolutionStatus = "unresolved" + } + if err := conflicts.Upsert(context.Background(), c); err != nil { + t.Fatalf("Upsert conflict: %v", err) + } + return c +} + +func TestABSConflictCoverage_ResolveRejectsBadInput(t *testing.T) { + _, h, conflicts, _, _ := absConflictFixture(t) + c := covSySeedConflict(t, conflicts, &models.ABSMetadataConflict{ + ItemID: "li-1", EntityType: "book", LocalID: 999, FieldName: "description", + ABSValue: "abs", UpstreamValue: "up", + }) + cases := []struct { + name, id, body string + want int + wantErr string + }{ + {"non-numeric id", "abc", `{"source":"abs"}`, http.StatusBadRequest, "invalid id"}, + {"unknown id", "4242", `{"source":"abs"}`, http.StatusNotFound, "conflict not found"}, + {"malformed body", covSyID(c.ID), `{`, http.StatusBadRequest, "invalid request body"}, + {"unknown source", covSyID(c.ID), `{"source":"calibre"}`, http.StatusBadRequest, "source must be 'abs' or 'upstream'"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + rec := httptest.NewRecorder() + h.Resolve(rec, covSyChiReq(http.MethodPost, "/api/v1/abs/conflicts/x/resolve", tc.body, map[string]string{"id": tc.id})) + if rec.Code != tc.want { + t.Fatalf("status = %d, want %d (body %s)", rec.Code, tc.want, rec.Body.String()) + } + if got := covSyErrorBody(t, rec); got != tc.wantErr { + t.Fatalf("error = %q, want %q", got, tc.wantErr) + } + }) + } + // None of the rejected requests may have claimed the conflict. + stored, err := conflicts.GetByID(context.Background(), c.ID) + if err != nil || stored == nil { + t.Fatalf("GetByID: %v", err) + } + if stored.ResolutionStatus != "unresolved" { + t.Fatalf("status = %q after rejected requests, want unresolved", stored.ResolutionStatus) + } +} + +func covSyID(n int64) string { return strconv.FormatInt(n, 10) } + +func TestABSConflictCoverage_ResolveWithoutStoreIs404(t *testing.T) { + h := NewABSConflictHandler(nil, nil, nil) + rec := httptest.NewRecorder() + h.Resolve(rec, covSyChiReq(http.MethodPost, "/api/v1/abs/conflicts/1/resolve", `{"source":"abs"}`, map[string]string{"id": "1"})) + if rec.Code != http.StatusNotFound { + t.Fatalf("status = %d, want 404", rec.Code) + } + + rec = httptest.NewRecorder() + h.List(rec, httptest.NewRequest(http.MethodGet, "/api/v1/abs/conflicts?limit=7&offset=3", nil)) + if rec.Code != http.StatusOK { + t.Fatalf("List status = %d, want 200", rec.Code) + } + var out absConflictListResponse + if err := json.Unmarshal(rec.Body.Bytes(), &out); err != nil { + t.Fatal(err) + } + if out.Items == nil || len(out.Items) != 0 || out.Total != 0 || out.Limit != 7 || out.Offset != 3 { + t.Fatalf("List without store = %+v, want empty page echoing limit/offset", out) + } +} + +// A conflict another caller already resolved must not be applied again: the +// handler returns the stored state and leaves the entity alone. +func TestABSConflictCoverage_ResolveAlreadyResolvedReturnsCurrentState(t *testing.T) { + _, h, conflicts, authors, books := absConflictFixture(t) + ctx := context.Background() + author := &models.Author{ForeignID: "OL-COV-A", Name: "Cov Author", SortName: "Author, Cov", MetadataProvider: "openlibrary", Monitored: true} + if err := authors.Create(ctx, author); err != nil { + t.Fatal(err) + } + book := &models.Book{ForeignID: "OL-COV-B", AuthorID: author.ID, Title: "Cov Book", SortTitle: "Cov Book", Description: "kept", Status: models.BookStatusWanted, MetadataProvider: "openlibrary"} + if err := books.Create(ctx, book); err != nil { + t.Fatal(err) + } + c := covSySeedConflict(t, conflicts, &models.ABSMetadataConflict{ + ItemID: "li-res", EntityType: "book", LocalID: book.ID, FieldName: "description", + ABSValue: "abs text", UpstreamValue: "kept", + AppliedSource: abs.MetadataSourceUpstream, PreferredSource: abs.MetadataSourceUpstream, + ResolutionStatus: "resolved", + }) + + rec := httptest.NewRecorder() + h.Resolve(rec, covSyChiReq(http.MethodPost, "/x", `{"source":"abs"}`, map[string]string{"id": covSyID(c.ID)})) + if rec.Code != http.StatusOK { + t.Fatalf("status = %d body=%s", rec.Code, rec.Body.String()) + } + var got absConflictResponse + if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil { + t.Fatal(err) + } + if got.ResolutionStatus != "resolved" || got.AppliedSource != abs.MetadataSourceUpstream || got.AppliedValue != "kept" || got.EntityName != "Cov Book" { + t.Fatalf("response = %+v, want the existing upstream resolution", got) + } + reloaded, _ := books.GetByID(ctx, book.ID) + if reloaded.Description != "kept" { + t.Fatalf("description = %q, the losing resolve must not apply its value", reloaded.Description) + } +} + +// An apply failure (an unsupported field) must release the claim so the +// conflict can be resolved again, rather than stranding it in "resolving". +func TestABSConflictCoverage_ResolveApplyFailureUnclaims(t *testing.T) { + _, h, conflicts, _, books := absConflictFixture(t) + ctx := context.Background() + authorRepo := h.authors + author := &models.Author{ForeignID: "OL-COV-A2", Name: "Fail Author", SortName: "Author, Fail", MetadataProvider: "openlibrary"} + if err := authorRepo.Create(ctx, author); err != nil { + t.Fatal(err) + } + book := &models.Book{ForeignID: "OL-COV-B2", AuthorID: author.ID, Title: "Fail Book", SortTitle: "Fail Book", Status: models.BookStatusWanted, MetadataProvider: "openlibrary"} + if err := books.Create(ctx, book); err != nil { + t.Fatal(err) + } + c := covSySeedConflict(t, conflicts, &models.ABSMetadataConflict{ + ItemID: "li-bad", EntityType: "book", LocalID: book.ID, FieldName: "no_such_field", + ABSValue: "a", UpstreamValue: "b", + }) + + rec := httptest.NewRecorder() + h.Resolve(rec, covSyChiReq(http.MethodPost, "/x", `{"source":"upstream"}`, map[string]string{"id": covSyID(c.ID)})) + if rec.Code != http.StatusInternalServerError { + t.Fatalf("status = %d, want 500 (body %s)", rec.Code, rec.Body.String()) + } + stored, err := conflicts.GetByID(ctx, c.ID) + if err != nil || stored == nil { + t.Fatalf("GetByID: %v", err) + } + if stored.ResolutionStatus != "unresolved" { + t.Fatalf("status = %q, want unresolved after a failed apply", stored.ResolutionStatus) + } +} + +func TestABSConflictCoverage_ResolveAuthorConflictAppliesValue(t *testing.T) { + _, h, conflicts, authors, _ := absConflictFixture(t) + ctx := context.Background() + author := &models.Author{ForeignID: "OL-COV-A3", Name: "Sorted Author", SortName: "old sort", MetadataProvider: "openlibrary"} + if err := authors.Create(ctx, author); err != nil { + t.Fatal(err) + } + c := covSySeedConflict(t, conflicts, &models.ABSMetadataConflict{ + ItemID: "li-auth", EntityType: "author", LocalID: author.ID, FieldName: "sort_name", + ABSValue: " Author, Sorted ", UpstreamValue: "old sort", + }) + + rec := httptest.NewRecorder() + h.Resolve(rec, covSyChiReq(http.MethodPost, "/x", `{"source":"abs"}`, map[string]string{"id": covSyID(c.ID)})) + if rec.Code != http.StatusOK { + t.Fatalf("status = %d body=%s", rec.Code, rec.Body.String()) + } + var got absConflictResponse + if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil { + t.Fatal(err) + } + if got.EntityName != "Sorted Author" || got.AppliedSource != abs.MetadataSourceABS || got.ResolutionStatus != "resolved" { + t.Fatalf("response = %+v", got) + } + reloaded, _ := authors.GetByID(ctx, author.ID) + if reloaded.SortName != "Author, Sorted" { + t.Fatalf("sort name = %q, want trimmed ABS value", reloaded.SortName) + } + if reloaded.LastMetadataRefreshAt == nil { + t.Fatal("LastMetadataRefreshAt not stamped") + } +} + +// A conflict whose local entity is gone resolves +// without writing anything, and the response falls back to the ABS item id. +func TestABSConflictCoverage_ResolveMissingEntityFallsBackToItemID(t *testing.T) { + _, h, conflicts, _, _ := absConflictFixture(t) + for _, entity := range []string{"author", "book"} { + c := covSySeedConflict(t, conflicts, &models.ABSMetadataConflict{ + ItemID: "li-gone-" + entity, EntityType: entity, LocalID: 9999, FieldName: "description", + ABSValue: "a", UpstreamValue: "b", + }) + rec := httptest.NewRecorder() + h.Resolve(rec, covSyChiReq(http.MethodPost, "/x", `{"source":"abs"}`, map[string]string{"id": covSyID(c.ID)})) + if rec.Code != http.StatusOK { + t.Fatalf("%s: status = %d body=%s", entity, rec.Code, rec.Body.String()) + } + var got absConflictResponse + if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil { + t.Fatal(err) + } + if got.EntityName != "li-gone-"+entity || got.ResolutionStatus != "resolved" || got.AppliedValue != "a" { + t.Fatalf("%s: response = %+v", entity, got) + } + } +} + +// --- ABS import -------------------------------------------------------------- + +type covSyABSImporter struct { + runsErr error + runs []models.ABSImportRun + rollbackErr error + gotRunIDs []int64 +} + +func (s *covSyABSImporter) Start(context.Context, abs.ImportConfig) error { return nil } +func (s *covSyABSImporter) Progress() abs.ImportProgress { return abs.ImportProgress{} } +func (s *covSyABSImporter) RecentRuns(context.Context, int) ([]models.ABSImportRun, error) { + return s.runs, s.runsErr +} +func (s *covSyABSImporter) RollbackPreview(_ context.Context, id int64) (*abs.RollbackResult, error) { + s.gotRunIDs = append(s.gotRunIDs, id) + if s.rollbackErr != nil { + return nil, s.rollbackErr + } + return &abs.RollbackResult{RunID: id, Preview: true, Status: "preview"}, nil +} +func (s *covSyABSImporter) Rollback(_ context.Context, id int64) (*abs.RollbackResult, error) { + s.gotRunIDs = append(s.gotRunIDs, id) + if s.rollbackErr != nil { + return nil, s.rollbackErr + } + return &abs.RollbackResult{RunID: id, Status: "rolled_back"}, nil +} + +func TestABSImportCoverage_StartRejectsMalformedBody(t *testing.T) { + stub := &stubABSImporter{} + h := NewABSImportHandler(stub, func(context.Context) ABSStoredConfig { return ABSStoredConfig{} }) + rec := httptest.NewRecorder() + h.Start(rec, httptest.NewRequest(http.MethodPost, "/api/v1/abs/import", strings.NewReader(`{"dryRun":`))) + if rec.Code != http.StatusBadRequest || covSyErrorBody(t, rec) != "invalid request body" { + t.Fatalf("status = %d body=%s, want 400 invalid request body", rec.Code, rec.Body.String()) + } + if stub.lastCfg.SourceID != "" { + t.Fatal("importer started despite a malformed body") + } +} + +func TestABSImportCoverage_RunsHydratesAndReportsErrors(t *testing.T) { + started := time.Date(2026, 5, 1, 10, 0, 0, 0, time.UTC) + stub := &covSyABSImporter{runs: []models.ABSImportRun{{ID: 3, SourceID: "default", Status: "completed", StartedAt: started}}} + h := NewABSImportHandler(stub, func(context.Context) ABSStoredConfig { return ABSStoredConfig{} }) + + rec := httptest.NewRecorder() + h.Runs(rec, httptest.NewRequest(http.MethodGet, "/api/v1/abs/import/runs", nil)) + if rec.Code != http.StatusOK { + t.Fatalf("status = %d", rec.Code) + } + var runs []abs.PersistedImportRun + if err := json.Unmarshal(rec.Body.Bytes(), &runs); err != nil { + t.Fatal(err) + } + if len(runs) != 1 || runs[0].ID != 3 || runs[0].Status != "completed" { + t.Fatalf("runs = %+v", runs) + } + + // No runs serialises as [] rather than null. + stub.runs = nil + rec = httptest.NewRecorder() + h.Runs(rec, httptest.NewRequest(http.MethodGet, "/api/v1/abs/import/runs", nil)) + if strings.TrimSpace(rec.Body.String()) != "[]" { + t.Fatalf("empty runs body = %q, want []", rec.Body.String()) + } + + stub.runsErr = errors.New("db down") + rec = httptest.NewRecorder() + h.Runs(rec, httptest.NewRequest(http.MethodGet, "/api/v1/abs/import/runs", nil)) + if rec.Code != http.StatusInternalServerError { + t.Fatalf("status = %d, want 500", rec.Code) + } +} + +func TestABSImportCoverage_RollbackValidatesRunID(t *testing.T) { + stub := &covSyABSImporter{} + h := NewABSImportHandler(stub, func(context.Context) ABSStoredConfig { return ABSStoredConfig{} }) + handlers := map[string]http.HandlerFunc{"preview": h.RollbackPreview, "rollback": h.Rollback} + + for name, fn := range handlers { + for _, tc := range []struct{ raw, wantErr string }{ + {"", "run id is required"}, + {" ", "run id is required"}, + {"abc", "invalid run id"}, + {"0", "invalid run id"}, + {"-4", "invalid run id"}, + } { + rec := httptest.NewRecorder() + fn(rec, covSyChiReq(http.MethodPost, "/x", "", map[string]string{"runID": tc.raw})) + if rec.Code != http.StatusBadRequest || covSyErrorBody(t, rec) != tc.wantErr { + t.Fatalf("%s(%q): status=%d body=%s, want 400 %q", name, tc.raw, rec.Code, rec.Body.String(), tc.wantErr) + } + } + } + if len(stub.gotRunIDs) != 0 { + t.Fatalf("importer called with %v for invalid ids", stub.gotRunIDs) + } + + rec := httptest.NewRecorder() + h.RollbackPreview(rec, covSyChiReq(http.MethodGet, "/x", "", map[string]string{"runID": "12"})) + var res abs.RollbackResult + if rec.Code != http.StatusOK || json.Unmarshal(rec.Body.Bytes(), &res) != nil || res.RunID != 12 || !res.Preview { + t.Fatalf("preview status=%d body=%s", rec.Code, rec.Body.String()) + } + rec = httptest.NewRecorder() + h.Rollback(rec, covSyChiReq(http.MethodPost, "/x", "", map[string]string{"runID": "12"})) + if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), "rolled_back") { + t.Fatalf("rollback status=%d body=%s", rec.Code, rec.Body.String()) + } + + stub.rollbackErr = errors.New("run 12 is still running") + for name, fn := range handlers { + rec := httptest.NewRecorder() + fn(rec, covSyChiReq(http.MethodPost, "/x", "", map[string]string{"runID": "12"})) + if rec.Code != http.StatusBadRequest || covSyErrorBody(t, rec) != "run 12 is still running" { + t.Fatalf("%s: status=%d body=%s, want 400 with importer error", name, rec.Code, rec.Body.String()) + } + } +} + +// --- ABS review -------------------------------------------------------------- + +type covSyABSReviewImporter struct { + err error + calls int +} + +func (s *covSyABSReviewImporter) ImportReview(context.Context, abs.ImportConfig, abs.NormalizedLibraryItem) (abs.ImportItemResult, error) { + s.calls++ + return abs.ImportItemResult{}, s.err +} + +func (s *covSyABSReviewImporter) ReviewFileMapping(context.Context, abs.ImportConfig, abs.NormalizedLibraryItem) abs.ReviewFileMapping { + return abs.ReviewFileMapping{} +} + +func covSyValidABSConfig(context.Context) ABSStoredConfig { + return ABSStoredConfig{BaseURL: "https://abs.example.com", APIKey: "secret", LibraryID: "lib-books", Enabled: true} +} + +func covSySeedReview(t *testing.T, reviews *db.ABSReviewItemRepo, itemID, payload string) *models.ABSReviewItem { + t.Helper() + item := &models.ABSReviewItem{ + SourceID: abs.DefaultSourceID, LibraryID: "lib-books", ItemID: itemID, + Title: "Title " + itemID, PrimaryAuthor: "Author " + itemID, + MediaType: models.MediaTypeAudiobook, ReviewReason: "unmatched_book", + PayloadJSON: payload, Status: "pending", + } + if err := reviews.UpsertPending(context.Background(), item); err != nil { + t.Fatal(err) + } + items, err := reviews.ListByStatus(context.Background(), "pending") + if err != nil { + t.Fatal(err) + } + for i := range items { + if items[i].ItemID == itemID { + return &items[i] + } + } + t.Fatalf("seeded review %s not found", itemID) + return nil +} + +func covSyReviewStatus(t *testing.T, reviews *db.ABSReviewItemRepo, id int64) string { + t.Helper() + got, err := reviews.GetByID(context.Background(), id) + if err != nil || got == nil { + t.Fatalf("GetByID(%d): %v", id, err) + } + return got.Status +} + +func TestABSReviewCoverage_ItemHandlersRejectBadIDs(t *testing.T) { + _, h, _ := absReviewFixture(t) + handlers := map[string]http.HandlerFunc{ + "approve": h.Approve, + "resolve-author": h.ResolveAuthor, + "resolve-book": h.ResolveBook, + "dismiss": h.Dismiss, + } + for name, fn := range handlers { + for _, tc := range []struct { + raw string + want int + wantErr string + }{ + {"", http.StatusBadRequest, "review item id is required"}, + {"x", http.StatusBadRequest, "invalid review item id"}, + {"0", http.StatusBadRequest, "invalid review item id"}, + {"777", http.StatusNotFound, "review item not found"}, + } { + rec := httptest.NewRecorder() + fn(rec, covSyChiReq(http.MethodPost, "/x", `{}`, map[string]string{"id": tc.raw})) + if rec.Code != tc.want || covSyErrorBody(t, rec) != tc.wantErr { + t.Fatalf("%s(%q): status=%d body=%s, want %d %q", name, tc.raw, rec.Code, rec.Body.String(), tc.want, tc.wantErr) + } + } + } +} + +func TestABSReviewCoverage_ApproveGuards(t *testing.T) { + database, err := db.OpenMemory() + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = database.Close() }) + database.SetMaxOpenConns(1) + reviews := db.NewABSReviewItemRepo(database) + runs := db.NewABSImportRunRepo(database) + + valid := `{"itemId":"ok","libraryId":"lib-books","title":"T"}` + + t.Run("not pending is 409 and never imports", func(t *testing.T) { + imp := &covSyABSReviewImporter{} + h := NewABSReviewHandler(reviews, runs, imp, covSyValidABSConfig) + item := covSySeedReview(t, reviews, "done", valid) + if err := reviews.UpdateStatus(context.Background(), item.ID, "approved"); err != nil { + t.Fatal(err) + } + rec := httptest.NewRecorder() + h.Approve(rec, covSyChiReq(http.MethodPost, "/x", "", map[string]string{"id": covSyID(item.ID)})) + if rec.Code != http.StatusConflict || !strings.Contains(covSyErrorBody(t, rec), "status: approved") { + t.Fatalf("status=%d body=%s, want 409", rec.Code, rec.Body.String()) + } + if imp.calls != 0 { + t.Fatal("ImportReview ran for a non-pending item") + } + }) + + t.Run("corrupt payload is 400", func(t *testing.T) { + imp := &covSyABSReviewImporter{} + h := NewABSReviewHandler(reviews, runs, imp, covSyValidABSConfig) + item := covSySeedReview(t, reviews, "corrupt", `{not json`) + rec := httptest.NewRecorder() + h.Approve(rec, covSyChiReq(http.MethodPost, "/x", "", map[string]string{"id": covSyID(item.ID)})) + if rec.Code != http.StatusBadRequest || covSyErrorBody(t, rec) != "stored review payload is invalid" { + t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String()) + } + if got := covSyReviewStatus(t, reviews, item.ID); got != "pending" { + t.Fatalf("status = %q, want pending", got) + } + }) + + t.Run("disabled source is 400 and stays pending", func(t *testing.T) { + imp := &covSyABSReviewImporter{} + h := NewABSReviewHandler(reviews, runs, imp, func(context.Context) ABSStoredConfig { return ABSStoredConfig{} }) + item := covSySeedReview(t, reviews, "disabled", valid) + rec := httptest.NewRecorder() + h.Approve(rec, covSyChiReq(http.MethodPost, "/x", "", map[string]string{"id": covSyID(item.ID)})) + if rec.Code != http.StatusBadRequest || covSyErrorBody(t, rec) != "abs source is disabled" { + t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String()) + } + if imp.calls != 0 || covSyReviewStatus(t, reviews, item.ID) != "pending" { + t.Fatalf("calls=%d, item should remain pending and unimported", imp.calls) + } + }) + + t.Run("import failure reverts to pending", func(t *testing.T) { + imp := &covSyABSReviewImporter{err: errors.New("no matching author upstream")} + h := NewABSReviewHandler(reviews, runs, imp, covSyValidABSConfig) + item := covSySeedReview(t, reviews, "fails", valid) + rec := httptest.NewRecorder() + h.Approve(rec, covSyChiReq(http.MethodPost, "/x", "", map[string]string{"id": covSyID(item.ID)})) + if rec.Code != http.StatusBadRequest || covSyErrorBody(t, rec) != "no matching author upstream" { + t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String()) + } + if imp.calls != 1 { + t.Fatalf("ImportReview calls = %d, want 1", imp.calls) + } + if got := covSyReviewStatus(t, reviews, item.ID); got != "pending" { + t.Fatalf("status = %q, want reverted to pending", got) + } + }) +} + +func TestABSReviewCoverage_ResolveValidation(t *testing.T) { + _, h, reviews := absReviewFixture(t) + item := covSySeedReview(t, reviews, "resolve", `{"itemId":"resolve"}`) + id := covSyID(item.ID) + + cases := []struct { + name string + fn http.HandlerFunc + body string + wantErr string + }{ + {"author malformed body", h.ResolveAuthor, `{`, "invalid request body"}, + {"author bad applyTo", h.ResolveAuthor, `{"foreignAuthorId":"OL1A","authorName":"A","applyTo":"everyone"}`, "applyTo must be same_author"}, + {"author missing fields", h.ResolveAuthor, `{"applyTo":"same_author"}`, "foreignAuthorId and authorName required"}, + {"book malformed body", h.ResolveBook, `[`, "invalid request body"}, + {"book missing fields", h.ResolveBook, `{"title":"only a title"}`, "foreignBookId and title required"}, + } + for _, tc := range cases { + rec := httptest.NewRecorder() + tc.fn(rec, covSyChiReq(http.MethodPost, "/x", tc.body, map[string]string{"id": id})) + if rec.Code != http.StatusBadRequest || covSyErrorBody(t, rec) != tc.wantErr { + t.Fatalf("%s: status=%d body=%s, want 400 %q", tc.name, rec.Code, rec.Body.String(), tc.wantErr) + } + } + stored, _ := reviews.GetByID(context.Background(), item.ID) + if stored.ResolvedAuthorForeignID != "" || stored.ResolvedBookForeignID != "" { + t.Fatalf("rejected resolves wrote data: %+v", stored) + } +} + +func TestABSReviewCoverage_DismissRunValidation(t *testing.T) { + _, h, reviews := absReviewFixture(t) + for _, tc := range []struct{ raw, wantErr string }{ + {"", "run id is required"}, + {"nope", "invalid run id"}, + {"-1", "invalid run id"}, + } { + rec := httptest.NewRecorder() + h.DismissRun(rec, covSyChiReq(http.MethodPost, "/x", "", map[string]string{"runID": tc.raw})) + if rec.Code != http.StatusBadRequest || covSyErrorBody(t, rec) != tc.wantErr { + t.Fatalf("DismissRun(%q): status=%d body=%s", tc.raw, rec.Code, rec.Body.String()) + } + } + + // Without a run repo the existence check is skipped and the dismissal is + // scoped to the run id alone: an unknown run dismisses nothing. + item := covSySeedReview(t, reviews, "keep", `{}`) + noRuns := NewABSReviewHandler(reviews, nil, &covSyABSReviewImporter{}, covSyValidABSConfig) + rec := httptest.NewRecorder() + noRuns.DismissRun(rec, covSyChiReq(http.MethodPost, "/x", "", map[string]string{"runID": "55"})) + if rec.Code != http.StatusOK || strings.TrimSpace(rec.Body.String()) != `{"dismissed":0}` { + t.Fatalf("status=%d body=%s, want 200 dismissed 0", rec.Code, rec.Body.String()) + } + if got := covSyReviewStatus(t, reviews, item.ID); got != "pending" { + t.Fatalf("unrelated item status = %q, want pending", got) + } +} diff --git a/internal/api/adoption_coverage_test.go b/internal/api/adoption_coverage_test.go new file mode 100644 index 000000000..465d81f6e --- /dev/null +++ b/internal/api/adoption_coverage_test.go @@ -0,0 +1,95 @@ +package api + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "path/filepath" + "testing" + + "github.com/vavallee/bindery/internal/db" +) + +// TestAdoptionListCoverageScanStatus: the unmatched list carries the last +// scan's summary when one is stored, ignores a corrupt blob rather than +// failing the page, includes facets only on request, and answers 500 when the +// repo cannot be read. +func TestAdoptionListCoverageScanStatus(t *testing.T) { + f := newAdoptionFixture(t, &stubMetaProvider{}) + f.seedUnit(t, db.UnmatchedUnitScan{UnitPath: filepath.Join(f.lib, "Loose", "book.epub"), MemberPaths: []string{filepath.Join(f.lib, "Loose", "book.epub")}}) + settings := db.NewSettingsRepo(f.db) + + list := func(query string) (*httptest.ResponseRecorder, adoptionListResponse) { + rec := httptest.NewRecorder() + f.router.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/library/unmatched"+query, nil)) + var out adoptionListResponse + _ = json.Unmarshal(rec.Body.Bytes(), &out) + return rec, out + } + + rec, got := list("") + if rec.Code != http.StatusOK || got.Scan.Ran || got.Total != 1 || got.Facets != nil { + t.Fatalf("no scan stored: %d scan=%+v total=%d facets=%v", rec.Code, got.Scan, got.Total, got.Facets) + } + + blob := `{"ran_at":"2026-10-01T10:00:00Z","files_found":12,"units_truncated":true,"scan_error":"root missing","no_files_found":false}` + if err := settings.Set(t.Context(), SettingLibraryLastScan, blob); err != nil { + t.Fatal(err) + } + rec, got = list("?facets=1") + if rec.Code != http.StatusOK { + t.Fatalf("with scan: %d %s", rec.Code, rec.Body.String()) + } + want := adoptionScanStatus{Ran: true, RanAt: "2026-10-01T10:00:00Z", FilesFound: 12, Truncated: true, Error: "root missing"} + if got.Scan != want { + t.Fatalf("scan = %+v, want %+v", got.Scan, want) + } + if got.Facets == nil { + t.Fatal("facets=1 returned no facets") + } + + if err := settings.Set(t.Context(), SettingLibraryLastScan, `{not json`); err != nil { + t.Fatal(err) + } + if rec, got = list(""); rec.Code != http.StatusOK || got.Scan.Ran { + t.Fatalf("corrupt blob: %d scan=%+v, want 200 with ran=false", rec.Code, got.Scan) + } + + f.db.Close() + if rec, _ := list(""); rec.Code != http.StatusInternalServerError { + t.Fatalf("repo error: %d, want 500", rec.Code) + } +} + +// TestAdoptionSummaryCoverage: the nav badge counts pending units and files +// and carries the scan status; a repo failure is a 500. +func TestAdoptionSummaryCoverage(t *testing.T) { + f := newAdoptionFixture(t, &stubMetaProvider{}) + a := filepath.Join(f.lib, "A", "one.epub") + b := filepath.Join(f.lib, "B", "two.epub") + f.seedUnit(t, db.UnmatchedUnitScan{UnitPath: a, MemberPaths: []string{a}}) + f.seedUnit(t, db.UnmatchedUnitScan{UnitPath: b, MemberPaths: []string{b}}) + if err := db.NewSettingsRepo(f.db).Set(t.Context(), SettingLibraryLastScan, `{"ran_at":"2026-10-02T00:00:00Z","files_found":2}`); err != nil { + t.Fatal(err) + } + get := func() *httptest.ResponseRecorder { + rec := httptest.NewRecorder() + f.router.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/library/unmatched/summary", nil)) + return rec + } + rec := get() + if rec.Code != http.StatusOK { + t.Fatalf("summary: %d %s", rec.Code, rec.Body.String()) + } + var got adoptionSummaryResponse + if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil { + t.Fatal(err) + } + if got.Pending != 2 || got.PendingFiles != 2 || got.Adopted != 0 || !got.Scan.Ran || got.Scan.FilesFound != 2 { + t.Fatalf("summary = %+v", got) + } + f.db.Close() + if rec := get(); rec.Code != http.StatusInternalServerError { + t.Fatalf("repo error: %d, want 500", rec.Code) + } +} diff --git a/internal/api/auth_coverage_test.go b/internal/api/auth_coverage_test.go new file mode 100644 index 000000000..1ecb2e1ea --- /dev/null +++ b/internal/api/auth_coverage_test.go @@ -0,0 +1,150 @@ +package api + +import ( + "crypto/tls" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/vavallee/bindery/internal/auth" +) + +// TestAuthCSRFCoverage pins the CSRF bootstrap: no session cookie means an +// empty token and no cookie, a session cookie yields the token derived from +// the session secret, mirrored into a JS readable cookie whose Secure flag +// follows BINDERY_COOKIE_SECURE (or the request scheme on auto). +func TestAuthCSRFCoverage(t *testing.T) { + h, _, _, _ := newAuthFixture(t) + want := auth.MakeCSRFToken([]byte("test-secret-32-bytes-long-enough"), "session-value") // gitleaks:allow + + csrf := func(req *http.Request) (*httptest.ResponseRecorder, string) { + t.Helper() + rec := httptest.NewRecorder() + h.CSRF(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("status = %d body=%s", rec.Code, rec.Body.String()) + } + var body struct { + CSRFToken string `json:"csrfToken"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil { + t.Fatal(err) + } + return rec, body.CSRFToken + } + withSession := func(req *http.Request) *http.Request { + req.AddCookie(&http.Cookie{Name: auth.SessionCookieName, Value: "session-value"}) + return req + } + csrfCookie := func(t *testing.T, rec *httptest.ResponseRecorder) *http.Cookie { + t.Helper() + for _, c := range rec.Result().Cookies() { + if c.Name == auth.CSRFCookieName { + return c + } + } + t.Fatal("no csrf cookie set") + return nil + } + + t.Run("no session", func(t *testing.T) { + rec, token := csrf(httptest.NewRequest(http.MethodGet, "/api/v1/auth/csrf", nil)) + if token != "" { + t.Fatalf("token = %q without a session, want empty", token) + } + if len(rec.Result().Cookies()) != 0 { + t.Fatalf("cookies set without a session: %v", rec.Result().Cookies()) + } + }) + + cases := []struct { + name string + env string + mutate func(*http.Request) + wantSecure bool + }{ + {name: "auto over plain http", env: "", wantSecure: false}, + {name: "auto behind https proxy", env: "", mutate: func(r *http.Request) { r.Header.Set("X-Forwarded-Proto", "https") }, wantSecure: true}, + {name: "auto over tls", env: "", mutate: func(r *http.Request) { r.TLS = &tls.ConnectionState{} }, wantSecure: true}, + {name: "always", env: "always", wantSecure: true}, + {name: "never even over tls", env: "never", mutate: func(r *http.Request) { r.TLS = &tls.ConnectionState{} }, wantSecure: false}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + t.Setenv("BINDERY_COOKIE_SECURE", c.env) + req := withSession(httptest.NewRequest(http.MethodGet, "/api/v1/auth/csrf", nil)) + if c.mutate != nil { + c.mutate(req) + } + rec, token := csrf(req) + if token != want { + t.Fatalf("token = %q, want %q", token, want) + } + ck := csrfCookie(t, rec) + if ck.Value != want || ck.HttpOnly || ck.Path != "/" || ck.SameSite != http.SameSiteLaxMode { + t.Fatalf("csrf cookie = %+v", ck) + } + if ck.Secure != c.wantSecure { + t.Fatalf("Secure = %v, want %v", ck.Secure, c.wantSecure) + } + }) + } +} + +// TestAuthChangePasswordCoverageRefusals covers the refusals in front of the +// hash update. Each one must leave the stored hash untouched. +func TestAuthChangePasswordCoverageRefusals(t *testing.T) { + h, users, _, ctx := newAuthFixture(t) + hash, err := auth.HashPassword("original-password") + if err != nil { + t.Fatal(err) + } + alice, err := users.Create(ctx, "alice", hash) + if err != nil { + t.Fatal(err) + } + + change := func(req *http.Request) *httptest.ResponseRecorder { + rec := httptest.NewRecorder() + h.ChangePassword(rec, req) + return rec + } + post := func(body string) *http.Request { + return httptest.NewRequest(http.MethodPost, "/api/v1/auth/password", strings.NewReader(body)) + } + asAlice := func(r *http.Request) *http.Request { + return r.WithContext(auth.WithUserID(r.Context(), alice.ID)) + } + good := `{"currentPassword":"original-password","newPassword":"brand-new-password"}` + + if rec := change(asAlice(post(`{`))); rec.Code != http.StatusBadRequest { + t.Fatalf("bad body: %d, want 400", rec.Code) + } + if rec := change(asAlice(post(`{"currentPassword":"original-password","newPassword":"short"}`))); rec.Code != http.StatusBadRequest { + t.Fatalf("short password: %d, want 400", rec.Code) + } + // A session for a user id that no longer exists. + rec := change(post(good).WithContext(auth.WithUserID(post(good).Context(), alice.ID+100))) + if rec.Code != http.StatusUnauthorized || !strings.Contains(rec.Body.String(), "user not found") { + t.Fatalf("ghost user: %d %s, want 401 user not found", rec.Code, rec.Body.String()) + } + + // With no identity the handler only acts when exactly one user exists. + if _, err := users.Create(ctx, "bob", hash); err != nil { + t.Fatal(err) + } + rec = change(post(good)) + if rec.Code != http.StatusUnauthorized || !strings.Contains(rec.Body.String(), "not logged in") { + t.Fatalf("anonymous with two users: %d %s, want 401 not logged in", rec.Code, rec.Body.String()) + } + + after, err := users.GetByID(ctx, alice.ID) + if err != nil { + t.Fatal(err) + } + if after.PasswordHash != hash { + t.Fatal("a refused change rewrote the password hash") + } +} diff --git a/internal/api/authors_alias_coverage_test.go b/internal/api/authors_alias_coverage_test.go new file mode 100644 index 000000000..f0dffba1e --- /dev/null +++ b/internal/api/authors_alias_coverage_test.go @@ -0,0 +1,437 @@ +package api + +import ( + "context" + "database/sql" + "encoding/json" + "net/http" + "net/http/httptest" + "strconv" + "strings" + "testing" + + "github.com/go-chi/chi/v5" + + "github.com/vavallee/bindery/internal/auth" + "github.com/vavallee/bindery/internal/db" + "github.com/vavallee/bindery/internal/models" +) + +// covAuEnv is a small in-memory fixture for the alias and genre handler +// tests: two users, an author owned by each, and the repos the handlers need. +type covAuEnv struct { + ctx context.Context + database *sql.DB + authors *db.AuthorRepo + aliases *db.AuthorAliasRepo + books *db.BookRepo + series *db.SeriesRepo + alice int64 + bob int64 + aliceA *models.Author + bobA *models.Author +} + +func covAuSetup(t *testing.T) *covAuEnv { + t.Helper() + database, err := db.OpenMemory() + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = database.Close() }) + ctx := context.Background() + users := db.NewUserRepo(database) + alice, err := users.Create(ctx, "alice", "h1") + if err != nil { + t.Fatal(err) + } + bob, err := users.Create(ctx, "bob", "h2") + if err != nil { + t.Fatal(err) + } + env := &covAuEnv{ + ctx: ctx, + database: database, + authors: db.NewAuthorRepo(database), + aliases: db.NewAuthorAliasRepo(database), + books: db.NewBookRepo(database), + series: db.NewSeriesRepo(database), + alice: alice.ID, + bob: bob.ID, + } + env.aliceA = &models.Author{ForeignID: "OL-ALICE-A", Name: "Alice Writer", SortName: "Writer, Alice", Monitored: true} + if err := env.authors.CreateForUser(ctx, env.aliceA, alice.ID); err != nil { + t.Fatal(err) + } + env.bobA = &models.Author{ForeignID: "OL-BOB-A", Name: "Bob Writer", SortName: "Writer, Bob", Monitored: true} + if err := env.authors.CreateForUser(ctx, env.bobA, bob.ID); err != nil { + t.Fatal(err) + } + return env +} + +// covAuRequest builds a request with chi URL params and an optional caller +// identity. userID 0 leaves the context anonymous; role "" leaves it unset. +func covAuRequest(method, path, body string, params map[string]string, userID int64, role string) *http.Request { + var req *http.Request + if body == "" { + req = httptest.NewRequest(method, path, nil) + } else { + req = httptest.NewRequest(method, path, strings.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + } + rctx := chi.NewRouteContext() + for k, v := range params { + rctx.URLParams.Add(k, v) + } + ctx := context.WithValue(req.Context(), chi.RouteCtxKey, rctx) + if userID != 0 { + ctx = auth.WithUserID(ctx, userID) + } + if role != "" { + ctx = auth.WithUserRole(ctx, role) + } + return req.WithContext(ctx) +} + +func covAuID(id int64) string { return strconv.FormatInt(id, 10) } + +func covAuErrorBody(t *testing.T, rec *httptest.ResponseRecorder) string { + t.Helper() + var body map[string]any + if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil { + t.Fatalf("decode error body %q: %v", rec.Body.String(), err) + } + s, _ := body["error"].(string) + return s +} + +func TestAuthorAliasListCoverage(t *testing.T) { + auth.SetEnforceTenancyForTests(t, true) + env := covAuSetup(t) + h := NewAuthorAliasHandler(env.authors, env.aliases) + if err := env.aliases.Create(env.ctx, &models.AuthorAlias{AuthorID: env.aliceA.ID, Name: "A. Writer"}); err != nil { + t.Fatal(err) + } + + t.Run("bad id is 400", func(t *testing.T) { + rec := httptest.NewRecorder() + h.List(rec, covAuRequest(http.MethodGet, "/", "", map[string]string{"id": "abc"}, env.alice, "user")) + if rec.Code != http.StatusBadRequest || covAuErrorBody(t, rec) != "invalid id" { + t.Fatalf("got %d %s", rec.Code, rec.Body.String()) + } + }) + t.Run("missing author is 404", func(t *testing.T) { + rec := httptest.NewRecorder() + h.List(rec, covAuRequest(http.MethodGet, "/", "", map[string]string{"id": "9999"}, env.alice, "user")) + if rec.Code != http.StatusNotFound { + t.Fatalf("got %d %s", rec.Code, rec.Body.String()) + } + }) + t.Run("another user's author is 404 not its aliases", func(t *testing.T) { + rec := httptest.NewRecorder() + h.List(rec, covAuRequest(http.MethodGet, "/", "", map[string]string{"id": covAuID(env.aliceA.ID)}, env.bob, "user")) + if rec.Code != http.StatusNotFound || strings.Contains(rec.Body.String(), "A. Writer") { + t.Fatalf("cross-user alias list: got %d %s", rec.Code, rec.Body.String()) + } + }) + t.Run("owner sees aliases", func(t *testing.T) { + rec := httptest.NewRecorder() + h.List(rec, covAuRequest(http.MethodGet, "/", "", map[string]string{"id": covAuID(env.aliceA.ID)}, env.alice, "user")) + if rec.Code != http.StatusOK { + t.Fatalf("got %d %s", rec.Code, rec.Body.String()) + } + var got []models.AuthorAlias + if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil { + t.Fatal(err) + } + if len(got) != 1 || got[0].Name != "A. Writer" { + t.Fatalf("aliases = %+v", got) + } + }) + t.Run("admin sees another user's aliases", func(t *testing.T) { + rec := httptest.NewRecorder() + h.List(rec, covAuRequest(http.MethodGet, "/", "", map[string]string{"id": covAuID(env.aliceA.ID)}, env.bob, "admin")) + if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), "A. Writer") { + t.Fatalf("got %d %s", rec.Code, rec.Body.String()) + } + }) + t.Run("no aliases is an empty array", func(t *testing.T) { + rec := httptest.NewRecorder() + h.List(rec, covAuRequest(http.MethodGet, "/", "", map[string]string{"id": covAuID(env.bobA.ID)}, env.bob, "user")) + if rec.Code != http.StatusOK || strings.TrimSpace(rec.Body.String()) != "[]" { + t.Fatalf("got %d %q", rec.Code, rec.Body.String()) + } + }) + t.Run("repo error is 500", func(t *testing.T) { + _ = env.database.Close() + rec := httptest.NewRecorder() + h.List(rec, covAuRequest(http.MethodGet, "/", "", map[string]string{"id": covAuID(env.aliceA.ID)}, env.alice, "user")) + if rec.Code != http.StatusInternalServerError || covAuErrorBody(t, rec) != "internal server error" { + t.Fatalf("got %d %s", rec.Code, rec.Body.String()) + } + }) +} + +func TestAuthorAliasDeleteCoverage(t *testing.T) { + auth.SetEnforceTenancyForTests(t, true) + env := covAuSetup(t) + h := NewAuthorAliasHandler(env.authors, env.aliases) + alias := &models.AuthorAlias{AuthorID: env.aliceA.ID, Name: "A. Writer"} + if err := env.aliases.Create(env.ctx, alias); err != nil { + t.Fatal(err) + } + params := func(author, aliasID string) map[string]string { + return map[string]string{"id": author, "aliasID": aliasID} + } + + cases := []struct { + name string + params map[string]string + user int64 + want int + errMsg string + }{ + {"bad author id", params("x", "1"), env.alice, http.StatusBadRequest, "invalid id"}, + {"bad alias id", params(covAuID(env.aliceA.ID), "x"), env.alice, http.StatusBadRequest, "invalid alias id"}, + {"missing author", params("9999", covAuID(alias.ID)), env.alice, http.StatusNotFound, "author not found"}, + {"another user's author", params(covAuID(env.aliceA.ID), covAuID(alias.ID)), env.bob, http.StatusNotFound, "author not found"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + rec := httptest.NewRecorder() + h.Delete(rec, covAuRequest(http.MethodDelete, "/", "", tc.params, tc.user, "user")) + if rec.Code != tc.want || covAuErrorBody(t, rec) != tc.errMsg { + t.Fatalf("got %d %s, want %d %q", rec.Code, rec.Body.String(), tc.want, tc.errMsg) + } + }) + } + // None of the rejected calls may have removed the alias. + got, err := env.aliases.ListByAuthor(env.ctx, env.aliceA.ID) + if err != nil || len(got) != 1 { + t.Fatalf("alias removed by a rejected call: %+v err=%v", got, err) + } + + _ = env.database.Close() + rec := httptest.NewRecorder() + h.Delete(rec, covAuRequest(http.MethodDelete, "/", "", params(covAuID(env.aliceA.ID), covAuID(alias.ID)), env.alice, "user")) + if rec.Code != http.StatusInternalServerError { + t.Fatalf("closed db: got %d %s", rec.Code, rec.Body.String()) + } +} + +func TestAuthorAliasMergeCoverage(t *testing.T) { + auth.SetEnforceTenancyForTests(t, true) + + t.Run("validation", func(t *testing.T) { + env := covAuSetup(t) + h := NewAuthorAliasHandler(env.authors, env.aliases) + target := covAuID(env.aliceA.ID) + cases := []struct { + name, id, body, errMsg string + }{ + {"bad id", "x", `{"sourceId":1}`, "invalid id"}, + {"bad body", target, `{`, "invalid request body"}, + {"missing source", target, `{}`, "sourceId required"}, + {"self merge", target, `{"sourceId":` + target + `}`, "source and target must differ"}, + } + for _, tc := range cases { + rec := httptest.NewRecorder() + h.Merge(rec, covAuRequest(http.MethodPost, "/", tc.body, map[string]string{"id": tc.id}, env.alice, "user")) + if rec.Code != http.StatusBadRequest || covAuErrorBody(t, rec) != tc.errMsg { + t.Fatalf("%s: got %d %s", tc.name, rec.Code, rec.Body.String()) + } + } + }) + + t.Run("cannot absorb another user's author", func(t *testing.T) { + env := covAuSetup(t) + h := NewAuthorAliasHandler(env.authors, env.aliases) + body := `{"sourceId":` + covAuID(env.aliceA.ID) + `}` + rec := httptest.NewRecorder() + h.Merge(rec, covAuRequest(http.MethodPost, "/", body, map[string]string{"id": covAuID(env.bobA.ID)}, env.bob, "user")) + if rec.Code != http.StatusNotFound || covAuErrorBody(t, rec) != "author not found: "+covAuID(env.aliceA.ID) { + t.Fatalf("got %d %s", rec.Code, rec.Body.String()) + } + if a, _ := env.authors.GetByID(env.ctx, env.aliceA.ID); a == nil { + t.Fatal("alice's author was deleted by bob's merge") + } + }) + + t.Run("cannot merge into another user's author", func(t *testing.T) { + env := covAuSetup(t) + h := NewAuthorAliasHandler(env.authors, env.aliases) + body := `{"sourceId":` + covAuID(env.bobA.ID) + `}` + rec := httptest.NewRecorder() + h.Merge(rec, covAuRequest(http.MethodPost, "/", body, map[string]string{"id": covAuID(env.aliceA.ID)}, env.bob, "user")) + if rec.Code != http.StatusNotFound || covAuErrorBody(t, rec) != "author not found: "+covAuID(env.aliceA.ID) { + t.Fatalf("got %d %s", rec.Code, rec.Body.String()) + } + if a, _ := env.authors.GetByID(env.ctx, env.bobA.ID); a == nil { + t.Fatal("bob's author was deleted by a rejected merge") + } + }) + + t.Run("overwriteDefaults false still merges", func(t *testing.T) { + env := covAuSetup(t) + h := NewAuthorAliasHandler(env.authors, env.aliases) + book := &models.Book{ForeignID: "OL-BOB-BOOK", AuthorID: env.bobA.ID, Title: "Bob's Book", Status: models.BookStatusWanted} + if err := env.books.Create(env.ctx, book); err != nil { + t.Fatal(err) + } + body := `{"sourceId":` + covAuID(env.bobA.ID) + `,"overwriteDefaults":false}` + rec := httptest.NewRecorder() + h.Merge(rec, covAuRequest(http.MethodPost, "/", body, map[string]string{"id": covAuID(env.aliceA.ID)}, env.alice, "admin")) + if rec.Code != http.StatusOK { + t.Fatalf("got %d %s", rec.Code, rec.Body.String()) + } + var res db.MergeResult + if err := json.Unmarshal(rec.Body.Bytes(), &res); err != nil { + t.Fatal(err) + } + if res.BooksReparented != 1 { + t.Fatalf("result = %+v", res) + } + if a, _ := env.authors.GetByID(env.ctx, env.bobA.ID); a != nil { + t.Fatal("source author survived the merge") + } + moved, _ := env.books.GetByID(env.ctx, book.ID) + if moved == nil || moved.AuthorID != env.aliceA.ID { + t.Fatalf("book not reparented: %+v", moved) + } + }) + + t.Run("repo error is 500", func(t *testing.T) { + env := covAuSetup(t) + h := NewAuthorAliasHandler(env.authors, env.aliases) + _ = env.database.Close() + body := `{"sourceId":` + covAuID(env.bobA.ID) + `}` + rec := httptest.NewRecorder() + h.Merge(rec, covAuRequest(http.MethodPost, "/", body, map[string]string{"id": covAuID(env.aliceA.ID)}, env.alice, "admin")) + if rec.Code != http.StatusInternalServerError { + t.Fatalf("got %d %s", rec.Code, rec.Body.String()) + } + }) +} + +func TestApplyGenresAuthorCoverage(t *testing.T) { + auth.SetEnforceTenancyForTests(t, true) + env := covAuSetup(t) + h := NewAuthorHandler(env.authors, env.aliases, env.books, env.series, nil, nil, nil, nil) + book := &models.Book{ForeignID: "OL-ALICE-BOOK", AuthorID: env.aliceA.ID, Title: "Alice's Book", Status: models.BookStatusWanted, Genres: []string{"Old"}} + if err := env.books.Create(env.ctx, book); err != nil { + t.Fatal(err) + } + id := covAuID(env.aliceA.ID) + + cases := []struct { + name, id, body string + user int64 + want int + errMsg string + }{ + {"bad id", "x", `{"genres":["A"]}`, env.alice, http.StatusBadRequest, "invalid id"}, + {"missing author", "9999", `{"genres":["A"]}`, env.alice, http.StatusNotFound, "author not found"}, + {"another user's author", id, `{"genres":["Hijacked"]}`, env.bob, http.StatusNotFound, "author not found"}, + {"bad body", id, `{`, env.alice, http.StatusBadRequest, "invalid request body"}, + } + for _, tc := range cases { + rec := httptest.NewRecorder() + h.ApplyGenres(rec, covAuRequest(http.MethodPut, "/", tc.body, map[string]string{"id": tc.id}, tc.user, "user")) + if rec.Code != tc.want || covAuErrorBody(t, rec) != tc.errMsg { + t.Fatalf("%s: got %d %s", tc.name, rec.Code, rec.Body.String()) + } + } + got, _ := env.books.GetByID(env.ctx, book.ID) + if got == nil || len(got.Genres) != 1 || got.Genres[0] != "Old" || got.IsFieldLocked(models.BookFieldGenres) { + t.Fatalf("a rejected call changed the book: %+v", got) + } + + rec := httptest.NewRecorder() + h.ApplyGenres(rec, covAuRequest(http.MethodPut, "/", `{"genres":[" Fantasy ",""]}`, map[string]string{"id": id}, env.alice, "user")) + if rec.Code != http.StatusOK || strings.TrimSpace(rec.Body.String()) != `{"updated":1}` { + t.Fatalf("owner apply: got %d %s", rec.Code, rec.Body.String()) + } + got, _ = env.books.GetByID(env.ctx, book.ID) + if got == nil || len(got.Genres) != 1 || got.Genres[0] != "Fantasy" || !got.IsFieldLocked(models.BookFieldGenres) { + t.Fatalf("genres not applied and locked: %+v", got) + } +} + +func TestApplyGenresSeriesCoverage(t *testing.T) { + env := covAuSetup(t) + h := NewSeriesHandler(env.series, env.books, env.authors, nil, nil) + s, err := env.series.CreateManual(env.ctx, "Saga") + if err != nil { + t.Fatal(err) + } + book := &models.Book{ForeignID: "OL-SAGA-1", AuthorID: env.aliceA.ID, Title: "Saga One", Status: models.BookStatusWanted} + if err := env.books.Create(env.ctx, book); err != nil { + t.Fatal(err) + } + if err := env.series.UpsertBookLink(env.ctx, s.ID, book.ID, "1", true); err != nil { + t.Fatal(err) + } + id := covAuID(s.ID) + + for _, tc := range []struct { + name, id, body string + want int + errMsg string + }{ + {"bad id", "x", `{"genres":[]}`, http.StatusBadRequest, "invalid id"}, + {"missing series", "9999", `{"genres":[]}`, http.StatusNotFound, "series not found"}, + {"bad body", id, `{`, http.StatusBadRequest, "invalid request body"}, + } { + rec := httptest.NewRecorder() + h.ApplyGenres(rec, covAuRequest(http.MethodPut, "/", tc.body, map[string]string{"id": tc.id}, 0, "")) + if rec.Code != tc.want || covAuErrorBody(t, rec) != tc.errMsg { + t.Fatalf("apply %s: got %d %s", tc.name, rec.Code, rec.Body.String()) + } + } + for _, tc := range []struct { + name, id string + want int + }{ + {"bad id", "x", http.StatusBadRequest}, + {"missing series", "9999", http.StatusNotFound}, + } { + rec := httptest.NewRecorder() + h.ClearGenres(rec, covAuRequest(http.MethodDelete, "/", "", map[string]string{"id": tc.id}, 0, "")) + if rec.Code != tc.want { + t.Fatalf("clear %s: got %d %s", tc.name, rec.Code, rec.Body.String()) + } + } + + rec := httptest.NewRecorder() + h.ApplyGenres(rec, covAuRequest(http.MethodPut, "/", `{"genres":["Space Opera"]}`, map[string]string{"id": id}, 0, "")) + if rec.Code != http.StatusOK || strings.TrimSpace(rec.Body.String()) != `{"updated":1}` { + t.Fatalf("apply: got %d %s", rec.Code, rec.Body.String()) + } + saved, _ := env.series.GetByID(env.ctx, s.ID) + if saved == nil || !saved.GenreOverrideSet || len(saved.GenreOverride) != 1 || saved.GenreOverride[0] != "Space Opera" { + t.Fatalf("override not stored: %+v", saved) + } + + rec = httptest.NewRecorder() + h.ClearGenres(rec, covAuRequest(http.MethodDelete, "/", "", map[string]string{"id": id}, 0, "")) + if rec.Code != http.StatusOK || strings.TrimSpace(rec.Body.String()) != `{"cleared":true}` { + t.Fatalf("clear: got %d %s", rec.Code, rec.Body.String()) + } + saved, _ = env.series.GetByID(env.ctx, s.ID) + if saved == nil || saved.GenreOverrideSet { + t.Fatalf("override not cleared: %+v", saved) + } + // Clearing the policy leaves the book's applied genres alone (#1709). + b, _ := env.books.GetByID(env.ctx, book.ID) + if b == nil || len(b.Genres) != 1 || b.Genres[0] != "Space Opera" || !b.IsFieldLocked(models.BookFieldGenres) { + t.Fatalf("book genres changed by clear: %+v", b) + } + + _ = env.database.Close() + rec = httptest.NewRecorder() + h.ApplyGenres(rec, covAuRequest(http.MethodPut, "/", `{"genres":[]}`, map[string]string{"id": id}, 0, "")) + if rec.Code != http.StatusNotFound { + t.Fatalf("closed db apply: got %d %s", rec.Code, rec.Body.String()) + } +} diff --git a/internal/api/authors_coverage_test.go b/internal/api/authors_coverage_test.go new file mode 100644 index 000000000..bfc3ab739 --- /dev/null +++ b/internal/api/authors_coverage_test.go @@ -0,0 +1,559 @@ +package api + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/vavallee/bindery/internal/auth" + "github.com/vavallee/bindery/internal/metadata" + "github.com/vavallee/bindery/internal/models" +) + +// covAuSearchProvider answers SearchAuthors with a fixed list (or error) for +// every query and GetAuthor from a map (or error), so the relink and create +// paths can be driven without a real provider. +type covAuSearchProvider struct { + stubMetaProvider + results []models.Author + searchErr error + byID map[string]*models.Author + getErr error +} + +func (p *covAuSearchProvider) SearchAuthors(context.Context, string) ([]models.Author, error) { + if p.searchErr != nil { + return nil, p.searchErr + } + return p.results, nil +} + +func (p *covAuSearchProvider) GetAuthor(_ context.Context, foreignID string) (*models.Author, error) { + if p.getErr != nil { + return nil, p.getErr + } + if a, ok := p.byID[foreignID]; ok { + cp := *a + return &cp, nil + } + return nil, nil +} + +func covAuHandler(env *covAuEnv, meta *metadata.Aggregator) *AuthorHandler { + return NewAuthorHandler(env.authors, env.aliases, env.books, env.series, meta, nil, nil, nil) +} + +func covAuDecodeList(t *testing.T, rec *httptest.ResponseRecorder) authorListResponse { + t.Helper() + var out authorListResponse + if err := json.Unmarshal(rec.Body.Bytes(), &out); err != nil { + t.Fatalf("decode %q: %v", rec.Body.String(), err) + } + return out +} + +func TestAuthorListCoverage(t *testing.T) { + auth.SetEnforceTenancyForTests(t, true) + env := covAuSetup(t) + unmonitored := &models.Author{ForeignID: "OL-ALICE-B", Name: "Quiet Alice", SortName: "Alice, Quiet", Monitored: false} + if err := env.authors.CreateForUser(env.ctx, unmonitored, env.alice); err != nil { + t.Fatal(err) + } + h := covAuHandler(env, nil) + + names := func(resp authorListResponse) []string { + out := make([]string, 0, len(resp.Items)) + for _, a := range resp.Items { + out = append(out, a.Name) + } + return out + } + + t.Run("non-admin sees only own authors", func(t *testing.T) { + rec := httptest.NewRecorder() + h.List(rec, covAuRequest(http.MethodGet, "/api/v1/author", "", nil, env.bob, "user")) + resp := covAuDecodeList(t, rec) + if rec.Code != http.StatusOK || resp.Total != 1 || len(resp.Items) != 1 || resp.Items[0].ID != env.bobA.ID { + t.Fatalf("bob list: %d %v total=%d", rec.Code, names(resp), resp.Total) + } + }) + t.Run("admin sees everyone", func(t *testing.T) { + rec := httptest.NewRecorder() + h.List(rec, covAuRequest(http.MethodGet, "/api/v1/author", "", nil, env.bob, "admin")) + if resp := covAuDecodeList(t, rec); resp.Total != 3 { + t.Fatalf("admin list: %v total=%d", names(resp), resp.Total) + } + }) + t.Run("monitored=true filter", func(t *testing.T) { + rec := httptest.NewRecorder() + h.List(rec, covAuRequest(http.MethodGet, "/api/v1/author?monitored=true", "", nil, env.alice, "user")) + resp := covAuDecodeList(t, rec) + if resp.Total != 1 || resp.Items[0].ID != env.aliceA.ID { + t.Fatalf("monitored=true: %v", names(resp)) + } + }) + t.Run("monitored=false filter", func(t *testing.T) { + rec := httptest.NewRecorder() + h.List(rec, covAuRequest(http.MethodGet, "/api/v1/author?monitored=false", "", nil, env.alice, "user")) + resp := covAuDecodeList(t, rec) + if resp.Total != 1 || resp.Items[0].ID != unmonitored.ID { + t.Fatalf("monitored=false: %v", names(resp)) + } + }) + t.Run("search with no hits is an empty array", func(t *testing.T) { + rec := httptest.NewRecorder() + h.List(rec, covAuRequest(http.MethodGet, "/api/v1/author?search=zzzz", "", nil, env.alice, "user")) + if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), `"items":[]`) { + t.Fatalf("empty search: %d %s", rec.Code, rec.Body.String()) + } + }) + t.Run("repo error is 500", func(t *testing.T) { + _ = env.database.Close() + rec := httptest.NewRecorder() + h.List(rec, covAuRequest(http.MethodGet, "/api/v1/author", "", nil, env.alice, "user")) + if rec.Code != http.StatusInternalServerError { + t.Fatalf("got %d %s", rec.Code, rec.Body.String()) + } + }) +} + +func TestAuthorGetCoverage(t *testing.T) { + auth.SetEnforceTenancyForTests(t, true) + env := covAuSetup(t) + h := covAuHandler(env, nil) + if err := env.aliases.Create(env.ctx, &models.AuthorAlias{AuthorID: env.aliceA.ID, Name: "A. Writer"}); err != nil { + t.Fatal(err) + } + book := &models.Book{ForeignID: "OL-ALICE-S1", AuthorID: env.aliceA.ID, Title: "Saga One", Status: models.BookStatusWanted} + if err := env.books.Create(env.ctx, book); err != nil { + t.Fatal(err) + } + s, err := env.series.CreateManual(env.ctx, "Saga") + if err != nil { + t.Fatal(err) + } + if err := env.series.UpsertBookLink(env.ctx, s.ID, book.ID, "1", true); err != nil { + t.Fatal(err) + } + env.aliceA.MonitorMode = models.AuthorMonitorModeSeries + if err := env.authors.Update(env.ctx, env.aliceA); err != nil { + t.Fatal(err) + } + if err := env.authors.SetMonitoredSeriesIDs(env.ctx, env.aliceA.ID, []int64{s.ID}); err != nil { + t.Fatal(err) + } + id := covAuID(env.aliceA.ID) + + rec := httptest.NewRecorder() + h.Get(rec, covAuRequest(http.MethodGet, "/", "", map[string]string{"id": "x"}, env.alice, "user")) + if rec.Code != http.StatusBadRequest { + t.Fatalf("bad id: %d", rec.Code) + } + + rec = httptest.NewRecorder() + h.Get(rec, covAuRequest(http.MethodGet, "/", "", map[string]string{"id": id}, env.bob, "user")) + if rec.Code != http.StatusNotFound || strings.Contains(rec.Body.String(), "Alice Writer") { + t.Fatalf("cross-user get: %d %s", rec.Code, rec.Body.String()) + } + + rec = httptest.NewRecorder() + h.Get(rec, covAuRequest(http.MethodGet, "/", "", map[string]string{"id": id}, env.alice, "user")) + if rec.Code != http.StatusOK { + t.Fatalf("owner get: %d %s", rec.Code, rec.Body.String()) + } + var got models.Author + if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil { + t.Fatal(err) + } + if len(got.Aliases) != 1 || got.Aliases[0].Name != "A. Writer" { + t.Fatalf("aliases not attached: %+v", got.Aliases) + } + if len(got.MonitoredSeriesIDs) != 1 || got.MonitoredSeriesIDs[0] != s.ID { + t.Fatalf("series pins not attached: %+v", got.MonitoredSeriesIDs) + } + if len(got.Books) != 1 || got.Books[0].ID != book.ID { + t.Fatalf("books not attached: %+v", got.Books) + } + + _ = env.database.Close() + rec = httptest.NewRecorder() + h.Get(rec, covAuRequest(http.MethodGet, "/", "", map[string]string{"id": id}, env.alice, "user")) + if rec.Code != http.StatusInternalServerError { + t.Fatalf("closed db: %d %s", rec.Code, rec.Body.String()) + } +} + +func TestAuthorListSeriesCoverage(t *testing.T) { + auth.SetEnforceTenancyForTests(t, true) + env := covAuSetup(t) + + noSeries := NewAuthorHandler(env.authors, env.aliases, env.books, nil, nil, nil, nil, nil) + rec := httptest.NewRecorder() + noSeries.ListSeries(rec, covAuRequest(http.MethodGet, "/", "", map[string]string{"id": covAuID(env.aliceA.ID)}, env.alice, "user")) + if rec.Code != http.StatusOK || strings.TrimSpace(rec.Body.String()) != "[]" { + t.Fatalf("nil series repo: %d %q", rec.Code, rec.Body.String()) + } + + h := covAuHandler(env, nil) + rec = httptest.NewRecorder() + h.ListSeries(rec, covAuRequest(http.MethodGet, "/", "", map[string]string{"id": covAuID(env.aliceA.ID)}, env.bob, "user")) + if rec.Code != http.StatusNotFound { + t.Fatalf("cross-user: %d %s", rec.Code, rec.Body.String()) + } +} + +func TestAuthorUpdateCoverage(t *testing.T) { + auth.SetEnforceTenancyForTests(t, true) + env := covAuSetup(t) + h := covAuHandler(env, nil) + id := covAuID(env.aliceA.ID) + update := func(user int64, body string) *httptest.ResponseRecorder { + rec := httptest.NewRecorder() + h.Update(rec, covAuRequest(http.MethodPut, "/", body, map[string]string{"id": id}, user, "user")) + return rec + } + + for _, tc := range []struct { + name, body, errMsg string + }{ + {"bad body", `{`, "invalid request body"}, + {"bad monitor mode", `{"monitorMode":"sometimes"}`, "monitorMode must be one of: all, future, latest, none, series"}, + {"bad monitorNewItems", `{"monitorNewItems":"some"}`, "monitorNewItems must be one of: all, none"}, + {"zero latest count", `{"monitorLatestCount":0}`, "monitorLatestCount must be a positive integer"}, + } { + rec := update(env.alice, tc.body) + if rec.Code != http.StatusBadRequest || covAuErrorBody(t, rec) != tc.errMsg { + t.Fatalf("%s: %d %s", tc.name, rec.Code, rec.Body.String()) + } + } + + rec := update(env.bob, `{"monitored":false}`) + if rec.Code != http.StatusNotFound { + t.Fatalf("cross-user update: %d %s", rec.Code, rec.Body.String()) + } + if a, _ := env.authors.GetByID(env.ctx, env.aliceA.ID); a == nil || !a.Monitored { + t.Fatalf("bob's rejected update changed alice's author: %+v", a) + } + + rec = update(env.alice, `{"monitored":false,"monitorNewItems":"none","monitorLatestCount":3,"qualityProfileId":1,"metadataProfileId":1,"rootFolderId":7,"audiobookRootFolderId":8}`) + if rec.Code != http.StatusOK { + t.Fatalf("full update: %d %s", rec.Code, rec.Body.String()) + } + a, _ := env.authors.GetByID(env.ctx, env.aliceA.ID) + if a == nil || a.Monitored || a.MonitorNewItems != "none" || a.MonitorLatestCount != 3 || + a.QualityProfileID == nil || *a.QualityProfileID != 1 || a.MetadataProfileID == nil || *a.MetadataProfileID != 1 || + a.RootFolderID == nil || *a.RootFolderID != 7 || a.AudiobookRootFolderID == nil || *a.AudiobookRootFolderID != 8 { + t.Fatalf("update not persisted: %+v", a) + } + + rec = update(env.alice, `{"clearAudiobookRootFolder":true}`) + if rec.Code != http.StatusOK { + t.Fatalf("clear audiobook root: %d %s", rec.Code, rec.Body.String()) + } + if a, _ := env.authors.GetByID(env.ctx, env.aliceA.ID); a == nil || a.AudiobookRootFolderID != nil || a.RootFolderID == nil { + t.Fatalf("audiobook root not cleared alone: %+v", a) + } + + // Series mode without a pin set in the request surfaces the stored pins. + book := &models.Book{ForeignID: "OL-ALICE-S1", AuthorID: env.aliceA.ID, Title: "Saga One", Status: models.BookStatusWanted} + if err := env.books.Create(env.ctx, book); err != nil { + t.Fatal(err) + } + s, err := env.series.CreateManual(env.ctx, "Saga") + if err != nil { + t.Fatal(err) + } + if err := env.series.UpsertBookLink(env.ctx, s.ID, book.ID, "1", true); err != nil { + t.Fatal(err) + } + if err := env.authors.SetMonitoredSeriesIDs(env.ctx, env.aliceA.ID, []int64{s.ID}); err != nil { + t.Fatal(err) + } + rec = update(env.alice, `{"monitorMode":"series"}`) + if rec.Code != http.StatusOK { + t.Fatalf("series mode: %d %s", rec.Code, rec.Body.String()) + } + var got models.Author + if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil { + t.Fatal(err) + } + if len(got.MonitoredSeriesIDs) != 1 || got.MonitoredSeriesIDs[0] != s.ID { + t.Fatalf("stored pins not surfaced: %+v", got.MonitoredSeriesIDs) + } + + // An explicit empty pin set clears the selection. + rec = update(env.alice, `{"monitoredSeriesIds":[]}`) + if rec.Code != http.StatusOK { + t.Fatalf("clear pins: %d %s", rec.Code, rec.Body.String()) + } + if ids, _ := env.authors.ListMonitoredSeriesIDs(env.ctx, env.aliceA.ID); len(ids) != 0 { + t.Fatalf("pins not cleared: %v", ids) + } + + _ = env.database.Close() + rec = update(env.alice, `{"monitored":true}`) + if rec.Code != http.StatusInternalServerError { + t.Fatalf("closed db: %d %s", rec.Code, rec.Body.String()) + } +} + +func TestAuthorDeleteCoverage(t *testing.T) { + auth.SetEnforceTenancyForTests(t, true) + env := covAuSetup(t) + h := covAuHandler(env, nil) + + rec := httptest.NewRecorder() + h.Delete(rec, covAuRequest(http.MethodDelete, "/?deleteFiles=true", "", map[string]string{"id": covAuID(env.aliceA.ID)}, env.bob, "user")) + if rec.Code != http.StatusNotFound { + t.Fatalf("cross-user delete: %d %s", rec.Code, rec.Body.String()) + } + if a, _ := env.authors.GetByID(env.ctx, env.aliceA.ID); a == nil { + t.Fatal("bob deleted alice's author") + } + + rec = httptest.NewRecorder() + h.Delete(rec, covAuRequest(http.MethodDelete, "/", "", map[string]string{"id": covAuID(env.aliceA.ID)}, env.alice, "user")) + if rec.Code != http.StatusNoContent { + t.Fatalf("owner delete: %d %s", rec.Code, rec.Body.String()) + } + if a, _ := env.authors.GetByID(env.ctx, env.aliceA.ID); a != nil { + t.Fatal("author survived delete") + } +} + +func TestAuthorCreateCoverageValidation(t *testing.T) { + env := covAuSetup(t) + h := covAuHandler(env, nil) + for _, tc := range []struct { + name, body, errMsg string + }{ + {"bad body", `{`, "invalid request body"}, + {"missing fields", `{"foreignAuthorId":"OL-NEW"}`, ""}, + {"bad monitor mode", `{"foreignAuthorId":"OL-NEW","authorName":"New","monitorMode":"sometimes"}`, "monitorMode must be one of: all, future, latest, none"}, + {"zero latest count", `{"foreignAuthorId":"OL-NEW","authorName":"New","monitorLatestCount":0}`, "monitorLatestCount must be a positive integer"}, + {"bad monitorNewItems", `{"foreignAuthorId":"OL-NEW","authorName":"New","monitorNewItems":"some"}`, ""}, + } { + rec := httptest.NewRecorder() + h.Create(rec, covAuRequest(http.MethodPost, "/", tc.body, nil, env.alice, "user")) + if rec.Code != http.StatusBadRequest { + t.Fatalf("%s: %d %s", tc.name, rec.Code, rec.Body.String()) + } + if tc.errMsg != "" && covAuErrorBody(t, rec) != tc.errMsg { + t.Fatalf("%s: error %q", tc.name, covAuErrorBody(t, rec)) + } + } + if a, _ := env.authors.GetByForeignID(env.ctx, "OL-NEW"); a != nil { + t.Fatalf("a rejected create wrote an author: %+v", a) + } +} + +// TestAuthorCreateCoverageNameConflicts drives the name-resolution conflicts +// in createAuthorCore: the upstream fetch failing, returning nothing, or +// returning a nameless record all fall back to the requested name, which then +// resolves to an existing author (by name, alias, or normalised alias) and +// must answer 409 instead of creating a duplicate. +func TestAuthorCreateCoverageNameConflicts(t *testing.T) { + type check struct { + name string + provider *covAuSearchProvider + nilMeta bool + reqName string + wantMsg string + wantCanon bool + } + nameless := &covAuSearchProvider{byID: map[string]*models.Author{"OL-NEW": {ForeignID: "OL-NEW"}}} + checks := []check{ + {name: "provider error falls back to name", provider: &covAuSearchProvider{getErr: errors.New("upstream down")}, reqName: "Bob Writer", wantMsg: "author name already resolves to an existing author — confirm merge", wantCanon: true}, + {name: "provider nil falls back to name", provider: &covAuSearchProvider{}, reqName: "bob writer", wantMsg: "author name already resolves to an existing author — confirm merge", wantCanon: true}, + {name: "nameless upstream record", provider: nameless, reqName: "Bob Writer", wantMsg: "author name already resolves to an existing author — confirm merge", wantCanon: true}, + {name: "no aggregator", nilMeta: true, reqName: "Pen Name", wantMsg: "author name already resolves to an existing author — confirm merge", wantCanon: true}, + {name: "normalised alias", nilMeta: true, reqName: "Pen-Name.", wantMsg: "author name already resolves to an existing author — confirm merge", wantCanon: true}, + {name: "ambiguous name", nilMeta: true, reqName: "Twin Name", wantMsg: "author name resolves ambiguously — merge manually"}, + } + for _, c := range checks { + t.Run(c.name, func(t *testing.T) { + env := covAuSetup(t) + if err := env.aliases.Create(env.ctx, &models.AuthorAlias{AuthorID: env.bobA.ID, Name: "Pen Name"}); err != nil { + t.Fatal(err) + } + for i, fid := range []string{"OL-TWIN-1", "OL-TWIN-2"} { + twin := &models.Author{ForeignID: fid, Name: "Twin Name", SortName: "Name, Twin"} + if i == 1 { + twin.Name = "twin name" + } + if err := env.authors.Create(env.ctx, twin); err != nil { + t.Fatal(err) + } + } + var meta *metadata.Aggregator + if !c.nilMeta { + meta = metadata.NewAggregator(c.provider) + } + h := covAuHandler(env, meta) + body, _ := json.Marshal(map[string]any{"foreignAuthorId": "OL-NEW", "authorName": c.reqName}) + rec := httptest.NewRecorder() + h.Create(rec, covAuRequest(http.MethodPost, "/", string(body), nil, 0, "")) + if rec.Code != http.StatusConflict || covAuErrorBody(t, rec) != c.wantMsg { + t.Fatalf("got %d %s", rec.Code, rec.Body.String()) + } + var resp map[string]any + _ = json.Unmarshal(rec.Body.Bytes(), &resp) + if c.wantCanon { + if id, _ := resp["canonicalAuthorId"].(float64); int64(id) != env.bobA.ID { + t.Fatalf("canonicalAuthorId = %v, want %d", resp["canonicalAuthorId"], env.bobA.ID) + } + } else if _, ok := resp["canonicalAuthorId"]; ok { + t.Fatalf("ambiguous conflict must not name a canonical author: %s", rec.Body.String()) + } + if a, _ := env.authors.GetByForeignID(env.ctx, "OL-NEW"); a != nil { + t.Fatalf("conflict still created the author: %+v", a) + } + }) + } +} + +func TestAuthorRelinkUpstreamCoverage(t *testing.T) { + absAuthor := func() *models.Author { + return &models.Author{ForeignID: "abs:author:jay", Name: "Jay Dee", SortName: "Dee, Jay", MetadataProvider: "audiobookshelf", Monitored: true} + } + jay := models.Author{ForeignID: "OL-JAY", Name: "Jay Dee", SortName: "Dee, Jay", MetadataProvider: "openlibrary"} + + type tc struct { + name string + meta func() *metadata.Aggregator + seed func(t *testing.T, env *covAuEnv) + author func() *models.Author + body string + urlID string + want int + wantMsg string + unlinked bool + } + primary := func(p *covAuSearchProvider) func() *metadata.Aggregator { + return func() *metadata.Aggregator { + p.name = "openlibrary" + return metadata.NewAggregator(p) + } + } + cases := []tc{ + {name: "bad id", urlID: "x", want: http.StatusBadRequest, wantMsg: "invalid id"}, + {name: "missing author", urlID: "9999", want: http.StatusNotFound, wantMsg: "author not found"}, + {name: "already linked", author: func() *models.Author { + return &models.Author{ForeignID: "OL-LINKED", Name: "Linked", SortName: "Linked", MetadataProvider: "openlibrary"} + }, want: http.StatusConflict, wantMsg: "author is already linked to upstream metadata"}, + {name: "no aggregator", want: http.StatusFailedDependency}, + {name: "no match", meta: primary(&covAuSearchProvider{}), want: http.StatusConflict, wantMsg: "no confident upstream author match found"}, + {name: "provider error", meta: primary(&covAuSearchProvider{searchErr: errors.New("boom")}), want: http.StatusBadGateway, wantMsg: "boom"}, + {name: "primary down, enricher match", meta: func() *metadata.Aggregator { + return metadata.NewAggregator( + &covAuSearchProvider{stubMetaProvider: stubMetaProvider{name: "openlibrary"}, searchErr: errors.New("ol down")}, + &covAuSearchProvider{stubMetaProvider: stubMetaProvider{name: "hardcover"}, results: []models.Author{{ForeignID: "hc:jay", Name: "Jay Dee", MetadataProvider: "hardcover"}}}, + ) + }, want: http.StatusServiceUnavailable}, + {name: "matched but upstream record gone", meta: primary(&covAuSearchProvider{results: []models.Author{jay}}), want: http.StatusConflict, wantMsg: "no confident upstream author match found"}, + {name: "explicit id not found", meta: primary(&covAuSearchProvider{}), body: `{"foreignAuthorId":"OL-NOPE"}`, want: http.StatusConflict, wantMsg: "no confident upstream author match found"}, + {name: "explicit id lookup error", meta: primary(&covAuSearchProvider{getErr: errors.New("get failed")}), body: `{"foreignAuthorId":"OL-NOPE"}`, want: http.StatusBadGateway, wantMsg: "get failed"}, + {name: "upstream name already a local author", meta: primary(&covAuSearchProvider{byID: map[string]*models.Author{"OL-BOB": {ForeignID: "OL-BOB", Name: "Bob Writer"}}}), + body: `{"foreignAuthorId":"OL-BOB"}`, want: http.StatusConflict, wantMsg: "author name already resolves to an existing author — confirm merge"}, + {name: "upstream id already local", meta: primary(&covAuSearchProvider{byID: map[string]*models.Author{"OL-BOB-A": {ForeignID: "OL-BOB-A", Name: "Robert Writer"}}}), + body: `{"foreignAuthorId":"OL-BOB-A"}`, want: http.StatusConflict, wantMsg: "upstream author already exists locally"}, + {name: "ambiguous local name", meta: primary(&covAuSearchProvider{byID: map[string]*models.Author{"OL-TWIN": {ForeignID: "OL-TWIN", Name: "Twin Name"}}}), + seed: func(t *testing.T, env *covAuEnv) { + for _, fid := range []string{"OL-TWIN-1", "OL-TWIN-2"} { + if err := env.authors.Create(env.ctx, &models.Author{ForeignID: fid, Name: "Twin Name", SortName: "Name, Twin"}); err != nil { + t.Fatal(err) + } + } + }, + body: `{"foreignAuthorId":"OL-TWIN"}`, want: http.StatusConflict, wantMsg: "author name resolves ambiguously — merge manually"}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + env := covAuSetup(t) + var meta *metadata.Aggregator + if c.meta != nil { + meta = c.meta() + } + if c.seed != nil { + c.seed(t, env) + } + mk := absAuthor + if c.author != nil { + mk = c.author + } + target := mk() + if err := env.authors.Create(env.ctx, target); err != nil { + t.Fatal(err) + } + urlID := c.urlID + if urlID == "" { + urlID = covAuID(target.ID) + } + h := covAuHandler(env, meta) + rec := httptest.NewRecorder() + h.RelinkUpstream(rec, covAuRequest(http.MethodPost, "/", c.body, map[string]string{"id": urlID}, 0, "")) + if rec.Code != c.want { + t.Fatalf("got %d %s, want %d", rec.Code, rec.Body.String(), c.want) + } + if c.wantMsg != "" && covAuErrorBody(t, rec) != c.wantMsg { + t.Fatalf("error = %q, want %q", covAuErrorBody(t, rec), c.wantMsg) + } + after, _ := env.authors.GetByID(env.ctx, target.ID) + if after == nil || after.ForeignID != target.ForeignID || after.Name != target.Name { + t.Fatalf("a rejected relink changed the author: %+v", after) + } + }) + } + + t.Run("repo error is 500", func(t *testing.T) { + env := covAuSetup(t) + h := covAuHandler(env, nil) + _ = env.database.Close() + rec := httptest.NewRecorder() + h.RelinkUpstream(rec, covAuRequest(http.MethodPost, "/", "", map[string]string{"id": "1"}, 0, "")) + if rec.Code != http.StatusInternalServerError { + t.Fatalf("got %d %s", rec.Code, rec.Body.String()) + } + }) +} + +func TestAuthorRelinkCandidatesCoverage(t *testing.T) { + auth.SetEnforceTenancyForTests(t, true) + call := func(h *AuthorHandler, id string, user int64) *httptest.ResponseRecorder { + rec := httptest.NewRecorder() + h.RelinkCandidates(rec, covAuRequest(http.MethodGet, "/", "", map[string]string{"id": id}, user, "user")) + return rec + } + + env := covAuSetup(t) + if rec := call(covAuHandler(env, nil), "x", env.alice); rec.Code != http.StatusBadRequest { + t.Fatalf("bad id: %d", rec.Code) + } + if rec := call(covAuHandler(env, nil), "9999", env.alice); rec.Code != http.StatusNotFound { + t.Fatalf("missing: %d", rec.Code) + } + if rec := call(covAuHandler(env, nil), covAuID(env.aliceA.ID), env.bob); rec.Code != http.StatusNotFound { + t.Fatalf("cross-user: %d %s", rec.Code, rec.Body.String()) + } + if rec := call(covAuHandler(env, nil), covAuID(env.aliceA.ID), env.alice); rec.Code != http.StatusFailedDependency { + t.Fatalf("no aggregator: %d %s", rec.Code, rec.Body.String()) + } + failing := metadata.NewAggregator(&covAuSearchProvider{searchErr: errors.New("search down")}) + if rec := call(covAuHandler(env, failing), covAuID(env.aliceA.ID), env.alice); rec.Code != http.StatusBadGateway || !strings.Contains(covAuErrorBody(t, rec), "search down") { + t.Fatalf("search error: %d %s", rec.Code, rec.Body.String()) + } + empty := metadata.NewAggregator(&covAuSearchProvider{}) + rec := call(covAuHandler(env, empty), covAuID(env.aliceA.ID), env.alice) + if rec.Code != http.StatusOK || strings.TrimSpace(rec.Body.String()) != "[]" { + t.Fatalf("no candidates: %d %q", rec.Code, rec.Body.String()) + } + + _ = env.database.Close() + if rec := call(covAuHandler(env, empty), covAuID(env.aliceA.ID), env.alice); rec.Code != http.StatusInternalServerError { + t.Fatalf("closed db: %d %s", rec.Code, rec.Body.String()) + } +} diff --git a/internal/api/backup_coverage_test.go b/internal/api/backup_coverage_test.go new file mode 100644 index 000000000..df6101edf --- /dev/null +++ b/internal/api/backup_coverage_test.go @@ -0,0 +1,165 @@ +package api + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" +) + +// TestBackupListCoverage pins the listing contract: a missing backups +// directory is an empty list, not an error, and only regular *.db files are +// reported (sidecars, staging files and subdirectories are skipped). +func TestBackupListCoverage(t *testing.T) { + t.Run("missing directory is empty list", func(t *testing.T) { + h := NewBackupHandler(nil, "", t.TempDir()) + rec := httptest.NewRecorder() + h.List(rec, httptest.NewRequest(http.MethodGet, "/api/v1/backup", nil)) + if rec.Code != http.StatusOK { + t.Fatalf("status = %d body=%s", rec.Code, rec.Body.String()) + } + if strings.TrimSpace(rec.Body.String()) != "[]" { + t.Fatalf("body = %q, want []", rec.Body.String()) + } + }) + + t.Run("empty directory is empty list", func(t *testing.T) { + dataDir := t.TempDir() + if err := os.MkdirAll(filepath.Join(dataDir, "backups"), 0o700); err != nil { + t.Fatal(err) + } + h := NewBackupHandler(nil, "", dataDir) + rec := httptest.NewRecorder() + h.List(rec, httptest.NewRequest(http.MethodGet, "/api/v1/backup", nil)) + if rec.Code != http.StatusOK || strings.TrimSpace(rec.Body.String()) != "[]" { + t.Fatalf("status = %d body = %q, want 200 []", rec.Code, rec.Body.String()) + } + }) + + t.Run("filters non-db entries", func(t *testing.T) { + dataDir := t.TempDir() + dir := filepath.Join(dataDir, "backups") + if err := os.MkdirAll(filepath.Join(dir, "nested.db"), 0o700); err != nil { + t.Fatal(err) + } + for name, body := range map[string]string{ + "bindery_20260101_000000.db": "abc", + "bindery_20260101_000000.db.tmp": "x", + "notes.txt": "x", + } { + if err := os.WriteFile(filepath.Join(dir, name), []byte(body), 0o600); err != nil { + t.Fatal(err) + } + } + h := NewBackupHandler(nil, "", dataDir) + rec := httptest.NewRecorder() + h.List(rec, httptest.NewRequest(http.MethodGet, "/api/v1/backup", nil)) + if rec.Code != http.StatusOK { + t.Fatalf("status = %d body=%s", rec.Code, rec.Body.String()) + } + var got []struct { + Name string `json:"name"` + Size int64 `json:"size"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil { + t.Fatal(err) + } + if len(got) != 1 || got[0].Name != "bindery_20260101_000000.db" || got[0].Size != 3 { + t.Fatalf("listing = %+v, want only the one .db file with size 3", got) + } + }) + + t.Run("unreadable path is 500", func(t *testing.T) { + dataDir := t.TempDir() + // backups exists but is a file, so ReadDir fails with ENOTDIR. + if err := os.WriteFile(filepath.Join(dataDir, "backups"), []byte("x"), 0o600); err != nil { + t.Fatal(err) + } + h := NewBackupHandler(nil, "", dataDir) + rec := httptest.NewRecorder() + h.List(rec, httptest.NewRequest(http.MethodGet, "/api/v1/backup", nil)) + if rec.Code != http.StatusInternalServerError { + t.Fatalf("status = %d, want 500", rec.Code) + } + }) +} + +// TestBackupCreateCoverageFailures covers the failure branches: a backups path +// that cannot be created and a handler without a database. Neither may leave a +// file that List would later report as a backup. +func TestBackupCreateCoverageFailures(t *testing.T) { + t.Run("backup dir cannot be created", func(t *testing.T) { + dataDir := t.TempDir() + if err := os.WriteFile(filepath.Join(dataDir, "backups"), []byte("x"), 0o600); err != nil { + t.Fatal(err) + } + h := NewBackupHandler(nil, "", dataDir) + rec := httptest.NewRecorder() + h.Create(rec, httptest.NewRequest(http.MethodPost, "/api/v1/backup", nil)) + if rec.Code != http.StatusInternalServerError { + t.Fatalf("status = %d, want 500", rec.Code) + } + if !strings.Contains(rec.Body.String(), "failed to create backup directory") { + t.Fatalf("body = %s", rec.Body.String()) + } + }) + + t.Run("no database handle", func(t *testing.T) { + dataDir := t.TempDir() + h := NewBackupHandler(nil, "", dataDir) + rec := httptest.NewRecorder() + h.Create(rec, httptest.NewRequest(http.MethodPost, "/api/v1/backup", strings.NewReader(`{"label":"pre import"}`))) + if rec.Code != http.StatusInternalServerError { + t.Fatalf("status = %d, want 500", rec.Code) + } + entries, err := os.ReadDir(filepath.Join(dataDir, "backups")) + if err != nil { + t.Fatal(err) + } + if len(entries) != 0 { + t.Fatalf("failed backup left files behind: %v", entries) + } + }) +} + +// TestBackupDeleteAndRestoreCoverageValidation covers the filename and +// confirmation gates on the two destructive endpoints. +func TestBackupDeleteAndRestoreCoverageValidation(t *testing.T) { + dataDir := t.TempDir() + h := NewBackupHandler(nil, filepath.Join(dataDir, "bindery.db"), dataDir) + + rec := httptest.NewRecorder() + h.Delete(rec, backupURLRequest(http.MethodDelete, "bindery_missing.db")) + if rec.Code != http.StatusNotFound { + t.Fatalf("delete missing: status = %d, want 404", rec.Code) + } + + rec = httptest.NewRecorder() + h.Delete(rec, backupURLRequest(http.MethodDelete, "../bindery.db")) + if rec.Code != http.StatusBadRequest { + t.Fatalf("delete traversal: status = %d, want 400", rec.Code) + } + + rec = httptest.NewRecorder() + h.Restore(rec, backupURLRequest(http.MethodPost, "evil.db")) + if rec.Code != http.StatusBadRequest || !strings.Contains(rec.Body.String(), "invalid backup filename") { + t.Fatalf("restore bad name: status = %d body=%s", rec.Code, rec.Body.String()) + } + + rec = httptest.NewRecorder() + h.Restore(rec, backupURLRequest(http.MethodPost, "bindery_20260101_000000.db")) + if rec.Code != http.StatusBadRequest || !strings.Contains(rec.Body.String(), "X-Confirm-Restore") { + t.Fatalf("restore unconfirmed: status = %d body=%s", rec.Code, rec.Body.String()) + } + + rec = callRestore(t, h, "bindery_20260101_000000.db") + if rec.Code != http.StatusNotFound { + t.Fatalf("restore missing: status = %d, want 404", rec.Code) + } + if _, err := os.Stat(filepath.Join(dataDir, "bindery.db.restore-pending")); !os.IsNotExist(err) { + t.Fatalf("a refused restore staged a pending file: %v", err) + } +} diff --git a/internal/api/books_coverage_test.go b/internal/api/books_coverage_test.go new file mode 100644 index 000000000..cdd3100c2 --- /dev/null +++ b/internal/api/books_coverage_test.go @@ -0,0 +1,88 @@ +package api + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "strconv" + "testing" + + "github.com/vavallee/bindery/internal/auth" + "github.com/vavallee/bindery/internal/db" + "github.com/vavallee/bindery/internal/models" +) + +// TestBookToggleExcludedCoverage flips the excluded flag both ways, and under +// tenancy hides another user's book behind a 404 without touching it. +func TestBookToggleExcludedCoverage(t *testing.T) { + database, err := db.OpenMemory() + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { database.Close() }) + ctx := t.Context() + books := db.NewBookRepo(database) + authors := db.NewAuthorRepo(database) + users := db.NewUserRepo(database) + h := NewBookHandler(books, nil, db.NewHistoryRepo(database), nil) + alice, err := users.Create(ctx, "alice", "x") + if err != nil { + t.Fatal(err) + } + bob, err := users.Create(ctx, "bob", "x") + if err != nil { + t.Fatal(err) + } + owner := alice.ID + author := &models.Author{ForeignID: "OL-EXCL-A", Name: "Excl Author", SortName: "Author, Excl", MetadataProvider: "openlibrary", Monitored: true} + if err := authors.Create(ctx, author); err != nil { + t.Fatal(err) + } + book := &models.Book{ForeignID: "OL-EXCL", AuthorID: author.ID, Title: "Excludable", SortTitle: "excludable", + Status: models.BookStatusWanted, Monitored: true, OwnerUserID: owner} + if err := books.Create(ctx, book); err != nil { + t.Fatal(err) + } + toggle := func(uid int64, role, id string) *httptest.ResponseRecorder { + req := httptest.NewRequest(http.MethodPost, "/api/v1/book/"+id+"/exclude", nil) + req = req.WithContext(auth.WithUserRole(auth.WithUserID(req.Context(), uid), role)) + rec := httptest.NewRecorder() + h.ToggleExcluded(rec, withURLParam(req, "id", id)) + return rec + } + id := strconv.FormatInt(book.ID, 10) + + for _, want := range []bool{true, false} { + rec := toggle(owner, auth.RoleUser, id) + if rec.Code != http.StatusOK { + t.Fatalf("toggle: %d %s", rec.Code, rec.Body.String()) + } + var got models.Book + if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil { + t.Fatal(err) + } + stored, _ := books.GetByID(ctx, book.ID) + if got.Excluded != want || stored.Excluded != want { + t.Fatalf("excluded response=%v stored=%v, want %v", got.Excluded, stored.Excluded, want) + } + } + + if rec := toggle(owner, auth.RoleUser, "nope"); rec.Code != http.StatusBadRequest { + t.Fatalf("bad id: %d, want 400", rec.Code) + } + if rec := toggle(owner, auth.RoleUser, "999999"); rec.Code != http.StatusNotFound { + t.Fatalf("missing book: %d, want 404", rec.Code) + } + + auth.SetEnforceTenancyForTests(t, true) + if rec := toggle(bob.ID, auth.RoleUser, id); rec.Code != http.StatusNotFound { + t.Fatalf("other user's book: %d, want 404", rec.Code) + } + if stored, _ := books.GetByID(ctx, book.ID); stored.Excluded { + t.Fatal("a cross-user toggle changed the book") + } + // An admin may manage every user's library. + if rec := toggle(bob.ID, auth.RoleAdmin, id); rec.Code != http.StatusOK { + t.Fatalf("admin toggle: %d, want 200", rec.Code) + } +} diff --git a/internal/api/calibre_coverage_test.go b/internal/api/calibre_coverage_test.go new file mode 100644 index 000000000..6f41eb81a --- /dev/null +++ b/internal/api/calibre_coverage_test.go @@ -0,0 +1,70 @@ +package api + +import ( + "net/http" + "net/http/httptest" + "strings" + "sync/atomic" + "testing" +) + +// TestCalibreTestCoveragePluginRefusals covers plugin mode's failure answers +// on Test connection: no plugin URL is a 400 without any outbound call, a +// health failure is a 502, and a degraded bridge is a 502 naming the reason +// rather than the false green of "reachable". +func TestCalibreTestCoveragePluginRefusals(t *testing.T) { + t.Run("no plugin url", func(t *testing.T) { + h, repo, ctx := calibreFixture(t) + if err := repo.Set(ctx, SettingCalibreMode, "plugin"); err != nil { + t.Fatal(err) + } + code, body := testConnection(t, h) + if code != http.StatusBadRequest || body["error"] != "plugin_url is not configured" { + t.Fatalf("%d %v, want 400 plugin_url is not configured", code, body) + } + }) + + t.Run("health fails", func(t *testing.T) { + var hits atomic.Int32 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + hits.Add(1) + w.WriteHeader(http.StatusInternalServerError) + })) + t.Cleanup(srv.Close) + h, repo, ctx := calibreFixture(t) + for k, v := range map[string]string{SettingCalibreMode: "plugin", SettingCalibrePluginURL: srv.URL} { + if err := repo.Set(ctx, k, v); err != nil { + t.Fatal(err) + } + } + code, body := testConnection(t, h) + if code != http.StatusBadGateway || body["error"] == "" || body["ok"] != "" { + t.Fatalf("%d %v, want 502 with an error", code, body) + } + if hits.Load() == 0 { + t.Fatal("health was never requested") + } + }) + + t.Run("degraded bridge", func(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/v1/health" { + t.Errorf("unexpected call to %s on a degraded bridge", r.URL.Path) + w.WriteHeader(http.StatusNotFound) + return + } + _, _ = w.Write([]byte(`{"plugin_version":"0.6.2","calibre_version":"9.8","status":"degraded","error":"api_key missing"}`)) + })) + t.Cleanup(srv.Close) + h, repo, ctx := calibreFixture(t) + for k, v := range map[string]string{SettingCalibreMode: "plugin", SettingCalibrePluginURL: srv.URL} { + if err := repo.Set(ctx, k, v); err != nil { + t.Fatal(err) + } + } + code, body := testConnection(t, h) + if code != http.StatusBadGateway || !strings.Contains(body["error"], "not serving the API: api_key missing") { + t.Fatalf("%d %v, want 502 naming the degraded reason", code, body) + } + }) +} diff --git a/internal/api/download_clients_coverage_test.go b/internal/api/download_clients_coverage_test.go new file mode 100644 index 000000000..ae5d3de1c --- /dev/null +++ b/internal/api/download_clients_coverage_test.go @@ -0,0 +1,49 @@ +package api + +import ( + "bytes" + "net/http" + "net/http/httptest" + "strconv" + "strings" + "testing" + + "github.com/vavallee/bindery/internal/models" +) + +// TestDownloadClientUpdateCoverageRefusals covers the refusals on update: +// unknown id, malformed body, a credential set and cleared in one request, and +// a host the outbound guard refuses. None may touch the stored row. +func TestDownloadClientUpdateCoverageRefusals(t *testing.T) { + h, clients := downloadClientFixture(t) + c := &models.DownloadClient{Name: "SAB", Type: "sabnzbd", Host: "10.0.0.7", Port: 8080, APIKey: "sab-key", Enabled: true} + if err := clients.Create(t.Context(), c); err != nil { + t.Fatal(err) + } + idStr := strconv.FormatInt(c.ID, 10) + for _, tc := range []struct { + name, id, body string + want int + wantErr string + }{ + {"bad id", "x", `{}`, http.StatusBadRequest, ""}, + {"missing", "9999", `{}`, http.StatusNotFound, "download client not found"}, + {"bad body", idStr, `{"name":`, http.StatusBadRequest, "invalid request body"}, + {"api key and clear", idStr, `{"apiKey":"new","clearApiKey":true}`, http.StatusBadRequest, "apiKey and clearApiKey cannot both be set"}, + {"password and clear", idStr, `{"password":"pw","clearPassword":true}`, http.StatusBadRequest, "password and clearPassword cannot both be set"}, + {"clear flag wrong type", idStr, `{"clearApiKey":"yes"}`, http.StatusBadRequest, ""}, + {"link local host", idStr, `{"host":"169.254.169.254"}`, http.StatusBadRequest, "link-local"}, + } { + t.Run(tc.name, func(t *testing.T) { + rec := httptest.NewRecorder() + h.Update(rec, withURLParam(httptest.NewRequest(http.MethodPut, "/api/v1/downloadclient/"+tc.id, bytes.NewBufferString(tc.body)), "id", tc.id)) + if rec.Code != tc.want || !strings.Contains(rec.Body.String(), tc.wantErr) { + t.Fatalf("%d %s, want %d %q", rec.Code, rec.Body.String(), tc.want, tc.wantErr) + } + got, _ := clients.GetByID(t.Context(), c.ID) + if got.Host != "10.0.0.7" || got.APIKey != "sab-key" || got.Name != "SAB" { + t.Fatalf("a refused update changed the client: %+v", got) + } + }) + } +} diff --git a/internal/api/library_coverage_test.go b/internal/api/library_coverage_test.go new file mode 100644 index 000000000..eed0d6312 --- /dev/null +++ b/internal/api/library_coverage_test.go @@ -0,0 +1,48 @@ +package api + +import ( + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/vavallee/bindery/internal/db" +) + +// TestLibraryScanStatusCoverage: 404 until a scan result is stored (and when +// the handler has no settings at all), then the stored JSON verbatim. +func TestLibraryScanStatusCoverage(t *testing.T) { + get := func(h *LibraryHandler) *httptest.ResponseRecorder { + rec := httptest.NewRecorder() + h.ScanStatus(rec, httptest.NewRequest(http.MethodGet, "/api/v1/library/scan/status", nil)) + return rec + } + if rec := get(NewLibraryHandler(nil)); rec.Code != http.StatusNotFound { + t.Fatalf("no settings: %d, want 404", rec.Code) + } + + database, err := db.OpenMemory() + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { database.Close() }) + settings := db.NewSettingsRepo(database) + h := NewLibraryHandler(nil).WithSettings(settings) + + if rec := get(h); rec.Code != http.StatusNotFound || !strings.Contains(rec.Body.String(), "no scan result available") { + t.Fatalf("before any scan: %d %s, want 404", rec.Code, rec.Body.String()) + } + const stored = `{"filesFound":3,"matched":2}` + if err := settings.Set(t.Context(), "library.lastScan", stored); err != nil { + t.Fatal(err) + } + rec := get(h) + if rec.Code != http.StatusOK || rec.Body.String() != stored || rec.Header().Get("Content-Type") != "application/json" { + t.Fatalf("after scan: %d %q %q", rec.Code, rec.Body.String(), rec.Header().Get("Content-Type")) + } + + database.Close() + if rec := get(h); rec.Code != http.StatusInternalServerError { + t.Fatalf("settings error: %d, want 500", rec.Code) + } +} diff --git a/internal/api/metadata_profiles_coverage_test.go b/internal/api/metadata_profiles_coverage_test.go new file mode 100644 index 000000000..e50a63907 --- /dev/null +++ b/internal/api/metadata_profiles_coverage_test.go @@ -0,0 +1,128 @@ +package api + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "strconv" + "strings" + "testing" + + "github.com/vavallee/bindery/internal/auth" + "github.com/vavallee/bindery/internal/db" + "github.com/vavallee/bindery/internal/models" +) + +// TestMetadataProfileUpdateCoverage covers id, existence, ownership and body +// checks on update, and the unknown-language normalisation on a valid write. +func TestMetadataProfileUpdateCoverage(t *testing.T) { + database, err := db.OpenMemory() + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { database.Close() }) + ctx := t.Context() + repo := db.NewMetadataProfileRepo(database) + users := db.NewUserRepo(database) + h := NewMetadataProfileHandler(repo) + alice, err := users.Create(ctx, "alice", "x") + if err != nil { + t.Fatal(err) + } + bob, err := users.Create(ctx, "bob", "x") + if err != nil { + t.Fatal(err) + } + p := &models.MetadataProfile{Name: "Alice Profile", AllowedLanguages: "eng"} + if err := repo.CreateForUser(ctx, p, alice.ID); err != nil { + t.Fatal(err) + } + idStr := strconv.FormatInt(p.ID, 10) + update := func(uid int64, id, body string) *httptest.ResponseRecorder { + req := httptest.NewRequest(http.MethodPut, "/api/v1/metadataprofile/"+id, bytes.NewBufferString(body)) + req = req.WithContext(auth.WithUserRole(auth.WithUserID(req.Context(), uid), auth.RoleUser)) + rec := httptest.NewRecorder() + h.Update(rec, withURLParam(req, "id", id)) + return rec + } + unchanged := func(t *testing.T) { + t.Helper() + got, _ := repo.GetByID(ctx, p.ID) + if got.Name != "Alice Profile" { + t.Fatalf("a refused update changed the profile: %+v", got) + } + } + + auth.SetEnforceTenancyForTests(t, true) + for _, c := range []struct { + name string + uid int64 + id string + body string + want int + }{ + {"bad id", alice.ID, "x", `{"name":"N"}`, http.StatusBadRequest}, + {"missing", alice.ID, "9999", `{"name":"N"}`, http.StatusNotFound}, + {"other user", bob.ID, idStr, `{"name":"Hijacked"}`, http.StatusNotFound}, + {"bad body", alice.ID, idStr, `{"name":`, http.StatusBadRequest}, + } { + t.Run(c.name, func(t *testing.T) { + rec := update(c.uid, c.id, c.body) + if rec.Code != c.want { + t.Fatalf("%d %s, want %d", rec.Code, rec.Body.String(), c.want) + } + unchanged(t) + }) + } + + rec := update(alice.ID, idStr, `{"name":"Renamed","allowedLanguages":"eng","unknownLanguageBehavior":"bogus"}`) + if rec.Code != http.StatusOK { + t.Fatalf("owner update: %d %s", rec.Code, rec.Body.String()) + } + var got models.MetadataProfile + if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil { + t.Fatal(err) + } + if got.Name != "Renamed" || got.UnknownLanguageBehavior != models.UnknownLanguagePass { + t.Fatalf("response = %+v, want renamed with unknown language pass", got) + } + stored, _ := repo.GetByID(ctx, p.ID) + if stored.Name != "Renamed" || stored.UnknownLanguageBehavior != models.UnknownLanguagePass { + t.Fatalf("stored = %+v", stored) + } +} + +// TestIndexerUpdateCoverageRefusals covers the refusals on indexer update; a +// refused update must not change the stored row or its key. +func TestIndexerUpdateCoverageRefusals(t *testing.T) { + h := indexerFixture(t) + idx := &models.Indexer{Name: "Keep", Type: "newznab", URL: "http://10.0.0.9/api", APIKey: "secret-key", Enabled: true} + if err := h.indexers.Create(t.Context(), idx); err != nil { + t.Fatal(err) + } + idStr := strconv.FormatInt(idx.ID, 10) + for _, c := range []struct { + name, id, body string + want int + wantErr string + }{ + {"bad id", "x", `{}`, http.StatusBadRequest, ""}, + {"missing", "9999", `{}`, http.StatusNotFound, "indexer not found"}, + {"bad body", idStr, `{"name":`, http.StatusBadRequest, "invalid request body"}, + {"key and clear", idStr, `{"apiKey":"k2","clearApiKey":true}`, http.StatusBadRequest, "apiKey and clearApiKey cannot both be set"}, + {"bad url", idStr, `{"url":"gopher://10.0.0.9/"}`, http.StatusBadRequest, "scheme must be http or https"}, + } { + t.Run(c.name, func(t *testing.T) { + rec := httptest.NewRecorder() + h.Update(rec, withURLParam(httptest.NewRequest(http.MethodPut, "/api/v1/indexer/"+c.id, bytes.NewBufferString(c.body)), "id", c.id)) + if rec.Code != c.want || !strings.Contains(rec.Body.String(), c.wantErr) { + t.Fatalf("%d %s, want %d %q", rec.Code, rec.Body.String(), c.want, c.wantErr) + } + got, _ := h.indexers.GetByID(t.Context(), idx.ID) + if got.Name != "Keep" || got.APIKey != "secret-key" || got.URL != "http://10.0.0.9/api" { + t.Fatalf("a refused update changed the indexer: %+v", got) + } + }) + } +} diff --git a/internal/api/migrate_coverage_test.go b/internal/api/migrate_coverage_test.go new file mode 100644 index 000000000..ea7ce6fca --- /dev/null +++ b/internal/api/migrate_coverage_test.go @@ -0,0 +1,71 @@ +package api + +import ( + "bytes" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +// TestMigrateGoodreadsCoverageRefusals covers the upload and body refusals on +// the Goodreads preview and commit endpoints. +func TestMigrateGoodreadsCoverageRefusals(t *testing.T) { + h := migrateFixture(t, &stubProvider{}) + + preview := func(body *bytes.Buffer, ct string) *httptest.ResponseRecorder { + req := httptest.NewRequest(http.MethodPost, "/api/v1/migrate/goodreads/preview", body) + req.Header.Set("Content-Type", ct) + rec := httptest.NewRecorder() + h.ImportGoodreadsPreview(rec, req) + return rec + } + + // Not multipart at all. + if rec := preview(bytes.NewBufferString(`{"x":1}`), "application/json"); rec.Code != http.StatusBadRequest { + t.Fatalf("non-multipart: %d, want 400", rec.Code) + } + // Multipart without the "file" field. + body, ct := multipartBody(t, "upload", "export.csv", "Title,Author\nDune,Frank Herbert\n") + if rec := preview(body, ct); rec.Code != http.StatusBadRequest { + t.Fatalf("missing file field: %d, want 400", rec.Code) + } + // A valid header with no data rows. + body, ct = multipartBody(t, "file", "export.csv", "Title,Author\n") + rec := preview(body, ct) + if rec.Code != http.StatusBadRequest || !strings.Contains(rec.Body.String(), "no usable rows") { + t.Fatalf("header only: %d %s, want 400 no usable rows", rec.Code, rec.Body.String()) + } + + commit := func(body string) *httptest.ResponseRecorder { + req := httptest.NewRequest(http.MethodPost, "/api/v1/migrate/goodreads/commit", strings.NewReader(body)) + rec := httptest.NewRecorder() + h.ImportGoodreadsCommit(rec, req) + return rec + } + if rec := commit(`{"token":`); rec.Code != http.StatusBadRequest || !strings.Contains(rec.Body.String(), "invalid request body") { + t.Fatalf("bad commit body: %d %s", rec.Code, rec.Body.String()) + } + if rec := commit(`{"token":" "}`); rec.Code != http.StatusBadRequest || !strings.Contains(rec.Body.String(), "token is required") { + t.Fatalf("blank token: %d %s", rec.Code, rec.Body.String()) + } +} + +// TestMigrateReadarrCoverageMissingFile: an upload without the "file" field +// is refused before anything is spooled or started. +func TestMigrateReadarrCoverageMissingFile(t *testing.T) { + h := migrateFixture(t, &stubProvider{}) + body, ct := multipartBody(t, "notfile", "readarr.db", "x") + req := httptest.NewRequest(http.MethodPost, "/api/v1/migrate/readarr", body) + req.Header.Set("Content-Type", ct) + rec := httptest.NewRecorder() + h.ImportReadarr(rec, req) + if rec.Code != http.StatusBadRequest { + t.Fatalf("missing file: %d %s, want 400", rec.Code, rec.Body.String()) + } + status := httptest.NewRecorder() + h.ImportReadarrStatus(status, httptest.NewRequest(http.MethodGet, "/api/v1/migrate/readarr/status", nil)) + if status.Code != http.StatusOK || strings.Contains(status.Body.String(), `"running":true`) { + t.Fatalf("status after refused upload: %d %s", status.Code, status.Body.String()) + } +} diff --git a/internal/api/oidc_coverage_test.go b/internal/api/oidc_coverage_test.go new file mode 100644 index 000000000..ff6dad428 --- /dev/null +++ b/internal/api/oidc_coverage_test.go @@ -0,0 +1,111 @@ +package api + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/go-chi/chi/v5" + + "github.com/vavallee/bindery/internal/auth/oidc" + "github.com/vavallee/bindery/internal/db" +) + +func covOIDCSettings(t *testing.T) (*db.SettingsRepo, func()) { + t.Helper() + database, err := db.OpenMemory() + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { database.Close() }) + return db.NewSettingsRepo(database), func() { database.Close() } +} + +// TestOIDCGetProvidersCoverage: no configuration lists nothing, a stored +// configuration lists every provider with its runtime status and never the +// client secret, and an unparsable setting is a 500 rather than a silent +// empty list. +func TestOIDCGetProvidersCoverage(t *testing.T) { + settings, closeDB := covOIDCSettings(t) + h := NewOIDCHandler(oidc.NewManager(), nil, settings, nil, nil) + get := func() *httptest.ResponseRecorder { + rec := httptest.NewRecorder() + h.GetProviders(rec, httptest.NewRequest(http.MethodGet, "/api/v1/auth/oidc/providers", nil)) + return rec + } + + rec := get() + if rec.Code != http.StatusOK || strings.TrimSpace(rec.Body.String()) != "[]" { + t.Fatalf("unconfigured: %d %q, want 200 []", rec.Code, rec.Body.String()) + } + + stored := `[{"id":"authelia","name":"Authelia","issuer":"https://auth.example.invalid","client_id":"bindery","client_secret":"s3cr3t-value","scopes":["openid","email"]}]` + if err := settings.Set(t.Context(), SettingOIDCProviders, stored); err != nil { + t.Fatal(err) + } + rec = get() + if rec.Code != http.StatusOK { + t.Fatalf("configured: %d %s", rec.Code, rec.Body.String()) + } + if strings.Contains(rec.Body.String(), "s3cr3t-value") || strings.Contains(rec.Body.String(), "client_secret") { + t.Fatalf("provider listing leaked the client secret: %s", rec.Body.String()) + } + var got []providerWithStatus + if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil { + t.Fatal(err) + } + if len(got) != 1 || got[0].ID != "authelia" || got[0].Name != "Authelia" || got[0].ClientID != "bindery" || len(got[0].Scopes) != 2 { + t.Fatalf("providers = %+v", got) + } + + if err := settings.Set(t.Context(), SettingOIDCProviders, `{not json`); err != nil { + t.Fatal(err) + } + if rec := get(); rec.Code != http.StatusInternalServerError { + t.Fatalf("corrupt setting: %d, want 500", rec.Code) + } + + closeDB() + // A settings read failure is treated as unconfigured. + if rec := get(); rec.Code != http.StatusOK || strings.TrimSpace(rec.Body.String()) != "[]" { + t.Fatalf("settings error: %d %q, want 200 []", rec.Code, rec.Body.String()) + } +} + +// TestOIDCLoginCoverageRefusals: a malformed provider id and a provider the +// manager does not know are both 400, and neither starts a flow (no cookie, +// no redirect). +func TestOIDCLoginCoverageRefusals(t *testing.T) { + settings, _ := covOIDCSettings(t) + h := NewOIDCHandler(oidc.NewManager(), nil, settings, nil, func(*http.Request) string { return "https://bindery.example.invalid" }) + login := func(provider string) *httptest.ResponseRecorder { + req := httptest.NewRequest(http.MethodGet, "/api/v1/auth/oidc/x/login", nil) + rctx := chi.NewRouteContext() + rctx.URLParams.Add("provider", provider) + req = req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, rctx)) + rec := httptest.NewRecorder() + h.Login(rec, req) + return rec + } + for _, c := range []struct { + provider, wantErr string + }{ + {"Bad.ID", "invalid provider id"}, + {strings.Repeat("a", 33), "invalid provider id"}, + {"unknown", ""}, + } { + rec := login(c.provider) + if rec.Code != http.StatusBadRequest { + t.Fatalf("%q: %d %s, want 400", c.provider, rec.Code, rec.Body.String()) + } + if c.wantErr != "" && !strings.Contains(rec.Body.String(), c.wantErr) { + t.Fatalf("%q: body %s, want %q", c.provider, rec.Body.String(), c.wantErr) + } + if len(rec.Result().Cookies()) != 0 || rec.Header().Get("Location") != "" { + t.Fatalf("%q: refused login started a flow: cookies=%v location=%q", c.provider, rec.Result().Cookies(), rec.Header().Get("Location")) + } + } +} diff --git a/internal/api/opds_coverage_test.go b/internal/api/opds_coverage_test.go new file mode 100644 index 000000000..8d74864a0 --- /dev/null +++ b/internal/api/opds_coverage_test.go @@ -0,0 +1,42 @@ +package api + +import ( + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +// TestOPDSSeriesCoverage covers the series navigation feed and the single +// series feed's id and existence checks, all behind the OPDS auth middleware. +func TestOPDSSeriesCoverage(t *testing.T) { + r, _, _, apiKey := opdsFixture(t) + + body := doOK(t, r, "/opds/series", apiKey) + if !strings.Contains(body, "`)) + })) + defer okSrv.Close() + badSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusUnauthorized) + })) + defer badSrv.Close() + + h := indexerFixture(t) + save := func(url string) string { + idx := &models.Indexer{Name: "idx " + url, Type: "newznab", URL: url, APIKey: "k", Enabled: true} + if err := h.indexers.Create(t.Context(), idx); err != nil { + t.Fatal(err) + } + return strconv.FormatInt(idx.ID, 10) + } + probe := func(id string) (*httptest.ResponseRecorder, IndexerTestResponse) { + rec := httptest.NewRecorder() + h.Test(rec, withURLParam(httptest.NewRequest(http.MethodPost, "/api/v1/indexer/"+id+"/test", nil), "id", id)) + var out IndexerTestResponse + _ = json.Unmarshal(rec.Body.Bytes(), &out) + return rec, out + } + + if rec, _ := probe("x"); rec.Code != http.StatusBadRequest { + t.Fatalf("bad id: %d, want 400", rec.Code) + } + if rec, _ := probe("4242"); rec.Code != http.StatusNotFound { + t.Fatalf("missing indexer: %d, want 404", rec.Code) + } + + blocked := save("ftp://indexer.invalid/api") + if rec, _ := probe(blocked); rec.Code != http.StatusBadRequest || !strings.Contains(rec.Body.String(), "scheme must be http or https") { + t.Fatalf("blocked url: %d %s, want 400", rec.Code, rec.Body.String()) + } + + rec, out := probe(save(okSrv.URL)) + if rec.Code != http.StatusOK || !out.OK || out.Message != "ok" || out.Error != "" { + t.Fatalf("good indexer: %d %+v", rec.Code, out) + } + if hits.Load() == 0 { + t.Fatal("probe never reached the indexer") + } + + rec, out = probe(save(badSrv.URL)) + if rec.Code != http.StatusOK || out.OK || out.Error == "" { + t.Fatalf("failing indexer: %d %+v, want 200 with inline error", rec.Code, out) + } +} + +// TestUserManagementResetPasswordCoverage covers the admin reset: id, body +// and length refusals leave the hash alone, a valid reset replaces it. +func TestUserManagementResetPasswordCoverage(t *testing.T) { + h, users := newUserMgmtFixture(t) + ctx := t.Context() + oldHash, err := auth.HashPassword("old-password-1") + if err != nil { + t.Fatal(err) + } + u, err := users.Create(ctx, "carol", oldHash) + if err != nil { + t.Fatal(err) + } + id := strconv.FormatInt(u.ID, 10) + reset := func(id, body string) *httptest.ResponseRecorder { + rec := httptest.NewRecorder() + h.ResetPassword(rec, withURLParam(httptest.NewRequest(http.MethodPut, "/api/v1/auth/users/"+id+"/reset-password", strings.NewReader(body)), "id", id)) + return rec + } + + for _, c := range []struct { + name, id, body string + }{ + {"bad id", "zz", `{"password":"long-enough-pw"}`}, + {"bad body", id, `{"password":`}, + {"short password", id, `{"password":"short"}`}, + } { + if rec := reset(c.id, c.body); rec.Code != http.StatusBadRequest { + t.Fatalf("%s: %d %s, want 400", c.name, rec.Code, rec.Body.String()) + } + } + if got, _ := users.GetByID(ctx, u.ID); got.PasswordHash != oldHash { + t.Fatal("a refused reset changed the hash") + } + + if rec := reset(id, `{"password":"a-new-password-9"}`); rec.Code != http.StatusOK { + t.Fatalf("reset: %d %s", rec.Code, rec.Body.String()) + } + got, _ := users.GetByID(ctx, u.ID) + if !auth.VerifyPassword("a-new-password-9", got.PasswordHash) || auth.VerifyPassword("old-password-1", got.PasswordHash) { + t.Fatal("reset did not replace the password") + } +} diff --git a/internal/api/prowlarr_coverage_test.go b/internal/api/prowlarr_coverage_test.go new file mode 100644 index 000000000..f16db64e3 --- /dev/null +++ b/internal/api/prowlarr_coverage_test.go @@ -0,0 +1,88 @@ +package api + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "strconv" + "strings" + "testing" + + "github.com/vavallee/bindery/internal/models" +) + +// TestProwlarrUpdateCoverage covers the refusals on update (none may write) +// and the write-only key contract: a blank key keeps the stored one and leaves +// synced indexers alone, clearApiKey wipes it and cascades, and the response +// never carries the key. +func TestProwlarrUpdateCoverage(t *testing.T) { + h, instances, indexers, _ := prowlarrFixture(t) + ctx := t.Context() + inst := &models.ProwlarrInstance{Name: "P", URL: "http://10.0.0.5:9696", APIKey: "stored-key", Enabled: true} + if err := instances.Create(ctx, inst); err != nil { + t.Fatal(err) + } + idx := &models.Indexer{Name: "Synced", Type: "torznab", URL: "http://10.0.0.5:9696/1/api", APIKey: "stored-key", Enabled: true, ProwlarrInstanceID: &inst.ID} + if err := indexers.Create(ctx, idx); err != nil { + t.Fatal(err) + } + idStr := strconv.FormatInt(inst.ID, 10) + update := func(id, body string) *httptest.ResponseRecorder { + rec := httptest.NewRecorder() + h.Update(rec, withURLParam(httptest.NewRequest(http.MethodPut, "/prowlarr/"+id, bytes.NewBufferString(body)), "id", id)) + return rec + } + assertStored := func(t *testing.T, wantName, wantKey string) { + t.Helper() + got, _ := instances.GetByID(ctx, inst.ID) + if got.Name != wantName || got.APIKey != wantKey { + t.Fatalf("instance = name %q key %q, want %q %q", got.Name, got.APIKey, wantName, wantKey) + } + rows, _ := indexers.ListByProwlarrInstance(ctx, inst.ID) + if len(rows) != 1 || rows[0].APIKey != wantKey { + t.Fatalf("synced indexer key = %+v, want %q", rows, wantKey) + } + } + + for _, c := range []struct { + name, id, body, wantErr string + }{ + {"bad id", "x", `{}`, "invalid id"}, + {"bad json", idStr, `{"name":`, "invalid JSON"}, + {"wrong type", idStr, `{"apiKey":5}`, "invalid JSON"}, + {"key and clear", idStr, `{"name":"Q","apiKey":"new","clearApiKey":true}`, "apiKey and clearApiKey cannot both be set"}, + {"bad url", idStr, `{"name":"Q","url":"file:///etc/passwd"}`, "invalid indexer URL"}, + } { + t.Run(c.name, func(t *testing.T) { + rec := update(c.id, c.body) + if rec.Code != http.StatusBadRequest || !strings.Contains(rec.Body.String(), c.wantErr) { + t.Fatalf("%d %s, want 400 %q", rec.Code, rec.Body.String(), c.wantErr) + } + assertStored(t, "P", "stored-key") + }) + } + + // The web app sends the redacted (blank) key back on every save. + rec := update(idStr, `{"name":"Renamed","url":"http://10.0.0.5:9696","apiKey":"","enabled":true}`) + if rec.Code != http.StatusOK { + t.Fatalf("rename: %d %s", rec.Code, rec.Body.String()) + } + if strings.Contains(rec.Body.String(), "stored-key") { + t.Fatalf("response leaked the api key: %s", rec.Body.String()) + } + var resp models.ProwlarrInstance + if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { + t.Fatal(err) + } + if !resp.APIKeyConfigured || resp.Name != "Renamed" { + t.Fatalf("response = %+v, want renamed with key configured", resp) + } + assertStored(t, "Renamed", "stored-key") + + rec = update(idStr, `{"name":"Renamed","url":"http://10.0.0.5:9696","clearApiKey":true,"enabled":true}`) + if rec.Code != http.StatusOK { + t.Fatalf("clear: %d %s", rec.Code, rec.Body.String()) + } + assertStored(t, "Renamed", "") +} diff --git a/internal/api/queue_coverage_test.go b/internal/api/queue_coverage_test.go new file mode 100644 index 000000000..1272fe2d4 --- /dev/null +++ b/internal/api/queue_coverage_test.go @@ -0,0 +1,477 @@ +package api + +import ( + "context" + "database/sql" + "encoding/json" + "net/http" + "net/http/httptest" + "strconv" + "strings" + "testing" + "time" + + "github.com/vavallee/bindery/internal/auth" + "github.com/vavallee/bindery/internal/db" + "github.com/vavallee/bindery/internal/downloader" + "github.com/vavallee/bindery/internal/httpsec" + "github.com/vavallee/bindery/internal/models" +) + +// covQIDRequest builds a request carrying a chi {id} param. +func covQIDRequest(method, target, id string, body string) *http.Request { + var r *http.Request + if body == "" { + r = httptest.NewRequest(method, target, nil) + } else { + r = httptest.NewRequest(method, target, strings.NewReader(body)) + } + return withURLParam(r, "id", id) +} + +// covQAsUser scopes a request to a non-admin user. +func covQAsUser(r *http.Request, uid int64) *http.Request { + ctx := auth.WithUserRole(auth.WithUserID(r.Context(), uid), "user") + return r.WithContext(ctx) +} + +func covQErrorBody(t *testing.T, rec *httptest.ResponseRecorder) string { + t.Helper() + var m map[string]string + if err := json.Unmarshal(rec.Body.Bytes(), &m); err != nil { + t.Fatalf("decode error body %q: %v", rec.Body.String(), err) + } + return m["error"] +} + +func TestQueueSortArrQueueRecordsByEveryKey(t *testing.T) { + base := []arrQueueRecord{ + {ID: 2, Title: "beta", Status: "queued", Size: 20, SizeLeft: 5, DownloadClient: "Zed", Protocol: "usenet"}, + {ID: 1, Title: "Alpha", Status: "completed", Size: 30, SizeLeft: 1, DownloadClient: "alpha", Protocol: "torrent"}, + {ID: 3, Title: "gamma", Status: "paused", Size: 10, SizeLeft: 9, DownloadClient: "mid", Protocol: "usenet"}, + } + ids := func(rs []arrQueueRecord) []int64 { + out := make([]int64, len(rs)) + for i, r := range rs { + out[i] = r.ID + } + return out + } + cases := []struct { + key, dir string + want []int64 + }{ + {"id", "ascending", []int64{1, 2, 3}}, + {"ID", "desc", []int64{3, 2, 1}}, + {"title", "", []int64{1, 2, 3}}, + {"status", "", []int64{1, 3, 2}}, + {"size", "descending", []int64{1, 2, 3}}, + {"sizeleft", "", []int64{1, 2, 3}}, + {"downloadClient", "", []int64{1, 3, 2}}, + {"protocol", "", []int64{1, 2, 3}}, + // An unknown key compares nothing, so the stable sort keeps input order. + {"bogus", "", []int64{2, 1, 3}}, + // No key leaves the slice untouched. + {"", "descending", []int64{2, 1, 3}}, + } + for _, c := range cases { + rs := append([]arrQueueRecord(nil), base...) + sortArrQueueRecords(rs, c.key, c.dir) + got := ids(rs) + for i := range got { + if got[i] != c.want[i] { + t.Errorf("sort %q %q = %v, want %v", c.key, c.dir, got, c.want) + break + } + } + } +} + +func TestQueuePaginateArrQueueRecordsEdges(t *testing.T) { + recs := []arrQueueRecord{{ID: 1}, {ID: 2}, {ID: 3}} + if got := paginateArrQueueRecords(recs, 1, 0); len(got) != 3 { + t.Errorf("pageSize 0 should return everything, got %d", len(got)) + } + if got := paginateArrQueueRecords(recs, 0, 2); len(got) != 2 || got[0].ID != 1 { + t.Errorf("page 0 should be treated as page 1, got %+v", got) + } + if got := paginateArrQueueRecords(nil, 1, 2); got == nil || len(got) != 0 { + t.Errorf("empty input should be a non-nil empty page, got %#v", got) + } + if got := paginateArrQueueRecords(recs, 2, 2); len(got) != 1 || got[0].ID != 3 { + t.Errorf("last partial page = %+v, want [3]", got) + } + if got := paginateArrQueueRecords(recs, 5, 2); got == nil || len(got) != 0 { + t.Errorf("page past the end should be an empty page, got %#v", got) + } +} + +func TestQueueSizeLeftFromPercentage(t *testing.T) { + cases := []struct { + size int64 + pct string + want int64 + wantOK bool + }{ + {0, "50", 0, false}, + {100, "", 0, false}, + {100, " % ", 0, false}, + {100, "abc", 0, false}, + {100, "0", 100, true}, + {100, "-5", 100, true}, + {100, "100%", 0, true}, + {100, "150", 0, true}, + {200, "25%", 150, true}, + {1000, " 33.3 ", 667, true}, + } + for _, c := range cases { + got, ok := sizeLeftFromPercentage(c.size, c.pct) + if got != c.want || ok != c.wantOK { + t.Errorf("sizeLeftFromPercentage(%d, %q) = (%d, %v), want (%d, %v)", c.size, c.pct, got, ok, c.want, c.wantOK) + } + } +} + +func TestQueueItemSizeLeftPrefersLiveData(t *testing.T) { + dl := models.Download{Size: 400, Status: models.StateDownloading} + cases := []struct { + name string + item enrichedQueueItem + want int64 + }{ + {"live size left", enrichedQueueItem{Download: dl, HasLive: true, Live: downloader.LiveStatus{SizeLeft: 7}}, 7}, + {"live size with nothing left", enrichedQueueItem{Download: dl, HasLive: true, Live: downloader.LiveStatus{Size: 400}}, 0}, + {"live percentage only", enrichedQueueItem{Download: dl, HasLive: true, Live: downloader.LiveStatus{Percentage: "75%"}}, 100}, + {"live with nothing usable falls back to stored size", enrichedQueueItem{Download: dl, HasLive: true}, 400}, + {"no live, downloading", enrichedQueueItem{Download: dl}, 400}, + {"no live, completed", enrichedQueueItem{Download: models.Download{Size: 400, Status: models.StateCompleted}}, 0}, + {"no live, import failed", enrichedQueueItem{Download: models.Download{Size: 400, Status: models.StateImportFailed}}, 0}, + } + for _, c := range cases { + if got := queueItemSizeLeft(c.item); got != c.want { + t.Errorf("%s: sizeLeft = %d, want %d", c.name, got, c.want) + } + } +} + +// covQClosedQueue returns a queue handler whose database is already closed, +// so every repository call fails. +func covQClosedQueue(t *testing.T) *QueueHandler { + t.Helper() + h, database, _, _, _, _ := queueFixture(t) + if err := database.Close(); err != nil { + t.Fatal(err) + } + return h +} + +func TestQueueHandlersRejectBadIDs(t *testing.T) { + h, _, _, _, _, _ := queueFixture(t) + for name, fn := range map[string]http.HandlerFunc{ + "RetryImport": h.RetryImport, + "RetryDownload": h.RetryDownload, + "Delete": h.Delete, + } { + rec := httptest.NewRecorder() + fn(rec, covQIDRequest(http.MethodPost, "/api/v1/queue/abc", "abc", "")) + if rec.Code != http.StatusBadRequest { + t.Errorf("%s with a non-numeric id: status %d, want 400", name, rec.Code) + } + } +} + +func TestQueueHandlersSurfaceRepositoryErrors(t *testing.T) { + h := covQClosedQueue(t) + cases := map[string]struct { + fn http.HandlerFunc + req *http.Request + want int + }{ + "List": {h.List, httptest.NewRequest(http.MethodGet, "/api/v1/queue", nil), http.StatusInternalServerError}, + "ArrList": {h.ListArrCompatible, httptest.NewRequest(http.MethodGet, "/api/v1/queue/arr", nil), http.StatusInternalServerError}, + "RetryImport": {h.RetryImport, covQIDRequest(http.MethodPost, "/", "1", ""), http.StatusInternalServerError}, + "RetryDownload": {h.RetryDownload, covQIDRequest(http.MethodPost, "/", "1", ""), http.StatusInternalServerError}, + "Delete": {h.Delete, covQIDRequest(http.MethodDelete, "/", "1", ""), http.StatusInternalServerError}, + "BulkRetry": {h.BulkRetry, httptest.NewRequest(http.MethodPost, "/", strings.NewReader(`{"ids":[1]}`)), http.StatusInternalServerError}, + "BulkDelete": {h.BulkDelete, httptest.NewRequest(http.MethodPost, "/", strings.NewReader(`{"ids":[1]}`)), http.StatusInternalServerError}, + } + for name, c := range cases { + rec := httptest.NewRecorder() + c.fn(rec, c.req) + if rec.Code != c.want { + t.Errorf("%s on a closed database: status %d, want %d (%s)", name, rec.Code, c.want, rec.Body.String()) + } + } +} + +func TestQueueBulkEndpointsValidateBody(t *testing.T) { + h, _, _, _, _, _ := queueFixture(t) + for _, body := range []string{`not json`, `{"ids":[]}`, `{}`} { + rec := httptest.NewRecorder() + h.BulkRetry(rec, httptest.NewRequest(http.MethodPost, "/", strings.NewReader(body))) + if rec.Code != http.StatusBadRequest { + t.Errorf("BulkRetry %s: status %d, want 400", body, rec.Code) + } + rec = httptest.NewRecorder() + h.BulkDelete(rec, httptest.NewRequest(http.MethodPost, "/", strings.NewReader(body))) + if rec.Code != http.StatusBadRequest { + t.Errorf("BulkDelete %s: status %d, want 400", body, rec.Code) + } + } +} + +// TestQueueBulkDeleteRefusesDeleteFilesWithoutClient: deleteFiles is passed +// only to the download client, so asking for it while keeping the client out +// of the removal is refused before any row is touched. +func TestQueueBulkDeleteRefusesDeleteFilesWithoutClient(t *testing.T) { + h, _, downloads, _, _, ctx := queueFixture(t) + dl := &models.Download{GUID: "covq-bd", Title: "T", Status: models.StateDownloading, Protocol: "usenet"} + if err := downloads.Create(ctx, dl); err != nil { + t.Fatal(err) + } + body := `{"ids":[` + strconv.FormatInt(dl.ID, 10) + `],"deleteFiles":true,"removeFromClient":false}` + rec := httptest.NewRecorder() + h.BulkDelete(rec, httptest.NewRequest(http.MethodPost, "/", strings.NewReader(body))) + if rec.Code != http.StatusBadRequest || !strings.Contains(covQErrorBody(t, rec), "removeFromClient") { + t.Fatalf("status %d body %s, want 400 naming removeFromClient", rec.Code, rec.Body.String()) + } + if got, _ := downloads.GetByID(ctx, dl.ID); got == nil { + t.Fatal("refused bulk delete still removed the row") + } +} + +// TestQueueBulkDeleteReportsPerItem: an unknown id and another user's row +// both answer the same opaque "download not found", the caller's own row is +// removed, and a removal with removeFromClient=false never needs a client. +func TestQueueBulkDeleteReportsPerItem(t *testing.T) { + auth.SetEnforceTenancyForTests(t, true) + h, database, downloads, _, _, ctx := queueFixture(t) + owner, caller := seedTwoUsers(t, database, ctx) + + mine := &models.Download{GUID: "covq-mine", Title: "Mine", Status: models.StateDownloading, Protocol: "usenet"} + theirs := &models.Download{GUID: "covq-theirs", Title: "Theirs", Status: models.StateDownloading, Protocol: "usenet"} + for _, d := range []*models.Download{mine, theirs} { + if err := downloads.Create(ctx, d); err != nil { + t.Fatal(err) + } + } + covQSetOwner(t, database, "downloads", mine.ID, caller) + covQSetOwner(t, database, "downloads", theirs.ID, owner) + + body := `{"ids":[` + strconv.FormatInt(mine.ID, 10) + `,` + strconv.FormatInt(theirs.ID, 10) + `,999999],"removeFromClient":false}` + rec := httptest.NewRecorder() + h.BulkDelete(rec, covQAsUser(httptest.NewRequest(http.MethodPost, "/", strings.NewReader(body)), caller)) + if rec.Code != http.StatusOK { + t.Fatalf("status %d: %s", rec.Code, rec.Body.String()) + } + var resp bulkResponse + if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { + t.Fatal(err) + } + if !resp.Results[strconv.FormatInt(mine.ID, 10)].OK { + t.Errorf("own row not removed: %+v", resp.Results) + } + for _, id := range []string{strconv.FormatInt(theirs.ID, 10), "999999"} { + if r := resp.Results[id]; r.OK || r.Error != "download not found" { + t.Errorf("id %s result = %+v, want download not found", id, r) + } + } + if got, _ := downloads.GetByID(ctx, theirs.ID); got == nil { + t.Error("another user's row was removed") + } + if got, _ := downloads.GetByID(ctx, mine.ID); got != nil { + t.Error("own row still present after bulk delete") + } +} + +func covQSetOwner(t *testing.T, database *sql.DB, table string, id, owner int64) { + t.Helper() + if _, err := database.Exec("UPDATE "+table+" SET owner_user_id=? WHERE id=?", owner, id); err != nil { + t.Fatal(err) + } +} + +// TestQueueRetryDownloadWithoutClientIs400: a failed row whose protocol has no +// enabled client is a configuration problem the user can fix, so it answers +// 400 with the actionable message rather than a 502. +func TestQueueRetryDownloadWithoutClientIs400(t *testing.T) { + h, _, downloads, _, _, ctx := queueFixture(t) + dl := &models.Download{GUID: "covq-noclient", Title: "No Client", Status: models.StateFailed, Protocol: "usenet", NZBURL: "http://example.invalid/x.nzb"} + if err := downloads.Create(ctx, dl); err != nil { + t.Fatal(err) + } + rec := httptest.NewRecorder() + h.RetryDownload(rec, retryDownloadRequest(t, dl.ID)) + if rec.Code != http.StatusBadRequest { + t.Fatalf("status %d (%s), want 400", rec.Code, rec.Body.String()) + } + if msg := covQErrorBody(t, rec); !strings.Contains(msg, "no enabled") { + t.Fatalf("error %q should say no enabled client is configured", msg) + } + got, _ := downloads.GetByID(ctx, dl.ID) + if got == nil || got.Status != models.StateFailed { + t.Fatalf("row after a refused retry = %+v, want still failed", got) + } +} + +// TestQueueRetryDownloadClientFailureIs502: when the client rejects the +// release, the retry answers 502 and the row records the client's error. +func TestQueueRetryDownloadClientFailureIs502(t *testing.T) { + t.Cleanup(httpsec.AllowLoopbackForTests()) + sab := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _ = json.NewEncoder(w).Encode(map[string]any{"status": false, "error": "covq refused"}) + })) + t.Cleanup(sab.Close) + indexerSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write([]byte(``)) + })) + t.Cleanup(indexerSrv.Close) + + h, _, downloads, clients, _, ctx := queueFixture(t) + host, port := testServerHostPort(t, sab.URL) + if err := clients.Create(ctx, &models.DownloadClient{Name: "sab", Type: "sabnzbd", Host: host, Port: port, Enabled: true}); err != nil { + t.Fatal(err) + } + dl := &models.Download{GUID: "covq-502", Title: "Refused", Status: models.StateFailed, Protocol: "usenet", NZBURL: indexerSrv.URL + "/x.nzb"} + if err := downloads.Create(ctx, dl); err != nil { + t.Fatal(err) + } + rec := httptest.NewRecorder() + h.RetryDownload(rec, retryDownloadRequest(t, dl.ID)) + if rec.Code != http.StatusBadGateway { + t.Fatalf("status %d (%s), want 502", rec.Code, rec.Body.String()) + } + got, _ := downloads.GetByID(ctx, dl.ID) + if got == nil || got.ErrorMessage == "" { + t.Fatalf("row after a client failure = %+v, want an error message recorded", got) + } +} + +// TestQueueRetryDownloadForeignBookIs404: the caller owns the row but the +// book it names now belongs to another user; the retry must not send for a +// book the caller may not act on, and answers the same 404 as a missing book. +func TestQueueRetryDownloadForeignBookIs404(t *testing.T) { + auth.SetEnforceTenancyForTests(t, true) + var adds sabAddRecorder + h, database, downloads, release := retryFixture(t, &adds) + ctx := context.Background() + owner, caller := seedTwoUsers(t, database, ctx) + + authors := db.NewAuthorRepo(database) + a := &models.Author{ForeignID: "covq-a", Name: "A", SortName: "A"} + if err := authors.CreateForUser(ctx, a, owner); err != nil { + t.Fatal(err) + } + book := &models.Book{ForeignID: "covq-b", AuthorID: a.ID, Title: "Theirs", SortTitle: "Theirs"} + if err := db.NewBookRepo(database).Create(ctx, book); err != nil { + t.Fatal(err) + } + covQSetOwner(t, database, "books", book.ID, owner) + + dl := &models.Download{GUID: "covq-foreign-book", Title: "Theirs", Status: models.StateFailed, Protocol: "usenet", NZBURL: release, BookID: &book.ID} + if err := downloads.Create(ctx, dl); err != nil { + t.Fatal(err) + } + covQSetOwner(t, database, "downloads", dl.ID, caller) + + rec := httptest.NewRecorder() + h.RetryDownload(rec, covQAsUser(retryDownloadRequest(t, dl.ID), caller)) + if rec.Code != http.StatusNotFound { + t.Fatalf("status %d (%s), want 404", rec.Code, rec.Body.String()) + } + if adds.calls != 0 { + t.Fatalf("download client was called %d times for a foreign book", adds.calls) + } +} + +// TestQueueBulkRetryReportsResendFailure: a failed row whose resend cannot +// reach a client gets a per-ID error rather than failing the batch. +func TestQueueBulkRetryReportsResendFailure(t *testing.T) { + h, _, downloads, _, _, ctx := queueFixture(t) + dl := &models.Download{GUID: "covq-br", Title: "T", Status: models.StateFailed, Protocol: "usenet", NZBURL: "http://example.invalid/x.nzb"} + if err := downloads.Create(ctx, dl); err != nil { + t.Fatal(err) + } + rec, parsed := postBulkRetry(t, h, `{"ids":[`+strconv.FormatInt(dl.ID, 10)+`,424242]}`) + if rec.Code != http.StatusOK { + t.Fatalf("status %d: %s", rec.Code, rec.Body.String()) + } + got := parsed.Results[strconv.FormatInt(dl.ID, 10)] + if got.OK || !strings.Contains(got.Error, "no enabled") { + t.Errorf("failed row result = %+v, want a no-client error", got) + } + if missing := parsed.Results["424242"]; missing.OK || missing.Error != "download not found" { + t.Errorf("unknown id result = %+v", missing) + } +} + +// TestQueueRetryImportAcceptsImportBlocked: RetryImport re-arms an +// importBlocked row as well as importFailed, answering 202. +func TestQueueRetryImportAcceptsImportBlocked(t *testing.T) { + h, _, downloads, _, _, ctx := queueFixture(t) + dl := &models.Download{GUID: "covq-blocked", Title: "Blocked", Status: models.StateImportBlocked, Protocol: "usenet"} + if err := downloads.Create(ctx, dl); err != nil { + t.Fatal(err) + } + rec := httptest.NewRecorder() + h.RetryImport(rec, covQIDRequest(http.MethodPost, "/", strconv.FormatInt(dl.ID, 10), "")) + if rec.Code != http.StatusAccepted { + t.Fatalf("status %d (%s), want 202", rec.Code, rec.Body.String()) + } + got, _ := downloads.GetByID(ctx, dl.ID) + if got == nil || got.Status == models.StateImportBlocked { + t.Fatalf("row after retry-import = %+v, want it moved out of importBlocked", got) + } +} + +// TestQueueGrabRecordsFailureHistory: when the client refuses a fresh grab +// the handler answers an error, marks the new row failed and writes a +// downloadFailed history event. +func TestQueueGrabRecordsFailureHistory(t *testing.T) { + t.Cleanup(httpsec.AllowLoopbackForTests()) + sab := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + })) + t.Cleanup(sab.Close) + h, database, downloads, clients, _, ctx := queueFixture(t) + host, port := testServerHostPort(t, sab.URL) + if err := clients.Create(ctx, &models.DownloadClient{Name: "sab", Type: "sabnzbd", Host: host, Port: port, Enabled: true}); err != nil { + t.Fatal(err) + } + _, err := h.grab(ctx, grabRequest{GUID: "covq-grabfail", Title: "Some.Book.epub", NZBURL: sab.URL + "/x.nzb"}) + if err == nil || !strings.Contains(err.Error(), "failed to send to downloader") { + t.Fatalf("grab err = %v, want a send failure", err) + } + dl, _ := downloads.GetByGUID(ctx, "covq-grabfail") + if dl == nil || dl.ErrorMessage == "" { + t.Fatalf("row after failed send = %+v, want an error recorded", dl) + } + events, _, err := db.NewHistoryRepo(database).ListPage(ctx, db.HistoryListOpts{EventType: models.HistoryEventDownloadFailed, Limit: 10}) + if err != nil { + t.Fatal(err) + } + if len(events) != 1 || events[0].SourceTitle != "Some.Book.epub" { + t.Fatalf("downloadFailed history = %+v, want one event for the release", events) + } +} + +// TestQueueRecordHistoryWithoutRepo: a handler built without a history repo +// skips the write rather than panicking. +func TestQueueRecordHistoryWithoutRepo(t *testing.T) { + h := newQueueTestHandler(t) + h.recordHistory(context.Background(), models.HistoryEventGrabbed, "t", nil, map[string]any{"x": time.Now()}) +} + +// TestQueueRecordHistoryUnmarshalableData: data that cannot be marshalled is +// logged and dropped, not written as a half event. +func TestQueueRecordHistoryUnmarshalableData(t *testing.T) { + h, database, _, _, _, ctx := queueFixture(t) + h.recordHistory(ctx, models.HistoryEventGrabbed, "covq-bad", nil, map[string]any{"ch": make(chan int)}) + events, _, err := db.NewHistoryRepo(database).ListPage(ctx, db.HistoryListOpts{Limit: 10}) + if err != nil { + t.Fatal(err) + } + if len(events) != 0 { + t.Fatalf("history written for unmarshalable data: %+v", events) + } +} diff --git a/internal/api/recommendations_coverage_test.go b/internal/api/recommendations_coverage_test.go new file mode 100644 index 000000000..28fe35147 --- /dev/null +++ b/internal/api/recommendations_coverage_test.go @@ -0,0 +1,471 @@ +package api + +import ( + "context" + "database/sql" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "strconv" + "strings" + "testing" + "time" + + "github.com/vavallee/bindery/internal/auth" + "github.com/vavallee/bindery/internal/db" + "github.com/vavallee/bindery/internal/models" +) + +// covSrRecFixture is an in-memory recommendation handler with author and book +// repos wired, plus the raw database so a test can close it to force repo +// errors. +type covSrRecFixture struct { + database *sql.DB + recs *db.RecommendationRepo + authors *db.AuthorRepo + books *db.BookRepo + searcher *mockBookSearcher + engine *covSrRecEngine + h *RecommendationHandler +} + +// covSrRecEngine records each Run call's user id on a channel so the +// background refresh goroutine can be observed without sleeping. +type covSrRecEngine struct { + calls chan int64 + err error +} + +func (e *covSrRecEngine) Run(_ context.Context, userID int64) error { + e.calls <- userID + return e.err +} + +func covSrNewRecFixture(t *testing.T) *covSrRecFixture { + t.Helper() + database, err := db.OpenMemory() + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { database.Close() }) + f := &covSrRecFixture{ + database: database, + recs: db.NewRecommendationRepo(database), + authors: db.NewAuthorRepo(database), + books: db.NewBookRepo(database), + searcher: newMockBookSearcher(), + engine: &covSrRecEngine{calls: make(chan int64, 1)}, + } + f.h = NewRecommendationHandler(f.recs, f.engine, f.authors, f.books, f.searcher). + WithAppContext(context.Background()) + return f +} + +// seed replaces userID's feed with candidates and returns the stored rows in +// score order. +func (f *covSrRecFixture) seed(t *testing.T, userID int64, candidates ...models.RecommendationCandidate) []models.Recommendation { + t.Helper() + ctx := context.Background() + for i := range candidates { + if candidates[i].Genres == nil { + candidates[i].Genres = []string{} + } + } + if err := f.recs.ReplaceBatch(ctx, userID, candidates); err != nil { + t.Fatal(err) + } + rows, err := f.recs.List(ctx, userID, "", 0, 0) + if err != nil { + t.Fatal(err) + } + return rows +} + +func covSrRecRequest(method, target, body string, userID int64, role string, params map[string]string) *http.Request { + var req *http.Request + if body == "" { + req = httptest.NewRequest(method, target, nil) + } else { + req = httptest.NewRequest(method, target, strings.NewReader(body)) + } + ctx := req.Context() + if userID != 0 { + ctx = auth.WithUserID(ctx, userID) + } + if role != "" { + ctx = auth.WithUserRole(ctx, role) + } + req = req.WithContext(ctx) + if params != nil { + req = withURLParams(req, params) + } + return req +} + +func covSrDecodeError(t *testing.T, rec *httptest.ResponseRecorder) string { + t.Helper() + var body map[string]string + if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil { + t.Fatalf("decode error body %q: %v", rec.Body.String(), err) + } + return body["error"] +} + +func TestRecommendationListAppliesTypeLimitAndOffset(t *testing.T) { + f := covSrNewRecFixture(t) + f.seed(t, 7, + models.RecommendationCandidate{ForeignID: "hc:a", RecType: models.RecTypeSeries, Title: "A", Score: 3}, + models.RecommendationCandidate{ForeignID: "hc:b", RecType: models.RecTypeSeries, Title: "B", Score: 2}, + models.RecommendationCandidate{ForeignID: "hc:c", RecType: models.RecTypeGenreSimilar, Title: "C", Score: 1}, + ) + + cases := []struct { + query string + want []string + }{ + {"", []string{"hc:a", "hc:b", "hc:c"}}, + {"?type=series", []string{"hc:a", "hc:b"}}, + {"?limit=1&offset=1", []string{"hc:b"}}, + // Garbage and non-positive values fall back to the defaults. + {"?limit=0&offset=-1", []string{"hc:a", "hc:b", "hc:c"}}, + {"?limit=x&offset=y", []string{"hc:a", "hc:b", "hc:c"}}, + {"?type=serendipity", []string{}}, + } + for _, tc := range cases { + t.Run(tc.query, func(t *testing.T) { + rec := httptest.NewRecorder() + f.h.List(rec, covSrRecRequest(http.MethodGet, "/api/v1/recommendations"+tc.query, "", 7, "", nil)) + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200: %s", rec.Code, rec.Body.String()) + } + // An empty page must encode as [], not null. + if len(tc.want) == 0 && strings.TrimSpace(rec.Body.String()) != "[]" { + t.Fatalf("empty page body = %q, want []", rec.Body.String()) + } + var got []models.Recommendation + if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil { + t.Fatal(err) + } + ids := make([]string, 0, len(got)) + for _, r := range got { + ids = append(ids, r.ForeignID) + } + if strings.Join(ids, ",") != strings.Join(tc.want, ",") { + t.Fatalf("foreign ids = %v, want %v", ids, tc.want) + } + }) + } +} + +func TestRecommendationDismissOwnershipUnderTenancy(t *testing.T) { + auth.SetEnforceTenancyForTests(t, true) + f := covSrNewRecFixture(t) + const alice, bob int64 = 11, 12 + rows := f.seed(t, alice, models.RecommendationCandidate{ForeignID: "hc:alice", RecType: models.RecTypeSeries, Title: "Alice's", Score: 1}) + id := strconv.FormatInt(rows[0].ID, 10) + ctx := context.Background() + + // Bob cannot dismiss alice's row: 404, not 403, and nothing changes. + rec := httptest.NewRecorder() + f.h.Dismiss(rec, covSrRecRequest(http.MethodPost, "/api/v1/recommendations/"+id+"/dismiss", "", bob, "user", map[string]string{"id": id})) + if rec.Code != http.StatusNotFound || covSrDecodeError(t, rec) != "recommendation not found" { + t.Fatalf("cross-user dismiss = %d %s, want 404 recommendation not found", rec.Code, rec.Body.String()) + } + if dismissed, _ := f.recs.IsDismissed(ctx, bob, "hc:alice"); dismissed { + t.Fatal("cross-user dismiss recorded a dismissal for bob") + } + if left, _ := f.recs.List(ctx, alice, "", 0, 0); len(left) != 1 { + t.Fatalf("alice's feed changed after bob's refused dismiss: %+v", left) + } + + // An unknown id is the same 404. + rec = httptest.NewRecorder() + f.h.Dismiss(rec, covSrRecRequest(http.MethodPost, "/", "", alice, "user", map[string]string{"id": "9999"})) + if rec.Code != http.StatusNotFound { + t.Fatalf("unknown id dismiss = %d, want 404", rec.Code) + } + + // A non-numeric id is rejected before any lookup. + rec = httptest.NewRecorder() + f.h.Dismiss(rec, covSrRecRequest(http.MethodPost, "/", "", alice, "user", map[string]string{"id": "abc"})) + if rec.Code != http.StatusBadRequest { + t.Fatalf("bad id dismiss = %d, want 400", rec.Code) + } + + // The owner can dismiss: 204, the row leaves the feed and the dismissal + // is persisted for the owner. + rec = httptest.NewRecorder() + f.h.Dismiss(rec, covSrRecRequest(http.MethodPost, "/", "", alice, "user", map[string]string{"id": id})) + if rec.Code != http.StatusNoContent { + t.Fatalf("owner dismiss = %d %s, want 204", rec.Code, rec.Body.String()) + } + if left, _ := f.recs.List(ctx, alice, "", 0, 0); len(left) != 0 { + t.Fatalf("dismissed row still listed: %+v", left) + } + if dismissed, _ := f.recs.IsDismissed(ctx, alice, "hc:alice"); !dismissed { + t.Fatal("owner dismissal not persisted") + } + + // ClearDismissals forgets it again for the caller only. + rec = httptest.NewRecorder() + f.h.ClearDismissals(rec, covSrRecRequest(http.MethodDelete, "/", "", alice, "user", nil)) + if rec.Code != http.StatusNoContent { + t.Fatalf("clear dismissals = %d, want 204", rec.Code) + } + if dismissed, _ := f.recs.IsDismissed(ctx, alice, "hc:alice"); dismissed { + t.Fatal("ClearDismissals left the dismissal in place") + } +} + +func TestRecommendationAddRefusals(t *testing.T) { + auth.SetEnforceTenancyForTests(t, true) + f := covSrNewRecFixture(t) + ctx := context.Background() + const alice, bob int64 = 21, 22 + + author := &models.Author{ForeignID: "OL-REC-A", Name: "Known Author", SortName: "Author, Known", MetadataProvider: "openlibrary", Monitored: true} + if err := f.authors.Create(ctx, author); err != nil { + t.Fatal(err) + } + existing := &models.Book{ForeignID: "OL-EXISTS", AuthorID: author.ID, Title: "Already Here", SortTitle: "Already Here", Status: models.BookStatusWanted, Genres: []string{}, MetadataProvider: "openlibrary"} + if err := f.books.Create(ctx, existing); err != nil { + t.Fatal(err) + } + rows := f.seed(t, alice, + models.RecommendationCandidate{ForeignID: "OL-EXISTS", RecType: models.RecTypeSeries, Title: "Already Here", AuthorID: &author.ID, Score: 3}, + models.RecommendationCandidate{ForeignID: "OL-ORPHAN", RecType: models.RecTypeSeries, Title: "No Author", AuthorName: "Nobody We Know", Score: 2}, + models.RecommendationCandidate{ForeignID: "OL-NONAME", RecType: models.RecTypeSeries, Title: "Blank Author", Score: 1}, + ) + idOf := func(foreignID string) string { + for _, r := range rows { + if r.ForeignID == foreignID { + return strconv.FormatInt(r.ID, 10) + } + } + t.Fatalf("no seeded row %s", foreignID) + return "" + } + + cases := []struct { + name string + id string + caller int64 + wantCode int + wantErr string + }{ + {"bad id", "x", alice, http.StatusBadRequest, "invalid id"}, + {"unknown id", "424242", alice, http.StatusNotFound, "recommendation not found"}, + {"other user's row", idOf("OL-ORPHAN"), bob, http.StatusNotFound, "recommendation not found"}, + {"book already exists", idOf("OL-EXISTS"), alice, http.StatusConflict, "book already exists"}, + {"author name unknown", idOf("OL-ORPHAN"), alice, http.StatusBadRequest, "cannot resolve author for this recommendation"}, + {"no author at all", idOf("OL-NONAME"), alice, http.StatusBadRequest, "cannot resolve author for this recommendation"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + rec := httptest.NewRecorder() + f.h.Add(rec, covSrRecRequest(http.MethodPost, "/", "", tc.caller, "user", map[string]string{"id": tc.id})) + if rec.Code != tc.wantCode { + t.Fatalf("status = %d, want %d: %s", rec.Code, tc.wantCode, rec.Body.String()) + } + if got := covSrDecodeError(t, rec); got != tc.wantErr { + t.Fatalf("error = %q, want %q", got, tc.wantErr) + } + }) + } + + // None of the refusals created a book or queued a search. + for _, fid := range []string{"OL-ORPHAN", "OL-NONAME"} { + if b, _ := f.books.GetByForeignID(ctx, fid); b != nil { + t.Fatalf("refused add created book %s", fid) + } + } + select { + case b := <-f.searcher.ch: + t.Fatalf("refused add queued a search for %q", b.Title) + default: + } + if left, _ := f.recs.List(ctx, alice, "", 0, 0); len(left) != 3 { + t.Fatalf("refused adds dismissed rows: %d left, want 3", len(left)) + } +} + +// TestRecommendationAddResolvesAuthorByName covers the branch where the +// recommendation carries only an author name: the handler must find the local +// author with that exact name, create the book as wanted under it, dismiss the +// row and queue a search. +func TestRecommendationAddResolvesAuthorByName(t *testing.T) { + f := covSrNewRecFixture(t) + ctx := context.Background() + for _, name := range []string{"Someone Else", "Named Author"} { + if err := f.authors.Create(ctx, &models.Author{ForeignID: "OL-" + strings.ReplaceAll(name, " ", ""), Name: name, SortName: name, MetadataProvider: "openlibrary", Monitored: true}); err != nil { + t.Fatal(err) + } + } + target, err := f.authors.GetByForeignID(ctx, "OL-NamedAuthor") + if err != nil || target == nil { + t.Fatalf("author lookup: %v %v", target, err) + } + fetcherCalled := false + f.h.WithEditionFetcher(func(context.Context, string) ([]models.Edition, error) { + fetcherCalled = true + return nil, nil + }) + rows := f.seed(t, 1, models.RecommendationCandidate{ + ForeignID: "OL-BYNAME", RecType: models.RecTypeAuthorNew, Title: "Found By Name", + AuthorName: "Named Author", MediaType: models.MediaTypeEbook, Score: 1, + }) + id := strconv.FormatInt(rows[0].ID, 10) + + rec := httptest.NewRecorder() + f.h.Add(rec, covSrRecRequest(http.MethodPost, "/", "", 1, "", map[string]string{"id": id})) + if rec.Code != http.StatusCreated { + t.Fatalf("status = %d, want 201: %s", rec.Code, rec.Body.String()) + } + var created models.Book + if err := json.Unmarshal(rec.Body.Bytes(), &created); err != nil { + t.Fatal(err) + } + if created.AuthorID != target.ID || created.Status != models.BookStatusWanted || !created.Monitored { + t.Fatalf("created book = author %d status %q monitored %v; want author %d wanted monitored", created.AuthorID, created.Status, created.Monitored, target.ID) + } + stored, err := f.books.GetByForeignID(ctx, "OL-BYNAME") + if err != nil || stored == nil || stored.AuthorID != target.ID { + t.Fatalf("stored book = %+v, %v", stored, err) + } + if queued := f.searcher.waitForCall(t, time.Second); queued.ForeignID != "OL-BYNAME" { + t.Fatalf("queued search for %q, want OL-BYNAME", queued.ForeignID) + } + if left, _ := f.recs.List(ctx, 1, "", 0, 0); len(left) != 0 { + t.Fatalf("added recommendation still listed: %+v", left) + } + // Edition hydration is not wired (no edition repo), so the fetcher must + // not have been consulted. + if fetcherCalled { + t.Fatal("edition fetcher called without an edition repo") + } +} + +func TestRecommendationRefreshRunsEngineForCaller(t *testing.T) { + for _, engineErr := range []error{nil, errors.New("engine exploded")} { + name := "ok" + if engineErr != nil { + name = "engine error" + } + t.Run(name, func(t *testing.T) { + f := covSrNewRecFixture(t) + f.engine.err = engineErr + rec := httptest.NewRecorder() + f.h.Refresh(rec, covSrRecRequest(http.MethodPost, "/", "", 33, "", nil)) + if rec.Code != http.StatusAccepted || !strings.Contains(rec.Body.String(), "refresh started") { + t.Fatalf("refresh = %d %s, want 202 refresh started", rec.Code, rec.Body.String()) + } + select { + case uid := <-f.engine.calls: + if uid != 33 { + t.Fatalf("engine ran for user %d, want 33", uid) + } + case <-time.After(10 * time.Second): + t.Fatal("engine was not run") + } + }) + } +} + +func TestRecommendationAuthorExclusionsLifecycle(t *testing.T) { + f := covSrNewRecFixture(t) + const user int64 = 44 + + list := func() []string { + t.Helper() + rec := httptest.NewRecorder() + f.h.ListAuthorExclusions(rec, covSrRecRequest(http.MethodGet, "/", "", user, "", nil)) + if rec.Code != http.StatusOK { + t.Fatalf("list exclusions = %d", rec.Code) + } + var names []string + if err := json.Unmarshal(rec.Body.Bytes(), &names); err != nil { + t.Fatal(err) + } + if names == nil { + t.Fatalf("empty exclusion list encoded as %q, want []", rec.Body.String()) + } + return names + } + + if got := list(); len(got) != 0 { + t.Fatalf("fresh exclusions = %v, want none", got) + } + + for _, body := range []string{"not json", `{}`, `{"authorName":""}`} { + rec := httptest.NewRecorder() + f.h.ExcludeAuthor(rec, covSrRecRequest(http.MethodPost, "/", body, user, "", nil)) + if rec.Code != http.StatusBadRequest || covSrDecodeError(t, rec) != "authorName required" { + t.Fatalf("exclude %q = %d %s, want 400 authorName required", body, rec.Code, rec.Body.String()) + } + } + + rec := httptest.NewRecorder() + f.h.ExcludeAuthor(rec, covSrRecRequest(http.MethodPost, "/", `{"authorName":"Prolific Person"}`, user, "", nil)) + if rec.Code != http.StatusCreated { + t.Fatalf("exclude = %d %s, want 201", rec.Code, rec.Body.String()) + } + if got := list(); len(got) != 1 || got[0] != "Prolific Person" { + t.Fatalf("exclusions = %v, want [Prolific Person]", got) + } + // Exclusions are per user. + if other, _ := f.recs.ListAuthorExclusions(context.Background(), user+1); len(other) != 0 { + t.Fatalf("exclusion leaked to another user: %v", other) + } + + rec = httptest.NewRecorder() + f.h.RemoveAuthorExclusion(rec, covSrRecRequest(http.MethodDelete, "/", "", user, "", map[string]string{"name": ""})) + if rec.Code != http.StatusBadRequest || covSrDecodeError(t, rec) != "author name required" { + t.Fatalf("remove blank = %d %s, want 400", rec.Code, rec.Body.String()) + } + + rec = httptest.NewRecorder() + f.h.RemoveAuthorExclusion(rec, covSrRecRequest(http.MethodDelete, "/", "", user, "", map[string]string{"name": "Prolific Person"})) + if rec.Code != http.StatusNoContent { + t.Fatalf("remove = %d %s, want 204", rec.Code, rec.Body.String()) + } + if got := list(); len(got) != 0 { + t.Fatalf("exclusions after remove = %v, want none", got) + } +} + +// TestRecommendationHandlersReturn500OnRepoFailure closes the database under +// every handler that reads or writes the recommendation repo and checks each +// answers a generic 500 rather than leaking the SQL error. +func TestRecommendationHandlersReturn500OnRepoFailure(t *testing.T) { + f := covSrNewRecFixture(t) + if err := f.database.Close(); err != nil { + t.Fatal(err) + } + cases := []struct { + name string + call func(w http.ResponseWriter, r *http.Request) + body string + args map[string]string + }{ + {"List", f.h.List, "", nil}, + {"Dismiss", f.h.Dismiss, "", map[string]string{"id": "1"}}, + {"Add", f.h.Add, "", map[string]string{"id": "1"}}, + {"ClearDismissals", f.h.ClearDismissals, "", nil}, + {"ListAuthorExclusions", f.h.ListAuthorExclusions, "", nil}, + {"ExcludeAuthor", f.h.ExcludeAuthor, `{"authorName":"X"}`, nil}, + {"RemoveAuthorExclusion", f.h.RemoveAuthorExclusion, "", map[string]string{"name": "X"}}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + rec := httptest.NewRecorder() + tc.call(rec, covSrRecRequest(http.MethodPost, "/", tc.body, 1, "", tc.args)) + if rec.Code != http.StatusInternalServerError { + t.Fatalf("status = %d, want 500: %s", rec.Code, rec.Body.String()) + } + if got := covSrDecodeError(t, rec); got != "internal server error" { + t.Fatalf("error = %q, want the generic message", got) + } + }) + } +} diff --git a/internal/api/requests_coverage_test.go b/internal/api/requests_coverage_test.go new file mode 100644 index 000000000..032271cf9 --- /dev/null +++ b/internal/api/requests_coverage_test.go @@ -0,0 +1,211 @@ +package api + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/vavallee/bindery/internal/auth" +) + +// TestRequestsCreateCoverageValidation walks the refusals in front of the +// provider lookup: no identity, unknown fields, bad kind, bad foreign id and +// bad media type. None of them may store a request. +func TestRequestsCreateCoverageValidation(t *testing.T) { + f := newRequestsFixture(t, wellsRequestStub(), &fakeAdder{}) + + // A context with no user id (an install API key reaching the handler) is + // refused: requests belong to people. + rec := httptest.NewRecorder() + f.h.Create(rec, httptest.NewRequest(http.MethodPost, "/api/v1/requests", strings.NewReader(`{"kind":"book","foreignId":"OL27482W"}`))) + if rec.Code != http.StatusForbidden || !strings.Contains(requestErrorBody(rec), "Sign in") { + t.Fatalf("anonymous create: %d %q, want 403", rec.Code, requestErrorBody(rec)) + } + + cases := []struct { + name, body, want string + }{ + {"unknown field", `{"kind":"book","foreignId":"OL27482W","title":"spoofed"}`, "A request takes only kind, foreignId and mediaType."}, + {"trailing data", `{"kind":"book","foreignId":"OL27482W"} {}`, "A request takes only kind, foreignId and mediaType."}, + {"bad kind", `{"kind":"series","foreignId":"OL27482W"}`, "kind must be book or author."}, + {"empty foreign id", `{"kind":"book","foreignId":" "}`, "foreignId is missing or not a provider id."}, + {"control char in foreign id", `{"kind":"book","foreignId":"OL\u0001X"}`, "foreignId is missing or not a provider id."}, + {"overlong foreign id", `{"kind":"book","foreignId":"` + strings.Repeat("A", requestForeignIDMaxLen+1) + `"}`, "foreignId is missing or not a provider id."}, + {"bad media type", `{"kind":"book","foreignId":"OL27482W","mediaType":"vinyl"}`, "mediaType must be ebook, audiobook, both or empty."}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + rec := f.create(t, f.requester, c.body) + if rec.Code != http.StatusBadRequest || requestErrorBody(rec) != c.want { + t.Fatalf("%d %q, want 400 %q", rec.Code, requestErrorBody(rec), c.want) + } + }) + } + if n, _ := f.requests.CountPendingByOwner(context.Background(), f.requester.ID); n != 0 { + t.Fatalf("refused creates stored %d requests", n) + } +} + +// TestRequestsCreateCoverageProviderRateLimit: the provider bucket is spent +// by requesters only. Once it is empty a requester gets 429 with Retry-After, +// while an admin making the same call is not charged. +func TestRequestsCreateCoverageProviderRateLimit(t *testing.T) { + f := newRequestsFixture(t, wellsRequestStub(), &fakeAdder{}) + f.h.WithProviderLimiter(auth.NewRequesterLimiter(1, 0.0001, time.Minute, 8)) + + if rec := f.create(t, f.requester, `{"kind":"book","foreignId":"OL27482W"}`); rec.Code != http.StatusCreated { + t.Fatalf("first create: %d %s", rec.Code, rec.Body.String()) + } + rec := f.create(t, f.requester, `{"kind":"author","foreignId":"OL39307A"}`) + if rec.Code != http.StatusTooManyRequests { + t.Fatalf("second create: %d %s, want 429", rec.Code, rec.Body.String()) + } + if rec.Header().Get("Retry-After") == "" { + t.Fatal("429 without Retry-After") + } + if n, _ := f.requests.CountPendingByOwner(context.Background(), f.requester.ID); n != 1 { + t.Fatalf("pending = %d, want only the first request", n) + } + // Admins are never charged from the requester bucket. + if rec := f.create(t, f.admin, `{"kind":"book","foreignId":"OL27482W"}`); rec.Code != http.StatusCreated { + t.Fatalf("admin create: %d %s, want 201", rec.Code, rec.Body.String()) + } +} + +// TestRequestsQueueCoverage covers the admin queue's status filter and its +// paging envelope. +func TestRequestsQueueCoverage(t *testing.T) { + f := newRequestsFixture(t, wellsRequestStub(), &fakeAdder{}) + first := decodeRequest(t, f.create(t, f.requester, `{"kind":"book","foreignId":"OL27482W"}`)) + if rec := f.create(t, f.other, `{"kind":"book","foreignId":"OL27482W"}`); rec.Code != http.StatusCreated { + t.Fatalf("second request: %d %s", rec.Code, rec.Body.String()) + } + if err := f.requests.Decline(context.Background(), first.ID, f.admin.ID, "no"); err != nil { + t.Fatal(err) + } + + queue := func(query string) *httptest.ResponseRecorder { + rec := httptest.NewRecorder() + f.h.Queue(rec, asUser(httptest.NewRequest(http.MethodGet, "/api/v1/requests/queue"+query, nil), f.admin)) + return rec + } + decode := func(rec *httptest.ResponseRecorder) requestListResponse { + t.Helper() + var out requestListResponse + if err := json.Unmarshal(rec.Body.Bytes(), &out); err != nil { + t.Fatalf("decode %q: %v", rec.Body.String(), err) + } + return out + } + + for _, c := range []struct { + query string + wantTotal int + wantStatus string + }{ + {"", 1, "pending"}, + {"?status=pending", 1, "pending"}, + {"?status=declined", 1, "declined"}, + {"?status=approved", 0, ""}, + {"?status=all", 2, ""}, + } { + rec := queue(c.query) + if rec.Code != http.StatusOK { + t.Fatalf("%q: %d %s", c.query, rec.Code, rec.Body.String()) + } + got := decode(rec) + if got.Total != c.wantTotal || len(got.Items) != c.wantTotal { + t.Fatalf("%q: total=%d items=%d, want %d", c.query, got.Total, len(got.Items), c.wantTotal) + } + if c.wantStatus != "" && got.Items[0].Status != c.wantStatus { + t.Fatalf("%q: status = %q, want %q", c.query, got.Items[0].Status, c.wantStatus) + } + } + + paged := decode(queue("?status=all&limit=1&offset=1")) + if paged.Total != 2 || len(paged.Items) != 1 || paged.Limit != 1 || paged.Offset != 1 { + t.Fatalf("paged = %+v, want total 2, one item, limit 1 offset 1", paged) + } + + rec := queue("?status=bogus") + if rec.Code != http.StatusBadRequest || !strings.Contains(requestErrorBody(rec), "status must be") { + t.Fatalf("bad status: %d %q", rec.Code, requestErrorBody(rec)) + } +} + +// TestRequestsDeclineCoverageValidation covers the id, body and existence +// checks on decline. +func TestRequestsDeclineCoverageValidation(t *testing.T) { + f := newRequestsFixture(t, wellsRequestStub(), &fakeAdder{}) + created := decodeRequest(t, f.create(t, f.requester, `{"kind":"book","foreignId":"OL27482W"}`)) + + rec := httptest.NewRecorder() + req := withURLParam(asUser(httptest.NewRequest(http.MethodPost, "/", strings.NewReader(`{}`)), f.admin), "id", "abc") + f.h.Decline(rec, req) + if rec.Code != http.StatusBadRequest || requestErrorBody(rec) != "invalid id" { + t.Fatalf("bad id: %d %q", rec.Code, requestErrorBody(rec)) + } + + rec = httptest.NewRecorder() + f.h.Decline(rec, withID(asUser(httptest.NewRequest(http.MethodPost, "/", strings.NewReader(`{"reason":"x","extra":1}`)), f.admin), created.ID)) + if rec.Code != http.StatusBadRequest || requestErrorBody(rec) != "invalid decline body" { + t.Fatalf("bad body: %d %q", rec.Code, requestErrorBody(rec)) + } + + rec = httptest.NewRecorder() + f.h.Decline(rec, withID(asUser(httptest.NewRequest(http.MethodPost, "/", strings.NewReader(`{}`)), f.admin), created.ID+999)) + if rec.Code != http.StatusNotFound { + t.Fatalf("unknown id: %d %s, want 404", rec.Code, rec.Body.String()) + } + + // The refused calls left the request pending. + got, err := f.requests.GetByID(context.Background(), created.ID) + if err != nil || got == nil || got.Status != "pending" { + t.Fatalf("request after refused declines = %+v, %v; want pending", got, err) + } +} + +// TestRequestsPendingCountCoverage: the admin badge counts pending requests +// across every user and drops a request once it is decided. +func TestRequestsPendingCountCoverage(t *testing.T) { + f := newRequestsFixture(t, wellsRequestStub(), &fakeAdder{}) + count := func() int { + t.Helper() + rec := httptest.NewRecorder() + f.h.PendingCount(rec, asUser(httptest.NewRequest(http.MethodGet, "/api/v1/requests/pending-count", nil), f.admin)) + if rec.Code != http.StatusOK { + t.Fatalf("pending count: %d %s", rec.Code, rec.Body.String()) + } + var out map[string]int + if err := json.Unmarshal(rec.Body.Bytes(), &out); err != nil { + t.Fatal(err) + } + return out["count"] + } + if n := count(); n != 0 { + t.Fatalf("empty count = %d", n) + } + first := decodeRequest(t, f.create(t, f.requester, `{"kind":"book","foreignId":"OL27482W"}`)) + if rec := f.create(t, f.other, `{"kind":"book","foreignId":"OL27482W"}`); rec.Code != http.StatusCreated { + t.Fatalf("second request: %d", rec.Code) + } + if n := count(); n != 2 { + t.Fatalf("count = %d, want 2 across users", n) + } + if err := f.requests.Decline(context.Background(), first.ID, f.admin.ID, ""); err != nil { + t.Fatal(err) + } + if n := count(); n != 1 { + t.Fatalf("count after decline = %d, want 1", n) + } + f.database.Close() + rec := httptest.NewRecorder() + f.h.PendingCount(rec, asUser(httptest.NewRequest(http.MethodGet, "/", nil), f.admin)) + if rec.Code != http.StatusInternalServerError { + t.Fatalf("repo error: %d, want 500", rec.Code) + } +} diff --git a/internal/api/series_coverage_test.go b/internal/api/series_coverage_test.go new file mode 100644 index 000000000..0785ebeb8 --- /dev/null +++ b/internal/api/series_coverage_test.go @@ -0,0 +1,679 @@ +package api + +import ( + "context" + "database/sql" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "strconv" + "strings" + "testing" + + "github.com/vavallee/bindery/internal/db" + "github.com/vavallee/bindery/internal/metadata" + "github.com/vavallee/bindery/internal/models" +) + +// covSrSeriesFixture wires a SeriesHandler over a fresh in-memory database and +// also hands back the *sql.DB so a test can close it to force repo errors. +// provider may be nil, in which case the handler has no metadata aggregator. +type covSrSeriesFixture struct { + database *sql.DB + h *SeriesHandler + series *db.SeriesRepo + authors *db.AuthorRepo + books *db.BookRepo + searcher *mockBookSearcher +} + +func covSrNewSeriesFixture(t *testing.T, provider *stubSeriesProvider) *covSrSeriesFixture { + t.Helper() + database, err := db.OpenMemory() + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { database.Close() }) + f := &covSrSeriesFixture{ + database: database, + series: db.NewSeriesRepo(database), + authors: db.NewAuthorRepo(database), + books: db.NewBookRepo(database), + searcher: newMockBookSearcher(), + } + var meta *metadata.Aggregator + if provider != nil { + meta = metadata.NewAggregator(provider).WithAudnexClient(nil) + } + f.h = NewSeriesHandler(f.series, f.books, f.authors, meta, f.searcher) + return f +} + +func (f *covSrSeriesFixture) createSeries(t *testing.T, foreignID, title string) *models.Series { + t.Helper() + s := &models.Series{ForeignID: foreignID, Title: title} + if err := f.series.Create(context.Background(), s); err != nil { + t.Fatal(err) + } + return s +} + +func (f *covSrSeriesFixture) link(t *testing.T, seriesID int64, hardcoverID string) { + t.Helper() + if err := f.series.UpsertHardcoverLink(context.Background(), &models.SeriesHardcoverLink{ + SeriesID: seriesID, HardcoverSeriesID: hardcoverID, Confidence: 1, LinkedBy: "manual", + }); err != nil { + t.Fatal(err) + } +} + +func covSrSeriesRequest(method, body string, params map[string]string) *http.Request { + var req *http.Request + if body == "" { + req = httptest.NewRequest(method, "/api/v1/series", nil) + } else { + req = httptest.NewRequest(method, "/api/v1/series", strings.NewReader(body)) + } + if params != nil { + req = withURLParams(req, params) + } + return req +} + +// TestSeriesHandlersReturn500OnRepoFailure closes the database under every +// series handler whose first repo call can fail, and checks each answers the +// generic 500 body instead of a partial result or the SQL text. +func TestSeriesHandlersReturn500OnRepoFailure(t *testing.T) { + f := covSrNewSeriesFixture(t, nil) + if err := f.database.Close(); err != nil { + t.Fatal(err) + } + idOnly := map[string]string{"id": "1"} + cases := []struct { + name string + call http.HandlerFunc + target string + body string + params map[string]string + }{ + {"List", f.h.List, "/api/v1/series", "", nil}, + {"List paged", f.h.List, "/api/v1/series?limit=5", "", nil}, + {"Get", f.h.Get, "", "", idOnly}, + {"Create", f.h.Create, "", `{"title":"New Series"}`, nil}, + {"Update", f.h.Update, "", `{"title":"Renamed"}`, idOnly}, + {"Delete", f.h.Delete, "", "", idOnly}, + {"AddBook", f.h.AddBook, "", `{"bookId":5}`, idOnly}, + {"RemoveBook", f.h.RemoveBook, "", "", map[string]string{"id": "1", "bookId": "5"}}, + {"SetPrimaryBook", f.h.SetPrimaryBook, "", "", map[string]string{"id": "1", "bookId": "5"}}, + {"Monitor", f.h.Monitor, "", `{"monitored":true}`, idOnly}, + {"GetHardcoverLink", f.h.GetHardcoverLink, "", "", idOnly}, + {"AutoLinkHardcover", f.h.AutoLinkHardcover, "", "", idOnly}, + {"PutHardcoverLink", f.h.PutHardcoverLink, "", `{"foreignId":"hc-series:1"}`, idOnly}, + {"DeleteHardcoverLink", f.h.DeleteHardcoverLink, "", "", idOnly}, + {"HardcoverDiff", f.h.HardcoverDiff, "", "", idOnly}, + {"Fill", f.h.Fill, "", `{}`, idOnly}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + target := tc.target + if target == "" { + target = "/api/v1/series/1" + } + var req *http.Request + if tc.body == "" { + req = httptest.NewRequest(http.MethodPost, target, nil) + } else { + req = httptest.NewRequest(http.MethodPost, target, strings.NewReader(tc.body)) + } + if tc.params != nil { + req = withURLParams(req, tc.params) + } + rec := httptest.NewRecorder() + tc.call(rec, req) + if rec.Code != http.StatusInternalServerError { + t.Fatalf("status = %d, want 500: %s", rec.Code, rec.Body.String()) + } + if got := covSrDecodeError(t, rec); got != "internal server error" { + t.Fatalf("error = %q, want the generic message", got) + } + }) + } +} + +// TestSeriesFillReturns500WhenCatalogExpansionReadFails covers the enhanced +// fill path with an aggregator wired: the database closing under +// createMissingHardcoverBooks must fail the request instead of silently +// queueing nothing. +func TestSeriesFillReturns500WhenCatalogExpansionReadFails(t *testing.T) { + f := covSrNewSeriesFixture(t, &stubSeriesProvider{}) + if err := f.database.Close(); err != nil { + t.Fatal(err) + } + rec := httptest.NewRecorder() + f.h.Fill(rec, covSrSeriesRequest(http.MethodPost, "", map[string]string{"id": "1"})) + if rec.Code != http.StatusInternalServerError { + t.Fatalf("status = %d, want 500: %s", rec.Code, rec.Body.String()) + } +} + +// TestSeriesManagementValidationCoverage drives the 400/404 branches of the +// manual series management endpoints that the main suite does not reach, and +// checks the store is untouched by each refusal. +func TestSeriesManagementValidationCoverage(t *testing.T) { + f := covSrNewSeriesFixture(t, nil) + ctx := context.Background() + s := f.createSeries(t, "manual:series:kept", "Kept Title") + sid := strconv.FormatInt(s.ID, 10) + + cases := []struct { + name string + call http.HandlerFunc + body string + params map[string]string + wantCode int + wantErr string + }{ + {"create bad json", f.h.Create, `{`, nil, http.StatusBadRequest, "invalid request body"}, + {"update bad id", f.h.Update, `{"title":"x"}`, map[string]string{"id": "nope"}, http.StatusBadRequest, "invalid id"}, + {"update bad json", f.h.Update, `{`, map[string]string{"id": sid}, http.StatusBadRequest, "invalid request body"}, + {"update blank title", f.h.Update, `{"title":" "}`, map[string]string{"id": sid}, http.StatusBadRequest, "title is required"}, + {"update missing series", f.h.Update, `{"title":"x"}`, map[string]string{"id": "9999"}, http.StatusNotFound, "series not found"}, + {"delete bad id", f.h.Delete, "", map[string]string{"id": "nope"}, http.StatusBadRequest, "invalid id"}, + {"delete missing series", f.h.Delete, "", map[string]string{"id": "9999"}, http.StatusNotFound, "series not found"}, + {"add book bad id", f.h.AddBook, `{"bookId":1}`, map[string]string{"id": "nope"}, http.StatusBadRequest, "invalid id"}, + {"add book bad json", f.h.AddBook, `{`, map[string]string{"id": sid}, http.StatusBadRequest, "invalid request body"}, + {"add book missing series", f.h.AddBook, `{"bookId":1}`, map[string]string{"id": "9999"}, http.StatusNotFound, "series not found"}, + {"add book missing book", f.h.AddBook, `{"bookId":424242}`, map[string]string{"id": sid}, http.StatusNotFound, "book not found"}, + {"remove book bad series id", f.h.RemoveBook, "", map[string]string{"id": "nope", "bookId": "1"}, http.StatusBadRequest, "invalid id"}, + {"remove book bad book id", f.h.RemoveBook, "", map[string]string{"id": sid, "bookId": "nope"}, http.StatusBadRequest, "invalid book id"}, + {"remove book missing series", f.h.RemoveBook, "", map[string]string{"id": "9999", "bookId": "1"}, http.StatusNotFound, "series not found"}, + {"set primary bad series id", f.h.SetPrimaryBook, "", map[string]string{"id": "nope", "bookId": "1"}, http.StatusBadRequest, "invalid id"}, + {"set primary bad book id", f.h.SetPrimaryBook, "", map[string]string{"id": sid, "bookId": "nope"}, http.StatusBadRequest, "invalid book id"}, + {"set primary not a member", f.h.SetPrimaryBook, "", map[string]string{"id": sid, "bookId": "424242"}, http.StatusNotFound, "book is not in this series"}, + {"monitor bad json", f.h.Monitor, `{`, map[string]string{"id": sid}, http.StatusBadRequest, "invalid request body"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + rec := httptest.NewRecorder() + tc.call(rec, covSrSeriesRequest(http.MethodPost, tc.body, tc.params)) + if rec.Code != tc.wantCode { + t.Fatalf("status = %d, want %d: %s", rec.Code, tc.wantCode, rec.Body.String()) + } + if got := covSrDecodeError(t, rec); got != tc.wantErr { + t.Fatalf("error = %q, want %q", got, tc.wantErr) + } + }) + } + + got, err := f.series.GetByID(ctx, s.ID) + if err != nil || got == nil { + t.Fatalf("series vanished: %v %v", got, err) + } + if got.Title != "Kept Title" || len(got.Books) != 0 || got.Monitored { + t.Fatalf("refused requests changed the series: %+v", got) + } +} + +// TestSeriesAddBookRefusesExcludedBook pins that a book the user excluded is +// treated as absent: linking it into a series would resurrect it in the +// series view. +func TestSeriesAddBookRefusesExcludedBook(t *testing.T) { + f := covSrNewSeriesFixture(t, nil) + ctx := context.Background() + s := f.createSeries(t, "manual:series:x", "X") + author := &models.Author{ForeignID: "OL-X", Name: "X Author", SortName: "Author, X", MetadataProvider: "openlibrary"} + if err := f.authors.Create(ctx, author); err != nil { + t.Fatal(err) + } + book := &models.Book{ForeignID: "OL-XB", AuthorID: author.ID, Title: "Excluded", SortTitle: "Excluded", Status: models.BookStatusWanted, Genres: []string{}} + if err := f.books.Create(ctx, book); err != nil { + t.Fatal(err) + } + if err := f.books.SetExcluded(ctx, book.ID, true); err != nil { + t.Fatal(err) + } + rec := httptest.NewRecorder() + f.h.AddBook(rec, covSrSeriesRequest(http.MethodPost, `{"bookId":`+strconv.FormatInt(book.ID, 10)+`}`, map[string]string{"id": strconv.FormatInt(s.ID, 10)})) + if rec.Code != http.StatusNotFound || covSrDecodeError(t, rec) != "book not found" { + t.Fatalf("add excluded = %d %s, want 404 book not found", rec.Code, rec.Body.String()) + } + ids, err := f.series.GetSeriesIDsForBook(ctx, book.ID) + if err != nil { + t.Fatal(err) + } + if len(ids) != 0 { + t.Fatalf("excluded book was linked into series %v", ids) + } +} + +// TestSeriesHardcoverEndpointsRefusedWhenFeatureDisabled checks every +// Hardcover link endpoint answers 404 with the disabled reason when the +// enhanced API is off, and that the refused DELETE leaves the stored link. +func TestSeriesHardcoverEndpointsRefusedWhenFeatureDisabled(t *testing.T) { + h, seriesRepo, _, _, _ := seriesFixtureWithProviderAndSettings(t, &stubSeriesProvider{}, nil, false) + ctx := context.Background() + s := &models.Series{ForeignID: "manual:series:off", Title: "Off"} + if err := seriesRepo.Create(ctx, s); err != nil { + t.Fatal(err) + } + if err := seriesRepo.UpsertHardcoverLink(ctx, &models.SeriesHardcoverLink{SeriesID: s.ID, HardcoverSeriesID: "hc-series:off", Confidence: 1, LinkedBy: "manual"}); err != nil { + t.Fatal(err) + } + sid := strconv.FormatInt(s.ID, 10) + cases := []struct { + name string + call http.HandlerFunc + body string + }{ + {"auto link", h.AutoLinkHardcover, ""}, + {"put link", h.PutHardcoverLink, `{"foreignId":"hc-series:other"}`}, + {"delete link", h.DeleteHardcoverLink, ""}, + {"diff", h.HardcoverDiff, ""}, + {"fill one book", h.Fill, `{"position":"1"}`}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + rec := httptest.NewRecorder() + tc.call(rec, covSrSeriesRequest(http.MethodPost, tc.body, map[string]string{"id": sid})) + if rec.Code != http.StatusNotFound || covSrDecodeError(t, rec) != "enhanced hardcover api disabled" { + t.Fatalf("status = %d %s, want 404 enhanced hardcover api disabled", rec.Code, rec.Body.String()) + } + }) + } + link, err := seriesRepo.GetHardcoverLink(ctx, s.ID) + if err != nil || link == nil || link.HardcoverSeriesID != "hc-series:off" { + t.Fatalf("refused requests changed the link: %+v %v", link, err) + } +} + +func TestSeriesHardcoverLinkEndpointsRejectBadIDs(t *testing.T) { + f := covSrNewSeriesFixture(t, &stubSeriesProvider{}) + for name, call := range map[string]http.HandlerFunc{ + "get": f.h.GetHardcoverLink, + "auto": f.h.AutoLinkHardcover, + "put": f.h.PutHardcoverLink, + "delete": f.h.DeleteHardcoverLink, + "diff": f.h.HardcoverDiff, + } { + t.Run(name, func(t *testing.T) { + rec := httptest.NewRecorder() + call(rec, covSrSeriesRequest(http.MethodPost, `{"foreignId":"hc-series:1"}`, map[string]string{"id": "abc"})) + if rec.Code != http.StatusBadRequest || covSrDecodeError(t, rec) != "invalid id" { + t.Fatalf("status = %d %s, want 400 invalid id", rec.Code, rec.Body.String()) + } + }) + } +} + +// TestSeriesAutoLinkHardcoverOutcomes covers the auto-link decisions that do +// not end in a stored link, plus the author-agreement evidence path that does. +func TestSeriesAutoLinkHardcoverOutcomes(t *testing.T) { + ctx := context.Background() + + t.Run("missing series", func(t *testing.T) { + f := covSrNewSeriesFixture(t, &stubSeriesProvider{}) + rec := httptest.NewRecorder() + f.h.AutoLinkHardcover(rec, covSrSeriesRequest(http.MethodPost, "", map[string]string{"id": "9999"})) + if rec.Code != http.StatusNotFound || covSrDecodeError(t, rec) != "series not found" { + t.Fatalf("status = %d %s, want 404", rec.Code, rec.Body.String()) + } + }) + + t.Run("no aggregator", func(t *testing.T) { + f := covSrNewSeriesFixture(t, nil) + s := f.createSeries(t, "manual:series:none", "None") + rec := httptest.NewRecorder() + f.h.AutoLinkHardcover(rec, covSrSeriesRequest(http.MethodPost, "", map[string]string{"id": strconv.FormatInt(s.ID, 10)})) + if rec.Code != http.StatusBadGateway { + t.Fatalf("status = %d %s, want 502", rec.Code, rec.Body.String()) + } + }) + + t.Run("catalog failure while scoring", func(t *testing.T) { + provider := &stubSeriesProvider{ + searchResults: []metadata.SeriesSearchResult{{ForeignID: "hc-series:boom", Title: "Boom"}}, + catalogErr: errors.New("hardcover down"), + } + f := covSrNewSeriesFixture(t, provider) + s := f.createSeries(t, "manual:series:boom", "Boom") + rec := httptest.NewRecorder() + f.h.AutoLinkHardcover(rec, covSrSeriesRequest(http.MethodPost, "", map[string]string{"id": strconv.FormatInt(s.ID, 10)})) + if rec.Code != http.StatusBadGateway || covSrDecodeError(t, rec) != "metadata provider unavailable" { + t.Fatalf("status = %d %s, want 502", rec.Code, rec.Body.String()) + } + if link, _ := f.series.GetHardcoverLink(ctx, s.ID); link != nil { + t.Fatalf("failed scoring stored a link: %+v", link) + } + }) + + t.Run("no candidates", func(t *testing.T) { + f := covSrNewSeriesFixture(t, &stubSeriesProvider{}) + s := f.createSeries(t, "manual:series:empty", "Nothing Matches") + rec := httptest.NewRecorder() + f.h.AutoLinkHardcover(rec, covSrSeriesRequest(http.MethodPost, "", map[string]string{"id": strconv.FormatInt(s.ID, 10)})) + if rec.Code != http.StatusOK { + t.Fatalf("status = %d %s, want 200", rec.Code, rec.Body.String()) + } + var resp seriesHardcoverAutoResponse + if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { + t.Fatal(err) + } + if resp.Linked || resp.Reason != "no candidates" || resp.Candidates == nil || len(resp.Candidates) != 0 { + t.Fatalf("response = %+v, want unlinked with no candidates", resp) + } + }) + + t.Run("low confidence", func(t *testing.T) { + provider := &stubSeriesProvider{ + searchResults: []metadata.SeriesSearchResult{{ForeignID: "hc-series:far", Title: "Completely Different Saga"}}, + } + f := covSrNewSeriesFixture(t, provider) + s := f.createSeries(t, "manual:series:low", "Quiet Garden Mysteries") + rec := httptest.NewRecorder() + f.h.AutoLinkHardcover(rec, covSrSeriesRequest(http.MethodPost, "", map[string]string{"id": strconv.FormatInt(s.ID, 10)})) + var resp seriesHardcoverAutoResponse + if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { + t.Fatal(err) + } + if rec.Code != http.StatusOK || resp.Linked || resp.Reason != "low confidence" || len(resp.Candidates) != 1 { + t.Fatalf("status %d response = %+v, want unlinked low confidence", rec.Code, resp) + } + if link, _ := f.series.GetHardcoverLink(ctx, s.ID); link != nil { + t.Fatalf("low confidence stored a link: %+v", link) + } + }) + + t.Run("author agreement is evidence", func(t *testing.T) { + // Same series title, no overlapping book titles, but the catalog's + // author is the author of the local book. The handler has to look the + // author up by id because series books carry no joined author. + catalog := &metadata.SeriesCatalog{ + ForeignID: "hc-series:lantern", + ProviderID: "lantern", + Slug: "lantern-chronicles", + Title: "Lantern Chronicles", + AuthorName: "Mara Quill", + BookCount: 1, + Books: []metadata.SeriesCatalogBook{{ + ForeignID: "hc:unshared-volume", Title: "An Unshared Volume", Position: "1", + Book: models.Book{ForeignID: "hc:unshared-volume", Title: "An Unshared Volume"}, + }}, + } + provider := &stubSeriesProvider{ + // The search hit omits the slug, title, author and count, so the + // handler must fill them from the catalog. + searchResults: []metadata.SeriesSearchResult{{ForeignID: catalog.ForeignID}}, + catalogs: map[string]*metadata.SeriesCatalog{catalog.ForeignID: catalog}, + } + f := covSrNewSeriesFixture(t, provider) + s := f.createSeries(t, "manual:series:lantern", "Lantern Chronicles") + author := &models.Author{ForeignID: "OL-MQ", Name: "Mara Quill", SortName: "Quill, Mara", MetadataProvider: "openlibrary"} + if err := f.authors.Create(ctx, author); err != nil { + t.Fatal(err) + } + for i, title := range []string{"Local Only Story", "Another Local Story"} { + book := &models.Book{ForeignID: "OL-L" + strconv.Itoa(i), AuthorID: author.ID, Title: title, SortTitle: title, Status: models.BookStatusWanted, Genres: []string{}} + if err := f.books.Create(ctx, book); err != nil { + t.Fatal(err) + } + if _, err := f.series.LinkBookIfMissing(ctx, s.ID, book.ID, strconv.Itoa(i+5), true); err != nil { + t.Fatal(err) + } + } + + rec := httptest.NewRecorder() + f.h.AutoLinkHardcover(rec, covSrSeriesRequest(http.MethodPost, "", map[string]string{"id": strconv.FormatInt(s.ID, 10)})) + if rec.Code != http.StatusOK { + t.Fatalf("status = %d %s", rec.Code, rec.Body.String()) + } + var resp seriesHardcoverAutoResponse + if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { + t.Fatal(err) + } + if !resp.Linked || resp.Link == nil { + t.Fatalf("response = %+v, want linked on author agreement", resp) + } + top := resp.Candidates[0] + if top.Slug != "lantern-chronicles" || top.Title != "Lantern Chronicles" || top.AuthorName != "Mara Quill" || top.BookCount != 1 { + t.Fatalf("candidate not filled from catalog: %+v", top) + } + stored, err := f.series.GetHardcoverLink(ctx, s.ID) + if err != nil || stored == nil { + t.Fatalf("link not stored: %v %v", stored, err) + } + if stored.LinkedBy != "auto" || stored.HardcoverSeriesID != catalog.ForeignID || stored.HardcoverSlug != "lantern-chronicles" { + t.Fatalf("stored link = %+v", stored) + } + }) +} + +// TestSeriesPutHardcoverLinkWithoutCatalog covers the two ways a manual link +// is stored from the request body alone: no aggregator, and an aggregator +// that has no catalog for the id. A non-positive confidence defaults to 1. +func TestSeriesPutHardcoverLinkWithoutCatalog(t *testing.T) { + for name, provider := range map[string]*stubSeriesProvider{ + "no aggregator": nil, + "catalog not found": {}, + } { + t.Run(name, func(t *testing.T) { + f := covSrNewSeriesFixture(t, provider) + s := f.createSeries(t, "manual:series:put", "Put") + body := `{"foreignId":" hc-series:777 ","slug":" put-slug ","title":"Echoed Title","authorName":"Echo","bookCount":3,"confidence":-2}` + rec := httptest.NewRecorder() + f.h.PutHardcoverLink(rec, covSrSeriesRequest(http.MethodPut, body, map[string]string{"id": strconv.FormatInt(s.ID, 10)})) + if rec.Code != http.StatusOK { + t.Fatalf("status = %d %s, want 200", rec.Code, rec.Body.String()) + } + stored, err := f.series.GetHardcoverLink(context.Background(), s.ID) + if err != nil || stored == nil { + t.Fatalf("link not stored: %v %v", stored, err) + } + if stored.HardcoverSeriesID != "hc-series:777" || stored.HardcoverProviderID != "777" || + stored.HardcoverSlug != "put-slug" || stored.HardcoverTitle != "Echoed Title" || + stored.Confidence != 1 || stored.LinkedBy != "manual" { + t.Fatalf("stored link = %+v", stored) + } + }) + } +} + +func TestSeriesHardcoverDiffRequiresLink(t *testing.T) { + f := covSrNewSeriesFixture(t, &stubSeriesProvider{}) + t.Run("missing series", func(t *testing.T) { + rec := httptest.NewRecorder() + f.h.HardcoverDiff(rec, covSrSeriesRequest(http.MethodGet, "", map[string]string{"id": "9999"})) + if rec.Code != http.StatusNotFound || covSrDecodeError(t, rec) != "series not found" { + t.Fatalf("status = %d %s, want 404 series not found", rec.Code, rec.Body.String()) + } + }) + t.Run("unlinked series", func(t *testing.T) { + s := f.createSeries(t, "manual:series:unlinked", "Unlinked") + rec := httptest.NewRecorder() + f.h.HardcoverDiff(rec, covSrSeriesRequest(http.MethodGet, "", map[string]string{"id": strconv.FormatInt(s.ID, 10)})) + if rec.Code != http.StatusNotFound || covSrDecodeError(t, rec) != "hardcover link not found" { + t.Fatalf("status = %d %s, want 404 hardcover link not found", rec.Code, rec.Body.String()) + } + }) + t.Run("catalog vanished upstream", func(t *testing.T) { + s := f.createSeries(t, "manual:series:gone", "Gone") + f.link(t, s.ID, "hc-series:gone") + rec := httptest.NewRecorder() + f.h.HardcoverDiff(rec, covSrSeriesRequest(http.MethodGet, "", map[string]string{"id": strconv.FormatInt(s.ID, 10)})) + if rec.Code != http.StatusBadGateway { + t.Fatalf("status = %d %s, want 502", rec.Code, rec.Body.String()) + } + }) +} + +// TestSeriesFillSingleBookRefusals covers the selector form of Fill (one +// catalog book) when it cannot resolve that book. None of them may create a +// book or queue a search. +func TestSeriesFillSingleBookRefusals(t *testing.T) { + ctx := context.Background() + catalog := &metadata.SeriesCatalog{ + ForeignID: "hc-series:fill", Title: "Fill Series", AuthorName: "Fill Author", + Books: []metadata.SeriesCatalogBook{{ + ForeignID: "hc:fill-1", Title: "Fill One", Position: "1", + Book: models.Book{ForeignID: "hc:fill-1", Title: "Fill One"}, + }}, + } + cases := []struct { + name string + provider *stubSeriesProvider + setup func(t *testing.T, f *covSrSeriesFixture) int64 + body string + wantCode int + wantErr string + }{ + { + name: "no aggregator", + provider: nil, + setup: func(t *testing.T, f *covSrSeriesFixture) int64 { return f.createSeries(t, "manual:a", "A").ID }, + body: `{"position":"1"}`, + wantCode: http.StatusInternalServerError, + wantErr: "internal server error", + }, + { + name: "missing series", + provider: &stubSeriesProvider{}, + setup: func(*testing.T, *covSrSeriesFixture) int64 { return 9999 }, + body: `{"position":"1"}`, + wantCode: http.StatusNotFound, + wantErr: "series not found", + }, + { + name: "series has no hardcover link", + provider: &stubSeriesProvider{}, + setup: func(t *testing.T, f *covSrSeriesFixture) int64 { return f.createSeries(t, "manual:b", "B").ID }, + body: `{"foreignBookId":"hc:fill-1"}`, + wantCode: http.StatusNotFound, + wantErr: "hardcover book not found in missing catalog", + }, + { + name: "catalog provider failure", + provider: &stubSeriesProvider{catalogErr: errors.New("hardcover down")}, + setup: func(t *testing.T, f *covSrSeriesFixture) int64 { + s := f.createSeries(t, "manual:c", "C") + f.link(t, s.ID, catalog.ForeignID) + return s.ID + }, + body: `{"position":"1"}`, + wantCode: http.StatusBadGateway, + wantErr: "metadata provider unavailable", + }, + { + name: "catalog missing upstream", + provider: &stubSeriesProvider{}, + setup: func(t *testing.T, f *covSrSeriesFixture) int64 { + s := f.createSeries(t, "manual:d", "D") + f.link(t, s.ID, catalog.ForeignID) + return s.ID + }, + body: `{"position":"1"}`, + wantCode: http.StatusNotFound, + wantErr: "hardcover book not found in missing catalog", + }, + { + name: "selector matches nothing missing", + provider: &stubSeriesProvider{catalogs: map[string]*metadata.SeriesCatalog{catalog.ForeignID: catalog}}, + setup: func(t *testing.T, f *covSrSeriesFixture) int64 { + s := f.createSeries(t, "manual:e", "E") + f.link(t, s.ID, catalog.ForeignID) + return s.ID + }, + body: `{"foreignBookId":"hc:not-in-catalog","providerId":"zzz"}`, + wantCode: http.StatusNotFound, + wantErr: "hardcover book not found in missing catalog", + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + f := covSrNewSeriesFixture(t, tc.provider) + id := tc.setup(t, f) + rec := httptest.NewRecorder() + f.h.Fill(rec, covSrSeriesRequest(http.MethodPost, tc.body, map[string]string{"id": strconv.FormatInt(id, 10)})) + if rec.Code != tc.wantCode { + t.Fatalf("status = %d, want %d: %s", rec.Code, tc.wantCode, rec.Body.String()) + } + if got := covSrDecodeError(t, rec); got != tc.wantErr { + t.Fatalf("error = %q, want %q", got, tc.wantErr) + } + if b, _ := f.books.GetByForeignID(ctx, "hc:fill-1"); b != nil { + t.Fatalf("refused fill created %+v", b) + } + select { + case b := <-f.searcher.ch: + t.Fatalf("refused fill queued a search for %q", b.Title) + default: + } + }) + } +} + +func TestSeriesTitleTiebreakHelpers(t *testing.T) { + cases := []struct { + name string + candidate, current, target string + want bool + }{ + {"candidate is the exact title", "The Way of Kings", "Kings", "The Way of Kings", true}, + {"current is the exact title", "Kings", "The Way of Kings", "The Way of Kings", false}, + {"candidate carries a series suffix", "The Way of Kings Stormlight Archive Book One", "Kings and Queens of Old", "The Way of Kings", true}, + {"current carries a series suffix", "Kings and Queens of Old", "The Way of Kings Stormlight Archive Book One", "The Way of Kings", false}, + {"closer spelling wins on ratio", "Mistborne", "Elantris", "Mistborn", true}, + {"farther spelling loses on ratio", "Elantris", "Mistborne", "Mistborn", false}, + {"identical cleaned titles fall back to length", "Dune", "Dune!!!", "Dune", true}, + {"identical cleaned titles, longer loses", "Dune!!!", "Dune", "Dune", false}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := titleTiebreakWins(tc.candidate, tc.current, tc.target); got != tc.want { + t.Fatalf("titleTiebreakWins(%q, %q, %q) = %v, want %v", tc.candidate, tc.current, tc.target, got, tc.want) + } + }) + } + + // A candidate shorter than the target exercises absInt's negative branch. + if !titleLengthCloser("ab", "abcdefgh", "abcd") { + t.Fatal("titleLengthCloser: 2 away should beat 4 away") + } + if titleLengthCloser("abcdefgh", "ab", "abcd") { + t.Fatal("titleLengthCloser: 4 away should not beat 2 away") + } + if absInt(-3) != 3 || absInt(3) != 3 { + t.Fatal("absInt") + } + if titleBoundaryMatch("anything", "") || titleBoundaryMatch("same", "same") { + t.Fatal("titleBoundaryMatch must refuse an empty target and equality") + } +} + +func TestSeriesHardcoverAuthorFallbackID(t *testing.T) { + if got := hardcoverAuthorFallbackID("Mara Quill"); !strings.HasPrefix(got, "hc-author:") || !strings.Contains(got, "-") || strings.Contains(got, " ") { + t.Fatalf("fallback id = %q, want hc-author:", got) + } + if got := hardcoverAuthorFallbackID(""); got != "hc-author:unknown" { + t.Fatalf("blank name fallback = %q, want hc-author:unknown", got) + } +} + +func TestSeriesFindCatalogBook(t *testing.T) { + books := []metadata.SeriesCatalogBook{ + {ForeignID: "hc:boxset", Position: "1", Title: "Box Set"}, + {ForeignID: "", Position: "2", Title: "Nested", Book: models.Book{ForeignID: "hc:nested"}}, + {ForeignID: "hc:real-one", Position: "1", Title: "Real One"}, + } + if got, ok := findCatalogBook(books, "hc:real-one", "1"); !ok || got.Title != "Real One" { + t.Fatalf("exact id = %+v %v, want Real One over the position-1 box set", got, ok) + } + if got, ok := findCatalogBook(books, "hc:nested", ""); !ok || got.Title != "Nested" { + t.Fatalf("nested book id = %+v %v, want Nested", got, ok) + } + if got, ok := findCatalogBook(books, "hc:unknown", "2"); !ok || got.Title != "Nested" { + t.Fatalf("position fallback = %+v %v, want Nested", got, ok) + } + if _, ok := findCatalogBook(books, "hc:unknown", "9"); ok { + t.Fatal("no id or position match must report not found") + } +}