Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions apps/server-nestjs/src/main.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import { ProjectServicesModule } from './modules/project-services/project-servic
import { ProjectModule } from './modules/project/project.module'
import { RepositoryModule } from './modules/repository/repository.module'
import { SystemConfigModule } from './modules/system-config/system-config.module'
import { StageModule } from './modules/stage/stage.module'
import { SystemSettingsModule } from './modules/system-settings/system-settings.module'
import { VersionModule } from './modules/version/version.module'
import { getDotenvPaths } from './utils/dotenv.utils'
Expand Down Expand Up @@ -49,6 +50,7 @@ import { getDotenvPaths } from './utils/dotenv.utils'
ProjectServicesModule,
RepositoryModule,
ScheduleModule.forRoot(),
StageModule,
SystemConfigModule,
SystemSettingsModule,
VersionModule,
Expand Down
135 changes: 135 additions & 0 deletions apps/server-nestjs/src/modules/stage/stage-queries.utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
import type { Cluster, Prisma, Stage } from '@prisma/client'

export const stageSelect = {
id: true,
name: true,
} satisfies Prisma.StageSelect
export type StageRecord = Prisma.StageGetPayload<{ select: typeof stageSelect }>

export const stageWithClustersSelect = {
id: true,
name: true,
clusters: { select: { id: true } },
} satisfies Prisma.StageSelect
export type StageWithClustersRecord = Prisma.StageGetPayload<{ select: typeof stageWithClustersSelect }>

export const stageEnvironmentsSelect = {
id: true,
name: true,
projectId: true,
memory: true,
cpu: true,
gpu: true,
autosync: true,
clusterId: true,
stageId: true,
createdAt: true,
updatedAt: true,
cluster: { select: { label: true } },
project: {
select: {
name: true,
owner: true,
slug: true,
},
},
} satisfies Prisma.EnvironmentSelect
export type StageEnvironmentsRecord = Prisma.EnvironmentGetPayload<{ select: typeof stageEnvironmentsSelect }>

export function listStages(tx: Prisma.TransactionClient) {
return tx.stage.findMany({
select: stageWithClustersSelect,
})
}

export function getAllStageIds(tx: Prisma.TransactionClient) {
return tx.stage.findMany({
select: { id: true },
})
}

export function getStageById(tx: Prisma.TransactionClient, id: Stage['id']) {
return tx.stage.findUnique({
where: { id },
select: stageWithClustersSelect,
})
}

export function getStageByName(tx: Prisma.TransactionClient, name: Stage['name']) {
return tx.stage.findUnique({ where: { name } })
}

export function getStageAssociatedEnvironments(tx: Prisma.TransactionClient, id: Stage['id']) {
return tx.environment.findMany({
where: { stageId: id },
select: stageEnvironmentsSelect,
})
}

export function getStageAssociatedEnvironmentCount(tx: Prisma.TransactionClient, id: Stage['id']) {
return tx.environment.count({
where: { stageId: id },
})
}

export function createStage(tx: Prisma.TransactionClient, { name }: { name: Stage['name'] }) {
return tx.stage.create({
data: { name },
select: stageSelect,
})
}

export function updateStageName(tx: Prisma.TransactionClient, id: Stage['id'], name: Stage['name']) {
return tx.stage.update({
where: { id },
data: { name },
})
}

export function linkStageToClusters(tx: Prisma.TransactionClient, id: Stage['id'], clusterIds: Cluster['id'][]) {
return tx.stage.update({
where: { id },
data: {
clusters: {
connect: clusterIds.map(clusterId => ({ id: clusterId })),
},
},
})
}

export function disconnectClusterFromStage(tx: Prisma.TransactionClient, clusterId: Cluster['id'], stageId: Stage['id']) {
return tx.stage.update({
where: { id: stageId },
data: {
clusters: {
disconnect: { id: clusterId },
},
},
})
}

export function deleteStage(tx: Prisma.TransactionClient, id: Stage['id']) {
return tx.stage.delete({ where: { id } })
}

export function linkClusterToStages(tx: Prisma.TransactionClient, clusterId: Cluster['id'], stageIds: Stage['id'][]) {
return tx.cluster.update({
where: { id: clusterId },
data: {
stages: {
connect: stageIds.map(stageId => ({ id: stageId })),
},
},
})
}

export function removeClusterFromStage(tx: Prisma.TransactionClient, clusterId: Cluster['id'], stageId: Stage['id']) {
return tx.cluster.update({
where: { id: clusterId },
data: {
stages: {
disconnect: { id: stageId },
},
},
})
}
35 changes: 35 additions & 0 deletions apps/server-nestjs/src/modules/stage/stage-testing.utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import type { StageEnvironmentsRecord, StageRecord, StageWithClustersRecord } from './stage-queries.utils'
import { faker } from '@faker-js/faker'
import { makeEnvironment } from '../environment/environment-testing.utils'
import { makeUser } from '../project/project-testing.utils'

export function makeStageRecord(overrides: Partial<StageRecord> = {}): StageRecord {
return {
id: faker.string.uuid(),
name: faker.helpers.slugify(faker.word.sample(3)).toLowerCase().slice(0, 20),
...overrides,
} satisfies StageRecord
}

export function makeStageWithClusters(overrides: Partial<StageWithClustersRecord> = {}): StageWithClustersRecord {
return {
id: faker.string.uuid(),
name: faker.helpers.slugify(faker.word.sample(3)).toLowerCase().slice(0, 20),
clusters: [{ id: faker.string.uuid() }],
...overrides,
} satisfies StageWithClustersRecord
}

export function makeStageEnvironmentRecord(overrides: Partial<StageEnvironmentsRecord> = {}): StageEnvironmentsRecord {
const environment = makeEnvironment()
return {
...environment,
cluster: { label: faker.helpers.slugify(faker.word.sample(3)).toLowerCase() },
project: {
slug: faker.helpers.slugify(faker.word.sample(3)).toLowerCase(),
name: faker.company.name(),
owner: makeUser(),
},
...overrides,
} satisfies StageEnvironmentsRecord
}
147 changes: 147 additions & 0 deletions apps/server-nestjs/src/modules/stage/stage.controller.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
import type { Stage, UpdateStageBody } from '@cpn-console/shared'
import type { NestFastifyApplication } from '@nestjs/platform-fastify'
import type { TestingModule } from '@nestjs/testing'
import type { MockProxy } from 'vitest-mock-extended'
import { faker } from '@faker-js/faker'
import { FastifyAdapter } from '@nestjs/platform-fastify'
import { Test } from '@nestjs/testing'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { mock } from 'vitest-mock-extended'
import { UserGuard } from '../infrastructure/permission/user/user.guard'
import { makeStageEnvironmentRecord, makeStageWithClusters } from './stage-testing.utils'
import { StageController } from './stage.controller'
import { StageService } from './stage.service'
import { toStage, toStageAssociatedEnvironments, toStages } from './stage.utils'

describe('stageController', () => {
let module: TestingModule
let controller: StageController
let service: MockProxy<StageService>

const stage: Stage = {
id: faker.string.uuid(),
name: 'dev',
clusterIds: [],
}

beforeEach(async () => {
service = mock<StageService>()

module = await Test.createTestingModule({
controllers: [StageController],
providers: [
{ provide: StageService, useValue: service },
],
})
.overrideGuard(UserGuard)
.useValue({ canActivate: () => true })
.compile()

controller = module.get<StageController>(StageController)
})

it('should be defined', () => {
expect(controller).toBeDefined()
Comment thread
shikanime marked this conversation as resolved.
})

it('maps raw stage records to the contract shape', async () => {
const records = [makeStageWithClusters()]
service.listStages.mockResolvedValue(records)

expect(await controller.list()).toEqual(toStages(records))
expect(service.listStages).toHaveBeenCalledTimes(1)
})

it('maps stage environments for the contract response', async () => {
const records = [makeStageEnvironmentRecord()]
const stageId = faker.string.uuid()
service.getStageAssociatedEnvironments.mockResolvedValue(records)

expect(await controller.getStageEnvironments(stageId)).toEqual(toStageAssociatedEnvironments(records))
expect(service.getStageAssociatedEnvironments).toHaveBeenCalledWith(stageId)
})

it('delegates create with the validated body', async () => {
const record = makeStageWithClusters()
service.createStage.mockResolvedValue(record)

expect(await controller.create(stage)).toEqual(toStage(record))
expect(service.createStage).toHaveBeenCalledWith(stage)
})

it('delegates update with stageId and validated body', async () => {
const stageId = faker.string.uuid()
const updateBody: UpdateStageBody = { name: 'staging', clusterIds: [] }
const record = makeStageWithClusters()
service.updateStage.mockResolvedValue(record)

expect(await controller.update(stageId, updateBody)).toEqual(toStage(record))
expect(service.updateStage).toHaveBeenCalledWith(stageId, updateBody)
})

it('delegates delete with stageId', async () => {
const stageId = faker.string.uuid()
service.deleteStage.mockResolvedValue(undefined)

await controller.delete(stageId)
expect(service.deleteStage).toHaveBeenCalledWith(stageId)
})
})

describe('stageController http pipeline', () => {
it('serves an anonymous GET /api/v1/stages with 200', async () => {
const service = mock<StageService>()
service.listStages.mockResolvedValue([])

const testingModule = await Test.createTestingModule({
controllers: [StageController],
providers: [
{ provide: StageService, useValue: service },
],
})
.overrideGuard(UserGuard)
.useValue({ canActivate: vi.fn(() => { throw new Error('UserGuard must not run on the public list route') }) })
.compile()

const app = testingModule.createNestApplication<NestFastifyApplication>(new FastifyAdapter())
await app.init()
await app.getHttpAdapter().getInstance().ready()

const response = await app.inject({ method: 'GET', url: '/api/v1/stages' })

expect(response.statusCode).toBe(200)

await app.close()
})

it.each([
['GET', '/api/v1/stages/not-an-uuid/environments'],
['PUT', '/api/v1/stages/not-an-uuid'],
['DELETE', '/api/v1/stages/not-an-uuid'],
])('rejects non-UUID stage params with 400 on %s %s', async (method, url) => {
const service = mock<StageService>()

const testingModule = await Test.createTestingModule({
controllers: [StageController],
providers: [
{ provide: StageService, useValue: service },
],
})
.overrideGuard(UserGuard)
.useValue({ canActivate: () => true })
.compile()

const app = testingModule.createNestApplication<NestFastifyApplication>(new FastifyAdapter())
await app.init()
await app.getHttpAdapter().getInstance().ready()

const response = await app.inject({ method, url, payload: method === 'PUT' ? {} : undefined })

expect(response.statusCode).toBe(400)
expect(service.getStageAssociatedEnvironments).not.toHaveBeenCalled()
expect(service.updateStage).not.toHaveBeenCalled()
expect(service.deleteStage).not.toHaveBeenCalled()

await app.close()
})
})
57 changes: 57 additions & 0 deletions apps/server-nestjs/src/modules/stage/stage.controller.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import type { CreateStageBody, Stage, StageAssociatedEnvironments, UpdateStageBody } from '@cpn-console/shared'
import { CreateStageBodySchema, UpdateStageBodySchema } from '@cpn-console/shared'
import { Body, Controller, Delete, Get, HttpCode, HttpStatus, Inject, Param, ParseUUIDPipe, Post, Put, UseGuards } from '@nestjs/common'
import { RequireAdminPermission } from '../infrastructure/permission/user/user-admin-permission.decorator'
import { UserGuard } from '../infrastructure/permission/user/user.guard'
import { ZodValidationPipe } from '../infrastructure/pipe/zod-validation.pipe'
import { StageService } from './stage.service'
import { toStage, toStageAssociatedEnvironments, toStages } from './stage.utils'

@Controller('api/v1/stages')
export class StageController {
constructor(@Inject(StageService) private readonly service: StageService) {}
Comment thread
shikanime marked this conversation as resolved.

@Get()
async list(): Promise<Stage[]> {
return toStages(await this.service.listStages())
}

@Get(':stageId/environments')
@UseGuards(UserGuard)
@RequireAdminPermission('ListStages')
async getStageEnvironments(
@Param('stageId', ParseUUIDPipe) stageId: string,
): Promise<StageAssociatedEnvironments> {
return toStageAssociatedEnvironments(await this.service.getStageAssociatedEnvironments(stageId))
}

@Post()
Comment thread
shikanime marked this conversation as resolved.
@HttpCode(HttpStatus.CREATED)
@UseGuards(UserGuard)
@RequireAdminPermission('ManageStages')
async create(
@Body(new ZodValidationPipe(CreateStageBodySchema)) data: CreateStageBody,
): Promise<Stage> {
return toStage(await this.service.createStage(data))
}

@Put(':stageId')
@UseGuards(UserGuard)
@RequireAdminPermission('ManageStages')
async update(
@Param('stageId', ParseUUIDPipe) stageId: string,
@Body(new ZodValidationPipe(UpdateStageBodySchema)) data: UpdateStageBody,
): Promise<Stage> {
return toStage(await this.service.updateStage(stageId, data))
}

@Delete(':stageId')
@HttpCode(HttpStatus.NO_CONTENT)
@UseGuards(UserGuard)
@RequireAdminPermission('ManageStages')
async delete(
@Param('stageId', ParseUUIDPipe) stageId: string,
): Promise<void> {
await this.service.deleteStage(stageId)
}
}
Loading
Loading