//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") } }