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>
299 lines
10 KiB
Go
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)
|
|
}
|
|
}
|