/* * ============================================================================ * 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") } }