1package issues_test
2
3import (
4 "testing"
5 "time"
6
7 "arche/internal/issues"
8
9 _ "github.com/mattn/go-sqlite3"
10)
11
12func buildEvent(issueID, kind string, payload []byte, hlcMS int64, hlcSeq int) issues.IssueEvent {
13 return issues.IssueEvent{
14 EventID: kind + "-" + string(rune('a'+hlcSeq)),
15 IssueID: issueID,
16 HLCMS: hlcMS,
17 HLCSeq: hlcSeq,
18 Kind: kind,
19 Payload: payload,
20 Author: "test",
21 Created: time.Now().Unix(),
22 }
23}
24
25func mustJSON(s string) []byte { return []byte(`"` + s + `"`) }
26
27func TestReduce_BasicIssue(t *testing.T) {
28 evs := []issues.IssueEvent{
29 buildEvent("id1", "create", []byte(`{"id":"id1"}`), 1, 0),
30 buildEvent("id1", "title", mustJSON("hello"), 2, 0),
31 buildEvent("id1", "status", mustJSON("open"), 3, 0),
32 buildEvent("id1", "body", mustJSON("first body"), 4, 0),
33 }
34 iss := issues.Reduce(evs)
35 if iss.Title != "hello" {
36 t.Errorf("title: want %q got %q", "hello", iss.Title)
37 }
38 if iss.Status != "open" {
39 t.Errorf("status: want open got %q", iss.Status)
40 }
41 if iss.Body != "first body" {
42 t.Errorf("body: want %q got %q", "first body", iss.Body)
43 }
44}
45
46func TestReduce_StatusLWW(t *testing.T) {
47 evs := []issues.IssueEvent{
48 buildEvent("id1", "status", mustJSON("closed"), 10, 0),
49 buildEvent("id1", "status", mustJSON("open"), 5, 0),
50 }
51 iss := issues.Reduce(evs)
52 if iss.Status != "closed" {
53 t.Errorf("LWW: want closed got %q", iss.Status)
54 }
55}
56
57func TestReduce_TitleLWW_SameMS_SeqBreaks(t *testing.T) {
58 evs := []issues.IssueEvent{
59 buildEvent("id1", "title", mustJSON("first"), 10, 0),
60 buildEvent("id1", "title", mustJSON("second"), 10, 1),
61 }
62 iss := issues.Reduce(evs)
63 if iss.Title != "second" {
64 t.Errorf("seq tiebreak: want %q got %q", "second", iss.Title)
65 }
66}
67
68func TestReduce_Comments_AppendOrder(t *testing.T) {
69 evs := []issues.IssueEvent{
70 buildEvent("id1", "comment", mustJSON("first comment"), 1, 0),
71 buildEvent("id1", "comment", mustJSON("second comment"), 2, 0),
72 buildEvent("id1", "comment", mustJSON("third comment"), 3, 0),
73 }
74 iss := issues.Reduce(evs)
75 if len(iss.Comments) != 3 {
76 t.Fatalf("want 3 comments, got %d", len(iss.Comments))
77 }
78 if iss.Comments[0].Text != "first comment" {
79 t.Errorf("comment[0]: want %q got %q", "first comment", iss.Comments[0].Text)
80 }
81 if iss.Comments[2].Text != "third comment" {
82 t.Errorf("comment[2]: want %q got %q", "third comment", iss.Comments[2].Text)
83 }
84}
85
86func TestReduce_Labels_ORSet(t *testing.T) {
87 evs := []issues.IssueEvent{
88 buildEvent("id1", "label_add", []byte(`{"label":"bug","token":"tok1"}`), 1, 0),
89 buildEvent("id1", "label_add", []byte(`{"label":"urgent","token":"tok2"}`), 2, 0),
90 buildEvent("id1", "label_rm", mustJSON("tok1"), 3, 0),
91 }
92 iss := issues.Reduce(evs)
93 if len(iss.Labels) != 1 {
94 t.Fatalf("want 1 label, got %d: %v", len(iss.Labels), iss.Labels)
95 }
96 if iss.Labels[0] != "urgent" {
97 t.Errorf("label: want urgent got %q", iss.Labels[0])
98 }
99}
100
101func TestReduce_Labels_AddTwiceRemoveOnce(t *testing.T) {
102 evs := []issues.IssueEvent{
103 buildEvent("id1", "label_add", []byte(`{"label":"bug","token":"tok1"}`), 1, 0),
104 buildEvent("id1", "label_add", []byte(`{"label":"bug","token":"tok2"}`), 2, 0),
105 buildEvent("id1", "label_rm", mustJSON("tok1"), 3, 0),
106 }
107 iss := issues.Reduce(evs)
108 if len(iss.Labels) != 1 || iss.Labels[0] != "bug" {
109 t.Errorf("OR-set: bug should survive remove of tok1; got %v", iss.Labels)
110 }
111}
112
113func TestReduce_Refs_GrowOnly(t *testing.T) {
114 evs := []issues.IssueEvent{
115 buildEvent("id1", "ref", mustJSON("abc123"), 1, 0),
116 buildEvent("id1", "ref", mustJSON("def456"), 2, 0),
117 }
118 iss := issues.Reduce(evs)
119 if len(iss.Refs) != 2 {
120 t.Fatalf("want 2 refs, got %d", len(iss.Refs))
121 }
122}
123
124func TestReduce_BodyConflict(t *testing.T) {
125 bc := `{"BaseEventID":"base","OurEdit":"ours","TheirEdit":"theirs"}`
126 evs := []issues.IssueEvent{
127 buildEvent("id1", "body_conflict", []byte(bc), 5, 0),
128 }
129 iss := issues.Reduce(evs)
130 if iss.BodyConflict == nil {
131 t.Fatal("expected BodyConflict, got nil")
132 }
133 if iss.BodyConflict.OurEdit != "ours" {
134 t.Errorf("OurEdit: want ours got %q", iss.BodyConflict.OurEdit)
135 }
136}
137
138func TestReduce_EmptyStatus_DefaultsOpen(t *testing.T) {
139 evs := []issues.IssueEvent{
140 buildEvent("id1", "create", []byte(`{"id":"id1"}`), 1, 0),
141 }
142 iss := issues.Reduce(evs)
143 if iss.Status != "open" {
144 t.Errorf("default status: want open got %q", iss.Status)
145 }
146}
147
148func openTestStore(t *testing.T) *issues.Store {
149 t.Helper()
150 s, err := issues.OpenForTesting()
151 if err != nil {
152 t.Fatalf("open store: %v", err)
153 }
154 t.Cleanup(func() { s.Close() })
155 return s
156}
157
158func TestStore_CreateAndGet(t *testing.T) {
159 s := openTestStore(t)
160
161 id, err := s.CreateIssue("fix the bug", "steps to reproduce", "alice")
162 if err != nil {
163 t.Fatalf("CreateIssue: %v", err)
164 }
165
166 iss, err := s.GetIssue(id)
167 if err != nil {
168 t.Fatalf("GetIssue: %v", err)
169 }
170 if iss.Title != "fix the bug" {
171 t.Errorf("title: want %q got %q", "fix the bug", iss.Title)
172 }
173 if iss.Status != "open" {
174 t.Errorf("status: want open got %q", iss.Status)
175 }
176 if iss.Body != "steps to reproduce" {
177 t.Errorf("body: want %q got %q", "steps to reproduce", iss.Body)
178 }
179}
180
181func TestStore_SetStatus(t *testing.T) {
182 s := openTestStore(t)
183 id, _ := s.CreateIssue("bug", "", "alice")
184
185 if err := s.SetStatus(id, "closed", "bob"); err != nil {
186 t.Fatalf("SetStatus: %v", err)
187 }
188 iss, _ := s.GetIssue(id)
189 if iss.Status != "closed" {
190 t.Errorf("want closed got %q", iss.Status)
191 }
192}
193
194func TestStore_AddComment(t *testing.T) {
195 s := openTestStore(t)
196 id, _ := s.CreateIssue("bug", "", "alice")
197
198 if err := s.AddComment(id, "this is a comment", "bob"); err != nil {
199 t.Fatalf("AddComment: %v", err)
200 }
201 iss, _ := s.GetIssue(id)
202 if len(iss.Comments) != 1 {
203 t.Fatalf("want 1 comment, got %d", len(iss.Comments))
204 }
205 if iss.Comments[0].Text != "this is a comment" {
206 t.Errorf("comment text: want %q got %q", "this is a comment", iss.Comments[0].Text)
207 }
208}
209
210func TestStore_ListIssues(t *testing.T) {
211 s := openTestStore(t)
212 s.CreateIssue("issue A", "", "alice") //nolint:errcheck
213 s.CreateIssue("issue B", "", "bob") //nolint:errcheck
214
215 stubs, err := s.ListIssues()
216 if err != nil {
217 t.Fatalf("ListIssues: %v", err)
218 }
219 if len(stubs) != 2 {
220 t.Errorf("want 2 issues, got %d", len(stubs))
221 }
222}
223
224func TestStore_MergeEvents_Idempotent(t *testing.T) {
225 s := openTestStore(t)
226 id, _ := s.CreateIssue("bug", "body", "alice")
227
228 all, err := s.AllEvents()
229 if err != nil {
230 t.Fatalf("AllEvents: %v", err)
231 }
232
233 if err := s.MergeEvents(all); err != nil {
234 t.Fatalf("MergeEvents idempotent: %v", err)
235 }
236
237 iss, _ := s.GetIssue(id)
238 if iss.Title != "bug" {
239 t.Errorf("title after idempotent merge: want bug got %q", iss.Title)
240 }
241}
242
243func TestStore_MergeEvents_Union(t *testing.T) {
244 s1 := openTestStore(t)
245 s2 := openTestStore(t)
246
247 id, _ := s1.CreateIssue("shared bug", "", "alice")
248 s1.AddComment(id, "from s1", "alice") //nolint:errcheck
249
250 evs1, _ := s1.AllEvents()
251 s2.MergeEvents(evs1) //nolint:errcheck
252
253 s2.AddComment(id, "from s2", "bob") //nolint:errcheck
254
255 evs2, _ := s2.AllEvents()
256 s1.MergeEvents(evs2) //nolint:errcheck
257
258 iss1, _ := s1.GetIssue(id)
259 iss2, _ := s2.GetIssue(id)
260 if len(iss1.Comments) != 2 {
261 t.Errorf("s1 wants 2 comments, got %d", len(iss1.Comments))
262 }
263 if len(iss2.Comments) != 2 {
264 t.Errorf("s2 wants 2 comments, got %d", len(iss2.Comments))
265 }
266}