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
This commit is contained in:
bwilliams committed 2026-10-14 15:55:25 +01:00
1 parent 803a8929e6
commit c9f42b62ad
42 files changed
+3619 -57

No files matched your search

+67 -3
View File
@@ -6,6 +6,8 @@ import (
"strings"
"testing"
"time"
"git.avsdev.uk/Go/job-search/internal/util"
)
// Test sections exercising every field shape section.go supports.
@@ -13,6 +15,13 @@ type testInner struct {
Name string `cfg:"name" default:"inner"`
}
// testItem is the item type of a list of sections.
type testItem struct {
Name string `cfg:"name" default:"item"`
N int `cfg:"n"`
Wait time.Duration `cfg:"wait"`
}
type testEmbedded struct {
On bool `cfg:"on" default:"true"`
}
@@ -27,8 +36,10 @@ type testSection struct {
Float float64 `cfg:"float" default:"1.5"`
Float32 float32 `cfg:"float32"`
Dur time.Duration `cfg:"dur" default:"90s"`
Size util.ByteSize `cfg:"size" default:"32KiB"`
When time.Time `cfg:"when"`
Slice []string `cfg:"slice"`
Items []testItem `cfg:"items"`
Inner testInner `cfg:"inner"`
NoTag string
Skipped string `cfg:"-"`
@@ -52,7 +63,7 @@ func (c *testValidated) validate(key string, value any) error {
func TestKeysOf(t *testing.T) {
got := keysOf(&testSection{})
want := []string{"on", "str", "int", "int8", "uint", "uint16", "float",
"float32", "dur", "when", "slice", "inner"}
"float32", "dur", "size", "when", "slice", "items", "inner"}
if !reflect.DeepEqual(got, want) {
t.Errorf("keysOf = %v; want %v", got, want)
}
@@ -70,6 +81,9 @@ func TestFieldsOfSkipsUntagged(t *testing.T) {
if f.key == "inner" && !f.section {
t.Error("struct field not treated as a section")
}
if f.key == "items" && f.section {
t.Error("list of sections treated as a section")
}
}
}
@@ -137,6 +151,7 @@ func TestApplyDefaults(t *testing.T) {
Uint: 7,
Float: 1.5,
Dur: 90 * time.Second,
Size: 32 << 10,
Inner: testInner{Name: "inner"},
}
if !reflect.DeepEqual(s, want) {
@@ -174,9 +189,11 @@ func TestTypeOf(t *testing.T) {
{"uint", reflect.Uint},
{"float", reflect.Float64},
{"on", reflect.Bool},
{"dur", reflect.String}, // durations are read as strings
{"dur", reflect.String}, // durations are read as strings
{"size", reflect.String}, // byte sizes are read as strings
{"when", reflect.Struct},
{"slice", reflect.Slice},
{"items", reflect.Slice},
{"inner.name", reflect.String},
}
for _, tt := range tests {
@@ -227,12 +244,38 @@ func TestSetValue(t *testing.T) {
{"float", "2.25", 2.25},
{"float32", "0.5", float32(0.5)},
{"dur", "1h30m", "1h30m0s"}, // durations are given out as strings
{"size", "1 mb", "1MB"}, // byte sizes are given out as strings
{"size", "1.5KiB", "1536B"},
{"size", "4096", "4KiB"},
{"slice", " a, b ,, c ", []string{"a", "b", "c"}},
{"slice", "", []string(nil)},
{"inner.name", "nested", "nested"},
// values of the field's type are used as-is
{"int", 5, 5},
{"on", true, true},
{"dur", 3 * time.Second, "3s"},
{"size", util.ByteSize(2048), "2KiB"},
{"slice", []string{"a", "b"}, []string{"a", "b"}},
{"slice", []string{}, []string(nil)}, // empty lists are stored as nil
// lists of sections are given out as lists of maps; items start
// from their defaults and their values are parsed
{"items", `[{"n": "1", "wait": "2s"}, {"name": "b", "n": 2}]`, []map[string]any{
{"name": "item", "n": 1, "wait": "2s"},
{"name": "b", "n": 2, "wait": "0s"},
}},
{"items", []any{map[string]any{"name": "a", "n": 3}}, []map[string]any{
{"name": "a", "n": 3, "wait": "0s"},
}},
{"items", []map[string]any{{"name": "a"}}, []map[string]any{
{"name": "a", "n": 0, "wait": "0s"},
}},
{"items", []testItem{{Name: "a", N: 4}}, []map[string]any{
{"name": "a", "n": 4, "wait": "0s"},
}},
{"items", "", []map[string]any(nil)},
{"items", "[]", []map[string]any(nil)},
{"items", []testItem{}, []map[string]any(nil)},
{"items", nil, []map[string]any(nil)},
{"when", time.Unix(0, 0).UTC(), time.Unix(0, 0).UTC()},
}
for _, tt := range tests {
@@ -261,7 +304,14 @@ func TestSetValueErrors(t *testing.T) {
{"float", "x", "unable to convert"},
{"on", "maybe", "unable to convert"},
{"dur", "soon", "unable to convert to duration"},
{"slice", "a,b", "unsupported type string"},
{"items", "not json", "unable to convert to a list of config.testItem"},
{"items", `[{"bogus": 1}]`, `item 0: unknown configuration key "bogus"`},
{"items", `[{"n": "x"}]`, "item 0: n: unable to convert"},
{"items", []any{"a"}, "item 0: unsupported type string"},
{"items", 5, "unsupported type int"},
{"size", "lots", "unable to convert to byte size"},
{"size", "-1KB", "unable to convert to byte size"},
{"size", "16EiB", "too large"},
{"when", "2024-01-01", "unsupported type string"},
{"int", 1.5, "unsupported type float64"},
{"int", int64(1), "unsupported type int64"},
@@ -281,6 +331,20 @@ func TestSetValueErrors(t *testing.T) {
}
}
// A []string is given out as a copy, so changing it leaves the section as it
// was.
func TestValueOfSliceIsACopy(t *testing.T) {
s := testSection{Slice: []string{"a", "b"}}
got, err := valueOf(s, "slice")
if err != nil {
t.Fatal(err)
}
got.([]string)[0] = "changed"
if s.Slice[0] != "a" {
t.Errorf("changing valueOf's slice changed the section: %v", s.Slice)
}
}
func TestSetValueValidator(t *testing.T) {
var s testValidated
if err := setValue(&s, "value", "good"); err != nil || s.Value != "good" {