check / check (push) Successful in 3m40s
The webhook page and everything under it move from /source/ID to /hook/ID, the list and new-webhook form to /hooks and /hooks/new, and the event log from .../logs to /hook/ID/events, body download included. Entrypoint URLs move from /webhook/UUID to /h/UUID, and the webhook page shows only that form. The old paths are gone. Links, redirects, form actions, tests, comments and the README follow. Both links to the event log page, and its title and heading, now read "Full Event Log". Go identifiers and template file names are unchanged. Route tests reach every moved page and form through the production router, taking each URL and token from the rendered page. Model: opus-5-5
363 lines
9.7 KiB
Go
363 lines
9.7 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 is
|
|
// headed "50 Most Recent Events" and 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.Contains(t, body, ">50 Most Recent Events</h2>")
|
|
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, "/h/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.")
|
|
}
|