audit 4: cards ordered by number, a notification owed to each recipient
test / test (push) Canceled after 0s

The cards layout (owner, 2026-10-13; the mockup first, b90a2f0): box_open
writes data-order for a box with InCards and a number, and panel.css orders
by it up to twelve - Health's eight and the kit's three.
TestCardsBoxesCarryTheirNumberAsTheirOrder reads the rendered pages.

Notifications (owner, 2026-10-14): migration 0011, notify_addressees. open
sends a subject's first message to every recipient and records who was
handed it; followUp, on every check while the subject is open and still
wrong, hands it to the owed and untold who are still recipients; closed
sends the end to the told (all recipients for a subject from before the
table). Tests: a failed copy goes out at the next check, once; the hourly
ceiling holds one back; a user added mid-subject hears nothing of it.

Also in the roadmap, from the owner today: dns-record-collapse,
notification-mail-recheck, settings-save-row (agreed, design first).

Tested on the test server: go vet + go test of 25 packages, make e2e (all
pass, 644 s) on this tree.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
mix
2026-10-15 11:49:50 +03:00
co-authored by Claude Opus 5.5
parent b90a2f0321
commit eee089ad4b
17 changed files with 538 additions and 74 deletions
+78 -7
View File
@@ -2,6 +2,7 @@ package notify
import (
"errors"
"fmt"
"path/filepath"
"strings"
"testing"
@@ -15,13 +16,14 @@ import (
// outbox collects what would have been handed to Postfix.
type outbox struct {
sent []string // "to|subject"
body []string
fail bool
sent []string // "to|subject"
body []string
fail bool
failTo map[string]bool // addresses whose copy fails
}
func (o *outbox) Send(to, subject, body string) error {
if o.fail {
if o.fail || o.failTo[to] {
return errors.New("sendmail: queue file write error")
}
o.sent = append(o.sent, to+"|"+subject)
@@ -474,16 +476,16 @@ func TestQueueLeavesOutTheNotifiersOwnMail(t *testing.T) {
func TestHourlyCeiling(t *testing.T) {
b := newBench(t)
for i := 0; i < hourlyCeiling+5; i++ {
b.n.send("", message{subject: "s", body: "b"})
b.n.open(fmt.Sprintf("test:%d", i), "", message{subject: "s", body: "b"})
}
if got := len(b.out.take()); got != hourlyCeiling {
t.Errorf("%d messages left in one hour, ceiling is %d", got, hourlyCeiling)
}
if b.n.send("", message{subject: "s", body: "b"}) != failed {
if b.n.open("test:x", "", message{subject: "s", body: "b"}) != failed {
t.Error("a message over the ceiling was counted as said")
}
b.now = b.now.Add(61 * time.Minute)
if b.n.send("", message{subject: "s", body: "b"}) != sent {
if b.n.open("test:y", "", message{subject: "s", body: "b"}) != sent {
t.Error("the ceiling did not lift after an hour")
}
}
@@ -548,3 +550,72 @@ func TestMailerComposesAndRefuses(t *testing.T) {
}
}
}
// A notification is owed to each recipient (owner, 2026-10-14): a copy that
// failed goes out at the next check, once, and the subject's end goes to
// those who were told.
func TestACopyThatFailedGoesOutAtTheNextCheck(t *testing.T) {
b := newBench(t)
b.dns["example.com"] = badDKIM()
problem := "[SelfPost] example.com: DKIM does not match"
b.out.failTo = map[string]bool{"mine@example.org": true}
want(t, "first bad check", b.hour())
want(t, "said, one copy failing", b.hour(), "root@example.org|"+problem)
b.out.failTo = nil
want(t, "the next check", b.hour(), "mine@example.org|"+problem)
want(t, "and not again", b.hour())
b.dns["example.com"] = good()
want(t, "first good check", b.hour())
cleared := "[SelfPost] example.com: DNS records match again"
want(t, "over", b.hour(), "mine@example.org|"+cleared, "root@example.org|"+cleared)
}
// The hourly ceiling holds a copy back instead of dropping it: it goes out at
// a check after the hour.
func TestTheCeilingHoldsACopyBack(t *testing.T) {
b := newBench(t)
b.dns["example.com"] = badDKIM()
want(t, "first bad check", b.hour())
// All but one of the hour's messages are spent.
for i := 0; i < hourlyCeiling-1; i++ {
b.n.sentAt = append(b.n.sentAt, b.now)
}
problem := "[SelfPost] example.com: DKIM does not match"
got := b.hour()
if len(got) != 1 {
t.Fatalf("under the ceiling with room for one: sent %v", got)
}
want(t, "the next check, the hour over", b.hour(), map[string]string{
"mine@example.org|" + problem: "root@example.org|" + problem,
"root@example.org|" + problem: "mine@example.org|" + problem,
}[got[0]])
want(t, "and not again", b.hour())
}
// A user who comes to hear of a subject while it is open is not mailed about
// it, nor about its end: the message was owed to those there when it was
// said.
func TestAUserAddedWhileASubjectIsOpenIsNotMailed(t *testing.T) {
b := newBench(t)
b.dns["example.com"] = badDKIM()
want(t, "first bad check", b.hour())
problem := "[SelfPost] example.com: DKIM does not match"
want(t, "said", b.hour(), "mine@example.org|"+problem, "root@example.org|"+problem)
id, err := b.st.CreateUser("late", "hash", store.RoleGlobal, store.Reach{})
if err != nil {
t.Fatal(err)
}
if err := b.st.UpdateUser(id, "late", "hash", "late@example.org"); err != nil {
t.Fatal(err)
}
want(t, "still wrong, a user added", b.hour())
b.dns["example.com"] = good()
want(t, "first good check", b.hour())
cleared := "[SelfPost] example.com: DNS records match again"
want(t, "over", b.hour(), "mine@example.org|"+cleared, "root@example.org|"+cleared)
if owed, _ := b.st.NotifyAddressees("dns:example.com"); len(owed) != 0 {
t.Errorf("who the subject was owed to outlived it: %v", owed)
}
}