node-mongo-demo

node.js and mongodb demo

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

commit 11e37f040ac32834fdaea55e0bba39f9f451f849
parent b040827476552df931ccb5424b90cb86cdafe289
Author: Jul <jul@9o.is>
Date:   Tue, 28 Jan 2025 15:04:49 -0500

add unit tests

Diffstat:
Mbackend/src/api/lucky7-bets.js | 2+-
Mbackend/src/events/lucky7-bets.js | 2+-
Abackend/src/events/lucky7-bets.test.js | 257+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Abackend/src/models/lucky7-bet.test.js | 118+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mbackend/src/models/lucky7-session.js | 2+-
Abackend/src/models/lucky7-session.test.js | 56++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mbackend/src/utils/timer.js | 4++--
Abackend/src/utils/timer.test.js | 83+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mreadme.md | 6++++++
9 files changed, 525 insertions(+), 5 deletions(-)

diff --git a/backend/src/api/lucky7-bets.js b/backend/src/api/lucky7-bets.js @@ -10,7 +10,7 @@ const schema = Joi.object({ const errors = { 'SESSION_EXPIRED': { code: 'SESSION_EXPIRED', error: 'Session has expired' }, - 'SESSION_TIMER': { code: 'SESSION_TIMER', error: 'Less than 5 seconds remaining' }, + 'SESSION_TIMER': { code: 'SESSION_TIMER', error: '5 seconds or less remaining' }, 'SESSION_EXPIRING': { code: 'SESSION_EXPIRING', error: 'Session has no more games' }, }; diff --git a/backend/src/events/lucky7-bets.js b/backend/src/events/lucky7-bets.js @@ -47,7 +47,7 @@ const handleUpdate = data => { const user = userId ? users.get(userId) : undefined; if (bet && user) { - const { lucky, win } = data.updateDescription.updatedFields; + const { lucky } = data.updateDescription.updatedFields; user.stream.writeJson({ operationType: 'update', bet: { diff --git a/backend/src/events/lucky7-bets.test.js b/backend/src/events/lucky7-bets.test.js @@ -0,0 +1,257 @@ +import { subscribe, onChange } from './lucky7-bets.js'; +import mongoose from 'mongoose'; +import { jest } from '@jest/globals'; + +describe('Bet Events', () => { + it('insert', () => { + setTime('2024-01-01T00:00:00Z'); + + const stream = createStream(); + const bet = { + _id: id(), + userId: id(), + lucky: true, + roll: [3,4], + rollAt: new Date('2024-01-01T00:00:15Z'), + }; + + subscribe(bet.userId.toString(), stream); + onChange(createInsertEvent(bet)); + + expect(stream.writeJson).toHaveBeenCalledTimes(1); + expect(stream.writeJson).toHaveBeenCalledWith({ + operationType: 'insert', + bet: { + id: bet._id.toString(), + state: 'pending', + lucky: bet.lucky, + rollAt: bet.rollAt, + roll: undefined, + win: undefined, + }, + }); + }); + + it('update', () => { + setTime('2024-01-01T00:00:00Z'); + + const stream = createStream(); + const bet = { + _id: id(), + userId: id(), + lucky: true, + rollAt: new Date('2024-01-01T00:00:15Z'), + }; + + subscribe(bet.userId.toString(), stream); + onChange(createInsertEvent(bet)); + onChange(createUpdateEvent(bet._id, { lucky: false })); + + expect(stream.writeJson).toHaveBeenCalledTimes(2); + expect(stream.writeJson).toHaveBeenNthCalledWith(2, { + operationType: 'update', + bet: { + id: bet._id.toString(), + state: 'pending', + lucky: false, + }, + }); + }); + + it('finished event', () => { + setTime('2024-01-01T00:00:00Z'); + + const stream = createStream(); + const bet = { + _id: id(), + userId: id(), + lucky: true, + roll: [3,4], + rollAt: new Date('2024-01-01T00:00:15Z'), + }; + + subscribe(bet.userId.toString(), stream); + onChange(createInsertEvent(bet)); + + jest.advanceTimersByTime(14999); + expect(stream.writeJson).toHaveBeenCalledTimes(1); + + jest.advanceTimersByTime(1); + expect(stream.writeJson).toHaveBeenCalledTimes(2); + + expect(stream.writeJson).toHaveBeenNthCalledWith(2, { + operationType: 'update', + bet: { + id: bet._id.toString(), + state: 'finished', + lucky: bet.lucky, + rollAt: bet.rollAt, + roll: bet.roll, + win: true, + }, + }); + }); + + it('ignores other user\' events', () => { + setTime('2024-01-01T00:00:00Z'); + + const stream = createStream(); + const userId = id(); + const bet = { + _id: id(), + userId: id(), + lucky: true, + roll: [3,4], + rollAt: new Date('2024-01-01T00:00:15Z'), + }; + + subscribe(userId.toString(), stream); + onChange(createInsertEvent(bet)); + + expect(stream.writeJson).toHaveBeenCalledTimes(0); + }); + + it('multiple bets', () => { + setTime('2024-01-01T00:00:00Z'); + + const stream = createStream(); + const userId = id(); + + const bet1 = { + _id: id(), + userId, + lucky: true, + roll: [3,4], + rollAt: new Date('2024-01-01T00:00:15Z'), + }; + + const bet2 = { + _id: id(), + userId, + lucky: false, + roll: [5,2], + rollAt: new Date('2024-01-01T00:00:10Z'), + }; + + subscribe(userId.toString(), stream); + onChange(createInsertEvent(bet1)); + onChange(createInsertEvent(bet2)); + + expect(stream.writeJson).toHaveBeenCalledTimes(2); + + expect(stream.writeJson).toHaveBeenCalledWith({ + operationType: 'insert', + bet: { + id: bet1._id.toString(), + state: 'pending', + lucky: bet1.lucky, + rollAt: bet1.rollAt, + roll: undefined, + win: undefined, + }, + }); + + expect(stream.writeJson).toHaveBeenCalledWith({ + operationType: 'insert', + bet: { + id: bet2._id.toString(), + state: 'pending', + lucky: bet2.lucky, + rollAt: bet2.rollAt, + roll: undefined, + win: undefined, + }, + }); + }); + + it('multiple users', () => { + setTime('2024-01-01T00:00:00Z'); + + const user1Id = id(); + const user2Id = id(); + const stream1 = createStream(); + const stream2 = createStream(); + + const bet1 = { + _id: id(), + userId: user1Id, + lucky: true, + roll: [3,4], + rollAt: new Date('2024-01-01T00:00:15Z'), + }; + + const bet2 = { + _id: id(), + userId: user2Id, + lucky: false, + roll: [5,2], + rollAt: new Date('2024-01-01T00:00:10Z'), + }; + + subscribe(user1Id.toString(), stream1); + subscribe(user2Id.toString(), stream2); + + onChange(createInsertEvent(bet2)); + onChange(createInsertEvent(bet1)); + + expect(stream1.writeJson).toHaveBeenCalledTimes(1); + expect(stream1.writeJson).toHaveBeenCalledWith({ + operationType: 'insert', + bet: { + id: bet1._id.toString(), + state: 'pending', + lucky: bet1.lucky, + rollAt: bet1.rollAt, + roll: undefined, + win: undefined, + }, + }); + + expect(stream2.writeJson).toHaveBeenCalledTimes(1); + expect(stream2.writeJson).toHaveBeenCalledWith({ + operationType: 'insert', + bet: { + id: bet2._id.toString(), + state: 'pending', + lucky: bet2.lucky, + rollAt: bet2.rollAt, + roll: undefined, + win: undefined, + }, + }); + }); +}); + +function createInsertEvent(fullDocument) { + return { + operationType: 'insert', + fullDocument, + }; +} + +function createUpdateEvent(_id, updatedFields) { + return { + operationType: 'update', + documentKey: { + _id, + }, + updateDescription: { + updatedFields, + }, + }; +} + +function createStream() { + return { + writeJson: jest.fn(), + onClose: () => {}, + }; +} + +function id(x) { + return new mongoose.Types.ObjectId(x); +} + +function setTime(dt) { + jest.useFakeTimers().setSystemTime(new Date(dt)); +} diff --git a/backend/src/models/lucky7-bet.test.js b/backend/src/models/lucky7-bet.test.js @@ -0,0 +1,118 @@ +import Lucky7Bet from './lucky7-bet.js'; +import { jest } from '@jest/globals'; + +describe('Lucky7Bet Model', () => { + it('state pending', () => { + setTime('2024-01-01T00:00:00Z'); + + const bet = new Lucky7Bet({ + rollAt: new Date('2024-01-01T00:00:01Z'), + }); + + expect(bet.state).toBe('pending'); + }); + + it('state finished', () => { + setTime('2024-01-01T00:00:01Z'); + + const bet = new Lucky7Bet({ + rollAt: new Date('2024-01-01T00:00:01Z'), + }); + + expect(bet.state).toBe('finished'); + }); + + it('won lucky', () => { + const bet = new Lucky7Bet({ + roll: [3,4], + lucky: true, + }); + + expect(bet.win).toBe(true); + }); + + it('won unlucky', () => { + const bet = new Lucky7Bet({ + roll: [1,1], + lucky: false, + }); + + expect(bet.win).toBe(true); + }); + + it('lost lucky', () => { + const bet = new Lucky7Bet({ + roll: [1,1], + lucky: true, + }); + + expect(bet.win).toBe(false); + }); + + it('lost unlucky', () => { + const bet = new Lucky7Bet({ + roll: [3,4], + lucky: false, + }); + + expect(bet.win).toBe(false); + }); + + it('played() false', () => { + const bet = new Lucky7Bet(); + expect(bet.played()).toBe(false); + }); + + it('played() true', () => { + const bet = new Lucky7Bet(); + bet.play(); + expect(bet.played()).toBe(true); + }); + + it('toRedactedObject() pending', () => { + setTime('2024-01-01T00:00:00Z'); + + const bet = new Lucky7Bet(createProps({ + rollAt: new Date('2024-01-01T01:00:00Z'), + })); + const obj = bet.toRedactedObject(); + + expect(Object.keys(obj).length).toBe(6); + expect(obj.id).toBeDefined(); + expect(obj.rollAt).toBeDefined(); + expect(obj.lucky).toBeDefined(); + expect(obj.state).toBeDefined(); + expect(obj.roll).toBeUndefined(); + expect(obj.win).toBeUndefined(); + }); + + it('toRedactedObject() finished', () => { + setTime('2024-01-01T00:00:00Z'); + + const bet = new Lucky7Bet(createProps({ + rollAt: new Date('2024-01-01T00:00:00Z'), + })); + const obj = bet.toRedactedObject(); + + expect(Object.keys(obj)).toHaveLength(6); + expect(obj.id).toBeDefined(); + expect(obj.rollAt).toBeDefined(); + expect(obj.lucky).toBeDefined(); + expect(obj.state).toBeDefined(); + expect(obj.roll).toBeDefined(); + expect(obj.win).toBeDefined(); + }); +}); + +function createProps(props) { + return { + rollAt: new Date('2024-01-01T00:00:00Z'), + lucky: true, + roll: [3,4], + ...props, + }; +} + +function setTime(dt) { + jest.useFakeTimers().setSystemTime(new Date(dt)); +} diff --git a/backend/src/models/lucky7-session.js b/backend/src/models/lucky7-session.js @@ -28,7 +28,7 @@ const lucky7SessionSchema = mongoose.Schema({ }, isPlayable() { const { nextIntervalAt, nextIntervalInMillis } = timer(this); - return nextIntervalInMillis >= GAME_UNPLAYABLE_IN_SECONDS * 1000; + return nextIntervalInMillis > GAME_UNPLAYABLE_IN_SECONDS * 1000; }, }, statics: { diff --git a/backend/src/models/lucky7-session.test.js b/backend/src/models/lucky7-session.test.js @@ -0,0 +1,56 @@ +import Lucky7Session from './lucky7-session.js'; +import { jest } from '@jest/globals'; + +describe('Lucky7Session Model', () => { + it('game intervals are set to 15 seconds', () => { + const session = new Lucky7Session(); + expect(session.gameIntervalInSeconds).toBe(15); + }); + + it('expiration set to one day', () => { + const date1 = new Date('2024-01-01T00:00:00Z'); + const date2 = new Date('2024-01-02T00:00:00Z'); + const session = new Lucky7Session({ + createdAt: date1, + }); + + const expiration = String(session.expiresAt); + expect(expiration).toBe(String(date2)); + }); + + it('nextIntervalAt()', () => { + setTime('2024-01-01T00:00:01Z'); + + const session = new Lucky7Session({ + createdAt: '2024-01-01T00:00:00Z', + }); + + const actual = session.nextIntervalAt().toISOString(); + expect(actual).toBe('2024-01-01T00:00:15.000Z'); + }); + + it('isPlayable() false', () => { + setTime('2024-01-01T00:00:10Z'); + + const session = new Lucky7Session({ + createdAt: '2024-01-01T00:00:00Z', + }); + + expect(session.isPlayable()).toBe(false); + }); + + it('isPlayable() true', () => { + setTime('2024-01-01T00:00:09.999Z'); + + const session = new Lucky7Session({ + createdAt: '2024-01-01T00:00:00Z', + }); + + expect(session.isPlayable()).toBe(true); + }); +}); + +function setTime(dt) { + jest.useFakeTimers().setSystemTime(new Date(dt)); +} + diff --git a/backend/src/utils/timer.js b/backend/src/utils/timer.js @@ -6,9 +6,9 @@ const nextInterval = (start, interval) => { }; const timer = session => { - const { createdAt, gameIntervalInSeconds } = session + const { createdAt, gameIntervalInSeconds } = session; const nextIntervalAt = nextInterval(createdAt, gameIntervalInSeconds); - const expiring = nextIntervalAt > session.expiresAt; + const expiring = nextIntervalAt >= session.expiresAt; return expiring ? {} : { nextIntervalAt, diff --git a/backend/src/utils/timer.test.js b/backend/src/utils/timer.test.js @@ -0,0 +1,83 @@ +import timer from './timer.js'; +import { jest } from '@jest/globals'; + +describe('Timer', () => { + it('nextIntervalAt before interval', () => { + setTime('2024-01-01T00:00:14.999Z'); + + const { nextIntervalAt } = timer({ + createdAt: new Date('2024-01-01T00:00:00Z'), + expiresAt: new Date('2024-01-01T01:00:00Z'), + gameIntervalInSeconds: 15, + }); + + const actual = nextIntervalAt.toISOString(); + expect(actual).toBe('2024-01-01T00:00:15.000Z'); + }); + + it('nextIntervalAt at interval', () => { + setTime('2024-01-01T00:00:15.000Z'); + + const { nextIntervalAt } = timer({ + createdAt: new Date('2024-01-01T00:00:00Z'), + expiresAt: new Date('2024-01-01T01:00:00Z'), + gameIntervalInSeconds: 15, + }); + + const actual = nextIntervalAt.toISOString(); + expect(actual).toBe('2024-01-01T00:00:15.000Z'); + }); + + it('nextIntervalAt after interval', () => { + setTime('2024-01-01T00:00:15.001Z'); + + const { nextIntervalAt } = timer({ + createdAt: new Date('2024-01-01T00:00:00Z'), + expiresAt: new Date('2024-01-01T01:00:00Z'), + gameIntervalInSeconds: 15, + }); + + const actual = nextIntervalAt.toISOString(); + expect(actual).toBe('2024-01-01T00:00:30.000Z'); + }); + + it('nextIntervalInMillis', () => { + setTime('2024-01-01T00:00:14.997Z'); + + const { nextIntervalInMillis } = timer({ + createdAt: new Date('2024-01-01T00:00:00Z'), + expiresAt: new Date('2024-01-01T01:00:00Z'), + gameIntervalInSeconds: 15, + }); + + expect(nextIntervalInMillis).toBe(3); + }); + + it('non-expiring session', () => { + setTime('2024-01-01T00:59:45.000Z'); + + const { nextIntervalAt } = timer({ + createdAt: new Date('2024-01-01T00:00:00Z'), + expiresAt: new Date('2024-01-01T01:00:00Z'), + gameIntervalInSeconds: 15, + }); + + expect(nextIntervalAt).toBeDefined(); + }); + + it('expiring session', () => { + setTime('2024-01-01T00:59:45.001Z'); + + const { nextIntervalAt } = timer({ + createdAt: new Date('2024-01-01T00:00:00Z'), + expiresAt: new Date('2024-01-01T01:00:00Z'), + gameIntervalInSeconds: 15, + }); + + expect(nextIntervalAt).toBeUndefined(); + }); +}); + +function setTime(dt) { + jest.useFakeTimers().setSystemTime(new Date(dt)); +} diff --git a/readme.md b/readme.md @@ -48,6 +48,12 @@ users and bets to inspect the leaderboard endpoint. MONGODB_URL="<INPUT URL>" node scripts/populate_database.js ``` +To run unit tests + +```bash +npm run test +``` + --- ## Frontend