Files
honeyDueAPI/internal/services/task_service_test.go
Trey t 4976eafc6c Rebrand from Casera/MyCrib to honeyDue
Total rebrand across all Go API source files:
- Go module path: casera-api -> honeydue-api
- All imports updated (130+ files)
- Docker: containers, images, networks renamed
- Email templates: support email, noreply, icon URL
- Domains: casera.app/mycrib.treytartt.com -> honeyDue.treytartt.com
- Bundle IDs: com.tt.casera -> com.tt.honeyDue
- IAP product IDs updated
- Landing page, admin panel, config defaults
- Seeds, CI workflows, Makefile, docs
- Database table names preserved (no migration needed)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 06:33:38 -06:00

851 lines
31 KiB
Go

package services
import (
"net/http"
"testing"
"time"
"github.com/shopspring/decimal"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"gorm.io/gorm"
"github.com/treytartt/honeydue-api/internal/dto/requests"
"github.com/treytartt/honeydue-api/internal/models"
"github.com/treytartt/honeydue-api/internal/repositories"
"github.com/treytartt/honeydue-api/internal/testutil"
)
func setupTaskService(t *testing.T) (*TaskService, *repositories.TaskRepository, *repositories.ResidenceRepository) {
db := testutil.SetupTestDB(t)
testutil.SeedLookupData(t, db)
taskRepo := repositories.NewTaskRepository(db)
residenceRepo := repositories.NewResidenceRepository(db)
service := NewTaskService(taskRepo, residenceRepo)
return service, taskRepo, residenceRepo
}
func TestTaskService_CreateTask(t *testing.T) {
db := testutil.SetupTestDB(t)
testutil.SeedLookupData(t, db)
taskRepo := repositories.NewTaskRepository(db)
residenceRepo := repositories.NewResidenceRepository(db)
service := NewTaskService(taskRepo, residenceRepo)
user := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "password")
residence := testutil.CreateTestResidence(t, db, user.ID, "Test House")
req := &requests.CreateTaskRequest{
ResidenceID: residence.ID,
Title: "Fix leaky faucet",
Description: "Kitchen faucet is dripping",
}
now := time.Now().UTC()
resp, err := service.CreateTask(req, user.ID, now)
require.NoError(t, err)
assert.NotZero(t, resp.Data.ID)
assert.Equal(t, "Fix leaky faucet", resp.Data.Title)
assert.Equal(t, "Kitchen faucet is dripping", resp.Data.Description)
}
func TestTaskService_CreateTask_WithOptionalFields(t *testing.T) {
db := testutil.SetupTestDB(t)
testutil.SeedLookupData(t, db)
taskRepo := repositories.NewTaskRepository(db)
residenceRepo := repositories.NewResidenceRepository(db)
service := NewTaskService(taskRepo, residenceRepo)
user := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "password")
residence := testutil.CreateTestResidence(t, db, user.ID, "Test House")
// Get category and priority IDs
var category models.TaskCategory
var priority models.TaskPriority
db.First(&category)
db.First(&priority)
dueDate := requests.FlexibleDate{Time: time.Now().Add(7 * 24 * time.Hour).UTC()}
cost := decimal.NewFromFloat(150.50)
req := &requests.CreateTaskRequest{
ResidenceID: residence.ID,
Title: "Fix leaky faucet",
CategoryID: &category.ID,
PriorityID: &priority.ID,
DueDate: &dueDate,
EstimatedCost: &cost,
}
now := time.Now().UTC()
resp, err := service.CreateTask(req, user.ID, now)
require.NoError(t, err)
// Note: Category and Priority are no longer preloaded for performance
// Client resolves from cache using CategoryID and PriorityID
assert.NotNil(t, resp.Data.CategoryID, "CategoryID should be set")
assert.NotNil(t, resp.Data.PriorityID, "PriorityID should be set")
assert.NotNil(t, resp.Data.DueDate)
assert.NotNil(t, resp.Data.EstimatedCost)
}
func TestTaskService_CreateTask_AccessDenied(t *testing.T) {
db := testutil.SetupTestDB(t)
testutil.SeedLookupData(t, db)
taskRepo := repositories.NewTaskRepository(db)
residenceRepo := repositories.NewResidenceRepository(db)
service := NewTaskService(taskRepo, residenceRepo)
owner := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "password")
otherUser := testutil.CreateTestUser(t, db, "other", "other@test.com", "password")
residence := testutil.CreateTestResidence(t, db, owner.ID, "Test House")
req := &requests.CreateTaskRequest{
ResidenceID: residence.ID,
Title: "Test Task",
}
now := time.Now().UTC()
_, err := service.CreateTask(req, otherUser.ID, now)
// When creating a task, residence access is checked first
testutil.AssertAppError(t, err, http.StatusForbidden, "error.residence_access_denied")
}
func TestTaskService_GetTask(t *testing.T) {
db := testutil.SetupTestDB(t)
testutil.SeedLookupData(t, db)
taskRepo := repositories.NewTaskRepository(db)
residenceRepo := repositories.NewResidenceRepository(db)
service := NewTaskService(taskRepo, residenceRepo)
user := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "password")
residence := testutil.CreateTestResidence(t, db, user.ID, "Test House")
task := testutil.CreateTestTask(t, db, residence.ID, user.ID, "Test Task")
resp, err := service.GetTask(task.ID, user.ID)
require.NoError(t, err)
assert.Equal(t, task.ID, resp.ID)
assert.Equal(t, "Test Task", resp.Title)
}
func TestTaskService_GetTask_AccessDenied(t *testing.T) {
db := testutil.SetupTestDB(t)
testutil.SeedLookupData(t, db)
taskRepo := repositories.NewTaskRepository(db)
residenceRepo := repositories.NewResidenceRepository(db)
service := NewTaskService(taskRepo, residenceRepo)
owner := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "password")
otherUser := testutil.CreateTestUser(t, db, "other", "other@test.com", "password")
residence := testutil.CreateTestResidence(t, db, owner.ID, "Test House")
task := testutil.CreateTestTask(t, db, residence.ID, owner.ID, "Test Task")
_, err := service.GetTask(task.ID, otherUser.ID)
testutil.AssertAppError(t, err, http.StatusForbidden, "error.task_access_denied")
}
func TestTaskService_ListTasks(t *testing.T) {
db := testutil.SetupTestDB(t)
testutil.SeedLookupData(t, db)
taskRepo := repositories.NewTaskRepository(db)
residenceRepo := repositories.NewResidenceRepository(db)
service := NewTaskService(taskRepo, residenceRepo)
user := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "password")
residence := testutil.CreateTestResidence(t, db, user.ID, "Test House")
testutil.CreateTestTask(t, db, residence.ID, user.ID, "Task 1")
testutil.CreateTestTask(t, db, residence.ID, user.ID, "Task 2")
testutil.CreateTestTask(t, db, residence.ID, user.ID, "Task 3")
resp, err := service.ListTasks(user.ID, 30, time.Now().UTC())
require.NoError(t, err)
// 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) {
db := testutil.SetupTestDB(t)
testutil.SeedLookupData(t, db)
taskRepo := repositories.NewTaskRepository(db)
residenceRepo := repositories.NewResidenceRepository(db)
service := NewTaskService(taskRepo, residenceRepo)
user := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "password")
residence := testutil.CreateTestResidence(t, db, user.ID, "Test House")
task := testutil.CreateTestTask(t, db, residence.ID, user.ID, "Original Title")
newTitle := "Updated Title"
newDesc := "Updated description"
req := &requests.UpdateTaskRequest{
Title: &newTitle,
Description: &newDesc,
}
now := time.Now().UTC()
resp, err := service.UpdateTask(task.ID, user.ID, req, now)
require.NoError(t, err)
assert.Equal(t, "Updated Title", resp.Data.Title)
assert.Equal(t, "Updated description", resp.Data.Description)
}
func TestTaskService_DeleteTask(t *testing.T) {
db := testutil.SetupTestDB(t)
testutil.SeedLookupData(t, db)
taskRepo := repositories.NewTaskRepository(db)
residenceRepo := repositories.NewResidenceRepository(db)
service := NewTaskService(taskRepo, residenceRepo)
user := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "password")
residence := testutil.CreateTestResidence(t, db, user.ID, "Test House")
task := testutil.CreateTestTask(t, db, residence.ID, user.ID, "Test Task")
_, err := service.DeleteTask(task.ID, user.ID)
require.NoError(t, err)
_, err = service.GetTask(task.ID, user.ID)
assert.Error(t, err)
}
func TestTaskService_CancelTask(t *testing.T) {
db := testutil.SetupTestDB(t)
testutil.SeedLookupData(t, db)
taskRepo := repositories.NewTaskRepository(db)
residenceRepo := repositories.NewResidenceRepository(db)
service := NewTaskService(taskRepo, residenceRepo)
user := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "password")
residence := testutil.CreateTestResidence(t, db, user.ID, "Test House")
task := testutil.CreateTestTask(t, db, residence.ID, user.ID, "Test Task")
now := time.Now().UTC()
resp, err := service.CancelTask(task.ID, user.ID, now)
require.NoError(t, err)
assert.True(t, resp.Data.IsCancelled)
}
func TestTaskService_CancelTask_AlreadyCancelled(t *testing.T) {
db := testutil.SetupTestDB(t)
testutil.SeedLookupData(t, db)
taskRepo := repositories.NewTaskRepository(db)
residenceRepo := repositories.NewResidenceRepository(db)
service := NewTaskService(taskRepo, residenceRepo)
user := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "password")
residence := testutil.CreateTestResidence(t, db, user.ID, "Test House")
task := testutil.CreateTestTask(t, db, residence.ID, user.ID, "Test Task")
now := time.Now().UTC()
service.CancelTask(task.ID, user.ID, now)
_, err := service.CancelTask(task.ID, user.ID, now)
testutil.AssertAppError(t, err, http.StatusBadRequest, "error.task_already_cancelled")
}
func TestTaskService_UncancelTask(t *testing.T) {
db := testutil.SetupTestDB(t)
testutil.SeedLookupData(t, db)
taskRepo := repositories.NewTaskRepository(db)
residenceRepo := repositories.NewResidenceRepository(db)
service := NewTaskService(taskRepo, residenceRepo)
user := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "password")
residence := testutil.CreateTestResidence(t, db, user.ID, "Test House")
task := testutil.CreateTestTask(t, db, residence.ID, user.ID, "Test Task")
now := time.Now().UTC()
service.CancelTask(task.ID, user.ID, now)
resp, err := service.UncancelTask(task.ID, user.ID, now)
require.NoError(t, err)
assert.False(t, resp.Data.IsCancelled)
}
func TestTaskService_ArchiveTask(t *testing.T) {
db := testutil.SetupTestDB(t)
testutil.SeedLookupData(t, db)
taskRepo := repositories.NewTaskRepository(db)
residenceRepo := repositories.NewResidenceRepository(db)
service := NewTaskService(taskRepo, residenceRepo)
user := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "password")
residence := testutil.CreateTestResidence(t, db, user.ID, "Test House")
task := testutil.CreateTestTask(t, db, residence.ID, user.ID, "Test Task")
now := time.Now().UTC()
resp, err := service.ArchiveTask(task.ID, user.ID, now)
require.NoError(t, err)
assert.True(t, resp.Data.IsArchived)
}
func TestTaskService_UnarchiveTask(t *testing.T) {
db := testutil.SetupTestDB(t)
testutil.SeedLookupData(t, db)
taskRepo := repositories.NewTaskRepository(db)
residenceRepo := repositories.NewResidenceRepository(db)
service := NewTaskService(taskRepo, residenceRepo)
user := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "password")
residence := testutil.CreateTestResidence(t, db, user.ID, "Test House")
task := testutil.CreateTestTask(t, db, residence.ID, user.ID, "Test Task")
now := time.Now().UTC()
service.ArchiveTask(task.ID, user.ID, now)
resp, err := service.UnarchiveTask(task.ID, user.ID, now)
require.NoError(t, err)
assert.False(t, resp.Data.IsArchived)
}
func TestTaskService_MarkInProgress(t *testing.T) {
db := testutil.SetupTestDB(t)
testutil.SeedLookupData(t, db)
taskRepo := repositories.NewTaskRepository(db)
residenceRepo := repositories.NewResidenceRepository(db)
service := NewTaskService(taskRepo, residenceRepo)
user := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "password")
residence := testutil.CreateTestResidence(t, db, user.ID, "Test House")
task := testutil.CreateTestTask(t, db, residence.ID, user.ID, "Test Task")
now := time.Now().UTC()
resp, err := service.MarkInProgress(task.ID, user.ID, now)
require.NoError(t, err)
assert.True(t, resp.Data.InProgress)
}
func TestTaskService_CreateCompletion(t *testing.T) {
db := testutil.SetupTestDB(t)
testutil.SeedLookupData(t, db)
taskRepo := repositories.NewTaskRepository(db)
residenceRepo := repositories.NewResidenceRepository(db)
service := NewTaskService(taskRepo, residenceRepo)
user := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "password")
residence := testutil.CreateTestResidence(t, db, user.ID, "Test House")
task := testutil.CreateTestTask(t, db, residence.ID, user.ID, "Test Task")
req := &requests.CreateTaskCompletionRequest{
TaskID: task.ID,
Notes: "Completed successfully",
}
now := time.Now().UTC()
resp, err := service.CreateCompletion(req, user.ID, now)
require.NoError(t, err)
assert.NotZero(t, resp.Data.ID)
assert.Equal(t, task.ID, resp.Data.TaskID)
assert.Equal(t, "Completed successfully", resp.Data.Notes)
}
func TestTaskService_CreateCompletion_RecurringTask_ResetsInProgress(t *testing.T) {
db := testutil.SetupTestDB(t)
testutil.SeedLookupData(t, db)
taskRepo := repositories.NewTaskRepository(db)
residenceRepo := repositories.NewResidenceRepository(db)
service := NewTaskService(taskRepo, residenceRepo)
user := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "password")
residence := testutil.CreateTestResidence(t, db, user.ID, "Test House")
var monthlyFrequency models.TaskFrequency
db.Where("name = ?", "Monthly").First(&monthlyFrequency)
// Create a recurring task that is in progress
dueDate := time.Now().AddDate(0, 0, 7) // Due in 7 days
task := &models.Task{
ResidenceID: residence.ID,
CreatedByID: user.ID,
Title: "Recurring Task",
InProgress: true,
FrequencyID: &monthlyFrequency.ID,
DueDate: &dueDate,
NextDueDate: &dueDate,
IsCancelled: false,
IsArchived: false,
}
err := db.Create(task).Error
require.NoError(t, err)
// Complete the task
req := &requests.CreateTaskCompletionRequest{
TaskID: task.ID,
Notes: "Monthly maintenance done",
}
now := time.Now().UTC()
resp, err := service.CreateCompletion(req, user.ID, now)
require.NoError(t, err)
assert.NotZero(t, resp.Data.ID)
// Verify the task in the response has InProgress reset to false
require.NotNil(t, resp.Data.Task, "Response should include the updated task")
assert.False(t, resp.Data.Task.InProgress, "Recurring task InProgress should be reset to false after completion")
// Verify NextDueDate was updated (should be ~30 days from now for monthly)
require.NotNil(t, resp.Data.Task.NextDueDate, "Recurring task should have NextDueDate set")
expectedNextDue := time.Now().AddDate(0, 0, 30) // Monthly = 30 days
assert.WithinDuration(t, expectedNextDue, *resp.Data.Task.NextDueDate, 24*time.Hour, "NextDueDate should be approximately 30 days from now")
// Also verify by reloading from database directly
var reloadedTask models.Task
db.First(&reloadedTask, task.ID)
assert.False(t, reloadedTask.InProgress, "Database should show InProgress=false")
}
func TestTaskService_GetCompletion(t *testing.T) {
db := testutil.SetupTestDB(t)
testutil.SeedLookupData(t, db)
taskRepo := repositories.NewTaskRepository(db)
residenceRepo := repositories.NewResidenceRepository(db)
service := NewTaskService(taskRepo, residenceRepo)
user := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "password")
residence := testutil.CreateTestResidence(t, db, user.ID, "Test House")
task := testutil.CreateTestTask(t, db, residence.ID, user.ID, "Test Task")
completion := &models.TaskCompletion{
TaskID: task.ID,
CompletedByID: user.ID,
CompletedAt: time.Now().UTC(),
Notes: "Test notes",
}
db.Create(completion)
resp, err := service.GetCompletion(completion.ID, user.ID)
require.NoError(t, err)
assert.Equal(t, completion.ID, resp.ID)
assert.Equal(t, "Test notes", resp.Notes)
}
func TestTaskService_DeleteCompletion(t *testing.T) {
db := testutil.SetupTestDB(t)
testutil.SeedLookupData(t, db)
taskRepo := repositories.NewTaskRepository(db)
residenceRepo := repositories.NewResidenceRepository(db)
service := NewTaskService(taskRepo, residenceRepo)
user := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "password")
residence := testutil.CreateTestResidence(t, db, user.ID, "Test House")
task := testutil.CreateTestTask(t, db, residence.ID, user.ID, "Test Task")
completion := &models.TaskCompletion{
TaskID: task.ID,
CompletedByID: user.ID,
CompletedAt: time.Now().UTC(),
}
db.Create(completion)
_, err := service.DeleteCompletion(completion.ID, user.ID)
require.NoError(t, err)
_, err = service.GetCompletion(completion.ID, user.ID)
assert.Error(t, err)
}
func TestTaskService_CreateCompletion_TransactionIntegrity(t *testing.T) {
// Verifies P1-5 / P1-6: completion creation and task update are atomic.
// After completion, both the completion record AND the task's NextDueDate update
// should succeed together, and errors should be propagated (not swallowed).
db := testutil.SetupTestDB(t)
testutil.SeedLookupData(t, db)
taskRepo := repositories.NewTaskRepository(db)
residenceRepo := repositories.NewResidenceRepository(db)
service := NewTaskService(taskRepo, residenceRepo)
user := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "password")
residence := testutil.CreateTestResidence(t, db, user.ID, "Test House")
// Create a one-time task with a due date
dueDate := time.Now().AddDate(0, 0, 7).UTC()
task := &models.Task{
ResidenceID: residence.ID,
CreatedByID: user.ID,
Title: "One-time Task",
DueDate: &dueDate,
NextDueDate: &dueDate,
IsCancelled: false,
IsArchived: false,
Version: 1,
}
err := db.Create(task).Error
require.NoError(t, err)
req := &requests.CreateTaskCompletionRequest{
TaskID: task.ID,
Notes: "Done",
}
now := time.Now().UTC()
resp, err := service.CreateCompletion(req, user.ID, now)
require.NoError(t, err)
assert.NotZero(t, resp.Data.ID)
// Verify the task was updated: NextDueDate should be nil for a one-time task
var reloaded models.Task
db.First(&reloaded, task.ID)
assert.Nil(t, reloaded.NextDueDate, "One-time task NextDueDate should be nil after completion")
assert.False(t, reloaded.InProgress, "InProgress should be false after completion")
// Verify completion record exists
var completion models.TaskCompletion
err = db.Where("task_id = ?", task.ID).First(&completion).Error
require.NoError(t, err, "Completion record should exist")
assert.Equal(t, "Done", completion.Notes)
}
func TestTaskService_CreateCompletion_UpdateError_ReturnedNotSwallowed(t *testing.T) {
// Verifies P1-5 and P1-6: the completion creation and task update are wrapped
// in a transaction, and update errors are returned (not swallowed).
//
// Strategy: We trigger a version conflict by using a goroutine that bumps
// the task version after the service reads the task but during the transaction.
// Since SQLite serializes writes, we instead verify the behavior by deleting
// the task between the service read and the transactional update. When UpdateTx
// tries to match the row by id+version, 0 rows are affected and ErrVersionConflict
// is returned. The transaction then rolls back the completion insert.
//
// However, because the entire CreateCompletion flow is synchronous and we cannot
// inject failures between steps, we instead verify the transactional guarantee
// indirectly: we confirm that a concurrent version bump (set before the call
// but after the SELECT) causes the version conflict to propagate. Since FindByID
// re-reads the current version, we must verify via a custom test that invokes
// the transaction layer directly.
db := testutil.SetupTestDB(t)
testutil.SeedLookupData(t, db)
taskRepo := repositories.NewTaskRepository(db)
residenceRepo := repositories.NewResidenceRepository(db)
user := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "password")
residence := testutil.CreateTestResidence(t, db, user.ID, "Test House")
dueDate := time.Now().AddDate(0, 0, 7).UTC()
task := &models.Task{
ResidenceID: residence.ID,
CreatedByID: user.ID,
Title: "Conflict Task",
DueDate: &dueDate,
NextDueDate: &dueDate,
IsCancelled: false,
IsArchived: false,
Version: 1,
}
err := db.Create(task).Error
require.NoError(t, err)
// Directly test that the transactional path returns an error on version conflict:
// Use a stale task object (version=1) when the DB has been bumped to version=999.
db.Model(&models.Task{}).Where("id = ?", task.ID).Update("version", 999)
completion := &models.TaskCompletion{
TaskID: task.ID,
CompletedByID: user.ID,
CompletedAt: time.Now().UTC(),
Notes: "Should be rolled back",
}
// Simulate the transaction that CreateCompletion now uses (task still has version=1)
txErr := taskRepo.DB().Transaction(func(tx *gorm.DB) error {
if err := taskRepo.CreateCompletionTx(tx, completion); err != nil {
return err
}
// task.Version is 1 but DB has 999 -> version conflict
if err := taskRepo.UpdateTx(tx, task); err != nil {
return err
}
return nil
})
require.Error(t, txErr, "Transaction should fail due to version conflict")
assert.ErrorIs(t, txErr, repositories.ErrVersionConflict, "Error should be ErrVersionConflict")
// Verify the completion was rolled back
var count int64
db.Model(&models.TaskCompletion{}).Where("task_id = ?", task.ID).Count(&count)
assert.Equal(t, int64(0), count, "Completion should not exist when transaction rolls back")
// Also verify that CreateCompletion (full service method) would propagate the error.
// Re-create the task with a normal version so FindByID works, then bump it.
db.Model(&models.Task{}).Where("id = ?", task.ID).Update("version", 1)
service := NewTaskService(taskRepo, residenceRepo)
req := &requests.CreateTaskCompletionRequest{
TaskID: task.ID,
Notes: "Test error propagation",
}
now := time.Now().UTC()
// This call will succeed because FindByID loads version=1, UpdateTx uses version=1, DB has version=1.
// To verify error propagation, we use the direct transaction test above.
resp, err := service.CreateCompletion(req, user.ID, now)
require.NoError(t, err, "CreateCompletion should succeed with matching versions")
assert.NotZero(t, resp.Data.ID)
}
func TestTaskService_DeleteCompletion_OneTime_RestoresOriginalDueDate(t *testing.T) {
// Verifies P1-7: deleting the only completion on a one-time task
// should restore NextDueDate to the original DueDate.
db := testutil.SetupTestDB(t)
testutil.SeedLookupData(t, db)
taskRepo := repositories.NewTaskRepository(db)
residenceRepo := repositories.NewResidenceRepository(db)
service := NewTaskService(taskRepo, residenceRepo)
user := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "password")
residence := testutil.CreateTestResidence(t, db, user.ID, "Test House")
// Create a one-time task with a due date
originalDueDate := time.Date(2026, 4, 15, 0, 0, 0, 0, time.UTC)
task := &models.Task{
ResidenceID: residence.ID,
CreatedByID: user.ID,
Title: "One-time Task",
DueDate: &originalDueDate,
NextDueDate: &originalDueDate,
IsCancelled: false,
IsArchived: false,
Version: 1,
// No FrequencyID = one-time task
}
err := db.Create(task).Error
require.NoError(t, err)
// Complete the task (sets NextDueDate to nil for one-time tasks)
req := &requests.CreateTaskCompletionRequest{
TaskID: task.ID,
Notes: "Completed",
}
now := time.Now().UTC()
completionResp, err := service.CreateCompletion(req, user.ID, now)
require.NoError(t, err)
// Confirm NextDueDate is nil after completion
var taskAfterComplete models.Task
db.First(&taskAfterComplete, task.ID)
assert.Nil(t, taskAfterComplete.NextDueDate, "NextDueDate should be nil after one-time completion")
// Delete the completion
_, err = service.DeleteCompletion(completionResp.Data.ID, user.ID)
require.NoError(t, err)
// Verify NextDueDate is restored to the original DueDate
var taskAfterDelete models.Task
db.First(&taskAfterDelete, task.ID)
require.NotNil(t, taskAfterDelete.NextDueDate, "NextDueDate should be restored after deleting completion")
assert.Equal(t, originalDueDate.Year(), taskAfterDelete.NextDueDate.Year())
assert.Equal(t, originalDueDate.Month(), taskAfterDelete.NextDueDate.Month())
assert.Equal(t, originalDueDate.Day(), taskAfterDelete.NextDueDate.Day())
}
func TestTaskService_DeleteCompletion_Recurring_RecalculatesFromLastCompletion(t *testing.T) {
// Verifies P1-7: deleting the latest completion on a recurring task
// should recalculate NextDueDate from the remaining latest completion.
db := testutil.SetupTestDB(t)
testutil.SeedLookupData(t, db)
taskRepo := repositories.NewTaskRepository(db)
residenceRepo := repositories.NewResidenceRepository(db)
service := NewTaskService(taskRepo, residenceRepo)
user := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "password")
residence := testutil.CreateTestResidence(t, db, user.ID, "Test House")
var monthlyFrequency models.TaskFrequency
db.Where("name = ?", "Monthly").First(&monthlyFrequency)
// Create a recurring task
originalDueDate := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)
task := &models.Task{
ResidenceID: residence.ID,
CreatedByID: user.ID,
Title: "Recurring Task",
FrequencyID: &monthlyFrequency.ID,
DueDate: &originalDueDate,
NextDueDate: &originalDueDate,
IsCancelled: false,
IsArchived: false,
Version: 1,
}
err := db.Create(task).Error
require.NoError(t, err)
// First completion on Jan 15
firstCompletedAt := time.Date(2026, 1, 15, 10, 0, 0, 0, time.UTC)
firstReq := &requests.CreateTaskCompletionRequest{
TaskID: task.ID,
Notes: "First completion",
CompletedAt: &firstCompletedAt,
}
now := time.Now().UTC()
_, err = service.CreateCompletion(firstReq, user.ID, now)
require.NoError(t, err)
// Second completion on Feb 15
secondCompletedAt := time.Date(2026, 2, 15, 10, 0, 0, 0, time.UTC)
secondReq := &requests.CreateTaskCompletionRequest{
TaskID: task.ID,
Notes: "Second completion",
CompletedAt: &secondCompletedAt,
}
resp, err := service.CreateCompletion(secondReq, user.ID, now)
require.NoError(t, err)
// NextDueDate should be Feb 15 + 30 days = Mar 17
var taskAfterSecond models.Task
db.First(&taskAfterSecond, task.ID)
require.NotNil(t, taskAfterSecond.NextDueDate)
expectedAfterSecond := secondCompletedAt.AddDate(0, 0, 30)
assert.Equal(t, expectedAfterSecond.Year(), taskAfterSecond.NextDueDate.Year())
assert.Equal(t, expectedAfterSecond.Month(), taskAfterSecond.NextDueDate.Month())
assert.Equal(t, expectedAfterSecond.Day(), taskAfterSecond.NextDueDate.Day())
// Delete the second (latest) completion
_, err = service.DeleteCompletion(resp.Data.ID, user.ID)
require.NoError(t, err)
// NextDueDate should be recalculated from the first completion: Jan 15 + 30 = Feb 14
var taskAfterDelete models.Task
db.First(&taskAfterDelete, task.ID)
require.NotNil(t, taskAfterDelete.NextDueDate, "NextDueDate should be set after deleting latest completion")
expectedRecalculated := firstCompletedAt.AddDate(0, 0, 30)
assert.Equal(t, expectedRecalculated.Year(), taskAfterDelete.NextDueDate.Year())
assert.Equal(t, expectedRecalculated.Month(), taskAfterDelete.NextDueDate.Month())
assert.Equal(t, expectedRecalculated.Day(), taskAfterDelete.NextDueDate.Day())
}
func TestTaskService_DeleteCompletion_LastCompletion_RestoresDueDate(t *testing.T) {
// Verifies P1-7: deleting the only completion on a recurring task
// should restore NextDueDate to the original DueDate.
db := testutil.SetupTestDB(t)
testutil.SeedLookupData(t, db)
taskRepo := repositories.NewTaskRepository(db)
residenceRepo := repositories.NewResidenceRepository(db)
service := NewTaskService(taskRepo, residenceRepo)
user := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "password")
residence := testutil.CreateTestResidence(t, db, user.ID, "Test House")
var weeklyFrequency models.TaskFrequency
db.Where("name = ?", "Weekly").First(&weeklyFrequency)
// Create a recurring task
originalDueDate := time.Date(2026, 3, 1, 0, 0, 0, 0, time.UTC)
task := &models.Task{
ResidenceID: residence.ID,
CreatedByID: user.ID,
Title: "Weekly Task",
FrequencyID: &weeklyFrequency.ID,
DueDate: &originalDueDate,
NextDueDate: &originalDueDate,
IsCancelled: false,
IsArchived: false,
Version: 1,
}
err := db.Create(task).Error
require.NoError(t, err)
// Complete the task
completedAt := time.Date(2026, 3, 2, 10, 0, 0, 0, time.UTC)
req := &requests.CreateTaskCompletionRequest{
TaskID: task.ID,
Notes: "First completion",
CompletedAt: &completedAt,
}
now := time.Now().UTC()
completionResp, err := service.CreateCompletion(req, user.ID, now)
require.NoError(t, err)
// Verify NextDueDate was set to completedAt + 7 days
var taskAfterComplete models.Task
db.First(&taskAfterComplete, task.ID)
require.NotNil(t, taskAfterComplete.NextDueDate)
// Delete the only completion
_, err = service.DeleteCompletion(completionResp.Data.ID, user.ID)
require.NoError(t, err)
// NextDueDate should be restored to original DueDate since no completions remain
var taskAfterDelete models.Task
db.First(&taskAfterDelete, task.ID)
require.NotNil(t, taskAfterDelete.NextDueDate, "NextDueDate should be restored to original DueDate")
assert.Equal(t, originalDueDate.Year(), taskAfterDelete.NextDueDate.Year())
assert.Equal(t, originalDueDate.Month(), taskAfterDelete.NextDueDate.Month())
assert.Equal(t, originalDueDate.Day(), taskAfterDelete.NextDueDate.Day())
}
func TestTaskService_GetCategories(t *testing.T) {
db := testutil.SetupTestDB(t)
testutil.SeedLookupData(t, db)
taskRepo := repositories.NewTaskRepository(db)
residenceRepo := repositories.NewResidenceRepository(db)
service := NewTaskService(taskRepo, residenceRepo)
categories, err := service.GetCategories()
require.NoError(t, err)
assert.Greater(t, len(categories), 0)
// Check JSON structure
for _, cat := range categories {
assert.NotZero(t, cat.ID)
assert.NotEmpty(t, cat.Name)
}
}
func TestTaskService_GetPriorities(t *testing.T) {
db := testutil.SetupTestDB(t)
testutil.SeedLookupData(t, db)
taskRepo := repositories.NewTaskRepository(db)
residenceRepo := repositories.NewResidenceRepository(db)
service := NewTaskService(taskRepo, residenceRepo)
priorities, err := service.GetPriorities()
require.NoError(t, err)
assert.Greater(t, len(priorities), 0)
// Check order by level
for i := 1; i < len(priorities); i++ {
assert.GreaterOrEqual(t, priorities[i].Level, priorities[i-1].Level)
}
}
func TestTaskService_GetFrequencies(t *testing.T) {
db := testutil.SetupTestDB(t)
testutil.SeedLookupData(t, db)
taskRepo := repositories.NewTaskRepository(db)
residenceRepo := repositories.NewResidenceRepository(db)
service := NewTaskService(taskRepo, residenceRepo)
frequencies, err := service.GetFrequencies()
require.NoError(t, err)
assert.Greater(t, len(frequencies), 0)
}
func TestTaskService_SharedUserAccess(t *testing.T) {
db := testutil.SetupTestDB(t)
testutil.SeedLookupData(t, db)
taskRepo := repositories.NewTaskRepository(db)
residenceRepo := repositories.NewResidenceRepository(db)
service := NewTaskService(taskRepo, residenceRepo)
owner := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "password")
sharedUser := testutil.CreateTestUser(t, db, "shared", "shared@test.com", "password")
residence := testutil.CreateTestResidence(t, db, owner.ID, "Test House")
// Share residence
residenceRepo.AddUser(residence.ID, sharedUser.ID)
// Create task as owner
task := testutil.CreateTestTask(t, db, residence.ID, owner.ID, "Test Task")
// Shared user should be able to see the task
resp, err := service.GetTask(task.ID, sharedUser.ID)
require.NoError(t, err)
assert.Equal(t, task.ID, resp.ID)
// Shared user should be able to create tasks
req := &requests.CreateTaskRequest{
ResidenceID: residence.ID,
Title: "Shared User Task",
}
now := time.Now().UTC()
_, err = service.CreateTask(req, sharedUser.ID, now)
require.NoError(t, err)
}