Mike 2 лет назад
Родитель
Сommit
e6f793e3f0

+ 3 - 23
handler/chat.go

@@ -29,15 +29,7 @@ func (s ChatService) ChannelMsgToHostHandler(ctx context.Context, sess *state.Se
 			TLVList: inBody.TLVList,
 		},
 	}
-	bodyOut.AddTLV(
-		oscar.NewTLV(oscar.ChatTLVSenderInformation, oscar.TLVUserInfo{
-			ScreenName:   sess.ScreenName(),
-			WarningLevel: sess.Warning(),
-			TLVBlock: oscar.TLVBlock{
-				TLVList: sess.UserInfo(),
-			},
-		}),
-	)
+	bodyOut.AddTLV(oscar.NewTLV(oscar.ChatTLVSenderInformation, sess.TLVUserInfo()))
 
 	_, chatSessMgr, err := s.chatRegistry.Retrieve(chatID)
 	if err != nil {
@@ -68,13 +60,7 @@ func setOnlineChatUsers(ctx context.Context, sess *state.Session, chatMessageRel
 	sessions := chatMessageRelayer.Participants()
 
 	for _, uSess := range sessions {
-		snacPayloadOut.Users = append(snacPayloadOut.Users, oscar.TLVUserInfo{
-			ScreenName:   uSess.ScreenName(),
-			WarningLevel: uSess.Warning(),
-			TLVBlock: oscar.TLVBlock{
-				TLVList: uSess.UserInfo(),
-			},
-		})
+		snacPayloadOut.Users = append(snacPayloadOut.Users, uSess.TLVUserInfo())
 	}
 
 	chatMessageRelayer.SendToScreenName(ctx, sess.ScreenName(), oscar.SNACMessage{
@@ -94,13 +80,7 @@ func alertUserJoined(ctx context.Context, sess *state.Session, chatMessageRelaye
 		},
 		Body: oscar.SNAC_0x0E_0x03_ChatUsersJoined{
 			Users: []oscar.TLVUserInfo{
-				{
-					ScreenName:   sess.ScreenName(),
-					WarningLevel: sess.Warning(),
-					TLVBlock: oscar.TLVBlock{
-						TLVList: sess.UserInfo(),
-					},
-				},
+				sess.TLVUserInfo(),
 			},
 		},
 	})

+ 4 - 24
handler/feedbag_test.go

@@ -438,12 +438,7 @@ func TestFeedbagService_InsertItemHandler(t *testing.T) {
 									SubGroup:  oscar.BuddyArrived,
 								},
 								Body: oscar.SNAC_0x03_0x0B_BuddyArrived{
-									TLVUserInfo: oscar.TLVUserInfo{
-										ScreenName: "buddy_1_online",
-										TLVBlock: oscar.TLVBlock{
-											TLVList: newTestSession("", sessOptCannedSignonTime).UserInfo(),
-										},
-									},
+									TLVUserInfo: newTestSession("buddy_1_online", sessOptCannedSignonTime).TLVUserInfo(),
 								},
 							},
 						},
@@ -455,12 +450,7 @@ func TestFeedbagService_InsertItemHandler(t *testing.T) {
 									SubGroup:  oscar.BuddyArrived,
 								},
 								Body: oscar.SNAC_0x03_0x0B_BuddyArrived{
-									TLVUserInfo: oscar.TLVUserInfo{
-										ScreenName: "buddy_2_online",
-										TLVBlock: oscar.TLVBlock{
-											TLVList: newTestSession("", sessOptCannedSignonTime).UserInfo(),
-										},
-									},
+									TLVUserInfo: newTestSession("buddy_2_online", sessOptCannedSignonTime).TLVUserInfo(),
 								},
 							},
 						},
@@ -869,12 +859,7 @@ func TestFeedbagService_UpdateItemHandler(t *testing.T) {
 									SubGroup:  oscar.BuddyArrived,
 								},
 								Body: oscar.SNAC_0x03_0x0B_BuddyArrived{
-									TLVUserInfo: oscar.TLVUserInfo{
-										ScreenName: "buddy_1_online",
-										TLVBlock: oscar.TLVBlock{
-											TLVList: newTestSession("", sessOptCannedSignonTime).UserInfo(),
-										},
-									},
+									TLVUserInfo: newTestSession("buddy_1_online", sessOptCannedSignonTime).TLVUserInfo(),
 								},
 							},
 						},
@@ -886,12 +871,7 @@ func TestFeedbagService_UpdateItemHandler(t *testing.T) {
 									SubGroup:  oscar.BuddyArrived,
 								},
 								Body: oscar.SNAC_0x03_0x0B_BuddyArrived{
-									TLVUserInfo: oscar.TLVUserInfo{
-										ScreenName: "buddy_2_online",
-										TLVBlock: oscar.TLVBlock{
-											TLVList: newTestSession("", sessOptCannedSignonTime).UserInfo(),
-										},
-									},
+									TLVUserInfo: newTestSession("buddy_2_online", sessOptCannedSignonTime).TLVUserInfo(),
 								},
 							},
 						},

+ 1 - 1
handler/icbm.go

@@ -193,7 +193,7 @@ func (s ICBMService) EvilRequestHandler(ctx context.Context, sess *state.Session
 	if inBody.SendAs == 1 {
 		increase = evilDeltaAnon
 	}
-	recipSess.IncreaseWarning(increase)
+	recipSess.IncrementWarning(increase)
 
 	var notif any
 	if inBody.SendAs == 0 {

+ 2 - 14
handler/icbm_test.go

@@ -384,13 +384,7 @@ func TestSendAndReceiveEvilRequest(t *testing.T) {
 					SubGroup:  oscar.BuddyArrived,
 				},
 				Body: oscar.SNAC_0x03_0x0B_BuddyArrived{
-					TLVUserInfo: oscar.TLVUserInfo{
-						ScreenName:   "recipient-screen-name",
-						WarningLevel: evilDeltaAnon,
-						TLVBlock: oscar.TLVBlock{
-							TLVList: newTestSession("", sessOptCannedSignonTime).UserInfo(),
-						},
-					},
+					TLVUserInfo: newTestSession("recipient-screen-name", sessOptCannedSignonTime, sessOptWarning(evilDeltaAnon)).TLVUserInfo(),
 				},
 			},
 			recipientBuddies: []string{"buddy1", "buddy2"},
@@ -436,13 +430,7 @@ func TestSendAndReceiveEvilRequest(t *testing.T) {
 					SubGroup:  oscar.BuddyArrived,
 				},
 				Body: oscar.SNAC_0x03_0x0B_BuddyArrived{
-					TLVUserInfo: oscar.TLVUserInfo{
-						ScreenName:   "recipient-screen-name",
-						WarningLevel: evilDelta,
-						TLVBlock: oscar.TLVBlock{
-							TLVList: newTestSession("", sessOptCannedSignonTime).UserInfo(),
-						},
-					},
+					TLVUserInfo: newTestSession("recipient-screen-name", sessOptCannedSignonTime, sessOptWarning(evilDelta)).TLVUserInfo(),
 				},
 			},
 			inputSNAC: oscar.SNACMessage{

+ 1 - 1
handler/oservice.go

@@ -302,7 +302,7 @@ func (s OServiceService) SetUserInfoFieldsHandler(ctx context.Context, sess *sta
 // message to all users who have this user on their buddy list.
 func (s OServiceService) IdleNotificationHandler(ctx context.Context, sess *state.Session, bodyIn oscar.SNAC_0x01_0x11_OServiceIdleNotification) error {
 	if bodyIn.IdleTime == 0 {
-		sess.SetActive()
+		sess.UnsetIdle()
 	} else {
 		sess.SetIdle(time.Duration(bodyIn.IdleTime) * time.Second)
 	}

+ 1 - 7
handler/oservice_test.go

@@ -860,13 +860,7 @@ func TestOServiceServiceForChat_ClientOnlineHandler(t *testing.T) {
 						},
 						Body: oscar.SNAC_0x0E_0x03_ChatUsersJoined{
 							Users: []oscar.TLVUserInfo{
-								{
-									ScreenName:   chatter1.ScreenName(),
-									WarningLevel: chatter1.Warning(),
-									TLVBlock: oscar.TLVBlock{
-										TLVList: chatter1.UserInfo(),
-									},
-								},
+								chatter1.TLVUserInfo(),
 							},
 						},
 					},

+ 1 - 1
handler/test_helpers.go

@@ -210,7 +210,7 @@ type broadcastExceptParams []struct {
 // sessOptWarning sets a warning level on the session object
 func sessOptWarning(level uint16) func(session *state.Session) {
 	return func(session *state.Session) {
-		session.IncreaseWarning(level)
+		session.IncrementWarning(level)
 	}
 }
 

+ 9 - 1
oscar/snacs.go

@@ -111,7 +111,15 @@ const (
 	OServiceBartQuery2        uint16 = 0x0022
 	OServiceBartReply2        uint16 = 0x0023
 
-	OServiceUserInfoStatus uint16 = 0x06
+	OServiceUserInfoUserFlags uint16 = 0x01
+	OServiceUserInfoSignonTOD uint16 = 0x03
+	OServiceUserInfoIdleTime  uint16 = 0x04
+	OServiceUserInfoStatus    uint16 = 0x06
+	OServiceUserInfoOscarCaps uint16 = 0x0D
+
+	OServiceUserFlagOSCARFree   uint16 = 0x0010 // AIM (not AOL) account
+	OServiceUserFlagUnavailable uint16 = 0x0020 // user is away
+	OServiceUserFlagInvisible   uint16 = 0x0100 // user is invisible
 
 	OServiceTLVTagsReconnectHere uint16 = 0x05
 	OServiceTLVTagsLoginCookie   uint16 = 0x06

+ 9 - 0
oscar/tlv.go

@@ -63,6 +63,15 @@ func (s TLVList) GetSlice(tType uint16) ([]byte, bool) {
 	return nil, false
 }
 
+func (s TLVList) GetUint16(tType uint16) (uint16, bool) {
+	for _, tlv := range s {
+		if tType == tlv.TType {
+			return binary.BigEndian.Uint16(tlv.Val), true
+		}
+	}
+	return 0, false
+}
+
 func (s TLVList) GetUint32(tType uint16) (uint32, bool) {
 	for _, tlv := range s {
 		if tType == tlv.TType {

+ 1 - 1
server/connection.go

@@ -127,7 +127,7 @@ func dispatchIncomingMessages(ctx context.Context, sess *state.Session, seq uint
 				logger.ErrorContext(ctx, "got unknown FLAP frame type", "flap", m.flap)
 				return
 			}
-		case m := <-sess.RecvMessage():
+		case m := <-sess.ReceiveMessage():
 			// forward a notification sent from another client to this client
 			if err := alertHandler(ctx, m, rw, &seq); err != nil {
 				logRequestError(ctx, logger, m.Frame, err)

+ 71 - 44
state/session.go

@@ -8,8 +8,10 @@ import (
 	"github.com/mkaminski/goaim/oscar"
 )
 
-var CapChat, _ = uuid.MustParse("748F2420-6287-11D1-8222-444553540000").MarshalBinary()
+// capChat is a UID that indicates a client supports the chat capability
+var capChat, _ = uuid.MustParse("748F2420-6287-11D1-8222-444553540000").MarshalBinary()
 
+// SessSendStatus is the result of sending a message to a user.
 type SessSendStatus int
 
 const (
@@ -22,6 +24,8 @@ const (
 	SessQueueFull
 )
 
+// Session represents a user's current session. Unless stated otherwise, all
+// methods may be safely accessed by multiple goroutines.
 type Session struct {
 	awayMessage string
 	closed      bool
@@ -31,92 +35,113 @@ type Session struct {
 	invisible   bool
 	msgCh       chan oscar.SNACMessage
 	mutex       sync.RWMutex
+	nowFn       func() time.Time
 	screenName  string
 	signonTime  time.Time
 	stopCh      chan struct{}
 	warning     uint16
 }
 
-func (s *Session) IncreaseWarning(incr uint16) {
+// NewSession returns a new instance of Session. By default, the user may have
+// up to 1000 pending messages before blocking.
+func NewSession() *Session {
+	return &Session{
+		msgCh:      make(chan oscar.SNACMessage, 1000),
+		nowFn:      time.Now,
+		stopCh:     make(chan struct{}),
+		signonTime: time.Now(),
+	}
+}
+
+// IncrementWarning increments the user's warning level. To decrease, pass a
+// negative increment value.
+func (s *Session) IncrementWarning(incr uint16) {
 	s.mutex.Lock()
 	defer s.mutex.Unlock()
 	s.warning += incr
 }
 
+// SetInvisible toggles the user's invisibility status.
 func (s *Session) SetInvisible(invisible bool) {
 	s.mutex.Lock()
 	defer s.mutex.Unlock()
 	s.invisible = invisible
 }
 
+// Invisible returns true if the user is idle.
+func (s *Session) Invisible() bool {
+	s.mutex.RLock()
+	defer s.mutex.RUnlock()
+	return s.invisible
+}
+
+// SetScreenName sets the user's screen name.
 func (s *Session) SetScreenName(screenName string) {
 	s.mutex.Lock()
 	defer s.mutex.Unlock()
 	s.screenName = screenName
 }
 
+// ScreenName returns the user's screen name.
 func (s *Session) ScreenName() string {
 	s.mutex.RLock()
 	defer s.mutex.RUnlock()
 	return s.screenName
 }
 
+// SetID sets the user's session ID.
 func (s *Session) SetID(ID string) {
 	s.mutex.Lock()
 	defer s.mutex.Unlock()
 	s.id = ID
 }
 
-func (s *Session) SetSignonTime(t time.Time) {
-	s.mutex.Lock()
-	defer s.mutex.Unlock()
-	s.signonTime = t
-}
-
+// ID returns the user's session ID.
 func (s *Session) ID() string {
 	s.mutex.RLock()
 	defer s.mutex.RUnlock()
 	return s.id
 }
 
-func (s *Session) Invisible() bool {
-	s.mutex.RLock()
-	defer s.mutex.RUnlock()
-	return s.invisible
+// SetSignonTime sets the user's sign-ontime.
+func (s *Session) SetSignonTime(t time.Time) {
+	s.mutex.Lock()
+	defer s.mutex.Unlock()
+	s.signonTime = t
 }
 
+// SetIdle sets the user's idle state.
 func (s *Session) SetIdle(dur time.Duration) {
 	s.mutex.Lock()
 	defer s.mutex.Unlock()
 	s.idle = true
 	// set the time the user became idle
-	s.idleTime = time.Now().Add(-dur)
+	s.idleTime = s.nowFn().Add(-dur)
 }
 
-func (s *Session) SetActive() {
+// UnsetIdle removes the user's idle state.
+func (s *Session) UnsetIdle() {
 	s.mutex.Lock()
 	defer s.mutex.Unlock()
 	s.idle = false
 }
 
-func (s *Session) Idle() bool {
-	s.mutex.RLock()
-	defer s.mutex.RUnlock()
-	return s.idle
-}
-
+// SetAwayMessage sets the user's away message.
 func (s *Session) SetAwayMessage(awayMessage string) {
 	s.mutex.Lock()
 	defer s.mutex.Unlock()
 	s.awayMessage = awayMessage
 }
 
+// AwayMessage returns the user's away message.
 func (s *Session) AwayMessage() string {
 	s.mutex.RLock()
 	defer s.mutex.RUnlock()
 	return s.awayMessage
 }
 
+// TLVUserInfo returns a TLV list containing session information required by
+// multiple SNAC message types that convey user information.
 func (s *Session) TLVUserInfo() oscar.TLVUserInfo {
 	s.mutex.RLock()
 	defer s.mutex.RUnlock()
@@ -124,50 +149,48 @@ func (s *Session) TLVUserInfo() oscar.TLVUserInfo {
 		ScreenName:   s.screenName,
 		WarningLevel: s.warning,
 		TLVBlock: oscar.TLVBlock{
-			TLVList: s.UserInfo(),
+			TLVList: s.userInfo(),
 		},
 	}
 }
 
-func (s *Session) UserInfo() oscar.TLVList {
-	s.mutex.RLock()
-	defer s.mutex.RUnlock()
-
+func (s *Session) userInfo() oscar.TLVList {
 	// sign-in timestamp
 	tlvs := oscar.TLVList{}
 
-	tlvs.AddTLV(oscar.NewTLV(0x03, uint32(s.signonTime.Unix())))
+	tlvs.AddTLV(oscar.NewTLV(oscar.OServiceUserInfoSignonTOD, uint32(s.signonTime.Unix())))
 
 	// away message status
 	if s.awayMessage != "" {
-		tlvs.AddTLV(oscar.NewTLV(0x01, uint16(0x0010)|uint16(0x0020)))
+		tlvs.AddTLV(oscar.NewTLV(oscar.OServiceUserInfoUserFlags, oscar.OServiceUserFlagOSCARFree|oscar.OServiceUserFlagUnavailable))
 	} else {
-		tlvs.AddTLV(oscar.NewTLV(0x01, uint16(0x0010)))
+		tlvs.AddTLV(oscar.NewTLV(oscar.OServiceUserInfoUserFlags, oscar.OServiceUserFlagOSCARFree))
 	}
 
 	// invisibility status
 	if s.invisible {
-		tlvs.AddTLV(oscar.NewTLV(0x06, uint16(0x0100)))
+		tlvs.AddTLV(oscar.NewTLV(oscar.OServiceUserInfoStatus, oscar.OServiceUserFlagInvisible))
 	} else {
-		tlvs.AddTLV(oscar.NewTLV(0x06, uint16(0x0000)))
+		tlvs.AddTLV(oscar.NewTLV(oscar.OServiceUserInfoStatus, uint16(0x0000)))
 	}
 
 	// idle status
 	if s.idle {
-		tlvs.AddTLV(oscar.NewTLV(0x04, uint16(time.Now().Sub(s.idleTime).Seconds())))
+		tlvs.AddTLV(oscar.NewTLV(oscar.OServiceUserInfoIdleTime, uint16(s.nowFn().Sub(s.idleTime).Seconds())))
 	} else {
-		tlvs.AddTLV(oscar.NewTLV(0x04, uint16(0)))
+		tlvs.AddTLV(oscar.NewTLV(oscar.OServiceUserInfoIdleTime, uint16(0)))
 	}
 
 	// capabilities
 	var caps []byte
 	// chat capability
-	caps = append(caps, CapChat...)
-	tlvs.AddTLV(oscar.NewTLV(0x0D, caps))
+	caps = append(caps, capChat...)
+	tlvs.AddTLV(oscar.NewTLV(oscar.OServiceUserInfoOscarCaps, caps))
 
 	return tlvs
 }
 
+// Warning returns the user's warning level.
 func (s *Session) Warning() uint16 {
 	s.mutex.RLock()
 	defer s.mutex.RUnlock()
@@ -176,11 +199,18 @@ func (s *Session) Warning() uint16 {
 	return w
 }
 
-func (s *Session) RecvMessage() chan oscar.SNACMessage {
+// ReceiveMessage returns a channel of messages relayed via this session. It
+// may only be read by one consumer. The channel never closes; call this method
+// in a select block along with Closed in order to detect session closure.
+func (s *Session) ReceiveMessage() chan oscar.SNACMessage {
 	return s.msgCh
 }
 
-func (s *Session) SendMessage(msg oscar.SNACMessage) SessSendStatus {
+// RelayMessage receives a SNAC message from a user and passes it on
+// asynchronously to the consumer of this session's messages. It returns
+// SessSendStatus to indicate whether the message was successfully sent or
+// not. This method is non-blocking.
+func (s *Session) RelayMessage(msg oscar.SNACMessage) SessSendStatus {
 	s.mutex.RLock()
 	defer s.mutex.RUnlock()
 	if s.closed {
@@ -196,6 +226,10 @@ func (s *Session) SendMessage(msg oscar.SNACMessage) SessSendStatus {
 	}
 }
 
+// Close shuts down the session's ability to relay messages. Once invoked,
+// RelayMessage returns SessQueueFull and Closed returns a closed channel.
+// It is not possible to re-open message relaying once closed. It is safe to
+// call from multiple go routines.
 func (s *Session) Close() {
 	s.mutex.Lock()
 	defer s.mutex.Unlock()
@@ -206,14 +240,7 @@ func (s *Session) Close() {
 	s.closed = true
 }
 
+// Closed blocks until the session is closed.
 func (s *Session) Closed() <-chan struct{} {
 	return s.stopCh
 }
-
-func NewSession() *Session {
-	return &Session{
-		msgCh:      make(chan oscar.SNACMessage, 1000),
-		stopCh:     make(chan struct{}),
-		signonTime: time.Now(),
-	}
-}

+ 1 - 1
state/session_manager.go

@@ -30,7 +30,7 @@ func (s *InMemorySessionManager) Broadcast(ctx context.Context, msg oscar.SNACMe
 }
 
 func (s *InMemorySessionManager) maybeSendMessage(ctx context.Context, msg oscar.SNACMessage, sess *Session) {
-	switch sess.SendMessage(msg) {
+	switch sess.RelayMessage(msg) {
 	case SessSendClosed:
 		s.logger.WarnContext(ctx, "can't send notification because the user's session is closed", "recipient", sess.ScreenName(), "message", msg)
 	case SessQueueFull:

+ 302 - 9
state/session_test.go

@@ -1,6 +1,7 @@
 package state
 
 import (
+	"sync"
 	"testing"
 	"time"
 
@@ -8,14 +9,294 @@ import (
 	"github.com/stretchr/testify/assert"
 )
 
-func TestSession_SendMessage_SessSendOK(t *testing.T) {
-	s := Session{
-		msgCh:  make(chan oscar.SNACMessage, 1),
-		stopCh: make(chan struct{}),
+func TestSession_SetAndGetAwayMessage(t *testing.T) {
+	s := NewSession()
+	assert.Empty(t, s.AwayMessage())
+
+	msg := "here's my message"
+	s.SetAwayMessage(msg)
+	assert.Equal(t, msg, s.AwayMessage())
+}
+
+func TestSession_SetAndGetID(t *testing.T) {
+	s := NewSession()
+	// make sure NewSession creates a default ID
+	assert.NotEmpty(t, s.SetID)
+	newID := "new-id"
+	s.SetID(newID)
+	assert.Equal(t, newID, s.ID())
+}
+
+func TestSession_IncrementAndGetWarning(t *testing.T) {
+	s := NewSession()
+	assert.Zero(t, s.Warning())
+	s.IncrementWarning(1)
+	s.IncrementWarning(2)
+	assert.Equal(t, uint16(3), s.Warning())
+}
+
+func TestSession_SetAndGetInvisible(t *testing.T) {
+	s := NewSession()
+	assert.False(t, s.Invisible())
+	s.SetInvisible(true)
+	assert.True(t, s.Invisible())
+	s.SetInvisible(false)
+	assert.False(t, s.Invisible())
+}
+
+func TestSession_SetAndGetScreenName(t *testing.T) {
+	s := NewSession()
+	assert.Empty(t, s.ScreenName())
+	sn := "user-screen-name"
+	s.SetScreenName(sn)
+	assert.Equal(t, sn, s.ScreenName())
+}
+
+func TestSession_SendMessage(t *testing.T) {
+	type fields struct {
+		awayMessage string
+		closed      bool
+		id          string
+		idle        bool
+		idleTime    time.Time
+		invisible   bool
+		msgCh       chan oscar.SNACMessage
+		mutex       sync.RWMutex
+		screenName  string
+		signonTime  time.Time
+		stopCh      chan struct{}
+		warning     uint16
+	}
+	type args struct {
+		msg oscar.SNACMessage
+	}
+	tests := []struct {
+		name   string
+		fields fields
+		args   args
+		want   SessSendStatus
+	}{
+		// TODO: Add test cases.
+	}
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			s := &Session{
+				awayMessage: tt.fields.awayMessage,
+				closed:      tt.fields.closed,
+				id:          tt.fields.id,
+				idle:        tt.fields.idle,
+				idleTime:    tt.fields.idleTime,
+				invisible:   tt.fields.invisible,
+				msgCh:       tt.fields.msgCh,
+				mutex:       tt.fields.mutex,
+				screenName:  tt.fields.screenName,
+				signonTime:  tt.fields.signonTime,
+				stopCh:      tt.fields.stopCh,
+				warning:     tt.fields.warning,
+			}
+			assert.Equalf(t, tt.want, s.RelayMessage(tt.args.msg), "RelayMessage(%v)", tt.args.msg)
+		})
+	}
+}
+
+func TestSession_SetAwayMessage(t *testing.T) {
+	type fields struct {
+		awayMessage string
+		closed      bool
+		id          string
+		idle        bool
+		idleTime    time.Time
+		invisible   bool
+		msgCh       chan oscar.SNACMessage
+		mutex       sync.RWMutex
+		screenName  string
+		signonTime  time.Time
+		stopCh      chan struct{}
+		warning     uint16
 	}
-	if res := s.SendMessage(oscar.SNACMessage{}); res != SessSendOK {
-		t.Fatalf("expected SessSendOK, got %+v", res)
+	type args struct {
+		awayMessage string
 	}
+	tests := []struct {
+		name   string
+		fields fields
+		args   args
+	}{
+		// TODO: Add test cases.
+	}
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			s := &Session{
+				awayMessage: tt.fields.awayMessage,
+				closed:      tt.fields.closed,
+				id:          tt.fields.id,
+				idle:        tt.fields.idle,
+				idleTime:    tt.fields.idleTime,
+				invisible:   tt.fields.invisible,
+				msgCh:       tt.fields.msgCh,
+				mutex:       tt.fields.mutex,
+				screenName:  tt.fields.screenName,
+				signonTime:  tt.fields.signonTime,
+				stopCh:      tt.fields.stopCh,
+				warning:     tt.fields.warning,
+			}
+			s.SetAwayMessage(tt.args.awayMessage)
+		})
+	}
+}
+
+func TestSession_TLVUserInfo(t *testing.T) {
+	tests := []struct {
+		name           string
+		givenSessionFn func() *Session
+		want           oscar.TLVUserInfo
+	}{
+		{
+			name: "user is active and visible",
+			givenSessionFn: func() *Session {
+				s := NewSession()
+				s.SetSignonTime(time.Unix(1, 0))
+				s.SetScreenName("xXAIMUSERXx")
+				s.IncrementWarning(10)
+				return s
+			},
+			want: oscar.TLVUserInfo{
+				ScreenName:   "xXAIMUSERXx",
+				WarningLevel: 10,
+				TLVBlock: oscar.TLVBlock{
+					TLVList: oscar.TLVList{
+						oscar.NewTLV(0x03, uint32(1)),
+						oscar.NewTLV(0x01, uint16(0x0010)),
+						oscar.NewTLV(0x06, uint16(0x0000)),
+						oscar.NewTLV(0x04, uint16(0)),
+						oscar.NewTLV(0x0D, capChat),
+					},
+				},
+			},
+		},
+		{
+			name: "user has away message set",
+			givenSessionFn: func() *Session {
+				s := NewSession()
+				s.SetSignonTime(time.Unix(1, 0))
+				s.SetAwayMessage("here's my away essage")
+				return s
+			},
+			want: oscar.TLVUserInfo{
+				TLVBlock: oscar.TLVBlock{
+					TLVList: oscar.TLVList{
+						oscar.NewTLV(0x03, uint32(1)),
+						oscar.NewTLV(0x01, uint16(0x30)),
+						oscar.NewTLV(0x06, uint16(0x0000)),
+						oscar.NewTLV(0x04, uint16(0)),
+						oscar.NewTLV(0x0D, capChat),
+					},
+				},
+			},
+		},
+		{
+			name: "user is invisible",
+			givenSessionFn: func() *Session {
+				s := NewSession()
+				s.SetSignonTime(time.Unix(1, 0))
+				s.SetInvisible(true)
+				return s
+			},
+			want: oscar.TLVUserInfo{
+				TLVBlock: oscar.TLVBlock{
+					TLVList: oscar.TLVList{
+						oscar.NewTLV(0x03, uint32(1)),
+						oscar.NewTLV(0x01, uint16(0x0010)),
+						oscar.NewTLV(0x06, uint16(0x0100)),
+						oscar.NewTLV(0x04, uint16(0)),
+						oscar.NewTLV(0x0D, capChat),
+					},
+				},
+			},
+		},
+		{
+			name: "user is idle",
+			givenSessionFn: func() *Session {
+				s := NewSession()
+				s.SetSignonTime(time.Unix(1, 0))
+				// now() returns T=1000 when SetIdle() is called
+				s.nowFn = func() time.Time { return time.Unix(1000, 0) }
+				s.SetIdle(1 * time.Second)
+				// now() returns T=2000 when TLVUserInfo() is called
+				s.nowFn = func() time.Time { return time.Unix(2000, 0) }
+				return s
+			},
+			want: oscar.TLVUserInfo{
+				TLVBlock: oscar.TLVBlock{
+					TLVList: oscar.TLVList{
+						oscar.NewTLV(0x03, uint32(1)),
+						oscar.NewTLV(0x01, uint16(0x0010)),
+						oscar.NewTLV(0x06, uint16(0x0000)),
+						oscar.NewTLV(0x04, uint16(1001)),
+						oscar.NewTLV(0x0D, capChat),
+					},
+				},
+			},
+		},
+		{
+			name: "user goes idle then returns",
+			givenSessionFn: func() *Session {
+				s := NewSession()
+				s.SetSignonTime(time.Unix(1, 0))
+				s.SetIdle(1 * time.Second)
+				s.UnsetIdle()
+				return s
+			},
+			want: oscar.TLVUserInfo{
+				TLVBlock: oscar.TLVBlock{
+					TLVList: oscar.TLVList{
+						oscar.NewTLV(0x03, uint32(1)),
+						oscar.NewTLV(0x01, uint16(0x0010)),
+						oscar.NewTLV(0x06, uint16(0x0000)),
+						oscar.NewTLV(0x04, uint16(0)),
+						oscar.NewTLV(0x0D, capChat),
+					},
+				},
+			},
+		},
+	}
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			s := tt.givenSessionFn()
+			assert.Equal(t, tt.want, s.TLVUserInfo())
+		})
+	}
+}
+
+func TestSession_SendAndRecvMessage_ExpectSessSendOK(t *testing.T) {
+	s := NewSession()
+
+	msg := oscar.SNACMessage{
+		Frame: oscar.SNACFrame{
+			FoodGroup: oscar.ICBM,
+		},
+	}
+
+	var wg sync.WaitGroup
+	wg.Add(1)
+	go func() {
+		defer wg.Done()
+		defer s.Close()
+		status := s.RelayMessage(msg)
+		assert.Equal(t, SessSendOK, status)
+	}()
+
+loop:
+	for {
+		select {
+		case m := <-s.ReceiveMessage():
+			assert.Equal(t, msg, m)
+		case <-s.Closed():
+			break loop
+		}
+	}
+
+	wg.Wait()
 }
 
 func TestSession_SendMessage_SessSendClosed(t *testing.T) {
@@ -24,7 +305,7 @@ func TestSession_SendMessage_SessSendClosed(t *testing.T) {
 		stopCh: make(chan struct{}),
 	}
 	s.Close()
-	if res := s.SendMessage(oscar.SNACMessage{}); res != SessSendClosed {
+	if res := s.RelayMessage(oscar.SNACMessage{}); res != SessSendClosed {
 		t.Fatalf("expected SessSendClosed, got %+v", res)
 	}
 }
@@ -36,9 +317,9 @@ func TestSession_SendMessage_SessQueueFull(t *testing.T) {
 		stopCh: make(chan struct{}),
 	}
 	for i := 0; i < bufSize; i++ {
-		assert.Equal(t, SessSendOK, s.SendMessage(oscar.SNACMessage{}))
+		assert.Equal(t, SessSendOK, s.RelayMessage(oscar.SNACMessage{}))
 	}
-	assert.Equal(t, SessQueueFull, s.SendMessage(oscar.SNACMessage{}))
+	assert.Equal(t, SessQueueFull, s.RelayMessage(oscar.SNACMessage{}))
 }
 
 func TestSession_Close_Twice(t *testing.T) {
@@ -56,3 +337,15 @@ func TestSession_Close_Twice(t *testing.T) {
 		t.Fatalf("channel is not closed")
 	}
 }
+
+func TestSession_Close(t *testing.T) {
+	s := NewSession()
+	select {
+	case <-s.Closed():
+		assert.Fail(t, "channel is closed")
+	default:
+		// channel is open by default
+	}
+	s.Close()
+	<-s.Closed()
+}