diff --git a/lib/javascript/fullstack_demo/package-lock.json b/lib/javascript/fullstack_demo/package-lock.json index 38dd22777..d651e7bd5 100644 --- a/lib/javascript/fullstack_demo/package-lock.json +++ b/lib/javascript/fullstack_demo/package-lock.json @@ -7,6 +7,7 @@ "": { "name": "fullstack_demo", "version": "0.1.0", + "hasInstallScript": true, "dependencies": { "@clerk/nextjs": "^6.9.2", "@prisma/client": "^6.1.0", @@ -24,6 +25,7 @@ "@types/react": "^19", "@types/react-dom": "^19", "@vitejs/plugin-react": "^4.3.4", + "@vitest/coverage-istanbul": "^2.1.8", "eslint": "^9", "eslint-config-next": "15.1.0", "eslint-config-prettier": "^9.1.0", @@ -1510,6 +1512,16 @@ "url": "https://github.com/chalk/strip-ansi?sponsor=1" } }, + "node_modules/@istanbuljs/schema": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.3.tgz", + "integrity": "sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/@jridgewell/gen-mapping": { "version": "0.3.5", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.5.tgz", @@ -2592,6 +2604,31 @@ "vite": "^4.2.0 || ^5.0.0 || ^6.0.0" } }, + "node_modules/@vitest/coverage-istanbul": { + "version": "2.1.8", + "resolved": "https://registry.npmjs.org/@vitest/coverage-istanbul/-/coverage-istanbul-2.1.8.tgz", + "integrity": "sha512-cSaCd8KcWWvgDwEJSXm0NEWZ1YTiJzjicKHy+zOEbUm0gjbbkz+qJf1p8q71uBzSlS7vdnZA8wRLeiwVE3fFTA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@istanbuljs/schema": "^0.1.3", + "debug": "^4.3.7", + "istanbul-lib-coverage": "^3.2.2", + "istanbul-lib-instrument": "^6.0.3", + "istanbul-lib-report": "^3.0.1", + "istanbul-lib-source-maps": "^5.0.6", + "istanbul-reports": "^3.1.7", + "magicast": "^0.3.5", + "test-exclude": "^7.0.1", + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "vitest": "2.1.8" + } + }, "node_modules/@vitest/expect": { "version": "2.1.8", "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-2.1.8.tgz", @@ -4755,6 +4792,27 @@ "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" } }, + "node_modules/glob": { + "version": "10.4.5", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.4.5.tgz", + "integrity": "sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==", + "dev": true, + "license": "ISC", + "dependencies": { + "foreground-child": "^3.1.0", + "jackspeak": "^3.1.2", + "minimatch": "^9.0.4", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^1.11.1" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/glob-parent": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", @@ -4774,6 +4832,32 @@ "integrity": "sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw==", "license": "BSD-2-Clause" }, + "node_modules/glob/node_modules/brace-expansion": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", + "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/glob/node_modules/minimatch": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", + "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/globals": { "version": "14.0.0", "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", @@ -4939,6 +5023,13 @@ "node": ">=18" } }, + "node_modules/html-escaper": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", + "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", + "dev": true, + "license": "MIT" + }, "node_modules/http-proxy-agent": { "version": "7.0.2", "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", @@ -5461,6 +5552,77 @@ "dev": true, "license": "ISC" }, + "node_modules/istanbul-lib-coverage": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", + "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=8" + } + }, + "node_modules/istanbul-lib-instrument": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/istanbul-lib-instrument/-/istanbul-lib-instrument-6.0.3.tgz", + "integrity": "sha512-Vtgk7L/R2JHyyGW07spoFlB8/lpjiOLTjMdms6AFMraYt3BaJauod/NGrfnVG/y4Ix1JEuMRPDPEj2ua+zz1/Q==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@babel/core": "^7.23.9", + "@babel/parser": "^7.23.9", + "@istanbuljs/schema": "^0.1.3", + "istanbul-lib-coverage": "^3.2.0", + "semver": "^7.5.4" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-lib-report": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz", + "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "istanbul-lib-coverage": "^3.0.0", + "make-dir": "^4.0.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-lib-source-maps": { + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/istanbul-lib-source-maps/-/istanbul-lib-source-maps-5.0.6.tgz", + "integrity": "sha512-yg2d+Em4KizZC5niWhQaIomgf5WlL4vOOjZ5xGCmF8SnPE/mDWWXgvRExdcpCgh9lLRRa1/fSYp2ymmbJ1pI+A==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.23", + "debug": "^4.1.1", + "istanbul-lib-coverage": "^3.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-reports": { + "version": "3.1.7", + "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.1.7.tgz", + "integrity": "sha512-BewmUXImeuRk2YY0PVbxgKAysvhRPUQE0h5QRM++nVWyubKGV0l8qQ5op8+B2DOmwSe63Jivj0BjkPQVf8fP5g==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "html-escaper": "^2.0.0", + "istanbul-lib-report": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/iterator.prototype": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/iterator.prototype/-/iterator.prototype-1.1.3.tgz", @@ -5804,6 +5966,34 @@ "@jridgewell/sourcemap-codec": "^1.5.0" } }, + "node_modules/magicast": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/magicast/-/magicast-0.3.5.tgz", + "integrity": "sha512-L0WhttDl+2BOsybvEOLK7fW3UA0OQ0IQ2d6Zl2x/a6vVRs3bAY0ECOSHHeL5jD+SbOpOCUEi0y1DgHEn9Qn1AQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.25.4", + "@babel/types": "^7.25.4", + "source-map-js": "^1.2.0" + } + }, + "node_modules/make-dir": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", + "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.5.3" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/map-obj": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/map-obj/-/map-obj-4.3.0.tgz", @@ -7445,53 +7635,6 @@ "node": ">=16 || 14 >=14.17" } }, - "node_modules/sucrase/node_modules/brace-expansion": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", - "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0" - } - }, - "node_modules/sucrase/node_modules/glob": { - "version": "10.4.5", - "resolved": "https://registry.npmjs.org/glob/-/glob-10.4.5.tgz", - "integrity": "sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==", - "dev": true, - "license": "ISC", - "dependencies": { - "foreground-child": "^3.1.0", - "jackspeak": "^3.1.2", - "minimatch": "^9.0.4", - "minipass": "^7.1.2", - "package-json-from-dist": "^1.0.0", - "path-scurry": "^1.11.1" - }, - "bin": { - "glob": "dist/esm/bin.mjs" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/sucrase/node_modules/minimatch": { - "version": "9.0.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", - "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", - "dev": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^2.0.1" - }, - "engines": { - "node": ">=16 || 14 >=14.17" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, "node_modules/supports-color": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", @@ -7603,6 +7746,47 @@ "node": ">=6" } }, + "node_modules/test-exclude": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-7.0.1.tgz", + "integrity": "sha512-pFYqmTw68LXVjeWJMST4+borgQP2AyMNbg1BpZh9LbyhUeNkeaPF9gzfPGUAnSMV3qPYdWUwDIjjCLiSDOl7vg==", + "dev": true, + "license": "ISC", + "dependencies": { + "@istanbuljs/schema": "^0.1.2", + "glob": "^10.4.1", + "minimatch": "^9.0.4" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/test-exclude/node_modules/brace-expansion": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", + "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/test-exclude/node_modules/minimatch": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", + "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/thenify": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz", diff --git a/lib/javascript/fullstack_demo/package.json b/lib/javascript/fullstack_demo/package.json index 0ed3866be..03a688b31 100644 --- a/lib/javascript/fullstack_demo/package.json +++ b/lib/javascript/fullstack_demo/package.json @@ -8,7 +8,7 @@ "start": "next start", "lint": "next lint", "fix": "prettier . --write", - "test": "vitest", + "test": "vitest run --coverage", "postinstall": "prisma generate" }, "dependencies": { @@ -28,6 +28,7 @@ "@types/react": "^19", "@types/react-dom": "^19", "@vitejs/plugin-react": "^4.3.4", + "@vitest/coverage-istanbul": "^2.1.8", "eslint": "^9", "eslint-config-next": "15.1.0", "eslint-config-prettier": "^9.1.0", @@ -41,4 +42,4 @@ "vitest": "^2.1.8", "vitest-fetch-mock": "^0.4.3" } -} \ No newline at end of file +} diff --git a/lib/javascript/fullstack_demo/src/repositories/fakes/fake-prima-client.ts b/lib/javascript/fullstack_demo/src/repositories/fakes/fake-prima-client.ts new file mode 100644 index 000000000..74f3c0479 --- /dev/null +++ b/lib/javascript/fullstack_demo/src/repositories/fakes/fake-prima-client.ts @@ -0,0 +1,95 @@ +type CollectionItem = { id?: number; updated_at: string }; + +export type DbTodo = CollectionItem & { + text: string; + completed: boolean; + user_id: string; +}; + +type FindManyArgs = { + where: Partial; + orderBy?: { [K in keyof T]?: 'asc' | 'desc' }; +}; + +type CreateArgs = { + data: T; +}; + +type UpdateArgs = FindManyArgs & { + data: Partial; +}; + +class FakePrimaCollection { + constructor(private readonly items: T[] = []) {} + + findMany(criteria: FindManyArgs): T[] { + return this.items + .filter((item) => + Object.keys(criteria.where).every( + (key) => item[key as keyof T] === criteria.where[key as keyof T], + ), + ) + .sort((a, b) => { + if (criteria.orderBy) { + const key = Object.keys(criteria.orderBy)[0] as keyof T; + return criteria.orderBy[key] === 'asc' + ? a[key] > b[key] + ? 1 + : -1 + : a[key] < b[key] + ? 1 + : -1; + } + return ( + new Date(a.updated_at).getTime() - new Date(b.updated_at).getTime() + ); + }); + } + + create(args: CreateArgs): T { + const item = { + ...args.data, + id: this.items.length + 1, + updated_at: new Date(), + }; + this.items.push(item); + return item; + } + + update(criteria: UpdateArgs): T | undefined { + const item = this.items.find((item) => + Object.keys(criteria.where).every( + (key) => item[key as keyof T] === criteria.where[key as keyof T], + ), + ); + if (item) { + Object.assign(item, criteria.data); + item.updated_at = new Date().toISOString(); + } + return item; + } + + delete(criteria: FindManyArgs): void { + this.items.splice( + this.items.findIndex((item) => + Object.keys(criteria.where).every( + (key) => item[key as keyof T] === criteria.where[key as keyof T], + ), + ), + 1, + ); + } +} + +type FakePrismaClientOptions = { + todos?: DbTodo[]; +}; + +export class FakePrismaClient { + private readonly _todos: DbTodo[] = []; + readonly todos: FakePrimaCollection; + + constructor(options: FakePrismaClientOptions = {}) { + this.todos = new FakePrimaCollection(options?.todos); + } +} diff --git a/lib/javascript/fullstack_demo/src/repositories/index.ts b/lib/javascript/fullstack_demo/src/repositories/index.ts index faf3cf884..5db3acc8d 100644 --- a/lib/javascript/fullstack_demo/src/repositories/index.ts +++ b/lib/javascript/fullstack_demo/src/repositories/index.ts @@ -1,16 +1,33 @@ export type { TodoRepository } from './todo-repository'; -import { JsonTodoRespository } from './json-todo-repository'; +import { PrismaClient } from '@prisma/client'; +import { Redis } from '@upstash/redis'; +import { JSONFilePreset } from 'lowdb/node'; +import { dirname } from 'path'; +import { fileURLToPath } from 'url'; +import { DbTables, JsonTodoRepository } from './json-todo-repository'; import { PostgresTodoRepository } from './postgres-todo-repository'; import { RedisTodoRepository } from './redis-todo-repository'; import { TodoRepository } from './todo-repository'; +const __dirname = dirname(fileURLToPath(import.meta.url)); +const db = await JSONFilePreset(`${__dirname}/db.json`, { + todos: [], +}); + export function createTodoRepository(): TodoRepository { if (process.env.DB_TYPE?.toUpperCase() === 'REDIS') { - return new RedisTodoRepository(); + const redisClient = new Redis({ + url: process.env.KV_REST_API_URL, + token: process.env.KV_REST_API_TOKEN, + }); + return new RedisTodoRepository(redisClient); } + if (process.env.DB_TYPE?.toUpperCase() === 'POSTGRES') { - return new PostgresTodoRepository(); + const prismaClient = new PrismaClient(); + return new PostgresTodoRepository(prismaClient); } - return new JsonTodoRespository(); + + return new JsonTodoRepository(db); } diff --git a/lib/javascript/fullstack_demo/src/repositories/json-todo-repository.ts b/lib/javascript/fullstack_demo/src/repositories/json-todo-repository.ts index f4f809824..ae3e56c0f 100644 --- a/lib/javascript/fullstack_demo/src/repositories/json-todo-repository.ts +++ b/lib/javascript/fullstack_demo/src/repositories/json-todo-repository.ts @@ -1,45 +1,46 @@ -import { JSONFilePreset } from 'lowdb/node'; -import { dirname } from 'path'; -import { fileURLToPath } from 'url'; import { Todo } from '../models'; import { TodoRepository } from './todo-repository'; -interface DbTables { +export interface DbTables { todos: { userId: string; items: Todo[]; }[]; } -const __dirname = dirname(fileURLToPath(import.meta.url)); -const db = await JSONFilePreset(`${__dirname}/db.json`, { - todos: [], -}); +interface JsonDb { + data: DbTables; + write: () => Promise; +} + +export class JsonTodoRepository implements TodoRepository { + constructor(private db: JsonDb) {} -export class JsonTodoRespository implements TodoRepository { async getAll(userId: string): Promise { const todos = - db.data.todos.find((todo) => todo.userId === userId)?.items || []; + this.db.data.todos.find((todo) => todo.userId === userId)?.items || []; return todos; } async create(todo: Todo, userId: string): Promise { - const userTodos = db.data.todos.find((todos) => todos.userId === userId); + const userTodos = this.db.data.todos.find( + (todos) => todos.userId === userId, + ); const id = todo.id ?? Date.now(); if (userTodos) { userTodos.items.push({ ...todo, id }); } else { - db.data.todos.push({ userId, items: [{ ...todo, id: Date.now() }] }); + this.db.data.todos.push({ userId, items: [{ ...todo, id: Date.now() }] }); } - await db.write(); + await this.db.write(); return id; } async patch(todo: Partial, userId: string): Promise { - let userTodos = db.data.todos.find((todos) => todos.userId === userId); + let userTodos = this.db.data.todos.find((todos) => todos.userId === userId); if (!userTodos) { userTodos = { userId, items: [] }; - db.data.todos.push(userTodos); + this.db.data.todos.push(userTodos); } let updatedTodo = userTodos?.items.find((t) => t.id === todo.id); if (userTodos && updatedTodo) { @@ -49,15 +50,17 @@ export class JsonTodoRespository implements TodoRepository { ); userTodos.items = items; } - await db.write(); + await this.db.write(); return updatedTodo; } async delete(id: number, userId: string): Promise { - const userTodos = db.data.todos.find((todos) => todos.userId === userId); + const userTodos = this.db.data.todos.find( + (todos) => todos.userId === userId, + ); if (userTodos) { userTodos.items = userTodos.items.filter((todo) => todo.id !== id); - await db.write(); + await this.db.write(); } } } diff --git a/lib/javascript/fullstack_demo/src/repositories/postgres-todo-repository.spec.ts b/lib/javascript/fullstack_demo/src/repositories/postgres-todo-repository.spec.ts new file mode 100644 index 000000000..f700cb90a --- /dev/null +++ b/lib/javascript/fullstack_demo/src/repositories/postgres-todo-repository.spec.ts @@ -0,0 +1,91 @@ +import { PrismaClient } from '@prisma/client'; +import { beforeEach, describe, expect, it } from 'vitest'; +import { DbTodo, FakePrismaClient } from './fakes/fake-prima-client'; +import { PostgresTodoRepository } from './postgres-todo-repository'; + +const INITIAL_TODOS: ReadonlyArray = [ + { + id: 1, + user_id: '1', + text: 'Buy milk', + completed: false, + updated_at: new Date().toISOString(), + }, + { + id: 2, + user_id: '1', + text: 'Walk the dog', + completed: true, + updated_at: new Date().toISOString(), + }, + { + id: 3, + user_id: '2', + text: 'Do homework', + completed: false, + updated_at: new Date().toISOString(), + }, +]; + +describe('PostgresTodoRepository', () => { + let repository: PostgresTodoRepository; + + beforeEach(() => { + const todos = JSON.parse(JSON.stringify(INITIAL_TODOS)); + const fakeClient = new FakePrismaClient({ todos }); + repository = new PostgresTodoRepository( + fakeClient as unknown as PrismaClient, + ); + }); + + it('should retrieve todos', async () => { + // Act + const todos = await repository.getAll('1'); + + // Assert + expect(todos).toEqual([ + { id: 1, text: 'Buy milk', completed: false }, + { id: 2, text: 'Walk the dog', completed: true }, + ]); + }); + + it('should create a todo', async () => { + // Act + await repository.create( + { id: 0, text: 'Take out the trash', completed: true }, + '2', + ); + + // Assert + const todos = await repository.getAll('2'); + expect(todos).toEqual([ + { id: 3, text: 'Do homework', completed: false }, + { id: 4, text: 'Take out the trash', completed: true }, + ]); + }); + + it('should patch a todo', async () => { + // Act + const updatedTodo = await repository.patch( + { id: 1, text: 'Buy milk', completed: true }, + '1', + ); + + // Assert + expect(updatedTodo).toEqual({ id: 1, text: 'Buy milk', completed: true }); + expect(await repository.getAll('1')).toEqual([ + { id: 1, text: 'Buy milk', completed: true }, + { id: 2, text: 'Walk the dog', completed: true }, + ]); + }); + + it('should delete a todo', async () => { + // Act + await repository.delete(1, '1'); + + // Assert + expect(await repository.getAll('1')).toEqual([ + { id: 2, text: 'Walk the dog', completed: true }, + ]); + }); +}); diff --git a/lib/javascript/fullstack_demo/src/repositories/postgres-todo-repository.ts b/lib/javascript/fullstack_demo/src/repositories/postgres-todo-repository.ts index a4d0ee85a..280c5681f 100644 --- a/lib/javascript/fullstack_demo/src/repositories/postgres-todo-repository.ts +++ b/lib/javascript/fullstack_demo/src/repositories/postgres-todo-repository.ts @@ -2,11 +2,14 @@ import { Todo } from '@/models'; import { PrismaClient } from '@prisma/client'; import { TodoRepository } from './todo-repository'; -const client = new PrismaClient(); - export class PostgresTodoRepository implements TodoRepository { + constructor(private readonly client: PrismaClient) {} + async getAll(userId: string): Promise { - const dbTodos = await client.todos.findMany({ where: { user_id: userId } }); + const dbTodos = await this.client.todos.findMany({ + where: { user_id: userId }, + orderBy: { id: 'asc' }, + }); return dbTodos.map((todo) => ({ id: Number(todo.id), text: todo.text, @@ -15,7 +18,7 @@ export class PostgresTodoRepository implements TodoRepository { } async create(todo: Todo, userId: string): Promise { - const dbTodo = await client.todos.create({ + const dbTodo = await this.client.todos.create({ data: { text: todo.text, completed: todo.completed, @@ -26,7 +29,7 @@ export class PostgresTodoRepository implements TodoRepository { } async patch(todo: Partial, userId: string): Promise { - const dbTodo = await client.todos.update({ + const dbTodo = await this.client.todos.update({ where: { id: todo.id, user_id: userId }, data: { text: todo.text, @@ -43,6 +46,6 @@ export class PostgresTodoRepository implements TodoRepository { } async delete(id: number, userId: string): Promise { - await client.todos.delete({ where: { id, user_id: userId } }); + await this.client.todos.delete({ where: { id, user_id: userId } }); } } diff --git a/lib/javascript/fullstack_demo/src/repositories/redis-todo-repository.ts b/lib/javascript/fullstack_demo/src/repositories/redis-todo-repository.ts index 9481f4aa0..9219f9d8c 100644 --- a/lib/javascript/fullstack_demo/src/repositories/redis-todo-repository.ts +++ b/lib/javascript/fullstack_demo/src/repositories/redis-todo-repository.ts @@ -3,10 +3,7 @@ import { Redis } from '@upstash/redis'; import { TodoRepository } from './todo-repository'; export class RedisTodoRepository implements TodoRepository { - private readonly redis = new Redis({ - url: process.env.KV_REST_API_URL, - token: process.env.KV_REST_API_TOKEN, - }); + constructor(private readonly redis: Redis) {} async getAll(userId: string): Promise { const todos = (await this.redis.get(`todos:${userId}`)) as Todo[]; diff --git a/lib/javascript/fullstack_demo/vitest.config.mts b/lib/javascript/fullstack_demo/vitest.config.mts index 3f512ec92..a570165a2 100644 --- a/lib/javascript/fullstack_demo/vitest.config.mts +++ b/lib/javascript/fullstack_demo/vitest.config.mts @@ -9,5 +9,8 @@ export default defineConfig({ environment: 'jsdom', setupFiles: ['./setupVitest.mjs'], env: loadEnv('test', process.cwd(), ''), + coverage: { + provider: 'istanbul', + }, }, });