Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -26,4 +26,4 @@ jobs:
bun run db:generate
bun run db:migrate

- run: bun test
- run: bun run test
24 changes: 3 additions & 21 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,8 @@ bun run dev # Run with hot reload (--watch)
bun run tsc # TypeScript type checking
bun run lint # Run ESLint
bun run lint:fix # Auto-fix linting issues
bun test # Run all tests
bun test:watch # Run tests in watch mode
bun run test # Run all tests (vitest)
bun run test:watch # Run tests in watch mode (vitest)
bun run verify # Run lint, type checking, and tests (comprehensive check)

# Deployment
Expand Down Expand Up @@ -234,25 +234,7 @@ The bot uses Pino for structured logging with the following features:

## Testing

### CI Environment Compatibility

Some tests may need to be conditionally skipped in CI environments due to infrastructure differences (e.g., Drizzle ORM compatibility issues with GitHub Actions). Use this pattern for database-dependent tests:

```typescript
// Skip database-dependent tests in CI environment where Drizzle methods may be undefined.
const isCI = process.env.CI === "true" || process.env.GITHUB_ACTIONS === "true";
const describeOrSkip = isCI ? describe.skip : describe;

describeOrSkip("DatabaseDependentService", () => {
// Tests that require database functionality
});
```

This ensures:

- Tests run normally in local development
- CI builds pass by skipping problematic tests
- Easy to remove when underlying issues are resolved
Tests use [Vitest](https://vitest.dev/) as the test runner (configured in `vitest.config.ts`). All tests run in both local development and CI environments.

## Common Gotchas

Expand Down
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -116,8 +116,8 @@ For production deployments, the bot is automatically deployed via Forge when cha
- `bun lint` - Run ESLint
- `bun lint:fix` - Run ESLint with auto-fix
- `bun tsc` - Run TypeScript type checking
- `bun test` - Run all tests
- `bun test:watch` - Run tests in watch mode
- `bun run test` - Run all tests
- `bun run test:watch` - Run tests in watch mode
- `bun verify` - Run lint, type checking, and tests (comprehensive check)

## Commands
Expand Down
179 changes: 179 additions & 0 deletions bun.lock

Large diffs are not rendered by default.

9 changes: 5 additions & 4 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -32,9 +32,9 @@
"tsc": "tsc --noEmit",
"lint": "eslint src --cache",
"lint:fix": "eslint src --fix",
"test": "bun test",
"test:watch": "bun test --watch",
"verify": "bun run lint:fix && bun run tsc && bun test",
"test": "vitest run",
"test:watch": "vitest",
"verify": "bun run lint:fix && bun run tsc && vitest run",
"postinstall": "git config core.hooksPath .hooks && chmod +x .hooks/*"
},
"dependencies": {
Expand All @@ -57,7 +57,8 @@
"eslint-plugin-simple-import-sort": "^12.1.1",
"eslint-plugin-sort-keys-shorthand": "^3.0.0",
"eslint-plugin-unicorn": "^59.0.1",
"typescript-eslint": "^8.37.0"
"typescript-eslint": "^8.37.0",
"vitest": "^4.0.18"
},
"peerDependencies": {
"typescript": "^5"
Expand Down
12 changes: 6 additions & 6 deletions src/commands/frames.command.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { afterEach, beforeEach, describe, expect, it, mock, spyOn } from "bun:test";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";

import { FramesService } from "../services/frames.service";
import { createMockMessage } from "../test/mocks/discord.mock";
Expand All @@ -12,7 +12,7 @@ describe("Command: frames", () => {
});

afterEach(() => {
mock.restore();
vi.restoreAllMocks();
});

it("is defined", () => {
Expand All @@ -34,7 +34,7 @@ describe("Command: frames", () => {

it("shows error message for invalid input", async () => {
// ARRANGE
spyOn(FramesService, "processInput").mockReturnValue(null);
vi.spyOn(FramesService, "processInput").mockReturnValue(null);

// ACT
await framesCommand.execute(mockMessage, ["invalid", "input"], {} as any);
Expand All @@ -49,7 +49,7 @@ describe("Command: frames", () => {
// ARRANGE
const expectedOutput =
"**Time:** `1h 0min 0s 0ms`\n**FPS:** `60`\n**Frames:** `216000 (0x34bc0)`";
spyOn(FramesService, "processInput").mockReturnValue(expectedOutput);
vi.spyOn(FramesService, "processInput").mockReturnValue(expectedOutput);

// ACT
await framesCommand.execute(mockMessage, ["1h"], {} as any);
Expand All @@ -63,7 +63,7 @@ describe("Command: frames", () => {
// ARRANGE
const expectedOutput =
"**Time:** `0h 1min 0s 0ms`\n**FPS:** `60`\n**Frames:** `3600 (0xe10)`";
spyOn(FramesService, "processInput").mockReturnValue(expectedOutput);
vi.spyOn(FramesService, "processInput").mockReturnValue(expectedOutput);

// ACT
await framesCommand.execute(mockMessage, ["3600"], {} as any);
Expand All @@ -77,7 +77,7 @@ describe("Command: frames", () => {
// ARRANGE
const expectedOutput =
"**Time:** `1h 30min 0s 0ms`\n**FPS:** `30`\n**Frames:** `162000 (0x27990)`";
spyOn(FramesService, "processInput").mockReturnValue(expectedOutput);
vi.spyOn(FramesService, "processInput").mockReturnValue(expectedOutput);

// ACT
await framesCommand.execute(mockMessage, ["1h", "30min", "30fps"], {} as any);
Expand Down
37 changes: 21 additions & 16 deletions src/commands/mem.command.test.ts
Original file line number Diff line number Diff line change
@@ -1,26 +1,31 @@
import * as ra from "@retroachievements/api";
import { afterEach, beforeEach, describe, expect, it, type Mock, mock, spyOn } from "bun:test";
import type { Message } from "discord.js";
import { afterEach, beforeEach, describe, expect, it, type Mock, vi } from "vitest";

import { connectApiService } from "../services/connect-api.service";
import { createMockMessage } from "../test/mocks/discord.mock";
import memCommand from "./mem.command";

const { mockGetAchievementUnlocks, mockBuildAuthorization } = vi.hoisted(() => ({
mockGetAchievementUnlocks: vi.fn(),
mockBuildAuthorization: vi.fn(() => ({ username: "RABot", webApiKey: "test" })),
}));

vi.mock("@retroachievements/api", () => ({
getAchievementUnlocks: mockGetAchievementUnlocks,
buildAuthorization: mockBuildAuthorization,
}));

describe("Command: mem", () => {
let mockMessage: ReturnType<typeof createMockMessage>;
const mockGetAchievementUnlocks = mock();

beforeEach(() => {
mockMessage = createMockMessage();
mockGetAchievementUnlocks.mockReset();

// ... mock the @retroachievements/api functions ...
spyOn(ra, "getAchievementUnlocks").mockImplementation(mockGetAchievementUnlocks);
spyOn(ra, "buildAuthorization").mockReturnValue({ username: "RABot", webApiKey: "test" });
mockBuildAuthorization.mockClear();
});

afterEach(() => {
mock.restore();
vi.restoreAllMocks();
});

it("is defined", () => {
Expand Down Expand Up @@ -69,14 +74,14 @@ describe("Command: mem", () => {

it("processes achievement ID successfully", async () => {
// ARRANGE
const sentMsg = { edit: mock() } as unknown as Message;
const sentMsg = { edit: vi.fn() } as unknown as Message;
(mockMessage.reply as Mock<() => Promise<Message>>).mockResolvedValueOnce(sentMsg);

mockGetAchievementUnlocks.mockResolvedValueOnce({
game: { id: 789 },
});

spyOn(connectApiService, "getMemAddr").mockResolvedValueOnce("0xH1234=5");
vi.spyOn(connectApiService, "getMemAddr").mockResolvedValueOnce("0xH1234=5");

// ACT
await memCommand.execute(mockMessage, ["123456"], {} as any);
Expand All @@ -90,14 +95,14 @@ describe("Command: mem", () => {

it("processes achievement URL successfully", async () => {
// ARRANGE
const sentMsg = { edit: mock() } as unknown as Message;
const sentMsg = { edit: vi.fn() } as unknown as Message;
(mockMessage.reply as Mock<() => Promise<Message>>).mockResolvedValueOnce(sentMsg);

mockGetAchievementUnlocks.mockResolvedValueOnce({
game: { id: 789 },
});

spyOn(connectApiService, "getMemAddr").mockResolvedValueOnce("0xH1234=5");
vi.spyOn(connectApiService, "getMemAddr").mockResolvedValueOnce("0xH1234=5");

// ACT
await memCommand.execute(
Expand All @@ -115,7 +120,7 @@ describe("Command: mem", () => {

it("shows error message when game ID not found", async () => {
// ARRANGE
const sentMsg = { edit: mock() } as unknown as Message;
const sentMsg = { edit: vi.fn() } as unknown as Message;
(mockMessage.reply as Mock<() => Promise<Message>>).mockResolvedValueOnce(sentMsg);

mockGetAchievementUnlocks.mockResolvedValueOnce({});
Expand All @@ -131,14 +136,14 @@ describe("Command: mem", () => {

it("shows error message when MemAddr not found", async () => {
// ARRANGE
const sentMsg = { edit: mock() } as unknown as Message;
const sentMsg = { edit: vi.fn() } as unknown as Message;
(mockMessage.reply as Mock<() => Promise<Message>>).mockResolvedValueOnce(sentMsg);

mockGetAchievementUnlocks.mockResolvedValueOnce({
game: { id: 789 },
});

spyOn(connectApiService, "getMemAddr").mockResolvedValueOnce(null);
vi.spyOn(connectApiService, "getMemAddr").mockResolvedValueOnce(null);

// ACT
await memCommand.execute(mockMessage, ["123456"], {} as any);
Expand All @@ -151,7 +156,7 @@ describe("Command: mem", () => {

it("handles API errors gracefully", async () => {
// ARRANGE
const sentMsg = { edit: mock() } as unknown as Message;
const sentMsg = { edit: vi.fn() } as unknown as Message;
(mockMessage.reply as Mock<() => Promise<Message>>).mockResolvedValueOnce(sentMsg);

mockGetAchievementUnlocks.mockRejectedValueOnce(new Error("API Error"));
Expand Down
34 changes: 18 additions & 16 deletions src/handlers/message.handler.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { afterEach, beforeEach, describe, expect, it, mock, spyOn } from "bun:test";
import { Collection, PermissionsBitField } from "discord.js";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";

import type { BotClient, Command, SlashCommand } from "../models";
import { createMockClient, createMockMessage } from "../test/mocks/discord.mock";
Expand All @@ -16,7 +16,7 @@ describe("Handler: handleMessage", () => {

afterEach(() => {
// ... clear all mock calls ...
mock.restore();
vi.restoreAllMocks();
});

beforeEach(() => {
Expand All @@ -27,7 +27,7 @@ describe("Handler: handleMessage", () => {
description: "Test command",
usage: "!test",
category: "utility",
execute: mock(() => Promise.resolve()),
execute: vi.fn(() => Promise.resolve()),
};

mockSlashCommand = {
Expand All @@ -36,7 +36,7 @@ describe("Handler: handleMessage", () => {
description: "Test slash command",
} as any,
legacyName: "test",
execute: mock(() => Promise.resolve()),
execute: vi.fn(() => Promise.resolve()),
};

mockClient = createMockClient({
Expand All @@ -46,15 +46,17 @@ describe("Handler: handleMessage", () => {
});

// ... spy on utility functions ...
spyOn(CooldownManager, "checkCooldown").mockReturnValue(0);
spyOn(CooldownManager, "setCooldown").mockImplementation(() => {});
spyOn(CooldownManager, "formatCooldownMessage").mockReturnValue("⏱️ Please wait **3** seconds");
spyOn(CommandAnalytics, "startTracking").mockReturnValue(Date.now());
spyOn(CommandAnalytics, "trackLegacyCommand").mockImplementation(() => {});
spyOn(logger, "logCommandExecution").mockImplementation(() => logger.logger);
spyOn(logger, "logError").mockImplementation(() => {});
spyOn(logger, "logMigrationNotice").mockImplementation(() => {});
spyOn(migrationHelper, "sendMigrationNotice").mockResolvedValue(null);
vi.spyOn(CooldownManager, "checkCooldown").mockReturnValue(0);
vi.spyOn(CooldownManager, "setCooldown").mockImplementation(() => {});
vi.spyOn(CooldownManager, "formatCooldownMessage").mockReturnValue(
"⏱️ Please wait **3** seconds",
);
vi.spyOn(CommandAnalytics, "startTracking").mockReturnValue(Date.now());
vi.spyOn(CommandAnalytics, "trackLegacyCommand").mockImplementation(() => {});
vi.spyOn(logger, "logCommandExecution").mockImplementation(() => logger.logger);
vi.spyOn(logger, "logError").mockImplementation(() => {});
vi.spyOn(logger, "logMigrationNotice").mockImplementation(() => {});
vi.spyOn(migrationHelper, "sendMigrationNotice").mockResolvedValue(null);
});

it("is defined", () => {
Expand Down Expand Up @@ -153,8 +155,8 @@ describe("Handler: handleMessage", () => {
});
(CooldownManager.checkCooldown as any).mockReturnValue(3000); // ... 3 seconds remaining ...

const mockReply = { delete: mock(() => Promise.resolve()) };
message.reply = mock(() => Promise.resolve(mockReply as any));
const mockReply = { delete: vi.fn(() => Promise.resolve()) };
message.reply = vi.fn(() => Promise.resolve(mockReply as any));

// ACT
await handleMessage(message, mockClient);
Expand Down Expand Up @@ -283,7 +285,7 @@ describe("Handler: handleMessage", () => {
it("handles and logs command execution errors", async () => {
// ARRANGE
const testError = new Error("Test error");
mockCommand.execute = mock(() => Promise.reject(testError));
mockCommand.execute = vi.fn(() => Promise.reject(testError));

const message = createMockMessage({
content: "!test",
Expand Down
40 changes: 21 additions & 19 deletions src/services/achievement-unlocks.service.test.ts
Original file line number Diff line number Diff line change
@@ -1,31 +1,33 @@
import { beforeEach, describe, expect, it, mock, test } from "bun:test";
import { beforeEach, describe, expect, it, test, vi } from "vitest";

import { createMockAchievementUnlocks } from "../test/mocks/achievement-unlocks.mock";
import { AchievementUnlocksService, PAGE_SIZE } from "./achievement-unlocks.service";

// ... mock the @retroachievements/api module ...
const mockBuildAuthorization = mock(() => ({ username: "RABot", webApiKey: "test-key" }));
const mockGetAchievementUnlocks = mock(async (_auth, { achievementId, offset, count }) => {
if (achievementId === 99999) {
throw new Error("API Error: 404");
} else {
const data = createMockAchievementUnlocks(achievementId);
data.unlocks = data.unlocks.slice(offset, offset + count);

return data;
}
});
const { mockBuildAuthorization, mockGetAchievementUnlocks } = vi.hoisted(() => ({
mockBuildAuthorization: vi.fn(() => ({ username: "RABot", webApiKey: "test-key" })),
mockGetAchievementUnlocks: vi.fn(),
}));

mock.module("@retroachievements/api", () => ({
vi.mock("@retroachievements/api", () => ({
buildAuthorization: mockBuildAuthorization,
getAchievementUnlocks: mockGetAchievementUnlocks,
}));

describe("Service: AchievementUnlocksService", () => {
beforeEach(() => {
// ... reset mocks ...
mockBuildAuthorization.mockClear();
mockGetAchievementUnlocks.mockClear();
mockGetAchievementUnlocks
.mockClear()
.mockImplementation(async (_auth: any, { achievementId, offset, count }: any) => {
if (achievementId === 99999) {
throw new Error("API Error: 404");
}

const data = createMockAchievementUnlocks(achievementId);
data.unlocks = data.unlocks.slice(offset, offset + count);

return data;
});
});

describe("getAllAchievementUnlocks", () => {
Expand All @@ -41,7 +43,7 @@ describe("Service: AchievementUnlocksService", () => {
const result = await AchievementUnlocksService.getAllAchievementUnlocks(n);

// ASSERT
expect(result).toBeArrayOfSize(n);
expect(result).toHaveLength(n);
expect(result!.at(0)).toBe("User0");
expect(result!.at(-1)).toBe(`User${n - 1}`);
expect(mockGetAchievementUnlocks).toHaveBeenCalledTimes(Math.ceil(n / PAGE_SIZE));
Expand All @@ -59,7 +61,7 @@ describe("Service: AchievementUnlocksService", () => {
const result = await AchievementUnlocksService.getAllAchievementUnlocks(0);

// ASSERT
expect(result).toBeArrayOfSize(0);
expect(result).toHaveLength(0);
});

it("returns null if the achievement is not found", async () => {
Expand All @@ -78,7 +80,7 @@ describe("Service: AchievementUnlocksService", () => {
const result = await AchievementUnlocksService.getAllAchievementUnlocks(1);

// ASSERT
expect(result).toBeArrayOfSize(1);
expect(result).toHaveLength(1);
expect(mockGetAchievementUnlocks).toHaveBeenCalledTimes(2);
});

Expand Down
Loading