Files
Agent-System/internal/docduty/store_pg_test.go
T
WeiCHEN 9c7a5be209 feat(arch): clear G1 U3/U4 sql_write and path exceptions
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.
2026-09-22 21:40:12 +08:00

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
}