package api import ( "net/http" "testing" "time" "gardomatic.kleiax.de/internal/storage" ) type taskTestModel struct { items map[int]storage.Task nextID int } func (m *taskTestModel) Insert(value storage.Task) (storage.Task, error) { if value.RepeatFromID != nil { for _, existing := range m.items { if existing.RepeatFromID != nil && *existing.RepeatFromID == *value.RepeatFromID { return storage.Task{}, storage.ErrConflict } } } if value.TemplateID != nil && value.PlantID != nil && value.GeneratedFor != nil { for _, existing := range m.items { if existing.TemplateID != nil && *existing.TemplateID == *value.TemplateID && existing.PlantID != nil && *existing.PlantID == *value.PlantID && existing.GeneratedFor != nil && existing.GeneratedFor.Format("2006-01-02") == value.GeneratedFor.Format("2006-01-02") { return storage.Task{}, storage.ErrConflict } } } m.nextID++ value.ID, value.Version = m.nextID, 1 m.items[value.ID] = value return value, nil } func TestCompletingRecurringTaskCreatesNextCalendarOccurrence(t *testing.T) { app, _, members := newGardenTestApplication() user := storage.User{ID: 12, Activated: true} members.members[[2]int{3, user.ID}] = storage.GardenMember{GardenID: 3, UserID: user.ID, Role: storage.GardenRoleMember} dueStart := time.Date(2026, time.January, 31, 8, 0, 0, 0, time.UTC) dueEnd := time.Date(2026, time.January, 31, 18, 0, 0, 0, time.UTC) app.models.Tasks = &taskTestModel{items: map[int]storage.Task{101: { ID: 101, GardenID: 3, Title: "Düngen", DueAtStart: &dueStart, DueAtEnd: &dueEnd, Recurrence: storage.TaskRecurrenceMonthly, RecurrenceInterval: 2, Active: true, CreatedBy: user.ID, Version: 1, }}, nextID: 101} completed := serveResourceRequest(app, user, http.MethodPatch, "/v1/gardens/3/tasks/101", []byte(`{"completed":true}`)) if completed.Code != http.StatusOK { t.Fatalf("complete: got %d; %s", completed.Code, completed.Body.String()) } next := app.models.Tasks.(*taskTestModel).items[102] if next.RepeatFromID == nil || *next.RepeatFromID != 101 || next.CompletedAt != nil { t.Fatalf("next occurrence links: %+v", next) } if got := next.DueAtStart.Format(time.RFC3339); got != "2026-03-31T08:00:00Z" { t.Errorf("next start = %s", got) } if got := next.DueAtEnd.Format(time.RFC3339); got != "2026-03-31T18:00:00Z" { t.Errorf("next end = %s", got) } completedAgain := serveResourceRequest(app, user, http.MethodPatch, "/v1/gardens/3/tasks/101", []byte(`{"completed":true}`)) if completedAgain.Code != http.StatusOK || len(app.models.Tasks.(*taskTestModel).items) != 2 { t.Fatalf("repeated completion created a duplicate: status=%d tasks=%+v", completedAgain.Code, app.models.Tasks.(*taskTestModel).items) } } func (m *taskTestModel) Get(gardenID, id int) (storage.Task, error) { value, ok := m.items[id] if !ok || value.GardenID != gardenID { return storage.Task{}, storage.ErrRecordNotFound } return value, nil } func (m *taskTestModel) GetAllForGarden(gardenID int) ([]storage.Task, error) { result := []storage.Task{} for _, value := range m.items { if value.GardenID == gardenID { result = append(result, value) } } return result, nil } func (m *taskTestModel) Update(gardenID int, value storage.Task) (storage.Task, error) { if _, err := m.Get(gardenID, value.ID); err != nil { return storage.Task{}, err } value.Version++ m.items[value.ID] = value return value, nil } func (m *taskTestModel) Delete(gardenID, id int) error { if _, err := m.Get(gardenID, id); err != nil { return err } delete(m.items, id) return nil } func TestTasksAreScopedAndValidateReferences(t *testing.T) { app, _, members := newGardenTestApplication() user := storage.User{ID: 12, Activated: true} members.members[[2]int{3, user.ID}] = storage.GardenMember{GardenID: 3, UserID: user.ID, Role: storage.GardenRoleMember} app.models.Tasks = &taskTestModel{items: map[int]storage.Task{90: {ID: 90, GardenID: 4, Title: "Fremd", Version: 1}}, nextID: 100} app.models.Plants = &plantTestModel{items: map[int]storage.Plant{5: {ID: 5, GardenID: 3, Name: "Tomate"}, 9: {ID: 9, GardenID: 4, Name: "Fremd"}}} app.models.Locations = &locationTestModel{items: map[int]storage.Location{6: {ID: 6, GardenID: 3, Name: "Beet"}, 8: {ID: 8, GardenID: 4, Name: "Fremd"}}} foreignReference := serveResourceRequest(app, user, http.MethodPost, "/v1/gardens/3/tasks", []byte(`{"title":"Gießen","plant_id":9,"location_id":8}`)) if foreignReference.Code != http.StatusUnprocessableEntity { t.Fatalf("foreign references: got %d; %s", foreignReference.Code, foreignReference.Body.String()) } created := serveResourceRequest(app, user, http.MethodPost, "/v1/gardens/3/tasks", []byte(`{"title":"Gießen","plant_id":5,"location_id":6,"priority":5}`)) if created.Code != http.StatusCreated { t.Fatalf("create: got %d; %s", created.Code, created.Body.String()) } value := app.models.Tasks.(*taskTestModel).items[101] if value.CreatedBy != user.ID || value.GardenID != 3 { t.Errorf("created task scope: %+v", value) } completed := serveResourceRequest(app, user, http.MethodPatch, "/v1/gardens/3/tasks/101", []byte(`{"completed":true}`)) if completed.Code != http.StatusOK || app.models.Tasks.(*taskTestModel).items[101].CompletedAt == nil { t.Fatalf("complete: got %d; %s", completed.Code, completed.Body.String()) } cleared := serveResourceRequest(app, user, http.MethodPatch, "/v1/gardens/3/tasks/101", []byte(`{"clear_plant_id":true,"clear_location_id":true}`)) if cleared.Code != http.StatusOK || app.models.Tasks.(*taskTestModel).items[101].PlantID != nil || app.models.Tasks.(*taskTestModel).items[101].LocationID != nil { t.Fatalf("clear references: got %d; %s", cleared.Code, cleared.Body.String()) } foreignRead := serveResourceRequest(app, user, http.MethodGet, "/v1/gardens/3/tasks/90", nil) if foreignRead.Code != http.StatusNotFound { t.Fatalf("foreign read: got %d", foreignRead.Code) } }