//go:build integration package integration import ( "context" "testing" "time" "github.com/google/uuid" "github.com/jackc/pgx/v5" "github.com/missionworkforce/missionmatch/internal/modules/eeo" "github.com/missionworkforce/missionmatch/internal/platform/database/dbgen" "github.com/missionworkforce/missionmatch/internal/platform/tenant" ) func seedSlate(t *testing.T, org uuid.UUID) uuid.UUID { t.Helper() req, err := dbgen.New(migrator).CreateRequirement(context.Background(), dbgen.CreateRequirementParams{ OrganizationID: org, Title: "AI Role", RequiredLevel: "none", RequiredPoly: "none", }) if err != nil { t.Fatalf("seed requirement: %v", err) } var slateID uuid.UUID if e := migrator.QueryRow(context.Background(), `INSERT INTO slates (organization_id, requirement_id, name, status) VALUES ($1,$2,'AI Slate','draft') RETURNING id`, org, req.ID).Scan(&slateID); e != nil { t.Fatalf("seed slate: %v", e) } return slateID } // seedDecidedMember inserts a candidate and a decided slate_members row via the migrator. func seedDecidedMember(t *testing.T, org, slateID uuid.UUID, decision string) uuid.UUID { t.Helper() cand := seedCandidate(t, org, "secret", "active", true) _, err := migrator.Exec(context.Background(), `INSERT INTO slate_members (organization_id, slate_id, candidate_id, total_score, band, clearance_score, skills_score, certs_score, customer_score, experience_score, location_score, explanation, trace, gap_summary, decision, model_version) VALUES ($1,$2,$3,90,'green',100,100,100,100,100,100,'seed','[]','[]',$4,'v1.0.0')`, org, slateID, cand.ID, decision) if err != nil { t.Fatalf("seed slate_member: %v", err) } return cand.ID } // seedDemographic writes a consented demographic row through the compliance pool inside the org tx. func seedDemographic(t *testing.T, org, candidateID uuid.UUID, gender, race string) { t.Helper() err := tenant.New(compliance).RunInTenantTx(ctxOrg(org), func(ctx context.Context, tx pgx.Tx) error { _, e := tx.Exec(ctx, `INSERT INTO eeo.candidate_demographics (organization_id, candidate_id, gender, race_ethnicity, consent_given) VALUES ($1,$2,$3,$4,true)`, org, candidateID, gender, race) return e }) if err != nil { t.Fatalf("seed demographic (does the compliance role have eeo INSERT?): %v", err) } } func TestAdverseImpactComplianceJoin(t *testing.T) { if compliance == nil { t.Skip("compliance pool not configured (MM_COMPLIANCE_DSN); skipping adverse impact integration") } org := seedOrg(t) slateID := seedSlate(t, org) // Group A (male): 4 approved of 4, rate 1.0. Group B (female): 1 approved of 4, rate 0.25. // Female ratio = 0.25 / 1.0 = 0.25, below the 0.80 four fifths threshold, so it must be flagged. for i := 0; i < 4; i++ { c := seedDecidedMember(t, org, slateID, "approved") seedDemographic(t, org, c, "male", "groupA") } for i := 0; i < 4; i++ { decision := "rejected" if i == 0 { decision = "approved" } c := seedDecidedMember(t, org, slateID, decision) seedDemographic(t, org, c, "female", "groupB") } rep, err := eeo.NewService(tenant.New(compliance)).AdverseImpact(ctxOrg(org), time.Now()) if err != nil { t.Fatalf("AdverseImpact (does the compliance role have SELECT on slate_members from 0010?): %v", err) } var female eeo.ImpactRow found := false for _, r := range rep.Selection.ByGender { if r.Group == "female" { female, found = r, true } } if !found { t.Fatalf("female group missing from report: %+v", rep.Selection.ByGender) } if !female.Flagged { t.Fatalf("female group should be flagged below 0.80, got ratio %s", female.ImpactRatio) } } func TestComplianceRoleCannotMutateOutcomes(t *testing.T) { if compliance == nil { t.Skip("compliance pool not configured (MM_COMPLIANCE_DSN)") } org := seedOrg(t) slateID := seedSlate(t, org) cand := seedDecidedMember(t, org, slateID, "approved") // The compliance grant is SELECT only: an UPDATE on a decision must be permission denied (42501). _, err := compliance.Exec(ctxOrg(org), `UPDATE slate_members SET decision='rejected' WHERE candidate_id=$1`, cand) if pgCode(err) != "42501" { t.Fatalf("compliance UPDATE on slate_members should be permission denied (42501), got %v", err) } }