feat: implement e2e test for CompanyResolver (#944)

* feat: wip e2e server test

* feat: use github action postgres & use infra for local

* feat: company e2e test

* feat: add company e2e test for permissions

* Simplify server e2e test run

* Fix lint

---------

Co-authored-by: Charles Bochet <charles@twenty.com>
This commit is contained in:
Jérémy M
2023-07-27 18:48:40 +02:00
committed by GitHub
parent 9027406fdf
commit 157e5b9a2e
25 changed files with 655 additions and 59 deletions

View File

@ -5,7 +5,26 @@ on:
- main - main
pull_request_target: pull_request_target:
jobs: jobs:
postgres-job:
runs-on: ubuntu-latest
container: node:10.18-jessie
steps:
- run: echo "Postgres job finished"
services:
postgres:
image: postgres
env:
POSTGRES_HOST: postgres
POSTGRES_PASSWORD: postgrespassword
POSTGRES_DB: test
POSTGRES_PORT: 5432
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
server-test: server-test:
needs: postgres-job
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v3 - uses: actions/checkout@v3
@ -27,3 +46,6 @@ jobs:
- name: Server / Run jest tests - name: Server / Run jest tests
run: | run: |
cd server && yarn test cd server && yarn test
- name: Server / Run e2e tests
run: |
cd server && yarn test:e2e

View File

@ -1637,7 +1637,7 @@ export type QueryFindManyWorkspaceMemberArgs = {
export type QueryFindUniqueCompanyArgs = { export type QueryFindUniqueCompanyArgs = {
id: Scalars['String']; where: CompanyWhereUniqueInput;
}; };
@ -2205,7 +2205,7 @@ export type GetCompaniesQueryVariables = Exact<{
export type GetCompaniesQuery = { __typename?: 'Query', companies: Array<{ __typename?: 'Company', id: string, domainName: string, name: string, createdAt: string, address: string, linkedinUrl?: string | null, employees?: number | null, _commentThreadCount: number, accountOwner?: { __typename?: 'User', id: string, email: string, displayName: string, firstName?: string | null, lastName?: string | null, avatarUrl?: string | null } | null }> }; export type GetCompaniesQuery = { __typename?: 'Query', companies: Array<{ __typename?: 'Company', id: string, domainName: string, name: string, createdAt: string, address: string, linkedinUrl?: string | null, employees?: number | null, _commentThreadCount: number, accountOwner?: { __typename?: 'User', id: string, email: string, displayName: string, firstName?: string | null, lastName?: string | null, avatarUrl?: string | null } | null }> };
export type GetCompanyQueryVariables = Exact<{ export type GetCompanyQueryVariables = Exact<{
id: Scalars['String']; where: CompanyWhereUniqueInput;
}>; }>;
@ -3284,8 +3284,8 @@ export type GetCompaniesQueryHookResult = ReturnType<typeof useGetCompaniesQuery
export type GetCompaniesLazyQueryHookResult = ReturnType<typeof useGetCompaniesLazyQuery>; export type GetCompaniesLazyQueryHookResult = ReturnType<typeof useGetCompaniesLazyQuery>;
export type GetCompaniesQueryResult = Apollo.QueryResult<GetCompaniesQuery, GetCompaniesQueryVariables>; export type GetCompaniesQueryResult = Apollo.QueryResult<GetCompaniesQuery, GetCompaniesQueryVariables>;
export const GetCompanyDocument = gql` export const GetCompanyDocument = gql`
query GetCompany($id: String!) { query GetCompany($where: CompanyWhereUniqueInput!) {
findUniqueCompany(id: $id) { findUniqueCompany(where: $where) {
id id
domainName domainName
name name
@ -3316,7 +3316,7 @@ export const GetCompanyDocument = gql`
* @example * @example
* const { data, loading, error } = useGetCompanyQuery({ * const { data, loading, error } = useGetCompanyQuery({
* variables: { * variables: {
* id: // value for 'id' * where: // value for 'where'
* }, * },
* }); * });
*/ */

View File

@ -3,8 +3,8 @@ import { gql } from '@apollo/client';
import { useGetCompanyQuery } from '~/generated/graphql'; import { useGetCompanyQuery } from '~/generated/graphql';
export const GET_COMPANY = gql` export const GET_COMPANY = gql`
query GetCompany($id: String!) { query GetCompany($where: CompanyWhereUniqueInput!) {
findUniqueCompany(id: $id) { findUniqueCompany(where: $where) {
id id
domainName domainName
name name
@ -24,5 +24,5 @@ export const GET_COMPANY = gql`
`; `;
export function useCompanyQuery(id: string) { export function useCompanyQuery(id: string) {
return useGetCompanyQuery({ variables: { id } }); return useGetCompanyQuery({ variables: { where: { id } } });
} }

View File

@ -1 +1,5 @@
-- Create the default database for development
CREATE DATABASE "default"; CREATE DATABASE "default";
-- Create the tests database for e2e testing
CREATE DATABASE "tests";

12
server/.env.test Normal file
View File

@ -0,0 +1,12 @@
DEBUG_MODE=true
AUTH_GOOGLE_ENABLED=false
ACCESS_TOKEN_SECRET=secret_jwt
ACCESS_TOKEN_EXPIRES_IN=1d
REFRESH_TOKEN_SECRET=secret_refresh_token
REFRESH_TOKEN_EXPIRES_IN=30d
LOGIN_TOKEN_SECRET=secret_login_token
LOGIN_TOKEN_EXPIRES_IN=15m
FRONT_AUTH_CALLBACK_URL=http://localhost:3001/auth/callback
PG_DATABASE_URL=postgres://postgres:postgrespassword@localhost:5432/tests?connection_limit=1
STORAGE_TYPE=local
STORAGE_LOCAL_PATH=.local-storage

View File

@ -2,7 +2,9 @@ module.exports = {
clearMocks: true, clearMocks: true,
preset: 'ts-jest', preset: 'ts-jest',
testEnvironment: 'node', testEnvironment: 'node',
setupFilesAfterEnv: ['<rootDir>/src/database/client-mock/jest-prisma-singleton.ts'], setupFilesAfterEnv: [
'<rootDir>/src/database/client-mock/jest-prisma-singleton.ts',
],
moduleFileExtensions: ['js', 'json', 'ts'], moduleFileExtensions: ['js', 'json', 'ts'],
moduleNameMapper: { moduleNameMapper: {

View File

@ -18,7 +18,7 @@
"test:watch": "jest --watch", "test:watch": "jest --watch",
"test:cov": "jest --coverage", "test:cov": "jest --coverage",
"test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand", "test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand",
"test:e2e": "jest --config ./test/jest-e2e.json", "test:e2e": "./scripts/run-integration.sh",
"prisma:generate-client": "npx prisma generate --generator client && yarn prisma:generate-gql-select", "prisma:generate-client": "npx prisma generate --generator client && yarn prisma:generate-gql-select",
"prisma:generate-gql-select": "node scripts/generate-model-select-map.js", "prisma:generate-gql-select": "node scripts/generate-model-select-map.js",
"prisma:generate-nest-graphql": "npx prisma generate --generator nestgraphql", "prisma:generate-nest-graphql": "npx prisma generate --generator nestgraphql",
@ -57,7 +57,7 @@
"class-validator": "^0.14.0", "class-validator": "^0.14.0",
"date-fns": "^2.30.0", "date-fns": "^2.30.0",
"file-type": "13.0.0", "file-type": "13.0.0",
"graphql": "^16.6.0", "graphql": "^16.7.1",
"graphql-type-json": "^0.3.2", "graphql-type-json": "^0.3.2",
"graphql-upload": "^13.0.0", "graphql-upload": "^13.0.0",
"jest-mock-extended": "^3.0.4", "jest-mock-extended": "^3.0.4",

View File

@ -0,0 +1,17 @@
#!/usr/bin/env bash
# src/run-integration.sh
DIR="$(cd "$(dirname "$0")" && pwd)"
source $DIR/setenv.sh
npx ts-node ./test/utils/check-db.ts
EXIT_CODE=$?
if [ $EXIT_CODE -ne 0 ]; then
echo '🟡 - Database is not initialized. Running migrations...'
npx prisma migrate reset --force && yarn prisma:generate
else
echo "🟢 - Database is already initialized."
fi
yarn jest --config ./test/jest-e2e.json

18
server/scripts/setenv.sh Executable file
View File

@ -0,0 +1,18 @@
#!/usr/bin/env bash
# scripts/setenv.sh
# Get script's directory
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# Construct the absolute path of .env file in the project root directory
ENV_PATH="${SCRIPT_DIR}/../.env.test"
# Check if the file exists
if [ -f "${ENV_PATH}" ]; then
echo "🔵 - Loading environment variables from "${ENV_PATH}"..."
# Export env vars
export $(grep -v '^#' ${ENV_PATH} | xargs)
else
echo "Error: ${ENV_PATH} does not exist."
exit 1
fi

View File

@ -26,7 +26,7 @@ import {
} from './handlers/workspace-member.ability-handler'; } from './handlers/workspace-member.ability-handler';
import { import {
ManageCompanyAbilityHandler, ManageCompanyAbilityHandler,
ReadCompanyAbilityHandler, ReadOneCompanyAbilityHandler,
CreateCompanyAbilityHandler, CreateCompanyAbilityHandler,
UpdateCompanyAbilityHandler, UpdateCompanyAbilityHandler,
DeleteCompanyAbilityHandler, DeleteCompanyAbilityHandler,
@ -124,7 +124,7 @@ import {
DeleteWorkspaceMemberAbilityHandler, DeleteWorkspaceMemberAbilityHandler,
// Company // Company
ManageCompanyAbilityHandler, ManageCompanyAbilityHandler,
ReadCompanyAbilityHandler, ReadOneCompanyAbilityHandler,
CreateCompanyAbilityHandler, CreateCompanyAbilityHandler,
UpdateCompanyAbilityHandler, UpdateCompanyAbilityHandler,
DeleteCompanyAbilityHandler, DeleteCompanyAbilityHandler,
@ -208,7 +208,7 @@ import {
DeleteWorkspaceMemberAbilityHandler, DeleteWorkspaceMemberAbilityHandler,
// Company // Company
ManageCompanyAbilityHandler, ManageCompanyAbilityHandler,
ReadCompanyAbilityHandler, ReadOneCompanyAbilityHandler,
CreateCompanyAbilityHandler, CreateCompanyAbilityHandler,
UpdateCompanyAbilityHandler, UpdateCompanyAbilityHandler,
DeleteCompanyAbilityHandler, DeleteCompanyAbilityHandler,

View File

@ -205,3 +205,42 @@ export async function relationAbilityChecker(
return true; return true;
} }
const isWhereInput = (input: any): boolean => {
return Object.values(input).some((value) => typeof value === 'object');
};
type ExcludeUnique<T> = T extends infer U
? 'AND' extends keyof U
? U
: never
: never;
/**
* Convert a where unique input to a where input prisma
* @param args Can be a where unique input or a where input
* @returns whare input
*/
export const convertToWhereInput = <T>(
where: T | undefined,
): ExcludeUnique<T> | undefined => {
const input = where as any;
if (!input) {
return input;
}
// If it's already a WhereInput, return it directly
if (isWhereInput(input)) {
return input;
}
// If not convert it to a WhereInput
const whereInput = {};
for (const key in input) {
whereInput[key] = { equals: input[key] };
}
return whereInput as ExcludeUnique<T>;
};

View File

@ -13,11 +13,15 @@ import { PrismaService } from 'src/database/prisma.service';
import { AbilityAction } from 'src/ability/ability.action'; import { AbilityAction } from 'src/ability/ability.action';
import { AppAbility } from 'src/ability/ability.factory'; import { AppAbility } from 'src/ability/ability.factory';
import { CompanyWhereInput } from 'src/core/@generated/company/company-where.input'; import { CompanyWhereInput } from 'src/core/@generated/company/company-where.input';
import { relationAbilityChecker } from 'src/ability/ability.util'; import { CompanyWhereUniqueInput } from 'src/core/@generated/company/company-where-unique.input';
import {
convertToWhereInput,
relationAbilityChecker,
} from 'src/ability/ability.util';
import { assert } from 'src/utils/assert'; import { assert } from 'src/utils/assert';
class CompanyArgs { class CompanyArgs {
where?: CompanyWhereInput; where?: CompanyWhereUniqueInput | CompanyWhereInput;
[key: string]: any; [key: string]: any;
} }
@ -29,9 +33,18 @@ export class ManageCompanyAbilityHandler implements IAbilityHandler {
} }
@Injectable() @Injectable()
export class ReadCompanyAbilityHandler implements IAbilityHandler { export class ReadOneCompanyAbilityHandler implements IAbilityHandler {
handle(ability: AppAbility) { constructor(private readonly prismaService: PrismaService) {}
return ability.can(AbilityAction.Read, 'Company');
async handle(ability: AppAbility, context: ExecutionContext) {
const gqlContext = GqlExecutionContext.create(context);
const args = gqlContext.getArgs<CompanyArgs>();
const company = await this.prismaService.client.company.findFirst({
where: args.where,
});
assert(company, '', NotFoundException);
return ability.can(AbilityAction.Read, subject('Company', company));
} }
} }
@ -65,10 +78,11 @@ export class UpdateCompanyAbilityHandler implements IAbilityHandler {
async handle(ability: AppAbility, context: ExecutionContext) { async handle(ability: AppAbility, context: ExecutionContext) {
const gqlContext = GqlExecutionContext.create(context); const gqlContext = GqlExecutionContext.create(context);
const args = gqlContext.getArgs<CompanyArgs>(); const args = gqlContext.getArgs<CompanyArgs>();
const company = await this.prismaService.client.company.findFirst({ const where = convertToWhereInput(args.where);
where: args.where, const companies = await this.prismaService.client.company.findMany({
where,
}); });
assert(company, '', NotFoundException); assert(companies.length, '', NotFoundException);
const allowed = await relationAbilityChecker( const allowed = await relationAbilityChecker(
'Company', 'Company',
@ -81,7 +95,18 @@ export class UpdateCompanyAbilityHandler implements IAbilityHandler {
return false; return false;
} }
return ability.can(AbilityAction.Update, subject('Company', company)); for (const company of companies) {
const allowed = ability.can(
AbilityAction.Delete,
subject('Company', company),
);
if (!allowed) {
return false;
}
}
return true;
} }
} }
@ -92,11 +117,23 @@ export class DeleteCompanyAbilityHandler implements IAbilityHandler {
async handle(ability: AppAbility, context: ExecutionContext) { async handle(ability: AppAbility, context: ExecutionContext) {
const gqlContext = GqlExecutionContext.create(context); const gqlContext = GqlExecutionContext.create(context);
const args = gqlContext.getArgs<CompanyArgs>(); const args = gqlContext.getArgs<CompanyArgs>();
const company = await this.prismaService.client.company.findFirst({ const where = convertToWhereInput(args.where);
where: args.where, const companies = await this.prismaService.client.company.findMany({
where,
}); });
assert(company, '', NotFoundException); assert(companies.length, '', NotFoundException);
return ability.can(AbilityAction.Delete, subject('Company', company)); for (const company of companies) {
const allowed = ability.can(
AbilityAction.Delete,
subject('Company', company),
);
if (!allowed) {
return false;
}
}
return true;
} }
} }

View File

@ -21,11 +21,12 @@ import { CheckAbilities } from 'src/decorators/check-abilities.decorator';
import { import {
CreateCompanyAbilityHandler, CreateCompanyAbilityHandler,
DeleteCompanyAbilityHandler, DeleteCompanyAbilityHandler,
ReadCompanyAbilityHandler, ReadOneCompanyAbilityHandler,
UpdateCompanyAbilityHandler, UpdateCompanyAbilityHandler,
} from 'src/ability/handlers/company.ability-handler'; } from 'src/ability/handlers/company.ability-handler';
import { UserAbility } from 'src/decorators/user-ability.decorator'; import { UserAbility } from 'src/decorators/user-ability.decorator';
import { AppAbility } from 'src/ability/ability.factory'; import { AppAbility } from 'src/ability/ability.factory';
import { FindUniqueCompanyArgs } from 'src/core/@generated/company/find-unique-company.args';
import { CompanyService } from './company.service'; import { CompanyService } from './company.service';
@ -36,7 +37,6 @@ export class CompanyResolver {
@Query(() => [Company]) @Query(() => [Company])
@UseGuards(AbilityGuard) @UseGuards(AbilityGuard)
@CheckAbilities(ReadCompanyAbilityHandler)
async findManyCompany( async findManyCompany(
@Args() args: FindManyCompanyArgs, @Args() args: FindManyCompanyArgs,
@UserAbility() ability: AppAbility, @UserAbility() ability: AppAbility,
@ -60,19 +60,18 @@ export class CompanyResolver {
@Query(() => Company) @Query(() => Company)
@UseGuards(AbilityGuard) @UseGuards(AbilityGuard)
@CheckAbilities(ReadCompanyAbilityHandler) @CheckAbilities(ReadOneCompanyAbilityHandler)
async findUniqueCompany( async findUniqueCompany(
@Args('id') id: string, @Args() args: FindUniqueCompanyArgs,
@UserAbility() ability: AppAbility,
@PrismaSelector({ modelName: 'Company' }) @PrismaSelector({ modelName: 'Company' })
prismaSelect: PrismaSelect<'Company'>, prismaSelect: PrismaSelect<'Company'>,
): Promise<Partial<Company>> { ): Promise<Partial<Company>> {
return this.companyService.findUniqueOrThrow({ const company = this.companyService.findUniqueOrThrow({
where: { where: args.where,
id: id,
},
select: prismaSelect.value, select: prismaSelect.value,
}); });
return company;
} }
@Mutation(() => Company, { @Mutation(() => Company, {

View File

@ -1,9 +1,9 @@
import { Test, TestingModule } from '@nestjs/testing'; import { Test, TestingModule } from '@nestjs/testing';
import { ViewFieldService } from 'src/core/view/services/view-field.service'; import { ViewFieldService } from 'src/core/view/services/view-field.service';
import { AbilityFactory } from 'src/ability/ability.factory';
import { ViewFieldResolver } from './view-field.resolver'; import { ViewFieldResolver } from './view-field.resolver';
import { AbilityFactory } from 'src/ability/ability.factory';
describe('ViewFieldResolver', () => { describe('ViewFieldResolver', () => {
let resolver: ViewFieldResolver; let resolver: ViewFieldResolver;

View File

@ -2,6 +2,7 @@ import {
INestApplication, INestApplication,
Injectable, Injectable,
Logger, Logger,
OnModuleDestroy,
OnModuleInit, OnModuleInit,
} from '@nestjs/common'; } from '@nestjs/common';
@ -20,7 +21,7 @@ const createPrismaClient = (options: Prisma.PrismaClientOptions) => {
type ExtendedPrismaClient = ReturnType<typeof createPrismaClient>; type ExtendedPrismaClient = ReturnType<typeof createPrismaClient>;
@Injectable() @Injectable()
export class PrismaService implements OnModuleInit { export class PrismaService implements OnModuleInit, OnModuleDestroy {
private readonly logger = new Logger(PrismaService.name); private readonly logger = new Logger(PrismaService.name);
private prismaClient!: ExtendedPrismaClient; private prismaClient!: ExtendedPrismaClient;
@ -61,6 +62,10 @@ export class PrismaService implements OnModuleInit {
await this.prismaClient.$connect(); await this.prismaClient.$connect();
} }
async onModuleDestroy(): Promise<void> {
await this.prismaClient.$disconnect();
}
async enableShutdownHooks(app: INestApplication) { async enableShutdownHooks(app: INestApplication) {
this.prismaClient.$on('beforeExit', async () => { this.prismaClient.$on('beforeExit', async () => {
await app.close(); await app.close();

View File

@ -1,24 +1,31 @@
import { Test, TestingModule } from '@nestjs/testing';
import { INestApplication } from '@nestjs/common'; import { INestApplication } from '@nestjs/common';
import * as request from 'supertest';
import { AppModule } from './../src/app.module'; import request from 'supertest';
import { createApp } from './utils/create-app';
describe('AppController (e2e)', () => { describe('AppController (e2e)', () => {
let app: INestApplication; let app: INestApplication;
beforeEach(async () => { beforeEach(async () => {
const moduleFixture: TestingModule = await Test.createTestingModule({ [app] = await createApp();
imports: [AppModule],
}).compile();
app = moduleFixture.createNestApplication();
await app.init();
}); });
it('/ (GET)', () => { afterEach(async () => {
await app.close();
});
it('/healthz (GET)', () => {
return request(app.getHttpServer()) return request(app.getHttpServer())
.get('/') .get('/healthz')
.expect(200) .expect(200)
.expect('Hello World!'); .expect((response) => {
expect(response.body).toEqual({
status: 'ok',
info: { database: { status: 'up' } },
error: {},
details: { database: { status: 'up' } },
});
});
}); });
}); });

View File

@ -0,0 +1,298 @@
import { INestApplication } from '@nestjs/common';
import request from 'supertest';
import { JwtAuthGuard } from 'src/guards/jwt.auth.guard';
import { createApp } from './utils/create-app';
describe('CompanyResolver (e2e)', () => {
let app: INestApplication;
let companyId: string | undefined;
const authGuardMock = { canActivate: (): any => true };
beforeEach(async () => {
[app] = await createApp({
moduleBuilderHook: (moduleBuilder) =>
moduleBuilder.overrideGuard(JwtAuthGuard).useValue(authGuardMock),
});
});
afterEach(async () => {
await app.close();
});
it('should create a company', () => {
const queryData = {
query: `
mutation CreateOneCompany($data: CompanyCreateInput!) {
createOneCompany(data: $data) {
id
name
domainName
address
}
}
`,
variables: {
data: {
name: 'New Company',
domainName: 'new-company.com',
address: 'New Address',
},
},
};
return request(app.getHttpServer())
.post('/graphql')
.send(queryData)
.expect(200)
.expect((res) => {
const data = res.body.data.createOneCompany;
companyId = data.id;
expect(data).toBeDefined();
expect(data).toHaveProperty('id');
expect(data).toHaveProperty('name', 'New Company');
expect(data).toHaveProperty('domainName', 'new-company.com');
expect(data).toHaveProperty('address', 'New Address');
});
});
it('should find many companies', () => {
const queryData = {
query: `
query FindManyCompany {
findManyCompany {
id
name
domainName
address
}
}
`,
};
return request(app.getHttpServer())
.post('/graphql')
.send(queryData)
.expect(200)
.expect((res) => {
const data = res.body.data.findManyCompany;
expect(data).toBeDefined();
expect(Array.isArray(data)).toBe(true);
expect(data.length).toBeGreaterThan(0);
const company = data.find((c) => c.id === companyId);
expect(company).toBeDefined();
expect(company).toHaveProperty('id');
expect(company).toHaveProperty('name', 'New Company');
expect(company).toHaveProperty('domainName', 'new-company.com');
expect(company).toHaveProperty('address', 'New Address');
// Check if we have access to ressources outside of our workspace
const instagramCompany = data.find((c) => c.name === 'Instagram');
expect(instagramCompany).toBeUndefined();
});
});
it('should find unique company', () => {
const queryData = {
query: `
query FindUniqueCompany($where: CompanyWhereUniqueInput!) {
findUniqueCompany(where: $where) {
id
name
domainName
address
}
}
`,
variables: {
where: {
id: companyId,
},
},
};
return request(app.getHttpServer())
.post('/graphql')
.send(queryData)
.expect(200)
.expect((res) => {
const data = res.body.data.findUniqueCompany;
expect(data).toBeDefined();
expect(data).toHaveProperty('id');
expect(data).toHaveProperty('name', 'New Company');
expect(data).toHaveProperty('domainName', 'new-company.com');
expect(data).toHaveProperty('address', 'New Address');
});
});
it('should not find unique company (forbidden because outside workspace)', () => {
const queryData = {
query: `
query FindUniqueCompany($where: CompanyWhereUniqueInput!) {
findUniqueCompany(where: $where) {
id
name
domainName
address
}
}
`,
variables: {
where: {
id: 'twenty-dev-a674fa6c-1455-4c57-afaf-dd5dc086361e',
},
},
};
return request(app.getHttpServer())
.post('/graphql')
.send(queryData)
.expect(200)
.expect((res) => {
const errors = res.body.errors;
const error = errors?.[0];
expect(error).toBeDefined();
expect(error.extensions.code).toBe('FORBIDDEN');
expect(error.extensions.originalError.statusCode).toBe(403);
});
});
it('should update a company', () => {
const queryData = {
query: `
mutation UpdateOneCompany($where: CompanyWhereUniqueInput!, $data: CompanyUpdateInput!) {
updateOneCompany(data: $data, where: $where) {
id
name
domainName
address
}
}
`,
variables: {
where: {
id: companyId,
},
data: {
name: 'Updated Company',
domainName: 'updated-company.com',
address: 'Updated Address',
},
},
};
return request(app.getHttpServer())
.post('/graphql')
.send(queryData)
.expect(200)
.expect((res) => {
const data = res.body.data.updateOneCompany;
expect(data).toBeDefined();
expect(data).toHaveProperty('id');
expect(data).toHaveProperty('name', 'Updated Company');
expect(data).toHaveProperty('domainName', 'updated-company.com');
expect(data).toHaveProperty('address', 'Updated Address');
});
});
it('should not update a company (forbidden because outside workspace)', () => {
const queryData = {
query: `
mutation UpdateOneCompany($where: CompanyWhereUniqueInput!, $data: CompanyUpdateInput!) {
updateOneCompany(data: $data, where: $where) {
id
name
domainName
address
}
}
`,
variables: {
where: {
id: 'twenty-dev-a674fa6c-1455-4c57-afaf-dd5dc086361e',
},
data: {
name: 'Updated Instagram',
},
},
};
return request(app.getHttpServer())
.post('/graphql')
.send(queryData)
.expect(200)
.expect((res) => {
const errors = res.body.errors;
const error = errors?.[0];
expect(error).toBeDefined();
expect(error.extensions.code).toBe('FORBIDDEN');
expect(error.extensions.originalError.statusCode).toBe(403);
});
});
it('should delete a company', () => {
const queryData = {
query: `
mutation DeleteManyCompany($ids: [String!]) {
deleteManyCompany(where: {id: {in: $ids}}) {
count
}
}
`,
variables: {
ids: [companyId],
},
};
return request(app.getHttpServer())
.post('/graphql')
.send(queryData)
.expect(200)
.expect((res) => {
const data = res.body.data.deleteManyCompany;
companyId = undefined;
expect(data).toBeDefined();
expect(data).toHaveProperty('count', 1);
});
});
it('should not delete a company (forbidden because outside workspace)', () => {
const queryData = {
query: `
mutation DeleteManyCompany($ids: [String!]) {
deleteManyCompany(where: {id: {in: $ids}}) {
count
}
}
`,
variables: {
ids: ['twenty-dev-a674fa6c-1455-4c57-afaf-dd5dc086361e'],
},
};
return request(app.getHttpServer())
.post('/graphql')
.send(queryData)
.expect(200)
.expect((res) => {
const errors = res.body.errors;
const error = errors?.[0];
expect(error).toBeDefined();
expect(error.extensions.code).toBe('FORBIDDEN');
expect(error.extensions.originalError.statusCode).toBe(403);
});
});
});

View File

@ -3,6 +3,11 @@
"rootDir": ".", "rootDir": ".",
"testEnvironment": "node", "testEnvironment": "node",
"testRegex": ".e2e-spec.ts$", "testRegex": ".e2e-spec.ts$",
"setupFilesAfterEnv": ["<rootDir>/utils/setup-tests.ts"],
"moduleNameMapper": {
"^src/(.*)": "<rootDir>/../src/$1",
"^test/(.*)": "<rootDir>/$1"
},
"transform": { "transform": {
"^.+\\.(t|j)s$": "ts-jest" "^.+\\.(t|j)s$": "ts-jest"
} }

View File

@ -0,0 +1,9 @@
{
"id": "twenty-ge256b39-3ec3-4fe3-8997-b76aa0bfc102",
"firstName": "Tim",
"lastName": "Apple",
"email": "tim@apple.dev",
"locale": "en",
"passwordHash": "$2b$10$66d.6DuQExxnrfI9rMqOg.U1XIYpagr6Lv05uoWLYbYmtK0HDIvS6",
"avatarUrl": null
}

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,41 @@
// check-db.ts
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();
const schemaDatabaseExists = async (databaseName: string) => {
try {
const result = await prisma.$queryRawUnsafe<[any]>(
`SELECT 1 FROM pg_database WHERE datname = '${databaseName}';`,
);
return result.length > 0;
} catch {
return false;
}
};
async function main() {
const databaseName = 'tests';
// Check if schema exists
const databaseExistsResult = await schemaDatabaseExists(databaseName);
if (!databaseExistsResult) {
throw new Error(`Schema ${databaseName} does not exist`);
}
// Check if database is initialized
await prisma.$queryRaw`SELECT 1 FROM pg_tables WHERE tablename='_prisma_migrations';`;
}
main()
.then(() => {
process.exit(0);
})
.catch(() => {
process.exit(1);
})
.finally(async () => {
await prisma.$disconnect();
});

View File

@ -0,0 +1,56 @@
import { NestExpressApplication } from '@nestjs/platform-express';
import { Test, TestingModule, TestingModuleBuilder } from '@nestjs/testing';
import mockUser from 'test/mock-data/user.json';
import mockWorkspace from 'test/mock-data/workspace.json';
import { RequestHandler } from 'express';
import { AppModule } from 'src/app.module';
interface TestingModuleCreatePreHook {
(moduleBuilder: TestingModuleBuilder): TestingModuleBuilder;
}
/**
* Hook for adding items to nest application
*/
export type TestingAppCreatePreHook = (
app: NestExpressApplication,
) => Promise<void>;
/**
* Sets basic e2e testing module of app
*/
export async function createApp(
config: {
moduleBuilderHook?: TestingModuleCreatePreHook;
appInitHook?: TestingAppCreatePreHook;
} = {},
): Promise<[NestExpressApplication, TestingModule]> {
let moduleBuilder: TestingModuleBuilder = Test.createTestingModule({
imports: [AppModule],
});
if (!!config.moduleBuilderHook) {
moduleBuilder = config.moduleBuilderHook(moduleBuilder);
}
const moduleFixture: TestingModule = await moduleBuilder.compile();
const app = moduleFixture.createNestApplication<NestExpressApplication>();
if (config.appInitHook) {
await config.appInitHook(app);
}
const mockAuthHandler: RequestHandler = (req, _res, next) => {
req.user = {
user: mockUser,
workspace: mockWorkspace,
};
next();
};
app.use(mockAuthHandler);
return [await app.init(), moduleFixture];
}

View File

@ -0,0 +1,18 @@
import { PrismaClient, Prisma } from '@prisma/client';
import { camelCase } from 'src/utils/camel-case';
const prisma = new PrismaClient();
export default async () => {
const models = Prisma.dmmf.datamodel.models;
const modelNames = models.map((model) => model.name);
const entities = modelNames.map((modelName) => camelCase(modelName));
await prisma.$transaction(
entities.map((entity) => {
console.log('entity: ', entity);
return prisma[entity].deleteMany();
}),
);
};

View File

@ -0,0 +1,5 @@
import resetDb from './reset-db';
global.beforeEach(() => {
// resetDb();
});

View File

@ -1711,9 +1711,9 @@
integrity sha512-Z7C/xXCiGWsg0KuKsHTKJxbWhpI3Vs5GwLfOean7MGyVFGqdRgBbAjOCh6u4bbjPc/8MJ2pZmK/0DLdCbivLDA== integrity sha512-Z7C/xXCiGWsg0KuKsHTKJxbWhpI3Vs5GwLfOean7MGyVFGqdRgBbAjOCh6u4bbjPc/8MJ2pZmK/0DLdCbivLDA==
"@mapbox/node-pre-gyp@^1.0.10": "@mapbox/node-pre-gyp@^1.0.10":
version "1.0.10" version "1.0.11"
resolved "https://registry.yarnpkg.com/@mapbox/node-pre-gyp/-/node-pre-gyp-1.0.10.tgz#8e6735ccebbb1581e5a7e652244cadc8a844d03c" resolved "https://registry.yarnpkg.com/@mapbox/node-pre-gyp/-/node-pre-gyp-1.0.11.tgz#417db42b7f5323d79e93b34a6d7a2a12c0df43fa"
integrity sha512-4ySo4CjzStuprMwk35H5pPbkymjv1SF3jGLj6rAHp/xT/RF7TL7bd9CTm1xDY49K2qF7jmR/g7k+SkLETP6opA== integrity sha512-Yhlar6v9WQgUp/He7BdgzOz8lqMQ8sU+jkCq7Wx8Myc5YFJLbEe7lgui/V7G1qB1DJykHSGwreceSaD60Y0PUQ==
dependencies: dependencies:
detect-libc "^2.0.0" detect-libc "^2.0.0"
https-proxy-agent "^5.0.0" https-proxy-agent "^5.0.0"
@ -5532,16 +5532,11 @@ graphql-ws@5.13.1:
resolved "https://registry.npmjs.org/graphql-ws/-/graphql-ws-5.13.1.tgz" resolved "https://registry.npmjs.org/graphql-ws/-/graphql-ws-5.13.1.tgz"
integrity sha512-eiX7ES/ZQr0q7hSM5UBOEIFfaAUmAY9/CSDyAnsETuybByU7l/v46drRg9DQoTvVABEHp3QnrvwgTRMhqy7zxQ== integrity sha512-eiX7ES/ZQr0q7hSM5UBOEIFfaAUmAY9/CSDyAnsETuybByU7l/v46drRg9DQoTvVABEHp3QnrvwgTRMhqy7zxQ==
"graphql@0.13.1 - 16": "graphql@0.13.1 - 16", graphql@^16.7.1:
version "16.7.1" version "16.7.1"
resolved "https://registry.yarnpkg.com/graphql/-/graphql-16.7.1.tgz#11475b74a7bff2aefd4691df52a0eca0abd9b642" resolved "https://registry.yarnpkg.com/graphql/-/graphql-16.7.1.tgz#11475b74a7bff2aefd4691df52a0eca0abd9b642"
integrity sha512-DRYR9tf+UGU0KOsMcKAlXeFfX89UiiIZ0dRU3mR0yJfu6OjZqUcp68NnFLnqQU5RexygFoDy1EW+ccOYcPfmHg== integrity sha512-DRYR9tf+UGU0KOsMcKAlXeFfX89UiiIZ0dRU3mR0yJfu6OjZqUcp68NnFLnqQU5RexygFoDy1EW+ccOYcPfmHg==
graphql@^16.6.0:
version "16.6.0"
resolved "https://registry.npmjs.org/graphql/-/graphql-16.6.0.tgz"
integrity sha512-KPIBPDlW7NxrbT/eh4qPXz5FiFdL5UbaA0XUNz2Rp3Z3hqBSkbj0GVjwFDztsWVauZUWsbKHgMg++sk8UX0bkw==
has-bigints@^1.0.1, has-bigints@^1.0.2: has-bigints@^1.0.1, has-bigints@^1.0.2:
version "1.0.2" version "1.0.2"
resolved "https://registry.yarnpkg.com/has-bigints/-/has-bigints-1.0.2.tgz#0871bd3e3d51626f6ca0966668ba35d5602d6eaa" resolved "https://registry.yarnpkg.com/has-bigints/-/has-bigints-1.0.2.tgz#0871bd3e3d51626f6ca0966668ba35d5602d6eaa"