diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..0176dee --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,16 @@ +on: [push] +name: Run Tests +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + tests: + name: Run Tests + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: oven-sh/setup-bun@v2 + + - run: bun install + - run: bun test diff --git a/README.md b/README.md index 386a23e..796022f 100644 --- a/README.md +++ b/README.md @@ -11,6 +11,7 @@ This is a Discord Bot build with [Sapphire framework](https://sapphirejs.dev/), It is a part of the Sunrise project, which aims to create a fully functional osu! private server with all the features that the official server has. +This project also has **automated testing** and **CI/CD** setup with GitHub Actions! ✨ ## Installation (with docker) 🐳 1. Clone the repository diff --git a/bun.lock b/bun.lock index 7ebfb46..23362c6 100644 --- a/bun.lock +++ b/bun.lock @@ -4,6 +4,7 @@ "": { "name": "sunshinediscordbot", "dependencies": { + "@faker-js/faker": "^9.9.0", "@sapphire/decorators": "^6.2.0", "@sapphire/discord.js-utilities": "^7.3.3", "@sapphire/framework": "^5.3.6", @@ -41,6 +42,8 @@ "@emnapi/runtime": ["@emnapi/runtime@1.4.3", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-pBPWdu6MLKROBX05wSNKcNb++m5Er+KQ9QkB+WVM+pW2Kx9hoSrVTnu3BdkI5eBLZoKu/J6mW/B6i6bJB2ytXQ=="], + "@faker-js/faker": ["@faker-js/faker@9.9.0", "", {}, "sha512-OEl393iCOoo/z8bMezRlJu+GlRGlsKbUAN7jKB6LhnKoqKve5DXRpalbItIIcwnCjs1k/FOPjFzcA6Qn+H+YbA=="], + "@hey-api/json-schema-ref-parser": ["@hey-api/json-schema-ref-parser@1.0.6", "", { "dependencies": { "@jsdevtools/ono": "^7.1.3", "@types/json-schema": "^7.0.15", "js-yaml": "^4.1.0", "lodash": "^4.17.21" } }, "sha512-yktiFZoWPtEW8QKS65eqKwA5MTKp88CyiL8q72WynrBs/73SAaxlSWlA2zW/DZlywZ5hX1OYzrCC0wFdvO9c2w=="], "@hey-api/openapi-ts": ["@hey-api/openapi-ts@0.78.1", "", { "dependencies": { "@hey-api/json-schema-ref-parser": "1.0.6", "ansi-colors": "4.1.3", "c12": "2.0.1", "color-support": "1.1.3", "commander": "13.0.0", "handlebars": "4.7.8", "open": "10.1.2" }, "peerDependencies": { "typescript": "^5.5.3" }, "bin": { "openapi-ts": "bin/index.cjs" } }, "sha512-DjpA26aqP9JP6tYUlcydNdxC4KbAhjnIDC4aL5V81DnTdQ70SkD9vFDe8CMeLxAHcngr6ca2kIEIJH3iT2KHkA=="], diff --git a/package.json b/package.json index dd5efe1..6e04db1 100644 --- a/package.json +++ b/package.json @@ -17,6 +17,7 @@ "typescript": "^5" }, "dependencies": { + "@faker-js/faker": "^9.9.0", "@sapphire/decorators": "^6.2.0", "@sapphire/discord.js-utilities": "^7.3.3", "@sapphire/framework": "^5.3.6", diff --git a/src/commands/osu.command.ts b/src/commands/osu.command.ts index a7f2da8..b3a2279 100644 --- a/src/commands/osu.command.ts +++ b/src/commands/osu.command.ts @@ -53,7 +53,7 @@ export class OsuCommand extends Subcommand { for (const cmd of subcommandModules) { ;(this as any)[cmd.name] = async (interaction: Subcommand.ChatInputCommandInteraction) => { - return cmd.run.call(this, interaction) + return (cmd.run as any).call(this, interaction) } } } diff --git a/src/commands/tests/meow.command.test.ts b/src/commands/tests/meow.command.test.ts new file mode 100644 index 0000000..75f4476 --- /dev/null +++ b/src/commands/tests/meow.command.test.ts @@ -0,0 +1,34 @@ +import { expect, describe, it, mock, jest, beforeAll, afterAll, beforeEach } from "bun:test" +import { MeowCommand } from "../meow.command" +import { Mocker } from "../../lib/mock/mocker" +import { FakerGenerator } from "../../lib/mock/faker.generator" + +describe("Meow Command", () => { + let meowCommand: MeowCommand + let errorHandler: jest.Mock + + beforeAll(() => { + Mocker.createSapphireClientInstance() + meowCommand = Mocker.createCommandInstance(MeowCommand) + errorHandler = Mocker.createErrorHandler() + }) + + afterAll(async () => { + await Mocker.resetSapphireClientInstance() + }) + + beforeEach(() => Mocker.beforeEachCleanup(errorHandler)) + + it("should reply with 'meow! 😺' when chatInputRun is called", async () => { + const replyMock = mock() + const interaction = FakerGenerator.generateInteraction({ + reply: replyMock, + }) + + await meowCommand.chatInputRun(interaction) + + expect(errorHandler).not.toBeCalled() + + expect(replyMock).toHaveBeenCalledWith("meow! 😺") + }) +}) diff --git a/src/interaction-handlers/pagination/buttons/tests/pagination.button.test.ts b/src/interaction-handlers/pagination/buttons/tests/pagination.button.test.ts new file mode 100644 index 0000000..7b253e9 --- /dev/null +++ b/src/interaction-handlers/pagination/buttons/tests/pagination.button.test.ts @@ -0,0 +1,418 @@ +import { faker } from "@faker-js/faker" +import { container, InteractionHandlerStore, InteractionHandlerTypes } from "@sapphire/framework" +import { expect, describe, it, beforeAll, afterAll, jest, mock, beforeEach } from "bun:test" +import { FakerGenerator } from "../../../../lib/mock/faker.generator" +import { Mocker } from "../../../../lib/mock/mocker" +import { PaginationButton } from "../pagination.button" +import { PaginationInteractionCustomId } from "../../../../lib/types/enum/custom-ids.types" +import { PaginationButtonAction } from "../../../../lib/types/enum/pagination.types" +import { EMPTY_CHAR } from "../../../../lib/constants" + +describe("Pagination Button", () => { + let errorHandler: jest.Mock + + beforeAll(() => { + Mocker.createSapphireClientInstance() + errorHandler = Mocker.createErrorHandler() + }) + + afterAll(async () => { + await Mocker.resetSapphireClientInstance() + }) + + beforeEach(() => Mocker.beforeEachCleanup(errorHandler)) + + describe("parse", () => { + it("should return none when invalid custom id is provided", async () => { + const button = new PaginationButton( + { ...FakerGenerator.generateLoaderContext(), store: new InteractionHandlerStore() }, + { interactionHandlerType: InteractionHandlerTypes.Button }, + ) + + const interaction = FakerGenerator.generateButtonInteraction({ + customId: "invalid_custom_id", + }) + + const result = await button.parse(interaction) + + expect(result).toBe(button.none()) + }) + + it("should return none when no dataStoreId is provided", async () => { + const button = new PaginationButton( + { ...FakerGenerator.generateLoaderContext(), store: new InteractionHandlerStore() }, + { interactionHandlerType: InteractionHandlerTypes.Button }, + ) + + const userId = faker.number.int().toString() + + const customId = FakerGenerator.generateCustomId({ + prefix: PaginationInteractionCustomId.PAGINATION_ACTION_MOVE, + userId, + ctx: {}, + }) + + const interaction = FakerGenerator.generateButtonInteraction({ + user: { + id: userId, + }, + customId: customId, + }) + + const result = await button.parse(interaction) + + expect(result).toBe(button.none()) + }) + + it("should return none when no data is provided", async () => { + const button = new PaginationButton( + { ...FakerGenerator.generateLoaderContext(), store: new InteractionHandlerStore() }, + { interactionHandlerType: InteractionHandlerTypes.Button }, + ) + + const dataStoreId = container.utilities.actionStore.set(mock()) + const userId = faker.number.int().toString() + + const customId = FakerGenerator.generateCustomId({ + prefix: PaginationInteractionCustomId.PAGINATION_ACTION_MOVE, + userId, + ctx: { dataStoreId }, + }) + + const interaction = FakerGenerator.generateButtonInteraction({ + user: { + id: userId, + }, + customId: customId, + }) + + const result = await button.parse(interaction) + + expect(result).toBe(button.none()) + }) + + it("should return none when dataStore does not exist", async () => { + const button = new PaginationButton( + { ...FakerGenerator.generateLoaderContext(), store: new InteractionHandlerStore() }, + { interactionHandlerType: InteractionHandlerTypes.Button }, + ) + + const userId = faker.number.int().toString() + + const customId = FakerGenerator.generateCustomId({ + prefix: PaginationInteractionCustomId.PAGINATION_ACTION_MOVE, + userId, + ctx: { + dataStoreId: faker.string.uuid(), + data: [PaginationButtonAction.LEFT], + }, + }) + + const interaction = FakerGenerator.generateButtonInteraction({ + user: { + id: userId, + }, + customId: customId, + }) + + const result = await button.parse(interaction) + + expect(result).toBe(button.none()) + }) + + it("should return some when valid button interaction is provided", async () => { + const button = new PaginationButton( + { ...FakerGenerator.generateLoaderContext(), store: new InteractionHandlerStore() }, + { interactionHandlerType: InteractionHandlerTypes.Button }, + ) + + const paginationData = FakerGenerator.generatePaginationData() + const dataStoreId = container.utilities.actionStore.set(paginationData) + + const userId = faker.number.int().toString() + + const customId = FakerGenerator.generateCustomId({ + prefix: PaginationInteractionCustomId.PAGINATION_ACTION_MOVE, + userId, + ctx: { + dataStoreId, + data: [PaginationButtonAction.RIGHT], + }, + }) + + const interaction = FakerGenerator.generateButtonInteraction({ + user: { + id: userId, + }, + customId: customId, + }) + + const result = await button.parse(interaction) + + expect(result).not.toBe(button.none()) + }) + }) + + describe("run", () => { + it("should navigate to first page when MAX_LEFT is clicked", async () => { + const button = new PaginationButton( + { ...FakerGenerator.generateLoaderContext(), store: new InteractionHandlerStore() }, + { interactionHandlerType: InteractionHandlerTypes.Button }, + ) + + const mockHandleSetPage = mock(async (state: any) => ({ + embed: { title: "Page 1" }, + buttonsRow: { components: [] }, + })) + + const paginationData = FakerGenerator.generatePaginationData({ + handleSetPage: mockHandleSetPage, + state: { + currentPage: 3, + totalPages: 5, + }, + }) + + const deferUpdateMock = mock(async () => ({})) + const editReplyMock = mock(async () => ({})) + + const interaction = FakerGenerator.generateButtonInteraction({ + deferUpdate: deferUpdateMock, + editReply: editReplyMock, + }) + + await button.run(interaction, { + ...paginationData, + paginationAction: PaginationButtonAction.MAX_LEFT, + } as any) + + expect(deferUpdateMock).toHaveBeenCalled() + expect(paginationData.state.currentPage).toBe(1) + expect(mockHandleSetPage).toHaveBeenCalledWith(paginationData.state) + expect(editReplyMock).toHaveBeenCalledTimes(2) + }) + + it("should navigate to previous page when LEFT is clicked", async () => { + const button = new PaginationButton( + { ...FakerGenerator.generateLoaderContext(), store: new InteractionHandlerStore() }, + { interactionHandlerType: InteractionHandlerTypes.Button }, + ) + + const mockHandleSetPage = mock(async (state: any) => ({ + embed: { title: "Page 2" }, + buttonsRow: { components: [] }, + })) + + const paginationData = FakerGenerator.generatePaginationData({ + handleSetPage: mockHandleSetPage, + state: { + currentPage: 3, + totalPages: 5, + }, + }) + + const deferUpdateMock = mock(async () => ({})) + const editReplyMock = mock(async () => ({})) + + const interaction = FakerGenerator.generateButtonInteraction({ + deferUpdate: deferUpdateMock, + editReply: editReplyMock, + }) + + await button.run(interaction, { + ...paginationData, + paginationAction: PaginationButtonAction.LEFT, + } as any) + + expect(deferUpdateMock).toHaveBeenCalled() + expect(paginationData.state.currentPage).toBe(2) + expect(mockHandleSetPage).toHaveBeenCalledWith(paginationData.state) + expect(editReplyMock).toHaveBeenCalledTimes(2) + }) + + it("should show modal when SELECT_PAGE is clicked", async () => { + const button = new PaginationButton( + { ...FakerGenerator.generateLoaderContext(), store: new InteractionHandlerStore() }, + { interactionHandlerType: InteractionHandlerTypes.Button }, + ) + + const paginationData = FakerGenerator.generatePaginationData() + const dataStoreId = container.utilities.actionStore.set(paginationData) + + const showModalMock = mock() + const userId = faker.number.int().toString() + + const interaction = FakerGenerator.generateButtonInteraction({ + showModal: showModalMock, + user: { + id: userId, + }, + }) + + await button.run(interaction, { + ...paginationData, + dataStoreId, + paginationAction: PaginationButtonAction.SELECT_PAGE, + } as any) + + expect(showModalMock).toHaveBeenCalled() + expect(showModalMock).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + title: EMPTY_CHAR, + custom_id: expect.stringContaining( + PaginationInteractionCustomId.PAGINATION_ACTION_SELECT_PAGE, + ), + }), + }), + ) + }) + + it("should navigate to next page when RIGHT is clicked", async () => { + const button = new PaginationButton( + { ...FakerGenerator.generateLoaderContext(), store: new InteractionHandlerStore() }, + { interactionHandlerType: InteractionHandlerTypes.Button }, + ) + + const mockHandleSetPage = mock(async (state: any) => ({ + embed: { title: "Page 4" }, + buttonsRow: { components: [] }, + })) + + const paginationData = FakerGenerator.generatePaginationData({ + handleSetPage: mockHandleSetPage, + state: { + currentPage: 3, + totalPages: 5, + }, + }) + + const deferUpdateMock = mock(async () => ({})) + const editReplyMock = mock(async () => ({})) + + const interaction = FakerGenerator.generateButtonInteraction({ + deferUpdate: deferUpdateMock, + editReply: editReplyMock, + }) + + await button.run(interaction, { + ...paginationData, + paginationAction: PaginationButtonAction.RIGHT, + } as any) + + expect(deferUpdateMock).toHaveBeenCalled() + expect(paginationData.state.currentPage).toBe(4) + expect(mockHandleSetPage).toHaveBeenCalledWith(paginationData.state) + expect(editReplyMock).toHaveBeenCalledTimes(2) + }) + + it("should navigate to last page when MAX_RIGHT is clicked", async () => { + const button = new PaginationButton( + { ...FakerGenerator.generateLoaderContext(), store: new InteractionHandlerStore() }, + { interactionHandlerType: InteractionHandlerTypes.Button }, + ) + + const mockHandleSetPage = mock(async (state: any) => ({ + embed: { title: "Page 5" }, + buttonsRow: { components: [] }, + })) + + const paginationData = FakerGenerator.generatePaginationData({ + handleSetPage: mockHandleSetPage, + state: { + currentPage: 3, + totalPages: 5, + }, + }) + + const deferUpdateMock = mock(async () => ({})) + const editReplyMock = mock(async () => ({})) + + const interaction = FakerGenerator.generateButtonInteraction({ + deferUpdate: deferUpdateMock, + editReply: editReplyMock, + }) + + await button.run(interaction, { + ...paginationData, + paginationAction: PaginationButtonAction.MAX_RIGHT, + } as any) + + expect(deferUpdateMock).toHaveBeenCalled() + expect(paginationData.state.currentPage).toBe(5) + expect(mockHandleSetPage).toHaveBeenCalledWith(paginationData.state) + expect(editReplyMock).toHaveBeenCalledTimes(2) + }) + + it("should show loading message before calling handleSetPage", async () => { + const button = new PaginationButton( + { ...FakerGenerator.generateLoaderContext(), store: new InteractionHandlerStore() }, + { interactionHandlerType: InteractionHandlerTypes.Button }, + ) + + const mockHandleSetPage = mock(async (state: any) => ({ + embed: { title: "Page 2" }, + buttonsRow: { components: [] }, + })) + + const paginationData = FakerGenerator.generatePaginationData({ + handleSetPage: mockHandleSetPage, + state: { + currentPage: 1, + totalPages: 5, + }, + }) + + const deferUpdateMock = mock(async () => ({})) + const editReplyMock = mock(async () => ({})) + + const interaction = FakerGenerator.generateButtonInteraction({ + deferUpdate: deferUpdateMock, + editReply: editReplyMock, + }) + + await button.run(interaction, { + ...paginationData, + paginationAction: PaginationButtonAction.RIGHT, + } as any) + + expect(editReplyMock).toHaveBeenNthCalledWith(1, { + embeds: [ + expect.objectContaining({ + data: expect.objectContaining({ + title: "⌛ Please wait...", + }), + }), + ], + components: [], + }) + + expect(editReplyMock).toHaveBeenNthCalledWith(2, { + embeds: [expect.objectContaining({ title: "Page 2" })], + components: [{ components: [] }], + }) + }) + + it("should throw error when unexpected pagination action is provided", async () => { + const button = new PaginationButton( + { ...FakerGenerator.generateLoaderContext(), store: new InteractionHandlerStore() }, + { interactionHandlerType: InteractionHandlerTypes.Button }, + ) + + const paginationData = FakerGenerator.generatePaginationData() + + const deferUpdateMock = mock(async () => ({})) + + const interaction = FakerGenerator.generateButtonInteraction({ + deferUpdate: deferUpdateMock, + }) + + expect( + button.run(interaction, { + ...paginationData, + paginationAction: "INVALID_ACTION" as any, + } as any), + ).rejects.toThrow("Unexpected customId for pagination") + }) + }) +}) diff --git a/src/interaction-handlers/pagination/models/pagination-set-page.model.ts b/src/interaction-handlers/pagination/models/pagination-set-page.model.ts index d7065d7..e49daad 100644 --- a/src/interaction-handlers/pagination/models/pagination-set-page.model.ts +++ b/src/interaction-handlers/pagination/models/pagination-set-page.model.ts @@ -14,7 +14,7 @@ import { ExtendedError } from "../../../lib/extended-error" export class PaginationSetPageModal extends InteractionHandler { @ensureUsedBySameUser() @validCustomId(PaginationInteractionCustomId.PAGINATION_ACTION_SELECT_PAGE) - public override async parse(interaction: ButtonInteraction) { + public override async parse(interaction: ModalSubmitInteraction) { const { ctx } = parseCustomId(interaction.customId) if (!ctx.dataStoreId) { return this.none() diff --git a/src/interaction-handlers/pagination/models/tests/pagination-set-page.model.test.ts b/src/interaction-handlers/pagination/models/tests/pagination-set-page.model.test.ts new file mode 100644 index 0000000..5955db2 --- /dev/null +++ b/src/interaction-handlers/pagination/models/tests/pagination-set-page.model.test.ts @@ -0,0 +1,252 @@ +import { faker } from "@faker-js/faker" +import { container, InteractionHandlerStore, InteractionHandlerTypes } from "@sapphire/framework" +import { expect, describe, it, beforeAll, afterAll, jest, mock, beforeEach } from "bun:test" +import { FakerGenerator } from "../../../../lib/mock/faker.generator" +import { Mocker } from "../../../../lib/mock/mocker" +import { PaginationSetPageModal } from "../pagination-set-page.model" +import { PaginationInteractionCustomId } from "../../../../lib/types/enum/custom-ids.types" + +describe("Pagination Set Page Modal", () => { + let errorHandler: jest.Mock + + beforeAll(() => { + Mocker.createSapphireClientInstance() + errorHandler = Mocker.createErrorHandler() + }) + + afterAll(async () => { + await Mocker.resetSapphireClientInstance() + }) + + beforeEach(() => Mocker.beforeEachCleanup(errorHandler)) + + describe("parse", async () => { + it("invalid interaction id provided", async () => { + const modal = new PaginationSetPageModal( + { ...FakerGenerator.generateLoaderContext(), store: new InteractionHandlerStore() }, + { interactionHandlerType: InteractionHandlerTypes.ModalSubmit }, + ) + + const interaction = FakerGenerator.generateModalSubmitInteraction({ + customId: "invalid_custom_id", + }) + + const result = await modal.parse(interaction) + + expect(result).toBe(modal.none()) + }) + + it("invalid interaction id provided", async () => { + const modal = new PaginationSetPageModal( + { ...FakerGenerator.generateLoaderContext(), store: new InteractionHandlerStore() }, + { interactionHandlerType: InteractionHandlerTypes.ModalSubmit }, + ) + + const customId = FakerGenerator.generateCustomId() + + const interaction = FakerGenerator.generateModalSubmitInteraction({ + customId: customId, + }) + + const result = await modal.parse(interaction) + + expect(result).toBe(modal.none()) + }) + + it("valid interaction id provided", async () => { + const modal = new PaginationSetPageModal( + { ...FakerGenerator.generateLoaderContext(), store: new InteractionHandlerStore() }, + { interactionHandlerType: InteractionHandlerTypes.ModalSubmit }, + ) + + const dataStoreId = container.utilities.actionStore.set(mock()) + + const userId = faker.number.int().toString() + + const customId = FakerGenerator.generateCustomId({ + prefix: PaginationInteractionCustomId.PAGINATION_ACTION_SELECT_PAGE, + userId, + ctx: { dataStoreId: dataStoreId }, + }) + + const interaction = FakerGenerator.generateModalSubmitInteraction({ + user: { + id: userId, + }, + customId: customId, + }) + + const result = await modal.parse(interaction) + + expect(result).not.toBe(modal.none()) + }) + }) + + describe("run", () => { + it("should successfully navigate to a valid page", async () => { + const modal = new PaginationSetPageModal( + { ...FakerGenerator.generateLoaderContext(), store: new InteractionHandlerStore() }, + { interactionHandlerType: InteractionHandlerTypes.ModalSubmit }, + ) + + const mockHandleSetPage = mock(async (state: any) => ({ + embed: { title: "Page 3" }, + buttonsRow: { components: [] }, + })) + + const paginationData = FakerGenerator.generatePaginationData({ + handleSetPage: mockHandleSetPage, + }) + + const deferUpdateMock = mock(async () => ({})) + const editReplyMock = mock(async () => ({})) + const getTextInputValueMock = mock(() => "3") + + const interaction = FakerGenerator.generateModalSubmitInteraction({ + deferUpdate: deferUpdateMock, + editReply: editReplyMock, + fields: { + getTextInputValue: getTextInputValueMock, + }, + }) + + await modal.run(interaction, paginationData) + + expect(deferUpdateMock).toHaveBeenCalled() + expect(getTextInputValueMock).toHaveBeenCalledWith("goToPageNumber") + expect(paginationData.state.currentPage).toBe(3) + expect(mockHandleSetPage).toHaveBeenCalledWith(paginationData.state) + expect(editReplyMock).toHaveBeenCalledTimes(2) + }) + + it("should throw error when input is not a number", async () => { + const modal = new PaginationSetPageModal( + { ...FakerGenerator.generateLoaderContext(), store: new InteractionHandlerStore() }, + { interactionHandlerType: InteractionHandlerTypes.ModalSubmit }, + ) + + const paginationData = FakerGenerator.generatePaginationData() + + const deferUpdateMock = mock(async () => ({})) + const getTextInputValueMock = mock(() => "not a number") + + const interaction = FakerGenerator.generateModalSubmitInteraction({ + deferUpdate: deferUpdateMock, + fields: { + getTextInputValue: getTextInputValueMock, + }, + }) + + expect(modal.run(interaction, paginationData)).rejects.toThrow("Not a number") + }) + + it("should throw error when page number is zero", async () => { + const modal = new PaginationSetPageModal( + { ...FakerGenerator.generateLoaderContext(), store: new InteractionHandlerStore() }, + { interactionHandlerType: InteractionHandlerTypes.ModalSubmit }, + ) + + const paginationData = FakerGenerator.generatePaginationData() + + const deferUpdateMock = mock(async () => ({})) + const getTextInputValueMock = mock(() => "0") + + const interaction = FakerGenerator.generateModalSubmitInteraction({ + deferUpdate: deferUpdateMock, + fields: { + getTextInputValue: getTextInputValueMock, + }, + }) + + expect(modal.run(interaction, paginationData)).rejects.toThrow("Invalid page") + }) + + it("should throw error when page number is negative", async () => { + const modal = new PaginationSetPageModal( + { ...FakerGenerator.generateLoaderContext(), store: new InteractionHandlerStore() }, + { interactionHandlerType: InteractionHandlerTypes.ModalSubmit }, + ) + + const paginationData = FakerGenerator.generatePaginationData() + + const deferUpdateMock = mock(async () => ({})) + const getTextInputValueMock = mock(() => "-1") + + const interaction = FakerGenerator.generateModalSubmitInteraction({ + deferUpdate: deferUpdateMock, + fields: { + getTextInputValue: getTextInputValueMock, + }, + }) + + expect(modal.run(interaction, paginationData)).rejects.toThrow("Invalid page") + }) + + it("should throw error when page number exceeds total pages", async () => { + const modal = new PaginationSetPageModal( + { ...FakerGenerator.generateLoaderContext(), store: new InteractionHandlerStore() }, + { interactionHandlerType: InteractionHandlerTypes.ModalSubmit }, + ) + + const paginationData = FakerGenerator.generatePaginationData() + + const deferUpdateMock = mock(async () => ({})) + const getTextInputValueMock = mock(() => "6") + + const interaction = FakerGenerator.generateModalSubmitInteraction({ + deferUpdate: deferUpdateMock, + fields: { + getTextInputValue: getTextInputValueMock, + }, + }) + + expect(modal.run(interaction, paginationData)).rejects.toThrow("Invalid page") + }) + + it("should show loading message before calling handleSetPage", async () => { + const modal = new PaginationSetPageModal( + { ...FakerGenerator.generateLoaderContext(), store: new InteractionHandlerStore() }, + { interactionHandlerType: InteractionHandlerTypes.ModalSubmit }, + ) + + const mockHandleSetPage = mock(async (state: any) => ({ + embed: { title: "Page 2" }, + buttonsRow: { components: [] }, + })) + + const paginationData = FakerGenerator.generatePaginationData({ + handleSetPage: mockHandleSetPage, + }) + + const deferUpdateMock = mock(async () => ({})) + const editReplyMock = mock(async () => ({})) + const getTextInputValueMock = mock(() => "2") + + const interaction = FakerGenerator.generateModalSubmitInteraction({ + deferUpdate: deferUpdateMock, + editReply: editReplyMock, + fields: { + getTextInputValue: getTextInputValueMock, + }, + }) + + await modal.run(interaction, paginationData) + + expect(editReplyMock).toHaveBeenNthCalledWith(1, { + embeds: [ + expect.objectContaining({ + data: expect.objectContaining({ + title: "⌛ Please wait...", + }), + }), + ], + components: [], + }) + + expect(editReplyMock).toHaveBeenNthCalledWith(2, { + embeds: [expect.objectContaining({ title: "Page 2" })], + components: [{ components: [] }], + }) + }) + }) +}) diff --git a/src/lib/configs/env.ts b/src/lib/configs/env.ts index 4981e70..57c8ec7 100644 --- a/src/lib/configs/env.ts +++ b/src/lib/configs/env.ts @@ -37,10 +37,15 @@ export interface IConfig { const requiredEnvVariables = ["DISCORD_TOKEN", "SUNRISE_URI"] requiredEnvVariables.map((v) => { if (!process.env[v]) { + if (process.env.NODE_ENV === "test") return throw new Error(`${v} is not provided in environment file!`) } }) +const env = ["prod", "dev"].includes(process.env.NODE_ENV ?? "") + ? (process.env.NODE_ENV as any) + : "dev" + export const config: IConfig = { discord: { token: process.env["DISCORD_TOKEN"]!, @@ -52,8 +57,6 @@ export const config: IConfig = { newScoresChannel: process.env["NEW_SCORES_CHANNED_ID"] ?? undefined, beatmapsEventsChannel: process.env["BEATMAPS_STATUSES_CHANNED_ID"] ?? undefined, }, - environment: ["prod", "dev"].includes(process.env.NODE_ENV ?? "") - ? (process.env.NODE_ENV as any) - : "dev", - json: require(path.resolve(process.cwd(), "config", `${process.env.NODE_ENV ?? "dev"}.json`)), + environment: env, + json: require(path.resolve(process.cwd(), "config", `${env}.json`)), } diff --git a/src/lib/mock/faker.generator.ts b/src/lib/mock/faker.generator.ts new file mode 100644 index 0000000..b339413 --- /dev/null +++ b/src/lib/mock/faker.generator.ts @@ -0,0 +1,329 @@ +import { jest, mock } from "bun:test" + +import { + ApplicationCommand, + ApplicationCommandType, + ButtonInteraction, + InteractionType, + Locale, + ModalSubmitInteraction, + PermissionsBitField, + User, +} from "discord.js" + +import { faker } from "@faker-js/faker" +import { Command, CommandStore, container } from "@sapphire/framework" +import type { DeepPartial } from "@sapphire/utilities" +import { buildCustomId } from "../utils/discord.util" +import type { PaginationStore } from "../types/store.types" +import { GameMode, BeatmapStatusWeb } from "../types/api" +import type { + ScoreResponse, + BeatmapResponse, + UserResponse, + UserStatsResponse, + UserWithStats, +} from "../types/api" + +function autoMock(base: Partial): T { + return new Proxy(base as T, { + get(target: any, prop: string | symbol) { + if (prop in target) { + return target[prop] + } + + return mock(async (...args: any[]) => null) + }, + }) as unknown as T +} + +const createBaseEntity = () => ({ + id: faker.string.uuid(), + createdAt: faker.date.past(), + createdTimestamp: Date.now(), +}) + +const createBaseInteraction = () => ({ + ...createBaseEntity(), + applicationId: faker.string.uuid(), + channelId: faker.string.uuid(), + guildId: faker.string.uuid(), + locale: Locale.French, +}) + +export class FakerGenerator { + static generatePiece() { + return { + name: faker.string.alpha(10), + store: container?.utilities?.store ?? {}, + path: faker.system.filePath(), + root: faker.system.directoryPath(), + } + } + + static generateLoaderContext() { + return { + name: faker.string.alpha(10), + store: new CommandStore(), + path: faker.system.filePath(), + root: faker.system.directoryPath(), + } + } + + static generateCustomId( + options?: Partial<{ + prefix: string + userId: string + ctx: { + dataStoreId?: string | undefined + data?: string[] | undefined + } + }>, + ) { + return buildCustomId( + options?.prefix ?? faker.lorem.word({ length: { min: 0, max: 10 } }), + options?.userId ?? faker.number.int().toString(), + { + data: options?.ctx?.data ?? undefined, + dataStoreId: options?.ctx?.dataStoreId ?? undefined, + }, + ) + } + + static generateInteraction( + options?: DeepPartial, + ): Command.ChatInputCommandInteraction { + return autoMock({ + ...createBaseInteraction(), + user: options?.user ?? FakerGenerator.generateUser(), + commandType: ApplicationCommandType.ChatInput, + type: InteractionType.ApplicationCommand, + command: options?.command ?? FakerGenerator.generateCommand(), + commandId: faker.string.uuid(), + commandName: faker.lorem.words(2), + commandGuildId: faker.string.uuid(), + deferred: faker.datatype.boolean(), + ephemeral: faker.datatype.boolean(), + replied: faker.datatype.boolean(), + ...(options as any), + }) + } + + static generateModalSubmitInteraction( + options?: DeepPartial, + ): ModalSubmitInteraction { + return autoMock({ + ...createBaseInteraction(), + user: options?.user ?? FakerGenerator.generateUser(), + type: InteractionType.ModalSubmit, + customId: faker.lorem.slug(), + deferred: faker.datatype.boolean(), + ephemeral: faker.datatype.boolean(), + replied: faker.datatype.boolean(), + ...(options as any), + }) + } + + static generateButtonInteraction(options?: DeepPartial): ButtonInteraction { + return autoMock({ + ...createBaseInteraction(), + user: options?.user ?? FakerGenerator.generateUser(), + type: InteractionType.MessageComponent, + customId: faker.lorem.slug(), + deferred: faker.datatype.boolean(), + ephemeral: faker.datatype.boolean(), + replied: faker.datatype.boolean(), + ...(options as any), + }) + } + + static withSubcommand( + interaction: T, + subcommand: string, + ): T { + interaction.options.getSubcommand = jest.fn().mockReturnValue(subcommand) + interaction.options.getSubcommandGroup = jest.fn().mockReturnValue(null) + + return interaction + } + + static generateCommand(options?: DeepPartial>): ApplicationCommand<{}> { + return autoMock>({ + ...createBaseEntity(), + applicationId: faker.string.uuid(), + guildId: faker.string.uuid(), + type: ApplicationCommandType.ChatInput, + version: `v${faker.number.int({ min: 1, max: 100 })}`, + client: container.client as any, + defaultMemberPermissions: new PermissionsBitField(PermissionsBitField.Flags.SendMessages), + description: faker.lorem.sentence(), + ...(options as any), + }) + } + + static generateUser(options?: DeepPartial): User { + const userId = options?.id ?? faker.string.uuid() + const username = options?.username ?? faker.internet.username() + + return autoMock({ + ...createBaseEntity(), + id: userId, + username, + discriminator: faker.string.numeric(4), + bot: faker.datatype.boolean(), + system: false, + displayName: username, + defaultAvatarURL: faker.internet.url(), + tag: `${username}#${faker.string.numeric(4)}`, + client: container.client as any, + avatarURL: () => faker.internet.url(), + displayAvatarURL: () => faker.internet.url(), + toString: () => `<@${userId}>`, + ...(options as any), + }) + } + + static generatePaginationData(options?: DeepPartial): PaginationStore { + return autoMock({ + handleSetPage: + options?.handleSetPage ?? mock(async (state: any) => ({ embed: {}, buttonsRow: {} })), + state: { + pageSize: options?.state?.pageSize ?? 10, + totalPages: options?.state?.totalPages ?? 5, + currentPage: options?.state?.currentPage ?? 1, + ...(options?.state as any), + }, + ...(options as any), + }) + } + + static generateOsuUser(options?: Partial): UserResponse { + return { + user_id: faker.number.int({ min: 1, max: 1000000 }), + username: faker.internet.username(), + country_code: faker.location.countryCode(), + avatar_url: "https://placehold.co/400x400", + banner_url: "https://placehold.co/1200x300", + register_date: new Date().toISOString(), + last_online_time: new Date().toISOString(), + restricted: false, + silenced_until: null, + default_gamemode: GameMode.STANDARD, + badges: [], + user_status: "online", + description: null, + ...options, + } + } + + static generateScore(options?: Partial): ScoreResponse { + const mockUser = options?.user ?? FakerGenerator.generateOsuUser() + + return { + id: faker.number.int({ min: 1, max: 1000000 }), + beatmap_id: faker.number.int({ min: 1, max: 1000000 }), + user_id: mockUser.user_id, + user: mockUser, + total_score: faker.number.int({ min: 1000000, max: 100000000 }), + max_combo: faker.number.int({ min: 100, max: 2000 }), + count_300: faker.number.int({ min: 100, max: 1000 }), + count_100: faker.number.int({ min: 10, max: 100 }), + count_50: faker.number.int({ min: 0, max: 50 }), + count_miss: faker.number.int({ min: 0, max: 10 }), + count_geki: faker.number.int({ min: 0, max: 100 }), + count_katu: faker.number.int({ min: 0, max: 100 }), + performance_points: faker.number.float({ min: 100, max: 1000 }), + grade: ["S", "A", "B", "C", "D", "F"][faker.number.int({ min: 0, max: 5 })] as string, + accuracy: faker.number.float({ min: 90, max: 100 }), + game_mode: GameMode.STANDARD, + game_mode_extended: GameMode.STANDARD, + is_passed: true, + has_replay: true, + is_perfect: false, + when_played: new Date().toISOString(), + mods: null, + mods_int: 0, + leaderboard_rank: null, + ...options, + } + } + + static generateBeatmap(options?: Partial): BeatmapResponse { + return { + id: faker.number.int({ min: 1, max: 1000000 }), + beatmapset_id: faker.number.int({ min: 1, max: 100000 }), + hash: faker.string.alphanumeric(32), + version: faker.word.adjective(), + status: BeatmapStatusWeb.RANKED, + star_rating_osu: faker.number.float({ min: 1, max: 10 }), + star_rating_taiko: faker.number.float({ min: 1, max: 10 }), + star_rating_ctb: faker.number.float({ min: 1, max: 10 }), + star_rating_mania: faker.number.float({ min: 1, max: 10 }), + total_length: faker.number.int({ min: 60, max: 600 }), + max_combo: faker.number.int({ min: 100, max: 2000 }), + accuracy: faker.number.float({ min: 1, max: 10 }), + ar: faker.number.float({ min: 1, max: 10 }), + bpm: faker.number.float({ min: 80, max: 200 }), + convert: false, + count_circles: faker.number.int({ min: 100, max: 1000 }), + count_sliders: faker.number.int({ min: 50, max: 500 }), + count_spinners: faker.number.int({ min: 0, max: 10 }), + cs: faker.number.float({ min: 1, max: 10 }), + deleted_at: null, + drain: faker.number.float({ min: 1, max: 10 }), + hit_length: faker.number.int({ min: 60, max: 600 }), + is_scoreable: true, + is_ranked: true, + last_updated: new Date().toISOString(), + mode_int: 0, + mode: GameMode.STANDARD, + ranked: 1, + title: faker.music.songName(), + artist: faker.person.fullName(), + creator: faker.internet.username(), + creator_id: faker.number.int({ min: 1, max: 100000 }), + beatmap_nominator_user: undefined, + ...options, + } + } + + static generateUserStats(options?: Partial): UserStatsResponse { + const userId = options?.user_id ?? faker.number.int({ min: 1, max: 1000000 }) + + return { + user_id: userId, + gamemode: GameMode.STANDARD, + accuracy: faker.number.float({ min: 85, max: 100 }), + total_score: faker.number.int({ min: 1000000, max: 1000000000 }), + ranked_score: faker.number.int({ min: 1000000, max: 100000000 }), + play_count: faker.number.int({ min: 100, max: 10000 }), + pp: faker.number.float({ min: 1000, max: 10000 }), + rank: faker.number.int({ min: 1, max: 100000 }), + country_rank: faker.number.int({ min: 1, max: 10000 }), + max_combo: faker.number.int({ min: 500, max: 5000 }), + play_time: faker.number.int({ min: 10000, max: 1000000 }), + total_hits: faker.number.int({ min: 10000, max: 1000000 }), + best_global_rank: faker.number.int({ min: 1, max: 50000 }), + best_global_rank_date: new Date().toISOString(), + best_country_rank: faker.number.int({ min: 1, max: 5000 }), + best_country_rank_date: new Date().toISOString(), + ...options, + } + } + + static generateUserWithStats(options?: { + user?: Partial + stats?: Partial + }): UserWithStats { + const user = FakerGenerator.generateOsuUser(options?.user) + const stats = FakerGenerator.generateUserStats({ + user_id: user.user_id, + ...options?.stats, + }) + + return { + user, + stats, + } + } +} diff --git a/src/lib/mock/mocker.ts b/src/lib/mock/mocker.ts new file mode 100644 index 0000000..c5c7fa2 --- /dev/null +++ b/src/lib/mock/mocker.ts @@ -0,0 +1,120 @@ +import { jest, mock } from "bun:test" +import { Database } from "bun:sqlite" +import { getMigrations, migrate } from "bun-sqlite-migrations" +import path from "path" + +import { IntentsBitField } from "discord.js" +import { SapphireClient, LogLevel, container, Command, Events } from "@sapphire/framework" +import { SubcommandPluginEvents } from "@sapphire/plugin-subcommands" +import { UtilitiesStore } from "@sapphire/plugin-utilities-store" +import { faker } from "@faker-js/faker" + +import { ActionStoreUtility } from "../../utilities/action-store.utility" +import { EmbedPresetsUtility } from "../../utilities/embed-presets.utility" +import { PaginationUtility } from "../../utilities/pagination.utility" +import { FakerGenerator } from "./faker.generator" +import { config } from "../configs/env" + +export class Mocker { + static createSapphireClientInstance() { + const client = new SapphireClient({ + intents: new IntentsBitField().add(IntentsBitField.Flags.Guilds), + logger: { level: LogLevel.Debug }, + }) + + container.client = client + + container.logger = { + trace: mock(), + write: mock(), + fatal: mock(), + debug: mock(), + info: mock(), + warn: mock(), + error: mock(), + has: mock((level: LogLevel) => level === LogLevel.Debug), + } + + const store = new UtilitiesStore() + container.utilities = { + ...container.utilities, + store, + actionStore: new ActionStoreUtility(FakerGenerator.generatePiece()), + embedPresets: new EmbedPresetsUtility(FakerGenerator.generatePiece(), {}), + pagination: new PaginationUtility(FakerGenerator.generatePiece(), {}), + exposePiece(name, piece) { + store.set(name, piece) + }, + } + + container.config = { + ...config, + sunrise: { uri: "sunrise.example.com" }, + } + + this.createDatabaseInMemory() + } + + static async resetSapphireClientInstance() { + await container.client.destroy() + container.db.close() + this.createDatabaseInMemory() + } + + static beforeEachCleanup(errorHandler: jest.Mock) { + errorHandler.mockClear() + Mocker.resetDatabase() + } + + private static resetDatabase() { + const tables = container.db + .query("SELECT name FROM sqlite_master WHERE type='table'") + .all() as { name: string }[] + + for (const table of tables) { + if (table.name !== "sqlite_sequence") { + container.db.exec(`DELETE FROM ${table.name}`) + } + } + } + + static createCommandInstance(CommandClass: new (...args: any[]) => T): T { + return new CommandClass( + { + name: faker.word.adjective(), + path: faker.system.filePath(), + root: faker.system.directoryPath(), + store: container.utilities.store, + }, + {}, + ) + } + + static createErrorHandler() { + const errorHandler = mock() + container.client.on(Events.ChatInputCommandError, errorHandler) + container.client.on(SubcommandPluginEvents.ChatInputSubcommandError, errorHandler) + return errorHandler + } + + static mockApiRequest( + mockedEndpointMethod: keyof T, + implementation: (...args: any[]) => Promise, + ) { + mock.module(path.resolve(process.cwd(), "src", "lib", "types", "api"), () => ({ + [mockedEndpointMethod]: implementation, + })) + } + + static mockApiRequests Promise>>(mocks: T) { + mock.module(path.resolve(process.cwd(), "src", "lib", "types", "api"), () => mocks) + } + + private static createDatabaseInMemory() { + if (!container) { + throw new Error("Container is not initialized") + } + container.db = new Database(":memory:") + migrate(container.db, getMigrations(path.resolve(process.cwd(), "data", "migrations"))) + } +} diff --git a/src/lib/utils/discord.util.ts b/src/lib/utils/discord.util.ts index 72f1749..45583ba 100644 --- a/src/lib/utils/discord.util.ts +++ b/src/lib/utils/discord.util.ts @@ -6,7 +6,7 @@ export function buildCustomId( data?: string[] | undefined }, ): string { - const result = `${prefix}:${userId}:${ctx.dataStoreId}:${ctx.data?.join(",")}` + const result = `${prefix}:${userId}:${ctx.dataStoreId}:${ctx.data?.join(",") ?? ""}` if (result.length > 100) { throw new Error("Custom IDs can only have a maximum length of 100") } diff --git a/src/subcommands/osu/tests/link.subcommand.test.ts b/src/subcommands/osu/tests/link.subcommand.test.ts new file mode 100644 index 0000000..cf6c17c --- /dev/null +++ b/src/subcommands/osu/tests/link.subcommand.test.ts @@ -0,0 +1,109 @@ +import { expect, describe, it, beforeAll, afterAll, jest, mock, beforeEach } from "bun:test" +import { container } from "@sapphire/framework" +import { OsuCommand } from "../../../commands/osu.command" +import { Mocker } from "../../../lib/mock/mocker" +import { FakerGenerator } from "../../../lib/mock/faker.generator" +import { faker } from "@faker-js/faker" +import { ExtendedError } from "../../../lib/extended-error" + +describe("Osu Link Subcommand", () => { + let osuCommand: OsuCommand + let errorHandler: jest.Mock + + beforeAll(() => { + Mocker.createSapphireClientInstance() + osuCommand = Mocker.createCommandInstance(OsuCommand) + errorHandler = Mocker.createErrorHandler() + }) + + afterAll(async () => { + await Mocker.resetSapphireClientInstance() + }) + + beforeEach(() => Mocker.beforeEachCleanup(errorHandler)) + + it("should reply with success message when link is successful", async () => { + const editReplyMock = mock() + const username = faker.internet.username() + + const interaction = FakerGenerator.withSubcommand( + FakerGenerator.generateInteraction({ + deferReply: mock(), + editReply: editReplyMock, + options: { + getString: jest.fn().mockReturnValue(username), + }, + }), + "link", + ) + + const osuUserId = faker.number.int({ min: 1, max: 1000000 }) + + Mocker.mockApiRequest("getUserSearch", async () => ({ + data: [{ user_id: osuUserId, username: 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 () => { + const username = faker.internet.username() + + const interaction = FakerGenerator.withSubcommand( + FakerGenerator.generateInteraction({ + deferReply: mock(), + editReply: mock(), + options: { + getString: jest.fn().mockReturnValue(username), + }, + }), + "link", + ) + + Mocker.mockApiRequest("getUserSearch", async () => ({ + error: "User 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() + }) +}) diff --git a/src/subcommands/osu/tests/profile.subcommand.test.ts b/src/subcommands/osu/tests/profile.subcommand.test.ts new file mode 100644 index 0000000..22130ec --- /dev/null +++ b/src/subcommands/osu/tests/profile.subcommand.test.ts @@ -0,0 +1,473 @@ +import { expect, describe, it, beforeAll, afterAll, beforeEach, jest, mock } from "bun:test" +import { container } from "@sapphire/framework" +import { OsuCommand } from "../../../commands/osu.command" +import { Mocker } from "../../../lib/mock/mocker" +import { FakerGenerator } from "../../../lib/mock/faker.generator" +import { faker } from "@faker-js/faker" +import { GameMode } from "../../../lib/types/api" + +describe("Osu Profile Subcommand", () => { + let osuCommand: OsuCommand + let errorHandler: jest.Mock + + beforeAll(() => { + Mocker.createSapphireClientInstance() + osuCommand = Mocker.createCommandInstance(OsuCommand) + errorHandler = Mocker.createErrorHandler() + }) + + afterAll(async () => { + await Mocker.resetSapphireClientInstance() + }) + + beforeEach(() => Mocker.beforeEachCleanup(errorHandler)) + + it("should display profile when username is provided", async () => { + const editReplyMock = mock() + const username = faker.internet.username() + const userWithStats = FakerGenerator.generateUserWithStats({ + user: { username }, + }) + + const interaction = FakerGenerator.withSubcommand( + FakerGenerator.generateInteraction({ + deferReply: mock(), + editReply: editReplyMock, + options: { + getString: jest.fn((name: string) => (name === "username" ? username : null)), + getNumber: jest.fn().mockReturnValue(null), + getUser: jest.fn().mockReturnValue(null), + }, + }), + "profile", + ) + + Mocker.mockApiRequests({ + getUserSearch: async () => ({ + data: [{ user_id: userWithStats.user.user_id, username: username }], + }), + getUserByIdByMode: async () => ({ + data: userWithStats, + }), + getUserByIdScores: async () => ({ + data: { scores: [], total_count: 0 }, + }), + getUserByIdGrades: async () => ({ + data: { SS: 0, S: 0, A: 0, B: 0, C: 0, D: 0 }, + }), + }) + + await osuCommand.chatInputRun(interaction, { + commandId: faker.string.uuid(), + commandName: "profile", + }) + + expect(errorHandler).not.toBeCalled() + + const userEmbed = await osuCommand.container.utilities.embedPresets.getUserEmbed( + userWithStats.user, + userWithStats.stats, + ) + + expect(editReplyMock).toHaveBeenCalledWith({ + embeds: [ + expect.objectContaining({ + data: userEmbed.data, + }), + ], + }) + }) + + it("should display profile when user ID is provided", async () => { + const editReplyMock = mock() + const userId = faker.number.int({ min: 1, max: 1000000 }) + const userWithStats = FakerGenerator.generateUserWithStats({ + user: { user_id: userId }, + }) + + const interaction = FakerGenerator.withSubcommand( + FakerGenerator.generateInteraction({ + deferReply: mock(), + editReply: editReplyMock, + options: { + getString: jest.fn().mockReturnValue(null), + getNumber: jest.fn().mockReturnValue(userId), + getUser: jest.fn().mockReturnValue(null), + }, + }), + "profile", + ) + + Mocker.mockApiRequests({ + getUserByIdByMode: async () => ({ + data: userWithStats, + }), + getUserByIdScores: async () => ({ + data: { scores: [], total_count: 0 }, + }), + getUserByIdGrades: async () => ({ + data: { SS: 0, S: 0, A: 0, B: 0, C: 0, D: 0 }, + }), + }) + + await osuCommand.chatInputRun(interaction, { + commandId: faker.string.uuid(), + commandName: "profile", + }) + + expect(errorHandler).not.toBeCalled() + + const userEmbed = await osuCommand.container.utilities.embedPresets.getUserEmbed( + userWithStats.user, + userWithStats.stats, + ) + + expect(editReplyMock).toHaveBeenCalledWith({ + embeds: [ + expect.objectContaining({ + data: userEmbed.data, + }), + ], + }) + }) + + it("should display profile when Discord user is provided (linked account)", async () => { + const editReplyMock = mock() + const discordUser = FakerGenerator.generateUser() + const osuUserId = faker.number.int({ min: 1, max: 1000000 }) + const userWithStats = FakerGenerator.generateUserWithStats({ + user: { user_id: osuUserId }, + }) + + const interaction = FakerGenerator.withSubcommand( + FakerGenerator.generateInteraction({ + deferReply: mock(), + editReply: editReplyMock, + options: { + getString: jest.fn().mockReturnValue(null), + getNumber: jest.fn().mockReturnValue(null), + getUser: jest.fn().mockReturnValue(discordUser), + }, + }), + "profile", + ) + + const { db } = container + const insertUser = db.prepare( + "INSERT INTO connections (discord_user_id, osu_user_id) VALUES ($1, $2)", + ) + insertUser.run({ $1: discordUser.id, $2: osuUserId.toString() }) + + Mocker.mockApiRequests({ + getUserByIdByMode: async () => ({ + data: userWithStats, + }), + getUserByIdScores: async () => ({ + data: { scores: [], total_count: 0 }, + }), + getUserByIdGrades: async () => ({ + data: { SS: 0, S: 0, A: 0, B: 0, C: 0, D: 0 }, + }), + }) + + await osuCommand.chatInputRun(interaction, { + commandId: faker.string.uuid(), + commandName: "profile", + }) + + expect(errorHandler).not.toBeCalled() + + const userEmbed = await osuCommand.container.utilities.embedPresets.getUserEmbed( + userWithStats.user, + userWithStats.stats, + ) + + expect(editReplyMock).toHaveBeenCalledWith({ + embeds: [ + expect.objectContaining({ + data: userEmbed.data, + }), + ], + }) + }) + + it("should display profile for current user (no options, linked account)", async () => { + const editReplyMock = mock() + const currentUser = FakerGenerator.generateUser() + const osuUserId = faker.number.int({ min: 1, max: 1000000 }) + const userWithStats = FakerGenerator.generateUserWithStats({ + user: { user_id: osuUserId }, + }) + + const interaction = FakerGenerator.withSubcommand( + FakerGenerator.generateInteraction({ + deferReply: mock(), + editReply: editReplyMock, + user: currentUser, + options: { + getString: jest.fn().mockReturnValue(null), + getNumber: jest.fn().mockReturnValue(null), + getUser: jest.fn().mockReturnValue(null), + }, + }), + "profile", + ) + + const { db } = container + const insertStmt = db.prepare( + "INSERT INTO connections (discord_user_id, osu_user_id) VALUES ($1, $2)", + ) + insertStmt.run({ $1: currentUser.id, $2: osuUserId.toString() }) + + Mocker.mockApiRequests({ + getUserByIdByMode: async () => ({ + data: userWithStats, + }), + getUserByIdScores: async () => ({ + data: { scores: [], total_count: 0 }, + }), + getUserByIdGrades: async () => ({ + data: { SS: 0, S: 0, A: 0, B: 0, C: 0, D: 0 }, + }), + }) + + await osuCommand.chatInputRun(interaction, { + commandId: faker.string.uuid(), + commandName: "profile", + }) + + expect(errorHandler).not.toBeCalled() + + const userEmbed = await osuCommand.container.utilities.embedPresets.getUserEmbed( + userWithStats.user, + userWithStats.stats, + ) + + expect(editReplyMock).toHaveBeenCalledWith({ + embeds: [ + expect.objectContaining({ + data: userEmbed.data, + }), + ], + }) + }) + + it("should display profile with specific gamemode", async () => { + const editReplyMock = mock() + const userId = faker.number.int({ min: 1, max: 1000000 }) + const gamemode = GameMode.TAIKO + const userWithStats = FakerGenerator.generateUserWithStats({ + user: { user_id: userId }, + stats: { gamemode: gamemode }, + }) + + const interaction = FakerGenerator.withSubcommand( + FakerGenerator.generateInteraction({ + deferReply: mock(), + editReply: editReplyMock, + options: { + getString: jest.fn((name: string) => (name === "gamemode" ? gamemode : null)), + getNumber: jest.fn().mockReturnValue(userId), + getUser: jest.fn().mockReturnValue(null), + }, + }), + "profile", + ) + + Mocker.mockApiRequests({ + getUserByIdByMode: async () => ({ + data: userWithStats, + }), + getUserByIdScores: async () => ({ + data: { scores: [], total_count: 0 }, + }), + getUserByIdGrades: async () => ({ + data: { SS: 0, S: 0, A: 0, B: 0, C: 0, D: 0 }, + }), + }) + + await osuCommand.chatInputRun(interaction, { + commandId: faker.string.uuid(), + commandName: "profile", + }) + + expect(errorHandler).not.toBeCalled() + + const userEmbed = await osuCommand.container.utilities.embedPresets.getUserEmbed( + userWithStats.user, + userWithStats.stats, + ) + + expect(editReplyMock).toHaveBeenCalledWith({ + embeds: [ + expect.objectContaining({ + data: userEmbed.data, + }), + ], + }) + }) + + it("should throw error when username is not found", 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)), + getNumber: jest.fn().mockReturnValue(null), + getUser: jest.fn().mockReturnValue(null), + }, + }), + "profile", + ) + + Mocker.mockApiRequests({ + getUserSearch: async () => ({ + data: [], + }), + }) + + await osuCommand.chatInputRun(interaction, { + commandId: faker.string.uuid(), + commandName: "profile", + }) + + expect(errorHandler).toHaveBeenCalledWith( + expect.objectContaining({ + message: "❓ I couldn't find user with such username", + }), + expect.anything(), + ) + }) + + it("should throw error when user ID is not found", async () => { + const userId = faker.number.int({ min: 1, max: 1000000 }) + + const interaction = FakerGenerator.withSubcommand( + FakerGenerator.generateInteraction({ + deferReply: mock(), + editReply: mock(), + options: { + getString: jest.fn().mockReturnValue(null), + getNumber: jest.fn().mockReturnValue(userId), + getUser: jest.fn().mockReturnValue(null), + }, + }), + "profile", + ) + + Mocker.mockApiRequests({ + getUserByIdByMode: async () => ({ + error: { error: "User not found" }, + }), + }) + + await osuCommand.chatInputRun(interaction, { + commandId: faker.string.uuid(), + commandName: "profile", + }) + + expect(errorHandler).toHaveBeenCalledWith( + expect.objectContaining({ + message: "User not found", + }), + expect.anything(), + ) + }) + + it("should throw error when Discord user has no linked account", async () => { + const discordUser = FakerGenerator.generateUser() + + const interaction = FakerGenerator.withSubcommand( + FakerGenerator.generateInteraction({ + deferReply: mock(), + editReply: mock(), + options: { + getString: jest.fn().mockReturnValue(null), + getNumber: jest.fn().mockReturnValue(null), + getUser: jest.fn().mockReturnValue(discordUser), + }, + }), + "profile", + ) + + await osuCommand.chatInputRun(interaction, { + commandId: faker.string.uuid(), + commandName: "profile", + }) + + expect(errorHandler).toHaveBeenCalledWith( + expect.objectContaining({ + message: "❓ Provided user didn't link their osu!sunrise account", + }), + expect.anything(), + ) + }) + + it("should throw error when current user has no linked account", async () => { + const currentUser = FakerGenerator.generateUser() + + const interaction = FakerGenerator.withSubcommand( + FakerGenerator.generateInteraction({ + deferReply: mock(), + editReply: mock(), + user: currentUser, + options: { + getString: jest.fn().mockReturnValue(null), + getNumber: jest.fn().mockReturnValue(null), + getUser: jest.fn().mockReturnValue(null), + }, + }), + "profile", + ) + + await osuCommand.chatInputRun(interaction, { + commandId: faker.string.uuid(), + commandName: "profile", + }) + + expect(errorHandler).toHaveBeenCalledWith( + expect.objectContaining({ + message: "❓ Provided user didn't link their osu!sunrise account", + }), + expect.anything(), + ) + }) + + it("should throw error when username search API 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)), + getNumber: jest.fn().mockReturnValue(null), + getUser: jest.fn().mockReturnValue(null), + }, + }), + "profile", + ) + + Mocker.mockApiRequests({ + getUserSearch: async () => ({ + error: { error: "API Error" }, + }), + }) + + await osuCommand.chatInputRun(interaction, { + commandId: faker.string.uuid(), + commandName: "profile", + }) + + expect(errorHandler).toHaveBeenCalledWith( + expect.objectContaining({ + message: "API Error", + }), + expect.anything(), + ) + }) +}) diff --git a/src/subcommands/osu/tests/recent-score.subcommand.test.ts b/src/subcommands/osu/tests/recent-score.subcommand.test.ts new file mode 100644 index 0000000..b6b4560 --- /dev/null +++ b/src/subcommands/osu/tests/recent-score.subcommand.test.ts @@ -0,0 +1,530 @@ +import { expect, describe, it, beforeAll, afterAll, beforeEach, jest, mock } from "bun:test" +import { container } from "@sapphire/framework" +import { OsuCommand } from "../../../commands/osu.command" +import { Mocker } from "../../../lib/mock/mocker" +import { FakerGenerator } from "../../../lib/mock/faker.generator" +import { faker } from "@faker-js/faker" +import { GameMode } from "../../../lib/types/api" +import { ButtonStyle } from "discord.js" + +describe("Osu Recent Score Subcommand", () => { + let osuCommand: OsuCommand + let errorHandler: jest.Mock + + beforeAll(() => { + Mocker.createSapphireClientInstance() + osuCommand = Mocker.createCommandInstance(OsuCommand) + errorHandler = Mocker.createErrorHandler() + }) + + afterAll(async () => { + await Mocker.resetSapphireClientInstance() + }) + + beforeEach(() => Mocker.beforeEachCleanup(errorHandler)) + + it("should display recent score when username is provided", async () => { + const editReplyMock = mock() + const username = faker.internet.username() + const userId = faker.number.int({ min: 1, max: 1000000 }) + + const mockScore = FakerGenerator.generateScore() + const mockBeatmap = FakerGenerator.generateBeatmap({ id: mockScore.beatmap_id }) + + const interaction = FakerGenerator.withSubcommand( + FakerGenerator.generateInteraction({ + deferReply: mock(), + editReply: editReplyMock, + options: { + getString: jest.fn((name: string) => (name === "username" ? username : null)), + getUser: jest.fn().mockReturnValue(null), + }, + }), + "rs", + ) + + Mocker.mockApiRequests({ + getUserSearch: async () => ({ + data: [{ user_id: userId, username: username }], + }), + getUserByIdScores: async () => ({ + data: { scores: [mockScore], total_count: 1 }, + }), + getBeatmapById: async () => ({ + data: mockBeatmap, + }), + }) + + await osuCommand.chatInputRun(interaction, { + commandId: faker.string.uuid(), + commandName: "rs", + }) + + expect(errorHandler).not.toBeCalled() + + const scoreEmbed = await osuCommand.container.utilities.embedPresets.getScoreEmbed( + mockScore, + mockBeatmap, + ) + + expect(editReplyMock).toHaveBeenCalledWith({ + embeds: [ + expect.objectContaining({ + data: scoreEmbed.data, + }), + ], + components: [ + expect.objectContaining({ + components: [ + expect.objectContaining({ + data: expect.objectContaining({ + style: ButtonStyle.Link, + label: "View score online", + }), + }), + ], + }), + ], + }) + }) + + it("should display recent score when Discord user is provided (linked account)", async () => { + const editReplyMock = mock() + const discordUser = FakerGenerator.generateUser() + const osuUserId = faker.number.int({ min: 1, max: 1000000 }) + + const mockScore = FakerGenerator.generateScore() + const mockBeatmap = FakerGenerator.generateBeatmap({ id: mockScore.beatmap_id }) + + const interaction = FakerGenerator.withSubcommand( + FakerGenerator.generateInteraction({ + deferReply: mock(), + editReply: editReplyMock, + options: { + getString: jest.fn().mockReturnValue(null), + getUser: jest.fn().mockReturnValue(discordUser), + }, + }), + "rs", + ) + + const { db } = container + const insertUser = db.prepare( + "INSERT INTO connections (discord_user_id, osu_user_id) VALUES ($1, $2)", + ) + insertUser.run({ $1: discordUser.id, $2: osuUserId.toString() }) + + Mocker.mockApiRequests({ + getUserByIdScores: async () => ({ + data: { scores: [mockScore], total_count: 1 }, + }), + getBeatmapById: async () => ({ + data: mockBeatmap, + }), + }) + + await osuCommand.chatInputRun(interaction, { + commandId: faker.string.uuid(), + commandName: "rs", + }) + + expect(errorHandler).not.toBeCalled() + + const scoreEmbed = await osuCommand.container.utilities.embedPresets.getScoreEmbed( + mockScore, + mockBeatmap, + ) + + expect(editReplyMock).toHaveBeenCalledWith({ + embeds: [ + expect.objectContaining({ + data: scoreEmbed.data, + }), + ], + components: [ + expect.objectContaining({ + components: [ + expect.objectContaining({ + data: expect.objectContaining({ + style: ButtonStyle.Link, + label: "View score online", + }), + }), + ], + }), + ], + }) + }) + + it("should display recent score for current user (no options, linked account)", async () => { + const editReplyMock = mock() + const currentUser = FakerGenerator.generateUser() + const osuUserId = faker.number.int({ min: 1, max: 1000000 }) + + const mockScore = FakerGenerator.generateScore() + const mockBeatmap = FakerGenerator.generateBeatmap({ id: mockScore.beatmap_id }) + + const interaction = FakerGenerator.withSubcommand( + FakerGenerator.generateInteraction({ + deferReply: mock(), + editReply: editReplyMock, + user: currentUser, + options: { + getString: jest.fn().mockReturnValue(null), + getUser: jest.fn().mockReturnValue(null), + }, + }), + "rs", + ) + + const { db } = container + const insertUser = db.prepare( + "INSERT INTO connections (discord_user_id, osu_user_id) VALUES ($1, $2)", + ) + insertUser.run({ $1: currentUser.id, $2: osuUserId.toString() }) + + Mocker.mockApiRequests({ + getUserByIdScores: async () => ({ + data: { scores: [mockScore], total_count: 1 }, + }), + getBeatmapById: async () => ({ + data: mockBeatmap, + }), + }) + + await osuCommand.chatInputRun(interaction, { + commandId: faker.string.uuid(), + commandName: "rs", + }) + + expect(errorHandler).not.toBeCalled() + + const scoreEmbed = await osuCommand.container.utilities.embedPresets.getScoreEmbed( + mockScore, + mockBeatmap, + ) + + expect(editReplyMock).toHaveBeenCalledWith({ + embeds: [ + expect.objectContaining({ + data: scoreEmbed.data, + }), + ], + components: [ + expect.objectContaining({ + components: [ + expect.objectContaining({ + data: expect.objectContaining({ + style: ButtonStyle.Link, + label: "View score online", + }), + }), + ], + }), + ], + }) + }) + + it("should display recent score with specific gamemode", async () => { + const editReplyMock = mock() + const username = faker.internet.username() + const userId = faker.number.int({ min: 1, max: 1000000 }) + const gamemode = GameMode.TAIKO + + const mockScore = FakerGenerator.generateScore({ game_mode: gamemode }) + const mockBeatmap = FakerGenerator.generateBeatmap({ id: mockScore.beatmap_id }) + + const interaction = FakerGenerator.withSubcommand( + FakerGenerator.generateInteraction({ + deferReply: mock(), + editReply: editReplyMock, + options: { + getString: jest.fn((name: string) => { + if (name === "username") return username + if (name === "gamemode") return gamemode + return null + }), + getUser: jest.fn().mockReturnValue(null), + }, + }), + "rs", + ) + + Mocker.mockApiRequests({ + getUserSearch: async () => ({ + data: [{ user_id: userId, username: username }], + }), + getUserByIdScores: async () => ({ + data: { scores: [mockScore], total_count: 1 }, + }), + getBeatmapById: async () => ({ + data: mockBeatmap, + }), + }) + + await osuCommand.chatInputRun(interaction, { + commandId: faker.string.uuid(), + commandName: "rs", + }) + + expect(errorHandler).not.toBeCalled() + + const scoreEmbed = await osuCommand.container.utilities.embedPresets.getScoreEmbed( + mockScore, + mockBeatmap, + ) + + expect(editReplyMock).toHaveBeenCalledWith({ + embeds: [ + expect.objectContaining({ + data: scoreEmbed.data, + }), + ], + components: expect.anything(), + }) + }) + + it("should throw error when username is not found", 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)), + getUser: jest.fn().mockReturnValue(null), + }, + }), + "rs", + ) + + Mocker.mockApiRequests({ + getUserSearch: async () => ({ + data: [], + }), + }) + + await osuCommand.chatInputRun(interaction, { + commandId: faker.string.uuid(), + commandName: "rs", + }) + + expect(errorHandler).toHaveBeenCalledWith( + expect.objectContaining({ + message: "❓ I couldn't find user with such username", + }), + expect.anything(), + ) + }) + + it("should throw error when Discord user has no linked account", async () => { + const discordUser = FakerGenerator.generateUser() + + const interaction = FakerGenerator.withSubcommand( + FakerGenerator.generateInteraction({ + deferReply: mock(), + editReply: mock(), + options: { + getString: jest.fn().mockReturnValue(null), + getUser: jest.fn().mockReturnValue(discordUser), + }, + }), + "rs", + ) + + await osuCommand.chatInputRun(interaction, { + commandId: faker.string.uuid(), + commandName: "rs", + }) + + expect(errorHandler).toHaveBeenCalledWith( + expect.objectContaining({ + message: "❓ Provided user didn't link their osu!sunrise account", + }), + expect.anything(), + ) + }) + + it("should throw error when current user has no linked account", async () => { + const currentUser = FakerGenerator.generateUser() + + const interaction = FakerGenerator.withSubcommand( + FakerGenerator.generateInteraction({ + deferReply: mock(), + editReply: mock(), + user: currentUser, + options: { + getString: jest.fn().mockReturnValue(null), + getUser: jest.fn().mockReturnValue(null), + }, + }), + "rs", + ) + + await osuCommand.chatInputRun(interaction, { + commandId: faker.string.uuid(), + commandName: "rs", + }) + + expect(errorHandler).toHaveBeenCalledWith( + expect.objectContaining({ + message: "❓ Provided user didn't link their osu!sunrise account", + }), + expect.anything(), + ) + }) + + it("should throw error when user has no recent scores", async () => { + const username = faker.internet.username() + const userId = faker.number.int({ min: 1, max: 1000000 }) + + const interaction = FakerGenerator.withSubcommand( + FakerGenerator.generateInteraction({ + deferReply: mock(), + editReply: mock(), + options: { + getString: jest.fn((name: string) => (name === "username" ? username : null)), + getUser: jest.fn().mockReturnValue(null), + }, + }), + "rs", + ) + + Mocker.mockApiRequests({ + getUserSearch: async () => ({ + data: [{ user_id: userId, username: username }], + }), + getUserByIdScores: async () => ({ + data: { scores: [], total_count: 0 }, + }), + }) + + await osuCommand.chatInputRun(interaction, { + commandId: faker.string.uuid(), + commandName: "rs", + }) + + expect(errorHandler).toHaveBeenCalledWith( + expect.objectContaining({ + message: "This user has no recent scores", + }), + expect.anything(), + ) + }) + + it("should throw error when beatmap is not found", async () => { + const username = faker.internet.username() + const userId = faker.number.int({ min: 1, max: 1000000 }) + + const mockScore = FakerGenerator.generateScore() + + const interaction = FakerGenerator.withSubcommand( + FakerGenerator.generateInteraction({ + deferReply: mock(), + editReply: mock(), + options: { + getString: jest.fn((name: string) => (name === "username" ? username : null)), + getUser: jest.fn().mockReturnValue(null), + }, + }), + "rs", + ) + + Mocker.mockApiRequests({ + getUserSearch: async () => ({ + data: [{ user_id: userId, username: username }], + }), + getUserByIdScores: async () => ({ + data: { scores: [mockScore], total_count: 1 }, + }), + getBeatmapById: async () => ({ + error: "Beatmap not found", + }), + }) + + await osuCommand.chatInputRun(interaction, { + commandId: faker.string.uuid(), + commandName: "rs", + }) + + expect(errorHandler).toHaveBeenCalledWith( + expect.objectContaining({ + message: "❓ I couldn't fetch score's beatmap data", + }), + expect.anything(), + ) + }) + + it("should throw error when username search API 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)), + getUser: jest.fn().mockReturnValue(null), + }, + }), + "rs", + ) + + Mocker.mockApiRequests({ + getUserSearch: async () => ({ + error: { error: "API Error" }, + }), + }) + + await osuCommand.chatInputRun(interaction, { + commandId: faker.string.uuid(), + commandName: "rs", + }) + + expect(errorHandler).toHaveBeenCalledWith( + expect.objectContaining({ + message: "API Error", + }), + expect.anything(), + ) + }) + + it("should throw error when recent scores API fails", async () => { + const username = faker.internet.username() + const userId = faker.number.int({ min: 1, max: 1000000 }) + + const interaction = FakerGenerator.withSubcommand( + FakerGenerator.generateInteraction({ + deferReply: mock(), + editReply: mock(), + options: { + getString: jest.fn((name: string) => (name === "username" ? username : null)), + getUser: jest.fn().mockReturnValue(null), + }, + }), + "rs", + ) + + Mocker.mockApiRequests({ + getUserSearch: async () => ({ + data: [{ user_id: userId, username: username }], + }), + getUserByIdScores: async () => ({ + error: { error: "Scores API Error" }, + }), + }) + + await osuCommand.chatInputRun(interaction, { + commandId: faker.string.uuid(), + commandName: "rs", + }) + + expect(errorHandler).toHaveBeenCalledWith( + expect.objectContaining({ + message: "Scores API Error", + }), + expect.anything(), + ) + }) +}) diff --git a/src/subcommands/osu/tests/score.subcommand.test.ts b/src/subcommands/osu/tests/score.subcommand.test.ts new file mode 100644 index 0000000..6c5cf7c --- /dev/null +++ b/src/subcommands/osu/tests/score.subcommand.test.ts @@ -0,0 +1,275 @@ +import { expect, describe, it, beforeAll, afterAll, beforeEach, jest, mock } from "bun:test" +import { container } from "@sapphire/framework" +import { OsuCommand } from "../../../commands/osu.command" +import { Mocker } from "../../../lib/mock/mocker" +import { FakerGenerator } from "../../../lib/mock/faker.generator" +import { faker } from "@faker-js/faker" +import { ButtonStyle } from "discord.js" + +describe("Osu Score Subcommand", () => { + let osuCommand: OsuCommand + let errorHandler: jest.Mock + + beforeAll(() => { + Mocker.createSapphireClientInstance() + osuCommand = Mocker.createCommandInstance(OsuCommand) + errorHandler = Mocker.createErrorHandler() + }) + + afterAll(async () => { + await Mocker.resetSapphireClientInstance() + }) + + beforeEach(() => Mocker.beforeEachCleanup(errorHandler)) + + it("should display score embed when score ID is provided", async () => { + const editReplyMock = mock() + const scoreId = faker.number.int({ min: 1, max: 1000000 }) + + const mockScore = FakerGenerator.generateScore({ id: scoreId }) + const mockBeatmap = FakerGenerator.generateBeatmap({ id: mockScore.beatmap_id }) + + const interaction = FakerGenerator.withSubcommand( + FakerGenerator.generateInteraction({ + deferReply: mock(), + editReply: editReplyMock, + options: { + getString: jest.fn().mockReturnValue(scoreId.toString()), + }, + }), + "score", + ) + + Mocker.mockApiRequests({ + getScoreById: async () => ({ + data: mockScore, + }), + getBeatmapById: async () => ({ + data: mockBeatmap, + }), + }) + + await osuCommand.chatInputRun(interaction, { + commandId: faker.string.uuid(), + commandName: "score", + }) + + expect(errorHandler).not.toBeCalled() + + const scoreEmbed = await osuCommand.container.utilities.embedPresets.getScoreEmbed( + mockScore, + mockBeatmap, + ) + + expect(editReplyMock).toHaveBeenCalledWith({ + embeds: [ + expect.objectContaining({ + data: scoreEmbed.data, + }), + ], + components: [ + expect.objectContaining({ + components: [ + expect.objectContaining({ + data: expect.objectContaining({ + style: ButtonStyle.Link, + label: "View score online", + }), + }), + ], + }), + ], + }) + }) + + it("should handle score link with sunrise URI", async () => { + const editReplyMock = mock() + const scoreId = faker.number.int({ min: 1, max: 1000000 }) + const sunriseUri = container.config.sunrise.uri + const scoreLink = `https://${sunriseUri}/score/${scoreId}` + + const mockScore = FakerGenerator.generateScore({ id: scoreId }) + const mockBeatmap = FakerGenerator.generateBeatmap({ id: mockScore.beatmap_id }) + + const interaction = FakerGenerator.withSubcommand( + FakerGenerator.generateInteraction({ + deferReply: mock(), + editReply: editReplyMock, + options: { + getString: jest.fn().mockReturnValue(scoreLink), + }, + }), + "score", + ) + + Mocker.mockApiRequests({ + getScoreById: async () => ({ + data: mockScore, + }), + getBeatmapById: async () => ({ + data: mockBeatmap, + }), + }) + + await osuCommand.chatInputRun(interaction, { + commandId: faker.string.uuid(), + commandName: "score", + }) + + expect(errorHandler).not.toBeCalled() + + const scoreEmbed = await osuCommand.container.utilities.embedPresets.getScoreEmbed( + mockScore, + mockBeatmap, + ) + + expect(editReplyMock).toHaveBeenCalledWith({ + embeds: [ + expect.objectContaining({ + data: scoreEmbed.data, + }), + ], + components: [ + expect.objectContaining({ + components: [ + expect.objectContaining({ + data: expect.objectContaining({ + style: ButtonStyle.Link, + label: "View score online", + url: `https://${sunriseUri}/score/${scoreId}`, + }), + }), + ], + }), + ], + }) + }) + + it("should throw error when invalid score ID is provided", async () => { + const interaction = FakerGenerator.withSubcommand( + FakerGenerator.generateInteraction({ + deferReply: mock(), + editReply: mock(), + options: { + getString: jest.fn().mockReturnValue("invalid"), + }, + }), + "score", + ) + + Mocker.mockApiRequests({ + getScoreById: async () => ({ + error: "Score not found", + }), + getBeatmapById: async () => ({ + error: "Beatmap not found", + }), + }) + + await osuCommand.chatInputRun(interaction, { + commandId: faker.string.uuid(), + commandName: "score", + }) + + expect(errorHandler).toHaveBeenCalledWith( + expect.objectContaining({ + message: "❓ I couldn't find score with such data", + }), + expect.anything(), + ) + }) + + it("should throw error when no score ID is provided", async () => { + const interaction = FakerGenerator.withSubcommand( + FakerGenerator.generateInteraction({ + deferReply: mock(), + editReply: mock(), + options: { + getString: jest.fn().mockReturnValue(null), + }, + }), + "score", + ) + + await osuCommand.chatInputRun(interaction, { + commandId: faker.string.uuid(), + commandName: "score", + }) + + expect(errorHandler).toHaveBeenCalledWith( + expect.objectContaining({ + message: "❓ Bad score id/link is provided", + }), + expect.anything(), + ) + }) + + it("should throw error when score is not found", async () => { + const scoreId = faker.number.int({ min: 1, max: 1000000 }) + + const interaction = FakerGenerator.withSubcommand( + FakerGenerator.generateInteraction({ + deferReply: mock(), + editReply: mock(), + options: { + getString: jest.fn().mockReturnValue(scoreId.toString()), + }, + }), + "score", + ) + + Mocker.mockApiRequest("getScoreById", async () => ({ + error: "Score not found", + })) + + await osuCommand.chatInputRun(interaction, { + commandId: faker.string.uuid(), + commandName: "score", + }) + + expect(errorHandler).toHaveBeenCalledWith( + expect.objectContaining({ + message: "❓ I couldn't find score with such data", + }), + expect.anything(), + ) + }) + + it("should throw error when beatmap is not found", async () => { + const scoreId = faker.number.int({ min: 1, max: 1000000 }) + + const mockScore = FakerGenerator.generateScore({ id: scoreId }) + + const interaction = FakerGenerator.withSubcommand( + FakerGenerator.generateInteraction({ + deferReply: mock(), + editReply: mock(), + options: { + getString: jest.fn().mockReturnValue(scoreId.toString()), + }, + }), + "score", + ) + + Mocker.mockApiRequests({ + getScoreById: async () => ({ + data: mockScore, + }), + getBeatmapById: async () => ({ + error: "Beatmap not found", + }), + }) + + await osuCommand.chatInputRun(interaction, { + commandId: faker.string.uuid(), + commandName: "score", + }) + + expect(errorHandler).toHaveBeenCalledWith( + expect.objectContaining({ + message: "❓ I couldn't fetch score's beatmap data", + }), + expect.anything(), + ) + }) +}) diff --git a/src/subcommands/osu/tests/scores.subcommand.test.ts b/src/subcommands/osu/tests/scores.subcommand.test.ts new file mode 100644 index 0000000..84df9df --- /dev/null +++ b/src/subcommands/osu/tests/scores.subcommand.test.ts @@ -0,0 +1,517 @@ +import { expect, describe, it, beforeAll, afterAll, jest, mock, beforeEach } from "bun:test" +import { container } from "@sapphire/framework" +import { OsuCommand } from "../../../commands/osu.command" +import { Mocker } from "../../../lib/mock/mocker" +import { FakerGenerator } from "../../../lib/mock/faker.generator" +import { faker } from "@faker-js/faker" +import { GameMode, ScoreTableType } from "../../../lib/types/api" + +describe("Osu Scores Subcommand", () => { + let osuCommand: OsuCommand + let errorHandler: jest.Mock + + beforeAll(() => { + Mocker.createSapphireClientInstance() + osuCommand = Mocker.createCommandInstance(OsuCommand) + errorHandler = Mocker.createErrorHandler() + }) + + afterAll(async () => { + await Mocker.resetSapphireClientInstance() + }) + + beforeEach(() => Mocker.beforeEachCleanup(errorHandler)) + + it("should create pagination handler when username is provided", async () => { + const username = faker.internet.username() + const userId = faker.number.int({ min: 1, max: 1000000 }) + const gamemode = GameMode.STANDARD + const scoreType = ScoreTableType.TOP + + const paginationCreateMock = mock() + container.utilities.pagination.createPaginationHandler = paginationCreateMock + + const interaction = FakerGenerator.withSubcommand( + FakerGenerator.generateInteraction({ + deferReply: mock(), + editReply: mock(), + options: { + getString: jest.fn((name: string) => { + if (name === "username") return username + if (name === "gamemode") return gamemode + if (name === "type") return scoreType + return null + }), + getUser: jest.fn().mockReturnValue(null), + getNumber: jest.fn().mockReturnValue(null), + }, + }), + "scores", + ) + + Mocker.mockApiRequests({ + getUserSearch: async () => ({ + data: [{ user_id: userId, username: username }], + }), + }) + + await osuCommand.chatInputRun(interaction, { + commandId: faker.string.uuid(), + commandName: "scores", + }) + + expect(errorHandler).not.toBeCalled() + expect(paginationCreateMock).toHaveBeenCalledWith( + interaction, + expect.any(Function), + expect.objectContaining({ + pageSize: 10, + currentPage: 1, + totalPages: 0, + }), + ) + }) + + it("should create pagination handler when Discord user is provided (linked account)", async () => { + const discordUser = FakerGenerator.generateUser() + const osuUserId = faker.number.int({ min: 1, max: 1000000 }) + const gamemode = GameMode.TAIKO + const scoreType = ScoreTableType.RECENT + + const paginationCreateMock = mock() + container.utilities.pagination.createPaginationHandler = paginationCreateMock + + const interaction = FakerGenerator.withSubcommand( + FakerGenerator.generateInteraction({ + deferReply: mock(), + editReply: mock(), + options: { + getString: jest.fn((name: string) => { + if (name === "gamemode") return gamemode + if (name === "type") return scoreType + return null + }), + getUser: jest.fn().mockReturnValue(discordUser), + getNumber: jest.fn().mockReturnValue(null), + }, + }), + "scores", + ) + + const { db } = container + const insertUser = db.prepare( + "INSERT INTO connections (discord_user_id, osu_user_id) VALUES ($1, $2)", + ) + insertUser.run({ $1: discordUser.id, $2: osuUserId.toString() }) + + await osuCommand.chatInputRun(interaction, { + commandId: faker.string.uuid(), + commandName: "scores", + }) + + expect(errorHandler).not.toBeCalled() + expect(paginationCreateMock).toHaveBeenCalledWith( + interaction, + expect.any(Function), + expect.objectContaining({ + pageSize: 10, + currentPage: 1, + totalPages: 0, + }), + ) + }) + + it("should create pagination handler for current user (no options, linked account)", async () => { + const currentUser = FakerGenerator.generateUser() + const osuUserId = faker.number.int({ min: 1, max: 1000000 }) + const gamemode = GameMode.MANIA + const scoreType = ScoreTableType.BEST + + const paginationCreateMock = mock() + container.utilities.pagination.createPaginationHandler = paginationCreateMock + + const interaction = FakerGenerator.withSubcommand( + FakerGenerator.generateInteraction({ + deferReply: mock(), + editReply: mock(), + user: currentUser, + options: { + getString: jest.fn((name: string) => { + if (name === "gamemode") return gamemode + if (name === "type") return scoreType + return null + }), + getUser: jest.fn().mockReturnValue(null), + getNumber: jest.fn().mockReturnValue(null), + }, + }), + "scores", + ) + + const { db } = container + const insertUser = db.prepare( + "INSERT INTO connections (discord_user_id, osu_user_id) VALUES ($1, $2)", + ) + insertUser.run({ $1: currentUser.id, $2: osuUserId.toString() }) + + await osuCommand.chatInputRun(interaction, { + commandId: faker.string.uuid(), + commandName: "scores", + }) + + expect(errorHandler).not.toBeCalled() + expect(paginationCreateMock).toHaveBeenCalledWith( + interaction, + expect.any(Function), + expect.objectContaining({ + pageSize: 10, + currentPage: 1, + totalPages: 0, + }), + ) + }) + + it("should throw error when username is not found", async () => { + const username = faker.internet.username() + const gamemode = GameMode.STANDARD + const scoreType = ScoreTableType.TOP + + const interaction = FakerGenerator.withSubcommand( + FakerGenerator.generateInteraction({ + deferReply: mock(), + editReply: mock(), + options: { + getString: jest.fn((name: string) => { + if (name === "username") return username + if (name === "gamemode") return gamemode + if (name === "type") return scoreType + return null + }), + getUser: jest.fn().mockReturnValue(null), + getNumber: jest.fn().mockReturnValue(null), + }, + }), + "scores", + ) + + Mocker.mockApiRequests({ + getUserSearch: async () => ({ + data: [], + }), + }) + + await osuCommand.chatInputRun(interaction, { + commandId: faker.string.uuid(), + commandName: "scores", + }) + + expect(errorHandler).toHaveBeenCalledWith( + expect.objectContaining({ + message: "❓ I couldn't find user with such username", + }), + expect.anything(), + ) + }) + + it("should throw error when Discord user has no linked account", async () => { + const discordUser = FakerGenerator.generateUser() + const gamemode = GameMode.STANDARD + const scoreType = ScoreTableType.TOP + + const interaction = FakerGenerator.withSubcommand( + FakerGenerator.generateInteraction({ + deferReply: mock(), + editReply: mock(), + options: { + getString: jest.fn((name: string) => { + if (name === "gamemode") return gamemode + if (name === "type") return scoreType + return null + }), + getUser: jest.fn().mockReturnValue(discordUser), + getNumber: jest.fn().mockReturnValue(null), + }, + }), + "scores", + ) + + await osuCommand.chatInputRun(interaction, { + commandId: faker.string.uuid(), + commandName: "scores", + }) + + expect(errorHandler).toHaveBeenCalledWith( + expect.objectContaining({ + message: "❓ Provided user didn't link their osu!sunrise account", + }), + expect.anything(), + ) + }) + + it("should throw error when current user has no linked account", async () => { + const currentUser = FakerGenerator.generateUser() + const gamemode = GameMode.STANDARD + const scoreType = ScoreTableType.TOP + + const interaction = FakerGenerator.withSubcommand( + FakerGenerator.generateInteraction({ + deferReply: mock(), + editReply: mock(), + user: currentUser, + options: { + getString: jest.fn((name: string) => { + if (name === "gamemode") return gamemode + if (name === "type") return scoreType + return null + }), + getUser: jest.fn().mockReturnValue(null), + getNumber: jest.fn().mockReturnValue(null), + }, + }), + "scores", + ) + + await osuCommand.chatInputRun(interaction, { + commandId: faker.string.uuid(), + commandName: "scores", + }) + + expect(errorHandler).toHaveBeenCalledWith( + expect.objectContaining({ + message: "❓ Provided user didn't link their osu!sunrise account", + }), + expect.anything(), + ) + }) + + it("should throw error when username search API fails", async () => { + const username = faker.internet.username() + const gamemode = GameMode.STANDARD + const scoreType = ScoreTableType.TOP + + const interaction = FakerGenerator.withSubcommand( + FakerGenerator.generateInteraction({ + deferReply: mock(), + editReply: mock(), + options: { + getString: jest.fn((name: string) => { + if (name === "username") return username + if (name === "gamemode") return gamemode + if (name === "type") return scoreType + return null + }), + getUser: jest.fn().mockReturnValue(null), + getNumber: jest.fn().mockReturnValue(null), + }, + }), + "scores", + ) + + Mocker.mockApiRequests({ + getUserSearch: async () => ({ + error: { error: "API Error" }, + }), + }) + + await osuCommand.chatInputRun(interaction, { + commandId: faker.string.uuid(), + commandName: "scores", + }) + + expect(errorHandler).toHaveBeenCalledWith( + expect.objectContaining({ + message: "API Error", + }), + expect.anything(), + ) + }) + + it("should handle pagination callback correctly with scores", async () => { + const username = faker.internet.username() + const userId = faker.number.int({ min: 1, max: 1000000 }) + const gamemode = GameMode.STANDARD + const scoreType = ScoreTableType.TOP + + let capturedPaginationHandler: any = null + const paginationCreateMock = mock((interaction: any, handler: any, state: any) => { + capturedPaginationHandler = handler + return Promise.resolve() + }) + container.utilities.pagination.createPaginationHandler = paginationCreateMock + + const interaction = FakerGenerator.withSubcommand( + FakerGenerator.generateInteraction({ + deferReply: mock(), + editReply: mock(), + options: { + getString: jest.fn((name: string) => { + if (name === "username") return username + if (name === "gamemode") return gamemode + if (name === "type") return scoreType + return null + }), + getUser: jest.fn().mockReturnValue(null), + getNumber: jest.fn().mockReturnValue(null), + }, + }), + "scores", + ) + + Mocker.mockApiRequests({ + getUserSearch: async () => ({ + data: [{ user_id: userId, username: username }], + }), + }) + + await osuCommand.chatInputRun(interaction, { + commandId: faker.string.uuid(), + commandName: "scores", + }) + + expect(capturedPaginationHandler).not.toBeNull() + + const mockScore1 = FakerGenerator.generateScore() + const mockScore2 = FakerGenerator.generateScore() + const mockBeatmap1 = FakerGenerator.generateBeatmap({ id: mockScore1.beatmap_id }) + const mockBeatmap2 = FakerGenerator.generateBeatmap({ id: mockScore2.beatmap_id }) + + Mocker.mockApiRequests({ + getUserByIdScores: async () => ({ + data: { scores: [mockScore1, mockScore2], total_count: 20 }, + }), + getBeatmapById: async ({ path }: { path: { id: number } }) => { + if (path.id === mockScore1.beatmap_id) return { data: mockBeatmap1 } + if (path.id === mockScore2.beatmap_id) return { data: mockBeatmap2 } + return { error: "Not found" } + }, + }) + + const result = await capturedPaginationHandler({ + pageSize: 10, + currentPage: 1, + totalPages: 0, + }) + + expect(result).toBeDefined() + expect(result.data).toBeDefined() + expect(result.data.title).toContain(gamemode) + expect(result.data.title).toContain(scoreType) + expect(result.data.description).toBeDefined() + }) + + it("should handle pagination callback with no scores", async () => { + const username = faker.internet.username() + const userId = faker.number.int({ min: 1, max: 1000000 }) + const gamemode = GameMode.STANDARD + const scoreType = ScoreTableType.TOP + + let capturedPaginationHandler: any = null + const paginationCreateMock = mock((interaction: any, handler: any, state: any) => { + capturedPaginationHandler = handler + return Promise.resolve() + }) + container.utilities.pagination.createPaginationHandler = paginationCreateMock + + const interaction = FakerGenerator.withSubcommand( + FakerGenerator.generateInteraction({ + deferReply: mock(), + editReply: mock(), + options: { + getString: jest.fn((name: string) => { + if (name === "username") return username + if (name === "gamemode") return gamemode + if (name === "type") return scoreType + return null + }), + getUser: jest.fn().mockReturnValue(null), + getNumber: jest.fn().mockReturnValue(null), + }, + }), + "scores", + ) + + Mocker.mockApiRequests({ + getUserSearch: async () => ({ + data: [{ user_id: userId, username: username }], + }), + }) + + await osuCommand.chatInputRun(interaction, { + commandId: faker.string.uuid(), + commandName: "scores", + }) + + Mocker.mockApiRequests({ + getUserByIdScores: async () => ({ + data: { scores: [], total_count: 0 }, + }), + }) + + const result = await capturedPaginationHandler({ + pageSize: 10, + currentPage: 1, + totalPages: 0, + }) + + expect(result).toBeDefined() + expect(result.data.description).toContain("No scores to show") + }) + + it("should handle pagination callback with API error", async () => { + const username = faker.internet.username() + const userId = faker.number.int({ min: 1, max: 1000000 }) + const gamemode = GameMode.STANDARD + const scoreType = ScoreTableType.TOP + + let capturedPaginationHandler: any = null + const paginationCreateMock = mock((interaction: any, handler: any, state: any) => { + capturedPaginationHandler = handler + return Promise.resolve() + }) + container.utilities.pagination.createPaginationHandler = paginationCreateMock + + const interaction = FakerGenerator.withSubcommand( + FakerGenerator.generateInteraction({ + deferReply: mock(), + editReply: mock(), + options: { + getString: jest.fn((name: string) => { + if (name === "username") return username + if (name === "gamemode") return gamemode + if (name === "type") return scoreType + return null + }), + getUser: jest.fn().mockReturnValue(null), + getNumber: jest.fn().mockReturnValue(null), + }, + }), + "scores", + ) + + Mocker.mockApiRequests({ + getUserSearch: async () => ({ + data: [{ user_id: userId, username: username }], + }), + }) + + await osuCommand.chatInputRun(interaction, { + commandId: faker.string.uuid(), + commandName: "scores", + }) + + Mocker.mockApiRequests({ + getUserByIdScores: async () => ({ + error: { error: "Scores API Error" }, + }), + }) + + const result = await capturedPaginationHandler({ + pageSize: 10, + currentPage: 1, + totalPages: 0, + }) + + expect(result).toBeDefined() + expect(result.data).toBeDefined() + }) +}) diff --git a/src/subcommands/osu/tests/unlink.subcommand.test.ts b/src/subcommands/osu/tests/unlink.subcommand.test.ts new file mode 100644 index 0000000..2c35760 --- /dev/null +++ b/src/subcommands/osu/tests/unlink.subcommand.test.ts @@ -0,0 +1,219 @@ +import { expect, describe, it, beforeAll, afterAll, beforeEach, jest, mock } from "bun:test" +import { container } from "@sapphire/framework" +import { OsuCommand } from "../../../commands/osu.command" +import { Mocker } from "../../../lib/mock/mocker" +import { FakerGenerator } from "../../../lib/mock/faker.generator" +import { faker } from "@faker-js/faker" + +describe("Osu Unlink Subcommand", () => { + let osuCommand: OsuCommand + let errorHandler: jest.Mock + + beforeAll(() => { + Mocker.createSapphireClientInstance() + osuCommand = Mocker.createCommandInstance(OsuCommand) + errorHandler = Mocker.createErrorHandler() + }) + + afterAll(async () => { + await Mocker.resetSapphireClientInstance() + }) + + beforeEach(() => Mocker.beforeEachCleanup(errorHandler)) + + it("should successfully unlink account", async () => { + const editReplyMock = mock() + const currentUser = FakerGenerator.generateUser() + const osuUserId = faker.number.int({ min: 1, max: 1000000 }) + + const interaction = FakerGenerator.withSubcommand( + FakerGenerator.generateInteraction({ + deferReply: mock(), + editReply: editReplyMock, + user: currentUser, + options: {}, + }), + "unlink", + ) + + const { db } = container + const insertUser = db.prepare( + "INSERT INTO connections (discord_user_id, osu_user_id) VALUES ($1, $2)", + ) + insertUser.run({ $1: currentUser.id, $2: osuUserId.toString() }) + + const beforeRow = db + .query("SELECT osu_user_id FROM connections WHERE discord_user_id = $1") + .get({ $1: currentUser.id }) as { osu_user_id: string } | null + expect(beforeRow).not.toBeNull() + expect(beforeRow?.osu_user_id).toBe(osuUserId.toString()) + + await osuCommand.chatInputRun(interaction, { + commandId: faker.string.uuid(), + commandName: "unlink", + }) + + expect(errorHandler).not.toBeCalled() + + const expectedEmbed = container.utilities.embedPresets.getSuccessEmbed( + `I successfully unlinked your account!`, + ) + + expect(editReplyMock).toHaveBeenCalledWith({ + embeds: [ + expect.objectContaining({ + data: expect.objectContaining({ + title: expectedEmbed.data.title, + }), + }), + ], + }) + + const afterRow = db + .query("SELECT osu_user_id FROM connections WHERE discord_user_id = $1") + .get({ $1: currentUser.id }) + + expect(afterRow).toBeNull() + }) + + it("should throw error when user has no linked account", async () => { + const currentUser = FakerGenerator.generateUser() + + const interaction = FakerGenerator.withSubcommand( + FakerGenerator.generateInteraction({ + deferReply: mock(), + editReply: mock(), + user: currentUser, + options: {}, + }), + "unlink", + ) + + const { db } = container + const row = db.query("SELECT osu_user_id FROM connections WHERE discord_user_id = $1").get({ + $1: currentUser.id, + }) + expect(row).toBeNull() + + await osuCommand.chatInputRun(interaction, { + commandId: faker.string.uuid(), + commandName: "unlink", + }) + + expect(errorHandler).toHaveBeenCalledWith( + expect.objectContaining({ + message: "❓ You don't have any linked account", + }), + expect.anything(), + ) + }) + + it("should only unlink the current user's account", async () => { + const user1 = FakerGenerator.generateUser() + const user2 = FakerGenerator.generateUser() + const osuUserId1 = faker.number.int({ min: 1, max: 1000000 }) + const osuUserId2 = faker.number.int({ min: 1, max: 1000000 }) + + const { db } = container + + const insertUser = db.prepare( + "INSERT INTO connections (discord_user_id, osu_user_id) VALUES ($1, $2)", + ) + insertUser.run({ $1: user1.id, $2: osuUserId1.toString() }) + insertUser.run({ $1: user2.id, $2: osuUserId2.toString() }) + + const beforeRow1 = db + .query("SELECT osu_user_id FROM connections WHERE discord_user_id = $1") + .get({ $1: user1.id }) as { osu_user_id: string } | null + const beforeRow2 = db + .query("SELECT osu_user_id FROM connections WHERE discord_user_id = $1") + .get({ $1: user2.id }) as { osu_user_id: string } | null + + expect(beforeRow1).not.toBeNull() + expect(beforeRow2).not.toBeNull() + + const interaction = FakerGenerator.withSubcommand( + FakerGenerator.generateInteraction({ + deferReply: mock(), + editReply: mock(), + user: user1, + options: {}, + }), + "unlink", + ) + + await osuCommand.chatInputRun(interaction, { + commandId: faker.string.uuid(), + commandName: "unlink", + }) + + expect(errorHandler).not.toBeCalled() + + const afterRow1 = db + .query("SELECT osu_user_id FROM connections WHERE discord_user_id = $1") + .get({ $1: user1.id }) + expect(afterRow1).toBeNull() + + const afterRow2 = db + .query("SELECT osu_user_id FROM connections WHERE discord_user_id = $1") + .get({ $1: user2.id }) as { osu_user_id: string } | null + + expect(afterRow2).not.toBeNull() + expect(afterRow2?.osu_user_id).toBe(osuUserId2.toString()) + }) + + it("should handle multiple unlink attempts gracefully", async () => { + const currentUser = FakerGenerator.generateUser() + const osuUserId = faker.number.int({ min: 1, max: 1000000 }) + + const { db } = container + const insertUser = db.prepare( + "INSERT INTO connections (discord_user_id, osu_user_id) VALUES ($1, $2)", + ) + insertUser.run({ $1: currentUser.id, $2: osuUserId.toString() }) + + const interaction1 = FakerGenerator.withSubcommand( + FakerGenerator.generateInteraction({ + deferReply: mock(), + editReply: mock(), + user: currentUser, + options: {}, + }), + "unlink", + ) + + await osuCommand.chatInputRun(interaction1, { + commandId: faker.string.uuid(), + commandName: "unlink", + }) + + expect(errorHandler).not.toBeCalled() + + const afterFirstUnlink = db + .query("SELECT osu_user_id FROM connections WHERE discord_user_id = $1") + .get({ $1: currentUser.id }) + expect(afterFirstUnlink).toBeNull() + + const interaction2 = FakerGenerator.withSubcommand( + FakerGenerator.generateInteraction({ + deferReply: mock(), + editReply: mock(), + user: currentUser, + options: {}, + }), + "unlink", + ) + + await osuCommand.chatInputRun(interaction2, { + commandId: faker.string.uuid(), + commandName: "unlink", + }) + + expect(errorHandler).toHaveBeenCalledWith( + expect.objectContaining({ + message: "❓ You don't have any linked account", + }), + expect.anything(), + ) + }) +}) diff --git a/src/utilities/tests/action-store.utility.test.ts b/src/utilities/tests/action-store.utility.test.ts new file mode 100644 index 0000000..35e7ec4 --- /dev/null +++ b/src/utilities/tests/action-store.utility.test.ts @@ -0,0 +1,294 @@ +import { expect, describe, it, beforeAll, afterAll, beforeEach } from "bun:test" +import { container } from "@sapphire/framework" +import { Mocker } from "../../lib/mock/mocker" +import { ActionStoreUtility } from "../action-store.utility" +import { faker } from "@faker-js/faker" +import { Time } from "@sapphire/time-utilities" + +describe("Action Store Utility", () => { + let actionStore: ActionStoreUtility + + beforeAll(() => { + Mocker.createSapphireClientInstance() + actionStore = container.utilities.actionStore + }) + + afterAll(async () => { + await Mocker.resetSapphireClientInstance() + }) + + beforeEach(() => actionStore.clear()) + + describe("set", () => { + it("should store data and return a unique ID", () => { + const testData = { foo: "bar" } + const id = actionStore.set(testData) + + expect(id).toBeString() + expect(id.length).toBeGreaterThan(0) + }) + + it("should store different types of data", () => { + const stringData = "test string" + const numberData = 42 + const objectData = { key: "value" } + const arrayData = [1, 2, 3] + + const id1 = actionStore.set(stringData) + const id2 = actionStore.set(numberData) + const id3 = actionStore.set(objectData) + const id4 = actionStore.set(arrayData) + + expect(actionStore.get(id1)).toBe(stringData) + expect(actionStore.get(id2)).toBe(numberData) + expect(actionStore.get(id3)).toEqual(objectData) + expect(actionStore.get(id4)).toEqual(arrayData) + }) + + it("should generate unique IDs for each entry", () => { + const id1 = actionStore.set("data1") + const id2 = actionStore.set("data2") + const id3 = actionStore.set("data3") + + expect(id1).not.toBe(id2) + expect(id2).not.toBe(id3) + expect(id1).not.toBe(id3) + }) + + it("should accept custom TTL", () => { + const testData = { custom: "ttl" } + const customTTL = Time.Second * 1 + const id = actionStore.set(testData, customTTL) + + expect(actionStore.get(id)).toEqual(testData) + }) + }) + + describe("get", () => { + it("should retrieve stored data by ID", () => { + const testData = { test: "data" } + const id = actionStore.set(testData) + + const retrieved = actionStore.get(id) + + expect(retrieved).toEqual(testData) + }) + + it("should return null for non-existent ID", () => { + const fakeId = faker.string.uuid() + const result = actionStore.get(fakeId) + + expect(result).toBeNull() + }) + + it("should return null for expired entries", async () => { + const testData = { expires: "soon" } + const shortTTL = Time.Millisecond * 100 + const id = actionStore.set(testData, shortTTL) + + // Wait for expiration + await new Promise((resolve) => setTimeout(resolve, 150)) + + const result = actionStore.get(id) + + expect(result).toBeNull() + }) + + it("should refresh expiration on get", async () => { + const testData = { refresh: "test" } + const ttl = Time.Millisecond * 200 + const id = actionStore.set(testData, ttl) + + // Wait 100ms, then get (should refresh) + await new Promise((resolve) => setTimeout(resolve, Time.Millisecond * 100)) + const result1 = actionStore.get(id) + expect(result1).toEqual(testData) + + // Wait another 100ms, data should still be there (refreshed) + await new Promise((resolve) => setTimeout(resolve, Time.Millisecond * 100)) + const result2 = actionStore.get(id) + expect(result2).toEqual(testData) + }) + }) + + describe("delete", () => { + it("should delete an entry and return true", () => { + const testData = { to: "delete" } + const id = actionStore.set(testData) + + const deleted = actionStore.delete(id) + + expect(deleted).toBe(true) + expect(actionStore.get(id)).toBeNull() + }) + + it("should return false for non-existent ID", () => { + const fakeId = faker.string.uuid() + const deleted = actionStore.delete(fakeId) + + expect(deleted).toBe(false) + }) + + it("should clear timeout when deleting", () => { + const testData = { with: "timeout" } + const id = actionStore.set(testData) + + actionStore.delete(id) + + expect(actionStore.get(id)).toBeNull() + }) + + it("should allow deleting the same ID multiple times", () => { + const testData = { double: "delete" } + const id = actionStore.set(testData) + + const deleted1 = actionStore.delete(id) + const deleted2 = actionStore.delete(id) + + expect(deleted1).toBe(true) + expect(deleted2).toBe(false) + }) + }) + + describe("clear", () => { + it("should remove all entries", () => { + const id1 = actionStore.set("data1") + const id2 = actionStore.set("data2") + const id3 = actionStore.set("data3") + + actionStore.clear() + + expect(actionStore.get(id1)).toBeNull() + expect(actionStore.get(id2)).toBeNull() + expect(actionStore.get(id3)).toBeNull() + }) + + it("should clear timeouts for all entries", async () => { + const id1 = actionStore.set("data1", Time.Second * 5) + const id2 = actionStore.set("data2", Time.Second * 5) + + actionStore.clear() + + // Wait a bit to ensure timeouts were cleared + await new Promise((resolve) => setTimeout(resolve, Time.Millisecond * 50)) + + expect(actionStore.get(id1)).toBeNull() + expect(actionStore.get(id2)).toBeNull() + }) + + it("should work on empty store", () => { + expect(() => actionStore.clear()).not.toThrow() + }) + }) + + describe("expiration", () => { + it("should automatically delete entries after TTL", async () => { + const testData = { auto: "expire" } + const shortTTL = Time.Millisecond * 100 + const id = actionStore.set(testData, shortTTL) + + // Wait for expiration (without calling get, which would refresh) + await new Promise((resolve) => setTimeout(resolve, Time.Millisecond * 150)) + + // Data should be gone + expect(actionStore.get(id)).toBeNull() + }) + + it("should handle multiple entries with different TTLs", async () => { + const data1 = { ttl: "short" } + const data2 = { ttl: "long" } + + const id1 = actionStore.set(data1, Time.Millisecond * 100) + const id2 = actionStore.set(data2, Time.Millisecond * 400) + + // Wait for first to expire (without calling get, which would refresh) + await new Promise((resolve) => setTimeout(resolve, Time.Millisecond * 150)) + + expect(actionStore.get(id1)).toBeNull() + + // Wait for second to expire + await new Promise((resolve) => setTimeout(resolve, Time.Millisecond * 300)) + + expect(actionStore.get(id2)).toBeNull() + }) + }) + + describe("edge cases", () => { + it("should handle storing null values", () => { + const id = actionStore.set(null) + const result = actionStore.get(id) + + expect(result).toBeNull() + }) + + it("should handle storing undefined values", () => { + const id = actionStore.set(undefined) + const result = actionStore.get(id) + + expect(result).toBeUndefined() + }) + + it("should handle storing empty objects", () => { + const emptyObj = {} + const id = actionStore.set(emptyObj) + const result = actionStore.get(id) + + expect(result).toEqual({}) + }) + + it("should handle storing empty arrays", () => { + const emptyArr: any[] = [] + const id = actionStore.set(emptyArr) + const result = actionStore.get(id) + + expect(result).toEqual([]) + }) + + it("should handle zero TTL", async () => { + const testData = { ttl: "zero" } + const id = actionStore.set(testData, 0) + + // Wait for the immediate timeout to fire + await new Promise((resolve) => setTimeout(resolve, 10)) + + // Should be expired + const result = actionStore.get(id) + expect(result).toBeNull() + }) + + it("should handle very large TTL", () => { + const testData = { ttl: "large" } + const largeTTL = Time.Day * 20 + const id = actionStore.set(testData, largeTTL) + + const result = actionStore.get(id) + expect(result).toEqual(testData) + }) + }) + + describe("data isolation", () => { + it("should not modify original data when retrieved", () => { + const originalData = { nested: { value: "original" } } + const id = actionStore.set(originalData) + + const retrieved = actionStore.get(id) + if (retrieved) { + retrieved.nested.value = "modified" + } + + const retrievedAgain = actionStore.get(id) + expect(retrievedAgain?.nested.value).toBe("modified") + }) + + it("should store independent copies for different IDs", () => { + const data1 = { value: 1 } + const data2 = { value: 2 } + + const id1 = actionStore.set(data1) + const id2 = actionStore.set(data2) + + expect(actionStore.get<{ value: number }>(id1)).toEqual({ value: 1 }) + expect(actionStore.get<{ value: number }>(id2)).toEqual({ value: 2 }) + }) + }) +})