package api import ( "context" "encoding/json" "fmt" "net/http" "net/http/httptest" "path/filepath" "slices" "strings" "sync" "testing" "time" "github.com/go-chi/chi/v5" "git.avsdev.uk/Go/job-search/internal/events" "git.avsdev.uk/Go/job-search/internal/store" "git.avsdev.uk/Go/job-search/internal/types" ) // opportunitiesServer serves the API, given opts as well as its store, over // a store holding count opportunities, ocds-1 the oldest, and returns its // URL. func opportunitiesServer(t *testing.T, count int, opts ...Option) string { t.Helper() opportunityStore, err := store.Open( context.Background(), filepath.Join(t.TempDir(), "api.db"), store.WithAutoMigrate(), ) if err != nil { t.Fatal(err) } t.Cleanup(func() { opportunityStore.Close() }) for i := 1; i <= count; i++ { opportunity := &types.Opportunity{ UUID: fmt.Sprintf("ocds-%d", i), UUIDType: "ocid", Title: fmt.Sprintf("Opportunity %d", i), Source: "FindATender", // Two codes, shared: ocds-1, ocds-3... have 72000000, the // others 03000000. Classifications: []types.ClassificationCode{ { IsCPV: true, Code: []string{"03000000", "72000000"}[i%2], Classification: []string{"Agriculture", "IT services"}[i%2], }, }, Releases: []types.OpportunityRelease{ { ReleaseID: "r1", ReleaseDate: time.Date(2026, 10, i, 12, 0, 0, 0, time.UTC), JSON: "{}", }, }, } _, err := opportunityStore.SaveOpportunity( context.Background(), opportunity, ) if err != nil { t.Fatal(err) } } router := chi.NewRouter() New(append([]Option{WithStore(opportunityStore)}, opts...)...).Routes(router) server := httptest.NewServer(router) t.Cleanup(server.Close) return server.URL } // request sends a bodyless request and decodes a JSON reply into reply (if // not nil), returning the status. func request(t *testing.T, method, url string, reply any) int { t.Helper() req, err := http.NewRequest(method, url, nil) if err != nil { t.Fatal(err) } res, err := http.DefaultClient.Do(req) if err != nil { t.Fatal(err) } defer res.Body.Close() if reply != nil && res.StatusCode < 300 { if err := json.NewDecoder(res.Body).Decode(reply); err != nil { t.Fatal(err) } } return res.StatusCode } // uuids returns the UUIDs of a page's opportunities. func uuids(page OpportunityPage) []string { var ids []string for _, opportunity := range page.Opportunities { ids = append(ids, opportunity.UUID) } return ids } // Opportunities are listed newest first, a page at a time, from // released_from if given (ocds-N is released 2026-10-0N at 12:00 UTC). func TestListOpportunitiesPaging(t *testing.T) { url := opportunitiesServer(t, 5) all := []string{"ocds-5", "ocds-4", "ocds-3", "ocds-2", "ocds-1"} tests := []struct { query string want []string hasMore bool }{ {"?limit=2", []string{"ocds-5", "ocds-4"}, true}, {"?limit=2&offset=2", []string{"ocds-3", "ocds-2"}, true}, {"?limit=2&offset=4", []string{"ocds-1"}, false}, {"?limit=5", all, false}, {"?offset=10", nil, false}, {"", all, false}, {"?released_from=", all, false}, {"?released_from=2026-10-03T12:00:00Z", []string{"ocds-5", "ocds-4", "ocds-3"}, false}, {"?released_from=2026-10-03T13:00:00%2B01:00", []string{"ocds-5", "ocds-4", "ocds-3"}, false}, {"?released_from=2026-10-03T12:00:01Z", []string{"ocds-5", "ocds-4"}, false}, {"?released_from=2026-10-02T00:00:00Z&limit=1", []string{"ocds-5"}, true}, {"?released_from=2026-10-02T00:00:00Z&limit=2&offset=2", []string{"ocds-3", "ocds-2"}, false}, } for _, tt := range tests { var page OpportunityPage status := request(t, "GET", url+"/opportunities"+tt.query, &page) if status != 200 { t.Fatalf("%s: status %d", tt.query, status) } got := strings.Join(uuids(page), ",") want := strings.Join(tt.want, ",") if got != want || page.HasMore != tt.hasMore { t.Errorf( "%s: got %s, has_more %v; want %s, %v", tt.query, got, page.HasMore, want, tt.hasMore, ) } } var page OpportunityPage request(t, "GET", url+"/opportunities", &page) if page.Limit != 50 || page.Offset != 0 { t.Errorf( "defaults: limit %d, offset %d; want 50, 0", page.Limit, page.Offset, ) } } // An empty page is [] in JSON, not null. func TestListOpportunitiesEmpty(t *testing.T) { url := opportunitiesServer(t, 0) res, err := http.Get(url + "/opportunities") if err != nil { t.Fatal(err) } defer res.Body.Close() var raw map[string]json.RawMessage json.NewDecoder(res.Body).Decode(&raw) if string(raw["opportunities"]) != "[]" { t.Errorf("opportunities = %s; want []", raw["opportunities"]) } } // Bad paging or released_from is 400. An unencoded "+" in released_from // arrives as a space, so it is refused too. func TestListOpportunitiesBadPaging(t *testing.T) { url := opportunitiesServer(t, 1) queries := []string{ "?limit=0", "?limit=101", "?limit=x", "?offset=-1", "?released_from=x", "?released_from=2026-10-03", "?released_from=2026-10-03T13:00:00+01:00", } for _, query := range queries { status := request(t, "GET", url+"/opportunities"+query, nil) if status != 400 { t.Errorf("%s: status %d; want 400", query, status) } } } // A page names the newest change batch, and changed_after lists what // changed after a batch: here, a change of marks. func TestListChangedOpportunities(t *testing.T) { url := opportunitiesServer(t, 3) var page OpportunityPage request(t, "GET", url+"/opportunities", &page) if page.Batch == "" { t.Fatal("the page names no batch") } var changed OpportunityPage status := request( t, "GET", url+"/opportunities?changed_after="+page.Batch, &changed, ) if status != 200 || len(changed.Opportunities) != 0 || changed.Opportunities == nil || changed.Batch != page.Batch { t.Fatalf( "nothing changed: status %d, %+v; want [] and the same batch", status, changed, ) } request(t, "PUT", url+"/opportunities/ocds-2/marks/skipped", nil) status = request( t, "GET", url+"/opportunities?changed_after="+page.Batch, &changed, ) if status != 200 || strings.Join(uuids(changed), ",") != "ocds-2" || !changed.Opportunities[0].Marks.Skipped { t.Fatalf("after a mark: status %d, %+v; want ocds-2", status, changed) } if changed.Batch == page.Batch || changed.Limit != 1 || changed.HasMore { t.Errorf( "after a mark: batch %q, limit %d, has_more %v; "+ "want a newer batch, 1, false", changed.Batch, changed.Limit, changed.HasMore, ) } // released_from leaves out ocds-2 (released on the 2nd), not the batch. var bounded OpportunityPage status = request( t, "GET", url+"/opportunities?released_from=2026-10-03T00:00:00Z"+ "&changed_after="+page.Batch, &bounded, ) if status != 200 || len(bounded.Opportunities) != 0 || bounded.Batch != changed.Batch { t.Errorf( "released_from the 3rd: status %d, %+v; want [] and batch %q", status, bounded, changed.Batch, ) } status = request( t, "GET", url+"/opportunities?changed_after="+page.Batch+ "&released_from=2026-10-02T12:00:00Z", &bounded, ) if status != 200 || strings.Join(uuids(bounded), ",") != "ocds-2" { t.Errorf( "released_from the 2nd: status %d, %+v; want ocds-2", status, bounded, ) } var latest OpportunityPage request(t, "GET", url+"/opportunities?limit=1", &latest) if latest.Batch != changed.Batch { t.Errorf("page batch %q; want %q", latest.Batch, changed.Batch) } } // An unknown batch is 410 Gone, and paging can't be used with changed_after; // a bad released_from is 400 there too. func TestListChangedOpportunitiesRefused(t *testing.T) { url := opportunitiesServer(t, 1) var page OpportunityPage request(t, "GET", url+"/opportunities", &page) tests := []struct { query string want int }{ {"?changed_after=unknown", http.StatusGone}, {"?changed_after=", http.StatusGone}, {"?changed_after=" + page.Batch + "&limit=5", http.StatusBadRequest}, {"?offset=0&changed_after=" + page.Batch, http.StatusBadRequest}, {"?changed_after=" + page.Batch + "&released_from=x", http.StatusBadRequest}, } for _, tt := range tests { status := request(t, "GET", url+"/opportunities"+tt.query, nil) if status != tt.want { t.Errorf("%s: status %d; want %d", tt.query, status, tt.want) } } } // One opportunity comes with its releases; a missing one is 404. func TestGetOpportunity(t *testing.T) { url := opportunitiesServer(t, 2) var opportunity types.Opportunity status := request(t, "GET", url+"/opportunities/ocds-2", &opportunity) if status != 200 { t.Fatalf("status %d", status) } if opportunity.UUID != "ocds-2" || len(opportunity.Releases) != 1 { t.Errorf("got %+v; want ocds-2 with its release", opportunity) } status = request(t, "GET", url+"/opportunities/nope", nil) if status != 404 { t.Errorf("missing: status %d; want 404", status) } } // Opportunities are read-only: there is no way to write one. func TestOpportunitiesReadOnly(t *testing.T) { url := opportunitiesServer(t, 1) for _, method := range []string{"POST", "PUT", "PATCH", "DELETE"} { status := request(t, method, url+"/opportunities/ocds-1", nil) if status != http.StatusMethodNotAllowed { t.Errorf("%s: status %d; want 405", method, status) } } if status := request(t, "POST", url+"/opportunities", nil); status != 405 { t.Errorf("POST /opportunities: status %d; want 405", status) } } // A mark is set with PUT and cleared with DELETE, leaving the others, and // both reply with all the marks. func TestChangeMark(t *testing.T) { url := opportunitiesServer(t, 1) marks := url + "/opportunities/ocds-1/marks/" var got types.OpportunityMarks request(t, "PUT", marks+"interested", &got) request(t, "PUT", marks+"bid_submitted", &got) if !got.Interested || !got.BidSubmitted || got.UUID != "ocds-1" { t.Fatalf("after two PUTs: %+v", got) } request(t, "DELETE", marks+"interested", &got) if got.Interested || !got.BidSubmitted { t.Errorf("after DELETE: %+v; want only bid_submitted", got) } // The change is stored, and setting a set mark again is harmless. request(t, "PUT", marks+"bid_submitted", &got) var opportunity types.Opportunity request(t, "GET", url+"/opportunities/ocds-1", &opportunity) if !opportunity.Marks.BidSubmitted || opportunity.Marks.Interested { t.Errorf("stored marks: %+v", opportunity.Marks) } } // Won and lost exclude each other; unknown marks and opportunities are 404. func TestChangeMarkRefused(t *testing.T) { url := opportunitiesServer(t, 1) marks := url + "/opportunities/ocds-1/marks/" request(t, "PUT", marks+"won", nil) status := request(t, "PUT", marks+"lost", nil) if status != http.StatusConflict { t.Errorf("lost while won: status %d; want 409", status) } status = request(t, "PUT", marks+"shiny", nil) if status != 404 { t.Errorf("unknown mark: status %d; want 404", status) } status = request(t, "PUT", url+"/opportunities/nope/marks/won", nil) if status != 404 { t.Errorf("unknown opportunity: status %d; want 404", status) } // Clearing won makes lost allowed. request(t, "DELETE", marks+"won", nil) status = request(t, "PUT", marks+"lost", nil) if status != 200 { t.Errorf("lost after clearing won: status %d; want 200", status) } } // Concurrent changes to different marks of one opportunity all stick. func TestChangeMarkConcurrent(t *testing.T) { url := opportunitiesServer(t, 1) names := []string{"hidden", "skipped", "interested", "bid_submitted", "won"} var wg sync.WaitGroup for _, name := range names { wg.Add(1) go func() { defer wg.Done() request(t, "PUT", url+"/opportunities/ocds-1/marks/"+name, nil) }() } wg.Wait() var opportunity types.Opportunity request(t, "GET", url+"/opportunities/ocds-1", &opportunity) m := opportunity.Marks if !m.Hidden || !m.Skipped || !m.Interested || !m.BidSubmitted || !m.Won { t.Errorf("marks after concurrent PUTs: %+v; want all five set", m) } } // The marks are OpportunityMarks' bool fields, by JSON name, and setting one // by name sets only that field. func TestFindMarks(t *testing.T) { want := "hidden,skipped,interested,bid_submitted,won,lost" if got := strings.Join(markNames, ","); got != want { t.Fatalf("markNames = %s; want %s", got, want) } for _, name := range markNames { var marks types.OpportunityMarks if !setMark(&marks, name, true) { t.Fatalf("setMark(%q) = false", name) } encoded, err := json.Marshal(marks) if err != nil { t.Fatal(err) } var fields map[string]any json.Unmarshal(encoded, &fields) for field, value := range fields { if set := value == true; set != (field == name) { t.Errorf("setting %s: %s = %v", name, field, value) } } } var marks types.OpportunityMarks if setMark(&marks, "uuid", true) || setMark(&marks, "shiny", true) { t.Error("setMark accepted a name that isn't a mark") } } // A change to a mark is published with all the marks; a change that changes // nothing, or is refused, isn't. func TestChangeMarkPublishes(t *testing.T) { var bus events.Bus var mu sync.Mutex var published []types.OpportunityMarks bus.Subscribe(func(event events.Event) { if event.Type != events.OpportunityMarksUpdated { t.Errorf("event type %q", event.Type) } mu.Lock() defer mu.Unlock() published = append(published, event.Payload.(types.OpportunityMarks)) }) url := opportunitiesServer(t, 1, WithEvents(&bus)) marks := url + "/opportunities/ocds-1/marks/" request(t, "PUT", marks+"interested", nil) // published request(t, "PUT", marks+"interested", nil) // already set: not published request(t, "DELETE", marks+"skipped", nil) // not set: not published request(t, "PUT", marks+"shiny", nil) // no such mark: refused request(t, "PUT", marks+"won", nil) // published request(t, "PUT", marks+"lost", nil) // won and lost: refused request(t, "DELETE", marks+"interested", nil) // published mu.Lock() defer mu.Unlock() want := []types.OpportunityMarks{ { UUID: "ocds-1", Interested: true, }, { UUID: "ocds-1", Interested: true, Won: true, }, { UUID: "ocds-1", Won: true, }, } if len(published) != len(want) { t.Fatalf("published %+v; want %+v", published, want) } for i := range want { if published[i] != want[i] { t.Errorf("event %d: %+v; want %+v", i, published[i], want[i]) } } } // GET /opportunities/search returns the matching UUIDs, bounded by // released_from, with the help; a search with nothing to search for matches // all, and unknown prompts are listed. func TestSearchOpportunities(t *testing.T) { url := opportunitiesServer(t, 5) + "/opportunities/search" from3 := "&released_from=2026-10-03T00:00:00Z" cases := []struct { query string uuids []string matchesAll bool unknown []string }{ {"", []string{}, true, []string{}}, {"?q=", []string{}, true, []string{}}, {"?q=cpv:72", []string{"ocds-1", "ocds-3", "ocds-5"}, false, []string{}}, {"?q=cpv:72" + from3, []string{"ocds-3", "ocds-5"}, false, []string{}}, {"?q=opportunity%20!cpv:72", []string{"ocds-2", "ocds-4"}, false, []string{}}, {"?q=nothing", []string{}, false, []string{}}, {"?q=foo:x%20Bar:y", []string{}, true, []string{"foo", "Bar"}}, {"?q=foo:x%20title:3", []string{"ocds-3"}, false, []string{"foo"}}, } for _, tc := range cases { var got OpportunitySearch status := request(t, http.MethodGet, url+tc.query, &got) slices.Sort(got.UUIDs) if status != http.StatusOK || !slices.Equal(got.UUIDs, tc.uuids) || got.MatchesAll != tc.matchesAll || !slices.Equal(got.UnknownPrompts, tc.unknown) { t.Errorf("%s: status %d, %+v; want %v, matches_all %v, unknown %v", tc.query, status, got, tc.uuids, tc.matchesAll, tc.unknown) } if len(got.Prompts) == 0 || len(got.Syntax) == 0 { t.Errorf("%s: no help: %+v", tc.query, got) } } // Empty lists are [], never null. var raw map[string]json.RawMessage request(t, http.MethodGet, url+"?q=nothing", &raw) for _, field := range []string{"uuids", "unknown_prompts"} { if string(raw[field]) != "[]" { t.Errorf("%s = %s; want []", field, raw[field]) } } status := request(t, http.MethodGet, url+"?q=x&released_from=x", nil) if status != http.StatusBadRequest { t.Errorf("bad released_from: status %d; want 400", status) } }