diff options
Diffstat (limited to 'hotline/client_test.go')
| -rw-r--r-- | hotline/client_test.go | 278 |
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 } |