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") require.True(t, found, "the page has no statistics pane") pane, _, _ = strings.Cut(pane, "