Files
agentic-coding-workflow/internal/app/integration_test.go
T

211 lines
7.7 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"
"net/http"
"net/http/httptest"
"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/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"
)
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)
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")
}