diff --git a/.golangci.yml b/.golangci.yml index 39b7b4d..aa8d9f8 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -1,18 +1,17 @@ +version: "2" + run: timeout: 5m tests: true linters: enable: - - gofmt - revive - govet - errcheck - staticcheck - unused - - gosimple - ineffassign - - typecheck - gosec - misspell - unparam @@ -23,8 +22,6 @@ linters: - gochecknoglobals linters-settings: - gofmt: - simplify: true revive: confidence: 0.8 govet: @@ -43,4 +40,4 @@ issues: # Exclude globals check for version variables in globals package - path: internal/globals/globals.go linters: - - gochecknoglobals \ No newline at end of file + - gochecknoglobals diff --git a/Dockerfile b/Dockerfile index 414fb8f..9559c97 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,56 +1,58 @@ -# golang:1.24 (bookworm) — 2026-03-01 -# Using Debian-based image because gorm.io/driver/sqlite pulls in -# mattn/go-sqlite3 (CGO), which does not compile on Alpine musl. -FROM golang@sha256:d2d2bc1c84f7e60d7d2438a3836ae7d0c847f4888464e7ec9ba3a1339a1ee804 AS builder +# Lint stage +# golangci/golangci-lint:v2.11.3 (Debian-based), 2026-03-17 +# Using Debian-based image because mattn/go-sqlite3 (CGO) does not +# compile on Alpine musl (off64_t is a glibc type). +FROM golangci/golangci-lint:v2.11.3@sha256:e838e8ab68aaefe83e2408691510867ade9329c0e0b895a3fb35eb93d1c2a4ba AS lint -# gcc is pre-installed in the Debian-based golang image RUN apt-get update && apt-get install -y --no-install-recommends make && rm -rf /var/lib/apt/lists/* -WORKDIR /build +WORKDIR /src -# Install golangci-lint v1.64.8 — 2026-03-01 -# Using v1.x because the repo's .golangci.yml uses v1 config format. -RUN set -eux; \ - GOLANGCI_VERSION="1.64.8"; \ - ARCH="$(uname -m)"; \ - case "${ARCH}" in \ - x86_64) \ - GOARCH="amd64"; \ - GOLANGCI_SHA256="b6270687afb143d019f387c791cd2a6f1cb383be9b3124d241ca11bd3ce2e54e"; \ - ;; \ - aarch64) \ - GOARCH="arm64"; \ - GOLANGCI_SHA256="a6ab58ebcb1c48572622146cdaec2956f56871038a54ed1149f1386e287789a5"; \ - ;; \ - *) echo "unsupported architecture: ${ARCH}" && exit 1 ;; \ - esac; \ - wget -q "https://github.com/golangci/golangci-lint/releases/download/v${GOLANGCI_VERSION}/golangci-lint-${GOLANGCI_VERSION}-linux-${GOARCH}.tar.gz" \ - -O /tmp/golangci-lint.tar.gz; \ - echo "${GOLANGCI_SHA256} /tmp/golangci-lint.tar.gz" | sha256sum -c -; \ - tar -xzf /tmp/golangci-lint.tar.gz -C /tmp; \ - mv "/tmp/golangci-lint-${GOLANGCI_VERSION}-linux-${GOARCH}/golangci-lint" /usr/local/bin/; \ - rm -rf /tmp/golangci-lint*; \ - golangci-lint --version - -# Copy go module files and download dependencies +# Copy go mod files first for better layer caching COPY go.mod go.sum ./ RUN go mod download # Copy source code COPY . . -# Run all checks (fmt-check, lint, test, build) -RUN make check +# Run formatting check and linter +RUN make fmt-check +RUN make lint + +# Build stage +# golang:1.26.1-bookworm (Debian-based), 2026-03-17 +# Using Debian-based image because gorm.io/driver/sqlite pulls in +# mattn/go-sqlite3 (CGO), which does not compile on Alpine musl. +FROM golang:1.26.1-bookworm@sha256:4465644228bc2857a954b092167e12aa59c006a3492282a6c820bf4755fd64a4 AS builder + +# Depend on lint stage passing +COPY --from=lint /src/go.sum /dev/null + +RUN apt-get update && apt-get install -y --no-install-recommends make && rm -rf /var/lib/apt/lists/* + +WORKDIR /build + +# Copy go mod files first for better layer caching +COPY go.mod go.sum ./ +RUN go mod download + +# Copy source code +COPY . . + +# Run tests and build +RUN make test +RUN make build # Rebuild with static linking for Alpine runtime. -# make check already verified formatting, linting, tests, and compilation. +# make build already verified compilation. # The CGO binary from `make build` is dynamically linked against glibc, # which doesn't exist on Alpine (musl). Rebuild with static linking so # the binary runs on Alpine without glibc. RUN CGO_ENABLED=1 go build -ldflags '-extldflags "-static"' -o bin/webhooker ./cmd/webhooker -# alpine:3.21 — 2026-03-01 -FROM alpine@sha256:c3f8e73fdb79deaebaa2037150150191b9dcbfba68b4a46d70103204c53f4709 +# Runtime stage +# alpine:3.21, 2026-03-17 +FROM alpine:3.21@sha256:c3f8e73fdb79deaebaa2037150150191b9dcbfba68b4a46d70103204c53f4709 RUN apk --no-cache add ca-certificates diff --git a/README.md b/README.md index 50faf6c..c6b2ae5 100644 --- a/README.md +++ b/README.md @@ -11,8 +11,8 @@ with retry support, logging, and observability. Category: infrastructure ### Prerequisites -- Go 1.24+ -- golangci-lint v1.64+ +- Go 1.26+ +- golangci-lint v2.11+ - Docker (for containerized deployment) ### Quick Start @@ -762,7 +762,7 @@ webhooker/ │ ├── css/style.css # Custom stylesheet (system font stack, card effects, layout) │ └── js/app.js # Client-side JavaScript (minimal bootstrap) ├── templates/ # Go HTML templates (base, index, login, etc.) -├── Dockerfile # Multi-stage: build + check, then Alpine runtime +├── Dockerfile # Multi-stage: lint, build+test, then Alpine runtime ├── Makefile # fmt, lint, test, check, build, docker targets ├── go.mod / go.sum └── .golangci.yml # Linter configuration diff --git a/cmd/webhooker/main.go b/cmd/webhooker/main.go index efcda15..f53b5ce 100644 --- a/cmd/webhooker/main.go +++ b/cmd/webhooker/main.go @@ -1,3 +1,4 @@ +// Package main is the entry point for the webhooker application. package main import ( @@ -15,6 +16,8 @@ import ( ) // Build-time variables set via -ldflags. +// +//nolint:gochecknoglobals // Build-time variables injected by the linker. var ( version = "dev" appname = "webhooker" diff --git a/internal/config/config.go b/internal/config/config.go index 95d3c5d..ff83a6e 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -1,3 +1,4 @@ +// Package config loads application configuration from environment variables. package config import ( @@ -23,13 +24,14 @@ const ( EnvironmentProd = "prod" ) -// nolint:revive // ConfigParams is a standard fx naming convention +//nolint:revive // ConfigParams is a standard fx naming convention. type ConfigParams struct { fx.In Globals *globals.Globals Logger *logger.Logger } +// Config holds all application configuration loaded from environment variables. type Config struct { DataDir string Debug bool @@ -79,7 +81,9 @@ func envInt(key string, defaultValue int) int { return defaultValue } -// nolint:revive // lc parameter is required by fx even if unused +// New creates a Config by reading environment variables. +// +//nolint:revive // lc parameter is required by fx even if unused. func New(lc fx.Lifecycle, params ConfigParams) (*Config, error) { log := params.Logger.Get() diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 1a4ab31..c104e4f 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -57,16 +57,14 @@ func TestEnvironmentConfig(t *testing.T) { t.Run(tt.name, func(t *testing.T) { // Set environment variable if specified if tt.envValue != "" { - os.Setenv("WEBHOOKER_ENVIRONMENT", tt.envValue) - defer os.Unsetenv("WEBHOOKER_ENVIRONMENT") + t.Setenv("WEBHOOKER_ENVIRONMENT", tt.envValue) } else { - os.Unsetenv("WEBHOOKER_ENVIRONMENT") + require.NoError(t, os.Unsetenv("WEBHOOKER_ENVIRONMENT")) } // Set additional environment variables for k, v := range tt.envVars { - os.Setenv(k, v) - defer os.Unsetenv(k) + t.Setenv(k, v) } if tt.expectError { @@ -115,12 +113,11 @@ func TestDefaultDataDir(t *testing.T) { } t.Run("env="+name, func(t *testing.T) { if env != "" { - os.Setenv("WEBHOOKER_ENVIRONMENT", env) - defer os.Unsetenv("WEBHOOKER_ENVIRONMENT") + t.Setenv("WEBHOOKER_ENVIRONMENT", env) } else { - os.Unsetenv("WEBHOOKER_ENVIRONMENT") + require.NoError(t, os.Unsetenv("WEBHOOKER_ENVIRONMENT")) } - os.Unsetenv("DATA_DIR") + require.NoError(t, os.Unsetenv("DATA_DIR")) var cfg *Config app := fxtest.New( diff --git a/internal/database/base_model.go b/internal/database/base_model.go index 68199a9..167e7ac 100644 --- a/internal/database/base_model.go +++ b/internal/database/base_model.go @@ -16,8 +16,8 @@ type BaseModel struct { DeletedAt gorm.DeletedAt `gorm:"index" json:"deleted_at,omitempty"` } -// BeforeCreate hook to set UUID before creating a record -func (b *BaseModel) BeforeCreate(tx *gorm.DB) error { +// BeforeCreate hook to set UUID before creating a record. +func (b *BaseModel) BeforeCreate(_ *gorm.DB) error { if b.ID == "" { b.ID = uuid.New().String() } diff --git a/internal/database/database.go b/internal/database/database.go index 933a9ca..5164701 100644 --- a/internal/database/database.go +++ b/internal/database/database.go @@ -1,3 +1,4 @@ +// Package database provides SQLite persistence for webhooks, events, and users. package database import ( @@ -19,19 +20,21 @@ import ( "sneak.berlin/go/webhooker/internal/logger" ) -// nolint:revive // DatabaseParams is a standard fx naming convention +//nolint:revive // DatabaseParams is a standard fx naming convention. type DatabaseParams struct { fx.In Config *config.Config Logger *logger.Logger } +// Database manages the main SQLite connection and schema migrations. type Database struct { db *gorm.DB log *slog.Logger params *DatabaseParams } +// New creates a Database that connects on fx start and disconnects on stop. func New(lc fx.Lifecycle, params DatabaseParams) (*Database, error) { d := &Database{ params: ¶ms, @@ -149,6 +152,7 @@ func (d *Database) close() error { return nil } +// DB returns the underlying GORM database handle. func (d *Database) DB() *gorm.DB { return d.db } diff --git a/internal/database/model_delivery.go b/internal/database/model_delivery.go index 4ce901e..e58e8f5 100644 --- a/internal/database/model_delivery.go +++ b/internal/database/model_delivery.go @@ -3,6 +3,7 @@ package database // DeliveryStatus represents the status of a delivery type DeliveryStatus string +// Delivery status values. const ( DeliveryStatusPending DeliveryStatus = "pending" DeliveryStatusDelivered DeliveryStatus = "delivered" diff --git a/internal/database/model_target.go b/internal/database/model_target.go index 71b4d02..fcd9e79 100644 --- a/internal/database/model_target.go +++ b/internal/database/model_target.go @@ -3,6 +3,7 @@ package database // TargetType represents the type of delivery target type TargetType string +// Target type values. const ( TargetTypeHTTP TargetType = "http" TargetTypeDatabase TargetType = "database" diff --git a/internal/database/password.go b/internal/database/password.go index b7f2b1e..33a25fc 100644 --- a/internal/database/password.go +++ b/internal/database/password.go @@ -169,16 +169,16 @@ func GenerateRandomPassword(length int) (string, error) { return string(password), nil } -// cryptoRandInt generates a cryptographically secure random integer in [0, max) -func cryptoRandInt(max int) int { - if max <= 0 { - panic("max must be positive") +// cryptoRandInt generates a cryptographically secure random integer in [0, upperBound). +func cryptoRandInt(upperBound int) int { + if upperBound <= 0 { + panic("upperBound must be positive") } // Calculate the maximum valid value to avoid modulo bias - // For example, if max=200 and we have 256 possible values, + // For example, if upperBound=200 and we have 256 possible values, // we only accept values 0-199 (reject 200-255) - nBig, err := rand.Int(rand.Reader, big.NewInt(int64(max))) + nBig, err := rand.Int(rand.Reader, big.NewInt(int64(upperBound))) if err != nil { panic(fmt.Sprintf("crypto/rand error: %v", err)) } diff --git a/internal/database/webhook_db_manager.go b/internal/database/webhook_db_manager.go index 56e19be..25cbb8b 100644 --- a/internal/database/webhook_db_manager.go +++ b/internal/database/webhook_db_manager.go @@ -73,13 +73,13 @@ func (m *WebhookDBManager) openDB(webhookID string) (*gorm.DB, error) { Conn: sqlDB, }, &gorm.Config{}) if err != nil { - sqlDB.Close() + _ = sqlDB.Close() return nil, fmt.Errorf("connecting to webhook database %s: %w", webhookID, err) } // Run migrations for event-tier models only if err := db.AutoMigrate(&Event{}, &Delivery{}, &DeliveryResult{}); err != nil { - sqlDB.Close() + _ = sqlDB.Close() return nil, fmt.Errorf("migrating webhook database %s: %w", webhookID, err) } @@ -111,7 +111,7 @@ func (m *WebhookDBManager) GetDB(webhookID string) (*gorm.DB, error) { if loaded { // Another goroutine created it first; close our duplicate if sqlDB, closeErr := db.DB(); closeErr == nil { - sqlDB.Close() + _ = sqlDB.Close() } existingDB, castOK := actual.(*gorm.DB) if !castOK { @@ -143,7 +143,7 @@ func (m *WebhookDBManager) DeleteDB(webhookID string) error { if val, ok := m.dbs.LoadAndDelete(webhookID); ok { if gormDB, castOK := val.(*gorm.DB); castOK { if sqlDB, err := gormDB.DB(); err == nil { - sqlDB.Close() + _ = sqlDB.Close() } } } diff --git a/internal/delivery/engine.go b/internal/delivery/engine.go index 78e692d..9ebaff4 100644 --- a/internal/delivery/engine.go +++ b/internal/delivery/engine.go @@ -1,3 +1,4 @@ +// Package delivery manages asynchronous event delivery to configured targets. package delivery import ( @@ -20,7 +21,7 @@ import ( const ( // deliveryChannelSize is the buffer size for the delivery channel. - // New DeliveryTasks from the webhook handler are sent here. Workers + // New Tasks from the webhook handler are sent here. Workers // drain this channel. Sized large enough that the webhook handler // should never block under normal load. deliveryChannelSize = 10000 @@ -41,7 +42,7 @@ const ( retrySweepInterval = 60 * time.Second // MaxInlineBodySize is the maximum event body size that will be carried - // inline in a DeliveryTask through the channel. Bodies at or above this + // inline in a Task through the channel. Bodies at or above this // size are left nil and fetched from the per-webhook database on demand. // This keeps channel buffer memory bounded under high traffic. MaxInlineBodySize = 16 * 1024 @@ -53,7 +54,7 @@ const ( maxBodyLog = 4096 ) -// DeliveryTask contains everything needed to deliver an event to a single +// Task contains everything needed to deliver an event to a single // target. In the ≤16KB happy path, Body is non-nil and the engine delivers // without touching any database — it trusts that the webhook handler wrote // the records correctly. Only after a delivery attempt (success or failure) @@ -61,7 +62,7 @@ const ( // // When Body is nil (payload ≥ MaxInlineBodySize), the engine fetches the // body from the per-webhook database using EventID before delivering. -type DeliveryTask struct { +type Task struct { DeliveryID string // ID of the Delivery record (for recording results) EventID string // Event ID (for DB lookup if body is nil) WebhookID string // Webhook ID (for per-webhook DB access) @@ -88,7 +89,7 @@ type DeliveryTask struct { // Notifier is the interface for notifying the delivery engine about new // deliveries. Implemented by Engine and injected into handlers. type Notifier interface { - Notify(tasks []DeliveryTask) + Notify(tasks []Task) } // HTTPTargetConfig holds configuration for http target types. @@ -116,7 +117,7 @@ type EngineParams struct { // Engine processes queued deliveries in the background using a bounded // worker pool architecture. New deliveries arrive as individual -// DeliveryTask values via a buffered delivery channel from the webhook +// Task values via a buffered delivery channel from the webhook // handler. Failed deliveries that need retry are scheduled via Go timers // with exponential backoff; each timer fires into a separate retry // channel. A fixed number of worker goroutines drain both channels, @@ -135,8 +136,8 @@ type Engine struct { client *http.Client cancel context.CancelFunc wg sync.WaitGroup - deliveryCh chan DeliveryTask - retryCh chan DeliveryTask + deliveryCh chan Task + retryCh chan Task workers int // circuitBreakers stores a *CircuitBreaker per target ID. Only used @@ -156,8 +157,8 @@ func New(lc fx.Lifecycle, params EngineParams) *Engine { Timeout: httpClientTimeout, Transport: NewSSRFSafeTransport(), }, - deliveryCh: make(chan DeliveryTask, deliveryChannelSize), - retryCh: make(chan DeliveryTask, retryChannelSize), + deliveryCh: make(chan Task, deliveryChannelSize), + retryCh: make(chan Task, retryChannelSize), workers: defaultWorkers, } @@ -208,11 +209,11 @@ func (e *Engine) stop() { // Notify signals the delivery engine that new deliveries are ready. // Called by the webhook handler after creating delivery records. Each -// DeliveryTask carries all data needed for delivery in the ≤16KB case. +// Task carries all data needed for delivery in the ≤16KB case. // Tasks are sent individually to the delivery channel. The call is // non-blocking; if the channel is full, a warning is logged and the // delivery will be recovered on the next engine restart. -func (e *Engine) Notify(tasks []DeliveryTask) { +func (e *Engine) Notify(tasks []Task) { for i := range tasks { select { case e.deliveryCh <- tasks[i]: @@ -255,7 +256,7 @@ func (e *Engine) recoverPending(ctx context.Context) { // channel. It builds the event and target context from the task's inline // data and executes the delivery. For large bodies (≥ MaxInlineBodySize), // the body is fetched from the per-webhook database on demand. -func (e *Engine) processNewTask(ctx context.Context, task *DeliveryTask) { +func (e *Engine) processNewTask(ctx context.Context, task *Task) { webhookDB, err := e.dbManager.GetDB(task.WebhookID) if err != nil { e.log.Error("failed to get webhook database", @@ -316,7 +317,7 @@ func (e *Engine) processNewTask(ctx context.Context, task *DeliveryTask) { // The task carries all data needed for delivery (same as the initial // notification). The only DB read is a status check to verify the delivery // hasn't been cancelled or resolved while the timer was pending. -func (e *Engine) processRetryTask(ctx context.Context, task *DeliveryTask) { +func (e *Engine) processRetryTask(ctx context.Context, task *Task) { webhookDB, err := e.dbManager.GetDB(task.WebhookID) if err != nil { e.log.Error("failed to get webhook database for retry", @@ -504,7 +505,7 @@ func (e *Engine) recoverWebhookDeliveries(ctx context.Context, webhookID string) bodyPtr = &bodyStr } - task := DeliveryTask{ + task := Task{ DeliveryID: d.ID, EventID: d.EventID, WebhookID: webhookID, @@ -604,7 +605,7 @@ func (e *Engine) recoverPendingDeliveries(ctx context.Context, webhookDB *gorm.D bodyPtr = &bodyStr } - task := DeliveryTask{ + task := Task{ DeliveryID: deliveries[i].ID, EventID: deliveries[i].EventID, WebhookID: webhookID, @@ -632,7 +633,7 @@ func (e *Engine) recoverPendingDeliveries(ctx context.Context, webhookDB *gorm.D } // scheduleRetry creates a Go timer that fires after the given delay and -// sends the full DeliveryTask to the engine's retry channel. The task +// sends the full Task to the engine's retry channel. The task // carries all data needed for the retry attempt, so when it fires, a // worker can deliver without reading event or target data from the DB. // @@ -640,7 +641,7 @@ func (e *Engine) recoverPendingDeliveries(ctx context.Context, webhookDB *gorm.D // dropped. The delivery remains in `retrying` status in the database // and will be picked up by the periodic retry sweep (DB-mediated // fallback path). No goroutines are blocked or re-armed. -func (e *Engine) scheduleRetry(task DeliveryTask, delay time.Duration) { +func (e *Engine) scheduleRetry(task Task, delay time.Duration) { e.log.Debug("scheduling delivery retry", "webhook_id", task.WebhookID, "delivery_id", task.DeliveryID, @@ -690,7 +691,7 @@ func (e *Engine) retrySweep(ctx context.Context) { // sweepOrphanedRetries scans all webhooks for retrying deliveries whose // backoff period has elapsed. For each eligible delivery, it builds a -// DeliveryTask and sends it to the retry channel. If the channel is +// Task and sends it to the retry channel. If the channel is // still full, the delivery is skipped and will be retried on the next // sweep cycle. func (e *Engine) sweepOrphanedRetries(ctx context.Context) { @@ -805,7 +806,7 @@ func (e *Engine) sweepWebhookRetries(ctx context.Context, webhookID string) { bodyPtr = &bodyStr } - task := DeliveryTask{ + task := Task{ DeliveryID: d.ID, EventID: d.EventID, WebhookID: webhookID, @@ -835,7 +836,7 @@ func (e *Engine) sweepWebhookRetries(ctx context.Context, webhookID string) { } } -func (e *Engine) processDelivery(ctx context.Context, webhookDB *gorm.DB, d *database.Delivery, task *DeliveryTask) { +func (e *Engine) processDelivery(ctx context.Context, webhookDB *gorm.DB, d *database.Delivery, task *Task) { switch d.Target.Type { case database.TargetTypeHTTP: e.deliverHTTP(ctx, webhookDB, d, task) @@ -854,7 +855,7 @@ func (e *Engine) processDelivery(ctx context.Context, webhookDB *gorm.DB, d *dat } } -func (e *Engine) deliverHTTP(_ context.Context, webhookDB *gorm.DB, d *database.Delivery, task *DeliveryTask) { +func (e *Engine) deliverHTTP(_ context.Context, webhookDB *gorm.DB, d *database.Delivery, task *Task) { cfg, err := e.parseHTTPConfig(d.Target.Config) if err != nil { e.log.Error("invalid HTTP target config", @@ -940,7 +941,7 @@ func (e *Engine) deliverHTTP(_ context.Context, webhookDB *gorm.DB, d *database. e.updateDeliveryStatus(webhookDB, d, database.DeliveryStatusRetrying) // Schedule a timer for the next retry with exponential backoff. - // The timer fires a DeliveryTask into the retry channel carrying + // The timer fires a Task into the retry channel carrying // all data needed for the next attempt. shift := attemptNum - 1 if shift > 30 { @@ -1038,7 +1039,7 @@ func (e *Engine) deliverSlack(webhookDB *gorm.DB, d *database.Delivery) { e.updateDeliveryStatus(webhookDB, d, database.DeliveryStatusFailed) return } - defer resp.Body.Close() + defer func() { _ = resp.Body.Close() }() body, readErr := io.ReadAll(io.LimitReader(resp.Body, maxBodyLog)) if readErr != nil { @@ -1082,10 +1083,10 @@ func FormatSlackMessage(event *database.Event) string { var b strings.Builder b.WriteString("*Webhook Event Received*\n") - b.WriteString(fmt.Sprintf("*Method:* `%s`\n", event.Method)) - b.WriteString(fmt.Sprintf("*Content-Type:* `%s`\n", event.ContentType)) - b.WriteString(fmt.Sprintf("*Timestamp:* `%s`\n", event.CreatedAt.UTC().Format(time.RFC3339))) - b.WriteString(fmt.Sprintf("*Body Size:* %d bytes\n", len(event.Body))) + fmt.Fprintf(&b, "*Method:* `%s`\n", event.Method) + fmt.Fprintf(&b, "*Content-Type:* `%s`\n", event.ContentType) + fmt.Fprintf(&b, "*Timestamp:* `%s`\n", event.CreatedAt.UTC().Format(time.RFC3339)) + fmt.Fprintf(&b, "*Body Size:* %d bytes\n", len(event.Body)) if event.Body == "" { b.WriteString("\n_(empty body)_\n") @@ -1172,7 +1173,7 @@ func (e *Engine) doHTTPRequest(cfg *HTTPTargetConfig, event *database.Event) (st if err != nil { return 0, "", durationMs, fmt.Errorf("sending request: %w", err) } - defer resp.Body.Close() + defer func() { _ = resp.Body.Close() }() body, readErr := io.ReadAll(io.LimitReader(resp.Body, maxBodyLog)) if readErr != nil { diff --git a/internal/delivery/engine_integration_test.go b/internal/delivery/engine_integration_test.go index 2ad8380..afa9912 100644 --- a/internal/delivery/engine_integration_test.go +++ b/internal/delivery/engine_integration_test.go @@ -34,7 +34,7 @@ func testMainDB(t *testing.T) *gorm.DB { sqlDB, err := sql.Open("sqlite", dsn) require.NoError(t, err) - t.Cleanup(func() { sqlDB.Close() }) + t.Cleanup(func() { _ = sqlDB.Close() }) db, err := gorm.Open(sqlite.Dialector{Conn: sqlDB}, &gorm.Config{}) require.NoError(t, err) @@ -80,8 +80,8 @@ func testEngineWithDB(t *testing.T, mainDB *gorm.DB, dbMgr *database.WebhookDBMa dbManager: dbMgr, log: slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelDebug})), client: &http.Client{Timeout: 5 * time.Second}, - deliveryCh: make(chan DeliveryTask, deliveryChannelSize), - retryCh: make(chan DeliveryTask, retryChannelSize), + deliveryCh: make(chan Task, deliveryChannelSize), + retryCh: make(chan Task, retryChannelSize), workers: 2, } } @@ -101,7 +101,7 @@ func TestProcessNewTask_InlineBody(t *testing.T) { received.Store(true) assert.Equal(t, "application/json", r.Header.Get("Content-Type")) w.WriteHeader(http.StatusOK) - fmt.Fprint(w, `{"ok":true}`) + _, _ = fmt.Fprint(w, `{"ok":true}`) })) defer ts.Close() @@ -128,7 +128,7 @@ func TestProcessNewTask_InlineBody(t *testing.T) { require.NoError(t, webhookDB.Create(&delivery).Error) bodyStr := event.Body - task := DeliveryTask{ + task := Task{ DeliveryID: delivery.ID, EventID: event.ID, WebhookID: webhookID, @@ -196,7 +196,7 @@ func TestProcessNewTask_LargeBody_FetchFromDB(t *testing.T) { require.NoError(t, webhookDB.Create(&delivery).Error) // Body is nil — engine should fetch from DB - task := DeliveryTask{ + task := Task{ DeliveryID: delivery.ID, EventID: event.ID, WebhookID: webhookID, @@ -231,7 +231,7 @@ func TestProcessNewTask_InvalidWebhookID(t *testing.T) { // Use a webhook ID that has no database // GetDB will create it lazily in the real impl, but the event won't exist - task := DeliveryTask{ + task := Task{ DeliveryID: uuid.New().String(), EventID: uuid.New().String(), WebhookID: uuid.New().String(), @@ -285,7 +285,7 @@ func TestProcessRetryTask_SuccessfulRetry(t *testing.T) { require.NoError(t, webhookDB.Create(&delivery).Error) bodyStr := event.Body - task := DeliveryTask{ + task := Task{ DeliveryID: delivery.ID, EventID: event.ID, WebhookID: webhookID, @@ -340,7 +340,7 @@ func TestProcessRetryTask_SkipsNonRetryingDelivery(t *testing.T) { require.NoError(t, webhookDB.Create(&delivery).Error) bodyStr := event.Body - task := DeliveryTask{ + task := Task{ DeliveryID: delivery.ID, EventID: event.ID, WebhookID: webhookID, @@ -399,7 +399,7 @@ func TestProcessRetryTask_LargeBody_FetchFromDB(t *testing.T) { } require.NoError(t, webhookDB.Create(&delivery).Error) - task := DeliveryTask{ + task := Task{ DeliveryID: delivery.ID, EventID: event.ID, WebhookID: webhookID, @@ -456,7 +456,7 @@ func TestWorkerLifecycle_StartStop(t *testing.T) { require.NoError(t, webhookDB.Create(&delivery).Error) bodyStr := event.Body - task := DeliveryTask{ + task := Task{ DeliveryID: delivery.ID, EventID: event.ID, WebhookID: webhookID, @@ -472,7 +472,7 @@ func TestWorkerLifecycle_StartStop(t *testing.T) { AttemptNum: 1, } - e.Notify([]DeliveryTask{task}) + e.Notify([]Task{task}) // Wait for the worker to process the task require.Eventually(t, func() bool { @@ -526,7 +526,7 @@ func TestWorkerLifecycle_ProcessesRetryChannel(t *testing.T) { // Send task directly to retry channel bodyStr := event.Body - task := DeliveryTask{ + task := Task{ DeliveryID: delivery.ID, EventID: event.ID, WebhookID: webhookID, @@ -597,7 +597,7 @@ func TestProcessDelivery_UnknownTargetType(t *testing.T) { } d.ID = delivery.ID - task := &DeliveryTask{ + task := &Task{ DeliveryID: delivery.ID, TargetType: database.TargetType("unknown"), } @@ -867,7 +867,7 @@ func TestDeliverHTTP_CustomTargetHeaders(t *testing.T) { require.NoError(t, webhookDB.Create(&delivery).Error) bodyStr := event.Body - task := DeliveryTask{ + task := Task{ DeliveryID: delivery.ID, EventID: event.ID, WebhookID: webhookID, @@ -914,7 +914,7 @@ func TestDeliverHTTP_TargetTimeout(t *testing.T) { event := seedEvent(t, db, `{"timeout":"test"}`) delivery := seedDelivery(t, db, event.ID, targetID, database.DeliveryStatusPending) - task := &DeliveryTask{ + task := &Task{ DeliveryID: delivery.ID, EventID: event.ID, WebhookID: event.WebhookID, @@ -964,7 +964,7 @@ func TestDeliverHTTP_InvalidConfig(t *testing.T) { event := seedEvent(t, db, `{"config":"invalid"}`) delivery := seedDelivery(t, db, event.ID, targetID, database.DeliveryStatusPending) - task := &DeliveryTask{ + task := &Task{ DeliveryID: delivery.ID, EventID: event.ID, WebhookID: event.WebhookID, @@ -1002,9 +1002,9 @@ func TestNotify_MultipleTasks(t *testing.T) { t.Parallel() e := testEngine(t, 1) - tasks := make([]DeliveryTask, 5) + tasks := make([]Task, 5) for i := range tasks { - tasks[i] = DeliveryTask{ + tasks[i] = Task{ DeliveryID: fmt.Sprintf("task-%d", i), } } diff --git a/internal/delivery/engine_test.go b/internal/delivery/engine_test.go index ddffd74..8e40648 100644 --- a/internal/delivery/engine_test.go +++ b/internal/delivery/engine_test.go @@ -35,7 +35,7 @@ func testWebhookDB(t *testing.T) *gorm.DB { sqlDB, err := sql.Open("sqlite", dsn) require.NoError(t, err) - t.Cleanup(func() { sqlDB.Close() }) + t.Cleanup(func() { _ = sqlDB.Close() }) db, err := gorm.Open(sqlite.Dialector{Conn: sqlDB}, &gorm.Config{}) require.NoError(t, err) @@ -56,8 +56,8 @@ func testEngine(t *testing.T, workers int) *Engine { return &Engine{ log: slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelDebug})), client: &http.Client{Timeout: 5 * time.Second}, - deliveryCh: make(chan DeliveryTask, deliveryChannelSize), - retryCh: make(chan DeliveryTask, retryChannelSize), + deliveryCh: make(chan Task, deliveryChannelSize), + retryCh: make(chan Task, retryChannelSize), workers: workers, } } @@ -108,13 +108,13 @@ func TestNotify_NonBlocking(t *testing.T) { // Fill the delivery channel to capacity for i := 0; i < deliveryChannelSize; i++ { - e.deliveryCh <- DeliveryTask{DeliveryID: fmt.Sprintf("fill-%d", i)} + e.deliveryCh <- Task{DeliveryID: fmt.Sprintf("fill-%d", i)} } // Notify should NOT block even though channel is full done := make(chan struct{}) go func() { - e.Notify([]DeliveryTask{ + e.Notify([]Task{ {DeliveryID: "overflow-1"}, {DeliveryID: "overflow-2"}, }) @@ -134,10 +134,10 @@ func TestDeliverHTTP_Success(t *testing.T) { db := testWebhookDB(t) var received atomic.Bool - ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { received.Store(true) w.WriteHeader(http.StatusOK) - fmt.Fprint(w, `{"ok":true}`) + _, _ = fmt.Fprint(w, `{"ok":true}`) })) defer ts.Close() @@ -147,7 +147,7 @@ func TestDeliverHTTP_Success(t *testing.T) { event := seedEvent(t, db, `{"hello":"world"}`) delivery := seedDelivery(t, db, event.ID, targetID, database.DeliveryStatusPending) - task := &DeliveryTask{ + task := &Task{ DeliveryID: delivery.ID, EventID: event.ID, WebhookID: event.WebhookID, @@ -194,7 +194,7 @@ func TestDeliverHTTP_Failure(t *testing.T) { ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusInternalServerError) - fmt.Fprint(w, "internal error") + _, _ = fmt.Fprint(w, "internal error") })) defer ts.Close() @@ -204,7 +204,7 @@ func TestDeliverHTTP_Failure(t *testing.T) { event := seedEvent(t, db, `{"test":true}`) delivery := seedDelivery(t, db, event.ID, targetID, database.DeliveryStatusPending) - task := &DeliveryTask{ + task := &Task{ DeliveryID: delivery.ID, EventID: event.ID, WebhookID: event.WebhookID, @@ -322,7 +322,7 @@ func TestDeliverHTTP_WithRetries_Success(t *testing.T) { event := seedEvent(t, db, `{"retry":"ok"}`) delivery := seedDelivery(t, db, event.ID, targetID, database.DeliveryStatusPending) - task := &DeliveryTask{ + task := &Task{ DeliveryID: delivery.ID, EventID: event.ID, WebhookID: event.WebhookID, @@ -376,7 +376,7 @@ func TestDeliverHTTP_MaxRetriesExhausted(t *testing.T) { delivery := seedDelivery(t, db, event.ID, targetID, database.DeliveryStatusRetrying) maxRetries := 3 - task := &DeliveryTask{ + task := &Task{ DeliveryID: delivery.ID, EventID: event.ID, WebhookID: event.WebhookID, @@ -427,7 +427,7 @@ func TestDeliverHTTP_SchedulesRetryOnFailure(t *testing.T) { event := seedEvent(t, db, `{"retry":"schedule"}`) delivery := seedDelivery(t, db, event.ID, targetID, database.DeliveryStatusPending) - task := &DeliveryTask{ + task := &Task{ DeliveryID: delivery.ID, EventID: event.ID, WebhookID: event.WebhookID, @@ -494,8 +494,8 @@ func TestExponentialBackoff_Durations(t *testing.T) { shift = 30 } backoff := time.Duration(1<