Following the design commit df96690.
- Two columns (owner, 2026-10-14): sender_check_days.messages (migration
0009, not deployed) holds the messages deferred mid-message, from
cleanup's warning; deferred stays the connections smtpd turned away.
AddSenderCheckDeferred takes which; the counter passes cleanup's as a
message. The card has "Connections turned away" and "Messages deferred";
it warns on either within 24 hours (one mark, as before).
- Supervisord silent at start: postfixStarted asks again every 2 s for 30 s
(postfixState: started, running, known). With no answer the downtime is
the time since the alive mark - an upper bound - booked with estimated = 1
(sender_check_days.estimated, MAX over a day); the card prefixes such a
period's time with "≈ " and its note says what ≈ means. Before, such a
start counted nothing and the number the owner decides by read low.
- Help topic sender-check and the log topic word for word as the mockup
(help.go).
Tests: store (connections and messages apart, a period with an estimated
day); logtail (cleanup's warnings are messages); cmd/panel (supervisord
silent: the time since the mark, estimated); view fixture and the Health
page at four columns with ≈; e2e reads the row's two counts (panel down:
connections; panel hung: messages). Schema at 0009 with the columns.
schema-migrations row 9, CHANGELOG [Unreleased], audit plan lines ticked
(two), roadmap 28/62.
Evidence: go vet ./... clean; go test of store, view, cmd/panel ok locally,
logtail but for the Windows-only test; the server run follows before the
push.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
160 lines
5.6 KiB
Go
160 lines
5.6 KiB
Go
package store
|
|
|
|
import (
|
|
"path/filepath"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
func TestSenderCheckStats(t *testing.T) {
|
|
st, err := Open(filepath.Join(t.TempDir(), "selfpost.db"))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer st.Close()
|
|
|
|
now := time.Date(2026, 10, 20, 15, 0, 0, 0, time.UTC)
|
|
day := func(n int) time.Time { return now.AddDate(0, 0, -n) }
|
|
|
|
// Nothing counted yet: zeros, and no first day.
|
|
got, err := st.SenderCheckStats(now)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if got != (SenderCheckStats{}) {
|
|
t.Fatalf("empty statistics = %+v", got)
|
|
}
|
|
|
|
// The first day is recorded once.
|
|
if err := st.StartSenderCheckStats(day(40)); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := st.StartSenderCheckStats(now); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
must := func(err error) {
|
|
t.Helper()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
must(st.AddSenderCheckDown(now, 3*time.Second, false))
|
|
must(st.AddSenderCheckDown(now, 1500*time.Millisecond, false)) // whole seconds: one
|
|
must(st.AddSenderCheckDown(now, 400*time.Millisecond, false)) // nothing
|
|
must(st.AddSenderCheckDeferred(now.Add(-time.Hour), 2, "", false))
|
|
must(st.AddSenderCheckDown(day(10), 30*time.Second, false))
|
|
must(st.AddSenderCheckDeferred(day(10), 1, "", false))
|
|
must(st.AddSenderCheckDown(day(29), time.Minute, false))
|
|
must(st.AddSenderCheckDown(day(30), time.Hour, false)) // the 31st day back: the year's only
|
|
must(st.AddSenderCheckDeferred(day(200), 5, "", false))
|
|
must(st.AddSenderCheckDown(day(365), 24*time.Hour, false)) // a year and a day: nobody's
|
|
|
|
got, err = st.SenderCheckStats(now)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
want := SenderCheckStats{
|
|
Today: SenderCheckTotals{Down: 4 * time.Second, Deferred: 2},
|
|
Month: SenderCheckTotals{Down: 94 * time.Second, Deferred: 3},
|
|
Year: SenderCheckTotals{Down: 94*time.Second + time.Hour, Deferred: 8},
|
|
Since: time.Date(2026, 9, 10, 0, 0, 0, 0, time.UTC),
|
|
DeferredAt: now.Add(-time.Hour),
|
|
}
|
|
if got != want {
|
|
t.Fatalf("statistics\n got %+v\nwant %+v", got, want)
|
|
}
|
|
|
|
// The last deferral is the latest one seen, not the last one written: a
|
|
// log read again after an unclean stop does not move it back.
|
|
if got.DeferredAt != now.Add(-time.Hour) {
|
|
t.Fatalf("an older deferral moved the mark to %s", got.DeferredAt)
|
|
}
|
|
|
|
// A year on, the old days are gone.
|
|
must(st.PruneSenderCheckDays(now.AddDate(1, 0, 0)))
|
|
var n int
|
|
if err := st.db.QueryRow(`SELECT COUNT(*) FROM sender_check_days`).Scan(&n); err != nil || n != 1 {
|
|
t.Fatalf("%d days left after a year's prune (%v), want the newest alone", n, err)
|
|
}
|
|
}
|
|
|
|
// A refused row keeps why it was refused and, for its From: line, what the
|
|
// line said; an accepted row has neither.
|
|
func TestRefusedRowKeepsItsReason(t *testing.T) {
|
|
st, err := Open(filepath.Join(t.TempDir(), "selfpost.db"))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer st.Close()
|
|
|
|
if err := st.InsertRejected(SendLogEntry{Domain: "shop.example.org", AppLogin: "shop", From: "shop@shop.example.org",
|
|
To: "k.berg@web.de", Subject: "s", Reason: ReasonFrom, HeaderFrom: "director@example.org"}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := st.InsertQueued(SendLogEntry{QueueID: "4XcB7k2Jm9z1", Domain: "shop.example.org", AppLogin: "shop",
|
|
From: "shop@shop.example.org", To: "k.berg@web.de"}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
rows, err := st.QuerySendLog(SendLogFilter{AllDomains: true}, 10, 0)
|
|
if err != nil || len(rows) != 2 {
|
|
t.Fatalf("rows = %d, %v", len(rows), err)
|
|
}
|
|
queued, refused := rows[0], rows[1]
|
|
if refused.Status != StatusRejected || refused.Reason != ReasonFrom || refused.HeaderFrom != "director@example.org" || refused.To != "k.berg@web.de" {
|
|
t.Errorf("refused row = %+v", refused)
|
|
}
|
|
if queued.Reason != "" || queued.HeaderFrom != "" {
|
|
t.Errorf("queued row carries a refusal: %+v", queued)
|
|
}
|
|
one, err := st.GetSendLog(refused.ID)
|
|
if err != nil || one.Reason != ReasonFrom || one.HeaderFrom != "director@example.org" {
|
|
t.Errorf("GetSendLog = %+v, %v", one, err)
|
|
}
|
|
}
|
|
|
|
// The place in the log is saved with the count, and the moment of the last
|
|
// deferral does not move back when a log is read again.
|
|
func TestSenderCheckDeferredSavesItsPlace(t *testing.T) {
|
|
st := openTestStore(t)
|
|
later := time.Date(2026, 10, 14, 12, 0, 1, 0, time.UTC)
|
|
if err := st.AddSenderCheckDeferred(later, 1, "abc:1.2@100", false); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := st.AddSenderCheckDeferred(later.Add(-time.Hour), 1, "abc:1.2@200", false); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if p, err := st.SenderCheckCountedPlace(); err != nil || p != "abc:1.2@200" {
|
|
t.Fatalf("place = %q, %v", p, err)
|
|
}
|
|
if at, err := st.SenderCheckDeferredAt(); err != nil || !at.Equal(later) {
|
|
t.Fatalf("last deferral = %s, %v; want %s", at, err, later)
|
|
}
|
|
}
|
|
|
|
// Connections turned away and messages deferred in the middle are counted
|
|
// apart (the owner, 2026-10-14), and a period holding a day whose downtime is
|
|
// an estimate says so.
|
|
func TestSenderCheckCountsConnectionsAndMessagesApart(t *testing.T) {
|
|
st := openTestStore(t)
|
|
now := time.Date(2026, 10, 14, 12, 0, 0, 0, time.UTC)
|
|
must := func(err error) {
|
|
t.Helper()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
must(st.AddSenderCheckDeferred(now, 3, "", false))
|
|
must(st.AddSenderCheckDeferred(now, 1, "", true))
|
|
must(st.AddSenderCheckDown(now.AddDate(0, 0, -3), 20*time.Second, true))
|
|
must(st.AddSenderCheckDown(now.AddDate(0, 0, -3), 5*time.Second, false))
|
|
got, err := st.SenderCheckStats(now)
|
|
must(err)
|
|
if got.Today.Deferred != 3 || got.Today.Messages != 1 || got.Today.Estimated {
|
|
t.Errorf("today = %+v, want 3 connections, 1 message, not estimated", got.Today)
|
|
}
|
|
if got.Month.Down != 25*time.Second || !got.Month.Estimated {
|
|
t.Errorf("the month = %+v, want 25 s, estimated", got.Month)
|
|
}
|
|
}
|