Skip to content

Commit

Permalink
Merge branch 'main' into main
Browse files Browse the repository at this point in the history
  • Loading branch information
1WHISKY authored Oct 29, 2024
2 parents 04afc0f + 646b4d7 commit c473160
Show file tree
Hide file tree
Showing 31 changed files with 688 additions and 128 deletions.
236 changes: 235 additions & 1 deletion apps/backend-e2e/src/maps.e2e-spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,8 @@ import {
Role,
TrackType,
LeaderboardType,
FlatMapList
FlatMapList,
GamemodeCategory
} from '@momentum/constants';
import {
createSha1Hash,
Expand All @@ -45,6 +46,9 @@ import {
setupE2ETestEnvironment,
teardownE2ETestEnvironment
} from './support/environment';
import { HttpService } from '@nestjs/axios';
import { ConfigService } from '@nestjs/config';
import * as rxjs from 'rxjs';

describe('Maps', () => {
let app,
Expand Down Expand Up @@ -3022,6 +3026,236 @@ describe('Maps', () => {
);
});

describe('Discord webhooks', () => {
let httpPostMock: jest.SpyInstance,
httpPostObs: rxjs.Subject<void>,
configMock: jest.SpyInstance;

beforeAll(() => {
httpPostMock = jest.spyOn(app.get(HttpService), 'post');
httpPostObs = new rxjs.Subject();

httpPostMock.mockImplementation(() => {
httpPostObs.next();
return rxjs.of({
data: '',
status: 204,
statusText: 'No Content',
headers: {},
config: {}
});
});

const configService = app.get(ConfigService);
const getOrThrow = configService.getOrThrow;
configMock = jest.spyOn(app.get(ConfigService), 'getOrThrow');
configMock.mockImplementation((path) =>
path.startsWith('discordWebhooks.')
? 'http://localhost/webhook_' +
path.replace('discordWebhooks.', '')
: getOrThrow.bind(configService, path)
);
});

afterAll(() => {
httpPostMock.mockRestore();
configMock.mockRestore();
});

afterEach(() => httpPostMock.mockClear());

it('should execute discord webhook when map is in public testing', async () => {
const map = await db.createMap({
...createMapData,
status: MapStatus.FINAL_APPROVAL,
credits: {
create: {
type: MapCreditType.AUTHOR,
user: { connect: { id: user.id } }
}
},
submission: {
create: {
type: MapSubmissionType.ORIGINAL,
dates: [
{
status: MapStatus.PRIVATE_TESTING,
date: new Date().toJSON()
}
],
suggestions: [
{
trackType: TrackType.MAIN,
trackNum: 1,
gamemode: Gamemode.RJ,
tier: 1,
type: LeaderboardType.RANKED
},
{
trackType: TrackType.BONUS,
trackNum: 1,
gamemode: Gamemode.DEFRAG_CPM,
tier: 1,
type: LeaderboardType.UNRANKED
}
],
placeholders: [
{ type: MapCreditType.AUTHOR, alias: 'The Map Author' }
]
}
}
});

void req.patch({
url: `maps/${map.id}`,
status: 204,
body: {
status: MapStatus.PUBLIC_TESTING
},
token
});

await rxjs.firstValueFrom(httpPostObs);
expect(httpPostMock).toHaveBeenCalledTimes(1);
const requestBody = httpPostMock.mock.lastCall[1];
const embed = requestBody.embeds[0];

expect(embed.title).toBe(map.name);
expect(embed.description).toBe(
`By ${[user.alias, 'The Map Author']
.sort()
.map((a) => `**${a}**`)
.join(', ')}`
);
});

it('should execute discord webhook when map has been approved', async () => {
const map = await db.createMap({
...createMapData,
status: MapStatus.FINAL_APPROVAL,
credits: {
create: {
type: MapCreditType.AUTHOR,
user: { connect: { id: user.id } }
}
}
});

void req.patch({
url: `admin/maps/${map.id}`,
status: 204,
body: {
status: MapStatus.APPROVED,
finalLeaderboards: [
{
trackType: TrackType.MAIN,
trackNum: 1,
gamemode: Gamemode.RJ,
tier: 1,
type: LeaderboardType.RANKED
},
{
trackType: TrackType.BONUS,
trackNum: 1,
gamemode: Gamemode.DEFRAG_CPM,
tier: 1,
type: LeaderboardType.UNRANKED
}
]
},
token: adminToken
});

await rxjs.firstValueFrom(httpPostObs);
expect(httpPostMock).toHaveBeenCalledTimes(1);
const requestBody = httpPostMock.mock.lastCall[1];
const embed = requestBody.embeds[0];

expect(embed.title).toBe(map.name);
expect(embed.description).toBe(`By **${user.alias}**`);
});

it('should execute multiple webhooks for different gamemode categories', async () => {
const map = await db.createMap({
...createMapData,
status: MapStatus.FINAL_APPROVAL,
credits: {
create: {
type: MapCreditType.AUTHOR,
user: { connect: { id: user.id } }
}
},
submission: {
create: {
type: MapSubmissionType.ORIGINAL,
dates: [
{
status: MapStatus.PRIVATE_TESTING,
date: new Date().toJSON()
}
],
suggestions: [
{
trackType: TrackType.MAIN,
trackNum: 1,
gamemode: Gamemode.RJ,
tier: 1,
type: LeaderboardType.RANKED
},
{
trackType: TrackType.MAIN,
trackNum: 1,
gamemode: Gamemode.CONC,
tier: 1,
type: LeaderboardType.UNRANKED
},
{
trackType: TrackType.BONUS,
trackNum: 1,
gamemode: Gamemode.DEFRAG_CPM,
tier: 1,
type: LeaderboardType.UNRANKED
}
],
placeholders: [
{ type: MapCreditType.AUTHOR, alias: 'The Map Author' }
]
}
}
});

void req.patch({
url: `maps/${map.id}`,
status: 204,
body: {
status: MapStatus.PUBLIC_TESTING
},
token
});

await rxjs.firstValueFrom(httpPostObs.pipe(rxjs.take(2)));
expect(httpPostMock).toHaveBeenCalledTimes(2);

const requestUrls = httpPostMock.mock.calls.map((call) => call[0]);
expect(requestUrls.sort()).toEqual(
[GamemodeCategory.RJ, GamemodeCategory.CONC].map(
(gc) => 'http://localhost/webhook_' + gc
)
);

const requestBody = httpPostMock.mock.lastCall[1];
const embed = requestBody.embeds[0];

expect(embed.title).toBe(map.name);
expect(embed.description).toBe(
`By ${[user.alias, 'The Map Author']
.sort()
.map((a) => `**${a}**`)
.join(', ')}`
);
});
});

it('should 400 for invalid suggestions', async () => {
const map = await db.createMap({
...createMapData,
Expand Down
5 changes: 3 additions & 2 deletions apps/backend-e2e/src/users.e2e-spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,8 @@ import {
ActivityType,
MapCreditType,
MapStatus,
Role
Role,
steamAvatarUrl
} from '@momentum/constants';
import { PrismaClient } from '@prisma/client';
import {
Expand Down Expand Up @@ -220,7 +221,7 @@ describe('Users', () => {
});

expect(res.body.avatarURL).toBe(
'https://avatars.cloudflare.steamstatic.com/ac7305567f93a4c9eec4d857df993191c61fb240_full.jpg'
steamAvatarUrl('ac7305567f93a4c9eec4d857df993191c61fb240')
);
});

Expand Down
4 changes: 3 additions & 1 deletion apps/backend/src/app/config/config.interface.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import * as pino from 'pino';
import { GamemodeCategory } from '@momentum/constants';
import * as pino from 'pino';

export enum Environment {
DEVELOPMENT = 'dev',
Expand Down Expand Up @@ -48,5 +49,6 @@ export interface ConfigInterface {
maxCreditsExceptTesters: number;
preSignedUrlExpTime: number;
};
discordWebhooks: Record<GamemodeCategory, string>;
logLevel: pino.LevelWithSilent;
}
12 changes: 11 additions & 1 deletion apps/backend/src/app/config/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,10 @@
JWT_WEB_EXPIRY_TIME,
JWT_REFRESH_EXPIRY_TIME,
MAX_MAP_IMAGE_SIZE,
PRE_SIGNED_URL_EXPIRE_TIME
PRE_SIGNED_URL_EXPIRE_TIME,
GamemodeCategory
} from '@momentum/constants';
import * as Enum from '@momentum/enum';
import { ConfigInterface, Environment } from './config.interface';
import * as process from 'node:process';
import * as pino from 'pino';
Expand Down Expand Up @@ -69,6 +71,14 @@ export const ConfigFactory = (): ConfigInterface => {
maxCreditsExceptTesters: MAX_CREDITS_EXCEPT_TESTERS,
preSignedUrlExpTime: PRE_SIGNED_URL_EXPIRE_TIME
},
discordWebhooks: Object.fromEntries(
Enum.values(GamemodeCategory).map((cat) => [
cat,
isTest
? ''
: (process.env[`DISCORD_WEBHOOK_${GamemodeCategory[cat]}_URL`] ?? '')
])
) as Record<GamemodeCategory, string>,
logLevel: (process.env['LOG_LEVEL'] ??
(isTest ? 'warn' : 'info')) as pino.LevelWithSilent
};
Expand Down
37 changes: 37 additions & 0 deletions apps/backend/src/app/dto/map/map-zones.dto.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
Zone,
MainTrack,
BonusTrack,
GlobalRegions,
TrackZones
} from '@momentum/constants';
import {
Expand All @@ -23,6 +24,7 @@ import {
} from 'class-validator';
import { ApiProperty } from '@nestjs/swagger';
import {
MAX_REGIONS,
MAX_BONUS_TRACKS,
MAX_SEGMENT_CHECKPOINTS,
MAX_TRACK_SEGMENTS,
Expand Down Expand Up @@ -174,6 +176,15 @@ export class MainTrackDto /* extends JsonifiableDto */ implements MainTrack {
})
@IsBoolean()
readonly stagesEndAtStageStarts: boolean;

@ApiProperty({
required: false,
description:
"Overrides the game mode's settings to allow bhopping on this track"
})
@IsBoolean()
@IsOptional()
readonly bhopEnabled?: boolean;
}

export class BonusTrackDto /* extends JsonifiableDto */ implements BonusTrack {
Expand All @@ -184,6 +195,15 @@ export class BonusTrackDto /* extends JsonifiableDto */ implements BonusTrack {
@IsInt()
@IsOptional()
readonly defragModifiers?: number;

@ApiProperty({
required: false,
description:
"Overrides the game mode's settings to allow bhopping on this track"
})
@IsBoolean()
@IsOptional()
readonly bhopEnabled?: boolean;
}

export class MapTracksDto /* extends JsonifiableDto */ implements MapTracks {
Expand All @@ -203,6 +223,19 @@ export class MapTracksDto /* extends JsonifiableDto */ implements MapTracks {
readonly bonuses: BonusTrackDto[];
}

export class GlobalRegionsDto /* extends JsonifiableDto */
implements GlobalRegions
{
@NestedProperty(RegionDto, {
isArray: true,
required: false,
description: 'A collection of allow bhop regions'
})
@ArrayMinSize(0)
@ArrayMaxSize(MAX_REGIONS)
readonly allowBhop: RegionDto[];
}

export class MapZonesDto /* extends JsonifiableDto */ implements MapZones {
@ApiProperty()
@IsInt()
Expand All @@ -224,4 +257,8 @@ export class MapZonesDto /* extends JsonifiableDto */ implements MapZones {

@NestedProperty(MapTracksDto, { required: true })
readonly tracks: MapTracksDto;

@NestedProperty(GlobalRegionsDto, { required: false })
@IsOptional()
readonly globalRegions?: GlobalRegionsDto;
}
Loading

0 comments on commit c473160

Please sign in to comment.