File/package naming convention conforming better to go standards - as ugly as it is
This commit is contained in:
1 parent
be4770cbab
commit
d6688a6754
32 files changed
+36
-36
No files matched your search
@@ -0,0 +1 @@
|
||||
ocds_gen.go
|
||||
@@ -0,0 +1,496 @@
|
||||
package findatender
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.avsdev.uk/Go/job-search/internal/findatender/ocds"
|
||||
"git.avsdev.uk/Go/job-search/internal/logging"
|
||||
resty "resty.dev/v3"
|
||||
)
|
||||
|
||||
// Errors returned when fetching from the API.
|
||||
var (
|
||||
// ErrNoNext means a release package has no next page link.
|
||||
ErrNoNext = errors.New("no next link")
|
||||
// ErrNoReleases means the API returned no releases.
|
||||
ErrNoReleases = errors.New("no releases")
|
||||
// ErrRateLimitExceeded means the API refused a request with 429 Too Many
|
||||
// Requests, and waiting for it was not allowed or did not help.
|
||||
ErrRateLimitExceeded = errors.New("rate limit has been exceeded")
|
||||
// ErrFetchCancelled means the fetch's context was cancelled while waiting
|
||||
// out a rate limit.
|
||||
ErrFetchCancelled = errors.New("fetch cancelled")
|
||||
// ErrUnexpectedStatus wraps an API response status other than 200 OK.
|
||||
ErrUnexpectedStatus = errors.New("unexpected response status")
|
||||
)
|
||||
|
||||
// RequestOptions selects the releases a fetch asks for.
|
||||
type RequestOptions struct {
|
||||
// Since asks for releases updated from this time. Zero means 24 hours ago.
|
||||
Since time.Time
|
||||
// Limit is the number of releases per page. Zero means 100.
|
||||
Limit int
|
||||
// AllowRateLimit waits out a 429 response and retries once, instead of
|
||||
// failing with ErrRateLimitExceeded.
|
||||
AllowRateLimit bool
|
||||
}
|
||||
|
||||
// ---------- Utility methods for handling API irregularities -----------
|
||||
|
||||
// safeReleasePackage is ocds.ReleasePackage with the releases left undecoded,
|
||||
// so safeUnpackReleases can repair or skip each one.
|
||||
type safeReleasePackage struct {
|
||||
URI string `json:"uri,omitempty"`
|
||||
Version string `json:"version,omitempty"`
|
||||
Extensions []string `json:"extensions,omitempty"`
|
||||
PublishedDate *string `json:"publishedDate,omitempty"`
|
||||
Releases []json.RawMessage `json:"releases,omitempty"`
|
||||
Publisher *ocds.ReleasePackagePublisher `json:"publisher,omitempty"`
|
||||
License string `json:"license,omitempty"`
|
||||
PublicationPolicy string `json:"publicationPolicy,omitempty"`
|
||||
Links *ocds.ReleasePackageLinks `json:"links,omitempty"`
|
||||
}
|
||||
|
||||
// dateOnly matches a calendar date with no time of day: "2006-10-02".
|
||||
var dateOnly = regexp.MustCompile(`^\d{4}-\d{2}-\d{2}$`)
|
||||
|
||||
// unmarshalLenientDates decodes raw json into v like json.Unmarshal, after
|
||||
// repairing date fields that time.Time cannot parse.
|
||||
func unmarshalLenientDates(raw json.RawMessage, v any) error {
|
||||
fixed, err := repairDates(raw)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return json.Unmarshal(fixed, v)
|
||||
}
|
||||
|
||||
// repairDates returns a copy of raw in which every date field holding a
|
||||
// date-only value ("2006-10-02") is rewritten as midnight UTC
|
||||
// ("2006-10-02T00:00:00Z"), and every date field holding an empty string is
|
||||
// set to null. Values that already have a time, values in other fields, and
|
||||
// dates that appear inside free text are left alone. If nothing needs
|
||||
// repairing, raw is returned unchanged.
|
||||
//
|
||||
// A date field is one whose name is "date" or starts with "date", contains
|
||||
// "Date", or ends with "Deadline".
|
||||
func repairDates(raw json.RawMessage) (json.RawMessage, error) {
|
||||
dec := json.NewDecoder(bytes.NewReader(raw))
|
||||
dec.UseNumber() // keep numbers exactly as written, not as float64
|
||||
|
||||
var doc any
|
||||
if err := dec.Decode(&doc); err != nil {
|
||||
return nil, fmt.Errorf("repairing dates: %w", err)
|
||||
}
|
||||
|
||||
if _, err := dec.Token(); !errors.Is(err, io.EOF) {
|
||||
return nil, errors.New(
|
||||
"repairing dates: unexpected data after the JSON value",
|
||||
)
|
||||
}
|
||||
|
||||
if !recursiveRepairDates(doc) {
|
||||
return raw, nil
|
||||
}
|
||||
|
||||
return json.Marshal(doc)
|
||||
}
|
||||
|
||||
// recursiveRepairDates fixes date fields in v in place and reports whether it
|
||||
// changed anything.
|
||||
func recursiveRepairDates(v any) bool {
|
||||
changed := false
|
||||
switch v := v.(type) {
|
||||
case map[string]any:
|
||||
for key, val := range v {
|
||||
if s, ok := val.(string); ok && isDateField(key) {
|
||||
switch {
|
||||
case dateOnly.MatchString(s):
|
||||
v[key] = s + "T00:00:00Z"
|
||||
changed = true
|
||||
case s == "":
|
||||
v[key] = nil
|
||||
changed = true
|
||||
}
|
||||
continue
|
||||
}
|
||||
if recursiveRepairDates(val) {
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
case []any:
|
||||
for _, el := range v {
|
||||
if recursiveRepairDates(el) {
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
}
|
||||
return changed
|
||||
}
|
||||
|
||||
// isDateField reports whether a field holds a date, by its name (see
|
||||
// repairDates).
|
||||
func isDateField(name string) bool {
|
||||
return strings.HasPrefix(name, "date") ||
|
||||
strings.Contains(name, "Date") ||
|
||||
strings.HasSuffix(name, "Deadline")
|
||||
}
|
||||
|
||||
// releaseIDs reads just the ocid and the release id from a release, without
|
||||
// decoding the rest of it. It reads the top-level fields in order and stops
|
||||
// as soon as it has both, so an error later in the release (invalid JSON or a
|
||||
// value that wouldn't fit the release struct) doesn't matter. Fields before
|
||||
// them only need to be valid JSON.
|
||||
func releaseIDs(raw json.RawMessage) (ocid, id string, err error) {
|
||||
dec := json.NewDecoder(bytes.NewReader(raw))
|
||||
dec.UseNumber() // a numeric id keeps its exact digits
|
||||
|
||||
if tok, err := dec.Token(); err != nil {
|
||||
return "", "", fmt.Errorf("reading release: %w", err)
|
||||
} else if tok != json.Delim('{') {
|
||||
return "", "", errors.New("reading release: not a JSON object")
|
||||
}
|
||||
|
||||
var haveOCID, haveID bool
|
||||
for dec.More() {
|
||||
tok, err := dec.Token()
|
||||
if err != nil {
|
||||
return ocid, id, fmt.Errorf("reading release: %w", err)
|
||||
}
|
||||
switch key, _ := tok.(string); key {
|
||||
case "ocid":
|
||||
if err := dec.Decode(&ocid); err != nil {
|
||||
return ocid, id, fmt.Errorf("reading release ocid: %w", err)
|
||||
}
|
||||
haveOCID = true
|
||||
case "id":
|
||||
if id, err = decodeReleaseID(dec); err != nil {
|
||||
return ocid, id, err
|
||||
}
|
||||
haveID = true
|
||||
default:
|
||||
var skip json.RawMessage // must be valid JSON, but is otherwise ignored
|
||||
if err := dec.Decode(&skip); err != nil {
|
||||
return ocid, id, fmt.Errorf("reading release field %q: %w", key, err)
|
||||
}
|
||||
}
|
||||
if haveOCID && haveID {
|
||||
return ocid, id, nil
|
||||
}
|
||||
}
|
||||
|
||||
return ocid, id, missingIDsError(haveOCID, haveID)
|
||||
}
|
||||
|
||||
// decodeReleaseID decodes a release id. OCDS ids are strings, but a number is
|
||||
// tolerated and kept as written.
|
||||
func decodeReleaseID(dec *json.Decoder) (string, error) {
|
||||
var v any
|
||||
if err := dec.Decode(&v); err != nil {
|
||||
return "", fmt.Errorf("reading release id: %w", err)
|
||||
}
|
||||
switch v := v.(type) {
|
||||
case string:
|
||||
return v, nil
|
||||
case json.Number:
|
||||
return v.String(), nil
|
||||
default:
|
||||
return "", fmt.Errorf("reading release id: unexpected %T", v)
|
||||
}
|
||||
}
|
||||
|
||||
// missingIDsError reports which of the ocid and release id were not found.
|
||||
func missingIDsError(haveOCID, haveID bool) error {
|
||||
switch {
|
||||
case !haveOCID && !haveID:
|
||||
return errors.New("reading release: no ocid or id")
|
||||
case !haveOCID:
|
||||
return errors.New("reading release: no ocid")
|
||||
default:
|
||||
return errors.New("reading release: no id")
|
||||
}
|
||||
}
|
||||
|
||||
// safeUnpackReleases unpacks an API response into an OCDS ReleasePackage,
|
||||
// handling API irregularities (malformed dates, missing fields, invalid
|
||||
// releases, etc).
|
||||
//
|
||||
// This method takes ownership of the response body and closes it when done.
|
||||
func safeUnpackReleases(response *resty.Response) (*ocds.ReleasePackage, error) {
|
||||
var pkg safeReleasePackage
|
||||
|
||||
// Take ownership of the response body.
|
||||
defer response.Body.Close() //nolint:errcheck // read only
|
||||
|
||||
decoder := json.NewDecoder(response.Body)
|
||||
if err := decoder.Decode(&pkg); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
out := &ocds.ReleasePackage{
|
||||
URI: pkg.URI,
|
||||
Version: pkg.Version,
|
||||
Extensions: pkg.Extensions,
|
||||
Publisher: pkg.Publisher,
|
||||
License: pkg.License,
|
||||
PublicationPolicy: pkg.PublicationPolicy,
|
||||
Links: pkg.Links,
|
||||
}
|
||||
|
||||
// Correct PublishedDate of "" and "2006-01-02"
|
||||
if pkg.PublishedDate != nil && len(*pkg.PublishedDate) > 0 {
|
||||
t, err := time.Parse(time.RFC3339, *pkg.PublishedDate)
|
||||
if err != nil {
|
||||
t, err = time.Parse(time.DateOnly, *pkg.PublishedDate)
|
||||
}
|
||||
if err == nil {
|
||||
out.PublishedDate = &t
|
||||
}
|
||||
}
|
||||
|
||||
for _, rawRel := range pkg.Releases {
|
||||
var release ocds.Release
|
||||
err := unmarshalLenientDates(rawRel, &release)
|
||||
if err != nil {
|
||||
ocid, release_id, _ := releaseIDs(rawRel)
|
||||
slog.Warn(fmt.Sprintf(
|
||||
"Skipping release (%s/%s) due to error: %s", ocid, release_id, err,
|
||||
))
|
||||
continue
|
||||
}
|
||||
out.Releases = append(out.Releases, release)
|
||||
}
|
||||
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// ---------- API interaction methods -----------
|
||||
|
||||
// rateSleep sleeps the fetch worker until either the context is cancelled
|
||||
// (shutdown), returning ctx.Err(), or the duration has expired. The duration
|
||||
// is usually provided by the API; a zero duration returns immediately.
|
||||
func rateSleep(ctx context.Context, duration time.Duration) error {
|
||||
timer := time.NewTimer(duration)
|
||||
defer timer.Stop()
|
||||
|
||||
select {
|
||||
case <-timer.C:
|
||||
return nil
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
}
|
||||
}
|
||||
|
||||
// closeBody closes res's body if it has one, avoiding a double close.
|
||||
func closeBody(res *resty.Response) {
|
||||
if res.Body != nil {
|
||||
res.Body.Close() //nolint:errcheck,gosec // read only
|
||||
}
|
||||
}
|
||||
|
||||
// fetchURI performs a single GET of uri, using ft's context, and traces it at
|
||||
// debug level. Unlike the method of the same name it does not check the
|
||||
// response status.
|
||||
func fetchURI(ft *FindATender, uri string) (*resty.Response, error) {
|
||||
withTrace := ft.config.Logging.LogLevel() <= slog.LevelDebug
|
||||
|
||||
slog.Info("findTender.resty", "request_uri", uri)
|
||||
|
||||
ctx := ft.ctx
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
|
||||
req := ft.httpClient.R()
|
||||
req.SetContext(ctx)
|
||||
res, err := req.SetTrace(withTrace).Get(uri)
|
||||
|
||||
if withTrace {
|
||||
if logging.IsJSONHandler(slog.Default().Handler()) {
|
||||
slog.Debug(
|
||||
"Resty",
|
||||
"trace", res.Request.TraceInfo().JSON(),
|
||||
"status", res.Status(),
|
||||
"response", res,
|
||||
)
|
||||
} else {
|
||||
slog.Debug("Resty " + res.Request.TraceInfo().String())
|
||||
slog.Debug("Resty status: " + res.Status())
|
||||
slog.Debug("Resty response: " + res.String())
|
||||
}
|
||||
}
|
||||
|
||||
return res, err
|
||||
}
|
||||
|
||||
// fetchURI performs a fetch against the API. If the API returns with a rate limit
|
||||
// reached response and allowRateLimit is true, then wait and retry like a good
|
||||
// client. If allowRateLimit is false, return to the caller informing them of
|
||||
// the rate limit exceeded error. If, after waiting, the API still thinks we
|
||||
// have exceeded the rate limit, bail out anyway.
|
||||
func (ft *FindATender) fetchURI(
|
||||
uri string,
|
||||
allowRateLimit bool,
|
||||
) (*resty.Response, error) {
|
||||
res, err := fetchURI(ft, uri)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
ctx := ft.ctx
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
|
||||
if res.StatusCode() == 429 {
|
||||
closeBody(res)
|
||||
|
||||
if !allowRateLimit {
|
||||
return nil, ErrRateLimitExceeded
|
||||
}
|
||||
|
||||
retrySecStr := res.Header().Get("Retry-After")
|
||||
retrySec, err := strconv.ParseInt(retrySecStr, 10, 32)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("unknown retry-after value: %w", err)
|
||||
}
|
||||
|
||||
slog.Warn(fmt.Sprintf(
|
||||
"API rate limit hit, waiting as requested for %d seconds", retrySec,
|
||||
))
|
||||
if rateSleep(ctx, time.Duration(retrySec)*time.Second) != nil {
|
||||
return nil, ErrFetchCancelled
|
||||
}
|
||||
|
||||
res, err = fetchURI(ft, uri)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if res.StatusCode() == 429 {
|
||||
closeBody(res)
|
||||
return nil, ErrRateLimitExceeded
|
||||
}
|
||||
}
|
||||
|
||||
if res.StatusCode() != 200 {
|
||||
closeBody(res)
|
||||
return nil, fmt.Errorf("%w: %s", ErrUnexpectedStatus, res.Status())
|
||||
}
|
||||
|
||||
return res, nil
|
||||
}
|
||||
|
||||
// fetchWithOpts performs a fetch against the API with some sanitised default
|
||||
// options (see RequestOptions).
|
||||
func (ft *FindATender) fetchWithOpts(
|
||||
opts RequestOptions,
|
||||
) (*resty.Response, error) {
|
||||
if opts.Since.IsZero() {
|
||||
opts.Since = time.Now().AddDate(0, 0, -1)
|
||||
}
|
||||
if opts.Limit == 0 {
|
||||
opts.Limit = 100
|
||||
}
|
||||
|
||||
uri := fmt.Sprintf(
|
||||
"%s/ocdsReleasePackages?updatedFrom=%s&limit=%d",
|
||||
ft.apiRoot,
|
||||
opts.Since.Format("2006-01-02T15:04:05"),
|
||||
opts.Limit,
|
||||
)
|
||||
|
||||
return ft.fetchURI(uri, opts.AllowRateLimit)
|
||||
}
|
||||
|
||||
// fetchNext fetches the page pkg's "Next" link points to. It returns
|
||||
// ErrNoNext if there is no "Next" link; the caller can decide whether asking
|
||||
// for it was an error.
|
||||
func (ft *FindATender) fetchNext(
|
||||
pkg *ocds.ReleasePackage,
|
||||
allowRateLimit bool,
|
||||
) (*resty.Response, error) {
|
||||
if pkg.Links == nil || len(pkg.Links.Next) == 0 {
|
||||
return nil, ErrNoNext
|
||||
}
|
||||
return ft.fetchURI(pkg.Links.Next, allowRateLimit)
|
||||
}
|
||||
|
||||
// fetchOnce makes one call on the API. It does not follow any "Next" links.
|
||||
func (ft *FindATender) fetchOnce(opts RequestOptions) ([]ocds.Release, error) {
|
||||
res, err := ft.fetchWithOpts(opts)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
pkg, err := safeUnpackReleases(res)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if len(pkg.Releases) == 0 {
|
||||
return nil, ErrNoReleases
|
||||
}
|
||||
|
||||
return pkg.Releases, nil
|
||||
}
|
||||
|
||||
// fetchAll makes a call on the API. If there are any "Next" links, it calls
|
||||
// them in succession until the end has been reached.
|
||||
func (ft *FindATender) fetchAll(opts RequestOptions) ([]ocds.Release, error) {
|
||||
var releases []ocds.Release
|
||||
|
||||
res, err := ft.fetchWithOpts(opts)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
pkg, err := safeUnpackReleases(res)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
releases = append(releases, pkg.Releases...)
|
||||
|
||||
// Next links already followed, so a page that links back to an earlier
|
||||
// one cannot loop forever.
|
||||
seen := make(map[string]bool)
|
||||
for {
|
||||
if pkg.Links != nil && len(pkg.Links.Next) > 0 {
|
||||
if seen[pkg.Links.Next] {
|
||||
slog.Warn(fmt.Sprintf(
|
||||
"Stopping at repeated next link: %s", pkg.Links.Next,
|
||||
))
|
||||
break
|
||||
}
|
||||
seen[pkg.Links.Next] = true
|
||||
}
|
||||
|
||||
res, err := ft.fetchNext(pkg, opts.AllowRateLimit)
|
||||
if err == ErrNoNext {
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
pkg, err = safeUnpackReleases(res)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
releases = append(releases, pkg.Releases...)
|
||||
}
|
||||
|
||||
if len(releases) == 0 {
|
||||
return nil, ErrNoReleases
|
||||
}
|
||||
|
||||
return releases, nil
|
||||
}
|
||||
@@ -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.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.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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
package findatender
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"time"
|
||||
|
||||
"git.avsdev.uk/Go/job-search/internal/config"
|
||||
"git.avsdev.uk/Go/job-search/internal/types"
|
||||
resty "resty.dev/v3"
|
||||
)
|
||||
|
||||
// FindATender is a client for the Find a Tender OCDS API (or the mock
|
||||
// server). Create it with New and call Shutdown when finished with it.
|
||||
type FindATender struct {
|
||||
httpClient *resty.Client
|
||||
config *config.JobSearchConfig
|
||||
apiRoot string
|
||||
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,
|
||||
apiRoot: apiRoot,
|
||||
}
|
||||
ft.httpClient = resty.New().
|
||||
SetTimeout(60 * time.Duration(time.Second)).
|
||||
SetRetryCount(1)
|
||||
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
|
||||
}
|
||||
|
||||
copied := *ft
|
||||
copied.ctx = ctx
|
||||
return &copied
|
||||
}
|
||||
|
||||
// Shutdown closes the client's HTTP connections. A failure to close is
|
||||
// logged, as there is nothing a caller could do about it.
|
||||
func (ft *FindATender) Shutdown() {
|
||||
if err := ft.httpClient.Close(); err != nil {
|
||||
slog.Warn("findatender: closing HTTP client", "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
// 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,
|
||||
) ([]*types.Opportunity, error) {
|
||||
releases, err := ft.fetchOnce(RequestOptions{
|
||||
Since: since,
|
||||
AllowRateLimit: allowRateLimit,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
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,
|
||||
) ([]*types.Opportunity, error) {
|
||||
releases, err := ft.fetchAll(RequestOptions{
|
||||
Since: since,
|
||||
AllowRateLimit: allowRateLimit,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return Convert(releases), nil
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"releaseSchema": "https://raw.githubusercontent.com/open-contracting/standard/1.1/schema/release-schema.json",
|
||||
"releasePackageSchema": "https://raw.githubusercontent.com/open-contracting/standard/1.1/schema/release-package-schema.json",
|
||||
"extensions": [
|
||||
"https://standard.open-contracting.org/profiles/eu/latest/en/extension.json",
|
||||
"https://raw.githubusercontent.com/open-contracting-extensions/ocds_amendment_rationale_classifications_extension/master/extension.json",
|
||||
"https://raw.githubusercontent.com/open-contracting-extensions/ocds_budget_breakdown_extension/master/extension.json",
|
||||
"https://raw.githubusercontent.com/open-contracting-extensions/ocds_contract_completion_extension/master/extension.json",
|
||||
"https://raw.githubusercontent.com/open-contracting-extensions/ocds_documentation_extension/master/extension.json",
|
||||
"https://raw.githubusercontent.com/open-contracting-extensions/ocds_pagination_extension/master/extension.json",
|
||||
"https://raw.githubusercontent.com/open-contracting-extensions/ocds_suitability_extension/master/extension.json",
|
||||
"https://raw.githubusercontent.com/portaledcahn/ocds_Links_extension/main/extension.json",
|
||||
"https://raw.githubusercontent.com/cabinetoffice/ocds_uk_extension/main/extension.json",
|
||||
"https://raw.githubusercontent.com/open-contracting-extensions/ocds_performance_failures_extension/master/extension.json"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
// Package full contains OCDS 1.1 types with the full extension set applied:
|
||||
// the EU profile, UK extension, and the other extensions in extensions.json.
|
||||
package ocds
|
||||
|
||||
//go:generate go run git.avsdev.uk/Go/go-ocds/cmd/ocdsgen generate -c extensions.json -o ocds_gen.go -p ocds
|
||||
@@ -0,0 +1,209 @@
|
||||
package findatender
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"git.avsdev.uk/Go/job-search/internal/findatender/ocds"
|
||||
"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
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// ocdsConvertClassification converts an OCDS classification, noting whether
|
||||
// it is a CPV code.
|
||||
func ocdsConvertClassification(
|
||||
classif *ocds.Classification,
|
||||
) types.ClassificationCode {
|
||||
return types.ClassificationCode{
|
||||
IsCPV: strings.Compare(classif.Scheme, "CPV") == 0,
|
||||
Code: string(classif.ID),
|
||||
Classification: classif.Description,
|
||||
}
|
||||
}
|
||||
|
||||
// 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 {
|
||||
return classifications
|
||||
}
|
||||
add := func(classif *ocds.Classification) {
|
||||
newClassif := ocdsConvertClassification(classif)
|
||||
found := slices.ContainsFunc(
|
||||
classifications, func(r types.ClassificationCode) bool {
|
||||
return r.Code == newClassif.Code
|
||||
},
|
||||
)
|
||||
if !found {
|
||||
classifications = append(classifications, newClassif)
|
||||
}
|
||||
}
|
||||
if release.Tender.Classification != nil {
|
||||
add(release.Tender.Classification)
|
||||
}
|
||||
for _, val := range release.Tender.Items {
|
||||
if val.Classification != nil {
|
||||
add(val.Classification)
|
||||
}
|
||||
for _, classif := range val.AdditionalClassifications {
|
||||
add(&classif)
|
||||
}
|
||||
}
|
||||
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
|
||||
}
|
||||
if len(release.Tender.Lots) == 0 {
|
||||
return false
|
||||
}
|
||||
for _, lot := range release.Tender.Lots {
|
||||
if lot.Suitability != nil {
|
||||
if lot.Suitability.SME != nil && *lot.Suitability.SME {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
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
|
||||
}
|
||||
if len(release.Tender.Lots) == 0 {
|
||||
return false
|
||||
}
|
||||
for _, lot := range release.Tender.Lots {
|
||||
if lot.Suitability != nil {
|
||||
if lot.Suitability.Vcse != nil && *lot.Suitability.Vcse {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
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 {
|
||||
tags = append(tags, types.OpportunityReleaseTag{
|
||||
UUID: release.OCID,
|
||||
ReleaseID: release.ID,
|
||||
Tag: val,
|
||||
})
|
||||
}
|
||||
|
||||
rel := types.OpportunityRelease{
|
||||
UUID: release.OCID,
|
||||
ReleaseID: release.ID,
|
||||
Tags: tags,
|
||||
ReleaseDate: release.Date,
|
||||
}
|
||||
|
||||
reencoded, err := json.Marshal(release)
|
||||
if err == nil {
|
||||
rel.JSON = string(reencoded)
|
||||
}
|
||||
|
||||
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,
|
||||
UUIDType: "ocid",
|
||||
BuyerId: ocdsGetBuyerId(release),
|
||||
BuyerName: ocdsGetBuyerName(release),
|
||||
Source: "FindATender",
|
||||
}
|
||||
|
||||
if release.Tender != nil {
|
||||
t.Title = release.Tender.Title
|
||||
t.Status = release.Tender.Status
|
||||
t.Description = release.Tender.Description
|
||||
t.Classifications = ocdsGetClassifications(release)
|
||||
t.MainCategory = release.Tender.MainProcurementCategory
|
||||
t.ProcurementMethod = release.Tender.ProcurementMethod
|
||||
t.SMESuitable = ocdsIsSMESuitable(release)
|
||||
t.VCSESuitable = ocdsIsVCSESuitable(release)
|
||||
if release.Tender.TenderPeriod != nil {
|
||||
t.StartDate = release.Tender.TenderPeriod.StartDate
|
||||
t.EndDate = release.Tender.TenderPeriod.EndDate
|
||||
}
|
||||
if release.Tender.Value != nil && release.Tender.Value.Amount != nil {
|
||||
t.GrossValue = *release.Tender.Value.Amount
|
||||
}
|
||||
t.HasDocuments = len(release.Tender.Documents) > 0
|
||||
t.MultiLot = len(release.Tender.Lots) > 1
|
||||
}
|
||||
|
||||
t.LatestReleaseDate = release.Date
|
||||
t.Releases = append(t.Releases, ocdsRelease(release))
|
||||
|
||||
return t
|
||||
}
|
||||
|
||||
// Convert groups releases by ocid into opportunities, in the order each ocid
|
||||
// first appears. Each opportunity is built from its latest release (by date,
|
||||
// or the later one in releases on a tie) and lists all of its releases oldest
|
||||
// first.
|
||||
func Convert(releases []ocds.Release) []*types.Opportunity {
|
||||
var order []string
|
||||
byOCID := make(map[string][]*ocds.Release)
|
||||
|
||||
for idx := range releases {
|
||||
release := &releases[idx]
|
||||
if _, found := byOCID[release.OCID]; !found {
|
||||
order = append(order, release.OCID)
|
||||
}
|
||||
byOCID[release.OCID] = append(byOCID[release.OCID], release)
|
||||
}
|
||||
|
||||
opportunities := make([]*types.Opportunity, 0, len(order))
|
||||
for _, ocid := range order {
|
||||
group := byOCID[ocid]
|
||||
|
||||
slices.SortStableFunc(group, func(a, b *ocds.Release) int {
|
||||
return a.Date.Compare(b.Date)
|
||||
})
|
||||
|
||||
opportunity := convertOne(group[len(group)-1])
|
||||
opportunity.Releases = make([]types.OpportunityRelease, 0, len(group))
|
||||
for _, release := range group {
|
||||
opportunity.Releases = append(opportunity.Releases, ocdsRelease(release))
|
||||
}
|
||||
opportunities = append(opportunities, opportunity)
|
||||
}
|
||||
|
||||
return opportunities
|
||||
}
|
||||
@@ -0,0 +1,355 @@
|
||||
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")
|
||||
}
|
||||
}
|
||||
|
||||
// Every converted opportunity records that it came from Find a Tender,
|
||||
// whether or not its release has a tender.
|
||||
func TestConvertSetsSource(t *testing.T) {
|
||||
const want = "FindATender"
|
||||
|
||||
for name, release := range map[string]*ocds.Release{
|
||||
"with tender": {OCID: "ocds-a", ID: "1", Tender: &ocds.Tender{Title: "A"}},
|
||||
"nil tender": {OCID: "ocds-b", ID: "1"},
|
||||
} {
|
||||
if got := convertOne(release).Source; got != want {
|
||||
t.Errorf("convertOne (%s): Source = %q, want %q", name, got, want)
|
||||
}
|
||||
}
|
||||
|
||||
for _, o := range Convert([]ocds.Release{
|
||||
{OCID: "ocds-a", ID: "1", Tender: &ocds.Tender{Title: "A"}},
|
||||
{OCID: "ocds-a", ID: "2", Tender: &ocds.Tender{Title: "A amended"}},
|
||||
{OCID: "ocds-b", ID: "1"},
|
||||
}) {
|
||||
if o.Source != want {
|
||||
t.Errorf("Convert: %s Source = %q, want %q", o.UUID, o.Source, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user