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:
1 parent
803a8929e6
commit
c9f42b62ad
42 files changed
+3619
-57
No files matched your search
@@ -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" {
|
||||
|
||||
Reference in new issue
Block a user