635 lines
13 KiB
Go
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")
|
|
}
|
|
}
|