summaryrefslogtreecommitdiff
path: root/internal/storage
diff options
context:
space:
mode:
Diffstat (limited to 'internal/storage')
-rw-r--r--internal/storage/db.go30
-rw-r--r--internal/storage/epic.go88
-rw-r--r--internal/storage/epic_test.go117
-rw-r--r--internal/storage/story.go160
-rw-r--r--internal/storage/story_test.go211
5 files changed, 606 insertions, 0 deletions
diff --git a/internal/storage/db.go b/internal/storage/db.go
index d571c2e..8b563a6 100644
--- a/internal/storage/db.go
+++ b/internal/storage/db.go
@@ -154,6 +154,36 @@ func (s *DB) migrate() error {
UNIQUE(role, version)
)`,
`CREATE INDEX IF NOT EXISTS idx_role_configs_role_status ON role_configs(role, status)`,
+ `CREATE TABLE IF NOT EXISTS epics (
+ id TEXT PRIMARY KEY,
+ name TEXT NOT NULL,
+ description TEXT,
+ status TEXT NOT NULL DEFAULT 'OPEN',
+ discovery_source TEXT,
+ created_at DATETIME NOT NULL,
+ updated_at DATETIME NOT NULL
+ )`,
+ `CREATE TABLE IF NOT EXISTS stories (
+ id TEXT PRIMARY KEY,
+ epic_id TEXT,
+ project_id TEXT,
+ name TEXT NOT NULL,
+ branch_name TEXT,
+ status TEXT NOT NULL,
+ discovery_source TEXT,
+ spec TEXT,
+ acceptance_criteria_json TEXT NOT NULL DEFAULT '[]',
+ validation_json TEXT,
+ deploy_config TEXT,
+ priority TEXT,
+ root_task_id TEXT,
+ created_at DATETIME NOT NULL,
+ updated_at DATETIME NOT NULL
+ )`,
+ `CREATE INDEX IF NOT EXISTS idx_epics_status ON epics(status)`,
+ `CREATE INDEX IF NOT EXISTS idx_stories_status ON stories(status)`,
+ `CREATE INDEX IF NOT EXISTS idx_stories_epic_id ON stories(epic_id)`,
+ `CREATE INDEX IF NOT EXISTS idx_stories_root_task_id ON stories(root_task_id)`,
}
for _, m := range migrations {
if _, err := s.db.Exec(m); err != nil {
diff --git a/internal/storage/epic.go b/internal/storage/epic.go
new file mode 100644
index 0000000..9f3863c
--- /dev/null
+++ b/internal/storage/epic.go
@@ -0,0 +1,88 @@
+package storage
+
+import (
+ "database/sql"
+ "time"
+
+ "github.com/thepeterstone/claudomator/internal/story"
+)
+
+// CreateEpic inserts a new epic. Defaults Status to "OPEN" if unset.
+func (s *DB) CreateEpic(e *story.Epic) error {
+ if e.Status == "" {
+ e.Status = "OPEN"
+ }
+ now := time.Now().UTC()
+ e.CreatedAt = now
+ e.UpdatedAt = now
+ _, err := s.db.Exec(
+ `INSERT INTO epics (id, name, description, status, discovery_source, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?)`,
+ e.ID, e.Name, e.Description, e.Status, e.DiscoverySource, now, now,
+ )
+ return err
+}
+
+// GetEpic retrieves an epic by ID.
+func (s *DB) GetEpic(id string) (*story.Epic, error) {
+ row := s.db.QueryRow(`SELECT id, name, description, status, discovery_source, created_at, updated_at FROM epics WHERE id = ?`, id)
+ return scanEpic(row)
+}
+
+// ListEpics returns epics, optionally filtered by status. Pass an empty
+// string to list all epics.
+func (s *DB) ListEpics(status string) ([]*story.Epic, error) {
+ query := `SELECT id, name, description, status, discovery_source, created_at, updated_at FROM epics WHERE 1=1`
+ var args []interface{}
+ if status != "" {
+ query += " AND status = ?"
+ args = append(args, status)
+ }
+ query += " ORDER BY created_at DESC"
+
+ rows, err := s.db.Query(query, args...)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+
+ var epics []*story.Epic
+ for rows.Next() {
+ e, err := scanEpicRows(rows)
+ if err != nil {
+ return nil, err
+ }
+ epics = append(epics, e)
+ }
+ return epics, rows.Err()
+}
+
+// UpdateEpic updates an existing epic's name/description/status/discovery_source.
+// Mirrors UpdateProject's shape: no existence check, no status-machine validation.
+func (s *DB) UpdateEpic(e *story.Epic) error {
+ now := time.Now().UTC()
+ _, err := s.db.Exec(
+ `UPDATE epics SET name = ?, description = ?, status = ?, discovery_source = ?, updated_at = ? WHERE id = ?`,
+ e.Name, e.Description, e.Status, e.DiscoverySource, now, e.ID,
+ )
+ if err != nil {
+ return err
+ }
+ e.UpdatedAt = now
+ return nil
+}
+
+func scanEpic(row scanner) (*story.Epic, error) {
+ var e story.Epic
+ var description sql.NullString
+ var discoverySource sql.NullString
+ if err := row.Scan(&e.ID, &e.Name, &description, &e.Status, &discoverySource, &e.CreatedAt, &e.UpdatedAt); err != nil {
+ return nil, err
+ }
+ e.Description = description.String
+ e.DiscoverySource = discoverySource.String
+ return &e, nil
+}
+
+func scanEpicRows(rows *sql.Rows) (*story.Epic, error) {
+ return scanEpic(rows)
+}
diff --git a/internal/storage/epic_test.go b/internal/storage/epic_test.go
new file mode 100644
index 0000000..dfd5be1
--- /dev/null
+++ b/internal/storage/epic_test.go
@@ -0,0 +1,117 @@
+package storage
+
+import (
+ "testing"
+
+ "github.com/thepeterstone/claudomator/internal/story"
+)
+
+func TestCreateEpic_AndGetEpic(t *testing.T) {
+ db := testDB(t)
+
+ e := &story.Epic{
+ ID: "epic-1",
+ Name: "Bigger Auth Rework",
+ Description: "Overhaul the auth flow.",
+ DiscoverySource: "human",
+ }
+ if err := db.CreateEpic(e); err != nil {
+ t.Fatalf("CreateEpic: %v", err)
+ }
+ // CreateEpic defaults Status to OPEN when unset.
+ if e.Status != "OPEN" {
+ t.Errorf("Status after create: want OPEN, got %q", e.Status)
+ }
+
+ got, err := db.GetEpic("epic-1")
+ if err != nil {
+ t.Fatalf("GetEpic: %v", err)
+ }
+ if got.Name != "Bigger Auth Rework" {
+ t.Errorf("Name: want %q, got %q", "Bigger Auth Rework", got.Name)
+ }
+ if got.Description != "Overhaul the auth flow." {
+ t.Errorf("Description: want %q, got %q", "Overhaul the auth flow.", got.Description)
+ }
+ if got.Status != "OPEN" {
+ t.Errorf("Status: want OPEN, got %q", got.Status)
+ }
+ if got.DiscoverySource != "human" {
+ t.Errorf("DiscoverySource: want human, got %q", got.DiscoverySource)
+ }
+ if got.CreatedAt.IsZero() || got.UpdatedAt.IsZero() {
+ t.Errorf("expected CreatedAt/UpdatedAt to be set, got %+v", got)
+ }
+}
+
+func TestGetEpic_NotFound(t *testing.T) {
+ db := testDB(t)
+ if _, err := db.GetEpic("nope"); err == nil {
+ t.Fatal("expected error for missing epic, got nil")
+ }
+}
+
+func TestListEpics(t *testing.T) {
+ db := testDB(t)
+
+ for _, e := range []*story.Epic{
+ {ID: "e1", Name: "alpha", Status: "OPEN"},
+ {ID: "e2", Name: "beta", Status: "CLOSED"},
+ {ID: "e3", Name: "gamma", Status: "OPEN"},
+ } {
+ if err := db.CreateEpic(e); err != nil {
+ t.Fatalf("CreateEpic(%s): %v", e.ID, err)
+ }
+ }
+
+ all, err := db.ListEpics("")
+ if err != nil {
+ t.Fatalf("ListEpics: %v", err)
+ }
+ if len(all) != 3 {
+ t.Errorf("want 3 epics, got %d", len(all))
+ }
+
+ open, err := db.ListEpics("OPEN")
+ if err != nil {
+ t.Fatalf("ListEpics(OPEN): %v", err)
+ }
+ if len(open) != 2 {
+ t.Errorf("want 2 OPEN epics, got %d", len(open))
+ }
+ for _, e := range open {
+ if e.Status != "OPEN" {
+ t.Errorf("expected only OPEN epics, got %q", e.Status)
+ }
+ }
+}
+
+func TestUpdateEpic(t *testing.T) {
+ db := testDB(t)
+
+ e := &story.Epic{ID: "e1", Name: "original", Status: "OPEN"}
+ if err := db.CreateEpic(e); err != nil {
+ t.Fatalf("CreateEpic: %v", err)
+ }
+ e.Name = "updated"
+ e.Description = "now with a description"
+ e.Status = "CLOSED"
+ if err := db.UpdateEpic(e); err != nil {
+ t.Fatalf("UpdateEpic: %v", err)
+ }
+ got, err := db.GetEpic("e1")
+ if err != nil {
+ t.Fatalf("GetEpic: %v", err)
+ }
+ if got.Name != "updated" {
+ t.Errorf("Name after update: want updated, got %q", got.Name)
+ }
+ if got.Description != "now with a description" {
+ t.Errorf("Description after update: want %q, got %q", "now with a description", got.Description)
+ }
+ // UpdateEpic performs no status-machine validation — CLOSED is accepted
+ // as a plain pass-through write, matching UpdateProject's trust level.
+ if got.Status != "CLOSED" {
+ t.Errorf("Status after update: want CLOSED, got %q", got.Status)
+ }
+}
diff --git a/internal/storage/story.go b/internal/storage/story.go
new file mode 100644
index 0000000..7570dfc
--- /dev/null
+++ b/internal/storage/story.go
@@ -0,0 +1,160 @@
+package storage
+
+import (
+ "database/sql"
+ "encoding/json"
+ "fmt"
+ "time"
+
+ "github.com/thepeterstone/claudomator/internal/story"
+)
+
+// StoryFilter specifies criteria for listing stories. Zero-value fields are
+// not applied as filters.
+type StoryFilter struct {
+ Status string
+ EpicID string
+}
+
+// CreateStory inserts a new story.
+func (s *DB) CreateStory(st *story.Story) error {
+ now := time.Now().UTC()
+ st.CreatedAt = now
+ st.UpdatedAt = now
+
+ ac := st.AcceptanceCriteria
+ if ac == nil {
+ ac = []string{}
+ }
+ acJSON, err := json.Marshal(ac)
+ if err != nil {
+ return fmt.Errorf("marshaling acceptance_criteria: %w", err)
+ }
+
+ _, err = s.db.Exec(`
+ INSERT INTO stories (id, epic_id, project_id, name, branch_name, status, discovery_source, spec, acceptance_criteria_json, validation_json, deploy_config, priority, root_task_id, created_at, updated_at)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
+ st.ID, st.EpicID, st.ProjectID, st.Name, st.BranchName, st.Status, st.DiscoverySource, st.Spec,
+ string(acJSON), rawJSONOrNull(st.Validation), rawJSONOrNull(st.DeployConfig), st.Priority, st.RootTaskID, now, now,
+ )
+ return err
+}
+
+// GetStory retrieves a story by ID.
+func (s *DB) GetStory(id string) (*story.Story, error) {
+ row := s.db.QueryRow(storySelectColumns()+` FROM stories WHERE id = ?`, id)
+ return scanStory(row)
+}
+
+// ListStories returns stories matching the given filter.
+func (s *DB) ListStories(filter StoryFilter) ([]*story.Story, error) {
+ query := storySelectColumns() + ` FROM stories WHERE 1=1`
+ var args []interface{}
+ if filter.Status != "" {
+ query += " AND status = ?"
+ args = append(args, filter.Status)
+ }
+ if filter.EpicID != "" {
+ query += " AND epic_id = ?"
+ args = append(args, filter.EpicID)
+ }
+ query += " ORDER BY created_at DESC"
+
+ rows, err := s.db.Query(query, args...)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+
+ var stories []*story.Story
+ for rows.Next() {
+ st, err := scanStoryRows(rows)
+ if err != nil {
+ return nil, err
+ }
+ stories = append(stories, st)
+ }
+ return stories, rows.Err()
+}
+
+// UpdateStory replaces all editable fields (everything except id/created_at).
+// This phase enforces no state-machine validation on Status transitions —
+// it writes whatever status string it is given, the same trust level as
+// UpdateProject/UpdateTask's field replacement (not UpdateTaskState).
+func (s *DB) UpdateStory(st *story.Story) error {
+ now := time.Now().UTC()
+
+ ac := st.AcceptanceCriteria
+ if ac == nil {
+ ac = []string{}
+ }
+ acJSON, err := json.Marshal(ac)
+ if err != nil {
+ return fmt.Errorf("marshaling acceptance_criteria: %w", err)
+ }
+
+ _, err = s.db.Exec(`
+ UPDATE stories SET epic_id = ?, project_id = ?, name = ?, branch_name = ?, status = ?, discovery_source = ?,
+ spec = ?, acceptance_criteria_json = ?, validation_json = ?, deploy_config = ?, priority = ?, root_task_id = ?, updated_at = ?
+ WHERE id = ?`,
+ st.EpicID, st.ProjectID, st.Name, st.BranchName, st.Status, st.DiscoverySource,
+ st.Spec, string(acJSON), rawJSONOrNull(st.Validation), rawJSONOrNull(st.DeployConfig), st.Priority, st.RootTaskID, now,
+ st.ID,
+ )
+ if err != nil {
+ return err
+ }
+ st.UpdatedAt = now
+ return nil
+}
+
+func storySelectColumns() string {
+ return `SELECT id, epic_id, project_id, name, branch_name, status, discovery_source, spec, acceptance_criteria_json, validation_json, deploy_config, priority, root_task_id, created_at, updated_at`
+}
+
+func scanStory(row scanner) (*story.Story, error) {
+ var st story.Story
+ var epicID, projectID, branchName, discoverySource, spec, validationJSON, deployConfig, priority, rootTaskID sql.NullString
+ var acJSON string
+ if err := row.Scan(
+ &st.ID, &epicID, &projectID, &st.Name, &branchName, &st.Status, &discoverySource, &spec,
+ &acJSON, &validationJSON, &deployConfig, &priority, &rootTaskID, &st.CreatedAt, &st.UpdatedAt,
+ ); err != nil {
+ return nil, err
+ }
+ st.EpicID = epicID.String
+ st.ProjectID = projectID.String
+ st.BranchName = branchName.String
+ st.DiscoverySource = discoverySource.String
+ st.Spec = spec.String
+ st.Priority = priority.String
+ st.RootTaskID = rootTaskID.String
+
+ if acJSON == "" {
+ acJSON = "[]"
+ }
+ if err := json.Unmarshal([]byte(acJSON), &st.AcceptanceCriteria); err != nil {
+ return nil, fmt.Errorf("unmarshaling acceptance_criteria: %w", err)
+ }
+ if validationJSON.Valid && validationJSON.String != "" {
+ st.Validation = json.RawMessage(validationJSON.String)
+ }
+ if deployConfig.Valid && deployConfig.String != "" {
+ st.DeployConfig = json.RawMessage(deployConfig.String)
+ }
+ return &st, nil
+}
+
+func scanStoryRows(rows *sql.Rows) (*story.Story, error) {
+ return scanStory(rows)
+}
+
+// rawJSONOrNull returns nil (SQL NULL) for an empty json.RawMessage, or the
+// raw JSON text otherwise. Used for the nullable validation_json/deploy_config
+// columns, which carry freeform JSON blobs, not a fixed schema.
+func rawJSONOrNull(raw json.RawMessage) interface{} {
+ if len(raw) == 0 {
+ return nil
+ }
+ return string(raw)
+}
diff --git a/internal/storage/story_test.go b/internal/storage/story_test.go
new file mode 100644
index 0000000..9f2c723
--- /dev/null
+++ b/internal/storage/story_test.go
@@ -0,0 +1,211 @@
+package storage
+
+import (
+ "encoding/json"
+ "testing"
+
+ "github.com/thepeterstone/claudomator/internal/story"
+)
+
+func TestCreateStory_AndGetStory(t *testing.T) {
+ db := testDB(t)
+
+ st := &story.Story{
+ ID: "story-1",
+ EpicID: "epic-1",
+ ProjectID: "proj-1",
+ Name: "Add login page",
+ BranchName: "story/add-login-page",
+ Status: "DISCOVERY",
+ DiscoverySource: "agent",
+ Spec: "Product: users need to log in. Arch: reuse existing session middleware.",
+ AcceptanceCriteria: []string{"User can log in with email/password", "Invalid creds show an error"},
+ Validation: json.RawMessage(`{"type":"curl","steps":["GET /login returns 200"],"success_criteria":"all steps pass"}`),
+ Priority: "high",
+ RootTaskID: "task-1",
+ }
+ if err := db.CreateStory(st); err != nil {
+ t.Fatalf("CreateStory: %v", err)
+ }
+
+ got, err := db.GetStory("story-1")
+ if err != nil {
+ t.Fatalf("GetStory: %v", err)
+ }
+ if got.Name != "Add login page" {
+ t.Errorf("Name: want %q, got %q", "Add login page", got.Name)
+ }
+ if got.EpicID != "epic-1" {
+ t.Errorf("EpicID: want epic-1, got %q", got.EpicID)
+ }
+ if got.ProjectID != "proj-1" {
+ t.Errorf("ProjectID: want proj-1, got %q", got.ProjectID)
+ }
+ if got.BranchName != "story/add-login-page" {
+ t.Errorf("BranchName: want story/add-login-page, got %q", got.BranchName)
+ }
+ if got.Status != "DISCOVERY" {
+ t.Errorf("Status: want DISCOVERY, got %q", got.Status)
+ }
+ if len(got.AcceptanceCriteria) != 2 {
+ t.Errorf("AcceptanceCriteria: want 2 items, got %d: %+v", len(got.AcceptanceCriteria), got.AcceptanceCriteria)
+ }
+ if string(got.Validation) == "" {
+ t.Errorf("expected Validation to round-trip, got empty")
+ }
+ if got.RootTaskID != "task-1" {
+ t.Errorf("RootTaskID: want task-1, got %q", got.RootTaskID)
+ }
+ if got.CreatedAt.IsZero() || got.UpdatedAt.IsZero() {
+ t.Errorf("expected CreatedAt/UpdatedAt to be set, got %+v", got)
+ }
+}
+
+// TestCreateStory_LooseRefsToleratesUnmatchedIDs confirms epic_id,
+// project_id, and root_task_id are plain nullable-loose-ref strings with no
+// FK enforcement — a story referencing IDs that don't exist anywhere else
+// must still be created successfully, matching how tasks.project already
+// tolerates an unmatched string.
+func TestCreateStory_LooseRefsToleratesUnmatchedIDs(t *testing.T) {
+ db := testDB(t)
+
+ st := &story.Story{
+ ID: "story-loose",
+ EpicID: "epic-does-not-exist",
+ ProjectID: "project-does-not-exist",
+ Name: "Loose ref story",
+ Status: "DISCOVERY",
+ RootTaskID: "task-does-not-exist",
+ }
+ if err := db.CreateStory(st); err != nil {
+ t.Fatalf("CreateStory with unmatched loose refs should succeed, got: %v", err)
+ }
+ got, err := db.GetStory("story-loose")
+ if err != nil {
+ t.Fatalf("GetStory: %v", err)
+ }
+ if got.EpicID != "epic-does-not-exist" || got.ProjectID != "project-does-not-exist" || got.RootTaskID != "task-does-not-exist" {
+ t.Errorf("expected loose refs stored verbatim, got %+v", got)
+ }
+}
+
+func TestCreateStory_DefaultsAcceptanceCriteriaToEmptyList(t *testing.T) {
+ db := testDB(t)
+
+ st := &story.Story{ID: "story-nil-ac", Name: "No AC set", Status: "DISCOVERY"}
+ if err := db.CreateStory(st); err != nil {
+ t.Fatalf("CreateStory: %v", err)
+ }
+ got, err := db.GetStory("story-nil-ac")
+ if err != nil {
+ t.Fatalf("GetStory: %v", err)
+ }
+ if got.AcceptanceCriteria == nil {
+ t.Fatal("expected AcceptanceCriteria to be an empty slice, got nil")
+ }
+ if len(got.AcceptanceCriteria) != 0 {
+ t.Errorf("expected empty AcceptanceCriteria, got %+v", got.AcceptanceCriteria)
+ }
+}
+
+func TestGetStory_NotFound(t *testing.T) {
+ db := testDB(t)
+ if _, err := db.GetStory("nope"); err == nil {
+ t.Fatal("expected error for missing story, got nil")
+ }
+}
+
+func TestListStories_FilterByStatusAndEpic(t *testing.T) {
+ db := testDB(t)
+
+ stories := []*story.Story{
+ {ID: "s1", EpicID: "epic-a", Name: "one", Status: "DISCOVERY"},
+ {ID: "s2", EpicID: "epic-a", Name: "two", Status: "IN_PROGRESS"},
+ {ID: "s3", EpicID: "epic-b", Name: "three", Status: "DISCOVERY"},
+ }
+ for _, st := range stories {
+ if err := db.CreateStory(st); err != nil {
+ t.Fatalf("CreateStory(%s): %v", st.ID, err)
+ }
+ }
+
+ all, err := db.ListStories(StoryFilter{})
+ if err != nil {
+ t.Fatalf("ListStories: %v", err)
+ }
+ if len(all) != 3 {
+ t.Errorf("want 3 stories, got %d", len(all))
+ }
+
+ byEpic, err := db.ListStories(StoryFilter{EpicID: "epic-a"})
+ if err != nil {
+ t.Fatalf("ListStories(epic-a): %v", err)
+ }
+ if len(byEpic) != 2 {
+ t.Errorf("want 2 stories for epic-a, got %d", len(byEpic))
+ }
+
+ byStatus, err := db.ListStories(StoryFilter{Status: "DISCOVERY"})
+ if err != nil {
+ t.Fatalf("ListStories(DISCOVERY): %v", err)
+ }
+ if len(byStatus) != 2 {
+ t.Errorf("want 2 DISCOVERY stories, got %d", len(byStatus))
+ }
+
+ byBoth, err := db.ListStories(StoryFilter{EpicID: "epic-a", Status: "DISCOVERY"})
+ if err != nil {
+ t.Fatalf("ListStories(epic-a, DISCOVERY): %v", err)
+ }
+ if len(byBoth) != 1 || byBoth[0].ID != "s1" {
+ t.Errorf("want exactly [s1], got %+v", byBoth)
+ }
+}
+
+// TestUpdateStory_UnvalidatedStatusPassthrough confirms UpdateStory writes
+// whatever status string it is given with no state-machine enforcement
+// (unlike task.ValidTransition/UpdateTaskState) — this phase explicitly
+// defers that validation to a later orchestration phase.
+func TestUpdateStory_UnvalidatedStatusPassthrough(t *testing.T) {
+ db := testDB(t)
+
+ st := &story.Story{ID: "s1", Name: "original", Status: "DISCOVERY"}
+ if err := db.CreateStory(st); err != nil {
+ t.Fatalf("CreateStory: %v", err)
+ }
+ origCreatedAt := st.CreatedAt
+
+ // A nonsensical jump (DISCOVERY -> DONE, skipping every intermediate
+ // status) must still be accepted verbatim.
+ st.Name = "updated"
+ st.Status = "DONE"
+ st.EpicID = "epic-x"
+ st.RootTaskID = "task-x"
+ st.AcceptanceCriteria = []string{"criterion one"}
+ if err := db.UpdateStory(st); err != nil {
+ t.Fatalf("UpdateStory: %v", err)
+ }
+
+ got, err := db.GetStory("s1")
+ if err != nil {
+ t.Fatalf("GetStory: %v", err)
+ }
+ if got.Status != "DONE" {
+ t.Errorf("Status after update: want DONE (unvalidated passthrough), got %q", got.Status)
+ }
+ if got.Name != "updated" {
+ t.Errorf("Name after update: want updated, got %q", got.Name)
+ }
+ if got.EpicID != "epic-x" {
+ t.Errorf("EpicID after update: want epic-x, got %q", got.EpicID)
+ }
+ if got.RootTaskID != "task-x" {
+ t.Errorf("RootTaskID after update: want task-x, got %q", got.RootTaskID)
+ }
+ if len(got.AcceptanceCriteria) != 1 || got.AcceptanceCriteria[0] != "criterion one" {
+ t.Errorf("AcceptanceCriteria after update: want [criterion one], got %+v", got.AcceptanceCriteria)
+ }
+ if !got.CreatedAt.Equal(origCreatedAt) {
+ t.Errorf("CreatedAt must not change on update: want %v, got %v", origCreatedAt, got.CreatedAt)
+ }
+}