check / check (push) Waiting to run
Each webhook's event database keeps one row of running totals: events, deliveries and failures, and how many of each retention removed. Storing an event, creating a delivery, a delivery becoming failed and the retention sweep each update it in the transaction that writes or deletes the rows it counts. Deliveries get a finished_at column, the last column of the status index, so the last-10-minutes and last-24-hours figures are index-range counts. The pane is its own template, included at the top of the page. The schema changes in place with nothing back-filled, so an existing database must be recreated. Model: opus-5-5
329 lines
9.4 KiB
Go
329 lines
9.4 KiB
Go
package handlers_test
|
|
|
|
import (
|
|
"net/http"
|
|
"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 the id of an event's delivery to a target.
|
|
func statsDelivery(
|
|
t *testing.T, webhookDB *gorm.DB, eventID, targetID string,
|
|
) string {
|
|
t.Helper()
|
|
|
|
var d database.Delivery
|
|
|
|
require.NoError(t, webhookDB.Where(
|
|
"event_id = ? AND target_id = ?", eventID, targetID,
|
|
).First(&d).Error)
|
|
|
|
return d.ID
|
|
}
|
|
|
|
// statsFinish settles a delivery as the delivery engine does: its
|
|
// final status and the time it finished, and for a failure one more on
|
|
// the webhook's failure total, in one transaction.
|
|
func statsFinish(
|
|
t *testing.T,
|
|
webhookDB *gorm.DB,
|
|
deliveryID string,
|
|
status database.DeliveryStatus,
|
|
at time.Time,
|
|
) {
|
|
t.Helper()
|
|
|
|
require.NoError(t, webhookDB.Transaction(func(tx *gorm.DB) error {
|
|
err := tx.Model(&database.Delivery{}).
|
|
Where("id = ?", deliveryID).
|
|
Updates(map[string]any{"status": status, "finished_at": at}).
|
|
Error
|
|
if err != nil || status != database.DeliveryStatusFailed {
|
|
return err
|
|
}
|
|
|
|
return database.AddTotals(tx, database.Totals{Failures: 1})
|
|
}))
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
|
|
// 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. It returns the
|
|
// webhook, its event database and the newest event.
|
|
func seedStatsHistory(
|
|
t *testing.T,
|
|
h *handlers.Handlers,
|
|
sess *session.Session,
|
|
db *database.Database,
|
|
dbMgr *database.WebhookDBManager,
|
|
) (*database.Webhook, *gorm.DB, database.Event) {
|
|
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).Code)
|
|
|
|
return wh, webhookDB, newest
|
|
}
|
|
|
|
// 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.Totals
|
|
|
|
err := webhookDB.Take(&totals).Error
|
|
|
|
return err == nil && totals.EventsRemoved == 1
|
|
}, 10*time.Second, 10*time.Millisecond)
|
|
|
|
lc.RequireStop()
|
|
}
|
|
|
|
// assertStatsTotals checks the lifetime events, deliveries and
|
|
// failures, and those within retention.
|
|
func assertStatsTotals(
|
|
t *testing.T, totals database.Totals, lifetime, within [3]int64,
|
|
) {
|
|
t.Helper()
|
|
|
|
gotLifetime := [3]int64{
|
|
totals.Events, totals.Deliveries, totals.Failures,
|
|
}
|
|
gotWithin := [3]int64{
|
|
totals.EventsWithinRetention(),
|
|
totals.DeliveriesWithinRetention(),
|
|
totals.FailuresWithinRetention(),
|
|
}
|
|
|
|
assert.Equal(t, lifetime, gotLifetime,
|
|
"lifetime events, deliveries, failures")
|
|
assert.Equal(t, within, gotWithin,
|
|
"events, deliveries, failures within retention")
|
|
}
|
|
|
|
// TestWebhookStats_EveryFigureAcrossRetentionPrune checks every figure
|
|
// the statistics pane shows for the history seedStatsHistory builds,
|
|
// 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)
|
|
|
|
wh, webhookDB, newest := seedStatsHistory(t, h, sess, db, dbMgr)
|
|
|
|
stats := h.WebhookStatsForTest(wh.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)
|
|
assertStatsTotals(t, stats.Totals, [3]int64{3, 7, 3}, [3]int64{3, 7, 3})
|
|
assert.Equal(t, int64(2), stats.InProgress)
|
|
require.NotNil(t, stats.LastEventAt)
|
|
assert.True(t, 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())
|
|
|
|
// Retention removes the oldest event with its three deliveries,
|
|
// one of them failed and one the pending replay.
|
|
statsPrune(t, db, dbMgr, log, webhookDB)
|
|
|
|
after := h.WebhookStatsForTest(wh.ID)
|
|
require.NotNil(t, after)
|
|
|
|
assertStatsTotals(t, after.Totals, [3]int64{3, 7, 3}, [3]int64{2, 4, 2})
|
|
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)
|
|
|
|
body := renderSourceDetailPage(t, h, sess, wh.ID)
|
|
assert.Contains(t, body, "Statistics")
|
|
assert.Contains(t, body, "Within retention")
|
|
assert.Contains(t, body, "50.0%")
|
|
assert.Contains(t, body, "66.7%")
|
|
}
|
|
|
|
// 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())
|
|
|
|
body := renderSourceDetailPage(t, h, sess, wh.ID)
|
|
assert.Contains(t, body, "Statistics")
|
|
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)
|
|
}
|
|
}
|