Files
Agent-System/internal/server/phase5_acceptance_pg_test.go
T
weichen 70882918e5 test(pg): isolate PG integration tests from shared dev database
修复 go test -short 在设置 TEST_DATABASE_URL 时的既有欠债:
- storage/notification/docgov:移除硬编码 agent_system_dev 回退 DSN,改为
  无 TEST_DATABASE_URL 即 skip;固定项目名改为 name||code 唯一化。
- docgov engine_db:改为隔离库 + 自建项目行,替换随机 projectID 撞外键的旧写法。
- server/*_pg、projectinstance/pg_integration:迁移到官方 main 工作流
  (BuildDefaultPlan official-only 治理后的正确前提),补齐在任成员
  (DUTY_ASSIGNEE_NOT_ACTIVE_MEMBER)、任务键 bd_intake、阶段键 V0.1_triage、
  项目作用域 run record(events.ValidateEnvelope 拒绝 project<=0)、
  task_run_resume 去除内嵌 dev 凭证的 config 硬编码。
- 新增 seedProjectForRun 测试辅助。
验证:fresh 隔离库(55432)上 go test -short -p 1 ./... 除 archtest
(另行注册表分支修复)外全部通过;包内并行迁移竞争需 -p 1。
2026-09-22 01:10:17 +00:00

187 lines
7.3 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package server_test
import (
"bytes"
"context"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"testing"
"time"
"chat-agent-system/internal/db"
"chat-agent-system/internal/projectinstance"
"chat-agent-system/internal/projectworkflow"
"chat-agent-system/internal/workflowdef"
)
func TestPhase5_MigrationVersionGate_PG(t *testing.T) {
pool := openPGPoolWithMigrations(t)
if pool == nil {
return
}
defer pool.Close()
ctx := context.Background()
migDir := db.ResolveMigrationsDir()
expected, err := db.ExpectedMigrationCount(migDir)
if err != nil {
t.Fatalf("count migrations: %v", err)
}
current, err := db.CurrentVersion(ctx, pool)
if err != nil {
t.Fatalf("current version: %v", err)
}
if int(current) < expected {
t.Fatalf("schema version %d behind expected %d migration files", current, expected)
}
// 重复迁移应幂等(模拟备份恢复后再 Up)
if err := db.RunMigrations(ctx, pool, migDir); err != nil {
t.Fatalf("re-run migrations: %v", err)
}
current2, _ := db.CurrentVersion(ctx, pool)
if current2 != current {
t.Fatalf("migration not idempotent: %d -> %d", current, current2)
}
}
func TestPhase5_AuthzNegativeMatrix_PG(t *testing.T) {
pool := openPGPoolWithMigrations(t)
if pool == nil {
return
}
defer pool.Close()
handler, _ := setupPGGovernanceServer(t, pool)
creatorToken := loginTestUser(t, handler, "zhanggong", "123456")
outsiderToken := loginTestUser(t, handler, "lizhuguan", "123456")
createBody, _ := json.Marshal(projectworkflow.CreateProjectInput{Name: "Phase5 Authz Matrix Test"})
reqCreate := httptest.NewRequest(http.MethodPost, "/api/v1/projects", bytes.NewReader(createBody))
withBearerAuth(reqCreate, creatorToken)
recCreate := httptest.NewRecorder()
handler.ServeHTTP(recCreate, reqCreate)
if recCreate.Code != http.StatusCreated {
t.Fatalf("create project: %d", recCreate.Code)
}
var proj projectworkflow.Project
_ = json.Unmarshal(recCreate.Body.Bytes(), &proj)
t.Cleanup(func() { cleanupProject(t, pool, proj.ID) })
// 访客无权读项目设置(requireProjectAccess)
reqSettings := httptest.NewRequest(http.MethodGet, fmt.Sprintf("/api/v1/projects/%d/settings", proj.ID), nil)
withBearerAuth(reqSettings, outsiderToken)
recSettings := httptest.NewRecorder()
handler.ServeHTTP(recSettings, reqSettings)
if recSettings.Code != http.StatusForbidden {
t.Fatalf("outsider settings expected 403, got %d", recSettings.Code)
}
// 非成员无权保存工作流规划
saveBody, _ := json.Marshal(projectinstance.SavePlanInput{
Plan: projectinstance.WorkflowPlan{WorkflowDefinitionKey: "medical_device_development", WorkflowVersion: 1},
})
reqSave := httptest.NewRequest(http.MethodPut, fmt.Sprintf("/api/v1/projects/%d/workflow-plan", proj.ID), bytes.NewReader(saveBody))
withBearerAuth(reqSave, outsiderToken)
recSave := httptest.NewRecorder()
handler.ServeHTTP(recSave, reqSave)
if recSave.Code != http.StatusForbidden {
t.Fatalf("outsider save plan expected 403, got %d", recSave.Code)
}
// 未认证请求
reqAnon := httptest.NewRequest(http.MethodGet, fmt.Sprintf("/api/v1/projects/%d/settings", proj.ID), nil)
recAnon := httptest.NewRecorder()
handler.ServeHTTP(recAnon, reqAnon)
if recAnon.Code != http.StatusUnauthorized {
t.Fatalf("anonymous settings expected 401, got %d", recAnon.Code)
}
}
func TestPhase5_ActivateIdempotencyConflict_PG(t *testing.T) {
pool := openPGPoolWithMigrations(t)
if pool == nil {
return
}
defer pool.Close()
ctx := context.Background()
handler, _ := setupPGGovernanceServer(t, pool)
seedOfficialWorkflowManifest(t, pool, workflowdef.OfficialKeyMain)
creatorToken := loginTestUser(t, handler, "zhanggong", "123456")
creatorID := fetchAuthUserID(t, handler, creatorToken)
reviewerID := fetchAuthUserID(t, handler, loginTestUser(t, handler, "lizhuguan", "123456"))
approverID := fetchAuthUserID(t, handler, loginTestUser(t, handler, "wangzong", "123456"))
var projectID int64
code := "p5-idem-" + time.Now().Format("150405.000000")
if err := pool.QueryRow(ctx, `
INSERT INTO projects (code, name, status, creator_user_id, lifecycle_status, active_workflow_key, workflow_selection_version, workflow_plan_revision)
VALUES ($1, 'Phase5 Idempotency Test ' || $1, 'active', $2, 'planning', '', 1, 0) RETURNING id
`, code, creatorID).Scan(&projectID); err != nil {
t.Fatalf("insert project: %v", err)
}
ensureProjectMembers(t, pool, projectID, map[int64]string{creatorID: "owner", reviewerID: "reviewer", approverID: "approver"})
t.Cleanup(func() { cleanupProject(t, pool, projectID) })
reqDefault := httptest.NewRequest(http.MethodGet, fmt.Sprintf("/api/v1/projects/%d/workflow-plan/default?workflow_key=%s&version=0", projectID, workflowdef.OfficialKeyMain), nil)
withBearerAuth(reqDefault, creatorToken)
recDefault := httptest.NewRecorder()
handler.ServeHTTP(recDefault, reqDefault)
if recDefault.Code != http.StatusOK {
t.Fatalf("default plan: %d %s", recDefault.Code, recDefault.Body.String())
}
var plan projectinstance.WorkflowPlan
if err := json.Unmarshal(recDefault.Body.Bytes(), &plan); err != nil {
t.Fatalf("decode plan: %v", err)
}
for i := range plan.Stages {
for j := range plan.Stages[i].DocTasks {
plan.Stages[i].DocTasks[j].Duties = projectinstance.DocDuty{
AuthorUserID: creatorID, ReviewerUserID: reviewerID, ApproverUserID: approverID,
}
}
}
saveBody, _ := json.Marshal(projectinstance.SavePlanInput{Plan: plan, ExpectedPlanRevision: 0, ExpectedSelectionVersion: 1})
reqSave := httptest.NewRequest(http.MethodPut, fmt.Sprintf("/api/v1/projects/%d/workflow-plan", projectID), bytes.NewReader(saveBody))
withBearerAuth(reqSave, creatorToken)
recSave := httptest.NewRecorder()
handler.ServeHTTP(recSave, reqSave)
if recSave.Code != http.StatusOK {
t.Fatalf("save plan: %d %s", recSave.Code, recSave.Body.String())
}
reqPreview := httptest.NewRequest(http.MethodPost, fmt.Sprintf("/api/v1/projects/%d/activation-preview", projectID), nil)
withBearerAuth(reqPreview, creatorToken)
recPreview := httptest.NewRecorder()
handler.ServeHTTP(recPreview, reqPreview)
if recPreview.Code != http.StatusOK {
t.Fatalf("activation preview: %d %s", recPreview.Code, recPreview.Body.String())
}
var preview projectinstance.ActivationPreview
_ = json.Unmarshal(recPreview.Body.Bytes(), &preview)
activateBody, _ := json.Marshal(projectinstance.ActivateInput{
ConfirmationToken: preview.ConfirmationToken, ExpectedProjectVersion: preview.ExpectedProjectVersion,
ExpectedPlanRevision: preview.ExpectedPlanRevision,
})
reqActivate := httptest.NewRequest(http.MethodPost, fmt.Sprintf("/api/v1/projects/%d/workflow-instances/activate", projectID), bytes.NewReader(activateBody))
withBearerAuth(reqActivate, creatorToken)
recActivate := httptest.NewRecorder()
handler.ServeHTTP(recActivate, reqActivate)
if recActivate.Code != http.StatusOK {
t.Fatalf("first activate: %d %s", recActivate.Code, recActivate.Body.String())
}
// 二次激活应冲突
reqActivate2 := httptest.NewRequest(http.MethodPost, fmt.Sprintf("/api/v1/projects/%d/workflow-instances/activate", projectID), bytes.NewReader(activateBody))
withBearerAuth(reqActivate2, creatorToken)
recActivate2 := httptest.NewRecorder()
handler.ServeHTTP(recActivate2, reqActivate2)
if recActivate2.Code != http.StatusConflict {
t.Fatalf("second activate expected 409, got %d %s", recActivate2.Code, recActivate2.Body.String())
}
}