Files
weichenandCursor 9a19bcc12c fix(docduty): revoke inactive slots and explicit duty changes
Empty assigned heal now clears only exited or non-member reviewer/approver
holders and never blanks author. Explicit reconcile uses the pre-update
duty plus a project advisory lock so viewer demotion and transfers are
not left to generic heal.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-13 22:29:53 +00:00

247 lines
8.8 KiB
Go

package docduty
import (
"sync"
"testing"
)
func sampleMembers() ([]Member, Holder) {
creator := Holder{UserID: 170, Name: "编制人A"}
members := []Member{
{UserID: 170, Name: "编制人A", Status: StatusActive, ProjectRole: "owner", DocumentDuty: DutyAuthor},
{UserID: 171, Name: "审核人B", Status: StatusActive, ProjectRole: "member", DocumentDuty: DutyReviewer},
{UserID: 3, Name: "李主管", Status: StatusActive, ProjectRole: "member", DocumentDuty: DutyNone},
{UserID: 4, Name: "王总监", Status: StatusActive, ProjectRole: "member", DocumentDuty: DutyApprover},
}
return members, creator
}
func TestResolve_AAuthorBReviewerCNone(t *testing.T) {
members, creator := sampleMembers()
got := Resolve(members, creator, RequestDuties{
AuthorID: 2, ReviewerID: 2, ApproverID: 3,
AuthorName: "编制工程师", ReviewerName: "审核负责人", ApproverName: "批准负责人",
})
if got.Author.UserID != 170 || got.Author.Name != "编制人A" {
t.Fatalf("author: %+v", got.Author)
}
if got.Reviewer.UserID != 171 || got.Reviewer.Name != "审核人B" {
t.Fatalf("reviewer: %+v", got.Reviewer)
}
if got.Approver.UserID != 4 || got.Approver.Name != "王总监" {
t.Fatalf("approver: %+v", got.Approver)
}
if got.Author.UserID == got.Reviewer.UserID || got.Reviewer.UserID == got.Approver.UserID {
t.Fatalf("duties swapped: %+v", got)
}
if got.Reviewer.UserID == 99 || got.Author.UserID == 99 {
t.Fatal("non-member C must not receive a duty")
}
}
func TestResolve_NeverDefaultsToHardcodedTwoOrThree(t *testing.T) {
creator := Holder{UserID: 170, Name: "编制人A"}
members := []Member{
{UserID: 170, Name: "编制人A", Status: StatusActive, ProjectRole: "owner", DocumentDuty: DutyAuthor},
}
got := Resolve(members, creator, RequestDuties{})
if got.Reviewer.UserID != 0 || got.Approver.UserID != 0 {
t.Fatalf("expected empty reviewer/approver, got %+v", got)
}
if got.Reviewer.UserID == 2 || got.Approver.UserID == 3 {
t.Fatal("hardcoded 2/3 must not be invented")
}
}
func TestResolve_RequestIDsOnlyIfMembers(t *testing.T) {
creator := Holder{UserID: 170, Name: "编制人A"}
members := []Member{
{UserID: 170, Name: "编制人A", Status: StatusActive, ProjectRole: "owner"},
{UserID: 171, Name: "审核人B", Status: StatusActive, ProjectRole: "member"},
}
got := Resolve(members, creator, RequestDuties{ReviewerID: 2, ReviewerName: "审核负责人"})
if got.Reviewer.UserID != 0 {
t.Fatalf("non-member request reviewer 2 must be ignored, got %+v", got.Reviewer)
}
got = Resolve(members, creator, RequestDuties{ReviewerID: 171, ReviewerName: "审核人B"})
if got.Reviewer.UserID != 171 {
t.Fatalf("member request reviewer must apply: %+v", got.Reviewer)
}
}
func TestHealBinding_PlaceholderNonMemberBackfillIdempotent(t *testing.T) {
members, creator := sampleMembers()
assigned := Resolve(members, creator, RequestDuties{})
memberIDs := ActiveMemberIDs(members, creator)
current := Binding{
Author: Holder{UserID: 170, Name: "编制工程师"},
Reviewer: Holder{UserID: 2, Name: "审核负责人"},
Approver: Holder{UserID: 3, Name: "批准负责人"},
}
first, changed := HealBinding(current, assigned, memberIDs)
if !changed {
t.Fatal("placeholder reviewer 2 and stale/wrong assigned slots must be healed")
}
if first.Reviewer.UserID != 171 {
t.Fatalf("reviewer want 171 got %+v", first.Reviewer)
}
if first.Author.UserID != 170 || first.Author.Name != "编制人A" {
t.Fatalf("author must stay A with assigned name: %+v", first.Author)
}
if first.Approver.UserID != 4 || first.Approver.Name != "王总监" {
t.Fatalf("assigned approver 4 must replace member 3: %+v", first.Approver)
}
second, changedAgain := HealBinding(first, assigned, memberIDs)
if changedAgain {
t.Fatalf("heal must be idempotent, got %+v", second)
}
if second != first {
t.Fatalf("second pass changed binding: %+v vs %+v", first, second)
}
}
func TestHealBinding_WrongActiveMemberIDAlignsToAssigned(t *testing.T) {
members, creator := sampleMembers()
assigned := Resolve(members, creator, RequestDuties{})
memberIDs := ActiveMemberIDs(members, creator)
current := Binding{
Author: Holder{UserID: 170, Name: "编制人A"},
Reviewer: Holder{UserID: 3, Name: "李主管"},
Approver: Holder{UserID: 3, Name: "李主管"},
}
got, changed := HealBinding(current, assigned, memberIDs)
if !changed {
t.Fatal("wrong active-member IDs must align to assigned duties")
}
if got.Reviewer.UserID != 171 || got.Approver.UserID != 4 {
t.Fatalf("want reviewer=171 approver=4, got %+v", got)
}
if got.Author.UserID != 170 {
t.Fatalf("author must stay assigned A: %+v", got.Author)
}
}
func TestHealBinding_SameIDStaleNameAligns(t *testing.T) {
members, creator := sampleMembers()
assigned := Resolve(members, creator, RequestDuties{})
memberIDs := ActiveMemberIDs(members, creator)
current := Binding{
Author: Holder{UserID: 170, Name: "编制工程师"},
Reviewer: Holder{UserID: 171, Name: "审核负责人"},
Approver: Holder{UserID: 4, Name: "批准负责人"},
}
got, changed := HealBinding(current, assigned, memberIDs)
if !changed {
t.Fatal("same IDs with stale names must be healed")
}
if got.Author.Name != "编制人A" || got.Reviewer.Name != "审核人B" || got.Approver.Name != "王总监" {
t.Fatalf("stale names not aligned: %+v", got)
}
if got.Author.UserID != 170 || got.Reviewer.UserID != 171 || got.Approver.UserID != 4 {
t.Fatalf("IDs must stay assigned: %+v", got)
}
}
func TestHealBinding_EmptyAssignedKeepsActiveMember(t *testing.T) {
memberIDs := map[int64]struct{}{170: {}, 3: {}}
current := Binding{
Author: Holder{UserID: 170, Name: "编制人A"},
Reviewer: Holder{UserID: 3, Name: "李主管"},
Approver: Holder{UserID: 3, Name: "李主管"},
}
got, changed := HealBinding(current, Binding{}, memberIDs)
if changed {
t.Fatalf("empty assigned must not invent or clear active members: %+v", got)
}
if got != current {
t.Fatalf("empty assigned must keep current: %+v", got)
}
}
func TestHealBinding_EmptyAssignedClearsInactiveHolder(t *testing.T) {
memberIDs := map[int64]struct{}{170: {}}
current := Binding{
Author: Holder{UserID: 170, Name: "编制人A"},
Reviewer: Holder{UserID: 171, Name: "已退出审核人"},
Approver: Holder{UserID: 2, Name: "审核负责人"},
}
got, changed := HealBinding(current, Binding{}, memberIDs)
if !changed {
t.Fatal("inactive reviewer/approver must be cleared when assigned is empty")
}
if got.Reviewer.UserID != 0 || got.Approver.UserID != 0 {
t.Fatalf("want cleared review/approve slots, got %+v", got)
}
if got.Author.UserID != 170 {
t.Fatalf("author must stay: %+v", got.Author)
}
}
func TestHealBinding_EmptyAssignedDoesNotClearAuthor(t *testing.T) {
memberIDs := map[int64]struct{}{}
current := Binding{Author: Holder{UserID: 170, Name: "编制人A"}}
got, changed := HealBinding(current, Binding{}, memberIDs)
if changed || got.Author.UserID != 170 {
t.Fatalf("author must not be cleared when assigned is empty: changed=%v got=%+v", changed, got)
}
}
func TestHealBinding_ZeroDutyBackfill(t *testing.T) {
members, creator := sampleMembers()
assigned := Resolve(members, creator, RequestDuties{})
memberIDs := ActiveMemberIDs(members, creator)
got, changed := HealBinding(Binding{}, assigned, memberIDs)
if !changed || got.Reviewer.UserID != 171 || got.Author.UserID != 170 {
t.Fatalf("zero duties: changed=%v got=%+v", changed, got)
}
}
func TestResolveAndHeal_SecondDocumentInherits(t *testing.T) {
members, creator := sampleMembers()
first := Resolve(members, creator, RequestDuties{})
second := Resolve(members, creator, RequestDuties{})
if first.Reviewer.UserID != 171 || second.Reviewer.UserID != 171 {
t.Fatalf("second document must inherit reviewer B: first=%+v second=%+v", first, second)
}
if first.Author.UserID != second.Author.UserID || first.Approver.UserID != second.Approver.UserID {
t.Fatalf("second document duties diverged: %+v vs %+v", first, second)
}
}
func TestResolve_ConcurrentIdempotent(t *testing.T) {
members, creator := sampleMembers()
var wg sync.WaitGroup
errCh := make(chan string, 32)
for i := 0; i < 32; i++ {
wg.Add(1)
go func() {
defer wg.Done()
got := Resolve(members, creator, RequestDuties{ReviewerID: 2})
if got.Reviewer.UserID != 171 || got.Author.UserID != 170 {
errCh <- "resolve mismatch"
}
memberIDs := ActiveMemberIDs(members, creator)
healed, _ := HealBinding(Binding{Reviewer: Holder{UserID: 2, Name: "审核负责人"}}, got, memberIDs)
if healed.Reviewer.UserID != 171 {
errCh <- "heal mismatch"
}
}()
}
wg.Wait()
close(errCh)
for msg := range errCh {
t.Fatal(msg)
}
}
func TestNormalizeDocumentDuty(t *testing.T) {
if _, err := NormalizeDocumentDuty("reviewer"); err != nil {
t.Fatal(err)
}
if _, err := NormalizeDocumentDuty("viewer"); err == nil {
t.Fatal("viewer is not a document duty")
}
}