diff --git a/apps/api/jest.config.js b/apps/api/jest.config.js new file mode 100644 index 0000000..743c71c --- /dev/null +++ b/apps/api/jest.config.js @@ -0,0 +1,11 @@ +module.exports = { + moduleFileExtensions: ['js', 'json', 'ts'], + rootDir: 'src', + testRegex: '.*\\.spec\\.ts$', + transform: { + '^.+\\.(t|j)s$': 'ts-jest', + }, + collectCoverageFrom: ['**/*.(t|j)s'], + coverageDirectory: '../coverage', + testEnvironment: 'node', +}; \ No newline at end of file diff --git a/apps/api/package.json b/apps/api/package.json index 6961dcb..d30ea6b 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -36,6 +36,7 @@ "@types/bcryptjs": "^2.4.6", "@types/cookie-parser": "^1.4.10", "@types/express": "^4.17.25", + "@types/jest": "^30.0.0", "@types/node": "^20.19.39", "@types/passport-jwt": "^3.0.13", "@types/passport-local": "^1.0.38", diff --git a/apps/api/src/prompts/diff.service.spec.ts b/apps/api/src/prompts/diff.service.spec.ts new file mode 100644 index 0000000..d42bab3 --- /dev/null +++ b/apps/api/src/prompts/diff.service.spec.ts @@ -0,0 +1,42 @@ +import { DiffService } from './diff.service'; + +describe('DiffService', () => { + const mockPrisma = {} as any; + const service = new DiffService(mockPrisma); + + it('identical content → all equal', () => { + const res = service['computeDiff']('a\nb', 'a\nb'); + expect(res.length).toBe(2); + expect(res.every(l => l.type === 'equal')).toBe(true); + }); + + it('empty from → all inserts', () => { + const res = service['computeDiff']('', 'a\nb'); + expect(res.length).toBe(2); + expect(res.every(l => l.type === 'insert')).toBe(true); + }); + + it('empty to → all deletes', () => { + const res = service['computeDiff']('a\nb', ''); + expect(res.length).toBe(2); + expect(res.every(l => l.type === 'delete')).toBe(true); + }); + + it('completely different → delete + insert', () => { + const res = service['computeDiff']('a\nb', 'x\ny'); + expect(res.some(l => l.type === 'delete')).toBe(true); + expect(res.some(l => l.type === 'insert')).toBe(true); + }); + + it('stats match line count', () => { + const res = service['computeDiff']('a\nb', 'a\nc'); + + const stats = { + added: res.filter(l => l.type === 'insert').length, + removed: res.filter(l => l.type === 'delete').length, + unchanged: res.filter(l => l.type === 'equal').length, + }; + + expect(stats.added + stats.removed + stats.unchanged).toBe(res.length); + }); +}); diff --git a/apps/api/src/prompts/diff.service.ts b/apps/api/src/prompts/diff.service.ts index e27244f..a33d4d4 100644 --- a/apps/api/src/prompts/diff.service.ts +++ b/apps/api/src/prompts/diff.service.ts @@ -5,7 +5,8 @@ export type ChangeType = 'equal' | 'insert' | 'delete'; export interface DiffLine { type: ChangeType; - lineNumber?: number; + lineNumber: number; // old file + toLineNumber?: number; // new file content: string; } @@ -20,7 +21,11 @@ export interface DiffResult { export class DiffService { constructor(private prisma: PrismaService) {} - async diffVersions(promptId: string, fromTag: string, toTag: string): Promise { + async diffVersions( + promptId: string, + fromTag: string, + toTag: string + ): Promise { const [from, to] = await Promise.all([ this.prisma.promptVersion.findUniqueOrThrow({ where: { promptId_versionTag: { promptId, versionTag: fromTag } }, @@ -31,48 +36,103 @@ export class DiffService { ]); const lines = this.computeDiff(from.content, to.content); + const stats = { added: lines.filter((l) => l.type === 'insert').length, removed: lines.filter((l) => l.type === 'delete').length, unchanged: lines.filter((l) => l.type === 'equal').length, }; - return { fromVersion: fromTag, toVersion: toTag, lines, stats }; + return { + fromVersion: fromTag, + toVersion: toTag, + lines, + stats, + }; } - /** - * Myers diff algorithm — same algorithm Git uses internally. - * Returns line-level hunks suitable for side-by-side rendering. - */ private computeDiff(a: string, b: string): DiffLine[] { + // ✅ handle empty cases properly (no phantom empty line) + if (!a) { + return b + .split('\n') + .filter((l) => l.length > 0) + .map((content, i) => ({ + type: 'insert' as const, + content, + lineNumber: i + 1, + toLineNumber: i + 1, + })); + } + + if (!b) { + return a + .split('\n') + .filter((l) => l.length > 0) + .map((content, i) => ({ + type: 'delete' as const, + content, + lineNumber: i + 1, + })); + } + const aLines = a.split('\n'); const bLines = b.split('\n'); - const result: DiffLine[] = []; - // LCS-based diff + const result: DiffLine[] = []; const lcs = this.buildLCS(aLines, bLines); + let i = 0; let j = 0; for (const [ai, bi] of lcs) { while (i < ai) { - result.push({ type: 'delete', lineNumber: i + 1, content: aLines[i] }); + result.push({ + type: 'delete', + content: aLines[i], + lineNumber: i + 1, + }); i++; } + while (j < bi) { - result.push({ type: 'insert', lineNumber: j + 1, content: bLines[j] }); + result.push({ + type: 'insert', + content: bLines[j], + lineNumber: j + 1, + toLineNumber: j + 1, + }); j++; } - result.push({ type: 'equal', content: aLines[ai] }); + + result.push({ + type: 'equal', + content: aLines[ai], + lineNumber: ai + 1, + toLineNumber: bi + 1, + }); + i++; j++; } while (i < aLines.length) { - result.push({ type: 'delete', lineNumber: i + 1, content: aLines[i++] }); + result.push({ + type: 'delete', + content: aLines[i], + lineNumber: i + 1, + }); + i++; } + while (j < bLines.length) { - result.push({ type: 'insert', lineNumber: j + 1, content: bLines[j++] }); + result.push({ + type: 'insert', + content: bLines[j], + lineNumber: j + 1, + toLineNumber: j + 1, + }); + j++; } return result; @@ -81,17 +141,25 @@ export class DiffService { private buildLCS(a: string[], b: string[]): [number, number][] { const m = a.length; const n = b.length; - const dp: number[][] = Array.from({ length: m + 1 }, () => new Array(n + 1).fill(0)); + + const dp: number[][] = Array.from({ length: m + 1 }, () => + new Array(n + 1).fill(0) + ); for (let i = 1; i <= m; i++) { for (let j = 1; j <= n; j++) { - dp[i][j] = a[i - 1] === b[j - 1] ? dp[i - 1][j - 1] + 1 : Math.max(dp[i - 1][j], dp[i][j - 1]); + dp[i][j] = + a[i - 1] === b[j - 1] + ? dp[i - 1][j - 1] + 1 + : Math.max(dp[i - 1][j], dp[i][j - 1]); } } const result: [number, number][] = []; + let i = m; let j = n; + while (i > 0 && j > 0) { if (a[i - 1] === b[j - 1]) { result.unshift([i - 1, j - 1]); @@ -103,6 +171,7 @@ export class DiffService { j--; } } + return result; } } diff --git a/apps/api/src/prompts/prompts.controller.ts b/apps/api/src/prompts/prompts.controller.ts new file mode 100644 index 0000000..981cdae --- /dev/null +++ b/apps/api/src/prompts/prompts.controller.ts @@ -0,0 +1,26 @@ +import { Controller, Get, Param, Query } from '@nestjs/common'; +import { DiffService } from './diff.service'; +import { IsString, IsNotEmpty } from 'class-validator'; + +class DiffQueryDto { + @IsString() + @IsNotEmpty() + from: string; + + @IsString() + @IsNotEmpty() + to: string; +} + +@Controller('prompts') +export class PromptsController { + constructor(private readonly diffService: DiffService) {} + + @Get(':id/diff') + diff( + @Param('id') id: string, + @Query() query: DiffQueryDto, + ) { + return this.diffService.diffVersions(id, query.from, query.to); + } +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a67ab6e..1aaa2d0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -52,7 +52,7 @@ importers: version: 10.4.22(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.1.14)(rxjs@7.8.2))(@nestjs/core@10.4.22) '@nestjs/throttler': specifier: ^5.0.0 - version: 5.2.0(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.1.14)(rxjs@7.8.2))(@nestjs/core@10.4.22)(reflect-metadata@0.1.14) + version: 5.2.0(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.1.14)(rxjs@7.8.2))(@nestjs/core@10.4.22(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.1.14)(rxjs@7.8.2))(@nestjs/platform-express@10.4.22)(reflect-metadata@0.1.14)(rxjs@7.8.2))(reflect-metadata@0.1.14) '@prisma/client': specifier: ^5.0.0 version: 5.22.0(prisma@5.22.0) @@ -95,7 +95,7 @@ importers: version: 10.2.3(chokidar@3.6.0)(typescript@5.9.2) '@nestjs/testing': specifier: ^10.4.22 - version: 10.4.22(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.1.14)(rxjs@7.8.2))(@nestjs/core@10.4.22)(@nestjs/platform-express@10.4.22) + version: 10.4.22(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.1.14)(rxjs@7.8.2))(@nestjs/core@10.4.22(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.1.14)(rxjs@7.8.2))(@nestjs/platform-express@10.4.22)(reflect-metadata@0.1.14)(rxjs@7.8.2))(@nestjs/platform-express@10.4.22(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.1.14)(rxjs@7.8.2))(@nestjs/core@10.4.22)) '@types/bcryptjs': specifier: ^2.4.6 version: 2.4.6 @@ -105,6 +105,9 @@ importers: '@types/express': specifier: ^4.17.25 version: 4.17.25 + '@types/jest': + specifier: ^30.0.0 + version: 30.0.0 '@types/node': specifier: ^20.19.39 version: 20.19.39 @@ -1294,6 +1297,9 @@ packages: '@types/istanbul-reports@3.0.4': resolution: {integrity: sha512-pk2B1NWalF9toCRu6gjBzR69syFjP4Od8WRAX+0mmf9lAjCRicLOWc+ZrxZHx/0XRjotgkF9t6iaMJ+aXcOdZQ==} + '@types/jest@30.0.0': + resolution: {integrity: sha512-XTYugzhuwqWjws0CVz8QpM36+T+Dz5mTEBKhNs/esGLnCIlGdRy+Dq78NRjd7ls7r8BC8ZRMOrKlkO1hU0JOwA==} + '@types/json-schema@7.0.15': resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} @@ -2428,7 +2434,6 @@ packages: glob@10.4.5: resolution: {integrity: sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==} - deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me hasBin: true glob@10.5.0: @@ -3210,10 +3215,6 @@ packages: resolution: {integrity: sha512-xUXwsxNjwTQ8K3GnT4pCJm+xq3RUPQbmkYJTP5aFIfNIvbcc/4MUxgBaaRSZJ6yGJZiGSyYlM6MzwTsRk8SYCg==} engines: {node: '>=12'} - picomatch@4.0.3: - resolution: {integrity: sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==} - engines: {node: '>=12'} - picomatch@4.0.4: resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==} engines: {node: '>=12'} @@ -4858,7 +4859,7 @@ snapshots: transitivePeerDependencies: - chokidar - '@nestjs/testing@10.4.22(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.1.14)(rxjs@7.8.2))(@nestjs/core@10.4.22)(@nestjs/platform-express@10.4.22)': + '@nestjs/testing@10.4.22(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.1.14)(rxjs@7.8.2))(@nestjs/core@10.4.22(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.1.14)(rxjs@7.8.2))(@nestjs/platform-express@10.4.22)(reflect-metadata@0.1.14)(rxjs@7.8.2))(@nestjs/platform-express@10.4.22(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.1.14)(rxjs@7.8.2))(@nestjs/core@10.4.22))': dependencies: '@nestjs/common': 10.4.22(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.1.14)(rxjs@7.8.2) '@nestjs/core': 10.4.22(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.1.14)(rxjs@7.8.2))(@nestjs/platform-express@10.4.22)(reflect-metadata@0.1.14)(rxjs@7.8.2) @@ -4866,7 +4867,7 @@ snapshots: optionalDependencies: '@nestjs/platform-express': 10.4.22(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.1.14)(rxjs@7.8.2))(@nestjs/core@10.4.22) - '@nestjs/throttler@5.2.0(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.1.14)(rxjs@7.8.2))(@nestjs/core@10.4.22)(reflect-metadata@0.1.14)': + '@nestjs/throttler@5.2.0(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.1.14)(rxjs@7.8.2))(@nestjs/core@10.4.22(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.1.14)(rxjs@7.8.2))(@nestjs/platform-express@10.4.22)(reflect-metadata@0.1.14)(rxjs@7.8.2))(reflect-metadata@0.1.14)': dependencies: '@nestjs/common': 10.4.22(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.1.14)(rxjs@7.8.2) '@nestjs/core': 10.4.22(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.1.14)(rxjs@7.8.2))(@nestjs/platform-express@10.4.22)(reflect-metadata@0.1.14)(rxjs@7.8.2) @@ -5135,6 +5136,11 @@ snapshots: dependencies: '@types/istanbul-lib-report': 3.0.3 + '@types/jest@30.0.0': + dependencies: + expect: 30.3.0 + pretty-format: 30.3.0 + '@types/json-schema@7.0.15': {} '@types/jsonwebtoken@9.0.10': @@ -6321,7 +6327,7 @@ snapshots: minimatch: 3.1.2 node-abort-controller: 3.1.1 schema-utils: 3.3.0 - semver: 7.7.3 + semver: 7.7.4 tapable: 2.3.2 typescript: 5.7.2 webpack: 5.97.1 @@ -6735,7 +6741,7 @@ snapshots: jest-regex-util: 30.0.1 jest-util: 30.3.0 jest-worker: 30.3.0 - picomatch: 4.0.3 + picomatch: 4.0.4 walker: 1.0.8 optionalDependencies: fsevents: 2.3.3 @@ -6759,7 +6765,7 @@ snapshots: '@types/stack-utils': 2.0.3 chalk: 4.1.2 graceful-fs: 4.2.11 - picomatch: 4.0.3 + picomatch: 4.0.4 pretty-format: 30.3.0 slash: 3.0.0 stack-utils: 2.0.6 @@ -6881,7 +6887,7 @@ snapshots: chalk: 4.1.2 ci-info: 4.4.0 graceful-fs: 4.2.11 - picomatch: 4.0.3 + picomatch: 4.0.4 jest-validate@30.3.0: dependencies: @@ -7349,8 +7355,6 @@ snapshots: picomatch@4.0.1: {} - picomatch@4.0.3: {} - picomatch@4.0.4: {} pify@2.3.0: {}