check / check (push) Waiting to run
Each webhook's event database keeps running totals: one row for its events, and one row per target for that target's deliveries, delivered and failed, each with what retention removed. Every write to them shares the transaction of the rows it counts. 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. 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
433 lines
12 KiB
Go
433 lines
12 KiB
Go
package handlers_test
|
|
|
|
import (
|
|
"net/http"
|
|
"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, two entrypoints (one inactive) and three targets
|
|
// (one inactive). Three events arrive through the receiver, and so
|
|
// each has a delivery to the two active targets. The oldest event is
|
|
// past retention, the middle one six hours old, the newest just in.
|
|
// Their deliveries are settled as the delivery engine would, and a
|
|
// replay adds a pending delivery to the oldest event.
|
|
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)
|
|
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 3 {
|
|
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, 3)
|
|
|
|
oldest, middle, newest := events[0], events[1], events[2]
|
|
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 statistics pane from a rendered webhook page:
|
|
// everything from its heading to the next heading on the page.
|
|
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")
|
|
|
|
return pane
|
|
}
|
|
|
|
// 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, 2, stats.Entrypoints)
|
|
assert.Equal(t, 1, stats.ActiveEntrypoints)
|
|
assert.Equal(t, 3, stats.Targets)
|
|
assert.Equal(t, 2, stats.ActiveTargets)
|
|
assert.Equal(t, handlers.Counts{Events: 3, Deliveries: 7, Failures: 3},
|
|
stats.Lifetime)
|
|
assert.Equal(t, stats.Lifetime, stats.WithinRetention)
|
|
assert.Equal(t, int64(2), stats.InProgress)
|
|
require.NotNil(t, stats.LastEventAt)
|
|
assert.True(t, hist.newest.CreatedAt.Equal(*stats.LastEventAt))
|
|
assert.Equal(t, handlers.RecentWindow{
|
|
Events: 1, Delivered: 1, Failed: 1,
|
|
}, stats.Last10Minutes)
|
|
assert.Equal(t, handlers.RecentWindow{
|
|
Events: 2, Delivered: 1, Failed: 2,
|
|
}, stats.Last24Hours)
|
|
assert.Equal(t, "50.0%", stats.Last10Minutes.FailurePercent())
|
|
assert.Equal(t, "66.7%", stats.Last24Hours.FailurePercent())
|
|
|
|
// The first target has three deliveries and the replay, the second
|
|
// three; the inactive target has none and so no row.
|
|
assert.Equal(t, map[string]database.TargetTotals{
|
|
first: {TargetID: first, Deliveries: 4, Delivered: 1, Failed: 2},
|
|
second: {
|
|
TargetID: second, Deliveries: 3, 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)
|
|
|
|
// 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: 2, Deliveries: 4, Failures: 2},
|
|
after.WithinRetention)
|
|
assert.Equal(t, int64(1), 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: 4, Delivered: 1, Failed: 2,
|
|
DeliveriesRemoved: 2, FailedRemoved: 1,
|
|
},
|
|
second: {
|
|
TargetID: second, Deliveries: 3, Delivered: 1, Failed: 1,
|
|
DeliveriesRemoved: 1,
|
|
},
|
|
}, statsTargetTotals(t, hist.webhookDB))
|
|
|
|
pane := statsPane(t, renderSourceDetailPage(t, h, sess, hist.webhook.ID))
|
|
assert.Contains(t, pane, "Within retention")
|
|
assert.Contains(t, pane, "50.0%")
|
|
assert.Contains(t, pane, "66.7%")
|
|
}
|
|
|
|
// 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)
|
|
assert.Contains(t, pane, 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())
|
|
|
|
statsPane(t, renderSourceDetailPage(t, h, sess, wh.ID))
|
|
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)
|
|
}
|
|
}
|