Added the opportunities REST API, read-only apart from their marks, described in OpenAPI 3.1 with swag
GET /api/opportunities lists the stored opportunities, newest release first,
a page at a time (offset, and limit up to 100, default 50); has_more says
whether another page follows. GET /api/opportunities/{uuid} returns one with
its releases. PUT and DELETE /api/opportunities/{uuid}/marks/{mark} set and
clear one mark (hidden, skipped, interested, bid_submitted, won, lost) and
return them all; won and lost exclude each other (409). The API uses the
types in internal/types, and changing a mark is made atomic, as the store
replaces all of an opportunity's marks at once. The marks are found by
reflection: every bool field of types.OpportunityMarks, by its JSON name, so
a new mark needs no API change.
The API has one entry point, api.New, with its dependencies as options
(api.WithStore), and the server only mounts it under /api, keeping the
middleware. New resources are added inside internal/api. job-search serve
passes server.WithStore when database.file is set, opening the store before
the server; without one there are no opportunity routes.
The API is described by swag v2 annotations on the handlers. go generate
writes the OpenAPI 3.1 description (git-ignored, like the OCDS types), which
the server embeds and serves at /api/openapi.json, with Swagger UI at
/api/docs. A test checks the description and the routes match. swag is in
the README's dev tools, pinned to v2.0.0-rc6, and the build now needs it.
This commit is contained in:
1 parent
6e101d2b4d
commit
70d62f280e
14 files changed
+994
-18
No files matched your search
@@ -0,0 +1,304 @@
|
||||
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/store"
|
||||
"git.avsdev.uk/Go/job-search/internal/types"
|
||||
)
|
||||
|
||||
// opportunitiesServer serves the opportunity routes over a store holding
|
||||
// count opportunities, ocds-1 the oldest, and returns its URL.
|
||||
func opportunitiesServer(t *testing.T, count int) 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(WithStore(opportunityStore)).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")
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user