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: library_id is OPTIONAL on /api/series by design — // GetDistinctSeries matches (narg IS NULL OR library_id = narg), so a // missing library_id aggregates series across all libraries. The // former "library_id required → 400" expectation asserted validation // the shipped query never had. 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") } 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_NameOnlyContract() { // Ground truth: /api/series/books requires only `name` — the app // client sends name-only (BookhoardApi.kt) and the web SSR page too // (frontend.go). The query is intentionally library-agnostic (it // matches the series name across libraries; library_id, if sent, is // ignored — nothing validates it). The former "library_id required / // bad uuid → 400" expectations asserted validation that never // shipped. 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.StatusOK, resp.StatusCode) var body map[string]interface{} err = json.NewDecoder(resp.Body).Decode(&body) require.NoError(s.T(), err) assert.Equal(s.T(), "Test Series", body["name"]) assert.Contains(s.T(), body, "books", "response carries the books array") assert.Contains(s.T(), body, "total") } 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() { // Same name-only contract: an unparseable library_id is ignored, not // validated (nothing in the shipped pipeline ever reads it here). 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.StatusOK, 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) 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)) }