//go:build integration // The proactive alert engine integration. Proves at runtime against a REAL Postgres what the unit tests // cannot: the three watch loop triggers fire on real data and produce exactly one typed alert each with the // right subject and severity; a second sweep is idempotent (the unique dedupe upsert holds, the counts are // unchanged and no duplicate row appears); the RLS boundary holds (a second org sees none of org A's alerts); // and SetAlertStatus flips an alert from unread to dismissed. It seeds an org with a candidate whose clearance // expires inside the urgent window, a recently posted opportunity_record in an agency the org has a // requirement for with a pop_end inside the forward recompete window, and an open requirement that a seeded // bench candidate structurally covers. // // Run: go test -tags=integration ./internal/integration/ -run Alert -v // Env: MM_APP_DSN, MM_MIGRATOR_DSN (required). package integration import ( "context" "testing" "time" "github.com/google/uuid" "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgtype" "github.com/missionworkforce/missionmatch/internal/modules/alerts" "github.com/missionworkforce/missionmatch/internal/platform/database/dbgen" ) // seedExpiringCandidate seeds an available candidate whose clearance expires in the given number of days from // today, via the BYPASSRLS migrator. It returns the candidate id so the test can assert the alert's subject. func seedExpiringCandidate(t *testing.T, org uuid.UUID, name string, daysOut int) uuid.UUID { t.Helper() cand := seedCandidate(t, org, "secret", "active", true) expires := time.Now().UTC().AddDate(0, 0, daysOut).Format("2006-01-02") if _, err := migrator.Exec(context.Background(), "UPDATE candidates SET full_name=$2, clearance_expires_at=$3 WHERE id=$1", cand.ID, name, expires); err != nil { t.Fatalf("set candidate expiry: %v", err) } return cand.ID } // seedMarketOpportunity seeds one opportunity_record (global public reference data) posted recently with a // period of performance ending inside the forward recompete window, in the given agency. It returns the // generated opportunity_records id so the test can assert the alert's subject. func seedMarketOpportunity(t *testing.T, agency, naics string, postedDaysAgo, popEndMonthsOut int) uuid.UUID { t.Helper() run := uuid.NewString()[:8] srcID := "ALERT-OPP-" + run posted := time.Now().UTC().AddDate(0, 0, -postedDaysAgo) popEnd := time.Now().UTC().AddDate(0, popEndMonthsOut, 0) if err := dbgen.New(migrator).UpsertOpportunityRecord(context.Background(), dbgen.UpsertOpportunityRecordParams{ SourceName: "SAM.gov", SourceRecordID: srcID, RecordKind: "active_solicitation", Title: "Engineering services recompete", Agency: agency, Naics: naics, PostedDate: pgtype.Date{Time: posted, Valid: true}, PopEndDate: pgtype.Date{Time: popEnd, Valid: true}, FetchedAt: time.Now().UTC(), }); err != nil { t.Fatalf("seed market opportunity: %v", err) } var id uuid.UUID if err := migrator.QueryRow(context.Background(), "SELECT id FROM opportunity_records WHERE source_name='SAM.gov' AND source_record_id=$1", srcID).Scan(&id); err != nil { t.Fatalf("resolve opportunity id: %v", err) } return id } // alertByType returns the single alert of the given type from a list, failing if there is not exactly one. func alertByType(t *testing.T, list []alerts.Alert, alertType string) alerts.Alert { t.Helper() var found []alerts.Alert for _, a := range list { if a.AlertType == alertType { found = append(found, a) } } if len(found) != 1 { t.Fatalf("expected exactly one %s alert, got %d", alertType, len(found)) } return found[0] } // TestAlertSweepThreeTriggersIdempotentAndIsolated is the end to end proof of the watch loop. It seeds the // three actionable events for org A, runs SweepOrg, asserts exactly one alert of each type with the right // subject and severity, runs SweepOrg AGAIN and asserts no duplicate (the dedupe upsert holds, counts // unchanged), proves a second org sees none of org A's alerts, and flips an alert from unread to dismissed. func TestAlertSweepThreeTriggersIdempotentAndIsolated(t *testing.T) { ctx := context.Background() orgA, orgB := seedOrg(t), seedOrg(t) agency := "DEPT OF THE AIR FORCE " + uuid.NewString()[:8] // unique per run so the segment is isolated // (i) a candidate whose clearance expires in 20 days (inside the 30 day urgent window). candID := seedExpiringCandidate(t, orgA, "Jane Cleared", 20) // (ii) a recently posted opportunity in an agency org A has a requirement for, pop end inside the window. // The requirement gives org A its interest in the agency; the opportunity is the market event. var interestReqID uuid.UUID if err := migrator.QueryRow(ctx, `INSERT INTO requirements (organization_id, title, customer_agency, required_level, required_poly) VALUES ($1,'Existing AF requirement',$2,'none','none') RETURNING id`, orgA, agency).Scan(&interestReqID); err != nil { t.Fatalf("seed interest requirement: %v", err) } // Give the interest requirement a required skill no bench candidate holds, so it is open but NOT // structurally fillable. It exists only to establish org A's interest in the agency for the recompete // trigger and must not also trip the gap_fillable trigger (which would otherwise fire on any open // requirement with no real bar). interestSkill := seedSkillByName(t, "AF Specialty "+uuid.NewString()[:8]) seedRequiredSkill(t, orgA, interestReqID, interestSkill, true) oppID := seedMarketOpportunity(t, agency, "541512", 5, 6) // (iii) an open requirement with one required skill that a seeded bench candidate structurally covers. gapReq := seedRequirementWithLevel(t, orgA, "secret") skill := seedSkillByName(t, "Reverse Engineering "+uuid.NewString()[:8]) seedRequiredSkill(t, orgA, gapReq, skill, true) benchCand := seedCandidate(t, orgA, "top_secret", "active", true) // ts_sci over secret clears the ladder seedCandidateSkill(t, orgA, benchCand.ID, skill) // holds the one required skill (full coverage) svc := alerts.NewService(appDB, nil) sum, err := svc.SweepOrg(ctxOrg(orgA)) if err != nil { t.Fatalf("sweep org A: %v", err) } if sum.ClearanceExpiring != 1 || sum.RecompeteWinnable != 1 || sum.GapFillable != 1 { t.Fatalf("first sweep counts = clearance %d, recompete %d, gap %d; want 1,1,1", sum.ClearanceExpiring, sum.RecompeteWinnable, sum.GapFillable) } list, err := svc.ListAlerts(ctxOrg(orgA), "") if err != nil { t.Fatalf("list org A alerts: %v", err) } if len(list) != 3 { t.Fatalf("expected exactly 3 alerts after the first sweep, got %d", len(list)) } // (a) clearance_expiring: subject is the candidate, severity urgent (20 days is inside the 30 day window). ca := alertByType(t, list, alerts.TypeClearanceExpiring) if ca.SubjectType != alerts.SubjectCandidate || ca.SubjectID != candID.String() { t.Fatalf("clearance alert subject = %s/%s, want candidate/%s", ca.SubjectType, ca.SubjectID, candID) } if ca.Severity != alerts.SeverityUrgent { t.Fatalf("clearance alert severity = %s, want urgent (20 days out)", ca.Severity) } // (b) recompete_winnable: subject is the opportunity record, the market event arm. ra := alertByType(t, list, alerts.TypeRecompeteWinnable) if ra.SubjectType != alerts.SubjectOpportunity || ra.SubjectID != oppID.String() { t.Fatalf("recompete alert subject = %s/%s, want opportunity/%s", ra.SubjectType, ra.SubjectID, oppID) } if ra.Severity != alerts.SeverityWatch { t.Fatalf("recompete alert severity = %s, want watch", ra.Severity) } // (c) gap_fillable: subject is the open requirement the bench now structurally covers. ga := alertByType(t, list, alerts.TypeGapFillable) if ga.SubjectType != alerts.SubjectRequirement || ga.SubjectID != gapReq.String() { t.Fatalf("gap alert subject = %s/%s, want requirement/%s", ga.SubjectType, ga.SubjectID, gapReq) } // Re sweep: the unique dedupe upsert holds, the counts are unchanged and no duplicate row appears. sum2, err := svc.SweepOrg(ctxOrg(orgA)) if err != nil { t.Fatalf("second sweep org A: %v", err) } if sum2.ClearanceExpiring != 1 || sum2.RecompeteWinnable != 1 || sum2.GapFillable != 1 { t.Fatalf("second sweep counts changed: clearance %d, recompete %d, gap %d; want 1,1,1", sum2.ClearanceExpiring, sum2.RecompeteWinnable, sum2.GapFillable) } list2, err := svc.ListAlerts(ctxOrg(orgA), "") if err != nil { t.Fatalf("list org A alerts after re sweep: %v", err) } if len(list2) != 3 { t.Fatalf("re sweep created a duplicate: expected 3 alerts, got %d", len(list2)) } // RLS isolation: org B sees none of org A's alerts. Org B has no seeded events, so its sweep finds nothing. sumB, err := svc.SweepOrg(ctxOrg(orgB)) if err != nil { t.Fatalf("sweep org B: %v", err) } if sumB.Total() != 0 { t.Fatalf("org B should have no actionable events, got %d alerts", sumB.Total()) } listB, err := svc.ListAlerts(ctxOrg(orgB), "") if err != nil { t.Fatalf("list org B alerts: %v", err) } if len(listB) != 0 { t.Fatalf("ISOLATION BREACH: org B saw %d alerts (org A's?)", len(listB)) } // A direct read of org A's clearance alert under org B's session must return zero rows (RLS hides it). var n int if err := appDB.RunInTenantTx(ctxOrg(orgB), func(ctx context.Context, tx pgx.Tx) error { return tx.QueryRow(ctx, "SELECT count(*) FROM alerts WHERE id = $1", ca.ID).Scan(&n) }); err != nil { t.Fatalf("cross tenant read: %v", err) } if n != 0 { t.Fatalf("ISOLATION BREACH: org B read org A's alert by id (count %d)", n) } // SetAlertStatus flips unread to dismissed under RLS. if ca.Status != alerts.StatusUnread { t.Fatalf("a fresh alert must be unread, got %s", ca.Status) } updated, err := svc.SetAlertStatus(ctxOrg(orgA), uuid.MustParse(ca.ID), alerts.StatusDismissed) if err != nil { t.Fatalf("set alert status: %v", err) } if updated.Status != alerts.StatusDismissed { t.Fatalf("status after dismiss = %s, want dismissed", updated.Status) } // The dismissed status survives a re sweep (the upsert refreshes the content but leaves the status). if _, err := svc.SweepOrg(ctxOrg(orgA)); err != nil { t.Fatalf("third sweep org A: %v", err) } dismissed, err := svc.ListAlerts(ctxOrg(orgA), alerts.StatusDismissed) if err != nil { t.Fatalf("list dismissed alerts: %v", err) } if len(dismissed) != 1 || dismissed[0].ID != ca.ID { t.Fatalf("the dismissed alert must persist across a re sweep, got %d dismissed", len(dismissed)) } }