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:
1 parent
043e307663
commit
465358609a
78 files changed
+2374
-1402
No files matched your search
+82
-81
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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")},
|
||||
)
|
||||
|
||||
@@ -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])
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in new issue
Block a user