feat(app): wire jobs.Module + WebhookNotifier + integration test for webhook closed loop

This commit is contained in:
2026-05-06 09:25:19 +08:00
parent 7b3892c42e
commit c7db7a9604
2 changed files with 411 additions and 1 deletions
+92
View File
@@ -27,11 +27,14 @@ import (
"github.com/google/uuid"
"github.com/yan1h/agent-coding-workflow/internal/audit"
"github.com/yan1h/agent-coding-workflow/internal/infra/clock"
"github.com/yan1h/agent-coding-workflow/internal/infra/crypto"
"github.com/yan1h/agent-coding-workflow/internal/infra/db"
"github.com/yan1h/agent-coding-workflow/internal/infra/git"
"github.com/yan1h/agent-coding-workflow/internal/infra/logger"
"github.com/yan1h/agent-coding-workflow/internal/infra/notify"
"github.com/yan1h/agent-coding-workflow/internal/jobs"
"github.com/yan1h/agent-coding-workflow/internal/jobs/handlers"
"github.com/yan1h/agent-coding-workflow/internal/project"
"github.com/yan1h/agent-coding-workflow/internal/transport/http/middleware"
"github.com/yan1h/agent-coding-workflow/internal/user"
@@ -499,3 +502,92 @@ func TestPlan3_WorkspaceClosedLoop(t *testing.T) {
require.Equal(t, http.StatusNoContent, deleteJSON("/api/v1/worktrees/"+wtResp.ID.String()).StatusCode)
require.Equal(t, http.StatusNoContent, deleteJSON("/api/v1/workspaces/"+wsResp.ID.String()).StatusCode)
}
func TestEndToEnd_WebhookClosedLoop(t *testing.T) {
ctx := context.Background()
container, err := tcpg.Run(ctx, "postgres:16-alpine",
tcpg.WithDatabase("acw"), tcpg.WithUsername("acw"), tcpg.WithPassword("acw"),
)
require.NoError(t, err)
t.Cleanup(func() { _ = container.Terminate(ctx) })
dsn, err := container.ConnectionString(ctx, "sslmode=disable")
require.NoError(t, err)
require.NoError(t, db.Migrate(ctx, dsn, "file://../../migrations"))
pool, err := db.NewPool(ctx, dsn)
require.NoError(t, err)
t.Cleanup(pool.Close)
log := logger.New(logger.Options{Format: logger.FormatJSON, Level: logger.LevelInfo})
// Webhook receiver
type recv struct {
body map[string]any
topic string
eventID string
sig string
}
received := make(chan recv, 4)
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var body map[string]any
rawBody, _ := io.ReadAll(r.Body)
_ = json.Unmarshal(rawBody, &body)
received <- recv{
body: body,
topic: r.Header.Get("X-ACW-Topic"),
eventID: r.Header.Get("X-ACW-Event-Id"),
sig: r.Header.Get("X-ACW-Signature"),
}
w.WriteHeader(http.StatusOK)
}))
t.Cleanup(ts.Close)
auditRec := audit.NewPostgresRecorder(pool)
notifyRepo := notify.NewPostgresRepository(pool)
disp := notify.NewDispatcher(notifyRepo, log)
jobsRepo := jobs.NewPostgresRepository(pool)
h := handlers.NewWebhookHandler(handlers.WebhookConfig{
URL: ts.URL, Secret: "test-secret", Timeout: 2 * time.Second,
}, clock.Real())
n := jobs.NewWebhookNotifier(jobsRepo, jobs.WebhookNotifierConfig{
Enabled: true,
Topics: []string{"workspace.sync_failed"},
Backoff: []time.Duration{50 * time.Millisecond, 200 * time.Millisecond},
}, clock.Real())
disp.Register(n)
// Run a single Worker (no full Module needed for this test).
w := jobs.NewWorker(jobsRepo, map[jobs.JobType]jobs.Handler{jobs.TypeWebhookDeliver: h},
jobs.WorkerOptions{
ID: "itest", PollInterval: 20 * time.Millisecond,
Backoff: []time.Duration{50 * time.Millisecond}, Clock: clock.Real(), Log: log,
})
wctx, wcancel := context.WithCancel(ctx)
t.Cleanup(func() { wcancel(); w.Wait() })
go w.Run(wctx)
// Suppress unused-variable vet warning.
_ = auditRec
eventTime := time.Now()
require.NoError(t, disp.Dispatch(ctx, notify.Message{
ID: uuid.New(), UserID: uuid.New(),
Topic: "workspace.sync_failed", Severity: notify.SeverityError,
Title: "fetch failed", Body: "remote unreachable",
CreatedAt: eventTime,
}))
select {
case got := <-received:
require.Equal(t, "workspace.sync_failed", got.topic)
require.NotEmpty(t, got.eventID)
require.Contains(t, got.sig, "sha256=")
require.Equal(t, "workspace.sync_failed", got.body["topic"])
require.Equal(t, "fetch failed", got.body["title"])
case <-time.After(3 * time.Second):
t.Fatal("webhook never fired")
}
}