Files
selfpost/internal/web/handlers/handlers_queue_test.go
T
mixandClaude Opus 5.5 6db3704711 queue-table: Outbound › Queue as the drawn table, Inbound › Queue built
Owner, 2026-10-18: "Фиаско, что очередь не была сделана. Как мы это
допустили? Исправляем!" The mockups out-queue and in-queue (design 629de5e,
guards 8525e8e).

- postfix: postqueue -j gives each message's size and each recipient's
  delay_reason (src/postqueue/showq_json.c, format_json(): "delay_reason"
  only when LEN(why) > 0); QueueListing reads it with the page's 5 s,
  QueueMessages keeps the notifier's 30 s.
- view: one Queue page type for both directions (queue.go), one polled box
  (queue_body.html): a row per recipient, oldest first, cut at 200 with a
  foot; the head "N messages · size", "Refreshes on its own" while empty;
  Last reply is the delay reason, or where the message is (active, hold,
  incoming) before there was one. Inbound has no Details: an inbound
  message has no page of its own.
- handlers: one read of the queue, split by the recipient's domain against
  the inbound domains; an outbound recipient is linked to its send-log row
  (store.SendLogRefsByQueueID, through idx_send_log_queue_id, plan pinned in
  TestSendLogQueriesUseTheirIndexes), matched without regard to case, else
  the message's first row. Both pages are the global role's; the inbound
  one 404s with the relay off. Routes /inbound/queue (+ /fragment), menu,
  touch menu and sibling strip.
- Overview: Queues · Both empty · outbound and inbound with the relay on.
- Help: the queue topic rewritten, an Inbound queue topic (word for word with
  the mockup; domain administrators do not see it, as with the lists).
- TestUnbuiltScreensAreOnTheRoadmap and its rule in development.md: an
  exempted screen must be named in roadmap.md; queue-table leaves the
  roadmap; guide, architecture, panel-redesign.md updated.

Tests: go vet; go test ./internal/web/... ./internal/store ./internal/postfix
./internal/notify pass.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 23:17:14 +03:00

111 lines
4.9 KiB
Go

package handlers
import (
"errors"
"net/http"
"strconv"
"strings"
"testing"
"time"
"github.com/mixeme/selfpost/internal/postfix"
"github.com/mixeme/selfpost/internal/store"
"github.com/mixeme/selfpost/internal/web/auth"
)
// The two queues are one Postfix queue split by the recipient's domain. Their
// markup is held to the mockups by the guards in internal/web/view; what is
// checked here is the split, the link to the send-log row, and who may look.
// stubQueue answers queueListing with msgs (or err) for the test.
func stubQueue(t *testing.T, msgs []postfix.QueuedMessage, err error) {
t.Helper()
old := queueListing
t.Cleanup(func() { queueListing = old })
queueListing = func() ([]postfix.QueuedMessage, error) { return msgs, err }
}
func TestQueuesSplitByRecipientDomain(t *testing.T) {
s := newInboundLogStand(t) // inbound domains a.example.com, b.example.com
if err := s.st.InsertQueued(store.SendLogEntry{QueueID: "OUT00001", Domain: "shop.example.org", AppLogin: "shop",
From: "alerts@shop.example.org", To: "noc@example.net", Subject: "Hi"}); err != nil {
t.Fatal(err)
}
rows, err := s.st.QuerySendLog(store.SendLogFilter{AllDomains: true}, 1, 0)
if err != nil || len(rows) != 1 {
t.Fatalf("query: %v (%d rows)", err, len(rows))
}
at := time.Date(2026, 10, 18, 9, 0, 0, 0, time.UTC)
stubQueue(t, []postfix.QueuedMessage{
{ID: "OUT00001", Queue: "deferred", Sender: "alerts@shop.example.org", Arrival: at, Size: 3174,
Recipients: []postfix.QueuedRecipient{{Address: "NOC@example.net", DelayReason: "451 4.7.1 Greylisted"}}},
{ID: "IN000001", Queue: "deferred", Sender: "someone@example.net", Arrival: at, Size: 5000,
Recipients: []postfix.QueuedRecipient{{Address: "alice@A.example.com", DelayReason: "connect to upstream.example.com[192.0.2.7]:25: Connection refused"}}},
{ID: "NOTE0001", Queue: "active", Sender: "postmaster@mail.example.org", Arrival: at, Size: 900,
Recipients: []postfix.QueuedRecipient{{Address: "admin@example.net"}}},
}, nil)
out := s.get(s.h.HandleMailQueue, "/outbound/queue", globalPrincipal).Body.String()
has(t, "Outbound queue", out, "OUT00001", "451 4.7.1 Greylisted",
`<a href="/outbound/log/`+strconv.FormatInt(rows[0].ID, 10)+`">Details</a>`, // matched without regard to case
"NOTE0001", "Being delivered now", "2 messages")
if strings.Contains(out, "IN000001") {
t.Error("a recipient of an inbound domain is on the Outbound queue")
}
if strings.Count(out, ">Details</a>") != 1 {
t.Error("the panel's own message has no send-log row and should have no Details link")
}
in := s.get(s.h.HandleInboundQueue, "/inbound/queue", globalPrincipal).Body.String()
has(t, "Inbound queue", in, "IN000001", "Connection refused", "1 message · 4.9 kB")
for _, other := range []string{"OUT00001", "NOTE0001", ">Details</a>"} {
if strings.Contains(in, other) {
t.Errorf("the Inbound queue shows %q", other)
}
}
frag := s.get(s.h.HandleInboundQueueBody, "/inbound/queue/fragment", globalPrincipal).Body.String()
if !strings.HasPrefix(frag, `<div class="box" id="in-queue" data-poll`) || !strings.Contains(frag, "IN000001") {
t.Errorf("the Inbound queue's fragment is not its polled box:\n%.300s", frag)
}
}
// A queue that cannot be read says so on both pages, and never reads as empty.
func TestQueuesSayWhenTheyCannotBeRead(t *testing.T) {
s := newInboundLogStand(t)
stubQueue(t, nil, errors.New("postqueue: Permission denied"))
for _, page := range []struct {
name string
h http.HandlerFunc
}{{"/outbound/queue", s.h.HandleMailQueue}, {"/inbound/queue", s.h.HandleInboundQueue}} {
body := s.get(page.h, page.name, globalPrincipal).Body.String()
has(t, page.name, body, "Could not read the mail queue.")
if strings.Contains(body, "Nothing is waiting") || strings.Contains(body, "Permission denied") {
t.Errorf("%s reads as empty or shows the error itself", page.name)
}
}
}
// Both queues are the global role's: a domain administrator is answered 404,
// whatever they reach; with the relay off the Inbound queue does not exist.
func TestQueuesAreForTheGlobalRoleOnly(t *testing.T) {
s := newInboundLogStand(t)
stubQueue(t, nil, nil)
ops := s.owner(t, "owns-a", s.a)
all := userWith(t, s.st, "owns-all", store.RoleDomain, store.Reach{AllInbound: true, AllDomains: true})
for _, p := range []auth.Principal{ops, all} {
for target, h := range map[string]http.HandlerFunc{
"/inbound/queue": s.h.HandleInboundQueue, "/inbound/queue/fragment": s.h.HandleInboundQueueBody,
"/outbound/queue": s.h.HandleMailQueue, "/outbound/queue/fragment": s.h.HandleMailQueueBody,
} {
if rec := s.get(h, target, p); rec.Code != http.StatusNotFound {
t.Errorf("GET %s as %s = %d, want 404", target, p.Username, rec.Code)
}
}
}
s.h.cfg.InboundEnabled = false
if rec := s.get(s.h.HandleInboundQueue, "/inbound/queue", globalPrincipal); rec.Code != http.StatusNotFound {
t.Errorf("GET /inbound/queue with the relay off = %d, want 404", rec.Code)
}
}