Files
notify-gateway/internal/gateway/queue_test.go
T
2026-09-16 06:26:16 +02:00

167 lines
5.1 KiB
Go

package gateway
import (
"context"
"database/sql"
"net/http"
"net/http/httptest"
"path/filepath"
"testing"
"time"
"github.com/example/notify-gateway/internal/config"
"github.com/example/notify-gateway/internal/model"
"github.com/example/notify-gateway/internal/outbox"
)
func TestQueueRetriesOnlyFailedDestinationAndFreezesDryRun(t *testing.T) {
ctx := context.Background()
dir := t.TempDir()
store, err := config.Open(filepath.Join(dir, "config.json"))
if err != nil {
t.Fatal(err)
}
db, err := outbox.Open(filepath.Join(dir, "outbox.db"))
if err != nil {
t.Fatal(err)
}
defer db.Close()
good, bad := 0, 0
fail := true
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Header.Get("Idempotency-Key") == "" {
t.Error("missing delivery id")
}
if r.URL.Path == "/good" {
good++
w.WriteHeader(204)
} else {
bad++
if fail {
w.WriteHeader(400)
} else {
w.WriteHeader(200)
}
}
}))
defer srv.Close()
c := store.Get()
c.Outbounds = []config.OutboundConfig{{ID: "good", Provider: "webhook", URL: srv.URL + "/good", Live: true}, {ID: "bad", Provider: "webhook", URL: srv.URL + "/bad", Live: true}, {ID: "dry", Provider: "webhook", URL: srv.URL + "/good"}}
c.Mappings = nil
for _, id := range []string{"good", "bad", "dry"} {
c.Mappings = append(c.Mappings, config.Mapping{ID: id, Enabled: true, Target: "webhook", OutboundID: id, TextTemplate: "{{.Message}}"})
}
if err := store.Replace(c); err != nil {
t.Fatal(err)
}
q := &Queue{Store: db, Dispatcher: New(store, nil)}
msg := model.InboundMessage{Source: "webhook", Channel: "ops", Message: "test", ReceivedAt: time.Now()}
first, err := q.Accept(ctx, msg, "request-1")
if err != nil {
t.Fatal(err)
}
c.Outbounds[2].Live = true
store.Replace(c)
for i := 0; i < 3; i++ {
if ok, err := q.ProcessOne(ctx); err != nil || !ok {
t.Fatalf("process %v %v", ok, err)
}
}
if good != 1 || bad != 1 {
t.Fatalf("calls good=%d bad=%d", good, bad)
}
msg.ReceivedAt = time.Now().Add(time.Hour)
duplicate, err := q.Accept(ctx, msg, "request-1")
if err != nil || !duplicate.Duplicate || first.ID != duplicate.ID {
t.Fatalf("receipt %+v %v", duplicate, err)
}
rows, _ := db.List(ctx, 50, 0)
for _, j := range rows {
if j.MappingID == "bad" {
if j.State != "dead" {
t.Fatal(j.State)
}
if err := db.Retry(ctx, j.ID); err != nil {
t.Fatal(err)
}
}
if j.MappingID == "dry" && j.State != "dry_run" {
t.Fatal("dry-run turned live")
}
}
fail = false
if ok, err := q.ProcessOne(ctx); !ok || err != nil {
t.Fatal(err)
}
if good != 1 || bad != 2 {
t.Fatalf("successful destination repeated: good=%d bad=%d", good, bad)
}
}
func TestQueueBackoffAndAtomicInvalidMapping(t *testing.T) {
ctx := context.Background()
dir := t.TempDir()
store, _ := config.Open(filepath.Join(dir, "config.json"))
db, err := outbox.Open(filepath.Join(dir, "outbox.db"))
if err != nil {
t.Fatal(err)
}
defer db.Close()
calls := 0
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
calls++
w.Header().Set("Retry-After", "120")
w.WriteHeader(429)
}))
defer srv.Close()
c := store.Get()
c.Outbounds = []config.OutboundConfig{{ID: "rate", Provider: "webhook", URL: srv.URL, Live: true}}
c.Mappings = []config.Mapping{{ID: "rate", Enabled: true, Target: "webhook", OutboundID: "rate", TextTemplate: "{{.Message}}"}}
if err := store.Replace(c); err != nil {
t.Fatal(err)
}
q := &Queue{Store: db, Dispatcher: New(store, nil)}
if _, err := q.Accept(ctx, model.InboundMessage{Message: "one"}, "one"); err != nil {
t.Fatal(err)
}
if _, err := q.ProcessOne(ctx); err != nil {
t.Fatal(err)
}
rows, _ := db.List(ctx, 50, 0)
if rows[0].State != "pending" || rows[0].Next < time.Now().Add(119*time.Second).Unix() {
t.Fatalf("backoff: %+v", rows)
}
if worked, err := q.ProcessOne(ctx); err != nil || worked || calls != 1 {
t.Fatalf("early retry %v %v %d", worked, err, calls)
}
c.Mappings = append(c.Mappings, config.Mapping{ID: "invalid", Enabled: true, Target: "discord", OutboundID: "discord", TextTemplate: ""})
c.Outbounds = append(c.Outbounds, config.OutboundConfig{ID: "discord", Provider: "discord", URL: "https://discord.com/api/webhooks/123/token"})
store.Replace(c)
if _, err := q.Accept(ctx, model.InboundMessage{Message: "new"}, "new"); err == nil {
t.Fatal("expected invalid empty Discord payload")
}
rows, _ = db.List(ctx, 50, 0)
if len(rows) != 1 {
t.Fatal("partial message was queued")
}
// Advance the due time in the isolated database to exercise the retry cap
// without waiting for the backoff clock in this integration test.
clockDB, err := sql.Open("sqlite", filepath.Join(dir, "outbox.db"))
if err != nil {
t.Fatal(err)
}
defer clockDB.Close()
for i := 1; i < 8; i++ {
if _, err := clockDB.Exec("UPDATE deliveries SET next=0 WHERE state='pending'"); err != nil {
t.Fatal(err)
}
if _, err := q.ProcessOne(ctx); err != nil {
t.Fatal(err)
}
}
rows, _ = db.List(ctx, 50, 0)
if rows[0].State != "dead" || rows[0].Attempts != 8 || calls != 8 {
t.Fatalf("retry limit: %+v calls=%d", rows, calls)
}
}