Files
Maven/internal/delivery/ntfysink/ntfysink_test.go
T
2026-07-03 00:32:48 +02:00

314 lines
8.7 KiB
Go

package ntfysink
import (
"context"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/kami/maven/internal/delivery"
"github.com/kami/maven/internal/loop"
)
// recordingServer — captures the last request so tests assert the wire shape.
type recordingServer struct {
mu chan struct{}
method string
path string
body string
auth string
title string
prio string
status int
respond string
}
func newRecordingServer(t *testing.T, status int, respond string) *recordingServer {
t.Helper()
rs := &recordingServer{status: status, respond: respond, mu: make(chan struct{}, 1)}
rs.mu <- struct{}{}
return rs
}
func (rs *recordingServer) handler() http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
b, _ := io.ReadAll(r.Body)
<-rs.mu
rs.method = r.Method
rs.path = r.URL.Path
rs.body = string(b)
rs.auth = r.Header.Get("Authorization")
rs.title = r.Header.Get("Title")
rs.prio = r.Header.Get("Priority")
rs.mu <- struct{}{}
w.WriteHeader(rs.status)
if rs.respond != "" {
_, _ = w.Write([]byte(rs.respond))
}
})
}
func (rs *recordingServer) snapshot() (method, path, body, auth, title, prio string) {
<-rs.mu
method, path, body, auth, title, prio = rs.method, rs.path, rs.body, rs.auth, rs.title, rs.prio
rs.mu <- struct{}{}
return
}
func nudgeSendable(sev loop.Severity, summary string) delivery.Sendable {
return delivery.Sendable{
Channel: delivery.ChannelNtfy,
Kind: delivery.KindNudge,
Severity: sev,
RuleName: "service_down",
Body: "the backup service on homesrv is down - check journalctl",
Summary: summary,
Ts: time.Now(),
}
}
func reminderSendable(summary string) delivery.Sendable {
return delivery.Sendable{
Channel: delivery.ChannelNtfy,
Kind: delivery.KindReminder,
ReminderID: 42,
Body: "full reminder body with detail",
Summary: summary,
Ts: time.Now(),
}
}
// ----------------------------- config ---------------------------------------
func TestNewRejectsEmptyBaseURL(t *testing.T) {
_, err := New(Config{Topic: "maven"})
if err == nil {
t.Fatal("want error for empty BaseURL")
}
}
func TestNewRejectsEmptyTopic(t *testing.T) {
_, err := New(Config{BaseURL: "http://localhost:8085"})
if err == nil {
t.Fatal("want error for empty Topic")
}
}
func TestNewDefaultTimeout(t *testing.T) {
s, err := New(Config{BaseURL: "http://localhost:8085", Topic: "maven"})
if err != nil {
t.Fatalf("New: %v", err)
}
if s.hc.Timeout != DefaultTimeout {
t.Fatalf("default timeout: want %v, got %v", DefaultTimeout, s.hc.Timeout)
}
}
// ----------------------------- send shape ----------------------------------
func TestSendPostsToTopicPath(t *testing.T) {
rs := newRecordingServer(t, 200, "")
srv := httptest.NewServer(rs.handler())
defer srv.Close()
sink, err := New(Config{BaseURL: srv.URL, Topic: "maven"})
if err != nil {
t.Fatalf("New: %v", err)
}
if err := sink.Send(context.Background(), nudgeSendable(loop.Sev3, "cert expiring soon")); err != nil {
t.Fatalf("Send: %v", err)
}
method, path, _, _, _, _ := rs.snapshot()
if method != http.MethodPost {
t.Fatalf("method: want POST, got %s", method)
}
if path != "/maven" {
t.Fatalf("path: want /maven, got %s", path)
}
}
func TestSendBodyIsSummaryNotFullBody(t *testing.T) {
// the minimal-body rule: away channels get Summary, never Body. the sink
// must post Summary so a phraser bug (Body leaking detail) can't exfil.
rs := newRecordingServer(t, 200, "")
srv := httptest.NewServer(rs.handler())
defer srv.Close()
sink, _ := New(Config{BaseURL: srv.URL, Topic: "maven"})
if err := sink.Send(context.Background(), nudgeSendable(loop.Sev3, "cert expiring soon")); err != nil {
t.Fatalf("Send: %v", err)
}
_, _, body, _, _, _ := rs.snapshot()
if body != "cert expiring soon" {
t.Fatalf("body: want summary 'cert expiring soon', got %q", body)
}
}
func TestSendFallsBackToBodyWhenSummaryEmpty(t *testing.T) {
// a terse full message is better than no message; the phraser should
// produce a summary for away-bound severities, but don't silently drop.
rs := newRecordingServer(t, 200, "")
srv := httptest.NewServer(rs.handler())
defer srv.Close()
s := nudgeSendable(loop.Sev3, "")
sink, _ := New(Config{BaseURL: srv.URL, Topic: "maven"})
if err := sink.Send(context.Background(), s); err != nil {
t.Fatalf("Send: %v", err)
}
_, _, body, _, _, _ := rs.snapshot()
if body != s.Body {
t.Fatalf("fallback body: want %q, got %q", s.Body, body)
}
}
func TestSendRejectsEmptyMessage(t *testing.T) {
rs := newRecordingServer(t, 200, "")
srv := httptest.NewServer(rs.handler())
defer srv.Close()
sink, _ := New(Config{BaseURL: srv.URL, Topic: "maven"})
s := nudgeSendable(loop.Sev3, "")
s.Body = ""
err := sink.Send(context.Background(), s)
if err == nil {
t.Fatal("want error for empty message")
}
}
func TestSendSetsBasicAuth(t *testing.T) {
rs := newRecordingServer(t, 200, "")
srv := httptest.NewServer(rs.handler())
defer srv.Close()
sink, _ := New(Config{
BaseURL: srv.URL,
Topic: "maven",
Username: "maven",
Password: "secret",
})
if err := sink.Send(context.Background(), nudgeSendable(loop.Sev3, "down")); err != nil {
t.Fatalf("Send: %v", err)
}
_, _, _, auth, _, _ := rs.snapshot()
if auth == "" {
t.Fatal("want Basic auth header, got empty")
}
if !strings.HasPrefix(auth, "Basic ") {
t.Fatalf("auth: want 'Basic ...', got %q", auth)
}
}
func TestSendNoAuthWhenUsernameEmpty(t *testing.T) {
rs := newRecordingServer(t, 200, "")
srv := httptest.NewServer(rs.handler())
defer srv.Close()
sink, _ := New(Config{BaseURL: srv.URL, Topic: "maven"})
if err := sink.Send(context.Background(), nudgeSendable(loop.Sev3, "down")); err != nil {
t.Fatalf("Send: %v", err)
}
_, _, _, auth, _, _ := rs.snapshot()
if auth != "" {
t.Fatalf("want no auth header, got %q", auth)
}
}
func TestSendTitleIsMaven(t *testing.T) {
rs := newRecordingServer(t, 200, "")
srv := httptest.NewServer(rs.handler())
defer srv.Close()
sink, _ := New(Config{BaseURL: srv.URL, Topic: "maven"})
if err := sink.Send(context.Background(), nudgeSendable(loop.Sev3, "down")); err != nil {
t.Fatalf("Send: %v", err)
}
_, _, _, _, title, _ := rs.snapshot()
if title != "maven" {
t.Fatalf("title: want 'maven', got %q", title)
}
}
// ----------------------------- priority mapping ----------------------------
func TestPrioritySev3IsHigh(t *testing.T) {
rs := newRecordingServer(t, 200, "")
srv := httptest.NewServer(rs.handler())
defer srv.Close()
sink, _ := New(Config{BaseURL: srv.URL, Topic: "maven"})
_ = sink.Send(context.Background(), nudgeSendable(loop.Sev3, "cert"))
_, _, _, _, _, prio := rs.snapshot()
if prio != "4" {
t.Fatalf("sev3 priority: want 4 (high), got %s", prio)
}
}
func TestPrioritySev4IsMax(t *testing.T) {
rs := newRecordingServer(t, 200, "")
srv := httptest.NewServer(rs.handler())
defer srv.Close()
sink, _ := New(Config{BaseURL: srv.URL, Topic: "maven"})
_ = sink.Send(context.Background(), nudgeSendable(loop.Sev4, "down"))
_, _, _, _, _, prio := rs.snapshot()
if prio != "5" {
t.Fatalf("sev4 priority: want 5 (max), got %s", prio)
}
}
func TestPriorityReminderIsHigh(t *testing.T) {
rs := newRecordingServer(t, 200, "")
srv := httptest.NewServer(rs.handler())
defer srv.Close()
sink, _ := New(Config{BaseURL: srv.URL, Topic: "maven"})
_ = sink.Send(context.Background(), reminderSendable("wake up"))
_, _, _, _, _, prio := rs.snapshot()
if prio != "4" {
t.Fatalf("reminder priority: want 4 (high), got %s", prio)
}
}
// ----------------------------- error handling ------------------------------
func TestSendReturnsErrorOnNon2xx(t *testing.T) {
rs := newRecordingServer(t, http.StatusForbidden, `{"error":"forbidden"}`)
srv := httptest.NewServer(rs.handler())
defer srv.Close()
sink, _ := New(Config{BaseURL: srv.URL, Topic: "maven"})
err := sink.Send(context.Background(), nudgeSendable(loop.Sev3, "down"))
if err == nil {
t.Fatal("want error on 403")
}
if !strings.Contains(err.Error(), "403") {
t.Fatalf("error should mention status 403, got: %v", err)
}
}
func TestSendContextCancelReturnsError(t *testing.T) {
rs := newRecordingServer(t, 200, "")
srv := httptest.NewServer(rs.handler())
defer srv.Close()
sink, _ := New(Config{BaseURL: srv.URL, Topic: "maven"})
ctx, cancel := context.WithTimeout(context.Background(), 1*time.Nanosecond)
defer cancel()
err := sink.Send(ctx, nudgeSendable(loop.Sev3, "down"))
if err == nil {
t.Fatal("want error on canceled context")
}
}
func TestSendConnectionRefusedReturnsError(t *testing.T) {
sink, _ := New(Config{BaseURL: "http://127.0.0.1:1", Topic: "maven", Timeout: time.Second})
err := sink.Send(context.Background(), nudgeSendable(loop.Sev3, "down"))
if err == nil {
t.Fatal("want error on connection refused")
}
}