node-mongo-demo

node.js and mongodb demo

git clone https://9o.is/git/node-mongo-demo.git

commit 83c27d56cd041a9543dd30bb1e1c6e4cac64503e
parent 11e37f040ac32834fdaea55e0bba39f9f451f849
Author: Jul <jul@9o.is>
Date:   Tue, 28 Jan 2025 20:59:32 -0500

add integration tests

Diffstat:
Abackend/jest.int.config.json | 7+++++++
Abackend/jest/globalSetup.js | 24++++++++++++++++++++++++
Abackend/jest/globalTeardown.js | 11+++++++++++
Abackend/jest/setupFile.js | 9+++++++++
Abackend/jest/utils/config.js | 6++++++
Mbackend/package-lock.json | 143++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
Mbackend/package.json | 6++++--
Mbackend/src/api/lucky7-bets.js | 20++++++++++----------
Abackend/src/api/lucky7.int.test.js | 126+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mbackend/src/api/lucky7.js | 5+++--
Rbackend/src/events/lucky7-bets.test.js -> backend/src/events/lucky7-bets.unit.test.js | 0
Rbackend/src/models/lucky7-bet.test.js -> backend/src/models/lucky7-bet.unit.test.js | 0
Rbackend/src/models/lucky7-session.test.js -> backend/src/models/lucky7-session.unit.test.js | 0
Rbackend/src/utils/timer.test.js -> backend/src/utils/timer.unit.test.js | 0
Mfrontend/src/components/Lucky7.js | 2+-
Mreadme.md | 5++++-
16 files changed, 347 insertions(+), 17 deletions(-)

diff --git a/backend/jest.int.config.json b/backend/jest.int.config.json @@ -0,0 +1,7 @@ +{ + "globalSetup": "<rootDir>/jest/globalSetup.js", + "globalTeardown": "<rootDir>/jest/globalTeardown.js", + "setupFilesAfterEnv": [ + "<rootDir>/jest/setupFile.js" + ] +} diff --git a/backend/jest/globalSetup.js b/backend/jest/globalSetup.js @@ -0,0 +1,24 @@ +import { MongoMemoryReplSet } from 'mongodb-memory-server'; +import * as mongoose from 'mongoose'; +import { config } from './utils/config.js'; + +async function globalSetup() { + if (config.Memory) { // Config to decide if an mongodb-memory-server instance should be used + // it's needed in global space, because we don't want to create a new instance every test-suite + const instance = await MongoMemoryReplSet.create({ + replSet: { count: 3 }, + }); + const uri = instance.getUri(); + global.__MONGOINSTANCE = instance; + process.env.MONGO_URI = uri.slice(0, uri.lastIndexOf('/')); + } else { + process.env.MONGO_URI = `mongodb://${config.IP}:${config.Port}`; + } + + // The following is to make sure the database is clean before a test suite starts + const conn = await mongoose.connect(`${process.env.MONGO_URI}/${config.Database}`); + await conn.connection.db.dropDatabase(); + await mongoose.disconnect(); +}; + +export default globalSetup; diff --git a/backend/jest/globalTeardown.js b/backend/jest/globalTeardown.js @@ -0,0 +1,11 @@ +import { MongoMemoryServer } from 'mongodb-memory-server'; +import { config } from './utils/config.js'; + +async function globalTeardown() { + if (config.Memory) { // Config to decide if an mongodb-memory-server instance should be used + const instance = global.__MONGOINSTANCE; + await instance.stop(); + } +} + +export default globalTeardown; diff --git a/backend/jest/setupFile.js b/backend/jest/setupFile.js @@ -0,0 +1,9 @@ +import mongoose from 'mongoose'; + +beforeAll(async () => { + await mongoose.connect(process.env['MONGO_URI']); +}); + +afterAll(async () => { + await mongoose.disconnect(); +}); diff --git a/backend/jest/utils/config.js b/backend/jest/utils/config.js @@ -0,0 +1,6 @@ +export const config = { + Memory: true, + IP: '127.0.0.1', + Port: '27017', + Database: 'test', +}; diff --git a/backend/package-lock.json b/backend/package-lock.json @@ -21,7 +21,8 @@ }, "devDependencies": { "jest": "^29.7.0", - "mongodb-memory-server": "^10.1.3" + "mongodb-memory-server": "^10.1.3", + "supertest": "^7.0.0" } }, "node_modules/@ampproject/remapping": { @@ -1279,6 +1280,13 @@ "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==" }, + "node_modules/asap": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/asap/-/asap-2.0.6.tgz", + "integrity": "sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==", + "dev": true, + "license": "MIT" + }, "node_modules/async-mutex": { "version": "0.5.0", "resolved": "https://registry.npmjs.org/async-mutex/-/async-mutex-0.5.0.tgz", @@ -1779,6 +1787,16 @@ "dev": true, "license": "MIT" }, + "node_modules/component-emitter": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/component-emitter/-/component-emitter-1.3.1.tgz", + "integrity": "sha512-T0+barUSQRTUQASh8bx02dl+DhF54GtIDY13Y3m9oWTklKbb3Wv974meRpeZ3lp1JpLVECWWNHC4vaG2XHXouQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/concat-map": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", @@ -1826,6 +1844,13 @@ "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz", "integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==" }, + "node_modules/cookiejar": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/cookiejar/-/cookiejar-2.1.4.tgz", + "integrity": "sha512-LDx6oHrK+PhzLKJU9j5S7/Y3jM/mUHvD/DeI1WQmJn652iPC5Y4TBzC9l+5OMOXlyTTA+SmVUPm0HQUwpD5Jqw==", + "dev": true, + "license": "MIT" + }, "node_modules/cors": { "version": "2.8.5", "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.5.tgz", @@ -1943,6 +1968,17 @@ "node": ">=8" } }, + "node_modules/dezalgo": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/dezalgo/-/dezalgo-1.0.4.tgz", + "integrity": "sha512-rXSP0bf+5n0Qonsb+SVVfNfIsimO4HEtmnIpPHY8Q1UCzKlQrDMfdobr8nJOOsRgWCyMRqeSBQzmWUMq7zvVig==", + "dev": true, + "license": "ISC", + "dependencies": { + "asap": "^2.0.0", + "wrappy": "1" + } + }, "node_modules/diff-sequences": { "version": "29.6.3", "resolved": "https://registry.npmjs.org/diff-sequences/-/diff-sequences-29.6.3.tgz", @@ -2226,6 +2262,13 @@ "dev": true, "license": "MIT" }, + "node_modules/fast-safe-stringify": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/fast-safe-stringify/-/fast-safe-stringify-2.1.1.tgz", + "integrity": "sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==", + "dev": true, + "license": "MIT" + }, "node_modules/fb-watchman": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/fb-watchman/-/fb-watchman-2.0.2.tgz", @@ -2358,6 +2401,21 @@ "node": ">= 6" } }, + "node_modules/formidable": { + "version": "3.5.2", + "resolved": "https://registry.npmjs.org/formidable/-/formidable-3.5.2.tgz", + "integrity": "sha512-Jqc1btCy3QzRbJaICGwKcBfGWuLADRerLzDqi2NwSt/UkXLsHJw2TVResiaoBufHVHy9aSgClOHCeJsSsFLTbg==", + "dev": true, + "license": "MIT", + "dependencies": { + "dezalgo": "^1.0.4", + "hexoid": "^2.0.0", + "once": "^1.4.0" + }, + "funding": { + "url": "https://ko-fi.com/tunnckoCore/commissions" + } + }, "node_modules/forwarded": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", @@ -2571,6 +2629,16 @@ "node": ">= 0.4" } }, + "node_modules/hexoid": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/hexoid/-/hexoid-2.0.0.tgz", + "integrity": "sha512-qlspKUK7IlSQv2o+5I7yhUd7TxlOG2Vr5LTa3ve2XSNVKAL/n/u/7KLvKmFNimomDIKvZFXWHv0T12mv7rT8Aw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/html-escaper": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", @@ -4894,6 +4962,79 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/superagent": { + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/superagent/-/superagent-9.0.2.tgz", + "integrity": "sha512-xuW7dzkUpcJq7QnhOsnNUgtYp3xRwpt2F7abdRYIpCsAt0hhUqia0EdxyXZQQpNmGtsCzYHryaKSV3q3GJnq7w==", + "dev": true, + "license": "MIT", + "dependencies": { + "component-emitter": "^1.3.0", + "cookiejar": "^2.1.4", + "debug": "^4.3.4", + "fast-safe-stringify": "^2.1.1", + "form-data": "^4.0.0", + "formidable": "^3.5.1", + "methods": "^1.1.2", + "mime": "2.6.0", + "qs": "^6.11.0" + }, + "engines": { + "node": ">=14.18.0" + } + }, + "node_modules/superagent/node_modules/debug": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.0.tgz", + "integrity": "sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/superagent/node_modules/mime": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-2.6.0.tgz", + "integrity": "sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg==", + "dev": true, + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/superagent/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/supertest": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/supertest/-/supertest-7.0.0.tgz", + "integrity": "sha512-qlsr7fIC0lSddmA3tzojvzubYxvlGtzumcdHgPwbFWMISQwL22MhM2Y3LNt+6w9Yyx7559VW5ab70dgphm8qQA==", + "dev": true, + "license": "MIT", + "dependencies": { + "methods": "^1.1.2", + "superagent": "^9.0.1" + }, + "engines": { + "node": ">=14.18.0" + } + }, "node_modules/supports-color": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", diff --git a/backend/package.json b/backend/package.json @@ -7,7 +7,8 @@ "scripts": { "start": "node index.js", "db": "node scripts/mongodb-development.js", - "test": "NODE_OPTIONS=\"$NODE_OPTIONS --experimental-vm-modules\" jest", + "test": "NODE_OPTIONS=\"$NODE_OPTIONS --experimental-vm-modules\" jest unit", + "test:integration": "NODE_OPTIONS=\"$NODE_OPTIONS --experimental-vm-modules\" jest int --config=jest.int.config.json", "repl": "node" }, "author": "Pidwin LLC", @@ -25,6 +26,7 @@ }, "devDependencies": { "jest": "^29.7.0", - "mongodb-memory-server": "^10.1.3" + "mongodb-memory-server": "^10.1.3", + "supertest": "^7.0.0" } } diff --git a/backend/src/api/lucky7-bets.js b/backend/src/api/lucky7-bets.js @@ -14,7 +14,10 @@ const errors = { 'SESSION_EXPIRING': { code: 'SESSION_EXPIRING', error: 'Session has no more games' }, }; -Lucky7Bet.watch().on('change', betEvents.onChange); +export let changeStream; +export const subscribeEvents = () => { + changeStream = Lucky7Bet.watch().on('change', betEvents.onChange); +}; export const getEvents = async (req, res) => { const { userId } = req; @@ -24,10 +27,7 @@ export const getEvents = async (req, res) => { export const create = async (req, res) => { const { userId } = req; - const { - value: { lucky }, - error - } = schema.validate(req.body); + const { value, error } = schema.validate(req.body); if (error) { return res.status(400).json({ error }) @@ -37,19 +37,19 @@ export const create = async (req, res) => { const session = await Lucky7Session.findByUserId(userId); const rollAt = session?.nextIntervalAt(); - if (!session) return res.status(400).json(errors['SESSION_EXPIRED']); - if (!session.isPlayable()) return res.status(400).json(errors['SESSION_TIMER']); - if (!session.nextIntervalAt()) return res.status(400).json(errors['SESSION_EXPIRING']); + if (!session) return res.status(409).json(errors['SESSION_EXPIRED']); + if (!session.nextIntervalAt()) return res.status(409).json(errors['SESSION_EXPIRING']); + if (!session.isPlayable()) return res.status(409).json(errors['SESSION_TIMER']); const bet = await Lucky7Bet.findOne({ userId, rollAt }) || new Lucky7Bet({ userId, rollAt }); - bet.lucky = lucky; + bet.lucky = value.lucky; if (!bet.played()) { bet.play(); } await bet.save(); - res.status(200).json(bet.toRedactedObject()); + res.status(201).json(bet.toRedactedObject()); } catch (error) { console.error(error); res.status(500).json({ message: 'Something went wrong' }); diff --git a/backend/src/api/lucky7.int.test.js b/backend/src/api/lucky7.int.test.js @@ -0,0 +1,126 @@ +import express from 'express'; +import bodyParser from "body-parser"; +import jwt from 'jsonwebtoken'; +import request from 'supertest'; +import { jest } from '@jest/globals'; +import lucky7 from './lucky7.js'; +import { changeStream } from './lucky7-bets.js'; + +describe('Lucky7', () => { + let app; + + beforeAll(() => { + app = express(); + app.use(bodyParser.json()); + app.use(lucky7); + }); + + describe('POST /sessions', () => { + let sessionId; + + const post = () => + auth(request(app).post('/sessions')); + + it('creates', async () => { + const res = await post(); + + expect(res.status).toBe(201); + expect(res.body).toHaveProperty('id'); + expect(res.body).toHaveProperty('createdAt'); + + sessionId = res.body.id; + }); + + it('fetches existing session', async () => { + const res = await post(); + + expect(res.status).toBe(201); + expect(res.body.id).toBe(sessionId); + }); + }); + + describe('POST /bets', () => { + let betId; + + const post = body => + auth(request(app).post('/bets').send(body)); + + beforeAll(() => { + jest.useFakeTimers({ doNotFake: ['nextTick'] }); + }); + + it('creates', async () => { + const res = await post({ lucky: true }); + + expect(res.status).toBe(201); + expect(res.body).toHaveProperty('id'); + expect(res.body).toHaveProperty('state'); + expect(res.body).toHaveProperty('rollAt'); + expect(res.body).toHaveProperty('lucky'); + expect(res.body).not.toHaveProperty('roll'); + expect(res.body).not.toHaveProperty('win'); + expect(res.body.lucky).toBe(true); + + betId = res.body.id; + }); + + it('updates existing bet', async () => { + const res = await post({ lucky: false }); + + expect(res.status).toBe(201); + expect(res.body.id).toBe(betId); + expect(res.body.lucky).toBe(false); + }); + + it('errors on invalid input', async () => { + const res = await post({}); + + expect(res.status).toBe(400); + expect(res.body.error.details[0].message).toBe( + '"lucky" is required', + ); + }); + + it('errors on unplayable session', async () => { + // advance 10 seconds + jest.advanceTimersByTime(10 * 1000); + + const res = await post({ lucky: true }); + + expect(res.status).toBe(409); + expect(res.body.code).toBe('SESSION_TIMER'); + }); + + it('errors on expiring session', async () => { + // advance almost 1 day + jest.advanceTimersByTime(86390 * 1000); + + const res = await post({ lucky: true }); + + expect(res.status).toBe(409); + expect(res.body.code).toBe('SESSION_EXPIRING'); + }); + + }); + + it('GET /leaderboard/streaks', await () => { + const res = await request(app).get('/leaderboard/streaks'); + + expect(res.status).toBe(200); + expect(Array.isArray(res.body)).toBe(true); + }); + + afterAll(async () => { + await changeStream.close(); + }); +}); + +function auth(request) { + const token = jwt.sign( + { _id: '6799219573e6c41d3aaece71' }, + 'test', + { expiresIn: '1h' } + ); + + return request.set('Authorization', `Bearer ${token}`); +} diff --git a/backend/src/api/lucky7.js b/backend/src/api/lucky7.js @@ -4,12 +4,13 @@ import * as lucky7Sessions from './lucky7-sessions.js'; import * as lucky7Bets from './lucky7-bets.js'; import * as lucky7Leaderboard from './lucky7-leaderboard.js'; -const router = express.Router(); +const router = express(); router.post('/sessions', auth, lucky7Sessions.create); -router.get('/bets/events', auth, lucky7Bets.getEvents); router.post('/bets', auth, lucky7Bets.create); +router.get('/bets/events', auth, lucky7Bets.getEvents); +router.on('mount', lucky7Bets.subscribeEvents); router.get('/leaderboard/streaks', lucky7Leaderboard.getStreaks); diff --git a/backend/src/events/lucky7-bets.test.js b/backend/src/events/lucky7-bets.unit.test.js diff --git a/backend/src/models/lucky7-bet.test.js b/backend/src/models/lucky7-bet.unit.test.js diff --git a/backend/src/models/lucky7-session.test.js b/backend/src/models/lucky7-session.unit.test.js diff --git a/backend/src/utils/timer.test.js b/backend/src/utils/timer.unit.test.js diff --git a/frontend/src/components/Lucky7.js b/frontend/src/components/Lucky7.js @@ -113,7 +113,7 @@ const Lucky7 = user => { await api.lucky7Bet(lucky); setError(undefined); } catch (error) { - if (error.status === 400 && error.response.data.code === 'SESSION_TIMER') { + if (error.status === 409 && error.response.data.code === 'SESSION_TIMER') { setError(error.response.data.error); } else { console.error(error) diff --git a/readme.md b/readme.md @@ -48,12 +48,15 @@ users and bets to inspect the leaderboard endpoint. MONGODB_URL="<INPUT URL>" node scripts/populate_database.js ``` -To run unit tests +To run unit and integrations tests: ```bash npm run test +npm run test:integration ``` +Note, the app was tested with Node v20.18.1 + --- ## Frontend