Selaa lähdekoodia

issue #13 - support icq 2000b

Mike 1 vuosi sitten
vanhempi
commit
3d26ddbe5c

+ 13 - 1
.mockery.yaml

@@ -72,6 +72,9 @@ packages:
       AdminService:
         config:
           filename: "mock_admin_test.go"
+      ICQService:
+        config:
+          filename: "mock_icq_service_test.go"
   github.com/mk6i/retro-aim-server/foodgroup:
     interfaces:
       FeedbagManager:
@@ -112,4 +115,13 @@ packages:
           filename: "mock_buddy_broadcaster_test.go"
       AccountManager:
         config:
-          filename: "mock_account_manager_test.go"
+          filename: "mock_account_manager_test.go"
+      ICQUserFinder:
+        config:
+          filename: "mock_icq_user_finder_test.go"
+      ICQUserUpdater:
+        config:
+          filename: "mock_icq_user_updater_test.go"
+      SessionRetriever:
+        config:
+          filename: "mock_session_retriever_test.go"

+ 8 - 0
README.md

@@ -122,10 +122,18 @@ curl http://localhost:8080/user
 
 #### Create Users
 
+##### AIM
+
 ```shell
 curl -d'{"screen_name":"MyScreenName", "password":"thepassword"}' http://localhost:8080/user
 ```
 
+##### ICQ
+
+```shell
+curl -d'{"screen_name":"100003", "password":"thepassw", "is_icq": true}' http://localhost:8080/user
+```
+
 #### Delete Users
 
 ```shell

+ 31 - 16
api.yml

@@ -23,30 +23,39 @@ paths:
                       description: User's unique identifier.
                     screen_name:
                       type: string
-                      description: User's screen name.
+                      description: User's AIM screen name or ICQ UIN.
+                    is_icq:
+                      type: boolean
+                      description: If true, indicates an ICQ user instead of an AIM user.
     post:
       summary: Create a new user
-      description: Create a new user account with a screen name and password.
+      description: Create a new AIM or ICQ user account.
       requestBody:
         required: true
         content:
           application/json:
             schema:
               type: object
+              required:
+                - screen_name
+                - password
               properties:
                 screen_name:
                   type: string
-                  description: The user's screen name.
+                  description: The user's AIM screen name or ICQ UIN.
                 password:
                   type: string
                   description: The user's password for account creation.
+                is_icq:
+                  type: boolean
+                  description: If true, indicates an ICQ user instead of an AIM user.
       responses:
         '201':
           description: User account created successfully.
         '400':
           description: Bad request. Invalid input data.
         '409':
-          description: Conflict. A user with the specified screen name already exists.
+          description: Conflict. A user with the specified screen name or ICQ UIN already exists.
     delete:
       summary: Delete a user
       description: Delete a user account specified by their screen name.
@@ -91,22 +100,28 @@ paths:
                           description: User's unique identifier.
                         screen_name:
                           type: string
-                          description: User's screen name.
+                          description: User's AIM screen name or ICQ UIN.
+                        is_icq:
+                          type: boolean
+                          description: If true, indicates an ICQ user instead of an AIM user.
 
   /user/password:
     put:
       summary: Set a user's password
-      description: Update the password for a user specified by their screen name.
+      description: Update the password for a user specified by their screen name or ICQ UIN.
       requestBody:
         required: true
         content:
           application/json:
             schema:
               type: object
+              required:
+                - screen_name
+                - password
               properties:
                 screen_name:
                   type: string
-                  description: The screen name of the user whose password is to be updated.
+                  description: The AIM screen name or ICQ UIN of the user whose password is to be updated.
                 password:
                   type: string
                   description: The new password for the user.
@@ -120,8 +135,8 @@ paths:
 
   /chat/room/public:
     get:
-      summary: List all public chat rooms
-      description: Retrieve a list of all public chat rooms in exchange 5.
+      summary: List all public AIM chat rooms
+      description: Retrieve a list of all public AIM chat rooms in exchange 5.
       responses:
         '200':
           description: Successful response containing a list of chat rooms.
@@ -150,7 +165,7 @@ paths:
                             description: User's unique identifier.
                           screen_name:
                             type: string
-                            description: User's screen name.
+                            description: User's AIM screen name.
 
     post:
       summary: Create a new public chat room
@@ -175,8 +190,8 @@ paths:
 
   /chat/room/private:
     get:
-      summary: List all private chat rooms
-      description: Retrieve a list of all private chat rooms in exchange 4.
+      summary: List all private AIM chat rooms
+      description: Retrieve a list of all private AIM chat rooms in exchange 4.
       responses:
         '200':
           description: Successful response containing a list of chat rooms.
@@ -208,7 +223,7 @@ paths:
                             description: User's unique identifier.
                           screen_name:
                             type: string
-                            description: User's screen name.
+                            description: User's AIM screen name.
 
   /instant-message:
     post:
@@ -223,10 +238,10 @@ paths:
               properties:
                 from:
                   type: string
-                  description: The screen name of the sender.
+                  description: The AIM screen name or ICQ UIN of the sender.
                 to:
                   type: string
-                  description: The screen name of the recipient.
+                  description: The AIM screen name or ICQ UIN of the recipient.
                 text:
                   type: string
                   description: The text content of the message.
@@ -234,4 +249,4 @@ paths:
         '200':
           description: Message sent successfully.
         '400':
-          description: Bad request. Invalid input data.
+          description: Bad request. Invalid input data.

+ 2 - 0
cmd/server/main.go

@@ -59,6 +59,7 @@ func main() {
 		feedbagService := foodgroup.NewFeedbagService(logger, sessionManager, feedbagStore, feedbagStore, adjListBuddyListStore)
 		foodgroupService := foodgroup.NewPermitDenyService()
 		icbmService := foodgroup.NewICBMService(sessionManager, feedbagStore, adjListBuddyListStore)
+		icqService := foodgroup.NewICQService(sessionManager, feedbagStore, feedbagStore, logger, sessionManager)
 		locateService := foodgroup.NewLocateService(sessionManager, feedbagStore, feedbagStore, adjListBuddyListStore)
 		oServiceService := foodgroup.NewOServiceServiceForBOS(cfg, sessionManager, adjListBuddyListStore, logger, cookieBaker, feedbagStore, feedbagStore)
 
@@ -71,6 +72,7 @@ func main() {
 				BuddyHandler:      handler.NewBuddyHandler(logger, buddyService),
 				ChatNavHandler:    handler.NewChatNavHandler(chatNavService, logger),
 				FeedbagHandler:    handler.NewFeedbagHandler(logger, feedbagService),
+				ICQHandler:        handler.NewICQHandler(logger, icqService),
 				ICBMHandler:       handler.NewICBMHandler(logger, icbmService),
 				LocateHandler:     handler.NewLocateHandler(locateService, logger),
 				OServiceHandler:   handler.NewOServiceHandler(logger, oServiceService),

+ 183 - 0
docs/CLIENT_ICQ.md

@@ -0,0 +1,183 @@
+# ICQ Client Setup
+
+This guide explains how to install and configure **ICQ 2000b** for Retro AIM Server.
+
+> ICQ 2000b is the only version of ICQ currently supported by RAS.
+
+## Installation
+
+### Windows
+
+1. **Download ICQ**
+
+   Download ICQ 2000b from [archive.org](https://archive.org/details/icq2000b_202206).
+
+2. **Run Installer**
+
+   Install ICQ as you would normally install any application. For Windows 10/11,
+   set [Windows XP compatibility mode](https://support.microsoft.com/en-us/windows/make-older-apps-or-programs-compatible-with-windows-783d6dd7-b439-bdb0-0490-54eea0f45938)
+   on the executable.
+
+3. **Close the Registration Window**
+
+   Once installation is complete, you'll be greeted by an ICQ registration window.
+   *Do not complete the registration wizard.* Close the window and move on to
+   the [post-installation steps](#post-install-configuration).
+
+    <p align="center">
+       <img width="400" alt="screenshot of ICQ registration window" src="https://github.com/user-attachments/assets/b5684b93-02b0-4314-adfa-16ea9826cf69">
+    </p>
+
+### Linux
+
+You can ICQ 2000b under Linux via [WINE](https://www.winehq.org/).
+
+1. **Download ICQ**
+
+   Download ICQ 2000b from [archive.org](https://archive.org/details/icq2000b_202206).
+
+2. **Install WINE**
+
+   Run and install [WINE](https://wiki.winehq.org/Download)
+
+3. **Run the Installer**
+
+   Start the ICQ installer under WINE from a terminal:
+   ```shell
+   wine icq2000b.exe
+   ```
+
+4. **Close the Registration Window**
+
+   Once installation is complete, you'll be greeted by an ICQ registration window.
+   *Do not complete the registration wizard.* Close the window and move on to
+   the [post-installation steps](#post-install-configuration).
+
+    <p align="center">
+       <img width="400" alt="screenshot of ICQ registration window" src="https://github.com/user-attachments/assets/b5684b93-02b0-4314-adfa-16ea9826cf69">
+    </p>
+
+### macOS (Intel & Apple Silicon)
+
+Windows ICQ 2000b can run on modern macOS (including the Apple Silicon platform) without a VM
+using [WineskinServer](https://github.com/Gcenx/WineskinServer), a wrapper for WINE.
+
+1. **Install WineskinServer**
+
+   Install WineskinServer via homebrew:
+
+   ```shell
+   brew install --cask --no-quarantine gcenx/wine/wineskin
+   ```
+
+2. **Create a Blank Application Wrapper**
+
+   Launch `Wineskin Winery`. Install the latest engine and create a new blank
+   wrapper for installing ICQ.
+
+   Generating the wrapper might take 1-2 minutes, and the application might not
+3. respond during this time. Once complete, click `View wrapper in Finder`.
+
+   <p align="center">
+      <img width="325" alt="screenshot of wineskin server" src="https://github.com/user-attachments/assets/8d2ed477-f41c-4f00-90c2-d24c468b4aae">
+   </p>
+
+3. **Install ICQ into the application wrapper**
+
+   Launch the wrapper from the Finder window. Select `Install Software`.
+
+   <p align="center">
+      <img width="325" alt="screenshot of wineskin server" src="https://github.com/user-attachments/assets/a2c0882c-230f-4034-9670-6f309ec1f628">
+   </p>
+
+   Select `Choose Setup Executable` and open the ICQ installer executable.
+
+   <p align="center">
+      <img width="325" alt="screenshot of wineskin server" src="https://github.com/user-attachments/assets/cf6aa78b-6bc0-4cfe-b750-bf2c763acdfb">
+   </p>
+
+4. **Run the Installer**
+
+   Complete the ICQ installation wizard.
+
+5. **Close the Registration Window**
+
+   Once installation is complete, you'll be greeted by an ICQ registration window.
+   *Do not complete the registration wizard.* Close the window and move on to
+   the [post-installation steps](#post-install-configuration).
+
+    <p align="center">
+       <img width="400" alt="screenshot of ICQ registration window" src="https://github.com/user-attachments/assets/b5684b93-02b0-4314-adfa-16ea9826cf69">
+    </p>
+
+## Post-install Configuration
+
+Configure the default OSCAR hostname in the registry so that ICQ can connect to
+your server. This needs to be done before the first launch of the ICQ.
+
+> Do not attempt to set the ICQ hostname via the registration Window. If you do
+> this, a bug will surface that forever prevents the client from "remembering"
+>  settings such as saved passwords and OSCAR hostname.  
+
+1. **Open Registry Editor**
+
+    - Windows
+        - Click `Start > Run`.
+        - Enter `regedit` and click `OK`.
+    - Wine (Linux)
+        - Open a terminal.
+        - Run `wine regedit` in a terminal.
+    - WineskinServer (macOS)
+        - Open a terminal.
+        - Run the following command, substituting `icq2000b.app` with the file name of your wrapper:
+          ```shell
+          ~/Applications/Wineskin/icq2000b.app/Contents/Wineskin.app/Contents/Resources/regedit
+          ```
+      
+2. **Open Default ICQ Settings**
+
+   Navigate to `HKEY_CURRENT_USER\Software\Mirabilis\ICQ\DefaultPrefs`.
+   <p align="center">
+      <img width="500" alt="screenshot of regedit" src="https://github.com/user-attachments/assets/02b20e3a-769c-4c69-bbf5-395684d8f30f">
+   </p>
+
+3. **Configure OSCAR Host**
+
+   Set the `Default Server Host` registry entry to the value of `OSCAR_HOST`
+   found in `config/settings`.
+   <p align="center">
+      <img width="325" alt="screenshot editing Default Server Host in regedit" src="https://github.com/user-attachments/assets/ebcf66fa-1841-41f7-986a-90b24dd0a94d">
+   </p>
+
+4. **Configure Server Port**
+
+   Set `Default Server Port` to the value of `AUTH_PORT` found in `config/settings`. Make sure to tick the `Decimal`
+   radio button.
+   <p align="center">
+      <img width="325" alt="screenshot editing Default Server Port in regedit" src="https://github.com/user-attachments/assets/11a3efff-40f1-4f1d-b88a-9c78fddb9c3d">
+   </p>
+   
+5. **Exit Registry Editor**
+
+   Client configuration is complete. Close the Registry Editor.   
+
+## First Time Login
+
+Now that ICQ is installed and configured, all that remains is to log in.
+
+Start ICQ and complete the first-time registration wizard. Start by selecting `Existing User`.
+
+> Do not try to create a new user in the registration wizard. To create a new user in Retro AIM Server, follow account creation steps in the [server quickstart guides](https://github.com/mk6i/retro-aim-server?tab=readme-ov-file#-how-to-run).
+   
+   <p align="center">
+      <img width="400" alt="screenshot of ICQ registration wizard" src="https://github.com/user-attachments/assets/93bd3fc9-96a6-45ff-af90-e96b3f938dc3">
+   </p>
+
+
+Enter the ICQ user credentials and finish out the rest of the wizard.
+
+<p align="center">
+   <img width="400" alt="screenshot of ICQ registration wizard" src="https://github.com/user-attachments/assets/7520db7c-0512-42d1-88f3-e3f8f9d5eaec">
+</p>
+
+You should now be able to successfully connect ICQ 2000b to Retro AIM Server.

+ 702 - 0
foodgroup/icq.go

@@ -0,0 +1,702 @@
+package foodgroup
+
+import (
+	"context"
+	"errors"
+	"fmt"
+	"log/slog"
+	"strconv"
+	"strings"
+	"time"
+
+	"github.com/mk6i/retro-aim-server/state"
+	"github.com/mk6i/retro-aim-server/wire"
+)
+
+var errICQBadRequest = errors.New("bad ICQ request")
+
+// NewICQService creates an instance of ICQService.
+func NewICQService(
+	messageRelayer MessageRelayer,
+	finder ICQUserFinder,
+	userUpdater ICQUserUpdater,
+	logger *slog.Logger,
+	sessionRetriever SessionRetriever,
+) ICQService {
+	return ICQService{
+		messageRelayer:   messageRelayer,
+		userFinder:       finder,
+		userUpdater:      userUpdater,
+		logger:           logger,
+		sessionRetriever: sessionRetriever,
+		timeNow:          time.Now,
+	}
+}
+
+// ICQService provides functionality for the ICQ food group.
+type ICQService struct {
+	userFinder       ICQUserFinder
+	logger           *slog.Logger
+	messageRelayer   MessageRelayer
+	sessionRetriever SessionRetriever
+	userUpdater      ICQUserUpdater
+	timeNow          func() time.Time
+}
+
+func (s ICQService) FindByDetails(ctx context.Context, sess *state.Session, req wire.ICQ_0x07D0_0x0515_DBQueryMetaReqSearchByDetails, seq uint16) error {
+	resp := wire.ICQ_0x07DA_0x01AE_DBQueryMetaReplyLastUserFound{
+		ICQMetadata: wire.ICQMetadata{
+			UIN:     sess.UIN(),
+			ReqType: wire.ICQDBQueryMetaReply,
+			Seq:     seq,
+		},
+		Success:    wire.ICQStatusCodeOK,
+		ReqSubType: wire.ICQDBQueryMetaReplyLastUserFound,
+	}
+
+	res, err := s.userFinder.FindByDetails(req.FirstName, req.LastName, req.NickName)
+
+	if err != nil {
+		s.logger.Error("FindByDetails failed", "err", err.Error())
+		resp.Success = wire.ICQStatusCodeErr
+		return s.reply(ctx, sess, wire.ICQMessageReplyEnvelope{
+			Message: resp,
+		})
+	}
+	if len(res) == 0 {
+		resp.Success = wire.ICQStatusCodeFail
+		return s.reply(ctx, sess, wire.ICQMessageReplyEnvelope{
+			Message: resp,
+		})
+	}
+
+	for i := 0; i < len(res); i++ {
+		if i == len(res)-1 {
+			resp.LastResult()
+		} else {
+			resp.ReqSubType = wire.ICQDBQueryMetaReplyUserFound
+		}
+		resp.Details = s.createResult(res[i])
+		if err := s.reply(ctx, sess, wire.ICQMessageReplyEnvelope{
+			Message: resp,
+		}); err != nil {
+			return err
+		}
+	}
+
+	return nil
+}
+
+func (s ICQService) FindByEmail(ctx context.Context, sess *state.Session, req wire.ICQ_0x07D0_0x0529_DBQueryMetaReqSearchByEmail, seq uint16) error {
+	resp := wire.ICQ_0x07DA_0x01AE_DBQueryMetaReplyLastUserFound{
+		ICQMetadata: wire.ICQMetadata{
+			UIN:     sess.UIN(),
+			ReqType: wire.ICQDBQueryMetaReply,
+			Seq:     seq,
+		},
+		ReqSubType: wire.ICQDBQueryMetaReplyLastUserFound,
+		Success:    wire.ICQStatusCodeOK,
+	}
+	resp.LastResult()
+
+	res, err := s.userFinder.FindByEmail(req.Email)
+
+	switch {
+	case errors.Is(err, state.ErrNoUser):
+		resp.Success = wire.ICQStatusCodeFail
+	case err != nil:
+		s.logger.Error("FindByEmail failed", "err", err.Error())
+		resp.Success = wire.ICQStatusCodeErr
+	default:
+		resp.Success = wire.ICQStatusCodeOK
+		resp.Details = s.createResult(res)
+	}
+
+	return s.reply(ctx, sess, wire.ICQMessageReplyEnvelope{
+		Message: resp,
+	})
+}
+
+func (s ICQService) FindByInterests(ctx context.Context, sess *state.Session, req wire.ICQ_0x07D0_0x0533_DBQueryMetaReqSearchWhitePages, seq uint16) error {
+	resp := wire.ICQ_0x07DA_0x01AE_DBQueryMetaReplyLastUserFound{
+		ICQMetadata: wire.ICQMetadata{
+			UIN:     sess.UIN(),
+			ReqType: wire.ICQDBQueryMetaReply,
+			Seq:     seq,
+		},
+		Success:    wire.ICQStatusCodeOK,
+		ReqSubType: wire.ICQDBQueryMetaReplyLastUserFound,
+	}
+
+	interests := strings.Split(req.InterestsKeyword, ",")
+	res, err := s.userFinder.FindByInterests(req.InterestsCode, interests)
+
+	if err != nil {
+		s.logger.Error("FindByInterests failed", "err", err.Error())
+		resp.Success = wire.ICQStatusCodeErr
+		return s.reply(ctx, sess, wire.ICQMessageReplyEnvelope{
+			Message: resp,
+		})
+	}
+	if len(res) == 0 {
+		resp.Success = wire.ICQStatusCodeFail
+		return s.reply(ctx, sess, wire.ICQMessageReplyEnvelope{
+			Message: resp,
+		})
+	}
+
+	for i := 0; i < len(res); i++ {
+		if i == len(res)-1 {
+			resp.LastResult()
+		} else {
+			resp.ReqSubType = wire.ICQDBQueryMetaReplyUserFound
+		}
+		resp.Details = s.createResult(res[i])
+		if err := s.reply(ctx, sess, wire.ICQMessageReplyEnvelope{
+			Message: resp,
+		}); err != nil {
+			return err
+		}
+	}
+
+	return nil
+}
+
+func (s ICQService) FindByUIN(ctx context.Context, sess *state.Session, req wire.ICQ_0x07D0_0x051F_DBQueryMetaReqSearchByUIN, seq uint16) error {
+	resp := wire.ICQ_0x07DA_0x01AE_DBQueryMetaReplyLastUserFound{
+		ICQMetadata: wire.ICQMetadata{
+			UIN:     sess.UIN(),
+			ReqType: wire.ICQDBQueryMetaReply,
+			Seq:     seq,
+		},
+		ReqSubType: wire.ICQDBQueryMetaReplyLastUserFound,
+		Success:    wire.ICQStatusCodeOK,
+	}
+	resp.LastResult()
+
+	res, err := s.userFinder.FindByUIN(req.UIN)
+
+	switch {
+	case errors.Is(err, state.ErrNoUser):
+		resp.Success = wire.ICQStatusCodeFail
+	case err != nil:
+		s.logger.Error("FindByUIN failed", "err", err.Error())
+		resp.Success = wire.ICQStatusCodeErr
+	default:
+		resp.Success = wire.ICQStatusCodeOK
+		resp.Details = s.createResult(res)
+	}
+
+	return s.reply(ctx, sess, wire.ICQMessageReplyEnvelope{
+		Message: resp,
+	})
+}
+
+func (s ICQService) FullUserInfo(ctx context.Context, sess *state.Session, req wire.ICQ_0x07D0_0x051F_DBQueryMetaReqSearchByUIN, seq uint16) error {
+
+	user, err := s.userFinder.FindByUIN(req.UIN)
+	if err != nil {
+		return err
+	}
+
+	if err := s.userInfo(ctx, sess, user, seq); err != nil {
+		return err
+	}
+
+	if err := s.moreUserInfo(ctx, sess, user, seq); err != nil {
+		return err
+	}
+
+	if err := s.extraEmails(ctx, sess, user, seq); err != nil {
+		return err
+	}
+
+	if err := s.homepageCat(ctx, sess, user, seq); err != nil {
+		return err
+	}
+
+	if err := s.workInfo(ctx, sess, user, seq); err != nil {
+		return err
+	}
+
+	if err := s.notes(ctx, sess, user, seq); err != nil {
+		return err
+	}
+
+	if err := s.interests(ctx, sess, user, seq); err != nil {
+		return err
+	}
+
+	if err := s.affiliations(ctx, sess, user, seq); err != nil {
+		return err
+	}
+	return nil
+}
+
+func (s ICQService) MessagesEOF(ctx context.Context, sess *state.Session, seq uint16) error {
+	s.logger.Debug("returning offline messages is not yet supported")
+	msg := wire.ICQMessageReplyEnvelope{
+		Message: wire.ICQ_0x0042_DBQueryOfflineMsgReplyLast{
+			ICQMetadata: wire.ICQMetadata{
+				UIN:     sess.UIN(),
+				ReqType: wire.ICQDBQueryOfflineMsgReplyLast,
+				Seq:     seq,
+			},
+			DroppedMessages: 0,
+		},
+	}
+
+	return s.reply(ctx, sess, msg)
+}
+
+func (s ICQService) SetAffiliations(ctx context.Context, sess *state.Session, req wire.ICQ_0x07D0_0x041A_DBQueryMetaReqSetAffiliations, seq uint16) error {
+	if len(req.PastAffiliations) != 3 || len(req.Affiliations) != 3 {
+		return fmt.Errorf("%w: expected 3 past affiliations and 3 affiliations", errICQBadRequest)
+	}
+	u := state.ICQAffiliations{
+		PastCode1:       req.PastAffiliations[0].Code,
+		PastKeyword1:    req.PastAffiliations[0].Keyword,
+		PastCode2:       req.PastAffiliations[1].Code,
+		PastKeyword2:    req.PastAffiliations[1].Keyword,
+		PastCode3:       req.PastAffiliations[2].Code,
+		PastKeyword3:    req.PastAffiliations[2].Keyword,
+		CurrentCode1:    req.Affiliations[0].Code,
+		CurrentKeyword1: req.Affiliations[0].Keyword,
+		CurrentCode2:    req.Affiliations[1].Code,
+		CurrentKeyword2: req.Affiliations[1].Keyword,
+		CurrentCode3:    req.Affiliations[2].Code,
+		CurrentKeyword3: req.Affiliations[2].Keyword,
+	}
+
+	if err := s.userUpdater.SetAffiliations(sess.IdentScreenName(), u); err != nil {
+		return err
+	}
+
+	return s.reqAck(ctx, sess, seq, wire.ICQDBQueryMetaReplySetAffiliations)
+}
+
+func (s ICQService) SetBasicInfo(ctx context.Context, sess *state.Session, req wire.ICQ_0x07D0_0x03EA_DBQueryMetaReqSetBasicInfo, seq uint16) error {
+	u := state.ICQBasicInfo{
+		CellPhone:    req.CellPhone,
+		CountryCode:  req.CountryCode,
+		EmailAddress: req.EmailAddress,
+		FirstName:    req.FirstName,
+		GMTOffset:    req.GMTOffset,
+		Address:      req.HomeAddress,
+		City:         req.City,
+		Fax:          req.Fax,
+		Phone:        req.Phone,
+		State:        req.State,
+		LastName:     req.LastName,
+		Nickname:     req.Nickname,
+		PublishEmail: req.PublishEmail == wire.ICQUserFlagPublishEmailYes,
+		ZIPCode:      req.ZIP,
+	}
+
+	if err := s.userUpdater.SetBasicInfo(sess.IdentScreenName(), u); err != nil {
+		return err
+	}
+
+	return s.reqAck(ctx, sess, seq, wire.ICQDBQueryMetaReplySetBasicInfo)
+}
+
+func (s ICQService) SetEmails(ctx context.Context, sess *state.Session, req wire.ICQ_0x07D0_0x040B_DBQueryMetaReqSetEmails, seq uint16) error {
+	if len(req.Emails) > 0 {
+		s.logger.Debug("adding additional emails is not yet supported")
+	}
+	return s.reqAck(ctx, sess, seq, wire.ICQDBQueryMetaReplySetEmails)
+}
+
+func (s ICQService) SetInterests(ctx context.Context, sess *state.Session, req wire.ICQ_0x07D0_0x0410_DBQueryMetaReqSetInterests, seq uint16) error {
+	if len(req.Interests) != 4 {
+		return fmt.Errorf("%w: expected 4 interests", errICQBadRequest)
+	}
+	u := state.ICQInterests{
+		Code1:    req.Interests[0].Code,
+		Keyword1: req.Interests[0].Keyword,
+		Code2:    req.Interests[1].Code,
+		Keyword2: req.Interests[1].Keyword,
+		Code3:    req.Interests[2].Code,
+		Keyword3: req.Interests[2].Keyword,
+		Code4:    req.Interests[3].Code,
+		Keyword4: req.Interests[3].Keyword,
+	}
+
+	if err := s.userUpdater.SetInterests(sess.IdentScreenName(), u); err != nil {
+		return err
+	}
+
+	return s.reqAck(ctx, sess, seq, wire.ICQDBQueryMetaReplySetInterests)
+}
+
+func (s ICQService) SetMoreInfo(ctx context.Context, sess *state.Session, req wire.ICQ_0x07D0_0x03FD_DBQueryMetaReqSetMoreInfo, seq uint16) error {
+	u := state.ICQMoreInfo{
+		Gender:       req.Gender,
+		HomePageAddr: req.HomePageAddr,
+		BirthYear:    req.BirthYear,
+		BirthMonth:   req.BirthMonth,
+		BirthDay:     req.BirthDay,
+		Lang1:        req.Lang1,
+		Lang2:        req.Lang2,
+		Lang3:        req.Lang3,
+	}
+
+	if err := s.userUpdater.SetMoreInfo(sess.IdentScreenName(), u); err != nil {
+		return err
+	}
+
+	return s.reqAck(ctx, sess, seq, wire.ICQDBQueryMetaReplySetMoreInfo)
+}
+
+func (s ICQService) SetPermissions(ctx context.Context, sess *state.Session, req wire.ICQ_0x07D0_0x0424_DBQueryMetaReqSetPermissions, seq uint16) error {
+	s.logger.Debug("setting permissions is not yet supported")
+	return s.reqAck(ctx, sess, seq, wire.ICQDBQueryMetaReplySetPermissions)
+}
+
+func (s ICQService) SetUserNotes(ctx context.Context, sess *state.Session, req wire.ICQ_0x07D0_0x0406_DBQueryMetaReqSetNotes, seq uint16) error {
+	u := state.ICQUserNotes{
+		Notes: req.Notes,
+	}
+
+	if err := s.userUpdater.SetUserNotes(sess.IdentScreenName(), u); err != nil {
+		return err
+	}
+
+	return s.reqAck(ctx, sess, seq, wire.ICQDBQueryMetaReplySetNotes)
+}
+
+func (s ICQService) SetWorkInfo(ctx context.Context, sess *state.Session, req wire.ICQ_0x07D0_0x03F3_DBQueryMetaReqSetWorkInfo, seq uint16) error {
+	icqWorkInfo := state.ICQWorkInfo{
+		Company:        req.Company,
+		Department:     req.Department,
+		OccupationCode: req.OccupationCode,
+		Position:       req.Position,
+		Address:        req.Address,
+		City:           req.City,
+		CountryCode:    req.CountryCode,
+		Fax:            req.Fax,
+		Phone:          req.Phone,
+		State:          req.State,
+		WebPage:        req.WebPage,
+		ZIPCode:        req.ZIP,
+	}
+
+	if err := s.userUpdater.SetWorkInfo(sess.IdentScreenName(), icqWorkInfo); err != nil {
+		return err
+	}
+
+	return s.reqAck(ctx, sess, seq, wire.ICQDBQueryMetaReplySetWorkInfo)
+}
+
+func (s ICQService) XMLReqData(ctx context.Context, sess *state.Session, req wire.ICQ_0x07D0_0x0898_DBQueryMetaReqXMLReq, seq uint16) error {
+	msg := wire.ICQMessageReplyEnvelope{
+		Message: wire.ICQ_0x07DA_0x08A2_DBQueryMetaReplyXMLData{
+			ICQMetadata: wire.ICQMetadata{
+				UIN:     sess.UIN(),
+				ReqType: wire.ICQDBQueryMetaReply,
+				Seq:     seq,
+			},
+			ReqSubType: wire.ICQDBQueryMetaReplyXMLData,
+			Success:    wire.ICQStatusCodeFail,
+		},
+	}
+	return s.reply(ctx, sess, msg)
+}
+
+func (s ICQService) affiliations(ctx context.Context, sess *state.Session, user state.User, seq uint16) error {
+	msg := wire.ICQMessageReplyEnvelope{
+		Message: wire.ICQ_0x07DA_0x00FA_DBQueryMetaReplyAffiliations{
+			ICQMetadata: wire.ICQMetadata{
+				UIN:     sess.UIN(),
+				ReqType: wire.ICQDBQueryMetaReply,
+				Seq:     seq,
+			},
+			ReqSubType: wire.ICQDBQueryMetaReplyAffiliations,
+			Success:    wire.ICQStatusCodeOK,
+			ICQ_0x07D0_0x041A_DBQueryMetaReqSetAffiliations: wire.ICQ_0x07D0_0x041A_DBQueryMetaReqSetAffiliations{
+				PastAffiliations: []struct {
+					Code    uint16
+					Keyword string `oscar:"len_prefix=uint16,nullterm"`
+				}{
+					{
+						Code:    user.ICQAffiliations.PastCode1,
+						Keyword: user.ICQAffiliations.PastKeyword1,
+					},
+					{
+						Code:    user.ICQAffiliations.PastCode2,
+						Keyword: user.ICQAffiliations.PastKeyword2,
+					},
+					{
+						Code:    user.ICQAffiliations.PastCode3,
+						Keyword: user.ICQAffiliations.PastKeyword3,
+					},
+				},
+				Affiliations: []struct {
+					Code    uint16
+					Keyword string `oscar:"len_prefix=uint16,nullterm"`
+				}{
+					{
+						Code:    user.ICQAffiliations.CurrentCode1,
+						Keyword: user.ICQAffiliations.CurrentKeyword1,
+					},
+					{
+						Code:    user.ICQAffiliations.CurrentCode2,
+						Keyword: user.ICQAffiliations.CurrentKeyword2,
+					},
+					{
+						Code:    user.ICQAffiliations.CurrentCode3,
+						Keyword: user.ICQAffiliations.CurrentKeyword3,
+					},
+				},
+			},
+		},
+	}
+
+	return s.reply(ctx, sess, msg)
+}
+
+func (s ICQService) createResult(res state.User) wire.ICQUserSearchRecord {
+	uin, _ := strconv.Atoi(res.IdentScreenName.String())
+
+	searchRecord := wire.ICQUserSearchRecord{
+		UIN:       uint32(uin),
+		Nickname:  res.ICQBasicInfo.Nickname,
+		FirstName: res.ICQBasicInfo.FirstName,
+		LastName:  res.ICQBasicInfo.LastName,
+		Email:     res.ICQBasicInfo.EmailAddress,
+		Gender:    uint8(res.ICQMoreInfo.Gender),
+		Age:       res.Age(s.timeNow),
+	}
+	if res.ICQPermissions.AuthRequired {
+		searchRecord.Authorization = 1
+	}
+
+	userSess := s.sessionRetriever.RetrieveSession(res.IdentScreenName)
+	if userSess != nil {
+		searchRecord.OnlineStatus = 1
+	}
+	return searchRecord
+}
+
+func (s ICQService) extraEmails(ctx context.Context, sess *state.Session, user state.User, seq uint16) error {
+	msg := wire.ICQMessageReplyEnvelope{
+		Message: wire.ICQ_0x07DA_0x00EB_DBQueryMetaReplyExtEmailInfo{
+			ICQMetadata: wire.ICQMetadata{
+				UIN:     sess.UIN(),
+				ReqType: wire.ICQDBQueryMetaReply,
+				Seq:     seq,
+			},
+			ReqSubType: wire.ICQDBQueryMetaReplyExtEmailInfo,
+			Success:    wire.ICQStatusCodeOK,
+		},
+	}
+
+	return s.reply(ctx, sess, msg)
+}
+
+func (s ICQService) homepageCat(ctx context.Context, sess *state.Session, user state.User, seq uint16) error {
+	msg := wire.ICQMessageReplyEnvelope{
+		Message: wire.ICQ_0x07DA_0x010E_DBQueryMetaReplyHomePageCat{
+			ICQMetadata: wire.ICQMetadata{
+				UIN:     sess.UIN(),
+				ReqType: wire.ICQDBQueryMetaReply,
+				Seq:     seq,
+			},
+			ReqSubType: wire.ICQDBQueryMetaReplyHomePageCat,
+			Success:    wire.ICQStatusCodeOK,
+		},
+	}
+
+	return s.reply(ctx, sess, msg)
+}
+
+func (s ICQService) interests(ctx context.Context, sess *state.Session, user state.User, seq uint16) error {
+	msg := wire.ICQMessageReplyEnvelope{
+		Message: wire.ICQ_0x07DA_0x00F0_DBQueryMetaReplyInterests{
+			ICQMetadata: wire.ICQMetadata{
+				UIN:     sess.UIN(),
+				ReqType: wire.ICQDBQueryMetaReply,
+				Seq:     seq,
+			},
+			ReqSubType: wire.ICQDBQueryMetaReplyInterests,
+			Success:    wire.ICQStatusCodeOK,
+			Interests: []struct {
+				Code    uint16
+				Keyword string `oscar:"len_prefix=uint16,nullterm"`
+			}{
+				{
+					Code:    user.ICQInterests.Code1,
+					Keyword: user.ICQInterests.Keyword1,
+				},
+				{
+					Code:    user.ICQInterests.Code2,
+					Keyword: user.ICQInterests.Keyword2,
+				},
+				{
+					Code:    user.ICQInterests.Code3,
+					Keyword: user.ICQInterests.Keyword3,
+				},
+				{
+					Code:    user.ICQInterests.Code4,
+					Keyword: user.ICQInterests.Keyword4,
+				},
+			},
+		},
+	}
+
+	return s.reply(ctx, sess, msg)
+}
+
+func (s ICQService) moreUserInfo(ctx context.Context, sess *state.Session, user state.User, seq uint16) error {
+	msg := wire.ICQMessageReplyEnvelope{
+		Message: wire.ICQ_0x07DA_0x00DC_DBQueryMetaReplyMoreInfo{
+			ICQMetadata: wire.ICQMetadata{
+				UIN:     sess.UIN(),
+				ReqType: wire.ICQDBQueryMetaReply,
+				Seq:     seq,
+			},
+			ReqSubType: wire.ICQDBQueryMetaReplyMoreInfo,
+			Success:    wire.ICQStatusCodeOK,
+			ICQ_0x07D0_0x03FD_DBQueryMetaReqSetMoreInfo: wire.ICQ_0x07D0_0x03FD_DBQueryMetaReqSetMoreInfo{
+				Age:          uint8(user.Age(s.timeNow)),
+				Gender:       user.ICQMoreInfo.Gender,
+				HomePageAddr: user.ICQMoreInfo.HomePageAddr,
+				BirthYear:    user.ICQMoreInfo.BirthYear,
+				BirthMonth:   user.ICQMoreInfo.BirthMonth,
+				BirthDay:     user.ICQMoreInfo.BirthDay,
+				Lang1:        user.ICQMoreInfo.Lang1,
+				Lang2:        user.ICQMoreInfo.Lang2,
+				Lang3:        user.ICQMoreInfo.Lang3,
+			},
+			City:        user.ICQBasicInfo.City,
+			State:       user.ICQBasicInfo.State,
+			CountryCode: user.ICQBasicInfo.CountryCode,
+			TimeZone:    user.ICQBasicInfo.GMTOffset,
+		},
+	}
+
+	return s.reply(ctx, sess, msg)
+}
+
+func (s ICQService) notes(ctx context.Context, sess *state.Session, user state.User, seq uint16) error {
+	msg := wire.ICQMessageReplyEnvelope{
+		Message: wire.ICQ_0x07DA_0x00E6_DBQueryMetaReplyNotes{
+			ICQMetadata: wire.ICQMetadata{
+				UIN:     sess.UIN(),
+				ReqType: wire.ICQDBQueryMetaReply,
+				Seq:     seq,
+			},
+			ReqSubType: wire.ICQDBQueryMetaReplyNotes,
+			Success:    wire.ICQStatusCodeOK,
+			ICQ_0x07D0_0x0406_DBQueryMetaReqSetNotes: wire.ICQ_0x07D0_0x0406_DBQueryMetaReqSetNotes{
+				Notes: user.ICQNotes.Notes,
+			},
+		},
+	}
+
+	return s.reply(ctx, sess, msg)
+}
+
+func (s ICQService) reply(ctx context.Context, sess *state.Session, message wire.ICQMessageReplyEnvelope) error {
+	msg := wire.SNACMessage{
+		Frame: wire.SNACFrame{
+			FoodGroup: wire.ICQ,
+			SubGroup:  wire.ICQDBReply,
+		},
+		Body: wire.SNAC_0x0F_0x02_ICQDBReply{
+			TLVRestBlock: wire.TLVRestBlock{
+				TLVList: wire.TLVList{
+					wire.NewTLV(wire.ICQTLVTagsMetadata, message),
+				},
+			},
+		},
+	}
+
+	s.messageRelayer.RelayToScreenName(ctx, sess.IdentScreenName(), msg)
+	return nil
+}
+
+func (s ICQService) reqAck(ctx context.Context, sess *state.Session, seq uint16, subType uint16) error {
+	msg := wire.ICQMessageReplyEnvelope{
+		Message: wire.ICQ_0x07DA_0x00DC_DBQueryMetaReplyMoreInfo{
+			ICQMetadata: wire.ICQMetadata{
+				UIN:     sess.UIN(),
+				ReqType: wire.ICQDBQueryMetaReply,
+				Seq:     seq,
+			},
+			ReqSubType: subType,
+			Success:    wire.ICQStatusCodeOK,
+		},
+	}
+
+	return s.reply(ctx, sess, msg)
+}
+
+func (s ICQService) userInfo(ctx context.Context, sess *state.Session, user state.User, seq uint16) error {
+	userInfo := wire.ICQ_0x07DA_0x00C8_DBQueryMetaReplyBasicInfo{
+		ICQMetadata: wire.ICQMetadata{
+			UIN:     sess.UIN(),
+			ReqType: wire.ICQDBQueryMetaReply,
+			Seq:     seq,
+		},
+		ReqSubType:  wire.ICQDBQueryMetaReplyBasicInfo,
+		Success:     wire.ICQStatusCodeOK,
+		Nickname:    user.ICQBasicInfo.Nickname,
+		FirstName:   user.ICQBasicInfo.FirstName,
+		LastName:    user.ICQBasicInfo.LastName,
+		Email:       user.ICQBasicInfo.EmailAddress,
+		City:        user.ICQBasicInfo.City,
+		State:       user.ICQBasicInfo.State,
+		Phone:       user.ICQBasicInfo.Phone,
+		Fax:         user.ICQBasicInfo.Fax,
+		Address:     user.ICQBasicInfo.Address,
+		CellPhone:   user.ICQBasicInfo.CellPhone,
+		ZIP:         user.ICQBasicInfo.ZIPCode,
+		CountryCode: user.ICQBasicInfo.CountryCode,
+		GMTOffset:   user.ICQBasicInfo.GMTOffset,
+		AuthFlag:    0, // todo figure these out
+		WebAware:    1, // todo figure these out
+		DCPerms:     0, // todo figure these out
+	}
+
+	if user.ICQBasicInfo.PublishEmail {
+		userInfo.PublishEmail = wire.ICQUserFlagPublishEmailYes
+	} else {
+		userInfo.PublishEmail = wire.ICQUserFlagPublishEmailNo
+	}
+
+	msg := wire.ICQMessageReplyEnvelope{
+		Message: userInfo,
+	}
+	return s.reply(ctx, sess, msg)
+
+}
+
+func (s ICQService) workInfo(ctx context.Context, sess *state.Session, user state.User, seq uint16) error {
+	msg := wire.ICQMessageReplyEnvelope{
+		Message: wire.ICQ_0x07DA_0x00D2_DBQueryMetaReplyWorkInfo{
+			ICQMetadata: wire.ICQMetadata{
+				UIN:     sess.UIN(),
+				ReqType: wire.ICQDBQueryMetaReply,
+				Seq:     seq,
+			},
+			ReqSubType: wire.ICQDBQueryMetaReplyWorkInfo,
+			Success:    wire.ICQStatusCodeOK,
+			ICQ_0x07D0_0x03F3_DBQueryMetaReqSetWorkInfo: wire.ICQ_0x07D0_0x03F3_DBQueryMetaReqSetWorkInfo{
+				City:           user.ICQWorkInfo.City,
+				State:          user.ICQWorkInfo.State,
+				Phone:          user.ICQWorkInfo.Phone,
+				Fax:            user.ICQWorkInfo.Fax,
+				Address:        user.ICQWorkInfo.Address,
+				ZIP:            user.ICQWorkInfo.ZIPCode,
+				CountryCode:    user.ICQWorkInfo.CountryCode,
+				Company:        user.ICQWorkInfo.Company,
+				Department:     user.ICQWorkInfo.Department,
+				Position:       user.ICQWorkInfo.Position,
+				OccupationCode: user.ICQWorkInfo.OccupationCode,
+				WebPage:        user.ICQWorkInfo.WebPage,
+			},
+		},
+	}
+	return s.reply(ctx, sess, msg)
+}

+ 2095 - 0
foodgroup/icq_test.go

@@ -0,0 +1,2095 @@
+package foodgroup
+
+import (
+	"log/slog"
+	"testing"
+	"time"
+
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/mock"
+
+	"github.com/mk6i/retro-aim-server/state"
+	"github.com/mk6i/retro-aim-server/wire"
+)
+
+func TestICQService_FindByDetails(t *testing.T) {
+	tests := []struct {
+		name       string
+		timeNow    func() time.Time
+		seq        uint16
+		sess       *state.Session
+		req        wire.ICQ_0x07D0_0x0515_DBQueryMetaReqSearchByDetails
+		mockParams mockParams
+		wantErr    error
+	}{
+		{
+			name: "happy path",
+			timeNow: func() time.Time {
+				return time.Date(2020, time.August, 1, 0, 0, 0, 0, time.UTC)
+			},
+			seq:  1,
+			sess: newTestSession("11111111", sessOptUIN(11111111)),
+			req: wire.ICQ_0x07D0_0x0515_DBQueryMetaReqSearchByDetails{
+				FirstName: "John",
+				LastName:  "Doe",
+				NickName:  "Johnny",
+			},
+			mockParams: mockParams{
+				icqUserFinderParams: icqUserFinderParams{
+					findByDetailsParams: findByDetailsParams{
+						{
+							firstName: "John",
+							lastName:  "Doe",
+							nickName:  "Johnny",
+							result: []state.User{
+								{
+									IdentScreenName: state.NewIdentScreenName("987654321"),
+									ICQBasicInfo: state.ICQBasicInfo{
+										EmailAddress: "janey@example.com",
+										FirstName:    "Jane",
+										LastName:     "Doe",
+										Nickname:     "Janey",
+									},
+									ICQPermissions: state.ICQPermissions{
+										AuthRequired: false,
+									},
+									ICQMoreInfo: state.ICQMoreInfo{
+										BirthDay:   31,
+										BirthMonth: 7,
+										BirthYear:  1995,
+										Gender:     2,
+									},
+								},
+								{
+									IdentScreenName: state.NewIdentScreenName("123456789"),
+									ICQBasicInfo: state.ICQBasicInfo{
+										EmailAddress: "john@example.com",
+										FirstName:    "John",
+										LastName:     "Doe",
+										Nickname:     "Johnny",
+									},
+									ICQPermissions: state.ICQPermissions{
+										AuthRequired: true,
+									},
+									ICQMoreInfo: state.ICQMoreInfo{
+										Gender:     1,
+										BirthDay:   31,
+										BirthMonth: 7,
+										BirthYear:  1999,
+									},
+								},
+							},
+						},
+					},
+				},
+				messageRelayerParams: messageRelayerParams{
+					relayToScreenNameParams: relayToScreenNameParams{
+						{
+							screenName: state.NewIdentScreenName("11111111"),
+							message: wire.SNACMessage{
+								Frame: wire.SNACFrame{
+									FoodGroup: wire.ICQ,
+									SubGroup:  wire.ICQDBReply,
+								},
+								Body: wire.SNAC_0x0F_0x02_ICQDBReply{
+									TLVRestBlock: wire.TLVRestBlock{
+										TLVList: wire.TLVList{
+											wire.NewTLV(wire.ICQTLVTagsMetadata, wire.ICQMessageReplyEnvelope{
+												Message: wire.ICQ_0x07DA_0x01AE_DBQueryMetaReplyLastUserFound{
+													ICQMetadata: wire.ICQMetadata{
+														UIN:     11111111,
+														ReqType: wire.ICQDBQueryMetaReply,
+														Seq:     1,
+													},
+													Success:    wire.ICQStatusCodeOK,
+													ReqSubType: wire.ICQDBQueryMetaReplyUserFound,
+													Details: wire.ICQUserSearchRecord{
+														UIN:           987654321,
+														Nickname:      "Janey",
+														FirstName:     "Jane",
+														LastName:      "Doe",
+														Email:         "janey@example.com",
+														Authorization: 0,
+														OnlineStatus:  0,
+														Gender:        2,
+														Age:           25,
+													},
+												},
+											}),
+										},
+									},
+								},
+							},
+						},
+						{
+							screenName: state.NewIdentScreenName("11111111"),
+							message: wire.SNACMessage{
+								Frame: wire.SNACFrame{
+									FoodGroup: wire.ICQ,
+									SubGroup:  wire.ICQDBReply,
+								},
+								Body: wire.SNAC_0x0F_0x02_ICQDBReply{
+									TLVRestBlock: wire.TLVRestBlock{
+										TLVList: wire.TLVList{
+											wire.NewTLV(wire.ICQTLVTagsMetadata, wire.ICQMessageReplyEnvelope{
+												Message: wire.ICQ_0x07DA_0x01AE_DBQueryMetaReplyLastUserFound{
+													ICQMetadata: wire.ICQMetadata{
+														UIN:     11111111,
+														ReqType: wire.ICQDBQueryMetaReply,
+														Seq:     1,
+													},
+													Success:    wire.ICQStatusCodeOK,
+													ReqSubType: wire.ICQDBQueryMetaReplyLastUserFound,
+													Details: wire.ICQUserSearchRecord{
+														UIN:           123456789,
+														Nickname:      "Johnny",
+														FirstName:     "John",
+														LastName:      "Doe",
+														Email:         "john@example.com",
+														Authorization: 1,
+														OnlineStatus:  1,
+														Gender:        1,
+														Age:           21,
+													},
+													LastMessageFooter: &struct {
+														FoundUsersLeft uint32
+													}{
+														FoundUsersLeft: 0,
+													},
+												},
+											}),
+										},
+									},
+								},
+							},
+						},
+					},
+				},
+				sessionRetrieverParams: sessionRetrieverParams{
+					retrieveSessionParams{
+						{
+							screenName: state.NewIdentScreenName("987654321"),
+							result:     nil,
+						},
+						{
+							screenName: state.NewIdentScreenName("123456789"),
+							result:     &state.Session{},
+						},
+					},
+				},
+			},
+		},
+	}
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			userFinder := newMockICQUserFinder(t)
+			for _, params := range tt.mockParams.findByDetailsParams {
+				userFinder.EXPECT().
+					FindByDetails(params.firstName, params.lastName, params.nickName).
+					Return(params.result, params.err)
+			}
+
+			messageRelayer := newMockMessageRelayer(t)
+			for _, params := range tt.mockParams.relayToScreenNameParams {
+				messageRelayer.EXPECT().RelayToScreenName(mock.Anything, params.screenName, params.message)
+			}
+
+			sessionRetriever := newMockSessionRetriever(t)
+			for _, params := range tt.mockParams.retrieveSessionParams {
+				sessionRetriever.EXPECT().
+					RetrieveSession(params.screenName).
+					Return(params.result)
+			}
+
+			s := ICQService{
+				messageRelayer:   messageRelayer,
+				sessionRetriever: sessionRetriever,
+				timeNow:          tt.timeNow,
+				userFinder:       userFinder,
+			}
+			err := s.FindByDetails(nil, tt.sess, tt.req, tt.seq)
+			assert.NoError(t, err)
+		})
+	}
+}
+
+func TestICQService_FindByEmail(t *testing.T) {
+	tests := []struct {
+		name       string
+		timeNow    func() time.Time
+		seq        uint16
+		sess       *state.Session
+		req        wire.ICQ_0x07D0_0x0529_DBQueryMetaReqSearchByEmail
+		mockParams mockParams
+		wantErr    error
+	}{
+		{
+			name: "happy path",
+			timeNow: func() time.Time {
+				return time.Date(2020, time.August, 1, 0, 0, 0, 0, time.UTC)
+			},
+			seq:  1,
+			sess: newTestSession("11111111", sessOptUIN(11111111)),
+			req: wire.ICQ_0x07D0_0x0529_DBQueryMetaReqSearchByEmail{
+				Email: "john@example.com",
+			},
+			mockParams: mockParams{
+				icqUserFinderParams: icqUserFinderParams{
+					findByEmailParams: findByEmailParams{
+						{
+							email: "john@example.com",
+							result: state.User{
+								IdentScreenName: state.NewIdentScreenName("123456789"),
+								ICQBasicInfo: state.ICQBasicInfo{
+									EmailAddress: "john@example.com",
+									FirstName:    "John",
+									LastName:     "Doe",
+									Nickname:     "Johnny",
+								},
+								ICQPermissions: state.ICQPermissions{
+									AuthRequired: true,
+								},
+								ICQMoreInfo: state.ICQMoreInfo{
+									BirthDay:   31,
+									BirthMonth: 7,
+									BirthYear:  1999,
+									Gender:     1,
+								},
+							},
+						},
+					},
+				},
+				messageRelayerParams: messageRelayerParams{
+					relayToScreenNameParams: relayToScreenNameParams{
+						{
+							screenName: state.NewIdentScreenName("11111111"),
+							message: wire.SNACMessage{
+								Frame: wire.SNACFrame{
+									FoodGroup: wire.ICQ,
+									SubGroup:  wire.ICQDBReply,
+								},
+								Body: wire.SNAC_0x0F_0x02_ICQDBReply{
+									TLVRestBlock: wire.TLVRestBlock{
+										TLVList: wire.TLVList{
+											wire.NewTLV(wire.ICQTLVTagsMetadata, wire.ICQMessageReplyEnvelope{
+												Message: wire.ICQ_0x07DA_0x01AE_DBQueryMetaReplyLastUserFound{
+													ICQMetadata: wire.ICQMetadata{
+														UIN:     11111111,
+														ReqType: wire.ICQDBQueryMetaReply,
+														Seq:     1,
+													},
+													Success:    wire.ICQStatusCodeOK,
+													ReqSubType: wire.ICQDBQueryMetaReplyLastUserFound,
+													Details: wire.ICQUserSearchRecord{
+														UIN:           123456789,
+														Nickname:      "Johnny",
+														FirstName:     "John",
+														LastName:      "Doe",
+														Email:         "john@example.com",
+														Authorization: 1,
+														OnlineStatus:  1,
+														Gender:        1,
+														Age:           21,
+													},
+													LastMessageFooter: &struct {
+														FoundUsersLeft uint32
+													}{
+														FoundUsersLeft: 0,
+													},
+												},
+											}),
+										},
+									},
+								},
+							},
+						},
+					},
+				},
+				sessionRetrieverParams: sessionRetrieverParams{
+					retrieveSessionParams{
+						{
+							screenName: state.NewIdentScreenName("123456789"),
+							result:     &state.Session{},
+						},
+					},
+				},
+			},
+		},
+	}
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			userFinder := newMockICQUserFinder(t)
+			for _, params := range tt.mockParams.findByEmailParams {
+				userFinder.EXPECT().
+					FindByEmail(params.email).
+					Return(params.result, params.err)
+			}
+
+			messageRelayer := newMockMessageRelayer(t)
+			for _, params := range tt.mockParams.relayToScreenNameParams {
+				messageRelayer.EXPECT().RelayToScreenName(mock.Anything, params.screenName, params.message)
+			}
+
+			sessionRetriever := newMockSessionRetriever(t)
+			for _, params := range tt.mockParams.retrieveSessionParams {
+				sessionRetriever.EXPECT().
+					RetrieveSession(params.screenName).
+					Return(params.result)
+			}
+
+			s := ICQService{
+				messageRelayer:   messageRelayer,
+				sessionRetriever: sessionRetriever,
+				timeNow:          tt.timeNow,
+				userFinder:       userFinder,
+			}
+			err := s.FindByEmail(nil, tt.sess, tt.req, tt.seq)
+			assert.NoError(t, err)
+		})
+	}
+}
+
+func TestICQService_FindByUIN(t *testing.T) {
+	tests := []struct {
+		name       string
+		timeNow    func() time.Time
+		seq        uint16
+		sess       *state.Session
+		req        wire.ICQ_0x07D0_0x051F_DBQueryMetaReqSearchByUIN
+		mockParams mockParams
+		wantErr    error
+	}{
+		{
+			name: "happy path",
+			timeNow: func() time.Time {
+				return time.Date(2020, time.August, 1, 0, 0, 0, 0, time.UTC)
+			},
+			seq:  1,
+			sess: newTestSession("11111111", sessOptUIN(11111111)),
+			req: wire.ICQ_0x07D0_0x051F_DBQueryMetaReqSearchByUIN{
+				UIN: 123456789,
+			},
+			mockParams: mockParams{
+				icqUserFinderParams: icqUserFinderParams{
+					findByUINParams: findByUINParams{
+						{
+							UIN: 123456789,
+							result: state.User{
+								IdentScreenName: state.NewIdentScreenName("123456789"),
+								ICQPermissions: state.ICQPermissions{
+									AuthRequired: true,
+								},
+								ICQBasicInfo: state.ICQBasicInfo{
+									EmailAddress: "john@example.com",
+									FirstName:    "John",
+									LastName:     "Doe",
+									Nickname:     "Johnny",
+								},
+								ICQMoreInfo: state.ICQMoreInfo{
+									Gender:     1,
+									BirthDay:   31,
+									BirthMonth: 7,
+									BirthYear:  1999,
+								},
+							},
+						},
+					},
+				},
+				messageRelayerParams: messageRelayerParams{
+					relayToScreenNameParams: relayToScreenNameParams{
+						{
+							screenName: state.NewIdentScreenName("11111111"),
+							message: wire.SNACMessage{
+								Frame: wire.SNACFrame{
+									FoodGroup: wire.ICQ,
+									SubGroup:  wire.ICQDBReply,
+								},
+								Body: wire.SNAC_0x0F_0x02_ICQDBReply{
+									TLVRestBlock: wire.TLVRestBlock{
+										TLVList: wire.TLVList{
+											wire.NewTLV(wire.ICQTLVTagsMetadata, wire.ICQMessageReplyEnvelope{
+												Message: wire.ICQ_0x07DA_0x01AE_DBQueryMetaReplyLastUserFound{
+													ICQMetadata: wire.ICQMetadata{
+														UIN:     11111111,
+														ReqType: wire.ICQDBQueryMetaReply,
+														Seq:     1,
+													},
+													Success:    wire.ICQStatusCodeOK,
+													ReqSubType: wire.ICQDBQueryMetaReplyLastUserFound,
+													Details: wire.ICQUserSearchRecord{
+														UIN:           123456789,
+														Nickname:      "Johnny",
+														FirstName:     "John",
+														LastName:      "Doe",
+														Email:         "john@example.com",
+														Authorization: 1,
+														OnlineStatus:  1,
+														Gender:        1,
+														Age:           21,
+													},
+													LastMessageFooter: &struct {
+														FoundUsersLeft uint32
+													}{
+														FoundUsersLeft: 0,
+													},
+												},
+											}),
+										},
+									},
+								},
+							},
+						},
+					},
+				},
+				sessionRetrieverParams: sessionRetrieverParams{
+					retrieveSessionParams{
+						{
+							screenName: state.NewIdentScreenName("123456789"),
+							result:     &state.Session{},
+						},
+					},
+				},
+			},
+		},
+	}
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			userFinder := newMockICQUserFinder(t)
+			for _, params := range tt.mockParams.findByUINParams {
+				userFinder.EXPECT().
+					FindByUIN(params.UIN).
+					Return(params.result, params.err)
+			}
+
+			messageRelayer := newMockMessageRelayer(t)
+			for _, params := range tt.mockParams.relayToScreenNameParams {
+				messageRelayer.EXPECT().RelayToScreenName(mock.Anything, params.screenName, params.message)
+			}
+
+			sessionRetriever := newMockSessionRetriever(t)
+			for _, params := range tt.mockParams.retrieveSessionParams {
+				sessionRetriever.EXPECT().
+					RetrieveSession(params.screenName).
+					Return(params.result)
+			}
+
+			s := ICQService{
+				messageRelayer:   messageRelayer,
+				sessionRetriever: sessionRetriever,
+				timeNow:          tt.timeNow,
+				userFinder:       userFinder,
+			}
+			err := s.FindByUIN(nil, tt.sess, tt.req, tt.seq)
+			assert.NoError(t, err)
+		})
+	}
+}
+
+func TestICQService_FindByWhitePages(t *testing.T) {
+	tests := []struct {
+		name       string
+		timeNow    func() time.Time
+		seq        uint16
+		sess       *state.Session
+		req        wire.ICQ_0x07D0_0x0533_DBQueryMetaReqSearchWhitePages
+		mockParams mockParams
+		wantErr    error
+	}{
+		{
+			name: "happy path",
+			timeNow: func() time.Time {
+				return time.Date(2020, time.August, 1, 0, 0, 0, 0, time.UTC)
+			},
+			seq:  1,
+			sess: newTestSession("11111111", sessOptUIN(11111111)),
+			req: wire.ICQ_0x07D0_0x0533_DBQueryMetaReqSearchWhitePages{
+				InterestsCode:    10,
+				InterestsKeyword: "knitting,crocheting,sewing",
+			},
+			mockParams: mockParams{
+				icqUserFinderParams: icqUserFinderParams{
+					findByInterestsParams: findByInterestsParams{
+						{
+							code:     10,
+							keywords: []string{"knitting", "crocheting", "sewing"},
+							result: []state.User{
+								{
+									IdentScreenName: state.NewIdentScreenName("987654321"),
+									ICQBasicInfo: state.ICQBasicInfo{
+										EmailAddress: "janey@example.com",
+										FirstName:    "Jane",
+										LastName:     "Doe",
+										Nickname:     "Janey",
+									},
+									ICQPermissions: state.ICQPermissions{
+										AuthRequired: false,
+									},
+									ICQMoreInfo: state.ICQMoreInfo{
+										BirthDay:   31,
+										BirthMonth: 7,
+										BirthYear:  1995,
+										Gender:     2,
+									},
+								},
+								{
+									IdentScreenName: state.NewIdentScreenName("123456789"),
+									ICQBasicInfo: state.ICQBasicInfo{
+										EmailAddress: "alice@example.com",
+										FirstName:    "Alice",
+										LastName:     "Smith",
+										Nickname:     "Ally123",
+									},
+									ICQPermissions: state.ICQPermissions{
+										AuthRequired: true,
+									},
+									ICQMoreInfo: state.ICQMoreInfo{
+										BirthDay:   31,
+										BirthMonth: 7,
+										BirthYear:  1999,
+										Gender:     1,
+									},
+								},
+							},
+						},
+					},
+				},
+				messageRelayerParams: messageRelayerParams{
+					relayToScreenNameParams: relayToScreenNameParams{
+						{
+							screenName: state.NewIdentScreenName("11111111"),
+							message: wire.SNACMessage{
+								Frame: wire.SNACFrame{
+									FoodGroup: wire.ICQ,
+									SubGroup:  wire.ICQDBReply,
+								},
+								Body: wire.SNAC_0x0F_0x02_ICQDBReply{
+									TLVRestBlock: wire.TLVRestBlock{
+										TLVList: wire.TLVList{
+											wire.NewTLV(wire.ICQTLVTagsMetadata, wire.ICQMessageReplyEnvelope{
+												Message: wire.ICQ_0x07DA_0x01AE_DBQueryMetaReplyLastUserFound{
+													ICQMetadata: wire.ICQMetadata{
+														UIN:     11111111,
+														ReqType: wire.ICQDBQueryMetaReply,
+														Seq:     1,
+													},
+													Success:    wire.ICQStatusCodeOK,
+													ReqSubType: wire.ICQDBQueryMetaReplyUserFound,
+													Details: wire.ICQUserSearchRecord{
+														UIN:           987654321,
+														Nickname:      "Janey",
+														FirstName:     "Jane",
+														LastName:      "Doe",
+														Email:         "janey@example.com",
+														Authorization: 0,
+														OnlineStatus:  0,
+														Gender:        2,
+														Age:           25,
+													},
+												},
+											}),
+										},
+									},
+								},
+							},
+						},
+						{
+							screenName: state.NewIdentScreenName("11111111"),
+							message: wire.SNACMessage{
+								Frame: wire.SNACFrame{
+									FoodGroup: wire.ICQ,
+									SubGroup:  wire.ICQDBReply,
+								},
+								Body: wire.SNAC_0x0F_0x02_ICQDBReply{
+									TLVRestBlock: wire.TLVRestBlock{
+										TLVList: wire.TLVList{
+											wire.NewTLV(wire.ICQTLVTagsMetadata, wire.ICQMessageReplyEnvelope{
+												Message: wire.ICQ_0x07DA_0x01AE_DBQueryMetaReplyLastUserFound{
+													ICQMetadata: wire.ICQMetadata{
+														UIN:     11111111,
+														ReqType: wire.ICQDBQueryMetaReply,
+														Seq:     1,
+													},
+													Success:    wire.ICQStatusCodeOK,
+													ReqSubType: wire.ICQDBQueryMetaReplyLastUserFound,
+													Details: wire.ICQUserSearchRecord{
+														UIN:           123456789,
+														Nickname:      "Ally123",
+														FirstName:     "Alice",
+														LastName:      "Smith",
+														Email:         "alice@example.com",
+														Authorization: 1,
+														OnlineStatus:  1,
+														Gender:        1,
+														Age:           21,
+													},
+													LastMessageFooter: &struct {
+														FoundUsersLeft uint32
+													}{
+														FoundUsersLeft: 0,
+													},
+												},
+											}),
+										},
+									},
+								},
+							},
+						},
+					},
+				},
+				sessionRetrieverParams: sessionRetrieverParams{
+					retrieveSessionParams{
+						{
+							screenName: state.NewIdentScreenName("987654321"),
+							result:     nil,
+						},
+						{
+							screenName: state.NewIdentScreenName("123456789"),
+							result:     &state.Session{},
+						},
+					},
+				},
+			},
+		},
+	}
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			userFinder := newMockICQUserFinder(t)
+			for _, params := range tt.mockParams.findByInterestsParams {
+				userFinder.EXPECT().
+					FindByInterests(params.code, params.keywords).
+					Return(params.result, params.err)
+			}
+
+			messageRelayer := newMockMessageRelayer(t)
+			for _, params := range tt.mockParams.relayToScreenNameParams {
+				messageRelayer.EXPECT().RelayToScreenName(mock.Anything, params.screenName, params.message)
+			}
+
+			sessionRetriever := newMockSessionRetriever(t)
+			for _, params := range tt.mockParams.retrieveSessionParams {
+				sessionRetriever.EXPECT().
+					RetrieveSession(params.screenName).
+					Return(params.result)
+			}
+
+			s := ICQService{
+				messageRelayer:   messageRelayer,
+				sessionRetriever: sessionRetriever,
+				timeNow:          tt.timeNow,
+				userFinder:       userFinder,
+			}
+			err := s.FindByInterests(nil, tt.sess, tt.req, tt.seq)
+			assert.NoError(t, err)
+		})
+	}
+}
+
+func TestICQService_FullUserInfo(t *testing.T) {
+	tests := []struct {
+		name       string
+		timeNow    func() time.Time
+		seq        uint16
+		sess       *state.Session
+		req        wire.ICQ_0x07D0_0x051F_DBQueryMetaReqSearchByUIN
+		mockParams mockParams
+		wantErr    error
+	}{
+		{
+			name: "happy path",
+			timeNow: func() time.Time {
+				return time.Date(2020, time.August, 1, 0, 0, 0, 0, time.UTC)
+			},
+			seq:  1,
+			sess: newTestSession("11111111", sessOptUIN(11111111)),
+			req: wire.ICQ_0x07D0_0x051F_DBQueryMetaReqSearchByUIN{
+				UIN: 123456789,
+			},
+			mockParams: mockParams{
+				icqUserFinderParams: icqUserFinderParams{
+					findByUINParams: findByUINParams{
+						{
+							UIN: 123456789,
+							result: state.User{
+								IdentScreenName: state.NewIdentScreenName("123456789"),
+								ICQNotes: state.ICQUserNotes{
+									Notes: "This is a test user.",
+								},
+								ICQPermissions: state.ICQPermissions{
+									AuthRequired: true,
+								},
+								ICQMoreInfo: state.ICQMoreInfo{
+									BirthDay:     15,
+									BirthMonth:   6,
+									BirthYear:    1990,
+									Gender:       1,
+									HomePageAddr: "https://johnsdomain.com",
+									Lang1:        1,
+									Lang2:        2,
+									Lang3:        3,
+								},
+								ICQBasicInfo: state.ICQBasicInfo{
+									CellPhone:    "987-654-3210",
+									CountryCode:  1,
+									EmailAddress: "john.doe@example.com",
+									FirstName:    "John",
+									GMTOffset:    5,
+									Address:      "123 Main St, New York, NY 10001",
+									City:         "New York",
+									Fax:          "123-456-7891",
+									Phone:        "123-456-7890",
+									State:        "NY",
+									LastName:     "Doe",
+									Nickname:     "CoolUser123",
+									PublishEmail: true,
+									ZIPCode:      "10001",
+								},
+								ICQWorkInfo: state.ICQWorkInfo{
+									Company:        "TechCorp",
+									Department:     "Engineering",
+									OccupationCode: 1234,
+									Position:       "Staff Software Engineer",
+									Address:        "456 Work St, Los Angeles, CA 90001",
+									City:           "Los Angeles",
+									CountryCode:    1,
+									Fax:            "234-567-8902",
+									Phone:          "234-567-8901",
+									State:          "CA",
+									WebPage:        "https://techcorp.com",
+									ZIPCode:        "90001",
+								},
+								ICQInterests: state.ICQInterests{
+									Code1:    5678,
+									Keyword1: "Programming",
+									Code2:    6789,
+									Keyword2: "Gaming",
+									Code3:    7890,
+									Keyword3: "Music",
+									Code4:    8901,
+									Keyword4: "Traveling",
+								},
+								ICQAffiliations: state.ICQAffiliations{
+									CurrentCode1:    4567,
+									CurrentKeyword1: "Professional Org",
+									CurrentCode2:    5678,
+									CurrentKeyword2: "Alumni Group",
+									CurrentCode3:    6789,
+									CurrentKeyword3: "Community Group",
+									PastCode1:       9012,
+									PastKeyword1:    "College",
+									PastCode2:       1233,
+									PastKeyword2:    "High School",
+									PastCode3:       3456,
+									PastKeyword3:    "Previous Job",
+								},
+							},
+						},
+					},
+				},
+				messageRelayerParams: messageRelayerParams{
+					relayToScreenNameParams: relayToScreenNameParams{
+						{
+							screenName: state.NewIdentScreenName("11111111"),
+							message: wire.SNACMessage{
+								Frame: wire.SNACFrame{
+									FoodGroup: wire.ICQ,
+									SubGroup:  wire.ICQDBReply,
+								},
+								Body: wire.SNAC_0x0F_0x02_ICQDBReply{
+									TLVRestBlock: wire.TLVRestBlock{
+										TLVList: wire.TLVList{
+											wire.NewTLV(wire.ICQTLVTagsMetadata, wire.ICQMessageReplyEnvelope{
+												Message: wire.ICQ_0x07DA_0x00C8_DBQueryMetaReplyBasicInfo{
+													ICQMetadata: wire.ICQMetadata{
+														UIN:     11111111,
+														ReqType: wire.ICQDBQueryMetaReply,
+														Seq:     1,
+													},
+													Success:    wire.ICQStatusCodeOK,
+													ReqSubType: wire.ICQDBQueryMetaReplyBasicInfo,
+
+													Nickname:     "CoolUser123",
+													FirstName:    "John",
+													LastName:     "Doe",
+													Email:        "john.doe@example.com",
+													City:         "New York",
+													State:        "NY",
+													Phone:        "123-456-7890",
+													Fax:          "123-456-7891",
+													Address:      "123 Main St, New York, NY 10001",
+													CellPhone:    "987-654-3210",
+													ZIP:          "10001",
+													CountryCode:  1,
+													GMTOffset:    5,
+													AuthFlag:     0,
+													WebAware:     1,
+													DCPerms:      0,
+													PublishEmail: wire.ICQUserFlagPublishEmailYes,
+												},
+											}),
+										},
+									},
+								},
+							},
+						},
+						{
+							screenName: state.NewIdentScreenName("11111111"),
+							message: wire.SNACMessage{
+								Frame: wire.SNACFrame{
+									FoodGroup: wire.ICQ,
+									SubGroup:  wire.ICQDBReply,
+								},
+								Body: wire.SNAC_0x0F_0x02_ICQDBReply{
+									TLVRestBlock: wire.TLVRestBlock{
+										TLVList: wire.TLVList{
+											wire.NewTLV(wire.ICQTLVTagsMetadata, wire.ICQMessageReplyEnvelope{
+												Message: wire.ICQ_0x07DA_0x00DC_DBQueryMetaReplyMoreInfo{
+													ICQMetadata: wire.ICQMetadata{
+														UIN:     11111111,
+														ReqType: wire.ICQDBQueryMetaReply,
+														Seq:     1,
+													},
+													Success:    wire.ICQStatusCodeOK,
+													ReqSubType: wire.ICQDBQueryMetaReplyMoreInfo,
+													ICQ_0x07D0_0x03FD_DBQueryMetaReqSetMoreInfo: wire.ICQ_0x07D0_0x03FD_DBQueryMetaReqSetMoreInfo{
+														Age:          30,
+														Gender:       1,
+														HomePageAddr: "https://johnsdomain.com",
+														BirthYear:    1990,
+														BirthMonth:   6,
+														BirthDay:     15,
+														Lang1:        1,
+														Lang2:        2,
+														Lang3:        3,
+													},
+													City:        "New York",
+													State:       "NY",
+													CountryCode: 1,
+													TimeZone:    5,
+												},
+											}),
+										},
+									},
+								},
+							},
+						},
+						{
+							screenName: state.NewIdentScreenName("11111111"),
+							message: wire.SNACMessage{
+								Frame: wire.SNACFrame{
+									FoodGroup: wire.ICQ,
+									SubGroup:  wire.ICQDBReply,
+								},
+								Body: wire.SNAC_0x0F_0x02_ICQDBReply{
+									TLVRestBlock: wire.TLVRestBlock{
+										TLVList: wire.TLVList{
+											wire.NewTLV(wire.ICQTLVTagsMetadata, wire.ICQMessageReplyEnvelope{
+												Message: wire.ICQ_0x07DA_0x00EB_DBQueryMetaReplyExtEmailInfo{
+													ICQMetadata: wire.ICQMetadata{
+														UIN:     11111111,
+														ReqType: wire.ICQDBQueryMetaReply,
+														Seq:     1,
+													},
+													Success:    wire.ICQStatusCodeOK,
+													ReqSubType: wire.ICQDBQueryMetaReplyExtEmailInfo,
+												},
+											}),
+										},
+									},
+								},
+							},
+						},
+						{
+							screenName: state.NewIdentScreenName("11111111"),
+							message: wire.SNACMessage{
+								Frame: wire.SNACFrame{
+									FoodGroup: wire.ICQ,
+									SubGroup:  wire.ICQDBReply,
+								},
+								Body: wire.SNAC_0x0F_0x02_ICQDBReply{
+									TLVRestBlock: wire.TLVRestBlock{
+										TLVList: wire.TLVList{
+											wire.NewTLV(wire.ICQTLVTagsMetadata, wire.ICQMessageReplyEnvelope{
+												Message: wire.ICQ_0x07DA_0x010E_DBQueryMetaReplyHomePageCat{
+													ICQMetadata: wire.ICQMetadata{
+														UIN:     11111111,
+														ReqType: wire.ICQDBQueryMetaReply,
+														Seq:     1,
+													},
+													Success:    wire.ICQStatusCodeOK,
+													ReqSubType: wire.ICQDBQueryMetaReplyHomePageCat,
+												},
+											}),
+										},
+									},
+								},
+							},
+						},
+						{
+							screenName: state.NewIdentScreenName("11111111"),
+							message: wire.SNACMessage{
+								Frame: wire.SNACFrame{
+									FoodGroup: wire.ICQ,
+									SubGroup:  wire.ICQDBReply,
+								},
+								Body: wire.SNAC_0x0F_0x02_ICQDBReply{
+									TLVRestBlock: wire.TLVRestBlock{
+										TLVList: wire.TLVList{
+											wire.NewTLV(wire.ICQTLVTagsMetadata, wire.ICQMessageReplyEnvelope{
+												Message: wire.ICQ_0x07DA_0x00D2_DBQueryMetaReplyWorkInfo{
+													ICQMetadata: wire.ICQMetadata{
+														UIN:     11111111,
+														ReqType: wire.ICQDBQueryMetaReply,
+														Seq:     1,
+													},
+													Success:    wire.ICQStatusCodeOK,
+													ReqSubType: wire.ICQDBQueryMetaReplyWorkInfo,
+													ICQ_0x07D0_0x03F3_DBQueryMetaReqSetWorkInfo: wire.ICQ_0x07D0_0x03F3_DBQueryMetaReqSetWorkInfo{
+														City:           "Los Angeles",
+														State:          "CA",
+														Phone:          "234-567-8901",
+														Fax:            "234-567-8902",
+														Address:        "456 Work St, Los Angeles, CA 90001",
+														ZIP:            "90001",
+														CountryCode:    1,
+														Company:        "TechCorp",
+														Department:     "Engineering",
+														Position:       "Staff Software Engineer",
+														OccupationCode: 1234,
+														WebPage:        "https://techcorp.com",
+													},
+												},
+											}),
+										},
+									},
+								},
+							},
+						},
+						{
+							screenName: state.NewIdentScreenName("11111111"),
+							message: wire.SNACMessage{
+								Frame: wire.SNACFrame{
+									FoodGroup: wire.ICQ,
+									SubGroup:  wire.ICQDBReply,
+								},
+								Body: wire.SNAC_0x0F_0x02_ICQDBReply{
+									TLVRestBlock: wire.TLVRestBlock{
+										TLVList: wire.TLVList{
+											wire.NewTLV(wire.ICQTLVTagsMetadata, wire.ICQMessageReplyEnvelope{
+												Message: wire.ICQ_0x07DA_0x00E6_DBQueryMetaReplyNotes{
+													ICQMetadata: wire.ICQMetadata{
+														UIN:     11111111,
+														ReqType: wire.ICQDBQueryMetaReply,
+														Seq:     1,
+													},
+													Success:    wire.ICQStatusCodeOK,
+													ReqSubType: wire.ICQDBQueryMetaReplyNotes,
+													ICQ_0x07D0_0x0406_DBQueryMetaReqSetNotes: wire.ICQ_0x07D0_0x0406_DBQueryMetaReqSetNotes{
+														Notes: "This is a test user.",
+													},
+												},
+											}),
+										},
+									},
+								},
+							},
+						},
+						{
+							screenName: state.NewIdentScreenName("11111111"),
+							message: wire.SNACMessage{
+								Frame: wire.SNACFrame{
+									FoodGroup: wire.ICQ,
+									SubGroup:  wire.ICQDBReply,
+								},
+								Body: wire.SNAC_0x0F_0x02_ICQDBReply{
+									TLVRestBlock: wire.TLVRestBlock{
+										TLVList: wire.TLVList{
+											wire.NewTLV(wire.ICQTLVTagsMetadata, wire.ICQMessageReplyEnvelope{
+												Message: wire.ICQ_0x07DA_0x00F0_DBQueryMetaReplyInterests{
+													ICQMetadata: wire.ICQMetadata{
+														UIN:     11111111,
+														ReqType: wire.ICQDBQueryMetaReply,
+														Seq:     1,
+													},
+													Success:    wire.ICQStatusCodeOK,
+													ReqSubType: wire.ICQDBQueryMetaReplyInterests,
+													Interests: []struct {
+														Code    uint16
+														Keyword string `oscar:"len_prefix=uint16,nullterm"`
+													}{
+														{
+															Code:    5678,
+															Keyword: "Programming",
+														},
+														{
+															Code:    6789,
+															Keyword: "Gaming",
+														},
+														{
+															Code:    7890,
+															Keyword: "Music",
+														},
+														{
+															Code:    8901,
+															Keyword: "Traveling",
+														},
+													},
+												},
+											}),
+										},
+									},
+								},
+							},
+						},
+						{
+							screenName: state.NewIdentScreenName("11111111"),
+							message: wire.SNACMessage{
+								Frame: wire.SNACFrame{
+									FoodGroup: wire.ICQ,
+									SubGroup:  wire.ICQDBReply,
+								},
+								Body: wire.SNAC_0x0F_0x02_ICQDBReply{
+									TLVRestBlock: wire.TLVRestBlock{
+										TLVList: wire.TLVList{
+											wire.NewTLV(wire.ICQTLVTagsMetadata, wire.ICQMessageReplyEnvelope{
+												Message: wire.ICQ_0x07DA_0x00FA_DBQueryMetaReplyAffiliations{
+													ICQMetadata: wire.ICQMetadata{
+														UIN:     11111111,
+														ReqType: wire.ICQDBQueryMetaReply,
+														Seq:     1,
+													},
+													Success:    wire.ICQStatusCodeOK,
+													ReqSubType: wire.ICQDBQueryMetaReplyAffiliations,
+													ICQ_0x07D0_0x041A_DBQueryMetaReqSetAffiliations: wire.ICQ_0x07D0_0x041A_DBQueryMetaReqSetAffiliations{
+														PastAffiliations: []struct {
+															Code    uint16
+															Keyword string `oscar:"len_prefix=uint16,nullterm"`
+														}{
+															{
+																Code:    9012,
+																Keyword: "College",
+															},
+															{
+																Code:    1233,
+																Keyword: "High School",
+															},
+															{
+																Code:    3456,
+																Keyword: "Previous Job",
+															},
+														},
+														Affiliations: []struct {
+															Code    uint16
+															Keyword string `oscar:"len_prefix=uint16,nullterm"`
+														}{
+															{
+																Code:    4567,
+																Keyword: "Professional Org",
+															},
+															{
+																Code:    5678,
+																Keyword: "Alumni Group",
+															},
+															{
+																Code:    6789,
+																Keyword: "Community Group",
+															},
+														},
+													},
+												},
+											}),
+										},
+									},
+								},
+							},
+						},
+					},
+				},
+				sessionRetrieverParams: sessionRetrieverParams{
+					retrieveSessionParams{
+						{
+							screenName: state.NewIdentScreenName("123456789"),
+							result:     &state.Session{},
+						},
+					},
+				},
+			},
+		},
+	}
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			userFinder := newMockICQUserFinder(t)
+			for _, params := range tt.mockParams.findByUINParams {
+				userFinder.EXPECT().
+					FindByUIN(params.UIN).
+					Return(params.result, params.err)
+			}
+
+			messageRelayer := newMockMessageRelayer(t)
+			for _, params := range tt.mockParams.relayToScreenNameParams {
+				messageRelayer.EXPECT().RelayToScreenName(mock.Anything, params.screenName, params.message)
+			}
+
+			s := ICQService{
+				messageRelayer: messageRelayer,
+				timeNow:        tt.timeNow,
+				userFinder:     userFinder,
+			}
+			err := s.FullUserInfo(nil, tt.sess, tt.req, tt.seq)
+			assert.NoError(t, err)
+		})
+	}
+}
+
+func TestICQService_MessagesEOF(t *testing.T) {
+	tests := []struct {
+		name       string
+		seq        uint16
+		sess       *state.Session
+		mockParams mockParams
+		wantErr    error
+	}{
+		{
+			name: "happy path",
+			seq:  1,
+			sess: newTestSession("11111111", sessOptUIN(11111111)),
+			mockParams: mockParams{
+				messageRelayerParams: messageRelayerParams{
+					relayToScreenNameParams: relayToScreenNameParams{
+						{
+							screenName: state.NewIdentScreenName("11111111"),
+							message: wire.SNACMessage{
+								Frame: wire.SNACFrame{
+									FoodGroup: wire.ICQ,
+									SubGroup:  wire.ICQDBReply,
+								},
+								Body: wire.SNAC_0x0F_0x02_ICQDBReply{
+									TLVRestBlock: wire.TLVRestBlock{
+										TLVList: wire.TLVList{
+											wire.NewTLV(wire.ICQTLVTagsMetadata, wire.ICQMessageReplyEnvelope{
+												Message: wire.ICQ_0x0042_DBQueryOfflineMsgReplyLast{
+													ICQMetadata: wire.ICQMetadata{
+														UIN:     11111111,
+														ReqType: wire.ICQDBQueryOfflineMsgReplyLast,
+														Seq:     1,
+													},
+													DroppedMessages: 0,
+												},
+											}),
+										},
+									},
+								},
+							},
+						},
+					},
+				},
+			},
+		},
+	}
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			messageRelayer := newMockMessageRelayer(t)
+			for _, params := range tt.mockParams.relayToScreenNameParams {
+				messageRelayer.EXPECT().RelayToScreenName(mock.Anything, params.screenName, params.message)
+			}
+
+			s := NewICQService(messageRelayer, nil, nil, slog.Default(), nil)
+			err := s.MessagesEOF(nil, tt.sess, tt.seq)
+			assert.NoError(t, err)
+		})
+	}
+}
+
+func TestICQService_SetAffiliations(t *testing.T) {
+	tests := []struct {
+		name       string
+		seq        uint16
+		sess       *state.Session
+		req        wire.ICQ_0x07D0_0x041A_DBQueryMetaReqSetAffiliations
+		mockParams mockParams
+		wantErr    error
+	}{
+		{
+			name: "happy path",
+			seq:  1,
+			sess: newTestSession("100003", sessOptUIN(100003)),
+			req: wire.ICQ_0x07D0_0x041A_DBQueryMetaReqSetAffiliations{
+				PastAffiliations: []struct {
+					Code    uint16
+					Keyword string `oscar:"len_prefix=uint16,nullterm"`
+				}{
+					{
+						Code:    1,
+						Keyword: "kw1",
+					},
+					{
+						Code:    2,
+						Keyword: "kw2",
+					},
+					{
+						Code:    3,
+						Keyword: "kw3",
+					},
+				},
+				Affiliations: []struct {
+					Code    uint16
+					Keyword string `oscar:"len_prefix=uint16,nullterm"`
+				}{
+					{
+						Code:    4,
+						Keyword: "kw4",
+					},
+					{
+						Code:    5,
+						Keyword: "kw5",
+					},
+					{
+						Code:    6,
+						Keyword: "kw6",
+					},
+				},
+			},
+			mockParams: mockParams{
+				icqUserUpdaterParams: icqUserUpdaterParams{
+					setAffiliationsParams: setAffiliationsParams{
+						{
+							name: state.NewIdentScreenName("100003"),
+							data: state.ICQAffiliations{
+								PastCode1:       1,
+								PastKeyword1:    "kw1",
+								PastCode2:       2,
+								PastKeyword2:    "kw2",
+								PastCode3:       3,
+								PastKeyword3:    "kw3",
+								CurrentCode1:    4,
+								CurrentKeyword1: "kw4",
+								CurrentCode2:    5,
+								CurrentKeyword2: "kw5",
+								CurrentCode3:    6,
+								CurrentKeyword3: "kw6",
+							},
+						},
+					},
+				},
+				messageRelayerParams: messageRelayerParams{
+					relayToScreenNameParams: relayToScreenNameParams{
+						{
+							screenName: state.NewIdentScreenName("100003"),
+							message: wire.SNACMessage{
+								Frame: wire.SNACFrame{
+									FoodGroup: wire.ICQ,
+									SubGroup:  wire.ICQDBReply,
+								},
+								Body: wire.SNAC_0x0F_0x02_ICQDBReply{
+									TLVRestBlock: wire.TLVRestBlock{
+										TLVList: wire.TLVList{
+											wire.NewTLV(wire.ICQTLVTagsMetadata, wire.ICQMessageReplyEnvelope{
+												Message: wire.ICQ_0x07DA_0x00DC_DBQueryMetaReplyMoreInfo{
+													ICQMetadata: wire.ICQMetadata{
+														UIN:     100003,
+														ReqType: wire.ICQDBQueryMetaReply,
+														Seq:     1,
+													},
+													ReqSubType: wire.ICQDBQueryMetaReplySetAffiliations,
+													Success:    wire.ICQStatusCodeOK,
+												},
+											}),
+										},
+									},
+								},
+							},
+						},
+					},
+				},
+			},
+		},
+		{
+			name: "err: unexpected affiliations count",
+			seq:  1,
+			sess: newTestSession("100003", sessOptUIN(100003)),
+			req: wire.ICQ_0x07D0_0x041A_DBQueryMetaReqSetAffiliations{
+				PastAffiliations: []struct {
+					Code    uint16
+					Keyword string `oscar:"len_prefix=uint16,nullterm"`
+				}{
+					{
+						Code:    1,
+						Keyword: "kw1",
+					},
+				},
+				Affiliations: []struct {
+					Code    uint16
+					Keyword string `oscar:"len_prefix=uint16,nullterm"`
+				}{
+					{
+						Code:    4,
+						Keyword: "kw4",
+					},
+				},
+			},
+			wantErr: errICQBadRequest,
+		},
+	}
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			userUpdater := newMockICQUserUpdater(t)
+			for _, params := range tt.mockParams.setAffiliationsParams {
+				userUpdater.EXPECT().
+					SetAffiliations(params.name, params.data).
+					Return(params.err)
+			}
+
+			messageRelayer := newMockMessageRelayer(t)
+			for _, params := range tt.mockParams.relayToScreenNameParams {
+				messageRelayer.EXPECT().RelayToScreenName(mock.Anything, params.screenName, params.message)
+			}
+
+			s := ICQService{
+				userUpdater:    userUpdater,
+				messageRelayer: messageRelayer,
+			}
+			err := s.SetAffiliations(nil, tt.sess, tt.req, tt.seq)
+			assert.ErrorIs(t, err, tt.wantErr)
+		})
+	}
+}
+
+func TestICQService_SetEmails(t *testing.T) {
+	tests := []struct {
+		name       string
+		seq        uint16
+		sess       *state.Session
+		req        wire.ICQ_0x07D0_0x040B_DBQueryMetaReqSetEmails
+		mockParams mockParams
+		wantErr    error
+	}{
+		{
+			name: "happy path",
+			seq:  1,
+			sess: newTestSession("100003", sessOptUIN(100003)),
+			req: wire.ICQ_0x07D0_0x040B_DBQueryMetaReqSetEmails{
+				Emails: []struct {
+					Publish uint8
+					Email   string `oscar:"len_prefix=uint16,nullterm"`
+				}{
+					{
+						Publish: 1,
+						Email:   "test@aol.com",
+					},
+				},
+			},
+			mockParams: mockParams{
+				messageRelayerParams: messageRelayerParams{
+					relayToScreenNameParams: relayToScreenNameParams{
+						{
+							screenName: state.NewIdentScreenName("100003"),
+							message: wire.SNACMessage{
+								Frame: wire.SNACFrame{
+									FoodGroup: wire.ICQ,
+									SubGroup:  wire.ICQDBReply,
+								},
+								Body: wire.SNAC_0x0F_0x02_ICQDBReply{
+									TLVRestBlock: wire.TLVRestBlock{
+										TLVList: wire.TLVList{
+											wire.NewTLV(wire.ICQTLVTagsMetadata, wire.ICQMessageReplyEnvelope{
+												Message: wire.ICQ_0x07DA_0x00DC_DBQueryMetaReplyMoreInfo{
+													ICQMetadata: wire.ICQMetadata{
+														UIN:     100003,
+														ReqType: wire.ICQDBQueryMetaReply,
+														Seq:     1,
+													},
+													ReqSubType: wire.ICQDBQueryMetaReplySetEmails,
+													Success:    wire.ICQStatusCodeOK,
+												},
+											}),
+										},
+									},
+								},
+							},
+						},
+					},
+				},
+			},
+		},
+	}
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			messageRelayer := newMockMessageRelayer(t)
+			for _, params := range tt.mockParams.relayToScreenNameParams {
+				messageRelayer.EXPECT().RelayToScreenName(mock.Anything, params.screenName, params.message)
+			}
+
+			s := ICQService{
+				logger:         slog.Default(),
+				messageRelayer: messageRelayer,
+			}
+			err := s.SetEmails(nil, tt.sess, tt.req, tt.seq)
+			assert.NoError(t, err)
+		})
+	}
+}
+
+func TestICQService_SetBasicInfo(t *testing.T) {
+	tests := []struct {
+		name       string
+		seq        uint16
+		sess       *state.Session
+		req        wire.ICQ_0x07D0_0x03EA_DBQueryMetaReqSetBasicInfo
+		mockParams mockParams
+		wantErr    error
+	}{
+		{
+			name: "happy path",
+			seq:  1,
+			sess: newTestSession("100003", sessOptUIN(100003)),
+			req: wire.ICQ_0x07D0_0x03EA_DBQueryMetaReqSetBasicInfo{
+				CellPhone:    "123-456-7890",
+				CountryCode:  1,
+				EmailAddress: "test@example.com",
+				FirstName:    "John",
+				GMTOffset:    5,
+				HomeAddress:  "123 Main St",
+				City:         "Anytown",
+				Fax:          "098-765-4321",
+				Phone:        "111-222-3333",
+				State:        "CA",
+				LastName:     "Doe",
+				Nickname:     "Johnny",
+				PublishEmail: wire.ICQUserFlagPublishEmailYes,
+				ZIP:          "12345",
+			},
+			mockParams: mockParams{
+				icqUserUpdaterParams: icqUserUpdaterParams{
+					setBasicInfoParams: setBasicInfoParams{
+						{
+							name: state.NewIdentScreenName("100003"),
+							data: state.ICQBasicInfo{
+								CellPhone:    "123-456-7890",
+								CountryCode:  1,
+								EmailAddress: "test@example.com",
+								FirstName:    "John",
+								GMTOffset:    5,
+								Address:      "123 Main St",
+								City:         "Anytown",
+								Fax:          "098-765-4321",
+								Phone:        "111-222-3333",
+								State:        "CA",
+								LastName:     "Doe",
+								Nickname:     "Johnny",
+								PublishEmail: true,
+								ZIPCode:      "12345",
+							},
+						},
+					},
+				},
+				messageRelayerParams: messageRelayerParams{
+					relayToScreenNameParams: relayToScreenNameParams{
+						{
+							screenName: state.NewIdentScreenName("100003"),
+							message: wire.SNACMessage{
+								Frame: wire.SNACFrame{
+									FoodGroup: wire.ICQ,
+									SubGroup:  wire.ICQDBReply,
+								},
+								Body: wire.SNAC_0x0F_0x02_ICQDBReply{
+									TLVRestBlock: wire.TLVRestBlock{
+										TLVList: wire.TLVList{
+											wire.NewTLV(wire.ICQTLVTagsMetadata, wire.ICQMessageReplyEnvelope{
+												Message: wire.ICQ_0x07DA_0x00DC_DBQueryMetaReplyMoreInfo{
+													ICQMetadata: wire.ICQMetadata{
+														UIN:     100003,
+														ReqType: wire.ICQDBQueryMetaReply,
+														Seq:     1,
+													},
+													ReqSubType: wire.ICQDBQueryMetaReplySetBasicInfo,
+													Success:    wire.ICQStatusCodeOK,
+												},
+											}),
+										},
+									},
+								},
+							},
+						},
+					},
+				},
+			},
+		},
+	}
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			userUpdater := newMockICQUserUpdater(t)
+			for _, params := range tt.mockParams.setBasicInfoParams {
+				userUpdater.EXPECT().
+					SetBasicInfo(params.name, params.data).
+					Return(params.err)
+			}
+
+			messageRelayer := newMockMessageRelayer(t)
+			for _, params := range tt.mockParams.relayToScreenNameParams {
+				messageRelayer.EXPECT().RelayToScreenName(mock.Anything, params.screenName, params.message)
+			}
+
+			s := ICQService{
+				userUpdater:    userUpdater,
+				messageRelayer: messageRelayer,
+			}
+			err := s.SetBasicInfo(nil, tt.sess, tt.req, tt.seq)
+			assert.NoError(t, err)
+		})
+	}
+}
+
+func TestICQService_SetInterests(t *testing.T) {
+	tests := []struct {
+		name       string
+		seq        uint16
+		sess       *state.Session
+		req        wire.ICQ_0x07D0_0x0410_DBQueryMetaReqSetInterests
+		mockParams mockParams
+		wantErr    error
+	}{
+		{
+			name: "happy path",
+			seq:  1,
+			sess: newTestSession("100003", sessOptUIN(100003)),
+			req: wire.ICQ_0x07D0_0x0410_DBQueryMetaReqSetInterests{
+				Interests: []struct {
+					Code    uint16
+					Keyword string `oscar:"len_prefix=uint16,nullterm"`
+				}{
+					{
+						Code:    1,
+						Keyword: "kw1",
+					},
+					{
+						Code:    2,
+						Keyword: "kw2",
+					},
+					{
+						Code:    3,
+						Keyword: "kw3",
+					},
+					{
+						Code:    4,
+						Keyword: "kw4",
+					},
+				},
+			},
+			mockParams: mockParams{
+				icqUserUpdaterParams: icqUserUpdaterParams{
+					setInterestsParams: setInterestsParams{
+						{
+							name: state.NewIdentScreenName("100003"),
+							data: state.ICQInterests{
+								Code1:    1,
+								Keyword1: "kw1",
+								Code2:    2,
+								Keyword2: "kw2",
+								Code3:    3,
+								Keyword3: "kw3",
+								Code4:    4,
+								Keyword4: "kw4",
+							},
+						},
+					},
+				},
+				messageRelayerParams: messageRelayerParams{
+					relayToScreenNameParams: relayToScreenNameParams{
+						{
+							screenName: state.NewIdentScreenName("100003"),
+							message: wire.SNACMessage{
+								Frame: wire.SNACFrame{
+									FoodGroup: wire.ICQ,
+									SubGroup:  wire.ICQDBReply,
+								},
+								Body: wire.SNAC_0x0F_0x02_ICQDBReply{
+									TLVRestBlock: wire.TLVRestBlock{
+										TLVList: wire.TLVList{
+											wire.NewTLV(wire.ICQTLVTagsMetadata, wire.ICQMessageReplyEnvelope{
+												Message: wire.ICQ_0x07DA_0x00DC_DBQueryMetaReplyMoreInfo{
+													ICQMetadata: wire.ICQMetadata{
+														UIN:     100003,
+														ReqType: wire.ICQDBQueryMetaReply,
+														Seq:     1,
+													},
+													ReqSubType: wire.ICQDBQueryMetaReplySetInterests,
+													Success:    wire.ICQStatusCodeOK,
+												},
+											}),
+										},
+									},
+								},
+							},
+						},
+					},
+				},
+			},
+		},
+		{
+			name: "err: unexpected interest count",
+			seq:  1,
+			sess: newTestSession("100003", sessOptUIN(100003)),
+			req: wire.ICQ_0x07D0_0x0410_DBQueryMetaReqSetInterests{
+				Interests: []struct {
+					Code    uint16
+					Keyword string `oscar:"len_prefix=uint16,nullterm"`
+				}{
+					{
+						Code:    1,
+						Keyword: "kw1",
+					},
+					{
+						Code:    2,
+						Keyword: "kw2",
+					},
+				},
+			},
+			wantErr: errICQBadRequest,
+		},
+	}
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			userUpdater := newMockICQUserUpdater(t)
+			for _, params := range tt.mockParams.setInterestsParams {
+				userUpdater.EXPECT().
+					SetInterests(params.name, params.data).
+					Return(params.err)
+			}
+
+			messageRelayer := newMockMessageRelayer(t)
+			for _, params := range tt.mockParams.relayToScreenNameParams {
+				messageRelayer.EXPECT().RelayToScreenName(mock.Anything, params.screenName, params.message)
+			}
+
+			s := ICQService{
+				userUpdater:    userUpdater,
+				messageRelayer: messageRelayer,
+			}
+			err := s.SetInterests(nil, tt.sess, tt.req, tt.seq)
+			assert.ErrorIs(t, err, tt.wantErr)
+		})
+	}
+}
+
+func TestICQService_SetMoreInfo(t *testing.T) {
+	tests := []struct {
+		name       string
+		seq        uint16
+		sess       *state.Session
+		req        wire.ICQ_0x07D0_0x03FD_DBQueryMetaReqSetMoreInfo
+		mockParams mockParams
+		wantErr    error
+	}{
+		{
+			name: "happy path",
+			seq:  1,
+			sess: newTestSession("100003", sessOptUIN(100003)),
+			req: wire.ICQ_0x07D0_0x03FD_DBQueryMetaReqSetMoreInfo{
+				Age:          0,
+				BirthDay:     7,
+				BirthMonth:   8,
+				BirthYear:    1994,
+				Gender:       1,
+				HomePageAddr: "http://www.johndoe.com",
+				Lang1:        1,
+				Lang2:        2,
+				Lang3:        3,
+			},
+			mockParams: mockParams{
+				icqUserUpdaterParams: icqUserUpdaterParams{
+					setMoreInfoParams: setMoreInfoParams{
+						{
+							name: state.NewIdentScreenName("100003"),
+							data: state.ICQMoreInfo{
+								BirthDay:     7,
+								BirthMonth:   8,
+								BirthYear:    1994,
+								Gender:       1,
+								HomePageAddr: "http://www.johndoe.com",
+								Lang1:        1,
+								Lang2:        2,
+								Lang3:        3,
+							},
+						},
+					},
+				},
+				messageRelayerParams: messageRelayerParams{
+					relayToScreenNameParams: relayToScreenNameParams{
+						{
+							screenName: state.NewIdentScreenName("100003"),
+							message: wire.SNACMessage{
+								Frame: wire.SNACFrame{
+									FoodGroup: wire.ICQ,
+									SubGroup:  wire.ICQDBReply,
+								},
+								Body: wire.SNAC_0x0F_0x02_ICQDBReply{
+									TLVRestBlock: wire.TLVRestBlock{
+										TLVList: wire.TLVList{
+											wire.NewTLV(wire.ICQTLVTagsMetadata, wire.ICQMessageReplyEnvelope{
+												Message: wire.ICQ_0x07DA_0x00DC_DBQueryMetaReplyMoreInfo{
+													ICQMetadata: wire.ICQMetadata{
+														UIN:     100003,
+														ReqType: wire.ICQDBQueryMetaReply,
+														Seq:     1,
+													},
+													ReqSubType: wire.ICQDBQueryMetaReplySetMoreInfo,
+													Success:    wire.ICQStatusCodeOK,
+												},
+											}),
+										},
+									},
+								},
+							},
+						},
+					},
+				},
+			},
+		},
+	}
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			userUpdater := newMockICQUserUpdater(t)
+			for _, params := range tt.mockParams.setMoreInfoParams {
+				userUpdater.EXPECT().
+					SetMoreInfo(params.name, params.data).
+					Return(params.err)
+			}
+
+			messageRelayer := newMockMessageRelayer(t)
+			for _, params := range tt.mockParams.relayToScreenNameParams {
+				messageRelayer.EXPECT().RelayToScreenName(mock.Anything, params.screenName, params.message)
+			}
+
+			s := ICQService{
+				userUpdater:    userUpdater,
+				messageRelayer: messageRelayer,
+			}
+			err := s.SetMoreInfo(nil, tt.sess, tt.req, tt.seq)
+			assert.NoError(t, err)
+		})
+	}
+}
+
+func TestICQService_SetPermissions(t *testing.T) {
+	tests := []struct {
+		name       string
+		seq        uint16
+		sess       *state.Session
+		req        wire.ICQ_0x07D0_0x0424_DBQueryMetaReqSetPermissions
+		mockParams mockParams
+		wantErr    error
+	}{
+		{
+			name: "happy path",
+			seq:  1,
+			sess: newTestSession("100003", sessOptUIN(100003)),
+			req:  wire.ICQ_0x07D0_0x0424_DBQueryMetaReqSetPermissions{},
+			mockParams: mockParams{
+				messageRelayerParams: messageRelayerParams{
+					relayToScreenNameParams: relayToScreenNameParams{
+						{
+							screenName: state.NewIdentScreenName("100003"),
+							message: wire.SNACMessage{
+								Frame: wire.SNACFrame{
+									FoodGroup: wire.ICQ,
+									SubGroup:  wire.ICQDBReply,
+								},
+								Body: wire.SNAC_0x0F_0x02_ICQDBReply{
+									TLVRestBlock: wire.TLVRestBlock{
+										TLVList: wire.TLVList{
+											wire.NewTLV(wire.ICQTLVTagsMetadata, wire.ICQMessageReplyEnvelope{
+												Message: wire.ICQ_0x07DA_0x00DC_DBQueryMetaReplyMoreInfo{
+													ICQMetadata: wire.ICQMetadata{
+														UIN:     100003,
+														ReqType: wire.ICQDBQueryMetaReply,
+														Seq:     1,
+													},
+													ReqSubType: wire.ICQDBQueryMetaReplySetPermissions,
+													Success:    wire.ICQStatusCodeOK,
+												},
+											}),
+										},
+									},
+								},
+							},
+						},
+					},
+				},
+			},
+		},
+	}
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			messageRelayer := newMockMessageRelayer(t)
+			for _, params := range tt.mockParams.relayToScreenNameParams {
+				messageRelayer.EXPECT().RelayToScreenName(mock.Anything, params.screenName, params.message)
+			}
+
+			s := ICQService{
+				logger:         slog.Default(),
+				messageRelayer: messageRelayer,
+			}
+			err := s.SetPermissions(nil, tt.sess, tt.req, tt.seq)
+			assert.NoError(t, err)
+		})
+	}
+}
+
+func TestICQService_SetUserNotes(t *testing.T) {
+	tests := []struct {
+		name       string
+		seq        uint16
+		sess       *state.Session
+		req        wire.ICQ_0x07D0_0x0406_DBQueryMetaReqSetNotes
+		mockParams mockParams
+		wantErr    error
+	}{
+		{
+			name: "happy path",
+			seq:  1,
+			sess: newTestSession("100003", sessOptUIN(100003)),
+			req: wire.ICQ_0x07D0_0x0406_DBQueryMetaReqSetNotes{
+				Notes: "here is my note",
+			},
+			mockParams: mockParams{
+				icqUserUpdaterParams: icqUserUpdaterParams{
+					setUserNotesParams: setUserNotesParams{
+						{
+							name: state.NewIdentScreenName("100003"),
+							data: state.ICQUserNotes{
+								Notes: "here is my note",
+							},
+						},
+					},
+				},
+				messageRelayerParams: messageRelayerParams{
+					relayToScreenNameParams: relayToScreenNameParams{
+						{
+							screenName: state.NewIdentScreenName("100003"),
+							message: wire.SNACMessage{
+								Frame: wire.SNACFrame{
+									FoodGroup: wire.ICQ,
+									SubGroup:  wire.ICQDBReply,
+								},
+								Body: wire.SNAC_0x0F_0x02_ICQDBReply{
+									TLVRestBlock: wire.TLVRestBlock{
+										TLVList: wire.TLVList{
+											wire.NewTLV(wire.ICQTLVTagsMetadata, wire.ICQMessageReplyEnvelope{
+												Message: wire.ICQ_0x07DA_0x00DC_DBQueryMetaReplyMoreInfo{
+													ICQMetadata: wire.ICQMetadata{
+														UIN:     100003,
+														ReqType: wire.ICQDBQueryMetaReply,
+														Seq:     1,
+													},
+													ReqSubType: wire.ICQDBQueryMetaReplySetNotes,
+													Success:    wire.ICQStatusCodeOK,
+												},
+											}),
+										},
+									},
+								},
+							},
+						},
+					},
+				},
+			},
+		},
+	}
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			userUpdater := newMockICQUserUpdater(t)
+			for _, params := range tt.mockParams.setUserNotesParams {
+				userUpdater.EXPECT().
+					SetUserNotes(params.name, params.data).
+					Return(params.err)
+			}
+
+			messageRelayer := newMockMessageRelayer(t)
+			for _, params := range tt.mockParams.relayToScreenNameParams {
+				messageRelayer.EXPECT().RelayToScreenName(mock.Anything, params.screenName, params.message)
+			}
+
+			s := ICQService{
+				userUpdater:    userUpdater,
+				messageRelayer: messageRelayer,
+			}
+			err := s.SetUserNotes(nil, tt.sess, tt.req, tt.seq)
+			assert.NoError(t, err)
+		})
+	}
+}
+
+func TestICQService_SetWorkInfo(t *testing.T) {
+	tests := []struct {
+		name       string
+		seq        uint16
+		sess       *state.Session
+		req        wire.ICQ_0x07D0_0x03F3_DBQueryMetaReqSetWorkInfo
+		mockParams mockParams
+		wantErr    error
+	}{
+		{
+			name: "happy path",
+			seq:  1,
+			sess: newTestSession("100003", sessOptUIN(100003)),
+			req: wire.ICQ_0x07D0_0x03F3_DBQueryMetaReqSetWorkInfo{
+				Company:        "TechCorp Inc.",
+				Department:     "Engineering",
+				OccupationCode: 1023,
+				Position:       "Staff Software Engineer",
+				Address:        "456 Technology Blvd",
+				City:           "Innovate City",
+				CountryCode:    1,
+				Fax:            "987-654-3210",
+				Phone:          "222-333-4444",
+				State:          "CA",
+				WebPage:        "http://www.techcorp.com",
+				ZIP:            "67890",
+			},
+			mockParams: mockParams{
+				icqUserUpdaterParams: icqUserUpdaterParams{
+					setWorkInfoParams: setWorkInfoParams{
+						{
+							name: state.NewIdentScreenName("100003"),
+							data: state.ICQWorkInfo{
+								Company:        "TechCorp Inc.",
+								Department:     "Engineering",
+								OccupationCode: 1023,
+								Position:       "Staff Software Engineer",
+								Address:        "456 Technology Blvd",
+								City:           "Innovate City",
+								CountryCode:    1,
+								Fax:            "987-654-3210",
+								Phone:          "222-333-4444",
+								State:          "CA",
+								WebPage:        "http://www.techcorp.com",
+								ZIPCode:        "67890",
+							},
+						},
+					},
+				},
+				messageRelayerParams: messageRelayerParams{
+					relayToScreenNameParams: relayToScreenNameParams{
+						{
+							screenName: state.NewIdentScreenName("100003"),
+							message: wire.SNACMessage{
+								Frame: wire.SNACFrame{
+									FoodGroup: wire.ICQ,
+									SubGroup:  wire.ICQDBReply,
+								},
+								Body: wire.SNAC_0x0F_0x02_ICQDBReply{
+									TLVRestBlock: wire.TLVRestBlock{
+										TLVList: wire.TLVList{
+											wire.NewTLV(wire.ICQTLVTagsMetadata, wire.ICQMessageReplyEnvelope{
+												Message: wire.ICQ_0x07DA_0x00DC_DBQueryMetaReplyMoreInfo{
+													ICQMetadata: wire.ICQMetadata{
+														UIN:     100003,
+														ReqType: wire.ICQDBQueryMetaReply,
+														Seq:     1,
+													},
+													ReqSubType: wire.ICQDBQueryMetaReplySetWorkInfo,
+													Success:    wire.ICQStatusCodeOK,
+												},
+											}),
+										},
+									},
+								},
+							},
+						},
+					},
+				},
+			},
+		},
+	}
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			userUpdater := newMockICQUserUpdater(t)
+			for _, params := range tt.mockParams.setWorkInfoParams {
+				userUpdater.EXPECT().
+					SetWorkInfo(params.name, params.data).
+					Return(params.err)
+			}
+
+			messageRelayer := newMockMessageRelayer(t)
+			for _, params := range tt.mockParams.relayToScreenNameParams {
+				messageRelayer.EXPECT().RelayToScreenName(mock.Anything, params.screenName, params.message)
+			}
+
+			s := ICQService{
+				userUpdater:    userUpdater,
+				messageRelayer: messageRelayer,
+			}
+			err := s.SetWorkInfo(nil, tt.sess, tt.req, tt.seq)
+			assert.NoError(t, err)
+		})
+	}
+}
+
+func TestICQService_XMLReqData(t *testing.T) {
+	tests := []struct {
+		name       string
+		timeNow    func() time.Time
+		seq        uint16
+		sess       *state.Session
+		req        wire.ICQ_0x07D0_0x0898_DBQueryMetaReqXMLReq
+		mockParams mockParams
+		wantErr    error
+	}{
+		{
+			name: "happy path",
+			timeNow: func() time.Time {
+				return time.Date(2020, time.August, 1, 0, 0, 0, 0, time.UTC)
+			},
+			seq:  1,
+			sess: newTestSession("11111111", sessOptUIN(11111111)),
+			req: wire.ICQ_0x07D0_0x0898_DBQueryMetaReqXMLReq{
+				XMLRequest: "<xml></xml>",
+			},
+			mockParams: mockParams{
+				messageRelayerParams: messageRelayerParams{
+					relayToScreenNameParams: relayToScreenNameParams{
+						{
+							screenName: state.NewIdentScreenName("11111111"),
+							message: wire.SNACMessage{
+								Frame: wire.SNACFrame{
+									FoodGroup: wire.ICQ,
+									SubGroup:  wire.ICQDBReply,
+								},
+								Body: wire.SNAC_0x0F_0x02_ICQDBReply{
+									TLVRestBlock: wire.TLVRestBlock{
+										TLVList: wire.TLVList{
+											wire.NewTLV(wire.ICQTLVTagsMetadata, wire.ICQMessageReplyEnvelope{
+												Message: wire.ICQ_0x07DA_0x08A2_DBQueryMetaReplyXMLData{
+													ICQMetadata: wire.ICQMetadata{
+														UIN:     11111111,
+														ReqType: wire.ICQDBQueryMetaReply,
+														Seq:     1,
+													},
+													ReqSubType: wire.ICQDBQueryMetaReplyXMLData,
+													Success:    wire.ICQStatusCodeFail,
+												},
+											}),
+										},
+									},
+								},
+							},
+						},
+					},
+				},
+			},
+		},
+	}
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			messageRelayer := newMockMessageRelayer(t)
+			for _, params := range tt.mockParams.relayToScreenNameParams {
+				messageRelayer.EXPECT().RelayToScreenName(mock.Anything, params.screenName, params.message)
+			}
+			s := ICQService{
+				messageRelayer: messageRelayer,
+				timeNow:        tt.timeNow,
+			}
+			err := s.XMLReqData(nil, tt.sess, tt.req, tt.seq)
+			assert.NoError(t, err)
+		})
+	}
+}

+ 266 - 0
foodgroup/mock_icq_user_finder_test.go

@@ -0,0 +1,266 @@
+// Code generated by mockery v2.43.2. DO NOT EDIT.
+
+package foodgroup
+
+import (
+	state "github.com/mk6i/retro-aim-server/state"
+	mock "github.com/stretchr/testify/mock"
+)
+
+// mockICQUserFinder is an autogenerated mock type for the ICQUserFinder type
+type mockICQUserFinder struct {
+	mock.Mock
+}
+
+type mockICQUserFinder_Expecter struct {
+	mock *mock.Mock
+}
+
+func (_m *mockICQUserFinder) EXPECT() *mockICQUserFinder_Expecter {
+	return &mockICQUserFinder_Expecter{mock: &_m.Mock}
+}
+
+// FindByDetails provides a mock function with given fields: firstName, lastName, nickName
+func (_m *mockICQUserFinder) FindByDetails(firstName string, lastName string, nickName string) ([]state.User, error) {
+	ret := _m.Called(firstName, lastName, nickName)
+
+	if len(ret) == 0 {
+		panic("no return value specified for FindByDetails")
+	}
+
+	var r0 []state.User
+	var r1 error
+	if rf, ok := ret.Get(0).(func(string, string, string) ([]state.User, error)); ok {
+		return rf(firstName, lastName, nickName)
+	}
+	if rf, ok := ret.Get(0).(func(string, string, string) []state.User); ok {
+		r0 = rf(firstName, lastName, nickName)
+	} else {
+		if ret.Get(0) != nil {
+			r0 = ret.Get(0).([]state.User)
+		}
+	}
+
+	if rf, ok := ret.Get(1).(func(string, string, string) error); ok {
+		r1 = rf(firstName, lastName, nickName)
+	} else {
+		r1 = ret.Error(1)
+	}
+
+	return r0, r1
+}
+
+// mockICQUserFinder_FindByDetails_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'FindByDetails'
+type mockICQUserFinder_FindByDetails_Call struct {
+	*mock.Call
+}
+
+// FindByDetails is a helper method to define mock.On call
+//   - firstName string
+//   - lastName string
+//   - nickName string
+func (_e *mockICQUserFinder_Expecter) FindByDetails(firstName interface{}, lastName interface{}, nickName interface{}) *mockICQUserFinder_FindByDetails_Call {
+	return &mockICQUserFinder_FindByDetails_Call{Call: _e.mock.On("FindByDetails", firstName, lastName, nickName)}
+}
+
+func (_c *mockICQUserFinder_FindByDetails_Call) Run(run func(firstName string, lastName string, nickName string)) *mockICQUserFinder_FindByDetails_Call {
+	_c.Call.Run(func(args mock.Arguments) {
+		run(args[0].(string), args[1].(string), args[2].(string))
+	})
+	return _c
+}
+
+func (_c *mockICQUserFinder_FindByDetails_Call) Return(_a0 []state.User, _a1 error) *mockICQUserFinder_FindByDetails_Call {
+	_c.Call.Return(_a0, _a1)
+	return _c
+}
+
+func (_c *mockICQUserFinder_FindByDetails_Call) RunAndReturn(run func(string, string, string) ([]state.User, error)) *mockICQUserFinder_FindByDetails_Call {
+	_c.Call.Return(run)
+	return _c
+}
+
+// FindByEmail provides a mock function with given fields: email
+func (_m *mockICQUserFinder) FindByEmail(email string) (state.User, error) {
+	ret := _m.Called(email)
+
+	if len(ret) == 0 {
+		panic("no return value specified for FindByEmail")
+	}
+
+	var r0 state.User
+	var r1 error
+	if rf, ok := ret.Get(0).(func(string) (state.User, error)); ok {
+		return rf(email)
+	}
+	if rf, ok := ret.Get(0).(func(string) state.User); ok {
+		r0 = rf(email)
+	} else {
+		r0 = ret.Get(0).(state.User)
+	}
+
+	if rf, ok := ret.Get(1).(func(string) error); ok {
+		r1 = rf(email)
+	} else {
+		r1 = ret.Error(1)
+	}
+
+	return r0, r1
+}
+
+// mockICQUserFinder_FindByEmail_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'FindByEmail'
+type mockICQUserFinder_FindByEmail_Call struct {
+	*mock.Call
+}
+
+// FindByEmail is a helper method to define mock.On call
+//   - email string
+func (_e *mockICQUserFinder_Expecter) FindByEmail(email interface{}) *mockICQUserFinder_FindByEmail_Call {
+	return &mockICQUserFinder_FindByEmail_Call{Call: _e.mock.On("FindByEmail", email)}
+}
+
+func (_c *mockICQUserFinder_FindByEmail_Call) Run(run func(email string)) *mockICQUserFinder_FindByEmail_Call {
+	_c.Call.Run(func(args mock.Arguments) {
+		run(args[0].(string))
+	})
+	return _c
+}
+
+func (_c *mockICQUserFinder_FindByEmail_Call) Return(_a0 state.User, _a1 error) *mockICQUserFinder_FindByEmail_Call {
+	_c.Call.Return(_a0, _a1)
+	return _c
+}
+
+func (_c *mockICQUserFinder_FindByEmail_Call) RunAndReturn(run func(string) (state.User, error)) *mockICQUserFinder_FindByEmail_Call {
+	_c.Call.Return(run)
+	return _c
+}
+
+// FindByInterests provides a mock function with given fields: code, keywords
+func (_m *mockICQUserFinder) FindByInterests(code uint16, keywords []string) ([]state.User, error) {
+	ret := _m.Called(code, keywords)
+
+	if len(ret) == 0 {
+		panic("no return value specified for FindByInterests")
+	}
+
+	var r0 []state.User
+	var r1 error
+	if rf, ok := ret.Get(0).(func(uint16, []string) ([]state.User, error)); ok {
+		return rf(code, keywords)
+	}
+	if rf, ok := ret.Get(0).(func(uint16, []string) []state.User); ok {
+		r0 = rf(code, keywords)
+	} else {
+		if ret.Get(0) != nil {
+			r0 = ret.Get(0).([]state.User)
+		}
+	}
+
+	if rf, ok := ret.Get(1).(func(uint16, []string) error); ok {
+		r1 = rf(code, keywords)
+	} else {
+		r1 = ret.Error(1)
+	}
+
+	return r0, r1
+}
+
+// mockICQUserFinder_FindByInterests_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'FindByInterests'
+type mockICQUserFinder_FindByInterests_Call struct {
+	*mock.Call
+}
+
+// FindByInterests is a helper method to define mock.On call
+//   - code uint16
+//   - keywords []string
+func (_e *mockICQUserFinder_Expecter) FindByInterests(code interface{}, keywords interface{}) *mockICQUserFinder_FindByInterests_Call {
+	return &mockICQUserFinder_FindByInterests_Call{Call: _e.mock.On("FindByInterests", code, keywords)}
+}
+
+func (_c *mockICQUserFinder_FindByInterests_Call) Run(run func(code uint16, keywords []string)) *mockICQUserFinder_FindByInterests_Call {
+	_c.Call.Run(func(args mock.Arguments) {
+		run(args[0].(uint16), args[1].([]string))
+	})
+	return _c
+}
+
+func (_c *mockICQUserFinder_FindByInterests_Call) Return(_a0 []state.User, _a1 error) *mockICQUserFinder_FindByInterests_Call {
+	_c.Call.Return(_a0, _a1)
+	return _c
+}
+
+func (_c *mockICQUserFinder_FindByInterests_Call) RunAndReturn(run func(uint16, []string) ([]state.User, error)) *mockICQUserFinder_FindByInterests_Call {
+	_c.Call.Return(run)
+	return _c
+}
+
+// FindByUIN provides a mock function with given fields: UIN
+func (_m *mockICQUserFinder) FindByUIN(UIN uint32) (state.User, error) {
+	ret := _m.Called(UIN)
+
+	if len(ret) == 0 {
+		panic("no return value specified for FindByUIN")
+	}
+
+	var r0 state.User
+	var r1 error
+	if rf, ok := ret.Get(0).(func(uint32) (state.User, error)); ok {
+		return rf(UIN)
+	}
+	if rf, ok := ret.Get(0).(func(uint32) state.User); ok {
+		r0 = rf(UIN)
+	} else {
+		r0 = ret.Get(0).(state.User)
+	}
+
+	if rf, ok := ret.Get(1).(func(uint32) error); ok {
+		r1 = rf(UIN)
+	} else {
+		r1 = ret.Error(1)
+	}
+
+	return r0, r1
+}
+
+// mockICQUserFinder_FindByUIN_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'FindByUIN'
+type mockICQUserFinder_FindByUIN_Call struct {
+	*mock.Call
+}
+
+// FindByUIN is a helper method to define mock.On call
+//   - UIN uint32
+func (_e *mockICQUserFinder_Expecter) FindByUIN(UIN interface{}) *mockICQUserFinder_FindByUIN_Call {
+	return &mockICQUserFinder_FindByUIN_Call{Call: _e.mock.On("FindByUIN", UIN)}
+}
+
+func (_c *mockICQUserFinder_FindByUIN_Call) Run(run func(UIN uint32)) *mockICQUserFinder_FindByUIN_Call {
+	_c.Call.Run(func(args mock.Arguments) {
+		run(args[0].(uint32))
+	})
+	return _c
+}
+
+func (_c *mockICQUserFinder_FindByUIN_Call) Return(_a0 state.User, _a1 error) *mockICQUserFinder_FindByUIN_Call {
+	_c.Call.Return(_a0, _a1)
+	return _c
+}
+
+func (_c *mockICQUserFinder_FindByUIN_Call) RunAndReturn(run func(uint32) (state.User, error)) *mockICQUserFinder_FindByUIN_Call {
+	_c.Call.Return(run)
+	return _c
+}
+
+// newMockICQUserFinder creates a new instance of mockICQUserFinder. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
+// The first argument is typically a *testing.T value.
+func newMockICQUserFinder(t interface {
+	mock.TestingT
+	Cleanup(func())
+}) *mockICQUserFinder {
+	mock := &mockICQUserFinder{}
+	mock.Mock.Test(t)
+
+	t.Cleanup(func() { mock.AssertExpectations(t) })
+
+	return mock
+}

+ 317 - 0
foodgroup/mock_icq_user_updater_test.go

@@ -0,0 +1,317 @@
+// Code generated by mockery v2.43.2. DO NOT EDIT.
+
+package foodgroup
+
+import (
+	state "github.com/mk6i/retro-aim-server/state"
+	mock "github.com/stretchr/testify/mock"
+)
+
+// mockICQUserUpdater is an autogenerated mock type for the ICQUserUpdater type
+type mockICQUserUpdater struct {
+	mock.Mock
+}
+
+type mockICQUserUpdater_Expecter struct {
+	mock *mock.Mock
+}
+
+func (_m *mockICQUserUpdater) EXPECT() *mockICQUserUpdater_Expecter {
+	return &mockICQUserUpdater_Expecter{mock: &_m.Mock}
+}
+
+// SetAffiliations provides a mock function with given fields: name, data
+func (_m *mockICQUserUpdater) SetAffiliations(name state.IdentScreenName, data state.ICQAffiliations) error {
+	ret := _m.Called(name, data)
+
+	if len(ret) == 0 {
+		panic("no return value specified for SetAffiliations")
+	}
+
+	var r0 error
+	if rf, ok := ret.Get(0).(func(state.IdentScreenName, state.ICQAffiliations) error); ok {
+		r0 = rf(name, data)
+	} else {
+		r0 = ret.Error(0)
+	}
+
+	return r0
+}
+
+// mockICQUserUpdater_SetAffiliations_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'SetAffiliations'
+type mockICQUserUpdater_SetAffiliations_Call struct {
+	*mock.Call
+}
+
+// SetAffiliations is a helper method to define mock.On call
+//   - name state.IdentScreenName
+//   - data state.ICQAffiliations
+func (_e *mockICQUserUpdater_Expecter) SetAffiliations(name interface{}, data interface{}) *mockICQUserUpdater_SetAffiliations_Call {
+	return &mockICQUserUpdater_SetAffiliations_Call{Call: _e.mock.On("SetAffiliations", name, data)}
+}
+
+func (_c *mockICQUserUpdater_SetAffiliations_Call) Run(run func(name state.IdentScreenName, data state.ICQAffiliations)) *mockICQUserUpdater_SetAffiliations_Call {
+	_c.Call.Run(func(args mock.Arguments) {
+		run(args[0].(state.IdentScreenName), args[1].(state.ICQAffiliations))
+	})
+	return _c
+}
+
+func (_c *mockICQUserUpdater_SetAffiliations_Call) Return(_a0 error) *mockICQUserUpdater_SetAffiliations_Call {
+	_c.Call.Return(_a0)
+	return _c
+}
+
+func (_c *mockICQUserUpdater_SetAffiliations_Call) RunAndReturn(run func(state.IdentScreenName, state.ICQAffiliations) error) *mockICQUserUpdater_SetAffiliations_Call {
+	_c.Call.Return(run)
+	return _c
+}
+
+// SetBasicInfo provides a mock function with given fields: name, data
+func (_m *mockICQUserUpdater) SetBasicInfo(name state.IdentScreenName, data state.ICQBasicInfo) error {
+	ret := _m.Called(name, data)
+
+	if len(ret) == 0 {
+		panic("no return value specified for SetBasicInfo")
+	}
+
+	var r0 error
+	if rf, ok := ret.Get(0).(func(state.IdentScreenName, state.ICQBasicInfo) error); ok {
+		r0 = rf(name, data)
+	} else {
+		r0 = ret.Error(0)
+	}
+
+	return r0
+}
+
+// mockICQUserUpdater_SetBasicInfo_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'SetBasicInfo'
+type mockICQUserUpdater_SetBasicInfo_Call struct {
+	*mock.Call
+}
+
+// SetBasicInfo is a helper method to define mock.On call
+//   - name state.IdentScreenName
+//   - data state.ICQBasicInfo
+func (_e *mockICQUserUpdater_Expecter) SetBasicInfo(name interface{}, data interface{}) *mockICQUserUpdater_SetBasicInfo_Call {
+	return &mockICQUserUpdater_SetBasicInfo_Call{Call: _e.mock.On("SetBasicInfo", name, data)}
+}
+
+func (_c *mockICQUserUpdater_SetBasicInfo_Call) Run(run func(name state.IdentScreenName, data state.ICQBasicInfo)) *mockICQUserUpdater_SetBasicInfo_Call {
+	_c.Call.Run(func(args mock.Arguments) {
+		run(args[0].(state.IdentScreenName), args[1].(state.ICQBasicInfo))
+	})
+	return _c
+}
+
+func (_c *mockICQUserUpdater_SetBasicInfo_Call) Return(_a0 error) *mockICQUserUpdater_SetBasicInfo_Call {
+	_c.Call.Return(_a0)
+	return _c
+}
+
+func (_c *mockICQUserUpdater_SetBasicInfo_Call) RunAndReturn(run func(state.IdentScreenName, state.ICQBasicInfo) error) *mockICQUserUpdater_SetBasicInfo_Call {
+	_c.Call.Return(run)
+	return _c
+}
+
+// SetInterests provides a mock function with given fields: name, data
+func (_m *mockICQUserUpdater) SetInterests(name state.IdentScreenName, data state.ICQInterests) error {
+	ret := _m.Called(name, data)
+
+	if len(ret) == 0 {
+		panic("no return value specified for SetInterests")
+	}
+
+	var r0 error
+	if rf, ok := ret.Get(0).(func(state.IdentScreenName, state.ICQInterests) error); ok {
+		r0 = rf(name, data)
+	} else {
+		r0 = ret.Error(0)
+	}
+
+	return r0
+}
+
+// mockICQUserUpdater_SetInterests_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'SetInterests'
+type mockICQUserUpdater_SetInterests_Call struct {
+	*mock.Call
+}
+
+// SetInterests is a helper method to define mock.On call
+//   - name state.IdentScreenName
+//   - data state.ICQInterests
+func (_e *mockICQUserUpdater_Expecter) SetInterests(name interface{}, data interface{}) *mockICQUserUpdater_SetInterests_Call {
+	return &mockICQUserUpdater_SetInterests_Call{Call: _e.mock.On("SetInterests", name, data)}
+}
+
+func (_c *mockICQUserUpdater_SetInterests_Call) Run(run func(name state.IdentScreenName, data state.ICQInterests)) *mockICQUserUpdater_SetInterests_Call {
+	_c.Call.Run(func(args mock.Arguments) {
+		run(args[0].(state.IdentScreenName), args[1].(state.ICQInterests))
+	})
+	return _c
+}
+
+func (_c *mockICQUserUpdater_SetInterests_Call) Return(_a0 error) *mockICQUserUpdater_SetInterests_Call {
+	_c.Call.Return(_a0)
+	return _c
+}
+
+func (_c *mockICQUserUpdater_SetInterests_Call) RunAndReturn(run func(state.IdentScreenName, state.ICQInterests) error) *mockICQUserUpdater_SetInterests_Call {
+	_c.Call.Return(run)
+	return _c
+}
+
+// SetMoreInfo provides a mock function with given fields: name, data
+func (_m *mockICQUserUpdater) SetMoreInfo(name state.IdentScreenName, data state.ICQMoreInfo) error {
+	ret := _m.Called(name, data)
+
+	if len(ret) == 0 {
+		panic("no return value specified for SetMoreInfo")
+	}
+
+	var r0 error
+	if rf, ok := ret.Get(0).(func(state.IdentScreenName, state.ICQMoreInfo) error); ok {
+		r0 = rf(name, data)
+	} else {
+		r0 = ret.Error(0)
+	}
+
+	return r0
+}
+
+// mockICQUserUpdater_SetMoreInfo_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'SetMoreInfo'
+type mockICQUserUpdater_SetMoreInfo_Call struct {
+	*mock.Call
+}
+
+// SetMoreInfo is a helper method to define mock.On call
+//   - name state.IdentScreenName
+//   - data state.ICQMoreInfo
+func (_e *mockICQUserUpdater_Expecter) SetMoreInfo(name interface{}, data interface{}) *mockICQUserUpdater_SetMoreInfo_Call {
+	return &mockICQUserUpdater_SetMoreInfo_Call{Call: _e.mock.On("SetMoreInfo", name, data)}
+}
+
+func (_c *mockICQUserUpdater_SetMoreInfo_Call) Run(run func(name state.IdentScreenName, data state.ICQMoreInfo)) *mockICQUserUpdater_SetMoreInfo_Call {
+	_c.Call.Run(func(args mock.Arguments) {
+		run(args[0].(state.IdentScreenName), args[1].(state.ICQMoreInfo))
+	})
+	return _c
+}
+
+func (_c *mockICQUserUpdater_SetMoreInfo_Call) Return(_a0 error) *mockICQUserUpdater_SetMoreInfo_Call {
+	_c.Call.Return(_a0)
+	return _c
+}
+
+func (_c *mockICQUserUpdater_SetMoreInfo_Call) RunAndReturn(run func(state.IdentScreenName, state.ICQMoreInfo) error) *mockICQUserUpdater_SetMoreInfo_Call {
+	_c.Call.Return(run)
+	return _c
+}
+
+// SetUserNotes provides a mock function with given fields: name, data
+func (_m *mockICQUserUpdater) SetUserNotes(name state.IdentScreenName, data state.ICQUserNotes) error {
+	ret := _m.Called(name, data)
+
+	if len(ret) == 0 {
+		panic("no return value specified for SetUserNotes")
+	}
+
+	var r0 error
+	if rf, ok := ret.Get(0).(func(state.IdentScreenName, state.ICQUserNotes) error); ok {
+		r0 = rf(name, data)
+	} else {
+		r0 = ret.Error(0)
+	}
+
+	return r0
+}
+
+// mockICQUserUpdater_SetUserNotes_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'SetUserNotes'
+type mockICQUserUpdater_SetUserNotes_Call struct {
+	*mock.Call
+}
+
+// SetUserNotes is a helper method to define mock.On call
+//   - name state.IdentScreenName
+//   - data state.ICQUserNotes
+func (_e *mockICQUserUpdater_Expecter) SetUserNotes(name interface{}, data interface{}) *mockICQUserUpdater_SetUserNotes_Call {
+	return &mockICQUserUpdater_SetUserNotes_Call{Call: _e.mock.On("SetUserNotes", name, data)}
+}
+
+func (_c *mockICQUserUpdater_SetUserNotes_Call) Run(run func(name state.IdentScreenName, data state.ICQUserNotes)) *mockICQUserUpdater_SetUserNotes_Call {
+	_c.Call.Run(func(args mock.Arguments) {
+		run(args[0].(state.IdentScreenName), args[1].(state.ICQUserNotes))
+	})
+	return _c
+}
+
+func (_c *mockICQUserUpdater_SetUserNotes_Call) Return(_a0 error) *mockICQUserUpdater_SetUserNotes_Call {
+	_c.Call.Return(_a0)
+	return _c
+}
+
+func (_c *mockICQUserUpdater_SetUserNotes_Call) RunAndReturn(run func(state.IdentScreenName, state.ICQUserNotes) error) *mockICQUserUpdater_SetUserNotes_Call {
+	_c.Call.Return(run)
+	return _c
+}
+
+// SetWorkInfo provides a mock function with given fields: name, data
+func (_m *mockICQUserUpdater) SetWorkInfo(name state.IdentScreenName, data state.ICQWorkInfo) error {
+	ret := _m.Called(name, data)
+
+	if len(ret) == 0 {
+		panic("no return value specified for SetWorkInfo")
+	}
+
+	var r0 error
+	if rf, ok := ret.Get(0).(func(state.IdentScreenName, state.ICQWorkInfo) error); ok {
+		r0 = rf(name, data)
+	} else {
+		r0 = ret.Error(0)
+	}
+
+	return r0
+}
+
+// mockICQUserUpdater_SetWorkInfo_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'SetWorkInfo'
+type mockICQUserUpdater_SetWorkInfo_Call struct {
+	*mock.Call
+}
+
+// SetWorkInfo is a helper method to define mock.On call
+//   - name state.IdentScreenName
+//   - data state.ICQWorkInfo
+func (_e *mockICQUserUpdater_Expecter) SetWorkInfo(name interface{}, data interface{}) *mockICQUserUpdater_SetWorkInfo_Call {
+	return &mockICQUserUpdater_SetWorkInfo_Call{Call: _e.mock.On("SetWorkInfo", name, data)}
+}
+
+func (_c *mockICQUserUpdater_SetWorkInfo_Call) Run(run func(name state.IdentScreenName, data state.ICQWorkInfo)) *mockICQUserUpdater_SetWorkInfo_Call {
+	_c.Call.Run(func(args mock.Arguments) {
+		run(args[0].(state.IdentScreenName), args[1].(state.ICQWorkInfo))
+	})
+	return _c
+}
+
+func (_c *mockICQUserUpdater_SetWorkInfo_Call) Return(_a0 error) *mockICQUserUpdater_SetWorkInfo_Call {
+	_c.Call.Return(_a0)
+	return _c
+}
+
+func (_c *mockICQUserUpdater_SetWorkInfo_Call) RunAndReturn(run func(state.IdentScreenName, state.ICQWorkInfo) error) *mockICQUserUpdater_SetWorkInfo_Call {
+	_c.Call.Return(run)
+	return _c
+}
+
+// newMockICQUserUpdater creates a new instance of mockICQUserUpdater. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
+// The first argument is typically a *testing.T value.
+func newMockICQUserUpdater(t interface {
+	mock.TestingT
+	Cleanup(func())
+}) *mockICQUserUpdater {
+	mock := &mockICQUserUpdater{}
+	mock.Mock.Test(t)
+
+	t.Cleanup(func() { mock.AssertExpectations(t) })
+
+	return mock
+}

+ 83 - 0
foodgroup/mock_session_retriever_test.go

@@ -0,0 +1,83 @@
+// Code generated by mockery v2.43.2. DO NOT EDIT.
+
+package foodgroup
+
+import (
+	state "github.com/mk6i/retro-aim-server/state"
+	mock "github.com/stretchr/testify/mock"
+)
+
+// mockSessionRetriever is an autogenerated mock type for the SessionRetriever type
+type mockSessionRetriever struct {
+	mock.Mock
+}
+
+type mockSessionRetriever_Expecter struct {
+	mock *mock.Mock
+}
+
+func (_m *mockSessionRetriever) EXPECT() *mockSessionRetriever_Expecter {
+	return &mockSessionRetriever_Expecter{mock: &_m.Mock}
+}
+
+// RetrieveSession provides a mock function with given fields: screenName
+func (_m *mockSessionRetriever) RetrieveSession(screenName state.IdentScreenName) *state.Session {
+	ret := _m.Called(screenName)
+
+	if len(ret) == 0 {
+		panic("no return value specified for RetrieveSession")
+	}
+
+	var r0 *state.Session
+	if rf, ok := ret.Get(0).(func(state.IdentScreenName) *state.Session); ok {
+		r0 = rf(screenName)
+	} else {
+		if ret.Get(0) != nil {
+			r0 = ret.Get(0).(*state.Session)
+		}
+	}
+
+	return r0
+}
+
+// mockSessionRetriever_RetrieveSession_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'RetrieveSession'
+type mockSessionRetriever_RetrieveSession_Call struct {
+	*mock.Call
+}
+
+// RetrieveSession is a helper method to define mock.On call
+//   - screenName state.IdentScreenName
+func (_e *mockSessionRetriever_Expecter) RetrieveSession(screenName interface{}) *mockSessionRetriever_RetrieveSession_Call {
+	return &mockSessionRetriever_RetrieveSession_Call{Call: _e.mock.On("RetrieveSession", screenName)}
+}
+
+func (_c *mockSessionRetriever_RetrieveSession_Call) Run(run func(screenName state.IdentScreenName)) *mockSessionRetriever_RetrieveSession_Call {
+	_c.Call.Run(func(args mock.Arguments) {
+		run(args[0].(state.IdentScreenName))
+	})
+	return _c
+}
+
+func (_c *mockSessionRetriever_RetrieveSession_Call) Return(_a0 *state.Session) *mockSessionRetriever_RetrieveSession_Call {
+	_c.Call.Return(_a0)
+	return _c
+}
+
+func (_c *mockSessionRetriever_RetrieveSession_Call) RunAndReturn(run func(state.IdentScreenName) *state.Session) *mockSessionRetriever_RetrieveSession_Call {
+	_c.Call.Return(run)
+	return _c
+}
+
+// newMockSessionRetriever creates a new instance of mockSessionRetriever. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
+// The first argument is typically a *testing.T value.
+func newMockSessionRetriever(t interface {
+	mock.TestingT
+	Cleanup(func())
+}) *mockSessionRetriever {
+	mock := &mockSessionRetriever{}
+	mock.Mock.Test(t)
+
+	t.Cleanup(func() { mock.AssertExpectations(t) })
+
+	return mock
+}

+ 8 - 1
foodgroup/oservice.go

@@ -326,6 +326,11 @@ func init() {
 			wire.AlertNotifyDisplayCapabilities,
 			wire.AlertUserOnline,
 		},
+		wire.ICQ: {
+			wire.ICQErr,
+			wire.ICQDBQuery,
+			wire.ICQDBReply,
+		},
 		wire.PermitDeny: {
 			wire.PermitDenyErr,
 			wire.PermitDenyRightsQuery,
@@ -352,6 +357,7 @@ func init() {
 		wire.Feedbag,
 		wire.BUCP,
 		wire.Alert,
+		wire.ICQ,
 		wire.PermitDeny,
 	} {
 		subGroups := foodGroupToSubgroup[foodGroup]
@@ -534,13 +540,14 @@ func NewOServiceServiceForBOS(
 			logger:                 logger,
 			foodGroups: []uint16{
 				wire.Alert,
+				wire.BART,
 				wire.Buddy,
 				wire.ChatNav,
 				wire.Feedbag,
 				wire.ICBM,
+				wire.ICQ,
 				wire.Locate,
 				wire.OService,
-				wire.BART,
 				wire.PermitDeny,
 			},
 		},

+ 14 - 1
foodgroup/oservice_test.go

@@ -1383,6 +1383,18 @@ func TestOServiceService_RateParamsQuery(t *testing.T) {
 							FoodGroup: wire.Alert,
 							SubGroup:  wire.AlertUserOnline,
 						},
+						{
+							FoodGroup: wire.ICQ,
+							SubGroup:  wire.ICQErr,
+						},
+						{
+							FoodGroup: wire.ICQ,
+							SubGroup:  wire.ICQDBQuery,
+						},
+						{
+							FoodGroup: wire.ICQ,
+							SubGroup:  wire.ICQDBReply,
+						},
 						{
 							FoodGroup: wire.PermitDeny,
 							SubGroup:  wire.PermitDenyErr,
@@ -1448,13 +1460,14 @@ func TestOServiceServiceForBOS_OServiceHostOnline(t *testing.T) {
 		Body: wire.SNAC_0x01_0x03_OServiceHostOnline{
 			FoodGroups: []uint16{
 				wire.Alert,
+				wire.BART,
 				wire.Buddy,
 				wire.ChatNav,
 				wire.Feedbag,
 				wire.ICBM,
+				wire.ICQ,
 				wire.Locate,
 				wire.OService,
-				wire.BART,
 				wire.PermitDeny,
 			},
 		},

+ 126 - 0
foodgroup/test_helpers.go

@@ -20,13 +20,132 @@ type mockParams struct {
 	chatRoomRegistryParams
 	cookieBakerParams
 	feedbagManagerParams
+	icqUserFinderParams
+	icqUserUpdaterParams
 	legacyBuddyListManagerParams
 	messageRelayerParams
 	profileManagerParams
 	sessionManagerParams
+	sessionRetrieverParams
 	userManagerParams
 }
 
+// sessionRetrieverParams is a helper struct that contains mock parameters for
+// SessionRetriever methods
+type sessionRetrieverParams struct {
+	retrieveSessionParams
+}
+
+// retrieveSessionParams is a helper struct that contains mock parameters for
+// SessionRetriever methods
+type retrieveSessionParams []struct {
+	screenName state.IdentScreenName
+	result     *state.Session
+}
+
+// icqUserFinderParams is a helper struct that contains mock parameters for
+// ICQUserFinder methods
+type icqUserFinderParams struct {
+	findByUINParams
+	findByEmailParams
+	findByDetailsParams
+	findByInterestsParams
+}
+
+// findByUINParams is the list of parameters passed at the mock
+// ICQUserFinder.FindByUIN call site
+type findByUINParams []struct {
+	UIN    uint32
+	result state.User
+	err    error
+}
+
+// findByEmailParams is the list of parameters passed at the mock
+// ICQUserFinder.FindByEmail call site
+type findByEmailParams []struct {
+	email  string
+	result state.User
+	err    error
+}
+
+// setBasicInfoParams is the list of parameters passed at the mock
+// ICQUserFinder.FindByDetails call site
+type findByDetailsParams []struct {
+	firstName string
+	lastName  string
+	nickName  string
+	result    []state.User
+	err       error
+}
+
+// setBasicInfoParams is the list of parameters passed at the mock
+// ICQUserFinder.FindByInterests call site
+type findByInterestsParams []struct {
+	code     uint16
+	keywords []string
+	result   []state.User
+	err      error
+}
+
+// icqUserUpdaterParams is a helper struct that contains mock parameters for
+// ICQUserUpdater methods
+type icqUserUpdaterParams struct {
+	setAffiliationsParams
+	setBasicInfoParams
+	setInterestsParams
+	setMoreInfoParams
+	setUserNotesParams
+	setWorkInfoParams
+}
+
+// setAffiliationsParams is the list of parameters passed at the mock
+// ICQUserUpdater.SetAffiliations call site
+type setAffiliationsParams []struct {
+	name state.IdentScreenName
+	data state.ICQAffiliations
+	err  error
+}
+
+// setInterestsParams is the list of parameters passed at the mock
+// ICQUserUpdater.SetInterests call site
+type setInterestsParams []struct {
+	name state.IdentScreenName
+	data state.ICQInterests
+	err  error
+}
+
+// setUserNotesParams is the list of parameters passed at the mock
+// ICQUserUpdater.SetUserNotes call site
+type setUserNotesParams []struct {
+	name state.IdentScreenName
+	data state.ICQUserNotes
+	err  error
+}
+
+// setBasicInfoParams is the list of parameters passed at the mock
+// ICQUserUpdater.SetBasicInfo call site
+type setBasicInfoParams []struct {
+	name state.IdentScreenName
+	data state.ICQBasicInfo
+	err  error
+}
+
+// setWorkInfoParams is the list of parameters passed at the mock
+// ICQUserUpdater.SetWorkInfo call site
+type setWorkInfoParams []struct {
+	name state.IdentScreenName
+	data state.ICQWorkInfo
+	err  error
+}
+
+// setMoreInfoParams is the list of parameters passed at the mock
+// ICQUserUpdater.SetMoreInfo call site
+type setMoreInfoParams []struct {
+	name state.IdentScreenName
+	data state.ICQMoreInfo
+	err  error
+}
+
 // bartManagerParams is a helper struct that contains mock parameters for
 // BARTManager methods
 type bartManagerParams struct {
@@ -510,6 +629,13 @@ func sessOptCaps(caps [][16]byte) func(session *state.Session) {
 	}
 }
 
+// sessOptCaps sets caps
+func sessOptUIN(UIN uint32) func(session *state.Session) {
+	return func(session *state.Session) {
+		session.SetUIN(UIN)
+	}
+}
+
 // newTestSession creates a session object with 0 or more functional options
 // applied
 func newTestSession(screenName state.DisplayScreenName, options ...func(session *state.Session)) *state.Session {

+ 24 - 0
foodgroup/types.go

@@ -172,3 +172,27 @@ type AccountManager interface {
 	UpdateConfirmStatus(confirmStatus bool, screenName state.IdentScreenName) error
 	ConfirmStatusByName(screnName state.IdentScreenName) (bool, error)
 }
+
+type ICQUserFinder interface {
+	// FindByUIN returns a user where the UIN matches the ident screen name.
+	FindByUIN(UIN uint32) (state.User, error)
+	// FindByEmail returns a user with a matching email address.
+	FindByEmail(email string) (state.User, error)
+	// FindByDetails returns a user with either a matching first name, last name, or nickname.
+	FindByDetails(firstName, lastName, nickName string) ([]state.User, error)
+	// FindByInterests returns a user who has at least one matching interest.
+	FindByInterests(code uint16, keywords []string) ([]state.User, error)
+}
+
+type ICQUserUpdater interface {
+	SetAffiliations(name state.IdentScreenName, data state.ICQAffiliations) error
+	SetBasicInfo(name state.IdentScreenName, data state.ICQBasicInfo) error
+	SetInterests(name state.IdentScreenName, data state.ICQInterests) error
+	SetMoreInfo(name state.IdentScreenName, data state.ICQMoreInfo) error
+	SetUserNotes(name state.IdentScreenName, data state.ICQUserNotes) error
+	SetWorkInfo(name state.IdentScreenName, data state.ICQWorkInfo) error
+}
+
+type SessionRetriever interface {
+	RetrieveSession(screenName state.IdentScreenName) *state.Session
+}

+ 27 - 22
server/http/mgmt_api.go

@@ -35,7 +35,7 @@ func StartManagementAPI(
 		userHandler(w, r, userManager, uuid.New, logger)
 	})
 	mux.HandleFunc("/user/password", func(w http.ResponseWriter, r *http.Request) {
-		userPasswordHandler(w, r, userManager, uuid.New, logger)
+		userPasswordHandler(w, r, userManager, logger)
 	})
 	mux.HandleFunc("/user/login", func(w http.ResponseWriter, r *http.Request) {
 		loginHandler(w, r, userManager, logger)
@@ -96,39 +96,33 @@ func deleteUserHandler(w http.ResponseWriter, r *http.Request, manager UserManag
 	_, _ = fmt.Fprintln(w, "User account successfully deleted.")
 }
 
-func userPasswordHandler(w http.ResponseWriter, r *http.Request, userManager UserManager, newUUID func() uuid.UUID, logger *slog.Logger) {
+func userPasswordHandler(w http.ResponseWriter, r *http.Request, userManager UserManager, logger *slog.Logger) {
 	switch r.Method {
 	case http.MethodPut:
-		putUserPasswordHandler(w, r, userManager, newUUID, logger)
+		putUserPasswordHandler(w, r, userManager, logger)
 	default:
 		http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
 	}
 }
 
 // putUserPasswordHandler handles the PUT /user/password endpoint.
-func putUserPasswordHandler(w http.ResponseWriter, r *http.Request, userManager UserManager, newUUID func() uuid.UUID, logger *slog.Logger) {
+func putUserPasswordHandler(w http.ResponseWriter, r *http.Request, userManager UserManager, logger *slog.Logger) {
 	input, err := userFromBody(r)
 	if err != nil {
 		http.Error(w, err.Error(), http.StatusBadRequest)
 		return
 	}
 
-	user := state.User{
-		AuthKey:         newUUID().String(),
-		IdentScreenName: state.NewIdentScreenName(input.ScreenName),
-	}
-
-	if err := user.HashPassword(input.Password); err != nil {
-		logger.Error("error hashing user password in PUT /user/password", "err", err.Error())
-		http.Error(w, "internal server error", http.StatusInternalServerError)
-		return
-	}
+	sn := state.NewIdentScreenName(input.ScreenName)
 
-	if err := userManager.SetUserPassword(user); err != nil {
+	if err := userManager.SetUserPassword(sn, input.Password); err != nil {
 		switch {
 		case errors.Is(err, state.ErrNoUser):
 			http.Error(w, "user does not exist", http.StatusNotFound)
 			return
+		case errors.Is(err, state.ErrPasswordInvalid):
+			http.Error(w, err.Error(), http.StatusBadRequest)
+			return
 		case err != nil:
 			logger.Error("error updating user password PUT /user/password", "err", err.Error())
 			http.Error(w, "internal server error", http.StatusInternalServerError)
@@ -137,6 +131,7 @@ func putUserPasswordHandler(w http.ResponseWriter, r *http.Request, userManager
 	}
 
 	w.WriteHeader(http.StatusNoContent)
+	_, _ = fmt.Fprintln(w, "Password successfully reset.")
 }
 
 // sessionHandler handles GET /session
@@ -158,6 +153,7 @@ func sessionHandler(w http.ResponseWriter, r *http.Request, sessionRetriever Ses
 		ou.Sessions[i] = userHandle{
 			ID:         s.IdentScreenName().String(),
 			ScreenName: s.DisplayScreenName().String(),
+			IsICQ:      s.UIN() > 0,
 		}
 	}
 
@@ -183,6 +179,7 @@ func getUserHandler(w http.ResponseWriter, _ *http.Request, userManager UserMana
 		out[i] = userHandle{
 			ID:         u.IdentScreenName.String(),
 			ScreenName: u.DisplayScreenName.String(),
+			IsICQ:      u.IsICQ,
 		}
 	}
 
@@ -202,15 +199,23 @@ func postUserHandler(w http.ResponseWriter, r *http.Request, userManager UserMan
 
 	sn := state.DisplayScreenName(input.ScreenName)
 
-	if err := sn.ValidateAIMHandle(); err != nil {
-		http.Error(w, fmt.Sprintf("invalid screen name: %s", err), http.StatusBadRequest)
-		return
+	if input.IsICQ {
+		if err := sn.ValidateICQHandle(); err != nil {
+			http.Error(w, fmt.Sprintf("invalid uin: %s", err), http.StatusBadRequest)
+			return
+		}
+	} else {
+		if err := sn.ValidateAIMHandle(); err != nil {
+			http.Error(w, fmt.Sprintf("invalid screen name: %s", err), http.StatusBadRequest)
+			return
+		}
 	}
 
 	user := state.User{
 		AuthKey:           newUUID().String(),
 		DisplayScreenName: sn,
 		IdentScreenName:   sn.IdentScreenName(),
+		IsICQ:             input.IsICQ,
 	}
 
 	if err := user.HashPassword(input.Password); err != nil {
@@ -330,11 +335,11 @@ func getPublicChatHandler(w http.ResponseWriter, _ *http.Request, chatRoomRetrie
 		cr := chatRoom{
 			CreateTime:   room.CreateTime(),
 			Name:         room.Name(),
-			Participants: make([]userHandle, len(sessions)),
+			Participants: make([]aimChatUserHandle, len(sessions)),
 			URL:          room.URL().String(),
 		}
 		for j, sess := range sessions {
-			cr.Participants[j] = userHandle{
+			cr.Participants[j] = aimChatUserHandle{
 				ID:         sess.IdentScreenName().String(),
 				ScreenName: sess.DisplayScreenName().String(),
 			}
@@ -397,11 +402,11 @@ func getPrivateChatHandler(w http.ResponseWriter, _ *http.Request, chatRoomRetri
 			CreateTime:   room.CreateTime(),
 			CreatorID:    room.Creator().String(),
 			Name:         room.Name(),
-			Participants: make([]userHandle, len(sessions)),
+			Participants: make([]aimChatUserHandle, len(sessions)),
 			URL:          room.URL().String(),
 		}
 		for j, sess := range sessions {
-			cr.Participants[j] = userHandle{
+			cr.Participants[j] = aimChatUserHandle{
 				ID:         sess.IdentScreenName().String(),
 				ScreenName: sess.DisplayScreenName().String(),
 			}

+ 82 - 56
server/http/mgmt_api_test.go

@@ -17,10 +17,11 @@ import (
 )
 
 func TestSessionHandler_GET(t *testing.T) {
-	fnNewSess := func(screenName string) *state.Session {
+	fnNewSess := func(screenName string, uin uint32) *state.Session {
 		sess := state.NewSession()
 		sess.SetIdentScreenName(state.NewIdentScreenName(screenName))
 		sess.SetDisplayScreenName(state.DisplayScreenName(screenName))
+		sess.SetUIN(uin)
 		return sess
 	}
 	tt := []struct {
@@ -39,10 +40,11 @@ func TestSessionHandler_GET(t *testing.T) {
 		{
 			name: "with sessions",
 			sessions: []*state.Session{
-				fnNewSess("userA"),
-				fnNewSess("userB"),
+				fnNewSess("userA", 0),
+				fnNewSess("userB", 0),
+				fnNewSess("100003", 100003),
 			},
-			want:       `{"count":2,"sessions":[{"id":"usera","screen_name":"userA"},{"id":"userb","screen_name":"userB"}]}`,
+			want:       `{"count":3,"sessions":[{"id":"usera","screen_name":"userA","is_icq":false},{"id":"userb","screen_name":"userB","is_icq":false},{"id":"100003","screen_name":"100003","is_icq":true}]}`,
 			statusCode: http.StatusOK,
 		},
 	}
@@ -112,8 +114,13 @@ func TestUserHandler_GET(t *testing.T) {
 					DisplayScreenName: "userB",
 					IdentScreenName:   state.NewIdentScreenName("userB"),
 				},
+				{
+					DisplayScreenName: "100003",
+					IdentScreenName:   state.NewIdentScreenName("100003"),
+					IsICQ:             true,
+				},
 			},
-			want:       `[{"id":"usera","screen_name":"userA"},{"id":"userb","screen_name":"userB"}]`,
+			want:       `[{"id":"usera","screen_name":"userA","is_icq":false},{"id":"userb","screen_name":"userB","is_icq":false},{"id":"100003","screen_name":"100003","is_icq":true}]`,
 			statusCode: http.StatusOK,
 		},
 		{
@@ -240,7 +247,21 @@ func TestUserHandler_POST(t *testing.T) {
 			name:       "invalid AIM password",
 			body:       `{"screen_name":"userA", "password":"1"}`,
 			UUID:       uuid.MustParse("07c70701-ba68-49a9-9f9b-67a53816e37b"),
-			want:       `invalid password: password length must be between 4-16 characters`,
+			want:       `invalid password: invalid password length: password length must be between 4-16 characters`,
+			statusCode: http.StatusBadRequest,
+		},
+		{
+			name:       "invalid ICQ UIN",
+			body:       `{"screen_name":"1000", "password":"thepass", "is_icq":true}`,
+			UUID:       uuid.MustParse("07c70701-ba68-49a9-9f9b-67a53816e37b"),
+			want:       `invalid uin: uin must be a number in the range 10000-2147483646`,
+			statusCode: http.StatusBadRequest,
+		},
+		{
+			name:       "invalid ICQ password",
+			body:       `{"screen_name":"100003", "password":"thelongpassword", "is_icq":true}`,
+			UUID:       uuid.MustParse("07c70701-ba68-49a9-9f9b-67a53816e37b"),
+			want:       `invalid password: invalid password length: password must be between 6-8 characters`,
 			statusCode: http.StatusBadRequest,
 		},
 	}
@@ -344,68 +365,74 @@ func TestUserHandler_DELETE(t *testing.T) {
 }
 
 func TestUserPasswordHandler_PUT(t *testing.T) {
+	type setUserPasswordParams struct {
+		screenName  state.IdentScreenName
+		newPassword string
+		wantErr     error
+	}
 	tt := []struct {
-		name           string
-		body           string
-		user           state.User
-		UUID           uuid.UUID
-		userHandlerErr error
-		want           string
-		statusCode     int
+		name             string
+		body             string
+		updateUserParams []setUserPasswordParams
+		want             string
+		statusCode       int
 	}{
 		{
-			name: "with valid password",
-			body: `{"screen_name":"userA", "password":"thepassword"}`,
-			UUID: uuid.MustParse("07c70701-ba68-49a9-9f9b-67a53816e37b"),
-			user: func() state.User {
-				user := state.User{
-					AuthKey:         uuid.MustParse("07c70701-ba68-49a9-9f9b-67a53816e37b").String(),
-					IdentScreenName: state.NewIdentScreenName("userA"),
-				}
-				assert.NoError(t, user.HashPassword("thepassword"))
-				return user
-			}(),
-			want:       ``,
+			name: "user with valid password",
+			body: `{"screen_name":"userA", "password":"thenewpassword"}`,
+			updateUserParams: []setUserPasswordParams{
+				{
+					screenName:  state.NewIdentScreenName("userA"),
+					newPassword: "thenewpassword",
+				},
+			},
+			want:       `Password successfully reset.`,
 			statusCode: http.StatusNoContent,
 		},
+		{
+			name: "user with invalid password",
+			body: `{"screen_name":"userA", "password":"a"}`,
+			updateUserParams: []setUserPasswordParams{
+				{
+					screenName:  state.NewIdentScreenName("userA"),
+					newPassword: "a",
+					wantErr:     state.ErrPasswordInvalid,
+				},
+			},
+			want:       `invalid password length`,
+			statusCode: http.StatusBadRequest,
+		},
 		{
 			name:       "with malformed body",
 			body:       `{"screen_name":"userA", "password":"thepassword"`,
-			user:       state.User{},
 			want:       `malformed input`,
 			statusCode: http.StatusBadRequest,
 		},
 		{
-			name: "user password handler error",
+			name: "password updater returns runtime error",
 			body: `{"screen_name":"userA", "password":"thepassword"}`,
-			UUID: uuid.MustParse("07c70701-ba68-49a9-9f9b-67a53816e37b"),
-			user: func() state.User {
-				user := state.User{
-					AuthKey:         uuid.MustParse("07c70701-ba68-49a9-9f9b-67a53816e37b").String(),
-					IdentScreenName: state.NewIdentScreenName("userA"),
-				}
-				assert.NoError(t, user.HashPassword("thepassword"))
-				return user
-			}(),
-			userHandlerErr: io.EOF,
-			want:           `internal server error`,
-			statusCode:     http.StatusInternalServerError,
+			updateUserParams: []setUserPasswordParams{
+				{
+					screenName:  state.NewIdentScreenName("userA"),
+					newPassword: "thepassword",
+					wantErr:     io.EOF,
+				},
+			},
+			want:       `internal server error`,
+			statusCode: http.StatusInternalServerError,
 		},
 		{
 			name: "user doesn't exist",
 			body: `{"screen_name":"userA", "password":"thepassword"}`,
-			UUID: uuid.MustParse("07c70701-ba68-49a9-9f9b-67a53816e37b"),
-			user: func() state.User {
-				user := state.User{
-					AuthKey:         uuid.MustParse("07c70701-ba68-49a9-9f9b-67a53816e37b").String(),
-					IdentScreenName: state.NewIdentScreenName("userA"),
-				}
-				assert.NoError(t, user.HashPassword("thepassword"))
-				return user
-			}(),
-			userHandlerErr: state.ErrNoUser,
-			want:           `user does not exist`,
-			statusCode:     http.StatusNotFound,
+			updateUserParams: []setUserPasswordParams{
+				{
+					screenName:  state.NewIdentScreenName("userA"),
+					newPassword: "thepassword",
+					wantErr:     state.ErrNoUser,
+				},
+			},
+			want:       `user does not exist`,
+			statusCode: http.StatusNotFound,
 		},
 	}
 
@@ -415,14 +442,13 @@ func TestUserPasswordHandler_PUT(t *testing.T) {
 			responseRecorder := httptest.NewRecorder()
 
 			userManager := newMockUserManager(t)
-			if tc.user.IdentScreenName.String() != "" {
+			for _, params := range tc.updateUserParams {
 				userManager.EXPECT().
-					SetUserPassword(tc.user).
-					Return(tc.userHandlerErr)
+					SetUserPassword(params.screenName, params.newPassword).
+					Return(params.wantErr)
 			}
 
-			newUUID := func() uuid.UUID { return tc.UUID }
-			userPasswordHandler(responseRecorder, request, userManager, newUUID, slog.Default())
+			userPasswordHandler(responseRecorder, request, userManager, slog.Default())
 
 			if responseRecorder.Code != tc.statusCode {
 				t.Errorf("want status '%d', got '%d'", tc.statusCode, responseRecorder.Code)

+ 12 - 11
server/http/mock_user_manager_test.go

@@ -169,17 +169,17 @@ func (_c *mockUserManager_InsertUser_Call) RunAndReturn(run func(state.User) err
 	return _c
 }
 
-// SetUserPassword provides a mock function with given fields: u
-func (_m *mockUserManager) SetUserPassword(u state.User) error {
-	ret := _m.Called(u)
+// SetUserPassword provides a mock function with given fields: screenName, newPassword
+func (_m *mockUserManager) SetUserPassword(screenName state.IdentScreenName, newPassword string) error {
+	ret := _m.Called(screenName, newPassword)
 
 	if len(ret) == 0 {
 		panic("no return value specified for SetUserPassword")
 	}
 
 	var r0 error
-	if rf, ok := ret.Get(0).(func(state.User) error); ok {
-		r0 = rf(u)
+	if rf, ok := ret.Get(0).(func(state.IdentScreenName, string) error); ok {
+		r0 = rf(screenName, newPassword)
 	} else {
 		r0 = ret.Error(0)
 	}
@@ -193,14 +193,15 @@ type mockUserManager_SetUserPassword_Call struct {
 }
 
 // SetUserPassword is a helper method to define mock.On call
-//   - u state.User
-func (_e *mockUserManager_Expecter) SetUserPassword(u interface{}) *mockUserManager_SetUserPassword_Call {
-	return &mockUserManager_SetUserPassword_Call{Call: _e.mock.On("SetUserPassword", u)}
+//   - screenName state.IdentScreenName
+//   - newPassword string
+func (_e *mockUserManager_Expecter) SetUserPassword(screenName interface{}, newPassword interface{}) *mockUserManager_SetUserPassword_Call {
+	return &mockUserManager_SetUserPassword_Call{Call: _e.mock.On("SetUserPassword", screenName, newPassword)}
 }
 
-func (_c *mockUserManager_SetUserPassword_Call) Run(run func(u state.User)) *mockUserManager_SetUserPassword_Call {
+func (_c *mockUserManager_SetUserPassword_Call) Run(run func(screenName state.IdentScreenName, newPassword string)) *mockUserManager_SetUserPassword_Call {
 	_c.Call.Run(func(args mock.Arguments) {
-		run(args[0].(state.User))
+		run(args[0].(state.IdentScreenName), args[1].(string))
 	})
 	return _c
 }
@@ -210,7 +211,7 @@ func (_c *mockUserManager_SetUserPassword_Call) Return(_a0 error) *mockUserManag
 	return _c
 }
 
-func (_c *mockUserManager_SetUserPassword_Call) RunAndReturn(run func(state.User) error) *mockUserManager_SetUserPassword_Call {
+func (_c *mockUserManager_SetUserPassword_Call) RunAndReturn(run func(state.IdentScreenName, string) error) *mockUserManager_SetUserPassword_Call {
 	_c.Call.Return(run)
 	return _c
 }

+ 13 - 6
server/http/types.go

@@ -28,7 +28,7 @@ type UserManager interface {
 	AllUsers() ([]state.User, error)
 	DeleteUser(screenName state.IdentScreenName) error
 	InsertUser(u state.User) error
-	SetUserPassword(u state.User) error
+	SetUserPassword(screenName state.IdentScreenName, newPassword string) error
 	User(screenName state.IdentScreenName) (*state.User, error)
 }
 
@@ -39,6 +39,7 @@ type MessageRelayer interface {
 type userWithPassword struct {
 	ScreenName string `json:"screen_name"`
 	Password   string `json:"password,omitempty"`
+	IsICQ      bool   `json:"is_icq"`
 }
 
 type onlineUsers struct {
@@ -49,6 +50,12 @@ type onlineUsers struct {
 type userHandle struct {
 	ID         string `json:"id"`
 	ScreenName string `json:"screen_name"`
+	IsICQ      bool   `json:"is_icq"`
+}
+
+type aimChatUserHandle struct {
+	ID         string `json:"id"`
+	ScreenName string `json:"screen_name"`
 }
 
 type chatRoomCreate struct {
@@ -56,11 +63,11 @@ type chatRoomCreate struct {
 }
 
 type chatRoom struct {
-	Name         string       `json:"name"`
-	CreateTime   time.Time    `json:"create_time"`
-	CreatorID    string       `json:"creator_id,omitempty"`
-	URL          string       `json:"url"`
-	Participants []userHandle `json:"participants"`
+	Name         string              `json:"name"`
+	CreateTime   time.Time           `json:"create_time"`
+	CreatorID    string              `json:"creator_id,omitempty"`
+	URL          string              `json:"url"`
+	Participants []aimChatUserHandle `json:"participants"`
 }
 
 type instantMessage struct {

+ 221 - 0
server/oscar/handler/icq.go

@@ -0,0 +1,221 @@
+package handler
+
+import (
+	"bytes"
+	"context"
+	"encoding/binary"
+	"errors"
+	"fmt"
+	"io"
+	"log/slog"
+
+	"github.com/mk6i/retro-aim-server/server/oscar"
+	"github.com/mk6i/retro-aim-server/server/oscar/middleware"
+	"github.com/mk6i/retro-aim-server/state"
+	"github.com/mk6i/retro-aim-server/wire"
+)
+
+type ICQService interface {
+	FindByDetails(ctx context.Context, sess *state.Session, req wire.ICQ_0x07D0_0x0515_DBQueryMetaReqSearchByDetails, seq uint16) error
+	FindByEmail(ctx context.Context, sess *state.Session, req wire.ICQ_0x07D0_0x0529_DBQueryMetaReqSearchByEmail, seq uint16) error
+	FindByInterests(ctx context.Context, sess *state.Session, req wire.ICQ_0x07D0_0x0533_DBQueryMetaReqSearchWhitePages, seq uint16) error
+	FindByUIN(ctx context.Context, sess *state.Session, req wire.ICQ_0x07D0_0x051F_DBQueryMetaReqSearchByUIN, seq uint16) error
+	FullUserInfo(ctx context.Context, sess *state.Session, req wire.ICQ_0x07D0_0x051F_DBQueryMetaReqSearchByUIN, seq uint16) error
+	MessagesEOF(ctx context.Context, sess *state.Session, seq uint16) error
+	SetAffiliations(ctx context.Context, sess *state.Session, req wire.ICQ_0x07D0_0x041A_DBQueryMetaReqSetAffiliations, seq uint16) error
+	SetBasicInfo(ctx context.Context, sess *state.Session, req wire.ICQ_0x07D0_0x03EA_DBQueryMetaReqSetBasicInfo, seq uint16) error
+	SetEmails(ctx context.Context, sess *state.Session, req wire.ICQ_0x07D0_0x040B_DBQueryMetaReqSetEmails, seq uint16) error
+	SetInterests(ctx context.Context, sess *state.Session, req wire.ICQ_0x07D0_0x0410_DBQueryMetaReqSetInterests, seq uint16) error
+	SetMoreInfo(ctx context.Context, sess *state.Session, req wire.ICQ_0x07D0_0x03FD_DBQueryMetaReqSetMoreInfo, seq uint16) error
+	SetPermissions(ctx context.Context, sess *state.Session, req wire.ICQ_0x07D0_0x0424_DBQueryMetaReqSetPermissions, seq uint16) error
+	SetUserNotes(ctx context.Context, sess *state.Session, req wire.ICQ_0x07D0_0x0406_DBQueryMetaReqSetNotes, seq uint16) error
+	SetWorkInfo(ctx context.Context, sess *state.Session, req wire.ICQ_0x07D0_0x03F3_DBQueryMetaReqSetWorkInfo, seq uint16) error
+	XMLReqData(ctx context.Context, sess *state.Session, req wire.ICQ_0x07D0_0x0898_DBQueryMetaReqXMLReq, seq uint16) error
+}
+
+var (
+	errUnknownICQMetaReqType    = errors.New("unknown ICQ request type")
+	errUnknownICQMetaReqSubType = errors.New("unknown ICQ metadata request subtype")
+)
+
+func NewICQHandler(logger *slog.Logger, ICQService ICQService) ICQHandler {
+	return ICQHandler{
+		RouteLogger: middleware.RouteLogger{
+			Logger: logger,
+		},
+		ICQService: ICQService,
+	}
+}
+
+type ICQHandler struct {
+	ICQService
+	middleware.RouteLogger
+}
+
+func (rt ICQHandler) DBQuery(ctx context.Context, sess *state.Session, inFrame wire.SNACFrame, r io.Reader, rw oscar.ResponseWriter) error {
+	inBody := wire.SNAC_0x0F_0x02_ICQDBQuery{}
+	if err := wire.UnmarshalBE(&inBody, r); err != nil {
+		return err
+	}
+
+	md, ok := inBody.Slice(wire.ICQTLVTagsMetadata)
+	if !ok {
+		return errors.New("invalid ICQ frame")
+	}
+
+	icqChunk := wire.ICQMessageRequestEnvelope{}
+	if err := wire.UnmarshalLE(&icqChunk, bytes.NewBuffer(md)); err != nil {
+		return err
+	}
+	buf := bytes.NewBuffer(icqChunk.Body)
+	icqMD := wire.ICQMetadataWithSubType{}
+	if err := wire.UnmarshalLE(&icqMD, buf); err != nil {
+		return err
+	}
+
+	switch icqMD.ReqType {
+	case wire.ICQDBQueryOfflineMsgReq:
+		return rt.ICQService.MessagesEOF(ctx, sess, icqMD.Seq)
+	case wire.ICQDBQueryDeleteMsgReq:
+		rt.Logger.Debug("returning offline messages is not yet supported")
+	case wire.ICQDBQueryMetaReq:
+		if icqMD.Optional == nil {
+			return errors.New("got req without subtype")
+		}
+		rt.Logger.Debug("ICQ client request",
+			"query_name", wire.ICQDBQueryName(icqMD.ReqType),
+			"query_type", wire.ICQDBQueryMetaName(icqMD.Optional.ReqSubType),
+			"uin", sess.UIN())
+
+		switch icqMD.Optional.ReqSubType {
+		case wire.ICQDBQueryMetaReqFullInfo, wire.ICQDBQueryMetaReqFullInfo2:
+			userInfo := wire.ICQ_0x07D0_0x051F_DBQueryMetaReqSearchByUIN{}
+			if err := binary.Read(buf, binary.LittleEndian, &userInfo); err != nil {
+				return nil
+			}
+			return rt.ICQService.FullUserInfo(ctx, sess, userInfo, icqMD.Seq)
+		case wire.ICQDBQueryMetaReqXMLReq:
+			req := wire.ICQ_0x07D0_0x0898_DBQueryMetaReqXMLReq{}
+			if err := wire.UnmarshalLE(&req, buf); err != nil {
+				return err
+			}
+			if err := rt.ICQService.XMLReqData(ctx, sess, req, icqMD.Seq); err != nil {
+				return err
+			}
+		case wire.ICQDBQueryMetaReqSetPermissions:
+			req := wire.ICQ_0x07D0_0x0424_DBQueryMetaReqSetPermissions{}
+			if err := wire.UnmarshalLE(&req, buf); err != nil {
+				return err
+			}
+			if err := rt.ICQService.SetPermissions(ctx, sess, req, icqMD.Seq); err != nil {
+				return err
+			}
+		case wire.ICQDBQueryMetaReqSearchByUIN:
+			req := wire.ICQ_0x07D0_0x051F_DBQueryMetaReqSearchByUIN{}
+			if err := wire.UnmarshalLE(&req, buf); err != nil {
+				return err
+			}
+			if err := rt.ICQService.FindByUIN(ctx, sess, req, icqMD.Seq); err != nil {
+				return err
+			}
+		case wire.ICQDBQueryMetaReqSearchByEmail:
+			req := wire.ICQ_0x07D0_0x0529_DBQueryMetaReqSearchByEmail{}
+			if err := wire.UnmarshalLE(&req, buf); err != nil {
+				return err
+			}
+			if err := rt.ICQService.FindByEmail(ctx, sess, req, icqMD.Seq); err != nil {
+				return err
+			}
+		case wire.ICQDBQueryMetaReqSearchByDetails:
+			req := wire.ICQ_0x07D0_0x0515_DBQueryMetaReqSearchByDetails{}
+			if err := wire.UnmarshalLE(&req, buf); err != nil {
+				return err
+			}
+			if err := rt.ICQService.FindByDetails(ctx, sess, req, icqMD.Seq); err != nil {
+				return err
+			}
+		case wire.ICQDBQueryMetaReqSearchWhitePages:
+			req := wire.ICQ_0x07D0_0x0533_DBQueryMetaReqSearchWhitePages{}
+			if err := wire.UnmarshalLE(&req, buf); err != nil {
+				return err
+			}
+			if err := rt.ICQService.FindByInterests(ctx, sess, req, icqMD.Seq); err != nil {
+				return err
+			}
+		case wire.ICQDBQueryMetaReqSetBasicInfo:
+			req := wire.ICQ_0x07D0_0x03EA_DBQueryMetaReqSetBasicInfo{}
+			if err := wire.UnmarshalLE(&req, buf); err != nil {
+				return err
+			}
+			if err := rt.ICQService.SetBasicInfo(ctx, sess, req, icqMD.Seq); err != nil {
+				return err
+			}
+		case wire.ICQDBQueryMetaReqSetWorkInfo:
+			req := wire.ICQ_0x07D0_0x03F3_DBQueryMetaReqSetWorkInfo{}
+			if err := wire.UnmarshalLE(&req, buf); err != nil {
+				return err
+			}
+			if err := rt.ICQService.SetWorkInfo(ctx, sess, req, icqMD.Seq); err != nil {
+				return err
+			}
+		case wire.ICQDBQueryMetaReqSetMoreInfo:
+			req := wire.ICQ_0x07D0_0x03FD_DBQueryMetaReqSetMoreInfo{}
+			if err := wire.UnmarshalLE(&req, buf); err != nil {
+				return err
+			}
+			if err := rt.ICQService.SetMoreInfo(ctx, sess, req, icqMD.Seq); err != nil {
+				return err
+			}
+		case wire.ICQDBQueryMetaReqSetNotes:
+			req := wire.ICQ_0x07D0_0x0406_DBQueryMetaReqSetNotes{}
+			if err := wire.UnmarshalLE(&req, buf); err != nil {
+				return err
+			}
+			if err := rt.ICQService.SetUserNotes(ctx, sess, req, icqMD.Seq); err != nil {
+				return err
+			}
+		case wire.ICQDBQueryMetaReqSetEmails:
+			req := wire.ICQ_0x07D0_0x040B_DBQueryMetaReqSetEmails{}
+			if err := wire.UnmarshalLE(&req, buf); err != nil {
+				return err
+			}
+			if err := rt.ICQService.SetEmails(ctx, sess, req, icqMD.Seq); err != nil {
+				return err
+			}
+		case wire.ICQDBQueryMetaReqSetInterests:
+			req := wire.ICQ_0x07D0_0x0410_DBQueryMetaReqSetInterests{}
+			if err := wire.UnmarshalLE(&req, buf); err != nil {
+				return err
+			}
+			if err := rt.ICQService.SetInterests(ctx, sess, req, icqMD.Seq); err != nil {
+				return err
+			}
+		case wire.ICQDBQueryMetaReqSetAffiliations:
+			req := wire.ICQ_0x07D0_0x041A_DBQueryMetaReqSetAffiliations{}
+			if err := wire.UnmarshalLE(&req, buf); err != nil {
+				return err
+			}
+			if err := rt.ICQService.SetAffiliations(ctx, sess, req, icqMD.Seq); err != nil {
+				return err
+			}
+		case wire.ICQDBQueryMetaReqStat0a8c,
+			wire.ICQDBQueryMetaReqStat0a96,
+			wire.ICQDBQueryMetaReqStat0aaa,
+			wire.ICQDBQueryMetaReqStat0ab4,
+			wire.ICQDBQueryMetaReqStat0ab9,
+			wire.ICQDBQueryMetaReqStat0abe,
+			wire.ICQDBQueryMetaReqStat0ac8,
+			wire.ICQDBQueryMetaReqStat0acd,
+			wire.ICQDBQueryMetaReqStat0ad2,
+			wire.ICQDBQueryMetaReqStat0ad7,
+			wire.ICQDBQueryMetaReqStat0758:
+			rt.Logger.Debug("got a request for stats, not doing anything right now")
+		default:
+			return fmt.Errorf("%w: %X", errUnknownICQMetaReqSubType, icqMD.Optional.ReqSubType)
+		}
+	default:
+		return fmt.Errorf("%w: %X", errUnknownICQMetaReqType, icqMD.ReqType)
+	}
+
+	return nil
+}

+ 739 - 0
server/oscar/handler/icq_test.go

@@ -0,0 +1,739 @@
+package handler
+
+import (
+	"bytes"
+	"context"
+	"log/slog"
+	"testing"
+
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/mock"
+
+	"github.com/mk6i/retro-aim-server/server/oscar"
+	"github.com/mk6i/retro-aim-server/state"
+	"github.com/mk6i/retro-aim-server/wire"
+)
+
+func TestICQHandler_DBQuery(t *testing.T) {
+	type ICQMetaRequest struct {
+		wire.ICQMetadata
+		ReqSubType  uint16
+		MetaRequest any
+	}
+	type reqParams struct {
+		ctx     context.Context
+		sess    *state.Session
+		inFrame wire.SNACFrame
+		inBody  wire.SNAC_0x0F_0x02_ICQDBQuery
+		rw      oscar.ResponseWriter
+		seq     uint16
+		wantErr error
+	}
+	type mockParam struct {
+		req     any
+		wantErr error
+	}
+	type allMockParams struct {
+		fullUserInfo    *mockParam
+		xmlReqData      *mockParam
+		setPermissions  *mockParam
+		findByUIN       *mockParam
+		findByEmail     *mockParam
+		findByDetails   *mockParam
+		findByInterests *mockParam
+		setBasicInfo    *mockParam
+		setWorkInfo     *mockParam
+		setMoreInfo     *mockParam
+		setUserNotes    *mockParam
+		setEmails       *mockParam
+		setInterests    *mockParam
+		setAffiliations *mockParam
+	}
+	tests := []struct {
+		name          string
+		reqParams     reqParams
+		allMockParams allMockParams
+	}{
+		{
+			name: "MetaReqFullInfo - happy path",
+			reqParams: reqParams{
+				sess: &state.Session{},
+				inBody: wire.SNAC_0x0F_0x02_ICQDBQuery{
+					TLVRestBlock: wire.TLVRestBlock{
+						TLVList: wire.TLVList{
+							wire.NewTLV(wire.ICQTLVTagsMetadata, wire.ICQMessageReplyEnvelope{
+								Message: ICQMetaRequest{
+									ICQMetadata: wire.ICQMetadata{
+										ReqType: wire.ICQDBQueryMetaReq,
+										Seq:     1,
+									},
+									ReqSubType: wire.ICQDBQueryMetaReqFullInfo,
+									MetaRequest: wire.ICQ_0x07D0_0x051F_DBQueryMetaReqSearchByUIN{
+										UIN: 123456789,
+									},
+								},
+							}),
+						},
+					},
+				},
+				seq: 1,
+			},
+			allMockParams: allMockParams{
+				fullUserInfo: &mockParam{
+					req: wire.ICQ_0x07D0_0x051F_DBQueryMetaReqSearchByUIN{
+						UIN: 123456789,
+					},
+				},
+			},
+		},
+		{
+			name: "MetaReqFullInfo2 - happy path",
+			reqParams: reqParams{
+				sess: &state.Session{},
+				inBody: wire.SNAC_0x0F_0x02_ICQDBQuery{
+					TLVRestBlock: wire.TLVRestBlock{
+						TLVList: wire.TLVList{
+							wire.NewTLV(wire.ICQTLVTagsMetadata, wire.ICQMessageReplyEnvelope{
+								Message: ICQMetaRequest{
+									ICQMetadata: wire.ICQMetadata{
+										ReqType: wire.ICQDBQueryMetaReq,
+										Seq:     1,
+									},
+									ReqSubType: wire.ICQDBQueryMetaReqFullInfo2,
+									MetaRequest: wire.ICQ_0x07D0_0x051F_DBQueryMetaReqSearchByUIN{
+										UIN: 123456789,
+									},
+								},
+							}),
+						},
+					},
+				},
+				seq: 1,
+			},
+			allMockParams: allMockParams{
+				fullUserInfo: &mockParam{
+					req: wire.ICQ_0x07D0_0x051F_DBQueryMetaReqSearchByUIN{
+						UIN: 123456789,
+					},
+				},
+			},
+		},
+		{
+			name: "MetaReqXMLReq - happy path",
+			reqParams: reqParams{
+				sess: &state.Session{},
+				inBody: wire.SNAC_0x0F_0x02_ICQDBQuery{
+					TLVRestBlock: wire.TLVRestBlock{
+						TLVList: wire.TLVList{
+							wire.NewTLV(wire.ICQTLVTagsMetadata, wire.ICQMessageReplyEnvelope{
+								Message: ICQMetaRequest{
+									ICQMetadata: wire.ICQMetadata{
+										ReqType: wire.ICQDBQueryMetaReq,
+										Seq:     1,
+									},
+									ReqSubType: wire.ICQDBQueryMetaReqXMLReq,
+									MetaRequest: wire.ICQ_0x07D0_0x0898_DBQueryMetaReqXMLReq{
+										XMLRequest: "<xml></xml>",
+									},
+								},
+							}),
+						},
+					},
+				},
+				seq: 1,
+			},
+			allMockParams: allMockParams{
+				xmlReqData: &mockParam{
+					req: wire.ICQ_0x07D0_0x0898_DBQueryMetaReqXMLReq{
+						XMLRequest: "<xml></xml>",
+					},
+				},
+			},
+		},
+		{
+			name: "MetaReqSetPermissions - happy path",
+			reqParams: reqParams{
+				sess: &state.Session{},
+				inBody: wire.SNAC_0x0F_0x02_ICQDBQuery{
+					TLVRestBlock: wire.TLVRestBlock{
+						TLVList: wire.TLVList{
+							wire.NewTLV(wire.ICQTLVTagsMetadata, wire.ICQMessageReplyEnvelope{
+								Message: ICQMetaRequest{
+									ICQMetadata: wire.ICQMetadata{
+										ReqType: wire.ICQDBQueryMetaReq,
+										Seq:     1,
+									},
+									ReqSubType: wire.ICQDBQueryMetaReqSetPermissions,
+									MetaRequest: wire.ICQ_0x07D0_0x0424_DBQueryMetaReqSetPermissions{
+										Authorization: 1,
+									},
+								},
+							}),
+						},
+					},
+				},
+				seq: 1,
+			},
+			allMockParams: allMockParams{
+				setPermissions: &mockParam{
+					req: wire.ICQ_0x07D0_0x0424_DBQueryMetaReqSetPermissions{
+						Authorization: 1,
+					},
+				},
+			},
+		},
+		{
+			name: "MetaReqSearchByUIN - happy path",
+			reqParams: reqParams{
+				sess: &state.Session{},
+				inBody: wire.SNAC_0x0F_0x02_ICQDBQuery{
+					TLVRestBlock: wire.TLVRestBlock{
+						TLVList: wire.TLVList{
+							wire.NewTLV(wire.ICQTLVTagsMetadata, wire.ICQMessageReplyEnvelope{
+								Message: ICQMetaRequest{
+									ICQMetadata: wire.ICQMetadata{
+										ReqType: wire.ICQDBQueryMetaReq,
+										Seq:     1,
+									},
+									ReqSubType: wire.ICQDBQueryMetaReqSearchByUIN,
+									MetaRequest: wire.ICQ_0x07D0_0x051F_DBQueryMetaReqSearchByUIN{
+										UIN: 123456789,
+									},
+								},
+							}),
+						},
+					},
+				},
+				seq: 1,
+			},
+			allMockParams: allMockParams{
+				findByUIN: &mockParam{
+					req: wire.ICQ_0x07D0_0x051F_DBQueryMetaReqSearchByUIN{
+						UIN: 123456789,
+					},
+				},
+			},
+		},
+		{
+			name: "MetaReqSearchByEmail - happy path",
+			reqParams: reqParams{
+				sess: &state.Session{},
+				inBody: wire.SNAC_0x0F_0x02_ICQDBQuery{
+					TLVRestBlock: wire.TLVRestBlock{
+						TLVList: wire.TLVList{
+							wire.NewTLV(wire.ICQTLVTagsMetadata, wire.ICQMessageReplyEnvelope{
+								Message: ICQMetaRequest{
+									ICQMetadata: wire.ICQMetadata{
+										ReqType: wire.ICQDBQueryMetaReq,
+										Seq:     1,
+									},
+									ReqSubType: wire.ICQDBQueryMetaReqSearchByEmail,
+									MetaRequest: wire.ICQ_0x07D0_0x0529_DBQueryMetaReqSearchByEmail{
+										Email: "test@aol.com",
+									},
+								},
+							}),
+						},
+					},
+				},
+				seq: 1,
+			},
+			allMockParams: allMockParams{
+				findByEmail: &mockParam{
+					req: wire.ICQ_0x07D0_0x0529_DBQueryMetaReqSearchByEmail{
+						Email: "test@aol.com",
+					},
+				},
+			},
+		},
+		{
+			name: "MetaReqSearchByDetails - happy path",
+			reqParams: reqParams{
+				sess: &state.Session{},
+				inBody: wire.SNAC_0x0F_0x02_ICQDBQuery{
+					TLVRestBlock: wire.TLVRestBlock{
+						TLVList: wire.TLVList{
+							wire.NewTLV(wire.ICQTLVTagsMetadata, wire.ICQMessageReplyEnvelope{
+								Message: ICQMetaRequest{
+									ICQMetadata: wire.ICQMetadata{
+										ReqType: wire.ICQDBQueryMetaReq,
+										Seq:     1,
+									},
+									ReqSubType: wire.ICQDBQueryMetaReqSearchByDetails,
+									MetaRequest: wire.ICQ_0x07D0_0x0515_DBQueryMetaReqSearchByDetails{
+										FirstName: "john",
+									},
+								},
+							}),
+						},
+					},
+				},
+				seq: 1,
+			},
+			allMockParams: allMockParams{
+				findByDetails: &mockParam{
+					req: wire.ICQ_0x07D0_0x0515_DBQueryMetaReqSearchByDetails{
+						FirstName: "john",
+					},
+				},
+			},
+		},
+		{
+			name: "MetaReqSearchWhitePages - happy path",
+			reqParams: reqParams{
+				sess: &state.Session{},
+				inBody: wire.SNAC_0x0F_0x02_ICQDBQuery{
+					TLVRestBlock: wire.TLVRestBlock{
+						TLVList: wire.TLVList{
+							wire.NewTLV(wire.ICQTLVTagsMetadata, wire.ICQMessageReplyEnvelope{
+								Message: ICQMetaRequest{
+									ICQMetadata: wire.ICQMetadata{
+										ReqType: wire.ICQDBQueryMetaReq,
+										Seq:     1,
+									},
+									ReqSubType: wire.ICQDBQueryMetaReqSearchWhitePages,
+									MetaRequest: wire.ICQ_0x07D0_0x0533_DBQueryMetaReqSearchWhitePages{
+										InterestsCode: 1,
+									},
+								},
+							}),
+						},
+					},
+				},
+				seq: 1,
+			},
+			allMockParams: allMockParams{
+				findByInterests: &mockParam{
+					req: wire.ICQ_0x07D0_0x0533_DBQueryMetaReqSearchWhitePages{
+						InterestsCode: 1,
+					},
+				},
+			},
+		},
+		{
+			name: "MetaReqSetBasicInfo - happy path",
+			reqParams: reqParams{
+				sess: &state.Session{},
+				inBody: wire.SNAC_0x0F_0x02_ICQDBQuery{
+					TLVRestBlock: wire.TLVRestBlock{
+						TLVList: wire.TLVList{
+							wire.NewTLV(wire.ICQTLVTagsMetadata, wire.ICQMessageReplyEnvelope{
+								Message: ICQMetaRequest{
+									ICQMetadata: wire.ICQMetadata{
+										ReqType: wire.ICQDBQueryMetaReq,
+										Seq:     1,
+									},
+									ReqSubType: wire.ICQDBQueryMetaReqSetBasicInfo,
+									MetaRequest: wire.ICQ_0x07D0_0x03EA_DBQueryMetaReqSetBasicInfo{
+										FirstName: "john",
+									},
+								},
+							}),
+						},
+					},
+				},
+				seq: 1,
+			},
+			allMockParams: allMockParams{
+				setBasicInfo: &mockParam{
+					req: wire.ICQ_0x07D0_0x03EA_DBQueryMetaReqSetBasicInfo{
+						FirstName: "john",
+					},
+				},
+			},
+		},
+		{
+			name: "MetaReqSetWorkInfo - happy path",
+			reqParams: reqParams{
+				sess: &state.Session{},
+				inBody: wire.SNAC_0x0F_0x02_ICQDBQuery{
+					TLVRestBlock: wire.TLVRestBlock{
+						TLVList: wire.TLVList{
+							wire.NewTLV(wire.ICQTLVTagsMetadata, wire.ICQMessageReplyEnvelope{
+								Message: ICQMetaRequest{
+									ICQMetadata: wire.ICQMetadata{
+										ReqType: wire.ICQDBQueryMetaReq,
+										Seq:     1,
+									},
+									ReqSubType: wire.ICQDBQueryMetaReqSetWorkInfo,
+									MetaRequest: wire.ICQ_0x07D0_0x03F3_DBQueryMetaReqSetWorkInfo{
+										ZIP: "11111",
+									},
+								},
+							}),
+						},
+					},
+				},
+				seq: 1,
+			},
+			allMockParams: allMockParams{
+				setWorkInfo: &mockParam{
+					req: wire.ICQ_0x07D0_0x03F3_DBQueryMetaReqSetWorkInfo{
+						ZIP: "11111",
+					},
+				},
+			},
+		},
+		{
+			name: "MetaReqSetMoreInfo - happy path",
+			reqParams: reqParams{
+				sess: &state.Session{},
+				inBody: wire.SNAC_0x0F_0x02_ICQDBQuery{
+					TLVRestBlock: wire.TLVRestBlock{
+						TLVList: wire.TLVList{
+							wire.NewTLV(wire.ICQTLVTagsMetadata, wire.ICQMessageReplyEnvelope{
+								Message: ICQMetaRequest{
+									ICQMetadata: wire.ICQMetadata{
+										ReqType: wire.ICQDBQueryMetaReq,
+										Seq:     1,
+									},
+									ReqSubType: wire.ICQDBQueryMetaReqSetMoreInfo,
+									MetaRequest: wire.ICQ_0x07D0_0x03FD_DBQueryMetaReqSetMoreInfo{
+										Age: 100,
+									},
+								},
+							}),
+						},
+					},
+				},
+				seq: 1,
+			},
+			allMockParams: allMockParams{
+				setMoreInfo: &mockParam{
+					req: wire.ICQ_0x07D0_0x03FD_DBQueryMetaReqSetMoreInfo{
+						Age: 100,
+					},
+				},
+			},
+		},
+		{
+			name: "MetaReqSetNotes - happy path",
+			reqParams: reqParams{
+				sess: &state.Session{},
+				inBody: wire.SNAC_0x0F_0x02_ICQDBQuery{
+					TLVRestBlock: wire.TLVRestBlock{
+						TLVList: wire.TLVList{
+							wire.NewTLV(wire.ICQTLVTagsMetadata, wire.ICQMessageReplyEnvelope{
+								Message: ICQMetaRequest{
+									ICQMetadata: wire.ICQMetadata{
+										ReqType: wire.ICQDBQueryMetaReq,
+										Seq:     1,
+									},
+									ReqSubType: wire.ICQDBQueryMetaReqSetNotes,
+									MetaRequest: wire.ICQ_0x07D0_0x0406_DBQueryMetaReqSetNotes{
+										Notes: "my note",
+									},
+								},
+							}),
+						},
+					},
+				},
+				seq: 1,
+			},
+			allMockParams: allMockParams{
+				setUserNotes: &mockParam{
+					req: wire.ICQ_0x07D0_0x0406_DBQueryMetaReqSetNotes{
+						Notes: "my note",
+					},
+				},
+			},
+		},
+		{
+			name: "MetaReqSetEmails - happy path",
+			reqParams: reqParams{
+				sess: &state.Session{},
+				inBody: wire.SNAC_0x0F_0x02_ICQDBQuery{
+					TLVRestBlock: wire.TLVRestBlock{
+						TLVList: wire.TLVList{
+							wire.NewTLV(wire.ICQTLVTagsMetadata, wire.ICQMessageReplyEnvelope{
+								Message: ICQMetaRequest{
+									ICQMetadata: wire.ICQMetadata{
+										ReqType: wire.ICQDBQueryMetaReq,
+										Seq:     1,
+									},
+									ReqSubType: wire.ICQDBQueryMetaReqSetEmails,
+									MetaRequest: wire.ICQ_0x07D0_0x040B_DBQueryMetaReqSetEmails{
+										Emails: []struct {
+											Publish uint8
+											Email   string `oscar:"len_prefix=uint16,nullterm"`
+										}{
+											{
+												Email: "test@aol.com",
+											},
+										},
+									},
+								},
+							}),
+						},
+					},
+				},
+				seq: 1,
+			},
+			allMockParams: allMockParams{
+				setEmails: &mockParam{
+					req: wire.ICQ_0x07D0_0x040B_DBQueryMetaReqSetEmails{
+						Emails: []struct {
+							Publish uint8
+							Email   string `oscar:"len_prefix=uint16,nullterm"`
+						}{
+							{
+								Email: "test@aol.com",
+							},
+						},
+					},
+				},
+			},
+		},
+		{
+			name: "MetaReqSetInterests - happy path",
+			reqParams: reqParams{
+				sess: &state.Session{},
+				inBody: wire.SNAC_0x0F_0x02_ICQDBQuery{
+					TLVRestBlock: wire.TLVRestBlock{
+						TLVList: wire.TLVList{
+							wire.NewTLV(wire.ICQTLVTagsMetadata, wire.ICQMessageReplyEnvelope{
+								Message: ICQMetaRequest{
+									ICQMetadata: wire.ICQMetadata{
+										ReqType: wire.ICQDBQueryMetaReq,
+										Seq:     1,
+									},
+									ReqSubType: wire.ICQDBQueryMetaReqSetInterests,
+									MetaRequest: wire.ICQ_0x07D0_0x0410_DBQueryMetaReqSetInterests{
+										Interests: []struct {
+											Code    uint16
+											Keyword string `oscar:"len_prefix=uint16,nullterm"`
+										}{
+											{
+												Keyword: "an_interest",
+											},
+										},
+									},
+								},
+							}),
+						},
+					},
+				},
+				seq: 1,
+			},
+			allMockParams: allMockParams{
+				setInterests: &mockParam{
+					req: wire.ICQ_0x07D0_0x0410_DBQueryMetaReqSetInterests{
+						Interests: []struct {
+							Code    uint16
+							Keyword string `oscar:"len_prefix=uint16,nullterm"`
+						}{
+							{
+								Keyword: "an_interest",
+							},
+						},
+					},
+				},
+			},
+		},
+		{
+			name: "MetaReqSetAffiliations - happy path",
+			reqParams: reqParams{
+				sess: &state.Session{},
+				inBody: wire.SNAC_0x0F_0x02_ICQDBQuery{
+					TLVRestBlock: wire.TLVRestBlock{
+						TLVList: wire.TLVList{
+							wire.NewTLV(wire.ICQTLVTagsMetadata, wire.ICQMessageReplyEnvelope{
+								Message: ICQMetaRequest{
+									ICQMetadata: wire.ICQMetadata{
+										ReqType: wire.ICQDBQueryMetaReq,
+										Seq:     1,
+									},
+									ReqSubType: wire.ICQDBQueryMetaReqSetAffiliations,
+									MetaRequest: wire.ICQ_0x07D0_0x041A_DBQueryMetaReqSetAffiliations{
+										PastAffiliations: []struct {
+											Code    uint16
+											Keyword string `oscar:"len_prefix=uint16,nullterm"`
+										}{
+											{
+												Keyword: "a_past_affiliation",
+											},
+										},
+										Affiliations: []struct {
+											Code    uint16
+											Keyword string `oscar:"len_prefix=uint16,nullterm"`
+										}{
+											{
+												Keyword: "an_affiliation",
+											},
+										},
+									},
+								},
+							}),
+						},
+					},
+				},
+				seq: 1,
+			},
+			allMockParams: allMockParams{
+				setAffiliations: &mockParam{
+					req: wire.ICQ_0x07D0_0x041A_DBQueryMetaReqSetAffiliations{
+						PastAffiliations: []struct {
+							Code    uint16
+							Keyword string `oscar:"len_prefix=uint16,nullterm"`
+						}{
+							{
+								Keyword: "a_past_affiliation",
+							},
+						},
+						Affiliations: []struct {
+							Code    uint16
+							Keyword string `oscar:"len_prefix=uint16,nullterm"`
+						}{
+							{
+								Keyword: "an_affiliation",
+							},
+						},
+					},
+				},
+			},
+		},
+		{
+			name: "MetaReqStat - happy path",
+			reqParams: reqParams{
+				sess: &state.Session{},
+				inBody: wire.SNAC_0x0F_0x02_ICQDBQuery{
+					TLVRestBlock: wire.TLVRestBlock{
+						TLVList: wire.TLVList{
+							wire.NewTLV(wire.ICQTLVTagsMetadata, wire.ICQMessageReplyEnvelope{
+								Message: ICQMetaRequest{
+									ICQMetadata: wire.ICQMetadata{
+										ReqType: wire.ICQDBQueryMetaReq,
+										Seq:     1,
+									},
+									ReqSubType:  wire.ICQDBQueryMetaReqStat0a8c,
+									MetaRequest: struct{}{},
+								},
+							}),
+						},
+					},
+				},
+				seq: 1,
+			},
+		},
+		{
+			name: "unknown metadata request subtype",
+			reqParams: reqParams{
+				sess: &state.Session{},
+				inBody: wire.SNAC_0x0F_0x02_ICQDBQuery{
+					TLVRestBlock: wire.TLVRestBlock{
+						TLVList: wire.TLVList{
+							wire.NewTLV(wire.ICQTLVTagsMetadata, wire.ICQMessageReplyEnvelope{
+								Message: ICQMetaRequest{
+									ICQMetadata: wire.ICQMetadata{
+										ReqType: wire.ICQDBQueryMetaReq,
+										Seq:     1,
+									},
+									ReqSubType:  0xCA8E,
+									MetaRequest: struct{}{},
+								},
+							}),
+						},
+					},
+				},
+				seq:     1,
+				wantErr: errUnknownICQMetaReqSubType,
+			},
+		},
+		{
+			name: "unknown request type",
+			reqParams: reqParams{
+				sess: &state.Session{},
+				inBody: wire.SNAC_0x0F_0x02_ICQDBQuery{
+					TLVRestBlock: wire.TLVRestBlock{
+						TLVList: wire.TLVList{
+							wire.NewTLV(wire.ICQTLVTagsMetadata, wire.ICQMessageReplyEnvelope{
+								Message: ICQMetaRequest{
+									ICQMetadata: wire.ICQMetadata{
+										ReqType: 0x13B4,
+										Seq:     1,
+									},
+									ReqSubType:  0xCA8E,
+									MetaRequest: struct{}{},
+								},
+							}),
+						},
+					},
+				},
+				seq:     1,
+				wantErr: errUnknownICQMetaReqType,
+			},
+		}, // todo: add to a separate test
+	}
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+
+			icqService := newMockICQService(t)
+			switch {
+			case tt.allMockParams.fullUserInfo != nil:
+				icqService.EXPECT().
+					FullUserInfo(mock.Anything, tt.reqParams.sess, tt.allMockParams.fullUserInfo.req, tt.reqParams.seq).
+					Return(tt.allMockParams.fullUserInfo.wantErr)
+			case tt.allMockParams.xmlReqData != nil:
+				icqService.EXPECT().
+					XMLReqData(mock.Anything, tt.reqParams.sess, tt.allMockParams.xmlReqData.req, tt.reqParams.seq).
+					Return(tt.allMockParams.xmlReqData.wantErr)
+			case tt.allMockParams.setPermissions != nil:
+				icqService.EXPECT().
+					SetPermissions(mock.Anything, tt.reqParams.sess, tt.allMockParams.setPermissions.req, tt.reqParams.seq).
+					Return(tt.allMockParams.setPermissions.wantErr)
+			case tt.allMockParams.findByUIN != nil:
+				icqService.EXPECT().
+					FindByUIN(mock.Anything, tt.reqParams.sess, tt.allMockParams.findByUIN.req, tt.reqParams.seq).
+					Return(tt.allMockParams.findByUIN.wantErr)
+			case tt.allMockParams.findByEmail != nil:
+				icqService.EXPECT().
+					FindByEmail(mock.Anything, tt.reqParams.sess, tt.allMockParams.findByEmail.req, tt.reqParams.seq).
+					Return(tt.allMockParams.findByEmail.wantErr)
+			case tt.allMockParams.findByDetails != nil:
+				icqService.EXPECT().
+					FindByDetails(mock.Anything, tt.reqParams.sess, tt.allMockParams.findByDetails.req, tt.reqParams.seq).
+					Return(tt.allMockParams.findByDetails.wantErr)
+			case tt.allMockParams.findByInterests != nil:
+				icqService.EXPECT().
+					FindByInterests(mock.Anything, tt.reqParams.sess, tt.allMockParams.findByInterests.req, tt.reqParams.seq).
+					Return(tt.allMockParams.findByInterests.wantErr)
+			case tt.allMockParams.setBasicInfo != nil:
+				icqService.EXPECT().
+					SetBasicInfo(mock.Anything, tt.reqParams.sess, tt.allMockParams.setBasicInfo.req, tt.reqParams.seq).
+					Return(tt.allMockParams.setBasicInfo.wantErr)
+			case tt.allMockParams.setWorkInfo != nil:
+				icqService.EXPECT().
+					SetWorkInfo(mock.Anything, tt.reqParams.sess, tt.allMockParams.setWorkInfo.req, tt.reqParams.seq).
+					Return(tt.allMockParams.setWorkInfo.wantErr)
+			case tt.allMockParams.setMoreInfo != nil:
+				icqService.EXPECT().
+					SetMoreInfo(mock.Anything, tt.reqParams.sess, tt.allMockParams.setMoreInfo.req, tt.reqParams.seq).
+					Return(tt.allMockParams.setMoreInfo.wantErr)
+			case tt.allMockParams.setUserNotes != nil:
+				icqService.EXPECT().
+					SetUserNotes(mock.Anything, tt.reqParams.sess, tt.allMockParams.setUserNotes.req, tt.reqParams.seq).
+					Return(tt.allMockParams.setUserNotes.wantErr)
+			case tt.allMockParams.setEmails != nil:
+				icqService.EXPECT().
+					SetEmails(mock.Anything, tt.reqParams.sess, tt.allMockParams.setEmails.req, tt.reqParams.seq).
+					Return(tt.allMockParams.setEmails.wantErr)
+			case tt.allMockParams.setInterests != nil:
+				icqService.EXPECT().
+					SetInterests(mock.Anything, tt.reqParams.sess, tt.allMockParams.setInterests.req, tt.reqParams.seq).
+					Return(tt.allMockParams.setInterests.wantErr)
+			case tt.allMockParams.setAffiliations != nil:
+				icqService.EXPECT().
+					SetAffiliations(mock.Anything, tt.reqParams.sess, tt.allMockParams.setAffiliations.req, tt.reqParams.seq).
+					Return(tt.allMockParams.setAffiliations.wantErr)
+			}
+
+			rt := NewICQHandler(slog.Default(), icqService)
+
+			buf := &bytes.Buffer{}
+			assert.NoError(t, wire.MarshalBE(tt.reqParams.inBody, buf))
+
+			err := rt.DBQuery(nil, tt.reqParams.sess, wire.SNACFrame{}, buf, nil)
+			assert.ErrorIs(t, err, tt.reqParams.wantErr)
+		})
+	}
+}

+ 773 - 0
server/oscar/handler/mock_icq_service_test.go

@@ -0,0 +1,773 @@
+// Code generated by mockery v2.43.2. DO NOT EDIT.
+
+package handler
+
+import (
+	context "context"
+
+	state "github.com/mk6i/retro-aim-server/state"
+	mock "github.com/stretchr/testify/mock"
+
+	wire "github.com/mk6i/retro-aim-server/wire"
+)
+
+// mockICQService is an autogenerated mock type for the ICQService type
+type mockICQService struct {
+	mock.Mock
+}
+
+type mockICQService_Expecter struct {
+	mock *mock.Mock
+}
+
+func (_m *mockICQService) EXPECT() *mockICQService_Expecter {
+	return &mockICQService_Expecter{mock: &_m.Mock}
+}
+
+// FindByDetails provides a mock function with given fields: ctx, sess, req, seq
+func (_m *mockICQService) FindByDetails(ctx context.Context, sess *state.Session, req wire.ICQ_0x07D0_0x0515_DBQueryMetaReqSearchByDetails, seq uint16) error {
+	ret := _m.Called(ctx, sess, req, seq)
+
+	if len(ret) == 0 {
+		panic("no return value specified for FindByDetails")
+	}
+
+	var r0 error
+	if rf, ok := ret.Get(0).(func(context.Context, *state.Session, wire.ICQ_0x07D0_0x0515_DBQueryMetaReqSearchByDetails, uint16) error); ok {
+		r0 = rf(ctx, sess, req, seq)
+	} else {
+		r0 = ret.Error(0)
+	}
+
+	return r0
+}
+
+// mockICQService_FindByDetails_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'FindByDetails'
+type mockICQService_FindByDetails_Call struct {
+	*mock.Call
+}
+
+// FindByDetails is a helper method to define mock.On call
+//   - ctx context.Context
+//   - sess *state.Session
+//   - req wire.ICQ_0x07D0_0x0515_DBQueryMetaReqSearchByDetails
+//   - seq uint16
+func (_e *mockICQService_Expecter) FindByDetails(ctx interface{}, sess interface{}, req interface{}, seq interface{}) *mockICQService_FindByDetails_Call {
+	return &mockICQService_FindByDetails_Call{Call: _e.mock.On("FindByDetails", ctx, sess, req, seq)}
+}
+
+func (_c *mockICQService_FindByDetails_Call) Run(run func(ctx context.Context, sess *state.Session, req wire.ICQ_0x07D0_0x0515_DBQueryMetaReqSearchByDetails, seq uint16)) *mockICQService_FindByDetails_Call {
+	_c.Call.Run(func(args mock.Arguments) {
+		run(args[0].(context.Context), args[1].(*state.Session), args[2].(wire.ICQ_0x07D0_0x0515_DBQueryMetaReqSearchByDetails), args[3].(uint16))
+	})
+	return _c
+}
+
+func (_c *mockICQService_FindByDetails_Call) Return(_a0 error) *mockICQService_FindByDetails_Call {
+	_c.Call.Return(_a0)
+	return _c
+}
+
+func (_c *mockICQService_FindByDetails_Call) RunAndReturn(run func(context.Context, *state.Session, wire.ICQ_0x07D0_0x0515_DBQueryMetaReqSearchByDetails, uint16) error) *mockICQService_FindByDetails_Call {
+	_c.Call.Return(run)
+	return _c
+}
+
+// FindByEmail provides a mock function with given fields: ctx, sess, req, seq
+func (_m *mockICQService) FindByEmail(ctx context.Context, sess *state.Session, req wire.ICQ_0x07D0_0x0529_DBQueryMetaReqSearchByEmail, seq uint16) error {
+	ret := _m.Called(ctx, sess, req, seq)
+
+	if len(ret) == 0 {
+		panic("no return value specified for FindByEmail")
+	}
+
+	var r0 error
+	if rf, ok := ret.Get(0).(func(context.Context, *state.Session, wire.ICQ_0x07D0_0x0529_DBQueryMetaReqSearchByEmail, uint16) error); ok {
+		r0 = rf(ctx, sess, req, seq)
+	} else {
+		r0 = ret.Error(0)
+	}
+
+	return r0
+}
+
+// mockICQService_FindByEmail_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'FindByEmail'
+type mockICQService_FindByEmail_Call struct {
+	*mock.Call
+}
+
+// FindByEmail is a helper method to define mock.On call
+//   - ctx context.Context
+//   - sess *state.Session
+//   - req wire.ICQ_0x07D0_0x0529_DBQueryMetaReqSearchByEmail
+//   - seq uint16
+func (_e *mockICQService_Expecter) FindByEmail(ctx interface{}, sess interface{}, req interface{}, seq interface{}) *mockICQService_FindByEmail_Call {
+	return &mockICQService_FindByEmail_Call{Call: _e.mock.On("FindByEmail", ctx, sess, req, seq)}
+}
+
+func (_c *mockICQService_FindByEmail_Call) Run(run func(ctx context.Context, sess *state.Session, req wire.ICQ_0x07D0_0x0529_DBQueryMetaReqSearchByEmail, seq uint16)) *mockICQService_FindByEmail_Call {
+	_c.Call.Run(func(args mock.Arguments) {
+		run(args[0].(context.Context), args[1].(*state.Session), args[2].(wire.ICQ_0x07D0_0x0529_DBQueryMetaReqSearchByEmail), args[3].(uint16))
+	})
+	return _c
+}
+
+func (_c *mockICQService_FindByEmail_Call) Return(_a0 error) *mockICQService_FindByEmail_Call {
+	_c.Call.Return(_a0)
+	return _c
+}
+
+func (_c *mockICQService_FindByEmail_Call) RunAndReturn(run func(context.Context, *state.Session, wire.ICQ_0x07D0_0x0529_DBQueryMetaReqSearchByEmail, uint16) error) *mockICQService_FindByEmail_Call {
+	_c.Call.Return(run)
+	return _c
+}
+
+// FindByInterests provides a mock function with given fields: ctx, sess, req, seq
+func (_m *mockICQService) FindByInterests(ctx context.Context, sess *state.Session, req wire.ICQ_0x07D0_0x0533_DBQueryMetaReqSearchWhitePages, seq uint16) error {
+	ret := _m.Called(ctx, sess, req, seq)
+
+	if len(ret) == 0 {
+		panic("no return value specified for FindByInterests")
+	}
+
+	var r0 error
+	if rf, ok := ret.Get(0).(func(context.Context, *state.Session, wire.ICQ_0x07D0_0x0533_DBQueryMetaReqSearchWhitePages, uint16) error); ok {
+		r0 = rf(ctx, sess, req, seq)
+	} else {
+		r0 = ret.Error(0)
+	}
+
+	return r0
+}
+
+// mockICQService_FindByInterests_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'FindByInterests'
+type mockICQService_FindByInterests_Call struct {
+	*mock.Call
+}
+
+// FindByInterests is a helper method to define mock.On call
+//   - ctx context.Context
+//   - sess *state.Session
+//   - req wire.ICQ_0x07D0_0x0533_DBQueryMetaReqSearchWhitePages
+//   - seq uint16
+func (_e *mockICQService_Expecter) FindByInterests(ctx interface{}, sess interface{}, req interface{}, seq interface{}) *mockICQService_FindByInterests_Call {
+	return &mockICQService_FindByInterests_Call{Call: _e.mock.On("FindByInterests", ctx, sess, req, seq)}
+}
+
+func (_c *mockICQService_FindByInterests_Call) Run(run func(ctx context.Context, sess *state.Session, req wire.ICQ_0x07D0_0x0533_DBQueryMetaReqSearchWhitePages, seq uint16)) *mockICQService_FindByInterests_Call {
+	_c.Call.Run(func(args mock.Arguments) {
+		run(args[0].(context.Context), args[1].(*state.Session), args[2].(wire.ICQ_0x07D0_0x0533_DBQueryMetaReqSearchWhitePages), args[3].(uint16))
+	})
+	return _c
+}
+
+func (_c *mockICQService_FindByInterests_Call) Return(_a0 error) *mockICQService_FindByInterests_Call {
+	_c.Call.Return(_a0)
+	return _c
+}
+
+func (_c *mockICQService_FindByInterests_Call) RunAndReturn(run func(context.Context, *state.Session, wire.ICQ_0x07D0_0x0533_DBQueryMetaReqSearchWhitePages, uint16) error) *mockICQService_FindByInterests_Call {
+	_c.Call.Return(run)
+	return _c
+}
+
+// FindByUIN provides a mock function with given fields: ctx, sess, req, seq
+func (_m *mockICQService) FindByUIN(ctx context.Context, sess *state.Session, req wire.ICQ_0x07D0_0x051F_DBQueryMetaReqSearchByUIN, seq uint16) error {
+	ret := _m.Called(ctx, sess, req, seq)
+
+	if len(ret) == 0 {
+		panic("no return value specified for FindByUIN")
+	}
+
+	var r0 error
+	if rf, ok := ret.Get(0).(func(context.Context, *state.Session, wire.ICQ_0x07D0_0x051F_DBQueryMetaReqSearchByUIN, uint16) error); ok {
+		r0 = rf(ctx, sess, req, seq)
+	} else {
+		r0 = ret.Error(0)
+	}
+
+	return r0
+}
+
+// mockICQService_FindByUIN_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'FindByUIN'
+type mockICQService_FindByUIN_Call struct {
+	*mock.Call
+}
+
+// FindByUIN is a helper method to define mock.On call
+//   - ctx context.Context
+//   - sess *state.Session
+//   - req wire.ICQ_0x07D0_0x051F_DBQueryMetaReqSearchByUIN
+//   - seq uint16
+func (_e *mockICQService_Expecter) FindByUIN(ctx interface{}, sess interface{}, req interface{}, seq interface{}) *mockICQService_FindByUIN_Call {
+	return &mockICQService_FindByUIN_Call{Call: _e.mock.On("FindByUIN", ctx, sess, req, seq)}
+}
+
+func (_c *mockICQService_FindByUIN_Call) Run(run func(ctx context.Context, sess *state.Session, req wire.ICQ_0x07D0_0x051F_DBQueryMetaReqSearchByUIN, seq uint16)) *mockICQService_FindByUIN_Call {
+	_c.Call.Run(func(args mock.Arguments) {
+		run(args[0].(context.Context), args[1].(*state.Session), args[2].(wire.ICQ_0x07D0_0x051F_DBQueryMetaReqSearchByUIN), args[3].(uint16))
+	})
+	return _c
+}
+
+func (_c *mockICQService_FindByUIN_Call) Return(_a0 error) *mockICQService_FindByUIN_Call {
+	_c.Call.Return(_a0)
+	return _c
+}
+
+func (_c *mockICQService_FindByUIN_Call) RunAndReturn(run func(context.Context, *state.Session, wire.ICQ_0x07D0_0x051F_DBQueryMetaReqSearchByUIN, uint16) error) *mockICQService_FindByUIN_Call {
+	_c.Call.Return(run)
+	return _c
+}
+
+// FullUserInfo provides a mock function with given fields: ctx, sess, req, seq
+func (_m *mockICQService) FullUserInfo(ctx context.Context, sess *state.Session, req wire.ICQ_0x07D0_0x051F_DBQueryMetaReqSearchByUIN, seq uint16) error {
+	ret := _m.Called(ctx, sess, req, seq)
+
+	if len(ret) == 0 {
+		panic("no return value specified for FullUserInfo")
+	}
+
+	var r0 error
+	if rf, ok := ret.Get(0).(func(context.Context, *state.Session, wire.ICQ_0x07D0_0x051F_DBQueryMetaReqSearchByUIN, uint16) error); ok {
+		r0 = rf(ctx, sess, req, seq)
+	} else {
+		r0 = ret.Error(0)
+	}
+
+	return r0
+}
+
+// mockICQService_FullUserInfo_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'FullUserInfo'
+type mockICQService_FullUserInfo_Call struct {
+	*mock.Call
+}
+
+// FullUserInfo is a helper method to define mock.On call
+//   - ctx context.Context
+//   - sess *state.Session
+//   - req wire.ICQ_0x07D0_0x051F_DBQueryMetaReqSearchByUIN
+//   - seq uint16
+func (_e *mockICQService_Expecter) FullUserInfo(ctx interface{}, sess interface{}, req interface{}, seq interface{}) *mockICQService_FullUserInfo_Call {
+	return &mockICQService_FullUserInfo_Call{Call: _e.mock.On("FullUserInfo", ctx, sess, req, seq)}
+}
+
+func (_c *mockICQService_FullUserInfo_Call) Run(run func(ctx context.Context, sess *state.Session, req wire.ICQ_0x07D0_0x051F_DBQueryMetaReqSearchByUIN, seq uint16)) *mockICQService_FullUserInfo_Call {
+	_c.Call.Run(func(args mock.Arguments) {
+		run(args[0].(context.Context), args[1].(*state.Session), args[2].(wire.ICQ_0x07D0_0x051F_DBQueryMetaReqSearchByUIN), args[3].(uint16))
+	})
+	return _c
+}
+
+func (_c *mockICQService_FullUserInfo_Call) Return(_a0 error) *mockICQService_FullUserInfo_Call {
+	_c.Call.Return(_a0)
+	return _c
+}
+
+func (_c *mockICQService_FullUserInfo_Call) RunAndReturn(run func(context.Context, *state.Session, wire.ICQ_0x07D0_0x051F_DBQueryMetaReqSearchByUIN, uint16) error) *mockICQService_FullUserInfo_Call {
+	_c.Call.Return(run)
+	return _c
+}
+
+// MessagesEOF provides a mock function with given fields: ctx, sess, seq
+func (_m *mockICQService) MessagesEOF(ctx context.Context, sess *state.Session, seq uint16) error {
+	ret := _m.Called(ctx, sess, seq)
+
+	if len(ret) == 0 {
+		panic("no return value specified for MessagesEOF")
+	}
+
+	var r0 error
+	if rf, ok := ret.Get(0).(func(context.Context, *state.Session, uint16) error); ok {
+		r0 = rf(ctx, sess, seq)
+	} else {
+		r0 = ret.Error(0)
+	}
+
+	return r0
+}
+
+// mockICQService_MessagesEOF_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'MessagesEOF'
+type mockICQService_MessagesEOF_Call struct {
+	*mock.Call
+}
+
+// MessagesEOF is a helper method to define mock.On call
+//   - ctx context.Context
+//   - sess *state.Session
+//   - seq uint16
+func (_e *mockICQService_Expecter) MessagesEOF(ctx interface{}, sess interface{}, seq interface{}) *mockICQService_MessagesEOF_Call {
+	return &mockICQService_MessagesEOF_Call{Call: _e.mock.On("MessagesEOF", ctx, sess, seq)}
+}
+
+func (_c *mockICQService_MessagesEOF_Call) Run(run func(ctx context.Context, sess *state.Session, seq uint16)) *mockICQService_MessagesEOF_Call {
+	_c.Call.Run(func(args mock.Arguments) {
+		run(args[0].(context.Context), args[1].(*state.Session), args[2].(uint16))
+	})
+	return _c
+}
+
+func (_c *mockICQService_MessagesEOF_Call) Return(_a0 error) *mockICQService_MessagesEOF_Call {
+	_c.Call.Return(_a0)
+	return _c
+}
+
+func (_c *mockICQService_MessagesEOF_Call) RunAndReturn(run func(context.Context, *state.Session, uint16) error) *mockICQService_MessagesEOF_Call {
+	_c.Call.Return(run)
+	return _c
+}
+
+// SetAffiliations provides a mock function with given fields: ctx, sess, req, seq
+func (_m *mockICQService) SetAffiliations(ctx context.Context, sess *state.Session, req wire.ICQ_0x07D0_0x041A_DBQueryMetaReqSetAffiliations, seq uint16) error {
+	ret := _m.Called(ctx, sess, req, seq)
+
+	if len(ret) == 0 {
+		panic("no return value specified for SetAffiliations")
+	}
+
+	var r0 error
+	if rf, ok := ret.Get(0).(func(context.Context, *state.Session, wire.ICQ_0x07D0_0x041A_DBQueryMetaReqSetAffiliations, uint16) error); ok {
+		r0 = rf(ctx, sess, req, seq)
+	} else {
+		r0 = ret.Error(0)
+	}
+
+	return r0
+}
+
+// mockICQService_SetAffiliations_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'SetAffiliations'
+type mockICQService_SetAffiliations_Call struct {
+	*mock.Call
+}
+
+// SetAffiliations is a helper method to define mock.On call
+//   - ctx context.Context
+//   - sess *state.Session
+//   - req wire.ICQ_0x07D0_0x041A_DBQueryMetaReqSetAffiliations
+//   - seq uint16
+func (_e *mockICQService_Expecter) SetAffiliations(ctx interface{}, sess interface{}, req interface{}, seq interface{}) *mockICQService_SetAffiliations_Call {
+	return &mockICQService_SetAffiliations_Call{Call: _e.mock.On("SetAffiliations", ctx, sess, req, seq)}
+}
+
+func (_c *mockICQService_SetAffiliations_Call) Run(run func(ctx context.Context, sess *state.Session, req wire.ICQ_0x07D0_0x041A_DBQueryMetaReqSetAffiliations, seq uint16)) *mockICQService_SetAffiliations_Call {
+	_c.Call.Run(func(args mock.Arguments) {
+		run(args[0].(context.Context), args[1].(*state.Session), args[2].(wire.ICQ_0x07D0_0x041A_DBQueryMetaReqSetAffiliations), args[3].(uint16))
+	})
+	return _c
+}
+
+func (_c *mockICQService_SetAffiliations_Call) Return(_a0 error) *mockICQService_SetAffiliations_Call {
+	_c.Call.Return(_a0)
+	return _c
+}
+
+func (_c *mockICQService_SetAffiliations_Call) RunAndReturn(run func(context.Context, *state.Session, wire.ICQ_0x07D0_0x041A_DBQueryMetaReqSetAffiliations, uint16) error) *mockICQService_SetAffiliations_Call {
+	_c.Call.Return(run)
+	return _c
+}
+
+// SetBasicInfo provides a mock function with given fields: ctx, sess, req, seq
+func (_m *mockICQService) SetBasicInfo(ctx context.Context, sess *state.Session, req wire.ICQ_0x07D0_0x03EA_DBQueryMetaReqSetBasicInfo, seq uint16) error {
+	ret := _m.Called(ctx, sess, req, seq)
+
+	if len(ret) == 0 {
+		panic("no return value specified for SetBasicInfo")
+	}
+
+	var r0 error
+	if rf, ok := ret.Get(0).(func(context.Context, *state.Session, wire.ICQ_0x07D0_0x03EA_DBQueryMetaReqSetBasicInfo, uint16) error); ok {
+		r0 = rf(ctx, sess, req, seq)
+	} else {
+		r0 = ret.Error(0)
+	}
+
+	return r0
+}
+
+// mockICQService_SetBasicInfo_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'SetBasicInfo'
+type mockICQService_SetBasicInfo_Call struct {
+	*mock.Call
+}
+
+// SetBasicInfo is a helper method to define mock.On call
+//   - ctx context.Context
+//   - sess *state.Session
+//   - req wire.ICQ_0x07D0_0x03EA_DBQueryMetaReqSetBasicInfo
+//   - seq uint16
+func (_e *mockICQService_Expecter) SetBasicInfo(ctx interface{}, sess interface{}, req interface{}, seq interface{}) *mockICQService_SetBasicInfo_Call {
+	return &mockICQService_SetBasicInfo_Call{Call: _e.mock.On("SetBasicInfo", ctx, sess, req, seq)}
+}
+
+func (_c *mockICQService_SetBasicInfo_Call) Run(run func(ctx context.Context, sess *state.Session, req wire.ICQ_0x07D0_0x03EA_DBQueryMetaReqSetBasicInfo, seq uint16)) *mockICQService_SetBasicInfo_Call {
+	_c.Call.Run(func(args mock.Arguments) {
+		run(args[0].(context.Context), args[1].(*state.Session), args[2].(wire.ICQ_0x07D0_0x03EA_DBQueryMetaReqSetBasicInfo), args[3].(uint16))
+	})
+	return _c
+}
+
+func (_c *mockICQService_SetBasicInfo_Call) Return(_a0 error) *mockICQService_SetBasicInfo_Call {
+	_c.Call.Return(_a0)
+	return _c
+}
+
+func (_c *mockICQService_SetBasicInfo_Call) RunAndReturn(run func(context.Context, *state.Session, wire.ICQ_0x07D0_0x03EA_DBQueryMetaReqSetBasicInfo, uint16) error) *mockICQService_SetBasicInfo_Call {
+	_c.Call.Return(run)
+	return _c
+}
+
+// SetEmails provides a mock function with given fields: ctx, sess, req, seq
+func (_m *mockICQService) SetEmails(ctx context.Context, sess *state.Session, req wire.ICQ_0x07D0_0x040B_DBQueryMetaReqSetEmails, seq uint16) error {
+	ret := _m.Called(ctx, sess, req, seq)
+
+	if len(ret) == 0 {
+		panic("no return value specified for SetEmails")
+	}
+
+	var r0 error
+	if rf, ok := ret.Get(0).(func(context.Context, *state.Session, wire.ICQ_0x07D0_0x040B_DBQueryMetaReqSetEmails, uint16) error); ok {
+		r0 = rf(ctx, sess, req, seq)
+	} else {
+		r0 = ret.Error(0)
+	}
+
+	return r0
+}
+
+// mockICQService_SetEmails_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'SetEmails'
+type mockICQService_SetEmails_Call struct {
+	*mock.Call
+}
+
+// SetEmails is a helper method to define mock.On call
+//   - ctx context.Context
+//   - sess *state.Session
+//   - req wire.ICQ_0x07D0_0x040B_DBQueryMetaReqSetEmails
+//   - seq uint16
+func (_e *mockICQService_Expecter) SetEmails(ctx interface{}, sess interface{}, req interface{}, seq interface{}) *mockICQService_SetEmails_Call {
+	return &mockICQService_SetEmails_Call{Call: _e.mock.On("SetEmails", ctx, sess, req, seq)}
+}
+
+func (_c *mockICQService_SetEmails_Call) Run(run func(ctx context.Context, sess *state.Session, req wire.ICQ_0x07D0_0x040B_DBQueryMetaReqSetEmails, seq uint16)) *mockICQService_SetEmails_Call {
+	_c.Call.Run(func(args mock.Arguments) {
+		run(args[0].(context.Context), args[1].(*state.Session), args[2].(wire.ICQ_0x07D0_0x040B_DBQueryMetaReqSetEmails), args[3].(uint16))
+	})
+	return _c
+}
+
+func (_c *mockICQService_SetEmails_Call) Return(_a0 error) *mockICQService_SetEmails_Call {
+	_c.Call.Return(_a0)
+	return _c
+}
+
+func (_c *mockICQService_SetEmails_Call) RunAndReturn(run func(context.Context, *state.Session, wire.ICQ_0x07D0_0x040B_DBQueryMetaReqSetEmails, uint16) error) *mockICQService_SetEmails_Call {
+	_c.Call.Return(run)
+	return _c
+}
+
+// SetInterests provides a mock function with given fields: ctx, sess, req, seq
+func (_m *mockICQService) SetInterests(ctx context.Context, sess *state.Session, req wire.ICQ_0x07D0_0x0410_DBQueryMetaReqSetInterests, seq uint16) error {
+	ret := _m.Called(ctx, sess, req, seq)
+
+	if len(ret) == 0 {
+		panic("no return value specified for SetInterests")
+	}
+
+	var r0 error
+	if rf, ok := ret.Get(0).(func(context.Context, *state.Session, wire.ICQ_0x07D0_0x0410_DBQueryMetaReqSetInterests, uint16) error); ok {
+		r0 = rf(ctx, sess, req, seq)
+	} else {
+		r0 = ret.Error(0)
+	}
+
+	return r0
+}
+
+// mockICQService_SetInterests_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'SetInterests'
+type mockICQService_SetInterests_Call struct {
+	*mock.Call
+}
+
+// SetInterests is a helper method to define mock.On call
+//   - ctx context.Context
+//   - sess *state.Session
+//   - req wire.ICQ_0x07D0_0x0410_DBQueryMetaReqSetInterests
+//   - seq uint16
+func (_e *mockICQService_Expecter) SetInterests(ctx interface{}, sess interface{}, req interface{}, seq interface{}) *mockICQService_SetInterests_Call {
+	return &mockICQService_SetInterests_Call{Call: _e.mock.On("SetInterests", ctx, sess, req, seq)}
+}
+
+func (_c *mockICQService_SetInterests_Call) Run(run func(ctx context.Context, sess *state.Session, req wire.ICQ_0x07D0_0x0410_DBQueryMetaReqSetInterests, seq uint16)) *mockICQService_SetInterests_Call {
+	_c.Call.Run(func(args mock.Arguments) {
+		run(args[0].(context.Context), args[1].(*state.Session), args[2].(wire.ICQ_0x07D0_0x0410_DBQueryMetaReqSetInterests), args[3].(uint16))
+	})
+	return _c
+}
+
+func (_c *mockICQService_SetInterests_Call) Return(_a0 error) *mockICQService_SetInterests_Call {
+	_c.Call.Return(_a0)
+	return _c
+}
+
+func (_c *mockICQService_SetInterests_Call) RunAndReturn(run func(context.Context, *state.Session, wire.ICQ_0x07D0_0x0410_DBQueryMetaReqSetInterests, uint16) error) *mockICQService_SetInterests_Call {
+	_c.Call.Return(run)
+	return _c
+}
+
+// SetMoreInfo provides a mock function with given fields: ctx, sess, req, seq
+func (_m *mockICQService) SetMoreInfo(ctx context.Context, sess *state.Session, req wire.ICQ_0x07D0_0x03FD_DBQueryMetaReqSetMoreInfo, seq uint16) error {
+	ret := _m.Called(ctx, sess, req, seq)
+
+	if len(ret) == 0 {
+		panic("no return value specified for SetMoreInfo")
+	}
+
+	var r0 error
+	if rf, ok := ret.Get(0).(func(context.Context, *state.Session, wire.ICQ_0x07D0_0x03FD_DBQueryMetaReqSetMoreInfo, uint16) error); ok {
+		r0 = rf(ctx, sess, req, seq)
+	} else {
+		r0 = ret.Error(0)
+	}
+
+	return r0
+}
+
+// mockICQService_SetMoreInfo_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'SetMoreInfo'
+type mockICQService_SetMoreInfo_Call struct {
+	*mock.Call
+}
+
+// SetMoreInfo is a helper method to define mock.On call
+//   - ctx context.Context
+//   - sess *state.Session
+//   - req wire.ICQ_0x07D0_0x03FD_DBQueryMetaReqSetMoreInfo
+//   - seq uint16
+func (_e *mockICQService_Expecter) SetMoreInfo(ctx interface{}, sess interface{}, req interface{}, seq interface{}) *mockICQService_SetMoreInfo_Call {
+	return &mockICQService_SetMoreInfo_Call{Call: _e.mock.On("SetMoreInfo", ctx, sess, req, seq)}
+}
+
+func (_c *mockICQService_SetMoreInfo_Call) Run(run func(ctx context.Context, sess *state.Session, req wire.ICQ_0x07D0_0x03FD_DBQueryMetaReqSetMoreInfo, seq uint16)) *mockICQService_SetMoreInfo_Call {
+	_c.Call.Run(func(args mock.Arguments) {
+		run(args[0].(context.Context), args[1].(*state.Session), args[2].(wire.ICQ_0x07D0_0x03FD_DBQueryMetaReqSetMoreInfo), args[3].(uint16))
+	})
+	return _c
+}
+
+func (_c *mockICQService_SetMoreInfo_Call) Return(_a0 error) *mockICQService_SetMoreInfo_Call {
+	_c.Call.Return(_a0)
+	return _c
+}
+
+func (_c *mockICQService_SetMoreInfo_Call) RunAndReturn(run func(context.Context, *state.Session, wire.ICQ_0x07D0_0x03FD_DBQueryMetaReqSetMoreInfo, uint16) error) *mockICQService_SetMoreInfo_Call {
+	_c.Call.Return(run)
+	return _c
+}
+
+// SetPermissions provides a mock function with given fields: ctx, sess, req, seq
+func (_m *mockICQService) SetPermissions(ctx context.Context, sess *state.Session, req wire.ICQ_0x07D0_0x0424_DBQueryMetaReqSetPermissions, seq uint16) error {
+	ret := _m.Called(ctx, sess, req, seq)
+
+	if len(ret) == 0 {
+		panic("no return value specified for SetPermissions")
+	}
+
+	var r0 error
+	if rf, ok := ret.Get(0).(func(context.Context, *state.Session, wire.ICQ_0x07D0_0x0424_DBQueryMetaReqSetPermissions, uint16) error); ok {
+		r0 = rf(ctx, sess, req, seq)
+	} else {
+		r0 = ret.Error(0)
+	}
+
+	return r0
+}
+
+// mockICQService_SetPermissions_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'SetPermissions'
+type mockICQService_SetPermissions_Call struct {
+	*mock.Call
+}
+
+// SetPermissions is a helper method to define mock.On call
+//   - ctx context.Context
+//   - sess *state.Session
+//   - req wire.ICQ_0x07D0_0x0424_DBQueryMetaReqSetPermissions
+//   - seq uint16
+func (_e *mockICQService_Expecter) SetPermissions(ctx interface{}, sess interface{}, req interface{}, seq interface{}) *mockICQService_SetPermissions_Call {
+	return &mockICQService_SetPermissions_Call{Call: _e.mock.On("SetPermissions", ctx, sess, req, seq)}
+}
+
+func (_c *mockICQService_SetPermissions_Call) Run(run func(ctx context.Context, sess *state.Session, req wire.ICQ_0x07D0_0x0424_DBQueryMetaReqSetPermissions, seq uint16)) *mockICQService_SetPermissions_Call {
+	_c.Call.Run(func(args mock.Arguments) {
+		run(args[0].(context.Context), args[1].(*state.Session), args[2].(wire.ICQ_0x07D0_0x0424_DBQueryMetaReqSetPermissions), args[3].(uint16))
+	})
+	return _c
+}
+
+func (_c *mockICQService_SetPermissions_Call) Return(_a0 error) *mockICQService_SetPermissions_Call {
+	_c.Call.Return(_a0)
+	return _c
+}
+
+func (_c *mockICQService_SetPermissions_Call) RunAndReturn(run func(context.Context, *state.Session, wire.ICQ_0x07D0_0x0424_DBQueryMetaReqSetPermissions, uint16) error) *mockICQService_SetPermissions_Call {
+	_c.Call.Return(run)
+	return _c
+}
+
+// SetUserNotes provides a mock function with given fields: ctx, sess, req, seq
+func (_m *mockICQService) SetUserNotes(ctx context.Context, sess *state.Session, req wire.ICQ_0x07D0_0x0406_DBQueryMetaReqSetNotes, seq uint16) error {
+	ret := _m.Called(ctx, sess, req, seq)
+
+	if len(ret) == 0 {
+		panic("no return value specified for SetUserNotes")
+	}
+
+	var r0 error
+	if rf, ok := ret.Get(0).(func(context.Context, *state.Session, wire.ICQ_0x07D0_0x0406_DBQueryMetaReqSetNotes, uint16) error); ok {
+		r0 = rf(ctx, sess, req, seq)
+	} else {
+		r0 = ret.Error(0)
+	}
+
+	return r0
+}
+
+// mockICQService_SetUserNotes_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'SetUserNotes'
+type mockICQService_SetUserNotes_Call struct {
+	*mock.Call
+}
+
+// SetUserNotes is a helper method to define mock.On call
+//   - ctx context.Context
+//   - sess *state.Session
+//   - req wire.ICQ_0x07D0_0x0406_DBQueryMetaReqSetNotes
+//   - seq uint16
+func (_e *mockICQService_Expecter) SetUserNotes(ctx interface{}, sess interface{}, req interface{}, seq interface{}) *mockICQService_SetUserNotes_Call {
+	return &mockICQService_SetUserNotes_Call{Call: _e.mock.On("SetUserNotes", ctx, sess, req, seq)}
+}
+
+func (_c *mockICQService_SetUserNotes_Call) Run(run func(ctx context.Context, sess *state.Session, req wire.ICQ_0x07D0_0x0406_DBQueryMetaReqSetNotes, seq uint16)) *mockICQService_SetUserNotes_Call {
+	_c.Call.Run(func(args mock.Arguments) {
+		run(args[0].(context.Context), args[1].(*state.Session), args[2].(wire.ICQ_0x07D0_0x0406_DBQueryMetaReqSetNotes), args[3].(uint16))
+	})
+	return _c
+}
+
+func (_c *mockICQService_SetUserNotes_Call) Return(_a0 error) *mockICQService_SetUserNotes_Call {
+	_c.Call.Return(_a0)
+	return _c
+}
+
+func (_c *mockICQService_SetUserNotes_Call) RunAndReturn(run func(context.Context, *state.Session, wire.ICQ_0x07D0_0x0406_DBQueryMetaReqSetNotes, uint16) error) *mockICQService_SetUserNotes_Call {
+	_c.Call.Return(run)
+	return _c
+}
+
+// SetWorkInfo provides a mock function with given fields: ctx, sess, req, seq
+func (_m *mockICQService) SetWorkInfo(ctx context.Context, sess *state.Session, req wire.ICQ_0x07D0_0x03F3_DBQueryMetaReqSetWorkInfo, seq uint16) error {
+	ret := _m.Called(ctx, sess, req, seq)
+
+	if len(ret) == 0 {
+		panic("no return value specified for SetWorkInfo")
+	}
+
+	var r0 error
+	if rf, ok := ret.Get(0).(func(context.Context, *state.Session, wire.ICQ_0x07D0_0x03F3_DBQueryMetaReqSetWorkInfo, uint16) error); ok {
+		r0 = rf(ctx, sess, req, seq)
+	} else {
+		r0 = ret.Error(0)
+	}
+
+	return r0
+}
+
+// mockICQService_SetWorkInfo_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'SetWorkInfo'
+type mockICQService_SetWorkInfo_Call struct {
+	*mock.Call
+}
+
+// SetWorkInfo is a helper method to define mock.On call
+//   - ctx context.Context
+//   - sess *state.Session
+//   - req wire.ICQ_0x07D0_0x03F3_DBQueryMetaReqSetWorkInfo
+//   - seq uint16
+func (_e *mockICQService_Expecter) SetWorkInfo(ctx interface{}, sess interface{}, req interface{}, seq interface{}) *mockICQService_SetWorkInfo_Call {
+	return &mockICQService_SetWorkInfo_Call{Call: _e.mock.On("SetWorkInfo", ctx, sess, req, seq)}
+}
+
+func (_c *mockICQService_SetWorkInfo_Call) Run(run func(ctx context.Context, sess *state.Session, req wire.ICQ_0x07D0_0x03F3_DBQueryMetaReqSetWorkInfo, seq uint16)) *mockICQService_SetWorkInfo_Call {
+	_c.Call.Run(func(args mock.Arguments) {
+		run(args[0].(context.Context), args[1].(*state.Session), args[2].(wire.ICQ_0x07D0_0x03F3_DBQueryMetaReqSetWorkInfo), args[3].(uint16))
+	})
+	return _c
+}
+
+func (_c *mockICQService_SetWorkInfo_Call) Return(_a0 error) *mockICQService_SetWorkInfo_Call {
+	_c.Call.Return(_a0)
+	return _c
+}
+
+func (_c *mockICQService_SetWorkInfo_Call) RunAndReturn(run func(context.Context, *state.Session, wire.ICQ_0x07D0_0x03F3_DBQueryMetaReqSetWorkInfo, uint16) error) *mockICQService_SetWorkInfo_Call {
+	_c.Call.Return(run)
+	return _c
+}
+
+// XMLReqData provides a mock function with given fields: ctx, sess, req, seq
+func (_m *mockICQService) XMLReqData(ctx context.Context, sess *state.Session, req wire.ICQ_0x07D0_0x0898_DBQueryMetaReqXMLReq, seq uint16) error {
+	ret := _m.Called(ctx, sess, req, seq)
+
+	if len(ret) == 0 {
+		panic("no return value specified for XMLReqData")
+	}
+
+	var r0 error
+	if rf, ok := ret.Get(0).(func(context.Context, *state.Session, wire.ICQ_0x07D0_0x0898_DBQueryMetaReqXMLReq, uint16) error); ok {
+		r0 = rf(ctx, sess, req, seq)
+	} else {
+		r0 = ret.Error(0)
+	}
+
+	return r0
+}
+
+// mockICQService_XMLReqData_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'XMLReqData'
+type mockICQService_XMLReqData_Call struct {
+	*mock.Call
+}
+
+// XMLReqData is a helper method to define mock.On call
+//   - ctx context.Context
+//   - sess *state.Session
+//   - req wire.ICQ_0x07D0_0x0898_DBQueryMetaReqXMLReq
+//   - seq uint16
+func (_e *mockICQService_Expecter) XMLReqData(ctx interface{}, sess interface{}, req interface{}, seq interface{}) *mockICQService_XMLReqData_Call {
+	return &mockICQService_XMLReqData_Call{Call: _e.mock.On("XMLReqData", ctx, sess, req, seq)}
+}
+
+func (_c *mockICQService_XMLReqData_Call) Run(run func(ctx context.Context, sess *state.Session, req wire.ICQ_0x07D0_0x0898_DBQueryMetaReqXMLReq, seq uint16)) *mockICQService_XMLReqData_Call {
+	_c.Call.Run(func(args mock.Arguments) {
+		run(args[0].(context.Context), args[1].(*state.Session), args[2].(wire.ICQ_0x07D0_0x0898_DBQueryMetaReqXMLReq), args[3].(uint16))
+	})
+	return _c
+}
+
+func (_c *mockICQService_XMLReqData_Call) Return(_a0 error) *mockICQService_XMLReqData_Call {
+	_c.Call.Return(_a0)
+	return _c
+}
+
+func (_c *mockICQService_XMLReqData_Call) RunAndReturn(run func(context.Context, *state.Session, wire.ICQ_0x07D0_0x0898_DBQueryMetaReqXMLReq, uint16) error) *mockICQService_XMLReqData_Call {
+	_c.Call.Return(run)
+	return _c
+}
+
+// newMockICQService creates a new instance of mockICQService. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
+// The first argument is typically a *testing.T value.
+func newMockICQService(t interface {
+	mock.TestingT
+	Cleanup(func())
+}) *mockICQService {
+	mock := &mockICQService{}
+	mock.Mock.Test(t)
+
+	t.Cleanup(func() { mock.AssertExpectations(t) })
+
+	return mock
+}

+ 3 - 0
server/oscar/handler/routes.go

@@ -18,6 +18,7 @@ type Handlers struct {
 	ChatNavHandler
 	FeedbagHandler
 	ICBMHandler
+	ICQHandler
 	LocateHandler
 	OServiceHandler
 	PermitDenyHandler
@@ -53,6 +54,8 @@ func NewBOSRouter(h Handlers) oscar.Router {
 	router.Register(wire.Feedbag, wire.FeedbagUpdateItem, h.FeedbagHandler.UpdateItem)
 	router.Register(wire.Feedbag, wire.FeedbagUse, h.FeedbagHandler.Use)
 
+	router.Register(wire.ICQ, wire.ICQDBQuery, h.ICQHandler.DBQuery)
+
 	router.Register(wire.ICBM, wire.ICBMAddParameters, h.ICBMHandler.AddParameters)
 	router.Register(wire.ICBM, wire.ICBMChannelMsgToHost, h.ICBMHandler.ChannelMsgToHost)
 	router.Register(wire.ICBM, wire.ICBMClientErr, h.ICBMHandler.ClientErr)

+ 2 - 1
server/oscar/middleware/logger.go

@@ -2,6 +2,7 @@ package middleware
 
 import (
 	"context"
+	"fmt"
 	"log/slog"
 	"os"
 	"strings"
@@ -59,7 +60,7 @@ type handler struct {
 
 func (h handler) Handle(ctx context.Context, r slog.Record) error {
 	if sn := ctx.Value("screenName"); sn != nil {
-		//r.AddAttrs(slog.Attr{Key: "screenName", Value: slog.StringValue(sn.(string))})
+		r.AddAttrs(slog.Attr{Key: "screenName", Value: slog.StringValue(sn.(fmt.Stringer).String())})
 	}
 	if ip := ctx.Value("ip"); ip != nil {
 		r.AddAttrs(slog.Attr{Key: "ip", Value: slog.StringValue(ip.(string))})

+ 0 - 0
state/migrations/0007_icq_fields.down.sql


+ 114 - 0
state/migrations/0007_icq_fields.up.sql

@@ -0,0 +1,114 @@
+ALTER TABLE users
+    ADD COLUMN isICQ BOOLEAN NOT NULL DEFAULT false;
+ALTER TABLE users
+    ADD COLUMN icq_affiliations_currentCode1 INTEGER NOT NULL DEFAULT 0;
+ALTER TABLE users
+    ADD COLUMN icq_affiliations_currentCode2 INTEGER NOT NULL DEFAULT 0;
+ALTER TABLE users
+    ADD COLUMN icq_affiliations_currentCode3 INTEGER NOT NULL DEFAULT 0;
+ALTER TABLE users
+    ADD COLUMN icq_affiliations_currentKeyword1 TEXT NOT NULL DEFAULT '';
+ALTER TABLE users
+    ADD COLUMN icq_affiliations_currentKeyword2 TEXT NOT NULL DEFAULT '';
+ALTER TABLE users
+    ADD COLUMN icq_affiliations_currentKeyword3 TEXT NOT NULL DEFAULT '';
+ALTER TABLE users
+    ADD COLUMN icq_affiliations_pastCode1 INTEGER NOT NULL DEFAULT 0;
+ALTER TABLE users
+    ADD COLUMN icq_affiliations_pastCode2 INTEGER NOT NULL DEFAULT 0;
+ALTER TABLE users
+    ADD COLUMN icq_affiliations_pastCode3 INTEGER NOT NULL DEFAULT 0;
+ALTER TABLE users
+    ADD COLUMN icq_affiliations_pastKeyword1 TEXT NOT NULL DEFAULT '';
+ALTER TABLE users
+    ADD COLUMN icq_affiliations_pastKeyword2 TEXT NOT NULL DEFAULT '';
+ALTER TABLE users
+    ADD COLUMN icq_affiliations_pastKeyword3 TEXT NOT NULL DEFAULT '';
+ALTER TABLE users
+    ADD COLUMN icq_basicInfo_address TEXT NOT NULL DEFAULT '';
+ALTER TABLE users
+    ADD COLUMN icq_basicInfo_cellPhone TEXT NOT NULL DEFAULT '';
+ALTER TABLE users
+    ADD COLUMN icq_basicInfo_city TEXT NOT NULL DEFAULT '';
+ALTER TABLE users
+    ADD COLUMN icq_basicInfo_countryCode INTEGER NOT NULL DEFAULT 0;
+ALTER TABLE users
+    ADD COLUMN icq_basicInfo_emailAddress TEXT NOT NULL DEFAULT '';
+ALTER TABLE users
+    ADD COLUMN icq_basicInfo_fax TEXT NOT NULL DEFAULT '';
+ALTER TABLE users
+    ADD COLUMN icq_basicInfo_firstName TEXT NOT NULL DEFAULT '';
+ALTER TABLE users
+    ADD COLUMN icq_basicInfo_gmtOffset INTEGER NOT NULL DEFAULT 0;
+ALTER TABLE users
+    ADD COLUMN icq_basicInfo_lastName TEXT NOT NULL DEFAULT '';
+ALTER TABLE users
+    ADD COLUMN icq_basicInfo_nickName TEXT NOT NULL DEFAULT '';
+ALTER TABLE users
+    ADD COLUMN icq_basicInfo_phone TEXT NOT NULL DEFAULT '';
+ALTER TABLE users
+    ADD COLUMN icq_basicInfo_publishEmail BOOLEAN NOT NULL DEFAULT false;
+ALTER TABLE users
+    ADD COLUMN icq_basicInfo_state TEXT NOT NULL DEFAULT '';
+ALTER TABLE users
+    ADD COLUMN icq_basicInfo_zipCode TEXT NOT NULL DEFAULT '';
+ALTER TABLE users
+    ADD COLUMN icq_interests_code1 INTEGER NOT NULL DEFAULT 0;
+ALTER TABLE users
+    ADD COLUMN icq_interests_code2 INTEGER NOT NULL DEFAULT 0;
+ALTER TABLE users
+    ADD COLUMN icq_interests_code3 INTEGER NOT NULL DEFAULT 0;
+ALTER TABLE users
+    ADD COLUMN icq_interests_code4 INTEGER NOT NULL DEFAULT 0;
+ALTER TABLE users
+    ADD COLUMN icq_interests_keyword1 TEXT NOT NULL DEFAULT '';
+ALTER TABLE users
+    ADD COLUMN icq_interests_keyword2 TEXT NOT NULL DEFAULT '';
+ALTER TABLE users
+    ADD COLUMN icq_interests_keyword3 TEXT NOT NULL DEFAULT '';
+ALTER TABLE users
+    ADD COLUMN icq_interests_keyword4 TEXT NOT NULL DEFAULT '';
+ALTER TABLE users
+    ADD COLUMN icq_moreInfo_birthDay INTEGER NOT NULL DEFAULT 0;
+ALTER TABLE users
+    ADD COLUMN icq_moreInfo_birthMonth INTEGER NOT NULL DEFAULT 0;
+ALTER TABLE users
+    ADD COLUMN icq_moreInfo_birthYear INTEGER NOT NULL DEFAULT 0;
+ALTER TABLE users
+    ADD COLUMN icq_moreInfo_gender INTEGER NOT NULL DEFAULT 0;
+ALTER TABLE users
+    ADD COLUMN icq_moreInfo_homePageAddr TEXT NOT NULL DEFAULT '';
+ALTER TABLE users
+    ADD COLUMN icq_moreInfo_lang1 INTEGER NOT NULL DEFAULT 0;
+ALTER TABLE users
+    ADD COLUMN icq_moreInfo_lang2 INTEGER NOT NULL DEFAULT 0;
+ALTER TABLE users
+    ADD COLUMN icq_moreInfo_lang3 INTEGER NOT NULL DEFAULT 0;
+ALTER TABLE users
+    ADD COLUMN icq_notes TEXT NOT NULL DEFAULT '';
+ALTER TABLE users
+    ADD COLUMN icq_permissions_authRequired BOOLEAN NOT NULL DEFAULT false;
+ALTER TABLE users
+    ADD COLUMN icq_workInfo_address TEXT NOT NULL DEFAULT '';
+ALTER TABLE users
+    ADD COLUMN icq_workInfo_city TEXT NOT NULL DEFAULT '';
+ALTER TABLE users
+    ADD COLUMN icq_workInfo_company TEXT NOT NULL DEFAULT '';
+ALTER TABLE users
+    ADD COLUMN icq_workInfo_countryCode INTEGER NOT NULL DEFAULT 0;
+ALTER TABLE users
+    ADD COLUMN icq_workInfo_department TEXT NOT NULL DEFAULT '';
+ALTER TABLE users
+    ADD COLUMN icq_workInfo_fax TEXT NOT NULL DEFAULT '';
+ALTER TABLE users
+    ADD COLUMN icq_workInfo_occupationCode INTEGER NOT NULL DEFAULT 0;
+ALTER TABLE users
+    ADD COLUMN icq_workInfo_phone TEXT NOT NULL DEFAULT '';
+ALTER TABLE users
+    ADD COLUMN icq_workInfo_position TEXT NOT NULL DEFAULT '';
+ALTER TABLE users
+    ADD COLUMN icq_workInfo_state TEXT NOT NULL DEFAULT '';
+ALTER TABLE users
+    ADD COLUMN icq_workInfo_webPage TEXT NOT NULL DEFAULT '';
+ALTER TABLE users
+    ADD COLUMN icq_workInfo_zipCode TEXT NOT NULL DEFAULT '';

+ 210 - 5
state/user.go

@@ -3,8 +3,10 @@ package state
 import (
 	"bytes"
 	"errors"
+	"fmt"
 	"strconv"
 	"strings"
+	"time"
 	"unicode"
 
 	"github.com/google/uuid"
@@ -61,8 +63,9 @@ func NewIdentScreenName(screenName string) IdentScreenName {
 type DisplayScreenName string
 
 var (
-	ErrAIMHandleLength        = errors.New("screen name must be between 3 and 16 characters")
 	ErrAIMHandleInvalidFormat = errors.New("screen name must start with a letter, cannot end with a space, and must contain only letters, numbers, and spaces")
+	ErrAIMHandleLength        = errors.New("screen name must be between 3 and 16 characters")
+	ErrPasswordInvalid        = errors.New("invalid password length")
 	ErrICQUINInvalidFormat    = errors.New("uin must be a number in the range 10000-2147483646")
 )
 
@@ -155,6 +158,202 @@ type User struct {
 	// WeakMD5Pass is the MD5 password hash format used by AIM v3.5-v4.7. This
 	// hash is used to authenticate roasted passwords for AIM v1.0-v3.0.
 	WeakMD5Pass []byte
+	// IsICQ indicates whether the user is an ICQ account (true) or an AIM
+	// account (false).
+	IsICQ bool
+	// ConfirmStatus indicates whether the user has confirmed their AIM account.
+	ConfirmStatus bool
+	// RegStatus is the AIM registration status.
+	//  1: no disclosure
+	//  2: limit disclosure
+	//  3: full disclosure
+	RegStatus int
+	// EmailAddress is the email address set by the AIM client.
+	EmailAddress string
+	// ICQAffiliations holds information about the user's affiliations,
+	// including past and current affiliations.
+	ICQAffiliations ICQAffiliations
+	// ICQInterests holds information about the user's interests, categorized
+	// by code and associated keywords.
+	ICQInterests ICQInterests
+	// ICQMoreInfo contains additional information about the user.
+	ICQMoreInfo ICQMoreInfo
+	// ICQPermissions specifies the user's privacy settings.
+	ICQPermissions ICQPermissions
+	// ICQBasicInfo contains the user's basic profile information, including
+	// contact details and personal identifiers.
+	ICQBasicInfo ICQBasicInfo
+	// ICQNotes allows the user to store personal notes or additional
+	// information within their profile.
+	ICQNotes ICQUserNotes
+	// ICQWorkInfo contains the user's professional information, including
+	// their workplace address and job-related details.
+	ICQWorkInfo ICQWorkInfo
+}
+
+// ICQBasicInfo holds basic information about an ICQ user, including their name, contact details, and location.
+type ICQBasicInfo struct {
+	// Address is the user's residential address.
+	Address string
+	// CellPhone is the user's mobile phone number.
+	CellPhone string
+	// City is the city where the user resides.
+	City string
+	// CountryCode is the code representing the user's country of residence.
+	CountryCode uint16
+	// EmailAddress is the user's primary email address.
+	EmailAddress string
+	// Fax is the user's fax number.
+	Fax string
+	// FirstName is the user's first name.
+	FirstName string
+	// GMTOffset is the user's time zone offset from GMT.
+	GMTOffset uint8
+	// LastName is the user's last name.
+	LastName string
+	// Nickname is the user's nickname or preferred name.
+	Nickname string
+	// Phone is the user's landline phone number.
+	Phone string
+	// PublishEmail indicates whether the user's email address is public.
+	PublishEmail bool
+	// State is the state or region where the user resides.
+	State string
+	// ZIPCode is the user's postal code.
+	ZIPCode string
+}
+
+// ICQAffiliations contains information about the user's affiliations, both past and present.
+type ICQAffiliations struct {
+	// PastCode1 is the code representing the user's first past affiliation.
+	PastCode1 uint16
+	// PastKeyword1 is the keyword associated with the user's first past affiliation.
+	PastKeyword1 string
+	// PastCode2 is the code representing the user's second past affiliation.
+	PastCode2 uint16
+	// PastKeyword2 is the keyword associated with the user's second past affiliation.
+	PastKeyword2 string
+	// PastCode3 is the code representing the user's third past affiliation.
+	PastCode3 uint16
+	// PastKeyword3 is the keyword associated with the user's third past affiliation.
+	PastKeyword3 string
+	// CurrentCode1 is the code representing the user's current first affiliation.
+	CurrentCode1 uint16
+	// CurrentKeyword1 is the keyword associated with the user's current first affiliation.
+	CurrentKeyword1 string
+	// CurrentCode2 is the code representing the user's current second affiliation.
+	CurrentCode2 uint16
+	// CurrentKeyword2 is the keyword associated with the user's current second affiliation.
+	CurrentKeyword2 string
+	// CurrentCode3 is the code representing the user's current third affiliation.
+	CurrentCode3 uint16
+	// CurrentKeyword3 is the keyword associated with the user's current third affiliation.
+	CurrentKeyword3 string
+}
+
+// ICQInterests holds information about the user's interests, categorized by
+// interest code and associated keyword.
+type ICQInterests struct {
+	// Code1 is the code representing the user's first interest.
+	Code1 uint16
+	// Keyword1 is the keyword associated with the user's first interest.
+	Keyword1 string
+	// Code2 is the code representing the user's second interest.
+	Code2 uint16
+	// Keyword2 is the keyword associated with the user's second interest.
+	Keyword2 string
+	// Code3 is the code representing the user's third interest.
+	Code3 uint16
+	// Keyword3 is the keyword associated with the user's third interest.
+	Keyword3 string
+	// Code4 is the code representing the user's fourth interest.
+	Code4 uint16
+	// Keyword4 is the keyword associated with the user's fourth interest.
+	Keyword4 string
+}
+
+// ICQUserNotes contains personal notes or additional information added by the user.
+type ICQUserNotes struct {
+	// Notes are the personal notes or additional information the user has
+	// entered in their profile.
+	Notes string
+}
+
+// ICQMoreInfo contains additional information about the user, such as
+// demographic and language preferences.
+type ICQMoreInfo struct {
+	// Gender is the user's gender, represented by a code.
+	Gender uint16
+	// HomePageAddr is the URL of the user's personal homepage.
+	HomePageAddr string
+	// BirthYear is the user's birth year.
+	BirthYear uint16
+	// BirthMonth is the user's birth month.
+	BirthMonth uint8
+	// BirthDay is the user's birth day.
+	BirthDay uint8
+	// Lang1 is the code for the user's primary language.
+	Lang1 uint8
+	// Lang2 is the code for the user's secondary language.
+	Lang2 uint8
+	// Lang3 is the code for the user's tertiary language.
+	Lang3 uint8
+}
+
+// ICQWorkInfo contains information about the user's professional life,
+// including their workplace and job title.
+type ICQWorkInfo struct {
+	// Address is the address of the user's workplace.
+	Address string
+	// City is the city where the user's workplace is located.
+	City string
+	// Company is the name of the user's employer or company.
+	Company string
+	// CountryCode is the code representing the country where the user's
+	// workplace is located.
+	CountryCode uint16
+	// Department is the name of the department within the user's company.
+	Department string
+	// Fax is the fax number for the user's workplace.
+	Fax string
+	// OccupationCode is the code representing the user's occupation.
+	OccupationCode uint16
+	// Phone is the phone number for the user's workplace.
+	Phone string
+	// Position is the user's job title or position within the company.
+	Position string
+	// State is the state or region where the user's workplace is located.
+	State string
+	// WebPage is the URL of the user's company's website.
+	WebPage string
+	// ZIPCode is the postal code for the user's workplace.
+	ZIPCode string
+}
+
+// ICQPermissions specifies the privacy settings of an ICQ user.
+type ICQPermissions struct {
+	// AuthRequired indicates where users must ask this permission to add them
+	// to their contact list.
+	AuthRequired bool
+}
+
+// Age returns the user's age relative to their birthday and timeNow.
+func (u *User) Age(timeNow func() time.Time) uint16 {
+	now := timeNow().UTC()
+	switch {
+	case u.ICQMoreInfo.BirthYear > 0 && u.ICQMoreInfo.BirthDay == 0 && u.ICQMoreInfo.BirthMonth == 0:
+		bday := time.Date(int(u.ICQMoreInfo.BirthYear), time.January, 1, 0, 0, 0, 0, time.UTC)
+		return uint16(now.Year() - bday.Year())
+	case u.ICQMoreInfo.BirthYear > 0 && u.ICQMoreInfo.BirthDay > 0 && u.ICQMoreInfo.BirthMonth > 0:
+		bday := time.Date(int(u.ICQMoreInfo.BirthYear), time.Month(u.ICQMoreInfo.BirthMonth), int(u.ICQMoreInfo.BirthDay), 0, 0, 0, 0, time.UTC)
+		years := now.Year() - bday.Year()
+		if now.YearDay() < bday.YearDay() {
+			years--
+		}
+		return uint16(years)
+	default: // invalid date
+		return 0
+	}
 }
 
 // ValidateHash checks if md5Hash is identical to one of the password hashes.
@@ -174,8 +373,14 @@ func (u *User) ValidateRoastedPass(roastedPass []byte) bool {
 // HashPassword computes MD5 hashes of the user's password. It computes both
 // weak and strong variants and stores them in the struct.
 func (u *User) HashPassword(passwd string) error {
-	if err := validateAIMPassword(passwd); err != nil {
-		return err
+	if u.IsICQ {
+		if err := validateICQPassword(passwd); err != nil {
+			return err
+		}
+	} else {
+		if err := validateAIMPassword(passwd); err != nil {
+			return err
+		}
 	}
 	u.WeakMD5Pass = wire.WeakMD5PasswordHash(passwd, u.AuthKey)
 	u.StrongMD5Pass = wire.StrongMD5PasswordHash(passwd, u.AuthKey)
@@ -187,7 +392,7 @@ func (u *User) HashPassword(passwd string) error {
 // values reflect AOL's password validation rules circa 2000.
 func validateAIMPassword(pass string) error {
 	if len(pass) < 4 || len(pass) > 16 {
-		return errors.New("password length must be between 4-16 characters")
+		return fmt.Errorf("%w: password length must be between 4-16 characters", ErrPasswordInvalid)
 	}
 	return nil
 }
@@ -198,7 +403,7 @@ func validateAIMPassword(pass string) error {
 // reflects the password limitation imposed by old ICQ clients.
 func validateICQPassword(pass string) error {
 	if len(pass) < 6 || len(pass) > 8 {
-		return errors.New("password must be between 6 and 8 characters")
+		return fmt.Errorf("%w: password must be between 6-8 characters", ErrPasswordInvalid)
 	}
 	return nil
 }

+ 534 - 19
state/user_store.go

@@ -9,6 +9,8 @@ import (
 	"io/fs"
 	"net/http"
 	"net/mail"
+	"strconv"
+	"strings"
 	"time"
 
 	"github.com/mk6i/retro-aim-server/wire"
@@ -97,39 +99,292 @@ func (f SQLiteUserStore) AllUsers() ([]User, error) {
 	return users, nil
 }
 
+// FindByUIN returns a user where the UIN matches the ident screen name.
+func (f SQLiteUserStore) FindByUIN(UIN uint32) (User, error) {
+	users, err := getUsers(func() (string, []any) {
+		return `identScreenName = ?`, []any{strconv.Itoa(int(UIN))}
+	}, f.db)
+	if err != nil {
+		return User{}, fmt.Errorf("FindByUIN: %w", err)
+	}
+
+	if len(users) == 0 {
+		return User{}, ErrNoUser
+	}
+
+	return users[0], nil
+}
+
+// FindByEmail returns a user with a matching email address.
+func (f SQLiteUserStore) FindByEmail(email string) (User, error) {
+	users, err := getUsers(func() (string, []any) {
+		return `icq_basicInfo_emailAddress = ?`, []any{email}
+	}, f.db)
+	if err != nil {
+		return User{}, fmt.Errorf("FindByEmail: %w", err)
+	}
+
+	if len(users) == 0 {
+		return User{}, ErrNoUser
+	}
+
+	return users[0], nil
+}
+
+// FindByDetails returns a user with either a matching first name, last name, or nickname.
+func (f SQLiteUserStore) FindByDetails(firstName, lastName, nickName string) ([]User, error) {
+	users, err := getUsers(func() (string, []any) {
+		var conds []string
+		var vals []any
+		if firstName != "" {
+			conds = append(conds, `LOWER(icq_basicInfo_firstName) = LOWER(?)`)
+			vals = append(vals, firstName)
+		}
+		if lastName != "" {
+			conds = append(conds, `LOWER(icq_basicInfo_lastName) = LOWER(?)`)
+			vals = append(vals, lastName)
+		}
+		if nickName != "" {
+			conds = append(conds, `LOWER(icq_basicInfo_nickName) = LOWER(?)`)
+			vals = append(vals, nickName)
+		}
+		return strings.Join(conds, " AND "), vals
+	}, f.db)
+	if err != nil {
+		err = fmt.Errorf("FindByDetails: %w", err)
+	}
+	return users, nil
+}
+
+// FindByInterests returns a user who has at least one matching interest.
+func (f SQLiteUserStore) FindByInterests(code uint16, keywords []string) ([]User, error) {
+	users, err := getUsers(func() (string, []any) {
+		var conds []string
+		var vals []any
+
+		for i := 1; i <= 4; i++ {
+			var subConds []string
+			vals = append(vals, code)
+			for _, key := range keywords {
+				subConds = append(subConds, fmt.Sprintf("icq_interests_keyword%d LIKE ?", i))
+				vals = append(vals, "%"+key+"%")
+			}
+			conds = append(conds, fmt.Sprintf("(icq_interests_code%d = ? AND (%s))", i, strings.Join(subConds, " OR ")))
+		}
+
+		return strings.Join(conds, " OR "), vals
+	}, f.db)
+	if err != nil {
+		err = fmt.Errorf("FindByInterests: %w", err)
+	}
+	return users, nil
+}
+
 // User looks up a user by screen name. It populates the User record with
 // credentials that can be used to validate the user's password.
 func (f SQLiteUserStore) User(screenName IdentScreenName) (*User, error) {
+	users, err := getUsers(func() (string, []any) {
+		return `identScreenName = ?`, []any{screenName.String()}
+	}, f.db)
+	if err != nil {
+		return nil, fmt.Errorf("user: %w", err)
+	}
+
+	if len(users) == 0 {
+		return nil, nil
+	}
+
+	return &users[0], nil
+}
+
+type filterFN func() (string, []any)
+
+type queryer interface {
+	Query(query string, args ...any) (*sql.Rows, error)
+}
+
+// getUsers fetches users from the database by their screen name.
+func getUsers(filterFN filterFN, tx queryer) ([]User, error) {
+
+	cond, parms := filterFN()
+
 	q := `
 		SELECT
+			identScreenName,
 			displayScreenName,
+			emailAddress,
 			authKey,
+			strongMD5Pass,
 			weakMD5Pass,
-			strongMD5Pass
+			confirmStatus,
+			regStatus,
+			isICQ,
+			icq_affiliations_currentCode1,
+			icq_affiliations_currentCode2,
+			icq_affiliations_currentCode3,
+			icq_affiliations_currentKeyword1,
+			icq_affiliations_currentKeyword2,
+			icq_affiliations_currentKeyword3,
+			icq_affiliations_pastCode1,
+			icq_affiliations_pastCode2,
+			icq_affiliations_pastCode3,
+			icq_affiliations_pastKeyword1,
+			icq_affiliations_pastKeyword2,
+			icq_affiliations_pastKeyword3,
+			icq_basicInfo_address,
+			icq_basicInfo_cellPhone,
+			icq_basicInfo_city,
+			icq_basicInfo_countryCode,
+			icq_basicInfo_emailAddress,
+			icq_basicInfo_fax,
+			icq_basicInfo_firstName,
+			icq_basicInfo_gmtOffset,
+			icq_basicInfo_lastName,
+			icq_basicInfo_nickName,
+			icq_basicInfo_phone,
+			icq_basicInfo_publishEmail,
+			icq_basicInfo_state,
+			icq_basicInfo_zipCode,
+			icq_interests_code1,
+			icq_interests_code2,
+			icq_interests_code3,
+			icq_interests_code4,
+			icq_interests_keyword1,
+			icq_interests_keyword2,
+			icq_interests_keyword3,
+			icq_interests_keyword4,
+			icq_moreInfo_birthDay,
+			icq_moreInfo_birthMonth,
+			icq_moreInfo_birthYear,
+			icq_moreInfo_gender,
+			icq_moreInfo_homePageAddr,
+			icq_moreInfo_lang1,
+			icq_moreInfo_lang2,
+			icq_moreInfo_lang3,
+			icq_notes,
+			icq_permissions_authRequired,
+			icq_workInfo_address,
+			icq_workInfo_city,
+			icq_workInfo_company,
+			icq_workInfo_countryCode,
+			icq_workInfo_department,
+			icq_workInfo_fax,
+			icq_workInfo_occupationCode,
+			icq_workInfo_phone,
+			icq_workInfo_position,
+			icq_workInfo_state,
+			icq_workInfo_webPage,
+			icq_workInfo_zipCode
 		FROM users
-		WHERE identScreenName = ?
+		WHERE %s
 	`
-	u := &User{
-		IdentScreenName: screenName,
+	q = fmt.Sprintf(q, cond)
+	rows, err := tx.Query(q, parms...)
+	if err != nil {
+		return nil, err
 	}
-	err := f.db.QueryRow(q, screenName.String()).
-		Scan(&u.DisplayScreenName, &u.AuthKey, &u.WeakMD5Pass, &u.StrongMD5Pass)
+	defer rows.Close()
 
-	if errors.Is(err, sql.ErrNoRows) {
-		return nil, nil
+	var users []User
+	for rows.Next() {
+		var u User
+		var sn string
+		err := rows.Scan(
+			&sn,
+			&u.DisplayScreenName,
+			&u.EmailAddress,
+			&u.AuthKey,
+			&u.StrongMD5Pass,
+			&u.WeakMD5Pass,
+			&u.ConfirmStatus,
+			&u.RegStatus,
+			&u.IsICQ,
+			&u.ICQAffiliations.CurrentCode1,
+			&u.ICQAffiliations.CurrentCode2,
+			&u.ICQAffiliations.CurrentCode3,
+			&u.ICQAffiliations.CurrentKeyword1,
+			&u.ICQAffiliations.CurrentKeyword2,
+			&u.ICQAffiliations.CurrentKeyword3,
+			&u.ICQAffiliations.PastCode1,
+			&u.ICQAffiliations.PastCode2,
+			&u.ICQAffiliations.PastCode3,
+			&u.ICQAffiliations.PastKeyword1,
+			&u.ICQAffiliations.PastKeyword2,
+			&u.ICQAffiliations.PastKeyword3,
+			&u.ICQBasicInfo.Address,
+			&u.ICQBasicInfo.CellPhone,
+			&u.ICQBasicInfo.City,
+			&u.ICQBasicInfo.CountryCode,
+			&u.ICQBasicInfo.EmailAddress,
+			&u.ICQBasicInfo.Fax,
+			&u.ICQBasicInfo.FirstName,
+			&u.ICQBasicInfo.GMTOffset,
+			&u.ICQBasicInfo.LastName,
+			&u.ICQBasicInfo.Nickname,
+			&u.ICQBasicInfo.Phone,
+			&u.ICQBasicInfo.PublishEmail,
+			&u.ICQBasicInfo.State,
+			&u.ICQBasicInfo.ZIPCode,
+			&u.ICQInterests.Code1,
+			&u.ICQInterests.Code2,
+			&u.ICQInterests.Code3,
+			&u.ICQInterests.Code4,
+			&u.ICQInterests.Keyword1,
+			&u.ICQInterests.Keyword2,
+			&u.ICQInterests.Keyword3,
+			&u.ICQInterests.Keyword4,
+			&u.ICQMoreInfo.BirthDay,
+			&u.ICQMoreInfo.BirthMonth,
+			&u.ICQMoreInfo.BirthYear,
+			&u.ICQMoreInfo.Gender,
+			&u.ICQMoreInfo.HomePageAddr,
+			&u.ICQMoreInfo.Lang1,
+			&u.ICQMoreInfo.Lang2,
+			&u.ICQMoreInfo.Lang3,
+			&u.ICQNotes.Notes,
+			&u.ICQPermissions.AuthRequired,
+			&u.ICQWorkInfo.Address,
+			&u.ICQWorkInfo.City,
+			&u.ICQWorkInfo.Company,
+			&u.ICQWorkInfo.CountryCode,
+			&u.ICQWorkInfo.Department,
+			&u.ICQWorkInfo.Fax,
+			&u.ICQWorkInfo.OccupationCode,
+			&u.ICQWorkInfo.Phone,
+			&u.ICQWorkInfo.Position,
+			&u.ICQWorkInfo.State,
+			&u.ICQWorkInfo.WebPage,
+			&u.ICQWorkInfo.ZIPCode,
+		)
+		if err != nil {
+			return nil, err
+		}
+		u.IdentScreenName = NewIdentScreenName(sn)
+		users = append(users, u)
 	}
-	return u, err
+	if err = rows.Err(); err != nil {
+		return nil, err
+	}
+
+	return users, nil
 }
 
 // InsertUser inserts a user to the store. Return ErrDupUser if a user with the
 // same screen name already exists.
 func (f SQLiteUserStore) InsertUser(u User) error {
 	q := `
-		INSERT INTO users (identScreenName, displayScreenName, authKey, weakMD5Pass, strongMD5Pass)
-		VALUES (?, ?, ?, ?, ?)
+		INSERT INTO users (identScreenName, displayScreenName, authKey, weakMD5Pass, strongMD5Pass, isICQ)
+		VALUES (?, ?, ?, ?, ?, ?)
 		ON CONFLICT (identScreenName) DO NOTHING
 	`
-	result, err := f.db.Exec(q, u.IdentScreenName.String(), u.DisplayScreenName, u.AuthKey, u.WeakMD5Pass, u.StrongMD5Pass)
+	result, err := f.db.Exec(q,
+		u.IdentScreenName.String(),
+		u.DisplayScreenName,
+		u.AuthKey,
+		u.WeakMD5Pass,
+		u.StrongMD5Pass,
+		u.IsICQ,
+	)
 	if err != nil {
 		return err
 	}
@@ -173,10 +428,10 @@ func (f SQLiteUserStore) DeleteUser(screenName IdentScreenName) error {
 // - WeakMD5Pass
 // - StrongMD5Pass
 // - IdentScreenName
-func (f SQLiteUserStore) SetUserPassword(u User) (err error) {
+func (f SQLiteUserStore) SetUserPassword(screenName IdentScreenName, newPassword string) error {
 	tx, err := f.db.Begin()
 	if err != nil {
-		return
+		return err
 	}
 
 	defer func() {
@@ -186,18 +441,41 @@ func (f SQLiteUserStore) SetUserPassword(u User) (err error) {
 	}()
 
 	q := `
+		SELECT
+			authKey,
+			isICQ
+		FROM users
+		WHERE identScreenName = ?
+	`
+
+	u := User{}
+
+	err = tx.QueryRow(q, screenName.String()).Scan(
+		&u.AuthKey,
+		&u.IsICQ,
+	)
+
+	if errors.Is(err, sql.ErrNoRows) {
+		return ErrNoUser
+	}
+
+	if err = u.HashPassword(newPassword); err != nil {
+		return err
+	}
+
+	q = `
 		UPDATE users
 		SET authKey = ?, weakMD5Pass = ?, strongMD5Pass = ?
 		WHERE identScreenName = ?
 	`
-	result, err := tx.Exec(q, u.AuthKey, u.WeakMD5Pass, u.StrongMD5Pass, u.IdentScreenName.String())
+	result, err := tx.Exec(q, u.AuthKey, u.WeakMD5Pass, u.StrongMD5Pass, screenName.String())
 	if err != nil {
-		return
+		return err
 	}
 
 	rowsAffected, err := result.RowsAffected()
 	if err != nil {
-		return
+		return err
 	}
 
 	if rowsAffected == 0 {
@@ -206,11 +484,11 @@ func (f SQLiteUserStore) SetUserPassword(u User) (err error) {
 		var exists int
 		err = tx.QueryRow("SELECT COUNT(*) FROM users WHERE identScreenName = ?", u.IdentScreenName.String()).Scan(&exists)
 		if err != nil {
-			return // Handle possible SQL errors during the select
+			return err // Handle possible SQL errors during the select
 		}
 		if exists == 0 {
 			err = ErrNoUser // User does not exist
-			return
+			return err
 		}
 	}
 
@@ -683,3 +961,240 @@ func (f SQLiteUserStore) ConfirmStatusByName(screenName IdentScreenName) (bool,
 	}
 	return confirmStatus, nil
 }
+
+// SetWorkInfo updates the work-related information for an ICQ user.
+func (f SQLiteUserStore) SetWorkInfo(name IdentScreenName, data ICQWorkInfo) error {
+	q := `
+		UPDATE users SET 
+			icq_workInfo_company = ?,
+			icq_workInfo_department = ?,
+			icq_workInfo_occupationCode = ?,
+			icq_workInfo_position = ?,
+			icq_workInfo_address = ?,
+			icq_workInfo_city = ?,
+			icq_workInfo_countryCode = ?,
+			icq_workInfo_fax = ?,
+			icq_workInfo_phone = ?,
+			icq_workInfo_state = ?,
+			icq_workInfo_webPage = ?,
+			icq_workInfo_zipCode = ?
+		WHERE identScreenName = ?
+	`
+	res, err := f.db.Exec(q,
+		data.Company,
+		data.Department,
+		data.OccupationCode,
+		data.Position,
+		data.Address,
+		data.City,
+		data.CountryCode,
+		data.Fax,
+		data.Phone,
+		data.State,
+		data.WebPage,
+		data.ZIPCode,
+		name.String(),
+	)
+	if err != nil {
+		return fmt.Errorf("exec: %w", err)
+	}
+	c, err := res.RowsAffected()
+	if err != nil {
+		return fmt.Errorf("rows affected: %w", err)
+	}
+	if c == 0 {
+		return ErrNoUser
+	}
+	return nil
+}
+
+func (f SQLiteUserStore) SetMoreInfo(name IdentScreenName, data ICQMoreInfo) error {
+	q := `
+		UPDATE users SET 
+			icq_moreInfo_birthDay = ?,
+			icq_moreInfo_birthMonth = ?,
+			icq_moreInfo_birthYear = ?,
+			icq_moreInfo_gender = ?,
+			icq_moreInfo_homePageAddr = ?,
+			icq_moreInfo_lang1 = ?,
+			icq_moreInfo_lang2 = ?,
+			icq_moreInfo_lang3 = ?
+		WHERE identScreenName = ?
+	`
+	res, err := f.db.Exec(q,
+		data.BirthDay,
+		data.BirthMonth,
+		data.BirthYear,
+		data.Gender,
+		data.HomePageAddr,
+		data.Lang1,
+		data.Lang2,
+		data.Lang3,
+		name.String(),
+	)
+	if err != nil {
+		return fmt.Errorf("exec: %w", err)
+	}
+	c, err := res.RowsAffected()
+	if err != nil {
+		return fmt.Errorf("rows affected: %w", err)
+	}
+	if c == 0 {
+		return ErrNoUser
+	}
+	return nil
+}
+
+func (f SQLiteUserStore) SetUserNotes(name IdentScreenName, data ICQUserNotes) error {
+	q := `
+		UPDATE users
+		SET icq_notes = ?
+		WHERE identScreenName = ?
+	`
+	res, err := f.db.Exec(q,
+		data.Notes,
+		name.String(),
+	)
+	if err != nil {
+		return fmt.Errorf("exec: %w", err)
+	}
+	c, err := res.RowsAffected()
+	if err != nil {
+		return fmt.Errorf("rows affected: %w", err)
+	}
+	if c == 0 {
+		return ErrNoUser
+	}
+	return nil
+}
+
+func (f SQLiteUserStore) SetInterests(name IdentScreenName, data ICQInterests) error {
+	q := `
+		UPDATE users SET 
+			icq_interests_code1 = ?,
+			icq_interests_keyword1 = ?,
+			icq_interests_code2 = ?,
+			icq_interests_keyword2 = ?,
+			icq_interests_code3 = ?,
+			icq_interests_keyword3 = ?,
+			icq_interests_code4 = ?,
+			icq_interests_keyword4 = ?
+		WHERE identScreenName = ?
+	`
+	res, err := f.db.Exec(q,
+		data.Code1,
+		data.Keyword1,
+		data.Code2,
+		data.Keyword2,
+		data.Code3,
+		data.Keyword3,
+		data.Code4,
+		data.Keyword4,
+		name.String(),
+	)
+	if err != nil {
+		return fmt.Errorf("exec: %w", err)
+	}
+	c, err := res.RowsAffected()
+	if err != nil {
+		return fmt.Errorf("rows affected: %w", err)
+	}
+	if c == 0 {
+		return ErrNoUser
+	}
+	return nil
+}
+
+func (f SQLiteUserStore) SetAffiliations(name IdentScreenName, data ICQAffiliations) error {
+	q := `
+		UPDATE users SET 
+			icq_affiliations_currentCode1 = ?,
+			icq_affiliations_currentKeyword1 = ?,
+			icq_affiliations_currentCode2 = ?,
+			icq_affiliations_currentKeyword2 = ?,
+			icq_affiliations_currentCode3 = ?,
+			icq_affiliations_currentKeyword3 = ?,
+			icq_affiliations_pastCode1 = ?,
+			icq_affiliations_pastKeyword1 = ?,
+			icq_affiliations_pastCode2 = ?,
+			icq_affiliations_pastKeyword2 = ?,
+			icq_affiliations_pastCode3 = ?,
+			icq_affiliations_pastKeyword3 = ?
+		WHERE identScreenName = ?
+	`
+	res, err := f.db.Exec(q,
+		data.CurrentCode1,
+		data.CurrentKeyword1,
+		data.CurrentCode2,
+		data.CurrentKeyword2,
+		data.CurrentCode3,
+		data.CurrentKeyword3,
+		data.PastCode1,
+		data.PastKeyword1,
+		data.PastCode2,
+		data.PastKeyword2,
+		data.PastCode3,
+		data.PastKeyword3,
+		name.String(),
+	)
+	if err != nil {
+		return fmt.Errorf("exec: %w", err)
+	}
+	c, err := res.RowsAffected()
+	if err != nil {
+		return fmt.Errorf("rows affected: %w", err)
+	}
+	if c == 0 {
+		return ErrNoUser
+	}
+	return nil
+}
+
+func (f SQLiteUserStore) SetBasicInfo(name IdentScreenName, data ICQBasicInfo) error {
+	q := `
+		UPDATE users SET 
+			icq_basicInfo_cellPhone = ?,
+			icq_basicInfo_countryCode = ?,
+			icq_basicInfo_emailAddress = ?,
+			icq_basicInfo_firstName = ?,
+			icq_basicInfo_gmtOffset = ?,
+			icq_basicInfo_address = ?,
+			icq_basicInfo_city = ?,
+			icq_basicInfo_fax = ?,
+			icq_basicInfo_phone = ?,
+			icq_basicInfo_state = ?,
+			icq_basicInfo_lastName = ?,
+			icq_basicInfo_nickName = ?,
+			icq_basicInfo_publishEmail = ?,
+			icq_basicInfo_zipCode = ?
+		WHERE identScreenName = ?
+	`
+	res, err := f.db.Exec(q,
+		data.CellPhone,
+		data.CountryCode,
+		data.EmailAddress,
+		data.FirstName,
+		data.GMTOffset,
+		data.Address,
+		data.City,
+		data.Fax,
+		data.Phone,
+		data.State,
+		data.LastName,
+		data.Nickname,
+		data.PublishEmail,
+		data.ZIPCode,
+		name.String(),
+	)
+	if err != nil {
+		return fmt.Errorf("exec: %w", err)
+	}
+	c, err := res.RowsAffected()
+	if err != nil {
+		return fmt.Errorf("rows affected: %w", err)
+	}
+	if c == 0 {
+		return ErrNoUser
+	}
+	return nil
+}

+ 848 - 24
state/user_store_test.go

@@ -406,6 +406,7 @@ func TestGetUser(t *testing.T) {
 		DisplayScreenName: DisplayScreenName("testscreenname"),
 		AuthKey:           "theauthkey",
 		StrongMD5Pass:     []byte("thepasshash"),
+		RegStatus:         3,
 	}
 	err = f.InsertUser(*insertedUser)
 	assert.NoError(t, err)
@@ -647,16 +648,16 @@ func TestSQLiteUserStore_SetUserPassword_UserExists(t *testing.T) {
 	err = feedbagStore.InsertUser(u)
 	assert.NoError(t, err)
 
-	err = u.HashPassword("thenewpassword")
-	assert.NoError(t, err)
-
-	err = feedbagStore.SetUserPassword(u)
+	err = feedbagStore.SetUserPassword(u.IdentScreenName, "theNEWpassword")
 	assert.NoError(t, err)
 
 	gotUser, err := feedbagStore.User(u.IdentScreenName)
 	assert.NoError(t, err)
 
-	valid := gotUser.ValidateHash(u.StrongMD5Pass)
+	wantUser := User{}
+	wantUser.HashPassword("theNEWpassword")
+
+	valid := gotUser.ValidateHash(wantUser.StrongMD5Pass)
 	assert.True(t, valid)
 }
 
@@ -668,18 +669,7 @@ func TestSQLiteUserStore_SetUserPassword_ErrNoUser(t *testing.T) {
 	feedbagStore, err := NewSQLiteUserStore(testFile)
 	assert.NoError(t, err)
 
-	u := User{
-		IdentScreenName:   NewIdentScreenName("theuser"),
-		DisplayScreenName: "theUser",
-	}
-	err = u.HashPassword("thepassword")
-	assert.NoError(t, err)
-
-	err = feedbagStore.SetUserPassword(u)
-	assert.ErrorIs(t, err, ErrNoUser)
-
-	// make sure previous transaction previously closed
-	err = feedbagStore.SetUserPassword(u)
+	err = feedbagStore.SetUserPassword(NewIdentScreenName("some_user"), "thepassword")
 	assert.ErrorIs(t, err, ErrNoUser)
 }
 
@@ -826,15 +816,11 @@ func TestUpdateDisplayScreenName(t *testing.T) {
 	user := User{
 		DisplayScreenName: screenNameOriginal,
 		IdentScreenName:   screenNameOriginal.IdentScreenName(),
-	}
-	userFormatted := User{
-		DisplayScreenName: screenNameFormatted,
-		IdentScreenName:   screenNameFormatted.IdentScreenName(),
+		RegStatus:         3,
 	}
 	if err := f.InsertUser(user); err != nil {
 		t.Fatalf("failed to upsert new user: %s", err.Error())
 	}
-
 	err = f.UpdateDisplayScreenName(screenNameFormatted)
 	if err != nil {
 		t.Fatalf("failed to update display screen name: %s", err.Error())
@@ -844,7 +830,845 @@ func TestUpdateDisplayScreenName(t *testing.T) {
 	if err != nil {
 		t.Fatalf("failed to retrieve screen name: %s", err.Error())
 	}
-	if !reflect.DeepEqual(userFormatted, *dbUser) {
-		t.Fatalf("users did not match:\n expected: %v\n actual: %v", userFormatted, dbUser)
+
+	assert.Equal(t, dbUser.DisplayScreenName, screenNameFormatted)
+}
+
+func TestSQLiteUserStore_SetWorkInfo(t *testing.T) {
+	defer func() {
+		assert.NoError(t, os.Remove(testFile))
+	}()
+
+	f, err := NewSQLiteUserStore(testFile)
+	if err != nil {
+		assert.NoError(t, err)
+	}
+
+	screenName := NewIdentScreenName("testuser")
+	user := User{
+		IdentScreenName: screenName,
 	}
+	err = f.InsertUser(user)
+	assert.NoError(t, err)
+
+	// Define the work info to set
+	workInfo := ICQWorkInfo{
+		Company:        "Test Company",
+		Department:     "Test Department",
+		OccupationCode: 10,
+		Position:       "Test Position",
+		Address:        "123 Test St",
+		City:           "Test City",
+		CountryCode:    1,
+		Fax:            "123-456-7890",
+		Phone:          "098-765-4321",
+		State:          "Test State",
+		WebPage:        "http://www.test.com",
+		ZIPCode:        "12345",
+	}
+
+	t.Run("Successful Update", func(t *testing.T) {
+		// Call SetWorkInfo
+		err := f.SetWorkInfo(screenName, workInfo)
+		assert.NoError(t, err)
+
+		// Retrieve the user and verify the work info was set correctly
+		updatedUser, err := f.User(screenName)
+		assert.NoError(t, err)
+		assert.Equal(t, workInfo.Company, updatedUser.ICQWorkInfo.Company)
+		assert.Equal(t, workInfo.Department, updatedUser.ICQWorkInfo.Department)
+		assert.Equal(t, workInfo.OccupationCode, updatedUser.ICQWorkInfo.OccupationCode)
+		assert.Equal(t, workInfo.Position, updatedUser.ICQWorkInfo.Position)
+		assert.Equal(t, workInfo.Address, updatedUser.ICQWorkInfo.Address)
+		assert.Equal(t, workInfo.City, updatedUser.ICQWorkInfo.City)
+		assert.Equal(t, workInfo.CountryCode, updatedUser.ICQWorkInfo.CountryCode)
+		assert.Equal(t, workInfo.Fax, updatedUser.ICQWorkInfo.Fax)
+		assert.Equal(t, workInfo.Phone, updatedUser.ICQWorkInfo.Phone)
+		assert.Equal(t, workInfo.State, updatedUser.ICQWorkInfo.State)
+		assert.Equal(t, workInfo.WebPage, updatedUser.ICQWorkInfo.WebPage)
+		assert.Equal(t, workInfo.ZIPCode, updatedUser.ICQWorkInfo.ZIPCode)
+	})
+
+	t.Run("Update Non-Existing User", func(t *testing.T) {
+		// Try to set work info for a non-existing user
+		nonExistingScreenName := NewIdentScreenName("nonexistentuser")
+		err := f.SetWorkInfo(nonExistingScreenName, workInfo)
+
+		// This should return ErrNoUser, as the user does not exist
+		assert.ErrorIs(t, err, ErrNoUser)
+	})
+
+	t.Run("Empty Work Info", func(t *testing.T) {
+		// Test updating with empty work info (depending on business rules, this might be allowed or not)
+		emptyWorkInfo := ICQWorkInfo{}
+		err := f.SetWorkInfo(screenName, emptyWorkInfo)
+		assert.NoError(t, err)
+
+		// Retrieve the user and verify that fields are empty or have default values
+		updatedUser, err := f.User(screenName)
+		assert.NoError(t, err)
+		assert.Empty(t, updatedUser.ICQWorkInfo.Company)
+		assert.Empty(t, updatedUser.ICQWorkInfo.Department)
+		assert.Empty(t, updatedUser.ICQWorkInfo.OccupationCode)
+		assert.Empty(t, updatedUser.ICQWorkInfo.Position)
+		assert.Empty(t, updatedUser.ICQWorkInfo.Address)
+		assert.Empty(t, updatedUser.ICQWorkInfo.City)
+		assert.Empty(t, updatedUser.ICQWorkInfo.CountryCode)
+		assert.Empty(t, updatedUser.ICQWorkInfo.Fax)
+		assert.Empty(t, updatedUser.ICQWorkInfo.Phone)
+		assert.Empty(t, updatedUser.ICQWorkInfo.State)
+		assert.Empty(t, updatedUser.ICQWorkInfo.WebPage)
+		assert.Empty(t, updatedUser.ICQWorkInfo.ZIPCode)
+	})
+}
+
+func TestSQLiteUserStore_SetMoreInfo(t *testing.T) {
+	// Cleanup after test
+	defer func() {
+		assert.NoError(t, os.Remove(testFile))
+	}()
+
+	// Initialize the SQLiteUserStore with a test database file
+	f, err := NewSQLiteUserStore(testFile)
+	if err != nil {
+		assert.NoError(t, err)
+	}
+
+	// Create a test user
+	screenName := NewIdentScreenName("testuser")
+	user := User{
+		IdentScreenName: screenName,
+	}
+	err = f.InsertUser(user)
+	assert.NoError(t, err)
+
+	// Define the more info data to set
+	moreInfo := ICQMoreInfo{
+		BirthDay:     15,
+		BirthMonth:   8,
+		BirthYear:    1990,
+		Gender:       1,
+		HomePageAddr: "http://example.com",
+		Lang1:        1,
+		Lang2:        2,
+		Lang3:        3,
+	}
+
+	t.Run("Successful Update", func(t *testing.T) {
+		// Call SetMoreInfo
+		err := f.SetMoreInfo(screenName, moreInfo)
+		assert.NoError(t, err)
+
+		// Retrieve the user and verify the more info was set correctly
+		updatedUser, err := f.User(screenName)
+		assert.NoError(t, err)
+		assert.Equal(t, moreInfo.BirthDay, updatedUser.ICQMoreInfo.BirthDay)
+		assert.Equal(t, moreInfo.BirthMonth, updatedUser.ICQMoreInfo.BirthMonth)
+		assert.Equal(t, moreInfo.BirthYear, updatedUser.ICQMoreInfo.BirthYear)
+		assert.Equal(t, moreInfo.Gender, updatedUser.ICQMoreInfo.Gender)
+		assert.Equal(t, moreInfo.HomePageAddr, updatedUser.ICQMoreInfo.HomePageAddr)
+		assert.Equal(t, moreInfo.Lang1, updatedUser.ICQMoreInfo.Lang1)
+		assert.Equal(t, moreInfo.Lang2, updatedUser.ICQMoreInfo.Lang2)
+		assert.Equal(t, moreInfo.Lang3, updatedUser.ICQMoreInfo.Lang3)
+	})
+
+	t.Run("Update Non-Existing User", func(t *testing.T) {
+		// Try to set more info for a non-existing user
+		nonExistingScreenName := NewIdentScreenName("nonexistentuser")
+		err := f.SetMoreInfo(nonExistingScreenName, moreInfo)
+
+		// This should return ErrNoUser, as the user does not exist
+		assert.ErrorIs(t, err, ErrNoUser)
+	})
+
+	t.Run("Empty More Info", func(t *testing.T) {
+		// Test updating with empty more info
+		emptyMoreInfo := ICQMoreInfo{}
+		err := f.SetMoreInfo(screenName, emptyMoreInfo)
+		assert.NoError(t, err)
+
+		// Retrieve the user and verify that fields are empty or have default values
+		updatedUser, err := f.User(screenName)
+		assert.NoError(t, err)
+		assert.Empty(t, updatedUser.ICQMoreInfo.BirthDay)
+		assert.Empty(t, updatedUser.ICQMoreInfo.BirthMonth)
+		assert.Empty(t, updatedUser.ICQMoreInfo.BirthYear)
+		assert.Empty(t, updatedUser.ICQMoreInfo.Gender)
+		assert.Empty(t, updatedUser.ICQMoreInfo.HomePageAddr)
+		assert.Empty(t, updatedUser.ICQMoreInfo.Lang1)
+		assert.Empty(t, updatedUser.ICQMoreInfo.Lang2)
+		assert.Empty(t, updatedUser.ICQMoreInfo.Lang3)
+	})
+}
+
+func TestSQLiteUserStore_SetUserNotes(t *testing.T) {
+	// Cleanup after test
+	defer func() {
+		assert.NoError(t, os.Remove(testFile))
+	}()
+
+	// Initialize the SQLiteUserStore with a test database file
+	f, err := NewSQLiteUserStore(testFile)
+	if err != nil {
+		assert.NoError(t, err)
+	}
+
+	// Create a test user
+	screenName := NewIdentScreenName("testuser")
+	user := User{
+		IdentScreenName: screenName,
+	}
+	err = f.InsertUser(user)
+	assert.NoError(t, err)
+
+	// Define the user notes to set
+	userNotes := ICQUserNotes{
+		Notes: "This is a test note.",
+	}
+
+	t.Run("Successful Update", func(t *testing.T) {
+		// Call SetUserNotes
+		err := f.SetUserNotes(screenName, userNotes)
+		assert.NoError(t, err)
+
+		// Retrieve the user and verify the notes were set correctly
+		updatedUser, err := f.User(screenName)
+		assert.NoError(t, err)
+		assert.Equal(t, userNotes.Notes, updatedUser.ICQNotes.Notes)
+	})
+
+	t.Run("Update Non-Existing User", func(t *testing.T) {
+		// Try to set notes for a non-existing user
+		nonExistingScreenName := NewIdentScreenName("nonexistentuser")
+		err := f.SetUserNotes(nonExistingScreenName, userNotes)
+
+		// This should return ErrNoUser, as the user does not exist
+		assert.ErrorIs(t, err, ErrNoUser)
+	})
+
+	t.Run("Empty Notes", func(t *testing.T) {
+		// Test updating with empty notes
+		emptyNotes := ICQUserNotes{Notes: ""}
+		err := f.SetUserNotes(screenName, emptyNotes)
+		assert.NoError(t, err)
+
+		// Retrieve the user and verify that notes are empty
+		updatedUser, err := f.User(screenName)
+		assert.NoError(t, err)
+		assert.Empty(t, updatedUser.ICQNotes.Notes)
+	})
+}
+
+func TestSQLiteUserStore_SetInterests(t *testing.T) {
+	// Cleanup after test
+	defer func() {
+		assert.NoError(t, os.Remove(testFile))
+	}()
+
+	// Initialize the SQLiteUserStore with a test database file
+	f, err := NewSQLiteUserStore(testFile)
+	if err != nil {
+		assert.NoError(t, err)
+	}
+
+	// Create a test user
+	screenName := NewIdentScreenName("testuser")
+	user := User{
+		IdentScreenName: screenName,
+	}
+	err = f.InsertUser(user)
+	assert.NoError(t, err)
+
+	// Define the interests data to set
+	interests := ICQInterests{
+		Code1:    1,
+		Keyword1: "Coding",
+		Code2:    2,
+		Keyword2: "Music",
+		Code3:    3,
+		Keyword3: "Gaming",
+		Code4:    4,
+		Keyword4: "Travel",
+	}
+
+	t.Run("Successful Update", func(t *testing.T) {
+		// Call SetInterests
+		err := f.SetInterests(screenName, interests)
+		assert.NoError(t, err)
+
+		// Retrieve the user and verify the interests were set correctly
+		updatedUser, err := f.User(screenName)
+		assert.NoError(t, err)
+		assert.Equal(t, interests.Code1, updatedUser.ICQInterests.Code1)
+		assert.Equal(t, interests.Keyword1, updatedUser.ICQInterests.Keyword1)
+		assert.Equal(t, interests.Code2, updatedUser.ICQInterests.Code2)
+		assert.Equal(t, interests.Keyword2, updatedUser.ICQInterests.Keyword2)
+		assert.Equal(t, interests.Code3, updatedUser.ICQInterests.Code3)
+		assert.Equal(t, interests.Keyword3, updatedUser.ICQInterests.Keyword3)
+		assert.Equal(t, interests.Code4, updatedUser.ICQInterests.Code4)
+		assert.Equal(t, interests.Keyword4, updatedUser.ICQInterests.Keyword4)
+	})
+
+	t.Run("Update Non-Existing User", func(t *testing.T) {
+		// Try to set interests for a non-existing user
+		nonExistingScreenName := NewIdentScreenName("nonexistentuser")
+		err := f.SetInterests(nonExistingScreenName, interests)
+
+		// This should return ErrNoUser, as the user does not exist
+		assert.ErrorIs(t, err, ErrNoUser)
+	})
+
+	t.Run("Empty Interests", func(t *testing.T) {
+		// Test updating with empty interests
+		emptyInterests := ICQInterests{}
+		err := f.SetInterests(screenName, emptyInterests)
+		assert.NoError(t, err)
+
+		// Retrieve the user and verify that interests fields are empty or have default values
+		updatedUser, err := f.User(screenName)
+		assert.NoError(t, err)
+		assert.Empty(t, updatedUser.ICQInterests.Code1)
+		assert.Empty(t, updatedUser.ICQInterests.Keyword1)
+		assert.Empty(t, updatedUser.ICQInterests.Code2)
+		assert.Empty(t, updatedUser.ICQInterests.Keyword2)
+		assert.Empty(t, updatedUser.ICQInterests.Code3)
+		assert.Empty(t, updatedUser.ICQInterests.Keyword3)
+		assert.Empty(t, updatedUser.ICQInterests.Code4)
+		assert.Empty(t, updatedUser.ICQInterests.Keyword4)
+	})
+}
+
+func TestSQLiteUserStore_SetAffiliations(t *testing.T) {
+	// Cleanup after test
+	defer func() {
+		assert.NoError(t, os.Remove(testFile))
+	}()
+
+	// Initialize the SQLiteUserStore with a test database file
+	f, err := NewSQLiteUserStore(testFile)
+	if err != nil {
+		assert.NoError(t, err)
+	}
+
+	// Create a test user
+	screenName := NewIdentScreenName("testuser")
+	user := User{
+		IdentScreenName: screenName,
+	}
+	err = f.InsertUser(user)
+	assert.NoError(t, err)
+
+	// Define the affiliations data to set
+	affiliations := ICQAffiliations{
+		CurrentCode1:    1,
+		CurrentKeyword1: "Sports",
+		CurrentCode2:    2,
+		CurrentKeyword2: "Science",
+		CurrentCode3:    3,
+		CurrentKeyword3: "Arts",
+		PastCode1:       4,
+		PastKeyword1:    "Literature",
+		PastCode2:       5,
+		PastKeyword2:    "Music",
+		PastCode3:       6,
+		PastKeyword3:    "Technology",
+	}
+
+	t.Run("Successful Update", func(t *testing.T) {
+		// Call SetAffiliations
+		err := f.SetAffiliations(screenName, affiliations)
+		assert.NoError(t, err)
+
+		// Retrieve the user and verify the affiliations were set correctly
+		updatedUser, err := f.User(screenName)
+		assert.NoError(t, err)
+		assert.Equal(t, affiliations.CurrentCode1, updatedUser.ICQAffiliations.CurrentCode1)
+		assert.Equal(t, affiliations.CurrentKeyword1, updatedUser.ICQAffiliations.CurrentKeyword1)
+		assert.Equal(t, affiliations.CurrentCode2, updatedUser.ICQAffiliations.CurrentCode2)
+		assert.Equal(t, affiliations.CurrentKeyword2, updatedUser.ICQAffiliations.CurrentKeyword2)
+		assert.Equal(t, affiliations.CurrentCode3, updatedUser.ICQAffiliations.CurrentCode3)
+		assert.Equal(t, affiliations.CurrentKeyword3, updatedUser.ICQAffiliations.CurrentKeyword3)
+		assert.Equal(t, affiliations.PastCode1, updatedUser.ICQAffiliations.PastCode1)
+		assert.Equal(t, affiliations.PastKeyword1, updatedUser.ICQAffiliations.PastKeyword1)
+		assert.Equal(t, affiliations.PastCode2, updatedUser.ICQAffiliations.PastCode2)
+		assert.Equal(t, affiliations.PastKeyword2, updatedUser.ICQAffiliations.PastKeyword2)
+		assert.Equal(t, affiliations.PastCode3, updatedUser.ICQAffiliations.PastCode3)
+		assert.Equal(t, affiliations.PastKeyword3, updatedUser.ICQAffiliations.PastKeyword3)
+	})
+
+	t.Run("Update Non-Existing User", func(t *testing.T) {
+		// Try to set affiliations for a non-existing user
+		nonExistingScreenName := NewIdentScreenName("nonexistentuser")
+		err := f.SetAffiliations(nonExistingScreenName, affiliations)
+
+		// This should return ErrNoUser, as the user does not exist
+		assert.ErrorIs(t, err, ErrNoUser)
+	})
+
+	t.Run("Empty Affiliations", func(t *testing.T) {
+		// Test updating with empty affiliations
+		emptyAffiliations := ICQAffiliations{}
+		err := f.SetAffiliations(screenName, emptyAffiliations)
+		assert.NoError(t, err)
+
+		// Retrieve the user and verify that affiliations fields are empty or have default values
+		updatedUser, err := f.User(screenName)
+		assert.NoError(t, err)
+		assert.Empty(t, updatedUser.ICQAffiliations.CurrentCode1)
+		assert.Empty(t, updatedUser.ICQAffiliations.CurrentKeyword1)
+		assert.Empty(t, updatedUser.ICQAffiliations.CurrentCode2)
+		assert.Empty(t, updatedUser.ICQAffiliations.CurrentKeyword2)
+		assert.Empty(t, updatedUser.ICQAffiliations.CurrentCode3)
+		assert.Empty(t, updatedUser.ICQAffiliations.CurrentKeyword3)
+		assert.Empty(t, updatedUser.ICQAffiliations.PastCode1)
+		assert.Empty(t, updatedUser.ICQAffiliations.PastKeyword1)
+		assert.Empty(t, updatedUser.ICQAffiliations.PastCode2)
+		assert.Empty(t, updatedUser.ICQAffiliations.PastKeyword2)
+		assert.Empty(t, updatedUser.ICQAffiliations.PastCode3)
+		assert.Empty(t, updatedUser.ICQAffiliations.PastKeyword3)
+	})
+}
+
+func TestSQLiteUserStore_SetBasicInfo(t *testing.T) {
+	// Cleanup after test
+	defer func() {
+		assert.NoError(t, os.Remove(testFile))
+	}()
+
+	// Initialize the SQLiteUserStore with a test database file
+	f, err := NewSQLiteUserStore(testFile)
+	if err != nil {
+		assert.NoError(t, err)
+	}
+
+	// Create a test user
+	screenName := NewIdentScreenName("testuser")
+	user := User{
+		IdentScreenName: screenName,
+	}
+	err = f.InsertUser(user)
+	assert.NoError(t, err)
+
+	// Define the basic info data to set
+	basicInfo := ICQBasicInfo{
+		CellPhone:    "123-456-7890",
+		CountryCode:  1,
+		EmailAddress: "test@example.com",
+		FirstName:    "John",
+		GMTOffset:    5,
+		Address:      "123 Main St",
+		City:         "Test City",
+		Fax:          "098-765-4321",
+		Phone:        "111-222-3333",
+		State:        "Test State",
+		LastName:     "Doe",
+		Nickname:     "Johnny",
+		PublishEmail: true,
+		ZIPCode:      "12345",
+	}
+
+	t.Run("Successful Update", func(t *testing.T) {
+		// Call SetBasicInfo
+		err := f.SetBasicInfo(screenName, basicInfo)
+		assert.NoError(t, err)
+
+		// Retrieve the user and verify the basic info was set correctly
+		updatedUser, err := f.User(screenName)
+		assert.NoError(t, err)
+		assert.Equal(t, basicInfo.CellPhone, updatedUser.ICQBasicInfo.CellPhone)
+		assert.Equal(t, basicInfo.CountryCode, updatedUser.ICQBasicInfo.CountryCode)
+		assert.Equal(t, basicInfo.EmailAddress, updatedUser.ICQBasicInfo.EmailAddress)
+		assert.Equal(t, basicInfo.FirstName, updatedUser.ICQBasicInfo.FirstName)
+		assert.Equal(t, basicInfo.GMTOffset, updatedUser.ICQBasicInfo.GMTOffset)
+		assert.Equal(t, basicInfo.Address, updatedUser.ICQBasicInfo.Address)
+		assert.Equal(t, basicInfo.City, updatedUser.ICQBasicInfo.City)
+		assert.Equal(t, basicInfo.Fax, updatedUser.ICQBasicInfo.Fax)
+		assert.Equal(t, basicInfo.Phone, updatedUser.ICQBasicInfo.Phone)
+		assert.Equal(t, basicInfo.State, updatedUser.ICQBasicInfo.State)
+		assert.Equal(t, basicInfo.LastName, updatedUser.ICQBasicInfo.LastName)
+		assert.Equal(t, basicInfo.Nickname, updatedUser.ICQBasicInfo.Nickname)
+		assert.Equal(t, basicInfo.PublishEmail, updatedUser.ICQBasicInfo.PublishEmail)
+		assert.Equal(t, basicInfo.ZIPCode, updatedUser.ICQBasicInfo.ZIPCode)
+	})
+
+	t.Run("Update Non-Existing User", func(t *testing.T) {
+		// Try to set basic info for a non-existing user
+		nonExistingScreenName := NewIdentScreenName("nonexistentuser")
+		err := f.SetBasicInfo(nonExistingScreenName, basicInfo)
+
+		// This should return ErrNoUser, as the user does not exist
+		assert.ErrorIs(t, err, ErrNoUser)
+	})
+
+	t.Run("Empty Basic Info", func(t *testing.T) {
+		// Test updating with empty basic info
+		emptyBasicInfo := ICQBasicInfo{}
+		err := f.SetBasicInfo(screenName, emptyBasicInfo)
+		assert.NoError(t, err)
+
+		// Retrieve the user and verify that basic info fields are empty or have default values
+		updatedUser, err := f.User(screenName)
+		assert.NoError(t, err)
+		assert.Empty(t, updatedUser.ICQBasicInfo.CellPhone)
+		assert.Empty(t, updatedUser.ICQBasicInfo.CountryCode)
+		assert.Empty(t, updatedUser.ICQBasicInfo.EmailAddress)
+		assert.Empty(t, updatedUser.ICQBasicInfo.FirstName)
+		assert.Empty(t, updatedUser.ICQBasicInfo.GMTOffset)
+		assert.Empty(t, updatedUser.ICQBasicInfo.Address)
+		assert.Empty(t, updatedUser.ICQBasicInfo.City)
+		assert.Empty(t, updatedUser.ICQBasicInfo.Fax)
+		assert.Empty(t, updatedUser.ICQBasicInfo.Phone)
+		assert.Empty(t, updatedUser.ICQBasicInfo.State)
+		assert.Empty(t, updatedUser.ICQBasicInfo.LastName)
+		assert.Empty(t, updatedUser.ICQBasicInfo.Nickname)
+		assert.Empty(t, updatedUser.ICQBasicInfo.PublishEmail)
+		assert.Empty(t, updatedUser.ICQBasicInfo.ZIPCode)
+	})
+}
+
+func TestSQLiteUserStore_FindByInterests(t *testing.T) {
+	// Cleanup after test
+	defer func() {
+		assert.NoError(t, os.Remove(testFile))
+	}()
+
+	// Initialize the SQLiteUserStore with a test database file
+	f, err := NewSQLiteUserStore(testFile)
+	if err != nil {
+		assert.NoError(t, err)
+	}
+
+	// Create and set up test users with different interests
+	user1 := User{
+		IdentScreenName: NewIdentScreenName("user1"),
+	}
+	err = f.InsertUser(user1)
+	assert.NoError(t, err)
+	interests1 := ICQInterests{
+		Code1:    1,
+		Keyword1: "Coding",
+		Code2:    2,
+		Keyword2: "Music",
+	}
+	err = f.SetInterests(user1.IdentScreenName, interests1)
+	assert.NoError(t, err)
+
+	user2 := User{
+		IdentScreenName: NewIdentScreenName("user2"),
+	}
+	err = f.InsertUser(user2)
+	assert.NoError(t, err)
+	interests2 := ICQInterests{
+		Code1:    1,
+		Keyword1: "Coding",
+		Code3:    3,
+		Keyword3: "Gaming",
+	}
+	err = f.SetInterests(user2.IdentScreenName, interests2)
+	assert.NoError(t, err)
+
+	user3 := User{
+		IdentScreenName: NewIdentScreenName("user3"),
+	}
+	err = f.InsertUser(user3)
+	assert.NoError(t, err)
+	interests3 := ICQInterests{
+		Code2:    2,
+		Keyword2: "Music",
+		Code4:    4,
+		Keyword4: "Travel",
+	}
+	err = f.SetInterests(user3.IdentScreenName, interests3)
+	assert.NoError(t, err)
+
+	// Helper function to check if a user with a specific IdentScreenName exists in the results
+	containsUserWithScreenName := func(users []User, screenName IdentScreenName) bool {
+		for _, user := range users {
+			if user.IdentScreenName == screenName {
+				return true
+			}
+		}
+		return false
+	}
+
+	t.Run("Find Users by Single Keyword", func(t *testing.T) {
+		// Search for users interested in "Music"
+		users, err := f.FindByInterests(2, []string{"Music"})
+		assert.NoError(t, err)
+		assert.Len(t, users, 2)
+
+		// Check that the correct users are returned by IdentScreenName
+		assert.True(t, containsUserWithScreenName(users, user1.IdentScreenName))
+		assert.True(t, containsUserWithScreenName(users, user3.IdentScreenName))
+	})
+
+	t.Run("Find Users by Multiple Keywords", func(t *testing.T) {
+		// Search for users interested in "Coding" or "Gaming"
+		users, err := f.FindByInterests(1, []string{"Coding", "Gaming"})
+		assert.NoError(t, err)
+		assert.Len(t, users, 2)
+
+		// Check that the correct users are returned by IdentScreenName
+		assert.True(t, containsUserWithScreenName(users, user1.IdentScreenName))
+		assert.True(t, containsUserWithScreenName(users, user2.IdentScreenName))
+	})
+
+	t.Run("Find Users by Multiple Codes and Keywords", func(t *testing.T) {
+		// Search for users interested in "Coding"
+		users, err := f.FindByInterests(1, []string{"Coding"})
+		assert.NoError(t, err)
+		assert.Len(t, users, 2)
+		assert.True(t, containsUserWithScreenName(users, user1.IdentScreenName))
+		assert.True(t, containsUserWithScreenName(users, user2.IdentScreenName))
+
+		// Search for users interested in "Travel"
+		users, err = f.FindByInterests(4, []string{"Travel"})
+		assert.NoError(t, err)
+		assert.Len(t, users, 1)
+		assert.True(t, containsUserWithScreenName(users, user3.IdentScreenName))
+	})
+
+	t.Run("No Users Found", func(t *testing.T) {
+		// Search for users interested in a keyword that no user has
+		users, err := f.FindByInterests(1, []string{"Unknown"})
+		assert.NoError(t, err)
+		assert.Empty(t, users)
+	})
+}
+
+func TestSQLiteUserStore_FindByDetails(t *testing.T) {
+	// Cleanup after test
+	defer func() {
+		assert.NoError(t, os.Remove(testFile))
+	}()
+
+	// Initialize the SQLiteUserStore with a test database file
+	f, err := NewSQLiteUserStore(testFile)
+	if err != nil {
+		assert.NoError(t, err)
+	}
+
+	// Create and set up test users with different details using SetBasicInfo
+	user1 := User{
+		IdentScreenName: NewIdentScreenName("user1"),
+	}
+	err = f.InsertUser(user1)
+	assert.NoError(t, err)
+	basicInfo1 := ICQBasicInfo{
+		FirstName: "John",
+		LastName:  "Doe",
+		Nickname:  "Johnny",
+	}
+	err = f.SetBasicInfo(user1.IdentScreenName, basicInfo1)
+	assert.NoError(t, err)
+
+	user2 := User{
+		IdentScreenName: NewIdentScreenName("user2"),
+	}
+	err = f.InsertUser(user2)
+	assert.NoError(t, err)
+	basicInfo2 := ICQBasicInfo{
+		FirstName: "Jane",
+		LastName:  "Smith",
+		Nickname:  "Janey",
+	}
+	err = f.SetBasicInfo(user2.IdentScreenName, basicInfo2)
+	assert.NoError(t, err)
+
+	user3 := User{
+		IdentScreenName: NewIdentScreenName("user3"),
+	}
+	err = f.InsertUser(user3)
+	assert.NoError(t, err)
+	basicInfo3 := ICQBasicInfo{
+		FirstName: "John",
+		LastName:  "Smith",
+		Nickname:  "JohnnyS",
+	}
+	err = f.SetBasicInfo(user3.IdentScreenName, basicInfo3)
+	assert.NoError(t, err)
+
+	// Helper function to check if a user with a specific IdentScreenName exists in the results
+	containsUserWithScreenName := func(users []User, screenName IdentScreenName) bool {
+		for _, user := range users {
+			if user.IdentScreenName == screenName {
+				return true
+			}
+		}
+		return false
+	}
+
+	t.Run("Find Users by First Name", func(t *testing.T) {
+		// Search for users with the first name "John"
+		users, err := f.FindByDetails("John", "", "")
+		assert.NoError(t, err)
+		assert.Len(t, users, 2)
+
+		// Check that the correct users are returned by IdentScreenName
+		assert.True(t, containsUserWithScreenName(users, user1.IdentScreenName))
+		assert.True(t, containsUserWithScreenName(users, user3.IdentScreenName))
+	})
+
+	t.Run("Find Users by Last Name", func(t *testing.T) {
+		// Search for users with the last name "Smith"
+		users, err := f.FindByDetails("", "Smith", "")
+		assert.NoError(t, err)
+		assert.Len(t, users, 2)
+
+		// Check that the correct users are returned by IdentScreenName
+		assert.True(t, containsUserWithScreenName(users, user2.IdentScreenName))
+		assert.True(t, containsUserWithScreenName(users, user3.IdentScreenName))
+	})
+
+	t.Run("Find Users by Nickname", func(t *testing.T) {
+		// Search for users with the nickname "Johnny"
+		users, err := f.FindByDetails("", "", "Johnny")
+		assert.NoError(t, err)
+		assert.Len(t, users, 1)
+
+		// Check that the correct user is returned by IdentScreenName
+		assert.True(t, containsUserWithScreenName(users, user1.IdentScreenName))
+	})
+
+	t.Run("Find Users by Multiple Fields", func(t *testing.T) {
+		// Search for users with the first name "Jane" and last name "Smith"
+		users, err := f.FindByDetails("Jane", "Smith", "")
+		assert.NoError(t, err)
+		assert.Len(t, users, 1)
+
+		// Check that the correct user is returned by IdentScreenName
+		assert.True(t, containsUserWithScreenName(users, user2.IdentScreenName))
+	})
+
+	t.Run("No Users Found", func(t *testing.T) {
+		// Search for users with a first name that no user has
+		users, err := f.FindByDetails("NonExistent", "", "")
+		assert.NoError(t, err)
+		assert.Empty(t, users)
+	})
+}
+
+func TestSQLiteUserStore_FindByEmail(t *testing.T) {
+	// Cleanup after test
+	defer func() {
+		assert.NoError(t, os.Remove(testFile))
+	}()
+
+	// Initialize the SQLiteUserStore with a test database file
+	f, err := NewSQLiteUserStore(testFile)
+	if err != nil {
+		assert.NoError(t, err)
+	}
+
+	// Create and set up test users with different email addresses using SetBasicInfo
+	user1 := User{
+		IdentScreenName: NewIdentScreenName("user1"),
+	}
+	err = f.InsertUser(user1)
+	assert.NoError(t, err)
+	basicInfo1 := ICQBasicInfo{
+		EmailAddress: "user1@example.com",
+	}
+	err = f.SetBasicInfo(user1.IdentScreenName, basicInfo1)
+	assert.NoError(t, err)
+
+	user2 := User{
+		IdentScreenName: NewIdentScreenName("user2"),
+	}
+	err = f.InsertUser(user2)
+	assert.NoError(t, err)
+	basicInfo2 := ICQBasicInfo{
+		EmailAddress: "user2@example.com",
+	}
+	err = f.SetBasicInfo(user2.IdentScreenName, basicInfo2)
+	assert.NoError(t, err)
+
+	user3 := User{
+		IdentScreenName: NewIdentScreenName("user3"),
+	}
+	err = f.InsertUser(user3)
+	assert.NoError(t, err)
+	basicInfo3 := ICQBasicInfo{
+		EmailAddress: "user3@example.com",
+	}
+	err = f.SetBasicInfo(user3.IdentScreenName, basicInfo3)
+	assert.NoError(t, err)
+
+	t.Run("Find User by Email", func(t *testing.T) {
+		// Search for user with email "user1@example.com"
+		user, err := f.FindByEmail("user1@example.com")
+		assert.NoError(t, err)
+		assert.Equal(t, user1.IdentScreenName, user.IdentScreenName)
+
+		// Search for user with email "user2@example.com"
+		user, err = f.FindByEmail("user2@example.com")
+		assert.NoError(t, err)
+		assert.Equal(t, user2.IdentScreenName, user.IdentScreenName)
+
+		// Search for user with email "user3@example.com"
+		user, err = f.FindByEmail("user3@example.com")
+		assert.NoError(t, err)
+		assert.Equal(t, user3.IdentScreenName, user.IdentScreenName)
+	})
+
+	t.Run("User Not Found", func(t *testing.T) {
+		// Search for an email that doesn't exist
+		_, err := f.FindByEmail("nonexistent@example.com")
+		assert.ErrorIs(t, err, ErrNoUser)
+	})
+}
+
+func TestSQLiteUserStore_FindByUIN(t *testing.T) {
+	// Cleanup after test
+	defer func() {
+		assert.NoError(t, os.Remove(testFile))
+	}()
+
+	// Initialize the SQLiteUserStore with a test database file
+	f, err := NewSQLiteUserStore(testFile)
+	if err != nil {
+		assert.NoError(t, err)
+	}
+
+	// Create and set up test users where UIN is the same as IdentScreenName
+	user1 := User{
+		IdentScreenName: NewIdentScreenName("12345"),
+	}
+	err = f.InsertUser(user1)
+	assert.NoError(t, err)
+
+	user2 := User{
+		IdentScreenName: NewIdentScreenName("67890"),
+	}
+	err = f.InsertUser(user2)
+	assert.NoError(t, err)
+
+	user3 := User{
+		IdentScreenName: NewIdentScreenName("54321"),
+	}
+	err = f.InsertUser(user3)
+	assert.NoError(t, err)
+
+	t.Run("Find User by UIN", func(t *testing.T) {
+		// Search for user with UIN 12345
+		user, err := f.FindByUIN(12345)
+		assert.NoError(t, err)
+		assert.Equal(t, user1.IdentScreenName, user.IdentScreenName)
+
+		// Search for user with UIN 67890
+		user, err = f.FindByUIN(67890)
+		assert.NoError(t, err)
+		assert.Equal(t, user2.IdentScreenName, user.IdentScreenName)
+
+		// Search for user with UIN 54321
+		user, err = f.FindByUIN(54321)
+		assert.NoError(t, err)
+		assert.Equal(t, user3.IdentScreenName, user.IdentScreenName)
+	})
+
+	t.Run("User Not Found", func(t *testing.T) {
+		// Search for a UIN that doesn't exist
+		_, err := f.FindByUIN(99999)
+		assert.ErrorIs(t, err, ErrNoUser)
+	})
 }

+ 148 - 8
state/user_test.go

@@ -2,6 +2,7 @@ package state
 
 import (
 	"testing"
+	"time"
 
 	"github.com/stretchr/testify/assert"
 	"github.com/stretchr/testify/require"
@@ -19,37 +20,61 @@ func TestUser_HashPassword(t *testing.T) {
 		wantError         bool
 	}{
 		{
-			name:              "Valid password",
-			user:              User{AuthKey: "someAuthKey"},
+			name:              "Valid AIM password",
+			user:              User{AuthKey: "someAuthKey", IsICQ: false},
 			password:          "validPassword",
 			expectedWeakMD5:   wire.WeakMD5PasswordHash("validPassword", "someAuthKey"),
 			expectedStrongMD5: wire.StrongMD5PasswordHash("validPassword", "someAuthKey"),
 			wantError:         false,
 		},
 		{
-			name:              "Empty password",
-			user:              User{AuthKey: "someAuthKey"},
+			name:              "Empty AIM password",
+			user:              User{AuthKey: "someAuthKey", IsICQ: false},
 			password:          "",
 			expectedWeakMD5:   nil,
 			expectedStrongMD5: nil,
 			wantError:         true,
 		},
 		{
-			name:              "Password too short",
-			user:              User{AuthKey: "someAuthKey"},
+			name:              "AIM password too short",
+			user:              User{AuthKey: "someAuthKey", IsICQ: false},
 			password:          "abc",
 			expectedWeakMD5:   nil,
 			expectedStrongMD5: nil,
 			wantError:         true,
 		},
 		{
-			name:              "Password too long",
-			user:              User{AuthKey: "someAuthKey"},
+			name:              "AIM password too long",
+			user:              User{AuthKey: "someAuthKey", IsICQ: false},
 			password:          "thispasswordistoolong",
 			expectedWeakMD5:   nil,
 			expectedStrongMD5: nil,
 			wantError:         true,
 		},
+		{
+			name:              "Valid ICQ password",
+			user:              User{AuthKey: "someAuthKey", IsICQ: true},
+			password:          "validICQ",
+			expectedWeakMD5:   wire.WeakMD5PasswordHash("validICQ", "someAuthKey"),
+			expectedStrongMD5: wire.StrongMD5PasswordHash("validICQ", "someAuthKey"),
+			wantError:         false,
+		},
+		{
+			name:              "Empty ICQ password",
+			user:              User{AuthKey: "someAuthKey", IsICQ: true},
+			password:          "",
+			expectedWeakMD5:   nil,
+			expectedStrongMD5: nil,
+			wantError:         true,
+		},
+		{
+			name:              "ICQ password too long",
+			user:              User{AuthKey: "someAuthKey", IsICQ: true},
+			password:          "icqpass89",
+			expectedWeakMD5:   nil,
+			expectedStrongMD5: nil,
+			wantError:         true,
+		},
 	}
 
 	for _, tt := range tests {
@@ -67,6 +92,121 @@ func TestUser_HashPassword(t *testing.T) {
 	}
 }
 
+func TestAge(t *testing.T) {
+	tests := []struct {
+		name        string
+		user        User
+		timeNow     func() time.Time
+		expectedAge uint16
+	}{
+		{
+			name: "Valid birthday, only year is set",
+			user: User{
+				ICQMoreInfo: ICQMoreInfo{
+					BirthYear: 1990,
+				},
+			},
+			timeNow: func() time.Time {
+				return time.Date(2024, 8, 1, 0, 0, 0, 0, time.UTC)
+			},
+			expectedAge: 34,
+		},
+		{
+			name: "Valid birthday, birthday passed this year",
+			user: User{
+				ICQMoreInfo: ICQMoreInfo{
+					BirthYear:  1990,
+					BirthMonth: 5,
+					BirthDay:   10,
+				},
+			},
+			timeNow: func() time.Time {
+				return time.Date(2024, 8, 1, 0, 0, 0, 0, time.UTC)
+			},
+			expectedAge: 34,
+		},
+		{
+			name: "Valid birthday, birthday not yet passed this year",
+			user: User{
+				ICQMoreInfo: ICQMoreInfo{
+					BirthYear:  1990,
+					BirthMonth: 12,
+					BirthDay:   10,
+				},
+			},
+			timeNow: func() time.Time {
+				return time.Date(2024, 8, 1, 0, 0, 0, 0, time.UTC)
+			},
+			expectedAge: 33,
+		},
+		{
+			name: "Birthday is today",
+			user: User{
+				ICQMoreInfo: ICQMoreInfo{
+					BirthYear:  1990,
+					BirthMonth: 8,
+					BirthDay:   1,
+				},
+			},
+			timeNow: func() time.Time {
+				return time.Date(2024, 8, 1, 0, 0, 0, 0, time.UTC)
+			},
+			expectedAge: 34,
+		},
+		{
+			name: "Invalid birthday, year is zero",
+			user: User{
+				ICQMoreInfo: ICQMoreInfo{
+					BirthYear:  0,
+					BirthMonth: 8,
+					BirthDay:   1,
+				},
+			},
+			timeNow: func() time.Time {
+				return time.Date(2024, 8, 1, 0, 0, 0, 0, time.UTC)
+			},
+			expectedAge: 0,
+		},
+		{
+			name: "Invalid birthday, day is zero",
+			user: User{
+				ICQMoreInfo: ICQMoreInfo{
+					BirthYear:  1990,
+					BirthMonth: 8,
+					BirthDay:   0,
+				},
+			},
+			timeNow: func() time.Time {
+				return time.Date(2024, 8, 1, 0, 0, 0, 0, time.UTC)
+			},
+			expectedAge: 0,
+		},
+		{
+			name: "Invalid birthday, month is zero",
+			user: User{
+				ICQMoreInfo: ICQMoreInfo{
+					BirthYear:  1990,
+					BirthMonth: 0,
+					BirthDay:   1,
+				},
+			},
+			timeNow: func() time.Time {
+				return time.Date(2024, 8, 1, 0, 0, 0, 0, time.UTC)
+			},
+			expectedAge: 0,
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			age := tt.user.Age(tt.timeNow)
+			if age != tt.expectedAge {
+				t.Errorf("expected age %d, got %d", tt.expectedAge, age)
+			}
+		})
+	}
+}
+
 func TestDisplayScreenName_ValidateAIMHandle(t *testing.T) {
 	tests := []struct {
 		name    string

+ 16 - 0
wire/encode.go

@@ -66,11 +66,22 @@ func marshal(t reflect.Type, v reflect.Value, tag reflect.StructTag, w io.Writer
 		return marshalStruct(t, v, oscTag, w, order)
 	case reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64:
 		return binary.Write(w, order, v.Interface())
+	case reflect.Interface:
+		return marshalInterface(v, w, oscTag, order)
 	default:
 		return fmt.Errorf("unsupported type %v", t.Kind())
 	}
 }
 
+func marshalInterface(v reflect.Value, w io.Writer, tag oscarTag, order binary.ByteOrder) error {
+	elem := v.Elem()
+	if elem.Kind() != reflect.Struct {
+		return fmt.Errorf("interface underlying type must be a struct, got %v instead", elem.Kind())
+	}
+
+	return marshalStruct(elem.Type(), elem, tag, w, order)
+}
+
 func marshalSlice(t reflect.Type, v reflect.Value, oscTag oscarTag, w io.Writer, order binary.ByteOrder) error {
 	// todo: only write to temporary buffer if len_prefix is set
 	buf := &bytes.Buffer{}
@@ -119,6 +130,11 @@ func marshalString(oscTag oscarTag, v reflect.Value, w io.Writer, order binary.B
 }
 
 func marshalStruct(t reflect.Type, v reflect.Value, oscTag oscarTag, w io.Writer, order binary.ByteOrder) error {
+	// marshal ICQ messages in little endian order
+	if t.Name() == "ICQMessageReplyEnvelope" {
+		order = binary.LittleEndian
+	}
+
 	marshalEachField := func(w io.Writer) error {
 		for i := 0; i < t.NumField(); i++ {
 			field := t.Field(i)

+ 72 - 0
wire/encode_test.go

@@ -615,6 +615,78 @@ func TestMarshal(t *testing.T) {
 			},
 			wantErr: ErrMarshalFailure,
 		},
+		{
+			name: "struct with any type field containing a struct",
+			w:    &bytes.Buffer{},
+			given: struct {
+				Val1 uint8
+				Val2 any
+			}{
+				Val1: 0x12,
+				Val2: struct {
+					Field1 uint16
+					Field2 uint8
+				}{
+					Field1: 0x1234,
+					Field2: 0x56,
+				},
+			},
+			want: []byte{
+				0x12,       // Val1
+				0x12, 0x34, // Val2.Field1 (uint16)
+				0x56, // Val2.Field2 (uint8)
+			},
+		},
+		{
+			name: "struct with any type field containing an empty struct",
+			w:    &bytes.Buffer{},
+			given: struct {
+				Val1 uint8
+				Val2 any
+			}{
+				Val1: 0x12,
+				Val2: struct{}{}, // empty struct
+			},
+			want: []byte{
+				0x12, // Val1
+			},
+		},
+		{
+			name: "struct with any type field containing a non-struct value",
+			w:    &bytes.Buffer{},
+			given: struct {
+				Val1 uint8
+				Val2 any
+			}{
+				Val1: 0x12,
+				Val2: "non-struct value", // non-struct value
+			},
+			wantErr: ErrMarshalFailure, // expecting an error because Val2 is not a struct
+		},
+		{
+			name: "struct with ICQMessageReplyEnvelope field",
+			w:    &bytes.Buffer{},
+			given: struct {
+				Val1 uint16
+				Val2 ICQMessageReplyEnvelope
+			}{
+				Val1: 0x1234,
+				Val2: ICQMessageReplyEnvelope{
+					Message: struct {
+						Val3 uint16
+					}{
+						Val3: 0x1234,
+					},
+				},
+			},
+			want: []byte{
+				// Big-endian encoding for Val1
+				0x12, 0x34, // Val1
+				// Little-endian encoding for Val2.Message.Val3
+				0x2, 0x0, // Val2 len
+				0x34, 0x12, // Val2.Message.Val3
+			},
+		},
 	}
 	for _, tt := range tests {
 		t.Run(tt.name, func(t *testing.T) {

+ 366 - 0
wire/snacs.go

@@ -925,6 +925,372 @@ type SNAC_0x0E_0x06_ChatChannelMsgToClient struct {
 	TLVRestBlock
 }
 
+//
+// 0x0F: ICQ
+//
+
+const (
+	ICQErr     uint16 = 0x0001
+	ICQDBQuery uint16 = 0x0002
+	ICQDBReply uint16 = 0x0003
+
+	ICQTLVTagsMetadata uint16 = 0x01
+
+	ICQStatusCodeOK   uint8 = 0x0A
+	ICQStatusCodeFail uint8 = 0x32
+	ICQStatusCodeErr  uint8 = 0x14
+
+	ICQDBQueryOfflineMsgReq       uint16 = 0x003C
+	ICQDBQueryOfflineMsgReply     uint16 = 0x0041
+	ICQDBQueryOfflineMsgReplyLast uint16 = 0x0042
+	ICQDBQueryDeleteMsgReq        uint16 = 0x003E
+	ICQDBQueryMetaReq             uint16 = 0x07D0
+	ICQDBQueryMetaReply           uint16 = 0x07DA
+)
+
+const (
+	ICQUserFlagPublishEmailYes uint8 = 0
+	ICQUserFlagPublishEmailNo  uint8 = 1
+)
+
+const (
+	ICQDBQueryMetaReqSetBasicInfo     uint16 = 0x03EA
+	ICQDBQueryMetaReqSetWorkInfo      uint16 = 0x03F3
+	ICQDBQueryMetaReqSetMoreInfo      uint16 = 0x03FD
+	ICQDBQueryMetaReqSetNotes         uint16 = 0x0406
+	ICQDBQueryMetaReqSetEmails        uint16 = 0x040B
+	ICQDBQueryMetaReqSetInterests     uint16 = 0x0410
+	ICQDBQueryMetaReqSetAffiliations  uint16 = 0x041A
+	ICQDBQueryMetaReqSetPermissions   uint16 = 0x0424
+	ICQDBQueryMetaReqFullInfo         uint16 = 0x04B2
+	ICQDBQueryMetaReqFullInfo2        uint16 = 0x04D0
+	ICQDBQueryMetaReqSearchByDetails  uint16 = 0x0515
+	ICQDBQueryMetaReqSearchByUIN      uint16 = 0x051F
+	ICQDBQueryMetaReqSearchByEmail    uint16 = 0x0529
+	ICQDBQueryMetaReqSearchWhitePages uint16 = 0x0533
+	ICQDBQueryMetaReqXMLReq           uint16 = 0x0898
+	ICQDBQueryMetaReqStat0a8c         uint16 = 0x0A8C
+	ICQDBQueryMetaReqStat0a96         uint16 = 0x0A96
+	ICQDBQueryMetaReqStat0aaa         uint16 = 0x0AAA
+	ICQDBQueryMetaReqStat0ab4         uint16 = 0x0AB4
+	ICQDBQueryMetaReqStat0ab9         uint16 = 0x0AB9
+	ICQDBQueryMetaReqStat0abe         uint16 = 0x0ABE
+	ICQDBQueryMetaReqStat0ac8         uint16 = 0x0AC8
+	ICQDBQueryMetaReqStat0acd         uint16 = 0x0ACD
+	ICQDBQueryMetaReqStat0ad2         uint16 = 0x0AD2
+	ICQDBQueryMetaReqStat0ad7         uint16 = 0x0AD7
+	ICQDBQueryMetaReqStat0758         uint16 = 0x0758
+
+	ICQDBQueryMetaReplySetBasicInfo    uint16 = 0x0064
+	ICQDBQueryMetaReplySetWorkInfo     uint16 = 0x006E
+	ICQDBQueryMetaReplySetMoreInfo     uint16 = 0x0078
+	ICQDBQueryMetaReplySetNotes        uint16 = 0x0082
+	ICQDBQueryMetaReplySetEmails       uint16 = 0x0087
+	ICQDBQueryMetaReplySetInterests    uint16 = 0x008C
+	ICQDBQueryMetaReplySetAffiliations uint16 = 0x0096
+	ICQDBQueryMetaReplySetPermissions  uint16 = 0x00A0
+	ICQDBQueryMetaReplyBasicInfo       uint16 = 0x00C8
+	ICQDBQueryMetaReplyWorkInfo        uint16 = 0x00D2
+	ICQDBQueryMetaReplyMoreInfo        uint16 = 0x00DC
+	ICQDBQueryMetaReplyNotes           uint16 = 0x00E6
+	ICQDBQueryMetaReplyExtEmailInfo    uint16 = 0x00EB
+	ICQDBQueryMetaReplyInterests       uint16 = 0x00F0
+	ICQDBQueryMetaReplyAffiliations    uint16 = 0x00FA
+	ICQDBQueryMetaReplyHomePageCat     uint16 = 0x010E
+	ICQDBQueryMetaReplyUserFound       uint16 = 0x01A4
+	ICQDBQueryMetaReplyLastUserFound   uint16 = 0x01AE
+	ICQDBQueryMetaReplyXMLData         uint16 = 0x08A2
+)
+
+type SNAC_0x0F_0x02_ICQDBQuery struct {
+	TLVRestBlock
+}
+
+type ICQ_0x07D0_0x0898_DBQueryMetaReqXMLReq struct {
+	XMLRequest string `oscar:"len_prefix=uint16,nullterm"`
+}
+
+type ICQ_0x07D0_0x0424_DBQueryMetaReqSetPermissions struct {
+	Authorization uint8 // (1-required, 0-not required)
+	WebAware      uint8 // webaware (0-no, 1-yes)
+	DCPerms       uint8 // dc_perms (0-any, 1-contact, 2-authorization)
+	Unknown       uint8
+}
+
+type ICQ_0x07D0_0x051F_DBQueryMetaReqSearchByUIN struct {
+	UIN uint32
+}
+
+type ICQ_0x07D0_0x0529_DBQueryMetaReqSearchByEmail struct {
+	Email string `oscar:"len_prefix=uint16,nullterm"`
+}
+
+type ICQ_0x07D0_0x0515_DBQueryMetaReqSearchByDetails struct {
+	FirstName string `oscar:"len_prefix=uint16,nullterm"`
+	LastName  string `oscar:"len_prefix=uint16,nullterm"`
+	NickName  string `oscar:"len_prefix=uint16,nullterm"`
+}
+
+type ICQ_0x07D0_0x0533_DBQueryMetaReqSearchWhitePages struct {
+	FirstName           string `oscar:"len_prefix=uint16,nullterm"`
+	LastName            string `oscar:"len_prefix=uint16,nullterm"`
+	Nickname            string `oscar:"len_prefix=uint16,nullterm"`
+	Email               string `oscar:"len_prefix=uint16,nullterm"`
+	MinAge              uint16
+	MaxAge              uint16
+	Gender              uint8
+	SpeakingLang        uint8
+	City                string `oscar:"len_prefix=uint16,nullterm"`
+	State               string `oscar:"len_prefix=uint16,nullterm"`
+	CountryCode         uint16
+	Company             string `oscar:"len_prefix=uint16,nullterm"`
+	Department          string `oscar:"len_prefix=uint16,nullterm"`
+	Position            string `oscar:"len_prefix=uint16,nullterm"`
+	OccupationCode      uint16
+	PastCode            uint16
+	PastKeywords        string `oscar:"len_prefix=uint16,nullterm"`
+	InterestsCode       uint16
+	InterestsKeyword    string `oscar:"len_prefix=uint16,nullterm"`
+	AffiliationsCode    uint16
+	AffiliationsKeyword string `oscar:"len_prefix=uint16,nullterm"`
+	HomePageCode        uint16
+	HomePageKeywords    string `oscar:"len_prefix=uint16,nullterm"`
+	SearchScope         uint8
+}
+
+type ICQ_0x07D0_0x03FD_DBQueryMetaReqSetMoreInfo struct {
+	Age          uint8 // not used because age is calculated from birthdate
+	Gender       uint16
+	HomePageAddr string `oscar:"len_prefix=uint16,nullterm"`
+	BirthYear    uint16
+	BirthMonth   uint8
+	BirthDay     uint8
+	Lang1        uint8
+	Lang2        uint8
+	Lang3        uint8
+}
+
+type ICQ_0x07D0_0x03F3_DBQueryMetaReqSetWorkInfo struct {
+	City           string `oscar:"len_prefix=uint16,nullterm"`
+	State          string `oscar:"len_prefix=uint16,nullterm"`
+	Phone          string `oscar:"len_prefix=uint16,nullterm"`
+	Fax            string `oscar:"len_prefix=uint16,nullterm"`
+	Address        string `oscar:"len_prefix=uint16,nullterm"`
+	ZIP            string `oscar:"len_prefix=uint16,nullterm"`
+	CountryCode    uint16
+	Company        string `oscar:"len_prefix=uint16,nullterm"`
+	Department     string `oscar:"len_prefix=uint16,nullterm"`
+	Position       string `oscar:"len_prefix=uint16,nullterm"`
+	OccupationCode uint16
+	WebPage        string `oscar:"len_prefix=uint16,nullterm"`
+}
+
+type ICQ_0x07D0_0x040B_DBQueryMetaReqSetEmails struct {
+	Emails []struct {
+		Publish uint8
+		Email   string `oscar:"len_prefix=uint16,nullterm"`
+	} `oscar:"count_prefix=uint8"`
+}
+
+type ICQ_0x07D0_0x0406_DBQueryMetaReqSetNotes struct {
+	Notes string `oscar:"len_prefix=uint16,nullterm"`
+}
+
+type ICQ_0x07D0_0x0410_DBQueryMetaReqSetInterests struct {
+	Interests []struct {
+		Code    uint16
+		Keyword string `oscar:"len_prefix=uint16,nullterm"`
+	} `oscar:"count_prefix=uint8"`
+}
+
+type ICQ_0x07D0_0x041A_DBQueryMetaReqSetAffiliations struct {
+	PastAffiliations []struct {
+		Code    uint16
+		Keyword string `oscar:"len_prefix=uint16,nullterm"`
+	} `oscar:"count_prefix=uint8"`
+	Affiliations []struct {
+		Code    uint16
+		Keyword string `oscar:"len_prefix=uint16,nullterm"`
+	} `oscar:"count_prefix=uint8"`
+}
+
+type ICQ_0x07D0_0x03EA_DBQueryMetaReqSetBasicInfo struct {
+	Nickname     string `oscar:"len_prefix=uint16,nullterm"`
+	FirstName    string `oscar:"len_prefix=uint16,nullterm"`
+	LastName     string `oscar:"len_prefix=uint16,nullterm"`
+	EmailAddress string `oscar:"len_prefix=uint16,nullterm"`
+	City         string `oscar:"len_prefix=uint16,nullterm"`
+	State        string `oscar:"len_prefix=uint16,nullterm"`
+	Phone        string `oscar:"len_prefix=uint16,nullterm"`
+	Fax          string `oscar:"len_prefix=uint16,nullterm"`
+	HomeAddress  string `oscar:"len_prefix=uint16,nullterm"`
+	CellPhone    string `oscar:"len_prefix=uint16,nullterm"`
+	ZIP          string `oscar:"len_prefix=uint16,nullterm"`
+	CountryCode  uint16
+	GMTOffset    uint8
+	PublishEmail uint8
+}
+
+type SNAC_0x0F_0x02_ICQDBReply struct {
+	TLVRestBlock
+}
+
+type ICQ_0x07DA_0x010E_DBQueryMetaReplyHomePageCat struct {
+	ICQMetadata
+	ReqSubType uint16
+	Success    uint8
+	Enabled    uint8
+	CatCode    uint16
+	Keywords   string `oscar:"len_prefix=uint16,nullterm"`
+	Unknown    uint8
+}
+
+type ICQ_0x07DA_0x08A2_DBQueryMetaReplyXMLData struct {
+	ICQMetadata
+	ReqSubType uint16
+	Success    uint8
+	XML        string `oscar:"len_prefix=uint16,nullterm"`
+}
+
+type ICQ_0x07DA_0x00DC_DBQueryMetaReplyMoreInfo struct {
+	ICQMetadata
+	ReqSubType uint16
+	Success    uint8
+	ICQ_0x07D0_0x03FD_DBQueryMetaReqSetMoreInfo
+	Unknown     uint16
+	City        string `oscar:"len_prefix=uint16,nullterm"`
+	State       string `oscar:"len_prefix=uint16,nullterm"`
+	CountryCode uint16
+	TimeZone    uint8
+}
+
+type ICQ_0x07DA_0x00EB_DBQueryMetaReplyExtEmailInfo struct {
+	ICQMetadata
+	ReqSubType uint16
+	Success    uint8
+	Emails     []struct {
+		Flag  uint8  // (0-publish, 1-don't)
+		Email string `oscar:"len_prefix=uint16,nullterm"`
+	} `oscar:"count_prefix=uint8"`
+}
+
+type ICQ_0x07DA_0x00D2_DBQueryMetaReplyWorkInfo struct {
+	ICQMetadata
+	ReqSubType uint16
+	Success    uint8
+	ICQ_0x07D0_0x03F3_DBQueryMetaReqSetWorkInfo
+}
+
+type ICQ_0x07DA_0x00F0_DBQueryMetaReplyInterests struct {
+	ICQMetadata
+	ReqSubType uint16
+	Success    uint8
+	Interests  []struct {
+		Code    uint16
+		Keyword string `oscar:"len_prefix=uint16,nullterm"`
+	} `oscar:"count_prefix=uint8"`
+}
+
+type ICQ_0x07DA_0x00E6_DBQueryMetaReplyNotes struct {
+	ICQMetadata
+	ReqSubType uint16
+	Success    uint8
+	ICQ_0x07D0_0x0406_DBQueryMetaReqSetNotes
+}
+
+type ICQ_0x07DA_0x00FA_DBQueryMetaReplyAffiliations struct {
+	ICQMetadata
+	ReqSubType uint16
+	Success    uint8
+	ICQ_0x07D0_0x041A_DBQueryMetaReqSetAffiliations
+}
+
+type ICQ_0x07DA_0x00C8_DBQueryMetaReplyBasicInfo struct {
+	ICQMetadata
+	ReqSubType   uint16
+	Success      uint8
+	Nickname     string `oscar:"len_prefix=uint16,nullterm"`
+	FirstName    string `oscar:"len_prefix=uint16,nullterm"`
+	LastName     string `oscar:"len_prefix=uint16,nullterm"`
+	Email        string `oscar:"len_prefix=uint16,nullterm"`
+	City         string `oscar:"len_prefix=uint16,nullterm"`
+	State        string `oscar:"len_prefix=uint16,nullterm"`
+	Phone        string `oscar:"len_prefix=uint16,nullterm"`
+	Fax          string `oscar:"len_prefix=uint16,nullterm"`
+	Address      string `oscar:"len_prefix=uint16,nullterm"`
+	CellPhone    string `oscar:"len_prefix=uint16,nullterm"`
+	ZIP          string `oscar:"len_prefix=uint16,nullterm"`
+	CountryCode  uint16
+	GMTOffset    uint8
+	AuthFlag     uint8
+	WebAware     uint8
+	DCPerms      uint8
+	PublishEmail uint8
+}
+
+type ICQ_0x07DA_0x01AE_DBQueryMetaReplyLastUserFound struct {
+	ICQMetadata
+	ReqSubType uint16
+	Success    uint8
+	Details    ICQUserSearchRecord `oscar:"len_prefix=uint16"`
+	// LastMessageFooter is set only on the last message in the batch
+	LastMessageFooter *struct {
+		FoundUsersLeft uint32
+	} `oscar:"optional"`
+}
+
+// LastResult flags the message as the last message in the search results.
+func (s *ICQ_0x07DA_0x01AE_DBQueryMetaReplyLastUserFound) LastResult() {
+	s.ReqSubType = ICQDBQueryMetaReplyLastUserFound
+	s.LastMessageFooter = &struct {
+		FoundUsersLeft uint32
+	}{
+		FoundUsersLeft: 0,
+	}
+}
+
+type ICQ_0x0042_DBQueryOfflineMsgReplyLast struct {
+	ICQMetadata
+	DroppedMessages uint8
+}
+
+type ICQMetadataWithSubType struct {
+	ICQMetadata
+	Optional *struct {
+		ReqSubType uint16
+	} `oscar:"optional"`
+}
+
+type ICQMetadata struct {
+	UIN     uint32
+	ReqType uint16
+	Seq     uint16
+}
+
+// ICQMessageRequestEnvelope is a helper struct that provides syntactic sugar for
+// unmarshaling an ICQ message into a little-endian byte array.
+type ICQMessageRequestEnvelope struct {
+	Body []byte `oscar:"len_prefix=uint16"`
+}
+
+// ICQMessageReplyEnvelope is a helper struct that provides syntactic sugar for
+// marshaling an ICQ message into a little-endian byte array.
+type ICQMessageReplyEnvelope struct {
+	Message any `oscar:"len_prefix=uint16"`
+}
+
+type ICQUserSearchRecord struct {
+	UIN           uint32
+	Nickname      string `oscar:"len_prefix=uint16,nullterm"`
+	FirstName     string `oscar:"len_prefix=uint16,nullterm"`
+	LastName      string `oscar:"len_prefix=uint16,nullterm"`
+	Email         string `oscar:"len_prefix=uint16,nullterm"`
+	Authorization uint8
+	OnlineStatus  uint16
+	Gender        uint8
+	Age           uint16
+}
+
 //
 // 0x10: BART
 //

+ 79 - 0
wire/snacs_string.go

@@ -287,6 +287,11 @@ var subGroupName = map[uint16]map[uint16]string{
 		AdminAcctDeleteRequest:  "AdminAcctDeleteRequest",
 		AdminAcctDeleteReply:    "AdminAcctDeleteReply",
 	},
+	ICQ: {
+		ICQErr:     "ICQErr",
+		ICQDBQuery: "ICQDBQuery",
+		ICQDBReply: "ICQDBReply",
+	},
 }
 
 // SubGroupName gets the string name of a subgroup within a food group. It
@@ -298,3 +303,77 @@ func SubGroupName(foodGroup uint16, subGroup uint16) string {
 	}
 	return name
 }
+
+// ICQDBQueryName gets the string representation of a ICQ DB query const.
+func ICQDBQueryName(query uint16) string {
+	name := icqDBQuery[query]
+	if name == "" {
+		name = "unknown"
+	}
+	return name
+}
+
+var icqDBQuery = map[uint16]string{
+	ICQDBQueryOfflineMsgReq: "ICQDBQueryOfflineMsgReq",
+	ICQDBQueryDeleteMsgReq:  "ICQDBQueryDeleteMsgReq",
+	ICQDBQueryMetaReq:       "ICQDBQueryMetaReq",
+	ICQDBQueryMetaReply:     "ICQDBQueryMetaReply",
+}
+
+// ICQDBQueryMetaName gets the string representation of a ICQ DB meta query
+// const.
+func ICQDBQueryMetaName(query uint16) string {
+	name := icqDBQueryMeta[query]
+	if name == "" {
+		name = "unknown"
+	}
+	return name
+}
+
+var icqDBQueryMeta = map[uint16]string{
+	ICQDBQueryMetaReqSetBasicInfo:      "ICQDBQueryMetaReqSetBasicInfo",
+	ICQDBQueryMetaReqSetWorkInfo:       "ICQDBQueryMetaReqSetWorkInfo",
+	ICQDBQueryMetaReqSetMoreInfo:       "ICQDBQueryMetaReqSetMoreInfo",
+	ICQDBQueryMetaReqSetNotes:          "ICQDBQueryMetaReqSetNotes",
+	ICQDBQueryMetaReqSetEmails:         "ICQDBQueryMetaReqSetEmails",
+	ICQDBQueryMetaReqSetInterests:      "ICQDBQueryMetaReqSetInterests",
+	ICQDBQueryMetaReqSetAffiliations:   "ICQDBQueryMetaReqSetAffiliations",
+	ICQDBQueryMetaReqSetPermissions:    "ICQDBQueryMetaReqSetPermissions",
+	ICQDBQueryMetaReqFullInfo:          "ICQDBQueryMetaReqFullInfo",
+	ICQDBQueryMetaReqFullInfo2:         "ICQDBQueryMetaReqFullInfo2",
+	ICQDBQueryMetaReqSearchByDetails:   "ICQDBQueryMetaReqSearchByDetails",
+	ICQDBQueryMetaReqSearchByUIN:       "ICQDBQueryMetaReqSearchByUIN",
+	ICQDBQueryMetaReqSearchByEmail:     "ICQDBQueryMetaReqSearchByEmail",
+	ICQDBQueryMetaReqSearchWhitePages:  "ICQDBQueryMetaReqSearchWhitePages",
+	ICQDBQueryMetaReqXMLReq:            "ICQDBQueryMetaReqXMLReq",
+	ICQDBQueryMetaReqStat0a8c:          "ICQDBQueryMetaReqStat0a8c",
+	ICQDBQueryMetaReqStat0a96:          "ICQDBQueryMetaReqStat0a96",
+	ICQDBQueryMetaReqStat0aaa:          "ICQDBQueryMetaReqStat0aaa",
+	ICQDBQueryMetaReqStat0ab4:          "ICQDBQueryMetaReqStat0ab4",
+	ICQDBQueryMetaReqStat0ab9:          "ICQDBQueryMetaReqStat0ab9",
+	ICQDBQueryMetaReqStat0abe:          "ICQDBQueryMetaReqStat0abe",
+	ICQDBQueryMetaReqStat0ac8:          "ICQDBQueryMetaReqStat0ac8",
+	ICQDBQueryMetaReqStat0acd:          "ICQDBQueryMetaReqStat0acd",
+	ICQDBQueryMetaReqStat0ad2:          "ICQDBQueryMetaReqStat0ad2",
+	ICQDBQueryMetaReqStat0ad7:          "ICQDBQueryMetaReqStat0ad7",
+	ICQDBQueryMetaReqStat0758:          "ICQDBQueryMetaReqStat0758",
+	ICQDBQueryMetaReplySetBasicInfo:    "ICQDBQueryMetaReplySetBasicInfo",
+	ICQDBQueryMetaReplySetWorkInfo:     "ICQDBQueryMetaReplySetWorkInfo",
+	ICQDBQueryMetaReplySetMoreInfo:     "ICQDBQueryMetaReplySetMoreInfo",
+	ICQDBQueryMetaReplySetNotes:        "ICQDBQueryMetaReplySetNotes",
+	ICQDBQueryMetaReplySetEmails:       "ICQDBQueryMetaReplySetEmails",
+	ICQDBQueryMetaReplySetInterests:    "ICQDBQueryMetaReplySetInterests",
+	ICQDBQueryMetaReplySetAffiliations: "ICQDBQueryMetaReplySetAffiliations",
+	ICQDBQueryMetaReplySetPermissions:  "ICQDBQueryMetaReplySetPermissions",
+	ICQDBQueryMetaReplyBasicInfo:       "ICQDBQueryMetaReplyBasicInfo",
+	ICQDBQueryMetaReplyWorkInfo:        "ICQDBQueryMetaReplyWorkInfo",
+	ICQDBQueryMetaReplyMoreInfo:        "ICQDBQueryMetaReplyMoreInfo",
+	ICQDBQueryMetaReplyNotes:           "ICQDBQueryMetaReplyNotes",
+	ICQDBQueryMetaReplyExtEmailInfo:    "ICQDBQueryMetaReplyExtEmailInfo",
+	ICQDBQueryMetaReplyInterests:       "ICQDBQueryMetaReplyInterests",
+	ICQDBQueryMetaReplyAffiliations:    "ICQDBQueryMetaReplyAffiliations",
+	ICQDBQueryMetaReplyHomePageCat:     "ICQDBQueryMetaReplyHomePageCat",
+	ICQDBQueryMetaReplyUserFound:       "ICQDBQueryMetaReplyUserFound",
+	ICQDBQueryMetaReplyLastUserFound:   "ICQDBQueryMetaReplyLastUserFound",
+	ICQDBQueryMetaReplyXMLData:         "ICQDBQueryMetaReplyXMLData",
+}