From 39765b47e15b4722d4811e79aa1dde3fc2ae712f Mon Sep 17 00:00:00 2001 From: Kyan Van den Eynde <66461508+kyanvde@users.noreply.github.com> Date: Fri, 28 Aug 2026 22:17:30 +0200 Subject: [PATCH] feat(api/v2): :sparkles: Implement BuildTeam routes Adds the root section: the public team list, a single team by ID or slug, the two modpack projections, and an update scoped to the authenticated team that queues a website revalidation for the pages it just made stale. BuildTeamsController owns / and /:teamId, and that wildcard matches any top level path, so BuildTeamsModule is registered last and the ordering is pinned by tests, including one that the Swagger docs still resolve. The token and webhook columns are never selected into a public response; a team only reads back the webhook it configured itself. Closes #58 Co-Authored-By: Claude Opus 5 --- apps/api-v2/roadmap.md | 10 +- apps/api-v2/src/app.module.ts | 5 + .../buildteams/buildteams.controller.ts | 174 +++++++++++ .../sections/buildteams/buildteams.module.ts | 10 + .../sections/buildteams/buildteams.service.ts | 279 ++++++++++++++++++ .../sections/buildteams/dto/buildteam.dto.ts | 131 ++++++++ .../buildteams/dto/update.buildteam.dto.ts | 125 ++++++++ .../api-v2/test/bootstrap/docs-routes.spec.ts | 61 ++++ .../buildteams/buildteams.controller.spec.ts | 133 +++++++++ .../buildteams/buildteams.routes.spec.ts | 257 ++++++++++++++++ .../buildteams/buildteams.service.spec.ts | 264 +++++++++++++++++ 11 files changed, 1444 insertions(+), 5 deletions(-) create mode 100644 apps/api-v2/src/sections/buildteams/buildteams.controller.ts create mode 100644 apps/api-v2/src/sections/buildteams/buildteams.module.ts create mode 100644 apps/api-v2/src/sections/buildteams/buildteams.service.ts create mode 100644 apps/api-v2/src/sections/buildteams/dto/buildteam.dto.ts create mode 100644 apps/api-v2/src/sections/buildteams/dto/update.buildteam.dto.ts create mode 100644 apps/api-v2/test/bootstrap/docs-routes.spec.ts create mode 100644 apps/api-v2/test/sections/buildteams/buildteams.controller.spec.ts create mode 100644 apps/api-v2/test/sections/buildteams/buildteams.routes.spec.ts create mode 100644 apps/api-v2/test/sections/buildteams/buildteams.service.spec.ts diff --git a/apps/api-v2/roadmap.md b/apps/api-v2/roadmap.md index da25a4a6..f21c9be1 100644 --- a/apps/api-v2/roadmap.md +++ b/apps/api-v2/roadmap.md @@ -103,11 +103,11 @@ post /auth/account/link ## BuildTeam -get /[teamId] \ -get / \ -get /modpack \ -get /[teamId]/modpack \ -put / +✅ get /[teamId] \ +✅ get / \ +✅ get /modpack \ +✅ get /[teamId]/modpack \ +✅ put / ## Applications diff --git a/apps/api-v2/src/app.module.ts b/apps/api-v2/src/app.module.ts index 72f4282d..3277c10a 100644 --- a/apps/api-v2/src/app.module.ts +++ b/apps/api-v2/src/app.module.ts @@ -8,6 +8,7 @@ import { ApplicationQuestionsModule } from './sections/applications/questions/ap import { ApplicationsModule } from './sections/applications/applications.module'; import { ApplicationTemplatesModule } from './sections/applications/templates/application-templates.module'; import { AuthModule } from './sections/auth/auth.module'; +import { BuildTeamsModule } from './sections/buildteams/buildteams.module'; import { ClaimsModule } from './sections/claims/claims.module'; import { SocialsModule } from './sections/socials/socials.module'; import { StatusModule } from './sections/status/status.module'; @@ -28,6 +29,10 @@ import { UtilityModule } from './sections/utility/utility.module'; SocialsModule, StatusModule, UtilityModule, + // Last on purpose. BuildTeamsController owns `/` and `/:teamId`, and that + // wildcard matches any top level path, so it has to be tried after every + // other module's routes have had their chance. + BuildTeamsModule, ], providers: [PrismaService, { provide: APP_GUARD, useClass: AuthGuard }], }) diff --git a/apps/api-v2/src/sections/buildteams/buildteams.controller.ts b/apps/api-v2/src/sections/buildteams/buildteams.controller.ts new file mode 100644 index 00000000..79dae0b1 --- /dev/null +++ b/apps/api-v2/src/sections/buildteams/buildteams.controller.ts @@ -0,0 +1,174 @@ +import { Body, Controller, Get, Param, Put, Req } from '@nestjs/common'; +import { ApiBearerAuth, ApiOperation, ApiParam, ApiQuery } from '@nestjs/swagger'; +import { Request } from 'express'; +import { + ApiDefaultResponse, + ApiErrorResponse, + ApiPaginatedResponseDto, +} from 'src/common/decorators/api-response.decorator'; +import { Filter, FilterParams } from 'src/common/decorators/filter.decorator'; +import { Filtered } from 'src/common/decorators/filtered.decorator'; +import { OptionalAuth } from 'src/common/decorators/optional-auth.decorator'; +import { Paginated } from 'src/common/decorators/paginated.decorator'; +import { Pagination, PaginationParams } from 'src/common/decorators/pagination.decorator'; +import { SkipAuth } from 'src/common/decorators/skip-auth.decorator'; +import { Sortable } from 'src/common/decorators/sortable.decorator'; +import { Sorting, SortingParams } from 'src/common/decorators/sorting.decorator'; +import { TeamScope } from 'src/common/decorators/team-scope.decorator'; +import { ControllerResponse, PaginatedControllerResponse } from 'src/typings'; +import { BuildTeamsService } from './buildteams.service'; +import { BuildTeamDto, BuildTeamModpackDto } from './dto/buildteam.dto'; +import { UpdateBuildTeamDto } from './dto/update.buildteam.dto'; + +/** + * A build team is the resource the whole API is scoped by, so its routes sit at + * the root: `/` is the list and `/:teamId` is one team. + * + * That makes `:teamId` a single segment wildcard, which would match `/claims`, + * `/socials`, `/applications`, `/auth`, `/health` and every other top level + * route just as well. Express matches in registration order, so BuildTeamsModule + * has to stay last in AppModule's imports, and `modpack` has to be declared + * before `:teamId` here for the same reason. + */ +@Controller() +export class BuildTeamsController { + constructor(private readonly buildTeamsService: BuildTeamsService) {} + + /** + * Returns what the Minecraft modpack needs about every build team. + */ + @Get('modpack') + @SkipAuth() + @ApiOperation({ + summary: 'Get Modpack BuildTeams', + description: + 'Returns what the Minecraft modpack needs about every build team, keyed by team ID. Unpaginated, because the modpack loads it once and looks teams up by ID.', + }) + @ApiDefaultResponse(BuildTeamModpackDto, { description: 'Success' }) + async findAllForModpack(): ControllerResponse { + return await this.buildTeamsService.findAllForModpack(); + } + + /** + * Returns what the Minecraft modpack needs about a single build team. + */ + @Get(':teamId/modpack') + @SkipAuth() + @ApiOperation({ + summary: 'Get Modpack BuildTeam', + description: 'Returns what the Minecraft modpack needs about the build team in the path.', + }) + @ApiParam({ + name: 'teamId', + description: 'The ID of the build team, or its slug when the slug query parameter is set.', + }) + @Filtered({ fields: [{ name: 'slug', required: false, type: Boolean }] }) + @ApiDefaultResponse(BuildTeamModpackDto, { description: 'Success' }) + @ApiErrorResponse({ status: 404, description: 'BuildTeam not found' }) + async findOneForModpack(@Param('teamId') teamId: string, @Filter() filter: FilterParams): ControllerResponse { + const { slug }: { slug?: boolean } = filter.filter; + + return await this.buildTeamsService.findOneForModpack(teamId, Boolean(slug)); + } + + /** + * Returns every build team. + */ + @Get() + @SkipAuth() + @Paginated() + @Sortable({ + defaultSortBy: 'members', + allowedFields: ['members', 'name', 'location', 'slug', 'createdAt'], + defaultOrder: 'desc', + }) + @ApiOperation({ + summary: 'Get BuildTeams', + description: 'Returns every build team, biggest first by default. Public, and never includes a team secret.', + }) + @Filtered({ + fields: [ + { name: 'name', required: false, type: String }, + { name: 'location', required: false, type: String }, + { name: 'slug', required: false, type: String }, + { name: 'version', required: false, type: String }, + { name: 'allowApplications', required: false, type: Boolean }, + { name: 'allowBuilderClaim', required: false, type: Boolean }, + { name: 'allowTrial', required: false, type: Boolean }, + ], + }) + @ApiPaginatedResponseDto(BuildTeamDto, { description: 'Success' }) + @ApiErrorResponse({ status: 400, description: 'Bad Request' }) + async findAll( + @Pagination() pagination: PaginationParams, + @Sorting() sorting: SortingParams, + @Filter() filter: FilterParams, + ): PaginatedControllerResponse { + return await this.buildTeamsService.findAll(pagination, sorting.sortBy, sorting.order, filter.filter); + } + + /** + * Updates the currently authenticated build team. + */ + @Put(['', ':teamId']) + @ApiBearerAuth() + @ApiOperation({ + summary: 'Update BuildTeam', + description: + 'Updates the currently authenticated build team, and asks the frontend to revalidate its now stale pages.', + }) + @ApiParam({ name: 'teamId', required: false, description: 'Must be the authenticated team when given.' }) + @ApiDefaultResponse(BuildTeamDto, { description: 'Success' }) + @ApiErrorResponse({ status: 400, description: 'Bad Request' }) + @ApiErrorResponse({ status: 401, description: 'Unauthorized' }) + @ApiErrorResponse({ status: 404, description: 'BuildTeam not found' }) + @ApiErrorResponse({ status: 409, description: 'Name or slug already taken' }) + async update(@Body() updateBuildTeamDto: UpdateBuildTeamDto, @TeamScope() buildTeamId: string): ControllerResponse { + return await this.buildTeamsService.update(buildTeamId, updateBuildTeamDto); + } + + /** + * Returns a single build team. + * + * Declared last on purpose: `:teamId` matches any single segment, so every + * more specific route has to be registered before it. + */ + @Get(':teamId') + @OptionalAuth() + @ApiBearerAuth() + @ApiOperation({ + summary: 'Get BuildTeam', + description: + 'Returns the build team in the path. Public, but a team authenticated as itself also gets the webhook URL it configured.', + }) + @ApiParam({ + name: 'teamId', + description: 'The ID of the build team, or its slug when the slug query parameter is set.', + }) + @ApiQuery({ name: 'members', required: false, type: Boolean, description: 'Embed the members of the team.' }) + @ApiQuery({ name: 'showcases', required: false, type: Boolean, description: 'Embed the showcases of the team.' }) + @Filtered({ + fields: [ + { name: 'slug', required: false, type: Boolean }, + { name: 'members', required: false, type: Boolean }, + { name: 'showcases', required: false, type: Boolean }, + ], + }) + @ApiDefaultResponse(BuildTeamDto, { description: 'Success' }) + @ApiErrorResponse({ status: 400, description: 'Bad Request' }) + @ApiErrorResponse({ status: 404, description: 'BuildTeam not found' }) + async findOne( + @Param('teamId') teamId: string, + @Filter() filter: FilterParams, + @Req() req: Request, + ): ControllerResponse { + const { slug, members, showcases }: { slug?: boolean; members?: boolean; showcases?: boolean } = filter.filter; + + return await this.buildTeamsService.findOne( + teamId, + Boolean(slug), + { members: Boolean(members), showcases: Boolean(showcases) }, + req.token?.id, + ); + } +} diff --git a/apps/api-v2/src/sections/buildteams/buildteams.module.ts b/apps/api-v2/src/sections/buildteams/buildteams.module.ts new file mode 100644 index 00000000..d8b96222 --- /dev/null +++ b/apps/api-v2/src/sections/buildteams/buildteams.module.ts @@ -0,0 +1,10 @@ +import { Module } from '@nestjs/common'; +import { PrismaService } from 'src/common/db/prisma.service'; +import { BuildTeamsController } from './buildteams.controller'; +import { BuildTeamsService } from './buildteams.service'; + +@Module({ + controllers: [BuildTeamsController], + providers: [BuildTeamsService, PrismaService], +}) +export class BuildTeamsModule {} diff --git a/apps/api-v2/src/sections/buildteams/buildteams.service.ts b/apps/api-v2/src/sections/buildteams/buildteams.service.ts new file mode 100644 index 00000000..5ccd107c --- /dev/null +++ b/apps/api-v2/src/sections/buildteams/buildteams.service.ts @@ -0,0 +1,279 @@ +import { ConflictException, Injectable, NotFoundException } from '@nestjs/common'; +import { Prisma } from '@repo/db'; +import { PrismaService } from 'src/common/db/prisma.service'; +import { FilterParams } from 'src/common/decorators/filter.decorator'; +import { PaginationParams } from 'src/common/decorators/pagination.decorator'; +import { SortingParams } from 'src/common/decorators/sorting.decorator'; +import { WorkerJob } from 'src/common/queue/jobs'; +import { QueueService } from 'src/common/queue/queue.service'; +import { UpdateBuildTeamDto } from './dto/update.buildteam.dto'; + +/** + * Every column of a build team except `token` and `webhook`. + * + * `token` is the client secret a team exchanges for an access token, and + * `webhook` is a URL anyone who had it could post to, so neither may ever reach + * a public response. They are left out here rather than deleted from the result + * afterwards, so a column added to the model later has to be listed on purpose + * before it becomes public. + */ +const PUBLIC_SELECT = { + id: true, + name: true, + icon: true, + backgroundImage: true, + invite: true, + about: true, + creatorId: true, + createdAt: true, + location: true, + slug: true, + ip: true, + acceptionMessage: true, + rejectionMessage: true, + trialMessage: true, + allowTrial: true, + allowBuilderClaim: true, + allowApplications: true, + instantAccept: true, + version: true, + color: true, +} as const; + +/** The columns the Minecraft modpack needs, and nothing else. */ +const MODPACK_SELECT = { + id: true, + name: true, + ip: true, + version: true, + invite: true, +} as const; + +/** How many members, showcases and claims a team has. */ +const COUNT_SELECT = { select: { members: true, showcases: true, claims: true } } as const; + +/** + * The frontend pages that show a team, as they appear in the Next.js router. + * `[team]` is replaced with the team's slug before the paths are handed to the + * worker. Mirrors the `/teams` group v1 revalidated on a team update. + */ +const TEAM_PAGES = [ + '/teams', + '/teams/[team]', + '/teams/[team]/apply', + '/teams/[team]/manage/apply', + '/teams/[team]/manage/images', + '/teams/[team]/manage/members', + '/teams/[team]/manage/review', + '/teams/[team]/manage/settings', +]; + +/** Prisma's error code for a unique constraint violation. */ +const UNIQUE_VIOLATION = 'P2002'; + +@Injectable() +export class BuildTeamsService { + constructor( + private readonly prisma: PrismaService, + private readonly queue: QueueService, + ) {} + + /** + * Finds build teams based on pagination, sorting and filtering parameters. + * @param pagination Pagination parameters. + * @param sortBy Field to sort by, or `members` to sort by member count. + * @param order Order of sorting (asc/desc). + * @param filter Filter parameters. + * @returns A paginated response containing the build teams and metadata. + */ + async findAll( + pagination: PaginationParams, + sortBy?: SortingParams['sortBy'], + order?: SortingParams['order'], + filter?: FilterParams['filter'], + ) { + const sortOrder: Prisma.SortOrder = order === 'asc' ? 'asc' : 'desc'; + // The default matches v1: the biggest teams first, which is the order the + // public team list is expected to be in. + const orderBy: Prisma.BuildTeamOrderByWithRelationInput = + !sortBy || sortBy === 'members' ? { members: { _count: sortOrder } } : { [sortBy]: sortOrder }; + + const take = Math.max(Number(pagination.limit) || 20, 1); + const skip = Math.max((Number(pagination.page) || 1) - 1, 0) * take; + + const [buildTeams, count] = await Promise.all([ + this.prisma.buildTeam.findMany({ + where: filter, + orderBy, + skip, + take, + select: { ...PUBLIC_SELECT, _count: COUNT_SELECT }, + }), + this.prisma.buildTeam.count({ where: filter }), + ]); + + return { + data: buildTeams, + meta: { + page: pagination.page, + perPage: pagination.limit, + totalItems: count, + totalPages: Math.ceil(count / pagination.limit), + }, + }; + } + + /** + * Finds a single build team by ID or slug. + * @param teamId ID of the team, or its slug when useSlug is set. + * @param useSlug Whether teamId should be treated as a slug instead of an ID. + * @param include Which optional relations to embed. + * @param requestingTeamId ID of the team the request is authenticated as, if any. + * @returns The build team, with its webhook only when it is asking about itself. + * @throws NotFoundException if no team with the given ID or slug exists. + */ + async findOne( + teamId: string, + useSlug: boolean, + include: { members?: boolean; showcases?: boolean }, + requestingTeamId?: string, + ) { + const buildTeam = await this.prisma.buildTeam.findUnique({ + where: useSlug ? { slug: teamId } : { id: teamId }, + select: { + ...PUBLIC_SELECT, + socials: true, + _count: COUNT_SELECT, + ...(include.showcases ? { showcases: true } : {}), + ...(include.members + ? { members: { select: { id: true, ssoId: true, avatar: true, username: true, minecraft: true } } } + : {}), + // A team is allowed to read back the webhook it configured; nobody else + // is, so the column is only selected when the token names this team. + ...(requestingTeamId ? { webhook: true } : {}), + }, + }); + + if (!buildTeam) { + throw new NotFoundException('BuildTeam not found'); + } + + if (requestingTeamId && requestingTeamId !== buildTeam.id) { + const { webhook, ...withoutWebhook } = buildTeam as typeof buildTeam & { webhook?: string | null }; + + return withoutWebhook; + } + + return buildTeam; + } + + /** + * Returns what the Minecraft modpack needs about every build team, keyed by + * team ID. + * + * Unpaginated and shaped as a map rather than a list, because the modpack + * loads it once at startup and looks teams up by ID. Same shape v1 served. + * @returns Every team's modpack details, keyed by ID. + */ + async findAllForModpack() { + const buildTeams = await this.prisma.buildTeam.findMany({ + orderBy: { members: { _count: 'desc' } }, + select: MODPACK_SELECT, + }); + + return Object.fromEntries( + buildTeams.map(({ id, name, ip, version, invite }) => [id, { name, ip: this.parseIps(ip), version, invite }]), + ); + } + + /** + * Returns what the Minecraft modpack needs about a single build team. + * @param teamId ID of the team, or its slug when useSlug is set. + * @param useSlug Whether teamId should be treated as a slug instead of an ID. + * @returns The team's modpack details. + * @throws NotFoundException if no team with the given ID or slug exists. + */ + async findOneForModpack(teamId: string, useSlug: boolean) { + const buildTeam = await this.prisma.buildTeam.findUnique({ + where: useSlug ? { slug: teamId } : { id: teamId }, + select: MODPACK_SELECT, + }); + + if (!buildTeam) { + throw new NotFoundException('BuildTeam not found'); + } + + return { ...buildTeam, ip: this.parseIps(buildTeam.ip) }; + } + + /** + * Updates the given build team. + * + * The team pages are statically rendered, so the frontend is asked to + * revalidate them afterwards. When the slug changed, the pages under the old + * slug are revalidated too, or the team would keep being served under a URL + * that no longer resolves. + * @param buildTeamId ID of the team to update. + * @param dto The fields to update. + * @returns The updated team, including the webhook it owns. + * @throws NotFoundException if the team does not exist. + * @throws ConflictException if the name or slug is already taken. + */ + async update(buildTeamId: string, dto: UpdateBuildTeamDto) { + const current = await this.prisma.buildTeam.findUnique({ + where: { id: buildTeamId }, + select: { id: true, slug: true }, + }); + + if (!current) { + throw new NotFoundException('BuildTeam not found'); + } + + let buildTeam: { slug: string } & Record; + + try { + buildTeam = await this.prisma.buildTeam.update({ + where: { id: current.id }, + data: dto, + select: { ...PUBLIC_SELECT, webhook: true }, + }); + } catch (error) { + if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === UNIQUE_VIOLATION) { + throw new ConflictException('Another BuildTeam already uses that name or slug'); + } + + throw error; + } + + const slugs = current.slug === buildTeam.slug ? [buildTeam.slug] : [buildTeam.slug, current.slug]; + + await this.queue.dispatch(WorkerJob.RevalidateWebsite, { paths: this.teamPagesFor(slugs) }); + + return buildTeam; + } + + /** + * The frontend paths that show the given slugs, with the route parameter + * filled in. + */ + private teamPagesFor(slugs: string[]): string[] { + const paths = slugs.flatMap((slug) => TEAM_PAGES.map((page) => page.replace('[team]', slug))); + + return [...new Set(paths)]; + } + + /** + * A team's servers are stored as one semicolon separated string, but every + * consumer wants a list. + */ + private parseIps(ip: string | null): string[] { + if (!ip) { + return []; + } + + return ip + .split(';') + .map((entry) => entry.trim()) + .filter(Boolean); + } +} diff --git a/apps/api-v2/src/sections/buildteams/dto/buildteam.dto.ts b/apps/api-v2/src/sections/buildteams/dto/buildteam.dto.ts new file mode 100644 index 00000000..6459f840 --- /dev/null +++ b/apps/api-v2/src/sections/buildteams/dto/buildteam.dto.ts @@ -0,0 +1,131 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; + +export class BuildTeamCountDto { + @ApiProperty({ example: 412, description: 'The number of members of the team.' }) + members: number; + + @ApiProperty({ example: 37, description: 'The number of showcases of the team.' }) + showcases: number; + + @ApiProperty({ example: 1284, description: 'The number of claims of the team.' }) + claims: number; +} + +export class BuildTeamSocialDto { + @ApiProperty({ example: '00000000-0000-0000-0000-000000000000', description: 'The unique ID of the social link.' }) + id: string; + + @ApiProperty({ example: 'Discord', description: 'The name of the platform this link points at.' }) + name: string; + + @ApiProperty({ example: 'brand-discord', description: 'The icon shown next to the link.' }) + icon: string; + + @ApiProperty({ example: 'https://discord.gg/buildtheearth', description: 'The address the link points at.' }) + url: string; +} + +export class BuildTeamDto { + @ApiProperty({ example: '00000000-0000-0000-0000-000000000000', description: 'The unique ID of the build team.' }) + id: string; + + @ApiProperty({ example: 'Build The Earth Germany', description: 'The name of the build team.' }) + name: string; + + @ApiProperty({ example: 'bte-germany', description: 'The slug the team is reachable under on the website.' }) + slug: string; + + @ApiProperty({ example: 'https://example.com/icon.png', description: 'The icon of the build team.' }) + icon: string; + + @ApiProperty({ + example: 'https://example.com/background.png', + description: 'The image behind the header of the team page.', + }) + backgroundImage: string; + + @ApiProperty({ example: 'https://discord.gg/buildtheearth', description: 'The Discord invite of the team.' }) + invite: string; + + @ApiProperty({ description: 'The description shown on the team page.' }) + about: string; + + @ApiProperty({ example: 'Germany', description: 'The part of the world the team builds.' }) + location: string; + + @ApiProperty({ + example: 'buildtheearth.net;eu.buildtheearth.net', + description: 'The team\u2019s Minecraft servers, separated by semicolons.', + }) + ip: string; + + @ApiProperty({ example: '1.12.2', description: 'The Minecraft version the team builds on.' }) + version: string; + + @ApiProperty({ example: '#1098AD', description: 'The accent colour of the team page.' }) + color: string; + + @ApiProperty({ + example: '00000000-0000-0000-0000-000000000000', + description: 'The ID of the user who created the team.', + }) + creatorId: string; + + @ApiProperty({ example: '2025-04-19T16:45:18.767Z', description: 'When the team was created.' }) + createdAt: string; + + @ApiProperty({ description: 'The message sent to an applicant who was accepted.' }) + acceptionMessage: string; + + @ApiProperty({ description: 'The message sent to an applicant who was rejected.' }) + rejectionMessage: string; + + @ApiProperty({ description: 'The message sent to an applicant who was accepted as a trial builder.' }) + trialMessage: string; + + @ApiProperty({ example: false, description: 'Whether the team accepts trial applications.' }) + allowTrial: boolean; + + @ApiPropertyOptional({ example: true, description: 'Whether the team accepts applications at all.' }) + allowApplications: boolean | null; + + @ApiPropertyOptional({ example: true, description: 'Whether the team lets its builders create claims.' }) + allowBuilderClaim: boolean | null; + + @ApiPropertyOptional({ example: false, description: 'Whether applications are accepted without review.' }) + instantAccept: boolean | null; + + @ApiPropertyOptional({ type: BuildTeamCountDto, description: 'How much the team has to show for itself.' }) + _count?: BuildTeamCountDto; + + @ApiPropertyOptional({ type: [BuildTeamSocialDto], description: 'The social links of the team.' }) + socials?: BuildTeamSocialDto[]; + + @ApiPropertyOptional({ + example: 'https://example.com/hooks/buildtheearth', + description: + 'Where the team wants its events delivered. Only present when the request is authenticated as this team.', + }) + webhook?: string | null; +} + +export class BuildTeamModpackDto { + @ApiProperty({ example: '00000000-0000-0000-0000-000000000000', description: 'The unique ID of the build team.' }) + id: string; + + @ApiProperty({ example: 'Build The Earth Germany', description: 'The name of the build team.' }) + name: string; + + @ApiProperty({ + type: [String], + example: ['buildtheearth.net', 'eu.buildtheearth.net'], + description: 'The Minecraft servers of the team.', + }) + ip: string[]; + + @ApiProperty({ example: '1.12.2', description: 'The Minecraft version the team builds on.' }) + version: string; + + @ApiProperty({ example: 'https://discord.gg/buildtheearth', description: 'The Discord invite of the team.' }) + invite: string; +} diff --git a/apps/api-v2/src/sections/buildteams/dto/update.buildteam.dto.ts b/apps/api-v2/src/sections/buildteams/dto/update.buildteam.dto.ts new file mode 100644 index 00000000..44e128e7 --- /dev/null +++ b/apps/api-v2/src/sections/buildteams/dto/update.buildteam.dto.ts @@ -0,0 +1,125 @@ +import { ApiPropertyOptional } from '@nestjs/swagger'; +import { IsBoolean, IsHexColor, IsNotEmpty, IsOptional, IsString, IsUrl, Matches, MaxLength } from 'class-validator'; + +/** + * The fields a team may change about itself. + * + * `token` is deliberately absent: it is the client secret the team authenticates + * with, and rotating it is not an ordinary settings edit. `creatorId`, + * `createdAt` and `id` are absent for the same reason — they are not settings. + */ +export class UpdateBuildTeamDto { + @ApiPropertyOptional({ example: 'Build The Earth Germany', description: 'The name of the build team.' }) + @IsString() + @IsNotEmpty() + @MaxLength(255) + @IsOptional() + name?: string; + + @ApiPropertyOptional({ + example: 'bte-germany', + description: 'The slug the team is reachable under on the website. Lowercase letters, digits and dashes.', + }) + @IsString() + @Matches(/^[a-z0-9]+(?:-[a-z0-9]+)*$/, { + message: 'slug must contain only lowercase letters, digits and single dashes between them', + }) + @MaxLength(255) + @IsOptional() + slug?: string; + + @ApiPropertyOptional({ example: 'https://example.com/icon.png', description: 'The icon of the build team.' }) + @IsString() + @IsNotEmpty() + @IsOptional() + icon?: string; + + @ApiPropertyOptional({ + example: 'https://example.com/background.png', + description: 'The image behind the header of the team page.', + }) + @IsString() + @IsNotEmpty() + @IsOptional() + backgroundImage?: string; + + @ApiPropertyOptional({ example: 'https://discord.gg/buildtheearth', description: 'The Discord invite of the team.' }) + @IsString() + @IsNotEmpty() + @IsOptional() + invite?: string; + + @ApiPropertyOptional({ description: 'The description shown on the team page.' }) + @IsString() + @IsOptional() + about?: string; + + @ApiPropertyOptional({ example: 'Germany', description: 'The part of the world the team builds.' }) + @IsString() + @MaxLength(255) + @IsOptional() + location?: string; + + @ApiPropertyOptional({ + example: 'buildtheearth.net;eu.buildtheearth.net', + description: 'The team\u2019s Minecraft servers, separated by semicolons. Served to the modpack as a list.', + }) + @IsString() + @IsOptional() + ip?: string; + + @ApiPropertyOptional({ example: '1.12.2', description: 'The Minecraft version the team builds on.' }) + @IsString() + @IsNotEmpty() + @MaxLength(32) + @IsOptional() + version?: string; + + @ApiPropertyOptional({ example: '#1098AD', description: 'The accent colour of the team page.' }) + @IsHexColor() + @IsOptional() + color?: string; + + @ApiPropertyOptional({ description: 'The message sent to an applicant who was accepted.' }) + @IsString() + @IsOptional() + acceptionMessage?: string; + + @ApiPropertyOptional({ description: 'The message sent to an applicant who was rejected.' }) + @IsString() + @IsOptional() + rejectionMessage?: string; + + @ApiPropertyOptional({ description: 'The message sent to an applicant who was accepted as a trial builder.' }) + @IsString() + @IsOptional() + trialMessage?: string; + + @ApiPropertyOptional({ example: false, description: 'Whether the team accepts trial applications.' }) + @IsBoolean() + @IsOptional() + allowTrial?: boolean; + + @ApiPropertyOptional({ example: true, description: 'Whether the team accepts applications at all.' }) + @IsBoolean() + @IsOptional() + allowApplications?: boolean; + + @ApiPropertyOptional({ example: true, description: 'Whether the team lets its builders create claims.' }) + @IsBoolean() + @IsOptional() + allowBuilderClaim?: boolean; + + @ApiPropertyOptional({ example: false, description: 'Whether applications are accepted without review.' }) + @IsBoolean() + @IsOptional() + instantAccept?: boolean; + + @ApiPropertyOptional({ + example: 'https://example.com/hooks/buildtheearth', + description: 'Where the team wants its events delivered. Only ever returned to the team itself.', + }) + @IsUrl({ protocols: ['http', 'https'], require_protocol: true }) + @IsOptional() + webhook?: string; +} diff --git a/apps/api-v2/test/bootstrap/docs-routes.spec.ts b/apps/api-v2/test/bootstrap/docs-routes.spec.ts new file mode 100644 index 00000000..e3a49bdc --- /dev/null +++ b/apps/api-v2/test/bootstrap/docs-routes.spec.ts @@ -0,0 +1,61 @@ +import { VersioningType } from '@nestjs/common'; +import { HttpAdapterHost } from '@nestjs/core'; +import { Test, TestingModule } from '@nestjs/testing'; +import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger'; +import request from 'supertest'; +import { AppModule } from 'src/app.module'; +import { PrismaService } from 'src/common/db/prisma.service'; +import { ExceptionsFilter } from 'src/common/interceptors/error.interceptor'; +import { ResponseInterceptor } from 'src/common/interceptors/response.interceptor'; + +/** + * BuildTeamsController answers `/:teamId`, which matches `/docs` and + * `/docs.json` as readily as a team ID. Swagger stays reachable only because + * main.ts wires it onto the HTTP adapter before the controller routes are + * registered, which is invisible from the controller itself — so the ordering + * is pinned here, against an app built the same way main.ts builds it. + */ +describe('documentation routes', () => { + let app: Awaited>; + + beforeAll(async () => { + process.env.JWT_SECRET = 'test-secret'; + + const moduleRef: TestingModule = await Test.createTestingModule({ imports: [AppModule] }) + .overrideProvider(PrismaService) + // A build team lookup would answer 404, which is exactly what a swallowed + // docs route would look like. + .useValue({ $connect: jest.fn(), buildTeam: { findUnique: jest.fn().mockResolvedValue(null) } }) + .compile(); + + app = moduleRef.createNestApplication(); + app.enableVersioning({ type: VersioningType.URI, defaultVersion: '2' }); + + SwaggerModule.setup('/v2/docs', app, () => SwaggerModule.createDocument(app, new DocumentBuilder().build()), { + jsonDocumentUrl: '/v2/docs.json', + yamlDocumentUrl: '/v2/docs.yaml', + }); + + app.useGlobalInterceptors(new ResponseInterceptor()); + app.useGlobalFilters(new ExceptionsFilter(app.get(HttpAdapterHost).httpAdapter)); + await app.init(); + }); + + afterAll(async () => { + await app.close(); + delete process.env.JWT_SECRET; + }); + + it('serves the OpenAPI document rather than looking for a build team', async () => { + const response = await request(app.getHttpServer()).get('/v2/docs.json').expect(200); + + expect(response.body).toHaveProperty('openapi'); + expect(Object.keys(response.body.paths as object).length).toBeGreaterThan(0); + }); + + it('serves the YAML document too', async () => { + const response = await request(app.getHttpServer()).get('/v2/docs.yaml').expect(200); + + expect(response.text).toContain('openapi:'); + }); +}); diff --git a/apps/api-v2/test/sections/buildteams/buildteams.controller.spec.ts b/apps/api-v2/test/sections/buildteams/buildteams.controller.spec.ts new file mode 100644 index 00000000..c194e45a --- /dev/null +++ b/apps/api-v2/test/sections/buildteams/buildteams.controller.spec.ts @@ -0,0 +1,133 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { Request } from 'express'; +import { BuildTeamsController } from 'src/sections/buildteams/buildteams.controller'; +import { BuildTeamsService } from 'src/sections/buildteams/buildteams.service'; + +describe('BuildTeamsController', () => { + let buildTeamsController: BuildTeamsController; + let buildTeamsService: { + findAll: jest.Mock; + findOne: jest.Mock; + findAllForModpack: jest.Mock; + findOneForModpack: jest.Mock; + update: jest.Mock; + }; + + const pagination = { page: 1, limit: 20 }; + const sorting = { sortBy: 'members', order: 'desc' }; + + beforeEach(async () => { + buildTeamsService = { + findAll: jest.fn(), + findOne: jest.fn(), + findAllForModpack: jest.fn(), + findOneForModpack: jest.fn(), + update: jest.fn(), + }; + + const module: TestingModule = await Test.createTestingModule({ + controllers: [BuildTeamsController], + providers: [{ provide: BuildTeamsService, useValue: buildTeamsService }], + }).compile(); + + buildTeamsController = module.get(BuildTeamsController); + }); + + describe('findAll', () => { + it('should forward pagination, sorting and filtering', async () => { + buildTeamsService.findAll.mockResolvedValue({ data: [], meta: {} }); + + await buildTeamsController.findAll( + pagination as never, + sorting as never, + { + filter: { location: 'Germany' }, + } as never, + ); + + expect(buildTeamsService.findAll).toHaveBeenCalledWith(pagination, 'members', 'desc', { + location: 'Germany', + }); + }); + }); + + describe('findOne', () => { + it('should pass no authenticated team when the request has no token', async () => { + buildTeamsService.findOne.mockResolvedValue({ id: 'team-1' }); + + await buildTeamsController.findOne('team-1', { filter: {} } as never, {} as Request); + + expect(buildTeamsService.findOne).toHaveBeenCalledWith( + 'team-1', + false, + { members: false, showcases: false }, + undefined, + ); + }); + + it('should pass the authenticated team through, so it can see its own webhook', async () => { + buildTeamsService.findOne.mockResolvedValue({ id: 'team-1' }); + + await buildTeamsController.findOne( + 'team-1', + { filter: {} } as never, + { + token: { id: 'team-1' }, + } as Request, + ); + + expect(buildTeamsService.findOne).toHaveBeenCalledWith( + 'team-1', + false, + { members: false, showcases: false }, + 'team-1', + ); + }); + + it('should forward the slug flag and the optional embeds', async () => { + buildTeamsService.findOne.mockResolvedValue({ id: 'team-1' }); + + await buildTeamsController.findOne( + 'my-team', + { filter: { slug: true, members: true, showcases: true } } as never, + {} as Request, + ); + + expect(buildTeamsService.findOne).toHaveBeenCalledWith( + 'my-team', + true, + { members: true, showcases: true }, + undefined, + ); + }); + }); + + describe('modpack', () => { + it('should return every team for the modpack', async () => { + buildTeamsService.findAllForModpack.mockResolvedValue({ 'team-1': {} }); + + const result = await buildTeamsController.findAllForModpack(); + + expect(result).toEqual({ 'team-1': {} }); + }); + + it('should resolve a single team by slug when requested', async () => { + buildTeamsService.findOneForModpack.mockResolvedValue({ id: 'team-1' }); + + await buildTeamsController.findOneForModpack('my-team', { filter: { slug: true } } as never); + + expect(buildTeamsService.findOneForModpack).toHaveBeenCalledWith('my-team', true); + }); + }); + + describe('update', () => { + it('should update the authenticated team', async () => { + buildTeamsService.update.mockResolvedValue({ id: 'team-1' }); + + const result = await buildTeamsController.update({ about: 'Updated' }, 'team-1'); + + expect(buildTeamsService.update).toHaveBeenCalledWith('team-1', { about: 'Updated' }); + expect(result).toEqual({ id: 'team-1' }); + }); + }); +}); diff --git a/apps/api-v2/test/sections/buildteams/buildteams.routes.spec.ts b/apps/api-v2/test/sections/buildteams/buildteams.routes.spec.ts new file mode 100644 index 00000000..5c8085a3 --- /dev/null +++ b/apps/api-v2/test/sections/buildteams/buildteams.routes.spec.ts @@ -0,0 +1,257 @@ +import { INestApplication, ValidationPipe, VersioningType } from '@nestjs/common'; +import { HttpAdapterHost } from '@nestjs/core'; +import { JwtService } from '@nestjs/jwt'; +import { Test, TestingModule } from '@nestjs/testing'; +import request from 'supertest'; +import { AppModule } from 'src/app.module'; +import { PrismaService } from 'src/common/db/prisma.service'; +import { ExceptionsFilter } from 'src/common/interceptors/error.interceptor'; +import { ResponseInterceptor } from 'src/common/interceptors/response.interceptor'; + +/** + * BuildTeamsController owns `/` and `/:teamId`, and that wildcard matches any + * top level path — `/claims`, `/socials`, `/health` and the rest included. The + * only thing keeping those apart is registration order, which nothing but a + * request through the real router can check, so it is checked here. + */ +describe('build team routes', () => { + let app: INestApplication; + let token: string; + let prismaService: { + $connect: jest.Mock; + $transaction: jest.Mock; + buildTeam: { findMany: jest.Mock; findUnique: jest.Mock; count: jest.Mock; update: jest.Mock }; + claim: { findMany: jest.Mock; count: jest.Mock }; + social: { findMany: jest.Mock; count: jest.Mock }; + applicationQuestion: { findMany: jest.Mock; count: jest.Mock }; + }; + + beforeAll(async () => { + process.env.JWT_SECRET = 'test-secret'; + + prismaService = { + $connect: jest.fn(), + $transaction: jest.fn().mockResolvedValue([]), + buildTeam: { findMany: jest.fn(), findUnique: jest.fn(), count: jest.fn(), update: jest.fn() }, + claim: { findMany: jest.fn(), count: jest.fn() }, + social: { findMany: jest.fn(), count: jest.fn() }, + applicationQuestion: { findMany: jest.fn(), count: jest.fn() }, + }; + + const moduleRef: TestingModule = await Test.createTestingModule({ imports: [AppModule] }) + .overrideProvider(PrismaService) + .useValue(prismaService) + .compile(); + + app = moduleRef.createNestApplication(); + app.enableVersioning({ type: VersioningType.URI, defaultVersion: '2' }); + app.useGlobalPipes( + new ValidationPipe({ + whitelist: true, + forbidNonWhitelisted: true, + transform: true, + transformOptions: { enableImplicitConversion: true }, + }), + ); + app.useGlobalInterceptors(new ResponseInterceptor()); + app.useGlobalFilters(new ExceptionsFilter(app.get(HttpAdapterHost).httpAdapter)); + await app.init(); + + token = await app.get(JwtService).signAsync({ sub: 'team-123', id: 'team-123' }); + }); + + afterAll(async () => { + await app.close(); + delete process.env.JWT_SECRET; + }); + + beforeEach(() => { + jest.clearAllMocks(); + prismaService.buildTeam.findMany.mockResolvedValue([{ id: 'team-1' }]); + prismaService.buildTeam.count.mockResolvedValue(1); + prismaService.buildTeam.findUnique.mockResolvedValue({ id: 'team-1', slug: 'my-team', ip: 'a.example.net' }); + prismaService.claim.findMany.mockResolvedValue([]); + prismaService.claim.count.mockResolvedValue(0); + prismaService.social.findMany.mockResolvedValue([]); + prismaService.social.count.mockResolvedValue(0); + }); + + describe('the root wildcard does not swallow other sections', () => { + it.each([ + ['/v2/claims', 'claim'], + ['/v2/socials', 'social'], + ])('routes %s to its own controller, not to a build team', async (path, model) => { + await request(app.getHttpServer()).get(path).set('Authorization', `Bearer ${token}`).expect(200); + + expect(prismaService[model as 'claim' | 'social'].findMany).toHaveBeenCalled(); + expect(prismaService.buildTeam.findUnique).not.toHaveBeenCalled(); + }); + + it('still serves /v2/health', async () => { + const response = await request(app.getHttpServer()).get('/v2/health').expect(200); + + expect(prismaService.buildTeam.findUnique).not.toHaveBeenCalled(); + expect(response.body.data).toHaveProperty('status'); + }); + + it('still serves /v2/version', async () => { + await request(app.getHttpServer()).get('/v2/version').expect(200); + + expect(prismaService.buildTeam.findUnique).not.toHaveBeenCalled(); + }); + + it('still serves /v2/auth', async () => { + await request(app.getHttpServer()).get('/v2/auth').set('Authorization', `Bearer ${token}`).expect(200); + + expect(prismaService.buildTeam.findUnique).not.toHaveBeenCalled(); + }); + + it('still routes PUT /v2/socials to the socials bulk upsert', async () => { + await request(app.getHttpServer()) + .put('/v2/socials') + .set('Authorization', `Bearer ${token}`) + .send([]) + .expect(200); + + expect(prismaService.buildTeam.update).not.toHaveBeenCalled(); + }); + }); + + it('serves the team listing without a token', async () => { + const response = await request(app.getHttpServer()).get('/v2').expect(200); + + expect(prismaService.buildTeam.findMany).toHaveBeenCalledWith( + expect.objectContaining({ orderBy: { members: { _count: 'desc' } } }), + ); + expect(response.body).toEqual({ + status: 200, + message: 'Success', + data: [{ id: 'team-1' }], + meta: { page: 1, perPage: 20, totalItems: 1, totalPages: 1 }, + }); + }); + + it('rejects an unlisted sortBy', async () => { + await request(app.getHttpServer()).get('/v2?sortBy=token').expect(400); + }); + + it('routes /v2/modpack to the modpack listing rather than a team id', async () => { + prismaService.buildTeam.findMany.mockResolvedValue([ + { id: 'team-1', name: 'Germany', ip: 'a.example.net;b.example.net', version: '1.12.2', invite: 'inv' }, + ]); + + const response = await request(app.getHttpServer()).get('/v2/modpack').expect(200); + + expect(prismaService.buildTeam.findUnique).not.toHaveBeenCalled(); + expect(response.body.data).toEqual({ + 'team-1': { name: 'Germany', ip: ['a.example.net', 'b.example.net'], version: '1.12.2', invite: 'inv' }, + }); + }); + + it('serves the modpack details of a single team', async () => { + const response = await request(app.getHttpServer()).get('/v2/team-1/modpack').expect(200); + + expect(prismaService.buildTeam.findUnique).toHaveBeenCalledWith( + expect.objectContaining({ where: { id: 'team-1' } }), + ); + expect(response.body.data.ip).toEqual(['a.example.net']); + }); + + it('serves a single team without a token', async () => { + await request(app.getHttpServer()).get('/v2/my-team?slug=true').expect(200); + + expect(prismaService.buildTeam.findUnique).toHaveBeenCalledWith( + expect.objectContaining({ where: { slug: 'my-team' } }), + ); + }); + + it('answers 404 for a team that does not exist', async () => { + prismaService.buildTeam.findUnique.mockResolvedValue(null); + + await request(app.getHttpServer()).get('/v2/nope').expect(404); + }); + + it('does not leak the token or the webhook to an anonymous request', async () => { + prismaService.buildTeam.findUnique.mockResolvedValue({ + id: 'team-1', + slug: 'my-team', + webhook: 'https://example.com/hook', + }); + + const response = await request(app.getHttpServer()).get('/v2/team-1').expect(200); + + const { select } = prismaService.buildTeam.findUnique.mock.calls[0][0] as { + select: Record; + }; + expect(select).not.toHaveProperty('token'); + expect(select).not.toHaveProperty('webhook'); + expect(response.body.data).not.toHaveProperty('token'); + }); + + it('rejects updating a team without a token', async () => { + await request(app.getHttpServer()).put('/v2').send({ about: 'Updated' }).expect(401); + + expect(prismaService.buildTeam.update).not.toHaveBeenCalled(); + }); + + it('updates the authenticated team', async () => { + prismaService.buildTeam.findUnique.mockResolvedValue({ id: 'team-123', slug: 'my-team' }); + prismaService.buildTeam.update.mockResolvedValue({ id: 'team-123', slug: 'my-team', about: 'Updated' }); + + const response = await request(app.getHttpServer()) + .put('/v2') + .set('Authorization', `Bearer ${token}`) + .send({ about: 'Updated' }) + .expect(200); + + expect(prismaService.buildTeam.update).toHaveBeenCalledWith( + expect.objectContaining({ + where: { id: 'team-123' }, + data: expect.objectContaining({ about: 'Updated' }), + }), + ); + expect(response.body.data).toEqual(expect.objectContaining({ about: 'Updated' })); + }); + + it('accepts the prefixed form when it names the authenticated team', async () => { + prismaService.buildTeam.update.mockResolvedValue({ id: 'team-123', slug: 'my-team' }); + + await request(app.getHttpServer()) + .put('/v2/team-123') + .set('Authorization', `Bearer ${token}`) + .send({ about: 'Updated' }) + .expect(200); + + expect(prismaService.buildTeam.update).toHaveBeenCalled(); + }); + + it('refuses a prefix naming a team the token does not belong to', async () => { + await request(app.getHttpServer()) + .put('/v2/someone-else') + .set('Authorization', `Bearer ${token}`) + .send({ about: 'Updated' }) + .expect(404); + + expect(prismaService.buildTeam.update).not.toHaveBeenCalled(); + }); + + it('refuses to let a team set its own token', async () => { + await request(app.getHttpServer()) + .put('/v2') + .set('Authorization', `Bearer ${token}`) + .send({ token: 'a-secret-i-picked' }) + .expect(400); + + expect(prismaService.buildTeam.update).not.toHaveBeenCalled(); + }); + + it('rejects a slug that is not url safe', async () => { + await request(app.getHttpServer()) + .put('/v2') + .set('Authorization', `Bearer ${token}`) + .send({ slug: 'Not A Slug' }) + .expect(400); + + expect(prismaService.buildTeam.update).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/api-v2/test/sections/buildteams/buildteams.service.spec.ts b/apps/api-v2/test/sections/buildteams/buildteams.service.spec.ts new file mode 100644 index 00000000..ba29e4da --- /dev/null +++ b/apps/api-v2/test/sections/buildteams/buildteams.service.spec.ts @@ -0,0 +1,264 @@ +import { ConflictException, NotFoundException } from '@nestjs/common'; +import { Prisma } from '@repo/db'; +import { PrismaService } from 'src/common/db/prisma.service'; +import { WorkerJob } from 'src/common/queue/jobs'; +import { QueueService } from 'src/common/queue/queue.service'; +import { BuildTeamsService } from 'src/sections/buildteams/buildteams.service'; + +describe('BuildTeamsService', () => { + let buildTeamsService: BuildTeamsService; + let queueService: { dispatch: jest.Mock; dispatchAll: jest.Mock }; + let prismaService: { + buildTeam: { findMany: jest.Mock; findUnique: jest.Mock; count: jest.Mock; update: jest.Mock }; + }; + + const uniqueViolation = () => + new Prisma.PrismaClientKnownRequestError('Unique constraint failed', { + code: 'P2002', + clientVersion: 'test', + }); + + beforeEach(() => { + prismaService = { + buildTeam: { findMany: jest.fn(), findUnique: jest.fn(), count: jest.fn(), update: jest.fn() }, + }; + queueService = { dispatch: jest.fn().mockResolvedValue(true), dispatchAll: jest.fn().mockResolvedValue(0) }; + + buildTeamsService = new BuildTeamsService( + prismaService as unknown as PrismaService, + queueService as unknown as QueueService, + ); + }); + + describe('findAll', () => { + beforeEach(() => { + prismaService.buildTeam.findMany.mockResolvedValue([{ id: 'team-1' }]); + prismaService.buildTeam.count.mockResolvedValue(1); + }); + + it('should sort by member count, biggest first, by default', async () => { + await buildTeamsService.findAll({ page: 1, limit: 20 }); + + expect(prismaService.buildTeam.findMany).toHaveBeenCalledWith( + expect.objectContaining({ orderBy: { members: { _count: 'desc' } } }), + ); + }); + + it('should sort by member count through the relation when asked for members', async () => { + await buildTeamsService.findAll({ page: 1, limit: 20 }, 'members', 'asc'); + + expect(prismaService.buildTeam.findMany).toHaveBeenCalledWith( + expect.objectContaining({ orderBy: { members: { _count: 'asc' } } }), + ); + }); + + it('should sort by a plain column directly', async () => { + await buildTeamsService.findAll({ page: 1, limit: 20 }, 'name', 'asc'); + + expect(prismaService.buildTeam.findMany).toHaveBeenCalledWith( + expect.objectContaining({ orderBy: { name: 'asc' } }), + ); + }); + + it('should never select the token or the webhook', async () => { + await buildTeamsService.findAll({ page: 1, limit: 20 }); + + const { select } = prismaService.buildTeam.findMany.mock.calls[0][0] as { + select: Record; + }; + expect(select).not.toHaveProperty('token'); + expect(select).not.toHaveProperty('webhook'); + }); + + it('should paginate and report the totals', async () => { + prismaService.buildTeam.count.mockResolvedValue(5); + + const result = await buildTeamsService.findAll({ page: 2, limit: 2 }, undefined, undefined, { + location: 'Germany', + }); + + expect(prismaService.buildTeam.findMany).toHaveBeenCalledWith( + expect.objectContaining({ where: { location: 'Germany' }, skip: 2, take: 2 }), + ); + expect(result.meta).toEqual({ page: 2, perPage: 2, totalItems: 5, totalPages: 3 }); + }); + }); + + describe('findOne', () => { + it('should resolve the team by id and embed nothing optional by default', async () => { + prismaService.buildTeam.findUnique.mockResolvedValue({ id: 'team-1' }); + + await buildTeamsService.findOne('team-1', false, {}); + + const call = prismaService.buildTeam.findUnique.mock.calls[0][0] as { + where: unknown; + select: Record; + }; + expect(call.where).toEqual({ id: 'team-1' }); + expect(call.select).not.toHaveProperty('members'); + expect(call.select).not.toHaveProperty('showcases'); + expect(call.select).not.toHaveProperty('webhook'); + expect(call.select).not.toHaveProperty('token'); + }); + + it('should resolve the team by slug when requested', async () => { + prismaService.buildTeam.findUnique.mockResolvedValue({ id: 'team-1' }); + + await buildTeamsService.findOne('my-team', true, {}); + + expect(prismaService.buildTeam.findUnique).toHaveBeenCalledWith( + expect.objectContaining({ where: { slug: 'my-team' } }), + ); + }); + + it('should embed the members and showcases when asked to', async () => { + prismaService.buildTeam.findUnique.mockResolvedValue({ id: 'team-1' }); + + await buildTeamsService.findOne('team-1', false, { members: true, showcases: true }); + + const { select } = prismaService.buildTeam.findUnique.mock.calls[0][0] as { + select: Record; + }; + expect(select).toHaveProperty('members'); + expect(select).toHaveProperty('showcases'); + }); + + it('should give a team its own webhook back', async () => { + prismaService.buildTeam.findUnique.mockResolvedValue({ id: 'team-1', webhook: 'https://example.com/hook' }); + + const result = await buildTeamsService.findOne('team-1', false, {}, 'team-1'); + + expect(result).toHaveProperty('webhook', 'https://example.com/hook'); + }); + + it('should strip the webhook when another team is asking', async () => { + prismaService.buildTeam.findUnique.mockResolvedValue({ id: 'team-1', webhook: 'https://example.com/hook' }); + + const result = await buildTeamsService.findOne('team-1', false, {}, 'team-999'); + + expect(result).not.toHaveProperty('webhook'); + }); + + it('should throw when the team does not exist', async () => { + prismaService.buildTeam.findUnique.mockResolvedValue(null); + + await expect(buildTeamsService.findOne('nope', false, {})).rejects.toThrow(NotFoundException); + }); + }); + + describe('modpack', () => { + it('should key every team by its id and split the servers into a list', async () => { + prismaService.buildTeam.findMany.mockResolvedValue([ + { id: 'team-1', name: 'Germany', ip: 'a.example.net; b.example.net', version: '1.12.2', invite: 'inv' }, + ]); + + const result = await buildTeamsService.findAllForModpack(); + + expect(result).toEqual({ + 'team-1': { + name: 'Germany', + ip: ['a.example.net', 'b.example.net'], + version: '1.12.2', + invite: 'inv', + }, + }); + }); + + it('should return an empty server list when a team has none', async () => { + prismaService.buildTeam.findUnique.mockResolvedValue({ + id: 'team-1', + name: 'Germany', + ip: '', + version: '1.12.2', + invite: 'inv', + }); + + const result = (await buildTeamsService.findOneForModpack('team-1', false)) as { ip: string[] }; + + expect(result.ip).toEqual([]); + }); + + it('should resolve a single team by slug when requested', async () => { + prismaService.buildTeam.findUnique.mockResolvedValue({ id: 'team-1', ip: 'a.example.net' }); + + await buildTeamsService.findOneForModpack('my-team', true); + + expect(prismaService.buildTeam.findUnique).toHaveBeenCalledWith( + expect.objectContaining({ where: { slug: 'my-team' } }), + ); + }); + + it('should throw when the team does not exist', async () => { + prismaService.buildTeam.findUnique.mockResolvedValue(null); + + await expect(buildTeamsService.findOneForModpack('nope', false)).rejects.toThrow(NotFoundException); + }); + }); + + describe('update', () => { + beforeEach(() => { + prismaService.buildTeam.findUnique.mockResolvedValue({ id: 'team-1', slug: 'my-team' }); + }); + + it('should update the team and hand its own webhook back', async () => { + prismaService.buildTeam.update.mockResolvedValue({ id: 'team-1', slug: 'my-team', webhook: null }); + + const result = await buildTeamsService.update('team-1', { about: 'Updated' }); + + expect(prismaService.buildTeam.update).toHaveBeenCalledWith( + expect.objectContaining({ where: { id: 'team-1' }, data: { about: 'Updated' } }), + ); + expect(result).toHaveProperty('webhook'); + }); + + it('should never let the token be selected back out', async () => { + prismaService.buildTeam.update.mockResolvedValue({ id: 'team-1', slug: 'my-team' }); + + await buildTeamsService.update('team-1', { about: 'Updated' }); + + const { select } = prismaService.buildTeam.update.mock.calls[0][0] as { select: Record }; + expect(select).not.toHaveProperty('token'); + }); + + it('should ask the frontend to revalidate the team pages', async () => { + prismaService.buildTeam.update.mockResolvedValue({ id: 'team-1', slug: 'my-team' }); + + await buildTeamsService.update('team-1', { about: 'Updated' }); + + expect(queueService.dispatch).toHaveBeenCalledWith(WorkerJob.RevalidateWebsite, { + paths: expect.arrayContaining(['/teams', '/teams/my-team', '/teams/my-team/apply']), + }); + }); + + it('should revalidate the old slug too when the slug changed', async () => { + prismaService.buildTeam.update.mockResolvedValue({ id: 'team-1', slug: 'new-slug' }); + + await buildTeamsService.update('team-1', { slug: 'new-slug' }); + + const [, payload] = queueService.dispatch.mock.calls[0] as [string, { paths: string[] }]; + expect(payload.paths).toEqual(expect.arrayContaining(['/teams/new-slug', '/teams/my-team'])); + // `/teams` appears in both lists but is only worth revalidating once. + expect(payload.paths.filter((path) => path === '/teams')).toHaveLength(1); + }); + + it('should answer 409 when the name or slug is taken', async () => { + prismaService.buildTeam.update.mockRejectedValue(uniqueViolation()); + + await expect(buildTeamsService.update('team-1', { slug: 'taken' })).rejects.toThrow(ConflictException); + expect(queueService.dispatch).not.toHaveBeenCalled(); + }); + + it('should not swallow an unrelated database error', async () => { + prismaService.buildTeam.update.mockRejectedValue(new Error('connection lost')); + + await expect(buildTeamsService.update('team-1', { about: 'Updated' })).rejects.toThrow('connection lost'); + }); + + it('should throw when the team does not exist', async () => { + prismaService.buildTeam.findUnique.mockResolvedValue(null); + + await expect(buildTeamsService.update('nope', { about: 'Updated' })).rejects.toThrow(NotFoundException); + expect(prismaService.buildTeam.update).not.toHaveBeenCalled(); + }); + }); +});