package handlers_test import ( "context" "net/http" "net/http/httptest" "strings" "testing" "time" "github.com/go-chi/chi" "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" ) // statsReceive posts one event to an entrypoint through the real // receiver. func statsReceive(t *testing.T, h *handlers.Handlers, path string) { t.Helper() req := httptest.NewRequestWithContext( context.Background(), http.MethodPost, "/", strings.NewReader(`{"stats":true}`), ) rctx := chi.NewRouteContext() rctx.URLParams.Add("uuid", path) req = req.WithContext(context.WithValue( req.Context(), chi.RouteCtxKey, rctx, )) w := httptest.NewRecorder() h.HandleWebhook().ServeHTTP(w, req) require.Equal(t, http.StatusOK, w.Code) } // 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) } // 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() assert.Equal(t, lifetime, [3]int64{ totals.Events, totals.Deliveries, totals.Failures, }, "lifetime events, deliveries, failures") assert.Equal(t, within, [3]int64{ totals.EventsWithinRetention(), totals.DeliveriesWithinRetention(), totals.FailuresWithinRetention(), }, "events, deliveries, failures within retention") } // TestWebhookStats_EveryFigureAcrossRetentionPrune builds a webhook's // history through the receiver and a replay, settles its deliveries as // the delivery engine would, and checks every figure the statistics // pane shows, 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 := &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) // Three events, each delivered to the two active targets. for range 3 { statsReceive(t, h, 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() // The oldest is past the webhook's one day of retention. 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)) // A replay adds a delivery, still pending, to the oldest event. require.Equal(t, http.StatusSeeOther, postReplay(t, h, sess, wh.ID, oldestFailure).Code) 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 (1 day)") 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) } }