package adapters import ( "context" "errors" "net/http" "net/http/httptest" "sort" "strings" "testing" "github.com/missionworkforce/missionmatch/internal/modules/ontology" ) // calcFixture is a minimal GSA CALC v3 ceiling rates response shaped exactly like the real // Elasticsearch backed API (confirmed from https://api.gsa.gov/acquisition/calc/v3/api/ceilingrates/?q=engineer). // The labor category is at hits.hits[]._source.labor_category. // Two results share "Software Engineer I" to exercise deduplication; one is distinct. const calcFixture = `{ "took": 3, "timed_out": false, "_shards": {"total": 1, "successful": 1, "skipped": 0, "failed": 0}, "hits": { "total": {"value": 3, "relation": "eq"}, "hits": [ { "_index": "calc", "_id": "100001", "_score": 1.0, "_source": { "labor_category": "Software Engineer I", "vendor_name": "Acme Federal LLC", "current_price": 85.00, "education_level": "Bachelors", "min_years_experience": 3, "security_clearance": "None", "worksite": "Both" } }, { "_index": "calc", "_id": "100002", "_score": 1.0, "_source": { "labor_category": "Software Engineer I", "vendor_name": "Beta Solutions Inc", "current_price": 92.00, "education_level": "Masters", "min_years_experience": 5, "security_clearance": "Secret", "worksite": "Customer Site" } }, { "_index": "calc", "_id": "100003", "_score": 1.0, "_source": { "labor_category": "Systems Analyst II", "vendor_name": "GovCon Corp", "current_price": 110.00, "education_level": "Bachelors", "min_years_experience": 7, "security_clearance": "TS", "worksite": "Contractor Site" } } ] } }` // TestParseCALC feeds a literal fixture (two results with the same labor category and one // distinct) to parseCALC and confirms the returned slice contains the two distinct strings. func TestParseCALC(t *testing.T) { got, err := parseCALC([]byte(calcFixture)) if err != nil { t.Fatalf("parseCALC returned an error: %v", err) } // The fixture has 3 hits but only 2 distinct labor categories. // parseCALC returns them deduplicated; order is not guaranteed. want := map[string]bool{ "Software Engineer I": true, "Systems Analyst II": true, } if len(got) != 2 { t.Fatalf("want 2 distinct labor categories, got %d: %v", len(got), got) } for _, lc := range got { if !want[lc] { t.Errorf("unexpected labor category %q", lc) } } } // TestParseCALCEmpty confirms parseCALC handles an empty hits.hits array without error. func TestParseCALCEmpty(t *testing.T) { empty := `{"hits":{"total":{"value":0,"relation":"eq"},"hits":[]}}` got, err := parseCALC([]byte(empty)) if err != nil { t.Fatalf("parseCALC on empty hits returned error: %v", err) } if len(got) != 0 { t.Fatalf("want 0 results for empty hits, got %d", len(got)) } } // TestCALCSourceDedupes stands up an httptest.Server returning the calcFixture for every // query. It uses a two item seed list where both queries return the same fixture, confirming // that Fetch deduplicates across seeds and yields each distinct labor category exactly once. func TestCALCSourceDedupes(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusOK) _, _ = w.Write([]byte(calcFixture)) })) defer srv.Close() // Two seeds that both return the same fixture: total 4 hits but only 2 distinct categories. src := NewCALCSource(srv.URL+"/", "", []string{"engineer", "analyst"}, srv.Client()) var records []ontology.VocabRecord err := src.Fetch(context.Background(), func(r ontology.VocabRecord) error { records = append(records, r) return nil }) if err != nil { t.Fatalf("Fetch returned an error: %v", err) } // Exactly 2 distinct labor categories must be yielded regardless of seed count. if len(records) != 2 { t.Fatalf("want 2 records (deduplicated across seeds), got %d", len(records)) } // Sort for deterministic assertion order. sort.Slice(records, func(i, j int) bool { return records[i].Term < records[j].Term }) wantTerms := []string{"Software Engineer I", "Systems Analyst II"} for i, r := range records { if r.Kind != ontology.KindLaborCategory { t.Errorf("record[%d]: want Kind %q, got %q", i, ontology.KindLaborCategory, r.Kind) } if r.SourceName != "GSA CALC" { t.Errorf("record[%d]: want SourceName %q, got %q", i, "GSA CALC", r.SourceName) } if r.Category != "labor_category" { t.Errorf("record[%d]: want Category %q, got %q", i, "labor_category", r.Category) } if r.Term != wantTerms[i] { t.Errorf("record[%d]: want Term %q, got %q", i, wantTerms[i], r.Term) } } // SourceRecordID must be the lowercased labor category text. byTerm := make(map[string]ontology.VocabRecord, len(records)) for _, r := range records { byTerm[r.Term] = r } for _, term := range wantTerms { r := byTerm[term] wantID := strings.ToLower(term) if r.SourceRecordID != wantID { t.Errorf("Term %q: want SourceRecordID %q, got %q", term, wantID, r.SourceRecordID) } } } // TestCALCSourceThrottled confirms that a 429 response is surfaced as ontology.ErrThrottled. func TestCALCSourceThrottled(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusTooManyRequests) })) defer srv.Close() src := NewCALCSource(srv.URL+"/", "", []string{"engineer"}, srv.Client()) err := src.Fetch(context.Background(), func(r ontology.VocabRecord) error { return nil }) if err == nil { t.Fatal("expected an error on 429, got nil") } // The error must wrap or equal ErrThrottled. if !errors.Is(err, ontology.ErrThrottled) { t.Errorf("want ErrThrottled, got %v", err) } }