check / check (push) Waiting to run
The recent events list on a webhook's page now shows the 50 newest events, each with its time relative to now (the full UTC timestamp on hover), its body size, its processing time and, when the webhook has exactly one HTTP target, that target's last HTTP status, colour-coded. Resubmitted copies are marked, as in the event log. Each event now records its body's size in bytes when it is stored, on receipt and on resubmit, and the list reads that instead of the body. Processing time is read from the existing delivery and attempt timestamps. The list loads its deliveries and their attempts in batched queries of its own, reading only the columns it shows. A failed load answers with an error instead of an empty list. Model: opus-5-5
361 lines
9.6 KiB
Go
361 lines
9.6 KiB
Go
package handlers_test
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/go-chi/chi"
|
|
"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, with its body's
|
|
// size as the receiver records it.
|
|
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,
|
|
BodyBytes: int64(len(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 `<span class="font-medium ` + class + `" ` + statusTitle +
|
|
`>` + text + `</span>`
|
|
}
|
|
|
|
// 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,
|
|
`<span title="`+receivedAt.Format(time.DateTime)+
|
|
` UTC">3 minutes ago</span>`,
|
|
)
|
|
assert.Contains(t, body, `<span title="Body size">2.0 kB</span>`)
|
|
assert.Contains(t, body, ">1.5s</span>")
|
|
assert.Contains(t, body, ">in progress</span>")
|
|
}
|
|
|
|
// 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)
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestHandleWebhook_RecordsBodySize proves the receiver records the
|
|
// body's size in bytes, not characters, with the event it stores.
|
|
func TestHandleWebhook_RecordsBodySize(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
f := newRecentEventsFixture(t)
|
|
seedEntrypoint(t, f.db, f.webhook.ID)
|
|
|
|
// Two bytes per character.
|
|
body := strings.Repeat("é", 1024)
|
|
|
|
req := httptest.NewRequestWithContext(
|
|
context.Background(), http.MethodPost, "/webhook/x",
|
|
strings.NewReader(body),
|
|
)
|
|
|
|
rctx := chi.NewRouteContext()
|
|
rctx.URLParams.Add("uuid", "ep-"+f.webhook.ID)
|
|
|
|
req = req.WithContext(context.WithValue(
|
|
req.Context(), chi.RouteCtxKey, rctx,
|
|
))
|
|
|
|
w := httptest.NewRecorder()
|
|
f.h.HandleWebhook().ServeHTTP(w, req)
|
|
|
|
require.Equal(t, http.StatusOK, w.Code)
|
|
|
|
var stored database.Event
|
|
|
|
require.NoError(t, f.webhookDB.First(&stored).Error)
|
|
assert.Equal(t, int64(2048), stored.BodyBytes)
|
|
}
|
|
|
|
// TestHandleSourceDetail_FailedLoadIsAnError proves that when the
|
|
// list cannot be loaded the page answers with an error, rather than
|
|
// an empty list claiming the webhook has no events.
|
|
func TestHandleSourceDetail_FailedLoadIsAnError(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
f := newRecentEventsFixture(t)
|
|
target := seedTarget(t, f.db, f.webhook.ID, database.TargetTypeHTTP)
|
|
|
|
f.attempt(t, f.delivery(
|
|
t, f.event(t, contentTypeJSON, "{}", time.Now()), target.ID,
|
|
database.DeliveryStatusDelivered,
|
|
), 200, time.Second)
|
|
|
|
// The attempts are the list's last query, so its events and
|
|
// deliveries have already loaded when it fails.
|
|
require.NoError(t, f.webhookDB.Exec(
|
|
"DROP TABLE delivery_results",
|
|
).Error)
|
|
|
|
w := serveSourceDetailPage(t, f.h, f.sess, f.webhook.ID)
|
|
|
|
assert.Equal(t, http.StatusInternalServerError, w.Code)
|
|
assert.NotContains(t, w.Body.String(), "No events received yet.")
|
|
}
|