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(); + }); + }); +});