diff --git a/src/subcommands/osu/link.subcommand.ts b/src/subcommands/osu/link.subcommand.ts index 3f3bb12..d76fc17 100644 --- a/src/subcommands/osu/link.subcommand.ts +++ b/src/subcommands/osu/link.subcommand.ts @@ -4,14 +4,20 @@ import { bold } from "discord.js"; import type { OsuCommand } from "../../commands/osu.command"; import { ExtendedError } from "../../lib/extended-error"; -import { getUserSearch } from "../../lib/types/api"; +import { getUserById, getUserSearch } from "../../lib/types/api"; export function addLinkSubcommand(command: SlashCommandSubcommandBuilder) { return command .setName("link") .setDescription("Link your osu!sunrise profile") .addStringOption(o => - o.setName("username").setDescription("Your username on the server").setRequired(true), + o + .setName("username") + .setDescription("Your username on the server") + .setRequired(false) + .setName("id") + .setDescription("Your account ID on the server") + .setRequired(false), ); } @@ -21,21 +27,47 @@ export async function chatInputRunLinkSubcommand( ) { await interaction.deferReply(); - const userUsernameOption = interaction.options.getString("username", true); + const userUsernameOption = interaction.options.getString("username"); + const userIdOption = interaction.options.getString("id"); - const userSearchResponse = await getUserSearch({ - query: { limit: 1, page: 1, query: userUsernameOption }, - }); + if (!userUsernameOption && !userIdOption) { + throw new ExtendedError("You must provide either a username or an ID to link your account."); + } - if (userSearchResponse.error || userSearchResponse.data.length <= 0) { - throw new ExtendedError( - userSearchResponse?.error?.detail - || userSearchResponse?.error?.title - || "Couldn't fetch user!", - ); + let user = null; + + if (userIdOption) { + const userSearchResponseById = await getUserById({ path: { id: Number.parseInt(userIdOption, 10) } }); + + if (userSearchResponseById.error || !userSearchResponseById.data) { + throw new ExtendedError( + userSearchResponseById?.error?.detail + || userSearchResponseById?.error?.title + || "Couldn't fetch user by ID!", + ); + } + user = userSearchResponseById.data; } - const user = userSearchResponse.data[0]!; + if (userUsernameOption && !user) { + const userSearchResponse = await getUserSearch({ + query: { limit: 1, page: 1, query: userUsernameOption }, + }); + + if (userSearchResponse.error || userSearchResponse.data.length <= 0) { + throw new ExtendedError( + userSearchResponse?.error?.detail + || userSearchResponse?.error?.title + || "Couldn't fetch user!", + ); + } + + user = userSearchResponse.data[0]; + } + + if (!user) { + throw new ExtendedError("Couldn't find the specified user!"); + } const { db } = this.container; diff --git a/src/subcommands/osu/tests/link.subcommand.test.ts b/src/subcommands/osu/tests/link.subcommand.test.ts index eb1ae2a..cfcb7a1 100644 --- a/src/subcommands/osu/tests/link.subcommand.test.ts +++ b/src/subcommands/osu/tests/link.subcommand.test.ts @@ -1,6 +1,15 @@ import { faker } from "@faker-js/faker"; import { container } from "@sapphire/framework"; -import { afterAll, beforeAll, beforeEach, describe, expect, it, jest, mock } from "bun:test"; +import { + afterAll, + beforeAll, + beforeEach, + describe, + expect, + it, + jest, + mock, +} from "bun:test"; import { OsuCommand } from "../../../commands/osu.command"; import { ExtendedError } from "../../../lib/extended-error"; @@ -23,7 +32,7 @@ describe("Osu Link Subcommand", () => { beforeEach(() => Mocker.beforeEachCleanup(errorHandler)); - it("should reply with success message when link is successful", async () => { + it("should reply with success message when link is successful with username", async () => { const editReplyMock = mock(); const username = faker.internet.username(); @@ -32,7 +41,9 @@ describe("Osu Link Subcommand", () => { deferReply: mock(), editReply: editReplyMock, options: { - getString: jest.fn().mockReturnValue(username), + getString: jest.fn((name: string) => + name === "username" ? username : null, + ), }, }), "link", @@ -40,9 +51,68 @@ describe("Osu Link Subcommand", () => { const osuUserId = faker.number.int({ min: 1, max: 1000000 }); - Mocker.mockApiRequest("getUserSearch", async () => ({ - data: [{ user_id: osuUserId, username }], - })); + Mocker.mockApiRequests({ + getUserSearch: async () => ({ + data: [{ user_id: osuUserId, username }], + }), + }); + + await osuCommand.chatInputRun(interaction, { + commandId: faker.string.uuid(), + commandName: "link", + }); + + const expectedEmbed = container.utilities.embedPresets.getSuccessEmbed( + `🙂 You are now **${username}**!`, + ); + + expect(errorHandler).not.toBeCalled(); + + expect(editReplyMock).toHaveBeenLastCalledWith({ + embeds: [ + expect.objectContaining({ + data: expect.objectContaining({ + title: expectedEmbed.data.title, + }), + }), + ], + }); + + const { db } = container; + + const row = db + .query("SELECT osu_user_id FROM connections WHERE discord_user_id = $1") + .get({ + $1: interaction.user.id, + }); + + expect(row).toEqual({ osu_user_id: osuUserId.toString() }); + }); + + it("should reply with success message when link is successful with id", async () => { + const editReplyMock = mock(); + const username = faker.internet.username(); + const osuUserId = faker.number.int({ min: 1, max: 1000000 }); + const userIdString = osuUserId.toString(); + + const interaction = FakerGenerator.withSubcommand( + FakerGenerator.generateInteraction({ + deferReply: mock(), + editReply: editReplyMock, + options: { + getString: jest.fn((name: string) => + name === "id" ? userIdString : null, + ), + }, + }), + "link", + ); + + Mocker.mockApiRequests({ + getUserById: async () => ({ + data: { user_id: osuUserId, username }, + }), + }); await osuCommand.chatInputRun(interaction, { commandId: faker.string.uuid(), @@ -67,14 +137,157 @@ describe("Osu Link Subcommand", () => { const { db } = container; - const row = db.query("SELECT osu_user_id FROM connections WHERE discord_user_id = $1").get({ - $1: interaction.user.id, + const row = db + .query("SELECT osu_user_id FROM connections WHERE discord_user_id = $1") + .get({ + $1: interaction.user.id, + }); + + expect(row).toEqual({ osu_user_id: osuUserId.toString() }); + }); + + it("should prioritize id over username when both are provided", async () => { + const editReplyMock = mock(); + const username = faker.internet.username(); + const otherUsername = faker.internet.username(); + const osuUserId = faker.number.int({ min: 1, max: 1000000 }); + const userIdString = osuUserId.toString(); + + const interaction = FakerGenerator.withSubcommand( + FakerGenerator.generateInteraction({ + deferReply: mock(), + editReply: editReplyMock, + options: { + getString: jest.fn((name: string) => { + if (name === "id") + return userIdString; + if (name === "username") + return otherUsername; + return null; + }), + }, + }), + "link", + ); + + Mocker.mockApiRequests({ + getUserById: async () => ({ + data: { user_id: osuUserId, username }, + }), + }); + + await osuCommand.chatInputRun(interaction, { + commandId: faker.string.uuid(), + commandName: "link", + }); + + const expectedEmbed = container.utilities.embedPresets.getSuccessEmbed( + `🙂 You are now **${username}**!`, + ); + + expect(errorHandler).not.toBeCalled(); + + expect(editReplyMock).toHaveBeenLastCalledWith({ + embeds: [ + expect.objectContaining({ + data: expect.objectContaining({ + title: expectedEmbed.data.title, + }), + }), + ], }); + const { db } = container; + + const row = db + .query("SELECT osu_user_id FROM connections WHERE discord_user_id = $1") + .get({ + $1: interaction.user.id, + }); + expect(row).toEqual({ osu_user_id: osuUserId.toString() }); }); - it("should reply with error message when link fails", async () => { + it("should throw error when neither username nor id is provided", async () => { + const interaction = FakerGenerator.withSubcommand( + FakerGenerator.generateInteraction({ + deferReply: mock(), + editReply: mock(), + options: { + getString: jest.fn().mockReturnValue(null), + }, + }), + "link", + ); + + await osuCommand.chatInputRun(interaction, { + commandId: faker.string.uuid(), + commandName: "link", + }); + + expect(errorHandler).toHaveBeenCalledWith( + expect.objectContaining({ + message: + "You must provide either a username or an ID to link your account.", + }), + expect.anything(), + ); + + const { db } = container; + + const row = db + .query("SELECT osu_user_id FROM connections WHERE discord_user_id = $1") + .get({ + $1: interaction.user.id, + }); + + expect(row).toBeNull(); + }); + + it("should reply with error message when username search fails", async () => { + const username = faker.internet.username(); + + const interaction = FakerGenerator.withSubcommand( + FakerGenerator.generateInteraction({ + deferReply: mock(), + editReply: mock(), + options: { + getString: jest.fn((name: string) => + name === "username" ? username : null, + ), + }, + }), + "link", + ); + + Mocker.mockApiRequests({ + getUserSearch: async () => ({ + error: { detail: "User not found", title: "Not Found" }, + }), + }); + + await osuCommand.chatInputRun(interaction, { + commandId: faker.string.uuid(), + commandName: "link", + }); + + expect(errorHandler).toHaveBeenCalledWith( + expect.any(ExtendedError), + expect.anything(), + ); + + const { db } = container; + + const row = db + .query("SELECT osu_user_id FROM connections WHERE discord_user_id = $1") + .get({ + $1: interaction.user.id, + }); + + expect(row).toBeNull(); + }); + + it("should reply with error message when username search returns empty array", async () => { const username = faker.internet.username(); const interaction = FakerGenerator.withSubcommand( @@ -82,29 +295,126 @@ describe("Osu Link Subcommand", () => { deferReply: mock(), editReply: mock(), options: { - getString: jest.fn().mockReturnValue(username), + getString: jest.fn((name: string) => + name === "username" ? username : null, + ), + }, + }), + "link", + ); + + Mocker.mockApiRequests({ + getUserSearch: async () => ({ + data: [], + }), + }); + + await osuCommand.chatInputRun(interaction, { + commandId: faker.string.uuid(), + commandName: "link", + }); + + expect(errorHandler).toHaveBeenCalledWith( + expect.any(ExtendedError), + expect.anything(), + ); + + const { db } = container; + + const row = db + .query("SELECT osu_user_id FROM connections WHERE discord_user_id = $1") + .get({ + $1: interaction.user.id, + }); + + expect(row).toBeNull(); + }); + + it("should reply with error message when getUserById fails", async () => { + const osuUserId = faker.number.int({ min: 1, max: 1000000 }); + const userIdString = osuUserId.toString(); + + const interaction = FakerGenerator.withSubcommand( + FakerGenerator.generateInteraction({ + deferReply: mock(), + editReply: mock(), + options: { + getString: jest.fn((name: string) => + name === "id" ? userIdString : null, + ), }, }), "link", ); - Mocker.mockApiRequest("getUserSearch", async () => ({ - error: "User not found", - })); + Mocker.mockApiRequests({ + getUserById: async () => ({ + error: { detail: "User not found", title: "Not Found" }, + }), + }); await osuCommand.chatInputRun(interaction, { commandId: faker.string.uuid(), commandName: "link", }); - expect(errorHandler).toHaveBeenCalledWith(expect.any(ExtendedError), expect.anything()); + expect(errorHandler).toHaveBeenCalledWith( + expect.any(ExtendedError), + expect.anything(), + ); const { db } = container; - const row = db.query("SELECT osu_user_id FROM connections WHERE discord_user_id = $1").get({ - $1: interaction.user.id, + const row = db + .query("SELECT osu_user_id FROM connections WHERE discord_user_id = $1") + .get({ + $1: interaction.user.id, + }); + + expect(row).toBeNull(); + }); + + it("should reply with error message when getUserById returns no data", async () => { + const osuUserId = faker.number.int({ min: 1, max: 1000000 }); + const userIdString = osuUserId.toString(); + + const interaction = FakerGenerator.withSubcommand( + FakerGenerator.generateInteraction({ + deferReply: mock(), + editReply: mock(), + options: { + getString: jest.fn((name: string) => + name === "id" ? userIdString : null, + ), + }, + }), + "link", + ); + + Mocker.mockApiRequests({ + getUserById: async () => ({ + data: null, + }), + }); + + await osuCommand.chatInputRun(interaction, { + commandId: faker.string.uuid(), + commandName: "link", }); + expect(errorHandler).toHaveBeenCalledWith( + expect.any(ExtendedError), + expect.anything(), + ); + + const { db } = container; + + const row = db + .query("SELECT osu_user_id FROM connections WHERE discord_user_id = $1") + .get({ + $1: interaction.user.id, + }); + expect(row).toBeNull(); }); });