Skip to content

Commit

Permalink
feat: AOC Leaderboard commands (#246)
Browse files Browse the repository at this point in the history
* feat: add aoc key and leaderboard id field

* feat: add command to set aoc settings

* feat: create function to fetch AOC data

After fetching, the schema will also sort the members before returning the result

* feat: add util functions to get and save leaderboard

* feat: add command to fetch leaderboard

* feat: add db migration for aoc models and settings

* fix: remove filtering out 0 points user

* test: use fake leaderboard id, guild id and session key in tests
  • Loading branch information
samhwang authored Dec 14, 2024
1 parent 5de61f5 commit dd0274d
Show file tree
Hide file tree
Showing 16 changed files with 772 additions and 7 deletions.
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,7 @@
"msw": "^2.6.8",
"pino-pretty": "^13.0.0",
"prisma": "^6.0.1",
"prisma-json-types-generator": "^3.2.2",
"tsup": "^8.3.5",
"tsx": "^4.19.2",
"typescript": "^5.7.2",
Expand Down
30 changes: 30 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
-- AlterTable
ALTER TABLE "ServerChannelsSettings" ADD COLUMN "aocKey" TEXT,
ADD COLUMN "aocLeaderboardId" TEXT;

-- CreateTable
CREATE TABLE "AocLeaderboard" (
"guildId" TEXT NOT NULL,
"result" JSONB NOT NULL,
"updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP
);

-- CreateIndex
CREATE UNIQUE INDEX "AocLeaderboard_guildId_key" ON "AocLeaderboard"("guildId");

-- CreateIndex
CREATE INDEX "AocLeaderboard_guildId_idx" ON "AocLeaderboard"("guildId");
24 changes: 20 additions & 4 deletions prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,11 @@ datasource db {
generator client {
provider = "prisma-client-js"
binaryTargets = ["native", "debian-openssl-1.1.x"]
previewFeatures = ["relationJoins"]
previewFeatures = ["relationJoins", "omitApi"]
}

generator json {
provider = "prisma-json-types-generator"
}

model User {
Expand Down Expand Up @@ -56,9 +60,21 @@ model Reminder {
}

model ServerChannelsSettings {
guildId String @unique
reminderChannel String? @unique
autobumpThreads String[] @default([])
guildId String @unique
reminderChannel String? @unique
autobumpThreads String[] @default([])
aocKey String?
aocLeaderboardId String?
@@index(guildId)
}

model AocLeaderboard {
guildId String @unique
/// [AocLeaderboardData]
result Json
updatedAt DateTime @default(now())
@@index(guildId)
}
15 changes: 14 additions & 1 deletion src/clients/prisma.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,18 @@
import { PrismaClient } from '@prisma/client';
import type { AocLeaderboard } from '../slash-commands/aoc-leaderboard/schema';

const prisma: PrismaClient = new PrismaClient();
declare global {
namespace PrismaJson {
type AocLeaderboardData = AocLeaderboard;
}
}

const prisma = new PrismaClient({
omit: {
serverChannelsSettings: {
aocKey: true,
},
},
});

export const getDbClient = () => prisma;
24 changes: 24 additions & 0 deletions src/slash-commands/aoc-leaderboard/client.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import wretch from 'wretch';
import { logger } from '../../utils/logger';
import { AocLeaderboard } from './schema';

function getAocClient(aocKey: string) {
return wretch('https://adventofcode.com')
.options({ credentials: 'same-origin' })
.headers({
Cookie: `session=${aocKey}`,
'User-Agent': 'https://github.com/viet-aus-it/discord-bot by [email protected]',
});
}

export async function fetchLeaderboard(aocKey: string, leaderboardId: string, year: number) {
const result = await getAocClient(aocKey).url(`/${year}/leaderboard/private/view/${leaderboardId}.json`).get().json();

const parsedResult = AocLeaderboard.safeParse(result);
if (!parsedResult.success) {
logger.error('ERROR: Cannot get leaderboard format.', parsedResult.error);
throw new Error(parsedResult.error.stack);
}

return parsedResult.data;
}
163 changes: 163 additions & 0 deletions src/slash-commands/aoc-leaderboard/index.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,163 @@
import { faker } from '@faker-js/faker';
import { subHours } from 'date-fns';
import type { ChatInputCommandInteraction } from 'discord.js';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { mockDeep, mockReset } from 'vitest-mock-extended';
import { execute, formatLeaderboard, getAocYear } from '.';
import mockAocData from './sample/aoc-data.json';
import { AocLeaderboard } from './schema';
import { fetchAndSaveLeaderboard, getAocSettings, getSavedLeaderboard } from './utils';

vi.mock('./utils');
const mockGetSavedLeaderboard = vi.mocked(getSavedLeaderboard);
const mockGetAocSettings = vi.mocked(getAocSettings);
const mockFetchAndSaveLeaderboard = vi.mocked(fetchAndSaveLeaderboard);
const mockInteraction = mockDeep<ChatInputCommandInteraction>();
const parsedMockData = AocLeaderboard.parse(mockAocData);
const mockKey = faker.string.alphanumeric({ length: 127 });
const mockLeaderboardId = faker.string.alphanumeric();
const mockGuildId = faker.string.numeric();

const mockSystemTime = new Date(2024, 11, 25, 16, 0, 0); // 25/12/2024 16:00:00
const oneHourEarlier = subHours(mockSystemTime, 1); // 25/12/2024 15:00:00

describe('Get AOC Leaderboard test', () => {
beforeEach(() => {
vi.useFakeTimers();
vi.setSystemTime(mockSystemTime);
mockReset(mockInteraction);
});

afterEach(() => {
vi.useRealTimers();
});

describe('Get AOC Year', () => {
it('Should return this year leaderboard if it is December', () => {
const date = new Date(2024, 11, 1);
vi.setSystemTime(date);
const year = getAocYear();
expect(year).toEqual(2024);
});

it('Should return previous year leaderboard if it is not December', () => {
const date = new Date(2024, 1, 1);
vi.setSystemTime(date);
const year = getAocYear();
expect(year).toEqual(2023);
});
});

describe('Format leaderboard', () => {
it('Should match the leaderboard format', () => {
const leaderboardMessage = formatLeaderboard({
result: parsedMockData,
updatedAt: mockSystemTime,
});
expect(leaderboardMessage).toEqual(`\`\`\`
# name score
1 (anonymous user 4) 474
2 user2 361
3 user3 353
4 user1 0
Last updated at: 25/12/2024 16:00
\`\`\``);
});
});

describe('Command tests', () => {
it('Should reply with saved leaderboard if it can get one', async () => {
mockGetSavedLeaderboard.mockResolvedValueOnce({
result: parsedMockData,
updatedAt: mockSystemTime,
});

await execute(mockInteraction);

expect(mockInteraction.editReply).toHaveBeenCalledWith(`\`\`\`
# name score
1 (anonymous user 4) 474
2 user2 361
3 user3 353
4 user1 0
Last updated at: 25/12/2024 16:00
\`\`\``);
});

it('Should reply with error if it errors out while finding aoc settings', async () => {
mockGetSavedLeaderboard.mockResolvedValueOnce({
result: parsedMockData,
updatedAt: oneHourEarlier,
});
mockGetAocSettings.mockRejectedValueOnce(new Error('Synthetic Error Get Settings'));

await execute(mockInteraction);

expect(mockInteraction.editReply).toHaveBeenCalledWith('ERROR: Error: Synthetic Error Get Settings');
});

it('Should reply with error if server is not configured', async () => {
mockGetSavedLeaderboard.mockResolvedValueOnce({
result: parsedMockData,
updatedAt: oneHourEarlier,
});
mockGetAocSettings.mockResolvedValueOnce(null);

await execute(mockInteraction);

expect(mockInteraction.editReply).toHaveBeenCalledWith('ERROR: Server is not configured to get AOC results! Missing Key and/or Leaderboard ID.');
});

it('Should reply with error if there is one during fetching and saving', async () => {
mockGetSavedLeaderboard.mockResolvedValueOnce({
result: parsedMockData,
updatedAt: oneHourEarlier,
});
mockGetAocSettings.mockResolvedValueOnce({
guildId: mockGuildId,
aocKey: mockKey,
aocLeaderboardId: mockLeaderboardId,
});
mockFetchAndSaveLeaderboard.mockRejectedValueOnce(new Error('Synthetic error fetch and save'));

await execute(mockInteraction);

expect(mockInteraction.editReply).toHaveBeenCalledWith(
'ERROR: Error fetching and/or saving new leaderboard result: Error: Synthetic error fetch and save'
);
});

it('Should reply with newly fetched leaderboard after fetching and saving', async () => {
mockGetSavedLeaderboard.mockResolvedValueOnce({
result: parsedMockData,
updatedAt: oneHourEarlier,
});
mockGetAocSettings.mockResolvedValueOnce({
guildId: mockGuildId,
aocKey: mockKey,
aocLeaderboardId: mockLeaderboardId,
});
mockFetchAndSaveLeaderboard.mockResolvedValueOnce({
result: parsedMockData,
updatedAt: mockSystemTime,
});

await execute(mockInteraction);

expect(mockInteraction.editReply).toHaveBeenCalledWith(`\`\`\`
# name score
1 (anonymous user 4) 474
2 user2 361
3 user3 353
4 user1 0
Last updated at: 25/12/2024 16:00
\`\`\``);
});
});
});
Loading

0 comments on commit dd0274d

Please sign in to comment.