Added unit tests, documentation and function descriptions across the board. Increased cyclop complexity threshold to 15 which is a bit more sane than 10 and excluded _test.go files from linters.

This commit is contained in:
bwilliams committed 2026-10-09 18:27:51 +01:00
1 parent 005b335371
commit 7fd321df97
31 files changed
+4106 -1

No files matched your search

+215
View File
@@ -0,0 +1,215 @@
package mock
import (
"context"
"io"
"log"
"math/rand"
"path/filepath"
"strings"
"testing"
"time"
"git.avsdev.uk/Go/job-search/internal/mock/findATender/ocds"
)
func quietLog(t *testing.T) {
t.Helper()
w := log.Writer()
log.SetOutput(io.Discard)
t.Cleanup(func() { log.SetOutput(w) })
}
func TestGap(t *testing.T) {
//nolint:gosec // G404: seeded math/rand keeps the test deterministic
g := &Generator{rnd: 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":
if got := *r.Tender.TenderPeriod.EndDate; !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":
if got := *r.Tender.TenderPeriod.EndDate; !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) {
quietLog(t)
cat := loadTestCatalog(t)
store, err := OpenStore(filepath.Join(t.TempDir(), "r.jsonl"))
if err != nil {
t.Fatal(err)
}
defer store.Close()
ctx, cancel := context.WithCancel(context.Background())
done := make(chan struct{})
go func() {
NewGenerator(cat, store, 1).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) {
quietLog(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()
NewGenerator(cat, store, 1).Run(ctx, time.Millisecond, time.Millisecond)
if store.Len() != 0 {
t.Errorf("Len = %d, want 0", store.Len())
}
}