//go:build integration package integration import ( "context" "strings" "testing" "github.com/google/uuid" "github.com/jackc/pgx/v5" "github.com/missionworkforce/missionmatch/internal/modules/matching" "github.com/missionworkforce/missionmatch/internal/platform/audit" "github.com/missionworkforce/missionmatch/internal/platform/database/dbgen" "github.com/missionworkforce/missionmatch/internal/platform/tenant" ) // TestSlateMatchListDetailReadinessExport exercises Workstream C end to end against real Postgres: a // generated slate is listed, each member gets a human decision with a note, the detail and proposal // readiness rollups are computed from the STORED slate (never rescored), and the branded CSV export // carries the frozen scores, decisions, and notes. The requirement needs Secret and 5 years; the // strong candidate (Secret active, 10 years) scores Green 100 and the weak candidate (no clearance, 0 // years) scores Red 65, so the band and decision mix is deterministic. func TestSlateMatchListDetailReadinessExport(t *testing.T) { ctx := context.Background() org := seedOrg(t) var userID uuid.UUID if err := migrator.QueryRow(ctx, `INSERT INTO users (organization_id, email, full_name, role, status) VALUES ($1, $2, 'Slate Reviewer', 'owner', 'active') RETURNING id`, org, "slate-"+org.String()[:8]+"@example.com").Scan(&userID); err != nil { t.Fatalf("seed user: %v", err) } userCtx := tenant.WithUser(tenant.WithOrg(ctx, org.String()), tenant.Principal{UserID: userID.String(), Role: "owner"}) req, err := dbgen.New(migrator).CreateRequirement(ctx, dbgen.CreateRequirementParams{ OrganizationID: org, Title: "Slate Role", RequiredLevel: "secret", RequiredPoly: "none", RequiredYears: 5, }) if err != nil { t.Fatalf("seed requirement: %v", err) } strong := seedCandidate(t, org, "secret", "active", true) weak := seedCandidate(t, org, "none", "active", true) if _, err := migrator.Exec(ctx, "UPDATE candidates SET years_experience=10 WHERE id=$1", strong.ID); err != nil { t.Fatalf("enrich strong: %v", err) } svc := matching.NewService(appDB, nil, audit.New(appDB)) slate, err := svc.GenerateSlate(ctxOrg(org), matching.GenerateSlateInput{RequirementID: req.ID, Name: "Q3 Proposal Slate"}) if err != nil { t.Fatalf("GenerateSlate: %v", err) } if len(slate.Members) != 2 { t.Fatalf("want 2 members, got %d", len(slate.Members)) } // List the slates for the requirement. list, err := svc.ListSlates(ctxOrg(org), req.ID) if err != nil { t.Fatalf("ListSlates: %v", err) } if len(list) != 1 || list[0].MemberCount != 2 || list[0].Name != "Q3 Proposal Slate" { t.Fatalf("slate list = %+v", list) } // Human decisions with notes (needs a validated principal for decided_by). if err := svc.DecideSlateMember(userCtx, slate.SlateID, strong.ID, "approved", "strong fit, Key Personnel ready"); err != nil { t.Fatalf("approve strong: %v", err) } if err := svc.DecideSlateMember(userCtx, slate.SlateID, weak.ID, "rejected", "no active clearance"); err != nil { t.Fatalf("reject weak: %v", err) } // Detail and readiness are computed from the stored slate. detail, err := svc.SlateDetail(ctxOrg(org), slate.SlateID) if err != nil { t.Fatalf("SlateDetail: %v", err) } r := detail.Readiness if r.TotalMembers != 2 || r.Approved != 1 || r.Rejected != 1 || r.Pending != 0 { t.Fatalf("readiness decision mix = %+v", r) } if r.ProposalReady != 1 || r.Recruiting != 1 || r.ApprovedReady != 1 { t.Fatalf("readiness band mix = %+v", r) } if r.ReadinessPct != "50" || r.Status != "Proposal Ready" { t.Fatalf("readiness rollup = pct %s status %s, want 50 / Proposal Ready", r.ReadinessPct, r.Status) } // The note is surfaced on the member, and the persisted band agrees with what generation froze. noteSeen := false for _, m := range detail.Members { if m.CandidateID == strong.ID.String() { if m.Total != "100.00" || m.Band != "green" || m.Decision != "approved" { t.Fatalf("strong member = %+v", m) } if m.DecisionNote != "strong fit, Key Personnel ready" { t.Fatalf("strong note = %q", m.DecisionNote) } noteSeen = true } } if !noteSeen { t.Fatal("strong member not found in detail") } // ProposalReadiness directly agrees with the detail rollup. pr, err := svc.ProposalReadiness(ctxOrg(org), slate.SlateID) if err != nil { t.Fatalf("ProposalReadiness: %v", err) } if pr.Status != "Proposal Ready" || pr.ApprovedReady != 1 { t.Fatalf("proposal readiness = %+v", pr) } // The branded export carries the header, the names, the decisions, and the notes, and is recorded in // the append-only audit as a bulk egress (under a validated principal so the actor is stamped). csvText, err := svc.ExportSlate(userCtx, slate.SlateID) if err != nil { t.Fatalf("ExportSlate: %v", err) } var exportAudits int if err := appDB.RunInTenantTx(ctxOrg(org), func(ctx context.Context, tx pgx.Tx) error { return tx.QueryRow(ctx, "SELECT count(*) FROM audit_logs WHERE organization_id=$1 AND action='aedt.slate.exported'", org).Scan(&exportAudits) }); err != nil { t.Fatalf("count export audits: %v", err) } if exportAudits != 1 { t.Fatalf("want 1 slate export audit event, got %d", exportAudits) } for _, want := range []string{ "missionMATCH slateMATCH Proposal Candidate Slate", "Q3 Proposal Slate", "never verified", "approved", "rejected", "strong fit, Key Personnel ready", "no active clearance", "100.00", } { if !strings.Contains(csvText, want) { t.Fatalf("export CSV missing %q\n---\n%s", want, csvText) } } }