Files
job-search/internal/api/opportunities_test.go
bwilliams b0230e6847 Replaced the Title filter with a general search, run by the backend
GET /api/opportunities/search?q= returns the UUIDs of the matching
opportunities, and the frontend filters its list by them. Words without
a prompt search the title and description; title:, buyer:, buyer_id:
and cpv: search only their own field, and a leading ! leaves out what a
term matches. Repeats of one prompt are OR, everything else AND. CPV
terms are checked per code: cpv:72 !cpv:7221 needs a code starting 72
that doesn't start 7221.

Unknown prompts are reported, and every response carries the help that
the search box's "?" panel shows. Migration 00011 adds the description
and buyer name to the full-text index, and indexes CPV descriptions.
2026-10-22 13:10:25 +01:00

538 lines
16 KiB
Go

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