main
cover_letter_test.go
Eric Bower
·
2026-02-25
1package patchbin
2
3import (
4 "database/sql"
5 "strings"
6 "testing"
7 "time"
8)
9
10// buildTestPR returns a minimal PatchRequest for testing.
11func buildTestPR(id int64, name string) *PatchRequest {
12 return &PatchRequest{
13 ID: id,
14 Name: name,
15 RepoName: "test-repo",
16 Status: StatusOpen,
17 CreatedAt: time.Date(2025, 1, 15, 10, 30, 0, 0, time.UTC),
18 }
19}
20
21// buildTestUser returns a minimal User for testing.
22func buildTestUser(id int64, pubkey string) *User {
23 return &User{
24 ID: id,
25 Pubkey: pubkey,
26 }
27}
28
29// buildTestEvents returns event logs for testing.
30func buildTestEvents() []*EventLog {
31 return []*EventLog{
32 {
33 ID: 1,
34 UserID: 1,
35 PatchRequestID: sql.NullInt64{Int64: 42, Valid: true},
36 Event: "pr_created",
37 CreatedAt: time.Date(2025, 1, 15, 10, 30, 0, 0, time.UTC),
38 Data: EventData{Comment: "Initial submission."},
39 },
40 {
41 ID: 2,
42 UserID: 2,
43 PatchRequestID: sql.NullInt64{Int64: 42, Valid: true},
44 PatchsetID: sql.NullInt64{Int64: 3, Valid: true},
45 Event: "pr_patchset_added",
46 CreatedAt: time.Date(2025, 1, 16, 9, 0, 0, 0, time.UTC),
47 Data: EventData{Comment: "LGTM. One suggestion: add rate limiting."},
48 },
49 }
50}
51
52// buildTestUsers returns a user map for testing.
53func buildTestUsers() map[int64]*User {
54 return map[int64]*User{
55 1: buildTestUser(1, "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIGtest1 test1@host"),
56 2: buildTestUser(2, "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIGtest2 test2@host"),
57 }
58}
59
60// buildTestPatchesNoCover returns patches without a cover letter.
61func buildTestPatchesNoCover() []*Patch {
62 return []*Patch{
63 {
64 Title: "feat: add auth middleware",
65 Body: "Adds JWT-based authentication.",
66 RawText: "From abc123 Mon Sep 17 00:00:00 2001\nFrom: Test <test@example.com>\nDate: Wed, 3 Jul 2024 15:18:47 -0400\nSubject: [PATCH] feat: add auth middleware\n\nAdds JWT-based authentication.\n\ndiff --git a/auth.go b/auth.go\n",
67 AuthorName: "Test",
68 AuthorEmail: "test@example.com",
69 },
70 }
71}
72
73// buildTestPatchesWithCover returns patches with a user-provided cover letter.
74func buildTestPatchesWithCover() []*Patch {
75 return []*Patch{
76 {
77 Title: "Add torch deps",
78 Body: "I took the liberty of adding a requirements file for python.\n\nBob Sour (1):\n chore: add torch to requirements",
79 RawText: "From def456 Mon Sep 17 00:00:00 2001\nFrom: Bob <bob@example.com>\nDate: Sun, 14 Jul 2024 07:14:44 -0400\nSubject: [PATCH 0/2] Add torch deps\n\nI took the liberty of adding a requirements file for python.\n\nBob Sour (1):\n chore: add torch to requirements\n\n-- \n2.45.2\n",
80 AuthorName: "Bob",
81 AuthorEmail: "bob@example.com",
82 },
83 {
84 Title: "feat: build an rnn",
85 Body: "Build a simple RNN.",
86 RawText: "From abc123 Mon Sep 17 00:00:00 2001\nFrom: Bob <bob@example.com>\nDate: Wed, 3 Jul 2024 15:18:47 -0400\nSubject: [PATCH 1/2] feat: build an rnn\n\nBuild a simple RNN.\n\ndiff --git a/train.py b/train.py\n",
87 AuthorName: "Bob",
88 AuthorEmail: "bob@example.com",
89 },
90 }
91}
92
93func TestHasCoverLetter_NoCover(t *testing.T) {
94 patches := buildTestPatchesNoCover()
95 if HasCoverLetter(patches) {
96 t.Fatal("expected no cover letter, got true")
97 }
98}
99
100func TestHasCoverLetter_WithCover(t *testing.T) {
101 patches := buildTestPatchesWithCover()
102 if !HasCoverLetter(patches) {
103 t.Fatal("expected cover letter, got false")
104 }
105}
106
107func TestHasCoverLetter_EmptyPatches(t *testing.T) {
108 if HasCoverLetter(nil) {
109 t.Fatal("expected no cover letter for nil patches")
110 }
111 if HasCoverLetter([]*Patch{}) {
112 t.Fatal("expected no cover letter for empty patches")
113 }
114}
115
116func TestBuildDiscussion(t *testing.T) {
117 events := buildTestEvents()
118 users := buildTestUsers()
119
120 discussion := BuildDiscussion(events, users)
121
122 if discussion == "" {
123 t.Fatal("discussion should not be empty")
124 }
125
126 // Should contain pubkey fingerprints, not usernames
127 if !strings.Contains(discussion, "SHA256:") {
128 t.Fatal("discussion should contain SHA256 pubkey fingerprints")
129 }
130
131 // Should contain event comments
132 if !strings.Contains(discussion, "Initial submission") {
133 t.Fatal("discussion should contain first event comment")
134 }
135 if !strings.Contains(discussion, "rate limiting") {
136 t.Fatal("discussion should contain feedback comment")
137 }
138
139 // Should contain timestamps
140 if !strings.Contains(discussion, "2025-01-15") {
141 t.Fatal("discussion should contain date")
142 }
143
144 // Should contain revision markers for patchset events
145 if !strings.Contains(discussion, "Submitted revision ps-3") {
146 t.Fatalf("discussion should contain revision marker, got:\n%s", discussion)
147 }
148 // Revision marker should have pubkey fingerprint
149 if !strings.Contains(discussion, "SHA256:") {
150 t.Fatal("revision marker should include SHA256 fingerprint")
151 }
152}
153
154func TestBuildDiscussion_RevisionMarkers(t *testing.T) {
155 events := []*EventLog{
156 {
157 ID: 1,
158 UserID: 1,
159 PatchRequestID: sql.NullInt64{Int64: 1, Valid: true},
160 Event: "pr_created",
161 CreatedAt: time.Date(2025, 1, 15, 10, 0, 0, 0, time.UTC),
162 Data: EventData{Comment: "Initial submission."},
163 },
164 {
165 ID: 2,
166 UserID: 1,
167 PatchRequestID: sql.NullInt64{Int64: 1, Valid: true},
168 PatchsetID: sql.NullInt64{Int64: 3, Valid: true},
169 Event: "pr_patchset_added",
170 CreatedAt: time.Date(2025, 1, 16, 9, 0, 0, 0, time.UTC),
171 Data: EventData{Comment: "Updated based on feedback."},
172 },
173 {
174 ID: 3,
175 UserID: 2,
176 PatchRequestID: sql.NullInt64{Int64: 1, Valid: true},
177 PatchsetID: sql.NullInt64{Int64: 5, Valid: true},
178 Event: "pr_patchset_added",
179 CreatedAt: time.Date(2025, 1, 17, 11, 0, 0, 0, time.UTC),
180 Data: EventData{Comment: "LGTM."},
181 },
182 }
183 users := map[int64]*User{
184 1: buildTestUser(1, "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIGtest1 test1@host"),
185 2: buildTestUser(2, "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIGtest2 test2@host"),
186 }
187
188 discussion := BuildDiscussion(events, users)
189
190 // Should have revision markers interleaved with comments, same format as comments
191 if !strings.Contains(discussion, "Submitted revision ps-3") {
192 t.Fatal("should contain ps-3 revision marker")
193 }
194 if !strings.Contains(discussion, "Submitted revision ps-5") {
195 t.Fatal("should contain ps-5 revision marker")
196 }
197
198 // Revision markers should come before associated comments
199 ps3Idx := strings.Index(discussion, "Submitted revision ps-3")
200 updatedIdx := strings.Index(discussion, "Updated based on feedback")
201 if ps3Idx >= updatedIdx {
202 t.Fatal("revision marker should come before its comment")
203 }
204}
205
206func TestBuildDiscussion_EmptyEvents(t *testing.T) {
207 discussion := BuildDiscussion(nil, buildTestUsers())
208 if discussion != "" {
209 t.Fatalf("expected empty discussion for no events, got: %q", discussion)
210 }
211}
212
213func TestGenerateCoverLetterPatch(t *testing.T) {
214 pr := buildTestPR(42, "feat: add auth middleware")
215 discussion := "[2025-01-15] SHA256:test:\n Hello world."
216
217 patch := GenerateCoverLetterPatch(pr, discussion, "patchbin.example.com")
218
219 // Should contain PR title in subject
220 if !strings.Contains(patch, "feat: add auth middleware") {
221 t.Fatal("cover letter should contain PR title in subject")
222 }
223
224 // Should contain References trailer with URL
225 expectedRef := "References: https://patchbin.example.com/pr/42"
226 if !strings.Contains(patch, expectedRef) {
227 t.Fatalf("cover letter should contain References trailer, got:\n%s", patch)
228 }
229
230 // Should contain discussion
231 if !strings.Contains(patch, "Hello world.") {
232 t.Fatal("cover letter should contain discussion")
233 }
234
235 // Should be a valid mbox (starts with "From ")
236 if !strings.HasPrefix(patch, "From ") {
237 t.Fatal("cover letter should start with 'From ' (mbox format)")
238 }
239
240 // Should NOT contain diff --git (empty tree)
241 if strings.Contains(patch, "diff --git") {
242 t.Fatal("cover letter should not contain diffs (empty tree)")
243 }
244
245 // Discussion should be BEFORE any --- separator (in commit message body)
246 sepIdx := strings.Index(patch, "\n---\n")
247 discIdx := strings.Index(patch, "Hello world.")
248 if sepIdx != -1 && discIdx > sepIdx {
249 t.Fatal("discussion should be before the --- separator (in commit message body)")
250 }
251}
252
253func TestGenerateCoverLetterPatch_NoDiscussion(t *testing.T) {
254 pr := buildTestPR(1, "fix: typo")
255 patch := GenerateCoverLetterPatch(pr, "", "patchbin.example.com")
256
257 if !strings.Contains(patch, "fix: typo") {
258 t.Fatal("cover letter should contain PR title")
259 }
260 if !strings.Contains(patch, "References:") {
261 t.Fatal("cover letter should contain References trailer")
262 }
263}
264
265func TestAugmentCoverLetterPatch(t *testing.T) {
266 original := "From def456 Mon Sep 17 00:00:00 2001\nFrom: Bob <bob@example.com>\nDate: Sun, 14 Jul 2024 07:14:44 -0400\nSubject: [PATCH 0/2] Add torch deps\n\nI took the liberty of adding a requirements file.\n\n-- \n2.45.2\n"
267 discussion := "[2025-01-15] SHA256:test:\n Great patch!"
268
269 augmented := AugmentCoverLetterPatch(original, discussion, "patchbin.example.com", 42)
270
271 // Should preserve original content
272 if !strings.Contains(augmented, "Add torch deps") {
273 t.Fatal("augmented cover letter should preserve original subject")
274 }
275 if !strings.Contains(augmented, "I took the liberty") {
276 t.Fatal("augmented cover letter should preserve original body")
277 }
278
279 // Should add References trailer
280 if !strings.Contains(augmented, "References: https://patchbin.example.com/pr/42") {
281 t.Fatal("augmented cover letter should contain References trailer")
282 }
283
284 // Should add discussion
285 if !strings.Contains(augmented, "Great patch!") {
286 t.Fatal("augmented cover letter should contain discussion")
287 }
288
289 // Should still be valid mbox
290 if !strings.HasPrefix(augmented, "From ") {
291 t.Fatal("augmented cover letter should start with 'From ' (mbox format)")
292 }
293}
294
295func TestAugmentCoverLetterPatch_NoDiscussion(t *testing.T) {
296 original := "From def456 Mon Sep 17 00:00:00 2001\nFrom: Bob <bob@example.com>\nDate: Sun, 14 Jul 2024 07:14:44 -0400\nSubject: [PATCH 0/1] Simple patch\n\nJust a patch.\n\n-- \n2.45.2\n"
297
298 augmented := AugmentCoverLetterPatch(original, "", "patchbin.example.com", 1)
299
300 // Should preserve original content
301 if !strings.Contains(augmented, "Simple patch") {
302 t.Fatal("augmented cover letter should preserve original subject")
303 }
304 if !strings.Contains(augmented, "Just a patch.") {
305 t.Fatal("augmented cover letter should preserve original body")
306 }
307
308 // Should still add References
309 if !strings.Contains(augmented, "References:") {
310 t.Fatal("augmented cover letter should contain References trailer")
311 }
312}
313
314func TestGenerateMboxWithCoverLetter_NoExistingCover(t *testing.T) {
315 pr := buildTestPR(42, "feat: add auth middleware")
316 patches := buildTestPatchesNoCover()
317 events := buildTestEvents()
318 users := buildTestUsers()
319
320 mbox := GenerateMboxWithCoverLetter(pr, patches, events, users, "patchbin.example.com")
321
322 // Should start with a cover letter (From ... for the cover)
323 if !strings.HasPrefix(mbox, "From ") {
324 t.Fatal("mbox should start with 'From ' (cover letter)")
325 }
326
327 // Should contain cover letter with PR title
328 if !strings.Contains(mbox, "[patchbin #42] feat: add auth middleware") {
329 t.Fatal("mbox should contain cover letter with PR title")
330 }
331
332 // Should contain References
333 if !strings.Contains(mbox, "References: https://patchbin.example.com/pr/42") {
334 t.Fatal("mbox should contain References trailer")
335 }
336
337 // Should contain discussion with pubkey fingerprints
338 if !strings.Contains(mbox, "SHA256:") {
339 t.Fatal("mbox should contain discussion with SHA256 fingerprints")
340 }
341
342 // Should contain the original patches
343 if !strings.Contains(mbox, "feat: add auth middleware") {
344 t.Fatal("mbox should contain original patches")
345 }
346
347 // Should contain the diff from original patches
348 if !strings.Contains(mbox, "diff --git") {
349 t.Fatal("mbox should contain diffs from original patches")
350 }
351}
352
353func TestGenerateMboxWithCoverLetter_WithExistingCover(t *testing.T) {
354 pr := buildTestPR(7, "Add torch deps")
355 patches := buildTestPatchesWithCover()
356 events := buildTestEvents()
357 users := buildTestUsers()
358
359 mbox := GenerateMboxWithCoverLetter(pr, patches, events, users, "patchbin.example.com")
360
361 // Should preserve the user's cover letter content
362 if !strings.Contains(mbox, "I took the liberty") {
363 t.Fatal("mbox should preserve user's cover letter body")
364 }
365
366 // Should add References to the cover letter
367 if !strings.Contains(mbox, "References: https://patchbin.example.com/pr/7") {
368 t.Fatal("mbox should add References trailer to existing cover letter")
369 }
370
371 // Should add discussion
372 if !strings.Contains(mbox, "SHA256:") {
373 t.Fatal("mbox should contain discussion with SHA256 fingerprints")
374 }
375
376 // Should contain all original patches
377 if !strings.Contains(mbox, "feat: build an rnn") {
378 t.Fatal("mbox should contain original patches")
379 }
380}
381
382func TestGenerateMboxWithCoverLetter_NoEvents(t *testing.T) {
383 pr := buildTestPR(1, "fix: typo")
384 patches := buildTestPatchesNoCover()
385
386 mbox := GenerateMboxWithCoverLetter(pr, patches, nil, nil, "patchbin.example.com")
387
388 // Should still have a cover letter
389 if !strings.Contains(mbox, "[patchbin #1] fix: typo") {
390 t.Fatal("mbox should contain cover letter with PR title")
391 }
392
393 // Should still have References
394 if !strings.Contains(mbox, "References:") {
395 t.Fatal("mbox should contain References trailer")
396 }
397
398 // Should contain the original patches
399 if !strings.Contains(mbox, "diff --git") {
400 t.Fatal("mbox should contain diffs from original patches")
401 }
402}
403
404func TestGenerateMboxWithCoverLetter_PreservesPatchOrder(t *testing.T) {
405 pr := buildTestPR(10, "multi-patch series")
406 patches := []*Patch{
407 {
408 Title: "first commit",
409 RawText: "From aaa Mon Sep 17 00:00:00 2001\nFrom: A <a@b.com>\nSubject: [PATCH 1/3] first commit\n\ndiff --git a/a.go b/a.go\n",
410 },
411 {
412 Title: "second commit",
413 RawText: "From bbb Mon Sep 17 00:00:00 2001\nFrom: A <a@b.com>\nSubject: [PATCH 2/3] second commit\n\ndiff --git a/b.go b/b.go\n",
414 },
415 {
416 Title: "third commit",
417 RawText: "From ccc Mon Sep 17 00:00:00 2001\nFrom: A <a@b.com>\nSubject: [PATCH 3/3] third commit\n\ndiff --git a/c.go b/c.go\n",
418 },
419 }
420
421 mbox := GenerateMboxWithCoverLetter(pr, patches, nil, nil, "patchbin.example.com")
422
423 // Verify patch order is preserved
424 firstIdx := strings.Index(mbox, "first commit")
425 secondIdx := strings.Index(mbox, "second commit")
426 thirdIdx := strings.Index(mbox, "third commit")
427
428 if firstIdx >= secondIdx || secondIdx >= thirdIdx {
429 t.Fatalf("patch order not preserved: first=%d, second=%d, third=%d", firstIdx, secondIdx, thirdIdx)
430 }
431}