check / check (push) Successful in 3m33s
Each webhook's event database keeps running totals: one row for its events, with when the newest arrived, and one row per target for its deliveries, delivered and failed, each with what retention removed. Every write to them shares the transaction of the rows it counts, and a delivery already delivered or failed is not settled again. Deliveries get a finished_at column; it and target_id end the status index, so each target's deliveries finished in a window come from one index-range query grouped by target. Retention deletes 1000 expired events per transaction, pausing 200 ms between them so other writers get in, and stops between them on shutdown. The pane is its own template, its figures in tables. The schema changes in place with nothing back-filled, so an existing database must be recreated. Model: opus-5-5
531 lines
15 KiB
Go
531 lines
15 KiB
Go
package handlers_test
|
|
|
|
import (
|
|
"net/http"
|
|
"regexp"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/google/uuid"
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
"go.uber.org/fx/fxtest"
|
|
"gorm.io/gorm"
|
|
"gorm.io/gorm/clause"
|
|
"sneak.berlin/go/webhooker/internal/config"
|
|
"sneak.berlin/go/webhooker/internal/database"
|
|
"sneak.berlin/go/webhooker/internal/handlers"
|
|
"sneak.berlin/go/webhooker/internal/logger"
|
|
"sneak.berlin/go/webhooker/internal/session"
|
|
)
|
|
|
|
// statsEntrypoint adds an entrypoint to a webhook and returns its path.
|
|
func statsEntrypoint(
|
|
t *testing.T, db *database.Database, webhookID string, active bool,
|
|
) string {
|
|
t.Helper()
|
|
|
|
ep := &database.Entrypoint{
|
|
WebhookID: webhookID,
|
|
Path: uuid.New().String(),
|
|
}
|
|
|
|
require.NoError(t, db.DB().Omit(clause.Associations).Create(ep).Error)
|
|
require.NoError(t, db.DB().Model(ep).Update("active", active).Error)
|
|
|
|
return ep.Path
|
|
}
|
|
|
|
// statsDelivery returns an event's delivery to a target.
|
|
func statsDelivery(
|
|
t *testing.T, webhookDB *gorm.DB, eventID, targetID string,
|
|
) database.Delivery {
|
|
t.Helper()
|
|
|
|
var d database.Delivery
|
|
|
|
require.NoError(t, webhookDB.Where(
|
|
"event_id = ? AND target_id = ?", eventID, targetID,
|
|
).First(&d).Error)
|
|
|
|
return d
|
|
}
|
|
|
|
// statsFinish settles a delivery as the delivery engine does: its
|
|
// final status and the time it finished, and one more on its target's
|
|
// delivered or failed total, in one transaction.
|
|
func statsFinish(
|
|
t *testing.T,
|
|
webhookDB *gorm.DB,
|
|
d database.Delivery,
|
|
status database.DeliveryStatus,
|
|
at time.Time,
|
|
) {
|
|
t.Helper()
|
|
|
|
add := database.TargetTotals{TargetID: d.TargetID, Delivered: 1}
|
|
if status == database.DeliveryStatusFailed {
|
|
add = database.TargetTotals{TargetID: d.TargetID, Failed: 1}
|
|
}
|
|
|
|
require.NoError(t, webhookDB.Transaction(func(tx *gorm.DB) error {
|
|
err := tx.Model(&database.Delivery{}).
|
|
Where("id = ?", d.ID).
|
|
Updates(map[string]any{"status": status, "finished_at": at}).
|
|
Error
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
return database.AddTargetTotals(tx, add)
|
|
}))
|
|
}
|
|
|
|
// statsAge moves an event's arrival back to the given time.
|
|
func statsAge(
|
|
t *testing.T, webhookDB *gorm.DB, eventID string, at time.Time,
|
|
) {
|
|
t.Helper()
|
|
|
|
require.NoError(t, webhookDB.Model(&database.Event{}).
|
|
Where("id = ?", eventID).
|
|
Update("created_at", at).Error)
|
|
}
|
|
|
|
// statsTargetTotals reads a webhook database's target totals, keyed by
|
|
// target.
|
|
func statsTargetTotals(
|
|
t *testing.T, webhookDB *gorm.DB,
|
|
) map[string]database.TargetTotals {
|
|
t.Helper()
|
|
|
|
var rows []database.TargetTotals
|
|
|
|
require.NoError(t, webhookDB.Find(&rows).Error)
|
|
|
|
byTarget := make(map[string]database.TargetTotals, len(rows))
|
|
for _, row := range rows {
|
|
byTarget[row.TargetID] = row
|
|
}
|
|
|
|
return byTarget
|
|
}
|
|
|
|
// statsHistory is the webhook seedStatsHistory builds: its event
|
|
// database, its newest event, and its two active targets.
|
|
type statsHistory struct {
|
|
webhook *database.Webhook
|
|
webhookDB *gorm.DB
|
|
newest database.Event
|
|
first, second string
|
|
}
|
|
|
|
// seedStatsHistory builds the webhook the statistics test checks: one
|
|
// day of retention, five entrypoints (one inactive) and three targets
|
|
// (one inactive). Six events arrive through the receiver, and so each
|
|
// has a delivery to the two active targets. The oldest event is past
|
|
// retention, the next six hours old, the other four just in. Five
|
|
// deliveries are settled as the delivery engine would, the rest left
|
|
// pending, and a replay adds a pending delivery to the oldest event.
|
|
// Once retention has removed the oldest event, no two figures in the
|
|
// pane's top row, or in the same table, are equal.
|
|
func seedStatsHistory(
|
|
t *testing.T,
|
|
h *handlers.Handlers,
|
|
sess *session.Session,
|
|
db *database.Database,
|
|
dbMgr *database.WebhookDBManager,
|
|
) statsHistory {
|
|
t.Helper()
|
|
|
|
wh := &database.Webhook{
|
|
UserID: deleteTestUserID, Name: "stats", RetentionDays: 1,
|
|
}
|
|
require.NoError(t, db.DB().Omit(clause.Associations).Create(wh).Error)
|
|
|
|
path := statsEntrypoint(t, db, wh.ID, true)
|
|
for range 3 {
|
|
statsEntrypoint(t, db, wh.ID, true)
|
|
}
|
|
|
|
statsEntrypoint(t, db, wh.ID, false)
|
|
|
|
first := seedConfiguredTarget(
|
|
t, db, wh.ID, database.TargetTypeHTTP,
|
|
`{"url":"`+replayTargetURL+`"}`,
|
|
)
|
|
second := seedTarget(t, db, wh.ID, database.TargetTypeLog)
|
|
inactive := seedTarget(t, db, wh.ID, database.TargetTypeLog)
|
|
require.NoError(t, db.DB().Model(inactive).
|
|
Update("active", false).Error)
|
|
|
|
router := receiverRouter(h)
|
|
|
|
for range 6 {
|
|
require.Equal(t, http.StatusOK, postReceiver(t, router, path))
|
|
}
|
|
|
|
webhookDB, err := dbMgr.GetDB(wh.ID)
|
|
require.NoError(t, err)
|
|
|
|
events := listEvents(t, webhookDB)
|
|
require.Len(t, events, 6)
|
|
|
|
oldest, middle, newest := events[0], events[1], events[5]
|
|
now := time.Now()
|
|
|
|
statsAge(t, webhookDB, oldest.ID, now.Add(-50*time.Hour))
|
|
statsAge(t, webhookDB, middle.ID, now.Add(-6*time.Hour))
|
|
|
|
oldestFailure := statsDelivery(t, webhookDB, oldest.ID, first.ID)
|
|
statsFinish(t, webhookDB, oldestFailure,
|
|
database.DeliveryStatusFailed, now.Add(-49*time.Hour))
|
|
statsFinish(t, webhookDB,
|
|
statsDelivery(t, webhookDB, oldest.ID, second.ID),
|
|
database.DeliveryStatusDelivered, now.Add(-49*time.Hour))
|
|
statsFinish(t, webhookDB,
|
|
statsDelivery(t, webhookDB, middle.ID, first.ID),
|
|
database.DeliveryStatusFailed, now.Add(-5*time.Hour))
|
|
statsFinish(t, webhookDB,
|
|
statsDelivery(t, webhookDB, middle.ID, second.ID),
|
|
database.DeliveryStatusFailed, now.Add(-time.Minute))
|
|
statsFinish(t, webhookDB,
|
|
statsDelivery(t, webhookDB, newest.ID, first.ID),
|
|
database.DeliveryStatusDelivered, now.Add(-2*time.Minute))
|
|
|
|
require.Equal(t, http.StatusSeeOther,
|
|
postReplay(t, h, sess, wh.ID, oldestFailure.ID).Code)
|
|
|
|
return statsHistory{
|
|
webhook: wh,
|
|
webhookDB: webhookDB,
|
|
newest: newest,
|
|
first: first.ID,
|
|
second: second.ID,
|
|
}
|
|
}
|
|
|
|
// statsPrune runs the real retention reaper until it has removed one
|
|
// event from the webhook's database, then stops it.
|
|
func statsPrune(
|
|
t *testing.T,
|
|
db *database.Database,
|
|
dbMgr *database.WebhookDBManager,
|
|
log *logger.Logger,
|
|
webhookDB *gorm.DB,
|
|
) {
|
|
t.Helper()
|
|
|
|
lc := fxtest.NewLifecycle(t)
|
|
database.NewRetentionReaper(lc, database.RetentionReaperParams{
|
|
Config: &config.Config{
|
|
RetentionSweepInterval: 10 * time.Millisecond,
|
|
},
|
|
Database: db,
|
|
DBManager: dbMgr,
|
|
Logger: log,
|
|
})
|
|
|
|
lc.RequireStart()
|
|
|
|
require.Eventually(t, func() bool {
|
|
var totals database.EventTotals
|
|
|
|
err := webhookDB.Take(&totals).Error
|
|
|
|
return err == nil && totals.EventsRemoved == 1
|
|
}, 10*time.Second, 10*time.Millisecond)
|
|
|
|
lc.RequireStop()
|
|
}
|
|
|
|
// statsPane returns the text of the statistics pane in a rendered
|
|
// webhook page, everything from its heading to the next heading on the
|
|
// page, with the markup taken out and each run of space made one
|
|
// space. A table then reads header by header and row by row, each
|
|
// row's label followed by its figures in column order.
|
|
func statsPane(t *testing.T, page string) string {
|
|
t.Helper()
|
|
|
|
_, pane, found := strings.Cut(page, ">Statistics</h2>")
|
|
require.True(t, found, "the page has no statistics pane")
|
|
|
|
pane, _, _ = strings.Cut(pane, "<h2")
|
|
pane = regexp.MustCompile(`<[^>]*>`).ReplaceAllString(pane, " ")
|
|
|
|
return strings.Join(strings.Fields(pane), " ")
|
|
}
|
|
|
|
// assertStatsTargets checks, for the history seedStatsHistory builds,
|
|
// each target's totals and its deliveries finished in the last 24
|
|
// hours. The first target has six deliveries and the replay, the
|
|
// second six; the inactive target has none and so no row.
|
|
func assertStatsTargets(t *testing.T, hist statsHistory) {
|
|
t.Helper()
|
|
|
|
first, second := hist.first, hist.second
|
|
|
|
assert.Equal(t, map[string]database.TargetTotals{
|
|
first: {TargetID: first, Deliveries: 7, Delivered: 1, Failed: 2},
|
|
second: {
|
|
TargetID: second, Deliveries: 6, Delivered: 1, Failed: 1,
|
|
},
|
|
}, statsTargetTotals(t, hist.webhookDB))
|
|
|
|
lastDay, err := handlers.FinishedByTargetForTest(
|
|
hist.webhookDB, time.Now().Add(-24*time.Hour),
|
|
)
|
|
require.NoError(t, err)
|
|
assert.ElementsMatch(t, []handlers.TargetFinished{
|
|
{TargetID: first, Delivered: 1, Failed: 1},
|
|
{TargetID: second, Failed: 1},
|
|
}, lastDay)
|
|
}
|
|
|
|
// assertStatsPaneAfterPrune checks the rendered statistics pane for the
|
|
// history seedStatsHistory builds, once retention has removed the
|
|
// oldest event: each figure after its label, in its column.
|
|
func assertStatsPaneAfterPrune(
|
|
t *testing.T,
|
|
h *handlers.Handlers,
|
|
sess *session.Session,
|
|
hist statsHistory,
|
|
) {
|
|
t.Helper()
|
|
|
|
pane := statsPane(t, renderSourceDetailPage(t, h, sess, hist.webhook.ID))
|
|
lastEvent := hist.newest.CreatedAt.Format("2006-01-02 15:04:05 UTC")
|
|
|
|
assert.Contains(t, pane, "Entrypoints 5 (4 active) "+
|
|
"Targets 3 (2 active) "+
|
|
"Deliveries in progress 7 "+
|
|
"Last event "+lastEvent+" "+
|
|
"Retention 1 day")
|
|
assert.Contains(t, pane, "Lifetime Within retention "+
|
|
"Events 6 5 "+
|
|
"Deliveries 13 10 "+
|
|
"Failures 3 2")
|
|
assert.Contains(t, pane, "Last 10 minutes Last 24 hours "+
|
|
"Events 4 5 "+
|
|
"Failures 1 2 "+
|
|
"Failure percentage 50.0% 66.7%")
|
|
}
|
|
|
|
// TestWebhookStats_EveryFigureAcrossRetentionPrune checks every figure
|
|
// the statistics pane shows for the history seedStatsHistory builds,
|
|
// and each target's totals and recent figures, before and after the
|
|
// real retention reaper removes the oldest event.
|
|
func TestWebhookStats_EveryFigureAcrossRetentionPrune(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
var (
|
|
h *handlers.Handlers
|
|
sess *session.Session
|
|
db *database.Database
|
|
dbMgr *database.WebhookDBManager
|
|
log *logger.Logger
|
|
)
|
|
|
|
app := newTestApp(t, &h, &sess, &db, &dbMgr, &log)
|
|
app.RequireStart()
|
|
|
|
t.Cleanup(app.RequireStop)
|
|
|
|
hist := seedStatsHistory(t, h, sess, db, dbMgr)
|
|
first, second := hist.first, hist.second
|
|
|
|
stats := h.WebhookStatsForTest(hist.webhook.ID)
|
|
require.NotNil(t, stats)
|
|
|
|
assert.Equal(t, 5, stats.Entrypoints)
|
|
assert.Equal(t, 4, stats.ActiveEntrypoints)
|
|
assert.Equal(t, 3, stats.Targets)
|
|
assert.Equal(t, 2, stats.ActiveTargets)
|
|
assert.Equal(t, handlers.Counts{Events: 6, Deliveries: 13, Failures: 3},
|
|
stats.Lifetime)
|
|
assert.Equal(t, stats.Lifetime, stats.WithinRetention)
|
|
assert.Equal(t, int64(8), stats.InProgress)
|
|
require.NotNil(t, stats.LastEventAt)
|
|
assert.True(t, hist.newest.CreatedAt.Equal(*stats.LastEventAt))
|
|
assert.Equal(t, handlers.RecentWindow{
|
|
Events: 4, Delivered: 1, Failed: 1,
|
|
}, stats.Last10Minutes)
|
|
assert.Equal(t, handlers.RecentWindow{
|
|
Events: 5, Delivered: 1, Failed: 2,
|
|
}, stats.Last24Hours)
|
|
assert.Equal(t, "50.0%", stats.Last10Minutes.FailurePercent())
|
|
assert.Equal(t, "66.7%", stats.Last24Hours.FailurePercent())
|
|
|
|
assertStatsTargets(t, hist)
|
|
|
|
// Retention removes the oldest event with its three deliveries:
|
|
// the first target's failed one and the pending replay, and the
|
|
// second target's delivered one.
|
|
statsPrune(t, db, dbMgr, log, hist.webhookDB)
|
|
|
|
after := h.WebhookStatsForTest(hist.webhook.ID)
|
|
require.NotNil(t, after)
|
|
|
|
assert.Equal(t, stats.Lifetime, after.Lifetime)
|
|
assert.Equal(t, handlers.Counts{Events: 5, Deliveries: 10, Failures: 2},
|
|
after.WithinRetention)
|
|
assert.Equal(t, int64(7), after.InProgress)
|
|
assert.Equal(t, stats.LastEventAt, after.LastEventAt)
|
|
assert.Equal(t, stats.Last10Minutes, after.Last10Minutes)
|
|
assert.Equal(t, stats.Last24Hours, after.Last24Hours)
|
|
|
|
assert.Equal(t, map[string]database.TargetTotals{
|
|
first: {
|
|
TargetID: first, Deliveries: 7, Delivered: 1, Failed: 2,
|
|
DeliveriesRemoved: 2, FailedRemoved: 1,
|
|
},
|
|
second: {
|
|
TargetID: second, Deliveries: 6, Delivered: 1, Failed: 1,
|
|
DeliveriesRemoved: 1,
|
|
},
|
|
}, statsTargetTotals(t, hist.webhookDB))
|
|
|
|
assertStatsPaneAfterPrune(t, h, sess, hist)
|
|
}
|
|
|
|
// TestWebhookStats_LastEventSurvivesPruningEveryEvent checks that once
|
|
// retention has removed every event, the pane still shows when the last
|
|
// one arrived rather than "none".
|
|
func TestWebhookStats_LastEventSurvivesPruningEveryEvent(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
var (
|
|
h *handlers.Handlers
|
|
sess *session.Session
|
|
db *database.Database
|
|
dbMgr *database.WebhookDBManager
|
|
log *logger.Logger
|
|
)
|
|
|
|
app := newTestApp(t, &h, &sess, &db, &dbMgr, &log)
|
|
app.RequireStart()
|
|
|
|
t.Cleanup(app.RequireStop)
|
|
|
|
wh := &database.Webhook{
|
|
UserID: deleteTestUserID, Name: "pruned", RetentionDays: 1,
|
|
}
|
|
require.NoError(t, db.DB().Omit(clause.Associations).Create(wh).Error)
|
|
|
|
path := statsEntrypoint(t, db, wh.ID, true)
|
|
require.Equal(t, http.StatusOK,
|
|
postReceiver(t, receiverRouter(h), path))
|
|
|
|
webhookDB, err := dbMgr.GetDB(wh.ID)
|
|
require.NoError(t, err)
|
|
|
|
events := listEvents(t, webhookDB)
|
|
require.Len(t, events, 1)
|
|
|
|
arrived := events[0].CreatedAt
|
|
|
|
statsAge(t, webhookDB, events[0].ID, time.Now().Add(-50*time.Hour))
|
|
statsPrune(t, db, dbMgr, log, webhookDB)
|
|
require.Empty(t, listEvents(t, webhookDB))
|
|
|
|
stats := h.WebhookStatsForTest(wh.ID)
|
|
require.NotNil(t, stats)
|
|
require.NotNil(t, stats.LastEventAt)
|
|
assert.True(t, arrived.Equal(*stats.LastEventAt))
|
|
|
|
pane := statsPane(t, renderSourceDetailPage(t, h, sess, wh.ID))
|
|
assert.Contains(t, pane,
|
|
"Last event "+arrived.Format("2006-01-02 15:04:05 UTC"))
|
|
}
|
|
|
|
// TestWebhookStats_PaneShowsRetentionPeriod checks that the statistics
|
|
// pane itself, not only the line at the foot of the page, shows the
|
|
// webhook's retention period, for a finite one and for forever.
|
|
func TestWebhookStats_PaneShowsRetentionPeriod(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
var (
|
|
h *handlers.Handlers
|
|
sess *session.Session
|
|
db *database.Database
|
|
)
|
|
|
|
app := newTestApp(t, &h, &sess, &db)
|
|
app.RequireStart()
|
|
|
|
t.Cleanup(app.RequireStop)
|
|
|
|
tests := []struct {
|
|
retentionDays int
|
|
want string
|
|
}{
|
|
{30, "30 days"},
|
|
{database.RetentionForeverDays, "forever"},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
wh := &database.Webhook{
|
|
UserID: deleteTestUserID,
|
|
Name: "retention",
|
|
RetentionDays: tt.retentionDays,
|
|
}
|
|
require.NoError(t,
|
|
db.DB().Omit(clause.Associations).Create(wh).Error)
|
|
|
|
pane := statsPane(t, renderSourceDetailPage(t, h, sess, wh.ID))
|
|
assert.Contains(t, pane, "Retention "+tt.want)
|
|
}
|
|
}
|
|
|
|
// TestWebhookStats_WebhookWithNoEvents covers a webhook whose event
|
|
// database has never been opened: every count is zero, the
|
|
// percentages are a dash, and showing the page does not create the
|
|
// database.
|
|
func TestWebhookStats_WebhookWithNoEvents(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
var (
|
|
h *handlers.Handlers
|
|
sess *session.Session
|
|
db *database.Database
|
|
dbMgr *database.WebhookDBManager
|
|
)
|
|
|
|
app := newTestApp(t, &h, &sess, &db, &dbMgr)
|
|
app.RequireStart()
|
|
|
|
t.Cleanup(app.RequireStop)
|
|
|
|
wh := seedWebhook(t, db)
|
|
|
|
assert.Equal(t, &handlers.WebhookStats{}, h.WebhookStatsForTest(wh.ID))
|
|
assert.Equal(t, "—", handlers.RecentWindow{}.FailurePercent())
|
|
|
|
pane := statsPane(t, renderSourceDetailPage(t, h, sess, wh.ID))
|
|
assert.Contains(t, pane, "Last event none")
|
|
assert.Contains(t, pane, "Failure percentage — —")
|
|
assert.False(t, dbMgr.DBExists(wh.ID))
|
|
}
|
|
|
|
// TestRecentWindow_FailurePercent pins the percentage: failed
|
|
// deliveries out of all that finished in the window.
|
|
func TestRecentWindow_FailurePercent(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
tests := []struct {
|
|
window handlers.RecentWindow
|
|
want string
|
|
}{
|
|
{handlers.RecentWindow{}, "—"},
|
|
{handlers.RecentWindow{Events: 4}, "—"},
|
|
{handlers.RecentWindow{Delivered: 3, Failed: 1}, "25.0%"},
|
|
{handlers.RecentWindow{Failed: 2}, "100.0%"},
|
|
{handlers.RecentWindow{Delivered: 2}, "0.0%"},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
assert.Equal(t, tt.want, tt.window.FailurePercent(), tt.window)
|
|
}
|
|
}
|