Files
job-search/internal/findatender/api_test.go
T
bwilliams 67215a6aaa Added CODE_CONVENTIONS.md and linters to enforce it, and applied it across the codebase
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.
2026-10-15 16:26:42 +01:00

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,
)
}
})
}
}