Files
Agent-System/internal/docgov/engine_test.go
T
weichenandCursor 0b5f4997e2 fix(docgov): mark proposal applied after durable DB write
Write applied status to Postgres before mutating memory so a failed UPDATE
cannot leave a false in-memory applied state; treat already-applied as
idempotent success and expose PutChangeProposal for compensation seeding.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-13 10:09:18 +00:00

299 lines
10 KiB
Go

package docgov_test
import (
"context"
"testing"
"chat-agent-system/internal/docgov"
)
func TestDocGov_AuthorPermissionsAndLocking(t *testing.T) {
ctx := context.Background()
engine := docgov.NewGovernanceEngine(nil)
// 1. 初始化文档:Author=101(张工), Reviewer=102(李主管), Approver=103(王总监)
doc, err := engine.CreateOrGetDocument(
ctx,
"doc_feasibility_001",
1,
"feasibility_reg",
"4K超高清腹腔镜注册可行性评估报告",
"V0.5_feasibility",
"data/dev/workspaces/bizdev/feasibility_reg_1",
101, "张工",
102, "李主管",
103, "王总监",
)
if err != nil {
t.Fatalf("CreateOrGetDocument failed: %v", err)
}
// 2. 只有编制人有权修改正文
_, err = engine.RecordDocumentEdited(ctx, doc.DocID, 101, docgov.RoleAuthor, "张工")
if err != nil {
t.Fatalf("Author should be allowed to edit in drafting state, got: %v", err)
}
// 3. 审核人尝试直接修改正文 -> 应当被拒绝
_, err = engine.RecordDocumentEdited(ctx, doc.DocID, 102, docgov.RoleReviewer, "李主管")
if err != docgov.ErrReviewerCannotEdit {
t.Fatalf("expected ErrReviewerCannotEdit, got: %v", err)
}
// 4. 批准人尝试直接修改正文 -> 应当被拒绝
_, err = engine.RecordDocumentEdited(ctx, doc.DocID, 103, docgov.RoleApprover, "王总监")
if err != docgov.ErrApproverCannotEdit {
t.Fatalf("expected ErrApproverCannotEdit, got: %v", err)
}
// 5. 非指定的其他人尝试以 Author 角色修改 -> 权限拒绝
_, err = engine.RecordDocumentEdited(ctx, doc.DocID, 999, docgov.RoleAuthor, "非法用户")
if err == nil {
t.Fatalf("expected unauthorized actor error, got nil")
}
// 6. 送审后进入锁定状态,此时即使 Author 也不能直接修改正文
_, err = engine.SubmitForReview(ctx, doc.DocID, 101, docgov.RoleAuthor, "张工")
if err != nil {
t.Fatalf("SubmitForReview failed: %v", err)
}
_, err = engine.RecordDocumentEdited(ctx, doc.DocID, 101, docgov.RoleAuthor, "张工")
if err == nil {
t.Fatalf("expected document locked error when editing during reviewing state, got nil")
}
}
func TestDocGov_ClaimingMechanismAndProposal(t *testing.T) {
ctx := context.Background()
engine := docgov.NewGovernanceEngine(nil)
docID := "doc_intake_002"
doc, err := engine.CreateOrGetDocument(
ctx,
docID,
1,
"intake",
"立项与技术摸底需求表",
"V0.1_triage",
"workspaces/intake_2",
201, "刘工",
202, "赵主管",
203, "孙总监",
)
if err != nil {
t.Fatalf("CreateOrGetDocument failed: %v", err)
}
// 1. 初始状态为模板状态 (is_template_only = true)
if !doc.IsTemplateOnly {
t.Fatalf("initial state should be template only")
}
// 2. 编制人在模板状态下查看文档 -> 不算认领!
doc, err = engine.RecordDocumentViewed(ctx, docID, 201, docgov.RoleAuthor, "刘工")
if err != nil {
t.Fatalf("RecordDocumentViewed failed: %v", err)
}
if doc.AuthorClaimed {
t.Fatalf("author should NOT be claimed when viewing a template-only document")
}
// 3. 此时系统或阶段发起修改 -> 允许直接编辑 (未认领)
checkRes, err := engine.CheckModificationPermission(ctx, docID, docgov.RoleSystem)
if err != nil {
t.Fatalf("CheckModificationPermission failed: %v", err)
}
if !checkRes.AllowedDirectEdit || checkRes.RequiresProposal {
t.Fatalf("expected AllowedDirectEdit=true and RequiresProposal=false for unclaimed document")
}
// 4. 智能体小队生成了正式产出正文,脱离模板状态
err = engine.MarkDocumentContentUpdated(ctx, docID, "sha256_mock_content_12345", false)
if err != nil {
t.Fatalf("MarkDocumentContentUpdated failed: %v", err)
}
// 5. 编制人查看已生成的正式产出文档 -> 触发认领!
doc, err = engine.RecordDocumentViewed(ctx, docID, 201, docgov.RoleAuthor, "刘工")
if err != nil {
t.Fatalf("RecordDocumentViewed failed: %v", err)
}
if !doc.AuthorClaimed {
t.Fatalf("author MUST be claimed after viewing non-template document")
}
// 6. 再次发起阶段修改 -> 拦截直接编辑,必须生成变更提案
checkRes2, err := engine.CheckModificationPermission(ctx, docID, docgov.RoleSystem)
if err != nil {
t.Fatalf("CheckModificationPermission failed: %v", err)
}
if checkRes2.AllowedDirectEdit || !checkRes2.RequiresProposal {
t.Fatalf("expected AllowedDirectEdit=false and RequiresProposal=true for claimed document")
}
// 7. 生成变更提案
proposal, err := engine.ProposeModification(
ctx,
docID,
"agent_squad_upstream_sync",
"根据客户最新电话沟通,将目标市场从【国内】追加【欧盟CE】,并增加YY/T1603标准条款",
"影响法规注册路径,需要从单纯二类补充MDR Annex VIII分类与CER评估",
"可能延展型检周期4周,增加CE技术文档编制预算",
)
if err != nil {
t.Fatalf("ProposeModification failed: %v", err)
}
if proposal.Status != docgov.ProposalPendingAuthorApproval {
t.Fatalf("proposal status should be pending_author_approval, got %s", proposal.Status)
}
// 8. 编制人查看并拍板同意该变更
proposal, err = engine.DecideModificationProposal(ctx, proposal.ProposalID, 201, true, "经评估追加CE符合公司Q3出海战略,同意智能体小队自动增补章节")
if err != nil {
t.Fatalf("DecideModificationProposal failed: %v", err)
}
if proposal.Status != docgov.ProposalApprovedByAuthor {
t.Fatalf("expected status approved_by_author, got %s", proposal.Status)
}
}
func TestDocGov_FullApprovalLifecycleAndRejectRollback(t *testing.T) {
ctx := context.Background()
engine := docgov.NewGovernanceEngine(nil)
docID := "doc_cdo_quote_003"
doc, err := engine.CreateOrGetDocument(
ctx,
docID,
1,
"cdo_quote",
"4K腔镜系统CDO工程拆解与商务报价",
"V1.0_quoting",
"workspaces/cdo_quote_3",
301, "钱工", // 编制人
302, "周主管", // 审核人
303, "吴总监", // 批准人
)
if err != nil {
t.Fatalf("CreateOrGetDocument failed: %v", err)
}
// 1. 编制人完成正文并送审
doc, err = engine.SubmitForReview(ctx, docID, 301, docgov.RoleAuthor, "钱工")
if err != nil {
t.Fatalf("SubmitForReview failed: %v", err)
}
if doc.CurrentState != docgov.StateReviewing || !doc.IsLocked {
t.Fatalf("doc state should be reviewing and locked")
}
// 2. 审核人查阅并添加批注
comm, err := engine.AddComment(ctx, docID, 302, "周主管", docgov.RoleReviewer, "## BOM预算与工时", docgov.CommentSuggestion, "请复核EMC整改冗余工时是否按照2轮测算")
if err != nil {
t.Fatalf("AddComment failed: %v", err)
}
if comm.Status != "open" {
t.Fatalf("expected comment status open")
}
// 3. 审核人驳回测试:原因非空校验
_, err = engine.ReviewReject(ctx, docID, 302, docgov.RoleReviewer, "周主管", " ")
if err != docgov.ErrRejectReasonRequired {
t.Fatalf("expected ErrRejectReasonRequired for empty rejection reason, got: %v", err)
}
// 审核人提供明确理由驳回 -> 退回编制草稿并解锁
doc, err = engine.ReviewReject(ctx, docID, 302, docgov.RoleReviewer, "周主管", "型检EMC整改工时偏低,请补齐风险冗余后重提")
if err != nil {
t.Fatalf("ReviewReject failed: %v", err)
}
if doc.CurrentState != docgov.StateDrafting || doc.IsLocked {
t.Fatalf("doc should rollback to drafting and be unlocked, state=%s locked=%v", doc.CurrentState, doc.IsLocked)
}
if doc.LastRejectedReason != "型检EMC整改工时偏低,请补齐风险冗余后重提" {
t.Fatalf("unexpected last rejected reason: %s", doc.LastRejectedReason)
}
// 4. 编制人修改后重新送审
_, _ = engine.RecordDocumentEdited(ctx, docID, 301, docgov.RoleAuthor, "钱工")
doc, err = engine.SubmitForReview(ctx, docID, 301, docgov.RoleAuthor, "钱工")
if err != nil {
t.Fatalf("resubmit review failed: %v", err)
}
// 5. 审核人通过 -> 进入 approving (批准中)
doc, err = engine.ReviewApprove(ctx, docID, 302, docgov.RoleReviewer, "周主管", "复核无误,工时冗余合理,同意提交批准")
if err != nil {
t.Fatalf("ReviewApprove failed: %v", err)
}
if doc.CurrentState != docgov.StateApproving || !doc.IsLocked {
t.Fatalf("expected doc in approving state and locked")
}
// 6. 批准人查阅批注并最终批准 -> 进入 approved (受控归档)
doc, err = engine.ApproverApprove(ctx, docID, 303, docgov.RoleApprover, "吴总监", "战略预算与报价符合要求,批准受控下发")
if err != nil {
t.Fatalf("ApproverApprove failed: %v", err)
}
if doc.CurrentState != docgov.StateApproved || !doc.IsLocked || doc.ApprovedAt == nil {
t.Fatalf("doc should be approved and locked with approved_at set")
}
// 7. 验证审计日志完整记录
logs, err := engine.ListAuditLogs(ctx, docID)
if err != nil {
t.Fatalf("ListAuditLogs failed: %v", err)
}
if len(logs) < 5 {
t.Fatalf("expected at least 5 audit logs, got %d", len(logs))
}
}
func TestMarkProposalApplied_MissingOrNotApproved(t *testing.T) {
ctx := context.Background()
engine := docgov.NewGovernanceEngine(nil)
if err := engine.MarkProposalApplied(ctx, "prop_missing"); err != docgov.ErrProposalNotFound {
t.Fatalf("missing want ErrProposalNotFound got %v", err)
}
docID := "doc_mark_prop"
_, err := engine.CreateOrGetDocument(ctx, docID, 1, "intake", "摸底", "V0.1", "", 1, "a", 2, "r", 3, "p")
if err != nil {
t.Fatal(err)
}
prop, err := engine.ProposeModification(ctx, docID, "agent", "改", "影响", "风险")
if err != nil {
t.Fatal(err)
}
if err := engine.MarkProposalApplied(ctx, prop.ProposalID); err == nil {
t.Fatal("unapproved proposal must not MarkProposalApplied")
}
// Unapproved failure must leave status unchanged (no false applied in memory).
if prop.Status == docgov.ProposalApplied {
t.Fatal("memory must not be ProposalApplied after failed mark")
}
}
func TestMarkProposalApplied_IdempotentAfterApprove(t *testing.T) {
ctx := context.Background()
engine := docgov.NewGovernanceEngine(nil)
docID := "doc_mark_prop_ok"
_, err := engine.CreateOrGetDocument(ctx, docID, 1, "intake", "摸底", "V0.1", "", 1, "a", 2, "r", 3, "p")
if err != nil {
t.Fatal(err)
}
prop, err := engine.ProposeModification(ctx, docID, "agent", "改", "影响", "风险")
if err != nil {
t.Fatal(err)
}
if _, err := engine.DecideModificationProposal(ctx, prop.ProposalID, 1, true, "同意"); err != nil {
t.Fatal(err)
}
if err := engine.MarkProposalApplied(ctx, prop.ProposalID); err != nil {
t.Fatalf("first apply: %v", err)
}
if err := engine.MarkProposalApplied(ctx, prop.ProposalID); err != nil {
t.Fatalf("second apply must be idempotent: %v", err)
}
}