Skip to content

Commit

Permalink
Merge pull request #175 from alan2207/standalone-mock-server
Browse files Browse the repository at this point in the history
add standalone mock server
  • Loading branch information
alan2207 committed Jun 8, 2024
2 parents e8181b4 + 8ef3fa3 commit 5ecd1d1
Show file tree
Hide file tree
Showing 20 changed files with 966 additions and 86 deletions.
2 changes: 1 addition & 1 deletion .env.example
Original file line number Diff line number Diff line change
@@ -1,2 +1,2 @@
VITE_APP_API_URL=https://api.bulletproofapp.com
VITE_APP_ENABLE_API_MOCKING=true
VITE_APP_ENABLE_API_MOCKING=true
4 changes: 4 additions & 0 deletions .env.example-e2e
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
VITE_APP_API_URL=http://localhost:8080/api
VITE_APP_ENABLE_API_MOCKING=false
VITE_APP_MOCK_API_PORT=8080
VITE_APP_URL=http://localhost:3000
8 changes: 5 additions & 3 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -39,16 +39,18 @@ jobs:
with:
node-version: lts/*
- name: Set environment variables
run: mv .env.example .env
run: mv .env.example-e2e .env
- name: Install dependencies
run: npm install -g yarn && yarn
- name: Install Playwright Browsers
run: yarn playwright install --with-deps
- name: Run Playwright tests
run: yarn playwright test
run: yarn test-e2e
- uses: actions/upload-artifact@v4
if: always()
with:
name: playwright-report
path: playwright-report/
path: |
playwright-report/
mocked-db.json
retention-days: 30
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -34,3 +34,7 @@ npm-debug.log*
yarn-debug.log*
yarn-error.log*


# local
mocked-db.json

2 changes: 0 additions & 2 deletions docs/testing.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,6 @@ Integration testing checks how different parts of your application work together

End-to-End Testing is a method that evaluates an application as a whole. These tests involve automating the complete application, including both the frontend and backend, to confirm that the entire system functions correctly. End-to-End tests simulate how a user would interact with the application.

NOTE: In the sample app, the tests are ran against the mocked API server, so they are technically not fully e2e, but they are written in the same way as they would be if they were ran against the real API.

[E2E Example Code](../e2e/tests/smoke.spec.ts)

## Recommended Tooling:
Expand Down
30 changes: 30 additions & 0 deletions mock-server.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { createMiddleware } from '@mswjs/http-middleware';
import cors from 'cors';
import express from 'express';
import logger from 'pino-http';

import { env } from './src/config/env';
import { initializeDb } from './src/testing/mocks/db';
import { handlers } from './src/testing/mocks/handlers';

const app = express();

app.use(
cors({
origin: env.APP_URL,
credentials: true,
}),
);

app.use(express.json());
app.use(logger());
app.use(createMiddleware(...handlers));

initializeDb().then(() => {
console.log('Mock DB initialized');
app.listen(env.APP_MOCK_API_PORT, () => {
console.log(
`Mock API server started at http://localhost:${env.APP_MOCK_API_PORT}`,
);
});
});
12 changes: 11 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,14 @@
"build": "tsc && vite build --base=/",
"preview": "vite preview",
"test": "vitest",
"test-e2e": "pm2 start \"yarn run-mock-server\" --name server && yarn playwright test",
"prepare": "husky",
"lint": "eslint src --ignore-path .gitignore",
"check-types": "tsc --project tsconfig.json --pretty --noEmit",
"generate": "plop",
"storybook": "storybook dev -p 6006",
"build-storybook": "storybook build"
"build-storybook": "storybook build",
"run-mock-server": "vite-node mock-server.ts | pino-pretty -c"
},
"dependencies": {
"@hookform/resolvers": "^3.3.4",
Expand Down Expand Up @@ -50,6 +52,7 @@
"devDependencies": {
"@eslint/eslintrc": "^3.0.2",
"@mswjs/data": "^0.16.1",
"@mswjs/http-middleware": "^0.10.1",
"@playwright/test": "^1.43.1",
"@storybook/addon-a11y": "^8.0.10",
"@storybook/addon-actions": "^8.0.9",
Expand All @@ -62,6 +65,7 @@
"@testing-library/jest-dom": "^6.4.2",
"@testing-library/react": "^15.0.5",
"@testing-library/user-event": "^14.5.2",
"@types/cors": "^2.8.17",
"@types/dompurify": "^3.0.5",
"@types/js-cookie": "^3.0.6",
"@types/marked": "^6.0.0",
Expand All @@ -72,6 +76,7 @@
"@typescript-eslint/parser": "^7.8.0",
"@vitejs/plugin-react": "^4.2.1",
"autoprefixer": "^10.4.19",
"cors": "^2.8.5",
"eslint": "8",
"eslint-config-prettier": "^9.1.0",
"eslint-import-resolver-typescript": "^3.6.1",
Expand All @@ -85,19 +90,24 @@
"eslint-plugin-tailwindcss": "^3.15.1",
"eslint-plugin-testing-library": "^6.2.2",
"eslint-plugin-vitest": "^0.5.4",
"express": "^4.19.2",
"husky": "^9.0.11",
"jest-environment-jsdom": "^29.7.0",
"js-cookie": "^3.0.5",
"jsdom": "^24.0.0",
"lint-staged": "^15.2.2",
"msw": "^2.2.14",
"pino-http": "^10.1.0",
"pino-pretty": "^11.1.0",
"plop": "^4.0.1",
"pm2": "^5.4.0",
"postcss": "^8.4.38",
"prettier": "^3.2.5",
"storybook": "^8.0.9",
"tailwindcss": "^3.4.3",
"typescript": "^5.4.5",
"vite": "^5.2.10",
"vite-node": "^1.6.0",
"vite-tsconfig-paths": "^4.3.2",
"vitest": "^1.5.2"
},
Expand Down
2 changes: 1 addition & 1 deletion playwright.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,11 +37,11 @@ export default defineConfig({
{ name: 'setup', testMatch: /.*\.setup\.ts/ },
{
name: 'chromium',
testMatch: /.*\.spec\.ts/,
use: {
...devices['Desktop Chrome'],
storageState: 'e2e/.auth/user.json',
},

dependencies: ['setup'],
},
],
Expand Down
2 changes: 2 additions & 0 deletions src/config/env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ const createEnv = () => {
.refine((s) => s === 'true' || s === 'false')
.transform((s) => s === 'true')
.optional(),
APP_URL: z.string().optional().default('http://localhost:3000'),
APP_MOCK_API_PORT: z.string().optional().default('8080'),
});

const envVars = Object.entries(import.meta.env).reduce<
Expand Down
8 changes: 8 additions & 0 deletions src/lib/api-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ function authRequestInterceptor(config: InternalAxiosRequestConfig) {
if (config.headers) {
config.headers.Accept = 'application/json';
}

config.withCredentials = true;
return config;
}

Expand All @@ -27,6 +29,12 @@ api.interceptors.response.use(
message,
});

if (error.response?.status === 401) {
const searchParams = new URLSearchParams();
const redirectTo = searchParams.get('redirectTo');
window.location.href = `/auth/login?redirectTo=${redirectTo}`;
}

return Promise.reject(error);
},
);
48 changes: 41 additions & 7 deletions src/testing/mocks/db.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,18 +40,52 @@ export const db = factory(models);

export type Model = keyof typeof models;

export const loadDb = () =>
Object.assign(JSON.parse(window.localStorage.getItem('msw-db') || '{}'));
const dbFilePath = 'mocked-db.json';

export const persistDb = (model: Model) => {
export const loadDb = async () => {
// If we are running in a Node.js environment
if (typeof window === 'undefined') {
const { readFile, writeFile } = await import('fs/promises');
try {
const data = await readFile(dbFilePath, 'utf8');
return JSON.parse(data);
} catch (error: any) {
if (error?.code === 'ENOENT') {
const emptyDB = {};
await writeFile(dbFilePath, JSON.stringify(emptyDB, null, 2));
return emptyDB;
} else {
console.error('Error loading mocked DB:', error);
return null;
}
}
}
// If we are running in a browser environment
return Object.assign(
JSON.parse(window.localStorage.getItem('msw-db') || '{}'),
);
};

export const storeDb = async (data: string) => {
// If we are running in a Node.js environment
if (typeof window === 'undefined') {
const { writeFile } = await import('fs/promises');
await writeFile(dbFilePath, data);
} else {
// If we are running in a browser environment
window.localStorage.setItem('msw-db', data);
}
};

export const persistDb = async (model: Model) => {
if (process.env.NODE_ENV === 'test') return;
const data = loadDb();
const data = await loadDb();
data[model] = db[model].getAll();
window.localStorage.setItem('msw-db', JSON.stringify(data));
await storeDb(JSON.stringify(data));
};

export const initializeDb = () => {
const database = loadDb();
export const initializeDb = async () => {
const database = await loadDb();
Object.entries(db).forEach(([key, model]) => {
const dataEntres = database[key];
if (dataEntres) {
Expand Down
6 changes: 3 additions & 3 deletions src/testing/mocks/handlers/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ export const authHandlers = [
const team = db.team.create({
name: userObject.teamName ?? `${userObject.firstName} Team`,
});
persistDb('team');
await persistDb('team');
teamId = team.id;
role = 'ADMIN';
} else {
Expand Down Expand Up @@ -85,7 +85,7 @@ export const authHandlers = [
teamId,
});

persistDb('user');
await persistDb('user');

const result = authenticate({
email: userObject.email,
Expand Down Expand Up @@ -153,7 +153,7 @@ export const authHandlers = [
await networkDelay();

try {
const user = requireAuth(cookies, false);
const { user } = requireAuth(cookies);
return HttpResponse.json(user);
} catch (error: any) {
return HttpResponse.json(
Expand Down
19 changes: 14 additions & 5 deletions src/testing/mocks/handlers/comments.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,10 @@ export const commentsHandlers = [
await networkDelay();

try {
requireAuth(cookies);
const { error } = requireAuth(cookies);
if (error) {
return HttpResponse.json({ message: error }, { status: 401 });
}
const url = new URL(request.url);
const discussionId = url.searchParams.get('discussionId') || '';
const comments = db.comment
Expand Down Expand Up @@ -52,13 +55,16 @@ export const commentsHandlers = [
await networkDelay();

try {
const user = requireAuth(cookies);
const { user, error } = requireAuth(cookies);
if (error) {
return HttpResponse.json({ message: error }, { status: 401 });
}
const data = (await request.json()) as CreateCommentBody;
const result = db.comment.create({
authorId: user?.id,
...data,
});
persistDb('comment');
await persistDb('comment');
return HttpResponse.json(result);
} catch (error: any) {
return HttpResponse.json(
Expand All @@ -74,7 +80,10 @@ export const commentsHandlers = [
await networkDelay();

try {
const user = requireAuth(cookies);
const { user, error } = requireAuth(cookies);
if (error) {
return HttpResponse.json({ message: error }, { status: 401 });
}
const commentId = params.commentId as string;
const result = db.comment.delete({
where: {
Expand All @@ -88,7 +97,7 @@ export const commentsHandlers = [
}),
},
});
persistDb('comment');
await persistDb('comment');
return HttpResponse.json(result);
} catch (error: any) {
return HttpResponse.json(
Expand Down
Loading

0 comments on commit 5ecd1d1

Please sign in to comment.