Skip to content

Commit

Permalink
feat: Improve test coverage (powerhouse-inc#25)
Browse files Browse the repository at this point in the history
  • Loading branch information
KirillDogadin-std authored Mar 23, 2023
1 parent 125d8b1 commit 7cedbca
Show file tree
Hide file tree
Showing 16 changed files with 189 additions and 38 deletions.
9 changes: 9 additions & 0 deletions CONTRIBUTION.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
### Testing structure

The test suite is designed to ensure that some of the modules are fully covered by tests.
Therefore, there're 2 runs of tests: one validates that overall code coverage is on appropriate level, another validates that the code coverage for
a subset of modules is maximal.

The coverage report will therefore contain two tables.

See corresponding configs in `./vitest.config.ts` and `./vitest.full-coverage.config.ts`
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,8 @@
"postinstall": "prisma generate",
"lint": "eslint \"./**/*.{ts,tsx}\" --max-warnings=0",
"typecheck": "tsc --noEmit",
"test": "vitest run --coverage"
"test": "vitest run && vitest run --config ./vitest.full-coverage.config.ts",
"watch": "vitest watch"
},
"dependencies": {
"@prisma/client": "^4.11.0",
Expand Down
3 changes: 0 additions & 3 deletions src/context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,9 +36,6 @@ export function createContext(params: CreateContextParams): Context {
const authorizationHeader = req.get('Authorization');
const token = authorizationHeader?.replace('Bearer ', '');

if (!JWT_SECRET) {
throw new Error('Missing JWT_SECRET environment variable');
}
return {
request: params,
prisma,
Expand Down
14 changes: 0 additions & 14 deletions src/env.ts

This file was deleted.

8 changes: 8 additions & 0 deletions src/env/getters.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
export const getJwtSecret = (): string => {
if (!process.env.JWT_SECRET) {
if (process.env.NODE_ENV === 'production') {
throw new Error('JWT_SECRET is not defined');
}
}
return process.env.JWT_SECRET || 'dev';
};
11 changes: 11 additions & 0 deletions src/env/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import dotenv from 'dotenv';
import { getJwtSecret } from './getters';

dotenv.config();

export const JWT_SECRET = getJwtSecret();
export const PORT = Number(process.env.PORT ?? '3000');
export const isDevelopment = process.env.NODE_ENV === 'development';
export const AUTH_SIGNUP_ENABLED = Boolean(process.env.AUTH_SIGNUP_ENABLED);
// https://www.npmjs.com/package/jsonwebtoken for `expiresIn` format
export const JWT_EXPIRATION_PERIOD: number | string = process.env.JWT_EXPIRATION_PERIOD_SECONDS ? Number(process.env.JWT_EXPIRATION_PERIOD_SECONDS) : '7d';
1 change: 1 addition & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { createApp } from './app';
import { startServer } from './server';

const application = createApp();
/* istanbul ignore next @preserve */
startServer(application)
.then(() => {
// This should never happen, is only here until we add the real API which of course runs forever
Expand Down
5 changes: 3 additions & 2 deletions src/modules/User/model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ export function getUserCrud(prisma: PrismaClient) {
if (!user) {
throw new ApolloError('User not found', 'USER_NOT_FOUND');
}
const passwordValid = (await compare(password, user.password || '')) || false;
const passwordValid = await compare(password, user.password);
if (!passwordValid) {
throw new ApolloError('Invalid password', 'INVALID_PASSWORD');
}
Expand Down Expand Up @@ -75,7 +75,8 @@ export function getUserCrud(prisma: PrismaClient) {
if ('code' in e && e.code === 'P2002') {
throw new ApolloError('Username already taken', 'USERNAME_TAKEN');
}
throw new ApolloError('Failed to create user', 'USER_CREATE_FAILED');
/* istanbul ignore next @preserve */
throw e;
}
return {
token: sign({ userId: created.id }, JWT_SECRET),
Expand Down
1 change: 1 addition & 0 deletions src/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { validationPlugin } from 'nexus-validation-plugin';
import { applyMiddleware } from 'graphql-middleware';
import * as types from './modules';

/* istanbul ignore next @preserve */
export const schema = makeSchema({
types,
plugins: [
Expand Down
6 changes: 1 addition & 5 deletions src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,9 +21,5 @@ export const startServer = async (
await apollo.start();
apollo.applyMiddleware({ app });
const usedPort = port || PORT;
return httpServer.listen({ port: usedPort }, () => {
process.stdout.write(
`🚀 Server ready at port ${usedPort}`,
);
});
return httpServer.listen({ port: usedPort }, () => {});
};
38 changes: 37 additions & 1 deletion tests/auth.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import { test, expect } from 'vitest';
import { test, expect, vi } from 'vitest';
import builder from 'gql-query-builder';
import { cleanDatabase as cleanDatabaseBeforeAfterEachTest } from './helpers/database';
import { ctx, executeGraphQlQuery } from './helpers/server';
import { restoreEnvAfterEach } from './helpers/env';
import * as env from '../src/env';

const signUpMutation = builder.mutation({
operation: 'signUp',
Expand Down Expand Up @@ -39,6 +41,7 @@ const meQuery = builder.query({
});

cleanDatabaseBeforeAfterEachTest();
restoreEnvAfterEach();

test('Authentication: sign up, sign in, request protected enpoint', async () => {
const signUpResponse = (await executeGraphQlQuery(signUpMutation)) as Record<
Expand Down Expand Up @@ -110,3 +113,36 @@ test('Authentication: access protected endpoint without valid token', async () =
const response = (await executeGraphQlQuery(meQuery)) as any;
expect(response.errors[0].message).toBe('Invalid authentication token');
});

test('Authentication: token expiration error', async () => {
vi.spyOn(env, 'JWT_EXPIRATION_PERIOD', 'get').mockReturnValue('1ms');
const signUpResponse = (await executeGraphQlQuery(signUpMutation)) as Record<
string,
any
>;
expect(signUpResponse?.signUp?.user?.username).toBe('asdf');

const signInResponse = (await executeGraphQlQuery(singInMutation)) as Record<
string,
any
>;
expect(signInResponse?.signIn?.user?.username).toBe('asdf');
expect(signInResponse?.signIn?.token).toBeTruthy();

const token = signInResponse?.signIn?.token;
ctx.client.setHeader('Authorization', `Bearer ${token}`);

const meResponse = (await executeGraphQlQuery(meQuery)) as Record<
string,
any
>;
// wait until token expires
await new Promise((resolve) => { setTimeout(resolve, 20); resolve(null); });
expect(meResponse?.errors[0].message).toBe('Token expired');
});

test('Authentication: sign up disabled', async () => {
vi.spyOn(env, 'AUTH_SIGNUP_ENABLED', 'get').mockReturnValue(false);
const response = (await executeGraphQlQuery(signUpMutation)) as any;
expect(response.errors[0].message).toBe('Sign up is disabled');
});
16 changes: 16 additions & 0 deletions tests/auxilary.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { test, expect } from 'vitest';
import { getJwtSecret } from '../src/env/getters';
import { restoreEnvAfterEach } from './helpers/env';

restoreEnvAfterEach();

test('Env: production has jwt secret defined', async () => {
process.env.JWT_SECRET = '';
process.env.NODE_ENV = 'production';
expect(getJwtSecret).toThrowError('JWT_SECRET is not defined');
});

test('Env: dev environment has jwt secret automatically set', async () => {
process.env.JWT_SECRET = '';
expect(getJwtSecret()).toBe('dev');
});
65 changes: 65 additions & 0 deletions tests/coreunit.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import { test, expect } from 'vitest';
import builder from 'gql-query-builder';
import { CoreUnit } from '@prisma/client';
import { cleanDatabase as cleanDatabaseBeforeAfterEachTest } from './helpers/database';
import { executeGraphQlQuery } from './helpers/server';
import { getPrisma } from '../src/database';

cleanDatabaseBeforeAfterEachTest();

test('Core Unit: get all', async () => {
const prisma = getPrisma();
await prisma.coreUnit.create({
data: {
code: 'asdf',
shortCode: 'a',
name: 'name',
imageSource: '',
descriptionSentence: '',
descriptionParagraph: '',
descriptionParagraphImageSource: '',
},
});
const query = builder.query({
operation: 'coreUnits',
fields: ['code', 'shortCode', 'name'],
});
const response = await executeGraphQlQuery(query) as { coreUnits: CoreUnit[] };
expect(response.coreUnits).toHaveLength(1);
expect(response.coreUnits[0].code).toBe('asdf');
expect(response.coreUnits[0].shortCode).toBe('a');
expect(response.coreUnits[0].name).toBe('name');
});

test('Core Unit: get by id', async () => {
const prisma = getPrisma();
const created = await prisma.coreUnit.create({
data: {
code: 'asdf',
shortCode: 'a',
name: 'name',
imageSource: '',
descriptionSentence: '',
descriptionParagraph: '',
descriptionParagraphImageSource: '',
},
});
const query = builder.query({
operation: 'coreUnit',
variables: {
id: created.id,
},
fields: ['id'],
});
const response = await executeGraphQlQuery(query) as { coreUnit: CoreUnit };
expect(response.coreUnit.id).toBe(created.id);
});

test('Core Unit: get by id without id field throws', async () => {
const query = builder.query({
operation: 'coreUnit',
fields: ['id'],
});
const response = await executeGraphQlQuery(query) as { errors: Record<string, unknown>[] };
expect(response.errors[0].message).toBe('please provide id');
});
8 changes: 8 additions & 0 deletions tests/helpers/env.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import { afterEach } from 'vitest';

const originalEnv = { ...process.env };
export function restoreEnvAfterEach() {
afterEach(() => {
process.env = { ...originalEnv };
});
}
31 changes: 19 additions & 12 deletions vitest.config.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,21 @@
import { defineConfig } from 'vitest/config';
import { defineConfig, UserConfigExport } from 'vitest/config';

export default defineConfig({
test: {
coverage: {
provider: 'istanbul',
lines: 0,
functions: 0,
statements: 0,
all: true,
export const getVitestConfig = (fullyCoveredModulePaths?: string[]): UserConfigExport => {
const enableFullCoverage = !!fullyCoveredModulePaths && fullyCoveredModulePaths.length !== 0;
const coverage = enableFullCoverage ? 100 : 90;
return {
test: {
coverage: {
enabled: true,
provider: 'istanbul',
lines: coverage,
functions: coverage,
statements: coverage,
include: fullyCoveredModulePaths || undefined,
},
singleThread: true,
},
singleThread: true,
},
});
};
};

export default defineConfig(getVitestConfig());
8 changes: 8 additions & 0 deletions vitest.full-coverage.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import { defineConfig } from 'vitest/config';
import { getVitestConfig } from './vitest.config';

const fullyCoveredModulePaths = [
'src/modules/**',
];

export default defineConfig(getVitestConfig(fullyCoveredModulePaths));

0 comments on commit 7cedbca

Please sign in to comment.