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 Test 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") } // TestDeleteSystemCollection_TombstonesAndRestores pins the system- // collection permanence contract: DELETE hides the section everywhere // (tombstone) and restore brings back the EXACT SAME row (id stable), // never a fresh one. func newHTTPClient() *http.Client { return &http.Client{} } func (s *DashboardIntegrationTestSuite) TestDeleteSystemCollection_TombstonesAndRestores() { token := s.setup.Token libraryID := createTestLibraryWithFolder(s.T(), s.setup.Server, token, "Test Library", false) collectionID := func(sectionID string) string { 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) resp, err := newHTTPClient().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 response map[string]interface{} require.NoError(s.T(), json.NewDecoder(resp.Body).Decode(&response)) for _, sec := range response["sections"].([]interface{}) { section := sec.(map[string]interface{}) if section["id"] == sectionID { return section["collection_id"].(string) } } return "" } hasSection := func(sectionID string) bool { 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) resp, err := newHTTPClient().Do(req) require.NoError(s.T(), err) defer func(Body io.ReadCloser) { _ = Body.Close() }(resp.Body) var response map[string]interface{} require.NoError(s.T(), json.NewDecoder(resp.Body).Decode(&response)) for _, sec := range response["sections"].([]interface{}) { if sec.(map[string]interface{})["id"] == sectionID { return true } } return false } idBefore := collectionID("Continue Reading") require.NotEmpty(s.T(), idBefore, "Continue Reading should exist") // DELETE a system collection = tombstone (204, behaves like a delete). delReq, _ := http.NewRequest("DELETE", s.setup.Server.URL+"/api/collections/"+idBefore, nil) delReq.Header.Set("Authorization", "Bearer "+token) delResp, err := newHTTPClient().Do(delReq) require.NoError(s.T(), err) defer func(Body io.ReadCloser) { _ = Body.Close() }(delResp.Body) assert.Equal(s.T(), http.StatusNoContent, delResp.StatusCode) // Invisible on the dashboard. assert.False(s.T(), hasSection("Continue Reading"), "tombstoned section must vanish") // Restore brings back the EXACT SAME row. restoreBody, _ := json.Marshal(map[string]string{"collection_name": "Continue Reading"}) req, _ := http.NewRequest("POST", s.setup.Server.URL+"/api/dashboard/restore-system-collection", bytes.NewBuffer(restoreBody)) req.Header.Set("Content-Type", "application/json") req.Header.Set("Authorization", "Bearer "+token) restoreResp, err := newHTTPClient().Do(req) require.NoError(s.T(), err) defer func(Body io.ReadCloser) { _ = Body.Close() }(restoreResp.Body) require.Equal(s.T(), http.StatusOK, restoreResp.StatusCode) idAfter := collectionID("Continue Reading") assert.Equal(s.T(), idBefore, idAfter, "restore must resurrect the SAME row id") assert.True(s.T(), hasSection("Continue Reading")) } // TestDeleteUserCollection_HardDelete pins the user-collection contract: // user collections hard-delete exactly as before (no tombstone, no restore). func (s *DashboardIntegrationTestSuite) TestDeleteUserCollection_HardDelete() { token := s.setup.Token createBody, _ := json.Marshal(map[string]interface{}{"name": "Test User Shelf", "type": "ebooks"}) req, _ := http.NewRequest("POST", s.setup.Server.URL+"/api/collections", bytes.NewBuffer(createBody)) req.Header.Set("Content-Type", "application/json") req.Header.Set("Authorization", "Bearer "+token) createResp, err := newHTTPClient().Do(req) require.NoError(s.T(), err) defer func(Body io.ReadCloser) { _ = Body.Close() }(createResp.Body) require.Equal(s.T(), http.StatusCreated, createResp.StatusCode) var created map[string]interface{} require.NoError(s.T(), json.NewDecoder(createResp.Body).Decode(&created)) collectionID := created["id"].(string) delReq, _ := http.NewRequest("DELETE", s.setup.Server.URL+"/api/collections/"+collectionID, nil) delReq.Header.Set("Authorization", "Bearer "+token) delResp, err := newHTTPClient().Do(delReq) require.NoError(s.T(), err) defer func(Body io.ReadCloser) { _ = Body.Close() }(delResp.Body) assert.Equal(s.T(), http.StatusNoContent, delResp.StatusCode) listReq, _ := http.NewRequest("GET", s.setup.Server.URL+"/api/collections", nil) listReq.Header.Set("Authorization", "Bearer "+token) listResp, err := newHTTPClient().Do(listReq) require.NoError(s.T(), err) defer func(Body io.ReadCloser) { _ = Body.Close() }(listResp.Body) var list struct { Collections []map[string]interface{} `json:"collections"` } require.NoError(s.T(), json.NewDecoder(listResp.Body).Decode(&list)) for _, c := range list.Collections { assert.NotEqual(s.T(), collectionID, c["id"], "hard-deleted user collection must be gone") } } func TestDashboardIntegrationTestSuite(t *testing.T) { suite.Run(t, new(DashboardIntegrationTestSuite)) }