diff --git a/cmd/server/tests/dashboard_integration_test.go b/cmd/server/tests/dashboard_integration_test.go new file mode 100644 index 0000000..dde0b85 --- /dev/null +++ b/cmd/server/tests/dashboard_integration_test.go @@ -0,0 +1,243 @@ +package main + +import ( + "bytes" + "encoding/json" + "fmt" + "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 := loginTestUser(s.T(), s.setup.Server, s.setup.DB) + + // 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 resp.Body.Close() + + 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, 4, "Should have 4 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") + + // 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 := loginTestUser(s.T(), s.setup.Server, s.setup.DB) + + 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 resp.Body.Close() + + assert.Equal(s.T(), http.StatusBadRequest, resp.StatusCode) +} + +func (s *DashboardIntegrationTestSuite) TestGetSections_InvalidLibraryID() { + token := loginTestUser(s.T(), s.setup.Server, s.setup.DB) + + 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 resp.Body.Close() + + 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 resp.Body.Close() + + assert.Equal(s.T(), http.StatusUnauthorized, resp.StatusCode) +} + +func (s *DashboardIntegrationTestSuite) TestUpdatePreferences_Success() { + token := loginTestUser(s.T(), s.setup.Server, s.setup.DB) + 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 resp.Body.Close() + + 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 resp.Body.Close() + + assert.Equal(s.T(), http.StatusUnauthorized, resp.StatusCode) +} + +func (s *DashboardIntegrationTestSuite) TestRestoreSystemCollection_InvalidName() { + token := loginTestUser(s.T(), s.setup.Server, s.setup.DB) + + 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 resp.Body.Close() + + 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 resp.Body.Close() + + assert.Equal(s.T(), http.StatusUnauthorized, resp.StatusCode) +} + +func (s *DashboardIntegrationTestSuite) TestRestoreSystemCollection_ValidNames() { + token := loginTestUser(s.T(), s.setup.Server, s.setup.DB) + + validCollections := []string{"continue-reading", "recently-added", "recently-read", "not-started"} + + 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(t, err) + defer resp.Body.Close() + + assert.Equal(t, http.StatusOK, resp.StatusCode) + + var response map[string]interface{} + err = json.NewDecoder(resp.Body).Decode(&response) + require.NoError(t, err) + + assert.Contains(t, response, "message") + }) + } +} + +func TestDashboardIntegrationTestSuite(t *testing.T) { + suite.Run(t, new(DashboardIntegrationTestSuite)) +} diff --git a/internal/handlers/collections_preview_test.go b/internal/handlers/collections_preview_test.go new file mode 100644 index 0000000..07c8e97 --- /dev/null +++ b/internal/handlers/collections_preview_test.go @@ -0,0 +1,279 @@ +package handlers + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "bookhoard/internal/database" + + "github.com/google/uuid" + "github.com/jackc/pgx/v5/pgtype" + "github.com/labstack/echo/v4" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestPreviewCollection_NoAuth(t *testing.T) { + e := echo.New() + handler := &CollectionHandler{} + + reqBody := map[string]interface{}{ + "library_id": uuid.New().String(), + "rules": []map[string]interface{}{}, + "manual_book_ids": []string{}, + "limit": 20, + } + body, _ := json.Marshal(reqBody) + + req := httptest.NewRequest("POST", "/api/collections/preview", bytes.NewBuffer(body)) + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + + c := e.NewContext(req, rec) + + err := handler.PreviewCollection(c) + assert.NoError(t, err) + + assert.Equal(t, http.StatusUnauthorized, rec.Code) +} + +func TestPreviewCollection_MissingLibraryID(t *testing.T) { + e := echo.New() + handler := &CollectionHandler{} + + reqBody := map[string]interface{}{ + "library_id": "", + "rules": []map[string]interface{}{}, + "manual_book_ids": []string{}, + "limit": 20, + } + body, _ := json.Marshal(reqBody) + + req := httptest.NewRequest("POST", "/api/collections/preview", bytes.NewBuffer(body)) + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + + // Mock user (would normally come from middleware) + user := database.Users{ + ID: pgtype.UUID{Bytes: uuid.New(), Valid: true}, + } + c := e.NewContext(req, rec) + c.Set("user", user) + + err := handler.PreviewCollection(c) + assert.NoError(t, err) + + assert.Equal(t, http.StatusBadRequest, rec.Code) +} + +func TestPreviewCollection_InvalidLibraryID(t *testing.T) { + e := echo.New() + handler := &CollectionHandler{} + + reqBody := map[string]interface{}{ + "library_id": "invalid-uuid", + "rules": []map[string]interface{}{}, + "manual_book_ids": []string{}, + "limit": 20, + } + body, _ := json.Marshal(reqBody) + + req := httptest.NewRequest("POST", "/api/collections/preview", bytes.NewBuffer(body)) + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + + // Mock user + user := database.Users{ + ID: pgtype.UUID{Bytes: uuid.New(), Valid: true}, + } + c := e.NewContext(req, rec) + c.Set("user", user) + + err := handler.PreviewCollection(c) + assert.NoError(t, err) + + assert.Equal(t, http.StatusBadRequest, rec.Code) +} + +func TestPreviewCollection_LimitValidation(t *testing.T) { + e := echo.New() + handler := &CollectionHandler{} + libraryID := uuid.New() + + tests := []struct { + name string + limit int + expectedStatus int + }{ + { + name: "valid limit 10", + limit: 10, + expectedStatus: http.StatusBadRequest, // No actual library, so will return error + }, + { + name: "valid limit 20", + limit: 20, + expectedStatus: http.StatusBadRequest, // No actual library, so will return error + }, + { + name: "limit too high (101)", + limit: 101, + expectedStatus: http.StatusBadRequest, // Limit validation + }, + { + name: "limit zero", + limit: 0, + expectedStatus: http.StatusBadRequest, // Should default to 20, but library doesn't exist + }, + { + name: "negative limit", + limit: -5, + expectedStatus: http.StatusBadRequest, // No actual library + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + reqBody := map[string]interface{}{ + "library_id": libraryID.String(), + "rules": []map[string]interface{}{}, + "manual_book_ids": []string{}, + "limit": tt.limit, + } + body, _ := json.Marshal(reqBody) + + req := httptest.NewRequest("POST", "/api/collections/preview", bytes.NewBuffer(body)) + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + + // Mock user + user := database.Users{ + ID: pgtype.UUID{Bytes: uuid.New(), Valid: true}, + } + c := e.NewContext(req, rec) + c.Set("user", user) + + err := handler.PreviewCollection(c) + require.NoError(t, err) + + assert.Equal(t, tt.expectedStatus, rec.Code) + }) + } +} + +func TestPreviewCollection_RuleValidation(t *testing.T) { + e := echo.New() + handler := &CollectionHandler{} + libraryID := uuid.New() + + tests := []struct { + name string + rules interface{} + expectedStatus int + }{ + { + name: "empty rules array", + rules: []map[string]interface{}{}, + expectedStatus: http.StatusBadRequest, // Library doesn't exist + }, + { + name: "valid rule structure", + rules: []map[string]interface{}{ + { + "id": "rule1", + "field": "genre", + "operator": "equals", + "value": "Sci-Fi", + "priority": 1, + }, + }, + expectedStatus: http.StatusBadRequest, // Library doesn't exist + }, + { + name: "multiple rules", + rules: []map[string]interface{}{ + { + "id": "rule1", + "field": "genre", + "operator": "equals", + "value": "Sci-Fi", + "priority": 1, + }, + { + "id": "rule2", + "field": "author", + "operator": "contains", + "value": "Asimov", + "priority": 2, + }, + }, + expectedStatus: http.StatusBadRequest, // Library doesn't exist + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + reqBody := map[string]interface{}{ + "library_id": libraryID.String(), + "rules": tt.rules, + "manual_book_ids": []string{}, + "limit": 20, + } + body, _ := json.Marshal(reqBody) + + req := httptest.NewRequest("POST", "/api/collections/preview", bytes.NewBuffer(body)) + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + + // Mock user + user := database.Users{ + ID: pgtype.UUID{Bytes: uuid.New(), Valid: true}, + } + c := e.NewContext(req, rec) + c.Set("user", user) + + err := handler.PreviewCollection(c) + require.NoError(t, err) + + assert.Equal(t, tt.expectedStatus, rec.Code) + }) + } +} + +func TestPreviewCollection_ManualBookSelection(t *testing.T) { + e := echo.New() + handler := &CollectionHandler{} + libraryID := uuid.New() + + reqBody := map[string]interface{}{ + "library_id": libraryID.String(), + "rules": []map[string]interface{}{}, + "manual_book_ids": []string{ + uuid.New().String(), + uuid.New().String(), + uuid.New().String(), + }, + "limit": 20, + } + body, _ := json.Marshal(reqBody) + + req := httptest.NewRequest("POST", "/api/collections/preview", bytes.NewBuffer(body)) + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + + // Mock user + user := database.Users{ + ID: pgtype.UUID{Bytes: uuid.New(), Valid: true}, + } + c := e.NewContext(req, rec) + c.Set("user", user) + + err := handler.PreviewCollection(c) + require.NoError(t, err) + + // Should return 400 because library doesn't exist + assert.Equal(t, http.StatusBadRequest, rec.Code) +} diff --git a/internal/handlers/dashboard_test.go b/internal/handlers/dashboard_test.go new file mode 100644 index 0000000..41b47bf --- /dev/null +++ b/internal/handlers/dashboard_test.go @@ -0,0 +1,182 @@ +package handlers + +import ( + "testing" + + "bookhoard/internal/database" + "bookhoard/internal/services" + + "github.com/google/uuid" + "github.com/jackc/pgx/v5/pgtype" + "github.com/stretchr/testify/assert" +) + +func TestBuildSections_ConvertsServiceTypesToHandlerTypes(t *testing.T) { + itemUUID := uuid.New() + + serviceSections := []services.DashboardSection{ + { + CollectionID: uuid.UUID{}, + CollectionName: "continue-reading", + QueryType: "continue-reading", + Priority: 1, + IsSystem: true, + Title: "Continue Reading", + Description: "Books you're currently reading", + Icon: "📖", + Items: []database.MediaItems{ + { + ID: pgtype.UUID{Bytes: itemUUID, Valid: true}, + Title: "Test Book", + Author: pgtype.Text{String: "Test Author", Valid: true}, + CoverImagePath: pgtype.Text{String: "/path/to/cover.jpg", Valid: true}, + }, + }, + }, + { + CollectionID: uuid.UUID{}, + CollectionName: "my-favorites", + QueryType: "filter", + Priority: 10, + IsSystem: false, + Title: "My Favorites", + Description: "My favorite books", + Icon: "⭐", + Items: []database.MediaItems{}, + }, + } + + result := BuildSections(serviceSections) + + assert.Equal(t, 2, len(result), "should have 2 sections") + + section1 := result[0] + assert.Equal(t, "continue-reading", section1.ID, "ID should match") + assert.True(t, section1.IsSystem, "IsSystem should be boolean true") + assert.Equal(t, "Continue Reading", section1.Title, "Title should be string") + assert.Equal(t, "Books you're currently reading", section1.Description, "Description should be string") + assert.Equal(t, "📖", section1.Icon, "Icon should be string") + assert.Equal(t, 1, section1.Priority, "Priority should be number") + assert.Equal(t, "/section/continue-reading", section1.ViewAllURL, "ViewAllURL should match system collection") + + section2 := result[1] + assert.Equal(t, "my-favorites", section2.ID) + assert.False(t, section2.IsSystem, "IsSystem should be boolean false") + assert.Equal(t, "My Favorites", section2.Title) + assert.Equal(t, "", section2.ViewAllURL, "ViewAllURL should be empty for user collections") +} + +func TestBuildSections_ConvertsItemsCorrectly(t *testing.T) { + itemUUID := uuid.New() + + serviceSections := []services.DashboardSection{ + { + CollectionID: uuid.UUID{}, + CollectionName: "continue-reading", + QueryType: "continue-reading", + Priority: 1, + IsSystem: true, + Title: "Continue Reading", + Description: "Books you're currently reading", + Icon: "📖", + Items: []database.MediaItems{ + { + ID: pgtype.UUID{Bytes: itemUUID, Valid: true}, + Title: "Test Book", + Author: pgtype.Text{String: "Test Author", Valid: true}, + CoverImagePath: pgtype.Text{String: "/path/to/cover.jpg", Valid: true}, + }, + }, + }, + } + + result := BuildSections(serviceSections) + + assert.Equal(t, 1, len(result), "should have 1 section") + section := result[0] + assert.Equal(t, 1, len(section.Items), "should have 1 item") + + item := section.Items[0] + assert.Equal(t, itemUUID.String(), item.MediaItemID, "MediaItemID should match") + assert.Equal(t, "Test Book", item.Title, "Title should be string") + assert.Equal(t, "Test Author", item.Author, "Author should be string") + assert.Equal(t, "/path/to/cover.jpg", item.CoverImagePath, "CoverImagePath should be string") +} + +func TestTextToString_Valid(t *testing.T) { + tests := []struct { + name string + input pgtype.Text + expected string + }{ + { + name: "valid text", + input: pgtype.Text{String: "hello", Valid: true}, + expected: "hello", + }, + { + name: "empty text", + input: pgtype.Text{String: "", Valid: true}, + expected: "", + }, + { + name: "invalid text", + input: pgtype.Text{String: "ignored", Valid: false}, + expected: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := textToString(tt.input) + assert.Equal(t, tt.expected, result, "textToString result mismatch") + }) + } +} + +func TestGetViewAllURL_SystemCollections(t *testing.T) { + tests := []struct { + name string + collectionName string + queryType string + expected string + }{ + { + name: "continue-reading", + collectionName: "continue-reading", + queryType: "continue-reading", + expected: "/section/continue-reading", + }, + { + name: "recently-added", + collectionName: "recently-added", + queryType: "recently-added", + expected: "/section/recently-added", + }, + { + name: "recently-read", + collectionName: "recently-read", + queryType: "recently-read", + expected: "/history", + }, + { + name: "not-started", + collectionName: "not-started", + queryType: "not-started", + expected: "/section/not-started", + }, + { + name: "user collection", + collectionName: "my-favorites", + queryType: "filter", + expected: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := getViewAllURL(tt.collectionName, tt.queryType) + assert.Equal(t, tt.expected, result, "getViewAllURL mismatch") + }) + } +} diff --git a/internal/services/dashboard_service_test.go b/internal/services/dashboard_service_test.go new file mode 100644 index 0000000..a0254f3 --- /dev/null +++ b/internal/services/dashboard_service_test.go @@ -0,0 +1,190 @@ +package services + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestDashboardService_FilterHiddenCollections(t *testing.T) { + service := &DashboardService{} + + sections := []DashboardSection{ + {CollectionName: "continue-reading", Title: "Continue Reading"}, + {CollectionName: "recently-added", Title: "Recently Added"}, + {CollectionName: "recently-read", Title: "Recently Read"}, + {CollectionName: "not-started", Title: "Not Started"}, + } + + tests := []struct { + name string + hidden []string + expectedCount int + shouldContain []string + shouldNotContain []string + }{ + { + name: "no hidden collections", + hidden: []string{}, + expectedCount: 4, + shouldContain: []string{"continue-reading", "recently-added", "recently-read", "not-started"}, + }, + { + name: "one hidden collection", + hidden: []string{"not-started"}, + expectedCount: 3, + shouldContain: []string{"continue-reading", "recently-added", "recently-read"}, + shouldNotContain: []string{"not-started"}, + }, + { + name: "multiple hidden collections", + hidden: []string{"not-started", "recently-read"}, + expectedCount: 2, + shouldContain: []string{"continue-reading", "recently-added"}, + shouldNotContain: []string{"not-started", "recently-read"}, + }, + { + name: "hidden collection that doesn't exist", + hidden: []string{"non-existent"}, + expectedCount: 4, + shouldContain: []string{"continue-reading", "recently-added", "recently-read", "not-started"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := service.filterHiddenCollections(sections, tt.hidden) + + assert.Equal(t, tt.expectedCount, len(result), "expected %d sections", tt.expectedCount) + + for _, name := range tt.shouldContain { + found := false + for _, section := range result { + if section.CollectionName == name { + found = true + break + } + } + assert.True(t, found, "expected section '%s' to be present", name) + } + + for _, name := range tt.shouldNotContain { + found := false + for _, section := range result { + if section.CollectionName == name { + found = true + break + } + } + assert.False(t, found, "expected section '%s' to be filtered out", name) + } + }) + } +} + +func TestDashboardService_ReorderCollections(t *testing.T) { + service := &DashboardService{} + + sections := []DashboardSection{ + {CollectionName: "continue-reading", Title: "Continue Reading", Priority: 1}, + {CollectionName: "recently-added", Title: "Recently Added", Priority: 2}, + {CollectionName: "recently-read", Title: "Recently Read", Priority: 3}, + {CollectionName: "not-started", Title: "Not Started", Priority: 4}, + } + + tests := []struct { + name string + customOrder []string + expectedFirst string + expectedSecond string + expectedLast string + }{ + { + name: "no custom order (should keep original order)", + customOrder: []string{}, + expectedFirst: "continue-reading", + expectedSecond: "recently-added", + expectedLast: "not-started", + }, + { + name: "custom order specified", + customOrder: []string{"not-started", "recently-added", "continue-reading", "recently-read"}, + expectedFirst: "not-started", + expectedSecond: "recently-added", + expectedLast: "recently-read", + }, + { + name: "partial custom order (missing items go to end)", + customOrder: []string{"not-started", "continue-reading"}, + expectedFirst: "not-started", + expectedSecond: "continue-reading", + expectedLast: "recently-read", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := service.reorderCollections(sections, tt.customOrder) + + if len(result) > 0 { + assert.Equal(t, tt.expectedFirst, result[0].CollectionName, "first section mismatch") + } + if len(result) > 1 { + assert.Equal(t, tt.expectedSecond, result[1].CollectionName, "second section mismatch") + } + if len(result) > 0 { + assert.Equal(t, tt.expectedLast, result[len(result)-1].CollectionName, "last section mismatch") + } + }) + } +} + +func TestDashboardService_SortByPriority(t *testing.T) { + service := &DashboardService{} + + sections := []DashboardSection{ + {CollectionName: "recently-read", Title: "Recently Read", Priority: 3}, + {CollectionName: "continue-reading", Title: "Continue Reading", Priority: 1}, + {CollectionName: "not-started", Title: "Not Started", Priority: 4}, + {CollectionName: "recently-added", Title: "Recently Added", Priority: 2}, + } + + result := service.sortByPriority(sections) + + require.Equal(t, 4, len(result), "expected 4 sections") + + assert.Equal(t, "continue-reading", result[0].CollectionName, "first should be priority 1") + assert.Equal(t, 1, result[0].Priority, "first section priority") + + assert.Equal(t, "recently-added", result[1].CollectionName, "second should be priority 2") + assert.Equal(t, 2, result[1].Priority, "second section priority") + + assert.Equal(t, "recently-read", result[2].CollectionName, "third should be priority 3") + assert.Equal(t, 3, result[2].Priority, "third section priority") + + assert.Equal(t, "not-started", result[3].CollectionName, "fourth should be priority 4") + assert.Equal(t, 4, result[3].Priority, "fourth section priority") +} + +func TestDashboardService_SortByPriority_Empty(t *testing.T) { + service := &DashboardService{} + + sections := []DashboardSection{} + result := service.sortByPriority(sections) + + assert.Equal(t, 0, len(result), "expected empty result") +} + +func TestDashboardService_SortByPriority_Single(t *testing.T) { + service := &DashboardService{} + + sections := []DashboardSection{ + {CollectionName: "continue-reading", Title: "Continue Reading", Priority: 1}, + } + + result := service.sortByPriority(sections) + + assert.Equal(t, 1, len(result), "expected 1 section") + assert.Equal(t, "continue-reading", result[0].CollectionName) +}