check / check (push) Waiting to run
A target's settings move from the per-type JSON string in targets.config to columns: url, headers, timeout_seconds, forward_query, archive_expiry and archive_rotation. A column a type does not use stays empty. headers holds the header map as JSON text through GORM's JSON serializer, and archive_expiry holds a time.Duration, 0 meaning never. The forms check each setting once, with the same refusals and messages, and the edit form is filled from the columns, so the per-type config structs, their parsers and the "could not be read" branches are gone. A delivery task carries the six settings in place of the JSON string; tests check that each copy into a task, and back out of one, keeps all six. Model: opus-5-5
396 lines
10 KiB
Go
396 lines
10 KiB
Go
package delivery_test
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"fmt"
|
|
"log/slog"
|
|
"net/http"
|
|
"os"
|
|
"path/filepath"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/google/uuid"
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
"gorm.io/driver/sqlite"
|
|
"gorm.io/gorm"
|
|
"gorm.io/gorm/clause"
|
|
_ "modernc.org/sqlite" // Pure Go SQLite driver.
|
|
"sneak.berlin/go/webhooker/internal/archive"
|
|
"sneak.berlin/go/webhooker/internal/database"
|
|
"sneak.berlin/go/webhooker/internal/database/databasetest"
|
|
"sneak.berlin/go/webhooker/internal/db/migrations"
|
|
"sneak.berlin/go/webhooker/internal/delivery"
|
|
"sneak.berlin/go/webhooker/internal/gormlog"
|
|
)
|
|
|
|
// archiveTestWebhookName is the name of every webhook
|
|
// seedDatabaseTarget creates. It is not safe in a file name as it
|
|
// stands, so every archive file name made from it is made safe first.
|
|
const archiveTestWebhookName = "Sweep Test!"
|
|
|
|
// archiveEnv bundles the pieces a database target test drives: a main
|
|
// configuration database holding webhooks and targets, a delivery
|
|
// engine whose archive writers write into the data directory, and that
|
|
// directory.
|
|
type archiveEnv struct {
|
|
eng *delivery.Engine
|
|
mainDB *database.Database
|
|
dataDir string
|
|
}
|
|
|
|
func setupArchiveTest(t *testing.T) *archiveEnv {
|
|
t.Helper()
|
|
|
|
dataDir := t.TempDir()
|
|
|
|
mainDB, err := database.Open(dataDir, slog.New(slog.DiscardHandler))
|
|
require.NoError(t, err)
|
|
|
|
t.Cleanup(func() { _ = mainDB.Close() })
|
|
|
|
return &archiveEnv{
|
|
eng: delivery.NewTestEngineWithDB(
|
|
mainDB,
|
|
databasetest.NewWebhookDBManager(t, dataDir),
|
|
archiveTestLogger(),
|
|
&http.Client{Timeout: 5 * time.Second},
|
|
1,
|
|
),
|
|
mainDB: mainDB,
|
|
dataDir: dataDir,
|
|
}
|
|
}
|
|
|
|
func archiveTestLogger() *slog.Logger {
|
|
return slog.New(slog.NewTextHandler(
|
|
os.Stderr,
|
|
&slog.HandlerOptions{Level: slog.LevelDebug},
|
|
))
|
|
}
|
|
|
|
// archivePath returns where the engine keeps a database target's
|
|
// archive file, for the names seedDatabaseTarget gave it.
|
|
func (env *archiveEnv) archivePath(tgt *database.Target) string {
|
|
return filepath.Join(
|
|
env.dataDir,
|
|
archive.FileName(archiveTestWebhookName, tgt.Name, tgt.ID),
|
|
)
|
|
}
|
|
|
|
// seedDatabaseTarget creates a webhook with one database target,
|
|
// which keeps its archive forever in one file, and returns the
|
|
// target.
|
|
func (env *archiveEnv) seedDatabaseTarget(
|
|
t *testing.T,
|
|
) *database.Target {
|
|
t.Helper()
|
|
|
|
wh := &database.Webhook{
|
|
UserID: uuid.New().String(),
|
|
Name: archiveTestWebhookName,
|
|
}
|
|
require.NoError(
|
|
t,
|
|
env.mainDB.DB().
|
|
Omit(clause.Associations).
|
|
Create(wh).Error,
|
|
)
|
|
|
|
tgt := &database.Target{
|
|
WebhookID: wh.ID,
|
|
Name: "Archive",
|
|
Type: database.TargetTypeDatabase,
|
|
Active: true,
|
|
ArchiveRotation: "none",
|
|
MaxAttempts: 1,
|
|
}
|
|
require.NoError(
|
|
t,
|
|
env.mainDB.DB().
|
|
Omit(clause.Associations).
|
|
Create(tgt).Error,
|
|
)
|
|
|
|
return tgt
|
|
}
|
|
|
|
// seedDatabaseTargetDelivery seeds a pending delivery of an event
|
|
// to a database target and returns the in-memory delivery the
|
|
// target handler is invoked with.
|
|
func seedDatabaseTargetDelivery(
|
|
t *testing.T,
|
|
webhookDB *gorm.DB,
|
|
event database.Event,
|
|
tgt *database.Target,
|
|
) *database.Delivery {
|
|
t.Helper()
|
|
|
|
dlv := seedDelivery(
|
|
t, webhookDB, event.ID, tgt.ID,
|
|
database.DeliveryStatusPending,
|
|
)
|
|
|
|
d := &database.Delivery{
|
|
EventID: event.ID,
|
|
TargetID: tgt.ID,
|
|
Status: database.DeliveryStatusPending,
|
|
Event: event,
|
|
Target: database.Target{
|
|
Name: tgt.Name,
|
|
Type: database.TargetTypeDatabase,
|
|
ArchiveExpiry: tgt.ArchiveExpiry,
|
|
ArchiveRotation: tgt.ArchiveRotation,
|
|
},
|
|
}
|
|
d.ID = dlv.ID
|
|
|
|
return d
|
|
}
|
|
|
|
// openArchiveDBForRead opens an archive file read-only so a
|
|
// test can inspect the rows the database target archived.
|
|
func openArchiveDBForRead(
|
|
t *testing.T, path string,
|
|
) *gorm.DB {
|
|
t.Helper()
|
|
|
|
sqlDB, err := sql.Open(
|
|
"sqlite",
|
|
fmt.Sprintf("file:%s?mode=ro", path),
|
|
)
|
|
require.NoError(t, err)
|
|
|
|
t.Cleanup(func() { _ = sqlDB.Close() })
|
|
|
|
gdb, err := gorm.Open(
|
|
sqlite.Dialector{Conn: sqlDB},
|
|
&gorm.Config{Logger: gormlog.New(slog.New(slog.DiscardHandler))},
|
|
)
|
|
require.NoError(t, err)
|
|
|
|
return gdb
|
|
}
|
|
|
|
// countArchivedRows counts the rows in an archive file without
|
|
// asserting anything, so it is safe to poll from an
|
|
// assert.Eventually condition (which runs off the test
|
|
// goroutine, where testify assertions must not be used).
|
|
func countArchivedRows(path string) (int64, error) {
|
|
sqlDB, err := sql.Open(
|
|
"sqlite", fmt.Sprintf("file:%s?mode=ro", path),
|
|
)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
|
|
defer func() { _ = sqlDB.Close() }()
|
|
|
|
gdb, err := gorm.Open(
|
|
sqlite.Dialector{Conn: sqlDB},
|
|
&gorm.Config{Logger: gormlog.New(slog.New(slog.DiscardHandler))},
|
|
)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
|
|
var count int64
|
|
|
|
err = gdb.Table("archived_events").Count(&count).Error
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
|
|
return count, nil
|
|
}
|
|
|
|
// archivedRow is the columns of an archive row these tests read.
|
|
type archivedRow struct {
|
|
EventID string
|
|
WebhookID string
|
|
Method string
|
|
RawQuery string
|
|
Body string
|
|
}
|
|
|
|
// TestDeliverDatabase_ArchivesEvent verifies that delivering to
|
|
// a database target marks the delivery delivered and archives
|
|
// the full event into the target's own archive file.
|
|
func TestDeliverDatabase_ArchivesEvent(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
env := setupArchiveTest(t)
|
|
tgt := env.seedDatabaseTarget(t)
|
|
|
|
webhookDB := testWebhookDB(t)
|
|
event := seedEvent(t, webhookDB, `{"archived":true}`)
|
|
event.RawQuery = eventQuery
|
|
d := seedDatabaseTargetDelivery(t, webhookDB, event, tgt)
|
|
|
|
env.eng.ExportDeliverDatabase(webhookDB, d)
|
|
|
|
var updated database.Delivery
|
|
|
|
require.NoError(t, webhookDB.First(
|
|
&updated, "id = ?", d.ID,
|
|
).Error)
|
|
assert.Equal(t,
|
|
database.DeliveryStatusDelivered, updated.Status,
|
|
"database target should mark the delivery delivered",
|
|
)
|
|
|
|
archivePath := filepath.Join(
|
|
env.dataDir, "archive-sweep-test-archive-"+tgt.ID+".db",
|
|
)
|
|
assert.FileExists(t, archivePath)
|
|
|
|
rdb := openArchiveDBForRead(t, archivePath)
|
|
|
|
var rows []archivedRow
|
|
|
|
require.NoError(t, rdb.Table("archived_events").Find(&rows).Error)
|
|
require.Len(t, rows, 1)
|
|
assert.Equal(t, event.ID, rows[0].EventID)
|
|
assert.Equal(t, event.WebhookID, rows[0].WebhookID)
|
|
assert.Equal(t, event.Method, rows[0].Method)
|
|
assert.Equal(t, eventQuery, rows[0].RawQuery)
|
|
assert.JSONEq(t, `{"archived":true}`, rows[0].Body)
|
|
}
|
|
|
|
// TestDeliverDatabase_ArchiveFailureFailsDelivery verifies that
|
|
// an archive error (here: an event with no webhook id to record)
|
|
// fails the delivery loudly: the attempt is recorded as failed
|
|
// with the error and the delivery is marked failed, not
|
|
// delivered.
|
|
func TestDeliverDatabase_ArchiveFailureFailsDelivery(
|
|
t *testing.T,
|
|
) {
|
|
t.Parallel()
|
|
|
|
env := setupArchiveTest(t)
|
|
tgt := env.seedDatabaseTarget(t)
|
|
|
|
webhookDB := testWebhookDB(t)
|
|
event := seedEvent(t, webhookDB, `{"archived":false}`)
|
|
d := seedDatabaseTargetDelivery(t, webhookDB, event, tgt)
|
|
d.Event.WebhookID = ""
|
|
|
|
env.eng.ExportDeliverDatabase(webhookDB, d)
|
|
|
|
var updated database.Delivery
|
|
|
|
require.NoError(t, webhookDB.First(
|
|
&updated, "id = ?", d.ID,
|
|
).Error)
|
|
assert.Equal(t,
|
|
database.DeliveryStatusFailed, updated.Status,
|
|
"archive failure must mark the delivery failed",
|
|
)
|
|
|
|
var results []database.DeliveryResult
|
|
|
|
require.NoError(t, webhookDB.Where(
|
|
"delivery_id = ?", d.ID,
|
|
).Find(&results).Error)
|
|
require.Len(t, results, 1)
|
|
assert.False(t,
|
|
results[0].Success,
|
|
"the attempt must be recorded as failed",
|
|
)
|
|
assert.Contains(t,
|
|
results[0].Error, "without a webhook id",
|
|
"the archive error must be recorded on the attempt",
|
|
)
|
|
|
|
assert.NoFileExists(t,
|
|
env.archivePath(tgt),
|
|
"no archive file should exist for a failed write",
|
|
)
|
|
}
|
|
|
|
// seedArchivedRow creates the archive file at path holding one row, for
|
|
// eventID, archived at archivedAt.
|
|
func seedArchivedRow(
|
|
t *testing.T, path, eventID string, archivedAt time.Time,
|
|
) {
|
|
t.Helper()
|
|
|
|
sqlDB, err := sql.Open(
|
|
"sqlite", fmt.Sprintf("file:%s?mode=rwc", path),
|
|
)
|
|
require.NoError(t, err)
|
|
|
|
defer func() { _ = sqlDB.Close() }()
|
|
|
|
gdb, err := gorm.Open(
|
|
sqlite.Dialector{Conn: sqlDB},
|
|
&gorm.Config{Logger: gormlog.New(slog.New(slog.DiscardHandler))},
|
|
)
|
|
require.NoError(t, err)
|
|
|
|
require.NoError(t, migrations.Apply(gdb, migrations.Archive))
|
|
require.NoError(t, gdb.Exec(
|
|
"INSERT INTO archived_events (event_id, archived_at) VALUES (?, ?)",
|
|
eventID, archivedAt,
|
|
).Error)
|
|
}
|
|
|
|
// TestDeliverDatabase_TaskArchiveSettings proves a task's ArchiveExpiry
|
|
// and ArchiveRotation reach the archive through the engine. The
|
|
// target's row has neither, so only the task can bring them: with daily
|
|
// rotation the event goes to the file for its day, and opening that
|
|
// file with a one-hour expiry prunes the row it already held, archived
|
|
// two hours ago.
|
|
func TestDeliverDatabase_TaskArchiveSettings(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
s := newISetup(t)
|
|
|
|
webhook := database.Webhook{UserID: uuid.NewString(), Name: "settings"}
|
|
webhook.ID = s.WebhookID
|
|
require.NoError(t, s.MainDB.Create(&webhook).Error)
|
|
|
|
target := database.Target{
|
|
WebhookID: s.WebhookID,
|
|
Name: "archive",
|
|
Type: database.TargetTypeDatabase,
|
|
Active: true,
|
|
MaxAttempts: 1,
|
|
}
|
|
require.NoError(t, s.MainDB.Create(&target).Error)
|
|
|
|
event := iSeedEvent(t, s.WebhookDB, s.WebhookID, `{"archived":true}`)
|
|
d := iSeedDelivery(
|
|
t, s.WebhookDB, event.ID, target.ID,
|
|
database.DeliveryStatusPending,
|
|
)
|
|
|
|
task := iTask(
|
|
d, event, s.WebhookID, target.ID, target.Name, "", 1, 1,
|
|
&event.Body,
|
|
)
|
|
task.TargetType = database.TargetTypeDatabase
|
|
task.ArchiveExpiry = time.Hour
|
|
task.ArchiveRotation = "daily"
|
|
|
|
daily := target
|
|
daily.ArchiveRotation = task.ArchiveRotation
|
|
dailyFile := archive.PathAt(s.DBMgr, &webhook, &daily, event.CreatedAt)
|
|
seedArchivedRow(t, dailyFile, "expired", time.Now().Add(-2*time.Hour))
|
|
|
|
s.Engine.ExportProcessNewTask(context.TODO(), &task)
|
|
|
|
iAssertStatus(t, s.WebhookDB, d.ID, database.DeliveryStatusDelivered)
|
|
|
|
var rows []archivedRow
|
|
|
|
require.NoError(t, openArchiveDBForRead(t, dailyFile).
|
|
Table("archived_events").Find(&rows).Error)
|
|
require.Len(t, rows, 1)
|
|
assert.Equal(t, event.ID, rows[0].EventID,
|
|
"the event goes to its day's file and the expired row is pruned",
|
|
)
|
|
}
|