tiki/store/memory_store_test.go
2026-04-16 15:35:28 -04:00

513 lines
15 KiB
Go

package store
import (
"strings"
"testing"
"time"
taskpkg "github.com/boolean-maybe/tiki/task"
)
func TestInMemoryStore_CreateTask(t *testing.T) {
tests := []struct {
name string
inputID string
expected string
}{
{"normalizes ID to uppercase", "tiki-abc123", "TIKI-ABC123"},
{"trims whitespace from ID", " TIKI-XYZ ", "TIKI-XYZ"},
{"already uppercase passthrough", "TIKI-DEF456", "TIKI-DEF456"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
s := NewInMemoryStore()
task := &taskpkg.Task{ID: tt.inputID, Title: "Test", Type: taskpkg.TypeStory, Status: taskpkg.DefaultStatus()}
err := s.CreateTask(task)
if err != nil {
t.Fatalf("CreateTask() error = %v", err)
}
if task.ID != tt.expected {
t.Errorf("task.ID = %q, want %q", task.ID, tt.expected)
}
if task.CreatedAt.IsZero() {
t.Error("expected non-zero CreatedAt")
}
if task.UpdatedAt.IsZero() {
t.Error("expected non-zero UpdatedAt")
}
got := s.GetTask(tt.expected)
if got == nil {
t.Errorf("GetTask(%q) returned nil after CreateTask", tt.expected)
}
})
}
}
func TestInMemoryStore_UpdateTask(t *testing.T) {
t.Run("error when task not found", func(t *testing.T) {
s := NewInMemoryStore()
task := &taskpkg.Task{ID: "TIKI-MISSING", Title: "Ghost"}
err := s.UpdateTask(task)
if err == nil {
t.Error("expected error for non-existent task, got nil")
}
})
t.Run("normalizes ID before update", func(t *testing.T) {
s := NewInMemoryStore()
if err := s.CreateTask(&taskpkg.Task{ID: "TIKI-ABC123", Title: "Original"}); err != nil {
t.Fatalf("CreateTask() error = %v", err)
}
updated := &taskpkg.Task{ID: "tiki-abc123", Title: "Updated"}
if err := s.UpdateTask(updated); err != nil {
t.Fatalf("UpdateTask() error = %v", err)
}
got := s.GetTask("TIKI-ABC123")
if got == nil || got.Title != "Updated" {
t.Errorf("expected title %q, got %v", "Updated", got)
}
})
t.Run("sets UpdatedAt after update", func(t *testing.T) {
s := NewInMemoryStore()
if err := s.CreateTask(&taskpkg.Task{ID: "TIKI-UPD001", Title: "Before"}); err != nil {
t.Fatalf("CreateTask() error = %v", err)
}
task := s.GetTask("TIKI-UPD001")
before := task.UpdatedAt
time.Sleep(time.Millisecond)
task.Title = "After"
if err := s.UpdateTask(task); err != nil {
t.Fatalf("UpdateTask() error = %v", err)
}
if !task.UpdatedAt.After(before) {
t.Errorf("expected UpdatedAt to advance after update")
}
})
t.Run("updates task fields roundtrip", func(t *testing.T) {
s := NewInMemoryStore()
if err := s.CreateTask(&taskpkg.Task{ID: "TIKI-RT0001", Title: "Old Title"}); err != nil {
t.Fatalf("CreateTask() error = %v", err)
}
got := s.GetTask("TIKI-RT0001")
got.Title = "New Title"
if err := s.UpdateTask(got); err != nil {
t.Fatalf("UpdateTask() error = %v", err)
}
reloaded := s.GetTask("TIKI-RT0001")
if reloaded.Title != "New Title" {
t.Errorf("title = %q, want %q", reloaded.Title, "New Title")
}
})
}
func TestInMemoryStore_DeleteTask(t *testing.T) {
t.Run("removes existing task", func(t *testing.T) {
s := NewInMemoryStore()
if err := s.CreateTask(&taskpkg.Task{ID: "TIKI-DEL001", Title: "To Delete"}); err != nil {
t.Fatalf("CreateTask() error = %v", err)
}
s.DeleteTask("TIKI-DEL001")
if s.GetTask("TIKI-DEL001") != nil {
t.Error("expected nil after delete, got task")
}
})
t.Run("no panic for non-existent ID", func(t *testing.T) {
s := NewInMemoryStore()
s.DeleteTask("TIKI-GHOST1") // should not panic
})
t.Run("normalizes ID for delete", func(t *testing.T) {
s := NewInMemoryStore()
if err := s.CreateTask(&taskpkg.Task{ID: "TIKI-LOWER1", Title: "Task"}); err != nil {
t.Fatalf("CreateTask() error = %v", err)
}
s.DeleteTask("tiki-lower1") // lowercase
if s.GetTask("TIKI-LOWER1") != nil {
t.Error("expected nil after delete with lowercase ID")
}
})
}
func TestInMemoryStore_AddComment(t *testing.T) {
t.Run("returns false for unknown task", func(t *testing.T) {
s := NewInMemoryStore()
ok := s.AddComment("TIKI-NOEXST", taskpkg.Comment{Text: "hello"})
if ok {
t.Error("expected false for unknown task, got true")
}
})
t.Run("returns true for known task", func(t *testing.T) {
s := NewInMemoryStore()
if err := s.CreateTask(&taskpkg.Task{ID: "TIKI-CMT001", Title: "Task"}); err != nil {
t.Fatalf("CreateTask() error = %v", err)
}
ok := s.AddComment("TIKI-CMT001", taskpkg.Comment{Text: "first comment"})
if !ok {
t.Error("expected true for known task")
}
})
t.Run("sets comment CreatedAt", func(t *testing.T) {
s := NewInMemoryStore()
if err := s.CreateTask(&taskpkg.Task{ID: "TIKI-CMT002", Title: "Task"}); err != nil {
t.Fatalf("CreateTask() error = %v", err)
}
comment := taskpkg.Comment{Text: "check timestamp"}
s.AddComment("TIKI-CMT002", comment)
got := s.GetTask("TIKI-CMT002")
if got.Comments[0].CreatedAt.IsZero() {
t.Error("expected non-zero CreatedAt on comment")
}
})
t.Run("appends to existing comments", func(t *testing.T) {
s := NewInMemoryStore()
if err := s.CreateTask(&taskpkg.Task{ID: "TIKI-CMT003", Title: "Task"}); err != nil {
t.Fatalf("CreateTask() error = %v", err)
}
s.AddComment("TIKI-CMT003", taskpkg.Comment{Text: "one"})
s.AddComment("TIKI-CMT003", taskpkg.Comment{Text: "two"})
got := s.GetTask("TIKI-CMT003")
if len(got.Comments) != 2 {
t.Errorf("expected 2 comments, got %d", len(got.Comments))
}
})
t.Run("normalizes task ID", func(t *testing.T) {
s := NewInMemoryStore()
if err := s.CreateTask(&taskpkg.Task{ID: "TIKI-CMT004", Title: "Task"}); err != nil {
t.Fatalf("CreateTask() error = %v", err)
}
ok := s.AddComment("tiki-cmt004", taskpkg.Comment{Text: "lowercase key"})
if !ok {
t.Error("expected true with lowercase task ID")
}
})
}
func TestInMemoryStore_Search(t *testing.T) {
buildStore := func(tb testing.TB) *InMemoryStore {
tb.Helper()
s := NewInMemoryStore()
for _, task := range []*taskpkg.Task{
{ID: "TIKI-S00001", Title: "Alpha feature", Description: "desc alpha", Tags: []string{"ui", "frontend"}},
{ID: "TIKI-S00002", Title: "Beta Bug", Description: "beta description", Tags: []string{"backend"}},
{ID: "TIKI-S00003", Title: "Gamma chore", Description: "third task"},
} {
if err := s.CreateTask(task); err != nil {
tb.Fatalf("CreateTask() error = %v", err)
}
}
return s
}
tests := []struct {
name string
query string
filterFunc func(*taskpkg.Task) bool
minResults int
maxResults int
}{
{"empty query + nil filter returns all", "", nil, 3, 3},
{"matches ID case-insensitive", "tiki-s00001", nil, 1, 1},
{"matches title case-insensitive", "alpha", nil, 1, 1},
{"matches description", "beta description", nil, 1, 1},
{"matches first tag", "ui", nil, 1, 1},
{"matches second tag", "backend", nil, 1, 1},
{"non-matching query returns empty", "zzz-no-match", nil, 0, 0},
{"filterFunc excludes tasks", "", func(t *taskpkg.Task) bool { return t.ID == "TIKI-S00001" }, 1, 1},
{"filterFunc + query intersection", "beta", func(t *taskpkg.Task) bool { return t.ID == "TIKI-S00002" }, 1, 1},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
s := buildStore(t)
results := s.Search(tt.query, tt.filterFunc)
if len(results) < tt.minResults || len(results) > tt.maxResults {
t.Errorf("Search(%q) returned %d results, want [%d, %d]", tt.query, len(results), tt.minResults, tt.maxResults)
}
})
}
}
func TestInMemoryStore_Listeners(t *testing.T) {
t.Run("called after CreateTask", func(t *testing.T) {
s := NewInMemoryStore()
called := 0
s.AddListener(func() { called++ })
if err := s.CreateTask(&taskpkg.Task{ID: "TIKI-LIS001"}); err != nil {
t.Fatalf("CreateTask() error = %v", err)
}
if called != 1 {
t.Errorf("listener called %d times, want 1", called)
}
})
t.Run("called after UpdateTask", func(t *testing.T) {
s := NewInMemoryStore()
if err := s.CreateTask(&taskpkg.Task{ID: "TIKI-LIS002", Title: "orig"}); err != nil {
t.Fatalf("CreateTask() error = %v", err)
}
called := 0
s.AddListener(func() { called++ })
task := s.GetTask("TIKI-LIS002")
if err := s.UpdateTask(task); err != nil {
t.Fatalf("UpdateTask() error = %v", err)
}
if called != 1 {
t.Errorf("listener called %d times, want 1", called)
}
})
t.Run("called after DeleteTask", func(t *testing.T) {
s := NewInMemoryStore()
if err := s.CreateTask(&taskpkg.Task{ID: "TIKI-LIS003"}); err != nil {
t.Fatalf("CreateTask() error = %v", err)
}
called := 0
s.AddListener(func() { called++ })
s.DeleteTask("TIKI-LIS003")
if called != 1 {
t.Errorf("listener called %d times, want 1", called)
}
})
t.Run("called after AddComment success", func(t *testing.T) {
s := NewInMemoryStore()
if err := s.CreateTask(&taskpkg.Task{ID: "TIKI-LIS004"}); err != nil {
t.Fatalf("CreateTask() error = %v", err)
}
called := 0
s.AddListener(func() { called++ })
s.AddComment("TIKI-LIS004", taskpkg.Comment{Text: "hi"})
if called != 1 {
t.Errorf("listener called %d times, want 1", called)
}
})
t.Run("not called after RemoveListener", func(t *testing.T) {
s := NewInMemoryStore()
called := 0
id := s.AddListener(func() { called++ })
s.RemoveListener(id)
if err := s.CreateTask(&taskpkg.Task{ID: "TIKI-LIS005"}); err != nil {
t.Fatalf("CreateTask() error = %v", err)
}
if called != 0 {
t.Errorf("removed listener called %d times, want 0", called)
}
})
t.Run("multiple listeners all notified", func(t *testing.T) {
s := NewInMemoryStore()
a, b := 0, 0
s.AddListener(func() { a++ })
s.AddListener(func() { b++ })
if err := s.CreateTask(&taskpkg.Task{ID: "TIKI-LIS006"}); err != nil {
t.Fatalf("CreateTask() error = %v", err)
}
if a != 1 || b != 1 {
t.Errorf("listeners called a=%d b=%d, want both 1", a, b)
}
})
}
func TestInMemoryStore_NewTaskTemplate(t *testing.T) {
s := NewInMemoryStore()
tmpl, err := s.NewTaskTemplate()
if err != nil {
t.Fatalf("NewTaskTemplate() error = %v", err)
}
if !strings.HasPrefix(tmpl.ID, "TIKI-") || len(tmpl.ID) != 11 {
t.Errorf("ID = %q, want TIKI-XXXXXX format (11 chars)", tmpl.ID)
}
if tmpl.ID != strings.ToUpper(tmpl.ID) {
t.Errorf("ID = %q, should be uppercased", tmpl.ID)
}
if tmpl.Priority != 3 {
t.Errorf("Priority = %d, want 3", tmpl.Priority)
}
if tmpl.Points != 1 {
t.Errorf("Points = %d, want 1", tmpl.Points)
}
if len(tmpl.Tags) != 1 || tmpl.Tags[0] != "idea" {
t.Errorf("Tags = %v, want [idea]", tmpl.Tags)
}
if tmpl.Status != taskpkg.DefaultStatus() {
t.Errorf("Status = %q, want %q", tmpl.Status, taskpkg.DefaultStatus())
}
if tmpl.Type != taskpkg.DefaultType() {
t.Errorf("Type = %q, want %q", tmpl.Type, taskpkg.DefaultType())
}
}
func TestInMemoryStore_NewTaskTemplateCollision(t *testing.T) {
s := NewInMemoryStore()
// pre-populate store with a task that will collide
_ = s.CreateTask(&taskpkg.Task{ID: "TIKI-AAAAAA", Title: "existing"})
callCount := 0
s.idGenerator = func() string {
callCount++
if callCount == 1 {
return "aaaaaa" // will collide (normalized to TIKI-AAAAAA)
}
return "bbbbbb" // will succeed
}
tmpl, err := s.NewTaskTemplate()
if err != nil {
t.Fatalf("NewTaskTemplate() error = %v", err)
}
if tmpl.ID != "TIKI-BBBBBB" {
t.Errorf("ID = %q, want TIKI-BBBBBB (should skip collision)", tmpl.ID)
}
if callCount != 2 {
t.Errorf("idGenerator called %d times, want 2 (one collision + one success)", callCount)
}
}
func TestInMemoryStore_NewTaskTemplateExhaustion(t *testing.T) {
s := NewInMemoryStore()
// pre-populate with the only ID the generator will ever produce
_ = s.CreateTask(&taskpkg.Task{ID: "TIKI-AAAAAA", Title: "existing"})
s.idGenerator = func() string { return "aaaaaa" }
_, err := s.NewTaskTemplate()
if err == nil {
t.Fatal("expected error for ID exhaustion, got nil")
}
if !strings.Contains(err.Error(), "failed to generate unique task ID") {
t.Errorf("unexpected error message: %v", err)
}
}
func TestInMemoryStore_GetAllTasks(t *testing.T) {
t.Run("empty store returns empty slice", func(t *testing.T) {
s := NewInMemoryStore()
tasks := s.GetAllTasks()
if len(tasks) != 0 {
t.Errorf("got %d tasks, want 0", len(tasks))
}
})
t.Run("3 tasks returns len 3", func(t *testing.T) {
s := NewInMemoryStore()
for _, id := range []string{"TIKI-ALL001", "TIKI-ALL002", "TIKI-ALL003"} {
if err := s.CreateTask(&taskpkg.Task{ID: id}); err != nil {
t.Fatalf("CreateTask(%s) error = %v", id, err)
}
}
tasks := s.GetAllTasks()
if len(tasks) != 3 {
t.Errorf("got %d tasks, want 3", len(tasks))
}
})
t.Run("returns same pointers, not copies", func(t *testing.T) {
s := NewInMemoryStore()
original := &taskpkg.Task{ID: "TIKI-PTR001", Title: "Pointer Task"}
if err := s.CreateTask(original); err != nil {
t.Fatalf("CreateTask() error = %v", err)
}
tasks := s.GetAllTasks()
if len(tasks) != 1 {
t.Fatalf("got %d tasks, want 1", len(tasks))
}
// mutate via pointer returned from GetAllTasks
tasks[0].Title = "Mutated"
reloaded := s.GetTask("TIKI-PTR001")
if reloaded.Title != "Mutated" {
t.Errorf("title = %q, want %q — GetAllTasks should return pointers to stored tasks", reloaded.Title, "Mutated")
}
})
}
func TestSearch_WithQueryAndFilter(t *testing.T) {
s := NewInMemoryStore()
if err := s.CreateTask(&taskpkg.Task{ID: "TIKI-SRC001", Title: "Bug in parser", Tags: []string{"backend"}}); err != nil {
t.Fatalf("failed to create task: %v", err)
}
if err := s.CreateTask(&taskpkg.Task{ID: "TIKI-SRC002", Title: "Bug in UI", Tags: []string{"frontend"}}); err != nil {
t.Fatalf("failed to create task: %v", err)
}
if err := s.CreateTask(&taskpkg.Task{ID: "TIKI-SRC003", Title: "Feature request", Tags: []string{"backend"}}); err != nil {
t.Fatalf("failed to create task: %v", err)
}
// query "Bug" + filter for backend tag
results := s.Search("Bug", func(t *taskpkg.Task) bool {
for _, tag := range t.Tags {
if tag == "backend" {
return true
}
}
return false
})
if len(results) != 1 {
t.Fatalf("expected 1 result (Bug + backend), got %d", len(results))
}
if results[0].Task.ID != "TIKI-SRC001" {
t.Errorf("expected TIKI-SRC001, got %s", results[0].Task.ID)
}
}
func TestSearch_FilterRejectsAll(t *testing.T) {
s := NewInMemoryStore()
if err := s.CreateTask(&taskpkg.Task{ID: "TIKI-REJ001", Title: "Task"}); err != nil {
t.Fatalf("failed to create task: %v", err)
}
results := s.Search("", func(t *taskpkg.Task) bool {
return false // reject all
})
if len(results) != 0 {
t.Fatalf("expected 0 results when filter rejects all, got %d", len(results))
}
}
func TestSearch_MatchesTags(t *testing.T) {
s := NewInMemoryStore()
if err := s.CreateTask(&taskpkg.Task{ID: "TIKI-TAG001", Title: "No match in title", Tags: []string{"backend"}}); err != nil {
t.Fatalf("failed to create task: %v", err)
}
results := s.Search("backend", nil)
if len(results) != 1 {
t.Fatalf("expected 1 result (tag match), got %d", len(results))
}
}
func TestNewTaskTemplate_IDCollision(t *testing.T) {
s := NewInMemoryStore()
// pre-populate so the generated ID always collides
if err := s.CreateTask(&taskpkg.Task{ID: "TIKI-FIXED", Title: "blocker"}); err != nil {
t.Fatalf("failed to create task: %v", err)
}
// set idGenerator to always return the same ID
s.idGenerator = func() string { return "FIXED" }
_, err := s.NewTaskTemplate()
if err == nil {
t.Fatal("expected error for ID exhaustion")
}
}