Files
2026-06-22 08:55:57 +08:00

594 lines
22 KiB
Go

//go:build integration
// Package app_test 中的 integration_test.go 端到端覆盖:bootstrap admin →
// 通过 HTTP 登录拿到 token → dispatcher 写一条通知 → 用 token 通过 HTTP
// 查询未读计数为 1。所有外部依赖只有一个 postgres 容器,由 testcontainers
// 启动。需要本机 Docker;跑命令:
//
// go test -tags=integration -count=1 ./internal/app/...
package app_test
import (
"bytes"
"context"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"os"
"os/exec"
"path/filepath"
"testing"
"time"
"github.com/go-chi/chi/v5"
"github.com/stretchr/testify/require"
tcpg "github.com/testcontainers/testcontainers-go/modules/postgres"
"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"
"github.com/yan1h/agent-coding-workflow/internal/workspace"
)
func TestEndToEnd_LoginAndDispatchNotification(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})
auditRec := audit.NewPostgresRecorder(pool)
userRepo := user.NewPostgresRepository(pool)
userSvc := user.NewService(userRepo, auditRec)
notifyRepo := notify.NewPostgresRepository(pool)
disp := notify.NewDispatcher(notifyRepo, log)
disp.Register(notify.NewDummyNotifier(log))
u, err := userSvc.Bootstrap(ctx, "admin@local", "password123")
require.NoError(t, err)
require.NotNil(t, u)
r := chi.NewRouter()
r.Use(middleware.RequestID)
user.NewHandler(userSvc).Mount(r)
// userSvc 直接满足 middleware.SessionResolver;不需要任何函数适配器。
notify.NewHandler(notifyRepo, userSvc).Mount(r)
srv := httptest.NewServer(r)
t.Cleanup(srv.Close)
body, _ := json.Marshal(map[string]string{"email": "admin@local", "password": "password123"})
resp, err := http.Post(srv.URL+"/api/v1/auth/login", "application/json", bytes.NewReader(body))
require.NoError(t, err)
require.Equal(t, http.StatusOK, resp.StatusCode)
var lr struct {
Token string `json:"token"`
}
require.NoError(t, json.NewDecoder(resp.Body).Decode(&lr))
_ = resp.Body.Close()
require.NotEmpty(t, lr.Token)
require.NoError(t, disp.Dispatch(ctx, notify.Message{
UserID: u.ID,
Topic: "test",
Severity: notify.SeverityInfo,
Title: "hello",
Body: "world",
}))
require.Eventually(t, func() bool {
req, _ := http.NewRequest("GET", srv.URL+"/api/v1/notifications/unread-count", nil)
req.Header.Set("Authorization", "Bearer "+lr.Token)
r2, err := http.DefaultClient.Do(req)
if err != nil {
return false
}
defer r2.Body.Close()
var out struct {
Count int `json:"count"`
}
_ = json.NewDecoder(r2.Body).Decode(&out)
return out.Count == 1
}, 2*time.Second, 50*time.Millisecond, "expected unread count to reach 1 after dispatch")
}
// userAdminAdapterTest 把 user.Service 适配为 project.AdminLookup(test-internal)。
type userAdminAdapterTest struct{ svc user.Service }
func (a userAdminAdapterTest) IsAdmin(ctx context.Context, id uuid.UUID) (bool, error) {
u, err := a.svc.Get(ctx, id)
if err != nil {
return false, err
}
return u.IsAdmin, nil
}
func TestEndToEnd_ProjectClosedLoop(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)
auditRec := audit.NewPostgresRecorder(pool)
userRepo := user.NewPostgresRepository(pool)
userSvc := user.NewService(userRepo, auditRec)
projectRepo := project.NewPostgresRepository(pool)
projectSvc := project.NewProjectService(projectRepo, auditRec)
requirementSvc := project.NewRequirementService(projectRepo, auditRec, nil, nil)
issueSvc := project.NewIssueService(projectRepo, auditRec)
artifactSvc := project.NewArtifactService(projectRepo, auditRec)
adminUser, err := userSvc.Bootstrap(ctx, "admin@local", "password123")
require.NoError(t, err)
require.NotNil(t, adminUser)
r := chi.NewRouter()
r.Use(middleware.RequestID)
user.NewHandler(userSvc).Mount(r)
project.NewHandler(projectSvc, requirementSvc, issueSvc, artifactSvc, userSvc, userAdminAdapterTest{svc: userSvc}).Mount(r)
srv := httptest.NewServer(r)
t.Cleanup(srv.Close)
// 登录拿 token
loginBody, _ := json.Marshal(map[string]string{"email": "admin@local", "password": "password123"})
loginResp, err := http.Post(srv.URL+"/api/v1/auth/login", "application/json", bytes.NewReader(loginBody))
require.NoError(t, err)
require.Equal(t, http.StatusOK, loginResp.StatusCode)
var lr struct {
Token string `json:"token"`
}
require.NoError(t, json.NewDecoder(loginResp.Body).Decode(&lr))
_ = loginResp.Body.Close()
require.NotEmpty(t, lr.Token)
// post 辅助函数:发送带 token 的 POST 请求,返回状态码。
post := func(t *testing.T, path string, body any) int {
t.Helper()
b, _ := json.Marshal(body)
req, _ := http.NewRequestWithContext(ctx, http.MethodPost, srv.URL+path, bytes.NewReader(b))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+lr.Token)
resp, err := http.DefaultClient.Do(req)
require.NoError(t, err)
_ = resp.Body.Close()
return resp.StatusCode
}
// PM 闭环验证
require.Equal(t, http.StatusCreated, post(t, "/api/v1/projects/", map[string]string{"slug": "demo", "name": "Demo"}))
require.Equal(t, http.StatusConflict, post(t, "/api/v1/projects/", map[string]string{"slug": "demo", "name": "Demo"}))
require.Equal(t, http.StatusCreated, post(t, "/api/v1/projects/demo/requirements", map[string]string{"title": "Req1"}))
require.Equal(t, http.StatusCreated, post(t, "/api/v1/projects/demo/requirements", map[string]string{"title": "Req2"}))
require.Equal(t, http.StatusOK, post(t, "/api/v1/projects/demo/requirements/1/phase", map[string]string{"phase": "implementing"}))
require.Equal(t, http.StatusCreated, post(t, "/api/v1/projects/demo/issues", map[string]any{"title": "I1", "requirement_number": 1}))
require.Equal(t, http.StatusCreated, post(t, "/api/v1/projects/demo/issues", map[string]string{"title": "Free"}))
require.Equal(t, http.StatusNoContent, post(t, "/api/v1/projects/demo/requirements/1/close", nil))
require.Equal(t, http.StatusConflict, post(t, "/api/v1/projects/demo/requirements/1/phase", map[string]string{"phase": "done"}))
require.Equal(t, http.StatusNoContent, post(t, "/api/v1/projects/demo/archive", nil))
require.Equal(t, http.StatusConflict, post(t, "/api/v1/projects/demo/requirements", map[string]string{"title": "Should fail"}))
// 验证 audit_logs 中 project/requirement/issue 相关行数 >= 8
var count int
err = pool.QueryRow(ctx,
`SELECT COUNT(*) FROM audit_logs WHERE action LIKE 'project.%' OR action LIKE 'requirement.%' OR action LIKE 'issue.%'`,
).Scan(&count)
require.NoError(t, err)
require.GreaterOrEqual(t, count, 8, "expected at least 8 audit log entries for project/requirement/issue actions")
}
// projectAccessAdapterTest 把 project.ProjectService.Get / GetByID 桥接成 workspace.ProjectAccess。
// 与 internal/app/app.go 里的 projectAccessAdapter 同形(拷贝粘贴),保持 test-internal 不导出。
type projectAccessAdapterTest struct {
p project.ProjectService
}
func (a projectAccessAdapterTest) Resolve(ctx context.Context, callerID uuid.UUID, isAdmin bool, projectSlug string) (uuid.UUID, bool, bool, error) {
pj, err := a.p.Get(ctx, project.Caller{UserID: callerID, IsAdmin: isAdmin}, projectSlug)
if err != nil {
return uuid.Nil, false, false, err
}
canRead, canWrite := projectACLTest(pj, callerID, isAdmin)
return pj.ID, canRead, canWrite, nil
}
func (a projectAccessAdapterTest) ResolveByID(ctx context.Context, callerID uuid.UUID, isAdmin bool, projectID uuid.UUID) (bool, bool, error) {
pj, err := a.p.GetByID(ctx, project.Caller{UserID: callerID, IsAdmin: isAdmin}, projectID)
if err != nil {
return false, false, err
}
canRead, canWrite := projectACLTest(pj, callerID, isAdmin)
return canRead, canWrite, nil
}
func projectACLTest(pj *project.Project, callerID uuid.UUID, isAdmin bool) (canRead, canWrite bool) {
owned := pj.OwnerID == callerID
canWrite = isAdmin || owned
canRead = canWrite || pj.Visibility == project.VisibilityInternal
return canRead, canWrite
}
// makeLocalBareRepo 在临时目录里建一个带 main 分支与一次 commit 的 bare 仓库,
// 返回路径(用 file:// 协议作为 git_remote_url,让 Plan #3 的 clone 走 file 协议)。
func makeLocalBareRepo(t *testing.T) string {
t.Helper()
dir := t.TempDir()
bare := filepath.Join(dir, "remote.git")
require.NoError(t, exec.Command("git", "init", "--bare", "-b", "main", bare).Run())
work := filepath.Join(dir, "seed")
require.NoError(t, os.MkdirAll(work, 0o755))
for _, args := range [][]string{
{"init", "-b", "main"},
{"config", "user.email", "t@e.c"},
{"config", "user.name", "T"},
} {
c := exec.Command("git", args...)
c.Dir = work
require.NoError(t, c.Run())
}
require.NoError(t, os.WriteFile(filepath.Join(work, "README.md"), []byte("hi\n"), 0o644))
for _, args := range [][]string{
{"add", "."},
{"commit", "-m", "init"},
{"remote", "add", "origin", bare},
{"push", "origin", "main"},
} {
c := exec.Command("git", args...)
c.Dir = work
require.NoError(t, c.Run())
}
return bare
}
// TestPlan3_WorkspaceClosedLoop 端到端覆盖 workspace 模块:
// 1. bootstrap admin → login 拿 token
// 2. 创建 project + bare 远端
// 3. POST /workspaces 创建 workspace(异步 clone)
// 4. 轮询 GET /workspaces/{id} 直到 sync_status == "idle"
// 5. POST /worktrees 创建支线 → acquire/release 状态机切换
// 6. POST /sync(等同 git fetch)→ sync_status 仍为 idle
// 7. DELETE worktree → DELETE workspace 都返回 204
//
// 跳过条件:本机没有 git 二进制时直接 t.Skip。Docker 不可用会让 testcontainers
// Run 失败,由上层的 require 触发测试失败(与已有 PM 闭环测试一致)。
func TestPlan3_WorkspaceClosedLoop(t *testing.T) {
if _, err := exec.LookPath("git"); err != nil {
t.Skip("git not available")
}
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})
auditRec := audit.NewPostgresRecorder(pool)
userRepo := user.NewPostgresRepository(pool)
userSvc := user.NewService(userRepo, auditRec)
projectRepo := project.NewPostgresRepository(pool)
projectSvc := project.NewProjectService(projectRepo, auditRec)
requirementSvc := project.NewRequirementService(projectRepo, auditRec, nil, nil)
issueSvc := project.NewIssueService(projectRepo, auditRec)
artifactSvc := project.NewArtifactService(projectRepo, auditRec)
adminUser, err := userSvc.Bootstrap(ctx, "admin@local", "password123")
require.NoError(t, err)
require.NotNil(t, adminUser)
// ---- workspace 模块装配(与 app.go 同形,但不依赖 cfg) ----
dataDir := t.TempDir()
tmpDir := filepath.Join(dataDir, "tmp")
require.NoError(t, os.MkdirAll(tmpDir, 0o700))
enc, err := crypto.NewEncryptor(make([]byte, 32))
require.NoError(t, err)
gitr := git.NewDefaultRunner(git.Config{
Binary: "git", TmpDir: tmpDir,
CloneTimeout: 30 * time.Second, FetchTimeout: 30 * time.Second,
PushTimeout: 30 * time.Second, OpsTimeout: 30 * time.Second,
})
wsRepo := workspace.NewPostgresRepository(pool)
_, err = wsRepo.ResetStuckSyncStatuses(ctx)
require.NoError(t, err)
pa := projectAccessAdapterTest{p: projectSvc}
wsSvc := workspace.NewWorkspaceService(wsRepo, auditRec, enc, gitr, pa, nil, dataDir)
credAcc, ok := wsSvc.(workspace.CredentialAccessor)
require.True(t, ok)
credLoader := func(ctx context.Context, wsID uuid.UUID) (*git.Credential, error) {
return credAcc.LoadDecryptedCredential(ctx, wsID)
}
cloneRunner := workspace.NewCloneRunner(wsRepo, auditRec, gitr, log, credLoader, 30*time.Second)
if setter, ok := wsSvc.(workspace.SchedulerSetter); ok {
setter.SetScheduler(cloneRunner)
}
wtSvc := workspace.NewWorktreeService(wsRepo, auditRec, gitr, pa, dataDir)
gopSvc := workspace.NewGitOpsService(wsRepo, auditRec, gitr, pa, credLoader)
r := chi.NewRouter()
r.Use(middleware.RequestID)
user.NewHandler(userSvc).Mount(r)
project.NewHandler(projectSvc, requirementSvc, issueSvc, artifactSvc, userSvc, userAdminAdapterTest{svc: userSvc}).Mount(r)
workspace.NewHandler(wsSvc, wtSvc, gopSvc, userSvc, userAdminAdapterTest{svc: userSvc}).Mount(r)
srv := httptest.NewServer(r)
t.Cleanup(srv.Close)
// ---- 登录拿 token ----
loginBody, _ := json.Marshal(map[string]string{"email": "admin@local", "password": "password123"})
loginResp, err := http.Post(srv.URL+"/api/v1/auth/login", "application/json", bytes.NewReader(loginBody))
require.NoError(t, err)
require.Equal(t, http.StatusOK, loginResp.StatusCode)
var lr struct {
Token string `json:"token"`
}
require.NoError(t, json.NewDecoder(loginResp.Body).Decode(&lr))
_ = loginResp.Body.Close()
require.NotEmpty(t, lr.Token)
token := lr.Token
// 三个内联 helper:内联是有意为之,避免引入 test 外部 helper 包。
postJSON := func(path string, body any) (*http.Response, []byte) {
t.Helper()
b, _ := json.Marshal(body)
req, _ := http.NewRequestWithContext(ctx, http.MethodPost, srv.URL+path, bytes.NewReader(b))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+token)
resp, err := http.DefaultClient.Do(req)
require.NoError(t, err)
buf, _ := io.ReadAll(resp.Body)
_ = resp.Body.Close()
return resp, buf
}
getJSON := func(path string) (*http.Response, []byte) {
t.Helper()
req, _ := http.NewRequestWithContext(ctx, http.MethodGet, srv.URL+path, nil)
req.Header.Set("Authorization", "Bearer "+token)
resp, err := http.DefaultClient.Do(req)
require.NoError(t, err)
buf, _ := io.ReadAll(resp.Body)
_ = resp.Body.Close()
return resp, buf
}
deleteJSON := func(path string) *http.Response {
t.Helper()
req, _ := http.NewRequestWithContext(ctx, http.MethodDelete, srv.URL+path, nil)
req.Header.Set("Authorization", "Bearer "+token)
resp, err := http.DefaultClient.Do(req)
require.NoError(t, err)
_ = resp.Body.Close()
return resp
}
// ---- 1. 创建 project ----
resp, _ := postJSON("/api/v1/projects/", map[string]string{"slug": "p1", "name": "P1"})
require.Equal(t, http.StatusCreated, resp.StatusCode)
// ---- 2. 准备本地 bare 仓库(file://协议) ----
bare := makeLocalBareRepo(t)
remoteURL := "file://" + filepath.ToSlash(bare)
// ---- 3. 创建 workspace(credential.kind=none,本地 file 协议无需凭据) ----
resp, body := postJSON("/api/v1/projects/p1/workspaces", map[string]any{
"slug": "ws1", "name": "WS1",
"git_remote_url": remoteURL,
"default_branch": "main",
"credential": map[string]any{"kind": "none"},
})
require.Equalf(t, http.StatusCreated, resp.StatusCode, "create workspace failed: %s", string(body))
var wsResp struct {
ID uuid.UUID `json:"id"`
SyncStatus string `json:"sync_status"`
}
require.NoError(t, json.Unmarshal(body, &wsResp))
require.NotEqual(t, uuid.Nil, wsResp.ID)
// ---- 4. 等 clone 完成 → sync_status = idle ----
require.Eventuallyf(t, func() bool {
r, b := getJSON("/api/v1/workspaces/" + wsResp.ID.String())
if r.StatusCode != http.StatusOK {
return false
}
var ws struct {
SyncStatus string `json:"sync_status"`
LastSyncError string `json:"last_sync_error"`
}
_ = json.Unmarshal(b, &ws)
if ws.SyncStatus == "error" {
t.Logf("clone errored: %s", ws.LastSyncError)
return false
}
return ws.SyncStatus == "idle"
}, 30*time.Second, 200*time.Millisecond, "workspace clone did not become idle")
// ---- 5. 创建 worktree feat-x ----
resp, body = postJSON("/api/v1/workspaces/"+wsResp.ID.String()+"/worktrees", map[string]string{
"branch": "feat-x", "base_branch": "main",
})
require.Equalf(t, http.StatusCreated, resp.StatusCode, "create worktree: %s", string(body))
var wtResp struct {
ID uuid.UUID `json:"id"`
Branch string `json:"branch"`
Status string `json:"status"`
}
require.NoError(t, json.Unmarshal(body, &wtResp))
require.Equal(t, "feat-x", wtResp.Branch)
require.Equal(t, "idle", wtResp.Status)
// ---- 6. acquire / release ----
resp, body = postJSON("/api/v1/worktrees/"+wtResp.ID.String()+"/acquire", nil)
require.Equalf(t, http.StatusOK, resp.StatusCode, "acquire: %s", string(body))
var afterAcq struct {
Status string `json:"status"`
}
require.NoError(t, json.Unmarshal(body, &afterAcq))
require.Equal(t, "active", afterAcq.Status)
resp, body = postJSON("/api/v1/worktrees/"+wtResp.ID.String()+"/release", nil)
require.Equalf(t, http.StatusOK, resp.StatusCode, "release: %s", string(body))
var afterRel struct {
Status string `json:"status"`
}
require.NoError(t, json.Unmarshal(body, &afterRel))
require.Equal(t, "idle", afterRel.Status)
// ---- 7. 同步 workspace(git fetch)→ 仍为 idle ----
resp, body = postJSON("/api/v1/workspaces/"+wsResp.ID.String()+"/sync", nil)
require.Equalf(t, http.StatusOK, resp.StatusCode, "sync: %s", string(body))
var afterSync struct {
SyncStatus string `json:"sync_status"`
}
require.NoError(t, json.Unmarshal(body, &afterSync))
require.Equal(t, "idle", afterSync.SyncStatus)
// ---- 8. 删除 worktree / workspace ----
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(), auditRec, log)
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)
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")
}
}