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.
538 lines
16 KiB
Go
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)
|
|
}
|
|
}
|