Every save that stores new opportunities or new releases records them in a change batch (migration 00008: opportunity_change_batches and opportunity_change_batch_opportunities; the opportunities table is unchanged), as does every change of marks. A batch is named by a random SHA-256 hash, so it can't be guessed, and ordered by its created_at, which is kept strictly increasing even if the clock goes back. GET /api/opportunities pages now carry the newest batch, read before the page, and GET /api/opportunities?changed_after=<batch> returns every opportunity changed since that batch, unpaged, with the newest batch it includes. Offset and limit can't be combined with it, and an unknown batch is 410 Gone, telling the client to reload the whole list. OpportunitiesUpdated now carries its save's batch, and a "large" flag: over 50 opportunities, new and updated are left empty and only the summary counts them, so a big catch-up no longer sends tens of thousands of UUIDs.
437 lines
12 KiB
Go
437 lines
12 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.
|
|
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)
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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,
|
|
)
|
|
}
|
|
|
|
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.
|
|
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},
|
|
}
|
|
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])
|
|
}
|
|
}
|
|
}
|