check / check (push) Waiting to run
Target.MaxRetries (max_retries) becomes Target.MaxAttempts (max_attempts), and the form field, parser and ceiling are renamed to match. The column is NOT NULL and a check constraint refuses a value below 1, so every target, whatever its type, is stored with at least 1. 1 is a single attempt with no circuit breaker; above 1 retries behind the breaker up to that many attempts. Both target forms refuse 0 and less; a blank field on the add target form gives 1, and on the edit form keeps the stored value. The target page shows the plain number, and the README's two paragraphs on 0 against 1 become one sentence. Model: opus-5-5
444 lines
11 KiB
Go
444 lines
11 KiB
Go
package delivery_test
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"io"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/google/uuid"
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
"gorm.io/gorm"
|
|
"sneak.berlin/go/webhooker/internal/database"
|
|
"sneak.berlin/go/webhooker/internal/delivery"
|
|
)
|
|
|
|
// tsEventCreatedAt is the receipt time seeded on the events these
|
|
// tests deliver. It is far enough from both the zero time and from
|
|
// now that neither can be mistaken for it.
|
|
func tsEventCreatedAt() time.Time {
|
|
return time.Date(
|
|
2026, time.March, 4, 5, 6, 7, 0, time.UTC,
|
|
)
|
|
}
|
|
|
|
// tsZeroStamp is what a Slack message renders when the event handed
|
|
// to FormatSlackMessage carries no CreatedAt.
|
|
const tsZeroStamp = "*Timestamp:* `0001-01-01T00:00:00Z`"
|
|
|
|
// tsEventBody is the body seeded on every event in this file. It is
|
|
// small enough that a Task can inline it.
|
|
const tsEventBody = `{"hello":"world"}`
|
|
|
|
// tsUndeliverableHook stands in for a Slack incoming webhook on the
|
|
// tests that never send: the config parser requires a URL, but no
|
|
// request is made.
|
|
const tsUndeliverableHook = "https://hooks.slack.com/services/T/B/x"
|
|
|
|
// tsSink is a stand-in Slack incoming webhook that records the raw
|
|
// body posted to it.
|
|
type tsSink struct {
|
|
*httptest.Server
|
|
|
|
bodies chan []byte
|
|
}
|
|
|
|
func newTSSink(t *testing.T) *tsSink {
|
|
t.Helper()
|
|
|
|
s := &tsSink{bodies: make(chan []byte, 8)}
|
|
|
|
s.Server = httptest.NewServer(http.HandlerFunc(
|
|
func(w http.ResponseWriter, r *http.Request) {
|
|
body, _ := io.ReadAll(r.Body)
|
|
|
|
select {
|
|
case s.bodies <- body:
|
|
default:
|
|
}
|
|
|
|
w.WriteHeader(http.StatusOK)
|
|
},
|
|
))
|
|
|
|
t.Cleanup(s.Close)
|
|
|
|
return s
|
|
}
|
|
|
|
// text returns the Slack message text from the single payload the
|
|
// sink received.
|
|
func (s *tsSink) text(t *testing.T) string {
|
|
t.Helper()
|
|
|
|
select {
|
|
case raw := <-s.bodies:
|
|
t.Logf("raw slack payload: %s", raw)
|
|
|
|
var payload struct {
|
|
Text string `json:"text"`
|
|
}
|
|
|
|
require.NoError(t, json.Unmarshal(raw, &payload))
|
|
|
|
return payload.Text
|
|
case <-time.After(5 * time.Second):
|
|
t.Fatal("slack sink received no payload")
|
|
|
|
return ""
|
|
}
|
|
}
|
|
|
|
func tsSlackConfig(t *testing.T, url string) string {
|
|
t.Helper()
|
|
|
|
data, err := json.Marshal(
|
|
delivery.SlackTargetConfig{WebhookURL: url},
|
|
)
|
|
require.NoError(t, err)
|
|
|
|
return string(data)
|
|
}
|
|
|
|
// tsSeedEvent writes an event whose CreatedAt is tsEventCreatedAt
|
|
// rather than the write time, so an assertion on the rendered
|
|
// timestamp cannot pass by accident against "roughly now".
|
|
func tsSeedEvent(
|
|
t *testing.T, db *gorm.DB, webhookID string,
|
|
) database.Event {
|
|
t.Helper()
|
|
|
|
event := database.Event{
|
|
WebhookID: webhookID,
|
|
EntrypointID: uuid.New().String(),
|
|
Method: http.MethodPost,
|
|
Headers: `{}`,
|
|
Body: tsEventBody,
|
|
ContentType: "application/json",
|
|
}
|
|
event.ID = uuid.New().String()
|
|
event.CreatedAt = tsEventCreatedAt()
|
|
event.UpdatedAt = tsEventCreatedAt()
|
|
|
|
require.NoError(t, db.Create(&event).Error)
|
|
|
|
var stored database.Event
|
|
|
|
require.NoError(t,
|
|
db.First(&stored, "id = ?", event.ID).Error,
|
|
)
|
|
require.Equal(t,
|
|
tsEventCreatedAt().UTC(), stored.CreatedAt.UTC(),
|
|
"seeded created_at did not round-trip",
|
|
)
|
|
|
|
return event
|
|
}
|
|
|
|
// tsSeedTarget writes the slack target row into the main database.
|
|
// The retry path confirms the target still exists before sending.
|
|
func tsSeedTarget(
|
|
t *testing.T, mainDB *gorm.DB, webhookID, config string,
|
|
) database.Target {
|
|
t.Helper()
|
|
|
|
target := database.Target{
|
|
WebhookID: webhookID,
|
|
Name: "slack-sink",
|
|
Type: database.TargetTypeSlack,
|
|
Config: config,
|
|
Active: true,
|
|
MaxAttempts: 1,
|
|
}
|
|
|
|
require.NoError(t, mainDB.Create(&target).Error)
|
|
|
|
return target
|
|
}
|
|
|
|
func tsTask(
|
|
d database.Delivery,
|
|
event database.Event,
|
|
webhookID string,
|
|
target database.Target,
|
|
attemptNum int,
|
|
body *string,
|
|
) delivery.Task {
|
|
return delivery.Task{
|
|
DeliveryID: d.ID,
|
|
EventID: event.ID,
|
|
WebhookID: webhookID,
|
|
EntrypointID: event.EntrypointID,
|
|
TargetID: target.ID,
|
|
TargetName: target.Name,
|
|
TargetType: database.TargetTypeSlack,
|
|
TargetConfig: target.Config,
|
|
MaxAttempts: 1,
|
|
Method: event.Method,
|
|
Headers: event.Headers,
|
|
ContentType: event.ContentType,
|
|
Body: body,
|
|
AttemptNum: attemptNum,
|
|
}
|
|
}
|
|
|
|
func tsAssertRealTimestamp(t *testing.T, text string) {
|
|
t.Helper()
|
|
|
|
assert.NotContains(t, text, tsZeroStamp,
|
|
"slack message carries the zero timestamp",
|
|
)
|
|
assert.Contains(t, text,
|
|
"*Timestamp:* `"+
|
|
tsEventCreatedAt().UTC().Format(time.RFC3339)+"`",
|
|
"slack message does not carry the event's receipt time",
|
|
)
|
|
}
|
|
|
|
// tsCase is one end-to-end delivery of a seeded event to a slack
|
|
// sink, over whichever engine path `process` names.
|
|
type tsCase struct {
|
|
// status is the delivery row's status before the engine runs.
|
|
// The retry path refuses a delivery that is not retrying.
|
|
status database.DeliveryStatus
|
|
|
|
// inlineBody mirrors a Task built for a body under
|
|
// MaxInlineBodySize. When false the engine reads the body back
|
|
// from the stored row.
|
|
inlineBody bool
|
|
|
|
attemptNum int
|
|
|
|
process func(
|
|
ctx context.Context, e *delivery.Engine, task *delivery.Task,
|
|
)
|
|
}
|
|
|
|
// run delivers one event through the named path and returns the
|
|
// Slack message text the sink received.
|
|
func (c tsCase) run(t *testing.T) (iSetup, database.Delivery, string) {
|
|
t.Helper()
|
|
|
|
s := newISetup(t)
|
|
sink := newTSSink(t)
|
|
|
|
cfg := tsSlackConfig(t, sink.URL)
|
|
target := tsSeedTarget(t, s.MainDB, s.WebhookID, cfg)
|
|
event := tsSeedEvent(t, s.WebhookDB, s.WebhookID)
|
|
|
|
d := iSeedDelivery(
|
|
t, s.WebhookDB, event.ID, target.ID, c.status,
|
|
)
|
|
|
|
var body *string
|
|
|
|
if c.inlineBody {
|
|
bodyStr := event.Body
|
|
body = &bodyStr
|
|
}
|
|
|
|
task := tsTask(
|
|
d, event, s.WebhookID, target, c.attemptNum, body,
|
|
)
|
|
|
|
c.process(context.TODO(), s.Engine, &task)
|
|
|
|
return s, d, sink.text(t)
|
|
}
|
|
|
|
// TestSlackFirstAttemptCarriesEventTimestamp covers the path an
|
|
// event takes on its first delivery: the task comes from the
|
|
// receiver and the engine reconstructs the event from it.
|
|
func TestSlackFirstAttemptCarriesEventTimestamp(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
s, d, text := tsCase{
|
|
status: database.DeliveryStatusPending,
|
|
inlineBody: true,
|
|
attemptNum: 1,
|
|
process: func(
|
|
ctx context.Context,
|
|
e *delivery.Engine,
|
|
task *delivery.Task,
|
|
) {
|
|
e.ExportProcessNewTask(ctx, task)
|
|
},
|
|
}.run(t)
|
|
|
|
tsAssertRealTimestamp(t, text)
|
|
|
|
iAssertStatus(t, s.WebhookDB, d.ID,
|
|
database.DeliveryStatusDelivered,
|
|
)
|
|
}
|
|
|
|
// TestSlackFirstAttemptLargeBodyCarriesEventTimestamp covers the
|
|
// first-attempt path for an event whose body exceeded
|
|
// MaxInlineBodySize, so the task carries no body and the engine
|
|
// reads it back from the stored row.
|
|
func TestSlackFirstAttemptLargeBodyCarriesEventTimestamp(
|
|
t *testing.T,
|
|
) {
|
|
t.Parallel()
|
|
|
|
_, _, text := tsCase{
|
|
status: database.DeliveryStatusPending,
|
|
inlineBody: false,
|
|
attemptNum: 1,
|
|
process: func(
|
|
ctx context.Context,
|
|
e *delivery.Engine,
|
|
task *delivery.Task,
|
|
) {
|
|
e.ExportProcessNewTask(ctx, task)
|
|
},
|
|
}.run(t)
|
|
|
|
tsAssertRealTimestamp(t, text)
|
|
}
|
|
|
|
// TestSlackRetryCarriesEventTimestamp covers the retry path, which
|
|
// reconstructs the event from the same task the first attempt used.
|
|
func TestSlackRetryCarriesEventTimestamp(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
s, d, text := tsCase{
|
|
status: database.DeliveryStatusRetrying,
|
|
inlineBody: true,
|
|
attemptNum: 2,
|
|
process: func(
|
|
ctx context.Context,
|
|
e *delivery.Engine,
|
|
task *delivery.Task,
|
|
) {
|
|
e.ExportProcessRetryTask(ctx, task)
|
|
},
|
|
}.run(t)
|
|
|
|
tsAssertRealTimestamp(t, text)
|
|
|
|
iAssertStatus(t, s.WebhookDB, d.ID,
|
|
database.DeliveryStatusDelivered,
|
|
)
|
|
}
|
|
|
|
// TestFormatSlackMessageOverTaskReconstructedEvent asserts on the
|
|
// formatted message directly, over the event the delivery paths
|
|
// reconstruct from a Task. It is the unit-level guard under the
|
|
// end-to-end tests: revert the CreatedAt population in hydrateEvent
|
|
// and this fails on the zero timestamp.
|
|
func TestFormatSlackMessageOverTaskReconstructedEvent(
|
|
t *testing.T,
|
|
) {
|
|
t.Parallel()
|
|
|
|
s := newISetup(t)
|
|
|
|
cfg := tsSlackConfig(t, tsUndeliverableHook)
|
|
target := tsSeedTarget(t, s.MainDB, s.WebhookID, cfg)
|
|
event := tsSeedEvent(t, s.WebhookDB, s.WebhookID)
|
|
|
|
d := iSeedDelivery(
|
|
t, s.WebhookDB, event.ID, target.ID,
|
|
database.DeliveryStatusPending,
|
|
)
|
|
|
|
bodyStr := event.Body
|
|
task := tsTask(d, event, s.WebhookID, target, 1, &bodyStr)
|
|
|
|
rebuilt, err := s.Engine.ExportEventForTask(
|
|
s.WebhookDB, &task,
|
|
)
|
|
require.NoError(t, err)
|
|
assert.False(t, rebuilt.CreatedAt.IsZero(),
|
|
"reconstructed event carries the zero time",
|
|
)
|
|
assert.Equal(t,
|
|
tsEventCreatedAt().UTC(), rebuilt.CreatedAt.UTC(),
|
|
)
|
|
|
|
tsAssertRealTimestamp(
|
|
t, delivery.FormatSlackMessage(&rebuilt),
|
|
)
|
|
}
|
|
|
|
// TestFormatSlackMessageZeroTimestamp asserts the rendering choice
|
|
// directly, without going through the engine: a zero CreatedAt (the
|
|
// shape a reaped-row fallback produces) renders as "unknown" rather
|
|
// than the year-1 zero time, while a real CreatedAt still renders as
|
|
// RFC3339.
|
|
func TestFormatSlackMessageZeroTimestamp(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
zeroEvent := database.Event{
|
|
Method: http.MethodPost,
|
|
ContentType: testContentType,
|
|
Body: tsEventBody,
|
|
}
|
|
|
|
zeroText := delivery.FormatSlackMessage(&zeroEvent)
|
|
|
|
assert.NotContains(t, zeroText, "0001-01-01",
|
|
"slack message carries the zero-time year",
|
|
)
|
|
assert.Contains(t, zeroText, "*Timestamp:* `unknown`",
|
|
"slack message does not mark an unset receipt time as unknown",
|
|
)
|
|
|
|
nonZeroEvent := zeroEvent
|
|
nonZeroEvent.CreatedAt = tsEventCreatedAt()
|
|
|
|
nonZeroText := delivery.FormatSlackMessage(&nonZeroEvent)
|
|
|
|
assert.Contains(t, nonZeroText,
|
|
"*Timestamp:* `"+
|
|
tsEventCreatedAt().UTC().Format(time.RFC3339)+"`",
|
|
"slack message does not render a real receipt time as RFC3339",
|
|
)
|
|
}
|
|
|
|
// TestEventReconstructionSurvivesAReapedRow pins the fallback: an
|
|
// event row reaped by retention while its delivery still holds the
|
|
// body inline is still delivered, with the receipt time unset,
|
|
// rather than dropped.
|
|
func TestEventReconstructionSurvivesAReapedRow(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
s := newISetup(t)
|
|
|
|
cfg := tsSlackConfig(t, tsUndeliverableHook)
|
|
target := tsSeedTarget(t, s.MainDB, s.WebhookID, cfg)
|
|
event := tsSeedEvent(t, s.WebhookDB, s.WebhookID)
|
|
|
|
d := iSeedDelivery(
|
|
t, s.WebhookDB, event.ID, target.ID,
|
|
database.DeliveryStatusPending,
|
|
)
|
|
|
|
bodyStr := event.Body
|
|
task := tsTask(d, event, s.WebhookID, target, 1, &bodyStr)
|
|
|
|
require.NoError(t, s.WebhookDB.Unscoped().Delete(
|
|
&database.Event{}, "id = ?", event.ID,
|
|
).Error)
|
|
|
|
rebuilt, err := s.Engine.ExportEventForTask(
|
|
s.WebhookDB, &task,
|
|
)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, bodyStr, rebuilt.Body)
|
|
assert.True(t, rebuilt.CreatedAt.IsZero())
|
|
|
|
// A task with no inlined body has nothing left to deliver, so
|
|
// the same reaped row is an error there.
|
|
noBody := task
|
|
noBody.Body = nil
|
|
|
|
_, err = s.Engine.ExportEventForTask(s.WebhookDB, &noBody)
|
|
require.Error(t, err)
|
|
}
|