287 lines
11 KiB
Go
287 lines
11 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
func mattersEntry(sender, summary string) Entry {
|
|
return Entry{Account: "bob", SenderName: sender, Summary: summary, Mark: MarkMatters, Date: time.Now()}
|
|
}
|
|
|
|
// recordingCheck and recordingTell let a test see exactly what SendSignal asked, and answer however the
|
|
// test needs (1522/AC1, AC2).
|
|
func recordingCheck(allow bool) (MessengerCheck, *[]string) {
|
|
var asked []string
|
|
return func(text string) (bool, error) {
|
|
asked = append(asked, text)
|
|
return allow, nil
|
|
}, &asked
|
|
}
|
|
|
|
func recordingTell(sentOn string) (Tell, *[]string, *[]string) {
|
|
var headlines, subjects []string
|
|
return func(headline string, lines []string, subject string) (string, error) {
|
|
headlines = append(headlines, headline)
|
|
subjects = append(subjects, subject)
|
|
return sentOn, nil
|
|
}, &headlines, &subjects
|
|
}
|
|
|
|
// 1522/AC1: a batch marking mails as mattering asks messenger_check with the headline and lines, then
|
|
// sends one tell with subject "mail".
|
|
func TestSendSignalChecksWordsThenTellsWithSubjectMail(t *testing.T) {
|
|
ctx := context.Background()
|
|
store := newMemStore()
|
|
id, _ := store.AddEntry(ctx, mattersEntry("Alice", "pay the bill"))
|
|
check, asked := recordingCheck(true)
|
|
tell, headlines, subjects := recordingTell("2026-10-21T10:00:00Z")
|
|
|
|
if err := SendSignal(ctx, store, check, tell, time.Now); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(*asked) == 0 || (*asked)[0] != "1 mail matters" {
|
|
t.Fatalf("messenger_check was asked %v, want the headline first", *asked)
|
|
}
|
|
if len(*headlines) != 1 || (*headlines)[0] != "1 mail matters" {
|
|
t.Fatalf("tell's headlines = %v", *headlines)
|
|
}
|
|
if len(*subjects) != 1 || (*subjects)[0] != "mail" {
|
|
t.Fatalf("tell's subjects = %v, want \"mail\"", *subjects)
|
|
}
|
|
entry, _, _ := store.Entry(ctx, id)
|
|
if !entry.Told {
|
|
t.Fatal("the entry was not marked told")
|
|
}
|
|
}
|
|
|
|
// 1522/AC1: words messenger_check refuses are replaced by the safe form.
|
|
func TestSendSignalFallsBackToTheSafeFormWhenRefused(t *testing.T) {
|
|
ctx := context.Background()
|
|
store := newMemStore()
|
|
store.AddEntry(ctx, mattersEntry("Alice", "pay the bill"))
|
|
check, _ := recordingCheck(false)
|
|
tell, headlines, _ := recordingTell("sent")
|
|
|
|
if err := SendSignal(ctx, store, check, tell, time.Now); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(*headlines) != 1 {
|
|
t.Fatalf("tell called %d time(s), want 1", len(*headlines))
|
|
}
|
|
}
|
|
|
|
// 1522/AC2: a tell whose answer has sent_on non-empty marks the mails told; one with sent_on empty leaves
|
|
// them untold, and the next mail signal counts them (1429/AC4).
|
|
func TestSendSignalSentOnEmptyLeavesMailsUntold(t *testing.T) {
|
|
ctx := context.Background()
|
|
store := newMemStore()
|
|
id, _ := store.AddEntry(ctx, mattersEntry("Alice", "pay the bill"))
|
|
check, _ := recordingCheck(true)
|
|
tellDropped, calls, _ := recordingTell("") // every channel dropped it
|
|
|
|
if err := SendSignal(ctx, store, check, tellDropped, time.Now); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
entry, _, _ := store.Entry(ctx, id)
|
|
if entry.Told {
|
|
t.Fatal("the entry was marked told even though sent_on was empty")
|
|
}
|
|
if len(*calls) != 1 {
|
|
t.Fatalf("tell called %d time(s)", len(*calls))
|
|
}
|
|
|
|
// The next signal counts the still-untold mail (1429/AC4): SendSignal asks about it again.
|
|
tellSent, headlines, _ := recordingTell("sent-now")
|
|
if err := SendSignal(ctx, store, check, tellSent, time.Now); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(*headlines) != 1 {
|
|
t.Fatalf("the second signal was not sent")
|
|
}
|
|
entry, _, _ = store.Entry(ctx, id)
|
|
if !entry.Told {
|
|
t.Fatal("the entry was still not marked told after the second, successful signal")
|
|
}
|
|
}
|
|
|
|
// 1522/AC3: a batch whose only marks are scam warnings sends no tell.
|
|
func TestSendSignalSendsNothingForScamWarningsAlone(t *testing.T) {
|
|
ctx := context.Background()
|
|
store := newMemStore()
|
|
store.AddEntry(ctx, Entry{Account: "bob", Mark: MarkScamWarning, SenderName: "Eve", Summary: "a scam"})
|
|
check, asked := recordingCheck(true)
|
|
tell, headlines, _ := recordingTell("sent")
|
|
|
|
if err := SendSignal(ctx, store, check, tell, time.Now); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(*asked) != 0 || len(*headlines) != 0 {
|
|
t.Fatalf("messenger_check asked %v, tell called %d time(s); want neither for a scam-only batch", *asked, len(*headlines))
|
|
}
|
|
}
|
|
|
|
// 1522/AC3: no tell while the reader is unhealthy; the untold mails are counted by the first signal after
|
|
// recovery.
|
|
func TestSendSignalSendsNothingWhileUnhealthy(t *testing.T) {
|
|
ctx := context.Background()
|
|
store := newMemStore()
|
|
id, _ := store.AddEntry(ctx, mattersEntry("Alice", "pay the bill"))
|
|
store.SetReaderHealth(ctx, ReaderHealth{Healthy: false, Cause: OutcomeRefused, Why: "holds no licence"})
|
|
check, asked := recordingCheck(true)
|
|
tell, headlines, _ := recordingTell("sent")
|
|
|
|
if err := SendSignal(ctx, store, check, tell, time.Now); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(*asked) != 0 || len(*headlines) != 0 {
|
|
t.Fatalf("messenger_check asked %v, tell called %d time(s); want neither while unhealthy", *asked, len(*headlines))
|
|
}
|
|
|
|
// After recovery, the first signal counts the mail that went untold while unhealthy.
|
|
store.SetReaderHealth(ctx, ReaderHealth{Healthy: true})
|
|
if err := SendSignal(ctx, store, check, tell, time.Now); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(*headlines) != 1 {
|
|
t.Fatalf("after recovery, tell called %d time(s), want 1", len(*headlines))
|
|
}
|
|
entry, _, _ := store.Entry(ctx, id)
|
|
if !entry.Told {
|
|
t.Fatal("the entry was not marked told after recovery")
|
|
}
|
|
}
|
|
|
|
// 1522/AC4: the manifest invokes seat:operator-channel.tell and messenger.messenger_check.
|
|
func TestTheManifestInvokesTellAndMessengerCheck(t *testing.T) {
|
|
m, _ := readManifest(t)
|
|
found := map[string]bool{}
|
|
for _, inv := range m.Invokes {
|
|
found[inv] = true
|
|
}
|
|
for _, want := range []string{"seat:operator-channel.tell", "messenger.messenger_check"} {
|
|
if !found[want] {
|
|
t.Fatalf("invokes %v, want it to include %q", m.Invokes, want)
|
|
}
|
|
}
|
|
}
|
|
|
|
var signalNow = func() time.Time { return time.Date(2026, 10, 23, 21, 0, 0, 0, time.UTC) }
|
|
|
|
// 1926/AC1: the real operator-channel.tell answers sent_on as the list of channels that said it (messenger's
|
|
// own shape); read as a string it failed, nothing was marked told and every batch sent the same signal again.
|
|
func TestNewTellReadsSentOnAsMessengerAnswersIt(t *testing.T) {
|
|
for name, answer := range map[string]string{
|
|
"a list": `{"key":"tell.1","sent_on":["telegram"],"dropped":[]}`,
|
|
"a string": `{"sent_on":"telegram"}`,
|
|
} {
|
|
tell := NewTell(func(string, map[string]any) (json.RawMessage, error) { return json.RawMessage(answer), nil })
|
|
sentOn, err := tell("h", nil, "mail")
|
|
if err != nil || sentOn == "" {
|
|
t.Fatalf("1926/AC1: %s: sentOn = %q, err = %v, want a channel", name, sentOn, err)
|
|
}
|
|
}
|
|
for name, answer := range map[string]string{
|
|
"null": `{"sent_on":null,"dropped":["telegram"]}`,
|
|
"empty list": `{"sent_on":[],"dropped":["telegram"]}`,
|
|
"absent": `{"dropped":["telegram"]}`,
|
|
} {
|
|
tell := NewTell(func(string, map[string]any) (json.RawMessage, error) { return json.RawMessage(answer), nil })
|
|
sentOn, err := tell("h", nil, "mail")
|
|
if err != nil || sentOn != "" {
|
|
t.Fatalf("1926/AC1: %s: sentOn = %q, err = %v, want empty and no error", name, sentOn, err)
|
|
}
|
|
}
|
|
}
|
|
|
|
// 1926/AC1: a set already told is not sent again; a mail added to it is sent alone.
|
|
func TestSendSignalSendsAnUnchangedSetOnce(t *testing.T) {
|
|
ctx := context.Background()
|
|
store := newMemStore()
|
|
for i := 0; i < 3; i++ {
|
|
store.AddEntry(ctx, mattersEntry("Alice", "pay the bill"))
|
|
}
|
|
check, _ := recordingCheck(true)
|
|
tell, headlines, _ := recordingTell("telegram")
|
|
for i := 0; i < 3; i++ {
|
|
if err := SendSignal(ctx, store, check, tell, signalNow); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
if len(*headlines) != 1 || (*headlines)[0] != "3 mails matter" {
|
|
t.Fatalf("1926/AC1: tell headlines = %v, want one \"3 mails matter\"", *headlines)
|
|
}
|
|
store.AddEntry(ctx, mattersEntry("Bob", "new"))
|
|
if err := SendSignal(ctx, store, check, tell, signalNow); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(*headlines) != 2 || (*headlines)[1] != "1 mail matters" {
|
|
t.Fatalf("1926/AC1: tell headlines = %v, want the changed set sent again", *headlines)
|
|
}
|
|
}
|
|
|
|
// 1926/AC2: a mail whose due date has passed is not counted and does not lead; one due today still leads.
|
|
func TestSendSignalLeavesPastDueMailsOut(t *testing.T) {
|
|
ctx := context.Background()
|
|
store := newMemStore()
|
|
past := mattersEntry("bpost", "parcel notice")
|
|
past.DueDate = time.Date(2026, 10, 22, 0, 0, 0, 0, time.UTC)
|
|
today := mattersEntry("Alice", "pay today")
|
|
today.DueDate = time.Date(2026, 10, 23, 0, 0, 0, 0, time.UTC)
|
|
store.AddEntry(ctx, past)
|
|
store.AddEntry(ctx, today)
|
|
check, _ := recordingCheck(true)
|
|
var headlines []string
|
|
var lines [][]string
|
|
tell := func(h string, l []string, _ string) (string, error) {
|
|
headlines, lines = append(headlines, h), append(lines, l)
|
|
return "telegram", nil
|
|
}
|
|
if err := SendSignal(ctx, store, check, tell, signalNow); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(headlines) != 1 || headlines[0] != "1 mail matters" || len(lines[0]) != 1 || lines[0][0] != "Alice — pay today — due 2026-10-23" {
|
|
t.Fatalf("1926/AC2: told %v %v, want only Alice's mail due today", headlines, lines)
|
|
}
|
|
|
|
// A set holding only past-due mails says nothing.
|
|
store2 := newMemStore()
|
|
store2.AddEntry(ctx, past)
|
|
headlines = nil
|
|
if err := SendSignal(ctx, store2, check, tell, signalNow); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(headlines) != 0 {
|
|
t.Fatalf("1926/AC2: told %v for past-due mails alone, want nothing", headlines)
|
|
}
|
|
}
|
|
|
|
// 1926/AC3: the same notice in two languages (same sender, same day, same due date) counts once, and so does
|
|
// the same message id in two folders (issue 1849); two different mails of one sender stay two.
|
|
func TestSendSignalCountsADuplicateNoticeOnce(t *testing.T) {
|
|
ctx := context.Background()
|
|
store := newMemStore()
|
|
due := time.Date(2026, 10, 30, 0, 0, 0, 0, time.UTC)
|
|
day := time.Date(2026, 10, 21, 8, 0, 0, 0, time.UTC)
|
|
en := Entry{Account: "bob", MessageID: "<a>", SenderName: "bpost", SenderAddress: "no-reply@bpost.example", Summary: "parcel waiting", Mark: MarkMatters, Date: day, DueDate: due}
|
|
nl := en
|
|
nl.MessageID, nl.Summary, nl.Date = "<b>", "pakje wacht", day.Add(time.Minute)
|
|
inbox := Entry{Account: "bob", MessageID: "<c>", Folder: "INBOX", SenderName: "Carol", SenderAddress: "carol@x.example", Summary: "hello", Mark: MarkMatters, Date: day}
|
|
allMail := inbox
|
|
allMail.Folder = "All Mail"
|
|
other := Entry{Account: "bob", MessageID: "<d>", SenderName: "Carol", SenderAddress: "carol@x.example", Summary: "second", Mark: MarkMatters, Date: day}
|
|
for _, e := range []Entry{en, nl, inbox, allMail, other} {
|
|
store.AddEntry(ctx, e)
|
|
}
|
|
check, _ := recordingCheck(true)
|
|
tell, headlines, _ := recordingTell("telegram")
|
|
if err := SendSignal(ctx, store, check, tell, signalNow); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(*headlines) != 1 || (*headlines)[0] != "3 mails matter" {
|
|
t.Fatalf("1926/AC3: tell headlines = %v, want \"3 mails matter\"", *headlines)
|
|
}
|
|
}
|