Naming, layout and comments: descriptive full-word names, one item per line in wrapped calls and literals, and doc comments on exported names

Short and single-letter names in application code become full words (tests
keep theirs). Wrapped calls, signatures and composite literals put one item
per line, and one-line function bodies are expanded. Exported names and
groups of constants get doc comments, and comments refer to the renamed
identifiers.
This commit is contained in:
bwilliams committed 2026-10-15 16:12:56 +01:00
1 parent 043e307663
commit 465358609a
78 files changed
+2374 -1402

No files matched your search

+82 -81
View File
@@ -46,7 +46,7 @@ type RequestOptions struct {
AllowRateLimit bool
}
// ---------- Utility methods for handling API irregularities -----------
// ---------- Utility methods for handling API irregularities ----------
// safeReleasePackage is ocds.ReleasePackage with the releases left undecoded,
// so safeUnpackReleases can repair or skip each one.
@@ -65,14 +65,14 @@ type safeReleasePackage struct {
// 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
// unmarshalLenientDates decodes raw json into target like json.Unmarshal, after
// repairing date fields that time.Time cannot parse.
func unmarshalLenientDates(raw json.RawMessage, v any) error {
func unmarshalLenientDates(raw json.RawMessage, target any) error {
fixed, err := repairDates(raw)
if err != nil {
return err
}
return json.Unmarshal(fixed, v)
return json.Unmarshal(fixed, target)
}
// repairDates returns a copy of raw in which every date field holding a
@@ -85,15 +85,15 @@ func unmarshalLenientDates(raw json.RawMessage, v any) error {
// 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
decoder := json.NewDecoder(bytes.NewReader(raw))
decoder.UseNumber() // keep numbers exactly as written, not as float64
var doc any
if err := dec.Decode(&doc); err != nil {
if err := decoder.Decode(&doc); err != nil {
return nil, fmt.Errorf("repairing dates: %w", err)
}
if _, err := dec.Token(); !errors.Is(err, io.EOF) {
if _, err := decoder.Token(); !errors.Is(err, io.EOF) {
return nil, errors.New(
"repairing dates: unexpected data after the JSON value",
)
@@ -106,31 +106,31 @@ func repairDates(raw json.RawMessage) (json.RawMessage, error) {
return json.Marshal(doc)
}
// recursiveRepairDates fixes date fields in v in place and reports whether it
// recursiveRepairDates fixes date fields in value in place and reports whether it
// changed anything.
func recursiveRepairDates(v any) bool {
func recursiveRepairDates(value any) bool {
changed := false
switch v := v.(type) {
switch typed := value.(type) {
case map[string]any:
for key, val := range v {
if s, ok := val.(string); ok && isDateField(key) {
for key, fieldValue := range typed {
if text, ok := fieldValue.(string); ok && isDateField(key) {
switch {
case dateOnly.MatchString(s):
v[key] = s + "T00:00:00Z"
case dateOnly.MatchString(text):
typed[key] = text + "T00:00:00Z"
changed = true
case s == "":
v[key] = nil
case text == "":
typed[key] = nil
changed = true
}
continue
}
if recursiveRepairDates(val) {
if recursiveRepairDates(fieldValue) {
changed = true
}
}
case []any:
for _, el := range v {
if recursiveRepairDates(el) {
for _, element := range typed {
if recursiveRepairDates(element) {
changed = true
}
}
@@ -152,35 +152,35 @@ func isDateField(name string) bool {
// 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
decoder := json.NewDecoder(bytes.NewReader(raw))
decoder.UseNumber() // a numeric id keeps its exact digits
if tok, err := dec.Token(); err != nil {
if token, err := decoder.Token(); err != nil {
return "", "", fmt.Errorf("reading release: %w", err)
} else if tok != json.Delim('{') {
} else if token != json.Delim('{') {
return "", "", errors.New("reading release: not a JSON object")
}
var haveOCID, haveID bool
for dec.More() {
tok, err := dec.Token()
for decoder.More() {
token, err := decoder.Token()
if err != nil {
return ocid, id, fmt.Errorf("reading release: %w", err)
}
switch key, _ := tok.(string); key {
switch key, _ := token.(string); key {
case "ocid":
if err := dec.Decode(&ocid); err != nil {
if err := decoder.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 {
if id, err = decodeReleaseID(decoder); 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 {
if err := decoder.Decode(&skip); err != nil {
return ocid, id, fmt.Errorf("reading release field %q: %w", key, err)
}
}
@@ -194,18 +194,18 @@ func releaseIDs(raw json.RawMessage) (ocid, id string, err error) {
// 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 {
func decodeReleaseID(decoder *json.Decoder) (string, error) {
var decoded any
if err := decoder.Decode(&decoded); err != nil {
return "", fmt.Errorf("reading release id: %w", err)
}
switch v := v.(type) {
switch typed := decoded.(type) {
case string:
return v, nil
return typed, nil
case json.Number:
return v.String(), nil
return typed.String(), nil
default:
return "", fmt.Errorf("reading release id: unexpected %T", v)
return "", fmt.Errorf("reading release id: unexpected %T", typed)
}
}
@@ -227,48 +227,49 @@ func missingIDsError(haveOCID, haveID bool) error {
//
// This method takes ownership of the response body and closes it when done.
func safeUnpackReleases(
logger *slog.Logger, response *resty.Response,
logger *slog.Logger,
response *resty.Response,
) (*ocds.ReleasePackage, error) {
var pkg safeReleasePackage
var releasePackage 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 {
if err := decoder.Decode(&releasePackage); 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,
URI: releasePackage.URI,
Version: releasePackage.Version,
Extensions: releasePackage.Extensions,
Publisher: releasePackage.Publisher,
License: releasePackage.License,
PublicationPolicy: releasePackage.PublicationPolicy,
Links: releasePackage.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 releasePackage.PublishedDate != nil && len(*releasePackage.PublishedDate) > 0 {
published, err := time.Parse(time.RFC3339, *releasePackage.PublishedDate)
if err != nil {
t, err = time.Parse(time.DateOnly, *pkg.PublishedDate)
published, err = time.Parse(time.DateOnly, *releasePackage.PublishedDate)
}
if err == nil {
out.PublishedDate = &t
out.PublishedDate = &published
}
}
for _, rawRel := range pkg.Releases {
for _, rawRelease := range releasePackage.Releases {
var release ocds.Release
err := unmarshalLenientDates(rawRel, &release)
err := unmarshalLenientDates(rawRelease, &release)
if err != nil {
ocid, release_id, _ := releaseIDs(rawRel)
ocid, releaseID, _ := releaseIDs(rawRelease)
logger.Warn(
"skipping unreadable release",
"ocid", ocid,
"release_id", release_id,
"release_id", releaseID,
"error", err,
)
continue
@@ -279,7 +280,7 @@ func safeUnpackReleases(
return out, nil
}
// ---------- API interaction methods -----------
// ---------- 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
@@ -303,21 +304,21 @@ func closeBody(res *resty.Response) {
}
}
// fetchURI performs a single GET of uri, using ft's context, and traces it at
// fetchURI performs a single GET of uri, using findATender'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
logger := ft.logger()
func fetchURI(findATender *FindATender, uri string) (*resty.Response, error) {
withTrace := findATender.config.Logging.LogLevel() <= slog.LevelDebug
logger := findATender.logger()
logger.Info("fetching", "uri", uri)
ctx := ft.ctx
ctx := findATender.ctx
if ctx == nil {
ctx = context.Background()
}
req := ft.httpClient.R()
req := findATender.httpClient.R()
req.SetContext(ctx)
res, err := req.SetTrace(withTrace).Get(uri)
@@ -373,13 +374,13 @@ func (ft *FindATender) fetchURI(
return nil, ErrRateLimitExceeded
}
retrySecStr := res.Header().Get("Retry-After")
retrySec, err := strconv.ParseInt(retrySecStr, 10, 32)
retryAfterText := res.Header().Get("Retry-After")
retryAfterSeconds, err := strconv.ParseInt(retryAfterText, 10, 32)
if err != nil {
return nil, fmt.Errorf("unknown retry-after value: %w", err)
}
retryAfter := time.Duration(retrySec) * time.Second
retryAfter := time.Duration(retryAfterSeconds) * time.Second
ft.logger().Warn("api rate limit hit, waiting as asked", "retry_after", retryAfter)
if rateSleep(ctx, retryAfter) != nil {
return nil, ErrFetchCancelled
@@ -429,13 +430,13 @@ func (ft *FindATender) fetchWithOpts(
// 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,
releasePackage *ocds.ReleasePackage,
allowRateLimit bool,
) (*resty.Response, error) {
if pkg.Links == nil || len(pkg.Links.Next) == 0 {
if releasePackage.Links == nil || len(releasePackage.Links.Next) == 0 {
return nil, ErrNoNext
}
return ft.fetchURI(pkg.Links.Next, allowRateLimit)
return ft.fetchURI(releasePackage.Links.Next, allowRateLimit)
}
// fetchOnce makes one call on the API. It does not follow any "Next" links.
@@ -445,16 +446,16 @@ func (ft *FindATender) fetchOnce(opts RequestOptions) ([]ocds.Release, error) {
return nil, err
}
pkg, err := safeUnpackReleases(ft.logger(), res)
releasePackage, err := safeUnpackReleases(ft.logger(), res)
if err != nil {
return nil, err
}
if len(pkg.Releases) == 0 {
if len(releasePackage.Releases) == 0 {
return nil, ErrNoReleases
}
return pkg.Releases, nil
return releasePackage.Releases, nil
}
// fetchAll makes a call on the API. If there are any "Next" links, it calls
@@ -467,25 +468,25 @@ func (ft *FindATender) fetchAll(opts RequestOptions) ([]ocds.Release, error) {
return nil, err
}
pkg, err := safeUnpackReleases(ft.logger(), res)
releasePackage, err := safeUnpackReleases(ft.logger(), res)
if err != nil {
return nil, err
}
releases = append(releases, pkg.Releases...)
releases = append(releases, releasePackage.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] {
ft.logger().Warn("stopping at repeated next link", "link", pkg.Links.Next)
if releasePackage.Links != nil && len(releasePackage.Links.Next) > 0 {
if seen[releasePackage.Links.Next] {
ft.logger().Warn("stopping at repeated next link", "link", releasePackage.Links.Next)
break
}
seen[pkg.Links.Next] = true
seen[releasePackage.Links.Next] = true
}
res, err := ft.fetchNext(pkg, opts.AllowRateLimit)
res, err := ft.fetchNext(releasePackage, opts.AllowRateLimit)
if err == ErrNoNext {
break
}
@@ -493,11 +494,11 @@ func (ft *FindATender) fetchAll(opts RequestOptions) ([]ocds.Release, error) {
return nil, err
}
pkg, err = safeUnpackReleases(ft.logger(), res)
releasePackage, err = safeUnpackReleases(ft.logger(), res)
if err != nil {
return nil, err
}
releases = append(releases, pkg.Releases...)
releases = append(releases, releasePackage.Releases...)
}
if len(releases) == 0 {
@@ -505,4 +506,4 @@ func (ft *FindATender) fetchAll(opts RequestOptions) ([]ocds.Release, error) {
}
return releases, nil
}
}
+86 -29
View File
@@ -22,7 +22,11 @@ import (
)
func testConfig(level string) *config.JobSearchConfig {
return &config.JobSearchConfig{Logging: config.JSCLogging{Level: level}}
return &config.JobSearchConfig{
Logging: config.JSCLogging{
Level: level,
},
}
}
// quietLogs discards slog output for the duration of the test, or sends it
@@ -34,7 +38,9 @@ func quietLogs(t *testing.T, h slog.Handler) {
}
prev := slog.Default()
slog.SetDefault(slog.New(h))
t.Cleanup(func() { slog.SetDefault(prev) })
t.Cleanup(func() {
slog.SetDefault(prev)
})
}
// newTestFT starts h on a test server and returns a FindATender pointed at it.
@@ -92,7 +98,10 @@ func TestFetchWithOptsBuildsURI(t *testing.T) {
})
since := time.Date(2026, 10, 8, 9, 30, 15, 0, time.UTC)
res, err := ft.fetchWithOpts(RequestOptions{Since: since, Limit: 7})
res, err := ft.fetchWithOpts(RequestOptions{
Since: since,
Limit: 7,
})
if err != nil {
t.Fatal(err)
}
@@ -104,7 +113,9 @@ func TestFetchWithOptsBuildsURI(t *testing.T) {
t.Errorf("query = %q", gotQuery)
}
res, err = ft.fetchWithOpts(RequestOptions{Since: since})
res, err = ft.fetchWithOpts(RequestOptions{
Since: since,
})
if err != nil {
t.Fatal(err)
}
@@ -268,7 +279,8 @@ func TestFetchURIRateLimitThenError(t *testing.T) {
}
func TestFetchURIConnectionError(t *testing.T) {
ft, srv := newTestFT(t, "warn", func(w http.ResponseWriter, r *http.Request) {})
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 {
@@ -290,10 +302,14 @@ func TestFetchURICancelledContext(t *testing.T) {
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})
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})
return slog.NewJSONHandler(w, &slog.HandlerOptions{
Level: slog.LevelDebug,
})
},
}
for name, mk := range handlers {
@@ -305,7 +321,9 @@ func TestFetchURIDebugTrace(t *testing.T) {
quietLogs(t, mk(&logs))
// The body must still be readable after the debug logging.
releases, err := ft.fetchOnce(RequestOptions{Since: time.Now()})
releases, err := ft.fetchOnce(RequestOptions{
Since: time.Now(),
})
if err != nil {
t.Fatal(err)
}
@@ -325,7 +343,8 @@ func TestFetchURIDebugTrace(t *testing.T) {
}
func TestFetchURIDebugTraceConnectionError(t *testing.T) {
ft, srv := newTestFT(t, "debug", func(w http.ResponseWriter, r *http.Request) {})
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 {
@@ -341,8 +360,10 @@ func TestFetchNext(t *testing.T) {
})
for name, pkg := range map[string]*ocds.ReleasePackage{
"nil links": {},
"empty next": {Links: &ocds.ReleasePackageLinks{Prev: srv.URL}},
"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)
@@ -350,7 +371,11 @@ func TestFetchNext(t *testing.T) {
}
res, err := ft.fetchNext(
&ocds.ReleasePackage{Links: &ocds.ReleasePackageLinks{Next: srv.URL + "/page2"}},
&ocds.ReleasePackage{
Links: &ocds.ReleasePackageLinks{
Next: srv.URL + "/page2",
},
},
true,
)
if err != nil {
@@ -372,7 +397,9 @@ func responseFor(t *testing.T, body string) *resty.Response {
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { res.Body.Close() })
t.Cleanup(func() {
res.Body.Close()
})
return res
}
@@ -481,11 +508,14 @@ func pagedServer(t *testing.T, pages ...[]string) (*FindATender, *atomic.Int32)
}
func TestFetchOnce(t *testing.T) {
ft, hits := pagedServer(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()})
releases, err := ft.fetchOnce(RequestOptions{
Since: time.Now(),
})
if err != nil {
t.Fatal(err)
}
@@ -499,14 +529,18 @@ func TestFetchOnce(t *testing.T) {
func TestFetchOnceErrors(t *testing.T) {
ft, _ := pagedServer(t, []string{})
if _, err := ft.fetchOnce(RequestOptions{Since: time.Now()}); !errors.Is(err, ErrNoReleases) {
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 {
if _, err := ft.fetchOnce(RequestOptions{
Since: time.Now(),
}); err == nil {
t.Error("bad body: expected an error")
}
@@ -514,7 +548,9 @@ func TestFetchOnceErrors(t *testing.T) {
w.WriteHeader(http.StatusTooManyRequests)
})
if _, err := ft.fetchOnce(
RequestOptions{Since: time.Now()},
RequestOptions{
Since: time.Now(),
},
); !errors.Is(
err,
ErrRateLimitExceeded,
@@ -524,12 +560,15 @@ func TestFetchOnceErrors(t *testing.T) {
}
func TestFetchAll(t *testing.T) {
ft, hits := pagedServer(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()})
releases, err := ft.fetchAll(RequestOptions{
Since: time.Now(),
})
if err != nil {
t.Fatal(err)
}
@@ -547,7 +586,9 @@ func TestFetchAll(t *testing.T) {
func TestFetchAllErrors(t *testing.T) {
ft, _ := pagedServer(t, []string{}, []string{})
if _, err := ft.fetchAll(RequestOptions{Since: time.Now()}); !errors.Is(err, ErrNoReleases) {
if _, err := ft.fetchAll(RequestOptions{
Since: time.Now(),
}); !errors.Is(err, ErrNoReleases) {
t.Errorf("all pages empty: err = %v, want ErrNoReleases", err)
}
@@ -556,7 +597,9 @@ func TestFetchAllErrors(t *testing.T) {
w.WriteHeader(http.StatusTooManyRequests)
})
if _, err := ft.fetchAll(
RequestOptions{Since: time.Now()},
RequestOptions{
Since: time.Now(),
},
); !errors.Is(
err,
ErrRateLimitExceeded,
@@ -567,14 +610,20 @@ func TestFetchAllErrors(t *testing.T) {
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 {
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") },
"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
@@ -586,7 +635,9 @@ func TestFetchAllErrors(t *testing.T) {
fail(w, r)
})
srvURL = srv.URL
if _, err := ft.fetchAll(RequestOptions{Since: time.Now()}); err == nil {
if _, err := ft.fetchAll(RequestOptions{
Since: time.Now(),
}); err == nil {
t.Error("expected an error from the second page")
}
})
@@ -597,7 +648,9 @@ func TestFetchAllErrors(t *testing.T) {
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" },
"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" {
@@ -623,7 +676,9 @@ func TestFetchAllRepeatedNextLink(t *testing.T) {
})
srvURL = srv.URL
releases, err := ft.fetchAll(RequestOptions{Since: time.Now()})
releases, err := ft.fetchAll(RequestOptions{
Since: time.Now(),
})
if err != nil {
t.Fatal(err)
}
@@ -668,7 +723,9 @@ func TestUnmarshalLenientDates(t *testing.T) {
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) }
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)
}
+4 -4
View File
@@ -35,17 +35,17 @@ func WithLogger(logger *slog.Logger) Option {
// 60 seconds and are retried once. Call Shutdown when finished with it. It
// logs to slog.Default() unless given WithLogger.
func New(conf *config.JobSearchConfig, apiRoot string, opts ...Option) *FindATender {
ft := &FindATender{
findATender := &FindATender{
config: conf,
apiRoot: apiRoot,
}
for _, opt := range opts {
opt(ft)
opt(findATender)
}
ft.httpClient = resty.New().
findATender.httpClient = resty.New().
SetTimeout(60 * time.Duration(time.Second)).
SetRetryCount(1)
return ft
return findATender
}
// logger returns the logger the client logs to, tagged with its component.
+4 -2
View File
@@ -79,7 +79,8 @@ func opportunityIDs(ops []*types.Opportunity) []string {
}
func TestFetchOncePublic(t *testing.T) {
ft, _ := pagedServer(t,
ft, _ := pagedServer(
t,
[]string{releaseJSON("ocds-1", "1"), releaseJSON("ocds-1", "2"), releaseJSON("ocds-2", "1")},
[]string{releaseJSON("ocds-3", "1")},
)
@@ -98,7 +99,8 @@ func TestFetchOncePublic(t *testing.T) {
}
func TestFetchAllPublic(t *testing.T) {
ft, _ := pagedServer(t,
ft, _ := pagedServer(
t,
[]string{releaseJSON("ocds-1", "1"), releaseJSON("ocds-2", "1")},
[]string{releaseJSON("ocds-1", "2"), releaseJSON("ocds-3", "1")},
)
+32 -32
View File
@@ -48,8 +48,8 @@ func ocdsGetClassifications(release *ocds.Release) []types.ClassificationCode {
add := func(classif *ocds.Classification) {
newClassif := ocdsConvertClassification(classif)
found := slices.ContainsFunc(
classifications, func(r types.ClassificationCode) bool {
return r.Code == newClassif.Code
classifications, func(code types.ClassificationCode) bool {
return code.Code == newClassif.Code
},
)
if !found {
@@ -59,11 +59,11 @@ func ocdsGetClassifications(release *ocds.Release) []types.ClassificationCode {
if release.Tender.Classification != nil {
add(release.Tender.Classification)
}
for _, val := range release.Tender.Items {
if val.Classification != nil {
add(val.Classification)
for _, item := range release.Tender.Items {
if item.Classification != nil {
add(item.Classification)
}
for _, classif := range val.AdditionalClassifications {
for _, classif := range item.AdditionalClassifications {
add(&classif)
}
}
@@ -113,15 +113,15 @@ func ocdsIsVCSESuitable(release *ocds.Release) bool {
// 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 {
for _, tag := range release.Tag {
tags = append(tags, types.OpportunityReleaseTag{
UUID: release.OCID,
ReleaseID: release.ID,
Tag: val,
Tag: tag,
})
}
rel := types.OpportunityRelease{
converted := types.OpportunityRelease{
UUID: release.OCID,
ReleaseID: release.ID,
Tags: tags,
@@ -130,16 +130,16 @@ func ocdsRelease(release *ocds.Release) types.OpportunityRelease {
reencoded, err := json.Marshal(release)
if err == nil {
rel.JSON = string(reencoded)
converted.JSON = string(reencoded)
}
return rel
return converted
}
// 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{
opportunity := &types.Opportunity{
UUID: release.OCID,
UUIDType: "ocid",
BuyerId: ocdsGetBuyerId(release),
@@ -148,29 +148,29 @@ func convertOne(release *ocds.Release) *types.Opportunity {
}
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)
opportunity.Title = release.Tender.Title
opportunity.Status = release.Tender.Status
opportunity.Description = release.Tender.Description
opportunity.Classifications = ocdsGetClassifications(release)
opportunity.MainCategory = release.Tender.MainProcurementCategory
opportunity.ProcurementMethod = release.Tender.ProcurementMethod
opportunity.SMESuitable = ocdsIsSMESuitable(release)
opportunity.VCSESuitable = ocdsIsVCSESuitable(release)
if release.Tender.TenderPeriod != nil {
t.StartDate = release.Tender.TenderPeriod.StartDate
t.EndDate = release.Tender.TenderPeriod.EndDate
opportunity.StartDate = release.Tender.TenderPeriod.StartDate
opportunity.EndDate = release.Tender.TenderPeriod.EndDate
}
if release.Tender.Value != nil && release.Tender.Value.Amount != nil {
t.GrossValue = *release.Tender.Value.Amount
opportunity.GrossValue = *release.Tender.Value.Amount
}
t.HasDocuments = len(release.Tender.Documents) > 0
t.MultiLot = len(release.Tender.Lots) > 1
opportunity.HasDocuments = len(release.Tender.Documents) > 0
opportunity.MultiLot = len(release.Tender.Lots) > 1
}
t.LatestReleaseDate = release.Date
t.Releases = append(t.Releases, ocdsRelease(release))
opportunity.LatestReleaseDate = release.Date
opportunity.Releases = append(opportunity.Releases, ocdsRelease(release))
return t
return opportunity
}
// Convert groups releases by ocid into opportunities, in the order each ocid
@@ -181,8 +181,8 @@ func Convert(releases []ocds.Release) []*types.Opportunity {
var order []string
byOCID := make(map[string][]*ocds.Release)
for idx := range releases {
release := &releases[idx]
for i := range releases {
release := &releases[i]
if _, found := byOCID[release.OCID]; !found {
order = append(order, release.OCID)
}
@@ -193,8 +193,8 @@ func Convert(releases []ocds.Release) []*types.Opportunity {
for _, ocid := range order {
group := byOCID[ocid]
slices.SortStableFunc(group, func(a, b *ocds.Release) int {
return a.Date.Compare(b.Date)
slices.SortStableFunc(group, func(left, right *ocds.Release) int {
return left.Date.Compare(right.Date)
})
opportunity := convertOne(group[len(group)-1])
+150 -44
View File
@@ -13,7 +13,12 @@ import (
func boolPtr(b bool) *bool { return &b }
func lotWith(s *ocds.Suitability) ocds.Lot { return ocds.Lot{ID: "1", Suitability: s} }
func lotWith(s *ocds.Suitability) ocds.Lot {
return ocds.Lot{
ID: "1",
Suitability: s,
}
}
func TestOcdsGetBuyer(t *testing.T) {
tests := []struct {
@@ -23,11 +28,16 @@ func TestOcdsGetBuyer(t *testing.T) {
}{
{"nil buyer", nil, "", ""},
{"empty buyer", &ocds.OrganizationReference{}, "", ""},
{"full buyer", &ocds.OrganizationReference{ID: "GB-1", Name: "Council"}, "GB-1", "Council"},
{"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}
r := &ocds.Release{
Buyer: tt.buyer,
}
if got := ocdsGetBuyerId(r); got != tt.wantID {
t.Errorf("buyer id = %q, want %q", got, tt.wantID)
}
@@ -44,12 +54,34 @@ func TestOcdsConvertClassification(t *testing.T) {
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"}},
{"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 {
@@ -63,7 +95,11 @@ func TestOcdsConvertClassification(t *testing.T) {
func TestOcdsGetClassifications(t *testing.T) {
cpv := func(id string) ocds.Classification {
return ocds.Classification{Scheme: "CPV", ID: ocds.ID(id), Description: "d" + id}
return ocds.Classification{
Scheme: "CPV",
ID: ocds.ID(id),
Description: "d" + id,
}
}
codes := func(cs []types.ClassificationCode) []string {
out := []string{}
@@ -80,7 +116,9 @@ func TestOcdsGetClassifications(t *testing.T) {
}{
{"nil tender", nil, []string{}},
{"no classifications", &ocds.Tender{}, []string{}},
{"main only", &ocds.Tender{Classification: &main}, []string{"1"}},
{"main only", &ocds.Tender{
Classification: &main,
}, []string{"1"}},
{"main and item additionals, deduplicated", &ocds.Tender{
Classification: &main,
Items: []ocds.Item{
@@ -95,7 +133,9 @@ func TestOcdsGetClassifications(t *testing.T) {
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := ocdsGetClassifications(&ocds.Release{Tender: tt.tender})
got := ocdsGetClassifications(&ocds.Release{
Tender: tt.tender,
})
if got == nil {
t.Fatal("got nil slice, want non-nil")
}
@@ -107,7 +147,10 @@ func TestOcdsGetClassifications(t *testing.T) {
}
func TestOcdsGetClassificationsIncludesItemClassification(t *testing.T) {
item := ocds.Classification{Scheme: "CPV", ID: "9"}
item := ocds.Classification{
Scheme: "CPV",
ID: "9",
}
got := ocdsGetClassifications(&ocds.Release{Tender: &ocds.Tender{
Items: []ocds.Item{{ID: "a", Classification: &item}},
}})
@@ -125,31 +168,49 @@ func TestOcdsSuitability(t *testing.T) {
}{
{"nil tender", nil, false, false},
{"no lots", &ocds.Tender{}, false, false},
{"lot without suitability", &ocds.Tender{Lots: []ocds.Lot{lotWith(nil)}}, 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{})}},
&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)}),
lotWith(&ocds.Suitability{
SME: boolPtr(false),
Vcse: boolPtr(false),
}),
}}, false, false},
{"SME only", &ocds.Tender{Lots: []ocds.Lot{
lotWith(&ocds.Suitability{SME: boolPtr(true)}),
lotWith(&ocds.Suitability{
SME: boolPtr(true),
}),
}}, true, false},
{"VCSE only", &ocds.Tender{Lots: []ocds.Lot{
lotWith(&ocds.Suitability{Vcse: boolPtr(true)}),
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)}),
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}
r := &ocds.Release{
Tender: tt.tender,
}
if got := ocdsIsSMESuitable(r); got != tt.wantSME {
t.Errorf("SME = %v, want %v", got, tt.wantSME)
}
@@ -162,7 +223,12 @@ func TestOcdsSuitability(t *testing.T) {
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"}}
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) {
@@ -193,12 +259,19 @@ 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"}
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"},
OCID: "ocds-x-1",
ID: "001",
Tag: []string{"tender"},
Buyer: &ocds.OrganizationReference{
ID: "GB-1",
Name: "Council",
},
Tender: &ocds.Tender{
Title: "Website",
Status: "active",
@@ -206,10 +279,17 @@ func TestConvertOne(t *testing.T) {
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"}},
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"}},
},
}
@@ -250,12 +330,20 @@ func TestConvertOneMinimal(t *testing.T) {
}{
{"nil tender", nil},
{"empty tender", &ocds.Tender{}},
{"value without amount", &ocds.Tender{Value: &ocds.Value{}}},
{"empty tender period", &ocds.Tender{TenderPeriod: &ocds.Period{}}},
{"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})
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)
}
@@ -274,14 +362,22 @@ func TestConvert(t *testing.T) {
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) }
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-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"}},
{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)
@@ -321,8 +417,12 @@ func TestConvert(t *testing.T) {
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"}},
{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")
@@ -335,8 +435,10 @@ 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"},
"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)
@@ -344,8 +446,12 @@ func TestConvertSetsSource(t *testing.T) {
}
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-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 {