Add comprehensive kanban board tests and fix test suite

- Add 13 tests for task kanban categorization and button types
- Fix i18n initialization in test setup (was causing nil pointer panics)
- Add TaskCompletionImage to test DB auto-migrate
- Update ListTasks tests to expect kanban board response instead of array

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Trey t
2025-12-02 21:51:21 -06:00
parent 3419b66097
commit 7dc85aa4cf
4 changed files with 560 additions and 7 deletions

View File

@@ -168,11 +168,23 @@ func TestTaskHandler_ListTasks(t *testing.T) {
testutil.AssertStatusCode(t, w, http.StatusOK)
var response []map[string]interface{}
// ListTasks returns a kanban board object, not an array
var response map[string]interface{}
err := json.Unmarshal(w.Body.Bytes(), &response)
require.NoError(t, err)
assert.Len(t, response, 3)
// Verify kanban structure
assert.Contains(t, response, "columns")
assert.Contains(t, response, "days_threshold")
// Count total tasks across all columns
columns := response["columns"].([]interface{})
totalTasks := 0
for _, col := range columns {
column := col.(map[string]interface{})
totalTasks += int(column["count"].(float64))
}
assert.Equal(t, 3, totalTasks)
})
}
@@ -651,16 +663,19 @@ func TestTaskHandler_JSONResponses(t *testing.T) {
assert.IsType(t, false, response["is_archived"])
})
t.Run("list response returns array", func(t *testing.T) {
t.Run("list response returns kanban board", func(t *testing.T) {
w := testutil.MakeRequest(router, "GET", "/api/tasks/", nil, "test-token")
testutil.AssertStatusCode(t, w, http.StatusOK)
var response []map[string]interface{}
// ListTasks returns a kanban board object with columns
var response map[string]interface{}
err := json.Unmarshal(w.Body.Bytes(), &response)
require.NoError(t, err)
// Response should be an array of tasks
assert.IsType(t, []map[string]interface{}{}, response)
// Response should be a kanban board object
assert.Contains(t, response, "columns")
assert.Contains(t, response, "days_threshold")
assert.IsType(t, []interface{}{}, response["columns"])
})
}

View File

@@ -313,3 +313,525 @@ func TestTaskRepository_CountByResidence(t *testing.T) {
assert.Equal(t, int64(3), count)
}
// === Kanban Board Categorization Tests ===
func TestKanbanBoard_CancelledTasksGoToCancelledColumn(t *testing.T) {
db := testutil.SetupTestDB(t)
repo := NewTaskRepository(db)
testutil.SeedLookupData(t, db)
user := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "password")
residence := testutil.CreateTestResidence(t, db, user.ID, "Test House")
// Create a cancelled task
task := testutil.CreateTestTask(t, db, residence.ID, user.ID, "Cancelled Task")
repo.Cancel(task.ID)
board, err := repo.GetKanbanData(residence.ID, 30)
require.NoError(t, err)
// Find cancelled column
var cancelledColumn *models.KanbanColumn
for i := range board.Columns {
if board.Columns[i].Name == "cancelled_tasks" {
cancelledColumn = &board.Columns[i]
break
}
}
require.NotNil(t, cancelledColumn, "cancelled_tasks column should exist")
assert.Equal(t, 1, cancelledColumn.Count)
assert.Len(t, cancelledColumn.Tasks, 1)
assert.Equal(t, "Cancelled Task", cancelledColumn.Tasks[0].Title)
// Verify button types for cancelled column
assert.ElementsMatch(t, []string{"uncancel", "delete"}, cancelledColumn.ButtonTypes)
}
func TestKanbanBoard_CompletedTasksGoToCompletedColumn(t *testing.T) {
db := testutil.SetupTestDB(t)
repo := NewTaskRepository(db)
testutil.SeedLookupData(t, db)
user := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "password")
residence := testutil.CreateTestResidence(t, db, user.ID, "Test House")
// Create a task with a completion
task := testutil.CreateTestTask(t, db, residence.ID, user.ID, "Completed Task")
completion := &models.TaskCompletion{
TaskID: task.ID,
CompletedByID: user.ID,
CompletedAt: time.Now().UTC(),
Notes: "Done!",
}
err := repo.CreateCompletion(completion)
require.NoError(t, err)
board, err := repo.GetKanbanData(residence.ID, 30)
require.NoError(t, err)
// Find completed column
var completedColumn *models.KanbanColumn
for i := range board.Columns {
if board.Columns[i].Name == "completed_tasks" {
completedColumn = &board.Columns[i]
break
}
}
require.NotNil(t, completedColumn, "completed_tasks column should exist")
assert.Equal(t, 1, completedColumn.Count)
assert.Len(t, completedColumn.Tasks, 1)
assert.Equal(t, "Completed Task", completedColumn.Tasks[0].Title)
// Verify button types for completed column (view only)
assert.ElementsMatch(t, []string{"view"}, completedColumn.ButtonTypes)
}
func TestKanbanBoard_InProgressTasksGoToInProgressColumn(t *testing.T) {
db := testutil.SetupTestDB(t)
repo := NewTaskRepository(db)
testutil.SeedLookupData(t, db)
user := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "password")
residence := testutil.CreateTestResidence(t, db, user.ID, "Test House")
// Get "In Progress" status
var inProgressStatus models.TaskStatus
db.Where("name = ?", "In Progress").First(&inProgressStatus)
// Create a task with "In Progress" status
task := &models.Task{
ResidenceID: residence.ID,
CreatedByID: user.ID,
Title: "In Progress Task",
StatusID: &inProgressStatus.ID,
}
err := db.Create(task).Error
require.NoError(t, err)
board, err := repo.GetKanbanData(residence.ID, 30)
require.NoError(t, err)
// Find in_progress column
var inProgressColumn *models.KanbanColumn
for i := range board.Columns {
if board.Columns[i].Name == "in_progress_tasks" {
inProgressColumn = &board.Columns[i]
break
}
}
require.NotNil(t, inProgressColumn, "in_progress_tasks column should exist")
assert.Equal(t, 1, inProgressColumn.Count)
assert.Len(t, inProgressColumn.Tasks, 1)
assert.Equal(t, "In Progress Task", inProgressColumn.Tasks[0].Title)
// Verify button types for in-progress column (no mark_in_progress since already in progress)
assert.ElementsMatch(t, []string{"edit", "complete", "cancel"}, inProgressColumn.ButtonTypes)
}
func TestKanbanBoard_OverdueTasksGoToOverdueColumn(t *testing.T) {
db := testutil.SetupTestDB(t)
repo := NewTaskRepository(db)
testutil.SeedLookupData(t, db)
user := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "password")
residence := testutil.CreateTestResidence(t, db, user.ID, "Test House")
// Create a task with due date in the past
pastDue := time.Now().UTC().AddDate(0, 0, -5) // 5 days ago
task := &models.Task{
ResidenceID: residence.ID,
CreatedByID: user.ID,
Title: "Overdue Task",
DueDate: &pastDue,
}
err := db.Create(task).Error
require.NoError(t, err)
board, err := repo.GetKanbanData(residence.ID, 30)
require.NoError(t, err)
// Find overdue column
var overdueColumn *models.KanbanColumn
for i := range board.Columns {
if board.Columns[i].Name == "overdue_tasks" {
overdueColumn = &board.Columns[i]
break
}
}
require.NotNil(t, overdueColumn, "overdue_tasks column should exist")
assert.Equal(t, 1, overdueColumn.Count)
assert.Len(t, overdueColumn.Tasks, 1)
assert.Equal(t, "Overdue Task", overdueColumn.Tasks[0].Title)
// Verify button types for overdue column
assert.ElementsMatch(t, []string{"edit", "complete", "cancel", "mark_in_progress"}, overdueColumn.ButtonTypes)
}
func TestKanbanBoard_DueSoonTasksGoToDueSoonColumn(t *testing.T) {
db := testutil.SetupTestDB(t)
repo := NewTaskRepository(db)
testutil.SeedLookupData(t, db)
user := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "password")
residence := testutil.CreateTestResidence(t, db, user.ID, "Test House")
// Create a task with due date within threshold (default 30 days)
dueSoon := time.Now().UTC().AddDate(0, 0, 15) // 15 days from now
task := &models.Task{
ResidenceID: residence.ID,
CreatedByID: user.ID,
Title: "Due Soon Task",
DueDate: &dueSoon,
}
err := db.Create(task).Error
require.NoError(t, err)
board, err := repo.GetKanbanData(residence.ID, 30)
require.NoError(t, err)
// Find due_soon column
var dueSoonColumn *models.KanbanColumn
for i := range board.Columns {
if board.Columns[i].Name == "due_soon_tasks" {
dueSoonColumn = &board.Columns[i]
break
}
}
require.NotNil(t, dueSoonColumn, "due_soon_tasks column should exist")
assert.Equal(t, 1, dueSoonColumn.Count)
assert.Len(t, dueSoonColumn.Tasks, 1)
assert.Equal(t, "Due Soon Task", dueSoonColumn.Tasks[0].Title)
// Verify button types for due_soon column
assert.ElementsMatch(t, []string{"edit", "complete", "cancel", "mark_in_progress"}, dueSoonColumn.ButtonTypes)
}
func TestKanbanBoard_UpcomingTasksGoToUpcomingColumn(t *testing.T) {
db := testutil.SetupTestDB(t)
repo := NewTaskRepository(db)
testutil.SeedLookupData(t, db)
user := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "password")
residence := testutil.CreateTestResidence(t, db, user.ID, "Test House")
// Create a task with due date beyond threshold (default 30 days)
upcoming := time.Now().UTC().AddDate(0, 0, 45) // 45 days from now
task := &models.Task{
ResidenceID: residence.ID,
CreatedByID: user.ID,
Title: "Upcoming Task",
DueDate: &upcoming,
}
err := db.Create(task).Error
require.NoError(t, err)
board, err := repo.GetKanbanData(residence.ID, 30)
require.NoError(t, err)
// Find upcoming column
var upcomingColumn *models.KanbanColumn
for i := range board.Columns {
if board.Columns[i].Name == "upcoming_tasks" {
upcomingColumn = &board.Columns[i]
break
}
}
require.NotNil(t, upcomingColumn, "upcoming_tasks column should exist")
assert.Equal(t, 1, upcomingColumn.Count)
assert.Len(t, upcomingColumn.Tasks, 1)
assert.Equal(t, "Upcoming Task", upcomingColumn.Tasks[0].Title)
// Verify button types for upcoming column
assert.ElementsMatch(t, []string{"edit", "complete", "cancel", "mark_in_progress"}, upcomingColumn.ButtonTypes)
}
func TestKanbanBoard_TasksWithNoDueDateGoToUpcomingColumn(t *testing.T) {
db := testutil.SetupTestDB(t)
repo := NewTaskRepository(db)
testutil.SeedLookupData(t, db)
user := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "password")
residence := testutil.CreateTestResidence(t, db, user.ID, "Test House")
// Create a task with no due date
task := testutil.CreateTestTask(t, db, residence.ID, user.ID, "No Due Date Task")
board, err := repo.GetKanbanData(residence.ID, 30)
require.NoError(t, err)
// Find upcoming column
var upcomingColumn *models.KanbanColumn
for i := range board.Columns {
if board.Columns[i].Name == "upcoming_tasks" {
upcomingColumn = &board.Columns[i]
break
}
}
require.NotNil(t, upcomingColumn, "upcoming_tasks column should exist")
assert.Equal(t, 1, upcomingColumn.Count)
assert.Equal(t, task.ID, upcomingColumn.Tasks[0].ID)
}
func TestKanbanBoard_ArchivedTasksAreExcluded(t *testing.T) {
db := testutil.SetupTestDB(t)
repo := NewTaskRepository(db)
testutil.SeedLookupData(t, db)
user := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "password")
residence := testutil.CreateTestResidence(t, db, user.ID, "Test House")
// Create a regular task and an archived task
testutil.CreateTestTask(t, db, residence.ID, user.ID, "Regular Task")
archivedTask := testutil.CreateTestTask(t, db, residence.ID, user.ID, "Archived Task")
repo.Archive(archivedTask.ID)
board, err := repo.GetKanbanData(residence.ID, 30)
require.NoError(t, err)
// Count total tasks across all columns
totalTasks := 0
for _, col := range board.Columns {
totalTasks += col.Count
}
// Only the non-archived task should be in the board
assert.Equal(t, 1, totalTasks)
}
func TestKanbanBoard_CategoryPriority_CancelledTakesPrecedence(t *testing.T) {
db := testutil.SetupTestDB(t)
repo := NewTaskRepository(db)
testutil.SeedLookupData(t, db)
user := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "password")
residence := testutil.CreateTestResidence(t, db, user.ID, "Test House")
// Create a task that is BOTH overdue AND cancelled
// Cancelled should take precedence
pastDue := time.Now().UTC().AddDate(0, 0, -5)
task := &models.Task{
ResidenceID: residence.ID,
CreatedByID: user.ID,
Title: "Overdue and Cancelled Task",
DueDate: &pastDue,
IsCancelled: true,
}
err := db.Create(task).Error
require.NoError(t, err)
board, err := repo.GetKanbanData(residence.ID, 30)
require.NoError(t, err)
// Find cancelled column
var cancelledColumn *models.KanbanColumn
var overdueColumn *models.KanbanColumn
for i := range board.Columns {
if board.Columns[i].Name == "cancelled_tasks" {
cancelledColumn = &board.Columns[i]
}
if board.Columns[i].Name == "overdue_tasks" {
overdueColumn = &board.Columns[i]
}
}
// Task should be in cancelled, not overdue
assert.Equal(t, 1, cancelledColumn.Count, "Task should be in cancelled column")
assert.Equal(t, 0, overdueColumn.Count, "Task should NOT be in overdue column")
}
func TestKanbanBoard_CategoryPriority_CompletedTakesPrecedenceOverInProgress(t *testing.T) {
db := testutil.SetupTestDB(t)
repo := NewTaskRepository(db)
testutil.SeedLookupData(t, db)
user := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "password")
residence := testutil.CreateTestResidence(t, db, user.ID, "Test House")
// Get "In Progress" status
var inProgressStatus models.TaskStatus
db.Where("name = ?", "In Progress").First(&inProgressStatus)
// Create a task that has "In Progress" status AND a completion
// Completed should take precedence
task := &models.Task{
ResidenceID: residence.ID,
CreatedByID: user.ID,
Title: "In Progress with Completion",
StatusID: &inProgressStatus.ID,
}
err := db.Create(task).Error
require.NoError(t, err)
// Add a completion
completion := &models.TaskCompletion{
TaskID: task.ID,
CompletedByID: user.ID,
CompletedAt: time.Now().UTC(),
}
err = repo.CreateCompletion(completion)
require.NoError(t, err)
board, err := repo.GetKanbanData(residence.ID, 30)
require.NoError(t, err)
// Find columns
var completedColumn, inProgressColumn *models.KanbanColumn
for i := range board.Columns {
if board.Columns[i].Name == "completed_tasks" {
completedColumn = &board.Columns[i]
}
if board.Columns[i].Name == "in_progress_tasks" {
inProgressColumn = &board.Columns[i]
}
}
// Task should be in completed, not in_progress
assert.Equal(t, 1, completedColumn.Count, "Task should be in completed column")
assert.Equal(t, 0, inProgressColumn.Count, "Task should NOT be in in_progress column")
}
func TestKanbanBoard_DaysThresholdAffectsCategorization(t *testing.T) {
db := testutil.SetupTestDB(t)
repo := NewTaskRepository(db)
testutil.SeedLookupData(t, db)
user := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "password")
residence := testutil.CreateTestResidence(t, db, user.ID, "Test House")
// Create a task due in 10 days
dueIn10Days := time.Now().UTC().AddDate(0, 0, 10)
task := &models.Task{
ResidenceID: residence.ID,
CreatedByID: user.ID,
Title: "Task Due in 10 Days",
DueDate: &dueIn10Days,
}
err := db.Create(task).Error
require.NoError(t, err)
// With 30-day threshold, should be in "due_soon"
board30, err := repo.GetKanbanData(residence.ID, 30)
require.NoError(t, err)
var dueSoon30, upcoming30 *models.KanbanColumn
for i := range board30.Columns {
if board30.Columns[i].Name == "due_soon_tasks" {
dueSoon30 = &board30.Columns[i]
}
if board30.Columns[i].Name == "upcoming_tasks" {
upcoming30 = &board30.Columns[i]
}
}
assert.Equal(t, 1, dueSoon30.Count, "With 30-day threshold, task should be in due_soon")
assert.Equal(t, 0, upcoming30.Count, "With 30-day threshold, task should NOT be in upcoming")
// With 5-day threshold, should be in "upcoming"
board5, err := repo.GetKanbanData(residence.ID, 5)
require.NoError(t, err)
var dueSoon5, upcoming5 *models.KanbanColumn
for i := range board5.Columns {
if board5.Columns[i].Name == "due_soon_tasks" {
dueSoon5 = &board5.Columns[i]
}
if board5.Columns[i].Name == "upcoming_tasks" {
upcoming5 = &board5.Columns[i]
}
}
assert.Equal(t, 0, dueSoon5.Count, "With 5-day threshold, task should NOT be in due_soon")
assert.Equal(t, 1, upcoming5.Count, "With 5-day threshold, task should be in upcoming")
}
func TestKanbanBoard_ColumnMetadata(t *testing.T) {
db := testutil.SetupTestDB(t)
repo := NewTaskRepository(db)
testutil.SeedLookupData(t, db)
user := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "password")
residence := testutil.CreateTestResidence(t, db, user.ID, "Test House")
board, err := repo.GetKanbanData(residence.ID, 30)
require.NoError(t, err)
// Verify all 6 columns exist with correct metadata
expectedColumns := []struct {
name string
displayName string
color string
buttonTypes []string
iosIcon string
androidIcon string
}{
{"overdue_tasks", "Overdue", "#FF3B30", []string{"edit", "complete", "cancel", "mark_in_progress"}, "exclamationmark.triangle", "Warning"},
{"due_soon_tasks", "Due Soon", "#FF9500", []string{"edit", "complete", "cancel", "mark_in_progress"}, "clock", "Schedule"},
{"upcoming_tasks", "Upcoming", "#007AFF", []string{"edit", "complete", "cancel", "mark_in_progress"}, "calendar", "Event"},
{"in_progress_tasks", "In Progress", "#5856D6", []string{"edit", "complete", "cancel"}, "hammer", "Build"},
{"completed_tasks", "Completed", "#34C759", []string{"view"}, "checkmark.circle", "CheckCircle"},
{"cancelled_tasks", "Cancelled", "#8E8E93", []string{"uncancel", "delete"}, "xmark.circle", "Cancel"},
}
assert.Len(t, board.Columns, 6, "Board should have 6 columns")
for i, expected := range expectedColumns {
col := board.Columns[i]
assert.Equal(t, expected.name, col.Name, "Column %d name mismatch", i)
assert.Equal(t, expected.displayName, col.DisplayName, "Column %d display name mismatch", i)
assert.Equal(t, expected.color, col.Color, "Column %d color mismatch", i)
assert.ElementsMatch(t, expected.buttonTypes, col.ButtonTypes, "Column %d button types mismatch", i)
assert.Equal(t, expected.iosIcon, col.Icons["ios"], "Column %d iOS icon mismatch", i)
assert.Equal(t, expected.androidIcon, col.Icons["android"], "Column %d Android icon mismatch", i)
}
// Verify days threshold is returned
assert.Equal(t, 30, board.DaysThreshold)
}
func TestKanbanBoard_MultipleResidences(t *testing.T) {
db := testutil.SetupTestDB(t)
repo := NewTaskRepository(db)
testutil.SeedLookupData(t, db)
user := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "password")
residence1 := testutil.CreateTestResidence(t, db, user.ID, "House 1")
residence2 := testutil.CreateTestResidence(t, db, user.ID, "House 2")
// Create tasks in both residences
testutil.CreateTestTask(t, db, residence1.ID, user.ID, "Task in House 1")
testutil.CreateTestTask(t, db, residence2.ID, user.ID, "Task in House 2")
// Create a cancelled task in house 1
cancelledTask := testutil.CreateTestTask(t, db, residence1.ID, user.ID, "Cancelled in House 1")
repo.Cancel(cancelledTask.ID)
board, err := repo.GetKanbanDataForMultipleResidences([]uint{residence1.ID, residence2.ID}, 30)
require.NoError(t, err)
// Count total tasks
totalTasks := 0
for _, col := range board.Columns {
totalTasks += col.Count
}
assert.Equal(t, 3, totalTasks, "Should have 3 tasks total across both residences")
// Find upcoming and cancelled columns
var upcomingColumn, cancelledColumn *models.KanbanColumn
for i := range board.Columns {
if board.Columns[i].Name == "upcoming_tasks" {
upcomingColumn = &board.Columns[i]
}
if board.Columns[i].Name == "cancelled_tasks" {
cancelledColumn = &board.Columns[i]
}
}
assert.Equal(t, 2, upcomingColumn.Count, "Should have 2 upcoming tasks")
assert.Equal(t, 1, cancelledColumn.Count, "Should have 1 cancelled task")
}

View File

@@ -151,7 +151,13 @@ func TestTaskService_ListTasks(t *testing.T) {
resp, err := service.ListTasks(user.ID)
require.NoError(t, err)
assert.Len(t, resp, 3)
// ListTasks returns a KanbanBoardResponse with columns
// Count total tasks across all columns
totalTasks := 0
for _, col := range resp.Columns {
totalTasks += col.Count
}
assert.Equal(t, 3, totalTasks)
}
func TestTaskService_UpdateTask(t *testing.T) {

View File

@@ -5,6 +5,7 @@ import (
"encoding/json"
"net/http"
"net/http/httptest"
"sync"
"testing"
"github.com/gin-gonic/gin"
@@ -13,11 +14,19 @@ import (
"gorm.io/gorm"
"gorm.io/gorm/logger"
"github.com/treytartt/casera-api/internal/i18n"
"github.com/treytartt/casera-api/internal/models"
)
var i18nOnce sync.Once
// SetupTestDB creates an in-memory SQLite database for testing
func SetupTestDB(t *testing.T) *gorm.DB {
// Initialize i18n once for all tests
i18nOnce.Do(func() {
i18n.Init()
})
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{
Logger: logger.Default.LogMode(logger.Silent),
})
@@ -40,6 +49,7 @@ func SetupTestDB(t *testing.T) *gorm.DB {
&models.TaskStatus{},
&models.TaskFrequency{},
&models.TaskCompletion{},
&models.TaskCompletionImage{},
&models.Contractor{},
&models.ContractorSpecialty{},
&models.Document{},