Skip to content
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

feat: AOC Leaderboard commands #246

Merged
merged 8 commits into from
Dec 14, 2024
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
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
Loading