CODE_CONVENTIONS.md sets out the conventions for Go code in this repository: naming, layout, comments, errors, logging, configuration and tests. .golangci.json enables the linters that check the parts that can be checked (each limited to the rules the conventions use); the rest is applied by hand. Applied across the codebase: - Naming: full-word names in application code, initialisms in one case (BuyerID, PrintJSON, PrintYAML, ocdsGetBuyerID), no names shadowing built-ins (min/max are minGap/maxGap), mustRaw renamed rawJSON as it returns nil rather than panicking, and config.ConfigLevelToLogLevel renamed LevelToLogLevel. - Layout: lines kept to 80 characters where possible, assignments taken out of if and for statements that don't fit, wrapped arguments all on one line or one per line, steps separated by blank lines, no else after a return, function and struct literals wrapped. - Comments: doc comments on every exported name (including each section's Map, Flatten, TypeOf, ValueOf and SetValue), section banners, British spelling. - Errors: a created error starts with its package name, a wrapping error adds only what was being done, sentinels in one commented var block, errors.Is and errors.As in place of == and type assertions, and no err := shadowing of a named result. That fixes the mock server losing its access log's close error. - Logging: the fetcher passes its workers an untagged logger, so findatender's lines are tagged with their own component rather than with fetcher's as well; cmd/mock-fts logs "stopped" with slog. - Configuration: util.WithTimeout (moved from the websocket package) applies a timeout of 0 or less as no limit, used for the websocket write and ping, TCP close and tcpserver shutdown timeouts; every setting documents what 0 or empty means, including the rate limit rule's keys in internal/config/README.md. - Tests: related tests merged into table-driven ones with named cases. Separately from CODE_CONVENTIONS.md, internal/store/README.md gains a soft SQL style convention for the queries in internal/store/queries.go, and the queries are reformatted to follow it.
305 lines
6.9 KiB
Go
305 lines
6.9 KiB
Go
package util
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"log/slog"
|
|
"os"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
func TestMain(m *testing.M) {
|
|
// Keep worker log lines out of the test output.
|
|
slog.SetDefault(slog.New(slog.DiscardHandler))
|
|
os.Exit(m.Run())
|
|
}
|
|
|
|
func TestWorkerRunRejectsBadConfig(t *testing.T) {
|
|
noop := func(context.Context, time.Time) error {
|
|
return nil
|
|
}
|
|
tests := []struct {
|
|
name string
|
|
w Worker
|
|
}{
|
|
{"no task", Worker{
|
|
Name: "a",
|
|
Interval: time.Second,
|
|
}},
|
|
{"zero interval", Worker{
|
|
Name: "b",
|
|
Task: noop,
|
|
}},
|
|
{"negative interval", Worker{
|
|
Name: "c",
|
|
Interval: -time.Second,
|
|
Task: noop,
|
|
}},
|
|
}
|
|
for _, tc := range tests {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
if err := tc.w.Run(context.Background(), time.Time{}); err == nil {
|
|
t.Fatal("expected an error")
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestWorkerRunCancelledBeforeStart(t *testing.T) {
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
cancel()
|
|
called := false
|
|
w := Worker{
|
|
Name: "w",
|
|
Interval: time.Millisecond,
|
|
Task: func(context.Context, time.Time) error {
|
|
called = true
|
|
return nil
|
|
},
|
|
}
|
|
|
|
if err := w.Run(ctx, time.Time{}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if called {
|
|
t.Fatal("task ran after the context was already cancelled")
|
|
}
|
|
}
|
|
|
|
// The checkpoint passed to each run advances only when the previous run
|
|
// succeeded or reported ErrWorkerNoWork.
|
|
func TestWorkerRunCheckpoints(t *testing.T) {
|
|
errFail := errors.New("boom")
|
|
results := []error{
|
|
nil, // advance
|
|
errFail, // keep
|
|
context.Canceled, // keep
|
|
fmt.Errorf("%w: x", ErrWorkerNoWork), // advance
|
|
nil,
|
|
}
|
|
since0 := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)
|
|
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
defer cancel()
|
|
var (
|
|
mu sync.Mutex
|
|
seen []time.Time
|
|
starts []time.Time
|
|
)
|
|
w := Worker{
|
|
Name: "w",
|
|
Interval: 5 * time.Millisecond,
|
|
Task: func(_ context.Context, since time.Time) error {
|
|
mu.Lock()
|
|
defer mu.Unlock()
|
|
|
|
i := len(seen)
|
|
seen = append(seen, since)
|
|
starts = append(starts, time.Now())
|
|
if i == len(results)-1 {
|
|
cancel()
|
|
}
|
|
|
|
return results[i]
|
|
},
|
|
}
|
|
|
|
done := make(chan error, 1)
|
|
go func() {
|
|
done <- w.Run(ctx, since0)
|
|
}()
|
|
select {
|
|
case err := <-done:
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
case <-time.After(5 * time.Second):
|
|
t.Fatal("worker did not stop")
|
|
}
|
|
|
|
mu.Lock()
|
|
defer mu.Unlock()
|
|
if len(seen) != len(results) {
|
|
t.Fatalf("task ran %d times, want %d", len(seen), len(results))
|
|
}
|
|
if !seen[0].Equal(since0) {
|
|
t.Fatalf("first run since = %v, want %v", seen[0], since0)
|
|
}
|
|
// Run 0 succeeded, so runs 1..3 see a checkpoint from run 0's start.
|
|
for i := 1; i <= 3; i++ {
|
|
if !seen[i].Equal(seen[1]) {
|
|
t.Fatalf("run %d since = %v, want unchanged %v", i, seen[i], seen[1])
|
|
}
|
|
}
|
|
if !seen[1].After(since0) || seen[1].After(starts[1]) {
|
|
t.Fatalf(
|
|
"checkpoint after success = %v, want between %v and %v",
|
|
seen[1],
|
|
since0,
|
|
starts[1],
|
|
)
|
|
}
|
|
// Run 3 returned ErrWorkerNoWork, so run 4 sees a newer checkpoint.
|
|
if !seen[4].After(seen[3]) {
|
|
t.Fatalf(
|
|
"checkpoint did not advance after ErrWorkerNoWork: %v -> %v",
|
|
seen[3],
|
|
seen[4],
|
|
)
|
|
}
|
|
}
|
|
|
|
// An in-flight task keeps a live context after the parent is cancelled and is
|
|
// allowed to finish.
|
|
func TestWorkerRunGracefulShutdown(t *testing.T) {
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
var workErr error
|
|
w := Worker{
|
|
Name: "w",
|
|
Interval: time.Hour,
|
|
ShutdownTimeout: time.Second,
|
|
Task: func(taskCtx context.Context, _ time.Time) error {
|
|
cancel()
|
|
time.Sleep(10 * time.Millisecond)
|
|
workErr = taskCtx.Err()
|
|
return nil
|
|
},
|
|
}
|
|
|
|
if err := w.Run(ctx, time.Time{}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if workErr != nil {
|
|
t.Fatalf("task context cancelled before shutdown timeout: %v", workErr)
|
|
}
|
|
}
|
|
|
|
// A task that outlives ShutdownTimeout has its context cancelled.
|
|
func TestWorkerRunShutdownTimeout(t *testing.T) {
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
w := Worker{
|
|
Name: "w",
|
|
Interval: time.Hour,
|
|
ShutdownTimeout: 20 * time.Millisecond,
|
|
Task: func(taskCtx context.Context, _ time.Time) error {
|
|
cancel()
|
|
select {
|
|
case <-taskCtx.Done():
|
|
return taskCtx.Err()
|
|
case <-time.After(5 * time.Second):
|
|
return errors.New("task context never cancelled")
|
|
}
|
|
},
|
|
}
|
|
|
|
start := time.Now()
|
|
if err := w.Run(ctx, time.Time{}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if d := time.Since(start); d > 2*time.Second {
|
|
t.Fatalf("shutdown took %v", d)
|
|
}
|
|
}
|
|
|
|
func TestWorkerExecute(t *testing.T) {
|
|
since := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)
|
|
logger := slog.Default()
|
|
tests := []struct {
|
|
name string
|
|
err error
|
|
advance bool
|
|
}{
|
|
{"success", nil, true},
|
|
{"no work", ErrWorkerNoWork, true},
|
|
{"wrapped no work", fmt.Errorf("%w: nothing", ErrWorkerNoWork), true},
|
|
{"cancelled", context.Canceled, false},
|
|
{"wrapped cancelled", fmt.Errorf("x: %w", context.Canceled), false},
|
|
{"failure", errors.New("boom"), false},
|
|
}
|
|
for _, tc := range tests {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
w := Worker{
|
|
Task: func(context.Context, time.Time) error {
|
|
return tc.err
|
|
},
|
|
}
|
|
before := time.Now()
|
|
got := w.execute(context.Background(), logger, since)
|
|
if tc.advance {
|
|
if got.Before(before) {
|
|
t.Fatalf(
|
|
"checkpoint %v not advanced to run start (>= %v)", got, before,
|
|
)
|
|
}
|
|
} else if !got.Equal(since) {
|
|
t.Fatalf("checkpoint = %v, want unchanged %v", got, since)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestAwaitTask(t *testing.T) {
|
|
errFail := errors.New("boom")
|
|
v, err := AwaitTask(context.Background(), func() (int, error) {
|
|
return 42, nil
|
|
})
|
|
if v != 42 || err != nil {
|
|
t.Fatalf("got (%d, %v), want (42, nil)", v, err)
|
|
}
|
|
v, err = AwaitTask(context.Background(), func() (int, error) {
|
|
return 7, errFail
|
|
})
|
|
if v != 7 || !errors.Is(err, errFail) {
|
|
t.Fatalf("got (%d, %v), want (7, %v)", v, err, errFail)
|
|
}
|
|
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
release := make(chan struct{})
|
|
defer close(release)
|
|
go func() {
|
|
time.Sleep(5 * time.Millisecond)
|
|
cancel()
|
|
}()
|
|
v, err = AwaitTask(ctx, func() (int, error) {
|
|
<-release
|
|
return 1, nil
|
|
})
|
|
if v != 0 || !errors.Is(err, context.Canceled) {
|
|
t.Fatalf("got (%d, %v), want (0, context.Canceled)", v, err)
|
|
}
|
|
}
|
|
|
|
func TestAwaitTaskE(t *testing.T) {
|
|
errFail := errors.New("boom")
|
|
if err := AwaitTaskE(context.Background(), func() error {
|
|
return nil
|
|
}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := AwaitTaskE(
|
|
context.Background(),
|
|
func() error {
|
|
return errFail
|
|
},
|
|
); !errors.Is(err, errFail) {
|
|
t.Fatalf("got %v, want %v", err, errFail)
|
|
}
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Millisecond)
|
|
defer cancel()
|
|
release := make(chan struct{})
|
|
defer close(release)
|
|
if err := AwaitTaskE(
|
|
ctx,
|
|
func() error {
|
|
<-release
|
|
return nil
|
|
},
|
|
); !errors.Is(err, context.DeadlineExceeded) {
|
|
t.Fatalf("got %v, want context.DeadlineExceeded", err)
|
|
}
|
|
}
|