rs2322tcp/internal/client/connection_test.go

525 lines
10 KiB
Go

/*
* ============================================================================
* Projekt.....: rs2322tcp
* Datei.......: internal/client/connection_test.go
* Copyright (C) 2026 Dieter Lang
*
* SPDX-License-Identifier: GPL-3.0-or-later
*
* Beschreibung:
* Tests für den Aufbau einer Verbindung zwischen einem lokalen virtuellen
* seriellen Port und dem zugehörigen entfernten TCP-Gerät.
*
* Die Tests prüfen insbesondere die Auswahl des Remote-Geräts, die Erzeugung
* des virtuellen Ports, den Aufbau der TCP-Datenverbindung und die Verbindung
* dieser Endpunkte über eine Bridge.
* ============================================================================
*/
package client
import (
"net"
"strconv"
"strings"
"testing"
"git.lang-dieter.de/rs2322tcp/internal/config"
"git.lang-dieter.de/rs2322tcp/internal/transport"
)
///////////////////////////////////////////////////////////////////////////////
// Test connection
///////////////////////////////////////////////////////////////////////////////
// testRemoteAddrConn wraps a net.Conn and provides a TCP-style remote address.
//
// net.Pipe() is used for the control connection in these tests. Its default
// RemoteAddr() is "pipe", which cannot be used by OpenDataConnection() to
// determine the server host. This wrapper supplies a normal TCP address.
type testRemoteAddrConn struct {
net.Conn
remoteAddr net.Addr
}
// RemoteAddr returns the TCP-style remote address configured for the test.
func (c *testRemoteAddrConn) RemoteAddr() net.Addr {
return c.remoteAddr
}
///////////////////////////////////////////////////////////////////////////////
// Test helpers
///////////////////////////////////////////////////////////////////////////////
// startTestDataServer starts a local TCP listener for a simulated remote
// device.
//
// The listener accepts exactly one connection. The accepted connection is
// returned through the channel so the test can verify that the client has
// successfully established the data connection.
func startTestDataServer(t *testing.T) (string, <-chan net.Conn) {
t.Helper()
listener, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("listen for test data server: %v", err)
}
t.Cleanup(func() {
_ = listener.Close()
})
accepted := make(chan net.Conn, 1)
go func() {
conn, err := listener.Accept()
if err != nil {
return
}
accepted <- conn
}()
return listener.Addr().String(), accepted
}
// remoteDeviceFromAddress creates a RemoteDeviceInfo whose DataPort is
// taken from the supplied local TCP address.
func remoteDeviceFromAddress(
t *testing.T,
id string,
address string,
) transport.RemoteDeviceInfo {
t.Helper()
_, portText, err := net.SplitHostPort(address)
if err != nil {
t.Fatalf("split test server address %q: %v", address, err)
}
port, err := strconv.Atoi(portText)
if err != nil {
t.Fatalf("parse test server port %q: %v", portText, err)
}
return transport.RemoteDeviceInfo{
ID: id,
Name: id,
DataPort: port,
}
}
// newTestClient creates a Client whose control connection uses net.Pipe()
// but reports a normal TCP-style remote address.
func newTestClient(t *testing.T) *Client {
t.Helper()
controlClient, serverConn := net.Pipe()
t.Cleanup(func() {
_ = controlClient.Close()
_ = serverConn.Close()
})
return &Client{
conn: &testRemoteAddrConn{
Conn: controlClient,
remoteAddr: &net.TCPAddr{
IP: net.ParseIP("127.0.0.1"),
Port: 5000,
},
},
}
}
///////////////////////////////////////////////////////////////////////////////
// Tests
///////////////////////////////////////////////////////////////////////////////
func TestConnectVirtualPortNilClient(t *testing.T) {
manager := NewVirtualPortManager(
config.VirtualPortRangeConfig{
First: 100,
Last: 199,
},
)
cfg := config.VirtualPortConfig{
Port: virtualPortPath(100),
RemoteDevice: "radio",
}
devices := []transport.RemoteDeviceInfo{
{
ID: "radio",
DataPort: 50123,
},
}
_, _, err := connectVirtualPort(
nil,
manager,
cfg,
devices,
)
if err == nil {
t.Fatal("expected error for nil client")
}
if !strings.Contains(err.Error(), "client is not connected") {
t.Fatalf("unexpected error: %v", err)
}
}
func TestConnectVirtualPortNilManager(t *testing.T) {
cfg := config.VirtualPortConfig{
Port: virtualPortPath(100),
RemoteDevice: "radio",
}
devices := []transport.RemoteDeviceInfo{
{
ID: "radio",
DataPort: 50123,
},
}
_, _, err := connectVirtualPort(
&Client{},
nil,
cfg,
devices,
)
if err == nil {
t.Fatal("expected error for nil manager")
}
if !strings.Contains(err.Error(), "client is not connected") {
t.Fatalf("unexpected error: %v", err)
}
}
func TestConnectVirtualPortUnknownDevice(t *testing.T) {
manager := NewVirtualPortManager(
config.VirtualPortRangeConfig{
First: 100,
Last: 199,
},
)
client := newTestClient(t)
cfg := config.VirtualPortConfig{
Port: virtualPortPath(100),
RemoteDevice: "unknown",
}
devices := []transport.RemoteDeviceInfo{
{
ID: "radio",
DataPort: 50123,
},
}
_, _, err := connectVirtualPort(
client,
manager,
cfg,
devices,
)
if err == nil {
t.Fatal("expected error for unknown device")
}
if !strings.Contains(
err.Error(),
`remote device "unknown" not found`,
) {
t.Fatalf("unexpected error: %v", err)
}
}
func TestConnectVirtualPortClosesPortWhenDataConnectionFails(t *testing.T) {
manager := NewVirtualPortManager(
config.VirtualPortRangeConfig{
First: 100,
Last: 100,
},
)
client := newTestClient(t)
cfg := config.VirtualPortConfig{
Port: virtualPortPath(100),
RemoteDevice: "radio",
}
devices := []transport.RemoteDeviceInfo{
{
ID: "radio",
DataPort: 1,
},
}
port, conn, err := connectVirtualPort(
client,
manager,
cfg,
devices,
)
if err == nil {
if conn != nil {
_ = conn.Close()
}
if port != nil {
_ = port.Close()
}
t.Fatal("expected data connection error")
}
if port != nil {
t.Fatal(
"virtual port must not be returned after connection failure",
)
}
if conn != nil {
t.Fatal(
"TCP connection must not be returned after connection failure",
)
}
// Port 100 must have been released after the failed connection.
portNumber, err := manager.Reserve()
if err != nil {
t.Fatalf("Reserve after cleanup: %v", err)
}
if portNumber != 100 {
t.Fatalf("reserved port = %d, want 100", portNumber)
}
manager.Release(portNumber)
}
func TestConnectVirtualPortSuccess(t *testing.T) {
dataAddress, accepted := startTestDataServer(t)
device := remoteDeviceFromAddress(
t,
"radio",
dataAddress,
)
client := newTestClient(t)
manager := NewVirtualPortManager(
config.VirtualPortRangeConfig{
First: 100,
Last: 199,
},
)
cfg := config.VirtualPortConfig{
Port: virtualPortPath(100),
RemoteDevice: "radio",
}
virtualPort, dataConn, err := connectVirtualPort(
client,
manager,
cfg,
[]transport.RemoteDeviceInfo{device},
)
if err != nil {
t.Fatalf("connectVirtualPort: %v", err)
}
if virtualPort == nil {
t.Fatal("virtual port is nil")
}
if dataConn == nil {
t.Fatal("data connection is nil")
}
defer virtualPort.Close()
defer dataConn.Close()
if virtualPort.Path() != virtualPortPath(100) {
t.Fatalf(
"virtual port path = %q, want %q",
virtualPort.Path(),
virtualPortPath(100),
)
}
// The test data server must receive the connection created by
// OpenDataConnection().
select {
case conn := <-accepted:
_ = conn.Close()
case <-t.Context().Done():
t.Fatal("test context cancelled while waiting for data connection")
}
}
func TestConnectVirtualPortUsesConfiguredPort(t *testing.T) {
dataAddress, accepted := startTestDataServer(t)
device := remoteDeviceFromAddress(
t,
"radio",
dataAddress,
)
client := newTestClient(t)
manager := NewVirtualPortManager(
config.VirtualPortRangeConfig{
First: 100,
Last: 199,
},
)
cfg := config.VirtualPortConfig{
Port: virtualPortPath(100),
RemoteDevice: "radio",
}
virtualPort, dataConn, err := connectVirtualPort(
client,
manager,
cfg,
[]transport.RemoteDeviceInfo{device},
)
if err != nil {
t.Fatalf("connectVirtualPort: %v", err)
}
if virtualPort == nil {
t.Fatal("virtual port is nil")
}
if dataConn == nil {
t.Fatal("data connection is nil")
}
defer virtualPort.Close()
defer dataConn.Close()
if virtualPort.Path() != virtualPortPath(100) {
t.Fatalf(
"virtual port path = %q, want %q",
virtualPort.Path(),
virtualPortPath(100),
)
}
// Port 100 is the configured port and therefore must be reserved.
if !manager.used[100] {
t.Fatal("configured port 100 is not reserved")
}
// Port 101 must still be available. This proves that the connection
// did not simply take another free port.
number, err := manager.Reserve()
if err != nil {
t.Fatalf("Reserve() after connection: %v", err)
}
if number != 101 {
t.Fatalf(
"first free port after connection = %d, want 101",
number,
)
}
manager.Release(number)
select {
case conn := <-accepted:
_ = conn.Close()
case <-t.Context().Done():
t.Fatal("test context cancelled while waiting for data connection")
}
}
func TestConnectBridgeSuccess(t *testing.T) {
dataAddress, accepted := startTestDataServer(t)
device := remoteDeviceFromAddress(
t,
"radio",
dataAddress,
)
client := newTestClient(t)
manager := NewVirtualPortManager(
config.VirtualPortRangeConfig{
First: 100,
Last: 199,
},
)
cfg := config.VirtualPortConfig{
Port: virtualPortPath(100),
RemoteDevice: "radio",
}
bridge, virtualPort, dataConn, err := connectBridge(
client,
manager,
cfg,
[]transport.RemoteDeviceInfo{device},
)
if err != nil {
t.Fatalf("connectBridge: %v", err)
}
if bridge == nil {
t.Fatal("bridge is nil")
}
if virtualPort == nil {
t.Fatal("virtual port is nil")
}
if dataConn == nil {
t.Fatal("data connection is nil")
}
defer virtualPort.Close()
defer dataConn.Close()
if bridge.serial != virtualPort {
t.Fatal("bridge serial endpoint does not match virtual port")
}
if bridge.conn != dataConn {
t.Fatal("bridge TCP endpoint does not match data connection")
}
if virtualPort.Path() != virtualPortPath(100) {
t.Fatalf(
"virtual port path = %q, want %q",
virtualPort.Path(),
virtualPortPath(100),
)
}
// The test data server must receive the TCP data connection created by
// connectBridge().
select {
case conn := <-accepted:
_ = conn.Close()
case <-t.Context().Done():
t.Fatal("test context cancelled while waiting for data connection")
}
}