File/package naming convention conforming better to go standards - as ugly as it is
This commit is contained in:
1 parent
be4770cbab
commit
d6688a6754
32 files changed
+36
-36
No files matched your search
@@ -0,0 +1,191 @@
|
||||
package mock
|
||||
|
||||
import (
|
||||
_ "embed"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"math/rand"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"git.avsdev.uk/Go/job-search/internal/mock/findatender/ocds"
|
||||
)
|
||||
|
||||
// Method is a procurement method and its details, as published on a tender.
|
||||
type Method struct {
|
||||
Method string `json:"procurementMethod"`
|
||||
Details string `json:"procurementMethodDetails,omitempty"`
|
||||
}
|
||||
|
||||
// embeddedCatalog is data/catalog.json, compiled into the binary. Rebuild after
|
||||
// changing the file.
|
||||
//
|
||||
//go:embed data/catalog.json
|
||||
var embeddedCatalog []byte
|
||||
|
||||
// Catalog is the pool the generator draws from. It is read from
|
||||
// ./data/catalog.json. Classifications holds every classification the subjects
|
||||
// use, keyed "{scheme}-{id}" (e.g. "CPV-90513000"), and subjects and their
|
||||
// items refer to entries by that key.
|
||||
type Catalog struct {
|
||||
Buyers []ocds.Organization `json:"buyers"`
|
||||
Suppliers []ocds.Organization `json:"suppliers"`
|
||||
Subjects []Subject `json:"subjects"`
|
||||
Methods []Method `json:"methods"`
|
||||
Classifications map[string]ocds.Classification `json:"classifications"`
|
||||
}
|
||||
|
||||
// Subject is a tender as stored in the catalog: an ocds.Tender whose main
|
||||
// classification and additionalClassifications, on the tender and on each
|
||||
// item, are keys into Catalog.Classifications. The outer fields shadow the
|
||||
// embedded ones of the same JSON name, so everything else is read as
|
||||
// ocds.Tender.
|
||||
type Subject struct {
|
||||
ocds.Tender
|
||||
Classification string `json:"classification,omitempty"`
|
||||
AdditionalClassifications []string `json:"additionalClassifications,omitempty"`
|
||||
Items []SubjectItem `json:"items,omitempty"`
|
||||
}
|
||||
|
||||
// SubjectItem is an ocds.Item whose additionalClassifications are keys into
|
||||
// Catalog.Classifications.
|
||||
type SubjectItem struct {
|
||||
ocds.Item
|
||||
AdditionalClassifications []string `json:"additionalClassifications,omitempty"`
|
||||
}
|
||||
|
||||
// LoadCatalog parses the embedded catalog and checks it is usable: every
|
||||
// pool has at least one entry and every classification key resolves.
|
||||
func LoadCatalog() (*Catalog, error) {
|
||||
return parseCatalog(embeddedCatalog, "embedded catalog")
|
||||
}
|
||||
|
||||
// parseCatalog decodes and validates catalog JSON; name labels any error.
|
||||
func parseCatalog(b []byte, name string) (*Catalog, error) {
|
||||
var c Catalog
|
||||
if err := json.Unmarshal(b, &c); err != nil {
|
||||
return nil, fmt.Errorf("decoding %s: %w", name, err)
|
||||
}
|
||||
if err := c.validate(); err != nil {
|
||||
return nil, fmt.Errorf("invalid %s: %w", name, err)
|
||||
}
|
||||
return &c, nil
|
||||
}
|
||||
|
||||
// validate checks that every pool has at least one entry and that every
|
||||
// classification key used by a subject or its items is in Classifications.
|
||||
// It reports all the problems found, listing at most five missing keys.
|
||||
func (c *Catalog) validate() error {
|
||||
var errs []error
|
||||
|
||||
for _, pool := range []struct {
|
||||
name string
|
||||
n int
|
||||
}{
|
||||
{"buyers", len(c.Buyers)},
|
||||
{"suppliers", len(c.Suppliers)},
|
||||
{"subjects", len(c.Subjects)},
|
||||
{"methods", len(c.Methods)},
|
||||
{"classifications", len(c.Classifications)},
|
||||
} {
|
||||
if pool.n == 0 {
|
||||
errs = append(errs, fmt.Errorf("no %s", pool.name))
|
||||
}
|
||||
}
|
||||
|
||||
missing := map[string]bool{}
|
||||
check := func(keys ...string) {
|
||||
for _, k := range keys {
|
||||
if _, ok := c.Classifications[k]; k != "" && !ok {
|
||||
missing[k] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for _, s := range c.Subjects {
|
||||
check(s.Classification)
|
||||
check(s.AdditionalClassifications...)
|
||||
for _, it := range s.Items {
|
||||
check(it.AdditionalClassifications...)
|
||||
}
|
||||
}
|
||||
|
||||
if len(missing) > 0 {
|
||||
keys := make([]string, 0, len(missing))
|
||||
for k := range missing {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
if len(keys) > 5 {
|
||||
keys = append(keys[:5], "...")
|
||||
}
|
||||
errs = append(errs, fmt.Errorf(
|
||||
"%d classification key(s) not found in classifications: %s",
|
||||
len(missing), strings.Join(keys, ", "),
|
||||
))
|
||||
}
|
||||
|
||||
return errors.Join(errs...)
|
||||
}
|
||||
|
||||
// ---------- picking (always returns a copy the caller may modify) ----------
|
||||
|
||||
func (c *Catalog) buyer(r *rand.Rand) ocds.Organization {
|
||||
return clone(c.Buyers[r.Intn(len(c.Buyers))])
|
||||
}
|
||||
|
||||
func (c *Catalog) supplier(r *rand.Rand) ocds.Organization {
|
||||
return clone(c.Suppliers[r.Intn(len(c.Suppliers))])
|
||||
}
|
||||
|
||||
func (c *Catalog) method(r *rand.Rand) Method {
|
||||
return c.Methods[r.Intn(len(c.Methods))]
|
||||
}
|
||||
|
||||
// subject picks a subject and returns it as an ocds.Tender, with its
|
||||
// classification keys resolved to full classifications.
|
||||
func (c *Catalog) subject(r *rand.Rand) ocds.Tender {
|
||||
s := clone(c.Subjects[r.Intn(len(c.Subjects))])
|
||||
t := s.Tender
|
||||
t.Classification = c.resolveOne(s.Classification)
|
||||
t.AdditionalClassifications = c.resolve(s.AdditionalClassifications)
|
||||
t.Items = nil
|
||||
for _, it := range s.Items {
|
||||
item := it.Item
|
||||
item.AdditionalClassifications = c.resolve(it.AdditionalClassifications)
|
||||
t.Items = append(t.Items, item)
|
||||
}
|
||||
return t
|
||||
}
|
||||
|
||||
// resolveOne returns a copy of the classification with key, or nil if there
|
||||
// is none.
|
||||
func (c *Catalog) resolveOne(key string) *ocds.Classification {
|
||||
if cl, ok := c.Classifications[key]; ok {
|
||||
return &cl
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// resolve returns copies of the classifications with keys, skipping keys
|
||||
// that have none.
|
||||
func (c *Catalog) resolve(keys []string) []ocds.Classification {
|
||||
var out []ocds.Classification
|
||||
for _, k := range keys {
|
||||
if cl, ok := c.Classifications[k]; ok {
|
||||
out = append(out, cl)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// clone deep-copies a value through JSON, which is all the generated types need.
|
||||
func clone[T any](v T) T {
|
||||
var out T
|
||||
b, err := json.Marshal(v)
|
||||
if err == nil {
|
||||
json.Unmarshal(b, &out) //nolint:errcheck,gosec // b was just marshalled from a T
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
package mock
|
||||
|
||||
import (
|
||||
"math/rand"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// loadTestCatalog parses the small fixture catalog used by the tests.
|
||||
func loadTestCatalog(t *testing.T) *Catalog {
|
||||
t.Helper()
|
||||
b, err := os.ReadFile("testdata/catalog.json")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cat, err := parseCatalog(b, "testdata/catalog.json")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return cat
|
||||
}
|
||||
|
||||
// The catalog compiled into the build must load: a bad data/catalog.json
|
||||
// fails here rather than when the mock starts.
|
||||
func TestEmbeddedCatalogIsValid(t *testing.T) {
|
||||
if len(embeddedCatalog) == 0 {
|
||||
t.Fatal("data/catalog.json was not embedded")
|
||||
}
|
||||
cat, err := LoadCatalog()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(cat.Buyers) == 0 || len(cat.Suppliers) == 0 || len(cat.Subjects) == 0 ||
|
||||
len(cat.Methods) == 0 {
|
||||
t.Fatalf("embedded catalog has an empty pool: %d buyers, %d suppliers, %d subjects, %d methods",
|
||||
len(cat.Buyers), len(cat.Suppliers), len(cat.Subjects), len(cat.Methods))
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseCatalog(t *testing.T) {
|
||||
cat := loadTestCatalog(t)
|
||||
if len(cat.Buyers) != 2 || len(cat.Suppliers) != 1 || len(cat.Subjects) != 2 ||
|
||||
len(cat.Methods) != 2 {
|
||||
t.Fatalf("got %d buyers, %d suppliers, %d subjects, %d methods",
|
||||
len(cat.Buyers), len(cat.Suppliers), len(cat.Subjects), len(cat.Methods))
|
||||
}
|
||||
if d := cat.Buyers[0].Details; d == nil || len(d.Classifications) != 1 ||
|
||||
d.Classifications[0].Scheme != "UK_CA_TYPE" {
|
||||
t.Fatalf("buyer details.classifications not read: %+v", d)
|
||||
}
|
||||
s := cat.Subjects[0]
|
||||
if s.Classification != "CPV-77314000" || len(s.Items) != 1 {
|
||||
t.Fatalf("subject classification key/items not read: %+v", s)
|
||||
}
|
||||
if ac := s.Items[0].AdditionalClassifications; len(ac) != 1 || ac[0] != "CPV-77310000" {
|
||||
t.Fatalf("items.additionalClassifications keys not read: %+v", ac)
|
||||
}
|
||||
if v := s.Lots[0].Value; v == nil || v.AmountGross == nil || *v.AmountGross != 120000 {
|
||||
t.Fatalf("lot value not read: %+v", v)
|
||||
}
|
||||
}
|
||||
|
||||
// Picking a subject resolves its classification keys to full classifications.
|
||||
func TestSubjectResolvesClassifications(t *testing.T) {
|
||||
cat := loadTestCatalog(t)
|
||||
//nolint:gosec // G404: seeded math/rand keeps the test deterministic
|
||||
r := rand.New(rand.NewSource(1))
|
||||
for i := 0; i < 20; i++ {
|
||||
tender := cat.subject(r)
|
||||
if tender.Title != "Grounds maintenance services" {
|
||||
continue
|
||||
}
|
||||
if cl := tender.Classification; cl == nil || cl.ID != "77314000" ||
|
||||
cl.Description != "Grounds maintenance services" {
|
||||
t.Fatalf("main classification = %+v", cl)
|
||||
}
|
||||
ac := tender.Items[0].AdditionalClassifications
|
||||
if len(ac) != 1 || ac[0].Description != "Planting and maintenance services of green areas" {
|
||||
t.Fatalf("item classifications = %+v", ac)
|
||||
}
|
||||
// The catalog itself still holds keys, untouched by picking.
|
||||
if cat.Subjects[0].Classification != "CPV-77314000" {
|
||||
t.Fatal("picking modified the catalog")
|
||||
}
|
||||
return
|
||||
}
|
||||
t.Fatal("never picked the grounds maintenance subject")
|
||||
}
|
||||
|
||||
func TestParseCatalogRejectsInvalidCatalogs(t *testing.T) {
|
||||
cases := map[string]struct {
|
||||
doc string
|
||||
want []string
|
||||
}{
|
||||
"empty pools": {
|
||||
doc: `{"buyers": [], "subjects": [{"title": "T"}], "methods": [], "classifications": {}}`,
|
||||
want: []string{"no buyers", "no suppliers", "no methods"},
|
||||
},
|
||||
"unknown classification keys": {
|
||||
doc: `{"buyers": [{"name": "B"}], "suppliers": [{"name": "S"}], "methods": [{"procurementMethod": "open"}],
|
||||
"subjects": [{"title": "T", "classification": "CPV-1", "items": [{"id": "1", "additionalClassifications": ["CPV-2"]}]}],
|
||||
"classifications": {}}`,
|
||||
want: []string{"2 classification key(s) not found", "CPV-1", "CPV-2"},
|
||||
},
|
||||
"not JSON": {doc: `{`, want: []string{"decoding"}},
|
||||
}
|
||||
for name, tc := range cases {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
_, err := parseCatalog([]byte(tc.doc), "test catalog")
|
||||
if err == nil {
|
||||
t.Fatal("expected an error")
|
||||
}
|
||||
for _, w := range tc.want {
|
||||
if !strings.Contains(err.Error(), w) {
|
||||
t.Errorf("error %q does not mention %q", err, w)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveOneUnknownKey(t *testing.T) {
|
||||
cat := loadTestCatalog(t)
|
||||
if cl := cat.resolveOne("CPV-00000000"); cl != nil {
|
||||
t.Errorf("unknown key resolved to %+v", cl)
|
||||
}
|
||||
if cl := cat.resolveOne(""); cl != nil {
|
||||
t.Errorf("empty key resolved to %+v", cl)
|
||||
}
|
||||
if got := cat.resolve([]string{"CPV-00000000"}); got != nil {
|
||||
t.Errorf("resolve of unknown keys = %+v", got)
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,13 @@
|
||||
// Package mock implements a mock of the Find a Tender (FTS) OCDS release
|
||||
// package endpoint (GET /api/1.0/ocdsReleasePackages): a catalog of buyers,
|
||||
// suppliers, subjects and methods loaded from catalog.json, a persistent
|
||||
// release store, a generator with a background worker, the HTTP handler and a
|
||||
// per-client rate limiter.
|
||||
package mock
|
||||
|
||||
// Files kept in the mock-data directory.
|
||||
//
|
||||
// ReleasesFile is created and appended to by the mock.
|
||||
const (
|
||||
ReleasesFile = "releases.jsonl"
|
||||
)
|
||||
@@ -0,0 +1,292 @@
|
||||
package mock
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"hash/fnv"
|
||||
"log"
|
||||
"math"
|
||||
"math/rand"
|
||||
"time"
|
||||
|
||||
"git.avsdev.uk/Go/job-search/internal/mock/findatender/ocds"
|
||||
)
|
||||
|
||||
const ocidPrefix = "ocds-mock-"
|
||||
|
||||
// Generator makes new releases from the catalog. About a third of the time it
|
||||
// moves an existing procedure on (planning -> tender -> award) so the same ocid
|
||||
// appears in several releases, as it does on the real service.
|
||||
type Generator struct {
|
||||
cat *Catalog
|
||||
store *Store
|
||||
rnd *rand.Rand
|
||||
}
|
||||
|
||||
// NewGenerator returns a generator drawing from cat and storing releases in
|
||||
// store. The same seed and store contents give the same releases.
|
||||
func NewGenerator(cat *Catalog, store *Store, seed int64) *Generator {
|
||||
return &Generator{
|
||||
cat: cat,
|
||||
store: store,
|
||||
rnd: rand.New(rand.NewSource(seed)), //nolint:gosec // seeded for repeatable mock data
|
||||
}
|
||||
}
|
||||
|
||||
// Make returns a new release dated at (to the second). About a third of the
|
||||
// time it moves on a procedure still open from the last 60 days; otherwise it
|
||||
// starts a new one.
|
||||
func (g *Generator) Make(at time.Time) ocds.Release {
|
||||
at = at.UTC().Truncate(time.Second)
|
||||
if g.rnd.Float64() < 0.35 {
|
||||
if prev, ok := g.store.RandomOpen(g.rnd, at.AddDate(0, 0, -60)); ok {
|
||||
return g.advance(prev, at)
|
||||
}
|
||||
}
|
||||
return g.newProcedure(at)
|
||||
}
|
||||
|
||||
// newProcedure returns the first release of a new procedure: a tender (or,
|
||||
// one time in five, a planning notice) from a random catalog buyer and
|
||||
// subject, using a random procurement method. Values are scaled by a random
|
||||
// factor, and a tender without lots gets a single lot covering it all.
|
||||
func (g *Generator) newProcedure(at time.Time) ocds.Release {
|
||||
buyer := g.cat.buyer(g.rnd)
|
||||
ensurePartyID(&buyer)
|
||||
buyer.Roles = []string{"buyer"}
|
||||
m := g.cat.method(g.rnd)
|
||||
|
||||
tag, status := "tender", "active"
|
||||
if g.rnd.Float64() < 0.2 {
|
||||
tag, status = "planning", "planned"
|
||||
}
|
||||
ocid := g.newOCID()
|
||||
|
||||
t := g.cat.subject(g.rnd)
|
||||
t.ID = ocds.ID(ocid)
|
||||
t.ProcurementMethod = m.Method
|
||||
t.ProcurementMethodDetails = m.Details
|
||||
|
||||
factor := 0.5 + g.rnd.Float64()*1.5
|
||||
if t.Value == nil {
|
||||
amount := float64((g.rnd.Intn(5000) + 10) * 1000)
|
||||
t.Value = &ocds.Value{
|
||||
Amount: &amount,
|
||||
Currency: "GBP",
|
||||
}
|
||||
} else {
|
||||
scaleValue(t.Value, factor)
|
||||
}
|
||||
|
||||
for i := range t.Lots {
|
||||
scaleValue(t.Lots[i].Value, factor)
|
||||
}
|
||||
|
||||
if len(t.Lots) == 0 {
|
||||
v := clone(*t.Value)
|
||||
t.Lots = []ocds.Lot{{
|
||||
ID: "1",
|
||||
Title: t.Title,
|
||||
Value: &v,
|
||||
}}
|
||||
}
|
||||
|
||||
setStatus(&t, status)
|
||||
if status == "active" {
|
||||
t.TenderPeriod = &ocds.Period{
|
||||
EndDate: ptr(at.AddDate(0, 0, 30+g.rnd.Intn(30))),
|
||||
}
|
||||
}
|
||||
|
||||
return ocds.Release{
|
||||
OCID: ocid,
|
||||
ID: g.store.NextNoticeID(at.Year()),
|
||||
Date: at,
|
||||
Tag: []string{tag},
|
||||
InitiationType: "tender",
|
||||
Parties: []ocds.Organization{buyer},
|
||||
Buyer: &ocds.OrganizationReference{
|
||||
ID: ocds.ID(buyer.ID),
|
||||
Name: buyer.Name,
|
||||
},
|
||||
Tender: &t,
|
||||
Language: "en",
|
||||
}
|
||||
}
|
||||
|
||||
// advance returns the next release in prev's procedure: planning moves to
|
||||
// tender, and a tender either has its deadline extended (tenderUpdate) or is
|
||||
// awarded to a supplier from the catalog.
|
||||
func (g *Generator) advance(prev ocds.Release, at time.Time) ocds.Release {
|
||||
r := clone(prev)
|
||||
r.ID = g.store.NextNoticeID(at.Year())
|
||||
r.Date = at
|
||||
r.Awards = nil
|
||||
if r.Tender == nil {
|
||||
r.Tender = &ocds.Tender{
|
||||
ID: ocds.ID(r.OCID),
|
||||
}
|
||||
}
|
||||
|
||||
next := "award"
|
||||
switch prev.Tag[0] {
|
||||
case "planning":
|
||||
next = "tender"
|
||||
case "tender":
|
||||
if g.rnd.Float64() < 0.25 {
|
||||
next = "tenderUpdate"
|
||||
}
|
||||
}
|
||||
r.Tag = []string{next}
|
||||
|
||||
switch next {
|
||||
case "tender":
|
||||
setStatus(r.Tender, "active")
|
||||
r.Tender.TenderPeriod = &ocds.Period{
|
||||
EndDate: ptr(at.AddDate(0, 0, 30+g.rnd.Intn(30))),
|
||||
}
|
||||
case "tenderUpdate":
|
||||
// A typical update: the deadline is extended by two weeks.
|
||||
end := at.AddDate(0, 0, 30)
|
||||
if tp := r.Tender.TenderPeriod; tp != nil && tp.EndDate != nil {
|
||||
end = *tp.EndDate
|
||||
}
|
||||
r.Tender.TenderPeriod = &ocds.Period{
|
||||
EndDate: ptr(end.AddDate(0, 0, 14)),
|
||||
}
|
||||
case "award":
|
||||
setStatus(r.Tender, "complete")
|
||||
sup := g.cat.supplier(g.rnd)
|
||||
ensurePartyID(&sup)
|
||||
sup.Roles = []string{"supplier"}
|
||||
r.Parties = append(r.Parties, sup)
|
||||
|
||||
var value *ocds.Value
|
||||
if r.Tender.Value != nil {
|
||||
v := clone(*r.Tender.Value)
|
||||
scaleValue(&v, 0.8+g.rnd.Float64()*0.2)
|
||||
value = &v
|
||||
}
|
||||
|
||||
var lotIDs []string
|
||||
for _, lot := range r.Tender.Lots {
|
||||
if lot.ID != "" {
|
||||
lotIDs = append(lotIDs, lot.ID)
|
||||
}
|
||||
}
|
||||
|
||||
r.Awards = []ocds.Award{{
|
||||
ID: "1",
|
||||
Status: "active",
|
||||
Date: ptr(at),
|
||||
Value: value,
|
||||
Suppliers: []ocds.OrganizationReference{
|
||||
{
|
||||
ID: ocds.ID(sup.ID),
|
||||
Name: sup.Name,
|
||||
},
|
||||
},
|
||||
RelatedLots: lotIDs,
|
||||
}}
|
||||
}
|
||||
|
||||
return r
|
||||
}
|
||||
|
||||
// newOCID returns a random ocid not already in the store.
|
||||
func (g *Generator) newOCID() string {
|
||||
for {
|
||||
id := fmt.Sprintf("%s%06x", ocidPrefix, g.rnd.Intn(1<<24))
|
||||
if !g.store.HasOCID(id) {
|
||||
return id
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// gap returns a random duration from min to max inclusive, or min if max is
|
||||
// not larger.
|
||||
func (g *Generator) gap(min, max time.Duration) time.Duration {
|
||||
if max <= min {
|
||||
return min
|
||||
}
|
||||
return min + time.Duration(g.rnd.Int63n(int64(max-min)+1))
|
||||
}
|
||||
|
||||
// CatchUp fills the period since the last stored release (or the last backfill
|
||||
// period, whichever is shorter) so restarts leave no gap in the timeline.
|
||||
func (g *Generator) CatchUp(
|
||||
now time.Time, backfill, min, max time.Duration,
|
||||
) (int, error) {
|
||||
start := now.Add(-backfill)
|
||||
if last, ok := g.store.Last(); ok && last.After(start) {
|
||||
start = last
|
||||
}
|
||||
|
||||
n := 0
|
||||
for t := start.Add(g.gap(min, max)); !t.After(now); t = t.Add(g.gap(min, max)) {
|
||||
if err := g.store.AppendNoSync(g.Make(t)); err != nil {
|
||||
return n, err
|
||||
}
|
||||
n++
|
||||
}
|
||||
|
||||
return n, g.store.Sync()
|
||||
}
|
||||
|
||||
// Run publishes a new release after a random gap between min and max until ctx ends.
|
||||
func (g *Generator) Run(ctx context.Context, min, max time.Duration) {
|
||||
for {
|
||||
d := g.gap(min, max)
|
||||
log.Printf("generator: next release in %s", d.Round(time.Second))
|
||||
|
||||
timer := time.NewTimer(d)
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
timer.Stop()
|
||||
return
|
||||
case <-timer.C:
|
||||
}
|
||||
|
||||
r := g.Make(time.Now())
|
||||
if err := g.store.Append(r); err != nil {
|
||||
log.Printf("generator: storing release: %v", err)
|
||||
continue
|
||||
}
|
||||
|
||||
log.Printf("generator: published %s (%s) %s", r.ID, r.Tag[0], r.OCID)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- helpers ----------
|
||||
|
||||
func ptr[T any](v T) *T { return &v }
|
||||
|
||||
func setStatus(t *ocds.Tender, status string) {
|
||||
t.Status = status
|
||||
for i := range t.Lots {
|
||||
t.Lots[i].Status = status
|
||||
}
|
||||
}
|
||||
|
||||
// scaleValue multiplies v's amounts by f, rounded to the nearest 100.
|
||||
func scaleValue(v *ocds.Value, f float64) {
|
||||
if v == nil {
|
||||
return
|
||||
}
|
||||
for _, a := range []**float64{&v.Amount, &v.AmountGross} {
|
||||
if *a != nil {
|
||||
*a = ptr(math.Round(**a*f/100) * 100)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ensurePartyID gives p an id derived from its name if it has none, so the
|
||||
// same organisation always gets the same id.
|
||||
func ensurePartyID(p *ocds.Organization) {
|
||||
if p.ID != "" {
|
||||
return
|
||||
}
|
||||
h := fnv.New32a()
|
||||
h.Write([]byte(p.Name)) //nolint:gosec // never fails
|
||||
p.ID = fmt.Sprintf("GB-FTS-%d", h.Sum32()%1000000)
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
package mock
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.avsdev.uk/Go/job-search/internal/mock/findatender/ocds"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultLimit = 100
|
||||
maxLimit = 100
|
||||
timeLayout = "2006-01-02T15:04:05"
|
||||
BasePath = "/api/1.0/ocdsReleasePackages"
|
||||
)
|
||||
|
||||
// server serves release packages from a Store; see NewHandler.
|
||||
type server struct {
|
||||
store *Store
|
||||
baseURL string
|
||||
loc *time.Location // zone for updatedFrom/updatedTo without an offset; nil means UTC
|
||||
}
|
||||
|
||||
// NewHandler serves release packages from store. baseURL is the public base
|
||||
// URL used in each package's uri and links.next. loc is the time zone that
|
||||
// updatedFrom and updatedTo are read in when they carry no offset, as the
|
||||
// documented yyyy-MM-ddTHH:mm:ss format doesn't (Europe/London for FTS).
|
||||
// Stored release dates are UTC; comparisons are on absolute instants.
|
||||
func NewHandler(
|
||||
store *Store, baseURL string, loc *time.Location,
|
||||
) http.HandlerFunc {
|
||||
return (&server{store: store, baseURL: baseURL, loc: loc}).handle
|
||||
}
|
||||
|
||||
// encodeCursor makes the opaque cursor for a next link that resumes at store
|
||||
// position i.
|
||||
func encodeCursor(i int) string {
|
||||
return base64.RawURLEncoding.EncodeToString([]byte("idx:" + strconv.Itoa(i)))
|
||||
}
|
||||
|
||||
// decodeCursor returns the release index in a cursor made by encodeCursor.
|
||||
// Anything else, including a negative index, is an error.
|
||||
func decodeCursor(c string) (int, error) {
|
||||
b, err := base64.RawURLEncoding.DecodeString(c)
|
||||
if err != nil || !strings.HasPrefix(string(b), "idx:") {
|
||||
return 0, fmt.Errorf("invalid cursor")
|
||||
}
|
||||
|
||||
n, err := strconv.Atoi(strings.TrimPrefix(string(b), "idx:"))
|
||||
if err != nil || n < 0 {
|
||||
return 0, fmt.Errorf("invalid cursor")
|
||||
}
|
||||
|
||||
return n, nil
|
||||
}
|
||||
|
||||
func badRequest(w http.ResponseWriter, msg string) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
body := map[string]string{"error": msg}
|
||||
json.NewEncoder(w).Encode(body) //nolint:errcheck,gosec // the client may have gone; nothing to do
|
||||
}
|
||||
|
||||
// parseTime reads a query timestamp. Values without an offset (the documented
|
||||
// yyyy-MM-ddTHH:mm:ss, or a plain date) are local time in loc; values with an
|
||||
// offset or Z (RFC 3339) mean exactly what they say.
|
||||
func parseTime(v string, loc *time.Location) (time.Time, error) {
|
||||
if loc == nil {
|
||||
loc = time.UTC
|
||||
}
|
||||
|
||||
if t, err := time.Parse(time.RFC3339, v); err == nil {
|
||||
return t, nil
|
||||
}
|
||||
|
||||
for _, layout := range []string{timeLayout, "2006-01-02"} {
|
||||
if t, err := time.ParseInLocation(layout, v, loc); err == nil {
|
||||
return t, nil
|
||||
}
|
||||
}
|
||||
|
||||
return time.Time{}, fmt.Errorf("must be in format yyyy-MM-ddTHH:mm:ss")
|
||||
}
|
||||
|
||||
// pageQuery is a parsed release package request.
|
||||
type pageQuery struct {
|
||||
from, upper time.Time // releases dated in [from, upper]
|
||||
start int // index of the first release to return
|
||||
limit int
|
||||
}
|
||||
|
||||
// handle answers a GET for a page of releases as an OCDS release package,
|
||||
// with a links.next when more releases match.
|
||||
func (s *server) handle(w http.ResponseWriter, req *http.Request) {
|
||||
if req.Method != http.MethodGet {
|
||||
w.WriteHeader(http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
q := req.URL.Query()
|
||||
pq, err := s.parseQuery(q)
|
||||
if err != nil {
|
||||
badRequest(w, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
releases, next, more := s.store.Page(pq.from, pq.upper, pq.start, pq.limit)
|
||||
pkg := ocds.ReleasePackage{
|
||||
URI: s.baseURL + req.URL.RequestURI(),
|
||||
Version: "1.1",
|
||||
Extensions: ocds.DeclaredExtensions,
|
||||
PublishedDate: ptr(time.Now().UTC().Truncate(time.Second)),
|
||||
Publisher: &ocds.ReleasePackagePublisher{
|
||||
Name: "OCDS Mock",
|
||||
URI: s.baseURL,
|
||||
},
|
||||
Releases: releases,
|
||||
}
|
||||
if more {
|
||||
pkg.Links = &ocds.ReleasePackageLinks{Next: s.nextLink(q, next)}
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
w.Header().Set("Access-Control-Allow-Origin", "*")
|
||||
enc := json.NewEncoder(w)
|
||||
enc.SetIndent("", " ")
|
||||
enc.SetEscapeHTML(false)
|
||||
if err := enc.Encode(pkg); err != nil {
|
||||
log.Printf("handler: writing response: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// parseQuery reads and checks the query parameters. Its errors are meant for
|
||||
// the client.
|
||||
func (s *server) parseQuery(q url.Values) (pageQuery, error) {
|
||||
pq := pageQuery{limit: defaultLimit, upper: time.Now().UTC()}
|
||||
|
||||
if v := q.Get("limit"); v != "" {
|
||||
n, err := strconv.Atoi(v)
|
||||
if err != nil || n < 1 || n > maxLimit {
|
||||
return pq, fmt.Errorf("limit must be an integer between 1 and %d", maxLimit)
|
||||
}
|
||||
pq.limit = n
|
||||
}
|
||||
|
||||
if v := q.Get("updatedFrom"); v != "" {
|
||||
t, err := parseTime(v, s.loc)
|
||||
if err != nil {
|
||||
return pq, fmt.Errorf("updatedFrom %w", err)
|
||||
}
|
||||
pq.from = t
|
||||
}
|
||||
|
||||
if v := q.Get("updatedTo"); v != "" {
|
||||
t, err := parseTime(v, s.loc)
|
||||
if err != nil {
|
||||
return pq, fmt.Errorf("updatedTo %w", err)
|
||||
}
|
||||
if q.Get("updatedFrom") != "" && t.Before(pq.from) {
|
||||
return pq, errors.New("updatedTo must not be before updatedFrom")
|
||||
}
|
||||
if t.Before(pq.upper) {
|
||||
pq.upper = t
|
||||
}
|
||||
}
|
||||
|
||||
if c := q.Get("cursor"); c != "" {
|
||||
n, err := decodeCursor(c)
|
||||
if err != nil {
|
||||
return pq, err
|
||||
}
|
||||
pq.start = n
|
||||
}
|
||||
|
||||
return pq, nil
|
||||
}
|
||||
|
||||
// nextLink is the URL of the page starting at release index next: the same
|
||||
// query with a new cursor.
|
||||
func (s *server) nextLink(q url.Values, next int) string {
|
||||
nq := url.Values{}
|
||||
for k, vs := range q {
|
||||
if k != "cursor" {
|
||||
nq[k] = vs
|
||||
}
|
||||
}
|
||||
nq.Set("cursor", encodeCursor(next))
|
||||
return s.baseURL + BasePath + "?" + nq.Encode()
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
package mock
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.avsdev.uk/Go/job-search/internal/mock/findatender/ocds"
|
||||
)
|
||||
|
||||
func TestUpdatedFromIsLocalTime(t *testing.T) {
|
||||
london, err := time.LoadLocation("Europe/London")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
store, err := OpenStore(filepath.Join(t.TempDir(), "r.jsonl"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer store.Close()
|
||||
|
||||
// Two releases at 11:45Z and 12:15Z, i.e. 12:45 and 13:15 BST.
|
||||
for i, at := range []time.Time{
|
||||
time.Date(2026, 10, 8, 11, 45, 0, 0, time.UTC),
|
||||
time.Date(2026, 10, 8, 12, 15, 0, 0, time.UTC),
|
||||
} {
|
||||
if err := store.Append(
|
||||
ocds.Release{
|
||||
OCID: "ocds-test",
|
||||
ID: []string{"000001-2026", "000002-2026"}[i],
|
||||
Date: at,
|
||||
Tag: []string{"tender"},
|
||||
},
|
||||
); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
h := NewHandler(store, "", london)
|
||||
count := func(query string) int {
|
||||
rec := httptest.NewRecorder()
|
||||
h(rec, httptest.NewRequest("GET", BasePath+"?"+query, nil))
|
||||
var pkg ocds.ReleasePackage
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &pkg); err != nil {
|
||||
t.Fatalf("%s: %v (%s)", query, err, rec.Body.String())
|
||||
}
|
||||
return len(pkg.Releases)
|
||||
}
|
||||
|
||||
cases := map[string]int{
|
||||
"updatedFrom=2026-10-08T13:00:00": 1, // 13:00 BST = 12:00Z
|
||||
"updatedFrom=2026-10-08T12:30:00": 2, // 12:30 BST = 11:30Z
|
||||
"updatedFrom=2026-10-08T12:00:00Z": 1, // explicit UTC still honoured
|
||||
"updatedFrom=2026-10-08T12:00:00%2B01:00": 2, // explicit offset still honoured
|
||||
"updatedFrom=2026-10-08T12:00:00&updatedTo=2026-10-08T13:00:00": 1, // window in BST
|
||||
}
|
||||
for q, want := range cases {
|
||||
if got := count(q); got != want {
|
||||
t.Errorf("%s: got %d releases, want %d", q, got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// In winter (GMT) London local time equals UTC.
|
||||
winter := time.Date(2026, 12, 1, 9, 0, 0, 0, time.UTC)
|
||||
if got, _ := parseTime("2026-12-01T09:00:00", london); !got.Equal(winter) {
|
||||
t.Errorf("GMT: got %s, want %s", got, winter)
|
||||
}
|
||||
}
|
||||
|
||||
func openTestStore(t *testing.T) *Store {
|
||||
t.Helper()
|
||||
store, err := OpenStore(filepath.Join(t.TempDir(), "r.jsonl"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { store.Close() })
|
||||
return store
|
||||
}
|
||||
|
||||
func TestHandlerRejectsBadRequests(t *testing.T) {
|
||||
h := NewHandler(openTestStore(t), "", nil)
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
h(rec, httptest.NewRequest("POST", BasePath, nil))
|
||||
if rec.Code != http.StatusMethodNotAllowed {
|
||||
t.Errorf("POST: status %d, want 405", rec.Code)
|
||||
}
|
||||
|
||||
cursor := func(s string) string { return base64.RawURLEncoding.EncodeToString([]byte(s)) }
|
||||
cases := map[string]string{
|
||||
"limit=0": "limit must be an integer between 1 and 100",
|
||||
"limit=101": "limit must be an integer between 1 and 100",
|
||||
"limit=ten": "limit must be an integer between 1 and 100",
|
||||
"updatedFrom=yesterday": "updatedFrom must be in format yyyy-MM-ddTHH:mm:ss",
|
||||
"updatedTo=2026-13-01": "updatedTo must be in format yyyy-MM-ddTHH:mm:ss",
|
||||
"updatedFrom=2026-10-08T12:00:00&updatedTo=2026-10-08T11:00:00": "updatedTo must not be before updatedFrom",
|
||||
"cursor=!!!": "invalid cursor",
|
||||
"cursor=" + cursor("pos:1"): "invalid cursor",
|
||||
"cursor=" + cursor("idx:one"): "invalid cursor",
|
||||
"cursor=" + cursor("idx:-1"): "invalid cursor",
|
||||
}
|
||||
for q, want := range cases {
|
||||
rec := httptest.NewRecorder()
|
||||
h(rec, httptest.NewRequest("GET", BasePath+"?"+q, nil))
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Errorf("%s: status %d, want 400", q, rec.Code)
|
||||
continue
|
||||
}
|
||||
if ct := rec.Header().Get("Content-Type"); ct != "application/json" {
|
||||
t.Errorf("%s: Content-Type %q", q, ct)
|
||||
}
|
||||
var body map[string]string
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil || body["error"] != want {
|
||||
t.Errorf("%s: body %s, want error %q", q, rec.Body.String(), want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCursorRoundTrip(t *testing.T) {
|
||||
for _, i := range []int{0, 1, 99, 123456} {
|
||||
if got, err := decodeCursor(encodeCursor(i)); err != nil || got != i {
|
||||
t.Errorf("decodeCursor(encodeCursor(%d)) = %d, %v", i, got, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseTimeFormats(t *testing.T) {
|
||||
cases := map[string]time.Time{
|
||||
"2026-10-08T12:00:00Z": time.Date(2026, 10, 8, 12, 0, 0, 0, time.UTC),
|
||||
"2026-10-08T12:00:00+02:00": time.Date(2026, 10, 8, 10, 0, 0, 0, time.UTC),
|
||||
"2026-10-08T12:00:00": time.Date(2026, 10, 8, 12, 0, 0, 0, time.UTC),
|
||||
"2026-10-08": time.Date(2026, 10, 8, 0, 0, 0, 0, time.UTC),
|
||||
}
|
||||
for v, want := range cases {
|
||||
got, err := parseTime(v, nil) // nil location means UTC
|
||||
if err != nil || !got.Equal(want) {
|
||||
t.Errorf("parseTime(%q) = %s, %v; want %s", v, got, err, want)
|
||||
}
|
||||
}
|
||||
for _, v := range []string{"", "08/10/2026", "2026-10-08 12:00:00", "2026-10-08T25:00:00"} {
|
||||
if _, err := parseTime(v, nil); err == nil {
|
||||
t.Errorf("parseTime(%q): expected error", v)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Following links.next walks every release exactly once, keeping the other
|
||||
// query parameters, and the last page has no next link.
|
||||
func TestHandlerPagination(t *testing.T) {
|
||||
store := openTestStore(t)
|
||||
base := time.Date(2026, 10, 8, 9, 0, 0, 0, time.UTC)
|
||||
for i := 0; i < 5; i++ {
|
||||
r := ocds.Release{
|
||||
OCID: fmt.Sprintf("ocds-test-%d", i),
|
||||
ID: fmt.Sprintf("%06d-2026", i+1),
|
||||
Date: base.Add(time.Duration(i) * time.Minute),
|
||||
Tag: []string{"tender"},
|
||||
}
|
||||
if err := store.Append(r); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
const baseURL = "http://mock.test"
|
||||
h := NewHandler(store, baseURL, nil)
|
||||
next := BasePath + "?limit=2&updatedFrom=2026-10-08T09:01:00"
|
||||
var ids []string
|
||||
for pages := 0; next != ""; pages++ {
|
||||
if pages > 5 {
|
||||
t.Fatal("too many pages")
|
||||
}
|
||||
rec := httptest.NewRecorder()
|
||||
h(rec, httptest.NewRequest("GET", next, nil))
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("%s: status %d: %s", next, rec.Code, rec.Body.String())
|
||||
}
|
||||
var pkg ocds.ReleasePackage
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &pkg); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if pkg.URI != baseURL+next {
|
||||
t.Errorf("uri = %q, want %q", pkg.URI, baseURL+next)
|
||||
}
|
||||
for _, r := range pkg.Releases {
|
||||
ids = append(ids, r.ID)
|
||||
}
|
||||
next = ""
|
||||
if pkg.Links != nil && pkg.Links.Next != "" {
|
||||
if !strings.HasPrefix(pkg.Links.Next, baseURL+BasePath+"?") {
|
||||
t.Fatalf("next = %q", pkg.Links.Next)
|
||||
}
|
||||
if !strings.Contains(pkg.Links.Next, "limit=2") ||
|
||||
!strings.Contains(pkg.Links.Next, "updatedFrom=") {
|
||||
t.Errorf("next lost query parameters: %q", pkg.Links.Next)
|
||||
}
|
||||
next = strings.TrimPrefix(pkg.Links.Next, baseURL)
|
||||
}
|
||||
}
|
||||
want := "000002-2026,000003-2026,000004-2026,000005-2026"
|
||||
if got := strings.Join(ids, ","); got != want {
|
||||
t.Errorf("walked %s, want %s", got, want)
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,125 @@
|
||||
package mock
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ---------- rate limiting ----------
|
||||
|
||||
// clientWindow is one client's rate limiting state.
|
||||
type clientWindow struct {
|
||||
hour time.Time // clock hour the anchor belongs to
|
||||
anchor time.Time // first request of that hour
|
||||
windowStart time.Time // start of the current window
|
||||
count int // accepted requests in the current window
|
||||
}
|
||||
|
||||
// RateLimiter limits each client (by remote host) to a number of requests per
|
||||
// fixed window, answering the rest with 429 Too Many Requests and a
|
||||
// Retry-After header, as the Find a Tender API does. A client's windows are
|
||||
// counted from its first request in each clock hour.
|
||||
type RateLimiter struct {
|
||||
mu sync.Mutex
|
||||
limit int
|
||||
window time.Duration
|
||||
clients map[string]*clientWindow
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
// NewRateLimiter returns a RateLimiter allowing limit requests per window.
|
||||
func NewRateLimiter(limit int, window time.Duration) *RateLimiter {
|
||||
return &RateLimiter{
|
||||
limit: limit,
|
||||
window: window,
|
||||
clients: map[string]*clientWindow{},
|
||||
now: time.Now,
|
||||
}
|
||||
}
|
||||
|
||||
// allow records a request for key and reports whether it may proceed. When it
|
||||
// may not, it also returns the whole seconds until the client's next window.
|
||||
func (rl *RateLimiter) allow(key string) (bool, int) {
|
||||
rl.mu.Lock()
|
||||
defer rl.mu.Unlock()
|
||||
|
||||
now := rl.now().UTC()
|
||||
hour := now.Truncate(time.Hour)
|
||||
|
||||
c, ok := rl.clients[key]
|
||||
if !ok || !c.hour.Equal(hour) {
|
||||
// First request of this clock hour: it anchors the windows.
|
||||
if !ok && len(rl.clients) > 10000 {
|
||||
rl.prune(hour)
|
||||
}
|
||||
c = &clientWindow{hour: hour, anchor: now, windowStart: now}
|
||||
rl.clients[key] = c
|
||||
}
|
||||
|
||||
// Move to whichever fixed window, counted from the anchor, now falls in.
|
||||
idx := now.Sub(c.anchor) / rl.window
|
||||
if ws := c.anchor.Add(idx * rl.window); !ws.Equal(c.windowStart) {
|
||||
c.windowStart = ws
|
||||
c.count = 0
|
||||
}
|
||||
|
||||
if c.count < rl.limit {
|
||||
c.count++
|
||||
return true, 0
|
||||
}
|
||||
|
||||
// The next window opens at the end of this one, or at the top of the
|
||||
// next hour if that comes first (because a new hour re-anchors).
|
||||
next := c.windowStart.Add(rl.window)
|
||||
if nh := hour.Add(time.Hour); nh.Before(next) {
|
||||
next = nh
|
||||
}
|
||||
secs := int((next.Sub(now) + time.Second - 1) / time.Second) // round up
|
||||
if secs < 1 {
|
||||
secs = 1
|
||||
}
|
||||
return false, secs
|
||||
}
|
||||
|
||||
// prune drops clients whose state belongs to an earlier hour.
|
||||
func (rl *RateLimiter) prune(hour time.Time) {
|
||||
for k, c := range rl.clients {
|
||||
if c.hour.Before(hour) {
|
||||
delete(rl.clients, k)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// clientKey produces an identifier for the client based on remote address.
|
||||
// Could be updated to support proxy and forwarded-for headers.
|
||||
func clientKey(req *http.Request) string {
|
||||
host, _, err := net.SplitHostPort(req.RemoteAddr)
|
||||
if err != nil {
|
||||
return req.RemoteAddr
|
||||
}
|
||||
return host
|
||||
}
|
||||
|
||||
// Middleware attaches to http handler. Checks to see if a client is allowed
|
||||
// to make a request in their current window or returns a "TooManyRequests"
|
||||
// status and the time the client should wait before making another request.
|
||||
func (rl *RateLimiter) Middleware(next http.HandlerFunc) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, req *http.Request) {
|
||||
ok, retry := rl.allow(clientKey(req))
|
||||
if !ok {
|
||||
w.Header().Set("Retry-After", strconv.Itoa(retry))
|
||||
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
|
||||
w.WriteHeader(http.StatusTooManyRequests)
|
||||
fmt.Fprintf( //nolint:errcheck // the client may have gone; nothing to do
|
||||
w, "Rate limit of %d exceeded. Please retry after %d seconds.",
|
||||
rl.limit, retry,
|
||||
)
|
||||
return
|
||||
}
|
||||
next(w, req)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
package mock
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestRateLimitWindows(t *testing.T) {
|
||||
now := time.Date(2026, 10, 6, 10, 3, 20, 0, time.UTC) // first request of the hour
|
||||
rl := NewRateLimiter(12, 5*time.Minute)
|
||||
rl.now = func() time.Time { return now }
|
||||
|
||||
for i := 0; i < 12; i++ {
|
||||
if ok, _ := rl.allow("a"); !ok {
|
||||
t.Fatalf("request %d rejected", i+1)
|
||||
}
|
||||
}
|
||||
now = now.Add(100 * time.Second) // 10:05:00, window ends 10:08:20
|
||||
if ok, secs := rl.allow("a"); ok || secs != 200 {
|
||||
t.Fatalf("want reject with 200s, got ok=%v secs=%d", ok, secs)
|
||||
}
|
||||
if ok, _ := rl.allow("b"); !ok {
|
||||
t.Fatal("other client should be unaffected")
|
||||
}
|
||||
now = time.Date(2026, 10, 6, 10, 8, 20, 0, time.UTC) // next window opens
|
||||
if ok, _ := rl.allow("a"); !ok {
|
||||
t.Fatal("new window should allow")
|
||||
}
|
||||
|
||||
// Window 10:58:20-11:03:20 is cut short by the new hour at 11:00:00.
|
||||
now = time.Date(2026, 10, 6, 10, 58, 30, 0, time.UTC)
|
||||
for i := 0; i < 12; i++ {
|
||||
rl.allow("a")
|
||||
}
|
||||
if ok, secs := rl.allow("a"); ok || secs != 90 {
|
||||
t.Fatalf("want reject with 90s, got ok=%v secs=%d", ok, secs)
|
||||
}
|
||||
now = time.Date(2026, 10, 6, 11, 0, 5, 0, time.UTC) // re-anchors here
|
||||
if ok, _ := rl.allow("a"); !ok {
|
||||
t.Fatal("new hour should allow")
|
||||
}
|
||||
}
|
||||
|
||||
func TestClientKey(t *testing.T) {
|
||||
cases := map[string]string{
|
||||
"192.0.2.1:1234": "192.0.2.1",
|
||||
"[::1]:8080": "::1",
|
||||
"no-port": "no-port", // not host:port, used as-is
|
||||
}
|
||||
for addr, want := range cases {
|
||||
req := httptest.NewRequest("GET", "/", nil)
|
||||
req.RemoteAddr = addr
|
||||
if got := clientKey(req); got != want {
|
||||
t.Errorf("clientKey(%q) = %q, want %q", addr, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRateLimitMiddleware(t *testing.T) {
|
||||
now := time.Date(2026, 10, 6, 10, 0, 0, 0, time.UTC)
|
||||
rl := NewRateLimiter(2, 5*time.Minute)
|
||||
rl.now = func() time.Time { return now }
|
||||
|
||||
calls := 0
|
||||
h := rl.Middleware(func(w http.ResponseWriter, req *http.Request) {
|
||||
calls++
|
||||
w.WriteHeader(http.StatusOK)
|
||||
})
|
||||
do := func(addr string) *httptest.ResponseRecorder {
|
||||
req := httptest.NewRequest("GET", "/", nil)
|
||||
req.RemoteAddr = addr
|
||||
rec := httptest.NewRecorder()
|
||||
h(rec, req)
|
||||
return rec
|
||||
}
|
||||
|
||||
for i := 0; i < 2; i++ {
|
||||
if rec := do("192.0.2.1:1000"); rec.Code != http.StatusOK {
|
||||
t.Fatalf("request %d: status %d", i+1, rec.Code)
|
||||
}
|
||||
}
|
||||
// Same host, different port: still the same client.
|
||||
rec := do("192.0.2.1:2000")
|
||||
if rec.Code != http.StatusTooManyRequests {
|
||||
t.Fatalf("status %d, want 429", rec.Code)
|
||||
}
|
||||
if got := rec.Header().Get("Retry-After"); got != "300" {
|
||||
t.Errorf("Retry-After = %q, want 300", got)
|
||||
}
|
||||
if !strings.Contains(rec.Body.String(), "Rate limit of 2 exceeded") ||
|
||||
!strings.Contains(rec.Body.String(), "300 seconds") {
|
||||
t.Errorf("body = %q", rec.Body.String())
|
||||
}
|
||||
if calls != 2 {
|
||||
t.Errorf("next called %d times, want 2", calls)
|
||||
}
|
||||
if rec := do("192.0.2.2:1000"); rec.Code != http.StatusOK {
|
||||
t.Errorf("other client: status %d", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// Once more than 10000 clients are tracked, a new client in a new hour drops
|
||||
// every client left over from earlier hours.
|
||||
func TestRateLimitPrunesStaleClients(t *testing.T) {
|
||||
now := time.Date(2026, 10, 6, 10, 30, 0, 0, time.UTC)
|
||||
rl := NewRateLimiter(1, time.Minute)
|
||||
rl.now = func() time.Time { return now }
|
||||
|
||||
for i := 0; i <= 10000; i++ {
|
||||
rl.allow(strconv.Itoa(i))
|
||||
}
|
||||
if len(rl.clients) != 10001 {
|
||||
t.Fatalf("tracking %d clients", len(rl.clients))
|
||||
}
|
||||
|
||||
// Same hour: nothing is stale, so nothing is dropped.
|
||||
rl.allow("same-hour")
|
||||
if len(rl.clients) != 10002 {
|
||||
t.Fatalf("same hour: tracking %d clients, want 10002", len(rl.clients))
|
||||
}
|
||||
|
||||
now = now.Add(time.Hour)
|
||||
rl.allow("new")
|
||||
if len(rl.clients) != 1 {
|
||||
t.Fatalf("after prune: tracking %d clients, want 1", len(rl.clients))
|
||||
}
|
||||
if _, ok := rl.clients["new"]; !ok {
|
||||
t.Fatal("new client was pruned")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,272 @@
|
||||
package mock
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"math/rand"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"git.avsdev.uk/Go/job-search/internal/mock/findatender/ocds"
|
||||
)
|
||||
|
||||
// Store keeps every release in memory and appends each new one to a JSON Lines
|
||||
// file, so releases survive restarts. Releases are kept in date order.
|
||||
type Store struct {
|
||||
mu sync.RWMutex
|
||||
f *os.File
|
||||
releases []ocds.Release
|
||||
times []time.Time
|
||||
latest map[string]int // ocid -> index of its most recent release
|
||||
lastNum map[int]int // year -> highest notice number used
|
||||
|
||||
// Procedures not yet awarded, as a slice plus positions so that adding,
|
||||
// removing and picking one at random are all constant time.
|
||||
open []string
|
||||
openPos map[string]int
|
||||
}
|
||||
|
||||
// OpenStore loads the releases stored at path, creating the file and its
|
||||
// directory if needed, and opens it for appending. Lines that can't be read
|
||||
// are skipped, releases are put back in date order if needed, and a record
|
||||
// left incomplete by a crash is ended so new ones start on a fresh line.
|
||||
func OpenStore(path string) (*Store, error) {
|
||||
s := &Store{latest: map[string]int{}, lastNum: map[int]int{}, openPos: map[string]int{}}
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o750); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
releases, err := readReleases(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// The file is written in order, but sort anyway in case it was hand-edited.
|
||||
sort.SliceStable(releases, func(i, j int) bool {
|
||||
return releases[i].Date.Before(releases[j].Date)
|
||||
})
|
||||
for _, r := range releases {
|
||||
s.releases = append(s.releases, r)
|
||||
s.times = append(s.times, r.Date)
|
||||
}
|
||||
for i, r := range s.releases {
|
||||
s.index(i, r)
|
||||
}
|
||||
|
||||
if s.f, err = openForAppend(path); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// readReleases reads the releases stored at path, skipping lines that are
|
||||
// blank or can't be decoded. A missing file holds no releases.
|
||||
func readReleases(path string) ([]ocds.Release, error) {
|
||||
in, err := os.Open(filepath.Clean(path))
|
||||
if os.IsNotExist(err) {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer in.Close() //nolint:errcheck // read only
|
||||
|
||||
var releases []ocds.Release
|
||||
sc := bufio.NewScanner(in)
|
||||
sc.Buffer(make([]byte, 1<<20), 64<<20)
|
||||
for line := 1; sc.Scan(); line++ {
|
||||
b := bytes.TrimSpace(sc.Bytes())
|
||||
if len(b) == 0 {
|
||||
continue
|
||||
}
|
||||
var r ocds.Release
|
||||
if err := json.Unmarshal(b, &r); err != nil {
|
||||
log.Printf("store: skipping unreadable line %d: %v", line, err)
|
||||
continue
|
||||
}
|
||||
releases = append(releases, r)
|
||||
}
|
||||
if err := sc.Err(); err != nil {
|
||||
return nil, fmt.Errorf("reading %s: %w", path, err)
|
||||
}
|
||||
return releases, nil
|
||||
}
|
||||
|
||||
// openForAppend opens path for appending releases. If a previous run died
|
||||
// mid-write, it ends the partial record so the next one starts on a fresh
|
||||
// line.
|
||||
func openForAppend(path string) (*os.File, error) {
|
||||
f, err := os.OpenFile(filepath.Clean(path), os.O_CREATE|os.O_RDWR|os.O_APPEND, 0o600)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if st, err := f.Stat(); err == nil && st.Size() > 0 {
|
||||
last := make([]byte, 1)
|
||||
if _, err := f.ReadAt(last, st.Size()-1); err == nil && last[0] != '\n' {
|
||||
if _, err := f.Write([]byte("\n")); err != nil {
|
||||
return nil, errors.Join(fmt.Errorf("repairing %s: %w", path, err), f.Close())
|
||||
}
|
||||
}
|
||||
}
|
||||
return f, nil
|
||||
}
|
||||
|
||||
// index records release r, at position i, in the lookups: the ocid's latest
|
||||
// release, whether its procedure is still open (an award closes it), and the
|
||||
// highest notice number used each year.
|
||||
func (s *Store) index(i int, r ocds.Release) {
|
||||
s.latest[r.OCID] = i
|
||||
if len(r.Tag) > 0 && r.Tag[0] == "award" {
|
||||
s.closeProcedure(r.OCID)
|
||||
} else if _, ok := s.openPos[r.OCID]; !ok {
|
||||
s.openPos[r.OCID] = len(s.open)
|
||||
s.open = append(s.open, r.OCID)
|
||||
}
|
||||
if num, year, ok := strings.Cut(r.ID, "-"); ok {
|
||||
n, err1 := strconv.Atoi(num)
|
||||
y, err2 := strconv.Atoi(year)
|
||||
if err1 == nil && err2 == nil && n > s.lastNum[y] {
|
||||
s.lastNum[y] = n
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// closeProcedure removes ocid from the open procedures, if it is there.
|
||||
func (s *Store) closeProcedure(ocid string) {
|
||||
pos, ok := s.openPos[ocid]
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
last := len(s.open) - 1
|
||||
s.open[pos] = s.open[last]
|
||||
s.openPos[s.open[pos]] = pos
|
||||
s.open = s.open[:last]
|
||||
delete(s.openPos, ocid)
|
||||
}
|
||||
|
||||
// Append persists a release and flushes it to disk.
|
||||
func (s *Store) Append(r ocds.Release) error { return s.append(r, true) }
|
||||
|
||||
// AppendNoSync persists a release without an fsync; call Sync afterwards.
|
||||
// Used for bulk catch-up, where a sync per record would be very slow.
|
||||
func (s *Store) AppendNoSync(r ocds.Release) error { return s.append(r, false) }
|
||||
|
||||
// Sync flushes appended releases to disk. Call it after AppendNoSync.
|
||||
func (s *Store) Sync() error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return s.f.Sync()
|
||||
}
|
||||
|
||||
// append stores r, syncing the file to disk if sync is true. A date earlier
|
||||
// than the newest stored release (for example after a clock change) is moved
|
||||
// up so the order stays valid.
|
||||
func (s *Store) append(r ocds.Release, sync bool) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
r.Date = r.Date.UTC()
|
||||
if n := len(s.times); n > 0 && r.Date.Before(s.times[n-1]) {
|
||||
r.Date = s.times[n-1]
|
||||
}
|
||||
b, err := json.Marshal(r)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := s.f.Write(append(b, '\n')); err != nil {
|
||||
return err
|
||||
}
|
||||
if sync {
|
||||
if err := s.f.Sync(); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
s.releases = append(s.releases, r)
|
||||
s.times = append(s.times, r.Date)
|
||||
s.index(len(s.releases)-1, r)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Page returns up to limit releases dated in [from, upper], starting no earlier
|
||||
// than position start, plus the position to continue from and whether more exist.
|
||||
func (s *Store) Page(from, upper time.Time, start, limit int) ([]ocds.Release, int, bool) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
|
||||
i := sort.Search(len(s.times), func(i int) bool { return !s.times[i].Before(from) })
|
||||
if start > i {
|
||||
i = start
|
||||
}
|
||||
out := make([]ocds.Release, 0, limit)
|
||||
for i < len(s.releases) && len(out) < limit && !s.times[i].After(upper) {
|
||||
out = append(out, s.releases[i])
|
||||
i++
|
||||
}
|
||||
more := i < len(s.releases) && !s.times[i].After(upper)
|
||||
return out, i, more
|
||||
}
|
||||
|
||||
// Last returns the date of the newest release, or false if the store is
|
||||
// empty.
|
||||
func (s *Store) Last() (time.Time, bool) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
if len(s.times) == 0 {
|
||||
return time.Time{}, false
|
||||
}
|
||||
return s.times[len(s.times)-1], true
|
||||
}
|
||||
|
||||
// Len returns the number of stored releases.
|
||||
func (s *Store) Len() int {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
return len(s.releases)
|
||||
}
|
||||
|
||||
// HasOCID reports whether any release of the procedure ocid is stored.
|
||||
func (s *Store) HasOCID(ocid string) bool {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
_, ok := s.latest[ocid]
|
||||
return ok
|
||||
}
|
||||
|
||||
// NextNoticeID returns the next FTS-style notice id ("000123-2026") for year.
|
||||
func (s *Store) NextNoticeID(year int) string {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
return fmt.Sprintf("%06d-%d", s.lastNum[year]+1, year)
|
||||
}
|
||||
|
||||
// RandomOpen picks a procedure that has not been awarded and whose latest
|
||||
// release is after since. Procedures idle since before then are dropped from
|
||||
// the open set, as if they had been abandoned.
|
||||
func (s *Store) RandomOpen(r *rand.Rand, since time.Time) (ocds.Release, bool) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
for len(s.open) > 0 {
|
||||
ocid := s.open[r.Intn(len(s.open))]
|
||||
i := s.latest[ocid]
|
||||
if s.times[i].After(since) {
|
||||
return s.releases[i], true
|
||||
}
|
||||
s.closeProcedure(ocid)
|
||||
}
|
||||
return ocds.Release{}, false
|
||||
}
|
||||
|
||||
// Close closes the store's file. The store must not be used afterwards.
|
||||
func (s *Store) Close() error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return s.f.Close()
|
||||
}
|
||||
@@ -0,0 +1,269 @@
|
||||
package mock
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"math/rand"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.avsdev.uk/Go/job-search/internal/mock/findatender/ocds"
|
||||
)
|
||||
|
||||
func TestStorePersistsAndGeneratorProgresses(t *testing.T) {
|
||||
cat := loadTestCatalog(t)
|
||||
path := filepath.Join(t.TempDir(), "releases.jsonl")
|
||||
store, err := OpenStore(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
gen := NewGenerator(cat, store, 42)
|
||||
now := time.Date(2026, 10, 8, 12, 0, 0, 0, time.UTC)
|
||||
n, err := gen.CatchUp(now, 48*time.Hour, 3*time.Minute, 15*time.Minute)
|
||||
if err != nil || n < 48*60/15 {
|
||||
t.Fatalf("catch-up generated %d (err %v)", n, err)
|
||||
}
|
||||
|
||||
tags := map[string]int{}
|
||||
ocids := map[string]int{}
|
||||
all, _, _ := store.Page(time.Time{}, now, 0, 100000)
|
||||
for _, r := range all {
|
||||
tags[r.Tag[0]]++
|
||||
ocids[r.OCID]++
|
||||
if r.Tender == nil || r.Tender.Classification == nil {
|
||||
t.Fatalf("release %s has no tender.classification", r.ID)
|
||||
}
|
||||
}
|
||||
if tags["award"] == 0 || tags["tender"] == 0 {
|
||||
t.Fatalf("expected tenders and awards, got %v", tags)
|
||||
}
|
||||
if len(ocids) == len(all) {
|
||||
t.Fatal("expected some procedures with more than one release")
|
||||
}
|
||||
store.Close()
|
||||
|
||||
// Reopen: everything is still there, and catch-up only fills the new gap.
|
||||
store2, err := OpenStore(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer store2.Close()
|
||||
if store2.Len() != len(all) {
|
||||
t.Fatalf("after reopen: %d releases, want %d", store2.Len(), len(all))
|
||||
}
|
||||
gen2 := NewGenerator(cat, store2, 7)
|
||||
m, _ := gen2.CatchUp(now.Add(time.Hour), 48*time.Hour, 3*time.Minute, 15*time.Minute)
|
||||
if m < 4 || m > 20 {
|
||||
t.Fatalf("one hour gap produced %d releases", m)
|
||||
}
|
||||
if last, _ := store2.Last(); last.After(now.Add(time.Hour)) {
|
||||
t.Fatal("release dated in the future")
|
||||
}
|
||||
|
||||
// The HTTP handler pages through the store without gaps or repeats.
|
||||
srv := &server{store: store2, baseURL: "", loc: time.UTC}
|
||||
seen := map[string]bool{}
|
||||
next := BasePath + "?updatedFrom=2026-10-07T00:00:00&limit=7"
|
||||
for pages := 0; next != ""; pages++ {
|
||||
rec := httptest.NewRecorder()
|
||||
srv.handle(rec, httptest.NewRequest("GET", next, nil))
|
||||
// Strict decode: the output must contain only fields the ocds types define.
|
||||
var pkg ocds.ReleasePackage
|
||||
dec := json.NewDecoder(bytes.NewReader(rec.Body.Bytes()))
|
||||
dec.DisallowUnknownFields()
|
||||
if err := dec.Decode(&pkg); err != nil {
|
||||
t.Fatalf("response doesn't match ocds types: %v", err)
|
||||
}
|
||||
for _, r := range pkg.Releases {
|
||||
if seen[r.ID] {
|
||||
t.Fatalf("release %s returned twice", r.ID)
|
||||
}
|
||||
seen[r.ID] = true
|
||||
}
|
||||
next = ""
|
||||
if pkg.Links != nil {
|
||||
next = pkg.Links.Next
|
||||
}
|
||||
}
|
||||
from := time.Date(2026, 10, 7, 0, 0, 0, 0, time.UTC)
|
||||
want, _, _ := store2.Page(from, time.Now(), 0, 100000)
|
||||
if len(seen) != len(want) {
|
||||
t.Fatalf("paged %d releases, want %d", len(seen), len(want))
|
||||
}
|
||||
}
|
||||
|
||||
func writeLines(t *testing.T, path string, lines ...string) {
|
||||
t.Helper()
|
||||
if err := os.WriteFile(path, []byte(strings.Join(lines, "\n")), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func releaseLine(t *testing.T, ocid, id, tag string, at time.Time) string {
|
||||
t.Helper()
|
||||
b, err := json.Marshal(ocds.Release{OCID: ocid, ID: id, Date: at, Tag: []string{tag}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
|
||||
// Unreadable lines are skipped, blank lines ignored, a hand-edited file is put
|
||||
// back in date order, and the index (open procedures, notice numbers) is rebuilt.
|
||||
func TestOpenStoreRecoversFromMessyFile(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "r.jsonl")
|
||||
t0 := time.Date(2026, 10, 8, 9, 0, 0, 0, time.UTC)
|
||||
writeLines(t, path,
|
||||
releaseLine(t, "ocds-b", "000007-2026", "tender", t0.Add(2*time.Hour)),
|
||||
"{not json",
|
||||
"",
|
||||
releaseLine(t, "ocds-a", "000003-2026", "tender", t0),
|
||||
releaseLine(t, "ocds-a", "000005-2026", "award", t0.Add(time.Hour)),
|
||||
"",
|
||||
)
|
||||
|
||||
store, err := OpenStore(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer store.Close()
|
||||
|
||||
all, _, _ := store.Page(time.Time{}, t0.Add(24*time.Hour), 0, 100)
|
||||
var ids []string
|
||||
for _, r := range all {
|
||||
ids = append(ids, r.ID)
|
||||
}
|
||||
if got, want := strings.Join(ids, ","), "000003-2026,000005-2026,000007-2026"; got != want {
|
||||
t.Fatalf("releases %s, want %s", got, want)
|
||||
}
|
||||
if got := store.NextNoticeID(2026); got != "000008-2026" {
|
||||
t.Errorf("NextNoticeID = %s, want 000008-2026", got)
|
||||
}
|
||||
if got := store.NextNoticeID(2027); got != "000001-2027" {
|
||||
t.Errorf("NextNoticeID(2027) = %s", got)
|
||||
}
|
||||
// ocds-a was awarded, so only ocds-b is still open.
|
||||
//nolint:gosec // G404: seeded math/rand keeps the test deterministic
|
||||
r, ok := store.RandomOpen(rand.New(rand.NewSource(1)), t0)
|
||||
if !ok || r.OCID != "ocds-b" {
|
||||
t.Errorf("RandomOpen = %s, %v; want ocds-b", r.OCID, ok)
|
||||
}
|
||||
}
|
||||
|
||||
// A record cut off mid-write is skipped, and the next record starts on a fresh
|
||||
// line so it is still readable after a restart.
|
||||
func TestOpenStoreAfterTruncatedWrite(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "r.jsonl")
|
||||
t0 := time.Date(2026, 10, 8, 9, 0, 0, 0, time.UTC)
|
||||
full := releaseLine(t, "ocds-a", "000001-2026", "tender", t0)
|
||||
cut := releaseLine(t, "ocds-b", "000002-2026", "tender", t0.Add(time.Minute))
|
||||
writeLines(t, path, full, cut[:len(cut)/2]) // no trailing newline
|
||||
|
||||
store, err := OpenStore(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if store.Len() != 1 {
|
||||
t.Fatalf("Len = %d, want 1", store.Len())
|
||||
}
|
||||
if err := store.Append(
|
||||
ocds.Release{
|
||||
OCID: "ocds-c",
|
||||
ID: "000002-2026",
|
||||
Date: t0.Add(2 * time.Minute),
|
||||
Tag: []string{"tender"},
|
||||
},
|
||||
); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
store.Close()
|
||||
|
||||
store2, err := OpenStore(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer store2.Close()
|
||||
if store2.Len() != 2 || !store2.HasOCID("ocds-c") {
|
||||
t.Fatalf("after reopen: Len = %d, has ocds-c = %v", store2.Len(), store2.HasOCID("ocds-c"))
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenStoreErrors(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
|
||||
// The parent "directory" is a regular file.
|
||||
file := filepath.Join(dir, "file")
|
||||
if err := os.WriteFile(file, nil, 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := OpenStore(filepath.Join(file, "r.jsonl")); err == nil {
|
||||
t.Error("parent is a file: expected error")
|
||||
}
|
||||
|
||||
// The store path is a directory, which opens but can't be read.
|
||||
if _, err := OpenStore(dir); err == nil {
|
||||
t.Error("path is a directory: expected error")
|
||||
}
|
||||
|
||||
// A missing parent directory is created.
|
||||
store, err := OpenStore(filepath.Join(dir, "a", "b", "r.jsonl"))
|
||||
if err != nil {
|
||||
t.Fatalf("nested path: %v", err)
|
||||
}
|
||||
store.Close()
|
||||
}
|
||||
|
||||
// A release dated before the newest one is moved up to keep the order valid.
|
||||
func TestAppendKeepsDateOrder(t *testing.T) {
|
||||
store, err := OpenStore(filepath.Join(t.TempDir(), "r.jsonl"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer store.Close()
|
||||
t0 := time.Date(2026, 10, 8, 9, 0, 0, 0, time.UTC)
|
||||
store.Append(ocds.Release{OCID: "a", ID: "000001-2026", Date: t0, Tag: []string{"tender"}})
|
||||
store.Append(
|
||||
ocds.Release{OCID: "b", ID: "000002-2026", Date: t0.Add(-time.Hour), Tag: []string{"tender"}},
|
||||
)
|
||||
if last, _ := store.Last(); !last.Equal(t0) {
|
||||
t.Errorf("Last = %s, want %s", last, t0)
|
||||
}
|
||||
|
||||
// Appending to a closed store fails and leaves the store unchanged.
|
||||
store.Close()
|
||||
if err := store.Append(ocds.Release{OCID: "c", Date: t0, Tag: []string{"tender"}}); err == nil {
|
||||
t.Error("append after close: expected error")
|
||||
}
|
||||
if store.Len() != 2 || store.HasOCID("c") {
|
||||
t.Errorf("failed append changed the store: Len = %d", store.Len())
|
||||
}
|
||||
}
|
||||
|
||||
// Procedures idle since before the cut-off are dropped from the open set.
|
||||
func TestRandomOpenDropsStaleProcedures(t *testing.T) {
|
||||
store, err := OpenStore(filepath.Join(t.TempDir(), "r.jsonl"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer store.Close()
|
||||
t0 := time.Date(2026, 10, 8, 9, 0, 0, 0, time.UTC)
|
||||
store.Append(ocds.Release{OCID: "old", ID: "000001-2026", Date: t0, Tag: []string{"tender"}})
|
||||
store.Append(
|
||||
ocds.Release{OCID: "new", ID: "000002-2026", Date: t0.Add(time.Hour), Tag: []string{"tender"}},
|
||||
)
|
||||
|
||||
//nolint:gosec // G404: seeded math/rand keeps the test deterministic
|
||||
rnd := rand.New(rand.NewSource(1))
|
||||
for i := 0; i < 10; i++ {
|
||||
if r, ok := store.RandomOpen(rnd, t0.Add(time.Minute)); !ok || r.OCID != "new" {
|
||||
t.Fatalf("RandomOpen = %s, %v; want new", r.OCID, ok)
|
||||
}
|
||||
}
|
||||
if _, ok := store.RandomOpen(rnd, t0.Add(2*time.Hour)); ok {
|
||||
t.Error("everything is stale: expected none")
|
||||
}
|
||||
}
|
||||
+38
@@ -0,0 +1,38 @@
|
||||
{
|
||||
"buyers": [
|
||||
{
|
||||
"id": "ZZ-MPON-PAAA-1111-AAAA",
|
||||
"name": "Example Borough Council",
|
||||
"identifier": {"scheme": "ZZ-MPON", "id": "PAAA-1111-AAAA"},
|
||||
"address": {"locality": "Exampleton", "postalCode": "ZZ1 1AA", "countryName": "United Kingdom", "region": "UKJ"},
|
||||
"details": {"classifications": [{"scheme": "UK_CA_TYPE", "id": "publicAuthoritySubCentralGovernment", "description": "Public authority - sub-central government"}]}
|
||||
},
|
||||
{"id": "ZZ-MPON-PBBB-2222-BBBB", "name": "Example NHS Trust"}
|
||||
],
|
||||
"suppliers": [
|
||||
{"id": "ZZ-MCOH-01234567", "name": "Example Supplies Ltd", "identifier": {"scheme": "ZZ-MCOH", "id": "01234567"}}
|
||||
],
|
||||
"subjects": [
|
||||
{
|
||||
"title": "Grounds maintenance services",
|
||||
"mainProcurementCategory": "services",
|
||||
"classification": "CPV-77314000",
|
||||
"items": [{"id": "1", "additionalClassifications": ["CPV-77310000"], "deliveryAddresses": [{"region": "UKJ"}], "relatedLot": "1"}],
|
||||
"lots": [{"id": "1", "title": "Lot 1: Parks", "value": {"amountGross": 120000, "amount": 100000, "currency": "GBP"}}],
|
||||
"value": {"amountGross": 120000, "amount": 100000, "currency": "GBP"}
|
||||
},
|
||||
{
|
||||
"title": "Patient transport",
|
||||
"classification": "CPV-60130000"
|
||||
}
|
||||
],
|
||||
"methods": [
|
||||
{"procurementMethod": "open", "procurementMethodDetails": "Open procedure"},
|
||||
{"procurementMethod": "selective", "procurementMethodDetails": "Competitive flexible procedure"}
|
||||
],
|
||||
"classifications": {
|
||||
"CPV-60130000": {"scheme": "CPV", "id": "60130000", "description": "Special-purpose road passenger-transport services"},
|
||||
"CPV-77310000": {"scheme": "CPV", "id": "77310000", "description": "Planting and maintenance services of green areas"},
|
||||
"CPV-77314000": {"scheme": "CPV", "id": "77314000", "description": "Grounds maintenance services"}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user