check / check (push) Waiting to run
Each webhook's event database keeps running totals: one row for its events, with when the newest arrived, and one row per target for its deliveries, delivered and failed, each with what retention removed. Every write to them shares the transaction of the rows it counts, and a delivery already delivered or failed is not settled again. Deliveries get a finished_at column; it and target_id end the status index, so each target's deliveries finished in a window come from one index-range query grouped by target. Retention deletes 1000 expired events per transaction, pausing 200 ms between them so other writers get in, and stops between them on shutdown. The pane is its own template, its figures in tables. The schema changes in place with nothing back-filled, so an existing database must be recreated. Model: opus-5-5
570 lines
17 KiB
Go
570 lines
17 KiB
Go
package handlers_test
|
|
|
|
import (
|
|
"net/http"
|
|
"regexp"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"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"
|
|
)
|
|
|
|
// 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 an event's delivery to a target.
|
|
func statsDelivery(
|
|
t *testing.T, webhookDB *gorm.DB, eventID, targetID string,
|
|
) database.Delivery {
|
|
t.Helper()
|
|
|
|
var d database.Delivery
|
|
|
|
require.NoError(t, webhookDB.Where(
|
|
"event_id = ? AND target_id = ?", eventID, targetID,
|
|
).First(&d).Error)
|
|
|
|
return d
|
|
}
|
|
|
|
// statsFinish settles a delivery as the delivery engine does: its
|
|
// final status and the time it finished, and one more on its target's
|
|
// delivered or failed total, in one transaction.
|
|
func statsFinish(
|
|
t *testing.T,
|
|
webhookDB *gorm.DB,
|
|
d database.Delivery,
|
|
status database.DeliveryStatus,
|
|
at time.Time,
|
|
) {
|
|
t.Helper()
|
|
|
|
add := database.TargetTotals{TargetID: d.TargetID, Delivered: 1}
|
|
if status == database.DeliveryStatusFailed {
|
|
add = database.TargetTotals{TargetID: d.TargetID, Failed: 1}
|
|
}
|
|
|
|
require.NoError(t, webhookDB.Transaction(func(tx *gorm.DB) error {
|
|
err := tx.Model(&database.Delivery{}).
|
|
Where("id = ?", d.ID).
|
|
Updates(map[string]any{"status": status, "finished_at": at}).
|
|
Error
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
return database.AddTargetTotals(tx, add)
|
|
}))
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
|
|
// statsTargetTotals reads a webhook database's target totals, keyed by
|
|
// target.
|
|
func statsTargetTotals(
|
|
t *testing.T, webhookDB *gorm.DB,
|
|
) map[string]database.TargetTotals {
|
|
t.Helper()
|
|
|
|
var rows []database.TargetTotals
|
|
|
|
require.NoError(t, webhookDB.Find(&rows).Error)
|
|
|
|
byTarget := make(map[string]database.TargetTotals, len(rows))
|
|
for _, row := range rows {
|
|
byTarget[row.TargetID] = row
|
|
}
|
|
|
|
return byTarget
|
|
}
|
|
|
|
// statsHistory is the webhook seedStatsHistory builds: its event
|
|
// database, its newest event, and its two active targets.
|
|
type statsHistory struct {
|
|
webhook *database.Webhook
|
|
webhookDB *gorm.DB
|
|
newest database.Event
|
|
first, second string
|
|
}
|
|
|
|
// seedStatsHistory builds the webhook the statistics test checks: 14
|
|
// days of retention, twelve entrypoints (one inactive) and six targets
|
|
// (four inactive). Ten events arrive through the receiver, and so each
|
|
// has a delivery to the two active targets. The oldest event is past
|
|
// retention, the next 30 hours old, the next six hours old, the other
|
|
// seven just in. Six deliveries are settled as the delivery engine
|
|
// would, two of them inside a recent window though their event arrived
|
|
// before it. The newest event's delivery to the second target is
|
|
// retrying, the rest are left pending, and a replay adds a pending
|
|
// delivery to the oldest event. Once retention has removed the oldest
|
|
// event, every figure in the pane differs from every other.
|
|
func seedStatsHistory(
|
|
t *testing.T,
|
|
h *handlers.Handlers,
|
|
sess *session.Session,
|
|
db *database.Database,
|
|
dbMgr *database.WebhookDBManager,
|
|
) statsHistory {
|
|
t.Helper()
|
|
|
|
wh := &database.Webhook{UserID: deleteTestUserID, Name: "stats", RetentionDays: 14}
|
|
require.NoError(t, db.DB().Omit(clause.Associations).Create(wh).Error)
|
|
|
|
path := statsEntrypoint(t, db, wh.ID, true)
|
|
for range 10 {
|
|
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)
|
|
|
|
for range 4 {
|
|
inactive := seedTarget(t, db, wh.ID, database.TargetTypeLog)
|
|
require.NoError(t, db.DB().Model(inactive).
|
|
Update("active", false).Error)
|
|
}
|
|
|
|
router := receiverRouter(h)
|
|
|
|
for range 10 {
|
|
require.Equal(t, http.StatusOK, postReceiver(t, router, path))
|
|
}
|
|
|
|
webhookDB, err := dbMgr.GetDB(wh.ID)
|
|
require.NoError(t, err)
|
|
|
|
events := listEvents(t, webhookDB)
|
|
require.Len(t, events, 10)
|
|
|
|
oldest, yesterday, middle, newest := events[0], events[1], events[2], events[9]
|
|
now := time.Now()
|
|
|
|
statsAge(t, webhookDB, oldest.ID, now.Add(-15*24*time.Hour))
|
|
statsAge(t, webhookDB, yesterday.ID, now.Add(-30*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(-14*24*time.Hour))
|
|
statsFinish(t, webhookDB,
|
|
statsDelivery(t, webhookDB, yesterday.ID, first.ID),
|
|
database.DeliveryStatusFailed, now.Add(-29*time.Hour))
|
|
statsFinish(t, webhookDB,
|
|
statsDelivery(t, webhookDB, yesterday.ID, second.ID),
|
|
database.DeliveryStatusFailed, now.Add(-23*time.Hour))
|
|
statsFinish(t, webhookDB,
|
|
statsDelivery(t, webhookDB, middle.ID, second.ID),
|
|
database.DeliveryStatusFailed, now.Add(-5*time.Hour))
|
|
statsFinish(t, webhookDB,
|
|
statsDelivery(t, webhookDB, middle.ID, first.ID),
|
|
database.DeliveryStatusFailed, now.Add(-time.Minute))
|
|
statsFinish(t, webhookDB,
|
|
statsDelivery(t, webhookDB, newest.ID, first.ID),
|
|
database.DeliveryStatusDelivered, now.Add(-2*time.Minute))
|
|
|
|
retrying := statsDelivery(t, webhookDB, newest.ID, second.ID)
|
|
require.NoError(t, webhookDB.Model(&retrying).
|
|
Update("status", database.DeliveryStatusRetrying).Error)
|
|
|
|
require.Equal(t, http.StatusSeeOther,
|
|
postReplay(t, h, sess, wh.ID, oldestFailure.ID).Code)
|
|
|
|
return statsHistory{
|
|
webhook: wh, webhookDB: webhookDB, newest: newest,
|
|
first: first.ID, second: second.ID,
|
|
}
|
|
}
|
|
|
|
// 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.EventTotals
|
|
|
|
err := webhookDB.Take(&totals).Error
|
|
|
|
return err == nil && totals.EventsRemoved == 1
|
|
}, 10*time.Second, 10*time.Millisecond)
|
|
|
|
lc.RequireStop()
|
|
}
|
|
|
|
// statsPane returns the text of the statistics pane in a rendered
|
|
// webhook page, everything from its heading to the next heading on the
|
|
// page, with the markup taken out and each run of space made one
|
|
// space. A table then reads header by header and row by row, each
|
|
// row's label followed by its figures in column order.
|
|
func statsPane(t *testing.T, page string) string {
|
|
t.Helper()
|
|
|
|
_, pane, found := strings.Cut(page, ">Statistics</h2>")
|
|
require.True(t, found, "the page has no statistics pane")
|
|
|
|
pane, _, _ = strings.Cut(pane, "<h2")
|
|
pane = regexp.MustCompile(`<[^>]*>`).ReplaceAllString(pane, " ")
|
|
|
|
return strings.Join(strings.Fields(pane), " ")
|
|
}
|
|
|
|
// assertStatsTargets checks, for the history seedStatsHistory builds,
|
|
// each target's totals and its deliveries finished in the last 24
|
|
// hours. The first target has ten deliveries and the replay, the
|
|
// second ten; the inactive targets have none and so no row.
|
|
func assertStatsTargets(t *testing.T, hist statsHistory) {
|
|
t.Helper()
|
|
|
|
first, second := hist.first, hist.second
|
|
|
|
assert.Equal(t, map[string]database.TargetTotals{
|
|
first: {TargetID: first, Deliveries: 11, Delivered: 1, Failed: 3},
|
|
second: {TargetID: second, Deliveries: 10, Failed: 2},
|
|
}, statsTargetTotals(t, hist.webhookDB))
|
|
|
|
lastDay, err := handlers.FinishedByTargetForTest(
|
|
hist.webhookDB, time.Now().Add(-24*time.Hour),
|
|
)
|
|
require.NoError(t, err)
|
|
assert.ElementsMatch(t, []handlers.TargetFinished{
|
|
{TargetID: first, Delivered: 1, Failed: 1},
|
|
{TargetID: second, Failed: 2},
|
|
}, lastDay)
|
|
}
|
|
|
|
// assertStatsPaneAfterPrune checks the rendered statistics pane for the
|
|
// history seedStatsHistory builds, once retention has removed the
|
|
// oldest event: each figure after its label, in its column.
|
|
func assertStatsPaneAfterPrune(
|
|
t *testing.T,
|
|
h *handlers.Handlers,
|
|
sess *session.Session,
|
|
hist statsHistory,
|
|
) {
|
|
t.Helper()
|
|
|
|
pane := statsPane(t, renderSourceDetailPage(t, h, sess, hist.webhook.ID))
|
|
lastEvent := hist.newest.CreatedAt.UTC().Format("2006-01-02 15:04:05 UTC")
|
|
|
|
assert.Contains(t, pane, "Entrypoints 12 (11 active) "+
|
|
"Targets 6 (2 active) "+
|
|
"Deliveries in progress 13 "+
|
|
"Last event "+lastEvent+" "+
|
|
"Retention 14 days")
|
|
assert.Contains(t, pane, "Lifetime Within retention "+
|
|
"Events 10 9 "+
|
|
"Deliveries 21 18 "+
|
|
"Failures 5 4")
|
|
assert.Contains(t, pane, "Last 10 minutes Last 24 hours "+
|
|
"Events 7 8 "+
|
|
"Failures 1 3 "+
|
|
"Failure percentage 50.0% 75.0%")
|
|
}
|
|
|
|
// TestWebhookStats_EveryFigureAcrossRetentionPrune checks every figure
|
|
// the statistics pane shows for the history seedStatsHistory builds,
|
|
// and each target's totals and recent figures, 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)
|
|
|
|
hist := seedStatsHistory(t, h, sess, db, dbMgr)
|
|
first, second := hist.first, hist.second
|
|
|
|
stats := h.WebhookStatsForTest(hist.webhook.ID)
|
|
require.NotNil(t, stats)
|
|
|
|
assert.Equal(t, 12, stats.Entrypoints)
|
|
assert.Equal(t, 11, stats.ActiveEntrypoints)
|
|
assert.Equal(t, 6, stats.Targets)
|
|
assert.Equal(t, 2, stats.ActiveTargets)
|
|
assert.Equal(t, handlers.Counts{Events: 10, Deliveries: 21, Failures: 5},
|
|
stats.Lifetime)
|
|
assert.Equal(t, stats.Lifetime, stats.WithinRetention)
|
|
assert.Equal(t, int64(15), stats.InProgress)
|
|
require.NotNil(t, stats.LastEventAt)
|
|
assert.True(t, hist.newest.CreatedAt.Equal(*stats.LastEventAt))
|
|
assert.Equal(t, handlers.RecentWindow{
|
|
Events: 7, Delivered: 1, Failed: 1,
|
|
}, stats.Last10Minutes)
|
|
assert.Equal(t, handlers.RecentWindow{
|
|
Events: 8, Delivered: 1, Failed: 3,
|
|
}, stats.Last24Hours)
|
|
assert.Equal(t, "50.0%", stats.Last10Minutes.FailurePercent())
|
|
assert.Equal(t, "75.0%", stats.Last24Hours.FailurePercent())
|
|
|
|
assertStatsTargets(t, hist)
|
|
|
|
// Retention removes the oldest event with its three deliveries:
|
|
// the first target's failed one and the pending replay, and the
|
|
// second target's pending one.
|
|
statsPrune(t, db, dbMgr, log, hist.webhookDB)
|
|
|
|
after := h.WebhookStatsForTest(hist.webhook.ID)
|
|
require.NotNil(t, after)
|
|
|
|
assert.Equal(t, stats.Lifetime, after.Lifetime)
|
|
assert.Equal(t, handlers.Counts{Events: 9, Deliveries: 18, Failures: 4},
|
|
after.WithinRetention)
|
|
assert.Equal(t, int64(13), after.InProgress)
|
|
assert.Equal(t, stats.LastEventAt, after.LastEventAt)
|
|
assert.Equal(t, stats.Last10Minutes, after.Last10Minutes)
|
|
assert.Equal(t, stats.Last24Hours, after.Last24Hours)
|
|
|
|
assert.Equal(t, map[string]database.TargetTotals{
|
|
first: {
|
|
TargetID: first, Deliveries: 11, Delivered: 1, Failed: 3,
|
|
DeliveriesRemoved: 2, FailedRemoved: 1,
|
|
},
|
|
second: {
|
|
TargetID: second, Deliveries: 10, Failed: 2,
|
|
DeliveriesRemoved: 1,
|
|
},
|
|
}, statsTargetTotals(t, hist.webhookDB))
|
|
|
|
assertStatsPaneAfterPrune(t, h, sess, hist)
|
|
}
|
|
|
|
// TestWebhookStats_LastEventSurvivesPruningEveryEvent checks that once
|
|
// retention has removed every event, the pane still shows when the last
|
|
// one arrived rather than "none".
|
|
func TestWebhookStats_LastEventSurvivesPruningEveryEvent(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: "pruned", RetentionDays: 1,
|
|
}
|
|
require.NoError(t, db.DB().Omit(clause.Associations).Create(wh).Error)
|
|
|
|
path := statsEntrypoint(t, db, wh.ID, true)
|
|
require.Equal(t, http.StatusOK,
|
|
postReceiver(t, receiverRouter(h), path))
|
|
|
|
webhookDB, err := dbMgr.GetDB(wh.ID)
|
|
require.NoError(t, err)
|
|
|
|
events := listEvents(t, webhookDB)
|
|
require.Len(t, events, 1)
|
|
|
|
arrived := events[0].CreatedAt
|
|
|
|
statsAge(t, webhookDB, events[0].ID, time.Now().Add(-50*time.Hour))
|
|
statsPrune(t, db, dbMgr, log, webhookDB)
|
|
require.Empty(t, listEvents(t, webhookDB))
|
|
|
|
stats := h.WebhookStatsForTest(wh.ID)
|
|
require.NotNil(t, stats)
|
|
require.NotNil(t, stats.LastEventAt)
|
|
assert.True(t, arrived.Equal(*stats.LastEventAt))
|
|
|
|
pane := statsPane(t, renderSourceDetailPage(t, h, sess, wh.ID))
|
|
assert.Contains(t, pane,
|
|
"Last event "+arrived.UTC().Format("2006-01-02 15:04:05 UTC"))
|
|
}
|
|
|
|
// TestWebhookStats_LastEventInUTC checks that the pane shows when the
|
|
// last event arrived in UTC, as the event list does, when the time was
|
|
// stored in another zone, as it is on a host whose local time is not
|
|
// UTC.
|
|
func TestWebhookStats_LastEventInUTC(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)
|
|
|
|
webhookDB, err := dbMgr.GetDB(wh.ID)
|
|
require.NoError(t, err)
|
|
|
|
arrived := time.Date(2026, time.March, 4, 22, 30, 0, 0,
|
|
time.FixedZone("EST", -5*60*60))
|
|
require.NoError(t, database.AddEventTotals(webhookDB,
|
|
database.EventTotals{Events: 1, LastEventAt: &arrived}))
|
|
|
|
pane := statsPane(t, renderSourceDetailPage(t, h, sess, wh.ID))
|
|
assert.Contains(t, pane, "Last event 2026-03-05 03:30:00 UTC")
|
|
}
|
|
|
|
// TestWebhookStats_PaneShowsRetentionPeriod checks that the statistics
|
|
// pane itself, not only the line at the foot of the page, shows the
|
|
// webhook's retention period, for a finite one and for forever.
|
|
func TestWebhookStats_PaneShowsRetentionPeriod(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
var (
|
|
h *handlers.Handlers
|
|
sess *session.Session
|
|
db *database.Database
|
|
)
|
|
|
|
app := newTestApp(t, &h, &sess, &db)
|
|
app.RequireStart()
|
|
|
|
t.Cleanup(app.RequireStop)
|
|
|
|
tests := []struct {
|
|
retentionDays int
|
|
want string
|
|
}{
|
|
{30, "30 days"},
|
|
{database.RetentionForeverDays, "forever"},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
wh := &database.Webhook{
|
|
UserID: deleteTestUserID,
|
|
Name: "retention",
|
|
RetentionDays: tt.retentionDays,
|
|
}
|
|
require.NoError(t,
|
|
db.DB().Omit(clause.Associations).Create(wh).Error)
|
|
|
|
pane := statsPane(t, renderSourceDetailPage(t, h, sess, wh.ID))
|
|
assert.Contains(t, pane, "Retention "+tt.want)
|
|
}
|
|
}
|
|
|
|
// 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())
|
|
|
|
pane := statsPane(t, renderSourceDetailPage(t, h, sess, wh.ID))
|
|
assert.Contains(t, pane, "Last event none")
|
|
assert.Contains(t, pane, "Failure percentage — —")
|
|
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)
|
|
}
|
|
}
|