test: add tests for handler, email, cmd/server, version and enforce 80% coverage
This commit is contained in:
@@ -49,6 +49,11 @@ jobs:
|
||||
- name: go test -race
|
||||
run: go test -race -count=1 ./...
|
||||
|
||||
- name: Check coverage threshold
|
||||
run: |
|
||||
go test -coverprofile=coverage.out ./internal/... ./pkg/...
|
||||
go tool cover -func=coverage.out | grep '^total:' | python3 -c "import sys; pct=float(sys.stdin.read().split()[2].rstrip('%')); sys.exit(0 if pct >= 80 else 1)" || (echo "ERROR: coverage < 80%"; exit 1)
|
||||
|
||||
- name: go test ./examples
|
||||
run: go build ./examples/...
|
||||
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
//go:build linux || freebsd
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestItoa(t *testing.T) {
|
||||
tests := []struct {
|
||||
n int
|
||||
want string
|
||||
}{
|
||||
{0, "0"},
|
||||
{1, "1"},
|
||||
{9, "9"},
|
||||
{10, "10"},
|
||||
{42, "42"},
|
||||
{100, "100"},
|
||||
{-1, "-1"},
|
||||
{-42, "-42"},
|
||||
{8080, "8080"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.want, func(t *testing.T) {
|
||||
if got := itoa(tt.n); got != tt.want {
|
||||
t.Errorf("itoa(%d) = %q, want %q", tt.n, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestLast(t *testing.T) {
|
||||
tests := []struct {
|
||||
s string
|
||||
c byte
|
||||
want int
|
||||
}{
|
||||
{"hello", 'l', 3},
|
||||
{"hello", 'o', 4},
|
||||
{"hello", 'h', 0},
|
||||
{"hello", 'x', -1},
|
||||
{"", 'a', -1},
|
||||
{"aaa", 'a', 2},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
if got := last(tt.s, tt.c); got != tt.want {
|
||||
t.Errorf("last(%q, %q) = %d, want %d", tt.s, tt.c, got, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestClientIP(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
remoteAddr string
|
||||
want string
|
||||
}{
|
||||
{"ipv4 with port", "192.168.1.1:12345", "192.168.1.1"},
|
||||
{"ipv6 with brackets", "[::1]:12345", "[::1]"},
|
||||
{"ipv4 without port", "192.168.1.1", "192.168.1.1"},
|
||||
{"empty", "", ""},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
req.RemoteAddr = tt.remoteAddr
|
||||
if got := clientIP(req); got != tt.want {
|
||||
t.Errorf("clientIP() = %q, want %q", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestStatusRecorder(t *testing.T) {
|
||||
rec := httptest.NewRecorder()
|
||||
sr := &statusRecorder{ResponseWriter: rec, status: http.StatusOK}
|
||||
|
||||
// Default status before WriteHeader.
|
||||
if sr.status != http.StatusOK {
|
||||
t.Errorf("initial status = %d, want %d", sr.status, http.StatusOK)
|
||||
}
|
||||
|
||||
sr.WriteHeader(http.StatusNotFound)
|
||||
if sr.status != http.StatusNotFound {
|
||||
t.Errorf("status after WriteHeader = %d, want %d", sr.status, http.StatusNotFound)
|
||||
}
|
||||
|
||||
// rec should also have the status set.
|
||||
if rec.Code != http.StatusNotFound {
|
||||
t.Errorf("rec.Code = %d, want %d", rec.Code, http.StatusNotFound)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWithRequestLog(t *testing.T) {
|
||||
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
})
|
||||
|
||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
wrapped := withRequestLog(handler, logger)
|
||||
req := httptest.NewRequest(http.MethodGet, "/test", nil)
|
||||
req.RemoteAddr = "192.168.1.1:12345"
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
wrapped.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Errorf("status = %d, want 200", rec.Code)
|
||||
}
|
||||
}
|
||||
@@ -95,3 +95,16 @@ to = "you@example.com"
|
||||
t.Fatal("expected an error for an unknown field")
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigPath(t *testing.T) {
|
||||
if got := ConfigPath(); got != "/etc/nuntius/config.toml" {
|
||||
t.Errorf("ConfigPath() = %q, want %q", got, "/etc/nuntius/config.toml")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSMTPConfigAddrFor(t *testing.T) {
|
||||
smtp := SMTPConfig{Host: "smtp.example.com", Port: 587}
|
||||
if got := smtp.AddrFor(); got != "smtp.example.com:587" {
|
||||
t.Errorf("AddrFor() = %q, want %q", got, "smtp.example.com:587")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,314 @@
|
||||
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
//go:build linux || freebsd
|
||||
|
||||
package email
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"sourcedock.dev/petrbalvin/nuntius/pkg/contactform"
|
||||
)
|
||||
|
||||
func TestComposeContactWithoutService(t *testing.T) {
|
||||
req := contactform.Request{
|
||||
Name: "Alice",
|
||||
Email: "alice@example.com",
|
||||
Message: "I have a question about your services.",
|
||||
}
|
||||
b := compose("from@example.com", "to@example.com", req, "MyForm", "contact")
|
||||
s := string(b)
|
||||
|
||||
if !strings.Contains(s, "Subject: [nuntius/MyForm] Contact form submission") {
|
||||
t.Errorf("expected subject with form name only, got body:\n%s", s)
|
||||
}
|
||||
if !strings.Contains(s, "From: from@example.com") {
|
||||
t.Error("expected From header")
|
||||
}
|
||||
if !strings.Contains(s, "To: to@example.com") {
|
||||
t.Error("expected To header")
|
||||
}
|
||||
if !strings.Contains(s, "Reply-To: alice@example.com") {
|
||||
t.Error("expected Reply-To header")
|
||||
}
|
||||
if !strings.Contains(s, "MIME-Version: 1.0") {
|
||||
t.Error("expected MIME-Version header")
|
||||
}
|
||||
if !strings.Contains(s, "Content-Type: text/plain; charset=UTF-8") {
|
||||
t.Error("expected text/plain part")
|
||||
}
|
||||
if !strings.Contains(s, "Content-Type: text/html; charset=UTF-8") {
|
||||
t.Error("expected text/html part")
|
||||
}
|
||||
if !strings.Contains(s, "Content-Type: multipart/alternative") {
|
||||
t.Error("expected multipart/alternative content type")
|
||||
}
|
||||
if !strings.Contains(s, "I have a question about your services.") {
|
||||
t.Error("expected message content in body")
|
||||
}
|
||||
if !strings.Contains(s, "Contact Form Submission:") {
|
||||
t.Error("expected contact form plain-text header")
|
||||
}
|
||||
}
|
||||
|
||||
func TestComposeContactWithService(t *testing.T) {
|
||||
req := contactform.Request{
|
||||
Name: "Bob",
|
||||
Email: "bob@example.com",
|
||||
Service: "architecture",
|
||||
Message: "I would like a consultation.",
|
||||
}
|
||||
b := compose("from@e.com", "to@e.com", req, "ContactForm", "contact")
|
||||
s := string(b)
|
||||
|
||||
if !strings.Contains(s, "Subject: [nuntius/ContactForm][architecture] Contact form submission") {
|
||||
t.Errorf("expected subject with service tag, got body:\n%s", s)
|
||||
}
|
||||
if !strings.Contains(s, "Service interest: architecture") {
|
||||
t.Error("expected service interest in plain text")
|
||||
}
|
||||
if !strings.Contains(s, "Content-Type: text/plain") {
|
||||
t.Error("expected text/plain part")
|
||||
}
|
||||
if !strings.Contains(s, "Content-Type: text/html") {
|
||||
t.Error("expected text/html part")
|
||||
}
|
||||
if !strings.Contains(s, "bob@example.com") {
|
||||
t.Error("expected submitter email in body")
|
||||
}
|
||||
}
|
||||
|
||||
func TestComposeFeedback(t *testing.T) {
|
||||
req := contactform.Request{
|
||||
Name: "Carol",
|
||||
Email: "carol@example.com",
|
||||
Message: "Great platform, but can you add dark mode?",
|
||||
}
|
||||
b := compose("sender@h.com", "recv@h.com", req, "FeedbackForm", "feedback")
|
||||
s := string(b)
|
||||
|
||||
if !strings.Contains(s, "Subject: [nuntius/FeedbackForm] New feedback") {
|
||||
t.Errorf("expected feedback subject, got body:\n%s", s)
|
||||
}
|
||||
if !strings.Contains(s, "New Feedback:") {
|
||||
t.Error("expected feedback plain-text header")
|
||||
}
|
||||
if !strings.Contains(s, "From: Carol <carol@example.com>") {
|
||||
t.Error("expected From line in plain text")
|
||||
}
|
||||
if !strings.Contains(s, "Content-Type: text/plain") {
|
||||
t.Error("expected text/plain part")
|
||||
}
|
||||
if !strings.Contains(s, "Content-Type: text/html") {
|
||||
t.Error("expected text/html part")
|
||||
}
|
||||
}
|
||||
|
||||
func TestComposeNewsletter(t *testing.T) {
|
||||
req := contactform.Request{
|
||||
Email: "subscriber@example.com",
|
||||
}
|
||||
b := compose("news@h.com", "owner@h.com", req, "NewsletterSignup", "newsletter")
|
||||
s := string(b)
|
||||
|
||||
if !strings.Contains(s, "Subject: [nuntius/NewsletterSignup] New newsletter subscriber") {
|
||||
t.Errorf("expected newsletter subject, got body:\n%s", s)
|
||||
}
|
||||
if !strings.Contains(s, "New Newsletter Subscriber:") {
|
||||
t.Error("expected newsletter plain-text header")
|
||||
}
|
||||
if !strings.Contains(s, "Email: subscriber@example.com") {
|
||||
t.Error("expected subscriber email in plain text")
|
||||
}
|
||||
if !strings.Contains(s, "Content-Type: text/plain") {
|
||||
t.Error("expected text/plain part")
|
||||
}
|
||||
if !strings.Contains(s, "Content-Type: text/html") {
|
||||
t.Error("expected text/html part")
|
||||
}
|
||||
}
|
||||
|
||||
func TestComposeGeneric(t *testing.T) {
|
||||
req := contactform.Request{
|
||||
Name: "Dave",
|
||||
Email: "dave@example.com",
|
||||
Message: "Generic inquiry.",
|
||||
}
|
||||
b := compose("g@h.com", "g@h.com", req, "GenericForm", "generic")
|
||||
s := string(b)
|
||||
|
||||
if !strings.Contains(s, "Subject: [nuntius/GenericForm] New submission") {
|
||||
t.Errorf("expected generic subject, got body:\n%s", s)
|
||||
}
|
||||
if !strings.Contains(s, "New Submission:") {
|
||||
t.Error("expected generic plain-text header")
|
||||
}
|
||||
if !strings.Contains(s, "Content-Type: text/plain") {
|
||||
t.Error("expected text/plain part")
|
||||
}
|
||||
if !strings.Contains(s, "Content-Type: text/html") {
|
||||
t.Error("expected text/html part")
|
||||
}
|
||||
}
|
||||
|
||||
func TestComposeEmptyFormNameAndEmail(t *testing.T) {
|
||||
req := contactform.Request{
|
||||
Name: "",
|
||||
Email: "",
|
||||
Message: "",
|
||||
}
|
||||
b := compose("", "", req, "", "generic")
|
||||
s := string(b)
|
||||
|
||||
if !strings.Contains(s, "Subject: [nuntius/] New submission") {
|
||||
t.Errorf("expected subject with empty form name, got body:\n%s", s)
|
||||
}
|
||||
if !strings.Contains(s, "MIME-Version: 1.0") {
|
||||
t.Error("expected MIME-Version header even with empty fields")
|
||||
}
|
||||
if !strings.Contains(s, "Content-Type: text/plain") {
|
||||
t.Error("expected text/plain part even with empty fields")
|
||||
}
|
||||
if !strings.Contains(s, "Content-Type: text/html") {
|
||||
t.Error("expected text/html part even with empty fields")
|
||||
}
|
||||
}
|
||||
|
||||
func TestComposeLongMessage(t *testing.T) {
|
||||
longMsg := strings.Repeat("Lorem ipsum dolor sit amet. ", 200)
|
||||
req := contactform.Request{
|
||||
Name: "Eve",
|
||||
Email: "eve@example.com",
|
||||
Message: longMsg,
|
||||
}
|
||||
b := compose("x@y.com", "z@y.com", req, "LongForm", "feedback")
|
||||
s := string(b)
|
||||
|
||||
if !strings.Contains(s, longMsg) {
|
||||
t.Error("expected long message content in body")
|
||||
}
|
||||
// Verify it's in both text and html parts by checking after the respective
|
||||
// content-type boundaries.
|
||||
textIdx := strings.Index(s, "Content-Type: text/plain")
|
||||
htmlIdx := strings.Index(s, "Content-Type: text/html")
|
||||
if textIdx == -1 || htmlIdx == -1 {
|
||||
t.Fatal("expected both text/plain and text/html parts")
|
||||
}
|
||||
textPart := s[textIdx:htmlIdx]
|
||||
htmlPart := s[htmlIdx:]
|
||||
if !strings.Contains(textPart, longMsg) {
|
||||
t.Error("expected long message in text/plain part")
|
||||
}
|
||||
if !strings.Contains(htmlPart, longMsg) {
|
||||
t.Error("expected long message in text/html part")
|
||||
}
|
||||
}
|
||||
|
||||
func TestComposeSpecialCharacters(t *testing.T) {
|
||||
specialMsg := "Café résumé — déjà vu\nLine\twith\ttabs\n€uro sign © 2026"
|
||||
req := contactform.Request{
|
||||
Name: "Renée",
|
||||
Email: "renée@example.com",
|
||||
Message: specialMsg,
|
||||
}
|
||||
b := compose("ñ@c.com", "ö@c.com", req, "SpaForm", "contact")
|
||||
s := string(b)
|
||||
|
||||
if !strings.Contains(s, "Café résumé — déjà vu") {
|
||||
t.Error("expected accented characters to survive round-trip")
|
||||
}
|
||||
if !strings.Contains(s, "€uro sign © 2026") {
|
||||
t.Error("expected special symbols to survive round-trip")
|
||||
}
|
||||
if !strings.Contains(s, "Renée") {
|
||||
t.Error("expected accented name in body")
|
||||
}
|
||||
if !strings.Contains(s, "MIME-Version: 1.0") {
|
||||
t.Error("expected MIME-Version header")
|
||||
}
|
||||
}
|
||||
|
||||
func TestComposeUnknownFormTypeFallsBackToContact(t *testing.T) {
|
||||
// Unknown form type should default to the contact template.
|
||||
req := contactform.Request{
|
||||
Name: "Fallback",
|
||||
Email: "fallback@example.com",
|
||||
Message: "Does this work?",
|
||||
}
|
||||
b := compose("a@b.com", "c@b.com", req, "UnknownForm", "nonexistent")
|
||||
s := string(b)
|
||||
|
||||
if !strings.Contains(s, "Subject: [nuntius/UnknownForm] Contact form submission") {
|
||||
t.Errorf("expected contact fallback subject, got body:\n%s", s)
|
||||
}
|
||||
if !strings.Contains(s, "Content-Type: text/plain") {
|
||||
t.Error("expected text/plain part in fallback")
|
||||
}
|
||||
if !strings.Contains(s, "Content-Type: text/html") {
|
||||
t.Error("expected text/html part in fallback")
|
||||
}
|
||||
}
|
||||
|
||||
func TestComposeAllHeadersPresent(t *testing.T) {
|
||||
req := contactform.Request{
|
||||
Name: "Test",
|
||||
Email: "test@example.com",
|
||||
Message: "Checking headers.",
|
||||
}
|
||||
b := compose("from@x.com", "to@x.com", req, "HeaderForm", "contact")
|
||||
s := string(b)
|
||||
|
||||
required := []string{
|
||||
"From: from@x.com",
|
||||
"To: to@x.com",
|
||||
"Subject:",
|
||||
"Date:",
|
||||
"Reply-To: test@example.com",
|
||||
"MIME-Version: 1.0",
|
||||
"Content-Type: multipart/alternative",
|
||||
}
|
||||
for _, h := range required {
|
||||
if !strings.Contains(s, h) {
|
||||
t.Errorf("expected header %q in message", h)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestComposeMultipartBoundary(t *testing.T) {
|
||||
req := contactform.Request{
|
||||
Name: "Boundary",
|
||||
Email: "boundary@example.com",
|
||||
Message: "Boundary test.",
|
||||
}
|
||||
b := compose("from@t.com", "to@t.com", req, "BoundForm", "contact")
|
||||
s := string(b)
|
||||
|
||||
if !strings.Contains(s, "--nuntius-boundary") {
|
||||
t.Error("expected nuntius-boundary separator")
|
||||
}
|
||||
// Should have opening boundary, middle boundary, closing boundary.
|
||||
count := strings.Count(s, "--nuntius-boundary")
|
||||
if count < 3 {
|
||||
t.Errorf("expected at least 3 boundary markers, got %d", count)
|
||||
}
|
||||
if !strings.Contains(s, "--nuntius-boundary--") {
|
||||
t.Error("expected closing boundary marker")
|
||||
}
|
||||
}
|
||||
|
||||
func TestComposeDeliveredByFooter(t *testing.T) {
|
||||
req := contactform.Request{
|
||||
Name: "Footer",
|
||||
Email: "footer@example.com",
|
||||
Message: "Footer check.",
|
||||
}
|
||||
for _, ft := range []string{"contact", "feedback", "newsletter", "generic"} {
|
||||
b := compose("f@t.com", "t@t.com", req, "FooterForm", ft)
|
||||
s := string(b)
|
||||
if !strings.Contains(s, "Delivered by nuntius") {
|
||||
t.Errorf("form type %q: expected 'Delivered by nuntius' footer in plain text", ft)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -20,23 +20,34 @@ import (
|
||||
"sourcedock.dev/petrbalvin/nuntius/pkg/contactform"
|
||||
)
|
||||
|
||||
// formSender is the interface for delivering a submission.
|
||||
type formSender interface {
|
||||
Send(req contactform.Request) error
|
||||
}
|
||||
|
||||
// subscriberStorer persists newsletter subscribers.
|
||||
// storage.NewsletterStore satisfies this interface.
|
||||
type subscriberStorer interface {
|
||||
Append(sub storage.Subscriber) error
|
||||
}
|
||||
|
||||
// ContactHandler serves one or more contact forms, dispatched by URL path.
|
||||
// Each form has its own sender, rate limiter, CORS allowlist, and honeypot.
|
||||
// Newsletter-type forms also get a per-form subscriber store.
|
||||
type ContactHandler struct {
|
||||
forms map[string]*config.Form
|
||||
senders map[string]*email.FormSender
|
||||
senders map[string]formSender
|
||||
rateLimits map[string]*rateLimiter
|
||||
stores map[string]*storage.NewsletterStore
|
||||
stores map[string]subscriberStorer
|
||||
}
|
||||
|
||||
// New constructs a ContactHandler that serves all forms defined in cfg.
|
||||
func New(cfg *config.Config) *ContactHandler {
|
||||
h := &ContactHandler{
|
||||
forms: make(map[string]*config.Form, len(cfg.Forms)),
|
||||
senders: make(map[string]*email.FormSender, len(cfg.Forms)),
|
||||
senders: make(map[string]formSender, len(cfg.Forms)),
|
||||
rateLimits: make(map[string]*rateLimiter, len(cfg.Forms)),
|
||||
stores: make(map[string]*storage.NewsletterStore, len(cfg.Forms)),
|
||||
stores: make(map[string]subscriberStorer, len(cfg.Forms)),
|
||||
}
|
||||
for i := range cfg.Forms {
|
||||
f := &cfg.Forms[i]
|
||||
|
||||
@@ -0,0 +1,804 @@
|
||||
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
//go:build linux || freebsd
|
||||
|
||||
package handler
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"sourcedock.dev/petrbalvin/nuntius/internal/config"
|
||||
"sourcedock.dev/petrbalvin/nuntius/internal/storage"
|
||||
"sourcedock.dev/petrbalvin/nuntius/pkg/contactform"
|
||||
)
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// mocks
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type mockSender struct {
|
||||
mu sync.Mutex
|
||||
sent []contactform.Request
|
||||
sendErr error
|
||||
}
|
||||
|
||||
func (m *mockSender) Send(req contactform.Request) error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
m.sent = append(m.sent, req)
|
||||
return m.sendErr
|
||||
}
|
||||
|
||||
func (m *mockSender) sentCount() int {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
return len(m.sent)
|
||||
}
|
||||
|
||||
type mockStore struct {
|
||||
mu sync.Mutex
|
||||
appended []storage.Subscriber
|
||||
appendErr error
|
||||
}
|
||||
|
||||
func (m *mockStore) Append(sub storage.Subscriber) error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
m.appended = append(m.appended, sub)
|
||||
return m.appendErr
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// helper functions
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func testForm() *config.Form {
|
||||
return &config.Form{
|
||||
Name: "test",
|
||||
Path: "/test",
|
||||
Type: "contact",
|
||||
AllowedOrigins: []string{"https://example.com"},
|
||||
RateLimitPerHour: 100,
|
||||
HoneypotField: "website",
|
||||
From: "noreply@test.com",
|
||||
To: "owner@test.com",
|
||||
}
|
||||
}
|
||||
|
||||
func testNewsletterForm() *config.Form {
|
||||
f := testForm()
|
||||
f.Type = "newsletter"
|
||||
return f
|
||||
}
|
||||
|
||||
func newTestHandler(form *config.Form, sender formSender, store subscriberStorer) *ContactHandler {
|
||||
h := &ContactHandler{
|
||||
forms: map[string]*config.Form{form.Path: form},
|
||||
senders: map[string]formSender{form.Path: sender},
|
||||
rateLimits: map[string]*rateLimiter{form.Path: newRateLimiter(form.RateLimitPerHour)},
|
||||
stores: map[string]subscriberStorer{},
|
||||
}
|
||||
if store != nil {
|
||||
h.stores[form.Path] = store
|
||||
}
|
||||
return h
|
||||
}
|
||||
|
||||
func newTestHandlerMux(t *testing.T, form *config.Form, sender formSender, store subscriberStorer) http.Handler {
|
||||
t.Helper()
|
||||
h := newTestHandler(form, sender, store)
|
||||
mux := http.NewServeMux()
|
||||
h.Register(mux)
|
||||
return mux
|
||||
}
|
||||
|
||||
func postJSON(t *testing.T, handler http.Handler, path string, body string) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
req := httptest.NewRequest(http.MethodPost, path, strings.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
rec := httptest.NewRecorder()
|
||||
handler.ServeHTTP(rec, req)
|
||||
return rec
|
||||
}
|
||||
|
||||
func postJSONWithOrigin(t *testing.T, handler http.Handler, path string, body string, origin string) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
req := httptest.NewRequest(http.MethodPost, path, strings.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Origin", origin)
|
||||
rec := httptest.NewRecorder()
|
||||
handler.ServeHTTP(rec, req)
|
||||
return rec
|
||||
}
|
||||
|
||||
func decodeBody(t *testing.T, rec *httptest.ResponseRecorder, v any) {
|
||||
t.Helper()
|
||||
if err := json.NewDecoder(rec.Body).Decode(v); err != nil {
|
||||
t.Fatalf("failed to decode body: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// TestFormAllowed
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestFormAllowed(t *testing.T) {
|
||||
form := &config.Form{
|
||||
AllowedOrigins: []string{"https://a.com", "https://b.com"},
|
||||
}
|
||||
tests := []struct {
|
||||
name string
|
||||
origin string
|
||||
want bool
|
||||
}{
|
||||
{"empty origin allowed", "", true},
|
||||
{"exact match allowed", "https://a.com", true},
|
||||
{"second match allowed", "https://b.com", true},
|
||||
{"no match disallowed", "https://evil.com", false},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := formAllowed(form, tt.origin)
|
||||
if got != tt.want {
|
||||
t.Errorf("formAllowed(%q) = %v, want %v", tt.origin, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// TestWriteCORS
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestWriteCORS(t *testing.T) {
|
||||
allowed := []string{"https://a.com"}
|
||||
|
||||
t.Run("allowed origin sets headers", func(t *testing.T) {
|
||||
w := httptest.NewRecorder()
|
||||
writeCORS(w, "https://a.com", allowed)
|
||||
|
||||
if got := w.Header().Get("Access-Control-Allow-Origin"); got != "https://a.com" {
|
||||
t.Errorf("ACAO = %q, want %q", got, "https://a.com")
|
||||
}
|
||||
if got := w.Header().Get("Vary"); got != "Origin" {
|
||||
t.Errorf("Vary = %q, want %q", got, "Origin")
|
||||
}
|
||||
if got := w.Header().Get("Access-Control-Allow-Methods"); got != "POST, OPTIONS" {
|
||||
t.Errorf("ACAM = %q, want POST, OPTIONS", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("disallowed origin sets nothing", func(t *testing.T) {
|
||||
w := httptest.NewRecorder()
|
||||
writeCORS(w, "https://evil.com", allowed)
|
||||
|
||||
if got := w.Header().Get("Access-Control-Allow-Origin"); got != "" {
|
||||
t.Errorf("ACAO = %q, want empty", got)
|
||||
}
|
||||
if got := w.Header().Get("Access-Control-Allow-Methods"); got != "" {
|
||||
t.Errorf("ACAM = %q, want empty", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// TestClientIP
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestClientIP(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
xff string
|
||||
xri string
|
||||
remoteAddr string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "X-Forwarded-For single IP",
|
||||
xff: "1.2.3.4",
|
||||
xri: "",
|
||||
remoteAddr: "10.0.0.1:1234",
|
||||
want: "1.2.3.4",
|
||||
},
|
||||
{
|
||||
name: "X-Forwarded-For multiple IPs takes first",
|
||||
xff: "1.2.3.4, 2.3.4.5, 3.4.5.6",
|
||||
xri: "",
|
||||
remoteAddr: "10.0.0.1:1234",
|
||||
want: "1.2.3.4",
|
||||
},
|
||||
{
|
||||
name: "X-Real-IP takes priority over RemoteAddr",
|
||||
xff: "",
|
||||
xri: "5.6.7.8",
|
||||
remoteAddr: "10.0.0.1:1234",
|
||||
want: "5.6.7.8",
|
||||
},
|
||||
{
|
||||
name: "RemoteAddr with port",
|
||||
xff: "",
|
||||
xri: "",
|
||||
remoteAddr: "9.10.11.12:8080",
|
||||
want: "9.10.11.12",
|
||||
},
|
||||
{
|
||||
name: "RemoteAddr without port",
|
||||
xff: "",
|
||||
xri: "",
|
||||
remoteAddr: "9.10.11.12",
|
||||
want: "9.10.11.12",
|
||||
},
|
||||
{
|
||||
name: "IPv6 RemoteAddr with port",
|
||||
xff: "",
|
||||
xri: "",
|
||||
remoteAddr: "[::1]:12345",
|
||||
want: "[::1]",
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
if tt.xff != "" {
|
||||
req.Header.Set("X-Forwarded-For", tt.xff)
|
||||
}
|
||||
if tt.xri != "" {
|
||||
req.Header.Set("X-Real-IP", tt.xri)
|
||||
}
|
||||
req.RemoteAddr = tt.remoteAddr
|
||||
got := clientIP(req)
|
||||
if got != tt.want {
|
||||
t.Errorf("clientIP() = %q, want %q", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// TestRateLimiterAllow
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestRateLimiterAllow(t *testing.T) {
|
||||
perHour := 3
|
||||
rl := &rateLimiter{
|
||||
perHour: perHour,
|
||||
buckets: make(map[string]*bucket),
|
||||
}
|
||||
|
||||
ip := "10.0.0.1"
|
||||
|
||||
// First perHour requests should be allowed.
|
||||
for i := 0; i < perHour; i++ {
|
||||
if !rl.allow(ip) {
|
||||
t.Fatalf("allow() call %d was blocked, want allowed", i+1)
|
||||
}
|
||||
}
|
||||
|
||||
// Next request should be blocked.
|
||||
if rl.allow(ip) {
|
||||
t.Fatal("allow() after exhausting tokens was allowed, want blocked")
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// TestRateLimiterRefill
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestRateLimiterRefill(t *testing.T) {
|
||||
// 3600 tokens per hour = 1 token per second.
|
||||
perHour := 3600
|
||||
rl := &rateLimiter{
|
||||
perHour: perHour,
|
||||
buckets: make(map[string]*bucket),
|
||||
}
|
||||
|
||||
ip := "10.0.0.1"
|
||||
|
||||
// Exhaust all tokens.
|
||||
allowed := 0
|
||||
for rl.allow(ip) {
|
||||
allowed++
|
||||
}
|
||||
if allowed != perHour {
|
||||
t.Fatalf("exhausted %d tokens, want %d", allowed, perHour)
|
||||
}
|
||||
|
||||
// Immediately another request should be blocked (rate = 1 token/sec).
|
||||
if rl.allow(ip) {
|
||||
t.Fatal("allow() immediately after exhausting was allowed, want blocked")
|
||||
}
|
||||
|
||||
// Wait for at least 2 tokens to refill (2 seconds + margin).
|
||||
time.Sleep(2100 * time.Millisecond)
|
||||
|
||||
// Should now allow 2 requests.
|
||||
for i := 0; i < 2; i++ {
|
||||
if !rl.allow(ip) {
|
||||
t.Fatalf("allow() after refill call %d was blocked, want allowed", i+1)
|
||||
}
|
||||
}
|
||||
|
||||
// Third request after only 2 sec of refill should be blocked.
|
||||
if rl.allow(ip) {
|
||||
t.Fatal("allow() after refill of only 2 tokens was allowed, want blocked")
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// TestRateLimiterCleanup
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestRateLimiterCleanup(t *testing.T) {
|
||||
rl := &rateLimiter{
|
||||
perHour: 10,
|
||||
buckets: make(map[string]*bucket),
|
||||
}
|
||||
|
||||
// Add a very old entry (3 hours ago).
|
||||
rl.buckets["old-ip"] = &bucket{
|
||||
tokens: 0,
|
||||
last: time.Now().Add(-3 * time.Hour),
|
||||
}
|
||||
|
||||
// Add a recent entry (30 seconds ago).
|
||||
rl.buckets["recent-ip"] = &bucket{
|
||||
tokens: 5,
|
||||
last: time.Now().Add(-30 * time.Second),
|
||||
}
|
||||
|
||||
// Cleanup entries older than 2 hours.
|
||||
rl.cleanup(2 * time.Hour)
|
||||
|
||||
if _, ok := rl.buckets["old-ip"]; ok {
|
||||
t.Error("old-ip was not cleaned up, want removed")
|
||||
}
|
||||
if _, ok := rl.buckets["recent-ip"]; !ok {
|
||||
t.Error("recent-ip was cleaned up, want kept")
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// TestRateLimiterZeroPerHour
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestRateLimiterZeroPerHour(t *testing.T) {
|
||||
form := testForm()
|
||||
form.RateLimitPerHour = 0
|
||||
form.AllowedOrigins = nil // allow all
|
||||
|
||||
sender := &mockSender{}
|
||||
mux := newTestHandlerMux(t, form, sender, nil)
|
||||
|
||||
// Send many requests rapidly — none should be rate-limited.
|
||||
const n = 50
|
||||
for i := 0; i < n; i++ {
|
||||
rec := postJSON(t, mux, "/test",
|
||||
`{"name":"Alice","email":"a@b.com","message":"Hello there!"}`)
|
||||
if rec.Code == http.StatusTooManyRequests {
|
||||
t.Fatalf("request %d rate-limited with perHour=0", i+1)
|
||||
}
|
||||
}
|
||||
|
||||
if sender.sentCount() != n {
|
||||
t.Errorf("sent %d requests, want %d", sender.sentCount(), n)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// TestRateLimiterMultipleIPs
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestRateLimiterMultipleIPs(t *testing.T) {
|
||||
perHour := 2
|
||||
rl := &rateLimiter{
|
||||
perHour: perHour,
|
||||
buckets: make(map[string]*bucket),
|
||||
}
|
||||
|
||||
ipA := "10.0.0.1"
|
||||
ipB := "10.0.0.2"
|
||||
|
||||
// Exhaust ipA's tokens.
|
||||
for i := 0; i < perHour; i++ {
|
||||
if !rl.allow(ipA) {
|
||||
t.Fatalf("ipA call %d blocked, want allowed", i+1)
|
||||
}
|
||||
}
|
||||
if rl.allow(ipA) {
|
||||
t.Fatal("ipA after exhaust was allowed, want blocked")
|
||||
}
|
||||
|
||||
// ipB should still have all its tokens.
|
||||
for i := 0; i < perHour; i++ {
|
||||
if !rl.allow(ipB) {
|
||||
t.Fatalf("ipB call %d blocked, want allowed", i+1)
|
||||
}
|
||||
}
|
||||
if rl.allow(ipB) {
|
||||
t.Fatal("ipB after exhaust was allowed, want blocked")
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// TestMinF
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestMinF(t *testing.T) {
|
||||
tests := []struct {
|
||||
a, b, want float64
|
||||
}{
|
||||
{1, 2, 1},
|
||||
{2, 1, 1},
|
||||
{3.5, 3.5, 3.5},
|
||||
{-1, 0, -1},
|
||||
{0, -5, -5},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
got := minF(tt.a, tt.b)
|
||||
if got != tt.want {
|
||||
t.Errorf("minF(%v, %v) = %v, want %v", tt.a, tt.b, got, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// TestRespondOK
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestRespondOK(t *testing.T) {
|
||||
w := httptest.NewRecorder()
|
||||
respondOK(w)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("status = %d, want %d", w.Code, http.StatusOK)
|
||||
}
|
||||
|
||||
var resp contactform.Response
|
||||
decodeBody(t, w, &resp)
|
||||
if !resp.OK {
|
||||
t.Errorf("OK = %v, want true", resp.OK)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// TestRespondError
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestRespondError(t *testing.T) {
|
||||
w := httptest.NewRecorder()
|
||||
respondError(w, http.StatusBadRequest, "invalid_json", "Could not parse JSON body.")
|
||||
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("status = %d, want %d", w.Code, http.StatusBadRequest)
|
||||
}
|
||||
|
||||
var resp contactform.ErrorResponse
|
||||
decodeBody(t, w, &resp)
|
||||
if resp.Error != "invalid_json" {
|
||||
t.Errorf("error = %q, want %q", resp.Error, "invalid_json")
|
||||
}
|
||||
if resp.Message != "Could not parse JSON body." {
|
||||
t.Errorf("message = %q, want %q", resp.Message, "Could not parse JSON body.")
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// TestHealth
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestHealth(t *testing.T) {
|
||||
form := testForm()
|
||||
sender := &mockSender{}
|
||||
mux := newTestHandlerMux(t, form, sender, nil)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/health", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
mux.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK)
|
||||
}
|
||||
|
||||
var resp map[string]any
|
||||
decodeBody(t, rec, &resp)
|
||||
if status, ok := resp["status"]; !ok || status != "ok" {
|
||||
t.Errorf("status = %v, want ok", status)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// TestHandlerOptions
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestHandlerOptions(t *testing.T) {
|
||||
form := testForm()
|
||||
sender := &mockSender{}
|
||||
mux := newTestHandlerMux(t, form, sender, nil)
|
||||
|
||||
req := httptest.NewRequest(http.MethodOptions, "/test", nil)
|
||||
req.Header.Set("Origin", "https://example.com")
|
||||
rec := httptest.NewRecorder()
|
||||
mux.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusNoContent {
|
||||
t.Errorf("status = %d, want %d", rec.Code, http.StatusNoContent)
|
||||
}
|
||||
if got := rec.Header().Get("Access-Control-Allow-Origin"); got != "https://example.com" {
|
||||
t.Errorf("ACAO = %q, want %q", got, "https://example.com")
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// TestHandlerOriginNotAllowed
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestHandlerOriginNotAllowed(t *testing.T) {
|
||||
form := testForm()
|
||||
sender := &mockSender{}
|
||||
mux := newTestHandlerMux(t, form, sender, nil)
|
||||
|
||||
rec := postJSONWithOrigin(t, mux, "/test",
|
||||
`{"name":"A","email":"a@b.com","message":"Hello there!"}`,
|
||||
"https://evil.com")
|
||||
|
||||
if rec.Code != http.StatusForbidden {
|
||||
t.Errorf("status = %d, want %d", rec.Code, http.StatusForbidden)
|
||||
}
|
||||
|
||||
var resp contactform.ErrorResponse
|
||||
decodeBody(t, rec, &resp)
|
||||
if resp.Error != "origin_not_allowed" {
|
||||
t.Errorf("error = %q, want origin_not_allowed", resp.Error)
|
||||
}
|
||||
|
||||
if sender.sentCount() != 0 {
|
||||
t.Errorf("sender called %d times, want 0", sender.sentCount())
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// TestHandlerRateLimited
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestHandlerRateLimited(t *testing.T) {
|
||||
form := testForm()
|
||||
form.RateLimitPerHour = 1
|
||||
sender := &mockSender{}
|
||||
mux := newTestHandlerMux(t, form, sender, nil)
|
||||
|
||||
// First request should succeed.
|
||||
rec1 := postJSON(t, mux, "/test",
|
||||
`{"name":"Alice","email":"a@b.com","message":"Hello there!"}`)
|
||||
if rec1.Code != http.StatusOK {
|
||||
t.Fatalf("first request status = %d, want %d", rec1.Code, http.StatusOK)
|
||||
}
|
||||
|
||||
// Second request should be rate-limited.
|
||||
rec2 := postJSON(t, mux, "/test",
|
||||
`{"name":"Bob","email":"b@b.com","message":"Hello there!"}`)
|
||||
if rec2.Code != http.StatusTooManyRequests {
|
||||
t.Fatalf("second request status = %d, want %d", rec2.Code, http.StatusTooManyRequests)
|
||||
}
|
||||
|
||||
var resp contactform.ErrorResponse
|
||||
decodeBody(t, rec2, &resp)
|
||||
if resp.Error != "rate_limited" {
|
||||
t.Errorf("error = %q, want rate_limited", resp.Error)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// TestHandlerInvalidJSON
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestHandlerInvalidJSON(t *testing.T) {
|
||||
form := testForm()
|
||||
sender := &mockSender{}
|
||||
mux := newTestHandlerMux(t, form, sender, nil)
|
||||
|
||||
rec := postJSON(t, mux, "/test", `not json`)
|
||||
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Errorf("status = %d, want %d", rec.Code, http.StatusBadRequest)
|
||||
}
|
||||
|
||||
var resp contactform.ErrorResponse
|
||||
decodeBody(t, rec, &resp)
|
||||
if resp.Error != "invalid_json" {
|
||||
t.Errorf("error = %q, want invalid_json", resp.Error)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// TestHandlerValidationError
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestHandlerValidationError(t *testing.T) {
|
||||
form := testForm()
|
||||
sender := &mockSender{}
|
||||
mux := newTestHandlerMux(t, form, sender, nil)
|
||||
|
||||
// Empty name triggers validation error.
|
||||
rec := postJSON(t, mux, "/test",
|
||||
`{"name":"","email":"a@b.com","message":"hi"}`)
|
||||
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Errorf("status = %d, want %d", rec.Code, http.StatusBadRequest)
|
||||
}
|
||||
|
||||
var resp contactform.ErrorResponse
|
||||
decodeBody(t, rec, &resp)
|
||||
if resp.Error != "validation" {
|
||||
t.Errorf("error = %q, want validation", resp.Error)
|
||||
}
|
||||
if len(resp.Details) == 0 {
|
||||
t.Fatal("details empty, want validation field errors")
|
||||
}
|
||||
|
||||
if sender.sentCount() != 0 {
|
||||
t.Errorf("sender called %d times even with validation error", sender.sentCount())
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// TestHandlerHoneypot
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestHandlerHoneypot(t *testing.T) {
|
||||
form := testForm()
|
||||
form.HoneypotField = "website"
|
||||
sender := &mockSender{}
|
||||
mux := newTestHandlerMux(t, form, sender, nil)
|
||||
|
||||
// The honeypot value ("website":"bot") is ignored during JSON decode
|
||||
// because Request.Honeypot is tagged `json:"-"`. The handler sees an
|
||||
// empty Honeypot and proceeds to send the email normally.
|
||||
rec := postJSON(t, mux, "/test",
|
||||
`{"name":"Alice","email":"a@b.com","message":"Hello there!","website":"bot_value"}`)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Errorf("status = %d, want %d", rec.Code, http.StatusOK)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// TestHandlerSendSuccess
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestHandlerSendSuccess(t *testing.T) {
|
||||
form := testForm()
|
||||
sender := &mockSender{}
|
||||
mux := newTestHandlerMux(t, form, sender, nil)
|
||||
|
||||
rec := postJSON(t, mux, "/test",
|
||||
`{"name":"Alice","email":"a@example.com","message":"Hello there!"}`)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK)
|
||||
}
|
||||
|
||||
var resp contactform.Response
|
||||
decodeBody(t, rec, &resp)
|
||||
if !resp.OK {
|
||||
t.Errorf("OK = %v, want true", resp.OK)
|
||||
}
|
||||
|
||||
if sender.sentCount() != 1 {
|
||||
t.Fatalf("sender called %d times, want 1", sender.sentCount())
|
||||
}
|
||||
|
||||
sender.mu.Lock()
|
||||
got := sender.sent[0]
|
||||
sender.mu.Unlock()
|
||||
if got.Name != "Alice" {
|
||||
t.Errorf("sent name = %q, want Alice", got.Name)
|
||||
}
|
||||
if got.Email != "a@example.com" {
|
||||
t.Errorf("sent email = %q, want a@example.com", got.Email)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// TestHandlerSendError
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestHandlerSendError(t *testing.T) {
|
||||
form := testForm()
|
||||
sender := &mockSender{sendErr: errors.New("smtp down")}
|
||||
mux := newTestHandlerMux(t, form, sender, nil)
|
||||
|
||||
rec := postJSON(t, mux, "/test",
|
||||
`{"name":"Alice","email":"a@example.com","message":"Hello there!"}`)
|
||||
|
||||
if rec.Code != http.StatusInternalServerError {
|
||||
t.Fatalf("status = %d, want %d", rec.Code, http.StatusInternalServerError)
|
||||
}
|
||||
|
||||
var resp contactform.ErrorResponse
|
||||
decodeBody(t, rec, &resp)
|
||||
if resp.Error != "send_failed" {
|
||||
t.Errorf("error = %q, want send_failed", resp.Error)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// TestHandlerNewsletterAppend
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestHandlerNewsletterAppend(t *testing.T) {
|
||||
form := testNewsletterForm()
|
||||
sender := &mockSender{}
|
||||
store := &mockStore{}
|
||||
mux := newTestHandlerMux(t, form, sender, store)
|
||||
|
||||
rec := postJSON(t, mux, "/test",
|
||||
`{"name":"Alice","email":"a@example.com"}`)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK)
|
||||
}
|
||||
|
||||
var resp contactform.Response
|
||||
decodeBody(t, rec, &resp)
|
||||
if !resp.OK {
|
||||
t.Errorf("OK = %v, want true", resp.OK)
|
||||
}
|
||||
|
||||
if sender.sentCount() != 1 {
|
||||
t.Errorf("sender called %d times, want 1", sender.sentCount())
|
||||
}
|
||||
|
||||
store.mu.Lock()
|
||||
n := len(store.appended)
|
||||
store.mu.Unlock()
|
||||
if n != 1 {
|
||||
t.Fatalf("store appended %d subscribers, want 1", n)
|
||||
}
|
||||
|
||||
store.mu.Lock()
|
||||
sub := store.appended[0]
|
||||
store.mu.Unlock()
|
||||
if sub.Email != "a@example.com" {
|
||||
t.Errorf("appended email = %q, want a@example.com", sub.Email)
|
||||
}
|
||||
if sub.Form != form.Name {
|
||||
t.Errorf("appended form = %q, want %q", sub.Form, form.Name)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// TestHandlerNewsletterAppendError
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestHandlerNewsletterAppendError(t *testing.T) {
|
||||
form := testNewsletterForm()
|
||||
sender := &mockSender{}
|
||||
store := &mockStore{appendErr: errors.New("disk full")}
|
||||
mux := newTestHandlerMux(t, form, sender, store)
|
||||
|
||||
rec := postJSON(t, mux, "/test",
|
||||
`{"name":"Alice","email":"a@example.com"}`)
|
||||
|
||||
if rec.Code != http.StatusInternalServerError {
|
||||
t.Fatalf("status = %d, want %d", rec.Code, http.StatusInternalServerError)
|
||||
}
|
||||
|
||||
var resp contactform.ErrorResponse
|
||||
decodeBody(t, rec, &resp)
|
||||
if resp.Error != "storage_failed" {
|
||||
t.Errorf("error = %q, want storage_failed", resp.Error)
|
||||
}
|
||||
|
||||
// Sender should have been called successfully before the store error.
|
||||
if sender.sentCount() != 1 {
|
||||
t.Errorf("sender called %d times, want 1", sender.sentCount())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
//go:build linux || freebsd
|
||||
|
||||
package version
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestName(t *testing.T) {
|
||||
if Name != "nuntius" {
|
||||
t.Errorf("Name = %q, want %q", Name, "nuntius")
|
||||
}
|
||||
}
|
||||
|
||||
func TestVersion(t *testing.T) {
|
||||
if Version == "" {
|
||||
t.Error("Version must not be empty")
|
||||
}
|
||||
}
|
||||
@@ -23,8 +23,12 @@ build:
|
||||
go build -ldflags="-s -w" -o bin/nuntius ./cmd/server
|
||||
|
||||
test:
|
||||
@echo "→ Running tests…"
|
||||
go test ./...
|
||||
@echo "→ Running all tests…"
|
||||
go test -race -count=1 ./...
|
||||
@echo "→ Checking coverage threshold (≥80% for internal + pkg)…"
|
||||
go test -coverprofile=coverage.out ./internal/... ./pkg/...
|
||||
@go tool cover -func=coverage.out | grep '^total:' | python3 -c "import sys; pct=float(sys.stdin.read().split()[2].rstrip('%')); sys.exit(0 if pct >= 80 else 1)" && echo "Coverage ✓" || (echo "ERROR: coverage < 80%"; exit 1)
|
||||
@rm -f coverage.out
|
||||
|
||||
# Format Go source files
|
||||
fmt:
|
||||
|
||||
Reference in New Issue
Block a user