Files
webhooker/internal/handlers/source_list_test.go
T
clawbot 1692453bbd
check / check (push) Waiting to run
Show each webhook's activity in the webhook list (closes #394)
Each entry in the webhook list now shows when the webhook's last event
arrived, or "No events yet", and how many of its deliveries failed in
the last 24 hours, in red when that is not zero. The entrypoint and
target counts say how many are inactive.

The figures come from the statistics pane's own reads: the event totals
row, which now also gives the event count instead of counting every
stored event, and the pane's query over the deliveries finished in the
last 24 hours. A webhook whose event database cannot be read says so in
its entry rather than showing zeros.

Model: opus-5-5
2026-10-02 05:04:58 +00:00

268 lines
7.6 KiB
Go

package handlers_test
import (
"net/http"
"net/http/httptest"
"regexp"
"strings"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"gorm.io/gorm"
"gorm.io/gorm/clause"
"sneak.berlin/go/webhooker/internal/database"
"sneak.berlin/go/webhooker/internal/handlers"
"sneak.berlin/go/webhooker/internal/session"
)
// failedHighlight is how the list marks a number of failed deliveries
// that is not zero.
const failedHighlight = `class="font-medium text-red-600"`
// listWebhook adds a webhook with the given name, owned by the test
// user.
func listWebhook(
t *testing.T, db *database.Database, name string,
) *database.Webhook {
t.Helper()
wh := &database.Webhook{UserID: deleteTestUserID, Name: name}
require.NoError(t, db.DB().Omit(clause.Associations).Create(wh).Error)
return wh
}
// renderWebhookList runs the real webhook list handler as the test user
// and returns the rendered page.
func renderWebhookList(
t *testing.T, h *handlers.Handlers, sess *session.Session,
) string {
t.Helper()
cookies := authenticatedCookies(
t, sess, deleteTestUserID, deleteTestUsername,
)
w := httptest.NewRecorder()
h.HandleSourceList().ServeHTTP(
w, getRequest(t, "/hooks", cookies, nil),
)
require.Equal(t, http.StatusOK, w.Code)
return w.Body.String()
}
// listCard returns one webhook's entry in a rendered webhook list, its
// markup as rendered and its text with the markup taken out and each
// run of space made one space.
func listCard(t *testing.T, page, webhookID string) (string, string) {
t.Helper()
_, card, found := strings.Cut(page, `href="/hook/`+webhookID+`"`)
require.True(t, found, "the list has no entry for %s", webhookID)
card, _, _ = strings.Cut(card, "</a>")
text := regexp.MustCompile(`<[^>]*>`).ReplaceAllString(card, " ")
return card, strings.Join(strings.Fields(text), " ")
}
// receiveEvents posts the given number of events to an entrypoint
// through the real receiver, and returns the webhook's event database
// and its events, oldest first.
func receiveEvents(
t *testing.T,
h *handlers.Handlers,
dbMgr *database.WebhookDBManager,
webhookID, path string,
count int,
) (*gorm.DB, []database.Event) {
t.Helper()
router := receiverRouter(h)
for range count {
require.Equal(t, http.StatusOK, postReceiver(t, router, path))
}
webhookDB, err := dbMgr.GetDB(webhookID)
require.NoError(t, err)
events := listEvents(t, webhookDB)
require.Len(t, events, count)
return webhookDB, events
}
// seedFailingWebhook adds a webhook with two entrypoints, one inactive,
// and four targets, one inactive. Three events each reach the three
// active targets. Two deliveries failed in the last 24 hours, one 30
// hours ago, and one was delivered. It returns the webhook and its
// newest event.
func seedFailingWebhook(
t *testing.T,
h *handlers.Handlers,
db *database.Database,
dbMgr *database.WebhookDBManager,
) (*database.Webhook, database.Event) {
t.Helper()
wh := listWebhook(t, db, "failing")
path := statsEntrypoint(t, db, wh.ID, true)
statsEntrypoint(t, db, wh.ID, false)
first := seedTarget(t, db, wh.ID, database.TargetTypeLog)
second := seedTarget(t, db, wh.ID, database.TargetTypeLog)
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)
webhookDB, events := receiveEvents(t, h, dbMgr, wh.ID, path, 3)
now := time.Now()
statsFinish(t, webhookDB,
statsDelivery(t, webhookDB, events[0].ID, first.ID),
database.DeliveryStatusFailed, now.Add(-30*time.Hour))
statsFinish(t, webhookDB,
statsDelivery(t, webhookDB, events[1].ID, first.ID),
database.DeliveryStatusFailed, now.Add(-time.Hour))
statsFinish(t, webhookDB,
statsDelivery(t, webhookDB, events[2].ID, first.ID),
database.DeliveryStatusFailed, now.Add(-time.Minute))
statsFinish(t, webhookDB,
statsDelivery(t, webhookDB, events[2].ID, second.ID),
database.DeliveryStatusDelivered, now.Add(-time.Minute))
return wh, events[2]
}
// seedHealthyWebhook adds a webhook with one entrypoint and one target,
// both active, and two events, both delivered. It returns the webhook
// and its newest event.
func seedHealthyWebhook(
t *testing.T,
h *handlers.Handlers,
db *database.Database,
dbMgr *database.WebhookDBManager,
) (*database.Webhook, database.Event) {
t.Helper()
wh := listWebhook(t, db, "healthy")
path := statsEntrypoint(t, db, wh.ID, true)
target := seedTarget(t, db, wh.ID, database.TargetTypeLog)
webhookDB, events := receiveEvents(t, h, dbMgr, wh.ID, path, 2)
for _, ev := range events {
statsFinish(t, webhookDB,
statsDelivery(t, webhookDB, ev.ID, target.ID),
database.DeliveryStatusDelivered, time.Now())
}
return wh, events[1]
}
// lastEventText is how the list shows the arrival of an event.
func lastEventText(ev database.Event) string {
return ev.CreatedAt.UTC().Format("2006-01-02 15:04:05 UTC")
}
// TestSourceList_ShowsActivityOfEachWebhook checks the figures the list
// shows for a webhook with recent failures, a healthy one, and one that
// has never received an event.
func TestSourceList_ShowsActivityOfEachWebhook(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)
failing, failingNewest := seedFailingWebhook(t, h, db, dbMgr)
healthy, healthyNewest := seedHealthyWebhook(t, h, db, dbMgr)
quiet := listWebhook(t, db, "quiet")
page := renderWebhookList(t, h, sess)
card, text := listCard(t, page, failing.ID)
assert.Contains(t, text, "2 entrypoints, 1 inactive "+
"4 targets, 1 inactive "+
"3 events "+
"Last event "+lastEventText(failingNewest)+" "+
"2 failed deliveries in the last 24 hours")
assert.Contains(t, card,
failedHighlight+">2 failed deliveries in the last 24 hours<")
card, text = listCard(t, page, healthy.ID)
assert.Contains(t, text, "1 entrypoint "+
"1 target "+
"2 events "+
"Last event "+lastEventText(healthyNewest)+" "+
"0 failed deliveries in the last 24 hours")
assert.NotContains(t, text, "inactive")
assert.NotContains(t, card, failedHighlight)
card, text = listCard(t, page, quiet.ID)
assert.Contains(t, text, "0 entrypoints "+
"0 targets "+
"0 events "+
"No events yet "+
"0 failed deliveries in the last 24 hours")
assert.NotContains(t, card, failedHighlight)
assert.False(t, dbMgr.DBExists(quiet.ID),
"showing the list must not create an event database")
}
// TestSourceList_UnreadableEventDatabase checks that a webhook whose
// event database cannot be read says so in its entry instead of
// showing zeros, and that the rest of the list is still shown.
func TestSourceList_UnreadableEventDatabase(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)
broken := listWebhook(t, db, "broken")
statsEntrypoint(t, db, broken.ID, true)
brokenDB, err := dbMgr.GetDB(broken.ID)
require.NoError(t, err)
require.NoError(t,
brokenDB.Migrator().DropTable(&database.EventTotals{}))
quiet := listWebhook(t, db, "quiet")
page := renderWebhookList(t, h, sess)
_, text := listCard(t, page, broken.ID)
assert.Contains(t, text,
"1 entrypoint 0 targets The event figures could not be read.")
assert.NotContains(t, text, "events")
assert.NotContains(t, text, "failed")
_, text = listCard(t, page, quiet.ID)
assert.Contains(t, text, "No events yet")
}