package handlers
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/http/httptest"
"os"
"reflect"
"strings"
"testing"
"time"
"github.com/go-chi/chi/v5"
"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
}
// newTestRenderer creates a MockRenderer for testing
func newTestRenderer() *MockRenderer {
return NewMockRenderer()
}
// setupTestHandler creates a test handler with db, mock renderer, and default config
// Returns the handler and a cleanup function
func setupTestHandler(t *testing.T) (*Handler, func()) {
t.Helper()
db, cleanup := setupTestDB(t)
h := &Handler{
store: db,
renderer: newTestRenderer(),
config: &config.Config{CacheTTLMinutes: 5},
}
return h, cleanup
}
// 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{},
renderer: newTestRenderer(),
}
// 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 response body contains rendered completed-atom template
body := w.Body.String()
if !strings.Contains(body, "rendered:completed-atom") {
t.Errorf("Expected response body to contain 'rendered:completed-atom', got %q", body)
}
// Verify Content-Type header
ct := w.Header().Get("Content-Type")
if ct != "text/html; charset=utf-8" {
t.Errorf("Expected Content-Type 'text/html; charset=utf-8', got %q", ct)
}
// 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))
}
}
func TestHandleCompleteAtom_RendersCorrectTemplateData(t *testing.T) {
db, cleanup := setupTestDB(t)
defer cleanup()
// Seed a task with known title
tasks := []models.Task{
{ID: "task-data-1", Content: "Buy groceries", Labels: []string{}, CreatedAt: time.Now()},
}
if err := db.SaveTasks(tasks); err != nil {
t.Fatalf("Failed to seed: %v", err)
}
renderer := NewMockRenderer()
h := &Handler{
store: db,
todoistClient: &mockTodoistClientWithComplete{},
config: &config.Config{},
renderer: renderer,
}
req := httptest.NewRequest("POST", "/complete-atom", nil)
req.Form = map[string][]string{"id": {"task-data-1"}, "source": {"todoist"}}
w := httptest.NewRecorder()
h.HandleCompleteAtom(w, req)
// Verify renderer was called with "completed-atom" and correct data
if len(renderer.Calls) != 1 {
t.Fatalf("Expected 1 render call, got %d", len(renderer.Calls))
}
call := renderer.Calls[0]
if call.Name != "completed-atom" {
t.Errorf("Expected template 'completed-atom', got %q", call.Name)
}
// The data should contain the task ID, source, and title
type atomData struct {
ID string
Source string
Title string
}
// Use JSON round-trip to extract fields from the anonymous struct
jsonBytes, _ := json.Marshal(call.Data)
var got atomData
if err := json.Unmarshal(jsonBytes, &got); err != nil {
t.Fatalf("Failed to unmarshal render data: %v", err)
}
if got.ID != "task-data-1" {
t.Errorf("Expected data.ID 'task-data-1', got %q", got.ID)
}
if got.Source != "todoist" {
t.Errorf("Expected data.Source 'todoist', got %q", got.Source)
}
if got.Title != "Buy groceries" {
t.Errorf("Expected data.Title 'Buy groceries', got %q", got.Title)
}
}
// 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{},
renderer: newTestRenderer(),
}
// 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))
}
}
// TestHandleShoppingModeComplete tests completing (removing) shopping items
func TestHandleShoppingModeComplete(t *testing.T) {
db, cleanup := setupTestDB(t)
defer cleanup()
h := &Handler{
store: db,
renderer: newTestRenderer(),
config: &config.Config{TemplateDir: "web/templates"},
}
// Add a user shopping item
err := db.SaveUserShoppingItem("Test Item", "TestStore")
if err != nil {
t.Fatalf("Failed to save user shopping item: %v", err)
}
// Get the item to find its ID
items, err := db.GetUserShoppingItems()
if err != nil {
t.Fatalf("Failed to get user shopping items: %v", err)
}
if len(items) != 1 {
t.Fatalf("Expected 1 item, got %d", len(items))
}
itemID := items[0].ID
t.Run("complete user item deletes it", func(t *testing.T) {
req := httptest.NewRequest("POST", "/shopping/mode/TestStore/complete", nil)
req.Form = map[string][]string{
"id": {fmt.Sprintf("user-%d", itemID)},
"source": {"user"},
}
// Add chi URL params
rctx := chi.NewRouteContext()
rctx.URLParams.Add("store", "TestStore")
req = req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, rctx))
w := httptest.NewRecorder()
h.HandleShoppingModeComplete(w, req)
if w.Code != http.StatusOK {
t.Errorf("Expected status 200, got %d", w.Code)
}
// Verify item was deleted
remainingItems, _ := db.GetUserShoppingItems()
if len(remainingItems) != 0 {
t.Errorf("Expected 0 items after completion, got %d", len(remainingItems))
}
})
t.Run("complete external item marks it checked", func(t *testing.T) {
req := httptest.NewRequest("POST", "/shopping/mode/TestStore/complete", nil)
req.Form = map[string][]string{
"id": {"trello-123"},
"source": {"trello"},
}
rctx := chi.NewRouteContext()
rctx.URLParams.Add("store", "TestStore")
req = req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, rctx))
w := httptest.NewRecorder()
h.HandleShoppingModeComplete(w, req)
if w.Code != http.StatusOK {
t.Errorf("Expected status 200, got %d", w.Code)
}
// Verify item is marked as checked
checks, _ := db.GetShoppingItemChecks("trello")
if !checks["trello-123"] {
t.Error("Expected trello item to be marked as checked")
}
})
}
// TestShoppingListFiltersCheckedItems verifies that checked items are excluded from shopping lists
func TestShoppingListFiltersCheckedItems(t *testing.T) {
db, cleanup := setupTestDB(t)
defer cleanup()
// Add two user shopping items
err := db.SaveUserShoppingItem("Unchecked Item", "TestStore")
if err != nil {
t.Fatalf("Failed to save first item: %v", err)
}
err = db.SaveUserShoppingItem("Item To Check", "TestStore")
if err != nil {
t.Fatalf("Failed to save second item: %v", err)
}
// Get items and check one of them
items, _ := db.GetUserShoppingItems()
if len(items) != 2 {
t.Fatalf("Expected 2 items, got %d", len(items))
}
// Find and toggle the "Item To Check"
for _, item := range items {
if item.Name == "Item To Check" {
err = db.ToggleUserShoppingItem(item.ID, true)
if err != nil {
t.Fatalf("Failed to check item: %v", err)
}
break
}
}
// Verify the checked item is still in database but marked as checked
allItems, _ := db.GetUserShoppingItems()
checkedCount := 0
uncheckedCount := 0
for _, item := range allItems {
if item.Checked {
checkedCount++
} else {
uncheckedCount++
}
}
if checkedCount != 1 {
t.Errorf("Expected 1 checked item, got %d", checkedCount)
}
if uncheckedCount != 1 {
t.Errorf("Expected 1 unchecked item, got %d", uncheckedCount)
}
}
// TestShoppingModeItemsTemplateFiltersChecked verifies the template only shows unchecked items
func TestShoppingModeItemsTemplateFiltersChecked(t *testing.T) {
// This test documents the expected behavior:
// The shopping-mode-items template uses {{if not .Checked}} to filter items
// The shopping-tab template should also filter checked items
// Template filtering is handled in HTML, so we verify the data structure
// supports the Checked field that templates use for filtering
item := models.UnifiedShoppingItem{
ID: "test-1",
Name: "Test Item",
Checked: true,
}
if !item.Checked {
t.Error("Checked field should be true")
}
// Verify the field exists and can be used for filtering
items := []models.UnifiedShoppingItem{
{ID: "1", Name: "Unchecked", Checked: false},
{ID: "2", Name: "Checked", Checked: true},
}
uncheckedCount := 0
for _, i := range items {
if !i.Checked {
uncheckedCount++
}
}
if uncheckedCount != 1 {
t.Errorf("Expected 1 unchecked item when filtering, got %d", uncheckedCount)
}
}
// =============================================================================
// Quick Add UX Pattern Tests (per DESIGN.md)
// =============================================================================
// TestShoppingQuickAdd_Success verifies successful quick add returns updated list
func TestShoppingQuickAdd_Success(t *testing.T) {
db, cleanup := setupTestDB(t)
defer cleanup()
h := &Handler{
store: db,
renderer: newTestRenderer(),
config: &config.Config{},
}
// Add item via quick add
req := httptest.NewRequest("POST", "/shopping/add", nil)
req.Form = map[string][]string{
"name": {"Test Item"},
"store": {"TestStore"},
}
w := httptest.NewRecorder()
h.HandleShoppingQuickAdd(w, req)
if w.Code != http.StatusOK {
t.Errorf("Expected status 200, got %d", w.Code)
}
// Verify item was saved
items, _ := db.GetUserShoppingItems()
if len(items) != 1 {
t.Errorf("Expected 1 item saved, got %d", len(items))
}
if items[0].Name != "Test Item" {
t.Errorf("Expected item name 'Test Item', got '%s'", items[0].Name)
}
if items[0].Store != "TestStore" {
t.Errorf("Expected store 'TestStore', got '%s'", items[0].Store)
}
// Verify response contains HTML (updated list)
contentType := w.Header().Get("Content-Type")
if !strings.Contains(contentType, "text/html") {
t.Errorf("Expected text/html content type, got %s", contentType)
}
}
// TestShoppingQuickAdd_ValidationErrors verifies validation returns errors
func TestShoppingQuickAdd_ValidationErrors(t *testing.T) {
db, cleanup := setupTestDB(t)
defer cleanup()
h := &Handler{
store: db,
config: &config.Config{},
}
tests := []struct {
name string
formName string
formStore string
expectedError string
}{
{"missing name", "", "TestStore", "Name is required"},
{"missing store", "Test Item", "", "Store is required"},
{"whitespace only name", " ", "TestStore", "Name is required"},
{"whitespace only store", "Test Item", " ", "Store is required"},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
req := httptest.NewRequest("POST", "/shopping/add", nil)
req.Form = map[string][]string{
"name": {tc.formName},
"store": {tc.formStore},
}
w := httptest.NewRecorder()
h.HandleShoppingQuickAdd(w, req)
if w.Code != http.StatusBadRequest {
t.Errorf("Expected status 400, got %d", w.Code)
}
body := w.Body.String()
if !strings.Contains(body, tc.expectedError) {
t.Errorf("Expected error '%s' in response, got: %s", tc.expectedError, body)
}
})
}
}
// TestShoppingQuickAdd_ShoppingModeReturnsStoreItems tests shopping-mode variant
func TestShoppingQuickAdd_ShoppingModeReturnsStoreItems(t *testing.T) {
db, cleanup := setupTestDB(t)
defer cleanup()
h := &Handler{
store: db,
renderer: newTestRenderer(),
config: &config.Config{},
}
// Pre-add an item to another store
_ = db.SaveUserShoppingItem("Other Item", "OtherStore")
// Add item in shopping mode
req := httptest.NewRequest("POST", "/shopping/add", nil)
req.Form = map[string][]string{
"name": {"Shopping Mode Item"},
"store": {"TestStore"},
"mode": {"shopping-mode"},
}
w := httptest.NewRecorder()
h.HandleShoppingQuickAdd(w, req)
if w.Code != http.StatusOK {
t.Errorf("Expected status 200, got %d", w.Code)
}
// Verify both items were saved
items, _ := db.GetUserShoppingItems()
if len(items) != 2 {
t.Errorf("Expected 2 items, got %d", len(items))
}
}
// TestShoppingComplete_UserItemDeleted verifies user items are deleted on complete
func TestShoppingComplete_UserItemDeleted(t *testing.T) {
db, cleanup := setupTestDB(t)
defer cleanup()
h := &Handler{
store: db,
renderer: newTestRenderer(),
config: &config.Config{},
}
// Add user item
err := db.SaveUserShoppingItem("Item to Delete", "TestStore")
if err != nil {
t.Fatalf("Failed to save item: %v", err)
}
items, _ := db.GetUserShoppingItems()
if len(items) != 1 {
t.Fatalf("Expected 1 item, got %d", len(items))
}
itemID := items[0].ID
// Complete the item
req := httptest.NewRequest("POST", "/shopping/mode/TestStore/complete", nil)
req.Form = map[string][]string{
"id": {fmt.Sprintf("user-%d", itemID)},
"source": {"user"},
}
rctx := chi.NewRouteContext()
rctx.URLParams.Add("store", "TestStore")
req = req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, rctx))
w := httptest.NewRecorder()
h.HandleShoppingModeComplete(w, req)
if w.Code != http.StatusOK {
t.Errorf("Expected status 200, got %d", w.Code)
}
// Verify item was DELETED (not just marked checked)
remaining, _ := db.GetUserShoppingItems()
if len(remaining) != 0 {
t.Errorf("User item should be deleted, but found %d items", len(remaining))
}
}
// TestShoppingComplete_ExternalItemMarkedChecked verifies external items are marked checked
func TestShoppingComplete_ExternalItemMarkedChecked(t *testing.T) {
db, cleanup := setupTestDB(t)
defer cleanup()
h := &Handler{
store: db,
renderer: newTestRenderer(),
config: &config.Config{},
}
tests := []struct {
source string
itemID string
}{
{"trello", "trello-card-123"},
{"plantoeat", "pte-item-456"},
}
for _, tc := range tests {
t.Run(tc.source, func(t *testing.T) {
req := httptest.NewRequest("POST", "/shopping/mode/TestStore/complete", nil)
req.Form = map[string][]string{
"id": {tc.itemID},
"source": {tc.source},
}
rctx := chi.NewRouteContext()
rctx.URLParams.Add("store", "TestStore")
req = req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, rctx))
w := httptest.NewRecorder()
h.HandleShoppingModeComplete(w, req)
if w.Code != http.StatusOK {
t.Errorf("Expected status 200, got %d", w.Code)
}
// Verify item is marked as checked (not deleted)
checks, _ := db.GetShoppingItemChecks(tc.source)
if !checks[tc.itemID] {
t.Errorf("Expected %s item %s to be marked checked", tc.source, tc.itemID)
}
})
}
}
// TestShoppingToggle_UpdatesItemState verifies toggle correctly updates state
func TestShoppingToggle_UpdatesItemState(t *testing.T) {
db, cleanup := setupTestDB(t)
defer cleanup()
h := &Handler{
store: db,
renderer: newTestRenderer(),
config: &config.Config{},
}
// Add user item
_ = db.SaveUserShoppingItem("Toggle Item", "TestStore")
items, _ := db.GetUserShoppingItems()
itemID := items[0].ID
// Toggle to checked
req := httptest.NewRequest("POST", "/shopping/toggle", nil)
req.Form = map[string][]string{
"id": {fmt.Sprintf("user-%d", itemID)},
"source": {"user"},
"checked": {"true"},
}
w := httptest.NewRecorder()
h.HandleShoppingToggle(w, req)
if w.Code != http.StatusOK {
t.Errorf("Expected status 200, got %d", w.Code)
}
// Verify item is checked
items, _ = db.GetUserShoppingItems()
if !items[0].Checked {
t.Error("Expected item to be checked after toggle")
}
// Toggle back to unchecked
req = httptest.NewRequest("POST", "/shopping/toggle", nil)
req.Form = map[string][]string{
"id": {fmt.Sprintf("user-%d", itemID)},
"source": {"user"},
"checked": {"false"},
}
w = httptest.NewRecorder()
h.HandleShoppingToggle(w, req)
items, _ = db.GetUserShoppingItems()
if items[0].Checked {
t.Error("Expected item to be unchecked after second toggle")
}
}
// TestShoppingToggle_ExternalSources verifies toggle works for external sources
func TestShoppingToggle_ExternalSources(t *testing.T) {
db, cleanup := setupTestDB(t)
defer cleanup()
h := &Handler{
store: db,
renderer: newTestRenderer(),
config: &config.Config{},
}
tests := []struct {
source string
itemID string
}{
{"trello", "trello-toggle-123"},
{"plantoeat", "pte-toggle-456"},
}
for _, tc := range tests {
t.Run(tc.source, func(t *testing.T) {
// Toggle to checked
req := httptest.NewRequest("POST", "/shopping/toggle", nil)
req.Form = map[string][]string{
"id": {tc.itemID},
"source": {tc.source},
"checked": {"true"},
}
w := httptest.NewRecorder()
h.HandleShoppingToggle(w, req)
if w.Code != http.StatusOK {
t.Errorf("Expected status 200, got %d", w.Code)
}
checks, _ := db.GetShoppingItemChecks(tc.source)
if !checks[tc.itemID] {
t.Errorf("Expected %s item to be checked", tc.source)
}
})
}
}
// TestShoppingToggle_UnknownSource verifies error for unknown source
func TestShoppingToggle_UnknownSource(t *testing.T) {
db, cleanup := setupTestDB(t)
defer cleanup()
h := &Handler{
store: db,
config: &config.Config{},
}
req := httptest.NewRequest("POST", "/shopping/toggle", nil)
req.Form = map[string][]string{
"id": {"unknown-123"},
"source": {"unknown"},
"checked": {"true"},
}
w := httptest.NewRecorder()
h.HandleShoppingToggle(w, req)
if w.Code != http.StatusBadRequest {
t.Errorf("Expected status 400 for unknown source, got %d", w.Code)
}
}
// TestShoppingModeToggle_ReturnsUpdatedList verifies shopping mode toggle returns items
func TestShoppingModeToggle_ReturnsUpdatedList(t *testing.T) {
db, cleanup := setupTestDB(t)
defer cleanup()
h := &Handler{
store: db,
renderer: newTestRenderer(),
config: &config.Config{},
}
// Add items
_ = db.SaveUserShoppingItem("Item 1", "TestStore")
_ = db.SaveUserShoppingItem("Item 2", "TestStore")
items, _ := db.GetUserShoppingItems()
itemID := items[0].ID
// Toggle in shopping mode
req := httptest.NewRequest("POST", "/shopping/mode/TestStore/toggle", nil)
req.Form = map[string][]string{
"id": {fmt.Sprintf("user-%d", itemID)},
"source": {"user"},
"checked": {"true"},
}
rctx := chi.NewRouteContext()
rctx.URLParams.Add("store", "TestStore")
req = req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, rctx))
w := httptest.NewRecorder()
h.HandleShoppingModeToggle(w, req)
if w.Code != http.StatusOK {
t.Errorf("Expected status 200, got %d", w.Code)
}
// Response should be HTML (items list)
contentType := w.Header().Get("Content-Type")
if !strings.Contains(contentType, "text/html") {
t.Errorf("Expected HTML response, got %s", contentType)
}
}
// TestShoppingTabFiltersCheckedItems verifies checked items excluded from tab
func TestShoppingTabFiltersCheckedItems(t *testing.T) {
db, cleanup := setupTestDB(t)
defer cleanup()
h := &Handler{
store: db,
renderer: newTestRenderer(),
config: &config.Config{},
}
// Add items
_ = db.SaveUserShoppingItem("Visible Item", "TestStore")
_ = db.SaveUserShoppingItem("Checked Item", "TestStore")
// Check one item
items, _ := db.GetUserShoppingItems()
for _, item := range items {
if item.Name == "Checked Item" {
_ = db.ToggleUserShoppingItem(item.ID, true)
}
}
// Get shopping tab
req := httptest.NewRequest("GET", "/tabs/shopping", nil)
w := httptest.NewRecorder()
h.HandleTabShopping(w, req)
if w.Code != http.StatusOK {
t.Errorf("Expected status 200, got %d", w.Code)
}
// The template filters {{if not .Checked}} so we verify the data flow
// by checking the store returns both items (template does filtering)
items, _ = db.GetUserShoppingItems()
checkedCount := 0
for _, item := range items {
if item.Checked {
checkedCount++
}
}
if checkedCount != 1 {
t.Errorf("Expected 1 checked item in store, got %d", checkedCount)
}
}
// =============================================================================
// Tab Handler Tests
// =============================================================================
func TestHandleTabTasks(t *testing.T) {
db, cleanup := setupTestDB(t)
defer cleanup()
h := &Handler{
store: db,
renderer: newTestRenderer(),
config: &config.Config{CacheTTLMinutes: 5},
}
// Add some tasks
tasks := []models.Task{
{ID: "1", Content: "Task 1", Labels: []string{}, CreatedAt: time.Now()},
}
_ = db.SaveTasks(tasks)
req := httptest.NewRequest("GET", "/tabs/tasks", nil)
w := httptest.NewRecorder()
h.HandleTabTasks(w, req)
if w.Code != http.StatusOK {
t.Errorf("Expected status 200, got %d", w.Code)
}
contentType := w.Header().Get("Content-Type")
if !strings.Contains(contentType, "text/html") {
t.Errorf("Expected HTML response, got %s", contentType)
}
}
func TestHandleTabMeals(t *testing.T) {
db, cleanup := setupTestDB(t)
defer cleanup()
h := &Handler{
store: db,
renderer: newTestRenderer(),
config: &config.Config{CacheTTLMinutes: 5},
}
req := httptest.NewRequest("GET", "/tabs/meals", nil)
w := httptest.NewRecorder()
h.HandleTabMeals(w, req)
if w.Code != http.StatusOK {
t.Errorf("Expected status 200, got %d", w.Code)
}
}
func TestHandleTabPlanning(t *testing.T) {
db, cleanup := setupTestDB(t)
defer cleanup()
h := &Handler{
store: db,
renderer: newTestRenderer(),
config: &config.Config{CacheTTLMinutes: 5},
}
req := httptest.NewRequest("GET", "/tabs/planning", nil)
w := httptest.NewRecorder()
h.HandleTabPlanning(w, req)
if w.Code != http.StatusOK {
t.Errorf("Expected status 200, got %d", w.Code)
}
}
// =============================================================================
// Unified Add Tests
// =============================================================================
func TestHandleUnifiedAdd_MissingContent(t *testing.T) {
db, cleanup := setupTestDB(t)
defer cleanup()
h := &Handler{
store: db,
config: &config.Config{},
}
req := httptest.NewRequest("POST", "/unified-add", nil)
req.Form = map[string][]string{
"content": {""},
"type": {"task"},
}
w := httptest.NewRecorder()
h.HandleUnifiedAdd(w, req)
if w.Code != http.StatusBadRequest {
t.Errorf("Expected status 400 for empty content, got %d", w.Code)
}
}
// =============================================================================
// Settings Handler Tests
// =============================================================================
func TestHandleToggleFeature(t *testing.T) {
db, cleanup := setupTestDB(t)
defer cleanup()
h := &Handler{
store: db,
renderer: newTestRenderer(),
config: &config.Config{},
}
// Create a feature toggle
_ = db.CreateFeatureToggle("test_feature", "Test feature", false)
req := httptest.NewRequest("POST", "/settings/feature/toggle", nil)
req.Form = map[string][]string{
"name": {"test_feature"},
"enabled": {"true"},
}
w := httptest.NewRecorder()
h.HandleToggleFeature(w, req)
if w.Code != http.StatusOK {
t.Errorf("Expected status 200, got %d", w.Code)
}
// Verify feature was enabled
if !db.IsFeatureEnabled("test_feature") {
t.Error("Feature should be enabled after toggle")
}
}
func TestHandleCreateFeature(t *testing.T) {
db, cleanup := setupTestDB(t)
defer cleanup()
h := &Handler{
store: db,
renderer: newTestRenderer(),
config: &config.Config{},
}
req := httptest.NewRequest("POST", "/settings/feature/create", nil)
req.Form = map[string][]string{
"name": {"new_feature"},
"description": {"A new feature"},
}
w := httptest.NewRecorder()
h.HandleCreateFeature(w, req)
if w.Code != http.StatusOK {
t.Errorf("Expected status 200, got %d", w.Code)
}
// Verify feature was created
toggles, _ := db.GetFeatureToggles()
found := false
for _, t := range toggles {
if t.Name == "new_feature" {
found = true
break
}
}
if !found {
t.Error("Feature should be created")
}
}
func TestHandleDeleteFeature(t *testing.T) {
db, cleanup := setupTestDB(t)
defer cleanup()
h := &Handler{
store: db,
renderer: newTestRenderer(),
config: &config.Config{},
}
// Create a feature to delete
_ = db.CreateFeatureToggle("delete_me", "To be deleted", false)
req := httptest.NewRequest("DELETE", "/settings/feature/delete_me", nil)
// Add chi URL params
rctx := chi.NewRouteContext()
rctx.URLParams.Add("name", "delete_me")
req = req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, rctx))
w := httptest.NewRecorder()
h.HandleDeleteFeature(w, req)
if w.Code != http.StatusOK {
t.Errorf("Expected status 200, got %d", w.Code)
}
// Verify feature was deleted
toggles, _ := db.GetFeatureToggles()
for _, toggle := range toggles {
if toggle.Name == "delete_me" {
t.Error("Feature should be deleted")
}
}
}
// =============================================================================
// Response Helper Tests
// =============================================================================
func TestHTMLResponse_SetsNoCacheHeaders(t *testing.T) {
w := httptest.NewRecorder()
r := NewMockRenderer()
HTMLResponse(w, r, "test", nil)
cc := w.Header().Get("Cache-Control")
if !strings.Contains(cc, "no-cache") {
t.Errorf("Expected Cache-Control no-cache, got %s", cc)
}
}
func TestJSONResponse_SetsNoCacheHeaders(t *testing.T) {
w := httptest.NewRecorder()
JSONResponse(w, map[string]string{"ok": "true"})
cc := w.Header().Get("Cache-Control")
if !strings.Contains(cc, "no-cache") {
t.Errorf("Expected Cache-Control no-cache, got %s", cc)
}
}
func TestHTMLString(t *testing.T) {
w := httptest.NewRecorder()
HTMLString(w, "
Test
")
if w.Code != http.StatusOK {
t.Errorf("Expected status 200, got %d", w.Code)
}
contentType := w.Header().Get("Content-Type")
if !strings.Contains(contentType, "text/html") {
t.Errorf("Expected text/html, got %s", contentType)
}
if w.Body.String() != "Test
" {
t.Errorf("Body mismatch: %s", w.Body.String())
}
}
// =============================================================================
// Uncomplete Atom Tests
// =============================================================================
// mockTodoistClientWithReopen tracks ReopenTask calls
type mockTodoistClientWithReopen struct {
mockTodoistClient
reopenedTaskIDs []string
reopenErr error
}
func (m *mockTodoistClientWithReopen) ReopenTask(ctx context.Context, taskID string) error {
if m.reopenErr != nil {
return m.reopenErr
}
m.reopenedTaskIDs = append(m.reopenedTaskIDs, taskID)
return nil
}
func TestHandleUncompleteAtom_Todoist(t *testing.T) {
db, cleanup := setupTestDB(t)
defer cleanup()
mockTodoist := &mockTodoistClientWithReopen{}
h := &Handler{
store: db,
todoistClient: mockTodoist,
config: &config.Config{},
renderer: newTestRenderer(),
}
req := httptest.NewRequest("POST", "/uncomplete-atom", nil)
req.Form = map[string][]string{
"id": {"task123"},
"source": {"todoist"},
}
w := httptest.NewRecorder()
h.HandleUncompleteAtom(w, req)
if w.Code != http.StatusOK {
t.Errorf("Expected status 200, got %d", w.Code)
}
// Verify HX-Reswap header set to "none"
reswap := w.Header().Get("HX-Reswap")
if reswap != "none" {
t.Errorf("Expected HX-Reswap header 'none', got %q", reswap)
}
// Verify HX-Trigger header set to "refresh-tasks"
trigger := w.Header().Get("HX-Trigger")
if trigger != "refresh-tasks" {
t.Errorf("Expected HX-Trigger header 'refresh-tasks', got %q", trigger)
}
// Verify response body is empty (no template rendered)
body := w.Body.String()
if body != "" {
t.Errorf("Expected empty response body for uncomplete, got %q", body)
}
if len(mockTodoist.reopenedTaskIDs) != 1 || mockTodoist.reopenedTaskIDs[0] != "task123" {
t.Errorf("Expected ReopenTask to be called with 'task123', got %v", mockTodoist.reopenedTaskIDs)
}
}
// =============================================================================
// Settings Handler Tests
// =============================================================================
func TestHandleSettingsPage(t *testing.T) {
h, cleanup := setupTestHandler(t)
defer cleanup()
// Add some source configs
_ = h.store.SyncSourceConfigs("trello", "board", []models.SourceConfig{
{Source: "trello", ItemType: "board", ItemID: "board1", ItemName: "Test Board"},
})
req := httptest.NewRequest("GET", "/settings", nil)
w := httptest.NewRecorder()
h.HandleSettingsPage(w, req)
if w.Code != http.StatusOK {
t.Errorf("Expected status 200, got %d", w.Code)
}
}
func TestHandleToggleSourceConfig(t *testing.T) {
h, cleanup := setupTestHandler(t)
defer cleanup()
// Create a source config
_ = h.store.SyncSourceConfigs("trello", "board", []models.SourceConfig{
{Source: "trello", ItemType: "board", ItemID: "board1", ItemName: "Test Board", Enabled: true},
})
req := httptest.NewRequest("POST", "/settings/source/toggle", strings.NewReader("source=trello&item_type=board&item_id=board1&enabled=false"))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
w := httptest.NewRecorder()
h.HandleToggleSourceConfig(w, req)
if w.Code != http.StatusOK {
t.Errorf("Expected status 200, got %d", w.Code)
}
// Verify response
var result map[string]bool
json.NewDecoder(w.Body).Decode(&result)
if result["enabled"] != false {
t.Error("Expected enabled to be false")
}
}
func TestHandleToggleSourceConfig_MissingFields(t *testing.T) {
h, cleanup := setupTestHandler(t)
defer cleanup()
req := httptest.NewRequest("POST", "/settings/source/toggle", strings.NewReader("source=trello"))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
w := httptest.NewRecorder()
h.HandleToggleSourceConfig(w, req)
if w.Code != http.StatusBadRequest {
t.Errorf("Expected status 400, got %d", w.Code)
}
}
func TestHandleGetSourceOptions(t *testing.T) {
h, cleanup := setupTestHandler(t)
defer cleanup()
// Create source configs
_ = h.store.SyncSourceConfigs("trello", "board", []models.SourceConfig{
{Source: "trello", ItemType: "board", ItemID: "board1", ItemName: "Board 1"},
{Source: "trello", ItemType: "board", ItemID: "board2", ItemName: "Board 2"},
})
req := httptest.NewRequest("GET", "/settings/source/trello", nil)
rctx := chi.NewRouteContext()
rctx.URLParams.Add("source", "trello")
req = req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, rctx))
w := httptest.NewRecorder()
h.HandleGetSourceOptions(w, req)
// May fail if template not found, which is acceptable in test
if w.Code != http.StatusOK && w.Code != http.StatusInternalServerError {
t.Errorf("Expected status 200 or 500, got %d", w.Code)
}
}
func TestHandleGetSourceOptions_MissingSource(t *testing.T) {
h, cleanup := setupTestHandler(t)
defer cleanup()
req := httptest.NewRequest("GET", "/settings/source/", nil)
rctx := chi.NewRouteContext()
req = req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, rctx))
w := httptest.NewRecorder()
h.HandleGetSourceOptions(w, req)
if w.Code != http.StatusBadRequest {
t.Errorf("Expected status 400, got %d", w.Code)
}
}
// mockTodoistClientWithProjects returns mock projects
type mockTodoistClientWithProjects struct {
mockTodoistClient
projects []models.Project
}
func (m *mockTodoistClientWithProjects) GetProjects(ctx context.Context) ([]models.Project, error) {
return m.projects, nil
}
// mockTrelloClientWithBoards returns mock boards
type mockTrelloClientWithBoards struct {
mockTrelloClient
}
func TestHandleSyncSources(t *testing.T) {
h, cleanup := setupTestHandler(t)
defer cleanup()
// Seed tasks table with project info (projects now come from store, not REST API)
tasks := []models.Task{
{ID: "t1", Content: "Task 1", ProjectID: "proj1", ProjectName: "Project 1", Labels: []string{}},
{ID: "t2", Content: "Task 2", ProjectID: "proj2", ProjectName: "Project 2", Labels: []string{}},
}
_ = h.store.SaveTasks(tasks)
mockTodoist := &mockTodoistClient{}
mockTrello := &mockTrelloClientWithBoards{
mockTrelloClient: mockTrelloClient{
boards: []models.Board{
{ID: "board1", Name: "Board 1"},
},
},
}
h.todoistClient = mockTodoist
h.trelloClient = mockTrello
req := httptest.NewRequest("POST", "/settings/sync", nil)
w := httptest.NewRecorder()
h.HandleSyncSources(w, req)
if w.Code != http.StatusOK {
t.Errorf("Expected status 200, got %d", w.Code)
}
// Verify configs were synced from store (not REST API)
configs, _ := h.store.GetSourceConfigsBySource("todoist")
if len(configs) != 2 {
t.Errorf("Expected 2 todoist configs, got %d", len(configs))
}
}
// =============================================================================
// Helper function tests
// =============================================================================
func TestRequireFormValue(t *testing.T) {
// Test missing value
req := httptest.NewRequest("POST", "/test", strings.NewReader(""))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
_ = req.ParseForm()
w := httptest.NewRecorder()
val, ok := requireFormValue(w, req, "missing")
if ok {
t.Error("Expected ok to be false for missing value")
}
if val != "" {
t.Error("Expected empty string for missing value")
}
// Test with value present
req2 := httptest.NewRequest("POST", "/test", strings.NewReader("key=value"))
req2.Header.Set("Content-Type", "application/x-www-form-urlencoded")
_ = req2.ParseForm()
w2 := httptest.NewRecorder()
val2, ok2 := requireFormValue(w2, req2, "key")
if !ok2 {
t.Error("Expected ok to be true for present value")
}
if val2 != "value" {
t.Errorf("Expected 'value', got '%s'", val2)
}
}
func TestParseFormOr400(t *testing.T) {
// Test successful parse
req := httptest.NewRequest("POST", "/test", strings.NewReader("key=value"))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
w := httptest.NewRecorder()
if !parseFormOr400(w, req) {
t.Error("Expected parseFormOr400 to return true")
}
}
// =============================================================================
// Timeline and Conditions Handler Tests
// =============================================================================
func TestHandleTabConditions(t *testing.T) {
h, cleanup := setupTestHandler(t)
defer cleanup()
req := httptest.NewRequest("GET", "/tabs/conditions", nil)
w := httptest.NewRecorder()
h.HandleTabConditions(w, req)
// May return 500 if template not found, but the handler is executed
if w.Code != http.StatusOK && w.Code != http.StatusInternalServerError {
t.Errorf("Expected status 200 or 500, got %d", w.Code)
}
}
func TestHandleConditionsPage(t *testing.T) {
h, cleanup := setupTestHandler(t)
defer cleanup()
req := httptest.NewRequest("GET", "/conditions", nil)
w := httptest.NewRecorder()
h.HandleConditionsPage(w, req)
// May return 500 if template not found
if w.Code != http.StatusOK && w.Code != http.StatusInternalServerError {
t.Errorf("Expected status 200 or 500, got %d", w.Code)
}
}
func TestHandleTimeline(t *testing.T) {
h, cleanup := setupTestHandler(t)
defer cleanup()
// Test with default params
req := httptest.NewRequest("GET", "/timeline", nil)
w := httptest.NewRecorder()
h.HandleTimeline(w, req)
if w.Code != http.StatusOK {
t.Errorf("Expected status 200, got %d", w.Code)
}
}
func TestHandleTimeline_RendersDataToTemplate(t *testing.T) {
h, cleanup := setupTestHandler(t)
defer cleanup()
// Seed tasks with due dates in range
now := config.Now()
todayNoon := time.Date(now.Year(), now.Month(), now.Day(), 12, 0, 0, 0, now.Location())
tasks := []models.Task{
{ID: "t1", Content: "Today task", DueDate: &todayNoon, Labels: []string{}, CreatedAt: now},
}
_ = h.store.SaveTasks(tasks)
req := httptest.NewRequest("GET", "/tabs/timeline", nil)
w := httptest.NewRecorder()
h.HandleTimeline(w, req)
if w.Code != http.StatusOK {
t.Errorf("Expected status 200, got %d", w.Code)
}
// Verify the renderer received timeline data with items
mock := h.renderer.(*MockRenderer)
if len(mock.Calls) == 0 {
t.Fatal("Expected renderer to be called")
}
lastCall := mock.Calls[len(mock.Calls)-1]
if lastCall.Name != "timeline-tab" {
t.Errorf("Expected template 'timeline-tab', got '%s'", lastCall.Name)
}
data, ok := lastCall.Data.(TimelineData)
if !ok {
t.Fatalf("Expected TimelineData, got %T", lastCall.Data)
}
if len(data.TodayItems) == 0 {
t.Error("Expected TodayItems to contain at least one item, got 0")
}
}
// assertTemplateContains reads a template file and asserts it contains the expected string.
func assertTemplateContains(t *testing.T, templatePath, expected, errMsg string) {
t.Helper()
content, err := os.ReadFile(templatePath)
if err != nil {
t.Skipf("Cannot read template file: %v", err)
}
if !strings.Contains(string(content), expected) {
t.Error(errMsg)
}
}
// =============================================================================
// Dashboard Content Verification Tests
// =============================================================================
func TestHandleDashboard_ContainsSettingsLink(t *testing.T) {
db, cleanup := setupTestDB(t)
defer cleanup()
mockTodoist := &mockTodoistClient{}
mockTrello := &mockTrelloClient{}
// Use a renderer that outputs actual content so we can check for settings link
renderer := NewMockRenderer()
renderer.RenderFunc = func(w io.Writer, name string, data interface{}) error {
if name == "index.html" {
// Check that data includes what the template needs
// We verify the template at the source: index.html must contain /settings link
fmt.Fprintf(w, "rendered:%s", name)
}
return nil
}
h := &Handler{
store: db,
todoistClient: mockTodoist,
trelloClient: mockTrello,
config: &config.Config{CacheTTLMinutes: 5},
renderer: renderer,
}
req := httptest.NewRequest("GET", "/", nil)
w := httptest.NewRecorder()
h.HandleDashboard(w, req)
if w.Code != http.StatusOK {
t.Errorf("Expected status 200, got %d", w.Code)
}
}
// TestDashboardTemplate_HasSettingsLink verifies the index.html template
// contains a link to /settings. This catches regressions where the settings
// button is accidentally removed.
func TestDashboardTemplate_HasSettingsLink(t *testing.T) {
assertTemplateContains(t, "../../web/templates/index.html",
`href="/settings"`,
"index.html must contain a link to /settings (settings button missing from UI)")
}
// TestSettingsTemplate_HasCSRFToken verifies the settings page exposes a CSRF
// token in a meta tag so that JavaScript (e.g. passkey registration) can include
// it in POST requests. Without this, passkey registration fails with 403.
func TestSettingsTemplate_HasCSRFToken(t *testing.T) {
assertTemplateContains(t, "../../web/templates/settings.html",
".CSRFToken",
"settings.html must expose .CSRFToken (e.g. via meta tag) for JavaScript passkey registration")
}
// TestSettingsTemplate_InjectsCSRFForHTMX verifies that settings.html sends the
// CSRF token with HTMX requests via hx-headers on the body. Without this, all
// HTMX POST requests from the settings page (clear cache, sync) are rejected
// with 403 Forbidden and appear to silently fail.
func TestSettingsTemplate_InjectsCSRFForHTMX(t *testing.T) {
assertTemplateContains(t, "../../web/templates/settings.html",
`hx-headers`,
"settings.html body must set hx-headers with X-CSRF-Token so HTMX POSTs are not rejected with 403")
}
// TestSettingsTemplate_HidesPasskeysWhenDisabled verifies that the settings
// template conditionally shows the passkeys section based on WebAuthnEnabled.
func TestSettingsTemplate_HidesPasskeysWhenDisabled(t *testing.T) {
assertTemplateContains(t, "../../web/templates/settings.html",
".WebAuthnEnabled",
"settings.html must check .WebAuthnEnabled to conditionally show passkeys section")
}
// TestHandleSettingsPage_PassesWebAuthnEnabled verifies the settings handler
// includes WebAuthnEnabled in template data.
func TestHandleSettingsPage_PassesWebAuthnEnabled(t *testing.T) {
h, cleanup := setupTestHandler(t)
defer cleanup()
req := httptest.NewRequest("GET", "/settings", nil)
w := httptest.NewRecorder()
h.HandleSettingsPage(w, req)
if w.Code != http.StatusOK {
t.Fatalf("Expected status 200, got %d", w.Code)
}
mock := h.renderer.(*MockRenderer)
if len(mock.Calls) == 0 {
t.Fatal("Expected renderer to be called")
}
lastCall := mock.Calls[len(mock.Calls)-1]
dataStr := fmt.Sprintf("%+v", lastCall.Data)
if !strings.Contains(dataStr, "WebAuthnEnabled") {
t.Error("Settings page template data must include WebAuthnEnabled field")
}
}
// TestHandleSettingsPage_PassesCSRFToken verifies the settings handler includes
// a CSRFToken in its template data so the passkey registration JS can use it.
func TestHandleSettingsPage_PassesCSRFToken(t *testing.T) {
h, cleanup := setupTestHandler(t)
defer cleanup()
req := httptest.NewRequest("GET", "/settings", nil)
w := httptest.NewRecorder()
h.HandleSettingsPage(w, req)
if w.Code != http.StatusOK {
t.Fatalf("Expected status 200, got %d", w.Code)
}
mock := h.renderer.(*MockRenderer)
if len(mock.Calls) == 0 {
t.Fatal("Expected renderer to be called")
}
lastCall := mock.Calls[len(mock.Calls)-1]
// Use reflection or type assertion to check for CSRFToken field
// The data struct should have a CSRFToken field
type hasCSRF interface{ GetCSRFToken() string }
// Check via fmt to inspect the struct fields
dataStr := fmt.Sprintf("%+v", lastCall.Data)
if !strings.Contains(dataStr, "CSRFToken") {
t.Error("Settings page template data must include a CSRFToken field for passkey registration")
}
}
// TestHandleDashboard_PassesBuildVersion verifies the dashboard handler includes
// BuildVersion in the template data.
func TestHandleDashboard_PassesBuildVersion(t *testing.T) {
db, cleanup := setupTestDB(t)
defer cleanup()
mockTodoist := &mockTodoistClient{}
mockTrello := &mockTrelloClient{}
renderer := NewMockRenderer()
h := &Handler{
store: db,
todoistClient: mockTodoist,
trelloClient: mockTrello,
config: &config.Config{CacheTTLMinutes: 5},
renderer: renderer,
BuildVersion: "abc123",
}
req := httptest.NewRequest("GET", "/", nil)
w := httptest.NewRecorder()
h.HandleDashboard(w, req)
if w.Code != http.StatusOK {
t.Fatalf("Expected status 200, got %d", w.Code)
}
if len(renderer.Calls) == 0 {
t.Fatal("Expected renderer to be called")
}
lastCall := renderer.Calls[len(renderer.Calls)-1]
dataStr := fmt.Sprintf("%+v", lastCall.Data)
if !strings.Contains(dataStr, "abc123") {
t.Error("Dashboard template data must include BuildVersion value")
}
}
// TestDashboardTemplate_HasBuildVersion verifies that index.html contains
// a build version placeholder so users can see the deployed version.
func TestDashboardTemplate_HasBuildVersion(t *testing.T) {
assertTemplateContains(t, "../../web/templates/index.html",
".BuildVersion",
"index.html must contain .BuildVersion to display the build version in the footer")
}
// TestTimelineTemplate_CheckboxesTargetSelf verifies that completion checkboxes
// in the timeline calendar grid target their parent item, not #tab-content.
func TestTimelineTemplate_CheckboxesTargetSelf(t *testing.T) {
content, err := os.ReadFile("../../web/templates/partials/timeline-tab.html")
if err != nil {
t.Skipf("Cannot read template file: %v", err)
}
tmpl := string(content)
// Find all lines with complete-atom/uncomplete-atom that also reference hx-target
lines := strings.Split(tmpl, "\n")
for i, line := range lines {
if !strings.Contains(line, "complete-atom") {
continue
}
// Look at surrounding lines for the hx-target
for j := i; j < len(lines) && j < i+8; j++ {
if strings.Contains(lines[j], `hx-target="#tab-content"`) {
t.Errorf("Line %d: checkbox targeting #tab-content will replace entire view on complete. "+
"Should target closest parent element instead.", j+1)
}
}
}
}
func TestHandleTimeline_WithParams(t *testing.T) {
h, cleanup := setupTestHandler(t)
defer cleanup()
// Test with custom start and days params
req := httptest.NewRequest("GET", "/timeline?start=2024-01-15&days=7", nil)
w := httptest.NewRecorder()
h.HandleTimeline(w, req)
// May return 500 if template not found
if w.Code != http.StatusOK && w.Code != http.StatusInternalServerError {
t.Errorf("Expected status 200 or 500, got %d", w.Code)
}
}
func TestHandleTimeline_InvalidParams(t *testing.T) {
h, cleanup := setupTestHandler(t)
defer cleanup()
// Test with invalid params (should use defaults)
req := httptest.NewRequest("GET", "/timeline?start=invalid&days=abc", nil)
w := httptest.NewRecorder()
h.HandleTimeline(w, req)
// Should still work with defaults
if w.Code != http.StatusOK && w.Code != http.StatusInternalServerError {
t.Errorf("Expected status 200 or 500, got %d", w.Code)
}
}
// syncAwareMockTodoist records the sync token passed to Sync and returns a configurable response.
type syncAwareMockTodoist struct {
mockTodoistClient
syncResponse *api.TodoistSyncResponse
receivedTokens []string // tracks tokens passed to Sync
}
func (m *syncAwareMockTodoist) Sync(ctx context.Context, syncToken string) (*api.TodoistSyncResponse, error) {
m.receivedTokens = append(m.receivedTokens, syncToken)
if m.err != nil {
return nil, m.err
}
return m.syncResponse, nil
}
func TestFetchTasks_IncrementalSync_UpsertsActiveTasks(t *testing.T) {
h, cleanup := setupTestHandler(t)
defer cleanup()
// Seed DB with an existing task (simulating previous full sync)
existingTask := models.Task{ID: "existing-1", Content: "Old task", Priority: 1}
if err := h.store.SaveTasks([]models.Task{existingTask}); err != nil {
t.Fatalf("Failed to seed task: %v", err)
}
// Set a sync token so fetchTasks uses incremental sync
if err := h.store.SetSyncToken("todoist", "previous-token"); err != nil {
t.Fatalf("Failed to set sync token: %v", err)
}
mock := &syncAwareMockTodoist{
syncResponse: &api.TodoistSyncResponse{
SyncToken: "new-token-1",
FullSync: false,
Items: []api.SyncItemResponse{
{ID: "new-1", Content: "New task", Priority: 2},
{ID: "existing-1", Content: "Updated task", Priority: 3},
},
Projects: []api.SyncProjectResponse{},
},
}
h.todoistClient = mock
tasks, err := h.fetchTasks(context.Background(), false)
if err != nil {
t.Fatalf("fetchTasks returned error: %v", err)
}
// Should have 2 tasks: the upserted existing-1 (updated) and new-1
if len(tasks) != 2 {
t.Fatalf("Expected 2 tasks, got %d", len(tasks))
}
// Verify the existing task was updated
taskMap := make(map[string]models.Task)
for _, task := range tasks {
taskMap[task.ID] = task
}
if taskMap["existing-1"].Content != "Updated task" {
t.Errorf("Expected existing-1 content 'Updated task', got %q", taskMap["existing-1"].Content)
}
if taskMap["new-1"].Content != "New task" {
t.Errorf("Expected new-1 content 'New task', got %q", taskMap["new-1"].Content)
}
}
func TestFetchTasks_IncrementalSync_DeletesCompletedAndDeletedTasks(t *testing.T) {
h, cleanup := setupTestHandler(t)
defer cleanup()
// Seed DB with tasks
seeds := []models.Task{
{ID: "keep-1", Content: "Keep me"},
{ID: "complete-1", Content: "Will be completed"},
{ID: "delete-1", Content: "Will be deleted"},
}
if err := h.store.SaveTasks(seeds); err != nil {
t.Fatalf("Failed to seed tasks: %v", err)
}
if err := h.store.SetSyncToken("todoist", "prev-token"); err != nil {
t.Fatalf("Failed to set sync token: %v", err)
}
mock := &syncAwareMockTodoist{
syncResponse: &api.TodoistSyncResponse{
SyncToken: "new-token-2",
FullSync: false,
Items: []api.SyncItemResponse{
{ID: "complete-1", Content: "Will be completed", IsCompleted: true},
{ID: "delete-1", Content: "Will be deleted", IsDeleted: true},
},
Projects: []api.SyncProjectResponse{},
},
}
h.todoistClient = mock
tasks, err := h.fetchTasks(context.Background(), false)
if err != nil {
t.Fatalf("fetchTasks returned error: %v", err)
}
// Only keep-1 should remain
if len(tasks) != 1 {
t.Fatalf("Expected 1 task, got %d: %+v", len(tasks), tasks)
}
if tasks[0].ID != "keep-1" {
t.Errorf("Expected remaining task ID 'keep-1', got %q", tasks[0].ID)
}
}
func TestFetchTasks_IncrementalSync_StoresNewSyncToken(t *testing.T) {
h, cleanup := setupTestHandler(t)
defer cleanup()
if err := h.store.SetSyncToken("todoist", "old-token"); err != nil {
t.Fatalf("Failed to set sync token: %v", err)
}
mock := &syncAwareMockTodoist{
syncResponse: &api.TodoistSyncResponse{
SyncToken: "brand-new-token",
FullSync: false,
Items: []api.SyncItemResponse{},
Projects: []api.SyncProjectResponse{},
},
}
h.todoistClient = mock
_, err := h.fetchTasks(context.Background(), false)
if err != nil {
t.Fatalf("fetchTasks returned error: %v", err)
}
// Verify the new sync token was stored
token, err := h.store.GetSyncToken("todoist")
if err != nil {
t.Fatalf("Failed to get sync token: %v", err)
}
if token != "brand-new-token" {
t.Errorf("Expected sync token 'brand-new-token', got %q", token)
}
}
func TestFetchTasks_IncrementalSync_UsesSavedSyncToken(t *testing.T) {
h, cleanup := setupTestHandler(t)
defer cleanup()
// Set a known sync token
if err := h.store.SetSyncToken("todoist", "my-saved-token"); err != nil {
t.Fatalf("Failed to set sync token: %v", err)
}
mock := &syncAwareMockTodoist{
syncResponse: &api.TodoistSyncResponse{
SyncToken: "next-token",
FullSync: false,
Items: []api.SyncItemResponse{},
Projects: []api.SyncProjectResponse{},
},
}
h.todoistClient = mock
_, err := h.fetchTasks(context.Background(), false)
if err != nil {
t.Fatalf("fetchTasks returned error: %v", err)
}
// Verify the saved token was passed to Sync
if len(mock.receivedTokens) != 1 {
t.Fatalf("Expected 1 Sync call, got %d", len(mock.receivedTokens))
}
if mock.receivedTokens[0] != "my-saved-token" {
t.Errorf("Expected Sync to receive token 'my-saved-token', got %q", mock.receivedTokens[0])
}
}
func TestFetchTasks_ForceRefresh_ClearsSyncToken(t *testing.T) {
h, cleanup := setupTestHandler(t)
defer cleanup()
if err := h.store.SetSyncToken("todoist", "existing-token"); err != nil {
t.Fatalf("Failed to set sync token: %v", err)
}
mock := &syncAwareMockTodoist{
syncResponse: &api.TodoistSyncResponse{
SyncToken: "fresh-token",
FullSync: true,
Items: []api.SyncItemResponse{},
Projects: []api.SyncProjectResponse{},
},
}
h.todoistClient = mock
_, err := h.fetchTasks(context.Background(), true)
if err != nil {
t.Fatalf("fetchTasks returned error: %v", err)
}
// forceRefresh should send empty token (full sync)
if len(mock.receivedTokens) != 1 {
t.Fatalf("Expected 1 Sync call, got %d", len(mock.receivedTokens))
}
if mock.receivedTokens[0] != "" {
t.Errorf("Expected empty sync token for forceRefresh, got %q", mock.receivedTokens[0])
}
}
// =============================================================================
// Clear Cache + GetProjects removal tests
// =============================================================================
// TestHandleClearCache verifies that POST /settings/clear-cache invalidates
// all cache keys, clears the todoist sync token, and returns 200 with HX-Trigger.
func TestHandleClearCache(t *testing.T) {
h, cleanup := setupTestHandler(t)
defer cleanup()
// Seed cache metadata and sync token so we can verify they get cleared
_ = h.store.UpdateCacheMetadata("todoist_tasks", 5)
_ = h.store.UpdateCacheMetadata("trello_boards", 5)
_ = h.store.UpdateCacheMetadata("plantoeat_meals", 5)
_ = h.store.UpdateCacheMetadata("google_calendar", 5)
_ = h.store.SetSyncToken("todoist", "some-sync-token")
req := httptest.NewRequest("POST", "/settings/clear-cache", nil)
w := httptest.NewRecorder()
h.HandleClearCache(w, req)
if w.Code != http.StatusOK {
t.Errorf("Expected status 200, got %d", w.Code)
}
// Verify HX-Trigger header
if trigger := w.Header().Get("HX-Trigger"); trigger != "refresh-tasks" {
t.Errorf("Expected HX-Trigger 'refresh-tasks', got %q", trigger)
}
// Verify all cache keys invalidated
for _, key := range []string{"todoist_tasks", "trello_boards", "plantoeat_meals", "google_calendar"} {
valid, _ := h.store.IsCacheValid(key)
if valid {
t.Errorf("Cache key %q should be invalidated but is still valid", key)
}
}
// Verify sync token cleared
token, _ := h.store.GetSyncToken("todoist")
if token != "" {
t.Errorf("Expected empty sync token after clear cache, got %q", token)
}
}
// TestGetProjectsFromTasks verifies that distinct project pairs are extracted
// from the tasks table.
func TestGetProjectsFromTasks(t *testing.T) {
h, cleanup := setupTestHandler(t)
defer cleanup()
// Seed tasks with different projects (including duplicates and empty)
tasks := []models.Task{
{ID: "1", Content: "Task 1", ProjectID: "p1", ProjectName: "Inbox", Labels: []string{}},
{ID: "2", Content: "Task 2", ProjectID: "p1", ProjectName: "Inbox", Labels: []string{}},
{ID: "3", Content: "Task 3", ProjectID: "p2", ProjectName: "Work", Labels: []string{}},
{ID: "4", Content: "Task 4", ProjectID: "", ProjectName: "", Labels: []string{}},
}
if err := h.store.SaveTasks(tasks); err != nil {
t.Fatalf("Failed to save tasks: %v", err)
}
projects, err := h.store.GetProjectsFromTasks()
if err != nil {
t.Fatalf("GetProjectsFromTasks returned error: %v", err)
}
if len(projects) != 2 {
t.Fatalf("Expected 2 distinct projects, got %d: %v", len(projects), projects)
}
// Verify both projects are present
found := map[string]bool{}
for _, p := range projects {
found[p.ID] = true
}
if !found["p1"] || !found["p2"] {
t.Errorf("Expected projects p1 and p2, got %v", projects)
}
}
// TestInvalidateAllCaches verifies the convenience method clears all 4 keys.
func TestInvalidateAllCaches(t *testing.T) {
h, cleanup := setupTestHandler(t)
defer cleanup()
keys := []string{"todoist_tasks", "trello_boards", "plantoeat_meals", "google_calendar"}
for _, key := range keys {
_ = h.store.UpdateCacheMetadata(key, 5)
}
if err := h.store.InvalidateAllCaches(); err != nil {
t.Fatalf("InvalidateAllCaches returned error: %v", err)
}
for _, key := range keys {
valid, _ := h.store.IsCacheValid(key)
if valid {
t.Errorf("Cache key %q should be invalidated", key)
}
}
}
// TestSettingsTemplate_HasClearCacheButton verifies settings.html has a
// Clear Cache button with hx-post.
func TestSettingsTemplate_HasClearCacheButton(t *testing.T) {
assertTemplateContains(t, "../../web/templates/settings.html",
"clear-cache",
"settings.html must contain a clear-cache button/endpoint")
}
// TestAggregateData_DoesNotCallGetProjects verifies that aggregateData no longer
// calls the deprecated GetProjects REST API.
func TestAggregateData_DoesNotCallGetProjects(t *testing.T) {
h, cleanup := setupTestHandler(t)
defer cleanup()
getProjectsCalled := false
mock := &mockTodoistClientTracksGetProjects{
mockTodoistClient: mockTodoistClient{
tasks: []models.Task{
{ID: "1", Content: "Test", ProjectID: "p1", ProjectName: "Inbox", Labels: []string{}},
},
},
onGetProjects: func() { getProjectsCalled = true },
}
h.todoistClient = mock
h.trelloClient = &mockTrelloClient{boards: []models.Board{}}
_, err := h.aggregateData(context.Background(), false)
if err != nil {
t.Fatalf("aggregateData returned error: %v", err)
}
if getProjectsCalled {
t.Error("aggregateData should NOT call GetProjects (deprecated REST API)")
}
}
// TestHandleCreateTask_DoesNotCallGetProjects verifies that HandleCreateTask
// populates projects from the store, not the REST API.
func TestHandleCreateTask_DoesNotCallGetProjects(t *testing.T) {
h, cleanup := setupTestHandler(t)
defer cleanup()
// Seed tasks with project info
tasks := []models.Task{
{ID: "1", Content: "Existing", ProjectID: "p1", ProjectName: "Inbox", Labels: []string{}},
}
_ = h.store.SaveTasks(tasks)
getProjectsCalled := false
mock := &mockTodoistClientTracksGetProjects{
mockTodoistClient: mockTodoistClient{
tasks: tasks,
},
onGetProjects: func() { getProjectsCalled = true },
}
h.todoistClient = mock
body := strings.NewReader("content=New+Task&project_id=p1")
req := httptest.NewRequest("POST", "/tasks", body)
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
w := httptest.NewRecorder()
h.HandleCreateTask(w, req)
if getProjectsCalled {
t.Error("HandleCreateTask should NOT call GetProjects (deprecated REST API)")
}
}
// TestHandleSyncSources_UsesStoreForProjects verifies that HandleSyncSources
// reads projects from the tasks table instead of the REST API.
func TestHandleSyncSources_UsesStoreForProjects(t *testing.T) {
h, cleanup := setupTestHandler(t)
defer cleanup()
// Seed tasks with project info
tasks := []models.Task{
{ID: "1", Content: "Task1", ProjectID: "p1", ProjectName: "Inbox", Labels: []string{}},
{ID: "2", Content: "Task2", ProjectID: "p2", ProjectName: "Work", Labels: []string{}},
}
_ = h.store.SaveTasks(tasks)
getProjectsCalled := false
mock := &mockTodoistClientTracksGetProjects{
mockTodoistClient: mockTodoistClient{tasks: tasks},
onGetProjects: func() { getProjectsCalled = true },
}
h.todoistClient = mock
req := httptest.NewRequest("POST", "/settings/sync", nil)
w := httptest.NewRecorder()
h.HandleSyncSources(w, req)
if getProjectsCalled {
t.Error("HandleSyncSources should NOT call GetProjects (deprecated REST API)")
}
// Verify todoist project configs were still synced from store
configs, _ := h.store.GetSourceConfigsBySource("todoist")
if len(configs) != 2 {
t.Errorf("Expected 2 todoist configs from store, got %d", len(configs))
}
}
// mockTodoistClientTracksGetProjects wraps mockTodoistClient and tracks GetProjects calls.
type mockTodoistClientTracksGetProjects struct {
mockTodoistClient
onGetProjects func()
}
func (m *mockTodoistClientTracksGetProjects) GetProjects(ctx context.Context) ([]models.Project, error) {
if m.onGetProjects != nil {
m.onGetProjects()
}
return []models.Project{}, nil
}
// --- Sync Log Tests ---
// TestStore_AddAndGetSyncLog verifies that sync log entries can be added and retrieved.
func TestStore_AddAndGetSyncLog(t *testing.T) {
db, cleanup := setupTestDB(t)
defer cleanup()
if err := db.AddSyncLogEntry("cache_clear", "All caches cleared"); err != nil {
t.Fatalf("AddSyncLogEntry failed: %v", err)
}
if err := db.AddSyncLogEntry("sync", "Sources synced"); err != nil {
t.Fatalf("AddSyncLogEntry failed: %v", err)
}
entries, err := db.GetRecentSyncLog(10)
if err != nil {
t.Fatalf("GetRecentSyncLog failed: %v", err)
}
if len(entries) != 2 {
t.Fatalf("Expected 2 entries, got %d", len(entries))
}
// Most recent first
if entries[0].Message != "Sources synced" {
t.Errorf("Expected first entry to be 'Sources synced', got %q", entries[0].Message)
}
if entries[1].Message != "All caches cleared" {
t.Errorf("Expected second entry to be 'All caches cleared', got %q", entries[1].Message)
}
}
// TestStore_GetRecentSyncLog_LimitsResults verifies the limit parameter is respected.
func TestStore_GetRecentSyncLog_LimitsResults(t *testing.T) {
db, cleanup := setupTestDB(t)
defer cleanup()
for i := 0; i < 5; i++ {
_ = db.AddSyncLogEntry("test", "entry")
}
entries, err := db.GetRecentSyncLog(3)
if err != nil {
t.Fatalf("GetRecentSyncLog failed: %v", err)
}
if len(entries) != 3 {
t.Errorf("Expected 3 entries with limit=3, got %d", len(entries))
}
}
// TestHandleClearCache_AddsLogEntry verifies that clearing cache creates a sync log entry.
func TestHandleClearCache_AddsLogEntry(t *testing.T) {
h, cleanup := setupTestHandler(t)
defer cleanup()
req := httptest.NewRequest("POST", "/settings/clear-cache", nil)
w := httptest.NewRecorder()
h.HandleClearCache(w, req)
entries, err := h.store.GetRecentSyncLog(10)
if err != nil {
t.Fatalf("GetRecentSyncLog failed: %v", err)
}
if len(entries) == 0 {
t.Fatal("Expected sync log entry after clear cache, got none")
}
if entries[0].EventType != "cache_clear" {
t.Errorf("Expected event_type 'cache_clear', got %q", entries[0].EventType)
}
}
// TestHandleClearCache_ReturnsHTMLSyncLog verifies that the response renders the sync-log template.
func TestHandleClearCache_ReturnsHTMLSyncLog(t *testing.T) {
h, cleanup := setupTestHandler(t)
defer cleanup()
req := httptest.NewRequest("POST", "/settings/clear-cache", nil)
w := httptest.NewRecorder()
h.HandleClearCache(w, req)
if w.Code != http.StatusOK {
t.Fatalf("Expected status 200, got %d", w.Code)
}
// MockRenderer writes "rendered:{name}" — verify sync-log template was rendered
body := w.Body.String()
if !strings.Contains(body, "sync-log") {
t.Errorf("Expected response body to contain 'sync-log', got: %s", body)
}
// Verify the renderer was called with the sync-log template and log entries
mr := h.renderer.(*MockRenderer)
var found bool
for _, call := range mr.Calls {
if call.Name == "sync-log" {
found = true
entries, ok := call.Data.([]store.SyncLogEntry)
if !ok {
t.Errorf("Expected sync-log data to be []store.SyncLogEntry, got %T", call.Data)
}
if len(entries) == 0 {
t.Error("Expected sync-log data to contain at least one entry")
}
break
}
}
if !found {
t.Error("Expected renderer to be called with 'sync-log' template")
}
}
// TestHandleSettingsPage_IncludesSyncLog verifies settings page passes SyncLog to template.
func TestHandleSettingsPage_IncludesSyncLog(t *testing.T) {
h, cleanup := setupTestHandler(t)
defer cleanup()
_ = h.store.AddSyncLogEntry("cache_clear", "All caches cleared")
req := httptest.NewRequest("GET", "/settings", nil)
w := httptest.NewRecorder()
mr := h.renderer.(*MockRenderer)
h.HandleSettingsPage(w, req)
if len(mr.Calls) == 0 {
t.Fatal("Expected renderer to be called")
}
call := mr.Calls[len(mr.Calls)-1]
type hasSyncLog interface {
GetSyncLog() interface{}
}
// Use reflection to check for SyncLog field in the data struct
dataVal := reflect.ValueOf(call.Data)
syncLogField := dataVal.FieldByName("SyncLog")
if !syncLogField.IsValid() {
t.Fatal("Expected template data to have 'SyncLog' field")
}
}
// =============================================================================
// HandleGetTaskDetail template tests
// =============================================================================
// TestHandleGetTaskDetail_RendersTemplate verifies that HandleGetTaskDetail uses
// the renderer with the "task-detail" template.
func TestHandleGetTaskDetail_RendersTemplate(t *testing.T) {
h, cleanup := setupTestHandler(t)
defer cleanup()
req := httptest.NewRequest("GET", "/tasks/detail?id=abc&source=todoist", nil)
w := httptest.NewRecorder()
h.HandleGetTaskDetail(w, req)
mr := h.renderer.(*MockRenderer)
var found bool
for _, call := range mr.Calls {
if call.Name == "task-detail" {
found = true
break
}
}
if !found {
t.Error("Expected renderer to be called with 'task-detail' template")
}
}
// =============================================================================
// HandleGetListsOptions template tests
// =============================================================================
// TestHandleGetListsOptions_RendersTemplate verifies that HandleGetListsOptions uses
// the renderer with the "lists-options" template.
func TestHandleGetListsOptions_RendersTemplate(t *testing.T) {
h, cleanup := setupTestHandler(t)
defer cleanup()
h.trelloClient = &mockTrelloClient{boards: []models.Board{}}
req := httptest.NewRequest("GET", "/trello/lists?board_id=board1", nil)
w := httptest.NewRecorder()
h.HandleGetListsOptions(w, req)
mr := h.renderer.(*MockRenderer)
var found bool
for _, call := range mr.Calls {
if call.Name == "lists-options" {
found = true
break
}
}
if !found {
t.Error("Expected renderer to be called with 'lists-options' template")
}
}
// TestHandleSyncSources_AddsLogEntry verifies that syncing sources creates a sync log entry.
func TestHandleSyncSources_AddsLogEntry(t *testing.T) {
h, cleanup := setupTestHandler(t)
defer cleanup()
req := httptest.NewRequest("POST", "/settings/sync", nil)
w := httptest.NewRecorder()
h.HandleSyncSources(w, req)
entries, err := h.store.GetRecentSyncLog(10)
if err != nil {
t.Fatalf("GetRecentSyncLog failed: %v", err)
}
if len(entries) == 0 {
t.Fatal("Expected sync log entry after sync sources, got none")
}
if entries[0].EventType != "sync" {
t.Errorf("Expected event_type 'sync', got %q", entries[0].EventType)
}
}
// =============================================================================
// HandleTabPlanning data tests
// =============================================================================
// TestHandleTabPlanning_HappyPath verifies that tasks, events, and cards are
// placed into the correct planning sections (scheduled/unscheduled/upcoming).
func TestHandleTabPlanning_HappyPath(t *testing.T) {
db, cleanup := setupTestDB(t)
defer cleanup()
today := config.Today()
// Task at 10am today (has time) → goes to Scheduled
taskWithTime := today.Add(10 * time.Hour)
// Task at midnight today (no time component) → goes to Unscheduled
taskNoTime := today
// Task 2 days from now → goes to Upcoming (today+4 is the upper bound)
taskUpcoming := today.AddDate(0, 0, 2)
tasks := []models.Task{
{ID: "t-sched", Content: "Scheduled Task", DueDate: &taskWithTime, Labels: []string{}, CreatedAt: time.Now()},
{ID: "t-unsched", Content: "Unscheduled Task", DueDate: &taskNoTime, Labels: []string{}, CreatedAt: time.Now()},
{ID: "t-upcoming", Content: "Upcoming Task", DueDate: &taskUpcoming, Labels: []string{}, CreatedAt: time.Now()},
}
if err := db.SaveTasks(tasks); err != nil {
t.Fatalf("Failed to save tasks: %v", err)
}
// Card at 2pm today → goes to Scheduled
cardWithTime := today.Add(14 * time.Hour)
boards := []models.Board{{
ID: "board1",
Name: "Board 1",
Cards: []models.Card{
{ID: "c-sched", Name: "Scheduled Card", DueDate: &cardWithTime, URL: "http://trello.com/c1"},
},
}}
if err := db.SaveBoards(boards); err != nil {
t.Fatalf("Failed to save boards: %v", err)
}
renderer := NewMockRenderer()
h := &Handler{
store: db,
renderer: renderer,
config: &config.Config{CacheTTLMinutes: 5},
}
req := httptest.NewRequest("GET", "/tabs/planning", nil)
w := httptest.NewRecorder()
h.HandleTabPlanning(w, req)
if w.Code != http.StatusOK {
t.Errorf("Expected 200, got %d", w.Code)
}
var planningCall *RenderCall
for i, call := range renderer.Calls {
if call.Name == "planning-tab" {
c := renderer.Calls[i]
planningCall = &c
break
}
}
if planningCall == nil {
t.Fatal("Expected planning-tab to be rendered")
}
data, ok := planningCall.Data.(struct {
Scheduled []ScheduledItem
Unscheduled []models.Atom
Upcoming []ScheduledItem
Boards []models.Board
Today string
})
if !ok {
t.Fatalf("Expected planning data struct, got %T", planningCall.Data)
}
// t-sched (10am) and c-sched (2pm) should be in Scheduled
var foundTaskSched, foundCardSched bool
for _, s := range data.Scheduled {
if s.ID == "t-sched" {
foundTaskSched = true
}
if s.ID == "c-sched" {
foundCardSched = true
}
}
if !foundTaskSched {
t.Error("Expected t-sched (task with time today) in Scheduled")
}
if !foundCardSched {
t.Error("Expected c-sched (card with time today) in Scheduled")
}
// t-unsched (midnight, no time) should be in Unscheduled
var foundUnsched bool
for _, u := range data.Unscheduled {
if u.ID == "t-unsched" {
foundUnsched = true
}
}
if !foundUnsched {
t.Error("Expected t-unsched (midnight task) in Unscheduled")
}
// t-upcoming (2 days out) should be in Upcoming
var foundUpcoming bool
for _, u := range data.Upcoming {
if u.ID == "t-upcoming" {
foundUpcoming = true
}
}
if !foundUpcoming {
t.Error("Expected t-upcoming (task in 2 days) in Upcoming")
}
}
// TestHandleTabPlanning_TomorrowBoundary verifies that a task due exactly at
// midnight of "tomorrow" is NOT before tomorrow, so it lands in Upcoming.
func TestHandleTabPlanning_TomorrowBoundary(t *testing.T) {
db, cleanup := setupTestDB(t)
defer cleanup()
tomorrow := config.Today().AddDate(0, 0, 1) // midnight tomorrow
tasks := []models.Task{
{ID: "t-boundary", Content: "Midnight Tomorrow", DueDate: &tomorrow, Labels: []string{}, CreatedAt: time.Now()},
}
if err := db.SaveTasks(tasks); err != nil {
t.Fatalf("Failed to save tasks: %v", err)
}
renderer := NewMockRenderer()
h := &Handler{
store: db,
renderer: renderer,
config: &config.Config{CacheTTLMinutes: 5},
}
req := httptest.NewRequest("GET", "/tabs/planning", nil)
w := httptest.NewRecorder()
h.HandleTabPlanning(w, req)
if w.Code != http.StatusOK {
t.Fatalf("Expected 200, got %d", w.Code)
}
var planningCall *RenderCall
for i, call := range renderer.Calls {
if call.Name == "planning-tab" {
c := renderer.Calls[i]
planningCall = &c
break
}
}
if planningCall == nil {
t.Fatal("Expected planning-tab to be rendered")
}
data, ok := planningCall.Data.(struct {
Scheduled []ScheduledItem
Unscheduled []models.Atom
Upcoming []ScheduledItem
Boards []models.Board
Today string
})
if !ok {
t.Fatalf("Expected planning data struct, got %T", planningCall.Data)
}
// midnight-tomorrow: !dueDate.Before(tomorrow) → not in scheduled/unscheduled
for _, s := range data.Scheduled {
if s.ID == "t-boundary" {
t.Error("Midnight-tomorrow task must not appear in Scheduled")
}
}
for _, u := range data.Unscheduled {
if u.ID == "t-boundary" {
t.Error("Midnight-tomorrow task must not appear in Unscheduled")
}
}
// dueDate.Before(in3Days=today+4) → lands in Upcoming
var foundUpcoming bool
for _, u := range data.Upcoming {
if u.ID == "t-boundary" {
foundUpcoming = true
}
}
if !foundUpcoming {
t.Error("Expected midnight-tomorrow task in Upcoming")
}
}
// =============================================================================
// HandleTabMeals grouping test
// =============================================================================
// TestHandleTabMeals_GroupingMergesRecipes verifies that multiple meals sharing
// the same date+mealType are combined into a single CombinedMeal entry whose
// RecipeNames contains all merged recipe names.
func TestHandleTabMeals_GroupingMergesRecipes(t *testing.T) {
db, cleanup := setupTestDB(t)
defer cleanup()
today := config.Today()
meals := []models.Meal{
{ID: "m1", RecipeName: "Pasta", Date: today, MealType: "dinner", RecipeURL: "http://example.com/pasta"},
{ID: "m2", RecipeName: "Salad", Date: today, MealType: "dinner", RecipeURL: "http://example.com/salad"},
{ID: "m3", RecipeName: "Oatmeal", Date: today, MealType: "breakfast", RecipeURL: "http://example.com/oatmeal"},
}
if err := db.SaveMeals(meals); err != nil {
t.Fatalf("Failed to save meals: %v", err)
}
renderer := NewMockRenderer()
h := &Handler{
store: db,
renderer: renderer,
config: &config.Config{CacheTTLMinutes: 5},
}
req := httptest.NewRequest("GET", "/tabs/meals", nil)
w := httptest.NewRecorder()
h.HandleTabMeals(w, req)
if w.Code != http.StatusOK {
t.Errorf("Expected 200, got %d", w.Code)
}
var mealsCall *RenderCall
for i, call := range renderer.Calls {
if call.Name == "meals-tab" {
c := renderer.Calls[i]
mealsCall = &c
break
}
}
if mealsCall == nil {
t.Fatal("Expected meals-tab to be rendered")
}
data, ok := mealsCall.Data.(struct{ Meals []CombinedMeal })
if !ok {
t.Fatalf("Expected meals data struct, got %T", mealsCall.Data)
}
// m1 + m2 share date+dinner → 1 CombinedMeal; m3 is breakfast → 1 CombinedMeal
if len(data.Meals) != 2 {
t.Errorf("Expected 2 combined meals, got %d", len(data.Meals))
}
var dinner *CombinedMeal
for i := range data.Meals {
if data.Meals[i].MealType == "dinner" {
dinner = &data.Meals[i]
break
}
}
if dinner == nil {
t.Fatal("Expected a dinner CombinedMeal")
}
if len(dinner.RecipeNames) != 2 {
t.Errorf("Expected dinner to have 2 merged recipes, got %d: %v", len(dinner.RecipeNames), dinner.RecipeNames)
}
}