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>
851 lines
31 KiB
Go
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)
|
|
}
|