Backfilling a long window (an early --since, or a long time offline) hit the API's rate limit, which could still be exceeded after waiting once, and then everything fetched so far was lost. Before each Find a Tender source's worker starts, a start point more than 7 days ago is now backfilled in 7-day windows bounded on both sides, oldest first, each saved before the next is fetched. A window that fails is saved as far as it got and fetched again from its start after the source's interval. The worker then carries on from the end of the last window. A 429 is now waited out up to sources.<source>.rate_limit_retries times (default 5) rather than once; fts-once --ignore-rate-limit uses 0. FetchAll returns what it fetched before an error along with it, and the workers save it, so a failed run no longer loses its data. findatender's FetchAll and FetchOnce take RequestOptions, which gains Until (updatedTo), and RateLimitRetries replaces AllowRateLimit.
957 lines
27 KiB
Go
957 lines
27 KiB
Go
package fetcher
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"log/slog"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"os"
|
|
"reflect"
|
|
"strings"
|
|
"sync/atomic"
|
|
"testing"
|
|
"time"
|
|
|
|
"git.avsdev.uk/Go/job-search/internal/config"
|
|
"git.avsdev.uk/Go/job-search/internal/events"
|
|
"git.avsdev.uk/Go/job-search/internal/skiprules"
|
|
"git.avsdev.uk/Go/job-search/internal/store"
|
|
"git.avsdev.uk/Go/job-search/internal/types"
|
|
"git.avsdev.uk/Go/job-search/internal/util"
|
|
)
|
|
|
|
func TestMain(m *testing.M) {
|
|
// Keep worker and fetch log lines out of the test output.
|
|
slog.SetDefault(slog.New(slog.DiscardHandler))
|
|
os.Exit(m.Run())
|
|
}
|
|
|
|
const onePackage = `{
|
|
"uri": "x",
|
|
"releases": [{
|
|
"ocid": "ocds-1",
|
|
"id": "r1",
|
|
"date": "2026-10-01T12:00:00Z",
|
|
"tag": ["tender"],
|
|
"tender": {"id": "t1", "title": "A tender"}
|
|
}]
|
|
}`
|
|
|
|
const emptyPackage = `{"uri": "x", "releases": []}`
|
|
|
|
// ftsServer serves body (with status) for every request and counts requests.
|
|
func ftsServer(
|
|
t *testing.T, status int, body string,
|
|
) (*httptest.Server, *atomic.Int32) {
|
|
t.Helper()
|
|
var hits atomic.Int32
|
|
srv := httptest.NewServer(
|
|
http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
hits.Add(1)
|
|
if r.URL.Path != "/ocdsReleasePackages" {
|
|
t.Errorf("unexpected path %q", r.URL.Path)
|
|
}
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(status)
|
|
io.WriteString(w, body)
|
|
}),
|
|
)
|
|
t.Cleanup(srv.Close)
|
|
return srv, &hits
|
|
}
|
|
|
|
// fetchFunc fetches from one source at apiRoot.
|
|
type fetchFunc func(
|
|
ctx context.Context, apiRoot string, since time.Time,
|
|
) ([]*types.Opportunity, error)
|
|
|
|
// fetchers are the sources' fetch functions, each given only its API root.
|
|
var fetchers = map[string]fetchFunc{
|
|
"FindATender": func(
|
|
ctx context.Context, apiRoot string, since time.Time,
|
|
) ([]*types.Opportunity, error) {
|
|
conf := &config.JSCSourceFindATender{
|
|
APIRoot: apiRoot,
|
|
}
|
|
return fetchFindATender(ctx, slog.Default(), conf, since, time.Time{})
|
|
},
|
|
"FTSMock": func(
|
|
ctx context.Context, apiRoot string, since time.Time,
|
|
) ([]*types.Opportunity, error) {
|
|
conf := &config.JSCSourceFTSMock{
|
|
APIRoot: apiRoot,
|
|
}
|
|
return fetchFTSMock(ctx, slog.Default(), conf, since, time.Time{})
|
|
},
|
|
}
|
|
|
|
// noSave is a saveFunc that saves nothing.
|
|
func noSave(context.Context, *slog.Logger, []*types.Opportunity) error {
|
|
return nil
|
|
}
|
|
|
|
func TestFetchFunctions(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
status int
|
|
body string
|
|
check func(error) bool
|
|
wantErr string
|
|
}{
|
|
{
|
|
"releases found",
|
|
http.StatusOK,
|
|
onePackage,
|
|
func(err error) bool {
|
|
return err == nil
|
|
},
|
|
"nil",
|
|
},
|
|
{"no releases", http.StatusOK, emptyPackage, func(err error) bool {
|
|
return errors.Is(err, ErrNoNewReleases) &&
|
|
errors.Is(err, util.ErrWorkerNoWork)
|
|
}, "ErrNoNewReleases wrapping ErrWorkerNoWork"},
|
|
{"bad body", http.StatusInternalServerError, "oops", func(err error) bool {
|
|
return err != nil && !errors.Is(err, util.ErrWorkerNoWork)
|
|
}, "a failure error"},
|
|
}
|
|
for fname, fetch := range fetchers {
|
|
for _, tc := range tests {
|
|
t.Run(fname+"/"+tc.name, func(t *testing.T) {
|
|
srv, hits := ftsServer(t, tc.status, tc.body)
|
|
_, err := fetch(
|
|
context.Background(), srv.URL, time.Now().Add(-time.Hour),
|
|
)
|
|
if !tc.check(err) {
|
|
t.Fatalf("err = %v, want %s", err, tc.wantErr)
|
|
}
|
|
if hits.Load() == 0 {
|
|
t.Fatal("server was not called")
|
|
}
|
|
})
|
|
}
|
|
}
|
|
}
|
|
|
|
// Run gives each worker its own source's configuration: with only one source
|
|
// enabled, only its API root is called.
|
|
func TestRunUsesEachSourcesAPIRoot(t *testing.T) {
|
|
for _, enabled := range []string{"FindATender", "FTSMock"} {
|
|
fat, fatHits := ftsServer(t, http.StatusOK, emptyPackage)
|
|
mock, mockHits := ftsServer(t, http.StatusOK, emptyPackage)
|
|
sources := &config.JSCSources{}
|
|
sources.FindATender.APIRoot = fat.URL
|
|
sources.FindATender.Interval = time.Hour
|
|
sources.FTSMock.APIRoot = mock.URL
|
|
sources.FTSMock.Interval = time.Hour
|
|
wanted, other := fatHits, mockHits
|
|
if enabled == "FindATender" {
|
|
sources.FindATender.Enabled = true
|
|
} else {
|
|
sources.FTSMock.Enabled = true
|
|
wanted, other = mockHits, fatHits
|
|
}
|
|
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
done := make(chan error, 1)
|
|
go func() {
|
|
done <- New(sources).Run(ctx, time.Time{})
|
|
}()
|
|
|
|
deadline := time.Now().Add(5 * time.Second)
|
|
for wanted.Load() == 0 && time.Now().Before(deadline) {
|
|
time.Sleep(5 * time.Millisecond)
|
|
}
|
|
cancel()
|
|
<-done
|
|
|
|
if wanted.Load() == 0 || other.Load() != 0 {
|
|
t.Errorf(
|
|
"%s enabled: its root hit %d times, the other's %d; want >0 and 0",
|
|
enabled, wanted.Load(), other.Load(),
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestFetchCancelled(t *testing.T) {
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
cancel()
|
|
// Unroutable root: the request should never be waited on.
|
|
start := time.Now()
|
|
_, err := fetchers["FTSMock"](ctx, "http://192.0.2.1:9", time.Time{})
|
|
if !errors.Is(err, context.Canceled) {
|
|
t.Fatalf("err = %v, want context.Canceled", err)
|
|
}
|
|
if d := time.Since(start); d > time.Second {
|
|
t.Fatalf("cancelled fetch took %v", d)
|
|
}
|
|
}
|
|
|
|
func TestSleepCancelled(t *testing.T) {
|
|
for _, block := range []bool{false, true} {
|
|
ctx, cancel := context.WithTimeout(
|
|
context.Background(), 10*time.Millisecond,
|
|
)
|
|
defer cancel()
|
|
err := sleep(ctx, block, time.Hour)
|
|
if !errors.Is(err, context.DeadlineExceeded) {
|
|
t.Fatalf("block=%v: err = %v, want context.DeadlineExceeded", block, err)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestSleepCompletes(t *testing.T) {
|
|
for _, block := range []bool{false, true} {
|
|
if err := sleep(context.Background(), block, time.Millisecond); err != nil {
|
|
t.Fatalf("block=%v: err = %v", block, err)
|
|
}
|
|
}
|
|
}
|
|
|
|
// A non-blocking sleeper stops as soon as shutdown is signalled; a blocking
|
|
// one holds shutdown open until the worker's shutdown timeout.
|
|
func TestRunSleeperShutdown(t *testing.T) {
|
|
for _, block := range []bool{false, true} {
|
|
conf := &config.JSCSourceSleeper{
|
|
BlockShutdown: block,
|
|
}
|
|
conf.Interval = time.Hour
|
|
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
done := make(chan struct{})
|
|
go func() {
|
|
runSleeper(ctx, slog.Default(), conf, time.Time{})
|
|
close(done)
|
|
}()
|
|
time.Sleep(10 * time.Millisecond) // let the first sleep start
|
|
cancel()
|
|
|
|
select {
|
|
case <-done:
|
|
if block {
|
|
t.Fatal("block=true: shutdown was not held open")
|
|
}
|
|
case <-time.After(time.Second):
|
|
if !block {
|
|
t.Fatal("block=false: shutdown was held open")
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestRunWorkersRejectZeroInterval(t *testing.T) {
|
|
ctx, logger := context.Background(), slog.Default()
|
|
runs := map[string]func() error{
|
|
"FindATender": func() error {
|
|
conf := &config.JSCSourceFindATender{
|
|
APIRoot: "http://127.0.0.1:1",
|
|
}
|
|
return runFindATender(ctx, logger, conf, noSave, time.Time{})
|
|
},
|
|
"FTSMock": func() error {
|
|
conf := &config.JSCSourceFTSMock{
|
|
APIRoot: "http://127.0.0.1:1",
|
|
}
|
|
return runFTSMock(ctx, logger, conf, noSave, time.Time{})
|
|
},
|
|
"Sleeper": func() error {
|
|
return runSleeper(ctx, logger, &config.JSCSourceSleeper{}, time.Time{})
|
|
},
|
|
}
|
|
for name, run := range runs {
|
|
err := run()
|
|
if err == nil || !strings.Contains(err.Error(), "interval") {
|
|
t.Errorf("%s: err = %v; want an error for a zero interval", name, err)
|
|
}
|
|
}
|
|
}
|
|
|
|
// A source that stops with an error has the error logged.
|
|
func TestRunLogsSourceErrors(t *testing.T) {
|
|
var buf bytes.Buffer
|
|
def := slog.Default()
|
|
slog.SetDefault(slog.New(slog.NewTextHandler(&buf, nil)))
|
|
t.Cleanup(func() {
|
|
slog.SetDefault(def)
|
|
})
|
|
|
|
// A zero interval makes each worker fail straight away.
|
|
sources := &config.JSCSources{}
|
|
sources.FindATender.Enabled = true
|
|
sources.FindATender.APIRoot = "http://127.0.0.1:1"
|
|
sources.FTSMock.Enabled = true
|
|
sources.FTSMock.APIRoot = "http://127.0.0.1:1"
|
|
sources.Sleeper.Enabled = true
|
|
err := New(sources).Run(context.Background(), time.Time{})
|
|
|
|
for _, name := range []string{"FindATender", "FTSMock", "Sleeper"} {
|
|
if !strings.Contains(buf.String(), "source="+name) {
|
|
t.Errorf("no error logged for %s: %s", name, &buf)
|
|
}
|
|
if err == nil || !strings.Contains(err.Error(), name+": ") {
|
|
t.Errorf("returned error %v; want it to include %s", err, name)
|
|
}
|
|
}
|
|
if n := strings.Count(
|
|
buf.String(), "interval must be greater than 0",
|
|
); n != 3 {
|
|
t.Errorf("logged %d interval errors; want 3: %s", n, &buf)
|
|
}
|
|
}
|
|
|
|
// An enabled Find a Tender or mock source with no api_root fails straight
|
|
// away, naming the setting.
|
|
func TestRunMissingAPIRoot(t *testing.T) {
|
|
quiet := slog.New(slog.DiscardHandler)
|
|
tests := []struct {
|
|
name string
|
|
enable func(*config.JSCSources)
|
|
key string
|
|
}{
|
|
{"find_a_tender", func(sources *config.JSCSources) {
|
|
sources.FindATender.Enabled = true
|
|
sources.FindATender.Interval = time.Hour
|
|
}, "sources.find_a_tender.api_root"},
|
|
{"fts_mock", func(sources *config.JSCSources) {
|
|
sources.FTSMock.Enabled = true
|
|
sources.FTSMock.Interval = time.Hour
|
|
}, "sources.fts_mock.api_root"},
|
|
}
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
sources := &config.JSCSources{}
|
|
tt.enable(sources)
|
|
|
|
err := New(sources, WithLogger(quiet)).Run(
|
|
context.Background(), time.Time{},
|
|
)
|
|
if err == nil || !strings.Contains(err.Error(), tt.key) {
|
|
t.Errorf("Run = %v; want an error naming %s", err, tt.key)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestRunNoSourcesEnabled(t *testing.T) {
|
|
done := make(chan struct{})
|
|
go func() {
|
|
err := New(&config.JSCSources{}).Run(context.Background(), time.Time{})
|
|
if err != nil {
|
|
t.Errorf("Run = %v; want nil", err)
|
|
}
|
|
close(done)
|
|
}()
|
|
|
|
select {
|
|
case <-done:
|
|
case <-time.After(time.Second):
|
|
t.Fatal("Run with no sources enabled did not return")
|
|
}
|
|
}
|
|
|
|
// A worker that fails shuts the other workers down, without ctx being
|
|
// cancelled.
|
|
func TestRunShutsDownOnWorkerError(t *testing.T) {
|
|
mock, mockHits := ftsServer(t, http.StatusOK, emptyPackage)
|
|
sources := &config.JSCSources{}
|
|
sources.FTSMock.Enabled = true
|
|
sources.FTSMock.Interval = time.Hour
|
|
sources.FTSMock.APIRoot = mock.URL
|
|
// The sleeper fails straight away: a zero interval is invalid.
|
|
sources.Sleeper.Enabled = true
|
|
|
|
done := make(chan error, 1)
|
|
go func() {
|
|
done <- New(sources).Run(context.Background(), time.Now().Add(-time.Hour))
|
|
}()
|
|
|
|
select {
|
|
case err := <-done:
|
|
if err == nil || !strings.Contains(err.Error(), "Sleeper: ") {
|
|
t.Fatalf("Run = %v; want the Sleeper error", err)
|
|
}
|
|
if strings.Contains(err.Error(), "FTSMock") {
|
|
t.Errorf("Run = %v; FTSMock should have stopped cleanly", err)
|
|
}
|
|
case <-time.After(5 * time.Second):
|
|
t.Fatal("Run did not shut down after a worker failed")
|
|
}
|
|
|
|
// FTSMock either polled once before the shutdown or never started a run.
|
|
if n := mockHits.Load(); n > 1 {
|
|
t.Errorf("FTSMock polled %d times; want at most 1", n)
|
|
}
|
|
}
|
|
|
|
// Run starts each enabled source, and returns once ctx is cancelled.
|
|
func TestRunEnabledSources(t *testing.T) {
|
|
fat, fatHits := ftsServer(t, http.StatusOK, onePackage)
|
|
mock, mockHits := ftsServer(t, http.StatusOK, emptyPackage)
|
|
sources := &config.JSCSources{}
|
|
sources.FindATender.Enabled = true
|
|
sources.FindATender.Interval = time.Hour
|
|
sources.FindATender.APIRoot = fat.URL
|
|
sources.FTSMock.Enabled = true
|
|
sources.FTSMock.Interval = time.Hour
|
|
sources.FTSMock.APIRoot = mock.URL
|
|
sources.Sleeper.Enabled = true
|
|
sources.Sleeper.Interval = time.Hour
|
|
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
done := make(chan struct{})
|
|
go func() {
|
|
err := New(sources).Run(ctx, time.Now().Add(-time.Hour))
|
|
if err != nil {
|
|
t.Errorf("Run = %v; want nil after cancellation", err)
|
|
}
|
|
close(done)
|
|
}()
|
|
|
|
deadline := time.Now().Add(5 * time.Second)
|
|
for fatHits.Load() == 0 || mockHits.Load() == 0 {
|
|
if time.Now().After(deadline) {
|
|
t.Fatalf(
|
|
"sources not polled: fat=%d mock=%d", fatHits.Load(), mockHits.Load(),
|
|
)
|
|
}
|
|
time.Sleep(5 * time.Millisecond)
|
|
}
|
|
cancel()
|
|
|
|
select {
|
|
case <-done:
|
|
case <-time.After(5 * time.Second):
|
|
t.Fatal("Run did not return after cancellation")
|
|
}
|
|
}
|
|
|
|
// Each source's opportunities carry its own Source: the mock's are marked
|
|
// FTSMock, though its converter marks them as Find a Tender's.
|
|
func TestFetchSetsSource(t *testing.T) {
|
|
srv, _ := ftsServer(t, http.StatusOK, onePackage)
|
|
for name, want := range map[string]string{
|
|
"FindATender": "FindATender",
|
|
"FTSMock": "FTSMock",
|
|
} {
|
|
opportunities, err := fetchers[name](
|
|
context.Background(), srv.URL, time.Now().Add(-time.Hour),
|
|
)
|
|
if err != nil {
|
|
t.Fatalf("%s: %v", name, err)
|
|
}
|
|
if len(opportunities) != 1 || opportunities[0].Source != want {
|
|
t.Errorf(
|
|
"%s: opportunities = %+v; want one from %s",
|
|
name, opportunities, want,
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|
|
// openTestStore returns a migrated store in a temporary file.
|
|
func openTestStore(t *testing.T) *store.Store {
|
|
t.Helper()
|
|
path := t.TempDir() + "/fetcher.db"
|
|
opportunityStore, err := store.Open(
|
|
context.Background(), path, store.WithAutoMigrate(),
|
|
)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
t.Cleanup(func() {
|
|
opportunityStore.Close()
|
|
})
|
|
return opportunityStore
|
|
}
|
|
|
|
// WithStore makes the workers save what they fetch, each with its source.
|
|
func TestRunSavesToStore(t *testing.T) {
|
|
opportunityStore := openTestStore(t)
|
|
fat, _ := ftsServer(t, http.StatusOK, onePackage)
|
|
mock, _ := ftsServer(
|
|
t, http.StatusOK, strings.ReplaceAll(onePackage, "ocds-1", "ocds-2"),
|
|
)
|
|
sources := &config.JSCSources{}
|
|
sources.FindATender.Enabled = true
|
|
sources.FindATender.Interval = time.Hour
|
|
sources.FindATender.APIRoot = fat.URL
|
|
sources.FTSMock.Enabled = true
|
|
sources.FTSMock.Interval = time.Hour
|
|
sources.FTSMock.APIRoot = mock.URL
|
|
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
done := make(chan error, 1)
|
|
go func() {
|
|
done <- New(sources, WithStore(opportunityStore)).Run(
|
|
ctx, time.Now().Add(-time.Hour),
|
|
)
|
|
}()
|
|
|
|
deadline := time.Now().Add(5 * time.Second)
|
|
for {
|
|
stored, err := opportunityStore.ListOpportunities(
|
|
context.Background(), store.OpportunityFilter{},
|
|
)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(stored) == 2 {
|
|
break
|
|
}
|
|
if time.Now().After(deadline) {
|
|
t.Fatalf("stored %d opportunities; want 2", len(stored))
|
|
}
|
|
time.Sleep(5 * time.Millisecond)
|
|
}
|
|
cancel()
|
|
if err := <-done; err != nil {
|
|
t.Fatalf("Run = %v", err)
|
|
}
|
|
|
|
for uuid, want := range map[string]string{
|
|
"ocds-1": "FindATender",
|
|
"ocds-2": "FTSMock",
|
|
} {
|
|
opportunity, err := opportunityStore.GetOpportunity(
|
|
context.Background(), uuid,
|
|
)
|
|
if err != nil {
|
|
t.Fatalf("%s: %v", uuid, err)
|
|
}
|
|
if opportunity.Source != want {
|
|
t.Errorf("%s: Source = %q; want %q", uuid, opportunity.Source, want)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Without a store, save does nothing; with one that fails, it returns the
|
|
// error, so the worker's run fails and its checkpoint stays put.
|
|
func TestSave(t *testing.T) {
|
|
srv, _ := ftsServer(t, http.StatusOK, onePackage)
|
|
opportunities, err := fetchers["FindATender"](
|
|
context.Background(), srv.URL, time.Now().Add(-time.Hour),
|
|
)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
err = New(&config.JSCSources{}).save(
|
|
context.Background(), slog.Default(), opportunities,
|
|
)
|
|
if err != nil {
|
|
t.Errorf("save without a store = %v; want nil", err)
|
|
}
|
|
|
|
closed := openTestStore(t)
|
|
closed.Close()
|
|
err = New(&config.JSCSources{}, WithStore(closed)).save(
|
|
context.Background(), slog.Default(), opportunities,
|
|
)
|
|
if err == nil || !strings.Contains(err.Error(), "saving opportunities") {
|
|
t.Errorf("save to a closed store = %v; want a saving error", err)
|
|
}
|
|
}
|
|
|
|
// updatedFromServer serves emptyPackage and sends the updatedFrom of each
|
|
// request to the returned channel.
|
|
func updatedFromServer(t *testing.T) (*httptest.Server, chan string) {
|
|
t.Helper()
|
|
updatedFrom := make(chan string, 10)
|
|
srv := httptest.NewServer(
|
|
http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
select {
|
|
case updatedFrom <- r.URL.Query().Get("updatedFrom"):
|
|
default:
|
|
}
|
|
io.WriteString(w, emptyPackage)
|
|
}),
|
|
)
|
|
t.Cleanup(srv.Close)
|
|
return srv, updatedFrom
|
|
}
|
|
|
|
// Without since, each source starts from its own newest stored release, or
|
|
// with nothing stored from the client's default (the last day); since
|
|
// overrides both.
|
|
func TestRunStartsFromNewestStoredRelease(t *testing.T) {
|
|
opportunityStore := openTestStore(t)
|
|
srv, _ := ftsServer(t, http.StatusOK, onePackage)
|
|
stored, err := fetchers["FindATender"](
|
|
context.Background(), srv.URL, time.Now().Add(-time.Hour),
|
|
)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
_, err = opportunityStore.SaveOpportunities(context.Background(), stored)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
override := time.Date(2026, 9, 1, 0, 0, 0, 0, time.UTC)
|
|
tests := []struct {
|
|
name string
|
|
since time.Time
|
|
wantFAT string
|
|
wantMock string
|
|
}{
|
|
// "" is the client's default: about a day ago.
|
|
{"stored", time.Time{}, "2026-10-01T12:00:00", ""},
|
|
{"override", override, "2026-09-01T00:00:00", "2026-09-01T00:00:00"},
|
|
}
|
|
for _, tt := range tests {
|
|
fat, fatFrom := updatedFromServer(t)
|
|
mock, mockFrom := updatedFromServer(t)
|
|
sources := &config.JSCSources{}
|
|
sources.FindATender.Enabled = true
|
|
sources.FindATender.Interval = time.Hour
|
|
sources.FindATender.APIRoot = fat.URL
|
|
sources.FTSMock.Enabled = true
|
|
sources.FTSMock.Interval = time.Hour
|
|
sources.FTSMock.APIRoot = mock.URL
|
|
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
done := make(chan error, 1)
|
|
go func() {
|
|
done <- New(sources, WithStore(opportunityStore)).Run(ctx, tt.since)
|
|
}()
|
|
|
|
for source, got := range map[string]chan string{
|
|
"FindATender": fatFrom,
|
|
"FTSMock": mockFrom,
|
|
} {
|
|
want := tt.wantFAT
|
|
if source == "FTSMock" {
|
|
want = tt.wantMock
|
|
}
|
|
select {
|
|
case from := <-got:
|
|
if want == "" {
|
|
checkAboutADayAgo(t, from)
|
|
} else if from != want {
|
|
t.Errorf(
|
|
"%s: %s updatedFrom = %s; want %s",
|
|
tt.name, source, from, want,
|
|
)
|
|
}
|
|
case <-time.After(5 * time.Second):
|
|
t.Errorf("%s: %s was not polled", tt.name, source)
|
|
}
|
|
}
|
|
cancel()
|
|
<-done
|
|
}
|
|
}
|
|
|
|
// checkAboutADayAgo fails the test unless updatedFrom, in local time as the
|
|
// client sends it, is about a day ago: within an hour, as a calendar day
|
|
// across a clock change isn't 24 hours.
|
|
func checkAboutADayAgo(t *testing.T, updatedFrom string) {
|
|
t.Helper()
|
|
from, err := time.ParseInLocation(
|
|
"2006-01-02T15:04:05", updatedFrom, time.Local,
|
|
)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
gap := time.Since(from) - 24*time.Hour
|
|
if gap < -time.Hour || gap > time.Hour {
|
|
t.Errorf("updatedFrom = %s; want about a day ago", updatedFrom)
|
|
}
|
|
}
|
|
|
|
// fetched returns the opportunities converted from body, a release package.
|
|
func fetched(t *testing.T, body string) []*types.Opportunity {
|
|
t.Helper()
|
|
srv, _ := ftsServer(t, http.StatusOK, body)
|
|
opportunities, err := fetchers["FindATender"](
|
|
context.Background(), srv.URL, time.Now().Add(-time.Hour),
|
|
)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return opportunities
|
|
}
|
|
|
|
// A save publishes the new opportunities and those with new releases, and
|
|
// nothing when an opportunity is only fetched again.
|
|
func TestSavePublishesUpdates(t *testing.T) {
|
|
var bus events.Bus
|
|
var published []events.OpportunitiesUpdatedPayload
|
|
bus.Subscribe(func(event events.Event) {
|
|
if event.Type != events.OpportunitiesUpdated {
|
|
t.Errorf("event type = %q", event.Type)
|
|
}
|
|
payload := event.Payload.(events.OpportunitiesUpdatedPayload)
|
|
published = append(published, payload)
|
|
})
|
|
f := New(
|
|
&config.JSCSources{},
|
|
WithStore(openTestStore(t)),
|
|
WithEvents(&bus),
|
|
)
|
|
save := func(body string) {
|
|
t.Helper()
|
|
err := f.save(context.Background(), slog.Default(), fetched(t, body))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
newRelease := strings.NewReplacer(
|
|
`"id": "r1"`, `"id": "r2"`,
|
|
"2026-10-01T12:00:00Z", "2026-10-02T12:00:00Z",
|
|
).Replace(onePackage)
|
|
|
|
save(onePackage) // new
|
|
save(onePackage) // fetched again: nothing published
|
|
save(newRelease) // a new release of a stored opportunity
|
|
|
|
// Each event names its own batch; the hashes are random.
|
|
if len(published) == 2 {
|
|
if published[0].Batch == "" ||
|
|
published[0].Batch == published[1].Batch {
|
|
t.Errorf(
|
|
"batches %q and %q; want two different ones",
|
|
published[0].Batch, published[1].Batch,
|
|
)
|
|
}
|
|
published[0].Batch, published[1].Batch = "", ""
|
|
}
|
|
|
|
want := []events.OpportunitiesUpdatedPayload{
|
|
{
|
|
New: []string{"ocds-1"},
|
|
Updated: []string{},
|
|
Summary: events.OpportunitiesSummary{
|
|
New: 1,
|
|
},
|
|
},
|
|
{
|
|
New: []string{},
|
|
Updated: []string{"ocds-1"},
|
|
Summary: events.OpportunitiesSummary{
|
|
Updated: 1,
|
|
},
|
|
},
|
|
}
|
|
if !reflect.DeepEqual(published, want) {
|
|
t.Errorf("published %+v; want %+v", published, want)
|
|
}
|
|
}
|
|
|
|
// More than events.MaxListedOpportunities are only counted.
|
|
func TestPublishUpdatesLarge(t *testing.T) {
|
|
var bus events.Bus
|
|
var published []events.OpportunitiesUpdatedPayload
|
|
bus.Subscribe(func(event events.Event) {
|
|
payload := event.Payload.(events.OpportunitiesUpdatedPayload)
|
|
published = append(published, payload)
|
|
})
|
|
f := New(&config.JSCSources{}, WithEvents(&bus))
|
|
|
|
publish := func(count int) {
|
|
t.Helper()
|
|
var opportunities []*types.Opportunity
|
|
result := store.SaveMultipleResult{
|
|
ChangeBatch: "batch",
|
|
}
|
|
for i := range count {
|
|
opportunities = append(opportunities, &types.Opportunity{
|
|
UUID: fmt.Sprintf("ocds-%d", i),
|
|
})
|
|
result.OpportunityResults = append(
|
|
result.OpportunityResults,
|
|
store.SaveResult{
|
|
NewOpportunity: i%2 == 0,
|
|
NewReleases: 1,
|
|
},
|
|
)
|
|
}
|
|
f.publishUpdates(opportunities, result)
|
|
}
|
|
|
|
publish(events.MaxListedOpportunities)
|
|
publish(events.MaxListedOpportunities + 1)
|
|
if len(published) != 2 {
|
|
t.Fatalf("published %d events; want 2", len(published))
|
|
}
|
|
|
|
listed := published[0]
|
|
if listed.Large ||
|
|
len(listed.New)+len(listed.Updated) != events.MaxListedOpportunities {
|
|
t.Errorf("at the limit: %+v; want every UUID listed", listed)
|
|
}
|
|
|
|
large := published[1]
|
|
want := events.OpportunitiesSummary{
|
|
New: 26,
|
|
Updated: 25,
|
|
}
|
|
if !large.Large || len(large.New) != 0 || len(large.Updated) != 0 ||
|
|
large.New == nil || large.Updated == nil ||
|
|
large.Summary != want || large.Batch != "batch" {
|
|
t.Errorf("over the limit: %+v; want only counted", large)
|
|
}
|
|
}
|
|
|
|
// Without WithEvents, a save publishes nothing and still succeeds.
|
|
func TestSaveWithoutEvents(t *testing.T) {
|
|
f := New(&config.JSCSources{}, WithStore(openTestStore(t)))
|
|
err := f.save(context.Background(), slog.Default(), fetched(t, onePackage))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
|
|
// withClassification returns onePackage with its release changed to id and
|
|
// date, and its tender classified by code.
|
|
func withClassification(id, date, code string) string {
|
|
return strings.NewReplacer(
|
|
`"id": "r1"`, `"id": "`+id+`"`,
|
|
"2026-10-01T12:00:00Z", date,
|
|
`"tender": {`,
|
|
`"tender": {"classification": {"scheme": "CPV", "id": "`+code+
|
|
`", "description": "Code `+code+`"}, `,
|
|
).Replace(onePackage)
|
|
}
|
|
|
|
// A save publishes the classification codes it brings into use, in full, and
|
|
// the ids of those it takes out of use, before OpportunitiesUpdated; nothing
|
|
// when the codes in use don't change.
|
|
func TestSavePublishesClassificationChanges(t *testing.T) {
|
|
var bus events.Bus
|
|
var published []events.Event
|
|
bus.Subscribe(func(event events.Event) {
|
|
published = append(published, event)
|
|
})
|
|
f := New(
|
|
&config.JSCSources{},
|
|
WithStore(openTestStore(t)),
|
|
WithEvents(&bus),
|
|
)
|
|
save := func(body string) []events.ClassificationsUpdatedPayload {
|
|
t.Helper()
|
|
published = nil
|
|
err := f.save(context.Background(), slog.Default(), fetched(t, body))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
var changes []events.ClassificationsUpdatedPayload
|
|
for i, event := range published {
|
|
if event.Type != events.ClassificationsUpdated {
|
|
continue
|
|
}
|
|
if i != 0 {
|
|
t.Errorf("ClassificationsUpdated was event %d; want it first", i)
|
|
}
|
|
changes = append(
|
|
changes, event.Payload.(events.ClassificationsUpdatedPayload),
|
|
)
|
|
}
|
|
return changes
|
|
}
|
|
|
|
changes := save(withClassification("r1", "2026-10-01T12:00:00Z", "72000000"))
|
|
if len(changes) != 1 || len(changes[0].Added) != 1 ||
|
|
changes[0].Added[0].Code != "72000000" || len(changes[0].Removed) != 0 {
|
|
t.Fatalf("first save published %+v; want 72000000 added", changes)
|
|
}
|
|
oldID := changes[0].Added[0].ID
|
|
|
|
changes = save(withClassification("r1", "2026-10-01T12:00:00Z", "72000000"))
|
|
if len(changes) != 0 {
|
|
t.Errorf("saving again published %+v; want nothing", changes)
|
|
}
|
|
|
|
changes = save(withClassification("r2", "2026-10-02T12:00:00Z", "03000000"))
|
|
if len(changes) != 1 || len(changes[0].Added) != 1 ||
|
|
changes[0].Added[0].Code != "03000000" ||
|
|
len(changes[0].Removed) != 1 || changes[0].Removed[0] != oldID {
|
|
t.Errorf(
|
|
"replacing the code published %+v; want 03000000 added, %d removed",
|
|
changes, oldID,
|
|
)
|
|
}
|
|
}
|
|
|
|
// skipRulesFile writes rules to a file and opens it.
|
|
func skipRulesFile(t *testing.T, rules string) *skiprules.File {
|
|
t.Helper()
|
|
path := t.TempDir() + "/skip-rules.json"
|
|
if err := os.WriteFile(path, []byte(rules), 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
file, err := skiprules.Open(path)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return file
|
|
}
|
|
|
|
// A new opportunity that matches the skip rules is saved Skipped; one
|
|
// already stored keeps its marks.
|
|
func TestSaveSkipsNewOpportunities(t *testing.T) {
|
|
rules := skipRulesFile(t, `[{"comment": "IT", "active": true,
|
|
"rules": [{"rule_type": "cpv", "values": ["72000000"]}]}]`)
|
|
ctx := context.Background()
|
|
skipped := func(s *store.Store) bool {
|
|
t.Helper()
|
|
opportunity, err := s.GetOpportunity(ctx, "ocds-1")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return opportunity.Marks.Skipped
|
|
}
|
|
matching := withClassification("r1", "2026-10-01T12:00:00Z", "72000000")
|
|
|
|
// New and matching: skipped, and counted in the log.
|
|
var logged bytes.Buffer
|
|
logger := slog.New(slog.NewTextHandler(&logged, &slog.HandlerOptions{
|
|
Level: slog.LevelDebug,
|
|
}))
|
|
s := openTestStore(t)
|
|
f := New(&config.JSCSources{}, WithStore(s), WithSkipRules(rules))
|
|
if err := f.save(ctx, logger, fetched(t, matching)); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !skipped(s) {
|
|
t.Error("a new matching opportunity wasn't skipped")
|
|
}
|
|
for _, want := range []string{
|
|
"skipped_opportunities=1",
|
|
"msg=\"skipped new opportunity\" component=fetcher uuid=ocds-1 filter=IT",
|
|
} {
|
|
if !strings.Contains(logged.String(), want) {
|
|
t.Errorf("log has no %q:\n%s", want, logged.String())
|
|
}
|
|
}
|
|
|
|
// New and not matching: not skipped.
|
|
s = openTestStore(t)
|
|
f = New(&config.JSCSources{}, WithStore(s), WithSkipRules(rules))
|
|
other := withClassification("r1", "2026-10-01T12:00:00Z", "03000000")
|
|
if err := f.save(ctx, slog.Default(), fetched(t, other)); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if skipped(s) {
|
|
t.Error("a new opportunity that doesn't match was skipped")
|
|
}
|
|
|
|
// Stored already: a new release that matches doesn't skip it.
|
|
newRelease := withClassification("r2", "2026-10-02T12:00:00Z", "72000000")
|
|
logged.Reset()
|
|
if err := f.save(ctx, logger, fetched(t, newRelease)); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if skipped(s) {
|
|
t.Error("a stored opportunity was skipped by a new release")
|
|
}
|
|
if !strings.Contains(logged.String(), "skipped_opportunities=0") {
|
|
t.Errorf("log doesn't count 0 skipped:\n%s", logged.String())
|
|
}
|
|
}
|