package main import ( "bytes" "encoding/json" "fmt" "io" "net/http" "testing" "github.com/google/uuid" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/stretchr/testify/suite" ) type DashboardIntegrationTestSuite struct { suite.Suite setup *TestServerSetup } func (s *DashboardIntegrationTestSuite) SetupSuite() { s.setup = setupTestServer(s.T()) } func (s *DashboardIntegrationTestSuite) TearDownSuite() { s.setup.Close() } func (s *DashboardIntegrationTestSuite) TestGetSections_EndToEndFlow() { token := s.setup.Token // Create test library libraryID := createTestLibraryWithFolder(s.T(), s.setup.Server, token, "Test Library", false) // Execute API call url := fmt.Sprintf("%s/api/dashboard/sections?library_id=%s", s.setup.Server.URL, libraryID) req, _ := http.NewRequest("GET", url, nil) req.Header.Set("Authorization", "Bearer "+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") // Verify response structure sectionMap := make(map[string]map[string]interface{}) for _, sec := range sections { section := sec.(map[string]interface{}) sectionMap[section["id"].(string)] = section // Verify field types assert.IsType(s.T(), false, section["is_system"], "is_system should be boolean") assert.IsType(s.T(), "", section["title"], "title should be string") assert.IsType(s.T(), "", section["description"], "description should be string") assert.IsType(s.T(), "", section["icon"], "icon should be string") assert.IsType(s.T(), float64(0), section["priority"], "priority should be number") } // Verify all system collections exist (names are the section ids; // the canonical seed names are Title-case). assert.Contains(s.T(), sectionMap, "Continue Reading") assert.Contains(s.T(), sectionMap, "Recently Added") assert.Contains(s.T(), sectionMap, "Recently Read") assert.Contains(s.T(), sectionMap, "Not Started") assert.Contains(s.T(), sectionMap, "Continue Series") // Verify continue-reading is a system collection continueReading := sectionMap["Continue Reading"] assert.True(s.T(), continueReading["is_system"].(bool), "continue-reading should be system collection") assert.Equal(s.T(), "📖", continueReading["icon"], "icon should match") } func (s *DashboardIntegrationTestSuite) TestGetSections_MissingLibraryID() { token := s.setup.Token // No library_id = the All Libraries view: the endpoint aggregates // sections across every library (200), it does not 400. req, _ := http.NewRequest("GET", s.setup.Server.URL+"/api/dashboard/sections", nil) req.Header.Set("Authorization", "Bearer "+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") assert.NotEmpty(s.T(), sections, "All Libraries view should aggregate sections") } func (s *DashboardIntegrationTestSuite) TestGetSections_InvalidLibraryID() { token := s.setup.Token req, _ := http.NewRequest("GET", s.setup.Server.URL+"/api/dashboard/sections?library_id=invalid-uuid", nil) req.Header.Set("Authorization", "Bearer "+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 *DashboardIntegrationTestSuite) TestGetSections_Unauthorized() { req, _ := http.NewRequest("GET", s.setup.Server.URL+"/api/dashboard/sections?library_id="+uuid.New().String(), 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 *DashboardIntegrationTestSuite) TestUpdatePreferences_Success() { token := s.setup.Token libraryID := createTestLibraryWithFolder(s.T(), s.setup.Server, token, "Test Library", false) reqBody := map[string]interface{}{ "library_id": libraryID, "hidden_collections": []string{"not-started"}, "collection_order": []string{"recently-added", "continue-reading", "recently-read"}, "items_per_section": 20, } body, _ := json.Marshal(reqBody) req, _ := http.NewRequest("PUT", s.setup.Server.URL+"/api/dashboard/preferences", bytes.NewBuffer(body)) req.Header.Set("Content-Type", "application/json") req.Header.Set("Authorization", "Bearer "+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, "hidden_collections") assert.Contains(s.T(), response, "collection_order") assert.Contains(s.T(), response, "items_per_section") } func (s *DashboardIntegrationTestSuite) TestUpdatePreferences_Unauthorized() { reqBody := map[string]interface{}{ "library_id": uuid.New().String(), "hidden_collections": []string{}, "collection_order": []string{}, "items_per_section": 20, } body, _ := json.Marshal(reqBody) req, _ := http.NewRequest("PUT", s.setup.Server.URL+"/api/dashboard/preferences", bytes.NewBuffer(body)) req.Header.Set("Content-Type", "application/json") 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 *DashboardIntegrationTestSuite) TestRestoreSystemCollection_InvalidName() { token := s.setup.Token reqBody := map[string]interface{}{ "collection_name": "invalid-collection-name", } 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 "+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 *DashboardIntegrationTestSuite) TestRestoreSystemCollection_Unauthorized() { reqBody := map[string]interface{}{ "collection_name": "continue-reading", } 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") 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 *DashboardIntegrationTestSuite) TestRestoreSystemCollection_ValidNames() { token := s.setup.Token validCollections := []string{"Continue Reading", "Recently Added", "Recently Read", "Not Started", "Continue Series"} for _, collName := range validCollections { s.T().Run(collName, func(t *testing.T) { reqBody := map[string]interface{}{ "collection_name": collName, } 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 "+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(t, http.StatusOK, resp.StatusCode) var response map[string]interface{} err = json.NewDecoder(resp.Body).Decode(&response) require.NoError(s.T(), err) assert.Contains(t, response, "message") }) } } func (s *DashboardIntegrationTestSuite) TestUpdatePreferences_GlobalScope() { token := s.setup.Token // Absent library_id (JSON null after omit) = the global All Libraries // scope: stored in user_dashboard_preferences_global, one row per user. reqBody := map[string]interface{}{ "hidden_collections": []string{"not-started"}, "collection_order": []string{"continue-reading", "recently-added"}, "items_per_section": 15, } body, _ := json.Marshal(reqBody) req, _ := http.NewRequest("PUT", s.setup.Server.URL+"/api/dashboard/preferences", bytes.NewBuffer(body)) req.Header.Set("Content-Type", "application/json") req.Header.Set("Authorization", "Bearer "+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) // Second global save with different values: must UPDATE the same row // (the old ON CONFLICT (user_id, library_id) never fired for NULLs and // would have minted a duplicate per save). reqBody["items_per_section"] = 25 body, _ = json.Marshal(reqBody) req, _ = http.NewRequest("PUT", s.setup.Server.URL+"/api/dashboard/preferences", bytes.NewBuffer(body)) req.Header.Set("Content-Type", "application/json") req.Header.Set("Authorization", "Bearer "+token) 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) // Reading prefs without library_id returns the global row (not some // arbitrary library's). req, _ = http.NewRequest("GET", s.setup.Server.URL+"/api/dashboard/preferences", nil) req.Header.Set("Authorization", "Bearer "+token) 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 prefs map[string]interface{} err = json.NewDecoder(resp.Body).Decode(&prefs) require.NoError(s.T(), err) assert.Equal(s.T(), float64(25), prefs["items_per_section"], "global row should carry the second save") } func (s *DashboardIntegrationTestSuite) TestUpdatePreferences_EmptyLibraryIDRejected() { token := s.setup.Token // Empty string is a LOST value (a client bug), never a scope — must // stay a loud 400 rather than silently writing the global scope. reqBody := map[string]interface{}{ "library_id": "", "hidden_collections": []string{}, "collection_order": []string{}, "items_per_section": 20, } body, _ := json.Marshal(reqBody) req, _ := http.NewRequest("PUT", s.setup.Server.URL+"/api/dashboard/preferences", bytes.NewBuffer(body)) req.Header.Set("Content-Type", "application/json") req.Header.Set("Authorization", "Bearer "+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 *DashboardIntegrationTestSuite) TestGetPreferences_GlobalAndPerLibraryIndependent() { token := s.setup.Token libraryID := createTestLibraryWithFolder(s.T(), s.setup.Server, token, "Prefs Scope Library", false) // Global scope: 15. globalBody, _ := json.Marshal(map[string]interface{}{ "hidden_collections": []string{}, "collection_order": []string{}, "items_per_section": 15, }) req, _ := http.NewRequest("PUT", s.setup.Server.URL+"/api/dashboard/preferences", bytes.NewBuffer(globalBody)) req.Header.Set("Content-Type", "application/json") req.Header.Set("Authorization", "Bearer "+token) client := &http.Client{} resp, err := client.Do(req) require.NoError(s.T(), err) resp.Body.Close() require.Equal(s.T(), http.StatusOK, resp.StatusCode) // Per-library scope: 30. Must not bleed into global or vice versa. libBody, _ := json.Marshal(map[string]interface{}{ "library_id": libraryID, "hidden_collections": []string{}, "collection_order": []string{}, "items_per_section": 30, }) req, _ = http.NewRequest("PUT", s.setup.Server.URL+"/api/dashboard/preferences", bytes.NewBuffer(libBody)) req.Header.Set("Content-Type", "application/json") req.Header.Set("Authorization", "Bearer "+token) resp, err = client.Do(req) require.NoError(s.T(), err) resp.Body.Close() require.Equal(s.T(), http.StatusOK, resp.StatusCode) // Per-library read. req, _ = http.NewRequest("GET", s.setup.Server.URL+"/api/dashboard/preferences?library_id="+libraryID, nil) req.Header.Set("Authorization", "Bearer "+token) resp, err = client.Do(req) require.NoError(s.T(), err) defer func(Body io.ReadCloser) { _ = Body.Close() }(resp.Body) require.Equal(s.T(), http.StatusOK, resp.StatusCode) var libPrefs map[string]interface{} err = json.NewDecoder(resp.Body).Decode(&libPrefs) require.NoError(s.T(), err) assert.Equal(s.T(), float64(30), libPrefs["items_per_section"], "per-library scope should read its own row") // Global read. req, _ = http.NewRequest("GET", s.setup.Server.URL+"/api/dashboard/preferences", nil) req.Header.Set("Authorization", "Bearer "+token) resp, err = client.Do(req) require.NoError(s.T(), err) defer func(Body io.ReadCloser) { _ = Body.Close() }(resp.Body) require.Equal(s.T(), http.StatusOK, resp.StatusCode) var globalPrefs map[string]interface{} err = json.NewDecoder(resp.Body).Decode(&globalPrefs) require.NoError(s.T(), err) assert.Equal(s.T(), float64(15), globalPrefs["items_per_section"], "global scope should stay independent") } func TestDashboardIntegrationTestSuite(t *testing.T) { suite.Run(t, new(DashboardIntegrationTestSuite)) }