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) }, "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) }, } // 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()) } }