1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
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)
}
}
|