Repository: TheOdinProject/odin-bot-v2
Pull request #396 (2023-08-19 22:53:55)

Add /triage and /email commands for rotation management
<!-- Thank you for taking the time to contribute to The Odin Project. In order to get this pull request (PR) merged in a reasonable amount of time, you must complete this entire template. -->

## Because
<!-- Summarize the purpose or reasons for this PR, e.g. what problem it solves or what benefit it provides. -->
The rotations of the maintainer/core teams are less than ideal to manage at the moment.

## This PR
<!-- A bullet point list of one or more items describing the specific changes. -->
Adds automated rotation management functionality to the bot.

It also includes some QOL changes, such as adding eslint-config-prettier so that eslint and prettier get along, and lays some foundational infrastructure for testing around redis and discordjs slash commands.

## Issue
<!--
If this PR closes an open issue in this repo, replace the XXXXX below with the issue number, e.g. Closes #2013.

If this PR closes an open issue in another TOP repo, replace the #XXXXX with the URL of the issue, e.g. Closes https://github.com/TheOdinProject/curriculum/issues/XXXXX

If this PR does not close, but is related to another issue or PR, you can link it as above without the 'Closes' keyword, e.g. 'Related to #2013'.
-->
Closes #XXXXX

## Additional Information
<!-- Any other information about this PR, such as a link to a Discord discussion. -->


## Pull Request Requirements
<!-- Replace the whitespace between the square brackets with an 'x', e.g. [x]. After you create the PR, they will become checkboxes that you can click on. -->
-   [x] I have thoroughly read and understand [The Odin Project Contributing Guide](https://github.com/TheOdinProject/.github/blob/main/CONTRIBUTING.md)
-   [x] The title of this PR follows the `location of change: brief description of change` format, e.g. `Callbacks command: Update verbiage`
-   [x] The `Because` section summarizes the reason for this PR
-   [x] The `This PR` section has a bullet point list describing the changes in this PR
-   [ ] If this PR addresses an open issue, it is linked in the `Issue` section
-   [x] If this PR adds new features or functionality, I have added new tests
-   [x] If applicable, I have ensured all tests related to any command files included in this PR pass, and/or all snapshots are up to date



Patch:
diff --git a/.eslintrc.json b/.eslintrc.json
index 35562e5..d3ed811 100644
--- a/.eslintrc.json
+++ b/.eslintrc.json
@@ -5,7 +5,7 @@
     "es2021": true,
     "jest": true
   },
-  "extends": ["airbnb-base"],
+  "extends": ["airbnb-base", "prettier"],
   "parser": "@babel/eslint-parser",
   "parserOptions": {
     "ecmaVersion": 12,
diff --git a/.vscode/launch.json b/.vscode/launch.json
new file mode 100644
index 0000000..f1f350b
--- /dev/null
+++ b/.vscode/launch.json
@@ -0,0 +1,26 @@
+{
+  // Use IntelliSense to learn about possible attributes.
+  // Hover to view descriptions of existing attributes.
+  // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
+  "version": "0.2.0",
+  "configurations": [
+    {
+      "type": "pwa-node",
+      "request": "launch",
+      "name": "Launch Program",
+      "skipFiles": [
+        "<node_internals>/**"
+      ],
+      "program": "${workspaceFolder}/index.js"
+    },
+    {
+      "type": "node-terminal",
+      "request":"launch",
+      "name": "Run Tests",
+      "skipFiles": [
+        "<node_internals>/**"
+      ],
+      "command": "yarn jest --watch"
+    }
+  ]
+}
\ No newline at end of file
diff --git a/new-era-commands/rotations/email.js b/new-era-commands/rotations/email.js
new file mode 100644
index 0000000..03d1b11
--- /dev/null
+++ b/new-era-commands/rotations/email.js
@@ -0,0 +1,3 @@
+const { rotationBuilder } = require("../../utils/rotation-builder");
+
+module.exports = rotationBuilder("email", "emailRotationList");
diff --git a/new-era-commands/rotations/triage.js b/new-era-commands/rotations/triage.js
new file mode 100644
index 0000000..77bb06f
--- /dev/null
+++ b/new-era-commands/rotations/triage.js
@@ -0,0 +1,3 @@
+const { rotationBuilder } = require("../../utils/rotation-builder");
+
+module.exports = rotationBuilder("triage", "maintainerTriageRotationList");
diff --git a/package-lock.json b/package-lock.json
index ce01044..b1330ee 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -23,6 +23,7 @@
         "@babel/preset-env": "^7.22.5",
         "eslint": "^8.46.0",
         "eslint-config-airbnb-base": "^15.0.0",
+        "eslint-config-prettier": "^9.0.0",
         "eslint-plugin-import": "^2.27.5",
         "husky": "^8.0.3",
         "jest": "^29.6.1",
@@ -4038,6 +4039,18 @@
         "semver": "bin/semver.js"
       }
     },
+    "node_modules/eslint-config-prettier": {
+      "version": "9.0.0",
+      "resolved": "https://registry.npmjs.org/eslint-config-prettier/-/eslint-config-prettier-9.0.0.tgz",
+      "integrity": "sha512-IcJsTkJae2S35pRsRAwoCE+925rJJStOdkKnLVgtE+tEpqU0EVVM7OqrwxqgptKdX29NUwC82I5pXsGFIgSevw==",
+      "dev": true,
+      "bin": {
+        "eslint-config-prettier": "bin/cli.js"
+      },
+      "peerDependencies": {
+        "eslint": ">=7.0.0"
+      }
+    },
     "node_modules/eslint-import-resolver-node": {
       "version": "0.3.7",
       "resolved": "https://registry.npmjs.org/eslint-import-resolver-node/-/eslint-import-resolver-node-0.3.7.tgz",
@@ -11399,6 +11412,13 @@
         }
       }
     },
+    "eslint-config-prettier": {
+      "version": "9.0.0",
+      "resolved": "https://registry.npmjs.org/eslint-config-prettier/-/eslint-config-prettier-9.0.0.tgz",
+      "integrity": "sha512-IcJsTkJae2S35pRsRAwoCE+925rJJStOdkKnLVgtE+tEpqU0EVVM7OqrwxqgptKdX29NUwC82I5pXsGFIgSevw==",
+      "dev": true,
+      "requires": {}
+    },
     "eslint-import-resolver-node": {
       "version": "0.3.7",
       "resolved": "https://registry.npmjs.org/eslint-import-resolver-node/-/eslint-import-resolver-node-0.3.7.tgz",
diff --git a/package.json b/package.json
index 38cad3c..7eecb18 100644
--- a/package.json
+++ b/package.json
@@ -26,6 +26,7 @@
     "@babel/preset-env": "^7.22.5",
     "eslint": "^8.46.0",
     "eslint-config-airbnb-base": "^15.0.0",
+    "eslint-config-prettier": "^9.0.0",
     "eslint-plugin-import": "^2.27.5",
     "husky": "^8.0.3",
     "jest": "^29.6.1",
diff --git a/services/redis/__mocks__/index.js b/services/redis/__mocks__/index.js
new file mode 100644
index 0000000..b6bfab5
--- /dev/null
+++ b/services/redis/__mocks__/index.js
@@ -0,0 +1,3 @@
+const MockRedisService = require('./redis.service');
+
+module.exports = MockRedisService;
\ No newline at end of file
diff --git a/services/redis/__mocks__/redis.service.js b/services/redis/__mocks__/redis.service.js
new file mode 100644
index 0000000..bca0125
--- /dev/null
+++ b/services/redis/__mocks__/redis.service.js
@@ -0,0 +1,42 @@
+class MockRedisInstance {
+  constructor() {
+    this.store = {};
+  }
+  
+  async rpush(keyName, entry) {
+    if (!this.store[keyName]) {
+      this.store[keyName] = [];
+    }
+
+    const standardizedEntry = typeof entry === "string" ? [entry] : entry;
+
+    this.store[keyName].push(...standardizedEntry);
+  }
+
+  async lrange(keyName, start, end) {
+    if (end === -1) {
+      return this.store[keyName]?.slice(start);
+    }
+    return this.store[keyName]?.slice(start, end);
+  }
+
+  async del(keyName) {
+    this.store[keyName] = undefined;
+  }
+
+  async lrem(keyName, _count, element) {
+    this.store[keyName] = this.store[keyName].filter((entry) => entry !== element);
+  }
+
+  async lpop(keyName) {
+    return this.store[keyName].shift()
+  }
+}
+
+const MockRedisService = {
+  getInstance() {
+    return new MockRedisInstance();
+  },
+};
+
+module.exports = MockRedisService
diff --git a/services/rotations/index.js b/services/rotations/index.js
new file mode 100644
index 0000000..8def3bc
--- /dev/null
+++ b/services/rotations/index.js
@@ -0,0 +1,1 @@
+export * from "./rotation.service";
diff --git a/services/rotations/rotation.service.js b/services/rotations/rotation.service.js
new file mode 100644
index 0000000..7b84716
--- /dev/null
+++ b/services/rotations/rotation.service.js
@@ -0,0 +1,179 @@
+const RedisService = require("../redis");
+
+class RotationService {
+  constructor(keyName, rotationName) {
+    this.keyName = keyName;
+    this.rotationName = rotationName;
+    this.redis = RedisService.getInstance();
+  }
+
+  async #getQueue() {
+    const members = await this.redis.lrange(this.keyName, 0, -1);
+    return members;
+  }
+
+  async #addMembers(memberIds) {
+    const queue = await this.#getQueue();
+
+    const newMemberIds = memberIds.filter((id) => !queue?.includes(id));
+
+    if (newMemberIds.length > 0) {
+      await this.redis.rpush(this.keyName, newMemberIds);
+    }
+
+    return newMemberIds;
+  }
+
+  static #getFormattedPings(memberIds) {
+    return memberIds.reduce((acc, id) => `${acc} <@${id}>`, "");
+  }
+
+  async #getDisplayNames(members, server) {
+    this.displayNames = members.map(async (memberId) => {
+      const member = await server.members.fetch(memberId);
+      if (member.nickname) {
+        return member.nickname;
+      }
+      return member.user.username;
+    });
+
+    return Promise.all(this.displayNames);
+  }
+
+  async #getFormattedQueue(interaction) {
+    const members = await this.#getQueue();
+    const membersDisplayNames = await this.#getDisplayNames(
+      members,
+      interaction.guild
+    );
+    const formattedQueue = membersDisplayNames.reduce(
+      (acc, displayname) => `${acc} ${displayname} >`,
+      ""
+    );
+    if (formattedQueue) {
+      return `${this.rotationName} rotation queue order:${formattedQueue}`;
+    }
+    return "No members";
+  }
+
+  async #handleAddMembers(members, interaction) {
+    let reply = "";
+    const memberIds = members.map((member) => member?.id || member);
+    const addedIds = await this.#addMembers(memberIds);
+
+    if (addedIds.length !== memberIds.length) {
+      const nonAddedIds = memberIds.filter((id) => !addedIds.includes(id));
+      const nonAddedNames = await this.#getDisplayNames(
+        nonAddedIds,
+        interaction.guild
+      );
+      const formattedNonAddedNames = nonAddedNames
+        .reduce((acc, name) => `${acc} ${name},`, "")
+        .slice(0, -1);
+      reply += `${formattedNonAddedNames} not added as they are already in the queue\n\n`;
+    }
+
+    if (addedIds.length > 0) {
+      const addedNotifications = RotationService.#getFormattedPings(addedIds);
+      reply += `${addedNotifications} successfully added to the queue\n\n`;
+    }
+
+    reply += await this.#getFormattedQueue(interaction);
+
+    interaction.reply(reply.trim());
+  }
+
+  async #createQueue(members) {
+    await this.redis.del(this.keyName);
+    await this.#addMembers(members);
+  }
+
+  async #swapMembers(memberIds) {
+    const [firstMember, secondMember] = memberIds;
+    const queue = await this.#getQueue();
+
+    const firstMemberIndex = queue.indexOf(firstMember);
+    const secondMemberIndex = queue.indexOf(secondMember);
+
+    queue[firstMemberIndex] = secondMember;
+    queue[secondMemberIndex] = firstMember;
+
+    await this.#createQueue(queue);
+  }
+
+  async #handleSwapMembers(members, interaction) {
+    const memberIds = members.map((member) => member.id);
+    await this.#swapMembers(memberIds);
+
+    const swappedMemberPings = RotationService.#getFormattedPings(memberIds);
+    let reply = `${swappedMemberPings} swapped position in the queue\n\n`;
+    reply += await this.#getFormattedQueue(interaction);
+
+    interaction.reply(reply.trim());
+  }
+
+  async #rotateQueue() {
+    const memberToPing = await this.redis.lpop(this.keyName);
+    await this.#addMembers([memberToPing]);
+
+    return memberToPing;
+  }
+
+  async #handleRotateQueue(interaction) {
+    const memberToPing = await this.#rotateQueue(interaction);
+
+    let reply = `<@${memberToPing}> it's your turn for the ${this.rotationName} rotation.\n\n`;
+    reply += await this.#getFormattedQueue(interaction);
+
+    interaction.reply(reply.trim());
+  }
+
+  async #removeMember(memberId) {
+    await this.redis.lrem(this.keyName, 0, memberId);
+  }
+
+  async #handleRemoveMember(member, interaction) {
+    const memberId = member.id;
+    await this.#removeMember(memberId);
+
+    let reply = `<@${memberId}> removed from the queue\n\n`;
+    reply += await this.#getFormattedQueue(interaction);
+
+    interaction.reply(reply.trim());
+  }
+
+  #getMembers(interactionOptions) {
+    this.members = [];
+    for (let i = 0; i < 10; i += 1) {
+      this.members.push(interactionOptions.getUser(`user${i}`));
+    }
+    return this.members.filter((member) => !!member);
+  }
+
+  async handleInteraction(interaction) {
+    const actionType = interaction.options.getSubcommand();
+
+    const members = this.#getMembers(interaction.options);
+
+    switch (actionType) {
+      case "add":
+        await this.#handleAddMembers(members, interaction);
+        return;
+      case "swap":
+        await this.#handleSwapMembers(members, interaction);
+        return;
+      case "remove":
+        await this.#handleRemoveMember(members[0], interaction);
+        return;
+      case "rotate":
+        await this.#handleRotateQueue(interaction);
+        return;
+      default:
+    }
+
+    const reply = await this.#getFormattedQueue(interaction);
+    await interaction.reply(reply.trim());
+  }
+}
+
+module.exports = { RotationService };
diff --git a/utils/rotation-builder.js b/utils/rotation-builder.js
new file mode 100644
index 0000000..02b3f13
--- /dev/null
+++ b/utils/rotation-builder.js
@@ -0,0 +1,44 @@
+const { SlashCommandBuilder, PermissionFlagsBits } = require("discord.js");
+const { RotationService } = require("../services/rotations/rotation.service");
+const { addSubcommands } = require("./slash-command-helpers");
+
+function rotationBuilder(rotationName, redisKeyName) {
+  const subcommands = [
+    { name: "read", description: "report the current queue order" },
+    { name: "rotate", description: "rotate the queue" },
+    {
+      name: "add",
+      description: `add people to the ${rotationName} rotation queue`,
+      min: 1,
+      max: 10,
+    },
+    {
+      name: "swap",
+      description: "swap the position of two members in the queue",
+      min: 2,
+      max: 2,
+    },
+    {
+      name: "remove",
+      description: "remove members from the queue",
+      min: 1,
+      max: 1,
+    },
+  ];
+
+  const base = new SlashCommandBuilder()
+    .setName(rotationName)
+    .setDescription(`list ${rotationName} info`)
+    .setDefaultMemberPermissions(PermissionFlagsBits.ManageMessages);
+
+  const data = addSubcommands(base, subcommands);
+
+  async function execute(interaction) {
+    const triageService = new RotationService(redisKeyName, rotationName);
+    await triageService.handleInteraction(interaction);
+  }
+
+  return { data, execute };
+}
+
+module.exports = { rotationBuilder };
diff --git a/utils/slash-command-helpers/addSubcommands.js b/utils/slash-command-helpers/addSubcommands.js
new file mode 100644
index 0000000..8a6f367
--- /dev/null
+++ b/utils/slash-command-helpers/addSubcommands.js
@@ -0,0 +1,32 @@
+function addUserOptions(builder, min, max, description) {
+  for (let i = 0; i < min; i += 1) {
+    builder.addUserOption((option) =>
+      option.setName(`user${i}`).setDescription(description).setRequired(true)
+    );
+  }
+
+  for (let i = min; i < max; i += 1) {
+    builder.addUserOption((option) =>
+      option.setName(`user${i}`).setDescription(description)
+    );
+  }
+
+  return builder;
+}
+
+function addSubcommands(builder, subcommandObjects) {
+  subcommandObjects.forEach((object) => {
+    const { name, description, min, max } = object;
+    builder.addSubcommand((subcommand) => {
+      subcommand.setName(name).setDescription(description);
+      if (min) {
+        return addUserOptions(subcommand, min, max, description);
+      }
+      return subcommand;
+    });
+  });
+
+  return builder;
+}
+
+module.exports = { addSubcommands };
diff --git a/utils/slash-command-helpers/index.js b/utils/slash-command-helpers/index.js
new file mode 100644
index 0000000..dedde47
--- /dev/null
+++ b/utils/slash-command-helpers/index.js
@@ -0,0 +1,4 @@
+const testHelpers = require("./discordjsTestHelpers");
+const addUserOptions = require("./addSubcommands");
+
+module.exports = { ...testHelpers, ...addUserOptions };


Test patch:
diff --git a/services/rotations/rotation.service.test.js b/services/rotations/rotation.service.test.js
new file mode 100644
index 0000000..174e958
--- /dev/null
+++ b/services/rotations/rotation.service.test.js
@@ -0,0 +1,512 @@
+const { RotationService } = require("./rotation.service");
+const {
+  buildInteraction,
+  getUsers,
+  initializeServer,
+} = require("../../utils/slash-command-helpers");
+
+jest.mock("../redis");
+
+describe("addition", () => {
+  it("creates a rotation and reports the inital queue order", async () => {
+    const rotation = new RotationService("test", "test");
+
+    const reply = jest.fn();
+    const users = getUsers(2);
+    const server = initializeServer();
+    const interaction = buildInteraction("add", server, users, reply);
+
+    await rotation.handleInteraction(interaction);
+
+    expect(reply).toHaveBeenCalledWith(
+      "<@1234> <@5678> successfully added to the queue\n\ntest rotation queue order: Foo > Baz >"
+    );
+  });
+
+  it("adds one person to the queue and reports the new queue order", async () => {
+    const rotation = new RotationService("test", "test");
+
+    const creationUsers = getUsers(2);
+    const server = initializeServer();
+    const creationInteraction = buildInteraction(
+      "add",
+      server,
+      creationUsers,
+      () => {}
+    );
+
+    const reply = jest.fn();
+    const additionUsers = getUsers(1, 2);
+    const additionInteraction = buildInteraction(
+      "add",
+      server,
+      additionUsers,
+      reply
+    );
+
+    await rotation.handleInteraction(creationInteraction);
+
+    await rotation.handleInteraction(additionInteraction);
+
+    expect(reply).toHaveBeenCalledWith(
+      "<@9101> successfully added to the queue\n\ntest rotation queue order: Foo > Baz > Bang >"
+    );
+  });
+
+  it("adds multiple people to the queue and reports the new queue order", async () => {
+    const rotation = new RotationService("test", "test");
+
+    const creationUsers = getUsers(2);
+    const server = initializeServer();
+    const creationInteraction = buildInteraction(
+      "add",
+      server,
+      creationUsers,
+      () => {}
+    );
+
+    const reply = jest.fn();
+    const additionUsers = getUsers(2, 2);
+    const additionInteraction = buildInteraction(
+      "add",
+      server,
+      additionUsers,
+      reply
+    );
+
+    await rotation.handleInteraction(creationInteraction);
+
+    await rotation.handleInteraction(additionInteraction);
+
+    expect(reply).toHaveBeenCalledWith(
+      "<@9101> <@1121> successfully added to the queue\n\ntest rotation queue order: Foo > Baz > Bang > Bing >"
+    );
+  });
+
+  it("does not add people to the queue multiple times", async () => {
+    const rotation = new RotationService("test", "test");
+
+    const creationUsers = getUsers(2);
+    const server = initializeServer();
+    const creationInteraction = buildInteraction(
+      "add",
+      server,
+      creationUsers,
+      () => {}
+    );
+
+    const reply = jest.fn();
+    const additionUsers = getUsers(3, 1);
+    const additionInteraction = buildInteraction(
+      "add",
+      server,
+      additionUsers,
+      reply
+    );
+
+    await rotation.handleInteraction(creationInteraction);
+
+    await rotation.handleInteraction(additionInteraction);
+
+    expect(reply).toHaveBeenCalledWith(
+      "Baz not added as they are already in the queue\n\n <@9101> <@1121> successfully added to the queue\n\ntest rotation queue order: Foo > Baz > Bang > Bing >"
+    );
+  });
+
+  it("addresses rejected users by nickname if they have one", async () => {
+    const rotation = new RotationService("test", "test");
+
+    const creationUsers = getUsers(2);
+    const additionUsers = getUsers(1);
+    additionUsers[0].nickname = "Bar";
+    const server = initializeServer([...creationUsers, ...additionUsers]);
+
+    const creationInteraction = buildInteraction(
+      "add",
+      server,
+      creationUsers,
+      () => {}
+    );
+
+    const reply = jest.fn();
+
+    const additionInteraction = buildInteraction(
+      "add",
+      server,
+      additionUsers,
+      reply
+    );
+
+    await rotation.handleInteraction(creationInteraction);
+
+    await rotation.handleInteraction(additionInteraction);
+
+    expect(reply).toHaveBeenCalledWith(
+      "Bar not added as they are already in the queue\n\ntest rotation queue order: Bar > Baz >"
+    );
+  });
+
+  it("only replies once", async () => {
+    const rotation = new RotationService("test", "test");
+
+    const creationUsers = getUsers(2);
+    const server = initializeServer();
+    const creationInteraction = buildInteraction(
+      "add",
+      server,
+      creationUsers,
+      () => {}
+    );
+
+    const reply = jest.fn();
+    const additionUsers = getUsers(2, 2);
+    const additionInteraction = buildInteraction(
+      "add",
+      server,
+      additionUsers,
+      reply
+    );
+
+    await rotation.handleInteraction(creationInteraction);
+
+    await rotation.handleInteraction(additionInteraction);
+
+    expect(reply).toHaveBeenCalledTimes(1);
+  });
+});
+
+describe("removal", () => {
+  it("removes one person from the start of the queue and reports the new queue order", async () => {
+    const rotation = new RotationService("test", "test");
+
+    const creationUsers = getUsers(3);
+    const server = initializeServer();
+    const creationInteraction = buildInteraction(
+      "add",
+      server,
+      creationUsers,
+      () => {}
+    );
+
+    const reply = jest.fn();
+    const removalusers = creationUsers.slice(0, 1);
+    const removalInteraction = buildInteraction(
+      "remove",
+      server,
+      removalusers,
+      reply
+    );
+
+    await rotation.handleInteraction(creationInteraction);
+
+    await rotation.handleInteraction(removalInteraction);
+
+    expect(reply).toHaveBeenCalledWith(
+      "<@1234> removed from the queue\n\ntest rotation queue order: Baz > Bang >"
+    );
+  });
+
+  it("removes one person from the middle of the queue and reports the new queue order", async () => {
+    const rotation = new RotationService("test", "test");
+
+    const creationUsers = getUsers(3);
+    const server = initializeServer();
+    const creationInteraction = buildInteraction(
+      "add",
+      server,
+      creationUsers,
+      () => {}
+    );
+
+    const reply = jest.fn();
+    const removalusers = creationUsers.slice(1, 2);
+    const removalInteraction = buildInteraction(
+      "remove",
+      server,
+      removalusers,
+      reply
+    );
+
+    await rotation.handleInteraction(creationInteraction);
+
+    await rotation.handleInteraction(removalInteraction);
+
+    expect(reply).toHaveBeenCalledWith(
+      "<@5678> removed from the queue\n\ntest rotation queue order: Foo > Bang >"
+    );
+  });
+
+  it("removes one person from the end of the queue and reports the new queue order", async () => {
+    const rotation = new RotationService("test", "test");
+
+    const creationUsers = getUsers(3);
+    const server = initializeServer();
+    const creationInteraction = buildInteraction(
+      "add",
+      server,
+      creationUsers,
+      () => {}
+    );
+
+    const reply = jest.fn();
+    const removalusers = creationUsers.slice(2);
+    const removalInteraction = buildInteraction(
+      "remove",
+      server,
+      removalusers,
+      reply
+    );
+
+    await rotation.handleInteraction(creationInteraction);
+
+    await rotation.handleInteraction(removalInteraction);
+
+    expect(reply).toHaveBeenCalledWith(
+      "<@9101> removed from the queue\n\ntest rotation queue order: Foo > Baz >"
+    );
+  });
+
+  it("only replies once", async () => {
+    const rotation = new RotationService("test", "test");
+
+    const creationUsers = getUsers(3);
+    const server = initializeServer();
+    const creationInteraction = buildInteraction(
+      "add",
+      server,
+      creationUsers,
+      () => {}
+    );
+
+    const reply = jest.fn();
+    const removalusers = creationUsers.slice(2);
+    const removalInteraction = buildInteraction(
+      "remove",
+      server,
+      removalusers,
+      reply
+    );
+
+    await rotation.handleInteraction(creationInteraction);
+
+    await rotation.handleInteraction(removalInteraction);
+
+    expect(reply).toHaveBeenCalledTimes(1);
+  });
+});
+
+describe("swapping", () => {
+  it("swaps one person from the start of the queue with one from the end of the queue and reports the new queue order", async () => {
+    const rotation = new RotationService("test", "test");
+
+    const creationUsers = getUsers(3);
+    const server = initializeServer();
+    const creationInteraction = buildInteraction(
+      "add",
+      server,
+      creationUsers,
+      () => {}
+    );
+
+    const reply = jest.fn();
+    const swapUsers = [creationUsers[0], creationUsers[2]];
+    const swappingInteraction = buildInteraction(
+      "swap",
+      server,
+      swapUsers,
+      reply
+    );
+
+    await rotation.handleInteraction(creationInteraction);
+
+    await rotation.handleInteraction(swappingInteraction);
+
+    expect(reply).toHaveBeenCalledWith(
+      "<@1234> <@9101> swapped position in the queue\n\ntest rotation queue order: Bang > Baz > Foo >"
+    );
+  });
+
+  it("swaps one person from the start of the queue with one from the middle of the queue and reports the new queue order", async () => {
+    const rotation = new RotationService("test", "test");
+
+    const creationUsers = getUsers(3);
+    const server = initializeServer();
+    const creationInteraction = buildInteraction(
+      "add",
+      server,
+      creationUsers,
+      () => {}
+    );
+
+    const reply = jest.fn();
+    const swapUsers = [creationUsers[0], creationUsers[1]];
+    const swappingInteraction = buildInteraction(
+      "swap",
+      server,
+      swapUsers,
+      reply
+    );
+
+    await rotation.handleInteraction(creationInteraction);
+
+    await rotation.handleInteraction(swappingInteraction);
+
+    expect(reply).toHaveBeenCalledWith(
+      "<@1234> <@5678> swapped position in the queue\n\ntest rotation queue order: Baz > Foo > Bang >"
+    );
+  });
+
+  it("swaps one person from the middle of the queue with one from the end of the queue and reports the new queue order", async () => {
+    const rotation = new RotationService("test", "test");
+
+    const creationUsers = getUsers(3);
+    const server = initializeServer();
+    const creationInteraction = buildInteraction(
+      "add",
+      server,
+      creationUsers,
+      () => {}
+    );
+
+    const reply = jest.fn();
+    const swapUsers = [creationUsers[1], creationUsers[2]];
+    const swappingInteraction = buildInteraction(
+      "swap",
+      server,
+      swapUsers,
+      reply
+    );
+
+    await rotation.handleInteraction(creationInteraction);
+
+    await rotation.handleInteraction(swappingInteraction);
+
+    expect(reply).toHaveBeenCalledWith(
+      "<@5678> <@9101> swapped position in the queue\n\ntest rotation queue order: Foo > Bang > Baz >"
+    );
+  });
+
+  it("only replies once", async () => {
+    const rotation = new RotationService("test", "test");
+
+    const creationUsers = getUsers(3);
+    const server = initializeServer();
+    const creationInteraction = buildInteraction(
+      "add",
+      server,
+      creationUsers,
+      () => {}
+    );
+
+    const reply = jest.fn();
+    const swapUsers = [creationUsers[0], creationUsers[2]];
+    const swappingInteraction = buildInteraction(
+      "swap",
+      server,
+      swapUsers,
+      reply
+    );
+
+    await rotation.handleInteraction(creationInteraction);
+
+    await rotation.handleInteraction(swappingInteraction);
+
+    expect(reply).toHaveBeenCalledTimes(1);
+  });
+});
+
+describe("reading", () => {
+  it("reports the queue order", async () => {
+    const rotation = new RotationService("test", "test");
+
+    const creationUsers = getUsers(3);
+    const server = initializeServer();
+    const creationInteraction = buildInteraction(
+      "add",
+      server,
+      creationUsers,
+      () => {}
+    );
+
+    const reply = jest.fn();
+    const readInteraction = buildInteraction("read", server, [], reply);
+
+    await rotation.handleInteraction(creationInteraction);
+
+    await rotation.handleInteraction(readInteraction);
+
+    expect(reply).toHaveBeenCalledWith(
+      "test rotation queue order: Foo > Baz > Bang >"
+    );
+  });
+
+  it("only replies once", async () => {
+    const rotation = new RotationService("test", "test");
+
+    const creationUsers = getUsers(3);
+    const server = initializeServer();
+    const creationInteraction = buildInteraction(
+      "add",
+      server,
+      creationUsers,
+      () => {}
+    );
+
+    const reply = jest.fn();
+    const readInteraction = buildInteraction("read", server, [], reply);
+
+    await rotation.handleInteraction(creationInteraction);
+
+    await rotation.handleInteraction(readInteraction);
+
+    expect(reply).toHaveBeenCalledTimes(1);
+  });
+});
+
+describe("rotation", () => {
+  it("pings the user up in the rotation, rotates the queue, and reports the new queue order", async () => {
+    const rotation = new RotationService("test", "test");
+
+    const creationUsers = getUsers(3);
+    const server = initializeServer();
+    const creationInteraction = buildInteraction(
+      "add",
+      server,
+      creationUsers,
+      () => {}
+    );
+
+    const reply = jest.fn();
+    const rotationInteraction = buildInteraction("rotate", server, [], reply);
+
+    await rotation.handleInteraction(creationInteraction);
+
+    await rotation.handleInteraction(rotationInteraction);
+
+    expect(reply).toHaveBeenCalledWith(
+      "<@1234> it's your turn for the test rotation.\n\ntest rotation queue order: Baz > Bang > Foo >"
+    );
+  });
+
+  it("only replies once", async () => {
+    const rotation = new RotationService("test", "test");
+
+    const creationUsers = getUsers(3);
+    const server = initializeServer();
+    const creationInteraction = buildInteraction(
+      "add",
+      server,
+      creationUsers,
+      () => {}
+    );
+
+    const reply = jest.fn();
+    const rotationInteraction = buildInteraction("rotate", server, [], reply);
+
+    await rotation.handleInteraction(creationInteraction);
+
+    await rotation.handleInteraction(rotationInteraction);
+
+    expect(reply).toHaveBeenCalledTimes(1);
+  });
+});
diff --git a/utils/slash-command-helpers/addSubcommands.test.js b/utils/slash-command-helpers/addSubcommands.test.js
new file mode 100644
index 0000000..a8b2352
--- /dev/null
+++ b/utils/slash-command-helpers/addSubcommands.test.js
@@ -0,0 +1,339 @@
+const { addSubcommands } = require("./addSubcommands");
+
+describe("adding subcommands", () => {
+  it("does not call the addSubcommand method when no subcommands are passed", () => {
+    const mockAddSubcommand = jest.fn();
+    const builder = { addSubcommand: mockAddSubcommand };
+
+    addSubcommands(builder, []);
+
+    expect(mockAddSubcommand).not.toHaveBeenCalled();
+  });
+
+  it("calls addSubcommand once for each subcommand passed", () => {
+    const mockAddSubcommand = jest.fn();
+    const builder = { addSubcommand: mockAddSubcommand };
+
+    const subcommands = [
+      { name: "Foo", description: "Foos the bar" },
+      { name: "Bar", description: "Bars the bar" },
+      { name: "Baz", description: "Baz the bar" },
+    ];
+
+    addSubcommands(builder, subcommands);
+
+    expect(mockAddSubcommand).toHaveBeenCalledTimes(3);
+  });
+});
+
+describe("setting names", () => {
+  it("calls setName with the passed names", () => {
+    const mockSetName = jest.fn(() => ({ setDescription: () => {} }));
+    const subcommand = { setName: mockSetName };
+    const addSubcommand = (subcommandCB) => subcommandCB(subcommand);
+
+    const builder = { addSubcommand };
+
+    const subcommands = [
+      { name: "Foo", description: "Foos the bar" },
+      { name: "Bar", description: "Bars the bar" },
+      { name: "Baz", description: "Baz the bar" },
+    ];
+
+    addSubcommands(builder, subcommands);
+
+    expect(mockSetName).toHaveBeenCalledWith("Foo");
+    expect(mockSetName).toHaveBeenCalledWith("Bar");
+    expect(mockSetName).toHaveBeenCalledWith("Baz");
+  });
+
+  it("calls setName once for each subcommand", () => {
+    const mockSetName = jest.fn(() => ({ setDescription: () => {} }));
+    const subcommand = { setName: mockSetName };
+    const addSubcommand = (subcommandCB) => subcommandCB(subcommand);
+
+    const builder = { addSubcommand };
+
+    const subcommands = [
+      { name: "Foo", description: "Foos the bar" },
+      { name: "Bar", description: "Bars the bar" },
+      { name: "Baz", description: "Baz the bar" },
+    ];
+
+    addSubcommands(builder, subcommands);
+
+    expect(mockSetName).toHaveBeenCalledTimes(3);
+  });
+});
+
+describe("setting descriptions", () => {
+  it("calls setDescription with the passed descriptions", () => {
+    const mockSetDescription = jest.fn();
+    const mockSetName = () => ({ setDescription: mockSetDescription });
+    const subcommand = { setName: mockSetName };
+    const addSubcommand = (subcommandCB) => subcommandCB(subcommand);
+
+    const builder = { addSubcommand };
+
+    const subcommands = [
+      { name: "Foo", description: "Foos the bar" },
+      { name: "Bar", description: "Bars the bar" },
+      { name: "Baz", description: "Baz the bar" },
+    ];
+
+    addSubcommands(builder, subcommands);
+
+    expect(mockSetDescription).toHaveBeenCalledWith("Foos the bar");
+    expect(mockSetDescription).toHaveBeenCalledWith("Bars the bar");
+    expect(mockSetDescription).toHaveBeenCalledWith("Baz the bar");
+  });
+
+  it("calls setDescription once for each subcommand", () => {
+    const mockSetDescription = jest.fn();
+    const mockSetName = () => ({ setDescription: mockSetDescription });
+    const subcommand = { setName: mockSetName };
+    const addSubcommand = (subcommandCB) => subcommandCB(subcommand);
+
+    const builder = { addSubcommand };
+
+    const subcommands = [
+      { name: "Foo", description: "Foos the bar" },
+      { name: "Bar", description: "Bars the bar" },
+      { name: "Baz", description: "Baz the bar" },
+    ];
+
+    addSubcommands(builder, subcommands);
+
+    expect(mockSetDescription).toHaveBeenCalledTimes(3);
+  });
+});
+
+describe("adding user options", () => {
+  it("does not call addUserOption when no min value is passed", () => {
+    const mockAddUserOption = jest.fn();
+
+    const subcommand = {
+      setName: () => ({ setDescription: () => {} }),
+      addUserOption: mockAddUserOption,
+    };
+    const addSubcommand = (subcommandCB) => subcommandCB(subcommand);
+
+    const builder = { addSubcommand };
+
+    const subcommands = [
+      { name: "Foo", description: "Foos the bar" },
+      { name: "Bar", description: "Bars the bar" },
+      { name: "Baz", description: "Baz the bar" },
+    ];
+
+    addSubcommands(builder, subcommands);
+
+    expect(mockAddUserOption).not.toHaveBeenCalled();
+  });
+
+  it("calls addUserOption once for each user option requested", () => {
+    const mockAddUserOption = jest.fn();
+
+    const subcommand = {
+      setName: () => ({ setDescription: () => {} }),
+      addUserOption: mockAddUserOption,
+    };
+    const addSubcommand = (subcommandCB) => subcommandCB(subcommand);
+
+    const builder = { addSubcommand };
+
+    const subcommands = [
+      { name: "Foo", description: "Foos the bar", min: 2, max: 3 },
+      { name: "Bar", description: "Bars the bar", min: 3 },
+      { name: "Baz", description: "Baz the bar" },
+    ];
+
+    addSubcommands(builder, subcommands);
+
+    expect(mockAddUserOption).toHaveBeenCalledTimes(6);
+  });
+
+  describe("setting names of user options", () => {
+    it("sets option names of user0 and user1 when two user options are requested", () => {
+      const mockSetName = jest.fn(() => ({
+        setDescription: () => ({ setRequired: () => {} }),
+      }));
+      const option = {
+        setName: mockSetName,
+      };
+      const mockAddUserOption = (optionCB) => optionCB(option);
+
+      const subcommand = {
+        setName: () => ({ setDescription: () => {} }),
+        addUserOption: mockAddUserOption,
+      };
+      const addSubcommand = (subcommandCB) => subcommandCB(subcommand);
+
+      const builder = { addSubcommand };
+
+      const subcommands = [
+        { name: "Foo", description: "Foos the bar", min: 2, max: 3 },
+        { name: "Bar", description: "Bars the bar" },
+        { name: "Baz", description: "Baz the bar" },
+      ];
+
+      addSubcommands(builder, subcommands);
+
+      expect(mockSetName).toHaveBeenCalledWith("user0");
+      expect(mockSetName).toHaveBeenCalledWith("user1");
+      expect(mockSetName).toHaveBeenCalledWith("user2");
+    });
+
+    it("calls setName once for each user option requested", () => {
+      const mockSetName = jest.fn(() => ({
+        setDescription: () => ({ setRequired: () => {} }),
+      }));
+      const option = {
+        setName: mockSetName,
+      };
+      const mockAddUserOption = (optionCB) => optionCB(option);
+
+      const subcommand = {
+        setName: () => ({ setDescription: () => {} }),
+        addUserOption: mockAddUserOption,
+      };
+      const addSubcommand = (subcommandCB) => subcommandCB(subcommand);
+
+      const builder = { addSubcommand };
+
+      const subcommands = [
+        { name: "Foo", description: "Foos the bar", min: 2, max: 3 },
+        { name: "Bar", description: "Bars the bar", min: 1 },
+        { name: "Baz", description: "Baz the bar" },
+      ];
+
+      addSubcommands(builder, subcommands);
+
+      expect(mockSetName).toHaveBeenCalledTimes(4);
+    });
+  });
+
+  describe("setting descriptions of user options", () => {
+    it("calls setDescription on the user options with the associated description", () => {
+      const mockSetDescription = jest.fn(() => ({ setRequired: () => {} }));
+      const mockSetName = () => ({
+        setDescription: mockSetDescription,
+      });
+      const option = {
+        setName: mockSetName,
+      };
+      const mockAddUserOption = (optionCB) => optionCB(option);
+
+      const subcommand = {
+        setName: () => ({ setDescription: () => {} }),
+        addUserOption: mockAddUserOption,
+      };
+      const addSubcommand = (subcommandCB) => subcommandCB(subcommand);
+
+      const builder = { addSubcommand };
+
+      const subcommands = [
+        { name: "Foo", description: "Foos the bar", min: 2 },
+        { name: "Bar", description: "Bars the bar", min: 1 },
+        { name: "Baz", description: "Baz the bar" },
+      ];
+
+      addSubcommands(builder, subcommands);
+
+      expect(mockSetDescription).toHaveBeenCalledWith("Foos the bar");
+      expect(mockSetDescription).toHaveBeenCalledWith("Bars the bar");
+    });
+
+    it("calls setDescription once for each user option requested", () => {
+      const mockSetDescription = jest.fn(() => ({ setRequired: () => {} }));
+      const mockSetName = () => ({
+        setDescription: mockSetDescription,
+      });
+      const option = {
+        setName: mockSetName,
+      };
+      const mockAddUserOption = (optionCB) => optionCB(option);
+
+      const subcommand = {
+        setName: () => ({ setDescription: () => {} }),
+        addUserOption: mockAddUserOption,
+      };
+      const addSubcommand = (subcommandCB) => subcommandCB(subcommand);
+
+      const builder = { addSubcommand };
+
+      const subcommands = [
+        { name: "Foo", description: "Foos the bar", min: 2, max: 3 },
+        { name: "Bar", description: "Bars the bar", min: 1 },
+        { name: "Baz", description: "Baz the bar" },
+      ];
+
+      addSubcommands(builder, subcommands);
+
+      expect(mockSetDescription).toHaveBeenNthCalledWith(1, "Foos the bar");
+      expect(mockSetDescription).toHaveBeenNthCalledWith(2, "Foos the bar");
+      expect(mockSetDescription).toHaveBeenNthCalledWith(3, "Foos the bar");
+      expect(mockSetDescription).toHaveBeenNthCalledWith(4, "Bars the bar");
+    });
+  });
+
+  describe("setting user options as required", () => {
+    it("calls setRequired for the minimum user options needed", () => {
+      const mockSetRequired = jest.fn();
+      const mockSetName = () => ({
+        setDescription: () => ({ setRequired: mockSetRequired }),
+      });
+      const option = {
+        setName: mockSetName,
+      };
+      const mockAddUserOption = (optionCB) => optionCB(option);
+
+      const subcommand = {
+        setName: () => ({ setDescription: () => {} }),
+        addUserOption: mockAddUserOption,
+      };
+      const addSubcommand = (subcommandCB) => subcommandCB(subcommand);
+
+      const builder = { addSubcommand };
+
+      const subcommands = [
+        { name: "Foo", description: "Foos the bar", min: 2 },
+        { name: "Bar", description: "Bars the bar", min: 1 },
+        { name: "Baz", description: "Baz the bar" },
+      ];
+
+      addSubcommands(builder, subcommands);
+
+      expect(mockSetRequired).toHaveBeenCalledTimes(3);
+    });
+
+    it("does not call setRequired for the maximum user options needed", () => {
+      const mockSetRequired = jest.fn();
+      const mockSetName = () => ({
+        setDescription: () => ({ setRequired: mockSetRequired }),
+      });
+      const option = {
+        setName: mockSetName,
+      };
+      const mockAddUserOption = (optionCB) => optionCB(option);
+
+      const subcommand = {
+        setName: () => ({ setDescription: () => {} }),
+        addUserOption: mockAddUserOption,
+      };
+      const addSubcommand = (subcommandCB) => subcommandCB(subcommand);
+
+      const builder = { addSubcommand };
+
+      const subcommands = [
+        { name: "Foo", description: "Foos the bar", min: 2, max: 10 },
+        { name: "Bar", description: "Bars the bar", min: 1 },
+        { name: "Baz", description: "Baz the bar" },
+      ];
+
+      addSubcommands(builder, subcommands);
+
+      expect(mockSetRequired).toHaveBeenCalledTimes(3);
+    });
+  });
+});
diff --git a/utils/slash-command-helpers/discordjsTestHelpers.js b/utils/slash-command-helpers/discordjsTestHelpers.js
new file mode 100644
index 0000000..78de1c7
--- /dev/null
+++ b/utils/slash-command-helpers/discordjsTestHelpers.js
@@ -0,0 +1,60 @@
+function getUsers(count, start = 0) {
+  const users = [
+    { id: "1234", username: "Foo", nickname: "" },
+    { id: "5678", username: "Baz", nickname: "" },
+    { id: "9101", username: "Bang", nickname: "" },
+    { id: "1121", username: "Bing", nickname: "" },
+    { id: "3141", username: "Bong", nickname: "" },
+    { id: "5161", username: "Ding", nickname: "" },
+  ];
+
+  if (start > 0) {
+    return users.slice(start, count + start);
+  }
+
+  if (count) {
+    return users.slice(start, count);
+  }
+
+  return users.slice(start);
+}
+
+function buildInteractionUsers(usersArray) {
+  return usersArray.reduce((acc, user, index) => {
+    acc[`user${index}`] = user;
+    return acc;
+  }, {});
+}
+
+function buildInteraction(subcommand, guild, usersArray, reply) {
+  const users = buildInteractionUsers(usersArray)
+  return {
+    options: {
+      getSubcommand: () => subcommand,
+      getUser: (name) => users[name],
+    },
+    guild,
+    reply,
+  };
+}
+
+function initializeServer(usersArray) {
+  const users = usersArray || getUsers();
+  const usersById = users.reduce((acc, user) => {
+    const { id, username, nickname } = user;
+    acc[id] = { user: { username }, nickname };
+    return acc;
+  }, {});
+
+  return {
+    members: {
+      fetch: (id) => usersById[id],
+    },
+  };
+}
+
+module.exports = {
+  buildInteraction,
+  getUsers,
+  initializeServer,
+};
diff --git a/utils/slash-command-helpers/discordjsTestHelpers.test.js b/utils/slash-command-helpers/discordjsTestHelpers.test.js
new file mode 100644
index 0000000..b7ed7cf
--- /dev/null
+++ b/utils/slash-command-helpers/discordjsTestHelpers.test.js
@@ -0,0 +1,122 @@
+const {
+  getUsers,
+  buildInteraction,
+  initializeServer,
+} = require("./discordjsTestHelpers");
+
+describe("getUsers", () => {
+  it("returns the full list of users when called with no arguments", () => {
+    const expectedUsers = [
+      { id: "1234", username: "Foo", nickname: "" },
+      { id: "5678", username: "Baz", nickname: "" },
+      { id: "9101", username: "Bang", nickname: "" },
+      { id: "1121", username: "Bing", nickname: "" },
+      { id: "3141", username: "Bong", nickname: "" },
+      { id: "5161", username: "Ding", nickname: "" },
+    ];
+
+    const users = getUsers();
+
+    expect(users).toEqual(expectedUsers);
+  });
+
+  it("returns the requested number of users when called with a user count", () => {
+    const expectedUsers = [
+      { id: "1234", username: "Foo", nickname: "" },
+      { id: "5678", username: "Baz", nickname: "" },
+      { id: "9101", username: "Bang", nickname: "" },
+    ];
+
+    const users = getUsers(3);
+
+    expect(users).toEqual(expectedUsers);
+  });
+
+  it("returns the requested number of users with the requested offset when called with a user count and offset", () => {
+    const expectedUsers = [
+      { id: "1121", username: "Bing", nickname: "" },
+      { id: "3141", username: "Bong", nickname: "" },
+    ];
+
+    const users = getUsers(2, 3);
+
+    expect(users).toEqual(expectedUsers);
+  });
+});
+
+describe("buildInteraction", () => {
+  it("returns the passed subcommand when the getSubcommand option is called", () => {
+    const subcommand = "foo";
+    const guild = "odin";
+    const users = [];
+    const reply = () => {};
+
+    const interaction = buildInteraction(subcommand, guild, users, reply);
+
+    expect(interaction.options.getSubcommand()).toEqual("foo");
+  });
+
+  it("returns an interaction with the passed guild", () => {
+    const subcommand = "foo";
+    const guild = "odin";
+    const users = [];
+    const reply = () => {};
+
+    const interaction = buildInteraction(subcommand, guild, users, reply);
+
+    expect(interaction.guild).toEqual("odin");
+  });
+
+  it("returns the requested user when the getUser option is called", () => {
+    const subcommand = "foo";
+    const guild = "odin";
+    const users = [
+      { id: "1234", username: "Foo", nickname: "" },
+      { id: "5678", username: "Baz", nickname: "" },
+    ];
+    const reply = () => {};
+
+    const interaction = buildInteraction(subcommand, guild, users, reply);
+
+    expect(interaction.options.getUser("user0")).toEqual({ id: "1234", username: "Foo", nickname: "" });
+    expect(interaction.options.getUser("user1")).toEqual({ id: "5678", username: "Baz", nickname: "" });
+  });
+
+  it("returns an interaction with the passed reply", () => {
+    const subcommand = "foo";
+    const guild = "odin";
+    const users = [];
+    const reply = jest.fn();
+
+    const interaction = buildInteraction(subcommand, guild, users, reply);
+
+    interaction.reply("bar");
+
+    expect(reply).toHaveBeenCalledWith("bar");
+  });
+
+  it("does not call the passed reply", () => {
+    const subcommand = "foo";
+    const guild = "odin";
+    const users = [];
+    const reply = jest.fn();
+
+    buildInteraction(subcommand, guild, users, reply);
+
+    expect(reply).not.toHaveBeenCalled();
+  });
+});
+
+describe("initalizeServer", () => {
+  it ('returns the requested user when fetch is called with their id', () => {
+    const users = [
+      { id: "1234", username: "Foo", nickname: "bar" },
+      { id: "5678", username: "Baz", nickname: "" },
+    ];
+
+    const server = initializeServer(users);
+
+    expect(server.members.fetch("1234")).toEqual({ user: { username: "Foo" }, nickname: "bar" })
+    expect(server.members.fetch("5678")).toEqual({ user: { username: "Baz" }, nickname: "" })
+  })
+})