From c775ed0a8e939058c534d2bb9946e82560203fe9 Mon Sep 17 00:00:00 2001 From: John O'Keefe Date: Tue, 17 Feb 2026 22:25:48 -0500 Subject: [PATCH] docs(dashboard): add comprehensive unit and integration test phases MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 14: Unit Tests (2-3 hours) - Service layer tests (dashboard_service_test.go) - filterHiddenSections() - tests no filters, one hidden, multiple hidden - reorderSections() - tests default order, custom order, partial order - Handler helper tests (dashboard_test.go) - getSectionType() - smart vs collection sections - getSectionTitle() - all smart sections and collections - getSectionIcon() - icons for all sections - getSectionViewAllURL() - URLs for all sections - Table-driven tests for multiple scenarios - Uses testify/assert - Skips database-dependent tests (use integration tests instead) Phase 15: Integration Tests (2-3 hours) - File: cmd/server/tests/dashboard_test.go - Uses setupTestServer(t) helper from test_helpers.go - Tests /api/dashboard/sections JSON endpoint: - Three-context testing (no auth, user, admin) - Missing library_id → 400 - Invalid library_id → 400 - With limit parameter - Tests user preferences: - Hidden sections filtered correctly - Custom order applied correctly - Tests SSR /dashboard page: - Returns HTML with dashboard elements - Requires auth - Helper functions: - updateDashboardPreferences() - getUserUUIDFromToken() - parseUUID() Testing Strategy: - Unit tests alongside source files (project convention) - Integration tests in cmd/server/tests/ (project convention) - setupTestServer() helper creates full test environment - Uses loginTestUser(), loginRegularUser(), setupDeviceTest() - Follows existing patterns from auth_test.go, collections_bulk_test.go Updated Timeline: 23-31 days total (added 4-6 hours for testing) Benefits: - Comprehensive test coverage before production - Catches regressions in user preferences logic - Validates API endpoint behavior across contexts - Ensures SSR and JSON return consistent data - Follows project testing conventions --- CAROUSEL_DASHBOARD_PLAN.md | 531 ++++++++++++++++++++++++++++++++++++- 1 file changed, 530 insertions(+), 1 deletion(-) diff --git a/CAROUSEL_DASHBOARD_PLAN.md b/CAROUSEL_DASHBOARD_PLAN.md index 02d5552..7e4bbfb 100644 --- a/CAROUSEL_DASHBOARD_PLAN.md +++ b/CAROUSEL_DASHBOARD_PLAN.md @@ -1932,6 +1932,513 @@ bru run bruno/dashboard/ --env local --- +### **Phase 14: Unit Tests** (2-3 hours) + +**COMPLIANCE**: Unit tests alongside source files, following project patterns + +#### 14.1 Service Layer Unit Tests +**File: `internal/services/dashboard_service_test.go`** (new file) + +```go +package services + +import ( + "context" + "testing" + + "bookhoard/internal/database" + "github.com/google/uuid" + "github.com/jackc/pgx/v5/pgtype" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestFilterHiddenSections(t *testing.T) { + service := &DashboardService{} + + items := []SectionItems{ + {SectionKey: "continue-reading", Items: nil}, + {SectionKey: "in-progress", Items: nil}, + {SectionKey: "recently-added", Items: nil}, + } + + t.Run("No hidden sections", func(t *testing.T) { + result := service.filterHiddenSections(items, []string{}) + assert.Equal(t, 3, len(result)) + }) + + t.Run("Hide one section", func(t *testing.T) { + result := service.filterHiddenSections(items, []string{"in-progress"}) + assert.Equal(t, 2, len(result)) + assert.Equal(t, "continue-reading", result[0].SectionKey) + assert.Equal(t, "recently-added", result[1].SectionKey) + }) + + t.Run("Hide multiple sections", func(t *testing.T) { + result := service.filterHiddenSections(items, []string{"continue-reading", "recently-added"}) + assert.Equal(t, 1, len(result)) + assert.Equal(t, "in-progress", result[0].SectionKey) + }) +} + +func TestReorderSections(t *testing.T) { + service := &DashboardService{} + + items := []SectionItems{ + {SectionKey: "continue-reading", Items: nil}, + {SectionKey: "in-progress", Items: nil}, + {SectionKey: "recently-added", Items: nil}, + } + + t.Run("No custom order", func(t *testing.T) { + result := service.reorderSections(items, []string{}) + assert.Equal(t, 3, len(result)) + assert.Equal(t, "continue-reading", result[0].SectionKey) + }) + + t.Run("Custom order - all sections", func(t *testing.T) { + customOrder := []string{"recently-added", "continue-reading", "in-progress"} + result := service.reorderSections(items, customOrder) + assert.Equal(t, 3, len(result)) + assert.Equal(t, "recently-added", result[0].SectionKey) + assert.Equal(t, "continue-reading", result[1].SectionKey) + assert.Equal(t, "in-progress", result[2].SectionKey) + }) + + t.Run("Custom order - partial (new sections appended)", func(t *testing.T) { + customOrder := []string{"in-progress", "continue-reading"} + result := service.reorderSections(items, customOrder) + assert.Equal(t, 3, len(result)) + assert.Equal(t, "in-progress", result[0].SectionKey) + assert.Equal(t, "continue-reading", result[1].SectionKey) + assert.Equal(t, "recently-added", result[2].SectionKey) // Appended at end + }) + + t.Run("Custom order - unknown section ignored", func(t *testing.T) { + customOrder := []string{"unknown-section", "continue-reading"} + result := service.reorderSections(items, customOrder) + assert.Equal(t, 3, len(result)) + assert.Equal(t, "continue-reading", result[0].SectionKey) + }) +} + +func TestGetDashboardPreferences(t *testing.T) { + // This would require a test database setup + // For now, test with mock or skip + t.Skip("Requires database integration - use integration tests") +} +``` + +**Key Points**: +- ✅ Unit tests alongside source file (`dashboard_service_test.go`) +- ✅ Test pure functions (filterHiddenSections, reorderSections) +- ✅ Table-driven tests for multiple scenarios +- ✅ Use testify/assert for assertions +- ✅ Skip database-dependent tests (use integration tests) + +#### 14.2 Handler Unit Tests +**File: `internal/handlers/dashboard_test.go`** (new file) + +```go +package handlers + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestGetSectionType(t *testing.T) { + t.Run("Smart sections", func(t *testing.T) { + smartSections := []string{ + "continue-reading", "in-progress", "recently-added", + "recently-read", "unread", + } + for _, key := range smartSections { + result := getSectionType(key) + assert.Equal(t, "smart", result, "Section %s should be smart", key) + } + }) + + t.Run("Collection sections", func(t *testing.T) { + result := getSectionType("collection-uuid-123") + assert.Equal(t, "collection", result) + }) +} + +func TestGetSectionTitle(t *testing.T) { + tests := []struct { + key string + expected string + }{ + {"continue-reading", "Continue Reading"}, + {"in-progress", "In Progress"}, + {"recently-added", "Recently Added"}, + {"recently-read", "Recently Read"}, + {"unread", "Not Started"}, + {"my-custom-collection", "my-custom-collection"}, + } + + for _, tt := range tests { + t.Run(tt.key, func(t *testing.T) { + result := getSectionTitle(tt.key) + assert.Equal(t, tt.expected, result) + }) + } +} + +func TestGetSectionIcon(t *testing.T) { + tests := []struct { + key string + expected string + }{ + {"continue-reading", "📖"}, + {"in-progress", "📚"}, + {"recently-added", "🆕"}, + {"recently-read", "✅"}, + {"unread", "📕"}, + {"unknown", "📚"}, // Default + } + + for _, tt := range tests { + t.Run(tt.key, func(t *testing.T) { + result := getSectionIcon(tt.key) + assert.Equal(t, tt.expected, result) + }) + } +} + +func TestGetSectionViewAllURL(t *testing.T) { + tests := []struct { + key string + expected string + }{ + {"continue-reading", "/section/continue-reading"}, + {"in-progress", "/section/in-progress"}, + {"recently-added", "/section/recently-added"}, + {"recently-read", "/history"}, + {"unread", "/section/unread"}, + {"my-collection", ""}, // Collections don't have view-all + } + + for _, tt := range tests { + t.Run(tt.key, func(t *testing.T) { + result := getSectionViewAllURL(tt.key) + assert.Equal(t, tt.expected, result) + }) + } +} +``` + +**Key Points**: +- ✅ Unit tests alongside handler file (`dashboard_test.go`) +- ✅ Test pure helper functions (getSectionType, getSectionTitle, etc.) +- ✅ Table-driven tests for multiple scenarios +- ✅ No HTTP requests (use integration tests) + +--- + +### **Phase 15: Integration Tests** (2-3 hours) + +**COMPLIANCE**: Integration tests in `cmd/server/tests/`, using `setupTestServer` helper + +**File: `cmd/server/tests/dashboard_test.go`** (new file) + +```go +package main + +import ( + "bytes" + "encoding/json" + "net/http" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestDashboardAPI_GetSections(t *testing.T) { + setup := setupTestServer(t) + adminToken := loginTestUser(t, setup.Server, setup.DB) + + // Create test library with media items + deviceSetup := setupDeviceTest(t) + libraryID := deviceSetup.CreateLibrary(t, "Test Ebooks Library", "ebooks") + + t.Run("GetSections_AsAdmin", func(t *testing.T) { + req, _ := http.NewRequest("GET", setup.Server.URL+"/api/dashboard/sections?library_id="+libraryID, nil) + req.Header.Set("Authorization", "Bearer "+adminToken) + + resp, err := http.DefaultClient.Do(req) + require.NoError(t, err) + defer resp.Body.Close() + + assert.Equal(t, http.StatusOK, resp.StatusCode) + + var result map[string]interface{} + json.NewDecoder(resp.Body).Decode(&result) + + sections, exists := result["sections"] + assert.True(t, exists, "Response should contain sections") + assert.NotNil(t, sections) + + // Verify section structure + sectionsArray := sections.([]interface{}) + assert.Greater(t, len(sectionsArray), 0, "Should have at least one section") + + // Verify smart sections exist + sectionKeys := make(map[string]bool) + for _, s := range sectionsArray { + section := s.(map[string]interface{}) + key := section["id"].(string) + sectionKeys[key] = true + + // Verify structure + assert.Contains(t, section, "type") + assert.Contains(t, section, "title") + assert.Contains(t, section, "icon") + assert.Contains(t, section, "items") + } + + // Check for expected smart sections + assert.True(t, sectionKeys["continue-reading"] || sectionKeys["recently-added"], + "Should have at least one smart section") + }) + + t.Run("GetSections_WithoutAuth", func(t *testing.T) { + req, _ := http.NewRequest("GET", setup.Server.URL+"/api/dashboard/sections?library_id="+libraryID, nil) + // No authorization header + + resp, err := http.DefaultClient.Do(req) + require.NoError(t, err) + defer resp.Body.Close() + + assert.Equal(t, http.StatusUnauthorized, resp.StatusCode) + }) + + t.Run("GetSections_MissingLibraryID", func(t *testing.T) { + req, _ := http.NewRequest("GET", setup.Server.URL+"/api/dashboard/sections", nil) + req.Header.Set("Authorization", "Bearer "+adminToken) + + resp, err := http.DefaultClient.Do(req) + require.NoError(t, err) + defer resp.Body.Close() + + assert.Equal(t, http.StatusBadRequest, resp.StatusCode) + }) + + t.Run("GetSections_InvalidLibraryID", func(t *testing.T) { + req, _ := http.NewRequest("GET", setup.Server.URL+"/api/dashboard/sections?library_id=invalid-uuid", nil) + req.Header.Set("Authorization", "Bearer "+adminToken) + + resp, err := http.DefaultClient.Do(req) + require.NoError(t, err) + defer resp.Body.Close() + + assert.Equal(t, http.StatusBadRequest, resp.StatusCode) + }) + + t.Run("GetSections_WithLimit", func(t *testing.T) { + req, _ := http.NewRequest("GET", setup.Server.URL+"/api/dashboard/sections?library_id="+libraryID+"&limit=10", nil) + req.Header.Set("Authorization", "Bearer "+adminToken) + + resp, err := http.DefaultClient.Do(req) + require.NoError(t, err) + defer resp.Body.Close() + + assert.Equal(t, http.StatusOK, resp.StatusCode) + + var result map[string]interface{} + json.NewDecoder(resp.Body).Decode(&result) + + sections := result["sections"].([]interface{}) + for _, s := range sections { + section := s.(map[string]interface{}) + items := section["items"].([]interface{}) + assert.LessOrEqual(t, len(items), 10, "Should respect limit parameter") + } + }) + + t.Run("GetSections_WithRegularUser", func(t *testing.T) { + // Get regular user token + regularToken := loginRegularUser(t, setup.Server, setup.DB) + + req, _ := http.NewRequest("GET", setup.Server.URL+"/api/dashboard/sections?library_id="+libraryID, nil) + req.Header.Set("Authorization", "Bearer "+regularToken) + + resp, err := http.DefaultClient.Do(req) + require.NoError(t, err) + defer resp.Body.Close() + + assert.Equal(t, http.StatusOK, resp.StatusCode) + }) +} + +func TestDashboardAPI_UserPreferences(t *testing.T) { + setup := setupTestServer(t) + adminToken := loginTestUser(t, setup.Server, setup.DB) + + // Create test library + deviceSetup := setupDeviceTest(t) + libraryID := deviceSetup.CreateLibrary(t, "Test Library", "ebooks") + + t.Run("GetSections_WithHiddenSections", func(t *testing.T) { + // First, save preferences to hide a section + userUUID := getUserUUIDFromToken(t, setup.DB, adminToken) + libUUID := parseUUID(t, libraryID) + + // Save dashboard preferences with hidden sections + updateDashboardPreferences(t, setup.DB, userUUID, libUUID, map[string]interface{}{ + "hidden_sections": []string{"recently-added"}, + }) + + // Now get sections - "recently-added" should be hidden + req, _ := http.NewRequest("GET", setup.Server.URL+"/api/dashboard/sections?library_id="+libraryID, nil) + req.Header.Set("Authorization", "Bearer "+adminToken) + + resp, err := http.DefaultClient.Do(req) + require.NoError(t, err) + defer resp.Body.Close() + + assert.Equal(t, http.StatusOK, resp.StatusCode) + + var result map[string]interface{} + json.NewDecoder(resp.Body).Decode(&result) + + sections := result["sections"].([]interface{}) + + // Verify "recently-added" is not in response + for _, s := range sections { + section := s.(map[string]interface{}) + sectionID := section["id"].(string) + assert.NotEqual(t, "recently-added", sectionID, "Recently added should be hidden") + } + }) + + t.Run("GetSections_WithCustomOrder", func(t *testing.T) { + userUUID := getUserUUIDFromToken(t, setup.DB, adminToken) + libUUID := parseUUID(t, libraryID) + + // Save dashboard preferences with custom order + customOrder := []string{"recently-read", "continue-reading", "in-progress"} + updateDashboardPreferences(t, setup.DB, userUUID, libUUID, map[string]interface{}{ + "section_order": customOrder, + }) + + // Get sections - should return in custom order + req, _ := http.NewRequest("GET", setup.Server.URL+"/api/dashboard/sections?library_id="+libraryID, nil) + req.Header.Set("Authorization", "Bearer "+adminToken) + + resp, err := http.DefaultClient.Do(req) + require.NoError(t, err) + defer resp.Body.Close() + + assert.Equal(t, http.StatusOK, resp.StatusCode) + + var result map[string]interface{} + json.NewDecoder(resp.Body).Decode(&result) + + sections := result["sections"].([]interface{}) + + // Verify order matches custom order (for sections that exist) + sectionOrder := make([]string, 0) + for _, s := range sections { + section := s.(map[string]interface{}) + sectionID := section["id"].(string) + sectionOrder = append(sectionOrder, sectionID) + } + + // First section should be "recently-read" if it exists + if len(sectionOrder) > 0 { + assert.Equal(t, "recently-read", sectionOrder[0]) + } + }) +} + +func TestDashboardSSR_Page(t *testing.T) { + setup := setupTestServer(t) + adminToken := loginTestUser(t, setup.Server, setup.DB) + + // Create test library + deviceSetup := setupDeviceTest(t) + libraryID := deviceSetup.CreateLibrary(t, "Test Library", "ebooks") + + t.Run("GetDashboardPage_AsAdmin", func(t *testing.T) { + req, _ := http.NewRequest("GET", setup.Server.URL+"/dashboard?library_id="+libraryID, nil) + req.Header.Set("Authorization", "Bearer "+adminToken) + + resp, err := http.DefaultClient.Do(req) + require.NoError(t, err) + defer resp.Body.Close() + + assert.Equal(t, http.StatusOK, resp.StatusCode) + assert.Contains(t, resp.Header.Get("Content-Type"), "text/html") + + // Verify HTML contains dashboard elements + body := new(bytes.Buffer) + body.ReadFrom(resp.Body) + html := body.String() + + assert.Contains(t, html, "dashboard-section") + assert.Contains(t, html, "carousel-track") + }) + + t.Run("GetDashboardPage_WithoutAuth", func(t *testing.T) { + req, _ := http.NewRequest("GET", setup.Server.URL+"/dashboard?library_id="+libraryID, nil) + // No authorization header + + resp, err := http.DefaultClient.Do(req) + require.NoError(t, err) + defer resp.Body.Close() + + assert.Equal(t, http.StatusUnauthorized, resp.StatusCode) + }) +} + +// Helper functions for dashboard tests + +func updateDashboardPreferences(t *testing.T, db *database.Queries, userID, libraryID uuid.UUID, prefs map[string]interface{}) { + hiddenSections := prefs["hidden_sections"].([]string) + sectionOrder := prefs["section_order"].([]string) + + _, err := db.UpsertDashboardPreferences(context.Background(), database.UpsertDashboardPreferencesParams{ + UserID: pgtype.UUID{Bytes: userID, Valid: true}, + LibraryID: pgtype.UUID{Bytes: libraryID, Valid: true}, + HiddenSections: hiddenSections, + SectionOrder: sectionOrder, + ItemsPerSection: pgtype.Int4{Int32: 20, Valid: true}, + }) + require.NoError(t, err, "Failed to update dashboard preferences") +} + +func getUserUUIDFromToken(t *testing.T, db *database.Queries, token string) uuid.UUID { + // Parse JWT and extract user ID + // This would use the same logic as the JWT middleware + // For now, return test user UUID from database + user, err := db.GetUserByEmail(context.Background(), "testuser@example.com") + require.NoError(t, err) + + return uuid.UUID(user.ID.Bytes[0:16]) +} + +func parseUUID(t *testing.T, uuidStr string) uuid.UUID { + id, err := uuid.Parse(uuidStr) + require.NoError(t, err) + return id +} +``` + +**Key Points**: +- ✅ Integration tests in `cmd/server/tests/` +- ✅ Uses `setupTestServer(t)` helper (from `test_helpers.go`) +- ✅ Three-context testing (no auth, user, admin) +- ✅ Tests both JSON API (`/api/dashboard/sections`) and SSR (`/dashboard`) +- ✅ Tests user preferences (hidden sections, custom order) +- ✅ Follows existing test patterns (see `auth_test.go`, `collections_bulk_test.go`) +- ✅ Uses `require.NoError` for setup, `assert.Equal` for verification + +--- + ## Summary: Key Changes from Original Carousel Dashboard Plan ### ✅ **What's Unchanged** (Phases 1-3, 7-11): @@ -2009,7 +2516,8 @@ on('click', '[data-action="open-dashboard-settings"]', () => { | **Dashboard Phase 7-9** | 6-7 hours | TypeScript Conversion | Templates + Settings | | **Dashboard Phase 10-11** | 2-3 hours | TypeScript Conversion + Dashboard 1-9 | TypeScript modules | | **Dashboard Phase 12-13** | 1-2 hours | Dashboard 1-11 | Documentation + Bruno tests | -| **Total** | **19-25 days** | | Complete TypeScript + Carousel Dashboard | +| **Dashboard Phase 14-15** | 4-6 hours | Dashboard 1-13 | Unit tests + Integration tests | +| **Total** | **23-31 days** | | Complete TypeScript + Carousel Dashboard + Tests | --- @@ -2048,6 +2556,26 @@ on('click', '[data-action="open-dashboard-settings"]', () => { - [ ] Three-context testing verified - [ ] Documentation matches implementation +### Unit Tests (Phase 14): +- [ ] Service layer tests (`internal/services/dashboard_service_test.go`) +- [ ] Handler helper tests (`internal/handlers/dashboard_test.go`) +- [ ] filterHiddenSections() tested +- [ ] reorderSections() tested +- [ ] getSectionType() tested +- [ ] getSectionTitle() tested +- [ ] getSectionIcon() tested +- [ ] All unit tests passing (`go test ./internal/services/... ./internal/handlers/...`) + +### Integration Tests (Phase 15): +- [ ] Integration tests created (`cmd/server/tests/dashboard_test.go`) +- [ ] Uses setupTestServer() helper +- [ ] Three-context testing (no auth, user, admin) +- [ ] GET /api/dashboard/sections tested +- [ ] User preferences tested (hidden sections, custom order) +- [ ] SSR /dashboard tested +- [ ] Error cases tested (missing library_id, invalid UUID) +- [ ] All integration tests passing (`go test ./cmd/server/tests/...`) + --- *Updated: 2025-02-17* @@ -2055,3 +2583,4 @@ on('click', '[data-action="open-dashboard-settings"]', () => { *Follows: PROJECT_GUIDELINES.md + TYPESCRIPT_CONVERSION_PLAN.md* *Architecture: Hybrid SSR + Generic API with Single Source of Truth* *Key Changes: Added `/api/dashboard/sections` JSON endpoint, user preferences applied in service layer* +*Testing: Unit tests alongside files, Integration tests in cmd/server/tests/ with setupTestServer helper*