docs(dashboard): add comprehensive unit and integration test phases

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
This commit is contained in:
2026-02-17 22:25:48 -05:00
parent d981fdf517
commit c775ed0a8e
+530 -1
View File
@@ -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*