/* * ============================================================================ * Projekt.....: rs2322tcp * Datei.......: integration_test.go * Copyright (C) 2026 Dieter Lang * * SPDX-License-Identifier: GPL-3.0-or-later * * Beschreibung: * Integrationstest für die Client-/Server-Kommunikation einschließlich * serieller Datenübertragung über virtuelle serielle Schnittstellen. * ============================================================================ */ package client_test import ( "fmt" "io" "net" "os" "os/exec" "path/filepath" "testing" "time" "git.lang-dieter.de/rs2322tcp/internal/client" "git.lang-dieter.de/rs2322tcp/internal/config" "git.lang-dieter.de/rs2322tcp/internal/server" ) /////////////////////////////////////////////////////////////////////////////// // Test configuration /////////////////////////////////////////////////////////////////////////////// const testHardwareErrorResponse = "TEST ERROR RESPONSE" /////////////////////////////////////////////////////////////////////////////// // 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("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 { return portA, portB, cleanup } if time.Now().After(deadline) { cleanup() t.Fatal("timeout waiting for virtual serial ports") } time.Sleep(10 * time.Millisecond) } } /////////////////////////////////////////////////////////////////////////////// // 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) } } /////////////////////////////////////////////////////////////////////////////// // Integration test /////////////////////////////////////////////////////////////////////////////// func TestClientServerSerialIntegration(t *testing.T) { serialA, serialB, cleanup := startVirtualSerialPair(t) defer cleanup() //////////////////////////////////////////////////////////////////////////// // Server //////////////////////////////////////////////////////////////////////////// controlProbe, err := net.Listen("tcp", "127.0.0.1:0") if err != nil { t.Fatalf("find free control port: %v", err) } controlAddr := controlProbe.Addr().(*net.TCPAddr) controlPort := controlAddr.Port if err := controlProbe.Close(); err != nil { t.Fatalf("close control probe: %v", err) } cfg := &config.ServerConfig{ Listen: config.ListenConfig{ Address: "127.0.0.1", Port: controlPort, }, HardwareErrorResponse: testHardwareErrorResponse, Devices: []config.DeviceConfig{ { ID: "radio", Name: "Funkgerät", SerialPort: serialA, BaudRate: 9600, DataBits: 8, Parity: "none", StopBits: 1, }, }, } srv, err := server.NewControlServer(cfg) if err != nil { t.Fatalf("NewControlServer() failed: %v", err) } //////////////////////////////////////////////////////////////////////////// // Control server //////////////////////////////////////////////////////////////////////////// if err := srv.Listen(); err != nil { t.Fatalf("server Listen() failed: %v", err) } serveDone := make(chan error, 1) go func() { serveDone <- srv.Serve() }() address := srv.Addr().String() //////////////////////////////////////////////////////////////////////////// // Client //////////////////////////////////////////////////////////////////////////// c, err := client.New(address) if err != nil { t.Fatalf("client.New() failed: %v", err) } defer c.Close() devices, err := c.GetDevices() if err != nil { t.Fatalf("GetDevices() failed: %v", err) } if len(devices) != 1 { t.Fatalf( "len(devices) = %d, want 1", len(devices), ) } device := devices[0] if device.ID != "radio" { t.Fatalf( "device ID = %q, want %q", device.ID, "radio", ) } if device.DataPort == 0 { t.Fatal("server returned data port 0") } //////////////////////////////////////////////////////////////////////////// // Open data connection //////////////////////////////////////////////////////////////////////////// dataConn, err := c.OpenDataConnection(device) if err != nil { t.Fatalf("OpenDataConnection() failed: %v", err) } defer dataConn.Close() //////////////////////////////////////////////////////////////////////////// // Open serial peer //////////////////////////////////////////////////////////////////////////// serialPeer, err := os.OpenFile( serialB, os.O_RDWR, 0, ) if err != nil { t.Fatalf("open serial peer: %v", err) } defer serialPeer.Close() //////////////////////////////////////////////////////////////////////////// // Client -> TCP -> Server -> Serial //////////////////////////////////////////////////////////////////////////// clientMessage := []byte("hello from client") if _, err := dataConn.Write(clientMessage); err != nil { t.Fatalf("client data Write() failed: %v", err) } serialReceived := make([]byte, len(clientMessage)) readExactWithTimeout( t, serialPeer, serialReceived, 2*time.Second, ) if string(serialReceived) != string(clientMessage) { t.Fatalf( "serial received %q, want %q", string(serialReceived), string(clientMessage), ) } //////////////////////////////////////////////////////////////////////////// // Serial -> Server -> TCP -> Client //////////////////////////////////////////////////////////////////////////// serialMessage := []byte("hello from serial") if _, err := serialPeer.Write(serialMessage); err != nil { t.Fatalf("serial Write() failed: %v", err) } clientReceived := make([]byte, len(serialMessage)) readExactWithTimeout( t, dataConn, clientReceived, 2*time.Second, ) if string(clientReceived) != string(serialMessage) { t.Fatalf( "client received %q, want %q", string(clientReceived), string(serialMessage), ) } //////////////////////////////////////////////////////////////////////////// // Cleanup //////////////////////////////////////////////////////////////////////////// if err := dataConn.Close(); err != nil { t.Fatalf("close data connection: %v", err) } if err := c.Close(); err != nil { t.Fatalf("close client: %v", err) } // Give the server goroutines a moment to perform their cleanup. time.Sleep(20 * time.Millisecond) if err := srv.Close(); err != nil { t.Fatalf("close server: %v", err) } select { case err := <-serveDone: if err != nil { t.Fatalf("server Serve() failed: %v", err) } case <-time.After(2 * time.Second): t.Fatal("timeout waiting for server Serve() to stop") } }