235 lines
6.8 KiB
Go
235 lines
6.8 KiB
Go
|
|
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)
|
||
|
|
}
|
||
|
|
}
|