Files
job-search/internal/mock/findatender/store_test.go
T
bwilliams 465358609a Naming, layout and comments: descriptive full-word names, one item per line in wrapped calls and literals, and doc comments on exported names
Short and single-letter names in application code become full words (tests
keep theirs). Wrapped calls, signatures and composite literals put one item
per line, and one-line function bodies are expanded. Exported names and
groups of constants get doc comments, and comments refer to the renamed
identifiers.
2026-10-15 16:12:56 +01:00

305 lines
8.2 KiB
Go

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")
}
}