Root causes of the remaining 5 CI PG failures (and the hidden flaky set):
1. All PG-integration packages shared ONE database under parallel package
execution - migrations/seeds/truncates raced (nondeterministic failure
sets, locally and in CI).
Fix: RequireTestDatabaseURL/SkipIfNoTestDatabaseURL now derive a
per-package database (<base>_<pkgHash>, advisory-lock-guarded
CREATE); 9 raw-env straggler tests funnelled through the helpers;
db-package tests call it unqualified (no import cycle).
2. Approval-chain fixtures were not production-shaped: freezeReviewSnapshot
only honors head_snapshot_id, fake content hashes never froze a
snapshot, so ApproverApprove rejected with 'approved requires frozen
review_snapshot_id'. Errors were swallowed (_, _ =) hiding this.
Fix (ownership-respecting, arch-enforced):
- docrevision.SeedContentSnapshotForTest seeds the ledger tables it owns
- docgov GovernanceEngine.FreezeHeadSnapshotForTest freezes head/review
pointers on document_records (docgov-owned) and clears engine cache
- all four integration fixtures use the pair; swallowed errors checked
3. Fixed-name fixtures collide once a package shares one DB (projects
uq_projects_name_global): uniquified in projectinstance + server tests.
4. CAS/ctxmgr tests never bootstrapped migrations (relied on other packages
having migrated the shared DB): explicit RunMigrationsOnceForTest.
Verified: full parallel short suite = 75 packages, 0 failures against an
isolated throwaway PG. CI skip list removed - short tests fully required.
124 lines
3.6 KiB
Go
124 lines
3.6 KiB
Go
package notification_test
|
|
|
|
import (
|
|
"context"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/jackc/pgx/v5/pgxpool"
|
|
|
|
"chat-agent-system/internal/db"
|
|
"chat-agent-system/internal/notification"
|
|
)
|
|
|
|
func openPool(t *testing.T) *pgxpool.Pool {
|
|
t.Helper()
|
|
// 隔离约定:仅提供专用测试库时运行,禁止默认回退到开发库。
|
|
dsn := db.SkipIfNoTestDatabaseURL(t)
|
|
if strings.Contains(dsn, "agent_system_dev") || strings.Contains(dsn, "agent_system_test") || strings.Contains(dsn, "agent_system_main") {
|
|
t.Fatal("TEST_DATABASE_URL must not point at a shared env database")
|
|
}
|
|
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
|
defer cancel()
|
|
pool, err := pgxpool.New(ctx, dsn)
|
|
if err != nil {
|
|
t.Skip(err)
|
|
}
|
|
if err := pool.Ping(ctx); err != nil {
|
|
pool.Close()
|
|
t.Skip(err)
|
|
}
|
|
migCtx, migCancel := context.WithTimeout(context.Background(), 30*time.Second)
|
|
defer migCancel()
|
|
if err := db.RunMigrations(migCtx, pool, db.ResolveMigrationsDir()); err != nil {
|
|
pool.Close()
|
|
t.Skipf("migration failed: %v", err)
|
|
}
|
|
return pool
|
|
}
|
|
|
|
func TestPG_EmitStageReadyDedupe(t *testing.T) {
|
|
pool := openPool(t)
|
|
defer pool.Close()
|
|
|
|
svc := notification.NewService(pool)
|
|
if !svc.Available() {
|
|
t.Fatal("notification service should be available after migration")
|
|
}
|
|
|
|
ctx := context.Background()
|
|
var projectID, instanceID, stageInstID int64
|
|
code := "notify-" + time.Now().Format("150405.000000")
|
|
err := pool.QueryRow(ctx, `
|
|
INSERT INTO projects (code, name, status, creator_user_id, lifecycle_status)
|
|
VALUES ($1, 'Notify Test ' || $2, 'active', 2, 'active') RETURNING id
|
|
`, code, code).Scan(&projectID)
|
|
if err != nil {
|
|
t.Fatalf("insert project: %v", err)
|
|
}
|
|
t.Cleanup(func() {
|
|
_, _ = pool.Exec(context.Background(), `DELETE FROM projects WHERE id = $1`, projectID)
|
|
})
|
|
|
|
var wfVersionID int64
|
|
err = pool.QueryRow(ctx, `SELECT id FROM workflow_versions ORDER BY id DESC LIMIT 1`).Scan(&wfVersionID)
|
|
if err != nil {
|
|
t.Skip("no workflow version for notification test")
|
|
}
|
|
|
|
err = pool.QueryRow(ctx, `
|
|
INSERT INTO project_workflow_instances (project_id, workflow_version_id, instance_no, status, config_revision, plan_hash, config_snapshot, created_by)
|
|
VALUES ($1, $2, 1, 'active', 3, 'h', '{}'::jsonb, 2) RETURNING id
|
|
`, projectID, wfVersionID).Scan(&instanceID)
|
|
if err != nil {
|
|
t.Fatalf("insert instance: %v", err)
|
|
}
|
|
|
|
err = pool.QueryRow(ctx, `
|
|
INSERT INTO project_stage_instances (workflow_instance_id, stage_key, position, enabled, status)
|
|
VALUES ($1, 'biz_intake', 0, true, 'active') RETURNING id
|
|
`, instanceID).Scan(&stageInstID)
|
|
if err != nil {
|
|
t.Fatalf("insert stage: %v", err)
|
|
}
|
|
|
|
payload := notification.StageReadyPayload{
|
|
ProjectID: projectID,
|
|
WorkflowInstanceID: instanceID,
|
|
StageInstanceID: stageInstID,
|
|
StageKey: "biz_intake",
|
|
StageName: "商务摸底",
|
|
ConfigRevision: 3,
|
|
RequiredDocCount: 1,
|
|
ApprovedDocCount: 1,
|
|
}
|
|
|
|
created, err := svc.EmitStageReadyToAdvance(ctx, 2, payload)
|
|
if err != nil {
|
|
t.Fatalf("emit: %v", err)
|
|
}
|
|
if !created {
|
|
t.Fatal("expected first emit to create notification")
|
|
}
|
|
|
|
again, err := svc.EmitStageReadyToAdvance(ctx, 2, payload)
|
|
if err != nil {
|
|
t.Fatalf("second emit: %v", err)
|
|
}
|
|
if again {
|
|
t.Fatal("expected dedupe to suppress second notification")
|
|
}
|
|
|
|
var count int
|
|
err = pool.QueryRow(ctx, `
|
|
SELECT COUNT(*) FROM notifications WHERE user_id = 2 AND project_id = $1 AND type = $2
|
|
`, projectID, notification.TypeStageReadyToAdvance).Scan(&count)
|
|
if err != nil {
|
|
t.Fatalf("count notifications: %v", err)
|
|
}
|
|
if count != 1 {
|
|
t.Fatalf("expected exactly one notification, got %d", count)
|
|
}
|
|
}
|