Files
pitodo/internal/todos/store_test.go
T
2026-11-04 17:18:09 +01:00

159 lines
5.3 KiB
Go

package todos
import (
"errors"
"os"
"path/filepath"
"strings"
"testing"
)
const fixture = `{
"id": "deadbeef",
"title": "A brace } and quote \"",
"tags": ["qa", "pi"],
"status": "in_progress",
"created_at": "2026-01-25T17:00:00.000Z",
"assigned_to_session": "session.json"
}
## Details
Keep {braces} in the body.
`
func TestActualPiExtensionFixtureRoundTrip(t *testing.T) {
// Captured from a todo created by Pi's todo extension (not hand-written).
data, err := os.ReadFile("testdata/pi-created.md")
if err != nil {
t.Fatal(err)
}
parsed, err := Parse("1ddf1df4", data)
if err != nil || parsed.Title != "TodoApp compatibility probe" || parsed.Status != "open" || len(parsed.Tags) != 2 || parsed.Body != "# Compatibility\n\nCreated by the Pi todo extension for TodoApp tests.\n" {
t.Fatalf("could not read Pi fixture: %+v, %v", parsed, err)
}
dir := t.TempDir()
path := filepath.Join(dir, parsed.ID+".md")
if err := os.WriteFile(path, data, 0600); err != nil {
t.Fatal(err)
}
parsed.Title = "Edited in TodoApp"
if err := Update(dir, parsed); err != nil {
t.Fatal(err)
}
updated, err := Read(dir, parsed.ID)
if err != nil || updated.Title != "Edited in TodoApp" || updated.CreatedAt != parsed.CreatedAt || updated.Body != parsed.Body {
t.Fatalf("Pi fixture round-trip failed: %+v, %v", updated, err)
}
}
func TestParseAndSerializePiFixture(t *testing.T) {
todo, err := Parse("deadbeef", []byte(fixture))
if err != nil {
t.Fatal(err)
}
if todo.ID != "deadbeef" || todo.Title != `A brace } and quote "` || todo.Status != "in_progress" || todo.AssignedToSession != "session.json" || !strings.Contains(todo.Body, "{braces}") {
t.Fatalf("unexpected parsed todo: %+v", todo)
}
data, err := Serialize(todo)
if err != nil {
t.Fatal(err)
}
again, err := Parse(todo.ID, data)
if err != nil || again.Title != todo.Title || again.Status != todo.Status || again.Body != todo.Body {
t.Fatalf("round trip mismatch: %+v, %v", again, err)
}
}
func TestCreateUpdateAndList(t *testing.T) {
dir := filepath.Join(t.TempDir(), ".pi", "todos")
if items, err := List(dir); err != nil || len(items) != 0 {
t.Fatalf("missing directory should be empty: %v, %v", items, err)
}
created, err := Create(dir, Todo{Title: "First", Body: "hello\n"})
if err != nil || !idPattern.MatchString(created.ID) || created.Status != "open" || created.CreatedAt == "" {
t.Fatalf("unexpected create: %+v, %v", created, err)
}
loaded, err := Read(dir, created.ID)
if err != nil || loaded.Title != "First" || loaded.Body != "hello\n" {
t.Fatalf("unexpected read: %+v, %v", loaded, err)
}
loaded.Title = "Changed"
loaded.Status = "blocked" // Pi supports statuses besides open/closed
loaded.Tags = []string{"test"}
loaded.AssignedToSession = "session.json"
if err := Update(dir, loaded); err != nil {
t.Fatal(err)
}
items, err := List(dir)
if err != nil || len(items) != 1 || items[0].Title != "Changed" || items[0].Status != "blocked" || items[0].CreatedAt != created.CreatedAt || items[0].AssignedToSession != "" {
t.Fatalf("unexpected update: %+v, %v", items, err)
}
if _, err := os.Stat(filepath.Join(dir, created.ID+".lock")); !errors.Is(err, os.ErrNotExist) {
t.Fatalf("lock left behind: %v", err)
}
}
func TestLockedFileUnchanged(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "deadbeef.md")
if err := os.WriteFile(path, []byte(fixture), 0600); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(dir, "deadbeef.lock"), []byte(`{"session":"other"}`), 0600); err != nil {
t.Fatal(err)
}
err := Update(dir, Todo{ID: "deadbeef", Title: "Changed"})
if !errors.Is(err, ErrLocked) {
t.Fatalf("expected lock error: %v", err)
}
content, _ := os.ReadFile(path)
if string(content) != fixture {
t.Fatal("locked file was changed")
}
}
func TestUpdatePreservesAssignment(t *testing.T) {
dir := t.TempDir()
if err := os.WriteFile(filepath.Join(dir, "deadbeef.md"), []byte(fixture), 0600); err != nil {
t.Fatal(err)
}
if err := Update(dir, Todo{ID: "deadbeef", Title: "Edited", Body: "text"}); err != nil {
t.Fatal(err)
}
result, err := Read(dir, "deadbeef")
if err != nil || result.AssignedToSession != "session.json" || result.CreatedAt != "2026-01-25T17:00:00.000Z" || result.Status != "in_progress" || len(result.Tags) != 2 {
t.Fatalf("metadata lost: %+v, %v", result, err)
}
}
func TestSerializePreservesBodyIndentation(t *testing.T) {
data, err := Serialize(Todo{ID: "deadbeef", Body: "\n indented text\n"})
if err != nil || !strings.HasSuffix(string(data), "\n\n indented text\n") {
t.Fatalf("indentation lost: %s, %v", data, err)
}
}
func TestMalformedAndUnsafeIDs(t *testing.T) {
if _, err := Parse("../bad", []byte(fixture)); err == nil {
t.Fatal("unsafe ID accepted")
}
if _, err := Parse("deadbeef", []byte(`{bad`)); err == nil {
t.Fatal("bad JSON accepted")
}
if _, err := Parse("deadbeef", []byte(`{"id":"cafebabe"}`)); err == nil {
t.Fatal("mismatched ID accepted")
}
dir := t.TempDir()
if err := os.WriteFile(filepath.Join(dir, "deadbeef.md"), []byte(`{bad`), 0600); err != nil {
t.Fatal(err)
}
if _, err := List(dir); err == nil {
t.Fatal("malformed file silently ignored")
}
items, warnings, err := Scan(dir)
if err != nil || len(items) != 0 || len(warnings) != 1 {
t.Fatalf("scan should report malformed file: %v, %v, %v", items, warnings, err)
}
}