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) } }