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