359 lines
10 KiB
Go
359 lines
10 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",
|
|
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)
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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])
|
|
}
|
|
}
|
|
}
|