Skip to content

Commit

Permalink
67 inactivity purges (#68)
Browse files Browse the repository at this point in the history
* feat(#87): Made a start on the cron activity purges

* feat(#87): Added .gitattributes file so LF is always committed

* feat(#87): Added most of what is required to implement activity based purging. WSL is giving me dodgy test runs so screw that!

* feat(#87): Fixed line endings

* feat(#87): WIP: I give up with WSL, no more.

* fix: Fix test wonkyness

* feat: #67: Added meat to the bones for inactivity purges and maintenance of old activity records

* feat: #67: Activity scanning service fully tested

* feat: #67: Linting

* feat: #67: Fixed typing issue

* feat: #67: Purge service getPurgableMembers tested. Added inactive stats.

* feat: #67: Removed origin message from removeActivityRecord

* feat: #67: Comment

* feat: #67: Simplified IF statement

* feat: #67: Added dry run to thanos snap command

* feat: #67: Completed implementation and tests of the purge service.

* feat: #67: Added missing test to see if activity service was actually called

* feat: #67: Added missing test to see if activity service was actually called

* feat: #67: Migrated the start of the purges into the purge service, so cron and channel can be kept simple. Updated and added tests for purge cron service.

* feat: #67: Added sortMember basic tests, its a sort function

* feat: #67: Added guild member event tests

* feat: #67: Added thanos snap command tests

* feat: #67: Removed dead code

* feat: #67: Added purge cron service but commented out

* feat: #67: Migrated purge candidates report into the purging process itself. Added test to reflect this.

* feat: #67: Removed purge candidates command, this can be handled with a purge dry run now the report is in there.

* feat: #67: All tests now work

* feat: #67: Added DM sent to members to inform them they've been removed.

* feat: #67: Added additional checks to ensure DMs are not sent on dry run

* feat: #67: Added additional tests for Discord Service
  • Loading branch information
Maelstromeous committed Jul 11, 2024
1 parent 5fd599d commit 8c13c1e
Show file tree
Hide file tree
Showing 20 changed files with 1,908 additions and 524 deletions.
1 change: 1 addition & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -40,3 +40,4 @@ jobs:
with:
tag: ${{ steps.version.outputs.newTag }}
generateReleaseNotes: true
makeLatest: true
78 changes: 76 additions & 2 deletions src/discord/discord.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,11 @@ describe('DiscordService', () => {
TestBootstrapper.setupConfig(moduleRef);

service = moduleRef.get<DiscordService>(DiscordService);

jest.spyOn(service['logger'], 'error');
jest.spyOn(service['logger'], 'warn');
jest.spyOn(service['logger'], 'log');
jest.spyOn(service['logger'], 'debug');
});

afterEach(() => {
Expand Down Expand Up @@ -97,15 +102,34 @@ describe('DiscordService', () => {
expect(mockGuild.members.fetch).toHaveBeenCalledWith(memberId);
});

it('should throw an error when the member fetch attempt fails', async () => {
it('should throw with a warning log when the member is blank', async () => {
const guildId = '123456';
const memberId = '64321';
const mockGuild = TestBootstrapper.getMockGuild(guildId);
mockGuild.members.fetch = jest.fn().mockImplementation(() => null);

service.getGuild = jest.fn().mockResolvedValue(mockGuild);

const errorMsg = `Could not find member with ID ${memberId}`;
await expect(service.getGuildMember(guildId, memberId)).rejects.toThrow(errorMsg);
expect(service['logger'].warn).toHaveBeenCalledWith(errorMsg);
expect(service.getGuild).toHaveBeenCalledWith(guildId);
expect(mockGuild.members.fetch).toHaveBeenCalledWith(memberId);
});

it('should throw an error when Discord call errors', async () => {
const guildId = '123456';
const memberId = '64321';
const mockGuild = TestBootstrapper.getMockGuild(guildId);
mockGuild.members.fetch = jest.fn().mockImplementation(() => null);

service.getGuild = jest.fn().mockResolvedValue(mockGuild);

await expect(service.getGuildMember(guildId, memberId)).rejects.toThrow(`Could not find member with ID ${memberId}`);
mockGuild.members.fetch = jest.fn().mockImplementation(() => {throw new Error('Discord went boom');});

const errorMsg = `Failed to fetch member with ID ${memberId}. Err: Discord went boom`;
await expect(service.getGuildMember(guildId, memberId)).rejects.toThrow(errorMsg);
expect(service['logger'].error).toHaveBeenCalledWith(errorMsg, expect.any(Error));
expect(service.getGuild).toHaveBeenCalledWith(guildId);
expect(mockGuild.members.fetch).toHaveBeenCalledWith(memberId);
});
Expand Down Expand Up @@ -207,4 +231,54 @@ describe('DiscordService', () => {
await service.deleteMessage(message);
});
});

describe('batchSend', () => {
it('should send messages in batches of 10', async () => {
const originMessage = TestBootstrapper.getMockDiscordMessage();
const messages = Array.from({ length: 25 }, (_, i) => `Message ${i + 1}`);
originMessage.channel.send = jest.fn().mockResolvedValue(true);

await service.batchSend(messages, originMessage);

expect(originMessage.channel.send).toHaveBeenCalledTimes(3);
expect(originMessage.channel.send).toHaveBeenCalledWith(expect.stringContaining('Message 10'));
expect(originMessage.channel.send).toHaveBeenCalledWith(expect.stringContaining('Message 20'));
expect(originMessage.channel.send).toHaveBeenCalledWith(expect.stringContaining('Message 25'));
});

it('should send all messages if less than 10', async () => {
const originMessage = TestBootstrapper.getMockDiscordMessage();
const messages = Array.from({ length: 5 }, (_, i) => `Message ${i + 1}`);
originMessage.channel.send = jest.fn().mockResolvedValue(true);

await service.batchSend(messages, originMessage);

expect(originMessage.channel.send).toHaveBeenCalledTimes(1);
expect(originMessage.channel.send).toHaveBeenCalledWith(expect.stringContaining('Message 5'));
});
});

describe('sendDM', () => {
it('should send a DM to a member successfully', async () => {
const member = TestBootstrapper.getMockDiscordUser();
const message = 'Hello, member!';
member.send = jest.fn().mockResolvedValue(true);

await service.sendDM(member, message);

expect(member.send).toHaveBeenCalledWith(message);
});

it('should log an error if sending DM fails', async () => {
const member = TestBootstrapper.getMockDiscordUser();
const message = 'Hello, member!';
const error = new Error('Failed to send DM');
member.send = jest.fn().mockRejectedValue(error);

await service.sendDM(member, message);

expect(member.send).toHaveBeenCalledWith(message);
expect(service['logger'].error).toHaveBeenCalledWith(`Failed to send DM to member ${member.id}`, error);
});
});
});
36 changes: 33 additions & 3 deletions src/discord/discord.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ export class DiscordService {
}
}

// Gets a guild member from the Discord server cache
async getGuildMember(guildId: string, memberId: string): Promise<GuildMember> {
const server = await this.getGuild(guildId);

Expand All @@ -46,12 +47,15 @@ export class DiscordService {
member = await server.members.fetch(memberId);
}
catch (err) {
this.logger.error(`Failed to fetch member with ID ${memberId}`, err);
throw new Error(`Failed to fetch member with ID ${memberId}. Err: ${err.message}`);
const error = `Failed to fetch member with ID ${memberId}. Err: ${err.message}`;
this.logger.error(error, err);
throw new Error(error);
}

if (!member) {
throw new Error(`Could not find member with ID ${memberId}`);
const error = `Could not find member with ID ${memberId}`;
this.logger.warn(error);
throw new Error(error);
}
return member;
}
Expand Down Expand Up @@ -94,4 +98,30 @@ export class DiscordService {
this.logger.error('Failed to delete message', err);
}
}

async batchSend(messages: string[], originMessage: Message): Promise<void> {
let count = 0;

// Loop each of the messages and carve them up into batches of 10
const batchMessages = [];
for (const message of messages) {
count++;
if (count % 10 === 0 || count === messages.length) {
batchMessages.push(message);
}
}

for (const batch of batchMessages) {
await originMessage.channel.send(batch);
}
}

async sendDM(member: GuildMember, message: string): Promise<void> {
try {
await member.send(message);
}
catch (err) {
this.logger.error(`Failed to send DM to member ${member.id}`, err);
}
}
}
19 changes: 14 additions & 5 deletions src/general/commands/activity.scan.command.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import { Command, Handler } from '@discord-nestjs/core';
import { Command, EventParams, Handler, InteractionEvent } from '@discord-nestjs/core';
import { ApplicationCommandType, ChatInputCommandInteraction } from 'discord.js';
import { Logger } from '@nestjs/common';
import { ActivityService } from '../services/activity.service';
import { SlashCommandPipe } from '@discord-nestjs/common';
import { DryRunDto } from '../dto/dry.run.dto';

@Command({
name: 'activity-scan',
Expand All @@ -14,15 +16,22 @@ export class ActivityScanCommand {
private readonly activityService: ActivityService,
) {}
@Handler()
async onCommand(interaction: ChatInputCommandInteraction): Promise<void> {

async onCommand(
@InteractionEvent(SlashCommandPipe) dto: DryRunDto,
@EventParams() interaction: ChatInputCommandInteraction[]
): Promise<void> {
const channel = interaction[0].channel;
const content = 'Initiated activity scan.';

await interaction.reply({
await interaction[0].reply({
content,
});

this.activityService.scanAndRemoveLeavers(interaction.channel);
if (dto.dryRun) {
await channel.send('## This is a dry run! No members will be kicked!');
}

this.activityService.startScan(channel, dto.dryRun);

this.logger.log('Activity scan command executed!');
}
Expand Down
110 changes: 0 additions & 110 deletions src/general/commands/purge.candidates.command.ts

This file was deleted.

80 changes: 80 additions & 0 deletions src/general/commands/thanos.snap.command.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
import { Test } from '@nestjs/testing';
import { ThanosSnapCommand } from './thanos.snap.command';
import { PurgeService } from '../services/purge.service';
import { ChatInputCommandInteraction } from 'discord.js';
import { DryRunDto } from '../dto/dry.run.dto';
import { TestBootstrapper } from '../../test.bootstrapper';
import { ReflectMetadataProvider } from '@discord-nestjs/core';

describe('ThanosSnapCommand', () => {
let command: ThanosSnapCommand;
let purgeService: PurgeService;

const mockPurgeService = {
startPurge: jest.fn(),
};

const mockDiscordUser = TestBootstrapper.getMockDiscordUser();

const mockInteraction = TestBootstrapper.getMockDiscordInteraction('12345', mockDiscordUser) as unknown as ChatInputCommandInteraction[];

beforeEach(async () => {
const moduleRef = await Test.createTestingModule({
providers: [
ThanosSnapCommand,
ReflectMetadataProvider,
{
provide: PurgeService,
useValue: mockPurgeService,
},
],
}).compile();

command = moduleRef.get<ThanosSnapCommand>(ThanosSnapCommand);
purgeService = moduleRef.get<PurgeService>(PurgeService);

jest.spyOn(command['logger'], 'error');
jest.spyOn(command['logger'], 'warn');
jest.spyOn(command['logger'], 'log');
jest.spyOn(command['logger'], 'debug');
});

afterEach(() => {
jest.clearAllMocks();
});

it('should be defined', () => {
expect(command).toBeDefined();
});

describe('onThanosSnapCommand', () => {
it('should log the execution of the command', async () => {
const dto: DryRunDto = { dryRun: false };

await command.onThanosSnapCommand(dto, mockInteraction);

expect(command['logger'].log).toHaveBeenCalledWith('Executing Thanos Snap Command');
expect(mockInteraction[0].reply).toHaveBeenCalledWith('I am... inevitable.');
expect(purgeService.startPurge).toHaveBeenCalled();
});

it('should send a dry run message if dryRun is true', async () => {
const dto: DryRunDto = { dryRun: true };

await command.onThanosSnapCommand(dto, mockInteraction);

expect(mockInteraction[0].reply).toHaveBeenCalledWith('I am... inevitable.');
expect(mockInteraction[0].channel.send).toHaveBeenCalledWith('## This is a dry run! No members will be kicked!');
expect(purgeService.startPurge).toHaveBeenCalled();
});

it('should send a gif and start the purge', async () => {
const dto: DryRunDto = { dryRun: false };

await command.onThanosSnapCommand(dto, mockInteraction);

expect(mockInteraction[0].channel.send).toHaveBeenCalledWith('https://media.giphy.com/media/ie76dJeem4xBDcf83e/giphy.gif');
expect(purgeService.startPurge).toHaveBeenCalled();
});
});
});
Loading

0 comments on commit 8c13c1e

Please sign in to comment.