package storage import ( "path/filepath" "testing" "time" "github.com/sandrodach/mindsift/internal/domain" ) func TestNextDueDate(t *testing.T) { today := time.Date(2026, 10, 29, 15, 0, 0, 0, time.UTC) cases := []struct{ due, rule, want string }{ {"2026-10-30", "daily", "2026-10-31"}, {"2026-10-30", "weekly", "2026-11-06"}, {"2026-01-31", "monthly", "2026-02-28"}, {"2026-03-15", "monthly", "2026-04-15"}, {"2028-02-29", "yearly", "2029-02-28"}, {"", "weekly", "2026-11-05"}, {"2026-01-31T07:15", "monthly", "2026-02-28T07:15"}, {"2026-10-30T23:30", "daily", "2026-10-31T23:30"}, } for _, c := range cases { if got := NextDueDate(c.due, c.rule, today); got != c.want { t.Errorf("NextDueDate(%q, %s) = %s, want %s", c.due, c.rule, got, c.want) } } } func TestRepeatingTaskSpawnsItsSuccessorOnce(t *testing.T) { store, err := NewStore(filepath.Join(t.TempDir(), "recur.db")) if err != nil { t.Fatalf("NewStore: %v", err) } defer store.Close() openSharing(t, store) anna, _ := store.CreateUser("anna@example.com", "hash", "Anna", "member") ben, _ := store.CreateUser("ben@example.com", "hash", "Ben", "member") tl, _ := store.CreateTaskList(anna.ID, "", "", "Haushalt", "", "") _, _ = store.AddTaskListShare(tl.ID, "user", ben.ID, "editor", anna.ID) var pushed []*domain.Thought store.OnTaskCreated = func(th *domain.Thought) { pushed = append(pushed, th) } task, _ := store.CreateThoughtWithDetails(anna.ID, "Müll rausbringen #haushalt", "high", "manual", "2026-11-02", tl.ID, "todo", "Gelbe Tonne") _, _ = store.UpdateThoughtTriage(anna.ID, task.ID, "keep", "task", "", "2026-11-02", tl.ID) subs := []domain.SubTask{{ID: "s1", Title: "Tüten", IsCompleted: true}} _, _ = store.UpdateThoughtDetails(anna.ID, task.ID, nil, nil, nil, nil, nil, nil, nil, &subs, nil) _, _ = store.SetTaskAssignees(anna.ID, task.ID, []string{ben.ID}) if err := store.SetTaskRecurrence(anna.ID, task.ID, "weekly"); err != nil { t.Fatalf("SetTaskRecurrence: %v", err) } if err := store.SetTaskRecurrence(anna.ID, task.ID, "stündlich"); err == nil { t.Errorf("unknown rule must be rejected") } done := true if _, err := store.UpdateThoughtDetails(ben.ID, task.ID, nil, &done, nil, nil, nil, nil, nil, nil, nil); err != nil { t.Fatalf("complete: %v", err) } if len(pushed) != 1 { t.Fatalf("expected one successor pushed live, got %d", len(pushed)) } next := pushed[0] if next.Content != "Müll rausbringen #haushalt" || next.Priority != "high" || next.Notes != "Gelbe Tonne" || next.DueDate != "2026-11-09" || next.Recurrence != "weekly" || next.IsCompleted || next.TaskListID != tl.ID || next.KanbanStatus != "todo" || len(next.Subtasks) != 1 || next.Subtasks[0].IsCompleted || len(next.Assignees) != 1 || next.Assignees[0] != ben.ID || next.UserID != anna.ID { t.Errorf("successor wrong: %+v", next) } if orig, _ := store.GetThoughtByID(anna.ID, task.ID); !orig.IsCompleted { t.Errorf("the completed task stays completed for the history") } // Re-opening and completing again creates no duplicate open := false _, _ = store.UpdateThoughtDetails(anna.ID, task.ID, nil, &open, nil, nil, nil, nil, nil, nil, nil) _, _ = store.UpdateThoughtDetails(anna.ID, task.ID, nil, &done, nil, nil, nil, nil, nil, nil, nil) if len(pushed) != 1 { t.Fatalf("re-completing must not spawn again, got %d successors", len(pushed)) } // Dragging the successor into "done" on the Kanban board repeats as well status := "done" if err := store.ReorderTasks(tl.ID, []domain.ThoughtReorderItem{{ID: next.ID, SortOrder: 0, KanbanStatus: &status}}); err != nil { t.Fatalf("ReorderTasks: %v", err) } if len(pushed) != 2 || pushed[1].DueDate != "2026-11-16" { t.Fatalf("Kanban completion should spawn the next week, got %+v", pushed) } // A task without rule never repeats plain, _ := store.CreateThoughtWithDetails(anna.ID, "Einmalig", "normal", "manual", "", tl.ID, "todo", "") _, _ = store.UpdateThoughtTriage(anna.ID, plain.ID, "keep", "task", "", "", tl.ID) _, _ = store.UpdateThoughtDetails(anna.ID, plain.ID, nil, &done, nil, nil, nil, nil, nil, nil, nil) if len(pushed) != 2 { t.Errorf("non-repeating task must not spawn") } } func TestAlarmResetsOnNewDueDateOrReopen(t *testing.T) { store, err := NewStore(filepath.Join(t.TempDir(), "alarm.db")) if err != nil { t.Fatalf("NewStore: %v", err) } defer store.Close() u, _ := store.CreateUser("anna@example.com", "hash", "Anna", "member") task, _ := store.CreateThoughtWithDetails(u.ID, "Zahnarzt", "normal", "manual", "2026-10-30 14:30", "", "todo", "") _, _ = store.UpdateThoughtTriage(u.ID, task.ID, "keep", "task", "", "", "") sent := func() bool { th, _ := store.GetThoughtByID(u.ID, task.ID) return th.AlarmSent } if th, _ := store.GetThoughtByID(u.ID, task.ID); th.DueDate != "2026-10-30T14:30" { t.Fatalf("due date not normalized: %q", th.DueDate) } _ = store.MarkTaskAlarmSent(task.ID) same := "2026-10-30T14:30" _, _ = store.UpdateThoughtDetails(u.ID, task.ID, nil, nil, &same, nil, nil, nil, nil, nil, nil) if !sent() { t.Errorf("unchanged due date must not re-arm the alarm") } later := "2026-10-31T08:00" _, _ = store.UpdateThoughtDetails(u.ID, task.ID, nil, nil, &later, nil, nil, nil, nil, nil, nil) if sent() { t.Errorf("new due date must re-arm the alarm") } _ = store.MarkTaskAlarmSent(task.ID) done, open := true, false _, _ = store.UpdateThoughtDetails(u.ID, task.ID, nil, &done, nil, nil, nil, nil, nil, nil, nil) if !sent() { t.Errorf("completing keeps the flag") } _, _ = store.UpdateThoughtDetails(u.ID, task.ID, nil, &open, nil, nil, nil, nil, nil, nil, nil) if sent() { t.Errorf("reopening must re-arm the alarm") } }