Added unit tests, documentation and function descriptions across the board. Increased cyclop complexity threshold to 15 which is a bit more sane than 10 and excluded _test.go files from linters.

This commit is contained in:
bwilliams committed 2026-10-09 18:27:51 +01:00
1 parent 005b335371
commit 7fd321df97
31 files changed
+4106 -1

No files matched your search

+796
View File
@@ -0,0 +1,796 @@
package findATender
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"log/slog"
"net/http"
"net/http/httptest"
"strings"
"sync/atomic"
"testing"
"time"
"git.avsdev.uk/Go/job-search/internal/config"
"git.avsdev.uk/Go/job-search/internal/findATender/ocds"
resty "resty.dev/v3"
)
func testConfig(level string) *config.JobSearchConfig {
return &config.JobSearchConfig{Logging: config.JobSearchConfigLogging{Level: level}}
}
// quietLogs discards slog output for the duration of the test, or sends it
// through h when given.
func quietLogs(t *testing.T, h slog.Handler) {
t.Helper()
if h == nil {
h = slog.NewTextHandler(io.Discard, nil)
}
prev := slog.Default()
slog.SetDefault(slog.New(h))
t.Cleanup(func() { slog.SetDefault(prev) })
}
// newTestFT starts h on a test server and returns a FindATender pointed at it.
func newTestFT(t *testing.T, level string, h http.HandlerFunc) (*FindATender, *httptest.Server) {
t.Helper()
quietLogs(t, nil)
srv := httptest.NewServer(h)
t.Cleanup(srv.Close)
ft := New(testConfig(level), srv.URL)
t.Cleanup(ft.Shutdown)
return ft, srv
}
func releaseJSON(ocid, id string) string {
return fmt.Sprintf(
`{"ocid":%q,"id":%q,"date":"2026-10-08T12:00:00Z","tag":["tender"],"initiationType":"tender"}`,
ocid,
id,
)
}
func packageJSON(next string, releases ...string) string {
links := ""
if next != "" {
links = fmt.Sprintf(`,"links":{"next":%q}`, next)
}
return fmt.Sprintf(
`{"uri":"u","version":"1.1","releases":[%s]%s}`,
strings.Join(releases, ","),
links,
)
}
func TestRateSleep(t *testing.T) {
if err := rateSleep(context.Background(), time.Millisecond); err != nil {
t.Errorf("elapsed sleep returned %v", err)
}
ctx, cancel := context.WithCancel(context.Background())
cancel()
start := time.Now()
if err := rateSleep(ctx, time.Hour); !errors.Is(err, context.Canceled) {
t.Errorf("cancelled sleep returned %v, want context.Canceled", err)
}
if time.Since(start) > time.Second {
t.Error("cancelled sleep did not return promptly")
}
}
func TestFetchWithOptsBuildsURI(t *testing.T) {
var gotPath, gotQuery string
ft, _ := newTestFT(t, "warn", func(w http.ResponseWriter, r *http.Request) {
gotPath, gotQuery = r.URL.Path, r.URL.RawQuery
io.WriteString(w, packageJSON(""))
})
since := time.Date(2026, 10, 8, 9, 30, 15, 0, time.UTC)
res, err := ft.fetchWithOpts(RequestOptions{Since: since, Limit: 7})
if err != nil {
t.Fatal(err)
}
res.Body.Close()
if gotPath != "/ocdsReleasePackages" {
t.Errorf("path = %q", gotPath)
}
if gotQuery != "updatedFrom=2026-10-08T09:30:15&limit=7" {
t.Errorf("query = %q", gotQuery)
}
res, err = ft.fetchWithOpts(RequestOptions{Since: since})
if err != nil {
t.Fatal(err)
}
res.Body.Close()
if !strings.HasSuffix(gotQuery, "&limit=100") {
t.Errorf("default limit not applied: %q", gotQuery)
}
}
// A zero Since defaults to 24 hours ago.
func TestFetchWithOptsDefaultSince(t *testing.T) {
var gotQuery string
ft, _ := newTestFT(t, "warn", func(w http.ResponseWriter, r *http.Request) {
gotQuery = r.URL.Query().Get("updatedFrom")
io.WriteString(w, packageJSON(""))
})
// updatedFrom has whole seconds, so allow for the truncation.
before := time.Now().AddDate(0, 0, -1).Truncate(time.Second)
res, err := ft.fetchWithOpts(RequestOptions{})
if err != nil {
t.Fatal(err)
}
res.Body.Close()
after := time.Now().AddDate(0, 0, -1)
from, err := time.ParseInLocation("2006-01-02T15:04:05", gotQuery, time.Local)
if err != nil {
t.Fatalf("updatedFrom = %q: %v", gotQuery, err)
}
if from.Before(before) || from.After(after) {
t.Errorf("updatedFrom = %v, want between %v and %v", from, before, after)
}
}
func TestFetchURIRateLimit(t *testing.T) {
tests := []struct {
name string
retryAfter string
allowRateLimit bool
wantErr error
wantErrText string
wantHits int32
}{
{"not allowed", "0", false, ErrRateLimitExceeded, "", 1},
{"allowed, retries", "0", true, nil, "", 2},
{"allowed, bad retry-after", "soon", true, nil, "unknown retry-after value", 1},
{"allowed, missing retry-after", "", true, nil, "unknown retry-after value", 1},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
var hits atomic.Int32
ft, srv := newTestFT(t, "warn", func(w http.ResponseWriter, r *http.Request) {
if hits.Add(1) == 1 {
if tt.retryAfter != "" {
w.Header().Set("Retry-After", tt.retryAfter)
}
w.WriteHeader(http.StatusTooManyRequests)
return
}
io.WriteString(w, packageJSON("", releaseJSON("ocds-1", "1")))
})
res, err := ft.fetchURI(srv.URL+"/x", tt.allowRateLimit)
switch {
case tt.wantErr != nil:
if !errors.Is(err, tt.wantErr) {
t.Fatalf("err = %v, want %v", err, tt.wantErr)
}
case tt.wantErrText != "":
if err == nil || !strings.Contains(err.Error(), tt.wantErrText) {
t.Fatalf("err = %v, want containing %q", err, tt.wantErrText)
}
default:
if err != nil {
t.Fatal(err)
}
defer res.Body.Close()
if res.StatusCode() != http.StatusOK {
t.Errorf("status = %d after retry", res.StatusCode())
}
}
if got := hits.Load(); got != tt.wantHits {
t.Errorf("server hit %d times, want %d", got, tt.wantHits)
}
})
}
}
func TestFetchURIRateLimitCancelled(t *testing.T) {
ft, srv := newTestFT(t, "warn", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Retry-After", "3600")
w.WriteHeader(http.StatusTooManyRequests)
})
ctx, cancel := context.WithCancel(context.Background())
withCtx := ft.With(ctx)
go func() {
time.Sleep(20 * time.Millisecond)
cancel()
}()
start := time.Now()
if _, err := withCtx.fetchURI(srv.URL, true); !errors.Is(err, ErrFetchCancelled) {
t.Fatalf("err = %v, want ErrFetchCancelled", err)
}
if time.Since(start) > 5*time.Second {
t.Error("cancellation did not interrupt the rate limit wait")
}
}
func TestFetchURIRateLimitTwice(t *testing.T) {
// A second 429 after waiting is reported as ErrRateLimitExceeded.
var hits atomic.Int32
ft, srv := newTestFT(t, "warn", func(w http.ResponseWriter, r *http.Request) {
hits.Add(1)
w.Header().Set("Retry-After", "0")
w.WriteHeader(http.StatusTooManyRequests)
})
if _, err := ft.fetchURI(srv.URL, true); !errors.Is(err, ErrRateLimitExceeded) {
t.Fatalf("err = %v, want ErrRateLimitExceeded", err)
}
if n := hits.Load(); n != 2 {
t.Errorf("requests = %d, want 2", n)
}
}
func TestFetchURIUnexpectedStatus(t *testing.T) {
for _, status := range []int{
http.StatusMovedPermanently, http.StatusBadRequest,
http.StatusNotFound, http.StatusInternalServerError,
} {
t.Run(http.StatusText(status), func(t *testing.T) {
ft, srv := newTestFT(t, "warn", func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(status)
io.WriteString(w, packageJSON("", releaseJSON("ocds-1", "1")))
})
_, err := ft.fetchURI(srv.URL, true)
if !errors.Is(err, ErrUnexpectedStatus) {
t.Fatalf("err = %v, want ErrUnexpectedStatus", err)
}
if !strings.Contains(err.Error(), fmt.Sprint(status)) {
t.Errorf("err = %q, want it to include the status %d", err, status)
}
})
}
}
// A non-2xx after waiting out a 429 is reported as ErrUnexpectedStatus.
func TestFetchURIRateLimitThenError(t *testing.T) {
var hits atomic.Int32
ft, srv := newTestFT(t, "warn", func(w http.ResponseWriter, r *http.Request) {
if hits.Add(1) == 1 {
w.Header().Set("Retry-After", "0")
w.WriteHeader(http.StatusTooManyRequests)
return
}
w.WriteHeader(http.StatusServiceUnavailable)
})
if _, err := ft.fetchURI(srv.URL, true); !errors.Is(err, ErrUnexpectedStatus) {
t.Fatalf("err = %v, want ErrUnexpectedStatus", err)
}
}
func TestFetchURIConnectionError(t *testing.T) {
ft, srv := newTestFT(t, "warn", func(w http.ResponseWriter, r *http.Request) {})
srv.Close()
ft.httpClient.SetRetryCount(0)
if _, err := ft.fetchURI(srv.URL, true); err == nil {
t.Fatal("expected an error from a closed server")
}
}
func TestFetchURICancelledContext(t *testing.T) {
ft, srv := newTestFT(t, "warn", func(w http.ResponseWriter, r *http.Request) {
io.WriteString(w, packageJSON(""))
})
ctx, cancel := context.WithCancel(context.Background())
cancel()
if _, err := ft.With(ctx).fetchURI(srv.URL, true); err == nil {
t.Fatal("expected an error from a cancelled context")
}
}
func TestFetchURIDebugTrace(t *testing.T) {
handlers := map[string]func(io.Writer) slog.Handler{
"text": func(w io.Writer) slog.Handler {
return slog.NewTextHandler(w, &slog.HandlerOptions{Level: slog.LevelDebug})
},
"json": func(w io.Writer) slog.Handler {
return slog.NewJSONHandler(w, &slog.HandlerOptions{Level: slog.LevelDebug})
},
}
for name, mk := range handlers {
t.Run(name, func(t *testing.T) {
ft, _ := newTestFT(t, "debug", func(w http.ResponseWriter, r *http.Request) {
io.WriteString(w, packageJSON("", releaseJSON("ocds-1", "1")))
})
var logs strings.Builder
quietLogs(t, mk(&logs))
// The body must still be readable after the debug logging.
releases, err := ft.fetchOnce(RequestOptions{Since: time.Now()})
if err != nil {
t.Fatal(err)
}
if len(releases) != 1 {
t.Errorf("got %d releases, want 1", len(releases))
}
if !strings.Contains(logs.String(), "Resty") {
t.Errorf("no trace logged: %s", logs.String())
}
})
}
}
func TestFetchURIDebugTraceConnectionError(t *testing.T) {
ft, srv := newTestFT(t, "debug", func(w http.ResponseWriter, r *http.Request) {})
srv.Close()
ft.httpClient.SetRetryCount(0)
if _, err := fetchURI(ft, srv.URL); err == nil {
t.Fatal("expected an error from a closed server")
}
}
func TestFetchNext(t *testing.T) {
var gotPath string
ft, srv := newTestFT(t, "warn", func(w http.ResponseWriter, r *http.Request) {
gotPath = r.URL.Path
io.WriteString(w, packageJSON(""))
})
for name, pkg := range map[string]*ocds.ReleasePackage{
"nil links": {},
"empty next": {Links: &ocds.ReleasePackageLinks{Prev: srv.URL}},
} {
if _, err := ft.fetchNext(pkg, true); !errors.Is(err, ErrNoNext) {
t.Errorf("%s: err = %v, want ErrNoNext", name, err)
}
}
res, err := ft.fetchNext(
&ocds.ReleasePackage{Links: &ocds.ReleasePackageLinks{Next: srv.URL + "/page2"}},
true,
)
if err != nil {
t.Fatal(err)
}
res.Body.Close()
if gotPath != "/page2" {
t.Errorf("fetched %q, want /page2", gotPath)
}
}
// responseFor fetches body from a test server so it can be unpacked.
func responseFor(t *testing.T, body string) *resty.Response {
t.Helper()
ft, srv := newTestFT(t, "warn", func(w http.ResponseWriter, r *http.Request) {
io.WriteString(w, body)
})
res, err := ft.fetchURI(srv.URL, false)
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { res.Body.Close() })
return res
}
func TestSafeUnpackReleases(t *testing.T) {
body := `{
"uri": "https://example/pkg",
"version": "1.1",
"extensions": ["ext"],
"publishedDate": "2026-10-08T12:00:00Z",
"publisher": {"name": "FTS"},
"license": "OGL",
"publicationPolicy": "policy",
"links": {"next": "https://example/next"},
"releases": [
` + releaseJSON("ocds-1", "1") + `,
{"ocid": "ocds-bad", "id": "2", "date": 12345},
{"ocid": "ocds-3", "id": "3", "date": "2026-10-08", "tag": [], "initiationType": "tender"}
]
}`
pkg, err := safeUnpackReleases(responseFor(t, body))
if err != nil {
t.Fatal(err)
}
if pkg.URI != "https://example/pkg" || pkg.Version != "1.1" || len(pkg.Extensions) != 1 ||
pkg.Publisher == nil || pkg.Publisher.Name != "FTS" || pkg.License != "OGL" ||
pkg.PublicationPolicy != "policy" || pkg.Links == nil || pkg.Links.Next != "https://example/next" {
t.Errorf("package fields not copied: %+v", pkg)
}
if pkg.PublishedDate == nil ||
!pkg.PublishedDate.Equal(time.Date(2026, 10, 8, 12, 0, 0, 0, time.UTC)) {
t.Errorf("publishedDate = %v", pkg.PublishedDate)
}
if len(pkg.Releases) != 2 || pkg.Releases[0].OCID != "ocds-1" ||
pkg.Releases[1].OCID != "ocds-3" {
t.Errorf("releases = %+v, want ocds-1 and ocds-3 (bad one skipped)", pkg.Releases)
}
}
func TestSafeUnpackReleasesPublishedDate(t *testing.T) {
tests := []struct {
name string
date string // raw JSON value, empty to omit
want *time.Time
}{
{"omitted", "", nil},
{"empty string", `""`, nil},
{"RFC3339", `"2026-10-08T12:00:00Z"`, ptrTime(time.Date(2026, 10, 8, 12, 0, 0, 0, time.UTC))},
{"date only", `"2026-10-08"`, ptrTime(time.Date(2026, 10, 8, 0, 0, 0, 0, time.UTC))},
{"unparseable", `"yesterday"`, nil},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
body := `{"releases":[]}`
if tt.date != "" {
body = `{"publishedDate":` + tt.date + `,"releases":[]}`
}
pkg, err := safeUnpackReleases(responseFor(t, body))
if err != nil {
t.Fatal(err)
}
switch {
case tt.want == nil && pkg.PublishedDate != nil:
t.Errorf("publishedDate = %v, want nil", pkg.PublishedDate)
case tt.want != nil && (pkg.PublishedDate == nil || !pkg.PublishedDate.Equal(*tt.want)):
t.Errorf("publishedDate = %v, want %v", pkg.PublishedDate, tt.want)
}
})
}
}
func ptrTime(t time.Time) *time.Time { return &t }
func TestSafeUnpackReleasesInvalidJSON(t *testing.T) {
for name, body := range map[string]string{
"not JSON": `<html>oops</html>`,
"wrong shape": `{"releases": {}}`,
"empty body": ``,
"publisher array": `{"publisher": []}`,
} {
t.Run(name, func(t *testing.T) {
if _, err := safeUnpackReleases(responseFor(t, body)); err == nil {
t.Error("expected a decode error")
}
})
}
}
// pagedServer serves pages[i] for ?page=i (page 0 at the release package
// path), linking each page to the next.
func pagedServer(t *testing.T, pages ...[]string) (*FindATender, *atomic.Int32) {
t.Helper()
var hits atomic.Int32
var srvURL string
ft, srv := newTestFT(t, "warn", func(w http.ResponseWriter, r *http.Request) {
hits.Add(1)
i := 0
fmt.Sscan(r.URL.Query().Get("page"), &i)
next := ""
if i+1 < len(pages) {
next = fmt.Sprintf("%s/ocdsReleasePackages?page=%d", srvURL, i+1)
}
io.WriteString(w, packageJSON(next, pages[i]...))
})
srvURL = srv.URL
return ft, &hits
}
func TestFetchOnce(t *testing.T) {
ft, hits := pagedServer(t,
[]string{releaseJSON("ocds-1", "1"), releaseJSON("ocds-2", "1")},
[]string{releaseJSON("ocds-3", "1")},
)
releases, err := ft.fetchOnce(RequestOptions{Since: time.Now()})
if err != nil {
t.Fatal(err)
}
if len(releases) != 2 {
t.Errorf("got %d releases, want 2 (first page only)", len(releases))
}
if hits.Load() != 1 {
t.Errorf("server hit %d times, want 1", hits.Load())
}
}
func TestFetchOnceErrors(t *testing.T) {
ft, _ := pagedServer(t, []string{})
if _, err := ft.fetchOnce(RequestOptions{Since: time.Now()}); !errors.Is(err, ErrNoReleases) {
t.Errorf("empty page: err = %v, want ErrNoReleases", err)
}
ft, _ = newTestFT(t, "warn", func(w http.ResponseWriter, r *http.Request) {
io.WriteString(w, "not json")
})
if _, err := ft.fetchOnce(RequestOptions{Since: time.Now()}); err == nil {
t.Error("bad body: expected an error")
}
ft, _ = newTestFT(t, "warn", func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusTooManyRequests)
})
if _, err := ft.fetchOnce(
RequestOptions{Since: time.Now()},
); !errors.Is(
err,
ErrRateLimitExceeded,
) {
t.Errorf("rate limited: err = %v, want ErrRateLimitExceeded", err)
}
}
func TestFetchAll(t *testing.T) {
ft, hits := pagedServer(t,
[]string{releaseJSON("ocds-1", "1"), releaseJSON("ocds-2", "1")},
[]string{},
[]string{releaseJSON("ocds-1", "2")},
)
releases, err := ft.fetchAll(RequestOptions{Since: time.Now()})
if err != nil {
t.Fatal(err)
}
var ids []string
for _, r := range releases {
ids = append(ids, r.OCID+"/"+r.ID)
}
if strings.Join(ids, ",") != "ocds-1/1,ocds-2/1,ocds-1/2" {
t.Errorf("releases = %v", ids)
}
if hits.Load() != 3 {
t.Errorf("server hit %d times, want 3", hits.Load())
}
}
func TestFetchAllErrors(t *testing.T) {
ft, _ := pagedServer(t, []string{}, []string{})
if _, err := ft.fetchAll(RequestOptions{Since: time.Now()}); !errors.Is(err, ErrNoReleases) {
t.Errorf("all pages empty: err = %v, want ErrNoReleases", err)
}
// First page fails.
ft, _ = newTestFT(t, "warn", func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusTooManyRequests)
})
if _, err := ft.fetchAll(
RequestOptions{Since: time.Now()},
); !errors.Is(
err,
ErrRateLimitExceeded,
) {
t.Errorf("first page rate limited: err = %v", err)
}
ft, _ = newTestFT(t, "warn", func(w http.ResponseWriter, r *http.Request) {
io.WriteString(w, "not json")
})
if _, err := ft.fetchAll(RequestOptions{Since: time.Now()}); err == nil {
t.Error("first page bad body: expected an error")
}
// A later page fails, either to fetch or to decode.
for name, fail := range map[string]http.HandlerFunc{
"rate limited": func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusTooManyRequests) },
"bad body": func(w http.ResponseWriter, r *http.Request) { io.WriteString(w, "not json") },
} {
t.Run(name, func(t *testing.T) {
var srvURL string
ft, srv := newTestFT(t, "warn", func(w http.ResponseWriter, r *http.Request) {
if r.URL.Query().Get("page") == "" {
io.WriteString(w, packageJSON(srvURL+"/next?page=1", releaseJSON("ocds-1", "1")))
return
}
fail(w, r)
})
srvURL = srv.URL
if _, err := ft.fetchAll(RequestOptions{Since: time.Now()}); err == nil {
t.Error("expected an error from the second page")
}
})
}
}
// fetchAll stops, keeping what it has, when a next link repeats an earlier one.
func TestFetchAllRepeatedNextLink(t *testing.T) {
tests := map[string]func(base string, page string) string{
// page 0 -> page 1 -> page 1 ...
"self link": func(base, page string) string { return base + "/next?page=1" },
// page 0 -> page 1 -> page 2 -> page 1 ...
"cycle": func(base, page string) string {
if page == "1" {
return base + "/next?page=2"
}
return base + "/next?page=1"
},
}
for name, nextFor := range tests {
t.Run(name, func(t *testing.T) {
var hits atomic.Int32
var srvURL string
ft, srv := newTestFT(t, "error", func(w http.ResponseWriter, r *http.Request) {
if hits.Add(1) > 10 {
t.Error("fetchAll did not stop following next links")
w.WriteHeader(http.StatusBadRequest)
return
}
page := r.URL.Query().Get("page")
io.WriteString(w, packageJSON(
nextFor(srvURL, page), releaseJSON("ocds-"+page, "1"),
))
})
srvURL = srv.URL
releases, err := ft.fetchAll(RequestOptions{Since: time.Now()})
if err != nil {
t.Fatal(err)
}
if len(releases) != int(hits.Load()) {
t.Errorf("releases = %d, want one per request (%d)", len(releases), hits.Load())
}
})
}
}
func TestUnmarshalLenientDates(t *testing.T) {
raw := json.RawMessage(`{
"date": "2006-10-02",
"publishedDate": "",
"tender": {
"tenderPeriod": {"startDate": "2006-10-02T15:33:02+07:00", "endDate": "2006-11-30"},
"description": "Bids close on 2006-11-30",
"expressionOfInterestDeadline": "2006-10-20"
},
"awards": [{"date": "2006-12-01", "id": "2006-12-01"}],
"value": 1234567.891
}`)
var got struct {
Date time.Time `json:"date"`
PublishedDate *time.Time `json:"publishedDate"`
Tender struct {
TenderPeriod struct {
StartDate *time.Time `json:"startDate"`
EndDate *time.Time `json:"endDate"`
} `json:"tenderPeriod"`
Description string `json:"description"`
ExpressionOfInterestDeadline *time.Time `json:"expressionOfInterestDeadline"`
} `json:"tender"`
Awards []struct {
Date *time.Time `json:"date"`
ID string `json:"id"`
} `json:"awards"`
Value json.Number `json:"value"`
}
if err := unmarshalLenientDates(raw, &got); err != nil {
t.Fatal(err)
}
midnight := func(y int, m time.Month, d int) time.Time { return time.Date(y, m, d, 0, 0, 0, 0, time.UTC) }
if !got.Date.Equal(midnight(2006, 10, 2)) {
t.Errorf("date = %v", got.Date)
}
if got.PublishedDate != nil {
t.Errorf("empty publishedDate should decode as nil, got %v", got.PublishedDate)
}
if start := got.Tender.TenderPeriod.StartDate; start == nil ||
!start.Equal(time.Date(2006, 10, 2, 15, 33, 2, 0, time.FixedZone("", 7*3600))) {
t.Errorf("full timestamp changed: %v", start)
}
if end := got.Tender.TenderPeriod.EndDate; end == nil || !end.Equal(midnight(2006, 11, 30)) {
t.Errorf("endDate = %v", end)
}
if d := got.Tender.ExpressionOfInterestDeadline; d == nil || !d.Equal(midnight(2006, 10, 20)) {
t.Errorf("expressionOfInterestDeadline = %v", d)
}
if got.Tender.Description != "Bids close on 2006-11-30" {
t.Errorf("free text changed: %q", got.Tender.Description)
}
if a := got.Awards[0]; a.Date == nil || !a.Date.Equal(midnight(2006, 12, 1)) ||
a.ID != "2006-12-01" {
t.Errorf("award = %+v (date should be repaired, id left alone)", a)
}
if got.Value != "1234567.891" {
t.Errorf("number changed: %s", got.Value)
}
}
func TestRepairDatesLeavesValidJSONAlone(t *testing.T) {
raw := json.RawMessage(`{"date": "2006-10-02T15:33:02+07:00", "title": "x"}`)
out, err := repairDates(raw)
if err != nil {
t.Fatal(err)
}
if !bytes.Equal(out, raw) {
t.Errorf("valid input was rewritten: %s", out)
}
}
func TestRepairDatesRejectsBadJSON(t *testing.T) {
for _, raw := range []string{`{`, `{"date": "2006-10-02"} trailing`} {
if _, err := repairDates(json.RawMessage(raw)); err == nil {
t.Errorf("expected an error for %q", raw)
}
}
}
func TestUnmarshalLenientDatesErrors(t *testing.T) {
var v struct {
Date time.Time `json:"date"`
}
tests := map[string]string{
"bad JSON": `{`,
"wrong type": `{"date": 5}`,
"unparseable date": `{"date": "02/10/2006"}`,
"not a JSON object": `"2006-10-02"`,
}
for name, raw := range tests {
t.Run(name, func(t *testing.T) {
if err := unmarshalLenientDates(json.RawMessage(raw), &v); err == nil {
t.Errorf("expected an error for %s", raw)
}
})
}
}
func TestReleaseIDs(t *testing.T) {
tests := []struct {
name string
raw string
wantOCID string
wantID string
wantErr string // substring; empty means no error
}{
{"ocid then id", `{"ocid":"ocds-1","id":"r1"}`, "ocds-1", "r1", ""},
{"id then ocid", `{"id":"r1","ocid":"ocds-1"}`, "ocds-1", "r1", ""},
{
"numeric id keeps its digits",
`{"ocid":"ocds-1","id":12345678901234567890}`,
"ocds-1",
"12345678901234567890",
"",
},
{
"skips other fields",
`{"date":"2026-01-01","tender":{"items":[1,2]},"ocid":"ocds-1","id":"r1"}`,
"ocds-1",
"r1",
"",
},
{
"ignores errors after both ids",
`{"ocid":"ocds-1","id":"r1","tender":{"value":"oops"},` + `"bad":}`,
"ocds-1",
"r1",
"",
},
{"empty input", ``, "", "", "reading release"},
{"not an object", `["ocid","id"]`, "", "", "not a JSON object"},
{"empty object", `{}`, "", "", "no ocid or id"},
{"missing ocid", `{"id":"r1"}`, "", "r1", "no ocid"},
{"missing id", `{"ocid":"ocds-1"}`, "ocds-1", "", "no id"},
{"ocid wrong type", `{"ocid":5,"id":"r1"}`, "", "", "reading release ocid"},
{"id wrong type", `{"ocid":"ocds-1","id":true}`, "ocds-1", "", "unexpected bool"},
{"id is an object", `{"ocid":"ocds-1","id":{}}`, "ocds-1", "", "unexpected map"},
{"invalid id JSON", `{"ocid":"ocds-1","id":}`, "ocds-1", "", "reading release id"},
{
"invalid field before ids",
`{"date":nope,"ocid":"ocds-1","id":"r1"}`,
"",
"",
`reading release field "date"`,
},
{"truncated after ocid", `{"ocid":"ocds-1",`, "ocds-1", "", "reading release"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
ocid, id, err := releaseIDs(json.RawMessage(tt.raw))
if tt.wantErr == "" {
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
} else if err == nil || !strings.Contains(err.Error(), tt.wantErr) {
t.Fatalf("err = %v, want containing %q", err, tt.wantErr)
}
if ocid != tt.wantOCID || id != tt.wantID {
t.Errorf("got (%q, %q), want (%q, %q)", ocid, id, tt.wantOCID, tt.wantID)
}
})
}
}
+13
View File
@@ -16,6 +16,8 @@ type FindATender struct {
ctx context.Context
}
// New returns a client for the OCDS API at apiRoot. Requests time out after
// 60 seconds and are retried once. Call Shutdown when finished with it.
func New(conf *config.JobSearchConfig, apiRoot string) *FindATender {
ft := &FindATender{
config: conf,
@@ -27,10 +29,14 @@ func New(conf *config.JobSearchConfig, apiRoot string) *FindATender {
return ft
}
// ReloadConfig replaces the configuration the client reads, such as the log
// level. The HTTP client and API root set by New are not changed.
func (ft *FindATender) ReloadConfig(conf *config.JobSearchConfig) {
ft.config = conf
}
// With returns a copy of the client whose requests use ctx, so they stop
// when it is cancelled. A nil ctx returns the client unchanged.
func (ft *FindATender) With(ctx context.Context) *FindATender {
if ctx == nil {
return ft
@@ -41,10 +47,14 @@ func (ft *FindATender) With(ctx context.Context) *FindATender {
return &copied
}
// Shutdown closes the client's HTTP connections.
func (ft *FindATender) Shutdown() {
ft.httpClient.Close()
}
// FetchOnce fetches the first page of releases updated since since, and
// converts them to opportunities. If allowRateLimit is true, a 429 response
// is waited out and retried; otherwise it fails with ErrRateLimitExceeded.
func (ft *FindATender) FetchOnce(
since time.Time,
allowRateLimit bool,
@@ -59,6 +69,9 @@ func (ft *FindATender) FetchOnce(
return Convert(releases), nil
}
// FetchAll fetches every page of releases updated since since, following
// each page's next link, and converts them to opportunities. allowRateLimit
// behaves as for FetchOnce.
func (ft *FindATender) FetchAll(
since time.Time,
allowRateLimit bool,
+124
View File
@@ -0,0 +1,124 @@
package findATender
import (
"context"
"errors"
"io"
"net/http"
"slices"
"strings"
"testing"
"time"
"git.avsdev.uk/Go/job-search/internal/types"
)
func TestNew(t *testing.T) {
conf := testConfig("warn")
ft := New(conf, "https://example/api")
defer ft.Shutdown()
if ft.config != conf || ft.apiRoot != "https://example/api" {
t.Errorf("New did not keep its arguments: %+v", ft)
}
if ft.httpClient == nil {
t.Fatal("no http client")
}
if ft.httpClient.Timeout() != 60*time.Second || ft.httpClient.RetryCount() != 1 {
t.Errorf("client timeout %v, retries %d", ft.httpClient.Timeout(), ft.httpClient.RetryCount())
}
if ft.ctx != nil {
t.Error("New should not set a context")
}
}
func TestReloadConfig(t *testing.T) {
ft := New(testConfig("warn"), "")
defer ft.Shutdown()
conf := testConfig("debug")
ft.ReloadConfig(conf)
if ft.config != conf {
t.Error("config not replaced")
}
}
func TestWith(t *testing.T) {
ft := New(testConfig("warn"), "")
defer ft.Shutdown()
// Passing nil on purpose to test that case.
//nolint:staticcheck // SA1012
if got := ft.With(nil); got != ft {
t.Error("With(nil) should return the receiver")
}
type key struct{}
ctx := context.WithValue(context.Background(), key{}, "v")
got := ft.With(ctx)
if got == ft {
t.Fatal("With should return a copy")
}
if got.ctx != ctx {
t.Error("copy does not carry the context")
}
if ft.ctx != nil {
t.Error("With changed the original")
}
if got.httpClient != ft.httpClient || got.config != ft.config {
t.Error("copy should share the client and config")
}
}
func opportunityIDs(ops []*types.Opportunity) []string {
var ids []string
for _, op := range ops {
ids = append(ids, op.UUID)
}
slices.Sort(ids)
return ids
}
func TestFetchOncePublic(t *testing.T) {
ft, _ := pagedServer(t,
[]string{releaseJSON("ocds-1", "1"), releaseJSON("ocds-1", "2"), releaseJSON("ocds-2", "1")},
[]string{releaseJSON("ocds-3", "1")},
)
ops, err := ft.FetchOnce(time.Now(), false)
if err != nil {
t.Fatal(err)
}
if got := strings.Join(opportunityIDs(ops), ","); got != "ocds-1,ocds-2" {
t.Errorf("opportunities = %s", got)
}
ft, _ = pagedServer(t, []string{})
if _, err := ft.FetchOnce(time.Now(), false); !errors.Is(err, ErrNoReleases) {
t.Errorf("err = %v, want ErrNoReleases", err)
}
}
func TestFetchAllPublic(t *testing.T) {
ft, _ := pagedServer(t,
[]string{releaseJSON("ocds-1", "1"), releaseJSON("ocds-2", "1")},
[]string{releaseJSON("ocds-1", "2"), releaseJSON("ocds-3", "1")},
)
ops, err := ft.FetchAll(time.Now(), false)
if err != nil {
t.Fatal(err)
}
if got := strings.Join(opportunityIDs(ops), ","); got != "ocds-1,ocds-2,ocds-3" {
t.Errorf("opportunities = %s", got)
}
for _, op := range ops {
if op.UUID == "ocds-1" && len(op.Releases) != 2 {
t.Errorf("ocds-1 has %d releases, want 2", len(op.Releases))
}
}
ft, _ = newTestFT(t, "warn", func(w http.ResponseWriter, r *http.Request) {
io.WriteString(w, "not json")
})
if _, err := ft.FetchAll(time.Now(), false); err == nil {
t.Error("expected an error")
}
}
+18
View File
@@ -9,12 +9,16 @@ import (
"git.avsdev.uk/Go/job-search/internal/types"
)
// ocdsGetBuyerId returns the buyer's id, or "" if the release has no buyer.
func ocdsGetBuyerId(release *ocds.Release) string {
if release.Buyer != nil {
return string(release.Buyer.ID)
}
return ""
}
// ocdsGetBuyerName returns the buyer's name, or "" if the release has no
// buyer.
func ocdsGetBuyerName(release *ocds.Release) string {
if release.Buyer != nil {
return release.Buyer.Name
@@ -22,6 +26,8 @@ func ocdsGetBuyerName(release *ocds.Release) string {
return ""
}
// ocdsConvertClassification converts an OCDS classification, noting whether
// it is a CPV code.
func ocdsConvertClassification(
classif *ocds.Classification,
) types.ClassificationCode {
@@ -32,6 +38,8 @@ func ocdsConvertClassification(
}
}
// ocdsGetClassifications returns the tender's classification, then each
// item's main and additional classifications, without repeating a code.
func ocdsGetClassifications(release *ocds.Release) []types.ClassificationCode {
classifications := make([]types.ClassificationCode, 0)
if release.Tender == nil {
@@ -62,6 +70,8 @@ func ocdsGetClassifications(release *ocds.Release) []types.ClassificationCode {
return classifications
}
// ocdsIsSMESuitable reports whether any lot of the tender is marked as
// suitable for SMEs. A tender with no lots is not.
func ocdsIsSMESuitable(release *ocds.Release) bool {
if release.Tender == nil {
return false
@@ -78,6 +88,10 @@ func ocdsIsSMESuitable(release *ocds.Release) bool {
}
return false
}
// ocdsIsVCSESuitable reports whether any lot of the tender is marked as
// suitable for VCSEs (voluntary, community and social enterprises). A tender
// with no lots is not.
func ocdsIsVCSESuitable(release *ocds.Release) bool {
if release.Tender == nil {
return false
@@ -95,6 +109,8 @@ func ocdsIsVCSESuitable(release *ocds.Release) bool {
return false
}
// ocdsRelease converts a release's identity, tags and date, and keeps a
// re-encoded copy of the whole release as JSON.
func ocdsRelease(release *ocds.Release) types.OpportunityRelease {
tags := make([]types.OpportunityReleaseTag, 0)
for _, val := range release.Tag {
@@ -120,6 +136,8 @@ func ocdsRelease(release *ocds.Release) types.OpportunityRelease {
return rel
}
// convertOne builds an opportunity from a single release. Convert passes it
// each ocid's latest release, as that has the current state of the tender.
func convertOne(release *ocds.Release) *types.Opportunity {
t := &types.Opportunity{
UUID: release.OCID,
+330
View File
@@ -0,0 +1,330 @@
package findATender
import (
"encoding/json"
"reflect"
"slices"
"testing"
"time"
"git.avsdev.uk/Go/job-search/internal/findATender/ocds"
"git.avsdev.uk/Go/job-search/internal/types"
)
func boolPtr(b bool) *bool { return &b }
func lotWith(s *ocds.Suitability) ocds.Lot { return ocds.Lot{ID: "1", Suitability: s} }
func TestOcdsGetBuyer(t *testing.T) {
tests := []struct {
name string
buyer *ocds.OrganizationReference
wantID, wantName string
}{
{"nil buyer", nil, "", ""},
{"empty buyer", &ocds.OrganizationReference{}, "", ""},
{"full buyer", &ocds.OrganizationReference{ID: "GB-1", Name: "Council"}, "GB-1", "Council"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
r := &ocds.Release{Buyer: tt.buyer}
if got := ocdsGetBuyerId(r); got != tt.wantID {
t.Errorf("buyer id = %q, want %q", got, tt.wantID)
}
if got := ocdsGetBuyerName(r); got != tt.wantName {
t.Errorf("buyer name = %q, want %q", got, tt.wantName)
}
})
}
}
func TestOcdsConvertClassification(t *testing.T) {
tests := []struct {
name string
in ocds.Classification
want types.ClassificationCode
}{
{"CPV", ocds.Classification{Scheme: "CPV", ID: "72000000", Description: "IT services"},
types.ClassificationCode{IsCPV: true, Code: "72000000", Classification: "IT services"}},
{"non-CPV", ocds.Classification{Scheme: "UNSPSC", ID: "43000000", Description: "IT"},
types.ClassificationCode{IsCPV: false, Code: "43000000", Classification: "IT"}},
{"scheme is case sensitive", ocds.Classification{Scheme: "cpv", ID: "1"},
types.ClassificationCode{IsCPV: false, Code: "1"}},
{"empty", ocds.Classification{}, types.ClassificationCode{}},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := ocdsConvertClassification(&tt.in); got != tt.want {
t.Errorf("got %+v, want %+v", got, tt.want)
}
})
}
}
func TestOcdsGetClassifications(t *testing.T) {
cpv := func(id string) ocds.Classification {
return ocds.Classification{Scheme: "CPV", ID: ocds.ID(id), Description: "d" + id}
}
codes := func(cs []types.ClassificationCode) []string {
out := []string{}
for _, c := range cs {
out = append(out, c.Code)
}
return out
}
main := cpv("1")
tests := []struct {
name string
tender *ocds.Tender
want []string
}{
{"nil tender", nil, []string{}},
{"no classifications", &ocds.Tender{}, []string{}},
{"main only", &ocds.Tender{Classification: &main}, []string{"1"}},
{"main and item additionals, deduplicated", &ocds.Tender{
Classification: &main,
Items: []ocds.Item{
{ID: "a", AdditionalClassifications: []ocds.Classification{cpv("2"), cpv("1")}},
{ID: "b"},
{ID: "c", AdditionalClassifications: []ocds.Classification{cpv("3"), cpv("2")}},
},
}, []string{"1", "2", "3"}},
{"item additionals without main", &ocds.Tender{
Items: []ocds.Item{{ID: "a", AdditionalClassifications: []ocds.Classification{cpv("5")}}},
}, []string{"5"}},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := ocdsGetClassifications(&ocds.Release{Tender: tt.tender})
if got == nil {
t.Fatal("got nil slice, want non-nil")
}
if !slices.Equal(codes(got), tt.want) {
t.Errorf("codes = %v, want %v", codes(got), tt.want)
}
})
}
}
func TestOcdsGetClassificationsIncludesItemClassification(t *testing.T) {
item := ocds.Classification{Scheme: "CPV", ID: "9"}
got := ocdsGetClassifications(&ocds.Release{Tender: &ocds.Tender{
Items: []ocds.Item{{ID: "a", Classification: &item}},
}})
if len(got) != 1 || got[0].Code != "9" {
t.Errorf("got %+v, want the item classification 9", got)
}
}
func TestOcdsSuitability(t *testing.T) {
tests := []struct {
name string
tender *ocds.Tender
wantSME bool
wantVCSE bool
}{
{"nil tender", nil, false, false},
{"no lots", &ocds.Tender{}, false, false},
{"lot without suitability", &ocds.Tender{Lots: []ocds.Lot{lotWith(nil)}}, false, false},
{
"suitability fields nil",
&ocds.Tender{Lots: []ocds.Lot{lotWith(&ocds.Suitability{})}},
false,
false,
},
{"explicitly false", &ocds.Tender{Lots: []ocds.Lot{
lotWith(&ocds.Suitability{SME: boolPtr(false), Vcse: boolPtr(false)}),
}}, false, false},
{"SME only", &ocds.Tender{Lots: []ocds.Lot{
lotWith(&ocds.Suitability{SME: boolPtr(true)}),
}}, true, false},
{"VCSE only", &ocds.Tender{Lots: []ocds.Lot{
lotWith(&ocds.Suitability{Vcse: boolPtr(true)}),
}}, false, true},
{"any lot suffices", &ocds.Tender{Lots: []ocds.Lot{
lotWith(nil),
lotWith(&ocds.Suitability{SME: boolPtr(false)}),
lotWith(&ocds.Suitability{SME: boolPtr(true), Vcse: boolPtr(true)}),
}}, true, true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
r := &ocds.Release{Tender: tt.tender}
if got := ocdsIsSMESuitable(r); got != tt.wantSME {
t.Errorf("SME = %v, want %v", got, tt.wantSME)
}
if got := ocdsIsVCSESuitable(r); got != tt.wantVCSE {
t.Errorf("VCSE = %v, want %v", got, tt.wantVCSE)
}
})
}
}
func TestOcdsRelease(t *testing.T) {
date := time.Date(2026, 10, 8, 12, 0, 0, 0, time.UTC)
r := &ocds.Release{OCID: "ocds-x-1", ID: "001", Date: date, Tag: []string{"planning", "tender"}}
got := ocdsRelease(r)
if got.UUID != "ocds-x-1" || got.ReleaseID != "001" || !got.ReleaseDate.Equal(date) {
t.Errorf("release = %+v", got)
}
want := []types.OpportunityReleaseTag{
{UUID: "ocds-x-1", ReleaseID: "001", Tag: "planning"},
{UUID: "ocds-x-1", ReleaseID: "001", Tag: "tender"},
}
if !reflect.DeepEqual(got.Tags, want) {
t.Errorf("tags = %+v, want %+v", got.Tags, want)
}
var back ocds.Release
if err := json.Unmarshal([]byte(got.JSON), &back); err != nil {
t.Fatalf("JSON does not round trip: %v (%s)", err, got.JSON)
}
if back.OCID != r.OCID || back.ID != r.ID || !back.Date.Equal(date) {
t.Errorf("round tripped release = %+v", back)
}
if empty := ocdsRelease(&ocds.Release{}); empty.Tags == nil || len(empty.Tags) != 0 {
t.Errorf("no tags should give an empty, non-nil slice, got %#v", empty.Tags)
}
}
func TestConvertOne(t *testing.T) {
start := time.Date(2026, 10, 1, 0, 0, 0, 0, time.UTC)
end := time.Date(2026, 11, 1, 0, 0, 0, 0, time.UTC)
amount := 125000.5
main := ocds.Classification{Scheme: "CPV", ID: "72000000", Description: "IT"}
r := &ocds.Release{
OCID: "ocds-x-1",
ID: "001",
Tag: []string{"tender"},
Buyer: &ocds.OrganizationReference{ID: "GB-1", Name: "Council"},
Tender: &ocds.Tender{
Title: "Website",
Status: "active",
Description: "Build a website",
Classification: &main,
MainProcurementCategory: "services",
ProcurementMethod: "open",
Lots: []ocds.Lot{lotWith(&ocds.Suitability{SME: boolPtr(true)})},
TenderPeriod: &ocds.Period{StartDate: &start, EndDate: &end},
Value: &ocds.Value{Amount: &amount},
Documents: []ocds.Document{{ID: "doc"}},
},
}
got := convertOne(r)
checks := []struct {
field string
got, want any
}{
{"UUID", got.UUID, "ocds-x-1"},
{"UUIDType", got.UUIDType, "ocid"},
{"BuyerId", got.BuyerId, "GB-1"},
{"BuyerName", got.BuyerName, "Council"},
{"Title", got.Title, "Website"},
{"Status", got.Status, "active"},
{"Description", got.Description, "Build a website"},
{"MainCategory", got.MainCategory, "services"},
{"ProcurementMethod", got.ProcurementMethod, "open"},
{"SMESuitable", got.SMESuitable, true},
{"VCSESuitable", got.VCSESuitable, false},
{"StartDate", got.StartDate, &start},
{"EndDate", got.EndDate, &end},
{"GrossValue", got.GrossValue, 125000.5},
{"HasDocuments", got.HasDocuments, true},
{"Classifications", len(got.Classifications), 1},
{"Releases", len(got.Releases), 1},
}
for _, c := range checks {
if !reflect.DeepEqual(c.got, c.want) {
t.Errorf("%s = %v, want %v", c.field, c.got, c.want)
}
}
}
func TestConvertOneMinimal(t *testing.T) {
tests := []struct {
name string
tender *ocds.Tender
}{
{"nil tender", nil},
{"empty tender", &ocds.Tender{}},
{"value without amount", &ocds.Tender{Value: &ocds.Value{}}},
{"empty tender period", &ocds.Tender{TenderPeriod: &ocds.Period{}}},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := convertOne(&ocds.Release{OCID: "ocds-x-2", ID: "1", Tender: tt.tender})
if got.UUID != "ocds-x-2" || got.BuyerId != "" || got.BuyerName != "" {
t.Errorf("got %+v", got)
}
if got.GrossValue != 0 || got.StartDate != nil || got.EndDate != nil || got.HasDocuments {
t.Errorf("unexpected tender fields: %+v", got)
}
if len(got.Releases) != 1 {
t.Errorf("releases = %d, want 1", len(got.Releases))
}
})
}
}
func TestConvert(t *testing.T) {
if got := Convert(nil); len(got) != 0 {
t.Errorf("Convert(nil) = %v, want empty", got)
}
day := func(d int) time.Time { return time.Date(2026, 1, d, 0, 0, 0, 0, time.UTC) }
releases := []ocds.Release{
{OCID: "ocds-b", ID: "1", Date: day(1), Tender: &ocds.Tender{Title: "B"}},
{OCID: "ocds-a", ID: "2", Date: day(3), Tender: &ocds.Tender{
Title: "A latest", Lots: []ocds.Lot{{ID: "1"}, {ID: "2"}},
}},
{OCID: "ocds-a", ID: "1", Date: day(2), Tender: &ocds.Tender{Title: "A first"}},
{OCID: "ocds-a", ID: "3", Date: day(1), Tender: &ocds.Tender{Title: "A oldest"}},
}
got := Convert(releases)
// Opportunities come out in the order each ocid is first seen.
if len(got) != 2 {
t.Fatalf("got %d opportunities, want 2", len(got))
}
b, a := got[0], got[1]
if a.UUID != "ocds-a" || b.UUID != "ocds-b" {
t.Fatalf("uuids = %s, %s; want ocds-b, ocds-a", b.UUID, a.UUID)
}
// Fields come from the latest release; releases are listed oldest first.
if a.Title != "A latest" {
t.Errorf("ocds-a title = %q, want %q", a.Title, "A latest")
}
if !a.LatestReleaseDate.Equal(day(3)) {
t.Errorf("ocds-a LatestReleaseDate = %v, want %v", a.LatestReleaseDate, day(3))
}
if !a.MultiLot {
t.Error("ocds-a MultiLot = false, want true for two lots")
}
var ids []string
for _, r := range a.Releases {
ids = append(ids, r.ReleaseID)
}
if want := []string{"3", "1", "2"}; !slices.Equal(ids, want) {
t.Errorf("ocds-a release ids = %v, want %v", ids, want)
}
if b.Title != "B" || b.MultiLot || !b.LatestReleaseDate.Equal(day(1)) || len(b.Releases) != 1 {
t.Errorf("ocds-b = %+v", b)
}
}
// Releases with the same date keep their input order, so the later one wins.
func TestConvertSameDate(t *testing.T) {
date := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)
got := Convert([]ocds.Release{
{OCID: "ocds-a", ID: "1", Date: date, Tender: &ocds.Tender{Title: "first"}},
{OCID: "ocds-a", ID: "2", Date: date, Tender: &ocds.Tender{Title: "second"}},
})
if len(got) != 1 || got[0].Title != "second" {
t.Fatalf("got %+v, want one opportunity titled %q", got, "second")
}
}