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}