diff --git a/cmd/server/tests/dashboard_integration_test.go b/cmd/server/tests/dashboard_integration_test.go index b701b2df..be20f4dd 100644 --- a/cmd/server/tests/dashboard_integration_test.go +++ b/cmd/server/tests/dashboard_integration_test.go @@ -352,7 +352,7 @@ func (s *DashboardIntegrationTestSuite) TestUpdatePreferences_EmptyLibraryIDReje func (s *DashboardIntegrationTestSuite) TestGetPreferences_GlobalAndPerLibraryIndependent() { token := s.setup.Token - libraryID := createTestLibraryWithFolder(s.T(), s.setup.Server, token, "Prefs Scope Library", false) + libraryID := createTestLibraryWithFolder(s.T(), s.setup.Server, token, "Prefs Scope Test Library", false) // Global scope: 15. globalBody, _ := json.Marshal(map[string]interface{}{ @@ -413,6 +413,119 @@ func (s *DashboardIntegrationTestSuite) TestGetPreferences_GlobalAndPerLibraryIn 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)) } diff --git a/cmd/server/tests/device_test.go b/cmd/server/tests/device_test.go index 5c51b02d..efddc754 100644 --- a/cmd/server/tests/device_test.go +++ b/cmd/server/tests/device_test.go @@ -512,7 +512,7 @@ func TestRegenerateDeviceToken_Forbidden(t *testing.T) { defer setup.Server.Close() // Create device for user 1 - device1 := setup.CreateDevice(t, "User1 Device", "koreader", "user1-device") + device1 := setup.CreateDevice(t, "User1 Device", "koreader", "user1-test-device") // Create a second user with different credentials ctx := context.Background() diff --git a/cmd/server/tests/filters_test.go b/cmd/server/tests/filters_test.go index a8749070..b4af8709 100644 --- a/cmd/server/tests/filters_test.go +++ b/cmd/server/tests/filters_test.go @@ -47,7 +47,7 @@ func TestSavedFilters(t *testing.T) { t.Run("POST /api/saved-filters creates filter", func(t *testing.T) { reqBody := map[string]interface{}{ - "name": "My Sci-Fi Books", + "name": "My Sci-Fi Test Books", "resource_type": "media-items", "filters": map[string]string{ "genre_filter": "Science Fiction", @@ -71,7 +71,7 @@ func TestSavedFilters(t *testing.T) { var filter map[string]interface{} err = json.NewDecoder(resp.Body).Decode(&filter) require.NoError(t, err) - assert.Equal(t, "My Sci-Fi Books", filter["name"]) + assert.Equal(t, "My Sci-Fi Test Books", filter["name"]) assert.Equal(t, "media-items", filter["resource_type"]) assert.NotEmpty(t, filter["id"]) // Filters field will be JSON object (JSONB as raw bytes) @@ -113,7 +113,7 @@ func TestSavedFilters(t *testing.T) { t.Run("PUT /api/saved-filters/:id updates filter", func(t *testing.T) { // Create filter first createReq := map[string]interface{}{ - "name": "Original Name", + "name": "Original Test Name", "resource_type": "media-items", "filters": map[string]string{"search": "original"}, } @@ -134,11 +134,13 @@ func TestSavedFilters(t *testing.T) { var createdFilter map[string]interface{} err = json.NewDecoder(createResp.Body).Decode(&createdFilter) require.NoError(t, err) - filterID := createdFilter["id"].(string) + filterIDRaw, ok := createdFilter["id"] + require.True(t, ok, "create filter response should carry id: %v", createdFilter) + filterID := filterIDRaw.(string) // Update filter updateReq := map[string]interface{}{ - "name": "Updated Name", + "name": "Updated Test Name", "resource_type": "media-items", "filters": map[string]string{"search": "updated"}, } @@ -159,7 +161,7 @@ func TestSavedFilters(t *testing.T) { var updatedFilter map[string]interface{} err = json.NewDecoder(updateResp.Body).Decode(&updatedFilter) require.NoError(t, err) - assert.Equal(t, "Updated Name", updatedFilter["name"]) + assert.Equal(t, "Updated Test Name", updatedFilter["name"]) }) t.Run("DELETE /api/saved-filters/:id deletes filter", func(t *testing.T) { @@ -184,7 +186,9 @@ func TestSavedFilters(t *testing.T) { var createdFilter map[string]interface{} err = json.NewDecoder(createResp.Body).Decode(&createdFilter) require.NoError(t, err) - filterID := createdFilter["id"].(string) + filterIDRaw, ok := createdFilter["id"] + require.True(t, ok, "create filter response should carry id: %v", createdFilter) + filterID := filterIDRaw.(string) // Delete filter deleteHTTP, _ := http.NewRequest("DELETE", setup.Server.URL+"/api/saved-filters/"+filterID, nil) @@ -223,7 +227,9 @@ func TestSavedFilters(t *testing.T) { var createdFilter map[string]interface{} err = json.NewDecoder(createResp.Body).Decode(&createdFilter) require.NoError(t, err) - filterID := createdFilter["id"].(string) + filterIDRaw, ok := createdFilter["id"] + require.True(t, ok, "create filter response should carry id: %v", createdFilter) + filterID := filterIDRaw.(string) // Admin user tries to delete regular user's filter deleteHTTP, _ := http.NewRequest("DELETE", setup.Server.URL+"/api/saved-filters/"+filterID, nil) @@ -260,7 +266,9 @@ func TestSavedFilters(t *testing.T) { var createdFilter map[string]interface{} err = json.NewDecoder(createResp.Body).Decode(&createdFilter) require.NoError(t, err) - filterID := createdFilter["id"].(string) + filterIDRaw, ok := createdFilter["id"] + require.True(t, ok, "create filter response should carry id: %v", createdFilter) + filterID := filterIDRaw.(string) // Now retrieve the filter by ID getReq, _ := http.NewRequest("GET", setup.Server.URL+"/api/saved-filters/"+filterID, nil) @@ -324,7 +332,7 @@ func TestSavedFilters(t *testing.T) { t.Run("GET /api/saved-filters/:id from different user returns 404", func(t *testing.T) { // Create filter with admin user (setup.Token) reqBody := map[string]interface{}{ - "name": "Admin Filter", + "name": "Admin Test Filter", "resource_type": "media-items", "filters": map[string]string{"search": "test"}, } @@ -340,7 +348,9 @@ func TestSavedFilters(t *testing.T) { var createdFilter map[string]interface{} err = json.NewDecoder(createResp.Body).Decode(&createdFilter) require.NoError(t, err) - filterID := createdFilter["id"].(string) + filterIDRaw, ok := createdFilter["id"] + require.True(t, ok, "create filter response should carry id: %v", createdFilter) + filterID := filterIDRaw.(string) // Try to access with regular user (setup.RegularToken) httpReq, _ := http.NewRequest("GET", setup.Server.URL+"/api/saved-filters/"+filterID, nil) httpReq.Header.Set("Authorization", "Bearer "+setup.RegularToken) diff --git a/cmd/server/tests/test_helpers_db_test.go b/cmd/server/tests/test_helpers_db_test.go index 584695d6..931f24b3 100644 --- a/cmd/server/tests/test_helpers_db_test.go +++ b/cmd/server/tests/test_helpers_db_test.go @@ -109,6 +109,18 @@ func createTestLibraryWithFolder(t *testing.T, ts *httptest.Server, token, name require.NoError(t, err) libraryID := libResponse["id"].(string) + // Every library a test creates must not outlive that test — delete + // it on cleanup so runs leave no residue in the dev database. + t.Cleanup(func() { + delHTTP, _ := http.NewRequest("DELETE", fmt.Sprintf("%s/api/libraries/%s", ts.URL, libraryID), nil) + delHTTP.Header.Set("Authorization", "Bearer "+token) + delResp, err := client.Do(delHTTP) + if err == nil { + _, _ = io.Copy(io.Discard, delResp.Body) + _ = delResp.Body.Close() + } + }) + if withFolder { folderReq := map[string]interface{}{ "folder_path": "/app/uploads", diff --git a/cmd/server/tests/test_helpers_test.go b/cmd/server/tests/test_helpers_test.go index 16ad6365..48eb8adf 100644 --- a/cmd/server/tests/test_helpers_test.go +++ b/cmd/server/tests/test_helpers_test.go @@ -295,6 +295,26 @@ func createDefaultCollectionsForUser(t *testing.T, db *database.Queries, userID } for _, col := range defaultCollections { + // Idempotent by the STABLE identity (user + query_type): existing + // rows (renamed, tombstoned) heal to canonical metadata in place; + // insert only when the query type has no row. A blind insert here + // hit UNIQUE(user_id, name) on every preserved-admin rerun. + existing, gerr := db.GetSystemCollectionByQueryType(ctx, database.GetSystemCollectionByQueryTypeParams{ + UserID: userID, + QueryType: pgtype.Text{String: col.QueryType, Valid: true}, + }) + if gerr == nil { + _, uerr := db.RestoreSystemCollectionRow(ctx, database.RestoreSystemCollectionRowParams{ + ID: existing.ID, + Name: col.Name, + Description: pgtype.Text{String: col.Description, Valid: true}, + Icon: pgtype.Text{String: col.Icon, Valid: true}, + Color: pgtype.Text{String: col.Color, Valid: true}, + Priority: pgtype.Int4{Int32: col.Priority, Valid: true}, + }) + require.NoError(t, uerr, "Should restore default collection: "+col.Name) + continue + } _, err := db.CreateSystemCollection(ctx, database.CreateSystemCollectionParams{ UserID: userID, Name: col.Name, @@ -627,9 +647,41 @@ func setupTestServer(t *testing.T) *TestServerSetup { require.NoError(t, cerr, "Failed to create admin test user") adminUUID, err = uuid.FromBytes(adminUser.ID.Bytes[:]) require.NoError(t, err, "Failed to parse admin user UUID") - // Default collections are created exactly once per user; the - // preserved dev admin already has its set from the first run. - createDefaultCollectionsForUser(t, queries, pgtype.UUID{Bytes: adminUUID, Valid: true}) + } + // The canonical system-collection set is ensured on EVERY run: the + // helper is idempotent by (user, query_type) and heals tombstoned or + // renamed rows, so suite runs can't accumulate naming-drift copies. + createDefaultCollectionsForUser(t, queries, pgtype.UUID{Bytes: adminUUID, Valid: true}) + + // Delete test-created COLLECTIONS (names containing "test"): without + // this, every suite run leaves its collections behind and the next + // run's creates die on UNIQUE(user_id, name) — the "collections + // 500-panic" rot. System collections are skipped (permanent rows). + // Same convention as the library pass: names containing "test". + adminColls, cerr := queries.GetCollectionsByUser(ctx, pgtype.UUID{Bytes: adminUUID, Valid: true}) + if cerr == nil { + for _, coll := range adminColls { + if !coll.IsSystemCollection.Bool && strings.Contains(strings.ToLower(coll.Name), "test") { + queries.DeleteCollection(ctx, coll.ID) + } + } + } + + // Same passes for test DEVICES (identifier/name containing "test") + // and test SAVED FILTERS (name containing "test"): both accumulated + // across runs and poisoned later runs (duplicate identifiers, 409s, + // non-empty "initially empty" assertions). + _, derr := dbPool.Exec(ctx, + "DELETE FROM devices WHERE user_id = $1 AND (device_identifier ILIKE '%test%' OR device_name ILIKE '%test%')", + adminUUID) + if derr != nil { + t.Logf("device cleanup failed: %v", derr) + } + _, ferr := dbPool.Exec(ctx, + "DELETE FROM saved_filters WHERE user_id = $1 AND name ILIKE '%test%'", + adminUUID) + if ferr != nil { + t.Logf("saved-filter cleanup failed: %v", ferr) } // Create fresh regular test user @@ -680,6 +732,17 @@ func setupTestServer(t *testing.T) *TestServerSetup { } } + // Same pass for test-created COLLECTIONS (the "collections + // 500-panic" rot: they lingered forever and poisoned later runs). + adminColls, cerr := queries.GetCollectionsByUser(ctx, pgtype.UUID{Bytes: adminUUID, Valid: true}) + if cerr == nil { + for _, coll := range adminColls { + if !coll.IsSystemCollection.Bool && strings.Contains(strings.ToLower(coll.Name), "test") { + queries.DeleteCollection(ctx, coll.ID) + } + } + } + if err := setup.Close(); err != nil { t.Errorf("Failed to cleanup test server: %v", err) }