Files
mesh-catalog/modules/mesh-mail-reader/cmd/mesh-mail-reader/question_test.go
T

185 lines
6.6 KiB
Go

package main
import (
"encoding/json"
"strings"
"testing"
"time"
)
func bigString(n int, ch byte) string {
b := make([]byte, n)
for i := range b {
b[i] = ch
}
return string(b)
}
func manyMails(n int, textSize int) []QuestionMail {
out := make([]QuestionMail, n)
for i := range out {
out[i] = QuestionMail{
ID: int64(i + 1), Account: "bob", Folder: "INBOX", Date: time.Date(2026, 10, 20, 10, 0, 0, 0, time.UTC),
From: []Person{{Name: "Sender", Address: "sender@example.org"}}, Subject: bigString(2000, 's'),
Text: bigString(textSize, 't'),
}
}
return out
}
func manyGroups(n int) []GroupSummary {
out := make([]GroupSummary, n)
for i := range out {
out[i] = GroupSummary{Label: bigString(200, 'g'), Count: n - i}
}
return out
}
func manyLessons(n int) []Lesson {
out := make([]Lesson, n)
base := time.Date(2026, 10, 1, 0, 0, 0, 0, time.UTC)
for i := range out {
out[i] = Lesson{
ID: int64(i + 1), SenderAddress: bigString(100, 'l'), Subject: bigString(300, 'x'),
Change: bigString(200, 'c'), Made: base.Add(time.Duration(i) * time.Hour),
}
}
return out
}
// 1427/AC1: every part of the question is bounded, with each part given deliberately overfull input.
func TestBuildQuestionEnforcesEveryBudget(t *testing.T) {
q := BuildQuestion(manyMails(25, 10000), manyGroups(100), manyLessons(100))
if len(q.Text) > QuestionBudget {
t.Fatalf("question is %d bytes, want at most %d (QuestionBudget)", len(q.Text), QuestionBudget)
}
if len(Framing) > FramingBudget {
t.Fatalf("Framing is %d bytes, want at most %d (FramingBudget)", len(Framing), FramingBudget)
}
if n := strings.Count(q.Text, "\nmail "); n > MaxMailsPerBatch {
t.Fatalf("question holds %d mails, want at most %d (MaxMailsPerBatch)", n, MaxMailsPerBatch)
}
if g := groupsBlock(manyGroups(100)); len(g) > GroupsBudget {
t.Fatalf("groups block is %d bytes, want at most %d (GroupsBudget)", len(g), GroupsBudget)
}
if l := lessonsBlock(manyLessons(100), manyMails(1, 10)); len(l) > LessonsBudget {
t.Fatalf("lessons block is %d bytes, want at most %d (LessonsBudget)", len(l), LessonsBudget)
}
header := mailHeaderBlock(manyMails(1, 0)[0])
if len(header) > MailHeadersBudget {
t.Fatalf("one mail's headers are %d bytes, want at most %d (MailHeadersBudget)", len(header), MailHeadersBudget)
}
whole := mailBlock(manyMails(1, 10000)[0])
if len(whole) > MailBudget {
t.Fatalf("one whole mail is %d bytes, want at most %d (MailBudget)", len(whole), MailBudget)
}
}
// 1427/AC1: groups come largest first, and a group that would overflow the budget is left out whole, not
// cut mid-line.
func TestGroupsBlockIsLargestFirstAndNeverCutMidLine(t *testing.T) {
groups := []GroupSummary{{Label: "small", Count: 1}, {Label: "big", Count: 100}, {Label: "medium", Count: 10}}
block := groupsBlock(groups)
if i, j := strings.Index(block, "big"), strings.Index(block, "medium"); i == -1 || j == -1 || i > j {
t.Fatalf("groups block %q is not largest-first", block)
}
for _, line := range strings.Split(block, "\n") {
if line != "" && !strings.HasPrefix(line, "group ") {
t.Fatalf("a line was cut mid-item: %q", line)
}
}
}
// 1427/AC1: the lessons budget keeps at most 40, and within that, a lesson matching the batch's own sender
// comes before one that does not, even when the other is newer (also exercised by 1428/AC4 directly on
// selectLessons).
func TestLessonsBlockKeepsAtMostFortyMatchingFirst(t *testing.T) {
mails := []QuestionMail{{ID: 1, From: []Person{{Address: "match@example.org"}}}}
var lessons []Lesson
base := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)
for i := 0; i < 50; i++ {
lessons = append(lessons, Lesson{ID: int64(i + 1), SenderAddress: "other@example.org", Made: base.Add(time.Duration(i) * time.Hour)})
}
lessons = append(lessons, Lesson{ID: 999, SenderAddress: "match@example.org", Made: base})
selected := selectLessons(lessons, mails)
if len(selected) != MaxLessonsInBatch {
t.Fatalf("selected %d lessons, want %d (MaxLessonsInBatch)", len(selected), MaxLessonsInBatch)
}
if selected[0].ID != 999 {
t.Fatalf("selected[0] = %+v, want the matching sender's lesson first, even though it is the oldest", selected[0])
}
}
// 1427/AC2: a mail in a Junk or Spam folder, or carrying \Junk, says so in its header line.
func TestIsJunkAndItsLineInTheHeaderBlock(t *testing.T) {
cases := []struct {
folder string
flags []string
want bool
}{
{"Junk", nil, true},
{"JUNK", nil, true},
{"spam", nil, true},
{"Spam", nil, true},
{"INBOX", []string{`\Junk`}, true},
{"INBOX", []string{`\Seen`}, false},
{"INBOX", nil, false},
}
for _, c := range cases {
if got := IsJunk(c.folder, c.flags); got != c.want {
t.Errorf("IsJunk(%q, %v) = %v, want %v", c.folder, c.flags, got, c.want)
}
}
m := QuestionMail{ID: 1, Account: "bob", Folder: "Junk", Date: time.Now(), Subject: "x"}
if h := mailHeaderBlock(m); !strings.Contains(h, "filed this as junk") {
t.Fatalf("header block for a Junk-folder mail: %q, want it to say the provider filed it as junk", h)
}
}
func answerJSON(t *testing.T, mails ...map[string]any) []byte {
t.Helper()
raw, err := json.Marshal(map[string]any{"mails": mails})
if err != nil {
t.Fatal(err)
}
return raw
}
// 1427/AC3: a well-shaped answer parses, naming id, group, summary, mark, why and due date.
func TestParseAnswerAccepts(t *testing.T) {
raw := answerJSON(t, map[string]any{
"id": 1, "group": "newsletters", "summary": "a newsletter", "mark": "neither", "why": "routine", "due-date": "2026-11-01",
})
a, err := ParseAnswer(raw, []int64{1})
if err != nil {
t.Fatal(err)
}
j, ok := a.Mails[1]
if !ok || j.Group != "newsletters" || j.Mark != MarkNeither || j.DueDate != "2026-11-01" {
t.Fatalf("parsed = %+v", j)
}
}
// 1427/AC3: an answer missing a mail, naming an unknown id or mark, or a due date not a date is out of shape.
func TestParseAnswerRefusesOutOfShape(t *testing.T) {
cases := []struct {
name string
raw []byte
ids []int64
}{
{"missing a mail", answerJSON(t, map[string]any{"id": 1, "mark": "neither"}), []int64{1, 2}},
{"unknown id", answerJSON(t, map[string]any{"id": 99, "mark": "neither"}), []int64{1}},
{"unknown mark", answerJSON(t, map[string]any{"id": 1, "mark": "urgent"}), []int64{1}},
{"due date not a date", answerJSON(t, map[string]any{"id": 1, "mark": "neither", "due-date": "soon"}), []int64{1}},
{"not json at all", []byte(`not json`), []int64{1}},
}
for _, c := range cases {
if _, err := ParseAnswer(c.raw, c.ids); err == nil {
t.Errorf("%s: no error, want out of shape", c.name)
} else if !strings.Contains(err.Error(), "out of shape") {
t.Errorf("%s: %v, want it to say out of shape", c.name, err)
}
}
}