diff --git a/internal/app/app.go b/internal/app/app.go index 03be50e..8076c3b 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -26,11 +26,13 @@ import ( "net/http" "time" + "github.com/google/uuid" "github.com/yan1h/agent-coding-workflow/internal/audit" "github.com/yan1h/agent-coding-workflow/internal/config" "github.com/yan1h/agent-coding-workflow/internal/infra/db" "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/project" httpx "github.com/yan1h/agent-coding-workflow/internal/transport/http" "github.com/yan1h/agent-coding-workflow/internal/transport/http/middleware" "github.com/yan1h/agent-coding-workflow/internal/user" @@ -66,6 +68,11 @@ func New(ctx context.Context, cfg *config.Config, dist embed.FS) (*App, error) { userRepo := user.NewPostgresRepository(pool) userSvc := user.NewService(userRepo, auditRec) + projectRepo := project.NewPostgresRepository(pool) + projectSvc := project.NewProjectService(projectRepo, auditRec) + requirementSvc := project.NewRequirementService(projectRepo, auditRec) + issueSvc := project.NewIssueService(projectRepo, auditRec) + notifyRepo := notify.NewPostgresRepository(pool) notifyDispatcher := notify.NewDispatcher(notifyRepo, log) notifyDispatcher.Register(notify.NewDummyNotifier(log)) @@ -94,6 +101,7 @@ func New(ctx context.Context, cfg *config.Config, dist embed.FS) (*App, error) { // userSvc 直接满足 middleware.SessionResolver(ResolveSession 同名同签名)。 user.NewHandler(userSvc).Mount(r) notify.NewHandler(notifyRepo, userSvc).Mount(r) + project.NewHandler(projectSvc, requirementSvc, issueSvc, userSvc, userAdminAdapter{svc: userSvc}).Mount(r) if cfg.Bootstrap.AdminEmail != "" && cfg.Bootstrap.AdminPassword != "" { if u, err := userSvc.Bootstrap(ctx, cfg.Bootstrap.AdminEmail, cfg.Bootstrap.AdminPassword); err != nil { @@ -149,3 +157,15 @@ func (a *App) Run(ctx context.Context) error { return err } } + +// userAdminAdapter 把 user.Service 适配为 project.AdminLookup(窄接口)。 +// 复用 user.Service.Get:返回的 *User 直接读 IsAdmin。 +type userAdminAdapter struct{ svc user.Service } + +func (a userAdminAdapter) 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 +} diff --git a/internal/app/integration_test.go b/internal/app/integration_test.go index d52be48..cb4e541 100644 --- a/internal/app/integration_test.go +++ b/internal/app/integration_test.go @@ -21,10 +21,12 @@ import ( "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/db" "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/project" "github.com/yan1h/agent-coding-workflow/internal/transport/http/middleware" "github.com/yan1h/agent-coding-workflow/internal/user" ) @@ -106,3 +108,103 @@ func TestEndToEnd_LoginAndDispatchNotification(t *testing.T) { 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) + issueSvc := project.NewIssueService(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, 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") +}