diff --git a/internal/app/instance_duty_adapter_test.go b/internal/app/instance_duty_adapter_test.go new file mode 100644 index 00000000..5d7a7a22 --- /dev/null +++ b/internal/app/instance_duty_adapter_test.go @@ -0,0 +1,96 @@ +package app + +import ( + "context" + "sync" + "testing" + + "chat-agent-system/internal/projectinstance" + + "github.com/jackc/pgx/v5" +) + +type stubAuthorOnlyPolicy struct { + allowed bool + revision int64 +} + +func (s *stubAuthorOnlyPolicy) GetAuthorOnlyApprovalPolicy(_ context.Context) (projectinstance.AuthorOnlyPolicySnapshot, error) { + return projectinstance.AuthorOnlyPolicySnapshot{Allowed: s.allowed, Revision: s.revision}, nil +} + +func (s *stubAuthorOnlyPolicy) LockAuthorOnlyApprovalPolicy(ctx context.Context, _ pgx.Tx) (projectinstance.AuthorOnlyPolicySnapshot, error) { + return s.GetAuthorOnlyApprovalPolicy(ctx) +} + +type stubMemberLookup struct { + mu sync.Mutex + ids map[int64]struct{} +} + +func (s *stubMemberLookup) ActiveMemberIDs(_ context.Context, _ int64) (map[int64]struct{}, error) { + s.mu.Lock() + defer s.mu.Unlock() + out := make(map[int64]struct{}, len(s.ids)) + for id := range s.ids { + out[id] = struct{}{} + } + return out, nil +} + +func TestProjectInstanceDutyAdapter_AuthorOnlyLive(t *testing.T) { + ctx := context.Background() + store := projectinstance.NewMemoryStore() + _ = projectinstance.SeedProjectMeta(store, 501, projectinstance.LifecyclePlanning, "", 1, 0) + svc := projectinstance.NewService(store, projectinstance.TestResolver(), nil, nil) + svc.SetAuthorOnlyPolicySource(&stubAuthorOnlyPolicy{allowed: true, revision: 1}) + svc.SetActiveMemberLookup(&stubMemberLookup{ids: map[int64]struct{}{7: {}}}) + + plan := projectinstance.ValidTestPlan() + plan.Stages[0].DocTasks[0].DutyPolicy = projectinstance.DutyPolicyAuthorOnly + plan.Stages[0].DocTasks[0].Duties = projectinstance.DocDuty{AuthorUserID: 7} + + if _, err := svc.SaveWorkflowPlan(ctx, 501, 1, true, projectinstance.SavePlanInput{ + Plan: plan, ExpectedPlanRevision: 0, ExpectedSelectionVersion: 1, + }); err != nil { + t.Fatalf("save: %v", err) + } + preview, err := svc.ActivationPreview(ctx, 501, 1, true) + if err != nil { + t.Fatalf("preview: %v", err) + } + if _, err := svc.Activate(ctx, 501, 1, true, projectinstance.ActivateInput{ + ConfirmationToken: preview.ConfirmationToken, + ExpectedProjectVersion: preview.ExpectedProjectVersion, + ExpectedPlanRevision: preview.ExpectedPlanRevision, + }); err != nil { + t.Fatalf("activate: %v", err) + } + + adapter := &projectInstanceDutyAdapter{inst: svc} + author, reviewer, approver, policy, governed, err := adapter.ResolveGovernedDuty(ctx, 501, "intake") + if err != nil { + t.Fatalf("resolve: %v", err) + } + if !governed || policy != projectinstance.DutyPolicyAuthorOnly { + t.Fatalf("governed=%v policy=%q", governed, policy) + } + if author != 7 || reviewer != 7 || approver != 7 { + t.Fatalf("want 7/7/7 got %d/%d/%d", author, reviewer, approver) + } +} + +func TestProjectInstanceDutyAdapter_NoLiveInstance(t *testing.T) { + ctx := context.Background() + store := projectinstance.NewMemoryStore() + _ = projectinstance.SeedProjectMeta(store, 502, projectinstance.LifecyclePlanning, "", 1, 0) + svc := projectinstance.NewService(store, projectinstance.TestResolver(), nil, nil) + adapter := &projectInstanceDutyAdapter{inst: svc} + _, _, _, policy, governed, err := adapter.ResolveGovernedDuty(ctx, 502, "intake") + if err != nil { + t.Fatalf("resolve: %v", err) + } + if governed || policy != "" { + t.Fatalf("want ungoverned, got governed=%v policy=%q", governed, policy) + } +} diff --git a/internal/bizdev/document_duty_resolve_test.go b/internal/bizdev/document_duty_resolve_test.go index e519fc78..723f3325 100644 --- a/internal/bizdev/document_duty_resolve_test.go +++ b/internal/bizdev/document_duty_resolve_test.go @@ -5,14 +5,28 @@ import ( "testing" "chat-agent-system/internal/docduty" + "chat-agent-system/internal/docgov" ) +type fakeInstanceDuty struct { + author, reviewer, approver int64 + policy string + governed bool + err error + called bool +} + +func (f *fakeInstanceDuty) ResolveGovernedDuty(_ context.Context, _ int64, _ string) (int64, int64, int64, string, bool, error) { + f.called = true + return f.author, f.reviewer, f.approver, f.policy, f.governed, f.err +} + func TestResolveTaskDocumentDuties_UsesCreatorNotHardcodedReviewer(t *testing.T) { req := TaskRunRequest{ UserID: 170, ProjectID: 6145, AuthorName: "编制人A", } - got := resolveTaskDocumentDuties(context.Background(), nil, req) + got, _, _ := resolveTaskDocumentDuties(context.Background(), nil, req, nil) if got.Author.UserID != 170 { t.Fatalf("author want 170 got %+v", got.Author) } @@ -28,13 +42,87 @@ func TestResolveTaskDocumentDuties_IgnoresNonMemberRequestReviewer(t *testing.T) req := TaskRunRequest{ UserID: 170, ReviewerID: 2, ReviewerName: "审核负责人", } - got := resolveTaskDocumentDuties(context.Background(), nil, req) + got, _, _ := resolveTaskDocumentDuties(context.Background(), nil, req, nil) if got.Reviewer.UserID != 0 { t.Fatalf("request reviewer 2 is not a member: %+v", got.Reviewer) } } +func TestResolveTaskDocumentDuties_GovernedUsesInstance(t *testing.T) { + src := &fakeInstanceDuty{ + author: 9, reviewer: 9, approver: 9, + policy: "author_only", governed: true, + } + req := TaskRunRequest{ + UserID: 170, ProjectID: 42, TaskKey: "intake", + AuthorName: "编制人A", ReviewerID: 2, + } + got, policy, governed := resolveTaskDocumentDuties(context.Background(), nil, req, src) + if !governed || policy != "author_only" { + t.Fatalf("governed=%v policy=%q", governed, policy) + } + if got.Author.UserID != 9 || got.Reviewer.UserID != 9 || got.Approver.UserID != 9 { + t.Fatalf("want A/A/A=9, got %+v", got) + } + if !src.called { + t.Fatal("expected ResolveGovernedDuty called") + } +} + +func TestResolveTaskDocumentDuties_UngovernedFallsBack(t *testing.T) { + src := &fakeInstanceDuty{governed: false} + req := TaskRunRequest{ + UserID: 170, ProjectID: 42, TaskKey: "intake", + AuthorName: "编制人A", + } + got, policy, governed := resolveTaskDocumentDuties(context.Background(), nil, req, src) + if governed || policy != "" { + t.Fatalf("want ungoverned, got governed=%v policy=%q", governed, policy) + } + if got.Author.UserID != 170 { + t.Fatalf("author want 170 got %+v", got.Author) + } + if got.Reviewer.UserID != 0 { + t.Fatalf("no members → reviewer empty, got %+v", got.Reviewer) + } +} + +func TestGovernedSkipsMemoryHealOverwrite(t *testing.T) { + doc := &docgov.DocumentRecord{ + DocID: "doc_x", AuthorID: 9, AuthorName: "A", + ReviewerID: 9, ReviewerName: "A", + ApproverID: 9, ApproverName: "A", + DutyLocked: true, DutyPolicy: "author_only", + } + assigned := docduty.Binding{ + Author: docduty.Holder{UserID: 1, Name: "作者"}, + Reviewer: docduty.Holder{UserID: 2, Name: "审核"}, + Approver: docduty.Holder{UserID: 3, Name: "批准"}, + } + memberIDs := map[int64]struct{}{1: {}, 2: {}, 3: {}, 9: {}} + + governed := true + if !governed { + next, _ := docduty.HealBinding(bindingFromDoc(doc), assigned, memberIDs) + writeBindingToDoc(doc, next) + } + if doc.ReviewerID != 9 || doc.ApproverID != 9 { + t.Fatalf("governed must keep A/A/A, got R=%d Ap=%d", doc.ReviewerID, doc.ApproverID) + } + + doc2 := &docgov.DocumentRecord{ + DocID: "doc_y", AuthorID: 9, ReviewerID: 9, ApproverID: 9, + } + next, changed := docduty.HealBinding(bindingFromDoc(doc2), assigned, memberIDs) + if !changed { + t.Fatal("expected heal change for ungoverned path") + } + writeBindingToDoc(doc2, next) + if doc2.ReviewerID != 2 || doc2.ApproverID != 3 { + t.Fatalf("ungoverned heal want 2/3, got R=%d Ap=%d", doc2.ReviewerID, doc2.ApproverID) + } +} + func TestBindingFromDocRoundTrip(t *testing.T) { - // compile-time guard that helper stays aligned with Resolve slots _ = docduty.Binding{} } diff --git a/internal/docduty/duty_locked_filter_test.go b/internal/docduty/duty_locked_filter_test.go new file mode 100644 index 00000000..a7d17773 --- /dev/null +++ b/internal/docduty/duty_locked_filter_test.go @@ -0,0 +1,18 @@ +package docduty + +import ( + "strings" + "testing" +) + +func TestClearDutySlotSQL_SkipsLockedRows(t *testing.T) { + for _, duty := range []string{DutyReviewer, DutyApprover} { + sql, ok := clearDutySlotSQL(duty) + if !ok { + t.Fatalf("%s: expected sql", duty) + } + if !strings.Contains(sql, "NOT duty_locked") { + t.Fatalf("%s sql missing duty_locked filter:\n%s", duty, sql) + } + } +} diff --git a/internal/docduty/duty_locked_pg_test.go b/internal/docduty/duty_locked_pg_test.go new file mode 100644 index 00000000..59a83306 --- /dev/null +++ b/internal/docduty/duty_locked_pg_test.go @@ -0,0 +1,124 @@ +package docduty + +import ( + "context" + "fmt" + "testing" + "time" +) + +func TestHealProjectDocuments_SkipsDutyLocked_PG(t *testing.T) { + ctx, _, pool := openDutyTestPool(t) + suffix := time.Now().UnixNano() + authorID := insertDutyUser(t, ctx, pool, suffix, "lk_author") + reviewerID := insertDutyUser(t, ctx, pool, suffix, "lk_reviewer") + approverID := insertDutyUser(t, ctx, pool, suffix, "lk_approver") + + var projectID int64 + if err := pool.QueryRow(ctx, ` + INSERT INTO projects (code, name, status, creator_user_id) + VALUES ($1, $2, 'active', $3) + RETURNING id + `, fmt.Sprintf("duty-lock-%d", suffix), "locked heal", authorID).Scan(&projectID); err != nil { + t.Fatalf("insert project: %v", err) + } + t.Cleanup(func() { + _, _ = pool.Exec(context.Background(), `DELETE FROM document_records WHERE project_id = $1`, projectID) + _, _ = pool.Exec(context.Background(), `DELETE FROM project_members WHERE project_id = $1`, projectID) + _, _ = pool.Exec(context.Background(), `DELETE FROM projects WHERE id = $1`, projectID) + _, _ = pool.Exec(context.Background(), `DELETE FROM users WHERE id IN ($1,$2,$3)`, authorID, reviewerID, approverID) + }) + + if _, err := pool.Exec(ctx, ` + INSERT INTO project_members (project_id, user_id, project_role, document_duty, status, joined_at, updated_at) + VALUES ($1, $2, 'owner', 'author', 'active', now(), now()), + ($1, $3, 'member', 'reviewer', 'active', now(), now()), + ($1, $4, 'member', 'approver', 'active', now(), now()) + `, projectID, authorID, reviewerID, approverID); err != nil { + t.Fatalf("insert members: %v", err) + } + + lockedDoc := fmt.Sprintf("doc_proj%d_intake", projectID) + openDoc := fmt.Sprintf("doc_proj%d_research", projectID) + // 锁定文档:A/A/A,与项目默认分离职责不同 + insertDutyDoc(t, ctx, pool, projectID, lockedDoc, "intake", authorID, authorID, authorID) + if _, err := pool.Exec(ctx, ` + UPDATE document_records + SET duty_locked = true, duty_policy = 'author_only' + WHERE doc_id = $1 + `, lockedDoc); err != nil { + t.Fatalf("lock doc: %v", err) + } + // 未锁定文档:错误槽位,应被 heal 纠正 + insertDutyDoc(t, ctx, pool, projectID, openDoc, "research", authorID, 0, 0) + + _, n, err := HealProjectDocuments(ctx, pool, projectID) + if err != nil { + t.Fatalf("heal: %v", err) + } + if n == 0 { + t.Fatal("expected unlocked doc to be healed") + } + + assertDocAuthorID(t, ctx, pool, lockedDoc, authorID) + assertDocReviewer(t, ctx, pool, lockedDoc, authorID) + assertDocApprover(t, ctx, pool, lockedDoc, authorID) + assertDocReviewer(t, ctx, pool, openDoc, reviewerID) + assertDocApprover(t, ctx, pool, openDoc, approverID) +} + +func TestApplySlotRevocations_SkipsDutyLocked_PG(t *testing.T) { + ctx, _, pool := openDutyTestPool(t) + suffix := time.Now().UnixNano() + authorID := insertDutyUser(t, ctx, pool, suffix, "rv_author") + reviewerID := insertDutyUser(t, ctx, pool, suffix, "rv_reviewer") + + var projectID int64 + if err := pool.QueryRow(ctx, ` + INSERT INTO projects (code, name, status, creator_user_id) + VALUES ($1, $2, 'active', $3) + RETURNING id + `, fmt.Sprintf("duty-revlock-%d", suffix), "locked revoke", authorID).Scan(&projectID); err != nil { + t.Fatalf("insert project: %v", err) + } + t.Cleanup(func() { + _, _ = pool.Exec(context.Background(), `DELETE FROM document_records WHERE project_id = $1`, projectID) + _, _ = pool.Exec(context.Background(), `DELETE FROM project_members WHERE project_id = $1`, projectID) + _, _ = pool.Exec(context.Background(), `DELETE FROM projects WHERE id = $1`, projectID) + _, _ = pool.Exec(context.Background(), `DELETE FROM users WHERE id IN ($1,$2)`, authorID, reviewerID) + }) + if _, err := pool.Exec(ctx, ` + INSERT INTO project_members (project_id, user_id, project_role, document_duty, status, joined_at, updated_at) + VALUES ($1, $2, 'owner', 'author', 'active', now(), now()), + ($1, $3, 'member', 'reviewer', 'active', now(), now()) + `, projectID, authorID, reviewerID); err != nil { + t.Fatalf("insert members: %v", err) + } + + lockedDoc := fmt.Sprintf("doc_proj%d_intake", projectID) + openDoc := fmt.Sprintf("doc_proj%d_research", projectID) + insertDutyDoc(t, ctx, pool, projectID, lockedDoc, "intake", authorID, reviewerID, 0) + insertDutyDoc(t, ctx, pool, projectID, openDoc, "research", authorID, reviewerID, 0) + if _, err := pool.Exec(ctx, ` + UPDATE document_records SET duty_locked = true, duty_policy = 'author_only' WHERE doc_id = $1 + `, lockedDoc); err != nil { + t.Fatalf("lock: %v", err) + } + + tx, err := pool.Begin(ctx) + if err != nil { + t.Fatal(err) + } + if err := ApplySlotRevocations(ctx, tx, projectID, PlanSlotRevocations(MemberDutyChange{ + UserID: reviewerID, PreviousDuty: DutyReviewer, NextDuty: DutyNone, + })); err != nil { + _ = tx.Rollback(ctx) + t.Fatal(err) + } + if err := tx.Commit(ctx); err != nil { + t.Fatal(err) + } + + assertDocReviewer(t, ctx, pool, lockedDoc, reviewerID) + assertDocReviewer(t, ctx, pool, openDoc, 0) +} diff --git a/internal/docgov/duty_lock_test.go b/internal/docgov/duty_lock_test.go new file mode 100644 index 00000000..16373e6a --- /dev/null +++ b/internal/docgov/duty_lock_test.go @@ -0,0 +1,55 @@ +package docgov + +import ( + "context" + "testing" +) + +func TestLockDocumentDuty_Memory(t *testing.T) { + ctx := context.Background() + engine := NewGovernanceEngine(nil) + doc, err := engine.CreateOrGetDocument( + ctx, "doc_lock_mem", 9, "intake", "锁定测试", + "V0.5", "ws/lock", + 1, "A", 1, "A", 1, "A", + ) + if err != nil { + t.Fatalf("create: %v", err) + } + if doc.DutyLocked || doc.DutyPolicy != "" { + t.Fatalf("defaults: locked=%v policy=%q", doc.DutyLocked, doc.DutyPolicy) + } + if err := engine.LockDocumentDuty(ctx, doc.DocID, "author_only"); err != nil { + t.Fatalf("lock: %v", err) + } + got, err := engine.GetDocument(ctx, doc.DocID) + if err != nil { + t.Fatalf("get: %v", err) + } + if !got.DutyLocked || got.DutyPolicy != "author_only" { + t.Fatalf("after lock: locked=%v policy=%q", got.DutyLocked, got.DutyPolicy) + } +} + +func TestListWorkflowDocuments_IncludesDutyPolicy_Memory(t *testing.T) { + ctx := context.Background() + engine := NewGovernanceEngine(nil) + doc, err := engine.CreateOrGetDocument( + ctx, "doc_proj9_intake", 9, "intake", "投影测试", + "V0.5", "ws/proj", + 7, "编制", 7, "编制", 7, "编制", + ) + if err != nil { + t.Fatalf("create: %v", err) + } + if err := engine.LockDocumentDuty(ctx, doc.DocID, "author_only"); err != nil { + t.Fatalf("lock: %v", err) + } + list, err := engine.ListWorkflowDocumentsByProject(ctx, 9) + if err != nil { + t.Fatalf("list: %v", err) + } + if len(list) != 1 || list[0].DutyPolicy != "author_only" { + t.Fatalf("want duty_policy=author_only, got %+v", list) + } +}