package alerts import ( "context" "errors" "io" "log/slog" "os" "testing" "time" "github.com/google/uuid" "github.com/jackc/pgx/v5/pgxpool" ) func TestScannerDetectionDedupSilenceAndReopenLifecycle(t *testing.T) { databaseURL := os.Getenv("TEST_DATABASE_URL") if databaseURL == "" { t.Skip("TEST_DATABASE_URL is not set") } ctx := context.Background() pool, err := pgxpool.New(ctx, databaseURL) if err != nil { t.Fatal(err) } defer pool.Close() logger := slog.New(slog.NewTextHandler(io.Discard, nil)) scanner, err := NewScanner(pool, logger, time.Second) if err != nil { t.Fatal(err) } if _, err := NewScanner(nil, logger, time.Second); err == nil { t.Fatal("nil scanner pool accepted") } if _, err := NewScanner(pool, nil, time.Second); err == nil { t.Fatal("nil scanner logger accepted") } if _, err := NewScanner(pool, logger, 0); err == nil { t.Fatal("zero scanner interval accepted") } now := time.Now().UTC().Truncate(time.Second) tenantID := "10000000-0000-0000-0000-000000000001" resourceID := uuid.NewString() fingerprint := "core-detect-" + uuid.NewString() silenceID := uuid.NewString() defer func() { _, _ = pool.Exec(ctx, `DELETE FROM alert_incident WHERE tenant_id=$1::uuid AND fingerprint=$2`, tenantID, fingerprint) _, _ = pool.Exec(ctx, `DELETE FROM alert_silence WHERE id=$1::uuid`, silenceID) }() if err := scanner.detect(ctx, Signal{TenantID: tenantID, RuleCode: "bad code", Category: "TECHNICAL", Severity: "LOW", Fingerprint: fingerprint, Title: "标题", Summary: "摘要"}, now); !errors.Is(err, ErrInvalid) { t.Fatalf("invalid signal error=%v", err) } signal := Signal{ TenantID: tenantID, RuleCode: " core_detect_test ", Category: "TECHNICAL", Severity: "LOW", Fingerprint: fingerprint, Title: "核心告警发现", Summary: "首次发现合成告警。", ResourceType: "device", ResourcePublicID: resourceID, RequestID: "alerts-core-detect", } if err := scanner.detect(ctx, signal, now); err != nil { t.Fatal(err) } signal.Summary = "相同指纹再次命中。" if err := scanner.detect(ctx, signal, now.Add(time.Minute)); err != nil { t.Fatal(err) } var status, summary string var occurrences, rowVersion int64 if err := pool.QueryRow(ctx, `SELECT status,summary,occurrence_count,row_version FROM alert_incident WHERE tenant_id=$1::uuid AND fingerprint=$2`, tenantID, fingerprint).Scan(&status, &summary, &occurrences, &rowVersion); err != nil { t.Fatal(err) } if status != "OPEN" || summary != signal.Summary || occurrences != 2 || rowVersion != 2 { t.Fatalf("open status=%s summary=%q occurrences=%d version=%d", status, summary, occurrences, rowVersion) } if _, err := pool.Exec(ctx, ` INSERT INTO alert_silence(id,public_id,tenant_id,rule_code,category,resource_type,resource_public_id,matcher,reason,starts_at,ends_at,created_by) VALUES($1::uuid,$2::uuid,$3::uuid,'CORE_DETECT_TEST','TECHNICAL','device',$4::uuid,'{}'::jsonb,'核心检测静默窗口',$5,$6,'10000000-0000-0000-0000-000000000101'::uuid)`, silenceID, uuid.NewString(), tenantID, resourceID, now.Add(-time.Minute), now.Add(time.Hour)); err != nil { t.Fatal(err) } signal.Summary = "静默窗口内再次命中。" if err := scanner.detect(ctx, signal, now.Add(2*time.Minute)); err != nil { t.Fatal(err) } if _, err := pool.Exec(ctx, `UPDATE alert_silence SET revoked_at=$2,revoked_by='10000000-0000-0000-0000-000000000101'::uuid WHERE id=$1::uuid`, silenceID, now.Add(3*time.Minute)); err != nil { t.Fatal(err) } signal.Summary = "静默撤销后重新进入队列。" if err := scanner.detect(ctx, signal, now.Add(4*time.Minute)); err != nil { t.Fatal(err) } var actionCount int if err := pool.QueryRow(ctx, `SELECT ai.status,ai.summary,ai.occurrence_count,ai.row_version,count(aa.id) FROM alert_incident ai LEFT JOIN alert_action aa ON aa.incident_id=ai.id WHERE ai.tenant_id=$1::uuid AND ai.fingerprint=$2 GROUP BY ai.id`, tenantID, fingerprint).Scan(&status, &summary, &occurrences, &rowVersion, &actionCount); err != nil { t.Fatal(err) } if status != "OPEN" || summary != signal.Summary || occurrences != 4 || rowVersion != 4 || actionCount != 3 { t.Fatalf("reopened status=%s summary=%q occurrences=%d version=%d actions=%d", status, summary, occurrences, rowVersion, actionCount) } cancelled, cancel := context.WithCancel(context.Background()) cancel() scanner.Run(cancelled) if nullUUID("") != nil || nullUUID(resourceID) != resourceID { t.Fatal("null UUID normalization failed") } }