package handlers import ( "context" "encoding/json" "html/template" "net/http" "net/http/httptest" "os" "path/filepath" "testing" "time" "task-dashboard/internal/api" "task-dashboard/internal/config" "task-dashboard/internal/models" "task-dashboard/internal/store" ) // setupTestDB creates a temporary test database func setupTestDB(t *testing.T) (*store.Store, func()) { t.Helper() // Create temp database file tmpFile, err := os.CreateTemp("", "test_*.db") if err != nil { t.Fatalf("Failed to create temp db: %v", err) } _ = tmpFile.Close() // Save current directory and change to project root // This ensures migrations can be found originalDir, err := os.Getwd() if err != nil { t.Fatalf("Failed to get working directory: %v", err) } // Change to project root (2 levels up from internal/handlers) if err := os.Chdir("../../"); err != nil { t.Fatalf("Failed to change to project root: %v", err) } // Initialize store (this runs migrations) db, err := store.New(tmpFile.Name(), "migrations") if err != nil { _ = os.Chdir(originalDir) _ = os.Remove(tmpFile.Name()) t.Fatalf("Failed to initialize store: %v", err) } // Return to original directory _ = os.Chdir(originalDir) // Return cleanup function cleanup := func() { _ = db.Close() _ = os.Remove(tmpFile.Name()) } return db, cleanup } // loadTestTemplates loads templates for testing from project root func loadTestTemplates(t *testing.T) *template.Template { t.Helper() // Get path relative to project root tmpl, err := template.ParseGlob(filepath.Join("web", "templates", "*.html")) if err != nil { // Try from internal/handlers (2 levels up) tmpl, err = template.ParseGlob(filepath.Join("..", "..", "web", "templates", "*.html")) if err != nil { t.Logf("Warning: failed to parse templates: %v", err) return nil } } // Parse partials - don't reassign tmpl if parsing fails if parsed, err := tmpl.ParseGlob(filepath.Join("web", "templates", "partials", "*.html")); err == nil { tmpl = parsed } else if parsed, err := tmpl.ParseGlob(filepath.Join("..", "..", "web", "templates", "partials", "*.html")); err == nil { tmpl = parsed } return tmpl } // mockTodoistClient creates a mock Todoist client for testing type mockTodoistClient struct { tasks []models.Task err error } func (m *mockTodoistClient) GetTasks(ctx context.Context) ([]models.Task, error) { if m.err != nil { return nil, m.err } return m.tasks, nil } func (m *mockTodoistClient) GetProjects(ctx context.Context) ([]models.Project, error) { return []models.Project{}, nil } func (m *mockTodoistClient) CreateTask(ctx context.Context, content, projectID string, dueDate *time.Time, priority int) (*models.Task, error) { return nil, nil } func (m *mockTodoistClient) UpdateTask(ctx context.Context, taskID string, updates map[string]interface{}) error { return m.err } func (m *mockTodoistClient) CompleteTask(ctx context.Context, taskID string) error { return nil } func (m *mockTodoistClient) ReopenTask(ctx context.Context, taskID string) error { return nil } func (m *mockTodoistClient) Sync(ctx context.Context, syncToken string) (*api.TodoistSyncResponse, error) { if m.err != nil { return nil, m.err } // Return a mock sync response with tasks converted to sync items items := make([]api.SyncItemResponse, 0, len(m.tasks)) for _, task := range m.tasks { items = append(items, api.SyncItemResponse{ ID: task.ID, Content: task.Content, Description: task.Description, ProjectID: task.ProjectID, Priority: task.Priority, Labels: task.Labels, IsCompleted: task.Completed, IsDeleted: false, }) } return &api.TodoistSyncResponse{ SyncToken: "test-sync-token", FullSync: true, Items: items, Projects: []api.SyncProjectResponse{}, }, nil } // mockTrelloClient creates a mock Trello client for testing type mockTrelloClient struct { boards []models.Board err error } func (m *mockTrelloClient) GetBoardsWithCards(ctx context.Context) ([]models.Board, error) { if m.err != nil { return nil, m.err } return m.boards, nil } func (m *mockTrelloClient) GetBoards(ctx context.Context) ([]models.Board, error) { if m.err != nil { return nil, m.err } return m.boards, nil } func (m *mockTrelloClient) GetCards(ctx context.Context, boardID string) ([]models.Card, error) { return []models.Card{}, nil } func (m *mockTrelloClient) GetLists(ctx context.Context, boardID string) ([]models.List, error) { return []models.List{}, nil } func (m *mockTrelloClient) CreateCard(ctx context.Context, listID, name, description string, dueDate *time.Time) (*models.Card, error) { return nil, nil } func (m *mockTrelloClient) UpdateCard(ctx context.Context, cardID string, updates map[string]interface{}) error { return nil } // TestHandleGetTasks tests the HandleGetTasks handler func TestHandleGetTasks(t *testing.T) { db, cleanup := setupTestDB(t) defer cleanup() // Create test tasks testTasks := []models.Task{ { ID: "1", Content: "Test task 1", Description: "Description 1", ProjectID: "proj1", ProjectName: "Project 1", Priority: 1, Completed: false, Labels: []string{"label1"}, URL: "https://todoist.com/task/1", CreatedAt: time.Now(), }, { ID: "2", Content: "Test task 2", Description: "Description 2", ProjectID: "proj2", ProjectName: "Project 2", Priority: 2, Completed: true, Labels: []string{"label2"}, URL: "https://todoist.com/task/2", CreatedAt: time.Now(), }, } // Save tasks to database if err := db.SaveTasks(testTasks); err != nil { t.Fatalf("Failed to save test tasks: %v", err) } // Create handler with mock client cfg := &config.Config{ CacheTTLMinutes: 5, } mockTodoist := &mockTodoistClient{} h := &Handler{ store: db, todoistClient: mockTodoist, config: cfg, } // Create test request req := httptest.NewRequest("GET", "/api/tasks", nil) w := httptest.NewRecorder() // Execute handler h.HandleGetTasks(w, req) // Check response if w.Code != http.StatusOK { t.Errorf("Expected status 200, got %d", w.Code) } // Parse response var tasks []models.Task if err := json.NewDecoder(w.Body).Decode(&tasks); err != nil { t.Fatalf("Failed to decode response: %v", err) } // Verify tasks if len(tasks) != 2 { t.Errorf("Expected 2 tasks, got %d", len(tasks)) } } // TestHandleGetBoards tests the HandleGetBoards handler func TestHandleGetBoards(t *testing.T) { db, cleanup := setupTestDB(t) defer cleanup() // Create test boards testBoards := []models.Board{ { ID: "board1", Name: "Test Board 1", Cards: []models.Card{ { ID: "card1", Name: "Card 1", ListID: "list1", ListName: "To Do", URL: "https://trello.com/c/card1", }, }, }, { ID: "board2", Name: "Test Board 2", Cards: []models.Card{ { ID: "card2", Name: "Card 2", ListID: "list2", ListName: "Done", URL: "https://trello.com/c/card2", }, }, }, } // Save boards to database if err := db.SaveBoards(testBoards); err != nil { t.Fatalf("Failed to save test boards: %v", err) } // Create handler cfg := &config.Config{ CacheTTLMinutes: 5, } h := &Handler{ store: db, trelloClient: &mockTrelloClient{boards: testBoards}, config: cfg, } // Create test request req := httptest.NewRequest("GET", "/api/boards", nil) w := httptest.NewRecorder() // Execute handler h.HandleGetBoards(w, req) // Check response if w.Code != http.StatusOK { t.Errorf("Expected status 200, got %d", w.Code) } // Parse response var boards []models.Board if err := json.NewDecoder(w.Body).Decode(&boards); err != nil { t.Fatalf("Failed to decode response: %v", err) } // Verify boards if len(boards) != 2 { t.Errorf("Expected 2 boards, got %d", len(boards)) } // Just verify we got boards back - cards may or may not be populated // depending on how the store handles the board->card relationship } // TestHandleRefresh tests the HandleRefresh handler func TestHandleRefresh(t *testing.T) { db, cleanup := setupTestDB(t) defer cleanup() // Create mock clients mockTodoist := &mockTodoistClient{ tasks: []models.Task{ { ID: "1", Content: "Test task", }, }, } mockTrello := &mockTrelloClient{ boards: []models.Board{ { ID: "board1", Name: "Test Board", }, }, } // Create handler cfg := &config.Config{ CacheTTLMinutes: 5, } h := &Handler{ store: db, todoistClient: mockTodoist, trelloClient: mockTrello, config: cfg, } // Create test request req := httptest.NewRequest("POST", "/api/refresh", nil) w := httptest.NewRecorder() // Execute handler h.HandleRefresh(w, req) // Check response if w.Code != http.StatusOK { t.Errorf("Expected status 200, got %d", w.Code) } // Parse response - check that it returns aggregated data var response models.DashboardData if err := json.NewDecoder(w.Body).Decode(&response); err != nil { // If it's not DashboardData, try a success response t.Log("Response is not DashboardData format, checking alternative format") } // Just verify we got a 200 OK - the actual response format can vary // The important thing is the handler doesn't error } // TestHandleGetMeals tests the HandleGetMeals handler func TestHandleGetMeals(t *testing.T) { db, cleanup := setupTestDB(t) defer cleanup() // Test with nil client should return empty array cfg := &config.Config{ CacheTTLMinutes: 5, } h := &Handler{ store: db, planToEatClient: nil, config: cfg, } req := httptest.NewRequest("GET", "/api/meals", nil) w := httptest.NewRecorder() h.HandleGetMeals(w, req) if w.Code != http.StatusOK { t.Errorf("Expected status 200, got %d", w.Code) } var meals []models.Meal if err := json.NewDecoder(w.Body).Decode(&meals); err != nil { t.Fatalf("Failed to decode response: %v", err) } // Handler returns empty array when client is nil if len(meals) != 0 { t.Errorf("Expected 0 meals when client is nil, got %d", len(meals)) } } // mockTodoistClientWithComplete tracks CompleteTask calls type mockTodoistClientWithComplete struct { mockTodoistClient completedTaskIDs []string completeErr error } func (m *mockTodoistClientWithComplete) CompleteTask(ctx context.Context, taskID string) error { if m.completeErr != nil { return m.completeErr } m.completedTaskIDs = append(m.completedTaskIDs, taskID) return nil } // mockTrelloClientWithUpdate tracks UpdateCard calls type mockTrelloClientWithUpdate struct { mockTrelloClient updatedCards []string updateErr error } func (m *mockTrelloClientWithUpdate) UpdateCard(ctx context.Context, cardID string, updates map[string]interface{}) error { if m.updateErr != nil { return m.updateErr } m.updatedCards = append(m.updatedCards, cardID) return nil } // TestHandleCompleteAtom_Todoist tests completing a Todoist task func TestHandleCompleteAtom_Todoist(t *testing.T) { db, cleanup := setupTestDB(t) defer cleanup() // Save a task to the cache tasks := []models.Task{ { ID: "task123", Content: "Test task", Completed: false, Labels: []string{}, CreatedAt: time.Now(), }, } if err := db.SaveTasks(tasks); err != nil { t.Fatalf("Failed to save test task: %v", err) } // Verify task exists in cache cachedTasks, _ := db.GetTasks() if len(cachedTasks) != 1 { t.Fatalf("Expected 1 task in cache, got %d", len(cachedTasks)) } // Create handler with mock client mockTodoist := &mockTodoistClientWithComplete{} h := &Handler{ store: db, todoistClient: mockTodoist, config: &config.Config{}, templates: loadTestTemplates(t), } // Create request req := httptest.NewRequest("POST", "/complete-atom", nil) req.Form = map[string][]string{ "id": {"task123"}, "source": {"todoist"}, } w := httptest.NewRecorder() // Execute handler h.HandleCompleteAtom(w, req) // Check response if w.Code != http.StatusOK { t.Errorf("Expected status 200, got %d", w.Code) } // Verify CompleteTask was called on the API if len(mockTodoist.completedTaskIDs) != 1 || mockTodoist.completedTaskIDs[0] != "task123" { t.Errorf("Expected CompleteTask to be called with 'task123', got %v", mockTodoist.completedTaskIDs) } // Verify task was deleted from cache cachedTasks, _ = db.GetTasks() if len(cachedTasks) != 0 { t.Errorf("Expected task to be deleted from cache, but found %d tasks", len(cachedTasks)) } } // TestHandleCompleteAtom_Trello tests completing a Trello card func TestHandleCompleteAtom_Trello(t *testing.T) { db, cleanup := setupTestDB(t) defer cleanup() // Save a board with a card to the cache boards := []models.Board{ { ID: "board1", Name: "Test Board", Cards: []models.Card{ { ID: "card456", Name: "Test Card", ListID: "list1", ListName: "To Do", }, }, }, } if err := db.SaveBoards(boards); err != nil { t.Fatalf("Failed to save test board: %v", err) } // Verify card exists in cache cachedBoards, _ := db.GetBoards() if len(cachedBoards) != 1 || len(cachedBoards[0].Cards) != 1 { t.Fatalf("Expected 1 board with 1 card in cache") } // Create handler with mock client mockTrello := &mockTrelloClientWithUpdate{} h := &Handler{ store: db, trelloClient: mockTrello, config: &config.Config{}, templates: loadTestTemplates(t), } // Create request req := httptest.NewRequest("POST", "/complete-atom", nil) req.Form = map[string][]string{ "id": {"card456"}, "source": {"trello"}, } w := httptest.NewRecorder() // Execute handler h.HandleCompleteAtom(w, req) // Check response if w.Code != http.StatusOK { t.Errorf("Expected status 200, got %d", w.Code) } // Verify UpdateCard was called on the API if len(mockTrello.updatedCards) != 1 || mockTrello.updatedCards[0] != "card456" { t.Errorf("Expected UpdateCard to be called with 'card456', got %v", mockTrello.updatedCards) } // Verify card was deleted from cache cachedBoards, _ = db.GetBoards() if len(cachedBoards[0].Cards) != 0 { t.Errorf("Expected card to be deleted from cache, but found %d cards", len(cachedBoards[0].Cards)) } } // TestHandleCompleteAtom_MissingParams tests error handling for missing parameters func TestHandleCompleteAtom_MissingParams(t *testing.T) { db, cleanup := setupTestDB(t) defer cleanup() h := &Handler{ store: db, config: &config.Config{}, } tests := []struct { name string id string source string }{ {"missing id", "", "todoist"}, {"missing source", "task123", ""}, {"both missing", "", ""}, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { req := httptest.NewRequest("POST", "/complete-atom", nil) req.Form = map[string][]string{ "id": {tc.id}, "source": {tc.source}, } w := httptest.NewRecorder() h.HandleCompleteAtom(w, req) if w.Code != http.StatusBadRequest { t.Errorf("Expected status 400 for %s, got %d", tc.name, w.Code) } }) } } // TestHandleCompleteAtom_UnknownSource tests error handling for unknown source func TestHandleCompleteAtom_UnknownSource(t *testing.T) { db, cleanup := setupTestDB(t) defer cleanup() h := &Handler{ store: db, config: &config.Config{}, } req := httptest.NewRequest("POST", "/complete-atom", nil) req.Form = map[string][]string{ "id": {"task123"}, "source": {"unknown"}, } w := httptest.NewRecorder() h.HandleCompleteAtom(w, req) if w.Code != http.StatusBadRequest { t.Errorf("Expected status 400 for unknown source, got %d", w.Code) } } // TestHandleCompleteAtom_APIError tests that API errors are handled correctly func TestHandleCompleteAtom_APIError(t *testing.T) { db, cleanup := setupTestDB(t) defer cleanup() // Save a task to the cache tasks := []models.Task{ { ID: "task123", Content: "Test task", Completed: false, Labels: []string{}, CreatedAt: time.Now(), }, } if err := db.SaveTasks(tasks); err != nil { t.Fatalf("Failed to save test task: %v", err) } // Create handler with mock client that returns an error mockTodoist := &mockTodoistClientWithComplete{ completeErr: context.DeadlineExceeded, } h := &Handler{ store: db, todoistClient: mockTodoist, config: &config.Config{}, } req := httptest.NewRequest("POST", "/complete-atom", nil) req.Form = map[string][]string{ "id": {"task123"}, "source": {"todoist"}, } w := httptest.NewRecorder() h.HandleCompleteAtom(w, req) // Should return 500 on API error if w.Code != http.StatusInternalServerError { t.Errorf("Expected status 500 on API error, got %d", w.Code) } // Verify task was NOT deleted from cache (rollback behavior) cachedTasks, _ := db.GetTasks() if len(cachedTasks) != 1 { t.Errorf("Task should NOT be deleted from cache on API error, found %d tasks", len(cachedTasks)) } }