From cd106c1acbb53f2eadc9e51945854641e14f2b02 Mon Sep 17 00:00:00 2001 From: Eric McGarry <46828798+mcgarrye@users.noreply.github.com> Date: Tue, 26 Nov 2024 14:46:34 -0500 Subject: [PATCH] feat: feature flag consumption (#4489) * feat: feature flag controller service and tests 4459 * feat: associate jurisdictions tests * feat: permissions and permission tests * feat: make naming random * feat: edge case coverage * feat: refine uuid array validation * feat: remove unused import * feat: controller cleanup --- .../seed-helpers/feature-flag-factory.ts | 23 ++ .../controllers/feature-flag.controller.ts | 110 ++++++ .../feature-flag-associate.dto.ts | 31 ++ .../feature-flags/feature-flag-create.dto.ts | 4 + .../feature-flags/feature-flag-update.dto.ts | 8 + .../dtos/feature-flags/feature-flag.dto.ts | 39 ++ .../jurisdictions/jurisdiction-update.dto.ts | 1 + .../dtos/jurisdictions/jurisdiction.dto.ts | 16 +- api/src/modules/app.module.ts | 3 + api/src/modules/feature-flag.module.ts | 14 + .../permission-configs/permission_policy.csv | 2 + api/src/services/feature-flag.service.ts | 207 +++++++++++ api/src/services/jurisdiction.service.ts | 1 + api/test/integration/feature-flag.e2e-spec.ts | 314 ++++++++++++++++ .../permission-as-admin.e2e-spec.ts | 90 +++++ ...n-as-juris-admin-correct-juris.e2e-spec.ts | 54 +++ ...ion-as-juris-admin-wrong-juris.e2e-spec.ts | 54 +++ .../permission-as-no-user.e2e-spec.ts | 54 +++ ...ion-as-partner-correct-listing.e2e-spec.ts | 54 +++ ...ssion-as-partner-wrong-listing.e2e-spec.ts | 54 +++ .../permission-as-public.e2e-spec.ts | 54 +++ .../services/feature-flag.service.spec.ts | 345 ++++++++++++++++++ .../services/jurisdiction.service.spec.ts | 21 ++ shared-helpers/src/types/backend-swagger.ts | 188 ++++++++++ 24 files changed, 1737 insertions(+), 4 deletions(-) create mode 100644 api/prisma/seed-helpers/feature-flag-factory.ts create mode 100644 api/src/controllers/feature-flag.controller.ts create mode 100644 api/src/dtos/feature-flags/feature-flag-associate.dto.ts create mode 100644 api/src/dtos/feature-flags/feature-flag-create.dto.ts create mode 100644 api/src/dtos/feature-flags/feature-flag-update.dto.ts create mode 100644 api/src/dtos/feature-flags/feature-flag.dto.ts create mode 100644 api/src/modules/feature-flag.module.ts create mode 100644 api/src/services/feature-flag.service.ts create mode 100644 api/test/integration/feature-flag.e2e-spec.ts create mode 100644 api/test/unit/services/feature-flag.service.spec.ts diff --git a/api/prisma/seed-helpers/feature-flag-factory.ts b/api/prisma/seed-helpers/feature-flag-factory.ts new file mode 100644 index 0000000000..783f9777f3 --- /dev/null +++ b/api/prisma/seed-helpers/feature-flag-factory.ts @@ -0,0 +1,23 @@ +import { Prisma } from '@prisma/client'; +import { randomBoolean } from './boolean-generator'; +import { randomAdjective, randomName } from './word-generator'; + +export const featureFlagFactory = ( + name = randomName(), + active = randomBoolean(), + description = `${randomAdjective()} feature flag`, + jurisdictionIds?: string[], +): Prisma.FeatureFlagsCreateInput => ({ + name: name, + description: description, + active: active, + jurisdictions: jurisdictionIds + ? { + connect: jurisdictionIds.map((jurisdiction) => { + return { + id: jurisdiction, + }; + }), + } + : undefined, +}); diff --git a/api/src/controllers/feature-flag.controller.ts b/api/src/controllers/feature-flag.controller.ts new file mode 100644 index 0000000000..a460548635 --- /dev/null +++ b/api/src/controllers/feature-flag.controller.ts @@ -0,0 +1,110 @@ +import { + Body, + Controller, + Delete, + Get, + Param, + ParseUUIDPipe, + Post, + Put, + UseGuards, + UsePipes, + ValidationPipe, +} from '@nestjs/common'; +import { + ApiExtraModels, + ApiOkResponse, + ApiOperation, + ApiTags, +} from '@nestjs/swagger'; +import { FeatureFlagService } from '../services/feature-flag.service'; +import { FeatureFlag } from '../dtos/feature-flags/feature-flag.dto'; +import { FeatureFlagAssociate } from '../dtos/feature-flags/feature-flag-associate.dto'; +import { FeatureFlagCreate } from '../dtos/feature-flags/feature-flag-create.dto'; +import { FeatureFlagUpdate } from '../dtos/feature-flags/feature-flag-update.dto'; +import { defaultValidationPipeOptions } from '../utilities/default-validation-pipe-options'; +import { IdDTO } from '../dtos/shared/id.dto'; +import { SuccessDTO } from '../dtos/shared/success.dto'; +import { PermissionTypeDecorator } from '../decorators/permission-type.decorator'; +import { OptionalAuthGuard } from '../guards/optional.guard'; +import { PermissionGuard } from '../guards/permission.guard'; +import { ApiKeyGuard } from '../guards/api-key.guard'; + +@Controller('featureFlags') +@ApiTags('featureFlags') +@UsePipes(new ValidationPipe(defaultValidationPipeOptions)) +@ApiExtraModels( + FeatureFlagAssociate, + FeatureFlagCreate, + FeatureFlagUpdate, + IdDTO, +) +@PermissionTypeDecorator('featureFlags') +@UseGuards(ApiKeyGuard, OptionalAuthGuard, PermissionGuard) +export class FeatureFlagController { + constructor(private readonly featureFlagService: FeatureFlagService) {} + + @Get() + @ApiOperation({ summary: 'List of feature flags', operationId: 'list' }) + @ApiOkResponse({ type: FeatureFlag, isArray: true }) + async list(): Promise { + return await this.featureFlagService.list(); + } + + @Post() + @ApiOperation({ + summary: 'Create a feature flag', + operationId: 'create', + }) + @ApiOkResponse({ type: FeatureFlag }) + async create(@Body() featureFlag: FeatureFlagCreate): Promise { + return await this.featureFlagService.create(featureFlag); + } + + @Put() + @ApiOperation({ + summary: 'Update a feature flag', + operationId: 'update', + }) + @ApiOkResponse({ type: FeatureFlag }) + async update(@Body() featureFlag: FeatureFlagUpdate): Promise { + return await this.featureFlagService.update(featureFlag); + } + + @Delete() + @ApiOperation({ + summary: 'Delete a feature flag by id', + operationId: 'delete', + }) + @ApiOkResponse({ type: SuccessDTO }) + async delete(@Body() dto: IdDTO): Promise { + return await this.featureFlagService.delete(dto.id); + } + + @Put(`associateJurisdictions`) + @ApiOperation({ + summary: 'Associate and disassociate jurisdictions with a feature flag', + operationId: 'associateJurisdictions', + }) + @ApiOkResponse({ type: FeatureFlag }) + async associateJurisdictions( + @Body() featureFlagAssociate: FeatureFlagAssociate, + ): Promise { + return await this.featureFlagService.associateJurisdictions( + featureFlagAssociate, + ); + } + + @Get(`:featureFlagId`) + @ApiOperation({ + summary: 'Get a feature flag by id', + operationId: 'retrieve', + }) + @ApiOkResponse({ type: FeatureFlag }) + async retrieve( + @Param('featureFlagId', new ParseUUIDPipe({ version: '4' })) + featureFlagId: string, + ): Promise { + return this.featureFlagService.findOne(featureFlagId); + } +} diff --git a/api/src/dtos/feature-flags/feature-flag-associate.dto.ts b/api/src/dtos/feature-flags/feature-flag-associate.dto.ts new file mode 100644 index 0000000000..2dff0020fe --- /dev/null +++ b/api/src/dtos/feature-flags/feature-flag-associate.dto.ts @@ -0,0 +1,31 @@ +import { Expose } from 'class-transformer'; +import { IsArray, IsDefined, IsString, IsUUID } from 'class-validator'; +import { ApiProperty } from '@nestjs/swagger'; +import { ValidationsGroupsEnum } from '../../enums/shared/validation-groups-enum'; + +export class FeatureFlagAssociate { + @Expose() + @IsString({ groups: [ValidationsGroupsEnum.default] }) + @IsUUID(4, { groups: [ValidationsGroupsEnum.default] }) + @IsDefined({ groups: [ValidationsGroupsEnum.default] }) + @ApiProperty() + id: string; + + @Expose() + @IsArray({ groups: [ValidationsGroupsEnum.default] }) + @IsUUID(4, { + groups: [ValidationsGroupsEnum.default], + each: true, + }) + @ApiProperty() + associate: string[]; + + @Expose() + @IsArray({ groups: [ValidationsGroupsEnum.default] }) + @IsUUID(4, { + groups: [ValidationsGroupsEnum.default], + each: true, + }) + @ApiProperty() + remove: string[]; +} diff --git a/api/src/dtos/feature-flags/feature-flag-create.dto.ts b/api/src/dtos/feature-flags/feature-flag-create.dto.ts new file mode 100644 index 0000000000..077b86328f --- /dev/null +++ b/api/src/dtos/feature-flags/feature-flag-create.dto.ts @@ -0,0 +1,4 @@ +import { OmitType } from '@nestjs/swagger'; +import { FeatureFlagUpdate } from './feature-flag-update.dto'; + +export class FeatureFlagCreate extends OmitType(FeatureFlagUpdate, ['id']) {} diff --git a/api/src/dtos/feature-flags/feature-flag-update.dto.ts b/api/src/dtos/feature-flags/feature-flag-update.dto.ts new file mode 100644 index 0000000000..b8cffb41c3 --- /dev/null +++ b/api/src/dtos/feature-flags/feature-flag-update.dto.ts @@ -0,0 +1,8 @@ +import { OmitType } from '@nestjs/swagger'; +import { FeatureFlag } from './feature-flag.dto'; + +export class FeatureFlagUpdate extends OmitType(FeatureFlag, [ + 'createdAt', + 'updatedAt', + 'jurisdictions', +]) {} diff --git a/api/src/dtos/feature-flags/feature-flag.dto.ts b/api/src/dtos/feature-flags/feature-flag.dto.ts new file mode 100644 index 0000000000..05525ca953 --- /dev/null +++ b/api/src/dtos/feature-flags/feature-flag.dto.ts @@ -0,0 +1,39 @@ +import { Expose, Type } from 'class-transformer'; +import { + IsBoolean, + IsDefined, + IsString, + MaxLength, + ValidateNested, +} from 'class-validator'; +import { ApiProperty } from '@nestjs/swagger'; +import { AbstractDTO } from '../shared/abstract.dto'; +import { IdDTO } from '../shared/id.dto'; +import { ValidationsGroupsEnum } from '../../enums/shared/validation-groups-enum'; + +export class FeatureFlag extends AbstractDTO { + @Expose() + @IsString({ groups: [ValidationsGroupsEnum.default] }) + @MaxLength(256, { groups: [ValidationsGroupsEnum.default] }) + @IsDefined({ groups: [ValidationsGroupsEnum.default] }) + @ApiProperty() + name: string; + + @Expose() + @IsString({ groups: [ValidationsGroupsEnum.default] }) + @IsDefined({ groups: [ValidationsGroupsEnum.default] }) + @ApiProperty() + description: string; + + @Expose() + @IsBoolean({ groups: [ValidationsGroupsEnum.default] }) + @IsDefined({ groups: [ValidationsGroupsEnum.default] }) + @ApiProperty() + active: boolean; + + @Expose() + @ValidateNested({ groups: [ValidationsGroupsEnum.default], each: true }) + @Type(() => IdDTO) + @ApiProperty({ type: IdDTO, isArray: true }) + jurisdictions: IdDTO[]; +} diff --git a/api/src/dtos/jurisdictions/jurisdiction-update.dto.ts b/api/src/dtos/jurisdictions/jurisdiction-update.dto.ts index a2a2f4077e..b6ff5c1e90 100644 --- a/api/src/dtos/jurisdictions/jurisdiction-update.dto.ts +++ b/api/src/dtos/jurisdictions/jurisdiction-update.dto.ts @@ -4,5 +4,6 @@ import { Jurisdiction } from './jurisdiction.dto'; export class JurisdictionUpdate extends OmitType(Jurisdiction, [ 'createdAt', 'updatedAt', + 'featureFlags', 'multiselectQuestions', ]) {} diff --git a/api/src/dtos/jurisdictions/jurisdiction.dto.ts b/api/src/dtos/jurisdictions/jurisdiction.dto.ts index e4131ea3ba..11faa1c83a 100644 --- a/api/src/dtos/jurisdictions/jurisdiction.dto.ts +++ b/api/src/dtos/jurisdictions/jurisdiction.dto.ts @@ -1,4 +1,4 @@ -import { AbstractDTO } from '../shared/abstract.dto'; +import { Expose, Type } from 'class-transformer'; import { IsString, MaxLength, @@ -9,11 +9,12 @@ import { ValidateNested, IsBoolean, } from 'class-validator'; -import { ValidationsGroupsEnum } from '../../enums/shared/validation-groups-enum'; -import { LanguagesEnum, UserRoleEnum } from '@prisma/client'; -import { Expose, Type } from 'class-transformer'; import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { LanguagesEnum, UserRoleEnum } from '@prisma/client'; +import { FeatureFlag } from '../feature-flags/feature-flag.dto'; +import { AbstractDTO } from '../shared/abstract.dto'; import { IdDTO } from '../shared/id.dto'; +import { ValidationsGroupsEnum } from '../../enums/shared/validation-groups-enum'; export class Jurisdiction extends AbstractDTO { @Expose() @@ -140,4 +141,11 @@ export class Jurisdiction extends AbstractDTO { isArray: true, }) duplicateListingPermissions: UserRoleEnum[]; + + @Expose() + @ValidateNested({ groups: [ValidationsGroupsEnum.default] }) + @Type(() => FeatureFlag) + @IsDefined({ groups: [ValidationsGroupsEnum.default] }) + @ApiProperty({ type: FeatureFlag, isArray: true }) + featureFlags: FeatureFlag[]; } diff --git a/api/src/modules/app.module.ts b/api/src/modules/app.module.ts index 96df705527..ae37aea5e1 100644 --- a/api/src/modules/app.module.ts +++ b/api/src/modules/app.module.ts @@ -22,6 +22,7 @@ import { ThrottlerModule } from '@nestjs/throttler'; import { ThrottleGuard } from '../guards/throttler.guard'; import { ScirptRunnerModule } from './script-runner.module'; import { LotteryModule } from './lottery.module'; +import { FeatureFlagModule } from './feature-flag.module'; @Module({ imports: [ @@ -42,6 +43,7 @@ import { LotteryModule } from './lottery.module'; MapLayerModule, ScirptRunnerModule, LotteryModule, + FeatureFlagModule, ThrottlerModule.forRoot([ { ttl: Number(process.env.THROTTLE_TTL), @@ -77,6 +79,7 @@ import { LotteryModule } from './lottery.module'; MapLayerModule, ScirptRunnerModule, LotteryModule, + FeatureFlagModule, ], }) export class AppModule {} diff --git a/api/src/modules/feature-flag.module.ts b/api/src/modules/feature-flag.module.ts new file mode 100644 index 0000000000..8197264041 --- /dev/null +++ b/api/src/modules/feature-flag.module.ts @@ -0,0 +1,14 @@ +import { Module } from '@nestjs/common'; +import { FeatureFlagController } from '../controllers/feature-flag.controller'; +import { FeatureFlagService } from '../services/feature-flag.service'; +import { JurisdictionModule } from './jurisdiction.module'; +import { PermissionModule } from './permission.module'; +import { PrismaModule } from './prisma.module'; + +@Module({ + imports: [JurisdictionModule, PermissionModule, PrismaModule], + controllers: [FeatureFlagController], + providers: [FeatureFlagService], + exports: [FeatureFlagService], +}) +export class FeatureFlagModule {} diff --git a/api/src/permission-configs/permission_policy.csv b/api/src/permission-configs/permission_policy.csv index 15b0519f98..034690fa4c 100644 --- a/api/src/permission-configs/permission_policy.csv +++ b/api/src/permission-configs/permission_policy.csv @@ -81,6 +81,8 @@ p, partner, paperApplication, true, read p, admin, mapLayers, true, .* p, jurisdictionAdmin, mapLayers, true, .* +p, admin, featureFlags, true, .* + g, admin, jurisdictionAdmin g, jurisdictionAdmin, partner g, partner, user diff --git a/api/src/services/feature-flag.service.ts b/api/src/services/feature-flag.service.ts new file mode 100644 index 0000000000..7e90d97536 --- /dev/null +++ b/api/src/services/feature-flag.service.ts @@ -0,0 +1,207 @@ +import { + Injectable, + BadRequestException, + NotFoundException, +} from '@nestjs/common'; +import { JurisdictionService } from './jurisdiction.service'; +import { PrismaService } from './prisma.service'; +import { FeatureFlag } from '../dtos/feature-flags/feature-flag.dto'; +import { FeatureFlagAssociate } from '../dtos/feature-flags/feature-flag-associate.dto'; +import { FeatureFlagCreate } from '../dtos/feature-flags/feature-flag-create.dto'; +import { FeatureFlagUpdate } from '../dtos/feature-flags/feature-flag-update.dto'; +import { SuccessDTO } from '../dtos/shared/success.dto'; +import { mapTo } from '../utilities/mapTo'; + +/** + this is the service for feature flags + it handles all the backend's business logic for reading/writing/deleting feature flag data + */ +@Injectable() +export class FeatureFlagService { + constructor( + private prisma: PrismaService, + private jurisdictionService: JurisdictionService, + ) {} + + /** + this will get a set of feature flags + */ + async list(): Promise { + const rawfeatureFlags = await this.prisma.featureFlags.findMany({ + include: { + jurisdictions: { + select: { + id: true, + name: true, + }, + }, + }, + }); + return mapTo(FeatureFlag, rawfeatureFlags); + } + + /* + this will return 1 feature flag or error + */ + async findOne(featureFlagId: string): Promise { + if (!featureFlagId) { + throw new BadRequestException('a feature flag id must be provided'); + } + + const rawFeatureFlag = await this.prisma.featureFlags.findFirst({ + include: { + jurisdictions: { + select: { + id: true, + name: true, + }, + }, + }, + where: { + id: featureFlagId, + }, + }); + + if (!rawFeatureFlag) { + throw new NotFoundException( + `feature flag id ${featureFlagId} was requested but not found`, + ); + } + + return mapTo(FeatureFlag, rawFeatureFlag); + } + + /* + this will create a feature flag + */ + async create(dto: FeatureFlagCreate): Promise { + const rawResult = await this.prisma.featureFlags.create({ + data: { + ...dto, + }, + include: { + jurisdictions: { + select: { + id: true, + name: true, + }, + }, + }, + }); + + return mapTo(FeatureFlag, rawResult); + } + + /* + this will update a feature flag's name or description field + if no feature flag has the id of the incoming argument an error is thrown + */ + async update(dto: FeatureFlagUpdate): Promise { + await this.findOrThrow(dto.id); + + const rawResults = await this.prisma.featureFlags.update({ + data: { + ...dto, + id: undefined, + }, + include: { + jurisdictions: { + select: { + id: true, + name: true, + }, + }, + }, + where: { + id: dto.id, + }, + }); + return mapTo(FeatureFlag, rawResults); + } + + /* + this will delete a feature flag + */ + async delete(featureFlagId: string): Promise { + await this.findOrThrow(featureFlagId); + await this.prisma.featureFlags.delete({ + where: { + id: featureFlagId, + }, + }); + return { + success: true, + } as SuccessDTO; + } + + /* + this will either find a record or throw a customized error + */ + async findOrThrow(featureFlagId: string): Promise { + const featureFlag = await this.prisma.featureFlags.findFirst({ + where: { + id: featureFlagId, + }, + }); + + if (!featureFlag) { + throw new NotFoundException( + `feature flag id ${featureFlagId} was requested but not found`, + ); + } + + return true; + } + + async associateJurisdictions( + dto: FeatureFlagAssociate, + ): Promise { + await this.findOrThrow(dto.id); + + const idsToAssociateSet = new Set(dto.associate); + + dto.remove.forEach((id) => { + if (idsToAssociateSet.has(id)) { + // Remove the item from the set + idsToAssociateSet.delete(id); + } + }); + + const idsToAssociate = [...idsToAssociateSet]; + + for (const id of idsToAssociate) { + try { + await this.jurisdictionService.findOrThrow(id); + } catch (e) { + throw new BadRequestException( + `jurisdiction id ${id} was requested for association but not found`, + ); + } + } + + const rawResults = await this.prisma.featureFlags.update({ + data: { + jurisdictions: { + connect: idsToAssociate.map((id) => { + return { id: id }; + }), + disconnect: dto.remove.map((id) => { + return { id: id }; + }), + }, + }, + include: { + jurisdictions: { + select: { + id: true, + name: true, + }, + }, + }, + where: { + id: dto.id, + }, + }); + return mapTo(FeatureFlag, rawResults); + } +} diff --git a/api/src/services/jurisdiction.service.ts b/api/src/services/jurisdiction.service.ts index 349c175cf7..f0cd0bb868 100644 --- a/api/src/services/jurisdiction.service.ts +++ b/api/src/services/jurisdiction.service.ts @@ -12,6 +12,7 @@ import { Prisma } from '@prisma/client'; import { JurisdictionUpdate } from '../dtos/jurisdictions/jurisdiction-update.dto'; const view: Prisma.JurisdictionsInclude = { + featureFlags: true, multiselectQuestions: true, }; /** diff --git a/api/test/integration/feature-flag.e2e-spec.ts b/api/test/integration/feature-flag.e2e-spec.ts new file mode 100644 index 0000000000..2e8e22bf63 --- /dev/null +++ b/api/test/integration/feature-flag.e2e-spec.ts @@ -0,0 +1,314 @@ +import cookieParser from 'cookie-parser'; +import { randomUUID } from 'crypto'; +import request from 'supertest'; +import { Test, TestingModule } from '@nestjs/testing'; +import { INestApplication } from '@nestjs/common'; +import { AppModule } from '../../src/modules/app.module'; +import { PrismaService } from '../../src/services/prisma.service'; +import { featureFlagFactory } from '../../prisma/seed-helpers/feature-flag-factory'; +import { jurisdictionFactory } from '../../prisma/seed-helpers/jurisdiction-factory'; +import { randomName } from '../../prisma/seed-helpers/word-generator'; +import { userFactory } from '../../prisma/seed-helpers/user-factory'; +import { Login } from '../../src/dtos/auth/login.dto'; + +describe('Feature Flag Controller Tests', () => { + let app: INestApplication; + let prisma: PrismaService; + let adminAccessToken: string; + + beforeAll(async () => { + const moduleFixture: TestingModule = await Test.createTestingModule({ + imports: [AppModule], + }).compile(); + + app = moduleFixture.createNestApplication(); + prisma = moduleFixture.get(PrismaService); + app.use(cookieParser()); + await app.init(); + + const storedUser = await prisma.userAccounts.create({ + data: await userFactory({ + roles: { isAdmin: true }, + mfaEnabled: false, + confirmedAt: new Date(), + }), + }); + const resLogIn = await request(app.getHttpServer()) + .post('/auth/login') + .set({ passkey: process.env.API_PASS_KEY || '' }) + .send({ + email: storedUser.email, + password: 'Abcdef12345!', + } as Login) + .expect(201); + adminAccessToken = resLogIn.header?.['set-cookie'].find((cookie) => + cookie.startsWith('access-token='), + ); + }); + + afterAll(async () => { + await prisma.$disconnect(); + await app.close(); + }); + + describe('list endpoint', () => { + it('should return all existing feature flags', async () => { + const featureFlagA = await prisma.featureFlags.create({ + data: featureFlagFactory(), + }); + const featureFlagB = await prisma.featureFlags.create({ + data: featureFlagFactory(), + }); + + const res = await request(app.getHttpServer()) + .get(`/featureFlags`) + .set({ passkey: process.env.API_PASS_KEY || '' }) + .set('Cookie', adminAccessToken) + .expect(200); + + expect(res.body.length).toBeGreaterThanOrEqual(2); + const featureFlags = res.body.map((value) => value.name); + expect(featureFlags).toContain(featureFlagA.name); + expect(featureFlags).toContain(featureFlagB.name); + }); + }); + + describe('create endpoint', () => { + it('should create a feature flag', async () => { + const body = { + name: randomName(), + description: 'new description', + active: true, + }; + + const res = await request(app.getHttpServer()) + .post(`/featureFlags`) + .set({ passkey: process.env.API_PASS_KEY || '' }) + .send(body) + .set('Cookie', adminAccessToken) + .expect(201); + + expect(res.body).toEqual({ + ...body, + jurisdictions: [], + id: expect.anything(), + createdAt: expect.anything(), + updatedAt: expect.anything(), + }); + }); + }); + + describe('update endpoint', () => { + it('should update an existing feature flag', async () => { + const featureFlag = await prisma.featureFlags.create({ + data: featureFlagFactory(), + }); + + const body = { + id: featureFlag.id, + name: `updated ${randomName()}`, + description: 'updated description', + active: !featureFlag.active, + }; + + const res = await request(app.getHttpServer()) + .put(`/featureFlags`) + .set({ passkey: process.env.API_PASS_KEY || '' }) + .send(body) + .set('Cookie', adminAccessToken) + .expect(200); + + expect(res.body).toEqual({ + ...body, + jurisdictions: [], + createdAt: expect.anything(), + updatedAt: expect.anything(), + }); + }); + + it('should error when trying to update a feature flag that does not exist', async () => { + const body = { + id: randomUUID(), + name: 'updated name', + description: 'updated description', + active: true, + }; + + const res = await request(app.getHttpServer()) + .put(`/featureFlags`) + .set({ passkey: process.env.API_PASS_KEY || '' }) + .send(body) + .set('Cookie', adminAccessToken) + .expect(404); + + expect(res.body.message).toEqual( + `feature flag id ${body.id} was requested but not found`, + ); + }); + }); + + describe('delete endpoint', () => { + it('should delete an existing feature flag', async () => { + const featureFlag = await prisma.featureFlags.create({ + data: featureFlagFactory(), + }); + + const res = await request(app.getHttpServer()) + .delete(`/featureFlags`) + .set({ passkey: process.env.API_PASS_KEY || '' }) + .send({ id: featureFlag.id }) + .set('Cookie', adminAccessToken) + .expect(200); + + const featureFlagAfterDelete = await prisma.featureFlags.findUnique({ + where: { id: featureFlag.id }, + }); + expect(featureFlagAfterDelete).toBeNull(); + expect(res.body.success).toEqual(true); + }); + + it('should error when trying to delete a feature flag that does not exist', async () => { + const id = randomUUID(); + + const res = await request(app.getHttpServer()) + .delete(`/featureFlags`) + .set({ passkey: process.env.API_PASS_KEY || '' }) + .send({ id: id }) + .set('Cookie', adminAccessToken) + .expect(404); + + expect(res.body.message).toEqual( + `feature flag id ${id} was requested but not found`, + ); + }); + }); + + describe('associateJurisdictions endpoint', () => { + it('should associate and remove jurisdictions to an existing feature flag', async () => { + const jurisdiction1 = await prisma.jurisdictions.create({ + data: jurisdictionFactory(), + }); + const jurisdiction2 = await prisma.jurisdictions.create({ + data: jurisdictionFactory(), + }); + const jurisdiction3 = await prisma.jurisdictions.create({ + data: jurisdictionFactory(), + }); + const featureFlag = await prisma.featureFlags.create({ + data: featureFlagFactory(undefined, undefined, undefined, [ + jurisdiction1.id, + jurisdiction2.id, + ]), + }); + + const body = { + id: featureFlag.id, + associate: [jurisdiction3.id], + remove: [jurisdiction2.id], + }; + + const res = await request(app.getHttpServer()) + .put(`/featureFlags/associateJurisdictions`) + .set({ passkey: process.env.API_PASS_KEY || '' }) + .send(body) + .set('Cookie', adminAccessToken) + .expect(200); + + expect(res.body).toEqual({ + ...featureFlag, + jurisdictions: [ + { + id: jurisdiction1.id, + name: jurisdiction1.name, + }, + { + id: jurisdiction3.id, + name: jurisdiction3.name, + }, + ], + createdAt: expect.anything(), + updatedAt: expect.anything(), + }); + }); + + it('should not associate a jurisdiction also set to remove to an existing feature flag', async () => { + const jurisdiction = await prisma.jurisdictions.create({ + data: jurisdictionFactory(), + }); + const featureFlag = await prisma.featureFlags.create({ + data: featureFlagFactory(), + }); + + const body = { + id: featureFlag.id, + associate: [jurisdiction.id], + remove: [jurisdiction.id], + }; + + const res = await request(app.getHttpServer()) + .put(`/featureFlags/associateJurisdictions`) + .set({ passkey: process.env.API_PASS_KEY || '' }) + .send(body) + .set('Cookie', adminAccessToken) + .expect(200); + + expect(res.body).toEqual({ + ...featureFlag, + jurisdictions: [], + createdAt: expect.anything(), + updatedAt: expect.anything(), + }); + }); + + it('should error when trying to associateJurisdictions a feature flag that does not exist', async () => { + const body = { + id: randomUUID(), + associate: [], + remove: [], + }; + + const res = await request(app.getHttpServer()) + .put(`/featureFlags/associateJurisdictions`) + .set({ passkey: process.env.API_PASS_KEY || '' }) + .send(body) + .set('Cookie', adminAccessToken) + .expect(404); + + expect(res.body.message).toEqual( + `feature flag id ${body.id} was requested but not found`, + ); + }); + }); + + describe('retrieve endpoint', () => { + it('should return an existing feature flag by id', async () => { + const featureFlag = await prisma.featureFlags.create({ + data: featureFlagFactory(), + }); + + const res = await request(app.getHttpServer()) + .get(`/featureFlags/${featureFlag.id}`) + .set({ passkey: process.env.API_PASS_KEY || '' }) + .set('Cookie', adminAccessToken) + .expect(200); + + expect(res.body.name).toEqual(featureFlag.name); + expect(res.body.description).toEqual(featureFlag.description); + expect(res.body.active).toEqual(featureFlag.active); + }); + }); + + it('should error when trying to retrieve a feature flag that does not exist', async () => { + const id = randomUUID(); + + const res = await request(app.getHttpServer()) + .get(`/featureFlags/${id}`) + .set({ passkey: process.env.API_PASS_KEY || '' }) + .set('Cookie', adminAccessToken) + .expect(404); + + expect(res.body.message).toEqual( + `feature flag id ${id} was requested but not found`, + ); + }); +}); diff --git a/api/test/integration/permission-tests/permission-as-admin.e2e-spec.ts b/api/test/integration/permission-tests/permission-as-admin.e2e-spec.ts index 63dce5fd22..a33a569706 100644 --- a/api/test/integration/permission-tests/permission-as-admin.e2e-spec.ts +++ b/api/test/integration/permission-tests/permission-as-admin.e2e-spec.ts @@ -64,6 +64,7 @@ import { createSimpleListing, } from './helpers'; import { ApplicationFlaggedSetService } from '../../../src/services/application-flagged-set.service'; +import { featureFlagFactory } from '../../../prisma/seed-helpers/feature-flag-factory'; const testEmailService = { confirmation: jest.fn(), @@ -1419,4 +1420,93 @@ describe('Testing Permissioning of endpoints as Admin User', () => { .expect(200); }); }); + + describe('Testing feature flag endpoints', () => { + it('should succeed for list endpoint', async () => { + await request(app.getHttpServer()) + .get(`/featureFlags`) + .set({ passkey: process.env.API_PASS_KEY || '' }) + .set('Cookie', cookies) + .expect(200); + }); + + it('should succeed for create endpoint', async () => { + const body = { + name: 'new name', + description: 'new description', + active: true, + }; + + await request(app.getHttpServer()) + .post(`/featureFlags`) + .set({ passkey: process.env.API_PASS_KEY || '' }) + .send(body) + .set('Cookie', cookies) + .expect(201); + }); + + it('should succeed for update endpoint', async () => { + const featureFlag = await prisma.featureFlags.create({ + data: featureFlagFactory(), + }); + + const body = { + id: featureFlag.id, + name: 'updated name', + description: 'updated description', + active: !featureFlag.active, + }; + + await request(app.getHttpServer()) + .put(`/featureFlags`) + .set({ passkey: process.env.API_PASS_KEY || '' }) + .send(body) + .set('Cookie', cookies) + .expect(200); + }); + + it('should succeed for delete endpoint', async () => { + const featureFlag = await prisma.featureFlags.create({ + data: featureFlagFactory(), + }); + + await request(app.getHttpServer()) + .delete(`/featureFlags`) + .set({ passkey: process.env.API_PASS_KEY || '' }) + .send({ id: featureFlag.id }) + .set('Cookie', cookies) + .expect(200); + }); + + it('should succeed for associate jurisdictions endpoint', async () => { + const featureFlag = await prisma.featureFlags.create({ + data: featureFlagFactory(), + }); + + const body = { + id: featureFlag.id, + associate: [], + remove: [], + }; + + await request(app.getHttpServer()) + .put(`/featureFlags/associateJurisdictions`) + .set({ passkey: process.env.API_PASS_KEY || '' }) + .send(body) + .set('Cookie', cookies) + .expect(200); + }); + + it('should succeed for retrieve endpoint', async () => { + const featureFlag = await prisma.featureFlags.create({ + data: featureFlagFactory(), + }); + + await request(app.getHttpServer()) + .get(`/featureFlags/${featureFlag.id}`) + .set({ passkey: process.env.API_PASS_KEY || '' }) + .set('Cookie', cookies) + .expect(200); + }); + }); }); diff --git a/api/test/integration/permission-tests/permission-as-juris-admin-correct-juris.e2e-spec.ts b/api/test/integration/permission-tests/permission-as-juris-admin-correct-juris.e2e-spec.ts index 525c627172..f119648ddd 100644 --- a/api/test/integration/permission-tests/permission-as-juris-admin-correct-juris.e2e-spec.ts +++ b/api/test/integration/permission-tests/permission-as-juris-admin-correct-juris.e2e-spec.ts @@ -1379,4 +1379,58 @@ describe('Testing Permissioning of endpoints as Jurisdictional Admin in the corr .expect(403); }); }); + + describe('Testing feature flag endpoints', () => { + it('should error as forbidden for list endpoint', async () => { + await request(app.getHttpServer()) + .get(`/featureFlags`) + .set({ passkey: process.env.API_PASS_KEY || '' }) + .set('Cookie', cookies) + .expect(403); + }); + + it('should error as forbidden for create endpoint', async () => { + await request(app.getHttpServer()) + .post(`/featureFlags`) + .set({ passkey: process.env.API_PASS_KEY || '' }) + .send({}) + .set('Cookie', cookies) + .expect(403); + }); + + it('should error as forbidden for update endpoint', async () => { + await request(app.getHttpServer()) + .put(`/featureFlags`) + .set({ passkey: process.env.API_PASS_KEY || '' }) + .send({}) + .set('Cookie', cookies) + .expect(403); + }); + + it('should error as forbidden for delete endpoint', async () => { + await request(app.getHttpServer()) + .delete(`/featureFlags`) + .set({ passkey: process.env.API_PASS_KEY || '' }) + .send({}) + .set('Cookie', cookies) + .expect(403); + }); + + it('should error as forbidden for associate jurisdictions endpoint', async () => { + await request(app.getHttpServer()) + .put(`/featureFlags/associateJurisdictions`) + .set({ passkey: process.env.API_PASS_KEY || '' }) + .send({}) + .set('Cookie', cookies) + .expect(403); + }); + + it('should error as forbidden for retrieve endpoint', async () => { + await request(app.getHttpServer()) + .get(`/featureFlags/example`) + .set({ passkey: process.env.API_PASS_KEY || '' }) + .set('Cookie', cookies) + .expect(403); + }); + }); }); diff --git a/api/test/integration/permission-tests/permission-as-juris-admin-wrong-juris.e2e-spec.ts b/api/test/integration/permission-tests/permission-as-juris-admin-wrong-juris.e2e-spec.ts index 332f878f47..0aeeb23820 100644 --- a/api/test/integration/permission-tests/permission-as-juris-admin-wrong-juris.e2e-spec.ts +++ b/api/test/integration/permission-tests/permission-as-juris-admin-wrong-juris.e2e-spec.ts @@ -1318,4 +1318,58 @@ describe('Testing Permissioning of endpoints as Jurisdictional Admin in the wron .expect(403); }); }); + + describe('Testing feature flag endpoints', () => { + it('should error as forbidden for list endpoint', async () => { + await request(app.getHttpServer()) + .get(`/featureFlags`) + .set({ passkey: process.env.API_PASS_KEY || '' }) + .set('Cookie', cookies) + .expect(403); + }); + + it('should error as forbidden for create endpoint', async () => { + await request(app.getHttpServer()) + .post(`/featureFlags`) + .set({ passkey: process.env.API_PASS_KEY || '' }) + .send({}) + .set('Cookie', cookies) + .expect(403); + }); + + it('should error as forbidden for update endpoint', async () => { + await request(app.getHttpServer()) + .put(`/featureFlags`) + .set({ passkey: process.env.API_PASS_KEY || '' }) + .send({}) + .set('Cookie', cookies) + .expect(403); + }); + + it('should error as forbidden for delete endpoint', async () => { + await request(app.getHttpServer()) + .delete(`/featureFlags`) + .set({ passkey: process.env.API_PASS_KEY || '' }) + .send({}) + .set('Cookie', cookies) + .expect(403); + }); + + it('should error as forbidden for associate jurisdictions endpoint', async () => { + await request(app.getHttpServer()) + .put(`/featureFlags/associateJurisdictions`) + .set({ passkey: process.env.API_PASS_KEY || '' }) + .send({}) + .set('Cookie', cookies) + .expect(403); + }); + + it('should error as forbidden for retrieve endpoint', async () => { + await request(app.getHttpServer()) + .get(`/featureFlags/example`) + .set({ passkey: process.env.API_PASS_KEY || '' }) + .set('Cookie', cookies) + .expect(403); + }); + }); }); diff --git a/api/test/integration/permission-tests/permission-as-no-user.e2e-spec.ts b/api/test/integration/permission-tests/permission-as-no-user.e2e-spec.ts index 023267c4b3..5b3c38126a 100644 --- a/api/test/integration/permission-tests/permission-as-no-user.e2e-spec.ts +++ b/api/test/integration/permission-tests/permission-as-no-user.e2e-spec.ts @@ -1229,4 +1229,58 @@ describe('Testing Permissioning of endpoints as logged out user', () => { .expect(403); }); }); + + describe('Testing feature flag endpoints', () => { + it('should error as forbidden for list endpoint', async () => { + await request(app.getHttpServer()) + .get(`/featureFlags`) + .set({ passkey: process.env.API_PASS_KEY || '' }) + .set('Cookie', cookies) + .expect(403); + }); + + it('should error as forbidden for create endpoint', async () => { + await request(app.getHttpServer()) + .post(`/featureFlags`) + .set({ passkey: process.env.API_PASS_KEY || '' }) + .send({}) + .set('Cookie', cookies) + .expect(403); + }); + + it('should error as forbidden for update endpoint', async () => { + await request(app.getHttpServer()) + .put(`/featureFlags`) + .set({ passkey: process.env.API_PASS_KEY || '' }) + .send({}) + .set('Cookie', cookies) + .expect(403); + }); + + it('should error as forbidden for delete endpoint', async () => { + await request(app.getHttpServer()) + .delete(`/featureFlags`) + .set({ passkey: process.env.API_PASS_KEY || '' }) + .send({}) + .set('Cookie', cookies) + .expect(403); + }); + + it('should error as forbidden for associate jurisdictions endpoint', async () => { + await request(app.getHttpServer()) + .put(`/featureFlags/associateJurisdictions`) + .set({ passkey: process.env.API_PASS_KEY || '' }) + .send({}) + .set('Cookie', cookies) + .expect(403); + }); + + it('should error as forbidden for retrieve endpoint', async () => { + await request(app.getHttpServer()) + .get(`/featureFlags/example`) + .set({ passkey: process.env.API_PASS_KEY || '' }) + .set('Cookie', cookies) + .expect(403); + }); + }); }); diff --git a/api/test/integration/permission-tests/permission-as-partner-correct-listing.e2e-spec.ts b/api/test/integration/permission-tests/permission-as-partner-correct-listing.e2e-spec.ts index 6cd3ac63fa..18c6a8cf76 100644 --- a/api/test/integration/permission-tests/permission-as-partner-correct-listing.e2e-spec.ts +++ b/api/test/integration/permission-tests/permission-as-partner-correct-listing.e2e-spec.ts @@ -1297,4 +1297,58 @@ describe('Testing Permissioning of endpoints as partner with correct listing', ( .expect(200); }); }); + + describe('Testing feature flag endpoints', () => { + it('should error as forbidden for list endpoint', async () => { + await request(app.getHttpServer()) + .get(`/featureFlags`) + .set({ passkey: process.env.API_PASS_KEY || '' }) + .set('Cookie', cookies) + .expect(403); + }); + + it('should error as forbidden for create endpoint', async () => { + await request(app.getHttpServer()) + .post(`/featureFlags`) + .set({ passkey: process.env.API_PASS_KEY || '' }) + .send({}) + .set('Cookie', cookies) + .expect(403); + }); + + it('should error as forbidden for update endpoint', async () => { + await request(app.getHttpServer()) + .put(`/featureFlags`) + .set({ passkey: process.env.API_PASS_KEY || '' }) + .send({}) + .set('Cookie', cookies) + .expect(403); + }); + + it('should error as forbidden for delete endpoint', async () => { + await request(app.getHttpServer()) + .delete(`/featureFlags`) + .set({ passkey: process.env.API_PASS_KEY || '' }) + .send({}) + .set('Cookie', cookies) + .expect(403); + }); + + it('should error as forbidden for associate jurisdictions endpoint', async () => { + await request(app.getHttpServer()) + .put(`/featureFlags/associateJurisdictions`) + .set({ passkey: process.env.API_PASS_KEY || '' }) + .send({}) + .set('Cookie', cookies) + .expect(403); + }); + + it('should error as forbidden for retrieve endpoint', async () => { + await request(app.getHttpServer()) + .get(`/featureFlags/example`) + .set({ passkey: process.env.API_PASS_KEY || '' }) + .set('Cookie', cookies) + .expect(403); + }); + }); }); diff --git a/api/test/integration/permission-tests/permission-as-partner-wrong-listing.e2e-spec.ts b/api/test/integration/permission-tests/permission-as-partner-wrong-listing.e2e-spec.ts index 694e39f28e..0c2c79aec1 100644 --- a/api/test/integration/permission-tests/permission-as-partner-wrong-listing.e2e-spec.ts +++ b/api/test/integration/permission-tests/permission-as-partner-wrong-listing.e2e-spec.ts @@ -1259,4 +1259,58 @@ describe('Testing Permissioning of endpoints as partner with wrong listing', () .expect(403); }); }); + + describe('Testing feature flag endpoints', () => { + it('should error as forbidden for list endpoint', async () => { + await request(app.getHttpServer()) + .get(`/featureFlags`) + .set({ passkey: process.env.API_PASS_KEY || '' }) + .set('Cookie', cookies) + .expect(403); + }); + + it('should error as forbidden for create endpoint', async () => { + await request(app.getHttpServer()) + .post(`/featureFlags`) + .set({ passkey: process.env.API_PASS_KEY || '' }) + .send({}) + .set('Cookie', cookies) + .expect(403); + }); + + it('should error as forbidden for update endpoint', async () => { + await request(app.getHttpServer()) + .put(`/featureFlags`) + .set({ passkey: process.env.API_PASS_KEY || '' }) + .send({}) + .set('Cookie', cookies) + .expect(403); + }); + + it('should error as forbidden for delete endpoint', async () => { + await request(app.getHttpServer()) + .delete(`/featureFlags`) + .set({ passkey: process.env.API_PASS_KEY || '' }) + .send({}) + .set('Cookie', cookies) + .expect(403); + }); + + it('should error as forbidden for associate jurisdictions endpoint', async () => { + await request(app.getHttpServer()) + .put(`/featureFlags/associateJurisdictions`) + .set({ passkey: process.env.API_PASS_KEY || '' }) + .send({}) + .set('Cookie', cookies) + .expect(403); + }); + + it('should error as forbidden for retrieve endpoint', async () => { + await request(app.getHttpServer()) + .get(`/featureFlags/example`) + .set({ passkey: process.env.API_PASS_KEY || '' }) + .set('Cookie', cookies) + .expect(403); + }); + }); }); diff --git a/api/test/integration/permission-tests/permission-as-public.e2e-spec.ts b/api/test/integration/permission-tests/permission-as-public.e2e-spec.ts index ee62f6325b..c4838080c0 100644 --- a/api/test/integration/permission-tests/permission-as-public.e2e-spec.ts +++ b/api/test/integration/permission-tests/permission-as-public.e2e-spec.ts @@ -1300,4 +1300,58 @@ describe('Testing Permissioning of endpoints as public user', () => { .expect(403); }); }); + + describe('Testing feature flag endpoints', () => { + it('should error as forbidden for list endpoint', async () => { + await request(app.getHttpServer()) + .get(`/featureFlags`) + .set({ passkey: process.env.API_PASS_KEY || '' }) + .set('Cookie', cookies) + .expect(403); + }); + + it('should error as forbidden for create endpoint', async () => { + await request(app.getHttpServer()) + .post(`/featureFlags`) + .set({ passkey: process.env.API_PASS_KEY || '' }) + .send({}) + .set('Cookie', cookies) + .expect(403); + }); + + it('should error as forbidden for update endpoint', async () => { + await request(app.getHttpServer()) + .put(`/featureFlags`) + .set({ passkey: process.env.API_PASS_KEY || '' }) + .send({}) + .set('Cookie', cookies) + .expect(403); + }); + + it('should error as forbidden for delete endpoint', async () => { + await request(app.getHttpServer()) + .delete(`/featureFlags`) + .set({ passkey: process.env.API_PASS_KEY || '' }) + .send({}) + .set('Cookie', cookies) + .expect(403); + }); + + it('should error as forbidden for associate jurisdictions endpoint', async () => { + await request(app.getHttpServer()) + .put(`/featureFlags/associateJurisdictions`) + .set({ passkey: process.env.API_PASS_KEY || '' }) + .send({}) + .set('Cookie', cookies) + .expect(403); + }); + + it('should error as forbidden for retrieve endpoint', async () => { + await request(app.getHttpServer()) + .get(`/featureFlags/example`) + .set({ passkey: process.env.API_PASS_KEY || '' }) + .set('Cookie', cookies) + .expect(403); + }); + }); }); diff --git a/api/test/unit/services/feature-flag.service.spec.ts b/api/test/unit/services/feature-flag.service.spec.ts new file mode 100644 index 0000000000..185b99f7a2 --- /dev/null +++ b/api/test/unit/services/feature-flag.service.spec.ts @@ -0,0 +1,345 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { randomUUID } from 'crypto'; +import { FeatureFlagAssociate } from '../../../src/dtos/feature-flags/feature-flag-associate.dto'; +import { FeatureFlagCreate } from '../../../src/dtos/feature-flags/feature-flag-create.dto'; +import { FeatureFlagUpdate } from '../../../src/dtos/feature-flags/feature-flag-update.dto'; +import { FeatureFlagService } from '../../../src/services/feature-flag.service'; +import { JurisdictionService } from '../../../src/services/jurisdiction.service'; +import { PrismaService } from '../../../src/services/prisma.service'; + +describe('Testing feature flag service', () => { + let service: FeatureFlagService; + let prisma: PrismaService; + + const mockFeatureFlag = (position: number, date: Date, active = true) => { + return { + id: randomUUID(), + createdAt: date, + updatedAt: date, + name: `feature flag ${position}`, + description: `feature flag description ${position}`, + active: active, + }; + }; + + const mockFeatureFlagSet = (numberToCreate: number, date: Date) => { + const toReturn = []; + for (let i = 0; i < numberToCreate; i++) { + toReturn.push(mockFeatureFlag(i, date)); + } + return toReturn; + }; + + beforeAll(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [FeatureFlagService, JurisdictionService, PrismaService], + }).compile(); + + service = module.get(FeatureFlagService); + prisma = module.get(PrismaService); + }); + + describe('Testing list()', () => { + it('should return list of feature flags', async () => { + const date = new Date(); + const mockedValue = mockFeatureFlagSet(3, date); + prisma.featureFlags.findMany = jest.fn().mockResolvedValue(mockedValue); + + expect(await service.list()).toEqual(mockedValue); + + expect(prisma.featureFlags.findMany).toHaveBeenCalledWith({ + include: { + jurisdictions: { + select: { + id: true, + name: true, + }, + }, + }, + }); + }); + }); + + describe('Testing findOne()', () => { + it('should find and return one feature flag', async () => { + const date = new Date(); + const mockedValue = mockFeatureFlag(1, date); + prisma.featureFlags.findFirst = jest.fn().mockResolvedValue(mockedValue); + + expect(await service.findOne(mockedValue.id)).toEqual(mockedValue); + + expect(prisma.featureFlags.findFirst).toHaveBeenCalledWith({ + include: { + jurisdictions: { + select: { + id: true, + name: true, + }, + }, + }, + where: { + id: mockedValue.id, + }, + }); + }); + + it('should not find a feature flag and throw error', async () => { + prisma.featureFlags.findFirst = jest.fn().mockResolvedValue(null); + + await expect( + async () => await service.findOne('example Id'), + ).rejects.toThrowError( + 'feature flag id example Id was requested but not found', + ); + + expect(prisma.featureFlags.findFirst).toHaveBeenCalledWith({ + include: { + jurisdictions: { + select: { + id: true, + name: true, + }, + }, + }, + where: { + id: 'example Id', + }, + }); + }); + }); + + describe('Testing create()', () => { + it('should create a new feature flag record', async () => { + const date = new Date(); + const mockedValue = mockFeatureFlag(1, date); + prisma.featureFlags.create = jest.fn().mockResolvedValue(mockedValue); + + const params: FeatureFlagCreate = { + name: mockedValue.name, + description: mockedValue.description, + active: mockedValue.active, + }; + + expect(await service.create(params)).toEqual(mockedValue); + + expect(prisma.featureFlags.create).toHaveBeenCalledWith({ + data: { + name: mockedValue.name, + description: mockedValue.description, + active: mockedValue.active, + }, + include: { + jurisdictions: { + select: { + id: true, + name: true, + }, + }, + }, + }); + }); + }); + + describe('Testing update()', () => { + it('should update existing feature flag record', async () => { + const date = new Date(); + + const mockedValue = mockFeatureFlag(1, date); + + prisma.featureFlags.findFirst = jest.fn().mockResolvedValue(mockedValue); + prisma.featureFlags.update = jest.fn().mockResolvedValue({ + ...mockedValue, + name: 'updated feature flag 1', + }); + + const params: FeatureFlagUpdate = { + name: 'updated feature flag 1', + id: mockedValue.id, + description: mockedValue.description, + active: mockedValue.active, + }; + + expect(await service.update(params)).toEqual({ + id: mockedValue.id, + name: 'updated feature flag 1', + description: mockedValue.description, + active: mockedValue.active, + createdAt: date, + updatedAt: date, + jurisdictions: undefined, + }); + + expect(prisma.featureFlags.findFirst).toHaveBeenCalledWith({ + where: { + id: mockedValue.id, + }, + }); + + expect(prisma.featureFlags.update).toHaveBeenCalledWith({ + data: { + name: 'updated feature flag 1', + description: mockedValue.description, + active: mockedValue.active, + }, + include: { + jurisdictions: { + select: { + id: true, + name: true, + }, + }, + }, + where: { + id: mockedValue.id, + }, + }); + }); + + it('should not find a feature flag and throw error', async () => { + prisma.featureFlags.findFirst = jest.fn().mockResolvedValue(null); + prisma.featureFlags.update = jest.fn().mockResolvedValue(null); + + const params: FeatureFlagUpdate = { + id: 'example id', + name: 'example feature flag', + description: 'example description', + active: true, + }; + + await expect( + async () => await service.update(params), + ).rejects.toThrowError( + 'feature flag id example id was requested but not found', + ); + + expect(prisma.featureFlags.findFirst).toHaveBeenCalledWith({ + where: { + id: 'example id', + }, + }); + }); + }); + + describe('Testing delete()', () => { + it('should delete feature flag record', async () => { + const date = new Date(); + const mockedValue = mockFeatureFlag(1, date); + + prisma.featureFlags.findFirst = jest.fn().mockResolvedValue(mockedValue); + prisma.featureFlags.delete = jest.fn().mockResolvedValue(mockedValue); + + expect(await service.delete(mockedValue.id)).toEqual({ + success: true, + }); + + expect(prisma.featureFlags.delete).toHaveBeenCalledWith({ + where: { + id: mockedValue.id, + }, + }); + + expect(prisma.featureFlags.delete).toHaveBeenCalledWith({ + where: { + id: mockedValue.id, + }, + }); + }); + }); + + describe('Testing associateJurisdictions()', () => { + it('should associate and remove jurisdictions from feature flag record', async () => { + const date = new Date(); + + const mockedValue = mockFeatureFlag(1, date); + const unchangingJurisdiction = { + id: 'jurisdiction id 1', + name: 'jurisdiction name 1', + }; + const associateJurisdiction = { + id: 'jurisdiction id 2', + name: 'jurisdiction name 2', + }; + const removeJurisdiction = { + id: 'jurisdiction id 3', + name: 'jurisdiction name 3', + }; + + prisma.featureFlags.findFirst = jest.fn().mockResolvedValue({ + ...mockedValue, + jurisdictions: [unchangingJurisdiction, removeJurisdiction], + }); + prisma.jurisdictions.findFirst = jest + .fn() + .mockResolvedValue({ id: 'id' }); + prisma.featureFlags.update = jest.fn().mockResolvedValue({ + ...mockedValue, + jurisdictions: [unchangingJurisdiction, associateJurisdiction], + }); + + const params: FeatureFlagAssociate = { + id: mockedValue.id, + associate: [associateJurisdiction.id], + remove: [removeJurisdiction.id], + }; + + expect(await service.associateJurisdictions(params)).toEqual({ + id: mockedValue.id, + name: mockedValue.name, + description: mockedValue.description, + active: mockedValue.active, + createdAt: date, + updatedAt: date, + jurisdictions: [unchangingJurisdiction, associateJurisdiction], + }); + + expect(prisma.featureFlags.findFirst).toHaveBeenCalledWith({ + where: { + id: mockedValue.id, + }, + }); + + expect(prisma.featureFlags.update).toHaveBeenCalledWith({ + data: { + jurisdictions: { + connect: [{ id: associateJurisdiction.id }], + disconnect: [{ id: removeJurisdiction.id }], + }, + }, + include: { + jurisdictions: { + select: { + id: true, + name: true, + }, + }, + }, + where: { + id: mockedValue.id, + }, + }); + }); + + it('should not find a feature flag and throw error', async () => { + prisma.featureFlags.findFirst = jest.fn().mockResolvedValue(null); + prisma.featureFlags.update = jest.fn().mockResolvedValue(null); + + const params: FeatureFlagAssociate = { + id: 'example id', + associate: [], + remove: [], + }; + + await expect( + async () => await service.associateJurisdictions(params), + ).rejects.toThrowError( + 'feature flag id example id was requested but not found', + ); + + expect(prisma.featureFlags.findFirst).toHaveBeenCalledWith({ + where: { + id: 'example id', + }, + }); + }); + }); +}); diff --git a/api/test/unit/services/jurisdiction.service.spec.ts b/api/test/unit/services/jurisdiction.service.spec.ts index 9331652f81..82f8965191 100644 --- a/api/test/unit/services/jurisdiction.service.spec.ts +++ b/api/test/unit/services/jurisdiction.service.spec.ts @@ -54,6 +54,7 @@ describe('Testing jurisdiction service', () => { expect(prisma.jurisdictions.findMany).toHaveBeenCalledWith({ include: { + featureFlags: true, multiselectQuestions: true, }, }); @@ -75,6 +76,7 @@ describe('Testing jurisdiction service', () => { }, }, include: { + featureFlags: true, multiselectQuestions: true, }, }); @@ -96,6 +98,7 @@ describe('Testing jurisdiction service', () => { }, }, include: { + featureFlags: true, multiselectQuestions: true, }, }); @@ -117,6 +120,7 @@ describe('Testing jurisdiction service', () => { }, }, include: { + featureFlags: true, multiselectQuestions: true, }, }); @@ -138,6 +142,9 @@ describe('Testing jurisdiction service', () => { enablePartnerSettings: true, enableAccessibilityFeatures: true, enableUtilitiesIncluded: true, + allowSingleUseCodeLogin: false, + listingApprovalPermissions: [], + duplicateListingPermissions: [], }; expect(await service.create(params)).toEqual(mockedValue); @@ -154,8 +161,12 @@ describe('Testing jurisdiction service', () => { enablePartnerSettings: true, enableAccessibilityFeatures: true, enableUtilitiesIncluded: true, + allowSingleUseCodeLogin: false, + listingApprovalPermissions: [], + duplicateListingPermissions: [], }, include: { + featureFlags: true, multiselectQuestions: true, }, }); @@ -186,6 +197,9 @@ describe('Testing jurisdiction service', () => { enablePartnerSettings: true, enableAccessibilityFeatures: true, enableUtilitiesIncluded: true, + allowSingleUseCodeLogin: false, + listingApprovalPermissions: [], + duplicateListingPermissions: [], }; expect(await service.update(params)).toEqual({ @@ -222,11 +236,15 @@ describe('Testing jurisdiction service', () => { enablePartnerSettings: true, enableAccessibilityFeatures: true, enableUtilitiesIncluded: true, + allowSingleUseCodeLogin: false, + listingApprovalPermissions: [], + duplicateListingPermissions: [], }, where: { id: mockedJurisdiction.id, }, include: { + featureFlags: true, multiselectQuestions: true, }, }); @@ -248,6 +266,9 @@ describe('Testing jurisdiction service', () => { enablePartnerSettings: true, enableAccessibilityFeatures: true, enableUtilitiesIncluded: true, + allowSingleUseCodeLogin: false, + listingApprovalPermissions: [], + duplicateListingPermissions: [], }; await expect(async () => await service.update(params)).rejects.toThrowError( diff --git a/shared-helpers/src/types/backend-swagger.ts b/shared-helpers/src/types/backend-swagger.ts index aceeeea32a..ab96c82c4f 100644 --- a/shared-helpers/src/types/backend-swagger.ts +++ b/shared-helpers/src/types/backend-swagger.ts @@ -2500,6 +2500,132 @@ export class LotteryService { } } +export class FeatureFlagsService { + /** + * List of feature flags + */ + list(options: IRequestOptions = {}): Promise { + return new Promise((resolve, reject) => { + let url = basePath + "/featureFlags" + + const configs: IRequestConfig = getConfigs("get", "application/json", url, options) + + /** 适配ios13,get请求不允许带body */ + + axios(configs, resolve, reject) + }) + } + /** + * Create a feature flag + */ + create( + params: { + /** requestBody */ + body?: FeatureFlagCreate + } = {} as any, + options: IRequestOptions = {} + ): Promise { + return new Promise((resolve, reject) => { + let url = basePath + "/featureFlags" + + const configs: IRequestConfig = getConfigs("post", "application/json", url, options) + + let data = params.body + + configs.data = data + + axios(configs, resolve, reject) + }) + } + /** + * Update a feature flag + */ + update( + params: { + /** requestBody */ + body?: FeatureFlagUpdate + } = {} as any, + options: IRequestOptions = {} + ): Promise { + return new Promise((resolve, reject) => { + let url = basePath + "/featureFlags" + + const configs: IRequestConfig = getConfigs("put", "application/json", url, options) + + let data = params.body + + configs.data = data + + axios(configs, resolve, reject) + }) + } + /** + * Delete a feature flag by id + */ + delete( + params: { + /** requestBody */ + body?: IdDTO + } = {} as any, + options: IRequestOptions = {} + ): Promise { + return new Promise((resolve, reject) => { + let url = basePath + "/featureFlags" + + const configs: IRequestConfig = getConfigs("delete", "application/json", url, options) + + let data = params.body + + configs.data = data + + axios(configs, resolve, reject) + }) + } + /** + * Associate and disassociate jurisdictions with a feature flag + */ + associateJurisdictions( + params: { + /** requestBody */ + body?: FeatureFlagAssociate + } = {} as any, + options: IRequestOptions = {} + ): Promise { + return new Promise((resolve, reject) => { + let url = basePath + "/featureFlags/associateJurisdictions" + + const configs: IRequestConfig = getConfigs("put", "application/json", url, options) + + let data = params.body + + configs.data = data + + axios(configs, resolve, reject) + }) + } + /** + * Get a feature flag by id + */ + retrieve( + params: { + /** */ + featureFlagId: string + } = {} as any, + options: IRequestOptions = {} + ): Promise { + return new Promise((resolve, reject) => { + let url = basePath + "/featureFlags/{featureFlagId}" + url = url.replace("{featureFlagId}", params["featureFlagId"] + "") + + const configs: IRequestConfig = getConfigs("get", "application/json", url, options) + + /** 适配ios13,get请求不允许带body */ + + axios(configs, resolve, reject) + }) + } +} + export interface SuccessDTO { /** */ success: boolean @@ -4963,6 +5089,29 @@ export interface JurisdictionUpdate { duplicateListingPermissions: UserRoleEnum[] } +export interface FeatureFlag { + /** */ + id: string + + /** */ + createdAt: Date + + /** */ + updatedAt: Date + + /** */ + name: string + + /** */ + description: string + + /** */ + active: boolean + + /** */ + jurisdictions: IdDTO[] +} + export interface Jurisdiction { /** */ id: string @@ -5023,6 +5172,9 @@ export interface Jurisdiction { /** */ duplicateListingPermissions: UserRoleEnum[] + + /** */ + featureFlags: FeatureFlag[] } export interface MultiselectQuestionCreate { @@ -6040,6 +6192,42 @@ export interface PublicLotteryTotal { multiselectQuestionId?: string } +export interface FeatureFlagAssociate { + /** */ + id: string + + /** */ + associate: string[] + + /** */ + remove: string[] +} + +export interface FeatureFlagCreate { + /** */ + name: string + + /** */ + description: string + + /** */ + active: boolean +} + +export interface FeatureFlagUpdate { + /** */ + id: string + + /** */ + name: string + + /** */ + description: string + + /** */ + active: boolean +} + export enum ListingViews { "fundamentals" = "fundamentals", "base" = "base",