rs2322tcp/internal/server/control_test.go
2026-08-10 10:38:14 +02:00

635 lines
13 KiB
Go

/*
* ============================================================================
* Projekt.....: rs2322tcp
* Datei.......: control_test.go
* Copyright (C) 2026 Dieter Lang
*
* SPDX-License-Identifier: GPL-3.0-or-later
*
* Beschreibung:
* Tests für den TCP-Control-Server einschließlich Session-Verwaltung,
* dynamischer Data-Ports und der bidirektionalen Verbindung zur
* seriellen Schnittstelle.
* ============================================================================
*/
package server_test
import (
"bufio"
"fmt"
"io"
"net"
"os"
"os/exec"
"path/filepath"
"testing"
"time"
"git.lang-dieter.de/rs2322tcp/internal/config"
"git.lang-dieter.de/rs2322tcp/internal/server"
"git.lang-dieter.de/rs2322tcp/internal/transport"
)
///////////////////////////////////////////////////////////////////////////////
// Test configuration
///////////////////////////////////////////////////////////////////////////////
func testServerConfig() *config.ServerConfig {
return &config.ServerConfig{
Listen: config.ListenConfig{
Address: "127.0.0.1",
Port: 5000,
},
Devices: []config.DeviceConfig{
{
ID: "radio",
Name: "Funkgerät",
SerialPort: "/dev/ttyUSB0",
BaudRate: 9600,
DataBits: 8,
Parity: "none",
StopBits: 1,
},
{
ID: "rotor",
Name: "Antennenrotor",
SerialPort: "/dev/ttyUSB1",
BaudRate: 4800,
DataBits: 8,
Parity: "none",
StopBits: 1,
},
},
}
}
///////////////////////////////////////////////////////////////////////////////
// Basic server tests
///////////////////////////////////////////////////////////////////////////////
func TestNewControlServer(t *testing.T) {
cfg := testServerConfig()
srv, err := server.NewControlServer(cfg)
if err != nil {
t.Fatalf("NewControlServer() failed: %v", err)
}
if srv == nil {
t.Fatal("NewControlServer() returned nil server")
}
}
func TestControlServerHello(t *testing.T) {
cfg := testServerConfig()
srv, err := server.NewControlServer(cfg)
if err != nil {
t.Fatalf("NewControlServer() failed: %v", err)
}
serverConn, clientConn := net.Pipe()
defer serverConn.Close()
defer clientConn.Close()
done := make(chan struct{})
var session *server.Session
go func() {
session = srv.HandleConnectionForTest(serverConn)
close(done)
}()
if err := transport.WriteMessage(
clientConn,
transport.NewHello(),
); err != nil {
t.Fatalf("WriteMessage() failed: %v", err)
}
reader := bufio.NewReader(clientConn)
var response transport.HelloResponseMessage
if err := transport.ReadMessage(reader, &response); err != nil {
t.Fatalf("ReadMessage() failed: %v", err)
}
if response.Type != transport.MessageHelloResponse {
t.Errorf(
"Type = %q, want %q",
response.Type,
transport.MessageHelloResponse,
)
}
clientConn.Close()
select {
case <-done:
case <-time.After(time.Second):
t.Fatal("server connection handler did not terminate")
}
if session == nil {
t.Fatal("server returned nil session")
}
if session.ID() != 1 {
t.Errorf("Session ID = %d, want 1", session.ID())
}
if !session.IsClosed() {
t.Fatal("session is not closed")
}
}
func TestControlServerGetDevices(t *testing.T) {
cfg := testServerConfig()
srv, err := server.NewControlServer(cfg)
if err != nil {
t.Fatalf("NewControlServer() failed: %v", err)
}
serverConn, clientConn := net.Pipe()
defer serverConn.Close()
defer clientConn.Close()
done := make(chan struct{})
go func() {
srv.HandleConnectionForTest(serverConn)
close(done)
}()
if err := transport.WriteMessage(
clientConn,
transport.NewGetDevices(),
); err != nil {
t.Fatalf("WriteMessage() failed: %v", err)
}
reader := bufio.NewReader(clientConn)
var response transport.DeviceListMessage
if err := transport.ReadMessage(reader, &response); err != nil {
t.Fatalf("ReadMessage() failed: %v", err)
}
if response.Type != transport.MessageDeviceList {
t.Errorf(
"Type = %q, want %q",
response.Type,
transport.MessageDeviceList,
)
}
if len(response.Devices) != 2 {
t.Fatalf(
"len(Devices) = %d, want 2",
len(response.Devices),
)
}
if response.Devices[0].ID != "radio" {
t.Errorf(
"Devices[0].ID = %q, want %q",
response.Devices[0].ID,
"radio",
)
}
if response.Devices[1].ID != "rotor" {
t.Errorf(
"Devices[1].ID = %q, want %q",
response.Devices[1].ID,
"rotor",
)
}
if response.Devices[0].DataPort == 0 {
t.Fatal("radio data port is zero")
}
if response.Devices[1].DataPort == 0 {
t.Fatal("rotor data port is zero")
}
clientConn.Close()
select {
case <-done:
case <-time.After(time.Second):
t.Fatal("server connection handler did not terminate")
}
}
func TestControlServerReconnectCreatesNewSession(t *testing.T) {
cfg := testServerConfig()
srv, err := server.NewControlServer(cfg)
if err != nil {
t.Fatalf("NewControlServer() failed: %v", err)
}
// First connection.
serverConn1, clientConn1 := net.Pipe()
done1 := make(chan *server.Session)
go func() {
done1 <- srv.HandleConnectionForTest(serverConn1)
}()
if err := transport.WriteMessage(
clientConn1,
transport.NewHello(),
); err != nil {
t.Fatalf("first WriteMessage() failed: %v", err)
}
reader1 := bufio.NewReader(clientConn1)
var response1 transport.HelloResponseMessage
if err := transport.ReadMessage(reader1, &response1); err != nil {
t.Fatalf("first ReadMessage() failed: %v", err)
}
if response1.Type != transport.MessageHelloResponse {
t.Errorf(
"first response Type = %q, want %q",
response1.Type,
transport.MessageHelloResponse,
)
}
if err := clientConn1.Close(); err != nil {
t.Fatalf("close first client connection: %v", err)
}
session1 := <-done1
if session1 == nil {
t.Fatal("first session is nil")
}
if session1.ID() != 1 {
t.Errorf(
"first session ID = %d, want 1",
session1.ID(),
)
}
if !session1.IsClosed() {
t.Fatal("first session is not closed")
}
// Second connection - simulates reconnect.
serverConn2, clientConn2 := net.Pipe()
done2 := make(chan *server.Session)
go func() {
done2 <- srv.HandleConnectionForTest(serverConn2)
}()
if err := transport.WriteMessage(
clientConn2,
transport.NewHello(),
); err != nil {
t.Fatalf("second WriteMessage() failed: %v", err)
}
reader2 := bufio.NewReader(clientConn2)
var response2 transport.HelloResponseMessage
if err := transport.ReadMessage(reader2, &response2); err != nil {
t.Fatalf("second ReadMessage() failed: %v", err)
}
if response2.Type != transport.MessageHelloResponse {
t.Errorf(
"second response Type = %q, want %q",
response2.Type,
transport.MessageHelloResponse,
)
}
if err := clientConn2.Close(); err != nil {
t.Fatalf("close second client connection: %v", err)
}
session2 := <-done2
if session2 == nil {
t.Fatal("second session is nil")
}
if session2.ID() != 2 {
t.Errorf(
"second session ID = %d, want 2",
session2.ID(),
)
}
if session2.ID() == session1.ID() {
t.Fatal("reconnect reused the old session ID")
}
if !session2.IsClosed() {
t.Fatal("second session is not closed")
}
}
///////////////////////////////////////////////////////////////////////////////
// Virtual serial pair
///////////////////////////////////////////////////////////////////////////////
func startVirtualSerialPair(t *testing.T) (string, string, func()) {
t.Helper()
if _, err := exec.LookPath("socat"); err != nil {
t.Skip("socat not installed")
}
dir := t.TempDir()
portA := filepath.Join(dir, "ttyA")
portB := filepath.Join(dir, "ttyB")
cmd := exec.Command(
"socat",
"-d",
"-d",
fmt.Sprintf(
"pty,raw,echo=0,link=%s",
portA,
),
fmt.Sprintf(
"pty,raw,echo=0,link=%s",
portB,
),
)
if err := cmd.Start(); err != nil {
t.Fatalf("failed to start socat: %v", err)
}
cleanup := func() {
if cmd.Process != nil {
_ = cmd.Process.Kill()
_ = cmd.Wait()
}
}
deadline := time.Now().Add(2 * time.Second)
for {
_, errA := os.Stat(portA)
_, errB := os.Stat(portB)
if errA == nil && errB == nil {
break
}
if time.Now().After(deadline) {
cleanup()
t.Fatal("timeout waiting for virtual serial ports")
}
time.Sleep(10 * time.Millisecond)
}
return portA, portB, cleanup
}
///////////////////////////////////////////////////////////////////////////////
// Read helper
///////////////////////////////////////////////////////////////////////////////
func readExactWithTimeout(
t *testing.T,
reader io.Reader,
buffer []byte,
timeout time.Duration,
) {
t.Helper()
done := make(chan error, 1)
go func() {
_, err := io.ReadFull(reader, buffer)
done <- err
}()
select {
case err := <-done:
if err != nil {
t.Fatalf("read failed: %v", err)
}
case <-time.After(timeout):
t.Fatalf("read timeout after %s", timeout)
}
}
///////////////////////////////////////////////////////////////////////////////
// Data connection integration test
///////////////////////////////////////////////////////////////////////////////
func TestControlServerDataConnection(t *testing.T) {
portA, portB, cleanup := startVirtualSerialPair(t)
defer cleanup()
cfg := &config.ServerConfig{
Listen: config.ListenConfig{
Address: "127.0.0.1",
Port: 5000,
},
Devices: []config.DeviceConfig{
{
ID: "radio",
Name: "Funkgerät",
SerialPort: portA,
BaudRate: 9600,
DataBits: 8,
Parity: "none",
StopBits: 1,
},
},
}
srv, err := server.NewControlServer(cfg)
if err != nil {
t.Fatalf("NewControlServer() failed: %v", err)
}
////////////////////////////////////////////////////////////////////////////
// Control connection
////////////////////////////////////////////////////////////////////////////
serverConn, clientConn := net.Pipe()
defer serverConn.Close()
defer clientConn.Close()
done := make(chan *server.Session)
go func() {
done <- srv.HandleConnectionForTest(serverConn)
}()
if err := transport.WriteMessage(
clientConn,
transport.NewGetDevices(),
); err != nil {
t.Fatalf("WriteMessage() failed: %v", err)
}
controlReader := bufio.NewReader(clientConn)
var deviceList transport.DeviceListMessage
if err := transport.ReadMessage(
controlReader,
&deviceList,
); err != nil {
t.Fatalf("ReadMessage() failed: %v", err)
}
if len(deviceList.Devices) != 1 {
t.Fatalf(
"len(Devices) = %d, want 1",
len(deviceList.Devices),
)
}
if deviceList.Devices[0].ID != "radio" {
t.Fatalf(
"device ID = %q, want %q",
deviceList.Devices[0].ID,
"radio",
)
}
dataPort := deviceList.Devices[0].DataPort
if dataPort == 0 {
t.Fatal("data port is zero")
}
////////////////////////////////////////////////////////////////////////////
// Data connection
////////////////////////////////////////////////////////////////////////////
dataConn, err := net.DialTimeout(
"tcp",
net.JoinHostPort("127.0.0.1", fmt.Sprintf("%d", dataPort)),
time.Second,
)
if err != nil {
t.Fatalf("connect data port failed: %v", err)
}
defer dataConn.Close()
////////////////////////////////////////////////////////////////////////////
// Serial peer
////////////////////////////////////////////////////////////////////////////
serialPeer, err := os.OpenFile(
portB,
os.O_RDWR,
0,
)
if err != nil {
t.Fatalf("open serial peer failed: %v", err)
}
defer serialPeer.Close()
////////////////////////////////////////////////////////////////////////////
// TCP -> Serial
////////////////////////////////////////////////////////////////////////////
tcpToSerial := []byte("hello from tcp")
if _, err := dataConn.Write(tcpToSerial); err != nil {
t.Fatalf("TCP Write() failed: %v", err)
}
serialReceived := make([]byte, len(tcpToSerial))
readExactWithTimeout(
t,
serialPeer,
serialReceived,
2*time.Second,
)
if string(serialReceived) != string(tcpToSerial) {
t.Fatalf(
"serial received %q, want %q",
string(serialReceived),
string(tcpToSerial),
)
}
////////////////////////////////////////////////////////////////////////////
// Serial -> TCP
////////////////////////////////////////////////////////////////////////////
serialToTCP := []byte("hello from serial")
if _, err := serialPeer.Write(serialToTCP); err != nil {
t.Fatalf("serial Write() failed: %v", err)
}
tcpReceived := make([]byte, len(serialToTCP))
readExactWithTimeout(
t,
dataConn,
tcpReceived,
2*time.Second,
)
if string(tcpReceived) != string(serialToTCP) {
t.Fatalf(
"TCP received %q, want %q",
string(tcpReceived),
string(serialToTCP),
)
}
////////////////////////////////////////////////////////////////////////////
// Close data connection
////////////////////////////////////////////////////////////////////////////
if err := dataConn.Close(); err != nil {
t.Fatalf("close data connection failed: %v", err)
}
////////////////////////////////////////////////////////////////////////////
// Close control connection
////////////////////////////////////////////////////////////////////////////
if err := clientConn.Close(); err != nil {
t.Fatalf("close control connection failed: %v", err)
}
select {
case session := <-done:
if session == nil {
t.Fatal("server returned nil session")
}
if !session.IsClosed() {
t.Fatal("session is not closed")
}
case <-time.After(2 * time.Second):
t.Fatal("server connection handler did not terminate")
}
}