Files
job-search/internal/config/modify_test.go
T
bwilliams c9f42b62ad Added websocket hub for handling websocket clients, with a wsserve command
which runs a plain TCP server for receiving websocket hub clients, with
server config, CSRF and rate limit middleware.

- internal/websocket: Hub pushing messages to WebSocket clients (ServeHTTP,
  example.com/ws upgrade with optional CSRF check) and plain TCP clients (ServeTCP,
  newline-delimited JSON envelopes); ping/pong keepalive, per-client message
  rate limit, slow consumer handling, graceful Shutdown, debug logging of
  messages sent and received
- internal/websocket/tcpserver: standalone TCP server for a hub, with accept
  back-off, a per-IP connection limit (server.websocket.connection_limit) and
  graceful shutdown
- internal/cli/wsserve: `wsserve` command running the TCP server, with
  --listen/--host/--port overrides
- internal/middleware: keyed token bucket rate limiter, real client IP from
  trusted proxies
- internal/httpx: RFC 9457 problem responses, request IDs in context
- internal/config: server section (host, port, shutdown timeout, rate limiter
  idle TTL, websocket); util.ByteSize settings (e.g. "32KiB"); lists of
  sections as single keys; empty lists stored as nil; isExplicitlySet for
  printLoadedConfig; JobSearchConfigSource renamed to JSCSourcesSource;
  TestSections checks every section's methods
- internal/util: ByteSize, parsed and formatted with go-humanize
2026-10-14 15:55:25 +01:00

310 lines
9.2 KiB
Go

package config
import (
"errors"
"os"
"path/filepath"
"reflect"
"strings"
"testing"
"time"
"github.com/spf13/viper"
)
// useConfigFile points Viper at a new YAML config file holding contents.
func useConfigFile(t *testing.T, contents string) string {
t.Helper()
resetViper(t)
path := filepath.Join(t.TempDir(), "config.yaml")
if err := os.WriteFile(path, []byte(contents), 0o600); err != nil {
t.Fatal(err)
}
viper.SetConfigFile(path)
if err := viper.ReadInConfig(); err != nil {
t.Fatal(err)
}
return path
}
// readConfigFile reads path with a fresh Viper.
func readConfigFile(t *testing.T, path string) *viper.Viper {
t.Helper()
v := viper.New()
v.SetConfigFile(path)
if err := v.ReadInConfig(); err != nil {
t.Fatal(err)
}
return v
}
func TestStoreValue(t *testing.T) {
tests := []struct {
key, value string
want any
kind reflect.Kind
}{
{"logging.level", "debug", "debug", reflect.String},
{"sources.sleeper.enabled", "true", true, reflect.Bool},
{"sources.fts_mock.api_root", "http://mock", "http://mock", reflect.String},
{"sources.fts_mock.interval", "30s", "30s", reflect.String},
}
for _, tt := range tests {
t.Run(tt.key, func(t *testing.T) {
path := useConfigFile(t, "logging:\n level: warn\n")
conf := defaultConfig(t)
got, kind, err := StoreValue(&conf, tt.key, tt.value)
if err != nil {
t.Fatal(err)
}
if got != tt.want || kind != tt.kind {
t.Errorf("got %#v <%s>; want %#v <%s>", got, kind, tt.want, tt.kind)
}
if v := readConfigFile(t, path).Get(tt.key); v != tt.want {
t.Errorf("file has %s = %#v; want %#v", tt.key, v, tt.want)
}
})
}
}
func TestStoreValueKeepsOtherKeys(t *testing.T) {
path := useConfigFile(t, "logging:\n level: warn\n file: /tmp/js.log\n")
conf := defaultConfig(t)
if _, _, err := StoreValue(&conf, "logging.level", "error"); err != nil {
t.Fatal(err)
}
v := readConfigFile(t, path)
if got := v.GetString("logging.file"); got != "/tmp/js.log" {
t.Errorf("logging.file = %q; want it kept", got)
}
}
func TestStoreValueErrors(t *testing.T) {
tests := []struct {
name, key, value, wantErr string
}{
{"unknown key", "nope", "x", ""},
{"section key", "sources", "x", ""},
{"bad bool", "sources.sleeper.enabled", "maybe", "expected <bool>"},
{"invalid log level", "logging.level", "shouty", "invalid log level"},
{"bad duration", "sources.fts_mock.interval", "eventually", "duration"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
path := useConfigFile(t, "logging:\n level: warn\n")
//nolint:gosec // G304: reading a file the test created in t.TempDir
before, _ := os.ReadFile(path)
conf := defaultConfig(t)
_, _, err := StoreValue(&conf, tt.key, tt.value)
if err == nil {
t.Fatal("expected an error")
}
if !strings.Contains(err.Error(), tt.wantErr) {
t.Errorf("err = %v; want it to mention %q", err, tt.wantErr)
}
//nolint:gosec // G304: reading a file the test created in t.TempDir
if after, _ := os.ReadFile(path); string(after) != string(before) {
t.Errorf("config file changed:\n%s", after)
}
})
}
}
func TestStoreValueNoConfigFile(t *testing.T) {
resetViper(t)
conf := defaultConfig(t)
if _, _, err := StoreValue(&conf, "logging.level", "debug"); err == nil {
t.Fatal("expected an error with no config file to write")
}
}
func TestPurgeValue(t *testing.T) {
path := useConfigFile(t, "logging:\n level: debug\n file: /tmp/js.log\n")
conf := defaultConfig(t)
if err := PurgeValue(&conf, "logging.file"); err != nil {
t.Fatal(err)
}
v := readConfigFile(t, path)
if v.IsSet("logging.file") {
t.Errorf("logging.file still set: %v", v.Get("logging.file"))
}
if got := v.GetString("logging.level"); got != "debug" {
t.Errorf("logging.level = %q; want it kept", got)
}
}
func TestPurgeValueErrors(t *testing.T) {
path := useConfigFile(t, "logging:\n level: debug\n")
//nolint:gosec // G304: reading a file the test created in t.TempDir
before, _ := os.ReadFile(path)
conf := defaultConfig(t)
if err := PurgeValue(&conf, "nope"); err == nil {
t.Error("expected an error for an unknown key")
}
//nolint:gosec // G304: reading a file the test created in t.TempDir
if after, _ := os.ReadFile(path); string(after) != string(before) {
t.Errorf("config file changed:\n%s", after)
}
resetViper(t)
if err := PurgeValue(&conf, "logging.level"); err == nil {
t.Error("expected an error with no config file to write")
}
}
func TestStoreValueUpdatesConf(t *testing.T) {
useConfigFile(t, "logging:\n level: warn\n")
conf := defaultConfig(t)
if _, _, err := StoreValue(&conf, "sources.fts_mock.interval", "30s"); err != nil {
t.Fatal(err)
}
if conf.Sources.FTSMock.Interval != 30*time.Second {
t.Errorf("FTSMock.Interval = %v; want 30s", conf.Sources.FTSMock.Interval)
}
}
// parseValue parses the kinds a key can have; no current key is an integer
// or float, so StoreValue can't reach those cases yet.
func TestParseValue(t *testing.T) {
for _, tt := range []struct {
kind reflect.Kind
value string
want any
}{
{reflect.Bool, "true", true},
{reflect.String, "text", "text"},
{reflect.Int, "-42", int64(-42)},
{reflect.Int8, "7", int64(7)},
{reflect.Uint16, "65535", uint64(65535)},
{reflect.Float32, "1.5", 1.5},
{reflect.Slice, " a, b ,, c ", []string{"a", "b", "c"}},
} {
got, err := parseValue(tt.kind, tt.value)
if err != nil || !reflect.DeepEqual(got, tt.want) {
t.Errorf("parseValue(%v, %q) = %#v, %v; want %#v", tt.kind, tt.value, got, err, tt.want)
}
}
for _, tt := range []struct {
kind reflect.Kind
value string
}{
{reflect.Bool, "maybe"},
{reflect.Int, "1.5"},
{reflect.Uint, "-1"},
{reflect.Float64, "x"},
} {
if got, err := parseValue(tt.kind, tt.value); err == nil {
t.Errorf("parseValue(%v, %q) = %#v; want an error", tt.kind, tt.value, got)
}
}
if _, err := parseValue(reflect.Map, "a"); !errors.Is(err, errors.ErrUnsupported) {
t.Errorf("parseValue(map) err = %v; want ErrUnsupported", err)
}
}
// numberSection has the numeric kinds JobSearchConfig doesn't use yet, to
// test storeValue with them.
type numberSection struct {
Count int `cfg:"count"`
Small int8 `cfg:"small"`
Big uint16 `cfg:"big"`
Ratio float32 `cfg:"ratio"`
}
func (c numberSection) Map() map[string]any { return mapOf(c) }
func (c numberSection) Flatten() map[string]any { return flattenOf("", c) }
func (c numberSection) TypeOf(key string) (reflect.Kind, error) { return typeOf(c, key) }
func (c numberSection) ValueOf(key string) (any, error) { return valueOf(c, key) }
func (c *numberSection) SetValue(key string, value any) error { return setValue(c, key, value) }
// Numbers are stored as the field's own type, not as the int64, uint64 or
// float64 that parsing produces.
func TestStoreValueNumbers(t *testing.T) {
for _, tt := range []struct {
key, value string
want any
kind reflect.Kind
}{
{"count", "-42", -42, reflect.Int},
{"small", "7", int8(7), reflect.Int8},
{"big", "65535", uint16(65535), reflect.Uint16},
{"ratio", "1.5", float32(1.5), reflect.Float32},
} {
t.Run(tt.key, func(t *testing.T) {
path := useConfigFile(t, "count: 1\n")
var s numberSection
got, kind, err := storeValue(&s, tt.key, tt.value)
if err != nil {
t.Fatal(err)
}
if got != tt.want || kind != tt.kind {
t.Errorf("got %#v <%s>; want %#v <%s>", got, kind, tt.want, tt.kind)
}
if v, _ := s.ValueOf(tt.key); v != tt.want {
t.Errorf("section has %s = %#v; want %#v", tt.key, v, tt.want)
}
if v := readConfigFile(t, path).GetString(tt.key); v != tt.value {
t.Errorf("file has %s = %q; want %q", tt.key, v, tt.value)
}
})
}
for key, value := range map[string]string{
"count": "1.5", // not an integer
"small": "300", // too big for an int8
"big": "-1", // negative
"ratio": "lots", // not a number
} {
useConfigFile(t, "count: 1\n")
if _, _, err := storeValue(&numberSection{}, key, value); err == nil {
t.Errorf("storeValue(%s, %q) succeeded", key, value)
}
}
}
// Only the file's own settings are written back: defaults (and environment
// variables) loaded into Viper stay out of the file.
func TestStoreAndPurgeWriteOnlyFileSettings(t *testing.T) {
path := useConfigFile(t, "logging:\n level: warn\n file: /tmp/js.log\n")
LoadDefaultConfig()
conf := defaultConfig(t)
if _, _, err := StoreValue(&conf, "sources.sleeper.enabled", "true"); err != nil {
t.Fatal(err)
}
want := map[string]any{
"logging.level": "warn",
"logging.file": "/tmp/js.log",
"sources.sleeper.enabled": true,
}
checkFileSettings(t, path, want)
if err := PurgeValue(&conf, "logging.file"); err != nil {
t.Fatal(err)
}
delete(want, "logging.file")
checkFileSettings(t, path, want)
}
// checkFileSettings checks the config file at path holds exactly want.
func checkFileSettings(t *testing.T, path string, want map[string]any) {
t.Helper()
v := readConfigFile(t, path)
got := map[string]any{}
for _, k := range v.AllKeys() {
got[k] = v.Get(k)
}
if !reflect.DeepEqual(got, want) {
//nolint:gosec // G304: reading a file the test created in t.TempDir
b, _ := os.ReadFile(path)
t.Errorf("file settings = %v; want %v\n%s", got, want, b)
}
}