package federalsignal import ( "context" "sort" "testing" "github.com/missionworkforce/missionmatch/internal/platform/audit" "github.com/missionworkforce/missionmatch/internal/platform/database/dbgen" ) // fakeRepo is an in memory Repo: it serves fixture rows and records every upsert so the test can assert // the exact tags and incumbent roles the service wrote. It honors idempotency the way the real ON // CONFLICT DO UPDATE does, keying mission tags on (record_table, record_id, mission_code) and incumbent // roles on (record_table, record_id, labor_category), so a second run never grows the row set. type fakeRepo struct { aliasPairs map[string]string opps []dbgen.ListOpportunityRecordsForClassifyRow awards []dbgen.ListAwardsForClassifyRow sam []dbgen.ListSamForClassifyRow missionTags map[string]dbgen.UpsertFederalMissionTagParams incumbentRoles map[string]dbgen.UpsertAwardIncumbentRoleParams } func newFakeRepo() *fakeRepo { return &fakeRepo{ missionTags: map[string]dbgen.UpsertFederalMissionTagParams{}, incumbentRoles: map[string]dbgen.UpsertAwardIncumbentRoleParams{}, } } func (r *fakeRepo) AliasPairs(_ context.Context) (map[string]string, error) { return r.aliasPairs, nil } func (r *fakeRepo) ListOpportunityRecordsForClassify(_ context.Context) ([]dbgen.ListOpportunityRecordsForClassifyRow, error) { return r.opps, nil } func (r *fakeRepo) ListAwardsForClassify(_ context.Context) ([]dbgen.ListAwardsForClassifyRow, error) { return r.awards, nil } func (r *fakeRepo) ListSamForClassify(_ context.Context) ([]dbgen.ListSamForClassifyRow, error) { return r.sam, nil } func (r *fakeRepo) UpsertFederalMissionTag(_ context.Context, p dbgen.UpsertFederalMissionTagParams) error { r.missionTags[p.RecordTable+"|"+p.RecordID+"|"+p.MissionCode] = p return nil } func (r *fakeRepo) UpsertAwardIncumbentRole(_ context.Context, p dbgen.UpsertAwardIncumbentRoleParams) error { r.incumbentRoles[p.RecordTable+"|"+p.RecordID+"|"+p.LaborCategory] = p return nil } // fakeFetcher returns fixed attachment text per description URL, no network. A miss returns empty text. type fakeFetcher struct{ byURL map[string]string } func (f *fakeFetcher) FetchSolicitationText(_ context.Context, descURL string, _ []string) (string, error) { return f.byURL[descURL], nil } // fakeAuditor records the events the service emits so the test can assert exactly one, with the counts // only action and no PII or record text in the body. type fakeAuditor struct{ events []audit.Event } func (a *fakeAuditor) System(_ context.Context, e audit.Event) error { a.events = append(a.events, e) return nil } // fixtureRepo builds the spec fixtures: one opportunity titled "SIGINT collection support", one award // described "3 ISSO and 2 Cyber Engineer", and one sam notice whose fetcher returns // "SIGINT Analyst x2 and ISSO". The alias index maps a curated phrase to COMSEC so the alias mechanism is // exercised alongside the keyword mechanism. func fixtureRepo() *fakeRepo { r := newFakeRepo() r.aliasPairs = map[string]string{ "comsec custodian": "COMSEC", } r.opps = []dbgen.ListOpportunityRecordsForClassifyRow{ {RecordID: "opp-1", Title: "SIGINT collection support", Psc: "", Naics: ""}, } r.awards = []dbgen.ListAwardsForClassifyRow{ {RecordID: "award-1", Description: "3 ISSO and 2 Cyber Engineer", NaicsDescription: "", NaicsCode: ""}, } r.sam = []dbgen.ListSamForClassifyRow{ {RecordID: "sam-1", DescriptionUrl: "https://api.sam.gov/desc/sam-1", ResourceLinks: []byte(`[]`)}, } return r } func fixtureFetcher() *fakeFetcher { return &fakeFetcher{byURL: map[string]string{ "https://api.sam.gov/desc/sam-1": "SIGINT Analyst x2 and ISSO", }} } // TestClassifyAllWritesExactTagsAndRoles asserts the service writes exactly the expected mission tags and // incumbent roles from the fixture records, with the right codes, mechanisms, evidence, and counts. func TestClassifyAllWritesExactTagsAndRoles(t *testing.T) { repo := fixtureRepo() aud := &fakeAuditor{} svc := NewService(repo, fixtureFetcher(), aud, 50) res, err := svc.ClassifyAll(context.Background()) if err != nil { t.Fatalf("ClassifyAll: %v", err) } // Mission tags. The opportunity title yields a SIGINT keyword hit. The award has no mission bearing // text, so it produces no mission tag. SAM notices are never mission tagged here (the 0026 CHECK // forbids it), so the only mission tag is the opportunity SIGINT one. wantTags := map[string]dbgen.UpsertFederalMissionTagParams{ "opportunity_records|opp-1|SIGINT": { RecordTable: "opportunity_records", RecordID: "opp-1", MissionCode: "SIGINT", Mechanism: "keyword", Evidence: "sigint", Confidence: confidenceFull, }, } if len(repo.missionTags) != len(wantTags) { t.Fatalf("mission tags = %v, want %v", keysOf(repo.missionTags), keysOf(wantTags)) } for k, want := range wantTags { got, ok := repo.missionTags[k] if !ok { t.Fatalf("missing mission tag %q", k) } if got.MissionCode != want.MissionCode || got.Mechanism != want.Mechanism || got.Evidence != want.Evidence || !got.Confidence.Equal(want.Confidence) { t.Fatalf("mission tag %q = %+v, want %+v", k, got, want) } } // Incumbent roles. The award description "3 ISSO and 2 Cyber Engineer" yields ISSO (one "isso" match) // and Cyber Engineer (one "cyber engineer" match). The SAM attachment text "SIGINT Analyst x2 and // ISSO" yields ISSO (one match) and Intelligence Analyst (one "sigint" match). Counts are the literal // occurrence totals, never fabricated. wantRoles := map[string]dbgen.UpsertAwardIncumbentRoleParams{ "usaspending_awards|award-1|ISSO": {RecordTable: "usaspending_awards", RecordID: "award-1", LaborCategory: "ISSO", RoleCount: 1, Evidence: "isso"}, "usaspending_awards|award-1|Cyber Engineer": {RecordTable: "usaspending_awards", RecordID: "award-1", LaborCategory: "Cyber Engineer", RoleCount: 1, Evidence: "cyber engineer"}, "sam_opportunities|sam-1|ISSO": {RecordTable: "sam_opportunities", RecordID: "sam-1", LaborCategory: "ISSO", RoleCount: 1, Evidence: "isso"}, "sam_opportunities|sam-1|Intelligence Analyst": {RecordTable: "sam_opportunities", RecordID: "sam-1", LaborCategory: "Intelligence Analyst", RoleCount: 1, Evidence: "sigint"}, } if len(repo.incumbentRoles) != len(wantRoles) { t.Fatalf("incumbent roles = %v, want %v", keysOf(repo.incumbentRoles), keysOf(wantRoles)) } for k, want := range wantRoles { got, ok := repo.incumbentRoles[k] if !ok { t.Fatalf("missing incumbent role %q", k) } if got != want { t.Fatalf("incumbent role %q = %+v, want %+v", k, got, want) } } // Result counts reflect what was actually written. if res.RecordsScanned != 3 { t.Fatalf("RecordsScanned = %d, want 3", res.RecordsScanned) } if res.MissionTagsWritten != 1 { t.Fatalf("MissionTagsWritten = %d, want 1", res.MissionTagsWritten) } if res.IncumbentRoles != 4 { t.Fatalf("IncumbentRoles = %d, want 4", res.IncumbentRoles) } if res.SamFetched != 1 || res.SamSkippedCap != 0 { t.Fatalf("SamFetched=%d SamSkippedCap=%d, want 1 and 0", res.SamFetched, res.SamSkippedCap) } // Exactly one audit event, counts only action, no PII in the action or entity fields. if len(aud.events) != 1 { t.Fatalf("audit events = %d, want 1", len(aud.events)) } if aud.events[0].Action != "aedt.federalsignal.classified" { t.Fatalf("audit action = %q", aud.events[0].Action) } } // TestClassifyAllAliasMechanism proves a curated alias phrase tags a mission with the alias mechanism, // separate from the keyword path. The alias phrase "spectrum operations" carries no mission keyword of // its own (EW's keyword form is "electronic warfare"), so only the alias layer can produce the tag, // proving the alias mechanism is genuinely exercised and not shadowed by a keyword. func TestClassifyAllAliasMechanism(t *testing.T) { repo := newFakeRepo() repo.aliasPairs = map[string]string{"spectrum operations": "EW"} repo.opps = []dbgen.ListOpportunityRecordsForClassifyRow{ {RecordID: "opp-alias", Title: "seeking Spectrum Operations support for the program", Psc: "", Naics: ""}, } svc := NewService(repo, fixtureFetcher(), &fakeAuditor{}, 50) if _, err := svc.ClassifyAll(context.Background()); err != nil { t.Fatalf("ClassifyAll: %v", err) } got, ok := repo.missionTags["opportunity_records|opp-alias|EW"] if !ok { t.Fatalf("missing EW alias tag, have %v", keysOf(repo.missionTags)) } if got.Mechanism != "alias" || got.Evidence != "spectrum operations" { t.Fatalf("alias tag = %+v, want alias mechanism with evidence spectrum operations", got) } } // TestClassifyAllIsIdempotent runs the pass twice over the same fixtures and asserts the second run wrote // the exact same row set (no duplicates, no growth), the AEDT reproducibility mandate for a repeatable // sync. func TestClassifyAllIsIdempotent(t *testing.T) { repo := fixtureRepo() svc := NewService(repo, fixtureFetcher(), &fakeAuditor{}, 50) r1, err := svc.ClassifyAll(context.Background()) if err != nil { t.Fatalf("first run: %v", err) } tagsAfterFirst := keysOf(repo.missionTags) rolesAfterFirst := keysOf(repo.incumbentRoles) r2, err := svc.ClassifyAll(context.Background()) if err != nil { t.Fatalf("second run: %v", err) } if !equalStrings(tagsAfterFirst, keysOf(repo.missionTags)) { t.Fatalf("mission tags grew on rerun: %v then %v", tagsAfterFirst, keysOf(repo.missionTags)) } if !equalStrings(rolesAfterFirst, keysOf(repo.incumbentRoles)) { t.Fatalf("incumbent roles grew on rerun: %v then %v", rolesAfterFirst, keysOf(repo.incumbentRoles)) } if r1 != r2 { t.Fatalf("result not deterministic across runs: %+v then %+v", r1, r2) } } // TestClassifyAllAttachmentCap proves the max attachments bound skips SAM notices past the cap and counts // them as skipped, never silently dropping them, and that mission tags and award roles are unaffected. func TestClassifyAllAttachmentCap(t *testing.T) { repo := fixtureRepo() repo.sam = []dbgen.ListSamForClassifyRow{ {RecordID: "sam-1", DescriptionUrl: "https://api.sam.gov/desc/sam-1", ResourceLinks: []byte(`[]`)}, {RecordID: "sam-2", DescriptionUrl: "https://api.sam.gov/desc/sam-2", ResourceLinks: []byte(`[]`)}, } fetch := &fakeFetcher{byURL: map[string]string{ "https://api.sam.gov/desc/sam-1": "ISSO", "https://api.sam.gov/desc/sam-2": "ISSO", }} svc := NewService(repo, fetch, &fakeAuditor{}, 1) // cap at one SAM notice res, err := svc.ClassifyAll(context.Background()) if err != nil { t.Fatalf("ClassifyAll: %v", err) } if res.SamFetched != 1 || res.SamSkippedCap != 1 { t.Fatalf("SamFetched=%d SamSkippedCap=%d, want 1 and 1", res.SamFetched, res.SamSkippedCap) } if _, ok := repo.incumbentRoles["sam_opportunities|sam-2|ISSO"]; ok { t.Fatalf("sam-2 was over the cap and must not have been mined") } } func keysOf[V any](m map[string]V) []string { out := make([]string, 0, len(m)) for k := range m { out = append(out, k) } sort.Strings(out) return out } func equalStrings(a, b []string) bool { if len(a) != len(b) { return false } for i := range a { if a[i] != b[i] { return false } } return true }