Files
job-search/internal/api/opportunities_test.go
T
bwilliams 746fa0a9c1 Added a "Published in the last X days" filter, applied by the backend
GET /api/opportunities takes released_from, an RFC 3339 time, and lists
only the opportunities whose latest release is at or after it. It works
with paging and with changed_after, which keeps the same batch either
way; an invalid value is 400.

The frontend's filter defaults to 7 days, from midnight UK time, and
sends it with every load and catch-up, so a year of opportunities isn't
downloaded to show a week's. Changing the days loads the list again;
Clear filters goes back to 7.
2026-10-19 10:05:45 +01:00

486 lines
14 KiB
Go

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/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])
}
}
}