package federalsignal // Offline unit test for the Phase 5a tenant mission tagging service. It injects an in memory fake repo // with fixture candidates and requirements and a fixed AliasIndex, runs the service with NO database, and // asserts the exact candidate_mission_tags and requirement_mission_tags produced (codes, mechanisms, and // evidence), plus idempotency: a second run never grows the tag set. The classifier is the same pure // deterministic missionclass the global pass uses, so the SIGINT analyst gets a SIGINT tag and the COMSEC // skill gets a COMSEC tag. import ( "context" "sort" "testing" "github.com/google/uuid" "github.com/missionworkforce/missionmatch/internal/platform/database/dbgen" "github.com/missionworkforce/missionmatch/internal/platform/missionclass" "github.com/missionworkforce/missionmatch/internal/platform/tenant" ) // fakeTenantTagRepo serves fixture rows and records every upserted tag so the test can assert the exact // output. It honors idempotency the way the real ON CONFLICT DO UPDATE does, keying candidate tags on // (candidate_id, mission_code) and requirement tags on (requirement_id, mission_code), so a second run // never grows the row set. type fakeTenantTagRepo struct { candidates []dbgen.ListCandidatesForMissionTaggingRow requirements []dbgen.ListRequirementsForMissionTaggingRow candTags map[string]dbgen.UpsertCandidateMissionTagParams reqTags map[string]dbgen.UpsertRequirementMissionTagParams } func newFakeTenantTagRepo() *fakeTenantTagRepo { return &fakeTenantTagRepo{ candTags: map[string]dbgen.UpsertCandidateMissionTagParams{}, reqTags: map[string]dbgen.UpsertRequirementMissionTagParams{}, } } func (r *fakeTenantTagRepo) ListCandidatesForTagging(_ context.Context) ([]dbgen.ListCandidatesForMissionTaggingRow, error) { return r.candidates, nil } func (r *fakeTenantTagRepo) ListRequirementsForTagging(_ context.Context) ([]dbgen.ListRequirementsForMissionTaggingRow, error) { return r.requirements, nil } func (r *fakeTenantTagRepo) UpsertCandidateMissionTags(_ context.Context, tags []dbgen.UpsertCandidateMissionTagParams) error { for _, t := range tags { r.candTags[t.CandidateID.String()+"|"+t.MissionCode] = t } return nil } func (r *fakeTenantTagRepo) UpsertRequirementMissionTags(_ context.Context, tags []dbgen.UpsertRequirementMissionTagParams) error { for _, t := range tags { r.reqTags[t.RequirementID.String()+"|"+t.MissionCode] = t } return nil } // missionTag is the comparable shape the assertions use. type missionTag struct { code, mechanism, evidence string } func TestTenantTagCandidatesAndRequirements(t *testing.T) { org := uuid.New() candID := uuid.New() reqID := uuid.New() repo := newFakeTenantTagRepo() // One candidate: headline mentions SIGINT, with a COMSEC skill. The classifier should produce a SIGINT // tag (keyword in headline) and a COMSEC tag (keyword in the joined skill text). repo.candidates = []dbgen.ListCandidatesForMissionTaggingRow{ {CandidateID: candID, Headline: "SIGINT analyst", SkillText: "COMSEC"}, } // One requirement: title and labor category plus a required skill. The title carries Electronic Warfare // (EW) and the labor category carries GEOINT, proving title and labor category and skill text all feed // the classifier. repo.requirements = []dbgen.ListRequirementsForMissionTaggingRow{ {RequirementID: reqID, Title: "Electronic Warfare Engineer", LaborCategory: "GEOINT Analyst", SkillText: "COMSEC"}, } // A deliberately empty AliasIndex: the fixture relies on the literal keyword gazetteer only, so the // assertions are about the deterministic keyword mechanism, not a curated alias. idx := missionclass.NewAliasIndex(map[string]string{}) svc := NewTenantTagService(repo, idx) ctx := tenant.WithOrg(context.Background(), org.String()) cRes, err := svc.TagCandidatesForOrg(ctx) if err != nil { t.Fatalf("TagCandidatesForOrg: %v", err) } rRes, err := svc.TagRequirementsForOrg(ctx) if err != nil { t.Fatalf("TagRequirementsForOrg: %v", err) } // Candidate: exactly SIGINT and COMSEC, both by keyword, with the canonical evidence phrases. wantCand := []missionTag{ {"SIGINT", missionclass.MechKeyword, "sigint"}, {"COMSEC", missionclass.MechKeyword, "comsec"}, } gotCand := collectCandTags(repo, candID) assertTags(t, "candidate", wantCand, gotCand) if cRes.CandidatesScanned != 1 || cRes.CandidateTags != 2 { t.Fatalf("candidate result counts = scanned %d tags %d, want scanned 1 tags 2", cRes.CandidatesScanned, cRes.CandidateTags) } // Every candidate tag must carry the org from the context, never a blank or foreign org. for k, v := range repo.candTags { if v.OrganizationID != org { t.Fatalf("candidate tag %s has org %s, want %s", k, v.OrganizationID, org) } } // Requirement: EW from the title, GEOINT from the labor category, COMSEC from the required skill text, // all by keyword. This proves the title, the labor category, AND the required skill names each feed the // requirement classifier. wantReq := []missionTag{ {"GEOINT", missionclass.MechKeyword, "geoint"}, {"COMSEC", missionclass.MechKeyword, "comsec"}, {"EW", missionclass.MechKeyword, "electronic warfare"}, } gotReq := collectReqTags(repo, reqID) assertTags(t, "requirement", wantReq, gotReq) if rRes.RequirementsScanned != 1 || rRes.RequirementTags != 3 { t.Fatalf("requirement result counts = scanned %d tags %d, want scanned 1 tags 3", rRes.RequirementsScanned, rRes.RequirementTags) } for k, v := range repo.reqTags { if v.OrganizationID != org { t.Fatalf("requirement tag %s has org %s, want %s", k, v.OrganizationID, org) } } // Idempotency: a second identical run must not grow either tag set (ON CONFLICT refreshes in place). if _, err := svc.TagCandidatesForOrg(ctx); err != nil { t.Fatalf("second TagCandidatesForOrg: %v", err) } if _, err := svc.TagRequirementsForOrg(ctx); err != nil { t.Fatalf("second TagRequirementsForOrg: %v", err) } if len(repo.candTags) != 2 { t.Fatalf("idempotency broken: candidate tag set grew to %d after a second run (want 2)", len(repo.candTags)) } if len(repo.reqTags) != 3 { t.Fatalf("idempotency broken: requirement tag set grew to %d after a second run (want 3)", len(repo.reqTags)) } } // TestTenantTagRefusesUnscopedContext proves the service fails closed when no org is in the context, // mirroring RunInTenantTx: no validated org means no tagging. func TestTenantTagRefusesUnscopedContext(t *testing.T) { repo := newFakeTenantTagRepo() repo.candidates = []dbgen.ListCandidatesForMissionTaggingRow{ {CandidateID: uuid.New(), Headline: "SIGINT analyst"}, } svc := NewTenantTagService(repo, missionclass.NewAliasIndex(map[string]string{})) if _, err := svc.TagCandidatesForOrg(context.Background()); err == nil { t.Fatal("TagCandidatesForOrg with no org in context must error (fail closed)") } if _, err := svc.TagRequirementsForOrg(context.Background()); err == nil { t.Fatal("TagRequirementsForOrg with no org in context must error (fail closed)") } if len(repo.candTags) != 0 || len(repo.reqTags) != 0 { t.Fatal("an unscoped run must write nothing") } } // TestTenantTagAliasLayer proves the curated alias layer adds a mission beyond the literal keyword: an // alias phrase that is not a gazetteer keyword still produces the right code, with the alias mechanism. func TestTenantTagAliasLayer(t *testing.T) { org := uuid.New() candID := uuid.New() repo := newFakeTenantTagRepo() repo.candidates = []dbgen.ListCandidatesForMissionTaggingRow{ {CandidateID: candID, Headline: "intercept operator", SkillText: ""}, } // "intercept operator" is not a SIGINT gazetteer keyword; the curated alias maps it to SIGINT. idx := missionclass.NewAliasIndex(map[string]string{"intercept operator": "SIGINT"}) svc := NewTenantTagService(repo, idx) ctx := tenant.WithOrg(context.Background(), org.String()) if _, err := svc.TagCandidatesForOrg(ctx); err != nil { t.Fatalf("TagCandidatesForOrg: %v", err) } got := collectCandTags(repo, candID) assertTags(t, "alias candidate", []missionTag{{"SIGINT", missionclass.MechAlias, "intercept operator"}}, got) } func collectCandTags(r *fakeTenantTagRepo, candID uuid.UUID) []missionTag { var out []missionTag for _, v := range r.candTags { if v.CandidateID == candID { out = append(out, missionTag{v.MissionCode, v.Mechanism, v.Evidence}) } } return out } func collectReqTags(r *fakeTenantTagRepo, reqID uuid.UUID) []missionTag { var out []missionTag for _, v := range r.reqTags { if v.RequirementID == reqID { out = append(out, missionTag{v.MissionCode, v.Mechanism, v.Evidence}) } } return out } // assertTags compares two tag sets order independently: the upsert map iteration order is not stable, so // the test sorts both sides by code before comparing. func assertTags(t *testing.T, label string, want, got []missionTag) { t.Helper() byCode := func(s []missionTag) { sort.Slice(s, func(i, j int) bool { return s[i].code < s[j].code }) } byCode(want) byCode(got) if len(want) != len(got) { t.Fatalf("%s tags: got %d %+v, want %d %+v", label, len(got), got, len(want), want) } for i := range want { if want[i] != got[i] { t.Fatalf("%s tag %d: got %+v, want %+v", label, i, got[i], want[i]) } } }