The dashboard's All Libraries view could render sections across every library but could not persist its own preferences: the PUT required a UUID library_id, and the prefs GET-without-library_id matched an arbitrary library's row through a loose NULL OR-clause. Storage: a dedicated user_dashboard_preferences_global table (one row per user, user_id PK → users CASCADE) instead of a NULL/sentinel library_id — chosen over both because (a) the column's FK to libraries rules out a sentinel UUID without dropping the constraint or minting a fake library row, and (b) intent becomes structural: only the explicit global path writes that table, so a buggy or outside-the-API write can never masquerade as global. System and user collections are user-scoped, so the order/hidden arrays are library-independent and valid in this scope. API: PUT /api/dashboard/preferences binds library_id as *string — JSON null/absent = global, empty string = 400 (a lost value must stay loud, not silently clobber the global scope), valid UUID = per-library (unchanged). GET without library_id now returns the global row instead of an arbitrary one; the per-library query's dead NULL OR-arm is removed. GET /sections keeps requiring library_id for per-library views and aggregates across libraries when absent (unchanged). Tests: global save/update-dedupe/read round-trip, empty-string 400, global-vs-per-library independence. Two suite failures are the handoff's documented pre-existing rot (duplicate system collections in the dev DB; a sections test expecting a 400 the handler never implemented) — reproduced and confirmed unrelated to this change. Live-verified on the dev stack: null save 200 x2 with a single row updated, empty string 400, global read carries the second save.
409 lines
13 KiB
Go
409 lines
13 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
|
|
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
|
|
|
|
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.StatusBadRequest, resp.StatusCode)
|
|
}
|
|
|
|
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 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")
|
|
}
|
|
|
|
func TestDashboardIntegrationTestSuite(t *testing.T) {
|
|
suite.Run(t, new(DashboardIntegrationTestSuite))
|
|
}
|