diff --git a/internal/app/docadvance/e1b_pg_test.go b/internal/app/docadvance/e1b_pg_test.go new file mode 100644 index 00000000..4f0bc507 --- /dev/null +++ b/internal/app/docadvance/e1b_pg_test.go @@ -0,0 +1,260 @@ +package docadvance_test + +import ( + "context" + "fmt" + "os" + "sync" + "testing" + "time" + + "chat-agent-system/internal/app/docadvance" + "chat-agent-system/internal/app/uow" + "chat-agent-system/internal/db" + "chat-agent-system/internal/docgov" + "chat-agent-system/internal/events" + "chat-agent-system/internal/notification" + "chat-agent-system/internal/projectinstance" + "chat-agent-system/internal/projectworkflow" + "chat-agent-system/internal/workflowdef" + + "github.com/jackc/pgx/v5/pgxpool" +) + +func init() { + if _, err := os.Stat("deploy/postgres/goose"); err == nil { + return + } + for _, cand := range []string{"../../..", "../../../.."} { + if fi, err := os.Stat(cand + "/deploy/postgres/goose"); err == nil && fi.IsDir() { + _ = os.Chdir(cand) + return + } + } +} + +func TestE1B_AdvanceShadowLiveRollback_PG(t *testing.T) { + pool, ctx := e1bPool(t) + fix := seedAdvanceFixture(t, ctx, pool, "one") + + env := approvedEnvelope(fix.suffix+"-a", fix.docID, fix.projectID, 1) + appendEnv(t, ctx, pool, env, []string{events.ConsumerWorkflowAdvance}) + + d := newAdvanceDispatcher(t, pool, "e1b-w") + if err := events.SetMode(ctx, pool, "dev", events.ConsumerWorkflowAdvance, events.ModeShadow); err != nil { + t.Fatal(err) + } + if n, err := d.RunOnce(ctx, events.ConsumerWorkflowAdvance); err != nil || n < 1 { + t.Fatalf("shadow n=%d err=%v", n, err) + } + var reqs int + if err := pool.QueryRow(ctx, `SELECT COUNT(*) FROM stage_advance_requests WHERE source_event_id=$1`, env.EventID).Scan(&reqs); err != nil { + t.Fatal(err) + } + if reqs != 0 { + t.Fatalf("shadow wrote stage_advance_requests=%d", reqs) + } + var recon int + if err := pool.QueryRow(ctx, `SELECT COUNT(*) FROM event_reconciliations WHERE event_id=$1 AND mode='shadow'`, env.EventID).Scan(&recon); err != nil { + t.Fatal(err) + } + if recon < 1 { + t.Fatal("missing shadow recon") + } + + if err := events.SetMode(ctx, pool, "dev", events.ConsumerWorkflowAdvance, events.ModeLive); err != nil { + t.Fatal(err) + } + var wg sync.WaitGroup + for i := 0; i < 2; i++ { + wg.Add(1) + go func() { + defer wg.Done() + _, _ = d.RunOnce(ctx, events.ConsumerWorkflowAdvance) + }() + } + wg.Wait() + if _, err := d.RunOnce(ctx, events.ConsumerWorkflowAdvance); err != nil { + t.Fatal(err) + } + if err := pool.QueryRow(ctx, `SELECT COUNT(*) FROM stage_advance_requests WHERE workflow_instance_id=$1`, fix.instanceID).Scan(&reqs); err != nil { + t.Fatal(err) + } + if reqs != 1 { + t.Fatalf("concurrent/live unique stage_advance_requests=%d", reqs) + } + var notes int + if err := pool.QueryRow(ctx, `SELECT COUNT(*) FROM notifications WHERE source_event_id=$1 AND type=$2`, + env.EventID, notification.TypeStageReadyToAdvance).Scan(¬es); err != nil { + t.Fatal(err) + } + if notes != 1 { + t.Fatalf("visible stage-ready notifications=%d", notes) + } + + dup := approvedEnvelope(fix.suffix+"-dup", fix.docID, fix.projectID, 2) + appendEnv(t, ctx, pool, dup, []string{events.ConsumerWorkflowAdvance}) + if _, err := d.RunOnce(ctx, events.ConsumerWorkflowAdvance); err != nil { + t.Fatal(err) + } + if err := pool.QueryRow(ctx, `SELECT COUNT(*) FROM stage_advance_requests WHERE workflow_instance_id=$1`, fix.instanceID).Scan(&reqs); err != nil { + t.Fatal(err) + } + if reqs != 1 { + t.Fatalf("second approved event created extra request=%d", reqs) + } + + stale := approvedEnvelope(fix.suffix+"-stale", "missing-doc-"+fix.suffix, fix.projectID, 3) + appendEnv(t, ctx, pool, stale, []string{events.ConsumerWorkflowAdvance}) + if _, err := d.RunOnce(ctx, events.ConsumerWorkflowAdvance); err != nil { + t.Fatal(err) + } + var status string + if err := pool.QueryRow(ctx, `SELECT status FROM event_deliveries WHERE event_id=$1 AND consumer=$2`, stale.EventID, events.ConsumerWorkflowAdvance).Scan(&status); err != nil { + t.Fatal(err) + } + if status != "quarantine" { + t.Fatalf("stale/missing doc status=%s", status) + } + + if err := events.SetMode(ctx, pool, "dev", events.ConsumerWorkflowAdvance, events.ModeOff); err != nil { + t.Fatal(err) + } + n, err := d.RunOnce(ctx, events.ConsumerWorkflowAdvance) + if err != nil || n != 0 { + t.Fatalf("rollback claimed=%d err=%v", n, err) + } +} + +func e1bPool(t *testing.T) (*pgxpool.Pool, context.Context) { + t.Helper() + if testing.Short() { + t.Skip("e1b") + } + dsn := db.SkipIfNoTestDatabaseURL(t) + db.RefuseSharedEnvDSN(t, dsn) + ctx := context.Background() + pool, err := pgxpool.New(ctx, dsn) + if err != nil { + t.Fatal(err) + } + t.Cleanup(pool.Close) + if err := db.RunMigrationsOnceForTest(ctx, pool, dsn); err != nil { + t.Fatalf("migrate: %v", err) + } + return pool, ctx +} + +func newAdvanceDispatcher(t *testing.T, pool *pgxpool.Pool, owner string) *events.Dispatcher { + t.Helper() + gov := docgov.NewGovernanceEngine(pool) + wfDef := workflowdef.NewService(pool, nil) + pwSvc := projectworkflow.NewService(projectworkflow.NewStore(pool), nil) + inst := projectinstance.NewService(projectinstance.NewStore(pool), wfDef.Store(), pwSvc.Store(), projectinstance.NewDocGovChecker(gov)) + d := events.NewDispatcher(pool, "dev", owner) + if err := d.Register(events.ConsumerWorkflowAdvance, (&docadvance.EventConsumer{ + Inst: inst, Docs: gov, PW: pwSvc, Notify: notification.NewService(pool), Env: "dev", + }).HandleLease); err != nil { + t.Fatal(err) + } + return d +} + +type advanceFix struct { + suffix, docID string + projectID int64 + instanceID int64 +} + +func seedAdvanceFixture(t *testing.T, ctx context.Context, pool *pgxpool.Pool, tag string) advanceFix { + t.Helper() + suffix := fmt.Sprintf("%s-%d", tag, time.Now().UnixNano()) + var author, reviewer, approver int64 + if err := pool.QueryRow(ctx, `INSERT INTO users (account_id, name, role) VALUES ($1,'A','engineer') RETURNING id`, "e1b-a-"+suffix).Scan(&author); err != nil { + t.Fatal(err) + } + if err := pool.QueryRow(ctx, `INSERT INTO users (account_id, name, role) VALUES ($1,'R','engineer') RETURNING id`, "e1b-r-"+suffix).Scan(&reviewer); err != nil { + t.Fatal(err) + } + if err := pool.QueryRow(ctx, `INSERT INTO users (account_id, name, role) VALUES ($1,'P','engineer') RETURNING id`, "e1b-p-"+suffix).Scan(&approver); err != nil { + t.Fatal(err) + } + if err := workflowdef.BootstrapSeeds(ctx, pool, nil, "templates/docs", author); err != nil { + t.Fatal(err) + } + wfStore := workflowdef.NewStore(pool) + pwStore := projectworkflow.NewStore(pool) + instStore := projectinstance.NewStore(pool) + gov := docgov.NewGovernanceEngine(pool) + svc := projectinstance.NewService(instStore, wfStore, pwStore, projectinstance.NewDocGovChecker(gov)) + + var projectID int64 + 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, 'E1B', 'active', $2, 'planning', '', 1, 0) RETURNING id + `, "e1b-"+suffix, author).Scan(&projectID); err != nil { + t.Fatal(err) + } + plan, err := svc.BuildDefaultPlan(ctx, workflowdef.OfficialKeyMain, 0) + if err != nil { + t.Fatal(err) + } + for i := range plan.Stages { + for j := range plan.Stages[i].DocTasks { + plan.Stages[i].DocTasks[j].Duties = projectinstance.DocDuty{ + AuthorUserID: author, ReviewerUserID: reviewer, ApproverUserID: approver, + } + } + } + if _, err := svc.SaveWorkflowPlan(ctx, projectID, author, true, projectinstance.SavePlanInput{ + Plan: *plan, ExpectedPlanRevision: 0, ExpectedSelectionVersion: 1, + }); err != nil { + t.Fatal(err) + } + preview, err := svc.ActivationPreview(ctx, projectID, author, true) + if err != nil { + t.Fatal(err) + } + activated, err := svc.Activate(ctx, projectID, author, true, projectinstance.ActivateInput{ + ConfirmationToken: preview.ConfirmationToken, + ExpectedProjectVersion: preview.ExpectedProjectVersion, + ExpectedPlanRevision: preview.ExpectedPlanRevision, + }) + if err != nil { + t.Fatal(err) + } + docID := "doc_e1b_intake_" + suffix + if _, err := gov.CreateOrGetDocument(ctx, docID, projectID, "intake", "客户需求采集", "V0.1", "", author, "A", reviewer, "R", approver, "P"); err != nil { + t.Fatal(err) + } + _ = gov.MarkDocumentContentUpdated(ctx, docID, "hash_e1b", false) + if _, err := gov.SubmitForReview(ctx, docID, author, "author", "A"); err != nil { + t.Fatal(err) + } + if _, err := gov.ReviewApprove(ctx, docID, reviewer, "reviewer", "R", "ok"); err != nil { + t.Fatal(err) + } + if _, err := gov.ApproverApprove(ctx, docID, approver, "approver", "P", "ok"); err != nil { + t.Fatal(err) + } + return advanceFix{suffix: suffix, docID: docID, projectID: projectID, instanceID: activated.Instance.ID} +} + +func approvedEnvelope(id, docID string, projectID, ver int64) events.Envelope { + body := []byte(fmt.Sprintf(`{"doc_id":%q,"project_id":%d,"task_key":"intake"}`, docID, projectID)) + return events.Envelope{ + EventID: "approved-" + id, EventName: events.EventApproved, SchemaVersion: events.EnvelopeSchema, + Environment: "dev", ProjectID: projectID, AggregateType: "document_approval", AggregateID: docID, + AggregateVersion: ver, ActorRef: "user:4", IdempotencyKey: "approved-" + id, + PayloadDigest: events.Digest(body), Producer: "docflow", OccurredAt: time.Now().UTC(), Payload: body, + } +} + +func appendEnv(t *testing.T, ctx context.Context, pool *pgxpool.Pool, env events.Envelope, consumers []string) { + t.Helper() + if err := uow.NewPG(pool).Within(ctx, func(scope uow.TxScope) error { + return (events.PGSink{}).Append(uow.WithScope(ctx, scope), env, consumers) + }); err != nil { + t.Fatal(err) + } +}