package handlers_test import ( "fmt" "net/http" "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" ) // statusTitle marks the status column's cell in a recent events // row; it is absent from the page when the column is not shown. const statusTitle = `title="HTTP status from the HTTP target"` // recentEventsFixture is one started app and a webhook whose // recent events list a test fills. type recentEventsFixture struct { h *handlers.Handlers sess *session.Session db *database.Database webhook *database.Webhook webhookDB *gorm.DB } func newRecentEventsFixture(t *testing.T) *recentEventsFixture { t.Helper() f := &recentEventsFixture{} var dbMgr *database.WebhookDBManager app := newTestApp(t, &f.h, &f.sess, &f.db, &dbMgr) app.RequireStart() t.Cleanup(app.RequireStop) f.webhook = seedWebhook(t, f.db) webhookDB, err := dbMgr.GetDB(f.webhook.ID) require.NoError(t, err) f.webhookDB = webhookDB return f } func (f *recentEventsFixture) render(t *testing.T) string { t.Helper() return renderSourceDetailPage(t, f.h, f.sess, f.webhook.ID) } // event records an event received at receivedAt. func (f *recentEventsFixture) event( t *testing.T, contentType, body string, receivedAt time.Time, ) *database.Event { t.Helper() event := &database.Event{ WebhookID: f.webhook.ID, Method: http.MethodPost, Body: body, ContentType: contentType, } event.CreatedAt = receivedAt require.NoError(t, f.webhookDB.Omit( clause.Associations, ).Create(event).Error) return event } // delivery records a delivery of the event to the target, queued // when the event was received. func (f *recentEventsFixture) delivery( t *testing.T, event *database.Event, targetID string, status database.DeliveryStatus, ) *database.Delivery { t.Helper() return f.deliveryQueuedAt( t, event, targetID, status, event.CreatedAt, ) } // deliveryQueuedAt records a delivery of the event to the target, // queued at queuedAt, as a replay is. func (f *recentEventsFixture) deliveryQueuedAt( t *testing.T, event *database.Event, targetID string, status database.DeliveryStatus, queuedAt time.Time, ) *database.Delivery { t.Helper() dlv := &database.Delivery{ EventID: event.ID, TargetID: targetID, Status: status, } dlv.CreatedAt = queuedAt require.NoError(t, f.webhookDB.Omit( clause.Associations, ).Create(dlv).Error) return dlv } // attempt records one attempt of the delivery that finished took // after the delivery was queued, with HTTP status code (0 for no // response). func (f *recentEventsFixture) attempt( t *testing.T, dlv *database.Delivery, code int, took time.Duration, ) { t.Helper() result := &database.DeliveryResult{ DeliveryID: dlv.ID, AttemptNum: 1, StatusCode: code, } result.CreatedAt = dlv.CreatedAt.Add(took) require.NoError(t, f.webhookDB.Omit( clause.Associations, ).Create(result).Error) } // statusCell is the status column's cell as the page renders it. func statusCell(class, text string) string { return `` + text + `` } // TestHandleSourceDetail_ShowsFiftyNewestEvents proves the list // holds the 50 newest events, newest first, and not one more. func TestHandleSourceDetail_ShowsFiftyNewestEvents(t *testing.T) { t.Parallel() f := newRecentEventsFixture(t) base := time.Now().Add(-time.Hour) for i := range 51 { f.event( t, fmt.Sprintf("application/x-recent-%02d", i), "{}", base.Add(time.Duration(i)*time.Second), ) } body := f.render(t) assert.Equal(t, 50, strings.Count(body, `title="Body size"`)) assert.NotContains(t, body, "application/x-recent-00") assert.Contains(t, body, "application/x-recent-01") assert.Less( t, strings.Index(body, "application/x-recent-50"), strings.Index(body, "application/x-recent-49"), ) } // TestHandleSourceDetail_RecentEventColumns proves a row shows its // time relative with the UTC timestamp on hover, its body size, // and its processing time once every delivery has finished. func TestHandleSourceDetail_RecentEventColumns(t *testing.T) { t.Parallel() f := newRecentEventsFixture(t) logTarget := seedTarget(t, f.db, f.webhook.ID, database.TargetTypeLog) receivedAt := time.Now().Add(-210 * time.Second). UTC().Truncate(time.Second) done := f.event( t, contentTypeJSON, strings.Repeat("x", 2048), receivedAt, ) f.attempt( t, f.delivery(t, done, logTarget.ID, database.DeliveryStatusDelivered), 0, 1500*time.Millisecond, ) waiting := f.event(t, "text/plain", "{}", receivedAt) f.delivery(t, waiting, logTarget.ID, database.DeliveryStatusPending) body := f.render(t) assert.Contains( t, body, `3 minutes ago`, ) assert.Contains(t, body, `2.0 kB`) assert.Contains(t, body, ">1.5s") assert.Contains(t, body, ">in progress") } // TestHandleSourceDetail_StatusWithSingleHTTPTarget proves that a // webhook with exactly one HTTP target shows, colour-coded, what // that target answered for each event. The log target beside it // does not count against "exactly one". func TestHandleSourceDetail_StatusWithSingleHTTPTarget(t *testing.T) { t.Parallel() f := newRecentEventsFixture(t) target := seedTarget(t, f.db, f.webhook.ID, database.TargetTypeHTTP) seedTarget(t, f.db, f.webhook.ID, database.TargetTypeLog) now := time.Now() for _, code := range []int{204, 302, 404, 503, 0} { dlv := f.delivery( t, f.event(t, contentTypeJSON, "{}", now), target.ID, database.DeliveryStatusDelivered, ) f.attempt(t, dlv, code, time.Second) } f.delivery( t, f.event(t, contentTypeJSON, "{}", now), target.ID, database.DeliveryStatusPending, ) f.event(t, contentTypeJSON, "{}", now) // A replay is a newer delivery, and its answer is the one shown. replayed := f.event(t, contentTypeJSON, "{}", now) f.attempt(t, f.delivery( t, replayed, target.ID, database.DeliveryStatusFailed, ), 502, time.Second) f.attempt(t, f.deliveryQueuedAt( t, replayed, target.ID, database.DeliveryStatusDelivered, now.Add(time.Minute), ), 200, time.Second) body := f.render(t) assert.Contains(t, body, statusCell("text-green-600", "204")) assert.Contains(t, body, statusCell("text-gray-500", "302")) assert.Contains(t, body, statusCell("text-yellow-600", "404")) assert.Contains(t, body, statusCell("text-red-600", "503")) assert.Contains(t, body, statusCell("text-red-600", "no response")) assert.Contains(t, body, statusCell("text-gray-400", "pending")) assert.Contains(t, body, statusCell("text-gray-400", "not sent")) assert.Contains(t, body, statusCell("text-green-600", "200")) assert.NotContains(t, body, ">502<") } // TestHandleSourceDetail_NoStatusWithoutSingleHTTPTarget proves the // status column is absent when the webhook has no HTTP target or // more than one. func TestHandleSourceDetail_NoStatusWithoutSingleHTTPTarget( t *testing.T, ) { t.Parallel() cases := map[string][]database.TargetType{ "none": {database.TargetTypeLog}, "several": {database.TargetTypeHTTP, database.TargetTypeHTTP}, } for name, types := range cases { t.Run(name, func(t *testing.T) { t.Parallel() f := newRecentEventsFixture(t) event := f.event(t, contentTypeJSON, "{}", time.Now()) for _, tt := range types { target := seedTarget(t, f.db, f.webhook.ID, tt) f.attempt(t, f.delivery( t, event, target.ID, database.DeliveryStatusDelivered, ), 200, time.Second) } body := f.render(t) assert.Contains(t, body, `title="Body size"`) assert.NotContains(t, body, statusTitle) }) } }