1034 lines
26 KiB
Go
1034 lines
26 KiB
Go
package findatender
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"log/slog"
|
|
"math"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"sync/atomic"
|
|
"testing"
|
|
"time"
|
|
|
|
resty "resty.dev/v3"
|
|
|
|
"git.avsdev.uk/Go/job-search/internal/findatender/ocds"
|
|
"git.avsdev.uk/Go/job-search/internal/logging"
|
|
)
|
|
|
|
// 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.DiscardHandler
|
|
}
|
|
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.
|
|
// With debug, the logging level is debug for the test, so requests are
|
|
// traced.
|
|
func newTestFT(
|
|
t *testing.T, debug bool, h http.HandlerFunc,
|
|
) (*FindATender, *httptest.Server) {
|
|
t.Helper()
|
|
quietLogs(t, nil)
|
|
if debug {
|
|
logging.SetLevel(slog.LevelDebug)
|
|
t.Cleanup(func() {
|
|
logging.SetLevel(slog.LevelInfo)
|
|
})
|
|
}
|
|
|
|
srv := httptest.NewServer(h)
|
|
t.Cleanup(srv.Close)
|
|
|
|
ft := New(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, false, 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, false, 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,
|
|
false,
|
|
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, false, 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()
|
|
_, err := withCtx.fetchURI(srv.URL, true)
|
|
if !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, false, func(w http.ResponseWriter, r *http.Request) {
|
|
hits.Add(1)
|
|
w.Header().Set("Retry-After", "0")
|
|
w.WriteHeader(http.StatusTooManyRequests)
|
|
})
|
|
_, err := ft.fetchURI(srv.URL, true)
|
|
if !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,
|
|
false,
|
|
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, false, 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)
|
|
})
|
|
_, err := ft.fetchURI(srv.URL, true)
|
|
if !errors.Is(err, ErrUnexpectedStatus) {
|
|
t.Fatalf("err = %v, want ErrUnexpectedStatus", err)
|
|
}
|
|
}
|
|
|
|
func TestFetchURIConnectionError(t *testing.T) {
|
|
ft, srv := newTestFT(t, false, 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, false, 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,
|
|
true,
|
|
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(), "msg=fetched") &&
|
|
!strings.Contains(logs.String(), `"msg":"fetched"`) {
|
|
t.Errorf("no trace logged: %s", logs.String())
|
|
}
|
|
// Every field must be loggable (a *resty.Response isn't, as JSON).
|
|
if strings.Contains(logs.String(), "!ERROR") {
|
|
t.Errorf("a field could not be logged: %s", logs.String())
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestFetchURIDebugTraceConnectionError(t *testing.T) {
|
|
ft, srv := newTestFT(
|
|
t,
|
|
true,
|
|
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, false, 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, false, 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(slog.Default(), responseFor(t, body))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
copied := 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"
|
|
if !copied {
|
|
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(slog.Default(), 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(
|
|
slog.Default(), 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, false, 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, false, 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, false, 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, false, 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, false, 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,
|
|
false,
|
|
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,
|
|
false,
|
|
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")
|
|
body := packageJSON(
|
|
nextFor(srvURL, page), releaseJSON("ocds-"+page, "1"),
|
|
)
|
|
io.WriteString(w, body)
|
|
},
|
|
)
|
|
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 TestUnmarshalLenient(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 := unmarshalLenient(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,
|
|
)
|
|
}
|
|
wantStart := time.Date(2006, 10, 2, 15, 33, 2, 0, time.FixedZone("", 7*3600))
|
|
if start := got.Tender.TenderPeriod.StartDate; start == nil ||
|
|
!start.Equal(wantStart) {
|
|
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 TestUnmarshalLenientNumbers(t *testing.T) {
|
|
tests := []struct {
|
|
raw string
|
|
wantInt int64
|
|
wantFloat float64
|
|
}{
|
|
{`1e9999`, math.MaxInt64, math.MaxInt64},
|
|
{`-1e9999`, math.MinInt64, math.MinInt64},
|
|
{`1E+400`, math.MaxInt64, math.MaxInt64},
|
|
{`3`, 3, 3},
|
|
}
|
|
for _, tc := range tests {
|
|
t.Run(tc.raw, func(t *testing.T) {
|
|
var got struct {
|
|
Int *int64 `json:"int"`
|
|
Float *float64 `json:"float"`
|
|
}
|
|
raw := json.RawMessage(
|
|
`{"int": ` + tc.raw + `, "float": ` + tc.raw + `}`,
|
|
)
|
|
if err := unmarshalLenient(raw, &got); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if *got.Int != tc.wantInt {
|
|
t.Errorf("int = %d; want %d", *got.Int, tc.wantInt)
|
|
}
|
|
if *got.Float != tc.wantFloat {
|
|
t.Errorf("float = %v; want %v", *got.Float, tc.wantFloat)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestUnmarshalLenientNoLimit decodes OCDS's 1e9999 ("no limit") into the
|
|
// release fields Find a Tender sends it in, and checks the release can still
|
|
// be marshalled, as Convert does to store its JSON.
|
|
func TestUnmarshalLenientNoLimit(t *testing.T) {
|
|
raw := json.RawMessage(`{
|
|
"ocid": "ocds-1",
|
|
"id": "r1",
|
|
"date": "2026-10-16T15:52:30+01:00",
|
|
"tender": {
|
|
"lotDetails": {"maximumLotsBidPerSupplier": 1e9999},
|
|
"techniques": {
|
|
"frameworkAgreement": {"maximumParticipants": 1e9999}
|
|
}
|
|
}
|
|
}`)
|
|
|
|
var release ocds.Release
|
|
if err := unmarshalLenient(raw, &release); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
lots := release.Tender.LotDetails.MaximumLotsBidPerSupplier
|
|
if lots == nil || *lots != math.MaxInt64 {
|
|
t.Errorf("maximumLotsBidPerSupplier = %v", lots)
|
|
}
|
|
framework := release.Tender.Techniques.FrameworkAgreement
|
|
if participants := framework.MaximumParticipants; participants == nil ||
|
|
*participants != math.MaxInt64 {
|
|
t.Errorf("maximumParticipants = %v", participants)
|
|
}
|
|
|
|
if _, err := json.Marshal(release); err != nil {
|
|
t.Errorf("marshalling the repaired release: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestRepairJSONLeavesValidJSONAlone(t *testing.T) {
|
|
raw := json.RawMessage(`{"date": "2006-10-02T15:33:02+07:00", "title": "x"}`)
|
|
out, err := repairJSON(raw)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !bytes.Equal(out, raw) {
|
|
t.Errorf("valid input was rewritten: %s", out)
|
|
}
|
|
}
|
|
|
|
func TestRepairJSONRejectsBadJSON(t *testing.T) {
|
|
for _, raw := range []string{`{`, `{"date": "2006-10-02"} trailing`} {
|
|
if _, err := repairJSON(json.RawMessage(raw)); err == nil {
|
|
t.Errorf("expected an error for %q", raw)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestUnmarshalLenientErrors(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 := unmarshalLenient(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,
|
|
)
|
|
}
|
|
})
|
|
}
|
|
}
|