1// SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
  2//
  3// SPDX-License-Identifier: AGPL-3.0-or-later
  4
  5package task_test
  6
  7import (
  8	"context"
  9	"errors"
 10	"testing"
 11	"time"
 12
 13	"git.secluded.site/np/internal/task"
 14	"git.secluded.site/np/internal/testutil"
 15)
 16
 17func TestStoreCreateAndList(t *testing.T) {
 18	ctx := context.Background()
 19	db := testutil.OpenDB(t)
 20
 21	clock := &testutil.SequenceClock{
 22		Times: []time.Time{
 23			time.Date(2025, time.January, 1, 9, 0, 0, 0, time.FixedZone("A", 3600)),
 24			time.Date(2025, time.January, 1, 9, 5, 0, 0, time.FixedZone("B", -3600)),
 25		},
 26	}
 27
 28	store := task.NewStore(db, clock)
 29	sid := "sid-create"
 30
 31	first, err := store.Create(ctx, sid, task.CreateParams{
 32		Title:       " First Task ",
 33		Description: "first description",
 34		CreatedSeq:  2,
 35	})
 36	if err != nil {
 37		t.Fatalf("Create first: %v", err)
 38	}
 39
 40	if first.Title != "First Task" {
 41		t.Fatalf("Title not trimmed: %q", first.Title)
 42	}
 43	if first.Status != task.StatusPending {
 44		t.Fatalf("Expected default status pending, got %s", first.Status)
 45	}
 46	if want := clock.Times[0].UTC(); !first.CreatedAt.Equal(want) || !first.UpdatedAt.Equal(want) {
 47		t.Fatalf("Expected timestamps %v, got created %v updated %v", want, first.CreatedAt, first.UpdatedAt)
 48	}
 49	if want := task.GenerateID(sid, "First Task", "first description"); first.ID != want {
 50		t.Fatalf("GenerateID mismatch: want %q got %q", want, first.ID)
 51	}
 52
 53	second, err := store.Create(ctx, sid, task.CreateParams{
 54		Title:       "Second Task",
 55		Description: "second description",
 56		CreatedSeq:  1,
 57		Status:      task.StatusInProgress,
 58	})
 59	if err != nil {
 60		t.Fatalf("Create second: %v", err)
 61	}
 62	if second.Status != task.StatusInProgress {
 63		t.Fatalf("Explicit status not applied")
 64	}
 65	if want := clock.Times[1].UTC(); !second.CreatedAt.Equal(want) {
 66		t.Fatalf("Second CreatedAt mismatch: want %v got %v", want, second.CreatedAt)
 67	}
 68
 69	all, err := store.List(ctx, sid)
 70	if err != nil {
 71		t.Fatalf("List: %v", err)
 72	}
 73	if len(all) != 2 {
 74		t.Fatalf("Expected 2 tasks, got %d", len(all))
 75	}
 76	if all[0].ID != second.ID || all[1].ID != first.ID {
 77		t.Fatalf("Tasks not sorted by CreatedSeq: %+v", all)
 78	}
 79
 80	pending, err := store.ListByStatus(ctx, sid, task.StatusPending)
 81	if err != nil {
 82		t.Fatalf("ListByStatus pending: %v", err)
 83	}
 84	if len(pending) != 1 || pending[0].ID != first.ID {
 85		t.Fatalf("Pending list mismatch: %+v", pending)
 86	}
 87
 88	byProgress, err := store.ListByStatus(ctx, sid, task.StatusInProgress)
 89	if err != nil {
 90		t.Fatalf("ListByStatus in_progress: %v", err)
 91	}
 92	if len(byProgress) != 1 || byProgress[0].ID != second.ID {
 93		t.Fatalf("In-progress list mismatch: %+v", byProgress)
 94	}
 95
 96	exists, err := store.Exists(ctx, sid, first.ID)
 97	if err != nil {
 98		t.Fatalf("Exists: %v", err)
 99	}
100	if !exists {
101		t.Fatalf("Expected first task to exist")
102	}
103	exists, err = store.Exists(ctx, sid, "missing")
104	if err != nil {
105		t.Fatalf("Exists missing: %v", err)
106	}
107	if exists {
108		t.Fatalf("Expected missing task to be false")
109	}
110}
111
112func TestStoreUpdateAndStatusIndex(t *testing.T) {
113	ctx := context.Background()
114	db := testutil.OpenDB(t)
115
116	clock := &testutil.SequenceClock{
117		Times: []time.Time{
118			time.Date(2025, time.February, 2, 11, 0, 0, 0, time.FixedZone("A", 7200)),
119			time.Date(2025, time.February, 2, 12, 0, 0, 0, time.FixedZone("B", -3600)),
120			time.Date(2025, time.February, 2, 13, 0, 0, 0, time.FixedZone("C", 5400)),
121		},
122	}
123
124	store := task.NewStore(db, clock)
125	sid := "sid-update"
126
127	created, err := store.Create(ctx, sid, task.CreateParams{
128		Title:       "Initial",
129		Description: "Old",
130		CreatedSeq:  1,
131	})
132	if err != nil {
133		t.Fatalf("Create: %v", err)
134	}
135
136	updated, err := store.Update(ctx, sid, created.ID, func(tk *task.Task) error {
137		tk.Title = " Updated Title "
138		tk.Description = "New"
139		return nil
140	})
141	if err != nil {
142		t.Fatalf("Update: %v", err)
143	}
144	if updated.Title != "Updated Title" {
145		t.Fatalf("Title not trimmed: %q", updated.Title)
146	}
147	if updated.Description != "New" {
148		t.Fatalf("Description not updated")
149	}
150	if !updated.UpdatedAt.Equal(clock.Times[1].UTC()) {
151		t.Fatalf("UpdatedAt mismatch: want %v got %v", clock.Times[1].UTC(), updated.UpdatedAt)
152	}
153
154	changed, err := store.UpdateStatus(ctx, sid, created.ID, task.StatusCompleted)
155	if err != nil {
156		t.Fatalf("UpdateStatus: %v", err)
157	}
158	if changed.Status != task.StatusCompleted {
159		t.Fatalf("Status not updated")
160	}
161	if !changed.UpdatedAt.Equal(clock.Times[2].UTC()) {
162		t.Fatalf("Status UpdatedAt mismatch: want %v got %v", clock.Times[2].UTC(), changed.UpdatedAt)
163	}
164
165	pending, err := store.ListByStatus(ctx, sid, task.StatusPending)
166	if err != nil {
167		t.Fatalf("ListByStatus pending: %v", err)
168	}
169	if len(pending) != 0 {
170		t.Fatalf("Pending should be empty: %+v", pending)
171	}
172
173	completed, err := store.ListByStatus(ctx, sid, task.StatusCompleted)
174	if err != nil {
175		t.Fatalf("ListByStatus completed: %v", err)
176	}
177	if len(completed) != 1 || completed[0].ID != created.ID {
178		t.Fatalf("Completed list mismatch: %+v", completed)
179	}
180}
181
182func TestStoreValidation(t *testing.T) {
183	ctx := context.Background()
184	db := testutil.OpenDB(t)
185	store := task.NewStore(db, &testutil.SequenceClock{
186		Times: []time.Time{time.Now(), time.Now()},
187	})
188
189	sid := "sid-validate"
190
191	created, err := store.Create(ctx, sid, task.CreateParams{
192		Title:       "Valid",
193		Description: "desc",
194		CreatedSeq:  1,
195	})
196	if err != nil {
197		t.Fatalf("Create: %v", err)
198	}
199
200	_, err = store.Update(ctx, sid, created.ID, func(tk *task.Task) error {
201		tk.Title = "   "
202		return nil
203	})
204	if !errors.Is(err, task.ErrEmptyTitle) {
205		t.Fatalf("Expected ErrEmptyTitle, got %v", err)
206	}
207
208	if _, err = store.UpdateStatus(ctx, sid, created.ID, task.Status("bogus")); !errors.Is(err, task.ErrInvalidStatus) {
209		t.Fatalf("Expected ErrInvalidStatus, got %v", err)
210	}
211}