Files
bookhoard/cmd/server/tests/dashboard_integration_test.go
T
John O'Keefe 843f35bbfc 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).
2026-10-09 11:04:34 -04:00

532 lines
19 KiB
Go

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))
}