diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 3c04866..f8670ce 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -9,6 +9,20 @@ on: jobs: test: + services: + mongo: + image: mongo:6.0.20 + ports: + - 27017:27017 + # health check + # ref: https://engineering.synatic.com/a-simple-way-to-run-a-mongodb-replica-set-in-github-actions + options: >- + --health-cmd "mongosh --eval 'db.runCommand(\"ping\")'" + --health-interval 10s + --health-timeout 5s + --health-retries 5 + env: + MONGODB_URI: mongodb://localhost:27017 strategy: matrix: directory: [ "./backend" ] diff --git a/backend/.env.example b/backend/.env.example new file mode 100644 index 0000000..95ed253 --- /dev/null +++ b/backend/.env.example @@ -0,0 +1 @@ +MONGODB_URI=mongodb://localhost:27017 \ No newline at end of file diff --git a/backend/README.md b/backend/README.md new file mode 100644 index 0000000..87667aa --- /dev/null +++ b/backend/README.md @@ -0,0 +1,17 @@ +# Backend +## Run at Local +### Setup Infrastructure +```bash +cp .env.example .env # modify .env as needed +docker run --name mongodb -p 27017:27017 -d mongo:6.0.20 +``` + +### Run server +```bash +npm run start +``` + +### Run tests +```bash +npm run test +``` diff --git a/backend/jest.config.js b/backend/jest.config.js index e39f8f1..a96cab4 100644 --- a/backend/jest.config.js +++ b/backend/jest.config.js @@ -8,5 +8,6 @@ module.exports = { transform: { "^.+.tsx?$": ["ts-jest",{}], }, + setupFiles: ["dotenv/config"] }; \ No newline at end of file diff --git a/backend/package-lock.json b/backend/package-lock.json index 714221c..f448b34 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -12,8 +12,10 @@ "@types/body-parser": "^1.19.5", "@types/express": "^5.0.0", "@types/node": "^22.10.7", + "dotenv": "^16.4.7", "express": "^4.21.2", "mathjs": "^14.0.1", + "mongodb": "^6.12.0", "pino": "^9.6.0", "socket.io": "^4.8.1" }, @@ -1245,6 +1247,15 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@mongodb-js/saslprep": { + "version": "1.1.9", + "resolved": "https://registry.npmjs.org/@mongodb-js/saslprep/-/saslprep-1.1.9.tgz", + "integrity": "sha512-tVkljjeEaAhCqTzajSdgbQ6gE6f3oneVwa3iXR6csiEwXXOFsiC6Uh9iAjAhXPtqa/XMDHWjjeNH/77m/Yq2dw==", + "license": "MIT", + "dependencies": { + "sparse-bitfield": "^3.0.3" + } + }, "node_modules/@nodelib/fs.scandir": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", @@ -1570,6 +1581,21 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/webidl-conversions": { + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/@types/webidl-conversions/-/webidl-conversions-7.0.3.tgz", + "integrity": "sha512-CiJJvcRtIgzadHCYXw7dqEnMNRjhGZlYK05Mj9OyktqV8uVT8fD2BFOB7S1uwBE3Kj2Z+4UyPmFw/Ixgw/LAlA==", + "license": "MIT" + }, + "node_modules/@types/whatwg-url": { + "version": "11.0.5", + "resolved": "https://registry.npmjs.org/@types/whatwg-url/-/whatwg-url-11.0.5.tgz", + "integrity": "sha512-coYR071JRaHa+xoEvvYqvnIHaVqaYrLPbsufM9BF63HkwI5Lgmy2QR8Q5K/lYDYo5AK82wOvSOS0UsLTpTG7uQ==", + "license": "MIT", + "dependencies": { + "@types/webidl-conversions": "*" + } + }, "node_modules/@types/yargs": { "version": "17.0.33", "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.33.tgz", @@ -2367,6 +2393,15 @@ "node-int64": "^0.4.0" } }, + "node_modules/bson": { + "version": "6.10.1", + "resolved": "https://registry.npmjs.org/bson/-/bson-6.10.1.tgz", + "integrity": "sha512-P92xmHDQjSKPLHqFxefqMxASNq/aWJMEZugpCjf+AF/pgcUpMMQCg7t7+ewko0/u8AapvF3luf/FoehddEK+sA==", + "license": "Apache-2.0", + "engines": { + "node": ">=16.20.1" + } + }, "node_modules/buffer-from": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", @@ -2903,6 +2938,18 @@ "node": ">=0.10.0" } }, + "node_modules/dotenv": { + "version": "16.4.7", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.4.7.tgz", + "integrity": "sha512-47qPchRCykZC03FhkYAhrvwU4xDBFIj1QPqaarj6mdM/hgUzfPHcpkHJOn3mJAufFeeAxAzeGsr5X0M4k6fLZQ==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, "node_modules/dunder-proto": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", @@ -5759,6 +5806,12 @@ "node": ">= 0.6" } }, + "node_modules/memory-pager": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/memory-pager/-/memory-pager-1.5.0.tgz", + "integrity": "sha512-ZS4Bp4r/Zoeq6+NLJpP+0Zzm0pR8whtGPf1XExKLJBAczGMnSi3It14OiNCStjQjM6NU1okjQGSxgEZN8eBYKg==", + "license": "MIT" + }, "node_modules/merge-descriptors": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz", @@ -5864,6 +5917,62 @@ "node": "*" } }, + "node_modules/mongodb": { + "version": "6.12.0", + "resolved": "https://registry.npmjs.org/mongodb/-/mongodb-6.12.0.tgz", + "integrity": "sha512-RM7AHlvYfS7jv7+BXund/kR64DryVI+cHbVAy9P61fnb1RcWZqOW1/Wj2YhqMCx+MuYhqTRGv7AwHBzmsCKBfA==", + "license": "Apache-2.0", + "dependencies": { + "@mongodb-js/saslprep": "^1.1.9", + "bson": "^6.10.1", + "mongodb-connection-string-url": "^3.0.0" + }, + "engines": { + "node": ">=16.20.1" + }, + "peerDependencies": { + "@aws-sdk/credential-providers": "^3.188.0", + "@mongodb-js/zstd": "^1.1.0 || ^2.0.0", + "gcp-metadata": "^5.2.0", + "kerberos": "^2.0.1", + "mongodb-client-encryption": ">=6.0.0 <7", + "snappy": "^7.2.2", + "socks": "^2.7.1" + }, + "peerDependenciesMeta": { + "@aws-sdk/credential-providers": { + "optional": true + }, + "@mongodb-js/zstd": { + "optional": true + }, + "gcp-metadata": { + "optional": true + }, + "kerberos": { + "optional": true + }, + "mongodb-client-encryption": { + "optional": true + }, + "snappy": { + "optional": true + }, + "socks": { + "optional": true + } + } + }, + "node_modules/mongodb-connection-string-url": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mongodb-connection-string-url/-/mongodb-connection-string-url-3.0.2.tgz", + "integrity": "sha512-rMO7CGo/9BFwyZABcKAWL8UJwH/Kc2x0g72uhDWzG48URRax5TCIcJ7Rc3RZqffZzO/Gwff/jyKwCU9TN8gehA==", + "license": "Apache-2.0", + "dependencies": { + "@types/whatwg-url": "^11.0.2", + "whatwg-url": "^14.1.0 || ^13.0.0" + } + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -6487,7 +6596,6 @@ "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", - "dev": true, "license": "MIT", "engines": { "node": ">=6" @@ -7213,6 +7321,15 @@ "source-map": "^0.6.0" } }, + "node_modules/sparse-bitfield": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/sparse-bitfield/-/sparse-bitfield-3.0.3.tgz", + "integrity": "sha512-kvzhi7vqKTfkh0PZU+2D2PIllw2ymqJKujUcyPMd9Y75Nv4nPbGJZXNhxsgdQab2BmlDct1YnfQCguEvHr7VsQ==", + "license": "MIT", + "dependencies": { + "memory-pager": "^1.0.2" + } + }, "node_modules/split2": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", @@ -7519,6 +7636,18 @@ "node": ">=0.6" } }, + "node_modules/tr46": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-5.0.0.tgz", + "integrity": "sha512-tk2G5R2KRwBd+ZN0zaEXpmzdKyOYksXwywulIX95MBODjSzMIuQnQ3m8JxgbhnL1LeVo7lqQKsYa1O3Htl7K5g==", + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/ts-api-utils": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.0.0.tgz", @@ -7936,6 +8065,28 @@ "makeerror": "1.0.12" } }, + "node_modules/webidl-conversions": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz", + "integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/whatwg-url": { + "version": "14.1.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-14.1.0.tgz", + "integrity": "sha512-jlf/foYIKywAt3x/XWKZ/3rz8OSJPiWktjmk891alJUEjiVxKX9LEO92qH3hv4aJ0mN3MWPvGMCy8jQi95xK4w==", + "license": "MIT", + "dependencies": { + "tr46": "^5.0.0", + "webidl-conversions": "^7.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", diff --git a/backend/package.json b/backend/package.json index 619df5c..2bc4cb3 100644 --- a/backend/package.json +++ b/backend/package.json @@ -15,8 +15,10 @@ "@types/body-parser": "^1.19.5", "@types/express": "^5.0.0", "@types/node": "^22.10.7", + "dotenv": "^16.4.7", "express": "^4.21.2", "mathjs": "^14.0.1", + "mongodb": "^6.12.0", "pino": "^9.6.0", "socket.io": "^4.8.1" }, diff --git a/backend/src/command-service.spec.ts b/backend/src/command-service.spec.ts index 9371c02..a9fda6f 100644 --- a/backend/src/command-service.spec.ts +++ b/backend/src/command-service.spec.ts @@ -6,8 +6,15 @@ const mockRepository = { getLatest: jest.fn(), }; +type TestCase = { + command: string; + expected?: string; + hasError?: boolean; +}; + describe('evaluate', () => { - describe.each([ + const logger = pino(); + describe.each([ { command: '', hasError: true }, { command: ' ', hasError: true }, { command: '1', expected: '1' }, @@ -30,13 +37,14 @@ describe('evaluate', () => { { command: '5 * 7 / 3 * 3', expected: '35' }, ])('.evaluate($command)', ({ command, hasError, expected }) => { - test(`returns ${hasError}`, () => { + test(`returns ${hasError}`, async () => { const clientId = 'whatever'; - const commandService = new CommandService(pino(), mockRepository); + const commandService = new CommandService(logger, mockRepository); if (hasError) { - expect(() => commandService.evaluateAndSave(clientId, command)).toThrow('Invalid command'); + await expect(commandService.evaluateAndSave(clientId, command)).rejects.toThrow('Invalid command'); } else { - expect(commandService.evaluateAndSave(clientId, command)).toBe(expected); + const res = await commandService.evaluateAndSave(clientId, command); + expect(res).toBe(expected); expect(mockRepository.saveCommand).toHaveBeenCalledWith(clientId, command, expected); } }); diff --git a/backend/src/command-service.ts b/backend/src/command-service.ts index 6bb0f45..d083c0a 100644 --- a/backend/src/command-service.ts +++ b/backend/src/command-service.ts @@ -1,13 +1,7 @@ import { Logger } from "pino"; import { evaluate as evaluateMathjs } from 'mathjs'; import { CommandAndResult } from "./entities/command-result.entity"; - -interface IRepository { - saveCommand(clientId: string, command: string, result: string): void; - getLatest(clientId: string, count: number): CommandAndResult[]; -} - -const historyCount = 10; +import { IRepository } from "./repositories"; class CommandService { private operators = new Set(['+', '-', '*', '/']); @@ -19,15 +13,15 @@ class CommandService { } // evaluateAndSave evaluates a mathematical expression and saves the result to the repository - public evaluateAndSave(clientId: string, expression: string): string { + public async evaluateAndSave(clientId: string, expression: string): Promise { const result = this.evaluate(expression); - this.repository.saveCommand(clientId, expression, result); + await this.repository.saveCommand(clientId, expression, result); return result; } // getHistory returns the latest 10 commands for a client - public getHistory(clientId: string): CommandAndResult[] { - return this.repository.getLatest(clientId, historyCount); + public async getHistory(clientId: string): Promise { + return this.repository.getLatest(clientId); } // evaluate evaluate a mathematical expression diff --git a/backend/src/repositories/chat-repo.spec.ts b/backend/src/repositories/chat-repo.spec.ts new file mode 100644 index 0000000..306634f --- /dev/null +++ b/backend/src/repositories/chat-repo.spec.ts @@ -0,0 +1,56 @@ +import { Collection } from "mongodb"; +import { MongoClient } from "mongodb"; +import ChatRepo, {ChatSession} from "./chat-repo"; +import { CommandAndResult } from "../entities/command-result.entity"; + +describe("ChatRepo", () => { + let testCollection: Collection; + let repo: ChatRepo; + let client: MongoClient; + const clientId = "123"; + const clientId2 = "456"; + + beforeAll(async () => { + const uri: string = process.env.MONGODB_URI || "mongodb://localhost:27017"; + client = new MongoClient(uri); + await client.connect(); + testCollection = client.db("test").collection("chat-sessions"); + repo = new ChatRepo(testCollection); + }); + + beforeEach(async () => { + await testCollection.deleteMany({}); // delete all data but keep the index + }); + + afterAll(async () => { + await client.close(); + }); + + it("should be successful", async () => { + const expected: CommandAndResult[] = [{ expression: "1+4", result: "5" }, { expression: "1+2", result: "3" }]; + for (const command of expected) { + await repo.saveCommand(clientId, command.expression, command.result); + } + const history = await repo.getLatest(clientId); + expect(history).toEqual(expected); + + await repo.saveCommand(clientId2, expected[1].expression, expected[1].result); + const history2 = await repo.getLatest(clientId2); + expect(history2).toEqual([expected[1]]); + }); + + it("should throw an error if the chat session is not found", async () => { + await expect(repo.getLatest(clientId)).rejects.toThrow("Chat session not found"); + }); + + + it("should save the latest 10 commands and results", async () => { + const chatHistory: CommandAndResult[] = new Array(20).fill(0).map((_, index) => ({ expression: `1+${index}`, result: `${index}` })); + for (const command of chatHistory) { + await repo.saveCommand(clientId, command.expression, command.result); + } + const expected = chatHistory.slice(-10); + const history = await repo.getLatest(clientId); + expect(history).toEqual(expected); // the latest 10 commands and results + }); +}); diff --git a/backend/src/repositories/chat-repo.ts b/backend/src/repositories/chat-repo.ts new file mode 100644 index 0000000..0f840a6 --- /dev/null +++ b/backend/src/repositories/chat-repo.ts @@ -0,0 +1,55 @@ +import { Collection } from "mongodb"; +import { CommandAndResult } from "../entities/command-result.entity"; +import { IRepository } from "./"; + +// ChatSession represents a chat session +export class ChatSession { + // clientId is the id of the client (a.k.a. socket.id) + clientId: string; + // history is a list of commands and corresponding results + history: CommandAndResult[]; + constructor(clientId: string) { + this.clientId = clientId; + this.history = []; + } +} + +// ChatRepo is a repository for storing chat sessions +class ChatRepo implements IRepository { + private db_client: Collection; // connected db client + private latestCount: number; + constructor(db_client: Collection, latestCount: number = 10) { + this.db_client = db_client; + this.latestCount = latestCount; + this.ensureIndex(); + } + private async ensureIndex(): Promise { + // create a unique index on clientId because it will query by clientId + await this.db_client.createIndex({ clientId: 1 }, { unique: true }); + } + + // saveCommand saves a command and its result to the chat session + public async saveCommand(clientId: string, command: string, result: string): Promise { + const history: CommandAndResult = { + expression: command, + result: result + } + await this.db_client.updateOne( + { clientId }, + // use slice to only keep the latest CommandAndResult + // ref: https://www.mongodb.com/docs/manual/reference/operator/update/slice/ + { $push: { history: { $each: [history], $slice: -this.latestCount } } }, + { upsert: true } + ); + } + + public async getLatest(clientId: string): Promise { + const chatSession = await this.db_client.findOne({ clientId }); + if (!chatSession) { + throw new Error("Chat session not found"); + } + return chatSession.history; + } +} + +export default ChatRepo; \ No newline at end of file diff --git a/backend/src/repositories/index.ts b/backend/src/repositories/index.ts new file mode 100644 index 0000000..f4d170f --- /dev/null +++ b/backend/src/repositories/index.ts @@ -0,0 +1,9 @@ +import { CommandAndResult } from "../entities/command-result.entity"; +import ChatRepo from "./chat-repo"; + +interface IRepository { + saveCommand(clientId: string, command: string, result: string): Promise; + getLatest(clientId: string): Promise; +} + +export { ChatRepo, IRepository }; \ No newline at end of file