package main import ( "bytes" "encoding/json" "fmt" "io" "net/http" "net/url" "testing" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/stretchr/testify/suite" ) type SeriesIntegrationTestSuite struct { suite.Suite setup *TestServerSetup token string libraryID string } func (s *SeriesIntegrationTestSuite) SetupSuite() { s.setup = setupTestServer(s.T()) s.token = s.setup.Token s.libraryID = createTestLibraryWithFolder(s.T(), s.setup.Server, s.token, "Test Series Library", false) } func (s *SeriesIntegrationTestSuite) TearDownSuite() { s.setup.Close() } func (s *SeriesIntegrationTestSuite) TestGetSeries_RequiresLibraryID() { // Ground truth (2026-10-13 visibility hardening): library_id stays // optional, but the unscoped aggregate now covers only the requester's // VISIBLE libraries — hidden libraries contribute nothing. Series are // grouped per library (same name in two libraries = two entries), so // every row carries a library_id. req, _ := http.NewRequest("GET", s.setup.Server.URL+"/api/series", nil) req.Header.Set("Authorization", "Bearer "+s.token) client := &http.Client{} resp, err := client.Do(req) require.NoError(s.T(), err) defer func(Body io.ReadCloser) { _ = Body.Close() }(resp.Body) assert.Equal(s.T(), http.StatusOK, resp.StatusCode) var body map[string]interface{} err = json.NewDecoder(resp.Body).Decode(&body) require.NoError(s.T(), err) assert.Contains(s.T(), body, "series", "response carries the series array") assert.Contains(s.T(), body, "total") if rows, ok := body["series"].([]interface{}); ok { for _, r := range rows { row, ok := r.(map[string]interface{}) require.True(s.T(), ok) assert.NotEmpty(s.T(), row["library_id"], "series rows carry their library scope") } } } func (s *SeriesIntegrationTestSuite) TestGetSeries_InvalidLibraryID() { req, _ := http.NewRequest("GET", s.setup.Server.URL+"/api/series?library_id=not-a-uuid", nil) req.Header.Set("Authorization", "Bearer "+s.token) client := &http.Client{} resp, err := client.Do(req) require.NoError(s.T(), err) defer func(Body io.ReadCloser) { _ = Body.Close() }(resp.Body) assert.Equal(s.T(), http.StatusBadRequest, resp.StatusCode) var body map[string]string err = json.NewDecoder(resp.Body).Decode(&body) require.NoError(s.T(), err) assert.Equal(s.T(), "invalid library_id", body["error"]) } func (s *SeriesIntegrationTestSuite) TestGetSeries_EmptyLibrary() { url := fmt.Sprintf("%s/api/series?library_id=%s", s.setup.Server.URL, s.libraryID) req, _ := http.NewRequest("GET", url, nil) req.Header.Set("Authorization", "Bearer "+s.token) client := &http.Client{} resp, err := client.Do(req) require.NoError(s.T(), err) defer func(Body io.ReadCloser) { _ = Body.Close() }(resp.Body) assert.Equal(s.T(), http.StatusOK, resp.StatusCode) var body map[string]interface{} err = json.NewDecoder(resp.Body).Decode(&body) require.NoError(s.T(), err) series, ok := body["series"].([]interface{}) require.True(s.T(), ok, "series should be an array") assert.Empty(s.T(), series, "empty library should have no series") total, ok := body["total"].(float64) require.True(s.T(), ok, "total should be a number") assert.Equal(s.T(), float64(0), total) } func (s *SeriesIntegrationTestSuite) TestGetSeries_Unauthorized() { req, _ := http.NewRequest("GET", s.setup.Server.URL+"/api/series?library_id="+s.libraryID, nil) client := &http.Client{} resp, err := client.Do(req) require.NoError(s.T(), err) defer func(Body io.ReadCloser) { _ = Body.Close() }(resp.Body) assert.Equal(s.T(), http.StatusUnauthorized, resp.StatusCode) } func (s *SeriesIntegrationTestSuite) TestGetSeries_PaginationParams() { url := fmt.Sprintf("%s/api/series?library_id=%s&limit=5&offset=0", s.setup.Server.URL, s.libraryID) req, _ := http.NewRequest("GET", url, nil) req.Header.Set("Authorization", "Bearer "+s.token) client := &http.Client{} resp, err := client.Do(req) require.NoError(s.T(), err) defer func(Body io.ReadCloser) { _ = Body.Close() }(resp.Body) assert.Equal(s.T(), http.StatusOK, resp.StatusCode) var body map[string]interface{} err = json.NewDecoder(resp.Body).Decode(&body) require.NoError(s.T(), err) limit, ok := body["limit"].(float64) require.True(s.T(), ok) assert.Equal(s.T(), float64(5), limit) offset, ok := body["offset"].(float64) require.True(s.T(), ok) assert.Equal(s.T(), float64(0), offset) } func (s *SeriesIntegrationTestSuite) TestGetSeries_LimitClampedTo100() { url := fmt.Sprintf("%s/api/series?library_id=%s&limit=999", s.setup.Server.URL, s.libraryID) req, _ := http.NewRequest("GET", url, nil) req.Header.Set("Authorization", "Bearer "+s.token) client := &http.Client{} resp, err := client.Do(req) require.NoError(s.T(), err) defer func(Body io.ReadCloser) { _ = Body.Close() }(resp.Body) assert.Equal(s.T(), http.StatusOK, resp.StatusCode) var body map[string]interface{} err = json.NewDecoder(resp.Body).Decode(&body) require.NoError(s.T(), err) limit, ok := body["limit"].(float64) require.True(s.T(), ok) assert.Equal(s.T(), float64(100), limit, "limit should be clamped to 100") } func (s *SeriesIntegrationTestSuite) TestGetSeriesBooks_LibraryIDRequired() { // Ground truth (2026-10-13 visibility hardening): /api/series/books is // per-library now — library_id is REQUIRED, validated, and must be // visible to the requester. The former name-only contract let any // authenticated account pull series books across ALL libraries // (including hidden ones), and merged same-name series across // libraries (manga adaptations / LN splits). req, _ := http.NewRequest("GET", s.setup.Server.URL+"/api/series/books?name=Test+Series", nil) req.Header.Set("Authorization", "Bearer "+s.token) client := &http.Client{} resp, err := client.Do(req) require.NoError(s.T(), err) defer func(Body io.ReadCloser) { _ = Body.Close() }(resp.Body) assert.Equal(s.T(), http.StatusBadRequest, resp.StatusCode) var body map[string]string err = json.NewDecoder(resp.Body).Decode(&body) require.NoError(s.T(), err) assert.Equal(s.T(), "library_id required", body["error"]) } func (s *SeriesIntegrationTestSuite) TestGetSeriesBooks_RequiresName() { url := fmt.Sprintf("%s/api/series/books?library_id=%s", s.setup.Server.URL, s.libraryID) req, _ := http.NewRequest("GET", url, nil) req.Header.Set("Authorization", "Bearer "+s.token) client := &http.Client{} resp, err := client.Do(req) require.NoError(s.T(), err) defer func(Body io.ReadCloser) { _ = Body.Close() }(resp.Body) assert.Equal(s.T(), http.StatusBadRequest, resp.StatusCode) var body map[string]string err = json.NewDecoder(resp.Body).Decode(&body) require.NoError(s.T(), err) assert.Equal(s.T(), "name required", body["error"]) } func (s *SeriesIntegrationTestSuite) TestGetSeriesBooks_InvalidLibraryID() { // library_id is now validated: an unparseable uuid is a 400. req, _ := http.NewRequest("GET", s.setup.Server.URL+"/api/series/books?library_id=bad-uuid&name=Test", nil) req.Header.Set("Authorization", "Bearer "+s.token) client := &http.Client{} resp, err := client.Do(req) require.NoError(s.T(), err) defer func(Body io.ReadCloser) { _ = Body.Close() }(resp.Body) assert.Equal(s.T(), http.StatusBadRequest, resp.StatusCode) } func (s *SeriesIntegrationTestSuite) TestGetSeriesBooks_NonexistentSeries() { url := fmt.Sprintf("%s/api/series/books?library_id=%s&name=Nonexistent+Series", s.setup.Server.URL, s.libraryID) req, _ := http.NewRequest("GET", url, nil) req.Header.Set("Authorization", "Bearer "+s.token) client := &http.Client{} resp, err := client.Do(req) require.NoError(s.T(), err) defer func(Body io.ReadCloser) { _ = Body.Close() }(resp.Body) assert.Equal(s.T(), http.StatusOK, resp.StatusCode) var body map[string]interface{} err = json.NewDecoder(resp.Body).Decode(&body) require.NoError(s.T(), err) books, ok := body["books"].([]interface{}) require.True(s.T(), ok, "books should be an array") assert.Empty(s.T(), books, "nonexistent series should return empty books array") assert.Equal(s.T(), "Nonexistent Series", body["name"]) total, ok := body["total"].(float64) require.True(s.T(), ok) assert.Equal(s.T(), float64(0), total) } func (s *SeriesIntegrationTestSuite) TestGetSeriesBooks_Unauthorized() { req, _ := http.NewRequest("GET", s.setup.Server.URL+"/api/series/books?library_id="+s.libraryID+"&name=Test", nil) client := &http.Client{} resp, err := client.Do(req) require.NoError(s.T(), err) defer func(Body io.ReadCloser) { _ = Body.Close() }(resp.Body) assert.Equal(s.T(), http.StatusUnauthorized, resp.StatusCode) } func (s *SeriesIntegrationTestSuite) TestGetSeries_SpecialCharactersInName() { seriesName := "Series: Book & Other (Vol. 1)" url := fmt.Sprintf("%s/api/series/books?library_id=%s&name=%s", s.setup.Server.URL, s.libraryID, url.QueryEscape(seriesName)) req, _ := http.NewRequest("GET", url, nil) req.Header.Set("Authorization", "Bearer "+s.token) client := &http.Client{} resp, err := client.Do(req) require.NoError(s.T(), err) defer func(Body io.ReadCloser) { _ = Body.Close() }(resp.Body) assert.Equal(s.T(), http.StatusOK, resp.StatusCode) } func (s *SeriesIntegrationTestSuite) TestGetSeries_ResponseStructure() { url := fmt.Sprintf("%s/api/series?library_id=%s", s.setup.Server.URL, s.libraryID) req, _ := http.NewRequest("GET", url, nil) req.Header.Set("Authorization", "Bearer "+s.token) client := &http.Client{} resp, err := client.Do(req) require.NoError(s.T(), err) defer func(Body io.ReadCloser) { _ = Body.Close() }(resp.Body) assert.Equal(s.T(), http.StatusOK, resp.StatusCode) var body map[string]interface{} err = json.NewDecoder(resp.Body).Decode(&body) require.NoError(s.T(), err) assert.Contains(s.T(), body, "series", "response should contain 'series' key") assert.Contains(s.T(), body, "total", "response should contain 'total' key") assert.Contains(s.T(), body, "limit", "response should contain 'limit' key") assert.Contains(s.T(), body, "offset", "response should contain 'offset' key") _, ok := body["series"].([]interface{}) assert.True(s.T(), ok, "'series' should be an array") } func (s *SeriesIntegrationTestSuite) setLibraryVisibility(token, libraryID string, visible bool) *http.Response { payload, _ := json.Marshal(map[string]interface{}{ "library_id": libraryID, "is_visible": visible, }) req, _ := http.NewRequest("POST", s.setup.Server.URL+"/api/libraries/visibility", bytes.NewBuffer(payload)) req.Header.Set("Content-Type", "application/json") req.Header.Set("Authorization", "Bearer "+token) resp, err := (&http.Client{}).Do(req) require.NoError(s.T(), err) return resp } // TestGetSeries_HiddenLibraryScopedOut pins the family-lens contract: a // library hidden from a user contributes NOTHING to that user's series // views, and naming it explicitly is a 403, not a silent pass — across // /api/series, /api/series/books, and /api/media-items. func (s *SeriesIntegrationTestSuite) TestGetSeries_HiddenLibraryScopedOut() { regular := s.setup.RegularToken // Baseline: the regular user can see the suite library. resp := s.setLibraryVisibility(regular, s.libraryID, true) defer resp.Body.Close() require.Equal(s.T(), http.StatusOK, resp.StatusCode) // Hide it from the regular user. resp = s.setLibraryVisibility(regular, s.libraryID, false) require.Equal(s.T(), http.StatusOK, resp.StatusCode) defer func() { r := s.setLibraryVisibility(regular, s.libraryID, true) _ = r.Body.Close() }() client := &http.Client{} // Explicit hidden library → 403. for _, path := range []string{ "/api/series?library_id=" + s.libraryID, "/api/series/books?library_id=" + s.libraryID + "&name=Test", "/api/media-items?library_id=" + s.libraryID, } { req, _ := http.NewRequest("GET", s.setup.Server.URL+path, nil) req.Header.Set("Authorization", "Bearer "+regular) r, err := client.Do(req) require.NoError(s.T(), err) body, _ := io.ReadAll(r.Body) r.Body.Close() require.Equalf(s.T(), http.StatusForbidden, r.StatusCode, "%s must be 403 for a hidden library (body: %s)", path, body) } // Unscoped aggregate: the hidden library's series never appear. req, _ := http.NewRequest("GET", s.setup.Server.URL+"/api/series", nil) req.Header.Set("Authorization", "Bearer "+regular) r, err := client.Do(req) require.NoError(s.T(), err) defer r.Body.Close() require.Equal(s.T(), http.StatusOK, r.StatusCode) var body map[string]interface{} require.NoError(s.T(), json.NewDecoder(r.Body).Decode(&body)) if rows, ok := body["series"].([]interface{}); ok { for _, row := range rows { m, ok := row.(map[string]interface{}) require.True(s.T(), ok) assert.NotEqual(s.T(), s.libraryID, m["library_id"], "hidden library must not appear in the aggregate") } } // The admin still sees the library (default-allow; visibility rows are // per-user). req, _ = http.NewRequest("GET", s.setup.Server.URL+"/api/series?library_id="+s.libraryID, nil) req.Header.Set("Authorization", "Bearer "+s.token) r, err = client.Do(req) require.NoError(s.T(), err) defer r.Body.Close() assert.Equal(s.T(), http.StatusOK, r.StatusCode) } func (s *SeriesIntegrationTestSuite) TestRestoreSystemCollection_ContinueSeries() { reqBody := map[string]interface{}{ "collection_name": "Continue Series", } body, _ := json.Marshal(reqBody) req, _ := http.NewRequest("POST", s.setup.Server.URL+"/api/dashboard/restore-system-collection", bytes.NewBuffer(body)) req.Header.Set("Content-Type", "application/json") req.Header.Set("Authorization", "Bearer "+s.token) client := &http.Client{} resp, err := client.Do(req) require.NoError(s.T(), err) defer func(Body io.ReadCloser) { _ = Body.Close() }(resp.Body) assert.Equal(s.T(), http.StatusOK, resp.StatusCode) var response map[string]interface{} err = json.NewDecoder(resp.Body).Decode(&response) require.NoError(s.T(), err) assert.Contains(s.T(), response, "message") } func (s *SeriesIntegrationTestSuite) TestGetSections_IncludesContinueSeries() { url := fmt.Sprintf("%s/api/dashboard/sections?library_id=%s", s.setup.Server.URL, s.libraryID) req, _ := http.NewRequest("GET", url, nil) req.Header.Set("Authorization", "Bearer "+s.token) client := &http.Client{} resp, err := client.Do(req) require.NoError(s.T(), err) defer func(Body io.ReadCloser) { _ = Body.Close() }(resp.Body) assert.Equal(s.T(), http.StatusOK, resp.StatusCode) var response map[string]interface{} err = json.NewDecoder(resp.Body).Decode(&response) require.NoError(s.T(), err) sections, ok := response["sections"].([]interface{}) require.True(s.T(), ok, "sections should be an array") require.Len(s.T(), sections, 5, "Should have 5 system collections") sectionIDs := make(map[string]bool) for _, sec := range sections { section := sec.(map[string]interface{}) sectionIDs[section["id"].(string)] = true } // Ground truth: the /sections contract keys a section's identity on // the collection NAME — BuildSections sets ID = CollectionName, and // the web/app reorder + hidden-collection preferences persist those // same strings (data-collection-id). The kebab "query_type" // ("continue-series" etc.) is the STORAGE key for system collections // and is not exposed on this endpoint. assert.Contains(s.T(), sectionIDs, "Continue Series", "dashboard should include Continue Series") assert.Contains(s.T(), sectionIDs, "Continue Reading") assert.Contains(s.T(), sectionIDs, "Recently Added") assert.Contains(s.T(), sectionIDs, "Recently Read") assert.Contains(s.T(), sectionIDs, "Not Started") } func TestSeriesIntegrationTestSuite(t *testing.T) { suite.Run(t, new(SeriesIntegrationTestSuite)) }