CODE_CONVENTIONS.md sets out the conventions for Go code in this repository: naming, layout, comments, errors, logging, configuration and tests. .golangci.json enables the linters that check the parts that can be checked (each limited to the rules the conventions use); the rest is applied by hand. Applied across the codebase: - Naming: full-word names in application code, initialisms in one case (BuyerID, PrintJSON, PrintYAML, ocdsGetBuyerID), no names shadowing built-ins (min/max are minGap/maxGap), mustRaw renamed rawJSON as it returns nil rather than panicking, and config.ConfigLevelToLogLevel renamed LevelToLogLevel. - Layout: lines kept to 80 characters where possible, assignments taken out of if and for statements that don't fit, wrapped arguments all on one line or one per line, steps separated by blank lines, no else after a return, function and struct literals wrapped. - Comments: doc comments on every exported name (including each section's Map, Flatten, TypeOf, ValueOf and SetValue), section banners, British spelling. - Errors: a created error starts with its package name, a wrapping error adds only what was being done, sentinels in one commented var block, errors.Is and errors.As in place of == and type assertions, and no err := shadowing of a named result. That fixes the mock server losing its access log's close error. - Logging: the fetcher passes its workers an untagged logger, so findatender's lines are tagged with their own component rather than with fetcher's as well; cmd/mock-fts logs "stopped" with slog. - Configuration: util.WithTimeout (moved from the websocket package) applies a timeout of 0 or less as no limit, used for the websocket write and ping, TCP close and tcpserver shutdown timeouts; every setting documents what 0 or empty means, including the rate limit rule's keys in internal/config/README.md. - Tests: related tests merged into table-driven ones with named cases. Separately from CODE_CONVENTIONS.md, internal/store/README.md gains a soft SQL style convention for the queries in internal/store/queries.go, and the queries are reformatted to follow it.
965 lines
25 KiB
Go
965 lines
25 KiB
Go
package findatender
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"log/slog"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"sync/atomic"
|
|
"testing"
|
|
"time"
|
|
|
|
resty "resty.dev/v3"
|
|
|
|
"git.avsdev.uk/Go/job-search/internal/config"
|
|
"git.avsdev.uk/Go/job-search/internal/findatender/ocds"
|
|
)
|
|
|
|
func testConfig(level string) *config.JobSearchConfig {
|
|
return &config.JobSearchConfig{
|
|
Logging: config.JSCLogging{
|
|
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.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.
|
|
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()
|
|
_, 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, "warn", 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,
|
|
"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)
|
|
})
|
|
_, 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, "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(), "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,
|
|
"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(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, "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")
|
|
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 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,
|
|
)
|
|
}
|
|
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 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,
|
|
)
|
|
}
|
|
})
|
|
}
|
|
}
|