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>
247 lines
8.8 KiB
Go
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")
|
|
}
|
|
}
|