Skip to content
Open
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 5 additions & 3 deletions jest.config.js
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"],
};
5 changes: 3 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -70,5 +70,6 @@
"ts-jest": "^27.1.4",
"ts-node": "^10.7.0",
"typescript": "^4.6.3"
}
}
},
"packageManager": "yarn@1.22.22+sha512.a6b2f7906b721bba3d67d4aff083df04dad64c399707841b7acf00f6b133b7ac24255f2652fa22ae3534329dc6180534e98d17432037ff6fd140556e2bb3137e"
}
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;
Copy link
Owner Author

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.


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}>)`
);
Copy link
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This matcher should probably read more like 'toHaveReceivedMessage'

});

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))
Copy link
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Seems like the mock generator should already perform asButtonInteraction cast, no?

).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);
Copy link
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What is 1? Maybe add a custom matcher for this

});
});

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");
Copy link
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This could be cleaner, both how we get the content and how we assert on it. Probably a helper for getting the content on the interaction mock, and a fixture for the assert.

});
});

describe("customId", () => {
it("should inherit customId from ButtonCommand constructor", () => {
expect(requestApplication.customId).toBe("volunteer-application");
});
});
});
22 changes: 22 additions & 0 deletions src/features/applications/update-applications.fixture.ts
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!"_ ✨`,
};
121 changes: 121 additions & 0 deletions src/features/applications/update-applications.test.ts
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()),
}));
Copy link
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not crazy about this. Probably a cleaner way to do this. I expect we will need to do it a lot, also it looks like tight coupling. Maybe some IoC could fix this.


describe("UpdateApplicationInfoAction", () => {
let client: Client;
let action: UpdateApplicationInfoAction;

beforeEach(() => {
client = asClient(createMockClient());
Copy link
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why not do the cast inside the mock generator?

// Create instance using constructor
action = new UpdateApplicationInfoAction(client);
jest.clearAllMocks();
Copy link
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just do this in a global before each if its needed. But also, is it?

});

describe("execute", () => {
it("should create instructions with volunteer roles embed and button", async () => {
await action.execute();

expect(mockCreateOrUpdateInstructions).toHaveBeenCalledWith(
Copy link
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not sure this is particularly useful as an assert. Maybe more we can do in the embed assert instead in order to check for other things like buttons.

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;
Copy link
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ew, cast to any


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
);
});
});
2 changes: 1 addition & 1 deletion src/features/applications/update-applications.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ export const updateApplicationInfo = (
options?: ReadyActionExecutorOptions
) => readyActionExecutor(new UpdateApplicationInfoAction(client), options);

class UpdateApplicationInfoAction extends InstructionsReadyAction {
export class UpdateApplicationInfoAction extends InstructionsReadyAction {
Copy link
Owner Author

Choose a reason for hiding this comment

The 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(
{
Expand Down
15 changes: 15 additions & 0 deletions src/test/helpers/execution-helpers.ts
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);
}
41 changes: 41 additions & 0 deletions src/test/helpers/test-setup.ts
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,
};
}
Loading