test: cover duty lock, heal/revoke skip, and instance duty adapter

Unit evidence for AE5a/AE5b filters; optional PG cases when
TEST_DATABASE_URL is set.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
weichen
2026-09-17 09:59:38 +00:00
co-authored by Cursor
parent 750b904439
commit 67b3b5e070
5 changed files with 384 additions and 3 deletions
@@ -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)
}
}
+91 -3
View File
@@ -5,14 +5,28 @@ import (
"testing" "testing"
"chat-agent-system/internal/docduty" "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) { func TestResolveTaskDocumentDuties_UsesCreatorNotHardcodedReviewer(t *testing.T) {
req := TaskRunRequest{ req := TaskRunRequest{
UserID: 170, ProjectID: 6145, UserID: 170, ProjectID: 6145,
AuthorName: "编制人A", AuthorName: "编制人A",
} }
got := resolveTaskDocumentDuties(context.Background(), nil, req) got, _, _ := resolveTaskDocumentDuties(context.Background(), nil, req, nil)
if got.Author.UserID != 170 { if got.Author.UserID != 170 {
t.Fatalf("author want 170 got %+v", got.Author) t.Fatalf("author want 170 got %+v", got.Author)
} }
@@ -28,13 +42,87 @@ func TestResolveTaskDocumentDuties_IgnoresNonMemberRequestReviewer(t *testing.T)
req := TaskRunRequest{ req := TaskRunRequest{
UserID: 170, ReviewerID: 2, ReviewerName: "审核负责人", UserID: 170, ReviewerID: 2, ReviewerName: "审核负责人",
} }
got := resolveTaskDocumentDuties(context.Background(), nil, req) got, _, _ := resolveTaskDocumentDuties(context.Background(), nil, req, nil)
if got.Reviewer.UserID != 0 { if got.Reviewer.UserID != 0 {
t.Fatalf("request reviewer 2 is not a member: %+v", got.Reviewer) 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) { func TestBindingFromDocRoundTrip(t *testing.T) {
// compile-time guard that helper stays aligned with Resolve slots
_ = docduty.Binding{} _ = docduty.Binding{}
} }
@@ -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)
}
}
}
+124
View File
@@ -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)
}
+55
View File
@@ -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)
}
}