You've already forked agentic-coding-workflow
feat(app): wire project module and integration test for pm closed loop
This commit is contained in:
@@ -26,11 +26,13 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
"github.com/yan1h/agent-coding-workflow/internal/audit"
|
"github.com/yan1h/agent-coding-workflow/internal/audit"
|
||||||
"github.com/yan1h/agent-coding-workflow/internal/config"
|
"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/db"
|
||||||
"github.com/yan1h/agent-coding-workflow/internal/infra/logger"
|
"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/infra/notify"
|
||||||
|
"github.com/yan1h/agent-coding-workflow/internal/project"
|
||||||
httpx "github.com/yan1h/agent-coding-workflow/internal/transport/http"
|
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/transport/http/middleware"
|
||||||
"github.com/yan1h/agent-coding-workflow/internal/user"
|
"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)
|
userRepo := user.NewPostgresRepository(pool)
|
||||||
userSvc := user.NewService(userRepo, auditRec)
|
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)
|
notifyRepo := notify.NewPostgresRepository(pool)
|
||||||
notifyDispatcher := notify.NewDispatcher(notifyRepo, log)
|
notifyDispatcher := notify.NewDispatcher(notifyRepo, log)
|
||||||
notifyDispatcher.Register(notify.NewDummyNotifier(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 同名同签名)。
|
// userSvc 直接满足 middleware.SessionResolver(ResolveSession 同名同签名)。
|
||||||
user.NewHandler(userSvc).Mount(r)
|
user.NewHandler(userSvc).Mount(r)
|
||||||
notify.NewHandler(notifyRepo, 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 cfg.Bootstrap.AdminEmail != "" && cfg.Bootstrap.AdminPassword != "" {
|
||||||
if u, err := userSvc.Bootstrap(ctx, cfg.Bootstrap.AdminEmail, cfg.Bootstrap.AdminPassword); err != nil {
|
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
|
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
|
||||||
|
}
|
||||||
|
|||||||
@@ -21,10 +21,12 @@ import (
|
|||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
tcpg "github.com/testcontainers/testcontainers-go/modules/postgres"
|
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/audit"
|
||||||
"github.com/yan1h/agent-coding-workflow/internal/infra/db"
|
"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/logger"
|
||||||
"github.com/yan1h/agent-coding-workflow/internal/infra/notify"
|
"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/transport/http/middleware"
|
||||||
"github.com/yan1h/agent-coding-workflow/internal/user"
|
"github.com/yan1h/agent-coding-workflow/internal/user"
|
||||||
)
|
)
|
||||||
@@ -106,3 +108,103 @@ func TestEndToEnd_LoginAndDispatchNotification(t *testing.T) {
|
|||||||
return out.Count == 1
|
return out.Count == 1
|
||||||
}, 2*time.Second, 50*time.Millisecond, "expected unread count to reach 1 after dispatch")
|
}, 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")
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user