package samgov import ( "context" "errors" "fmt" "net/http" "net/http/httptest" "strconv" "strings" "testing" "time" ) func TestSearchRejectsWindowOverOneYear(t *testing.T) { c := New("http://127.0.0.1:0", "k") // must fail BEFORE any network call from := time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC) _, err := c.Search(context.Background(), SearchParams{ PostedFrom: from, PostedTo: from.AddDate(1, 0, 1), NAICS: "541512", Limit: 10, }) if err == nil { t.Fatal("a >1-year window must be rejected before the request is sent") } } func TestSearchWindowBoundary(t *testing.T) { // SAM accepts 364 civil days but rejects 365. The client must agree (probed live 2026 06 24). srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { _, _ = w.Write([]byte(`{"totalRecords":0,"limit":1,"offset":0,"opportunitiesData":[]}`)) })) defer srv.Close() c := New(srv.URL, "k") from := time.Date(2025, 6, 25, 0, 0, 0, 0, time.UTC) if _, err := c.Search(context.Background(), SearchParams{PostedFrom: from, PostedTo: from.AddDate(0, 0, 364), Limit: 1}); err != nil { t.Fatalf("364-day window must be accepted, got %v", err) } if _, err := c.Search(context.Background(), SearchParams{PostedFrom: from, PostedTo: from.AddDate(0, 0, 365), Limit: 1}); err == nil { t.Fatal("365-day window must be rejected before any call") } } func TestSearchSendsApiKeyAndDates(t *testing.T) { var gotKey, gotFrom, gotNcode string srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { gotKey = r.URL.Query().Get("api_key") gotFrom = r.URL.Query().Get("postedFrom") gotNcode = r.URL.Query().Get("ncode") _, _ = w.Write([]byte(`{"totalRecords":0,"limit":10,"offset":0,"opportunitiesData":[]}`)) })) defer srv.Close() c := New(srv.URL, "secret-key") from := time.Date(2026, 5, 1, 0, 0, 0, 0, time.UTC) if _, err := c.Search(context.Background(), SearchParams{ PostedFrom: from, PostedTo: from.AddDate(0, 1, 0), NAICS: "541513", Limit: 10, }); err != nil { t.Fatalf("search: %v", err) } if gotKey != "secret-key" { t.Fatalf("api_key must be sent as a query param, got %q", gotKey) } if gotFrom != "05/01/2026" { t.Fatalf("postedFrom must be MM/dd/yyyy, got %q", gotFrom) } if gotNcode != "541513" { t.Fatalf("ncode wrong: %q", gotNcode) } } func TestSearchErrorNeverLeaksApiKey(t *testing.T) { // A connection failure must not surface the api_key (url.Error embeds the full URL) [review SEC-1]. c := New("http://127.0.0.1:1", "SUPERSECRETKEY") from := time.Date(2026, 5, 1, 0, 0, 0, 0, time.UTC) _, err := c.Search(context.Background(), SearchParams{PostedFrom: from, PostedTo: from.AddDate(0, 0, 7), Limit: 1}) if err == nil { t.Fatal("expected a connection error") } if strings.Contains(err.Error(), "SUPERSECRETKEY") || strings.Contains(err.Error(), "api_key") { t.Fatalf("error must never leak the api_key: %q", err.Error()) } } func TestSearch429MapsToThrottled(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.Header().Set("Retry-After", "Wed, 24 Jun 2026 00:00:00 GMT") w.WriteHeader(http.StatusTooManyRequests) _, _ = w.Write([]byte(`{"error":"API rate limit exceeded"}`)) })) defer srv.Close() c := New(srv.URL, "k") from := time.Date(2026, 5, 1, 0, 0, 0, 0, time.UTC) _, err := c.Search(context.Background(), SearchParams{PostedFrom: from, PostedTo: from.AddDate(0, 0, 7), Limit: 1}) if !errors.Is(err, ErrThrottled) { t.Fatalf("429 must map to ErrThrottled, got %v", err) } var te *ThrottleError if !errors.As(err, &te) || te.RetryAfter == "" { t.Fatalf("Retry-After must be captured, got %+v", err) } } // pagedHandler serves total records in pages of `limit`, generating notices on the fly. func pagedHandler(total int) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { limit, _ := strconv.Atoi(r.URL.Query().Get("limit")) offset, _ := strconv.Atoi(r.URL.Query().Get("offset")) if limit <= 0 { limit = 1 } body := fmt.Sprintf(`{"totalRecords":%d,"limit":%d,"offset":%d,"opportunitiesData":[`, total, limit, offset) first := true for i := offset; i < offset+limit && i < total; i++ { if !first { body += "," } first = false body += fmt.Sprintf(`{"noticeId":"n%d","active":"Yes","naicsCode":"541512"}`, i) } body += "]}" _, _ = w.Write([]byte(body)) } } func TestSearchAllPaginatesToExhaustion(t *testing.T) { srv := httptest.NewServer(pagedHandler(5)) defer srv.Close() c := New(srv.URL, "k") from := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC) recs, total, capped, err := c.SearchAll(context.Background(), SearchParams{PostedFrom: from, PostedTo: from.AddDate(0, 6, 0), NAICS: "541512", Limit: 2}, 10) if err != nil { t.Fatalf("search all: %v", err) } if total != 5 || len(recs) != 5 { t.Fatalf("expected all 5 records, got total=%d len=%d", total, len(recs)) } if capped { t.Fatal("should have exhausted, not capped") } if recs[0].Op.NoticeID != "n0" || recs[4].Op.NoticeID != "n4" { t.Fatalf("pagination order wrong: %q .. %q", recs[0].Op.NoticeID, recs[4].Op.NoticeID) } } func TestSearchAllRespectsPageCap(t *testing.T) { srv := httptest.NewServer(pagedHandler(100)) defer srv.Close() c := New(srv.URL, "k") from := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC) recs, total, capped, err := c.SearchAll(context.Background(), SearchParams{PostedFrom: from, PostedTo: from.AddDate(0, 6, 0), Limit: 2}, 2) // 2 pages * 2 = 4 of 100 if err != nil { t.Fatalf("search all: %v", err) } if len(recs) != 4 || total != 100 || !capped { t.Fatalf("page cap not honored: len=%d total=%d capped=%v", len(recs), total, capped) } } func TestSearchAllReturnsPartialOnThrottle(t *testing.T) { var calls int srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { calls++ if calls == 1 { _, _ = w.Write([]byte(`{"totalRecords":10,"limit":2,"offset":0,"opportunitiesData":[` + `{"noticeId":"n0","active":"Yes"},{"noticeId":"n1","active":"Yes"}]}`)) return } w.WriteHeader(http.StatusTooManyRequests) _, _ = w.Write([]byte(`{"error":"rate limit"}`)) })) defer srv.Close() c := New(srv.URL, "k") from := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC) recs, _, _, err := c.SearchAll(context.Background(), SearchParams{PostedFrom: from, PostedTo: from.AddDate(0, 6, 0), Limit: 2}, 10) if !errors.Is(err, ErrThrottled) { t.Fatalf("a 429 mid-run must surface ErrThrottled, got %v", err) } if len(recs) != 2 { t.Fatalf("partial progress must be returned, got %d records", len(recs)) } }