package mock import ( "context" "log/slog" "math/rand" "path/filepath" "strings" "testing" "time" "git.avsdev.uk/Go/job-search/internal/mock/findatender/ocds" ) // quiet discards the logging of the Store and Generator it is given to. var quiet = WithLogger(slog.New(slog.DiscardHandler)) func TestGap(t *testing.T) { //nolint:gosec // G404: seeded math/rand keeps the test deterministic g := &Generator{ rng: rand.New(rand.NewSource(1)), } if d := g.gap(time.Second, time.Second); d != time.Second { t.Errorf("min == max: %s", d) } if d := g.gap(2*time.Second, time.Second); d != 2*time.Second { t.Errorf("max < min: %s, want min", d) } for i := 0; i < 1000; i++ { if d := g.gap(time.Second, 2*time.Second); d < time.Second || d > 2*time.Second { t.Fatalf("gap out of range: %s", d) } } } func TestScaleValue(t *testing.T) { scaleValue(nil, 2) // must not panic v := &ocds.Value{ Amount: ptr(12345.0), Currency: "GBP", } scaleValue(v, 1.5) if *v.Amount != 18500 { // 18517.5 rounded to the nearest 100 t.Errorf("amount = %v, want 18500", *v.Amount) } if v.AmountGross != nil { t.Error("nil gross amount was set") } v = &ocds.Value{ Amount: ptr(1000.0), AmountGross: ptr(1200.0), } scaleValue(v, 0.5) if *v.Amount != 500 || *v.AmountGross != 600 { t.Errorf("amounts = %v / %v, want 500 / 600", *v.Amount, *v.AmountGross) } } func TestEnsurePartyID(t *testing.T) { p := ocds.Organization{ ID: "GB-EXISTING", Name: "Council", } ensurePartyID(&p) if p.ID != "GB-EXISTING" { t.Errorf("existing id replaced: %s", p.ID) } a := ocds.Organization{ Name: "Somewhere Council", } b := ocds.Organization{ Name: "Somewhere Council", } c := ocds.Organization{ Name: "Elsewhere Council", } ensurePartyID(&a) ensurePartyID(&b) ensurePartyID(&c) if !strings.HasPrefix(a.ID, "GB-FTS-") { t.Errorf("id = %q", a.ID) } if a.ID != b.ID { t.Errorf("same name gave %s and %s", a.ID, b.ID) } if a.ID == c.ID { t.Errorf("different names gave the same id %s", a.ID) } } func TestAdvance(t *testing.T) { cat := loadTestCatalog(t) store, err := OpenStore(filepath.Join(t.TempDir(), "r.jsonl")) if err != nil { t.Fatal(err) } defer store.Close() g := NewGenerator(cat, store, 1) at := time.Date(2026, 10, 8, 12, 0, 0, 0, time.UTC) // A planning notice without a tender becomes an active tender. r := g.advance( ocds.Release{ OCID: "ocds-x", Tag: []string{"planning"}, }, at, ) if r.Tag[0] != "tender" || r.Tender == nil || r.Tender.Status != "active" || r.Tender.TenderPeriod == nil || r.Tender.TenderPeriod.EndDate == nil { t.Fatalf("planning -> %v, tender %+v", r.Tag, r.Tender) } if string(r.Tender.ID) != "ocds-x" || !r.Date.Equal(at) { t.Errorf("tender id %s, date %s", r.Tender.ID, r.Date) } // A tender is either updated (deadline + 14 days) or awarded. end := at.AddDate(0, 0, 20) prev := ocds.Release{ OCID: "ocds-y", Tag: []string{"tender"}, Tender: &ocds.Tender{ ID: "ocds-y", Value: &ocds.Value{ Amount: ptr(10000.0), Currency: "GBP", }, Lots: []ocds.Lot{ { ID: "1", }, { ID: "", }, }, TenderPeriod: &ocds.Period{ EndDate: &end, }, }, } seen := map[string]bool{} for i := 0; i < 50; i++ { r := g.advance(prev, at) seen[r.Tag[0]] = true switch r.Tag[0] { case "tenderUpdate": got := *r.Tender.TenderPeriod.EndDate if !got.Equal(end.AddDate(0, 0, 14)) { t.Fatalf("updated deadline %s", got) } case "award": if r.Tender.Status != "complete" || len(r.Awards) != 1 { t.Fatalf("award: status %s, %d awards", r.Tender.Status, len(r.Awards)) } aw := r.Awards[0] if aw.Value == nil || *aw.Value.Amount < 8000 || *aw.Value.Amount > 10000 { t.Fatalf("award value %+v", aw.Value) } if len(aw.RelatedLots) != 1 || aw.RelatedLots[0] != "1" { t.Fatalf("related lots %v", aw.RelatedLots) } if len(aw.Suppliers) != 1 || len(r.Parties) != 1 || r.Parties[0].Roles[0] != "supplier" { t.Fatalf("suppliers %v, parties %v", aw.Suppliers, r.Parties) } default: t.Fatalf("tender -> %s", r.Tag[0]) } } if !seen["tenderUpdate"] || !seen["award"] { t.Errorf("expected both updates and awards, saw %v", seen) } if *prev.Tender.TenderPeriod.EndDate != end || prev.Awards != nil { t.Error("advance modified the previous release") } // An update without a deadline gets 30 + 14 days; a tender with no value // is awarded with no value. prev = ocds.Release{ OCID: "ocds-z", Tag: []string{"tender"}, Tender: &ocds.Tender{ ID: "ocds-z", }, } for i := 0; i < 50; i++ { r := g.advance(prev, at) switch r.Tag[0] { case "tenderUpdate": got := *r.Tender.TenderPeriod.EndDate if !got.Equal(at.AddDate(0, 0, 44)) { t.Fatalf("default deadline %s", got) } case "award": if r.Awards[0].Value != nil { t.Fatalf("award value %+v, want nil", r.Awards[0].Value) } } } } func TestRunPublishesUntilCancelled(t *testing.T) { cat := loadTestCatalog(t) store, err := OpenStore(filepath.Join(t.TempDir(), "r.jsonl"), quiet) if err != nil { t.Fatal(err) } defer store.Close() ctx, cancel := context.WithCancel(context.Background()) done := make(chan struct{}) generator := NewGenerator(cat, store, 1, quiet) go func() { generator.Run(ctx, time.Millisecond, 2*time.Millisecond) close(done) }() deadline := time.After(5 * time.Second) for store.Len() < 3 { select { case <-deadline: cancel() t.Fatalf("only %d releases published", store.Len()) case <-time.After(time.Millisecond): } } cancel() select { case <-done: case <-time.After(5 * time.Second): t.Fatal("Run did not return after cancel") } } // A failing store is logged and Run carries on until cancelled. func TestRunSurvivesStoreErrors(t *testing.T) { cat := loadTestCatalog(t) store, err := OpenStore(filepath.Join(t.TempDir(), "r.jsonl")) if err != nil { t.Fatal(err) } store.Close() ctx, cancel := context.WithTimeout(context.Background(), 20*time.Millisecond) defer cancel() generator := NewGenerator(cat, store, 1, quiet) generator.Run(ctx, time.Millisecond, time.Millisecond) if store.Len() != 0 { t.Errorf("Len = %d, want 0", store.Len()) } }