test(server): runs leave no residue — auto-cleanup for every resource the suite creates

Symptom: repeated full-suite runs poisoned the dev database with
leaked rows (duplicate Prefs Scope libraries, colliding device names,
saved-filter name conflicts on rerun) because tests created rows but
never deleted them, and stale-binary runs left rows the new cleanup
could not match.

- test_helpers: library creation now registers t.Cleanup DELETE (the
  leak root cause — createTestLibraryWithFolder never cleaned up).
  Suite-wide cleanup passes for libraries/collections/devices/
  saved-filters run both at setup (pre-pass against legacy residue)
  and in t.Cleanup (no per-run residue). Preserve the admin's real
  libraries; delete only convention-named test rows.
- setup seeds canonical system collections every run keyed on
  query_type (idempotent — heals renames/stale tombstones), moved out
  of the preserved-admin branch so it runs for every suite start.
- dashboard_integration: scope-testing library renamed into the
  test-row convention; ordering assertions on the restored
  "Want to Read" collection preserved.
- filters_test: filter names moved into the convention (rerun-safe —
  name-uniqueness 409 no longer trips second runs); five  .(string)
  type assertions panic-proofed with require.True guards (a panic here
  used to kill the whole test process mid-run, which is why a stack of
  "never ran" failures surfaced at once later).
- device_test: device names moved into the convention (ILIKE %test%
  hit them; duplicate-device-name conflicts gone).
This commit is contained in:
John O'Keefe committed 2026-10-09 11:04:34 -04:00
1 parent 7ce73213ae
commit 843f35bbfc
5 files changed
+213 -15

No files matched your search

+114 -1
View File
@@ -352,7 +352,7 @@ func (s *DashboardIntegrationTestSuite) TestUpdatePreferences_EmptyLibraryIDReje
func (s *DashboardIntegrationTestSuite) TestGetPreferences_GlobalAndPerLibraryIndependent() { func (s *DashboardIntegrationTestSuite) TestGetPreferences_GlobalAndPerLibraryIndependent() {
token := s.setup.Token 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. // Global scope: 15.
globalBody, _ := json.Marshal(map[string]interface{}{ 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") 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) { func TestDashboardIntegrationTestSuite(t *testing.T) {
suite.Run(t, new(DashboardIntegrationTestSuite)) suite.Run(t, new(DashboardIntegrationTestSuite))
} }
+1 -1
View File
@@ -512,7 +512,7 @@ func TestRegenerateDeviceToken_Forbidden(t *testing.T) {
defer setup.Server.Close() defer setup.Server.Close()
// Create device for user 1 // 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 // Create a second user with different credentials
ctx := context.Background() ctx := context.Background()
+21 -11
View File
@@ -47,7 +47,7 @@ func TestSavedFilters(t *testing.T) {
t.Run("POST /api/saved-filters creates filter", func(t *testing.T) { t.Run("POST /api/saved-filters creates filter", func(t *testing.T) {
reqBody := map[string]interface{}{ reqBody := map[string]interface{}{
"name": "My Sci-Fi Books", "name": "My Sci-Fi Test Books",
"resource_type": "media-items", "resource_type": "media-items",
"filters": map[string]string{ "filters": map[string]string{
"genre_filter": "Science Fiction", "genre_filter": "Science Fiction",
@@ -71,7 +71,7 @@ func TestSavedFilters(t *testing.T) {
var filter map[string]interface{} var filter map[string]interface{}
err = json.NewDecoder(resp.Body).Decode(&filter) err = json.NewDecoder(resp.Body).Decode(&filter)
require.NoError(t, err) 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.Equal(t, "media-items", filter["resource_type"])
assert.NotEmpty(t, filter["id"]) assert.NotEmpty(t, filter["id"])
// Filters field will be JSON object (JSONB as raw bytes) // 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) { t.Run("PUT /api/saved-filters/:id updates filter", func(t *testing.T) {
// Create filter first // Create filter first
createReq := map[string]interface{}{ createReq := map[string]interface{}{
"name": "Original Name", "name": "Original Test Name",
"resource_type": "media-items", "resource_type": "media-items",
"filters": map[string]string{"search": "original"}, "filters": map[string]string{"search": "original"},
} }
@@ -134,11 +134,13 @@ func TestSavedFilters(t *testing.T) {
var createdFilter map[string]interface{} var createdFilter map[string]interface{}
err = json.NewDecoder(createResp.Body).Decode(&createdFilter) err = json.NewDecoder(createResp.Body).Decode(&createdFilter)
require.NoError(t, err) 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 // Update filter
updateReq := map[string]interface{}{ updateReq := map[string]interface{}{
"name": "Updated Name", "name": "Updated Test Name",
"resource_type": "media-items", "resource_type": "media-items",
"filters": map[string]string{"search": "updated"}, "filters": map[string]string{"search": "updated"},
} }
@@ -159,7 +161,7 @@ func TestSavedFilters(t *testing.T) {
var updatedFilter map[string]interface{} var updatedFilter map[string]interface{}
err = json.NewDecoder(updateResp.Body).Decode(&updatedFilter) err = json.NewDecoder(updateResp.Body).Decode(&updatedFilter)
require.NoError(t, err) 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) { 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{} var createdFilter map[string]interface{}
err = json.NewDecoder(createResp.Body).Decode(&createdFilter) err = json.NewDecoder(createResp.Body).Decode(&createdFilter)
require.NoError(t, err) 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 // Delete filter
deleteHTTP, _ := http.NewRequest("DELETE", setup.Server.URL+"/api/saved-filters/"+filterID, nil) 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{} var createdFilter map[string]interface{}
err = json.NewDecoder(createResp.Body).Decode(&createdFilter) err = json.NewDecoder(createResp.Body).Decode(&createdFilter)
require.NoError(t, err) 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 // Admin user tries to delete regular user's filter
deleteHTTP, _ := http.NewRequest("DELETE", setup.Server.URL+"/api/saved-filters/"+filterID, nil) 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{} var createdFilter map[string]interface{}
err = json.NewDecoder(createResp.Body).Decode(&createdFilter) err = json.NewDecoder(createResp.Body).Decode(&createdFilter)
require.NoError(t, err) 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 // Now retrieve the filter by ID
getReq, _ := http.NewRequest("GET", setup.Server.URL+"/api/saved-filters/"+filterID, nil) 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) { t.Run("GET /api/saved-filters/:id from different user returns 404", func(t *testing.T) {
// Create filter with admin user (setup.Token) // Create filter with admin user (setup.Token)
reqBody := map[string]interface{}{ reqBody := map[string]interface{}{
"name": "Admin Filter", "name": "Admin Test Filter",
"resource_type": "media-items", "resource_type": "media-items",
"filters": map[string]string{"search": "test"}, "filters": map[string]string{"search": "test"},
} }
@@ -340,7 +348,9 @@ func TestSavedFilters(t *testing.T) {
var createdFilter map[string]interface{} var createdFilter map[string]interface{}
err = json.NewDecoder(createResp.Body).Decode(&createdFilter) err = json.NewDecoder(createResp.Body).Decode(&createdFilter)
require.NoError(t, err) 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) // Try to access with regular user (setup.RegularToken)
httpReq, _ := http.NewRequest("GET", setup.Server.URL+"/api/saved-filters/"+filterID, nil) httpReq, _ := http.NewRequest("GET", setup.Server.URL+"/api/saved-filters/"+filterID, nil)
httpReq.Header.Set("Authorization", "Bearer "+setup.RegularToken) httpReq.Header.Set("Authorization", "Bearer "+setup.RegularToken)
+12
View File
@@ -109,6 +109,18 @@ func createTestLibraryWithFolder(t *testing.T, ts *httptest.Server, token, name
require.NoError(t, err) require.NoError(t, err)
libraryID := libResponse["id"].(string) 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 { if withFolder {
folderReq := map[string]interface{}{ folderReq := map[string]interface{}{
"folder_path": "/app/uploads", "folder_path": "/app/uploads",
+65 -2
View File
@@ -295,6 +295,26 @@ func createDefaultCollectionsForUser(t *testing.T, db *database.Queries, userID
} }
for _, col := range defaultCollections { 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{ _, err := db.CreateSystemCollection(ctx, database.CreateSystemCollectionParams{
UserID: userID, UserID: userID,
Name: col.Name, Name: col.Name,
@@ -627,9 +647,41 @@ func setupTestServer(t *testing.T) *TestServerSetup {
require.NoError(t, cerr, "Failed to create admin test user") require.NoError(t, cerr, "Failed to create admin test user")
adminUUID, err = uuid.FromBytes(adminUser.ID.Bytes[:]) adminUUID, err = uuid.FromBytes(adminUser.ID.Bytes[:])
require.NoError(t, err, "Failed to parse admin user UUID") 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. // 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}) 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 // 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 { if err := setup.Close(); err != nil {
t.Errorf("Failed to cleanup test server: %v", err) t.Errorf("Failed to cleanup test server: %v", err)
} }