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:
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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{}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user