-
Notifications
You must be signed in to change notification settings - Fork 6
test: add Discord.js mocks and example tests #180
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from 2 commits
d149514
412c9cd
72d7d06
df263e5
64a885d
32822c1
146fe07
86a38be
4679c13
deceee3
88dce1e
443de56
acdec8e
bcfbc48
45faee2
576b343
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,5 +1,7 @@ | ||
| /* eslint-disable no-undef */ | ||
| /** @type {import('ts-jest/dist/types').InitialOptionsTsJest} */ | ||
| module.exports = { | ||
| preset: 'ts-jest', | ||
| testEnvironment: 'node', | ||
| }; | ||
| preset: "ts-jest", | ||
| testEnvironment: "node", | ||
| setupFilesAfterEnv: ["<rootDir>/src/test/setup.ts"], | ||
| }; |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,98 @@ | ||
| import { RequestApplication } from "./request-application-button-commands"; | ||
| import { setupApplicationTest } from "../../test/helpers/test-setup"; | ||
| import { createMockGuild } from "../../test/mocks/create-mock-guild"; | ||
| import { createMockButtonInteraction } from "../../test/mocks/create-mock-button-interaction"; | ||
| import { asButtonInteraction } from "../../test/utils/discord-conversions"; | ||
| import { executeButtonCommand } from "../../test/helpers/execution-helpers"; | ||
| import { MessageCreateOptions } from "discord.js"; | ||
|
|
||
| jest.mock("../../config", () => ({ | ||
| requestDumpThreadId: "111222333", | ||
| })); | ||
|
|
||
| describe("RequestApplication", () => { | ||
| let requestApplication: RequestApplication; | ||
|
|
||
| beforeEach(() => { | ||
| requestApplication = new RequestApplication(); | ||
| }); | ||
|
|
||
| describe("execute", () => { | ||
| it("should send DM to user and log to request dump channel", async () => { | ||
| const { interaction, threadChannel } = setupApplicationTest(); | ||
| const resolvedThreadChannel = await threadChannel; | ||
|
|
||
| await requestApplication.execute(asButtonInteraction(interaction)); | ||
|
|
||
| expect(interaction.user).toHaveSentDm(/DO NOT REPLY TO THIS MESSAGE/); | ||
| expect(interaction).toHaveEditedReply( | ||
| "You have been DM'd the **Volunteer Application**." | ||
| ); | ||
| expect(resolvedThreadChannel).toHaveSentMessage( | ||
| `Volunteer Application sent to **${interaction.user.username}** (<@${interaction.user.id}>)` | ||
| ); | ||
|
||
| }); | ||
|
|
||
| it("should throw error if request dump channel not found", async () => { | ||
| const guild = createMockGuild({ threadChannels: [] }); | ||
| const interaction = createMockButtonInteraction({ guild }); | ||
|
|
||
| await expect( | ||
| requestApplication.execute(asButtonInteraction(interaction)) | ||
|
||
| ).rejects.toThrow("Could not locate the request dump channel"); | ||
| }); | ||
|
|
||
| it("should throw error if channel is not a public thread", async () => { | ||
| const guild = createMockGuild({ | ||
| textChannels: [{ id: "111222333" }], // Wrong channel type | ||
| }); | ||
| const interaction = createMockButtonInteraction({ guild }); | ||
|
|
||
| await expect( | ||
| requestApplication.execute(asButtonInteraction(interaction)) | ||
| ).rejects.toThrow("111222333 is not a text channel"); | ||
| }); | ||
| }); | ||
|
|
||
| describe("label", () => { | ||
| it("should return correct label", () => { | ||
| expect(requestApplication.label).toBe("Volunteer Application"); | ||
| }); | ||
| }); | ||
|
|
||
| describe("getButtonBuilder", () => { | ||
| it("should create button with correct properties", () => { | ||
| const buttonBuilder = requestApplication.getButtonBuilder(1); // ButtonStyle.Primary | ||
|
|
||
| expect(buttonBuilder).toHaveCustomId("volunteer-application"); | ||
| expect(buttonBuilder).toHaveLabel("Volunteer Application"); | ||
| expect(buttonBuilder.data.style).toBe(1); | ||
|
||
| }); | ||
| }); | ||
|
|
||
| describe("content", () => { | ||
| it("should contain required application information", async () => { | ||
| const { interaction } = setupApplicationTest(); | ||
|
|
||
| await requestApplication.execute(asButtonInteraction(interaction)); | ||
|
|
||
| const dmCall = interaction.user.send.mock.calls[0][0]; | ||
| const content = | ||
| typeof dmCall === "string" ? dmCall : (dmCall as any).content; | ||
|
|
||
| expect(content).toContain("DO NOT REPLY TO THIS MESSAGE"); | ||
| expect(content).toContain("How do I apply?"); | ||
| expect(content).toContain( | ||
| "https://docs.google.com/forms/d/e/1FAIpQLSelYSgoouJCOIV9qoOQ1FdOXj8oGC2pfv7P47iUUd1hjOic-g/viewform" | ||
| ); | ||
| expect(content).toContain("What happens to an application?"); | ||
| expect(content).toContain("less than a week"); | ||
|
||
| }); | ||
| }); | ||
|
|
||
| describe("customId", () => { | ||
| it("should inherit customId from ButtonCommand constructor", () => { | ||
| expect(requestApplication.customId).toBe("volunteer-application"); | ||
| }); | ||
| }); | ||
| }); | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,22 @@ | ||
| export const volunteerApplicationsEmbed = { | ||
| title: "Volunteer Applications", | ||
| description: `Castle is a casual-friendly guild and that applies to volunteer and leadership roles as well. We have many volunteer roles that help keep the guild running smoothly. | ||
|
|
||
| ❓ **What do volunteers do?** | ||
| • Read about each [volunteer role](https://docs.google.com/document/d/19fqGGGAW3tXTPb8syiCZQZED9qTGhY65dnaWL8YzZnE). | ||
|
|
||
| ❓ **What's expected of volunteers?** | ||
| • Represent us well, both internally and externally. | ||
| • Commit as much time as you like, when you'd like. | ||
| • You may take a break or step down at any time, but you will be required to re-apply if you become interested again. | ||
|
|
||
| ❓ **Am I a good candidate to volunteer? What if I'm an ally?** | ||
| • Yes! Everyone is encouraged to volunteer. | ||
| • All roles are open to alliance members except :red_square: **Officer** and :red_square: **Guard**, which are Castle-members only. | ||
|
|
||
| 📜 **How do I apply?** | ||
| • Press the button below to receive a link to the volunteer application in a DM. | ||
| • Retrieving the application is not a commitment to apply! | ||
|
|
||
| ✨ _"Many hands make light work!"_ ✨`, | ||
| }; |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,121 @@ | ||
| import { | ||
| UpdateApplicationInfoAction, | ||
| updateApplicationInfo, | ||
| } from "./update-applications"; | ||
| import { createMockClient } from "../../test/mocks/create-mock-client"; | ||
| import { asClient } from "../../test/utils/discord-conversions"; | ||
| import { volunteerApplicationsEmbed } from "./update-applications.fixture"; | ||
| import { Client, ButtonStyle } from "discord.js"; | ||
|
|
||
| jest.mock("../../config", () => ({ | ||
| applicationsChannelId: "999888777", | ||
| })); | ||
|
|
||
| // Mock the base class methods | ||
| const mockCreateOrUpdateInstructions = jest.fn(); | ||
| const mockGetChannel = jest.fn(); | ||
|
|
||
| jest.mock("../../shared/action/instructions-ready-action", () => ({ | ||
| InstructionsReadyAction: class { | ||
| createOrUpdateInstructions = mockCreateOrUpdateInstructions; | ||
| getChannel = mockGetChannel; | ||
| }, | ||
| })); | ||
|
|
||
| jest.mock("../../shared/action/ready-action", () => ({ | ||
| readyActionExecutor: jest.fn((action, options) => action.execute()), | ||
| })); | ||
|
||
|
|
||
| describe("UpdateApplicationInfoAction", () => { | ||
| let client: Client; | ||
| let action: UpdateApplicationInfoAction; | ||
|
|
||
| beforeEach(() => { | ||
| client = asClient(createMockClient()); | ||
|
||
| // Create instance using constructor | ||
| action = new UpdateApplicationInfoAction(client); | ||
| jest.clearAllMocks(); | ||
|
||
| }); | ||
|
|
||
| describe("execute", () => { | ||
| it("should create instructions with volunteer roles embed and button", async () => { | ||
| await action.execute(); | ||
|
|
||
| expect(mockCreateOrUpdateInstructions).toHaveBeenCalledWith( | ||
|
||
| expect.objectContaining({ | ||
| embeds: expect.arrayContaining([ | ||
| expect.objectContaining({ | ||
| data: expect.objectContaining({ | ||
| title: "Volunteer Applications", | ||
| }), | ||
| }), | ||
| ]), | ||
| components: expect.arrayContaining([ | ||
| expect.objectContaining({ | ||
| components: expect.arrayContaining([ | ||
| expect.objectContaining({ | ||
| data: expect.objectContaining({ | ||
| custom_id: "volunteer-application", | ||
| label: "Volunteer Application", | ||
| }), | ||
| }), | ||
| ]), | ||
| }), | ||
| ]), | ||
| }), | ||
| "applicationInstructions" | ||
| ); | ||
|
|
||
| const call = mockCreateOrUpdateInstructions.mock.calls[0][0]; | ||
|
|
||
| // Test embed matches fixture | ||
| const embed = call.embeds[0]; | ||
| expect(embed).toMatchFixture(volunteerApplicationsEmbed); | ||
|
|
||
| // Test component structure | ||
| const actionRow = call.components[0]; | ||
| expect(actionRow.components).toHaveLength(1); | ||
|
|
||
| const buttonBuilder = actionRow.components[0]; | ||
| expect(buttonBuilder.data.custom_id).toBe("volunteer-application"); | ||
| expect(buttonBuilder.data.label).toBe("Volunteer Application"); | ||
| expect(buttonBuilder.data.style).toBe(ButtonStyle.Primary); | ||
| }); | ||
| }); | ||
|
|
||
| describe("channel getter", () => { | ||
| it("should get applications channel with correct ID", () => { | ||
| // Access the protected property through reflection | ||
| const channel = (action as any).channel; | ||
|
||
|
|
||
| expect(mockGetChannel).toHaveBeenCalledWith("999888777", "applications"); | ||
| }); | ||
| }); | ||
| }); | ||
|
|
||
| describe("updateApplicationInfo", () => { | ||
| it("should execute UpdateApplicationInfoAction with readyActionExecutor", async () => { | ||
| const client = asClient(createMockClient()); | ||
| const options = {}; | ||
|
|
||
| await updateApplicationInfo(client, options); | ||
|
|
||
| const { readyActionExecutor } = require("../../shared/action/ready-action"); | ||
| expect(readyActionExecutor).toHaveBeenCalledWith( | ||
| expect.any(UpdateApplicationInfoAction), | ||
| options | ||
| ); | ||
| }); | ||
|
|
||
| it("should work without options", async () => { | ||
| const client = asClient(createMockClient()); | ||
|
|
||
| await updateApplicationInfo(client); | ||
|
|
||
| const { readyActionExecutor } = require("../../shared/action/ready-action"); | ||
| expect(readyActionExecutor).toHaveBeenCalledWith( | ||
| expect.any(UpdateApplicationInfoAction), | ||
| undefined | ||
| ); | ||
| }); | ||
| }); | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -21,7 +21,7 @@ export const updateApplicationInfo = ( | |
| options?: ReadyActionExecutorOptions | ||
| ) => readyActionExecutor(new UpdateApplicationInfoAction(client), options); | ||
|
|
||
| class UpdateApplicationInfoAction extends InstructionsReadyAction { | ||
| export class UpdateApplicationInfoAction extends InstructionsReadyAction { | ||
|
Owner
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Would be nice to not have to export this. Might mean tests need to interact at a higher level. That might also alleviate the need to mock that higher level tho. |
||
| public async execute(): Promise<void> { | ||
| await this.createOrUpdateInstructions( | ||
| { | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,15 @@ | ||
| import { TestButtonInteraction } from "../mocks/create-mock-button-interaction"; | ||
| import { TestClient } from "../mocks/create-mock-client"; | ||
| export async function executeButtonCommand( | ||
| command: { execute: (interaction: TestButtonInteraction) => Promise<void> }, | ||
| interaction: TestButtonInteraction | ||
| ): Promise<void> { | ||
| return command.execute(interaction); | ||
| } | ||
|
|
||
| export function executeWithMockClient<T>( | ||
| fn: (client: TestClient) => T, | ||
| client: TestClient | ||
| ): T { | ||
| return fn(client); | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,41 @@ | ||
| import { TestUser, createMockUser } from "../mocks/create-mock-user"; | ||
| import { TestGuild, createMockGuild } from "../mocks/create-mock-guild"; | ||
| import { | ||
| TestButtonInteraction, | ||
| createMockButtonInteraction, | ||
| } from "../mocks/create-mock-button-interaction"; | ||
| import { TestThreadChannel } from "../mocks/create-mock-thread-channel"; | ||
|
|
||
| export interface ApplicationTestSetupOptions { | ||
| requestDumpThreadId?: string; | ||
| customId?: string; | ||
| userId?: string; | ||
| username?: string; | ||
| } | ||
|
|
||
| export function setupApplicationTest({ | ||
| requestDumpThreadId = "111222333", | ||
| customId = "volunteer-application", | ||
| userId = "123456789", | ||
| username = "testuser", | ||
| }: ApplicationTestSetupOptions = {}) { | ||
| const user = createMockUser({ id: userId, username }); | ||
| const guild = createMockGuild({ | ||
| threadChannels: [{ id: requestDumpThreadId }], | ||
| }); | ||
| const interaction = createMockButtonInteraction({ | ||
| customId, | ||
| user, | ||
| guild, | ||
| }); | ||
|
|
||
| const threadChannel = guild.channels.fetch(requestDumpThreadId); | ||
|
|
||
| return { | ||
| interaction, | ||
| guild, | ||
| user, | ||
| threadChannel, | ||
| requestDumpThreadId, | ||
| }; | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This is weird, just make setupApplicationTest async.