From da83a6618adfa75af6ab26d2f348a671ad21b6b8 Mon Sep 17 00:00:00 2001 From: richardscull <106016833+richardscull@users.noreply.github.com> Date: Wed, 13 Aug 2025 00:58:19 +0300 Subject: [PATCH 01/19] feat: Default to dev if env is not prod or dev for both json and environment values; --- src/lib/configs/env.ts | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/src/lib/configs/env.ts b/src/lib/configs/env.ts index 4981e70..f530863 100644 --- a/src/lib/configs/env.ts +++ b/src/lib/configs/env.ts @@ -41,6 +41,10 @@ requiredEnvVariables.map((v) => { } }) +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 +56,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`)), } From 0680be319ab2e29a23b6436b33fe2d40b34a856c Mon Sep 17 00:00:00 2001 From: richardscull <106016833+richardscull@users.noreply.github.com> Date: Wed, 13 Aug 2025 00:59:08 +0300 Subject: [PATCH 02/19] feat: Create Mocker and FakerGenerator; --- bun.lock | 3 + package.json | 1 + src/lib/mock/faker.generator.ts | 150 ++++++++++++++++++++++++++++++++ src/lib/mock/mocker.ts | 96 ++++++++++++++++++++ 4 files changed, 250 insertions(+) create mode 100644 src/lib/mock/faker.generator.ts create mode 100644 src/lib/mock/mocker.ts 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/lib/mock/faker.generator.ts b/src/lib/mock/faker.generator.ts new file mode 100644 index 0000000..21720de --- /dev/null +++ b/src/lib/mock/faker.generator.ts @@ -0,0 +1,150 @@ +import { jest, mock } from "bun:test" + +import { + ApplicationCommand, + ApplicationCommandType, + Client, + DMChannel, + InteractionType, + Locale, + Message, + PermissionsBitField, + User, + UserFlagsBitField, + type UserMention, +} from "discord.js" + +import { faker } from "@faker-js/faker" +import { Command, CommandStore, container } from "@sapphire/framework" +import type { DeepPartial } from "@sapphire/utilities" + +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(): Command.LoaderContext { + return { + name: faker.string.alpha(10), + store: new CommandStore(), + path: faker.system.filePath(), + root: faker.system.directoryPath(), + } + } + + static generateInteraction( + options?: DeepPartial, + ): Command.ChatInputCommandInteraction { + return { + id: faker.string.uuid(), + applicationId: faker.string.uuid(), + channelId: faker.string.uuid(), + user: FakerGenerator.generateUser(), + guildId: faker.string.uuid(), + commandType: ApplicationCommandType.ChatInput, + type: InteractionType.ApplicationCommand, + 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(), + channel: null, + context: null, + createdAt: faker.date.past(), + createdTimestamp: Date.now(), + guild: null, + member: null, + token: "", + version: 0, + memberPermissions: null, + locale: Locale.French, + guildLocale: null, + attachmentSizeLimit: 0, + options: {}, + ...options, + } as unknown as Command.ChatInputCommandInteraction + } + + 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 { + id: faker.string.uuid(), + applicationId: faker.string.uuid(), + guildId: faker.string.uuid(), + type: ApplicationCommandType.ChatInput, + createdAt: faker.date.past(), + createdTimestamp: Date.now(), + guild: null, + version: `v${faker.number.int({ min: 1, max: 100 })}`, + contexts: [], + client: container.client as unknown as Client, + defaultMemberPermissions: new PermissionsBitField(PermissionsBitField.Flags.SendMessages), + description: faker.lorem.sentence(), + options: [], + descriptionLocalizations: {}, + descriptionLocalized: faker.lorem.sentence(), + dmPermission: true, + ...options, + } as unknown as ApplicationCommand<{}> + } + + static generateUser(options?: DeepPartial): User { + return { + id: faker.string.uuid(), + username: faker.internet.userName(), + discriminator: faker.string.numeric(4), + bot: faker.datatype.boolean(), + system: false, + accentColor: faker.datatype.boolean() ? faker.number.int({ min: 0, max: 0xffffff }) : null, + avatar: faker.datatype.boolean() ? faker.string.alphanumeric(32) : null, + avatarDecoration: null, + avatarDecorationData: null, + banner: null, + globalName: faker.datatype.boolean() ? faker.person.fullName() : null, + flags: null, + createdAt: faker.date.past(), + createdTimestamp: Date.now(), + displayName: faker.internet.userName(), + defaultAvatarURL: faker.internet.url(), + dmChannel: null, + hexAccentColor: null, + partial: false, + tag: `${faker.internet.userName()}#${faker.string.numeric(4)}`, + avatarURL: mock(() => faker.internet.url()), + avatarDecorationURL: mock(() => null), + bannerURL: mock(() => null), + displayAvatarURL: mock(() => faker.internet.url()), + equals: mock(() => false), + createDM: mock(async () => null) as unknown as ( + force?: boolean | undefined, + ) => Promise, + deleteDM: mock(async () => null) as unknown as () => Promise, + fetch: mock(async () => null) as unknown as (force?: boolean | undefined) => Promise, + fetchFlags: mock(async () => null) as unknown as () => Promise, + toString: mock(() => `<@${faker.string.uuid()}>`) as unknown as () => UserMention, + toJSON: mock(() => ({})) as unknown as () => Record, + client: container.client as unknown as Client, + send: mock(async () => null) as unknown as ( + content: string, + options?: any, + ) => Promise>, + ...options, + } as unknown as User + } +} diff --git a/src/lib/mock/mocker.ts b/src/lib/mock/mocker.ts new file mode 100644 index 0000000..982942b --- /dev/null +++ b/src/lib/mock/mocker.ts @@ -0,0 +1,96 @@ +import { mock } from "bun:test" + +import { IntentsBitField } from "discord.js" +import { Database } from "bun:sqlite" +import { getMigrations, migrate } from "bun-sqlite-migrations" +import path from "path" + +import { SapphireClient, LogLevel, container, Command, Events } from "@sapphire/framework" +import { EmbedPresetsUtility } from "../../utilities/embed-presets.utility" +import { PaginationUtility } from "../../utilities/pagination.utility" +import { UtilitiesStore } from "@sapphire/plugin-utilities-store" +import { ActionStoreUtility } from "../../utilities/action-store.utility" +import { FakerGenerator } from "./faker.generator" +import { faker } from "@faker-js/faker" +import { SubcommandPluginEvents } from "@sapphire/plugin-subcommands" + +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) => { + return level === LogLevel.Debug + }), + } + + container.utilities = { + ...container.utilities, + store: new UtilitiesStore(), + exposePiece(name, piece) { + container.utilities.store.set(name, piece) + }, + } + + container.utilities = { + ...container.utilities, + actionStore: new ActionStoreUtility(FakerGenerator.generatePiece()), + embedPresets: new EmbedPresetsUtility(FakerGenerator.generatePiece(), {}), + pagination: new PaginationUtility(FakerGenerator.generatePiece(), {}), + exposePiece: container.utilities.exposePiece.bind(container.utilities), + } + + Mocker.createDatabaseInMemory() + } + + static async resetSapphireClientInstance() { + await container.client.destroy() + + container.db.close() + Mocker.createDatabaseInMemory() + } + + 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: string, implementation: () => Promise) { + mock.module(path.resolve(process.cwd(), "src", "lib", "types", "api"), () => ({ + [mockedEndpointMethod]: implementation, + })) + } + + 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"))) + } +} From d2490e84d8d4dcefefb18973ca410ae778e54b37 Mon Sep 17 00:00:00 2001 From: richardscull <106016833+richardscull@users.noreply.github.com> Date: Wed, 13 Aug 2025 01:14:29 +0300 Subject: [PATCH 03/19] test: Add test for meow command; --- src/commands/tests/meow.command.test.ts | 32 +++++++++++++++++++++++++ 1 file changed, 32 insertions(+) create mode 100644 src/commands/tests/meow.command.test.ts diff --git a/src/commands/tests/meow.command.test.ts b/src/commands/tests/meow.command.test.ts new file mode 100644 index 0000000..e1f86b4 --- /dev/null +++ b/src/commands/tests/meow.command.test.ts @@ -0,0 +1,32 @@ +import { expect, describe, it, mock, jest, beforeAll, afterAll } 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() + }) + + 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! 😺") + }) +}) From bee57e40699b0a7948bcc125041bee963cba8ea9 Mon Sep 17 00:00:00 2001 From: richardscull <106016833+richardscull@users.noreply.github.com> Date: Wed, 13 Aug 2025 01:14:35 +0300 Subject: [PATCH 04/19] test: Add test for link osu subcommand; --- .../osu/tests/link.subcommand.test.ts | 107 ++++++++++++++++++ 1 file changed, 107 insertions(+) create mode 100644 src/subcommands/osu/tests/link.subcommand.test.ts 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..f3b50f0 --- /dev/null +++ b/src/subcommands/osu/tests/link.subcommand.test.ts @@ -0,0 +1,107 @@ +import { expect, describe, it, beforeAll, afterAll, 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 { 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() + }) + + 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() + }) +}) From ed05147052c3feb1919e9ae695a2804373c8294e Mon Sep 17 00:00:00 2001 From: richardscull <106016833+richardscull@users.noreply.github.com> Date: Wed, 13 Aug 2025 01:55:56 +0300 Subject: [PATCH 05/19] feat: Add tests for parse method of pagination set page model; --- .../models/pagination-set-page.model.ts | 2 +- .../tests/pagination-set-page.model.test.ts | 100 ++++++++++++++++++ src/lib/mock/faker.generator.ts | 71 ++++++++++++- src/lib/utils/discord.util.ts | 2 +- 4 files changed, 172 insertions(+), 3 deletions(-) create mode 100644 src/interaction-handlers/pagination/models/tests/pagination-set-page.model.test.ts 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..29bd9f3 --- /dev/null +++ b/src/interaction-handlers/pagination/models/tests/pagination-set-page.model.test.ts @@ -0,0 +1,100 @@ +import { faker } from "@faker-js/faker" +import { + CommandStore, + container, + InteractionHandlerStore, + InteractionHandlerTypes, +} from "@sapphire/framework" +import { expect, describe, it, beforeAll, afterAll, jest, mock } from "bun:test" +import type { OsuCommand } from "../../../../commands/osu.command" +import { ExtendedError } from "../../../../lib/extended-error" +import { FakerGenerator } from "../../../../lib/mock/faker.generator" +import { Mocker } from "../../../../lib/mock/mocker" +import { PaginationSetPageModal } from "../pagination-set-page.model" +import type { PaginationStore } from "../../../../lib/types/store.types" +import { PaginationInteractionCustomId } from "../../../../lib/types/enum/custom-ids.types" + +describe("Pagination Set Page Modal", () => { + const modal = new PaginationSetPageModal( + { ...FakerGenerator.generateLoaderContext(), store: new InteractionHandlerStore() }, + { interactionHandlerType: InteractionHandlerTypes.ModalSubmit }, + ) + + let errorHandler: jest.Mock + + beforeAll(() => { + Mocker.createSapphireClientInstance() + // osuCommand = Mocker.createCommandInstance(OsuCommand) + errorHandler = Mocker.createErrorHandler() + }) + + afterAll(async () => { + await Mocker.resetSapphireClientInstance() + }) + + 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()) + + console.log(dataStoreId) + + 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()) + }) + }) + + // TODO: Implement tests for run method +}) diff --git a/src/lib/mock/faker.generator.ts b/src/lib/mock/faker.generator.ts index 21720de..67e63e9 100644 --- a/src/lib/mock/faker.generator.ts +++ b/src/lib/mock/faker.generator.ts @@ -3,11 +3,13 @@ import { jest, mock } from "bun:test" import { ApplicationCommand, ApplicationCommandType, + ButtonInteraction, Client, DMChannel, InteractionType, Locale, Message, + ModalSubmitInteraction, PermissionsBitField, User, UserFlagsBitField, @@ -17,6 +19,7 @@ import { 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" export class FakerGenerator { static generatePiece() { @@ -28,7 +31,7 @@ export class FakerGenerator { } } - static generateLoaderContext(): Command.LoaderContext { + static generateLoaderContext() { return { name: faker.string.alpha(10), store: new CommandStore(), @@ -37,6 +40,26 @@ export class FakerGenerator { } } + 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 { @@ -72,6 +95,52 @@ export class FakerGenerator { } as unknown as Command.ChatInputCommandInteraction } + static generateModalSubmitInteraction( + options?: DeepPartial, + ): ModalSubmitInteraction { + return { + id: faker.string.uuid(), + applicationId: faker.string.uuid(), + channelId: faker.string.uuid(), + user: FakerGenerator.generateUser(), + guildId: faker.string.uuid(), + type: InteractionType.ModalSubmit, + customId: faker.lorem.slug(), + channel: null, + createdAt: faker.date.past(), + createdTimestamp: Date.now(), + guild: null, + member: null, + token: "", + version: 0, + memberPermissions: null, + locale: Locale.French, + guildLocale: null, + deferred: faker.datatype.boolean(), + ephemeral: faker.datatype.boolean(), + replied: faker.datatype.boolean(), + isFromMessage: false, + message: null, + fields: [], + isModalSubmit: () => true, + isButton: () => false, + isSelectMenu: () => false, + isAutocomplete: () => false, + isCommand: () => false, + isContextMenuCommand: () => false, + reply: mock(async () => null), + deferReply: mock(async () => null), + editReply: mock(async () => null), + fetchReply: mock(async () => null), + deleteReply: mock(async () => null), + followUp: mock(async () => null), + deferUpdate: mock(async () => null), + update: mock(async () => null), + showModal: mock(async () => null), + ...options, + } as unknown as ModalSubmitInteraction + } + static withSubcommand( interaction: T, subcommand: string, 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") } From 14c61bc07be3c631619006bcac4416e4611b071d Mon Sep 17 00:00:00 2001 From: richardscull <106016833+richardscull@users.noreply.github.com> Date: Wed, 13 Aug 2025 02:01:35 +0300 Subject: [PATCH 06/19] feat: Add workflows/test.yml --- .github/workflows/test.yml | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) create mode 100644 .github/workflows/test.yml 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 From 9d352fcb4d7a73387113b73d3037186db95a2d63 Mon Sep 17 00:00:00 2001 From: richardscull <106016833+richardscull@users.noreply.github.com> Date: Wed, 13 Aug 2025 02:02:15 +0300 Subject: [PATCH 07/19] chore: Update deprecated userName faker methods to username. --- src/lib/mock/faker.generator.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/lib/mock/faker.generator.ts b/src/lib/mock/faker.generator.ts index 67e63e9..398fec3 100644 --- a/src/lib/mock/faker.generator.ts +++ b/src/lib/mock/faker.generator.ts @@ -176,7 +176,7 @@ export class FakerGenerator { static generateUser(options?: DeepPartial): User { return { id: faker.string.uuid(), - username: faker.internet.userName(), + username: faker.internet.username(), discriminator: faker.string.numeric(4), bot: faker.datatype.boolean(), system: false, @@ -189,12 +189,12 @@ export class FakerGenerator { flags: null, createdAt: faker.date.past(), createdTimestamp: Date.now(), - displayName: faker.internet.userName(), + displayName: faker.internet.username(), defaultAvatarURL: faker.internet.url(), dmChannel: null, hexAccentColor: null, partial: false, - tag: `${faker.internet.userName()}#${faker.string.numeric(4)}`, + tag: `${faker.internet.username()}#${faker.string.numeric(4)}`, avatarURL: mock(() => faker.internet.url()), avatarDecorationURL: mock(() => null), bannerURL: mock(() => null), From 581b6850444590b190f77152aa1067a7764d5823 Mon Sep 17 00:00:00 2001 From: richardscull <106016833+richardscull@users.noreply.github.com> Date: Wed, 13 Aug 2025 02:05:05 +0300 Subject: [PATCH 08/19] fix: Ignore unfilled requiredEnvVariables for testing env; --- src/lib/configs/env.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/lib/configs/env.ts b/src/lib/configs/env.ts index f530863..57c8ec7 100644 --- a/src/lib/configs/env.ts +++ b/src/lib/configs/env.ts @@ -37,6 +37,7 @@ 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!`) } }) From 7b754882d7a918fc906f6251a5de1529eed5f191 Mon Sep 17 00:00:00 2001 From: richardscull <106016833+richardscull@users.noreply.github.com> Date: Mon, 20 Oct 2025 01:03:16 +0300 Subject: [PATCH 09/19] ref: clean up faker test mocks --- .../tests/pagination-set-page.model.test.ts | 2 - src/lib/mock/faker.generator.ts | 163 +++++++----------- src/lib/mock/mocker.ts | 44 ++--- 3 files changed, 85 insertions(+), 124 deletions(-) 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 index 29bd9f3..179ee16 100644 --- 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 @@ -73,8 +73,6 @@ describe("Pagination Set Page Modal", () => { const dataStoreId = container.utilities.actionStore.set(mock()) - console.log(dataStoreId) - const userId = faker.number.int().toString() const customId = FakerGenerator.generateCustomId({ diff --git a/src/lib/mock/faker.generator.ts b/src/lib/mock/faker.generator.ts index 398fec3..42543fd 100644 --- a/src/lib/mock/faker.generator.ts +++ b/src/lib/mock/faker.generator.ts @@ -3,17 +3,11 @@ import { jest, mock } from "bun:test" import { ApplicationCommand, ApplicationCommandType, - ButtonInteraction, - Client, - DMChannel, InteractionType, Locale, - Message, ModalSubmitInteraction, PermissionsBitField, User, - UserFlagsBitField, - type UserMention, } from "discord.js" import { faker } from "@faker-js/faker" @@ -21,6 +15,39 @@ import { Command, CommandStore, container } from "@sapphire/framework" import type { DeepPartial } from "@sapphire/utilities" import { buildCustomId } from "../utils/discord.util" +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(), + channel: null, + guild: null, + member: null, + token: "", + version: 0, + memberPermissions: null, + locale: Locale.French, + guildLocale: null, +}) + export class FakerGenerator { static generatePiece() { return { @@ -63,64 +90,38 @@ export class FakerGenerator { static generateInteraction( options?: DeepPartial, ): Command.ChatInputCommandInteraction { - return { - id: faker.string.uuid(), - applicationId: faker.string.uuid(), - channelId: faker.string.uuid(), - user: FakerGenerator.generateUser(), - guildId: faker.string.uuid(), + return autoMock({ + ...createBaseInteraction(), + user: options?.user ?? FakerGenerator.generateUser(), commandType: ApplicationCommandType.ChatInput, type: InteractionType.ApplicationCommand, - command: FakerGenerator.generateCommand(), + 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(), - channel: null, context: null, - createdAt: faker.date.past(), - createdTimestamp: Date.now(), - guild: null, - member: null, - token: "", - version: 0, - memberPermissions: null, - locale: Locale.French, - guildLocale: null, attachmentSizeLimit: 0, - options: {}, - ...options, - } as unknown as Command.ChatInputCommandInteraction + options: options?.options ?? {}, + ...(options as any), + }) } static generateModalSubmitInteraction( options?: DeepPartial, ): ModalSubmitInteraction { - return { - id: faker.string.uuid(), - applicationId: faker.string.uuid(), - channelId: faker.string.uuid(), - user: FakerGenerator.generateUser(), - guildId: faker.string.uuid(), + return autoMock({ + ...createBaseInteraction(), + user: options?.user ?? FakerGenerator.generateUser(), type: InteractionType.ModalSubmit, customId: faker.lorem.slug(), - channel: null, - createdAt: faker.date.past(), - createdTimestamp: Date.now(), - guild: null, - member: null, - token: "", - version: 0, - memberPermissions: null, - locale: Locale.French, - guildLocale: null, deferred: faker.datatype.boolean(), ephemeral: faker.datatype.boolean(), replied: faker.datatype.boolean(), isFromMessage: false, - message: null, + message: options?.message ?? null, fields: [], isModalSubmit: () => true, isButton: () => false, @@ -128,17 +129,8 @@ export class FakerGenerator { isAutocomplete: () => false, isCommand: () => false, isContextMenuCommand: () => false, - reply: mock(async () => null), - deferReply: mock(async () => null), - editReply: mock(async () => null), - fetchReply: mock(async () => null), - deleteReply: mock(async () => null), - followUp: mock(async () => null), - deferUpdate: mock(async () => null), - update: mock(async () => null), - showModal: mock(async () => null), - ...options, - } as unknown as ModalSubmitInteraction + ...(options as any), + }) } static withSubcommand( @@ -152,68 +144,45 @@ export class FakerGenerator { } static generateCommand(options?: DeepPartial>): ApplicationCommand<{}> { - return { - id: faker.string.uuid(), + return autoMock>({ + ...createBaseEntity(), applicationId: faker.string.uuid(), guildId: faker.string.uuid(), type: ApplicationCommandType.ChatInput, - createdAt: faker.date.past(), - createdTimestamp: Date.now(), guild: null, version: `v${faker.number.int({ min: 1, max: 100 })}`, contexts: [], - client: container.client as unknown as Client, + client: container.client as any, defaultMemberPermissions: new PermissionsBitField(PermissionsBitField.Flags.SendMessages), description: faker.lorem.sentence(), options: [], descriptionLocalizations: {}, descriptionLocalized: faker.lorem.sentence(), dmPermission: true, - ...options, - } as unknown as ApplicationCommand<{}> + ...(options as any), + }) } static generateUser(options?: DeepPartial): User { - return { - id: faker.string.uuid(), - username: faker.internet.username(), + 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, - accentColor: faker.datatype.boolean() ? faker.number.int({ min: 0, max: 0xffffff }) : null, - avatar: faker.datatype.boolean() ? faker.string.alphanumeric(32) : null, - avatarDecoration: null, - avatarDecorationData: null, - banner: null, - globalName: faker.datatype.boolean() ? faker.person.fullName() : null, - flags: null, - createdAt: faker.date.past(), - createdTimestamp: Date.now(), - displayName: faker.internet.username(), + displayName: username, defaultAvatarURL: faker.internet.url(), - dmChannel: null, - hexAccentColor: null, partial: false, - tag: `${faker.internet.username()}#${faker.string.numeric(4)}`, - avatarURL: mock(() => faker.internet.url()), - avatarDecorationURL: mock(() => null), - bannerURL: mock(() => null), - displayAvatarURL: mock(() => faker.internet.url()), - equals: mock(() => false), - createDM: mock(async () => null) as unknown as ( - force?: boolean | undefined, - ) => Promise, - deleteDM: mock(async () => null) as unknown as () => Promise, - fetch: mock(async () => null) as unknown as (force?: boolean | undefined) => Promise, - fetchFlags: mock(async () => null) as unknown as () => Promise, - toString: mock(() => `<@${faker.string.uuid()}>`) as unknown as () => UserMention, - toJSON: mock(() => ({})) as unknown as () => Record, - client: container.client as unknown as Client, - send: mock(async () => null) as unknown as ( - content: string, - options?: any, - ) => Promise>, - ...options, - } as unknown as User + tag: `${username}#${faker.string.numeric(4)}`, + client: container.client as any, + avatarURL: () => faker.internet.url(), + displayAvatarURL: () => faker.internet.url(), + toString: () => `<@${userId}>`, + ...(options as any), + }) } } diff --git a/src/lib/mock/mocker.ts b/src/lib/mock/mocker.ts index 982942b..d89d4dc 100644 --- a/src/lib/mock/mocker.ts +++ b/src/lib/mock/mocker.ts @@ -1,18 +1,18 @@ import { mock } from "bun:test" - -import { IntentsBitField } from "discord.js" 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 { EmbedPresetsUtility } from "../../utilities/embed-presets.utility" -import { PaginationUtility } from "../../utilities/pagination.utility" +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 { faker } from "@faker-js/faker" -import { SubcommandPluginEvents } from "@sapphire/plugin-subcommands" export class Mocker { static createSapphireClientInstance() { @@ -31,35 +31,28 @@ export class Mocker { info: mock(), warn: mock(), error: mock(), - has: mock((level: LogLevel) => { - return level === LogLevel.Debug - }), - } - - container.utilities = { - ...container.utilities, - store: new UtilitiesStore(), - exposePiece(name, piece) { - container.utilities.store.set(name, piece) - }, + 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: container.utilities.exposePiece.bind(container.utilities), + exposePiece(name, piece) { + store.set(name, piece) + }, } - Mocker.createDatabaseInMemory() + this.createDatabaseInMemory() } static async resetSapphireClientInstance() { await container.client.destroy() - container.db.close() - Mocker.createDatabaseInMemory() + this.createDatabaseInMemory() } static createCommandInstance(CommandClass: new (...args: any[]) => T): T { @@ -68,7 +61,7 @@ export class Mocker { name: faker.word.adjective(), path: faker.system.filePath(), root: faker.system.directoryPath(), - store: container?.utilities?.store ?? {}, + store: container.utilities.store, }, {}, ) @@ -78,9 +71,9 @@ export class Mocker { const errorHandler = mock() container.client.on(Events.ChatInputCommandError, errorHandler) container.client.on(SubcommandPluginEvents.ChatInputSubcommandError, errorHandler) - return errorHandler } + static mockApiRequest(mockedEndpointMethod: string, implementation: () => Promise) { mock.module(path.resolve(process.cwd(), "src", "lib", "types", "api"), () => ({ [mockedEndpointMethod]: implementation, @@ -88,8 +81,9 @@ export class Mocker { } private static createDatabaseInMemory() { - if (!container) throw new Error("Container is not initialized") - + if (!container) { + throw new Error("Container is not initialized") + } container.db = new Database(":memory:") migrate(container.db, getMigrations(path.resolve(process.cwd(), "data", "migrations"))) } From 62dda9c12609110116e970c24a12c03a95bf55c8 Mon Sep 17 00:00:00 2001 From: richardscull <106016833+richardscull@users.noreply.github.com> Date: Mon, 20 Oct 2025 01:24:32 +0300 Subject: [PATCH 10/19] feat: Add tests for run pagination set page modal func --- .../tests/pagination-set-page.model.test.ts | 169 +++++++++++++++++- src/lib/mock/faker.generator.ts | 41 ++--- 2 files changed, 182 insertions(+), 28 deletions(-) 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 index 179ee16..ff73ae7 100644 --- 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 @@ -24,7 +24,6 @@ describe("Pagination Set Page Modal", () => { beforeAll(() => { Mocker.createSapphireClientInstance() - // osuCommand = Mocker.createCommandInstance(OsuCommand) errorHandler = Mocker.createErrorHandler() }) @@ -94,5 +93,171 @@ describe("Pagination Set Page Modal", () => { }) }) - // TODO: Implement tests for run method + 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/mock/faker.generator.ts b/src/lib/mock/faker.generator.ts index 42543fd..f8c06cb 100644 --- a/src/lib/mock/faker.generator.ts +++ b/src/lib/mock/faker.generator.ts @@ -14,6 +14,7 @@ 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" function autoMock(base: Partial): T { return new Proxy(base as T, { @@ -38,14 +39,7 @@ const createBaseInteraction = () => ({ applicationId: faker.string.uuid(), channelId: faker.string.uuid(), guildId: faker.string.uuid(), - channel: null, - guild: null, - member: null, - token: "", - version: 0, - memberPermissions: null, locale: Locale.French, - guildLocale: null, }) export class FakerGenerator { @@ -102,9 +96,6 @@ export class FakerGenerator { deferred: faker.datatype.boolean(), ephemeral: faker.datatype.boolean(), replied: faker.datatype.boolean(), - context: null, - attachmentSizeLimit: 0, - options: options?.options ?? {}, ...(options as any), }) } @@ -120,15 +111,6 @@ export class FakerGenerator { deferred: faker.datatype.boolean(), ephemeral: faker.datatype.boolean(), replied: faker.datatype.boolean(), - isFromMessage: false, - message: options?.message ?? null, - fields: [], - isModalSubmit: () => true, - isButton: () => false, - isSelectMenu: () => false, - isAutocomplete: () => false, - isCommand: () => false, - isContextMenuCommand: () => false, ...(options as any), }) } @@ -149,16 +131,10 @@ export class FakerGenerator { applicationId: faker.string.uuid(), guildId: faker.string.uuid(), type: ApplicationCommandType.ChatInput, - guild: null, version: `v${faker.number.int({ min: 1, max: 100 })}`, - contexts: [], client: container.client as any, defaultMemberPermissions: new PermissionsBitField(PermissionsBitField.Flags.SendMessages), description: faker.lorem.sentence(), - options: [], - descriptionLocalizations: {}, - descriptionLocalized: faker.lorem.sentence(), - dmPermission: true, ...(options as any), }) } @@ -176,7 +152,6 @@ export class FakerGenerator { system: false, displayName: username, defaultAvatarURL: faker.internet.url(), - partial: false, tag: `${username}#${faker.string.numeric(4)}`, client: container.client as any, avatarURL: () => faker.internet.url(), @@ -185,4 +160,18 @@ export class FakerGenerator { ...(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), + }) + } } From 44e210525ba67157f15a1cf7b03f34425cdaafba Mon Sep 17 00:00:00 2001 From: richardscull <106016833+richardscull@users.noreply.github.com> Date: Mon, 20 Oct 2025 03:51:00 +0300 Subject: [PATCH 11/19] feat: add score subcommand tests --- src/lib/mock/faker.generator.ts | 92 ++++++ src/lib/mock/mocker.ts | 10 + .../osu/tests/score.subcommand.test.ts | 273 ++++++++++++++++++ 3 files changed, 375 insertions(+) create mode 100644 src/subcommands/osu/tests/score.subcommand.test.ts diff --git a/src/lib/mock/faker.generator.ts b/src/lib/mock/faker.generator.ts index f8c06cb..1a2e454 100644 --- a/src/lib/mock/faker.generator.ts +++ b/src/lib/mock/faker.generator.ts @@ -15,6 +15,8 @@ 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 } from "../types/api" function autoMock(base: Partial): T { return new Proxy(base as T, { @@ -174,4 +176,94 @@ export class FakerGenerator { ...(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: faker.internet.url(), + banner_url: faker.internet.url(), + 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, + } + } } diff --git a/src/lib/mock/mocker.ts b/src/lib/mock/mocker.ts index d89d4dc..08ae52e 100644 --- a/src/lib/mock/mocker.ts +++ b/src/lib/mock/mocker.ts @@ -13,6 +13,7 @@ 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() { @@ -46,6 +47,11 @@ export class Mocker { }, } + container.config = { + ...config, + sunrise: { uri: "sunrise.example.com" }, + } + this.createDatabaseInMemory() } @@ -80,6 +86,10 @@ export class Mocker { })) } + static mockApiRequests(mocks: Record Promise>) { + mock.module(path.resolve(process.cwd(), "src", "lib", "types", "api"), () => mocks) + } + private static createDatabaseInMemory() { if (!container) { throw new Error("Container is not initialized") 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..ec45aa9 --- /dev/null +++ b/src/subcommands/osu/tests/score.subcommand.test.ts @@ -0,0 +1,273 @@ +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() + }) + + 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(), + ) + }) +}) From f060998821a0325f02aba62863a5899afd2fde50 Mon Sep 17 00:00:00 2001 From: richardscull <106016833+richardscull@users.noreply.github.com> Date: Mon, 20 Oct 2025 04:06:16 +0300 Subject: [PATCH 12/19] feat: add profile subcommand tests --- src/lib/mock/faker.generator.ts | 52 +- .../osu/tests/profile.subcommand.test.ts | 475 ++++++++++++++++++ 2 files changed, 524 insertions(+), 3 deletions(-) create mode 100644 src/subcommands/osu/tests/profile.subcommand.test.ts diff --git a/src/lib/mock/faker.generator.ts b/src/lib/mock/faker.generator.ts index 1a2e454..d680883 100644 --- a/src/lib/mock/faker.generator.ts +++ b/src/lib/mock/faker.generator.ts @@ -16,7 +16,13 @@ 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 } from "../types/api" +import type { + ScoreResponse, + BeatmapResponse, + UserResponse, + UserStatsResponse, + UserWithStats, +} from "../types/api" function autoMock(base: Partial): T { return new Proxy(base as T, { @@ -182,8 +188,8 @@ export class FakerGenerator { user_id: faker.number.int({ min: 1, max: 1000000 }), username: faker.internet.username(), country_code: faker.location.countryCode(), - avatar_url: faker.internet.url(), - banner_url: faker.internet.url(), + 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, @@ -266,4 +272,44 @@ export class FakerGenerator { ...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/subcommands/osu/tests/profile.subcommand.test.ts b/src/subcommands/osu/tests/profile.subcommand.test.ts new file mode 100644 index 0000000..95dccd9 --- /dev/null +++ b/src/subcommands/osu/tests/profile.subcommand.test.ts @@ -0,0 +1,475 @@ +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() + }) + + beforeEach(() => { + errorHandler.mockClear() + }) + + afterAll(async () => { + await Mocker.resetSapphireClientInstance() + }) + + 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(), + ) + }) +}) From ebfbfeb63d06cfa18ad6d42c4d07686a6f950e6a Mon Sep 17 00:00:00 2001 From: richardscull <106016833+richardscull@users.noreply.github.com> Date: Mon, 20 Oct 2025 04:09:31 +0300 Subject: [PATCH 13/19] feat: add recent score subcommand tests --- .../osu/tests/recent-score.subcommand.test.ts | 528 ++++++++++++++++++ 1 file changed, 528 insertions(+) create mode 100644 src/subcommands/osu/tests/recent-score.subcommand.test.ts 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..3a5cc84 --- /dev/null +++ b/src/subcommands/osu/tests/recent-score.subcommand.test.ts @@ -0,0 +1,528 @@ +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() + }) + + 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(), + ) + }) +}) From fb4339fe4578f638e5462f6239d852818f15ff44 Mon Sep 17 00:00:00 2001 From: richardscull <106016833+richardscull@users.noreply.github.com> Date: Mon, 20 Oct 2025 04:16:58 +0300 Subject: [PATCH 14/19] feat: add scores subcommand tests --- src/lib/mock/mocker.ts | 7 +- .../osu/tests/scores.subcommand.test.ts | 515 ++++++++++++++++++ 2 files changed, 520 insertions(+), 2 deletions(-) create mode 100644 src/subcommands/osu/tests/scores.subcommand.test.ts diff --git a/src/lib/mock/mocker.ts b/src/lib/mock/mocker.ts index 08ae52e..1b9d357 100644 --- a/src/lib/mock/mocker.ts +++ b/src/lib/mock/mocker.ts @@ -80,13 +80,16 @@ export class Mocker { return errorHandler } - static mockApiRequest(mockedEndpointMethod: string, implementation: () => Promise) { + static mockApiRequest( + mockedEndpointMethod: keyof T, + implementation: (...args: any[]) => Promise, + ) { mock.module(path.resolve(process.cwd(), "src", "lib", "types", "api"), () => ({ [mockedEndpointMethod]: implementation, })) } - static mockApiRequests(mocks: Record Promise>) { + static mockApiRequests Promise>>(mocks: T) { mock.module(path.resolve(process.cwd(), "src", "lib", "types", "api"), () => mocks) } 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..7539589 --- /dev/null +++ b/src/subcommands/osu/tests/scores.subcommand.test.ts @@ -0,0 +1,515 @@ +import { expect, describe, it, beforeAll, afterAll, 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, 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() + }) + + 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() + }) +}) From c8b91a1917c07a365c55006aedfe4caa98ea64e3 Mon Sep 17 00:00:00 2001 From: richardscull <106016833+richardscull@users.noreply.github.com> Date: Mon, 20 Oct 2025 04:29:34 +0300 Subject: [PATCH 15/19] feat: add unlink subcommand tests --- src/lib/mock/mocker.ts | 19 +- .../osu/tests/unlink.subcommand.test.ts | 229 ++++++++++++++++++ 2 files changed, 247 insertions(+), 1 deletion(-) create mode 100644 src/subcommands/osu/tests/unlink.subcommand.test.ts diff --git a/src/lib/mock/mocker.ts b/src/lib/mock/mocker.ts index 1b9d357..c5c7fa2 100644 --- a/src/lib/mock/mocker.ts +++ b/src/lib/mock/mocker.ts @@ -1,4 +1,4 @@ -import { mock } from "bun:test" +import { jest, mock } from "bun:test" import { Database } from "bun:sqlite" import { getMigrations, migrate } from "bun-sqlite-migrations" import path from "path" @@ -61,6 +61,23 @@ export class Mocker { 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( { 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..3734cf3 --- /dev/null +++ b/src/subcommands/osu/tests/unlink.subcommand.test.ts @@ -0,0 +1,229 @@ +import { + expect, + describe, + it, + beforeAll, + afterAll, + beforeEach, + afterEach, + 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(), + ) + }) +}) From 9bba57a6039c4ab574724c1567a1508a1e0b8b9f Mon Sep 17 00:00:00 2001 From: richardscull <106016833+richardscull@users.noreply.github.com> Date: Mon, 20 Oct 2025 04:48:30 +0300 Subject: [PATCH 16/19] feat: Add beforeEachCleanup for each test + cleanup; --- src/commands/osu.command.ts | 2 +- src/commands/tests/meow.command.test.ts | 4 +++- .../tests/pagination-set-page.model.test.ts | 19 ++++--------------- .../osu/tests/link.subcommand.test.ts | 4 +++- .../osu/tests/profile.subcommand.test.ts | 6 ++---- .../osu/tests/recent-score.subcommand.test.ts | 2 ++ .../osu/tests/score.subcommand.test.ts | 2 ++ .../osu/tests/scores.subcommand.test.ts | 4 +++- .../osu/tests/unlink.subcommand.test.ts | 12 +----------- 9 files changed, 21 insertions(+), 34 deletions(-) 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 index e1f86b4..75f4476 100644 --- a/src/commands/tests/meow.command.test.ts +++ b/src/commands/tests/meow.command.test.ts @@ -1,4 +1,4 @@ -import { expect, describe, it, mock, jest, beforeAll, afterAll } from "bun:test" +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" @@ -17,6 +17,8 @@ describe("Meow Command", () => { await Mocker.resetSapphireClientInstance() }) + beforeEach(() => Mocker.beforeEachCleanup(errorHandler)) + it("should reply with 'meow! 😺' when chatInputRun is called", async () => { const replyMock = mock() const interaction = FakerGenerator.generateInteraction({ 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 index ff73ae7..5955db2 100644 --- 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 @@ -1,25 +1,12 @@ import { faker } from "@faker-js/faker" -import { - CommandStore, - container, - InteractionHandlerStore, - InteractionHandlerTypes, -} from "@sapphire/framework" -import { expect, describe, it, beforeAll, afterAll, jest, mock } from "bun:test" -import type { OsuCommand } from "../../../../commands/osu.command" -import { ExtendedError } from "../../../../lib/extended-error" +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 type { PaginationStore } from "../../../../lib/types/store.types" import { PaginationInteractionCustomId } from "../../../../lib/types/enum/custom-ids.types" describe("Pagination Set Page Modal", () => { - const modal = new PaginationSetPageModal( - { ...FakerGenerator.generateLoaderContext(), store: new InteractionHandlerStore() }, - { interactionHandlerType: InteractionHandlerTypes.ModalSubmit }, - ) - let errorHandler: jest.Mock beforeAll(() => { @@ -31,6 +18,8 @@ describe("Pagination Set Page Modal", () => { await Mocker.resetSapphireClientInstance() }) + beforeEach(() => Mocker.beforeEachCleanup(errorHandler)) + describe("parse", async () => { it("invalid interaction id provided", async () => { const modal = new PaginationSetPageModal( diff --git a/src/subcommands/osu/tests/link.subcommand.test.ts b/src/subcommands/osu/tests/link.subcommand.test.ts index f3b50f0..cf6c17c 100644 --- a/src/subcommands/osu/tests/link.subcommand.test.ts +++ b/src/subcommands/osu/tests/link.subcommand.test.ts @@ -1,4 +1,4 @@ -import { expect, describe, it, beforeAll, afterAll, jest, mock } from "bun:test" +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" @@ -20,6 +20,8 @@ describe("Osu Link Subcommand", () => { 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() diff --git a/src/subcommands/osu/tests/profile.subcommand.test.ts b/src/subcommands/osu/tests/profile.subcommand.test.ts index 95dccd9..22130ec 100644 --- a/src/subcommands/osu/tests/profile.subcommand.test.ts +++ b/src/subcommands/osu/tests/profile.subcommand.test.ts @@ -16,14 +16,12 @@ describe("Osu Profile Subcommand", () => { errorHandler = Mocker.createErrorHandler() }) - beforeEach(() => { - errorHandler.mockClear() - }) - 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() diff --git a/src/subcommands/osu/tests/recent-score.subcommand.test.ts b/src/subcommands/osu/tests/recent-score.subcommand.test.ts index 3a5cc84..b6b4560 100644 --- a/src/subcommands/osu/tests/recent-score.subcommand.test.ts +++ b/src/subcommands/osu/tests/recent-score.subcommand.test.ts @@ -21,6 +21,8 @@ describe("Osu Recent Score Subcommand", () => { 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() diff --git a/src/subcommands/osu/tests/score.subcommand.test.ts b/src/subcommands/osu/tests/score.subcommand.test.ts index ec45aa9..6c5cf7c 100644 --- a/src/subcommands/osu/tests/score.subcommand.test.ts +++ b/src/subcommands/osu/tests/score.subcommand.test.ts @@ -20,6 +20,8 @@ describe("Osu Score Subcommand", () => { 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 }) diff --git a/src/subcommands/osu/tests/scores.subcommand.test.ts b/src/subcommands/osu/tests/scores.subcommand.test.ts index 7539589..84df9df 100644 --- a/src/subcommands/osu/tests/scores.subcommand.test.ts +++ b/src/subcommands/osu/tests/scores.subcommand.test.ts @@ -1,4 +1,4 @@ -import { expect, describe, it, beforeAll, afterAll, jest, mock } from "bun:test" +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" @@ -20,6 +20,8 @@ describe("Osu Scores Subcommand", () => { 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 }) diff --git a/src/subcommands/osu/tests/unlink.subcommand.test.ts b/src/subcommands/osu/tests/unlink.subcommand.test.ts index 3734cf3..2c35760 100644 --- a/src/subcommands/osu/tests/unlink.subcommand.test.ts +++ b/src/subcommands/osu/tests/unlink.subcommand.test.ts @@ -1,14 +1,4 @@ -import { - expect, - describe, - it, - beforeAll, - afterAll, - beforeEach, - afterEach, - jest, - mock, -} from "bun:test" +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" From c8d820b700424192b055a5985f74a59e6f1bbc63 Mon Sep 17 00:00:00 2001 From: richardscull <106016833+richardscull@users.noreply.github.com> Date: Mon, 20 Oct 2025 04:56:44 +0300 Subject: [PATCH 17/19] feat: add pagination button tests --- .../buttons/tests/pagination.button.test.ts | 418 ++++++++++++++++++ src/lib/mock/faker.generator.ts | 14 + 2 files changed, 432 insertions(+) create mode 100644 src/interaction-handlers/pagination/buttons/tests/pagination.button.test.ts 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/lib/mock/faker.generator.ts b/src/lib/mock/faker.generator.ts index d680883..b339413 100644 --- a/src/lib/mock/faker.generator.ts +++ b/src/lib/mock/faker.generator.ts @@ -3,6 +3,7 @@ import { jest, mock } from "bun:test" import { ApplicationCommand, ApplicationCommandType, + ButtonInteraction, InteractionType, Locale, ModalSubmitInteraction, @@ -123,6 +124,19 @@ export class FakerGenerator { }) } + 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, From 7de8f74cead1d556a4058199792006f758606e90 Mon Sep 17 00:00:00 2001 From: richardscull <106016833+richardscull@users.noreply.github.com> Date: Mon, 20 Oct 2025 05:13:50 +0300 Subject: [PATCH 18/19] feat: add action-store utility tests --- .../tests/action-store.utility.test.ts | 294 ++++++++++++++++++ 1 file changed, 294 insertions(+) create mode 100644 src/utilities/tests/action-store.utility.test.ts 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 }) + }) + }) +}) From a47982c9ea090c4d156040e268949a9cf71f8bd2 Mon Sep 17 00:00:00 2001 From: richardscull <106016833+richardscull@users.noreply.github.com> Date: Mon, 20 Oct 2025 05:15:50 +0300 Subject: [PATCH 19/19] chore: Update README.md --- README.md | 1 + 1 file changed, 1 insertion(+) 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