aboutsummaryrefslogtreecommitdiff
path: root/hotline/client_test.go
diff options
context:
space:
mode:
authorJeff Halter <868228+jhalter@users.noreply.github.com>2026-03-16 11:46:04 -0700
committerJeff Halter <868228+jhalter@users.noreply.github.com>2026-03-16 11:46:04 -0700
commit2f46f87177234070044b5304ca3e0db471699ad8 (patch)
tree6dc72ce596e95850a587001a85b3f45e394b0e34 /hotline/client_test.go
parent92f4b7af1073b6cdd0786ce2090d6e446e0903ad (diff)
Improve test coverage for hotline and internal/mobius packages
Add comprehensive test cases across both packages to increase coverage: - hotline: 52.4% → ~55% (Disconnect, handleTransaction, SendAll, sendBanMessage, MemClientMgr, and other tests) - internal/mobius: 75.9% → ~80% (HandleUpdateUser, HandleDeleteUser, HandleSetUser, HandleNewUser, HandleUserBroadcast success paths)
Diffstat (limited to 'hotline/client_test.go')
-rw-r--r--hotline/client_test.go278
1 files changed, 278 insertions, 0 deletions
diff --git a/hotline/client_test.go b/hotline/client_test.go
new file mode 100644
index 0000000..23b6155
--- /dev/null
+++ b/hotline/client_test.go
@@ -0,0 +1,278 @@
+package hotline
+
+import (
+ "bytes"
+ "context"
+ "io"
+ "log/slog"
+ "net"
+ "os"
+ "testing"
+ "time"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func newTestClient() *Client {
+ return NewClient("testuser", slog.New(slog.NewTextHandler(os.Stdout, nil)))
+}
+
+func TestClient_Handshake(t *testing.T) {
+ t.Run("successful handshake", func(t *testing.T) {
+ clientConn, serverConn := net.Pipe()
+ defer clientConn.Close()
+ defer serverConn.Close()
+
+ c := newTestClient()
+ c.Connection = clientConn
+
+ // Server side: read the client handshake and respond
+ go func() {
+ buf := make([]byte, 12)
+ _, _ = io.ReadFull(serverConn, buf)
+ assert.Equal(t, ClientHandshake, buf)
+ _, _ = serverConn.Write(ServerHandshake)
+ }()
+
+ err := c.Handshake()
+ assert.NoError(t, err)
+ })
+
+ t.Run("server returns error response", func(t *testing.T) {
+ clientConn, serverConn := net.Pipe()
+ defer clientConn.Close()
+ defer serverConn.Close()
+
+ c := newTestClient()
+ c.Connection = clientConn
+
+ go func() {
+ buf := make([]byte, 12)
+ _, _ = io.ReadFull(serverConn, buf)
+ // Send a non-standard response
+ _, _ = serverConn.Write([]byte{0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01})
+ }()
+
+ err := c.Handshake()
+ assert.Error(t, err)
+ assert.Contains(t, err.Error(), "unexpected handshake response")
+ })
+
+ t.Run("connection closed during read", func(t *testing.T) {
+ clientConn, serverConn := net.Pipe()
+ defer clientConn.Close()
+
+ c := newTestClient()
+ c.Connection = clientConn
+
+ go func() {
+ buf := make([]byte, 12)
+ _, _ = io.ReadFull(serverConn, buf)
+ serverConn.Close()
+ }()
+
+ err := c.Handshake()
+ assert.Error(t, err)
+ assert.Contains(t, err.Error(), "handshake read err")
+ })
+}
+
+func TestClient_Send(t *testing.T) {
+ t.Run("sends non-reply transaction and tracks it", func(t *testing.T) {
+ clientConn, serverConn := net.Pipe()
+ defer clientConn.Close()
+ defer serverConn.Close()
+
+ c := newTestClient()
+ c.Connection = clientConn
+
+ tran := NewTransaction(TranChatSend, [2]byte{0, 1}, NewField(FieldData, []byte("hello")))
+
+ // Read what client sends
+ go func() {
+ buf := make([]byte, 4096)
+ _, _ = serverConn.Read(buf)
+ }()
+
+ err := c.Send(tran)
+ assert.NoError(t, err)
+
+ // Verify the transaction was added to activeTasks
+ assert.Contains(t, c.activeTasks, tran.ID)
+ })
+
+ t.Run("reply transactions are not tracked", func(t *testing.T) {
+ clientConn, serverConn := net.Pipe()
+ defer clientConn.Close()
+ defer serverConn.Close()
+
+ c := newTestClient()
+ c.Connection = clientConn
+
+ tran := NewTransaction(TranChatSend, [2]byte{0, 1})
+ tran.IsReply = 1
+
+ go func() {
+ buf := make([]byte, 4096)
+ _, _ = serverConn.Read(buf)
+ }()
+
+ err := c.Send(tran)
+ assert.NoError(t, err)
+ assert.NotContains(t, c.activeTasks, tran.ID)
+ })
+}
+
+func TestClient_HandleTransaction(t *testing.T) {
+ t.Run("dispatches to registered handler", func(t *testing.T) {
+ c := newTestClient()
+ c.Connection = &clientMockConn{WBuf: &bytes.Buffer{}}
+
+ handlerCalled := false
+ c.HandleFunc(TranChatMsg, func(ctx context.Context, client *Client, t *Transaction) ([]Transaction, error) {
+ handlerCalled = true
+ return nil, nil
+ })
+
+ tran := &Transaction{Type: TranChatMsg}
+ err := c.HandleTransaction(context.Background(), tran)
+
+ assert.NoError(t, err)
+ assert.True(t, handlerCalled)
+ })
+
+ t.Run("reply matches original request type", func(t *testing.T) {
+ c := newTestClient()
+ c.Connection = &clientMockConn{WBuf: &bytes.Buffer{}}
+
+ // Register a handler for the original type
+ var receivedType TranType
+ c.HandleFunc(TranGetUserNameList, func(ctx context.Context, client *Client, t *Transaction) ([]Transaction, error) {
+ receivedType = t.Type
+ return nil, nil
+ })
+
+ // Simulate sending a request first
+ origTran := NewTransaction(TranGetUserNameList, [2]byte{0, 1})
+ c.activeTasks[origTran.ID] = &origTran
+
+ // Create a reply with the same ID
+ reply := &Transaction{
+ ID: origTran.ID,
+ IsReply: 1,
+ }
+
+ err := c.HandleTransaction(context.Background(), reply)
+ assert.NoError(t, err)
+ assert.Equal(t, TranGetUserNameList, receivedType)
+ })
+
+ t.Run("reply with no matching request returns error", func(t *testing.T) {
+ c := newTestClient()
+ c.Connection = &clientMockConn{WBuf: &bytes.Buffer{}}
+
+ reply := &Transaction{
+ ID: [4]byte{0, 0, 0, 99},
+ IsReply: 1,
+ }
+
+ err := c.HandleTransaction(context.Background(), reply)
+ assert.Error(t, err)
+ assert.Contains(t, err.Error(), "no matching request")
+ })
+
+ t.Run("unhandled transaction type does not error", func(t *testing.T) {
+ c := newTestClient()
+ c.Connection = &clientMockConn{WBuf: &bytes.Buffer{}}
+
+ tran := &Transaction{Type: [2]byte{0xFF, 0xFF}}
+ err := c.HandleTransaction(context.Background(), tran)
+ assert.NoError(t, err)
+ })
+}
+
+func TestClient_Disconnect(t *testing.T) {
+ t.Run("closes connection and done channel", func(t *testing.T) {
+ clientConn, serverConn := net.Pipe()
+ defer serverConn.Close()
+
+ c := newTestClient()
+ c.Connection = clientConn
+ c.done = make(chan struct{})
+ doneCh := c.done // save reference before Disconnect sets it to nil
+
+ err := c.Disconnect()
+ assert.NoError(t, err)
+
+ // Verify done channel is closed
+ select {
+ case <-doneCh:
+ // expected - channel is closed
+ default:
+ t.Error("done channel should be closed")
+ }
+
+ // Verify connection is closed by attempting a write
+ _, err = clientConn.Write([]byte("test"))
+ assert.Error(t, err)
+ })
+
+ t.Run("handles nil done channel", func(t *testing.T) {
+ clientConn, serverConn := net.Pipe()
+ defer serverConn.Close()
+
+ c := newTestClient()
+ c.Connection = clientConn
+ // done is nil by default (before Connect is called)
+
+ err := c.Disconnect()
+ assert.NoError(t, err)
+ })
+}
+
+func TestClient_HandleFunc(t *testing.T) {
+ c := newTestClient()
+
+ handler := func(ctx context.Context, client *Client, t *Transaction) ([]Transaction, error) {
+ return nil, nil
+ }
+
+ c.HandleFunc(TranChatMsg, handler)
+
+ _, ok := c.Handlers[TranChatMsg]
+ require.True(t, ok, "handler should be registered")
+}
+
+func TestNewClient(t *testing.T) {
+ logger := slog.New(slog.NewTextHandler(os.Stdout, nil))
+ c := NewClient("myuser", logger)
+
+ assert.Equal(t, "myuser", c.Pref.Username)
+ assert.NotNil(t, c.Handlers)
+ assert.NotNil(t, c.activeTasks)
+}
+
+// clientMockConn implements net.Conn for testing Send without a net.Pipe.
+type clientMockConn struct {
+ RBuf *bytes.Buffer
+ WBuf *bytes.Buffer
+}
+
+func (mc *clientMockConn) Read(b []byte) (n int, err error) {
+ if mc.RBuf == nil {
+ return 0, io.EOF
+ }
+ return mc.RBuf.Read(b)
+}
+
+func (mc *clientMockConn) Write(b []byte) (n int, err error) {
+ return mc.WBuf.Write(b)
+}
+
+func (mc *clientMockConn) Close() error { return nil }
+func (mc *clientMockConn) LocalAddr() net.Addr { return nil }
+func (mc *clientMockConn) RemoteAddr() net.Addr { return nil }
+func (mc *clientMockConn) SetDeadline(_ time.Time) error { return nil }
+func (mc *clientMockConn) SetReadDeadline(_ time.Time) error { return nil }
+func (mc *clientMockConn) SetWriteDeadline(_ time.Time) error { return nil }