modelRT/cmd/manualsyncmock/server_test.go

235 lines
6.8 KiB
Go
Raw Permalink Normal View History

package main
import (
"bytes"
"context"
"errors"
"io"
"log/slog"
"net"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
)
func TestHealthEndpointReportsServerIsReady(t *testing.T) {
server := newProtocolServer("cl3611", cl3611Address, slog.Default())
request := httptest.NewRequest(http.MethodGet, healthPath, nil)
response := httptest.NewRecorder()
server.Handler.ServeHTTP(response, request)
result := response.Result()
defer result.Body.Close()
body, err := io.ReadAll(result.Body)
if err != nil {
t.Fatalf("read response body: %v", err)
}
if result.StatusCode != http.StatusOK {
t.Fatalf("status = %d, want %d", result.StatusCode, http.StatusOK)
}
if contentType := result.Header.Get("Content-Type"); contentType != "text/plain; charset=utf-8" {
t.Errorf("Content-Type = %q, want %q", contentType, "text/plain; charset=utf-8")
}
if string(body) != "ok\n" {
t.Errorf("body = %q, want %q", body, "ok\\n")
}
}
func TestManualEndpointLogsStructuredRequest(t *testing.T) {
var logs bytes.Buffer
logger := slog.New(slog.NewTextHandler(&logs, nil))
server := newProtocolServer("cl3611", cl3611Address, logger)
request := httptest.NewRequest(http.MethodPost, manualAPIPath, strings.NewReader(`{
"mode": 1,
"data": [{"time": 1736305467506000000, "value": 1.25}],
"target": {
"type": 1,
"station": "001",
"main_pos": "ssu001",
"sub_pos": "TM1",
"option": "RMS"
}
}`))
request.RemoteAddr = "127.0.0.1:52130"
response := httptest.NewRecorder()
server.Handler.ServeHTTP(response, request)
if response.Code != http.StatusNoContent {
t.Fatalf("status = %d, want %d", response.Code, http.StatusNoContent)
}
for _, fragment := range []string{
"protocol=cl3611",
"listen_port=9001",
"remote_addr=127.0.0.1:52130",
"mode=1",
"target.type=1",
"target.station=001",
"target.main_pos=ssu001",
"target.sub_pos=TM1",
"target.option=RMS",
"time=1736305467506000000",
"value=1.25",
} {
if !strings.Contains(logs.String(), fragment) {
t.Errorf("log %q does not contain %q", logs.String(), fragment)
}
}
}
func TestManualEndpointRejectsInvalidJSONAndLogsTheError(t *testing.T) {
var logs bytes.Buffer
logger := slog.New(slog.NewTextHandler(&logs, nil))
server := newProtocolServer("104", protocol104Address, logger)
request := httptest.NewRequest(http.MethodPost, manualAPIPath, strings.NewReader(`{"mode":`))
request.RemoteAddr = "127.0.0.1:52131"
response := httptest.NewRecorder()
server.Handler.ServeHTTP(response, request)
if response.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want %d", response.Code, http.StatusBadRequest)
}
for _, fragment := range []string{
"level=WARN",
"msg=\"invalid manual sync request\"",
"protocol=104",
"listen_port=9002",
"remote_addr=127.0.0.1:52131",
"error=",
} {
if !strings.Contains(logs.String(), fragment) {
t.Errorf("log %q does not contain %q", logs.String(), fragment)
}
}
}
func TestManualEndpointRejectsOversizedRequest(t *testing.T) {
server := newProtocolServer("cl3611", cl3611Address, slog.Default())
body := `{"extra":"` + strings.Repeat("a", maxRequestBody) + `"}`
request := httptest.NewRequest(http.MethodPost, manualAPIPath, strings.NewReader(body))
response := httptest.NewRecorder()
server.Handler.ServeHTTP(response, request)
if response.Code != http.StatusRequestEntityTooLarge {
t.Fatalf("status = %d, want %d", response.Code, http.StatusRequestEntityTooLarge)
}
}
func TestManualEndpointRejectsMultipleJSONDocuments(t *testing.T) {
server := newProtocolServer("104", protocol104Address, slog.Default())
request := httptest.NewRequest(http.MethodPost, manualAPIPath, strings.NewReader(`{"mode":1}{"mode":0}`))
response := httptest.NewRecorder()
server.Handler.ServeHTTP(response, request)
if response.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want %d", response.Code, http.StatusBadRequest)
}
}
func TestRunClosesFirstListenerWhenSecondListenerFails(t *testing.T) {
rawListener, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("listen on temporary port: %v", err)
}
firstListener := rawListener.(*net.TCPListener)
listenCalls := 0
listen := func(_, _ string) (net.Listener, error) {
listenCalls++
if listenCalls == 1 {
return firstListener, nil
}
return nil, errors.New("port is already in use")
}
err = run(context.Background(), slog.Default(), listen)
if err == nil || !strings.Contains(err.Error(), protocol104Address) {
t.Fatalf("run error = %v, want error containing %q", err, protocol104Address)
}
if err := firstListener.SetDeadline(time.Now()); err == nil {
t.Fatal("first listener is still open")
}
}
func TestRunServesBothProtocolsAndStopsOnCancellation(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
addresses := make(chan string, 2)
listen := func(_, _ string) (net.Listener, error) {
listener, err := net.Listen("tcp", "127.0.0.1:0")
if err == nil {
addresses <- listener.Addr().String()
}
return listener, err
}
runErrors := make(chan error, 1)
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
go func() {
runErrors <- run(ctx, logger, listen)
}()
cl3611Addr := <-addresses
protocol104Addr := <-addresses
waitForHealthyEndpoint(t, cl3611Addr)
waitForHealthyEndpoint(t, protocol104Addr)
cancel()
select {
case err := <-runErrors:
if err != nil {
t.Fatalf("run returned an error during graceful shutdown: %v", err)
}
case <-time.After(2 * time.Second):
t.Fatal("servers did not stop after context cancellation")
}
}
func TestProtocolServerEnforcesMethodAndPathRouting(t *testing.T) {
server := newProtocolServer("cl3611", cl3611Address, slog.Default())
tests := []struct {
name string
method string
path string
wantStatus int
}{
{name: "manual endpoint rejects GET", method: http.MethodGet, path: manualAPIPath, wantStatus: http.StatusMethodNotAllowed},
{name: "unknown path is not found", method: http.MethodGet, path: "/unknown", wantStatus: http.StatusNotFound},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
request := httptest.NewRequest(test.method, test.path, nil)
response := httptest.NewRecorder()
server.Handler.ServeHTTP(response, request)
if response.Code != test.wantStatus {
t.Fatalf("status = %d, want %d", response.Code, test.wantStatus)
}
})
}
}
func waitForHealthyEndpoint(t *testing.T, address string) {
t.Helper()
client := &http.Client{Timeout: 100 * time.Millisecond}
deadline := time.Now().Add(2 * time.Second)
for {
response, err := client.Get("http://" + address + healthPath)
if err == nil {
_ = response.Body.Close()
if response.StatusCode == http.StatusOK {
return
}
}
if time.Now().After(deadline) {
t.Fatalf("health endpoint at %s did not become ready", address)
}
time.Sleep(10 * time.Millisecond)
}
}