package api import ( "context" "encoding/json" "fmt" "net/http" "net/http/httptest" "path/filepath" "strings" "sync" "testing" "time" "github.com/go-chi/chi/v5" "git.avsdev.uk/Go/job-search/internal/store" "git.avsdev.uk/Go/job-search/internal/types" ) // opportunitiesServer serves the opportunity routes over a store holding // count opportunities, ocds-1 the oldest, and returns its URL. func opportunitiesServer(t *testing.T, count int) 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", 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(WithStore(opportunityStore)).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. 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}, } 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"]) } } func TestListOpportunitiesBadPaging(t *testing.T) { url := opportunitiesServer(t, 1) queries := []string{"?limit=0", "?limit=101", "?limit=x", "?offset=-1"} for _, query := range queries { status := request(t, "GET", url+"/opportunities"+query, nil) if status != 400 { t.Errorf("%s: status %d; want 400", query, status) } } } // 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") } }