Skip to content

Commit

Permalink
Merge pull request #1 from anthonyhastings/testcontainers
Browse files Browse the repository at this point in the history
adding integration tests with supertest and testcontainers
  • Loading branch information
anthonyhastings authored Oct 22, 2023
2 parents 2dc14f8 + 8f3289c commit 8ce6303
Show file tree
Hide file tree
Showing 9 changed files with 2,967 additions and 111 deletions.
30 changes: 30 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
name: Tests

on:
pull_request:
push:
branches:
- main

jobs:
test:
name: Integration Tests
runs-on: ubuntu-latest
steps:
- name: Clone repository
uses: actions/checkout@v4

- name: Set Node.js version
uses: actions/setup-node@v3
with:
node-version-file: '.nvmrc'
cache: 'yarn'

- name: Install application dependencies
run: yarn install --frozen-lockfile --prefer-offline

- name: Check formatting
run: yarn format:check

- name: Run integration tests
run: yarn test
2 changes: 1 addition & 1 deletion .vscode/settings.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
{
"[javascript][typescript]": {
"[javascript][typescript][markdown][json][yaml]": {
"editor.defaultFormatter": "esbenp.prettier-vscode",
"editor.formatOnSave": true
}
Expand Down
4 changes: 2 additions & 2 deletions compose.yml
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
version: "3.8"
version: '3.8'
services:
database:
image: mongo:7
Expand Down Expand Up @@ -42,4 +42,4 @@ services:
path: ./src
target: /url-shortener/src
- action: rebuild
path: package.json
path: package.json
5 changes: 5 additions & 0 deletions jest.config.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
export default {
testEnvironment: 'node',
testTimeout: 20000,
verbose: true,
};
12 changes: 9 additions & 3 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,10 @@
"scripts": {
"dev": "nodemon src/index.mjs",
"dev:native": "node --watch src/index.mjs",
"start": "node src/index.mjs"
"format": "prettier --write .",
"format:check": "prettier --list-different .",
"start": "node src/index.mjs",
"test": "DEBUG=testcontainers* node --experimental-vm-modules $(yarn bin jest) --runInBand"
},
"dependencies": {
"dotenv": "^16.3.1",
Expand All @@ -21,7 +24,10 @@
"zod": "^3.22.4"
},
"devDependencies": {
"@testcontainers/mongodb": "^10.2.1",
"jest": "^29.7.0",
"nodemon": "^3.0.1",
"prettier": "^3.0.3"
"prettier": "^3.0.3",
"supertest": "^6.3.3"
}
}
}
159 changes: 159 additions & 0 deletions src/__tests__/integration.spec.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
import path from 'node:path';
import { jest } from '@jest/globals';
import { MongoDBContainer } from '@testcontainers/mongodb';
import mongoose from 'mongoose';
import supertest from 'supertest';

jest.unstable_mockModule('nanoid', async () => {
return {
nanoid: jest.fn(() => 'fake-id'),
};
});

const nanoId = (await import('nanoid')).nanoid;

describe('App Tests', () => {
let mongoDBContainer;

beforeAll(async () => {
mongoDBContainer = await new MongoDBContainer('mongo:7')
.withCopyDirectoriesToContainer([
{
source: path.resolve(process.cwd(), './database/init/'),
target: '/docker-entrypoint-initdb.d/',
},
])
.start();

// Mongoose is using the docker hostname. This only works if the test code is running inside of docker.
// ?directConnection solves this connection issue.
process.env.MONGODB_URL = `${mongoDBContainer.getConnectionString()}/urlShortener?directConnection=true`;
process.env.APP_PORT = 5000;
});

afterAll(async () => {
await mongoose.connection.close();
await mongoDBContainer.stop();
});

describe('GET /links/:linkId', () => {
it('returns data when record is found', async () => {
const serverModule = (await import('../server.mjs')).app;

await supertest(serverModule)
.get('/links/Zx8lP5bWMhEgnvH')
.expect(200)
.then((response) => {
expect(response.body).toMatchObject({
success: true,
data: {
_id: expect.any(String),
shortId: 'Zx8lP5bWMhEgnvH',
target: 'https://www.youtube.com/',
},
});
});
});

it('returns error when record is not found', async () => {
const serverModule = (await import('../server.mjs')).app;

await supertest(serverModule)
.get('/links/fake-record')
.expect(404)
.then((response) => {
expect(response.body).toMatchObject({
success: false,
error: 'Record not found',
});
});
});
});

describe('GET /:shortId', () => {
it('returns redirect when record is found', async () => {
const serverModule = (await import('../server.mjs')).app;

await supertest(serverModule)
.get('/Zx8lP5bWMhEgnvH')
.expect(301)
.then((response) => {
expect(response.headers.location).toBe('https://www.youtube.com/');
});
});

it('returns error when record is not found', async () => {
const serverModule = (await import('../server.mjs')).app;

await supertest(serverModule)
.get('/fake-record')
.expect(404)
.then((response) => {
expect(response.body).toMatchObject({
success: false,
error: 'Record not found',
});
});
});
});

describe('POST /links', () => {
it('returns 201 whenever record created', async () => {
nanoId.mockReturnValueOnce('bingo');
const serverModule = (await import('../server.mjs')).app;

await supertest(serverModule)
.post('/links')
.set('Content-Type', 'application/json')
.set('Accept', 'application/json')
.send({ target: 'https://www.duckduckgo.com/' })
.expect(201)
.then((response) => {
expect(response.body).toMatchObject({
success: true,
data: {
_id: expect.any(String),
shortId: expect.any(String),
target: 'https://www.duckduckgo.com/',
},
});
});
});

it('returns 409 whenever URL is invalid', async () => {
nanoId.mockReturnValueOnce('bingo');
const serverModule = (await import('../server.mjs')).app;

await supertest(serverModule)
.post('/links')
.set('Content-Type', 'application/json')
.set('Accept', 'application/json')
.send({ target: 'bad-url' })
.expect(409)
.then((response) => {
expect(response.body).toMatchObject({
success: false,
error: 'Invalid target',
});
});
});

it('returns 409 when Short ID already exists', async () => {
nanoId.mockReturnValueOnce('Zx8lP5bWMhEgnvH');
const serverModule = (await import('../server.mjs')).app;

await supertest(serverModule)
.post('/links')
.set('Content-Type', 'application/json')
.set('Accept', 'application/json')
.send({ target: 'https://www.duckduckgo.com/' })
.expect(409)
.then((response) => {
expect(response.body).toMatchObject({
success: false,
error: 'Short Id already exists',
});
});
});
});
});
63 changes: 1 addition & 62 deletions src/index.mjs
Original file line number Diff line number Diff line change
@@ -1,67 +1,6 @@
import 'dotenv/config';
import express from 'express';
import mongoose from 'mongoose';
import { nanoid } from 'nanoid';
import { config } from './config.mjs';
import { LinkModel } from './models/link.mjs';
import { isValidHTTPURL } from './utils.mjs';

mongoose.connection.once('open', () => {
console.log('Connected to MongoDB');
});

await mongoose.connect(config.MONGODB_URL);

const app = express();

app.use(express.json());

app.get('/health', (_req, res) => {
res.status(200).json({ status: 'ok' });
});

app.get('/links/:shortId', async (req, res) => {
const link = await LinkModel.findOne({ shortId: req.params.shortId });

if (link === null) {
return res.status(404).json({ success: false, error: 'Record not found' });
}

res.status(200).json({ success: true, data: link.toJSON() });
});

app.post('/links', async (req, res) => {
const isValidTarget = isValidHTTPURL(req.body.target);
if (!isValidTarget) {
return res.status(409).json({ success: false, error: 'Invalid target' });
}

const shortId = nanoid(25);

const existingLink = await LinkModel.findOne({ shortId });
if (existingLink) {
return res
.status(409)
.json({ success: false, error: 'Short Id already exists' });
}

const link = await new LinkModel({
shortId,
target: req.body.target,
}).save();

res.status(201).json({ success: true, data: link.toJSON() });
});

app.get('/:shortId', async (req, res) => {
const link = await LinkModel.findOne({ shortId: req.params.shortId });

if (link === null) {
return res.status(404).json({ success: false, error: 'Record not found' });
}

res.redirect(301, link.target);
});
import { app } from './server.mjs';

app.listen(config.APP_PORT, '0.0.0.0', () => {
console.info(`Listening on port 0.0.0.0:${config.APP_PORT}`);
Expand Down
76 changes: 76 additions & 0 deletions src/server.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import 'dotenv/config';
import express from 'express';
import mongoose from 'mongoose';
import { nanoid } from 'nanoid';
import { config } from './config.mjs';
import { LinkModel } from './models/link.mjs';
import { isValidHTTPURL } from './utils.mjs';

mongoose.connection.once('open', () => {
console.log('Connected to MongoDB');
});

mongoose.connection.once('disconnected', () => {
console.log('Disconnected from MongoDB');
});

try {
await mongoose.connect(config.MONGODB_URL, {
useNewUrlParser: true,
useUnifiedTopology: true,
});
} catch (error) {
console.log('Mongoose connection failed:', error);
process.exit(1);
}

export const app = express();

app.use(express.json());

app.get('/health', (_req, res) => {
res.status(200).json({ status: 'ok' });
});

app.get('/links/:shortId', async (req, res) => {
const link = await LinkModel.findOne({ shortId: req.params.shortId });

if (link === null) {
return res.status(404).json({ success: false, error: 'Record not found' });
}

res.status(200).json({ success: true, data: link.toJSON() });
});

app.post('/links', async (req, res) => {
const isValidTarget = isValidHTTPURL(req.body.target);
if (!isValidTarget) {
return res.status(409).json({ success: false, error: 'Invalid target' });
}

const shortId = nanoid(25);

const existingLink = await LinkModel.findOne({ shortId });
if (existingLink) {
return res
.status(409)
.json({ success: false, error: 'Short Id already exists' });
}

const link = await new LinkModel({
shortId,
target: req.body.target,
}).save();

res.status(201).json({ success: true, data: link.toJSON() });
});

app.get('/:shortId', async (req, res) => {
const link = await LinkModel.findOne({ shortId: req.params.shortId });

if (link === null) {
return res.status(404).json({ success: false, error: 'Record not found' });
}

res.redirect(301, link.target);
});
Loading

0 comments on commit 8ce6303

Please sign in to comment.