Files
agent-bot ee1e53b510
CI / go-vet (pull_request) Failing after 10s
CI / backup-restore-drill (pull_request) Failing after 25s
CI / web (pull_request) Successful in 33s
fix(tests): per-package test databases + legitimate approval fixtures (issue #2 close)
Root causes of the remaining 5 CI PG failures (and the hidden flaky set):
1. All PG-integration packages shared ONE database under parallel package
   execution - migrations/seeds/truncates raced (nondeterministic failure
   sets, locally and in CI).
   Fix: RequireTestDatabaseURL/SkipIfNoTestDatabaseURL now derive a
   per-package database (<base>_<pkgHash>, advisory-lock-guarded
   CREATE); 9 raw-env straggler tests funnelled through the helpers;
   db-package tests call it unqualified (no import cycle).
2. Approval-chain fixtures were not production-shaped: freezeReviewSnapshot
   only honors head_snapshot_id, fake content hashes never froze a
   snapshot, so ApproverApprove rejected with 'approved requires frozen
   review_snapshot_id'. Errors were swallowed (_, _ =) hiding this.
   Fix (ownership-respecting, arch-enforced):
   - docrevision.SeedContentSnapshotForTest seeds the ledger tables it owns
   - docgov GovernanceEngine.FreezeHeadSnapshotForTest freezes head/review
     pointers on document_records (docgov-owned) and clears engine cache
   - all four integration fixtures use the pair; swallowed errors checked
3. Fixed-name fixtures collide once a package shares one DB (projects
   uq_projects_name_global): uniquified in projectinstance + server tests.
4. CAS/ctxmgr tests never bootstrapped migrations (relied on other packages
   having migrated the shared DB): explicit RunMigrationsOnceForTest.

Verified: full parallel short suite = 75 packages, 0 failures against an
isolated throwaway PG. CI skip list removed - short tests fully required.
2026-09-29 09:54:43 +00:00

231 lines
7.5 KiB
Go

package projectinstance_test
import (
"context"
"testing"
"time"
"github.com/jackc/pgx/v5/pgxpool"
"chat-agent-system/internal/db"
"chat-agent-system/internal/docgov"
"chat-agent-system/internal/docrevision"
"chat-agent-system/internal/notification"
"chat-agent-system/internal/projectinstance"
"chat-agent-system/internal/projectworkflow"
"chat-agent-system/internal/workflowdef"
)
func openPGPool(t *testing.T) *pgxpool.Pool {
t.Helper()
dsn := db.SkipIfNoTestDatabaseURL(t)
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
pool, err := pgxpool.New(ctx, dsn)
if err != nil {
t.Skip(err)
}
if err := pool.Ping(ctx); err != nil {
pool.Close()
t.Skip(err)
}
migCtx, migCancel := context.WithTimeout(context.Background(), 30*time.Second)
defer migCancel()
if err := db.RunMigrations(migCtx, pool, db.ResolveMigrationsDir()); err != nil {
pool.Close()
t.Skipf("migration failed: %v", err)
}
if !projectinstance.TablesReady(ctx, pool) {
pool.Close()
t.Skip("project_workflow_instances not migrated")
}
if !workflowdef.TablesReady(ctx, pool) {
pool.Close()
t.Skip("workflow_definitions not migrated")
}
return pool
}
func TestPG_ActivationAndStageAdvance(t *testing.T) {
pool := openPGPool(t)
defer pool.Close()
ctx := context.Background()
// 治理链路仅接受官方工作流(BuildDefaultPlan official-only),seed 官方 main manifest。
wfKey := workflowdef.OfficialKeyMain
def, err := workflowdef.LoadManifestDefinition(workflowdef.V2ManifestFiles[wfKey])
if err != nil {
t.Fatalf("load manifest: %v", err)
}
wfStore := workflowdef.NewStore(pool)
if err := wfStore.UpsertPublishedSeed(ctx, workflowdef.CreateDefinitionInput{
Key: wfKey,
Name: workflowdef.OfficialWorkflowName(wfKey),
Definition: def,
}, 1); err != nil {
t.Fatalf("seed official workflow: %v", err)
}
var authorID, reviewerID, approverID int64
rows, err := pool.Query(ctx, `SELECT id FROM users ORDER BY id ASC LIMIT 3`)
if err != nil {
t.Fatalf("list users: %v", err)
}
defer rows.Close()
ids := make([]int64, 0, 3)
for rows.Next() {
var id int64
if scanErr := rows.Scan(&id); scanErr != nil {
t.Fatalf("scan user: %v", scanErr)
}
ids = append(ids, id)
}
if len(ids) < 3 {
t.Skip("need at least 3 user rows for duty assignees")
}
authorID, reviewerID, approverID = ids[0], ids[1], ids[2]
pwStore := projectworkflow.NewStore(pool)
instStore := projectinstance.NewStore(pool)
docChecker := projectinstance.NewDocGovChecker(docgov.NewGovernanceEngine(pool))
svc := projectinstance.NewService(instStore, wfStore, pwStore, docChecker)
var projectID int64
code := "pg-p3-" + time.Now().Format("150405.000000")
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, $2, 'active', $3, 'planning', '', 1, 0) RETURNING id
`, code, "PG Phase3 Test "+code, authorID).Scan(&projectID)
if err != nil {
t.Fatalf("insert project: %v", err)
}
t.Cleanup(func() {
_, _ = pool.Exec(context.Background(), `DELETE FROM projects WHERE id = $1`, projectID)
})
plan, err := svc.BuildDefaultPlan(ctx, wfKey, 0)
if err != nil {
t.Fatalf("default plan: %v", err)
}
intakeTask := ""
for i := range plan.Stages {
for j := range plan.Stages[i].DocTasks {
tk := plan.Stages[i].DocTasks[j].TaskKey
plan.Stages[i].DocTasks[j].Duties = projectinstance.DocDuty{
AuthorUserID: authorID, ReviewerUserID: reviewerID, ApproverUserID: approverID,
}
if tk == "intake" || tk == "bd_intake" {
intakeTask = tk
} else {
// Keep a single blocking intake path so stage readiness is deterministic.
plan.Stages[i].DocTasks[j].Enabled = false
}
}
}
if intakeTask == "" {
t.Fatal("official main plan missing intake/bd_intake task")
}
_, err = svc.SaveWorkflowPlan(ctx, projectID, authorID, true, projectinstance.SavePlanInput{
Plan: *plan, ExpectedPlanRevision: 0, ExpectedSelectionVersion: 1,
})
if err != nil {
t.Fatalf("save plan: %v", err)
}
preview, err := svc.ActivationPreview(ctx, projectID, authorID, true)
if err != nil {
t.Fatalf("preview: %v", err)
}
activated, err := svc.Activate(ctx, projectID, authorID, true, projectinstance.ActivateInput{
ConfirmationToken: preview.ConfirmationToken,
ExpectedProjectVersion: preview.ExpectedProjectVersion,
ExpectedPlanRevision: preview.ExpectedPlanRevision,
})
if err != nil {
t.Fatalf("activate: %v", err)
}
docID := "doc_pg_p3_intake_" + code
engine := docgov.NewGovernanceEngine(pool)
_, err = engine.CreateOrGetDocument(ctx, docID, projectID, intakeTask, "客户需求采集", "V0.1", "", authorID, "张", reviewerID, "李", approverID, "王")
if err != nil {
t.Fatalf("create doc: %v", err)
}
_ = engine.MarkDocumentContentUpdated(ctx, docID, "hash_pg_p3", false)
if _, err := engine.SubmitForReview(ctx, docID, authorID, "author", "张"); err != nil {
t.Fatalf("submit for review: %v", err)
}
snapID, serr := docrevision.SeedContentSnapshotForTest(ctx, pool, docID, authorID, "# pg p3 intake")
if serr != nil {
t.Fatalf("seed snapshot: %v", serr)
}
if ferr := engine.FreezeHeadSnapshotForTest(ctx, docID, snapID); ferr != nil {
t.Fatalf("freeze head snapshot: %v", ferr)
}
// 直写绕过了 docgov 引擎缓存;换新引擎使 approve 从 PG 装载快照。
freshEngine := docgov.NewGovernanceEngine(pool)
if _, err := freshEngine.ReviewApprove(ctx, docID, reviewerID, "reviewer", "李", "ok"); err != nil {
t.Fatalf("review approve: %v", err)
}
_, err = freshEngine.ApproverApprove(ctx, docID, approverID, "approver", "王", "ok")
if err != nil {
t.Fatalf("approve doc: %v", err)
}
approvedResult, err := svc.HandleDocumentApproved(ctx, projectID, intakeTask)
if err != nil {
t.Fatalf("handle document approved: %v", err)
}
if approvedResult == nil || !approvedResult.StageReady {
t.Fatalf("expected stage ready after approval, got %+v", approvedResult)
}
notifySvc := notification.NewService(pool)
if notifySvc.Available() {
created, emitErr := notifySvc.EmitStageReadyToAdvance(ctx, authorID, notification.StageReadyPayload{
ProjectID: projectID,
WorkflowInstanceID: approvedResult.InstanceID,
StageInstanceID: approvedResult.StageInstanceID,
StageKey: approvedResult.StageKey,
StageName: approvedResult.StageName,
ConfigRevision: approvedResult.ConfigRevision,
RequiredDocCount: approvedResult.RequiredDocCount,
ApprovedDocCount: approvedResult.ApprovedDocCount,
})
if emitErr != nil {
t.Fatalf("emit stage ready notification: %v", emitErr)
}
if !created {
t.Fatal("expected stage ready notification to be created")
}
}
stageKey := approvedResult.StageKey
if stageKey == "" {
stageKey = "V0.1_triage"
}
readiness, err := svc.GetStageReadiness(ctx, activated.Instance.ID, stageKey, authorID, true)
if err != nil {
t.Fatalf("readiness: %v", err)
}
if !readiness.Ready {
otherTasks := []string{}
for _, dt := range readiness.DocTasks {
if !dt.Approved {
otherTasks = append(otherTasks, dt.TaskKey+":"+dt.DocState)
}
}
t.Fatalf("expected ready stage, pending=%v issues=%+v", otherTasks, readiness.BlockingIssues)
}
advanced, err := svc.AdvanceStage(ctx, activated.Instance.ID, authorID, true, projectinstance.AdvanceStageInput{
StageKey: stageKey, ExpectedConfigRevision: activated.Instance.ConfigRevision,
})
if err != nil {
t.Fatalf("advance: %v", err)
}
if advanced.NextStageKey == "" && !advanced.InstanceDone {
t.Fatalf("expected next stage or done, got %+v", advanced)
}
}