-
Notifications
You must be signed in to change notification settings - Fork 0
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Merge pull request #1 from anthonyhastings/testcontainers
adding integration tests with supertest and testcontainers
- Loading branch information
Showing
9 changed files
with
2,967 additions
and
111 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,5 @@ | ||
export default { | ||
testEnvironment: 'node', | ||
testTimeout: 20000, | ||
verbose: true, | ||
}; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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', | ||
}); | ||
}); | ||
}); | ||
}); | ||
}); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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); | ||
}); |
Oops, something went wrong.