package fetcher import ( "bytes" "context" "errors" "io" "log/slog" "net/http" "net/http/httptest" "os" "strings" "sync/atomic" "testing" "time" "git.avsdev.uk/Go/job-search/internal/config" "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 } func confFor(apiRoot string) *config.JobSearchConfig { conf := &config.JobSearchConfig{} conf.Sources.FindATender.APIRoot = apiRoot conf.Sources.FTSMock.APIRoot = apiRoot return conf } func TestFetchFunctions(t *testing.T) { fetchers := map[string]runFunc{ "FindAOpportunity": fetchFindAOpportunity, "FTSMock": fetchFTSMock, } 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(), slog.Default(), confFor(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") } }) } } } func TestFetchUsesConfiguredAPIRoot(t *testing.T) { fat, fatHits := ftsServer(t, http.StatusOK, emptyPackage) mock, mockHits := ftsServer(t, http.StatusOK, emptyPackage) conf := &config.JobSearchConfig{} conf.Sources.FindATender.APIRoot = fat.URL conf.Sources.FTSMock.APIRoot = mock.URL fetchFindAOpportunity(context.Background(), slog.Default(), conf, time.Time{}) if fatHits.Load() != 1 || mockHits.Load() != 0 { t.Fatalf( "FindAOpportunity hits: fat=%d mock=%d", fatHits.Load(), mockHits.Load(), ) } fetchFTSMock(context.Background(), slog.Default(), conf, time.Time{}) if fatHits.Load() != 1 || mockHits.Load() != 1 { t.Fatalf("FTSMock hits: fat=%d mock=%d", fatHits.Load(), mockHits.Load()) } } func TestFetchCancelled(t *testing.T) { ctx, cancel := context.WithCancel(context.Background()) cancel() // Unroutable root: the request should never be waited on. conf := confFor("http://192.0.2.1:9") start := time.Now() err := fetchFTSMock(ctx, slog.Default(), conf, 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.JobSearchConfig{} conf.Sources.Sleeper.Interval = time.Hour conf.Sources.Sleeper.BlockShutdown = block 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) { conf := &config.JobSearchConfig{} runs := map[string]runFunc{ "FindAOpportunity": runFindAOpportunity, "FTSMock": runFTSMock, "Sleeper": runSleeper, } for name, run := range runs { if err := run( context.Background(), slog.Default(), conf, time.Time{}, ); err == nil { t.Errorf("%s: expected an error for a zero interval", name) } } } // 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. conf := &config.JobSearchConfig{} conf.Sources.FindATender.Enabled = true conf.Sources.FTSMock.Enabled = true conf.Sources.Sleeper.Enabled = true err := Run(context.Background(), conf, 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) } } func TestRunNoSourcesEnabled(t *testing.T) { done := make(chan struct{}) go func() { if err := Run( context.Background(), &config.JobSearchConfig{}, time.Time{}, ); 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) conf := &config.JobSearchConfig{} conf.Sources.FTSMock.Enabled = true conf.Sources.FTSMock.Interval = time.Hour conf.Sources.FTSMock.APIRoot = mock.URL // The sleeper fails straight away: a zero interval is invalid. conf.Sources.Sleeper.Enabled = true done := make(chan error, 1) go func() { done <- Run(context.Background(), conf, 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) conf := &config.JobSearchConfig{} conf.Sources.FindATender.Enabled = true conf.Sources.FindATender.Interval = time.Hour conf.Sources.FindATender.APIRoot = fat.URL conf.Sources.FTSMock.Enabled = true conf.Sources.FTSMock.Interval = time.Hour conf.Sources.FTSMock.APIRoot = mock.URL conf.Sources.Sleeper.Enabled = true conf.Sources.Sleeper.Interval = time.Hour ctx, cancel := context.WithCancel(context.Background()) done := make(chan struct{}) go func() { if err := Run(ctx, conf, time.Now().Add(-time.Hour)); 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") } }