Move document_records duty-slot writes into docgov, project ledger heads through docgov/freedoc ports, and workspace deliverable I/O through DeliverableStore so arch-check baseline drops to sql_write:1 path:13.
579 lines
21 KiB
Go
579 lines
21 KiB
Go
package docduty_test
|
|
|
|
import (
|
|
"chat-agent-system/internal/docduty"
|
|
"chat-agent-system/internal/docgov"
|
|
"context"
|
|
"fmt"
|
|
"runtime"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/jackc/pgx/v5/pgxpool"
|
|
|
|
"chat-agent-system/internal/db"
|
|
)
|
|
|
|
func openDutyTestPool(t *testing.T) (context.Context, context.CancelFunc, *pgxpool.Pool) {
|
|
t.Helper()
|
|
if testing.Short() {
|
|
t.Skip("skipping PostgreSQL duty sync integration in -short")
|
|
}
|
|
dsn := db.SkipIfNoTestDatabaseURL(t)
|
|
ctx, cancel := context.WithTimeout(context.Background(), 45*time.Second)
|
|
pool, err := pgxpool.New(ctx, dsn)
|
|
if err != nil {
|
|
cancel()
|
|
t.Fatalf("postgres unavailable: %v", err)
|
|
}
|
|
if err := pool.Ping(ctx); err != nil {
|
|
pool.Close()
|
|
cancel()
|
|
t.Fatalf("postgres ping failed: %v", err)
|
|
}
|
|
if err := db.RunMigrationsOnceForTest(ctx, pool, dsn); err != nil {
|
|
pool.Close()
|
|
cancel()
|
|
t.Fatalf("migration failed: %v", err)
|
|
}
|
|
t.Cleanup(func() {
|
|
pool.Close()
|
|
cancel()
|
|
})
|
|
return ctx, cancel, pool
|
|
}
|
|
|
|
func TestProjectReviewerDutySync_PG(t *testing.T) {
|
|
ctx, _, pool := openDutyTestPool(t)
|
|
|
|
suffix := time.Now().UnixNano()
|
|
authorID := insertDutyUser(t, ctx, pool, suffix, "author")
|
|
reviewerID := insertDutyUser(t, ctx, pool, suffix, "reviewer")
|
|
outsiderID := insertDutyUser(t, ctx, pool, suffix, "none")
|
|
placeholderID := insertDutyUser(t, ctx, pool, suffix, "placeholder")
|
|
wrongApproverID := insertDutyUser(t, ctx, pool, suffix, "wrong_appr")
|
|
assignedApproverID := insertDutyUser(t, ctx, pool, suffix, "assigned_appr")
|
|
|
|
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-sync-%d", suffix), "reviewer duty sync", 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,$4,$5,$6)`, authorID, reviewerID, outsiderID, placeholderID, wrongApproverID, assignedApproverID)
|
|
})
|
|
|
|
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', '', 'active', now(), now()),
|
|
($1, $5, 'member', 'approver', 'active', now(), now())
|
|
`, projectID, authorID, reviewerID, wrongApproverID, assignedApproverID); err != nil {
|
|
t.Fatalf("insert members: %v", err)
|
|
}
|
|
|
|
bound, err := docduty.ResolveProject(ctx, pool, projectID, docduty.RequestDuties{ReviewerID: 2, ReviewerName: "审核负责人"})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if bound.Author.UserID != authorID || bound.Reviewer.UserID != reviewerID {
|
|
t.Fatalf("resolve A/B: %+v", bound)
|
|
}
|
|
if bound.Reviewer.UserID == outsiderID || bound.Author.UserID == outsiderID {
|
|
t.Fatal("C none must not receive duty")
|
|
}
|
|
|
|
intakeID := fmt.Sprintf("doc_proj%d_intake", projectID)
|
|
secondID := fmt.Sprintf("doc_proj%d_prelim_design", projectID)
|
|
insertDutyDoc(t, ctx, pool, projectID, intakeID, "intake", authorID, placeholderID, wrongApproverID)
|
|
insertDutyDoc(t, ctx, pool, projectID, secondID, "prelim_design", authorID, placeholderID, wrongApproverID)
|
|
|
|
assigned, n, err := docgov.HealProjectDocuments(ctx, pool, projectID)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if n == 0 {
|
|
t.Fatal("expected non-member placeholder reviewer to be backfilled")
|
|
}
|
|
if assigned.Reviewer.UserID != reviewerID {
|
|
t.Fatalf("assigned reviewer=%d", assigned.Reviewer.UserID)
|
|
}
|
|
assertDocReviewer(t, ctx, pool, intakeID, reviewerID)
|
|
assertDocReviewer(t, ctx, pool, secondID, reviewerID)
|
|
assertDocApprover(t, ctx, pool, intakeID, assignedApproverID)
|
|
assertDocApprover(t, ctx, pool, secondID, assignedApproverID)
|
|
assertDocAuthorName(t, ctx, pool, intakeID, "用户author")
|
|
|
|
_, n2, err := docgov.HealProjectDocuments(ctx, pool, projectID)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if n2 != 0 {
|
|
t.Fatalf("heal must be idempotent, second pass updated %d rows", n2)
|
|
}
|
|
assertDocReviewer(t, ctx, pool, secondID, reviewerID)
|
|
assertDocApprover(t, ctx, pool, secondID, assignedApproverID)
|
|
}
|
|
|
|
func TestHealBinding_EmptyAssignedKeepsMember_PG(t *testing.T) {
|
|
ctx, _, pool := openDutyTestPool(t)
|
|
suffix := time.Now().UnixNano()
|
|
authorID := insertDutyUser(t, ctx, pool, suffix, "keep_author")
|
|
memberID := insertDutyUser(t, ctx, pool, suffix, "keep_member")
|
|
|
|
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-keep-%d", suffix), "empty assigned keep", 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, memberID)
|
|
})
|
|
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', '', 'active', now(), now())
|
|
`, projectID, authorID, memberID); err != nil {
|
|
t.Fatalf("insert members: %v", err)
|
|
}
|
|
docID := fmt.Sprintf("doc_proj%d_intake", projectID)
|
|
insertDutyDoc(t, ctx, pool, projectID, docID, "intake", authorID, memberID, memberID)
|
|
if _, err := pool.Exec(ctx, `UPDATE document_records SET author_name = $2 WHERE doc_id = $1`, docID, "用户keep_author"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
_, n, err := docgov.HealProjectDocuments(ctx, pool, projectID)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if n != 0 {
|
|
t.Fatalf("empty assigned reviewer/approver must keep existing member, updated %d", n)
|
|
}
|
|
assertDocReviewer(t, ctx, pool, docID, memberID)
|
|
assertDocApprover(t, ctx, pool, docID, memberID)
|
|
}
|
|
|
|
func TestHealBinding_EmptyAssignedClearsExited_PG(t *testing.T) {
|
|
ctx, _, pool := openDutyTestPool(t)
|
|
suffix := time.Now().UnixNano()
|
|
authorID := insertDutyUser(t, ctx, pool, suffix, "ex_author")
|
|
exitedID := insertDutyUser(t, ctx, pool, suffix, "ex_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-exited-%d", suffix), "clear exited", 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, exitedID)
|
|
})
|
|
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', '', 'exited', now(), now())
|
|
`, projectID, authorID, exitedID); err != nil {
|
|
t.Fatalf("insert members: %v", err)
|
|
}
|
|
docID := fmt.Sprintf("doc_proj%d_intake", projectID)
|
|
insertDutyDoc(t, ctx, pool, projectID, docID, "intake", authorID, exitedID, 0)
|
|
if _, err := pool.Exec(ctx, `UPDATE document_records SET author_name = $2 WHERE doc_id = $1`, docID, "用户ex_author"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
_, n, err := docgov.HealProjectDocuments(ctx, pool, projectID)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if n == 0 {
|
|
t.Fatal("exited reviewer must be cleared when assigned is empty")
|
|
}
|
|
assertDocReviewer(t, ctx, pool, docID, 0)
|
|
assertDocAuthorName(t, ctx, pool, docID, "用户ex_author")
|
|
}
|
|
|
|
func TestApplySlotRevocations_SameTxnCommitAndRollback_PG(t *testing.T) {
|
|
ctx, _, pool := openDutyTestPool(t)
|
|
suffix := time.Now().UnixNano()
|
|
authorID := insertDutyUser(t, ctx, pool, suffix, "txn_author")
|
|
reviewerA := insertDutyUser(t, ctx, pool, suffix, "txn_ra")
|
|
|
|
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-txn-%d", suffix), "atomic revoke txn", 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, reviewerA)
|
|
})
|
|
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, reviewerA); err != nil {
|
|
t.Fatalf("insert members: %v", err)
|
|
}
|
|
docID := fmt.Sprintf("doc_proj%d_intake", projectID)
|
|
insertDutyDoc(t, ctx, pool, projectID, docID, "intake", authorID, reviewerA, 0)
|
|
|
|
tx, err := pool.Begin(ctx)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := tx.Exec(ctx, `
|
|
UPDATE project_members SET document_duty = '' WHERE project_id = $1 AND user_id = $2
|
|
`, projectID, reviewerA); err != nil {
|
|
_ = tx.Rollback(ctx)
|
|
t.Fatal(err)
|
|
}
|
|
if err := docgov.ClearDutySlots(ctx, tx, projectID, docduty.PlanSlotRevocations(docduty.MemberDutyChange{
|
|
UserID: reviewerA, PreviousDuty: docduty.DutyReviewer, NextDuty: docduty.DutyNone,
|
|
})); err != nil {
|
|
_ = tx.Rollback(ctx)
|
|
t.Fatal(err)
|
|
}
|
|
if err := tx.Rollback(ctx); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
assertDocReviewer(t, ctx, pool, docID, reviewerA)
|
|
assertMemberDuty(t, ctx, pool, projectID, reviewerA, docduty.DutyReviewer)
|
|
assertDocAuthorID(t, ctx, pool, docID, authorID)
|
|
|
|
tx, err = pool.Begin(ctx)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := docduty.AcquireProjectDutyLock(ctx, tx, projectID); err != nil {
|
|
_ = tx.Rollback(ctx)
|
|
t.Fatal(err)
|
|
}
|
|
prev, err := docduty.LoadMemberDutyTx(ctx, tx, projectID, reviewerA)
|
|
if err != nil {
|
|
_ = tx.Rollback(ctx)
|
|
t.Fatal(err)
|
|
}
|
|
if prev != docduty.DutyReviewer {
|
|
_ = tx.Rollback(ctx)
|
|
t.Fatalf("locked previous duty=%q", prev)
|
|
}
|
|
if _, err := tx.Exec(ctx, `
|
|
UPDATE project_members SET document_duty = '' WHERE project_id = $1 AND user_id = $2
|
|
`, projectID, reviewerA); err != nil {
|
|
_ = tx.Rollback(ctx)
|
|
t.Fatal(err)
|
|
}
|
|
if err := docgov.ClearDutySlots(ctx, tx, projectID, docduty.PlanSlotRevocations(docduty.MemberDutyChange{
|
|
UserID: reviewerA, PreviousDuty: prev, NextDuty: docduty.DutyNone,
|
|
})); err != nil {
|
|
_ = tx.Rollback(ctx)
|
|
t.Fatal(err)
|
|
}
|
|
if err := tx.Commit(ctx); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
assertDocReviewer(t, ctx, pool, docID, 0)
|
|
assertMemberDuty(t, ctx, pool, projectID, reviewerA, docduty.DutyNone)
|
|
assertDocAuthorID(t, ctx, pool, docID, authorID)
|
|
}
|
|
|
|
func TestOwnerAuthorBackfill_MultiOwnerDeterministic_PG(t *testing.T) {
|
|
ctx, _, pool := openDutyTestPool(t)
|
|
suffix := time.Now().UnixNano()
|
|
lowOwner := insertDutyUser(t, ctx, pool, suffix, "low_owner")
|
|
creatorOwner := insertDutyUser(t, ctx, pool, suffix, "creator_owner")
|
|
if lowOwner >= creatorOwner {
|
|
t.Fatalf("expected inserted low owner id < creator, got %d >= %d", lowOwner, creatorOwner)
|
|
}
|
|
|
|
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-owners-%d", suffix), "multi owner backfill", creatorOwner).Scan(&projectID); err != nil {
|
|
t.Fatalf("insert project: %v", err)
|
|
}
|
|
t.Cleanup(func() {
|
|
_, _ = 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)`, lowOwner, creatorOwner)
|
|
})
|
|
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', '', 'active', now(), now()),
|
|
($1, $3, 'owner', '', 'active', now(), now())
|
|
`, projectID, lowOwner, creatorOwner); err != nil {
|
|
t.Fatalf("insert owners: %v", err)
|
|
}
|
|
|
|
if _, err := pool.Exec(ctx, docduty.OwnerAuthorBackfillOneProjectSQL, projectID); err != nil {
|
|
t.Fatalf("owner author backfill: %v", err)
|
|
}
|
|
assertMemberDuty(t, ctx, pool, projectID, creatorOwner, docduty.DutyAuthor)
|
|
assertMemberDuty(t, ctx, pool, projectID, lowOwner, docduty.DutyNone)
|
|
|
|
if _, err := pool.Exec(ctx, docduty.OwnerAuthorBackfillOneProjectSQL, projectID); err != nil {
|
|
t.Fatalf("second backfill: %v", err)
|
|
}
|
|
assertMemberDuty(t, ctx, pool, projectID, creatorOwner, docduty.DutyAuthor)
|
|
assertMemberDuty(t, ctx, pool, projectID, lowOwner, docduty.DutyNone)
|
|
}
|
|
|
|
func TestHealProjectDocuments_TransferDoesNotResurrectOldReviewer_PG(t *testing.T) {
|
|
ctx, _, pool := openDutyTestPool(t)
|
|
suffix := time.Now().UnixNano()
|
|
authorID := insertDutyUser(t, ctx, pool, suffix, "heal_author")
|
|
reviewerA := insertDutyUser(t, ctx, pool, suffix, "heal_ra")
|
|
reviewerB := insertDutyUser(t, ctx, pool, suffix, "heal_rb")
|
|
|
|
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-heal-race-%d", suffix), "heal vs transfer", authorID).Scan(&projectID); err != nil {
|
|
t.Fatalf("insert project: %v", err)
|
|
}
|
|
t.Cleanup(func() {
|
|
docduty.SetProjectDutyLockHookForTest(nil)
|
|
_, _ = 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, reviewerA, reviewerB)
|
|
})
|
|
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', '', 'active', now(), now())
|
|
`, projectID, authorID, reviewerA, reviewerB); err != nil {
|
|
t.Fatalf("insert members: %v", err)
|
|
}
|
|
docID := fmt.Sprintf("doc_proj%d_intake", projectID)
|
|
insertDutyDoc(t, ctx, pool, projectID, docID, "intake", authorID, reviewerA, 0)
|
|
|
|
tx, err := pool.Begin(ctx)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer func() { _ = tx.Rollback(ctx) }()
|
|
if err := docduty.AcquireProjectDutyLock(ctx, tx, projectID); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := tx.Exec(ctx, `
|
|
UPDATE project_members SET document_duty = '', updated_at = now()
|
|
WHERE project_id = $1 AND document_duty = $2 AND user_id <> $3 AND status = $4
|
|
`, projectID, docduty.DutyReviewer, reviewerB, docduty.StatusActive); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := tx.Exec(ctx, `
|
|
UPDATE project_members SET document_duty = $3, updated_at = now()
|
|
WHERE project_id = $1 AND user_id = $2
|
|
`, projectID, reviewerB, docduty.DutyReviewer); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := docgov.ClearDutySlots(ctx, tx, projectID, docduty.PlanSlotRevocations(docduty.MemberDutyChange{
|
|
UserID: reviewerB, PreviousDuty: docduty.DutyNone, NextDuty: docduty.DutyReviewer,
|
|
PriorHolderIDs: map[string]int64{docduty.DutyReviewer: reviewerA},
|
|
})); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
aboutToLock := make(chan struct{})
|
|
docduty.SetProjectDutyLockHookForTest(func() {
|
|
close(aboutToLock)
|
|
docduty.SetProjectDutyLockHookForTest(nil)
|
|
})
|
|
|
|
type healResult struct {
|
|
assigned docduty.Binding
|
|
n int
|
|
err error
|
|
}
|
|
done := make(chan healResult, 1)
|
|
go func() {
|
|
assigned, n, err := docgov.HealProjectDocuments(ctx, pool, projectID)
|
|
done <- healResult{assigned: assigned, n: n, err: err}
|
|
}()
|
|
|
|
select {
|
|
case <-aboutToLock:
|
|
case <-ctx.Done():
|
|
t.Fatal("heal did not reach project advisory lock")
|
|
}
|
|
if err := waitForProjectAdvisoryLockWaiter(ctx, pool, projectID); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := tx.Commit(ctx); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
res := <-done
|
|
if res.err != nil {
|
|
t.Fatalf("heal after transfer: %v", res.err)
|
|
}
|
|
if res.assigned.Reviewer.UserID != reviewerB {
|
|
t.Fatalf("heal assigned reviewer=%d want %d", res.assigned.Reviewer.UserID, reviewerB)
|
|
}
|
|
assertDocReviewer(t, ctx, pool, docID, reviewerB)
|
|
assertDocAuthorID(t, ctx, pool, docID, authorID)
|
|
if res.assigned.Reviewer.UserID == reviewerA {
|
|
t.Fatal("old reviewer A must not be assigned after transfer")
|
|
}
|
|
|
|
_, n2, err := docgov.HealProjectDocuments(ctx, pool, projectID)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if n2 != 0 {
|
|
t.Fatalf("second heal must be idempotent, updated %d", n2)
|
|
}
|
|
assertDocReviewer(t, ctx, pool, docID, reviewerB)
|
|
assertDocAuthorID(t, ctx, pool, docID, authorID)
|
|
}
|
|
|
|
func waitForProjectAdvisoryLockWaiter(ctx context.Context, pool *pgxpool.Pool, projectID int64) error {
|
|
for {
|
|
var waiting bool
|
|
err := pool.QueryRow(ctx, `
|
|
SELECT EXISTS (
|
|
SELECT 1
|
|
FROM pg_locks
|
|
WHERE locktype = 'advisory'
|
|
AND NOT granted
|
|
AND classid = (($1::bigint) >> 32)::integer
|
|
AND objid = (($1::bigint) & 4294967295)::oid
|
|
)
|
|
`, projectID).Scan(&waiting)
|
|
if err != nil {
|
|
return fmt.Errorf("query advisory lock waiters: %w", err)
|
|
}
|
|
if waiting {
|
|
return nil
|
|
}
|
|
runtime.Gosched()
|
|
select {
|
|
case <-ctx.Done():
|
|
return fmt.Errorf("heal did not block on project advisory lock: %w", ctx.Err())
|
|
default:
|
|
}
|
|
}
|
|
}
|
|
|
|
func insertDutyUser(t *testing.T, ctx context.Context, pool *pgxpool.Pool, suffix int64, kind string) int64 {
|
|
t.Helper()
|
|
var id int64
|
|
account := fmt.Sprintf("duty_%s_%d", kind, suffix)
|
|
err := pool.QueryRow(ctx, `
|
|
INSERT INTO users (account_id, name, role)
|
|
VALUES ($1, $2, 'engineer')
|
|
RETURNING id
|
|
`, account, "用户"+kind).Scan(&id)
|
|
if err != nil {
|
|
t.Fatalf("insert user %s: %v", kind, err)
|
|
}
|
|
return id
|
|
}
|
|
|
|
func insertDutyDoc(t *testing.T, ctx context.Context, pool *pgxpool.Pool, projectID int64, docID, taskKey string, authorID, reviewerID, approverID int64) {
|
|
t.Helper()
|
|
_, err := pool.Exec(ctx, `
|
|
INSERT INTO document_records (
|
|
doc_id, project_id, task_key, title, stage, version,
|
|
current_state, is_locked, is_template_only,
|
|
author_id, author_name, reviewer_id, reviewer_name, approver_id, approver_name,
|
|
created_at, updated_at
|
|
) VALUES (
|
|
$1, $2, $3, $4, 'V0.5', 1,
|
|
'drafting', false, false,
|
|
$5, '编制工程师', $6, '审核负责人', $7, '批准负责人',
|
|
now(), now()
|
|
)
|
|
`, docID, projectID, taskKey, taskKey+" doc", authorID, reviewerID, nullIfZero(approverID))
|
|
if err != nil {
|
|
t.Fatalf("insert doc %s: %v", docID, err)
|
|
}
|
|
}
|
|
|
|
func assertDocReviewer(t *testing.T, ctx context.Context, pool *pgxpool.Pool, docID string, want int64) {
|
|
t.Helper()
|
|
assertDocSlot(t, ctx, pool, docID, "reviewer_id", want)
|
|
}
|
|
|
|
func assertDocApprover(t *testing.T, ctx context.Context, pool *pgxpool.Pool, docID string, want int64) {
|
|
t.Helper()
|
|
assertDocSlot(t, ctx, pool, docID, "approver_id", want)
|
|
}
|
|
|
|
func assertDocSlot(t *testing.T, ctx context.Context, pool *pgxpool.Pool, docID, column string, want int64) {
|
|
t.Helper()
|
|
var got int64
|
|
q := fmt.Sprintf(`SELECT COALESCE(%s, 0) FROM document_records WHERE doc_id = $1`, column)
|
|
if err := pool.QueryRow(ctx, q, docID).Scan(&got); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if got != want {
|
|
t.Fatalf("%s %s=%d want %d", docID, column, got, want)
|
|
}
|
|
}
|
|
|
|
func assertDocAuthorID(t *testing.T, ctx context.Context, pool *pgxpool.Pool, docID string, want int64) {
|
|
t.Helper()
|
|
assertDocSlot(t, ctx, pool, docID, "author_id", want)
|
|
}
|
|
|
|
func assertDocAuthorName(t *testing.T, ctx context.Context, pool *pgxpool.Pool, docID, want string) {
|
|
t.Helper()
|
|
var got string
|
|
if err := pool.QueryRow(ctx, `SELECT COALESCE(author_name, '') FROM document_records WHERE doc_id = $1`, docID).Scan(&got); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if got != want {
|
|
t.Fatalf("%s author_name=%q want %q", docID, got, want)
|
|
}
|
|
}
|
|
|
|
func assertMemberDuty(t *testing.T, ctx context.Context, pool *pgxpool.Pool, projectID, userID int64, want string) {
|
|
t.Helper()
|
|
var got string
|
|
if err := pool.QueryRow(ctx, `
|
|
SELECT COALESCE(document_duty, '') FROM project_members WHERE project_id = $1 AND user_id = $2
|
|
`, projectID, userID).Scan(&got); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if got != want {
|
|
t.Fatalf("user %d document_duty=%q want %q", userID, got, want)
|
|
}
|
|
}
|
|
|
|
func nullIfZero(id int64) any {
|
|
if id <= 0 {
|
|
return nil
|
|
}
|
|
return id
|
|
}
|