Files
bookhoard/cmd/server/tests/dashboard_integration_test.go
T
John O'Keefe f24a2d6c22 test(server): fixture collisions fixed, stale contract asserts re-anchored, gofmt — suite now fully green
Closes the remaining integration-suite failures (handoff open bug 3),
line by line:

- Fixture family (the upsert equivalence): tags_filter_test
  ("Science Fiction" found 1 of 3; "Fantasy" 404'd on zero results),
  media_item_isbn_test (normalization read back the first case's ISBN
  everywhere; edge cases saw a nil ISBN; pagination collapsed 5
  creates onto 1 row), search_unified_test (its 3 books were actually
  1 row + two no-op creates) — all POSTed a constant file_path into
  one library while CreateMediaItem upserts ON CONFLICT
  (library_id, file_path). Every fixture now uses unique per-item
  paths, the suite's universal convention elsewhere. TestTagsFilter
  confirmed same-family: counts came right with no second bug.
- New TestMediaItemCreateDuplicatePath pins the honest-create
  contract from da13898: 201 for the first create; 200 + duplicate
  flag + the EXISTING row (never a 201 lie), second payload not
  overwritten.
- library_browse_test: the admin subtest browsed the container's
  /tmp and required it non-empty — env-coupled (empty /tmp → "was
  []"). Now browses a self-created t.TempDir with two subdirs and a
  decoy file, asserting ONLY directories are listed.
- series_integration_test: three stale assertions re-anchored to the
  shipped contracts, each with a ground-truth comment: /dashboard/
  sections key identity on the collection NAME (BuildSections ID =
  CollectionName; the web/app reorder + hidden prefs persist those
  strings) — the kebab query_type is the storage key and is not
  exposed; /api/series/books is name-ONLY (the app client and the
  web SSR page both send name only; library_id is ignored, never
  validated); /api/series is optional-scope BY DESIGN (GetDistinct-
  Series: narg IS NULL OR library_id = narg aggregates across
  libraries when omitted). The former 400-on-missing-library_id
  expectations asserted validation that never shipped.
- gofmt rider on three untouched-in-this-arc test files
  (dashboard_integration, search, test_helpers).

Full suite with -parallel=1: 217 groups pass, zero failures.
2026-10-09 17:11:12 -04:00

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