diff --git a/README.md b/README.md index 88141f98..3d190858 100644 --- a/README.md +++ b/README.md @@ -5,3 +5,43 @@ Still on progress CodeFox LOGO ![](./assets/WechatIMG1000.svg) + +```mermaid +graph TD + subgraph Project_Generate_Layer[Project Generate Layer] + UP[User Project Info] --> PRD[Product Requirements Document] + PRD --> FRD[Feature Requirements Document] + PRD --> UXSD[UX Sitemap Document] + UXSD --> UXDD[UX Datamap Document] + UXDD --> DRD[Database Requirements Document] + DRD --> DBS[DB/schemas] + DRD --> DBP[DB/postgres] + DRD --> BRD[Backend Requirements Document] + + %% Frontend related generations + UXSD --> USS[ux/sitemap-structure] + USS --> ROUTE[frontend/routing] + UXDD --> UDS[ux/datamap-structure] + UXDD --> UDV[ux/datamap-views] + + %% Webview generations + USS --> WV1[webview/page1] + USS --> WV2[webview/page2] + USS --> WV3[webview/page3] + USS --> ROOT[webview/root] + UDV --> ROOT + + %% Optional: Show multiple pages with a note + note[...more webviews...] + USS --> note + end + + %% Styling + classDef default fill:#f9f9f9,stroke:#333,stroke-width:2px + classDef boxStyle fill:#fff,stroke:#666,stroke-width:1px + classDef noteStyle fill:#fff4e6,stroke:#d9480f,stroke-width:1px + class UP,PRD,FRD,UXSD,UXDD,DRD,DBS,DBP,BRD,USS,UDS,UDV,ROUTE,WV1,WV2,WV3,ROOT boxStyle + class note noteStyle + classDef layerStyle fill:#f4f4f4,stroke:#666,stroke-width:1px,stroke-dasharray: 5 5 + class Project_Generate_Layer layerStyle +``` diff --git a/backend/src/auth/auth.spec.ts b/backend/src/auth/auth.spec.ts deleted file mode 100644 index 05d4d26c..00000000 --- a/backend/src/auth/auth.spec.ts +++ /dev/null @@ -1,306 +0,0 @@ -import { Test, TestingModule } from '@nestjs/testing'; -import { AuthService } from './auth.service'; -import { AuthResolver } from './auth.resolver'; -import { JwtCacheService } from './jwt-cache.service'; -import { getRepositoryToken } from '@nestjs/typeorm'; -import { JwtService } from '@nestjs/jwt'; -import { ConfigService } from '@nestjs/config'; -import { User } from 'src/user/user.model'; -import { ConflictException, UnauthorizedException } from '@nestjs/common'; -import { Repository } from 'typeorm'; -import { hash, compare } from 'bcrypt'; - -// 简化的 mockDb 实现 -const mockDb = { - run: jest.fn().mockImplementation((query, params, callback) => { - if (callback) callback(null); - }), - get: jest.fn().mockImplementation((query, params, callback) => { - if (callback) callback(null, { token: 'test.token' }); - }), - close: jest.fn().mockImplementation((callback) => { - if (callback) callback(null); - }), -}; - -jest.mock('sqlite3', () => ({ - Database: jest.fn().mockImplementation(() => mockDb), -})); - -describe('Auth Module Tests', () => { - let authService: AuthService; - let authResolver: AuthResolver; - let jwtCacheService: JwtCacheService; - let userRepository: Repository; - let jwtService: JwtService; - let configService: ConfigService; - - const mockUser = { - id: '1', - username: 'testuser', - email: 'test@example.com', - password: 'hashedpassword', - roles: [], - }; - - const mockUserRepository = { - findOne: jest.fn(), - create: jest.fn(), - save: jest.fn(), - }; - - const mockJwtService = { - sign: jest.fn(), - verifyAsync: jest.fn(), - }; - - const mockConfigService = { - get: jest.fn(), - }; - - beforeEach(async () => { - const module: TestingModule = await Test.createTestingModule({ - providers: [ - AuthService, - AuthResolver, - JwtCacheService, - { - provide: getRepositoryToken(User), - useValue: mockUserRepository, - }, - { - provide: JwtService, - useValue: mockJwtService, - }, - { - provide: ConfigService, - useValue: mockConfigService, - }, - ], - }).compile(); - - authService = module.get(AuthService); - authResolver = module.get(AuthResolver); - jwtCacheService = module.get(JwtCacheService); - userRepository = module.get>(getRepositoryToken(User)); - jwtService = module.get(JwtService); - configService = module.get(ConfigService); - - jest.clearAllMocks(); - }); - - describe('AuthService', () => { - describe('register', () => { - it('should successfully register a new user', async () => { - const registerInput = { - username: 'newuser', - email: 'newuser@example.com', - password: 'password123', - }; - - const hashedPassword = await hash(registerInput.password, 10); - - mockUserRepository.findOne.mockResolvedValue(null); - mockUserRepository.create.mockReturnValue({ - ...registerInput, - password: hashedPassword, - }); - mockUserRepository.save.mockResolvedValue({ - ...registerInput, - id: '1', - password: hashedPassword, - }); - - const result = await authService.register(registerInput); - - expect(result).toBeDefined(); - expect(result.username).toBe(registerInput.username); - expect(result.email).toBe(registerInput.email); - expect( - await compare(registerInput.password, result.password), - ).toBeTruthy(); - }); - - it('should throw ConflictException if username already exists', async () => { - const registerInput = { - username: 'existinguser', - email: 'new@example.com', - password: 'password123', - }; - - mockUserRepository.findOne.mockResolvedValue(mockUser); - - await expect(authService.register(registerInput)).rejects.toThrow( - ConflictException, - ); - }); - }); - - describe('login', () => { - it('should successfully login and return access token', async () => { - const loginInput = { - username: 'testuser', - password: 'correctpassword', - }; - - const hashedPassword = await hash('correctpassword', 10); - mockUserRepository.findOne.mockResolvedValue({ - ...mockUser, - password: hashedPassword, - }); - - const mockToken = 'mock.jwt.token'; - mockJwtService.sign.mockReturnValue(mockToken); - - const result = await authService.login(loginInput); - - expect(result).toBeDefined(); - expect(result.access_token).toBe(mockToken); - }); - - it('should throw UnauthorizedException for invalid credentials', async () => { - const loginInput = { - username: 'testuser', - password: 'wrongpassword', - }; - - const hashedPassword = await hash('correctpassword', 10); - mockUserRepository.findOne.mockResolvedValue({ - ...mockUser, - password: hashedPassword, - }); - - await expect(authService.login(loginInput)).rejects.toThrow( - UnauthorizedException, - ); - }); - - it('should throw UnauthorizedException for non-existent user', async () => { - const loginInput = { - username: 'nonexistentuser', - password: 'password123', - }; - - mockUserRepository.findOne.mockResolvedValue(null); - - await expect(authService.login(loginInput)).rejects.toThrow( - UnauthorizedException, - ); - }); - }); - - describe('validateToken', () => { - it('should return true for valid token', async () => { - const token = 'valid.jwt.token'; - - mockJwtService.verifyAsync.mockResolvedValue({ userId: '1' }); - jest.spyOn(jwtCacheService, 'isTokenStored').mockResolvedValue(true); - - const result = await authService.validateToken({ token }); - - expect(result).toBe(true); - expect(mockJwtService.verifyAsync).toHaveBeenCalledWith(token); - }); - - it('should return false for invalid token without logging error', async () => { - const token = 'invalid.jwt.token'; - - const jwtError = new Error('jwt expired'); - jwtError.name = 'JsonWebTokenError'; - mockJwtService.verifyAsync.mockRejectedValue(jwtError); - - const result = await authService.validateToken({ token }); - - expect(result).toBe(false); - expect(mockJwtService.verifyAsync).toHaveBeenCalledWith(token); - }); - }); - - describe('logout', () => { - it('should successfully logout user', async () => { - const token = 'valid.jwt.token'; - - mockJwtService.verifyAsync.mockResolvedValue(true); - jest.spyOn(jwtCacheService, 'isTokenStored').mockResolvedValue(true); - jest.spyOn(jwtCacheService, 'removeToken').mockResolvedValue(undefined); - - const result = await authService.logout(token); - - expect(result).toBe(true); - expect(jwtCacheService.removeToken).toHaveBeenCalledWith(token); - }); - - it('should return false for invalid token', async () => { - const token = 'invalid.jwt.token'; - - mockJwtService.verifyAsync.mockRejectedValue( - new Error('Invalid token'), - ); - - const result = await authService.logout(token); - - expect(result).toBe(false); - }); - }); - }); - - describe('JwtCacheService', () => { - let service: JwtCacheService; - - beforeEach(() => { - service = new JwtCacheService(); - }); - - it('should store token', (done) => { - const token = 'test.token'; - - service.storeToken(token).then(() => { - expect(mockDb.run).toHaveBeenCalled(); - done(); - }); - }); - - it('should check if token is stored', (done) => { - const token = 'test.token'; - - service.isTokenStored(token).then((result) => { - expect(result).toBe(true); - expect(mockDb.get).toHaveBeenCalled(); - done(); - }); - }); - - it('should remove token', (done) => { - const token = 'test.token'; - - service.removeToken(token).then(() => { - expect(mockDb.run).toHaveBeenCalled(); - done(); - }); - }); - }); - - describe('AuthResolver', () => { - describe('checkToken', () => { - it('should return true for valid token', async () => { - const input = { token: 'valid.jwt.token' }; - jest.spyOn(authService, 'validateToken').mockResolvedValue(true); - - const result = await authResolver.checkToken(input); - - expect(result).toBe(true); - expect(authService.validateToken).toHaveBeenCalledWith(input); - }); - - it('should return false for invalid token', async () => { - const input = { token: 'invalid.jwt.token' }; - jest.spyOn(authService, 'validateToken').mockResolvedValue(false); - - const result = await authResolver.checkToken(input); - - expect(result).toBe(false); - expect(authService.validateToken).toHaveBeenCalledWith(input); - }); - }); - }); -}); diff --git a/backend/src/build-system/__tests__/test.spec.ts b/backend/src/build-system/__tests__/test.spec.ts new file mode 100644 index 00000000..4f8dde5b --- /dev/null +++ b/backend/src/build-system/__tests__/test.spec.ts @@ -0,0 +1,80 @@ +// src/build-system/__tests__/project-init-sequence.spec.ts +import { BuilderContext } from '../context'; +import { BuildSequenceExecutor } from '../executor'; +import { BuildHandlerManager } from '../hanlder-manager'; +import { ProjectInitHandler } from '../node/project-init'; +import { BuildSequence } from '../types'; + +describe('Project Init Handler Test', () => { + let context: BuilderContext; + let executor: BuildSequenceExecutor; + let handlerManager: BuildHandlerManager; + + const testSequence: BuildSequence = { + id: 'test:project-init', + version: '1.0', + name: 'Project Init Test', + description: 'Test sequence for project initialization', + steps: [ + { + id: 'step1', + name: 'Project Setup', + parallel: false, + nodes: [ + { + id: 'op:PROJECT::STATE:SETUP', + type: 'PROJECT_SETUP', + name: 'Project Setup', + }, + ], + }, + ], + }; + + beforeEach(() => { + handlerManager = BuildHandlerManager.getInstance(); + handlerManager.clear(); + + context = new BuilderContext(testSequence); + executor = new BuildSequenceExecutor(context); + }); + + describe('Handler Registration', () => { + test('should register handler correctly', () => { + const handler = handlerManager.getHandler('op:PROJECT::STATE:SETUP'); + expect(handler).toBeDefined(); + expect(handler instanceof ProjectInitHandler).toBeTruthy(); + }); + }); + + describe('State Management', () => { + test('should update execution state correctly', async () => { + let state = context.getState(); + expect(state.completed.size).toBe(0); + expect(state.pending.size).toBe(0); + + await executor.executeSequence(testSequence); + + state = context.getState(); + expect(state.completed.size).toBe(1); + expect(state.completed.has('op:PROJECT::STATE:SETUP')).toBe(true); + expect(state.pending.size).toBe(0); + expect(state.failed.size).toBe(0); + }); + }); + + describe('Direct Handler Execution', () => { + test('should be able to run handler directly', async () => { + const handler = new ProjectInitHandler(); + const result = await handler.run(context); + expect(result.success).toBe(true); + }); + }); + + describe('Handler ID', () => { + test('should have correct handler id', () => { + const handler = new ProjectInitHandler(); + expect(handler.id).toBe('op:PROJECT::STATE:SETUP'); + }); + }); +}); diff --git a/backend/src/build-system/context.ts b/backend/src/build-system/context.ts new file mode 100644 index 00000000..2d00c198 --- /dev/null +++ b/backend/src/build-system/context.ts @@ -0,0 +1,92 @@ +import { BuildHandlerManager } from './hanlder-manager'; +import { + BuildExecutionState, + BuildNode, + BuildResult, + BuildSequence, + BuildStep, +} from './types'; + +export class BuilderContext { + private state: BuildExecutionState = { + completed: new Set(), + pending: new Set(), + failed: new Set(), + waiting: new Set(), + }; + + private data: Record = {}; + private handlerManager: BuildHandlerManager; + + constructor(private sequence: BuildSequence) { + this.handlerManager = BuildHandlerManager.getInstance(); + } + + canExecute(nodeId: string): boolean { + const node = this.findNode(nodeId); + if (!node) return false; + + if (this.state.completed.has(nodeId) || this.state.pending.has(nodeId)) { + return false; + } + + return !node.requires?.some((dep) => !this.state.completed.has(dep)); + } + + private findNode(nodeId: string): BuildNode | null { + for (const step of this.sequence.steps) { + const node = step.nodes.find((n) => n.id === nodeId); + if (node) return node; + } + return null; + } + + async run(nodeId: string): Promise { + const node = this.findNode(nodeId); + if (!node) { + throw new Error(`Node not found: ${nodeId}`); + } + + if (!this.canExecute(nodeId)) { + throw new Error(`Dependencies not met for node: ${nodeId}`); + } + + try { + this.state.pending.add(nodeId); + const result = await this.executeNode(node); + this.state.completed.add(nodeId); + this.state.pending.delete(nodeId); + return result; + } catch (error) { + this.state.failed.add(nodeId); + this.state.pending.delete(nodeId); + throw error; + } + } + + getState(): BuildExecutionState { + return { ...this.state }; + } + + setData(key: string, value: any): void { + this.data[key] = value; + } + + getData(key: string): any { + return this.data[key]; + } + + private async executeNode(node: BuildNode): Promise { + if (process.env.NODE_ENV === 'test') { + console.log(`[TEST] Executing node: ${node.id}`); + return { success: true, data: { nodeId: node.id } }; + } + + console.log(`Executing node: ${node.id}`); + const handler = this.handlerManager.getHandler(node.id); + if (!handler) { + throw new Error(`No handler found for node: ${node.id}`); + } + return handler.run(this); + } +} diff --git a/backend/src/build-system/executor.ts b/backend/src/build-system/executor.ts new file mode 100644 index 00000000..adb7a67f --- /dev/null +++ b/backend/src/build-system/executor.ts @@ -0,0 +1,119 @@ +import { BuilderContext } from './context'; +import { BuildNode, BuildSequence, BuildStep } from './types'; + +export class BuildSequenceExecutor { + constructor(private context: BuilderContext) {} + + private async executeNode(node: BuildNode): Promise { + try { + if (this.context.getState().completed.has(node.id)) { + return; + } + + if (!this.context.canExecute(node.id)) { + console.log(`Waiting for dependencies: ${node.requires?.join(', ')}`); + await new Promise((resolve) => setTimeout(resolve, 100)); // 添加小延迟 + return; + } + + await this.context.run(node.id); + } catch (error) { + console.error(`Error executing node ${node.id}:`, error); + throw error; + } + } + + private async executeStep(step: BuildStep): Promise { + console.log(`Executing build step: ${step.id}`); + + if (step.parallel) { + let remainingNodes = [...step.nodes]; + let lastLength = remainingNodes.length; + let retryCount = 0; + const maxRetries = 10; + + while (remainingNodes.length > 0 && retryCount < maxRetries) { + const executableNodes = remainingNodes.filter((node) => + this.context.canExecute(node.id), + ); + + if (executableNodes.length > 0) { + await Promise.all( + executableNodes.map((node) => this.executeNode(node)), + ); + + remainingNodes = remainingNodes.filter( + (node) => !this.context.getState().completed.has(node.id), + ); + + if (remainingNodes.length < lastLength) { + retryCount = 0; + lastLength = remainingNodes.length; + } else { + retryCount++; + } + } else { + await new Promise((resolve) => setTimeout(resolve, 100)); + retryCount++; + } + } + + if (remainingNodes.length > 0) { + throw new Error( + `Unable to complete all nodes in step ${step.id}. Remaining: ${remainingNodes + .map((n) => n.id) + .join(', ')}`, + ); + } + } else { + for (const node of step.nodes) { + let retryCount = 0; + const maxRetries = 10; + + while ( + !this.context.getState().completed.has(node.id) && + retryCount < maxRetries + ) { + await this.executeNode(node); + + if (!this.context.getState().completed.has(node.id)) { + await new Promise((resolve) => setTimeout(resolve, 100)); + retryCount++; + } + } + + if (!this.context.getState().completed.has(node.id)) { + // TODO: change to error log + console.warn( + `Failed to execute node ${node.id} after ${maxRetries} attempts`, + ); + } + } + } + } + + async executeSequence(sequence: BuildSequence): Promise { + console.log(`Starting build sequence: ${sequence.id}`); + + for (const step of sequence.steps) { + await this.executeStep(step); + + const incompletedNodes = step.nodes.filter( + (node) => !this.context.getState().completed.has(node.id), + ); + + if (incompletedNodes.length > 0) { + // TODO: change to error log + console.warn( + `Step ${step.id} failed to complete nodes: ${incompletedNodes + .map((n) => n.id) + .join(', ')}`, + ); + return; + } + } + + console.log(`Build sequence completed: ${sequence.id}`); + console.log('Final state:', this.context.getState()); + } +} diff --git a/backend/src/build-system/hanlder-manager.ts b/backend/src/build-system/hanlder-manager.ts new file mode 100644 index 00000000..67b45dcd --- /dev/null +++ b/backend/src/build-system/hanlder-manager.ts @@ -0,0 +1,35 @@ +import { ProjectInitHandler } from './node/project-init'; +import { BuildHandler } from './types'; + +export class BuildHandlerManager { + private static instance: BuildHandlerManager; + private handlers: Map = new Map(); + + private constructor() { + this.registerBuiltInHandlers(); + } + + private registerBuiltInHandlers() { + const builtInHandlers: BuildHandler[] = [new ProjectInitHandler()]; + + for (const handler of builtInHandlers) { + this.handlers.set(handler.id, handler); + } + } + + static getInstance(): BuildHandlerManager { + if (!BuildHandlerManager.instance) { + BuildHandlerManager.instance = new BuildHandlerManager(); + } + return BuildHandlerManager.instance; + } + + getHandler(nodeId: string): BuildHandler | undefined { + return this.handlers.get(nodeId); + } + + clear(): void { + this.handlers.clear(); + this.registerBuiltInHandlers(); + } +} diff --git a/backend/src/build-system/node/project-init.ts b/backend/src/build-system/node/project-init.ts new file mode 100644 index 00000000..767b5575 --- /dev/null +++ b/backend/src/build-system/node/project-init.ts @@ -0,0 +1,20 @@ +import { BuilderContext } from '../context'; +import { BuildHandlerManager } from '../hanlder-manager'; +import { BuildHandler, BuildResult } from '../types'; + +export class ProjectInitHandler implements BuildHandler { + readonly id = 'op:PROJECT::STATE:SETUP'; + + async run(context: BuilderContext): Promise { + console.log('Setting up project...'); + const result = { + projectName: 'example', + path: '/path/to/project', + }; + context.setData('projectConfig', result); + return { + success: true, + data: result, + }; + } +} diff --git a/backend/src/build-system/project-builder.module.ts b/backend/src/build-system/project-builder.module.ts new file mode 100644 index 00000000..6e3682d6 --- /dev/null +++ b/backend/src/build-system/project-builder.module.ts @@ -0,0 +1,12 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { HttpModule } from '@nestjs/axios'; +import { ChatProxyService } from 'src/chat/chat.service'; +import { ProjectBuilderService } from './project-builder.service'; + +@Module({ + imports: [HttpModule], + providers: [ProjectBuilderService, ChatProxyService], + exports: [ProjectBuilderService], +}) +export class ProjectBuilderModule {} diff --git a/backend/src/build-system/project-builder.service.ts b/backend/src/build-system/project-builder.service.ts new file mode 100644 index 00000000..6f4c2d28 --- /dev/null +++ b/backend/src/build-system/project-builder.service.ts @@ -0,0 +1,18 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { ChatProxyService } from 'src/chat/chat.service'; +import { ModelProvider } from 'src/common/model-provider'; + +@Injectable() +export class ProjectBuilderService { + private readonly logger = new Logger(ProjectBuilderService.name); + + private models: ModelProvider = ModelProvider.getInstance(); + constructor(private chatProxyService: ChatProxyService) {} + + async createProject(input: { + name: string; + projectDescription: string; + }): Promise { + this.logger.log(`Creating project: ${input.name}`); + } +} diff --git a/backend/src/build-system/types.ts b/backend/src/build-system/types.ts new file mode 100644 index 00000000..47a88bc3 --- /dev/null +++ b/backend/src/build-system/types.ts @@ -0,0 +1,79 @@ +import { BuilderContext } from './context'; + +export type BuildNodeType = + | 'PROJECT_SETUP' + | 'ANALYSIS' + | 'DATABASE' + | 'BACKEND' + | 'UX' + | 'WEBAPP'; + +export type BuildSubType = { + ANALYSIS: 'PRD' | 'FRD' | 'DRD' | 'BRD' | 'UXSD' | 'UXDD'; + DATABASE: 'SCHEMAS' | 'POSTGRES'; + BACKEND: 'OPENAPI' | 'ASYNCAPI' | 'SERVER'; + UX: 'SITEMAP' | 'DATAMAP' | 'VIEWS'; + WEBAPP: 'STORE' | 'ROOT' | 'VIEW'; + PROJECT_SETUP: never; +}; + +export interface BuildBase { + id: string; + name: string; + description?: string; + requires?: string[]; +} + +export interface BuildNode extends BuildBase { + type: BuildNodeType; + subType?: BuildSubType[BuildNodeType]; + config?: Record; +} + +export interface BuildStep { + id: string; + name: string; + description?: string; + parallel?: boolean; + nodes: BuildNode[]; +} + +export interface BuildSequence { + id: string; + version: string; + name: string; + description?: string; + steps: BuildStep[]; +} + +export interface BuildHandlerContext { + data: Record; + run: (nodeId: string) => Promise; +} + +export interface BuildHandlerRegistry { + [key: string]: BuildHandler; +} +export interface BuildContext { + data: Record; + completedNodes: Set; + pendingNodes: Set; +} + +export interface BuildResult { + success: boolean; + data?: any; + error?: Error; +} + +export interface BuildExecutionState { + completed: Set; + pending: Set; + failed: Set; + waiting: Set; +} + +export interface BuildHandler { + id: string; + run(context: BuilderContext): Promise; +} diff --git a/backend/src/chat/chat.service.ts b/backend/src/chat/chat.service.ts index e1b356ad..45b39766 100644 --- a/backend/src/chat/chat.service.ts +++ b/backend/src/chat/chat.service.ts @@ -10,211 +10,27 @@ import { NewChatInput, UpdateChatTitleInput, } from 'src/chat/dto/chat.input'; +import { CustomAsyncIterableIterator } from 'src/common/model-provider/types'; +import { ModelProvider } from 'src/common/model-provider'; -type CustomAsyncIterableIterator = AsyncIterator & { - [Symbol.asyncIterator](): AsyncIterableIterator; -}; @Injectable() export class ChatProxyService { private readonly logger = new Logger('ChatProxyService'); + private models: ModelProvider; - constructor(private httpService: HttpService) {} + constructor(private httpService: HttpService) { + this.models = ModelProvider.getInstance(); + } streamChat( input: ChatInput, ): CustomAsyncIterableIterator { - this.logger.debug( - `Request chat input: ${input.message} with model: ${input.model}`, - ); - let isDone = false; - let responseSubscription: any; - const chunkQueue: ChatCompletionChunk[] = []; - let resolveNextChunk: - | ((value: IteratorResult) => void) - | null = null; - - const iterator: CustomAsyncIterableIterator = { - next: () => { - return new Promise>((resolve) => { - if (chunkQueue.length > 0) { - resolve({ done: false, value: chunkQueue.shift()! }); - } else if (isDone) { - resolve({ done: true, value: undefined }); - } else { - resolveNextChunk = resolve; - } - }); - }, - return: () => { - isDone = true; - if (responseSubscription) { - responseSubscription.unsubscribe(); - } - return Promise.resolve({ done: true, value: undefined }); - }, - throw: (error) => { - isDone = true; - if (responseSubscription) { - responseSubscription.unsubscribe(); - } - return Promise.reject(error); - }, - [Symbol.asyncIterator]() { - return this; - }, - }; - - responseSubscription = this.httpService - .post( - 'http://localhost:3001/chat/completion', - { content: input.message, model: input.model }, - { responseType: 'stream' }, - ) - .subscribe({ - next: (response) => { - let buffer = ''; - response.data.on('data', (chunk: Buffer) => { - buffer += chunk.toString(); - let newlineIndex; - while ((newlineIndex = buffer.indexOf('\n')) !== -1) { - const line = buffer.slice(0, newlineIndex).trim(); - buffer = buffer.slice(newlineIndex + 1); - - if (line.startsWith('data: ')) { - const jsonStr = line.slice(6); - // TODO: don't remove rn - if (jsonStr === '[DONE]') { - return; - } - // if (jsonStr === '[DONE]') { - // const doneChunk: ChatCompletionChunk = { - // id: 'done', - // object: 'chat.completion.chunk', - // created: Date.now(), - // model: '', - // systemFingerprint: null, - // choices: [], - // status: StreamStatus.DONE, - // }; - - // if (resolveNextChunk) { - // resolveNextChunk({ done: false, value: doneChunk }); - // resolveNextChunk = null; - // } else { - // chunkQueue.push(doneChunk); - // } - // return; - // } - try { - const parsed = JSON.parse(jsonStr); - if (this.isValidChunk(parsed)) { - const parsedChunk: ChatCompletionChunk = { - ...parsed, - status: StreamStatus.STREAMING, - }; - - if (resolveNextChunk) { - resolveNextChunk({ done: false, value: parsedChunk }); - resolveNextChunk = null; - } else { - chunkQueue.push(parsedChunk); - } - } else { - this.logger.warn('Invalid chunk received:', parsed); - } - } catch (error) { - this.logger.error('Error parsing chunk:', error); - } - } - } - }); - response.data.on('end', () => { - this.logger.debug('Stream ended'); - if (!isDone) { - const doneChunk: ChatCompletionChunk = { - id: 'done', - object: 'chat.completion.chunk', - created: Date.now(), - model: 'gpt-3.5-turbo', - systemFingerprint: null, - choices: [], - status: StreamStatus.DONE, - }; - - if (resolveNextChunk) { - resolveNextChunk({ done: false, value: doneChunk }); - resolveNextChunk = null; - } else { - chunkQueue.push(doneChunk); - } - } - - setTimeout(() => { - isDone = true; - if (resolveNextChunk) { - resolveNextChunk({ done: true, value: undefined }); - resolveNextChunk = null; - } - }, 0); - }); - }, - error: (error) => { - this.logger.error('Error in stream:', error); - const doneChunk: ChatCompletionChunk = { - id: 'done', - object: 'chat.completion.chunk', - created: Date.now(), - model: 'gpt-3.5-turbo', - systemFingerprint: null, - choices: [], - status: StreamStatus.DONE, - }; - - if (resolveNextChunk) { - resolveNextChunk({ done: false, value: doneChunk }); - setTimeout(() => { - isDone = true; - resolveNextChunk?.({ done: true, value: undefined }); - resolveNextChunk = null; - }, 0); - } else { - chunkQueue.push(doneChunk); - setTimeout(() => { - isDone = true; - }, 0); - } - }, - }); - - return iterator; - } - - private isValidChunk(chunk: any): boolean { - return ( - chunk && - typeof chunk.id === 'string' && - typeof chunk.object === 'string' && - typeof chunk.created === 'number' && - typeof chunk.model === 'string' - ); + return this.models.chat(input.message, input.model, input.chatId); } async fetchModelTags(): Promise { - try { - this.logger.debug('Requesting model tags from /tags endpoint.'); - - // Make a GET request to /tags - const response = await this.httpService - .get('http://localhost:3001/tags', { responseType: 'json' }) - .toPromise(); - - this.logger.debug('Model tags received:', response.data); - return response.data; - } catch (error) { - this.logger.error('Error fetching model tags:', error); - throw new Error('Failed to fetch model tags'); - } + return this.models.fetchModelsName(); } } diff --git a/backend/src/chat/dto/chat.input.ts b/backend/src/chat/dto/chat.input.ts index 2a3bde2a..feeb738c 100644 --- a/backend/src/chat/dto/chat.input.ts +++ b/backend/src/chat/dto/chat.input.ts @@ -16,6 +16,7 @@ export class UpdateChatTitleInput { title: string; } +// TODO: using ChatInput in model-provider.ts @InputType('ChatInputType') export class ChatInput { @Field() diff --git a/backend/src/common/model-provider/index.ts b/backend/src/common/model-provider/index.ts new file mode 100644 index 00000000..12f133a8 --- /dev/null +++ b/backend/src/common/model-provider/index.ts @@ -0,0 +1,267 @@ +import { Logger } from '@nestjs/common'; +import { HttpService } from '@nestjs/axios'; +import { ChatCompletionChunk, StreamStatus } from 'src/chat/chat.model'; + +export interface ChatInput { + content: string; + attachments?: Array<{ + type: string; + content: string | Buffer; + name?: string; + }>; + contextLength?: number; + temperature?: number; +} + +export interface ModelProviderConfig { + endpoint: string; + defaultModel?: string; +} + +export interface CustomAsyncIterableIterator extends AsyncIterator { + [Symbol.asyncIterator](): AsyncIterableIterator; +} + +export class ModelProvider { + private readonly logger = new Logger('ModelProvider'); + private isDone = false; + private responseSubscription: any; + private chunkQueue: ChatCompletionChunk[] = []; + private resolveNextChunk: + | ((value: IteratorResult) => void) + | null = null; + + private static instance: ModelProvider | undefined = undefined; + + public static getInstance() { + if (this.instance) { + return this.instance; + } + + return new ModelProvider(new HttpService(), { + // TODO: adding into env + endpoint: 'http://localhost:3001', + }); + } + + constructor( + private readonly httpService: HttpService, + private readonly config: ModelProviderConfig, + ) {} + + chat( + input: ChatInput | string, + model?: string, + chatId?: string, + ): CustomAsyncIterableIterator { + const chatInput = this.normalizeChatInput(input); + const selectedModel = model || this.config.defaultModel || undefined; + if (selectedModel === undefined) { + this.logger.error('No model selected for chat request'); + return; + } + + this.logger.debug( + `Chat request - Model: ${selectedModel}, ChatId: ${chatId || 'N/A'}`, + { input: chatInput }, + ); + + const iterator: CustomAsyncIterableIterator = { + next: () => this.handleNext(), + return: () => this.handleReturn(), + throw: (error) => this.handleThrow(error), + [Symbol.asyncIterator]() { + return this; + }, + }; + + this.startChat(chatInput, selectedModel, chatId); + return iterator; + } + + private normalizeChatInput(input: ChatInput | string): ChatInput { + if (typeof input === 'string') { + return { content: input }; + } + return input; + } + + private handleNext(): Promise> { + return new Promise>((resolve) => { + if (this.chunkQueue.length > 0) { + resolve({ done: false, value: this.chunkQueue.shift()! }); + } else if (this.isDone) { + resolve({ done: true, value: undefined }); + } else { + this.resolveNextChunk = resolve; + } + }); + } + + private handleReturn(): Promise> { + this.cleanup(); + return Promise.resolve({ done: true, value: undefined }); + } + + private handleThrow( + error: any, + ): Promise> { + this.cleanup(); + return Promise.reject(error); + } + + private cleanup() { + this.isDone = true; + if (this.responseSubscription) { + this.responseSubscription.unsubscribe(); + } + } + + private createRequestPayload( + input: ChatInput, + model: string, + chatId?: string, + ) { + return { + ...input, + model, + ...(chatId && { chatId }), + }; + } + + private createDoneChunk(model: string): ChatCompletionChunk { + return { + id: 'done', + object: 'chat.completion.chunk', + created: Date.now(), + model, + systemFingerprint: null, + choices: [], + status: StreamStatus.DONE, + }; + } + + private handleChunk(chunk: any) { + if (this.isValidChunk(chunk)) { + const parsedChunk: ChatCompletionChunk = { + ...chunk, + status: StreamStatus.STREAMING, + }; + + if (this.resolveNextChunk) { + this.resolveNextChunk({ done: false, value: parsedChunk }); + this.resolveNextChunk = null; + } else { + this.chunkQueue.push(parsedChunk); + } + } else { + this.logger.warn('Invalid chunk received:', chunk); + } + } + + private handleStreamEnd(model: string) { + this.logger.debug('Stream ended'); + if (!this.isDone) { + const doneChunk = this.createDoneChunk(model); + if (this.resolveNextChunk) { + this.resolveNextChunk({ done: false, value: doneChunk }); + this.resolveNextChunk = null; + } else { + this.chunkQueue.push(doneChunk); + } + } + + setTimeout(() => { + this.isDone = true; + if (this.resolveNextChunk) { + this.resolveNextChunk({ done: true, value: undefined }); + this.resolveNextChunk = null; + } + }, 0); + } + + private handleStreamError(error: any, model: string) { + this.logger.error('Error in stream:', error); + const doneChunk = this.createDoneChunk(model); + + if (this.resolveNextChunk) { + this.resolveNextChunk({ done: false, value: doneChunk }); + setTimeout(() => { + this.isDone = true; + this.resolveNextChunk?.({ done: true, value: undefined }); + this.resolveNextChunk = null; + }, 0); + } else { + this.chunkQueue.push(doneChunk); + setTimeout(() => { + this.isDone = true; + }, 0); + } + } + + private startChat(input: ChatInput, model: string, chatId?: string) { + const payload = this.createRequestPayload(input, model, chatId); + + this.responseSubscription = this.httpService + .post(`${this.config.endpoint}/chat/completion`, payload, { + responseType: 'stream', + headers: { + 'Content-Type': 'application/json', + }, + }) + .subscribe({ + next: (response) => { + let buffer = ''; + response.data.on('data', (chunk: Buffer) => { + buffer += chunk.toString(); + let newlineIndex; + while ((newlineIndex = buffer.indexOf('\n')) !== -1) { + const line = buffer.slice(0, newlineIndex).trim(); + buffer = buffer.slice(newlineIndex + 1); + + if (line.startsWith('data: ')) { + const jsonStr = line.slice(6); + if (jsonStr === '[DONE]') { + return; + } + try { + const parsed = JSON.parse(jsonStr); + this.handleChunk(parsed); + } catch (error) { + this.logger.error('Error parsing chunk:', error); + } + } + } + }); + response.data.on('end', () => this.handleStreamEnd(model)); + }, + error: (error) => this.handleStreamError(error, model), + }); + } + + private isValidChunk(chunk: any): boolean { + return ( + chunk && + typeof chunk.id === 'string' && + typeof chunk.object === 'string' && + typeof chunk.created === 'number' && + typeof chunk.model === 'string' + ); + } + + public async fetchModelsName() { + try { + this.logger.debug('Requesting model tags from /tags endpoint.'); + + // Make a GET request to /tags + const response = await this.httpService + .get(`${this.config.endpoint}/tags`, { responseType: 'json' }) + .toPromise(); + this.logger.debug('Model tags received:', response.data); + return response.data; + } catch (error) { + this.logger.error('Error fetching model tags:', error); + throw new Error('Failed to fetch model tags'); + } + } +} diff --git a/backend/src/common/model-provider/types.ts b/backend/src/common/model-provider/types.ts new file mode 100644 index 00000000..8c649851 --- /dev/null +++ b/backend/src/common/model-provider/types.ts @@ -0,0 +1,7 @@ +export interface ModelChatStreamConfig { + endpoint: string; + model?: string; +} +export type CustomAsyncIterableIterator = AsyncIterator & { + [Symbol.asyncIterator](): AsyncIterableIterator; +}; diff --git a/backend/src/project/__tests__/project.service.spec.ts b/backend/src/project/__tests__/project.service.spec.ts deleted file mode 100644 index 860924ba..00000000 --- a/backend/src/project/__tests__/project.service.spec.ts +++ /dev/null @@ -1,300 +0,0 @@ -import { Test, TestingModule } from '@nestjs/testing'; -import { getRepositoryToken } from '@nestjs/typeorm'; -import { Repository } from 'typeorm'; -import { ProjectService } from '../project.service'; -import { Project } from '../project.model'; -import { ProjectPackages } from '../project-packages.model'; -import { - NotFoundException, - InternalServerErrorException, -} from '@nestjs/common'; -import { UpsertProjectInput } from '../dto/project.input'; -import { User } from 'src/user/user.model'; - -describe('ProjectsService', () => { - let service: ProjectService; - let projectRepository: Repository; - let packageRepository: Repository; - - const mockProject: Project = { - id: '1', - projectName: 'Test Project 1', - path: '/test/path1', - userId: 'user1', - isDeleted: false, - isActive: true, - createdAt: new Date(), - updatedAt: new Date(), - projectPackages: [], - user: new User(), - }; - - beforeEach(async () => { - const module: TestingModule = await Test.createTestingModule({ - providers: [ - ProjectService, - { - provide: getRepositoryToken(Project), - useValue: { - find: jest.fn().mockResolvedValue([mockProject]), - findOne: jest.fn().mockResolvedValue(mockProject), - create: jest.fn().mockReturnValue(mockProject), - save: jest.fn().mockResolvedValue(mockProject), - update: jest.fn().mockResolvedValue({ affected: 1 }), - }, - }, - { - provide: getRepositoryToken(ProjectPackages), - useValue: { - create: jest.fn().mockImplementation((dto) => ({ - id: 'package-1', - ...dto, - is_deleted: false, - is_active: true, - })), - save: jest.fn().mockImplementation((dto) => Promise.resolve(dto)), - findOne: jest.fn(), - }, - }, - ], - }).compile(); - - service = module.get(ProjectService); - projectRepository = module.get>( - getRepositoryToken(Project), - ); - packageRepository = module.get>( - getRepositoryToken(ProjectPackages), - ); - }); - - describe('getProjectsByUser', () => { - it('should return projects for a user', async () => { - // Act - const result = await service.getProjectsByUser('user1'); - - // Assert - expect(result).toEqual([mockProject]); - }); - - it('should filter out deleted packages', async () => { - // Arrange - const projectWithPackages: Project = { - ...mockProject, - projectPackages: [], - user: new User(), - projectName: '', - userId: '', - }; - jest - .spyOn(projectRepository, 'find') - .mockResolvedValue([projectWithPackages]); - - // Act - const result = await service.getProjectsByUser('user1'); - - // Assert - }); - - it('should throw NotFoundException when no projects found', async () => { - // Arrange - jest.spyOn(projectRepository, 'find').mockResolvedValue([]); - - // Act & Assert - await expect(service.getProjectsByUser('user1')).rejects.toThrow( - NotFoundException, - ); - }); - }); - - describe('upsertProject', () => { - describe('create new project', () => { - it('should create a new project with packages', async () => { - // Arrange - const upsertInput: UpsertProjectInput = { - projectName: 'New Project', - path: '/new/path', - projectId: undefined, - projectPackages: ['package1', 'package2'], - }; - - const createdProject: Project = { - ...mockProject, - projectName: upsertInput.projectName, - path: upsertInput.path, - user: new User(), - userId: '', - }; - - jest.spyOn(projectRepository, 'findOne').mockResolvedValue(null); - jest.spyOn(projectRepository, 'create').mockReturnValue(createdProject); - jest.spyOn(projectRepository, 'save').mockResolvedValue(createdProject); - - // Act - const result = await service.upsertProject(upsertInput, 'user1'); - - // Assert - expect(projectRepository.create).toHaveBeenCalledWith({ - projectName: upsertInput.projectName, - path: upsertInput.path, - userId: 'user1', - }); - expect(packageRepository.create).toHaveBeenCalledTimes(2); - expect(packageRepository.save).toHaveBeenCalled(); - expect(result).toBeDefined(); - }); - }); - - describe('update existing project', () => { - it('should update project and add new packages', async () => { - // Arrange - const upsertInput: UpsertProjectInput = { - projectId: '1', - projectName: 'Updated Project', - path: '/updated/path', - projectPackages: ['new-package'], - }; - - const existingProject: Project = { - ...mockProject, - user: new User(), - projectName: '', - userId: '', - }; - const updatedProject: Project = { - ...existingProject, - projectName: upsertInput.projectName, - path: upsertInput.path, - }; - - jest - .spyOn(projectRepository, 'findOne') - .mockResolvedValueOnce(existingProject) // First call for finding project - .mockResolvedValueOnce(updatedProject); // Second call for final result - - jest.spyOn(projectRepository, 'save').mockResolvedValue(updatedProject); - - // Act - const result = await service.upsertProject(upsertInput, 'user1'); - - expect(packageRepository.create).toHaveBeenCalledWith( - expect.objectContaining({ - project: expect.any(Object), - content: 'new-package', - }), - ); - }); - - it('should not create packages if none provided', async () => { - // Arrange - const upsertInput: UpsertProjectInput = { - projectId: '1', - projectName: 'Updated Project', - path: '/updated/path', - projectPackages: [], - }; - - // Act - await service.upsertProject(upsertInput, 'user1'); - - // Assert - expect(packageRepository.create).not.toHaveBeenCalled(); - expect(packageRepository.save).not.toHaveBeenCalled(); - }); - }); - }); - - describe('deleteProject', () => { - it('should soft delete project and its packages', async () => { - // Arrange - const projectWithPackages: Project = { - ...mockProject, - projectPackages: [], - user: new User(), - projectName: '', - userId: '', - }; - jest - .spyOn(projectRepository, 'findOne') - .mockResolvedValue(projectWithPackages); - - // Act - const result = await service.deleteProject('1'); - - // Assert - expect(result).toBe(true); - }); - - it('should throw NotFoundException for non-existent project', async () => { - // Arrange - jest.spyOn(projectRepository, 'findOne').mockResolvedValue(null); - - // Act & Assert - await expect(service.deleteProject('999')).rejects.toThrow( - NotFoundException, - ); - }); - }); - - describe('removePackageFromProject', () => { - it('should soft delete a single package', async () => { - // Arrange - - const packageToRemove: ProjectPackages = { - id: 'pkg1', - isDeleted: false, - isActive: true, - project_id: '1', - content: '', - project: new Project(), - createdAt: undefined, - updatedAt: undefined, - }; - jest - .spyOn(packageRepository, 'findOne') - .mockResolvedValue(packageToRemove); - - // Act - const result = await service.removePackageFromProject('1', 'pkg1'); - - // Assert - expect(result).toBe(true); - }); - - it('should throw NotFoundException for non-existent package', async () => { - // Arrange - jest.spyOn(packageRepository, 'findOne').mockResolvedValue(null); - - // Act & Assert - await expect( - service.removePackageFromProject('1', 'non-existent'), - ).rejects.toThrow(NotFoundException); - }); - }); - - describe('updateProjectPath', () => { - it('should update project path', async () => { - // Arrange - const newPath = '/updated/path'; - - // Act - const result = await service.updateProjectPath('1', newPath); - - // Assert - expect(result).toBe(true); - expect(projectRepository.update).toHaveBeenCalledWith('1', { - path: newPath, - }); - }); - - it('should throw NotFoundException for non-existent project', async () => { - // Arrange - jest.spyOn(projectRepository, 'findOne').mockResolvedValue(null); - - // Act & Assert - await expect( - service.updateProjectPath('999', '/new/path'), - ).rejects.toThrow(NotFoundException); - }); - }); -}); diff --git a/backend/src/user/__tests__/dto/login-user.input.spec.ts b/backend/src/user/__tests__/dto/login-user.input.spec.ts deleted file mode 100644 index c81b1a7d..00000000 --- a/backend/src/user/__tests__/dto/login-user.input.spec.ts +++ /dev/null @@ -1,235 +0,0 @@ -import { validate } from 'class-validator'; -import { Menu } from 'src/auth/menu/menu.model'; -import { Role } from 'src/auth/role/role.model'; -import { User } from 'src/user/user.model'; -import { DataSource } from 'typeorm'; - -describe('User Model', () => { - let user: User; - let dataSource: DataSource; - - beforeEach(async () => { - user = new User(); - - // Initialize test database connection - dataSource = new DataSource({ - type: 'sqlite', - database: ':memory:', - entities: [User, Role, Menu], - synchronize: true, - dropSchema: true, - }); - await dataSource.initialize(); - }); - - afterEach(async () => { - if (dataSource && dataSource.isInitialized) { - await dataSource.destroy(); - } - }); - - describe('Basic Validation', () => { - it('should validate a valid user', async () => { - // Arrange - user.username = 'testuser'; - user.email = 'test@example.com'; - user.password = 'password123'; - - // Act - const errors = await validate(user); - - // Assert - expect(errors.length).toBe(0); - }); - - it('should fail validation with invalid email', async () => { - // Arrange - user.username = 'testuser'; - user.email = 'invalid-email'; - user.password = 'password123'; - - // Act - const errors = await validate(user); - - // Assert - expect(errors.length).toBeGreaterThan(0); - expect(errors[0].constraints).toHaveProperty('isEmail'); - }); - - it('should fail validation with empty email', async () => { - // Arrange - user.username = 'testuser'; - user.email = ''; - user.password = 'password123'; - - // Act - const errors = await validate(user); - - // Assert - expect(errors.length).toBeGreaterThan(0); - }); - }); - - describe('Database Constraints', () => { - it('should enforce unique username constraint', async () => { - // Arrange - const userRepo = dataSource.getRepository(User); - - const user1 = new User(); - user1.username = 'testuser'; - user1.email = 'test1@example.com'; - user1.password = 'password123'; - - const user2 = new User(); - user2.username = 'testuser'; // Same username - user2.email = 'test2@example.com'; - user2.password = 'password123'; - - // Act & Assert - await userRepo.save(user1); - await expect(userRepo.save(user2)).rejects.toThrow(); - }); - - it('should enforce unique email constraint', async () => { - // Arrange - const userRepo = dataSource.getRepository(User); - - const user1 = new User(); - user1.username = 'user1'; - user1.email = 'test@example.com'; - user1.password = 'password123'; - - const user2 = new User(); - user2.username = 'user2'; - user2.email = 'test@example.com'; // Same email - user2.password = 'password123'; - - // Act & Assert - await userRepo.save(user1); - await expect(userRepo.save(user2)).rejects.toThrow(); - }); - }); - - describe('Role Relationship', () => { - it('should allow adding roles to user', async () => { - // Arrange - const userRepo = dataSource.getRepository(User); - const roleRepo = dataSource.getRepository(Role); - - // Create test role - const role = new Role(); - role.name = 'TEST_ROLE'; - await roleRepo.save(role); - - // Create user with role - user.username = 'testuser'; - user.email = 'test@example.com'; - user.password = 'password123'; - user.roles = [role]; - - // Act - const savedUser = await userRepo.save(user); - const foundUser = await userRepo.findOne({ - where: { id: savedUser.id }, - relations: ['roles'], - }); - - // Assert - expect(foundUser.roles).toBeDefined(); - expect(foundUser.roles.length).toBe(1); - expect(foundUser.roles[0].name).toBe('TEST_ROLE'); - }); - - it('should allow multiple roles for a user', async () => { - // Arrange - const userRepo = dataSource.getRepository(User); - const roleRepo = dataSource.getRepository(Role); - - // Create test roles - const role1 = new Role(); - role1.name = 'ROLE_1'; - await roleRepo.save(role1); - - const role2 = new Role(); - role2.name = 'ROLE_2'; - await roleRepo.save(role2); - - // Create user with multiple roles - user.username = 'testuser'; - user.email = 'test@example.com'; - user.password = 'password123'; - user.roles = [role1, role2]; - - // Act - const savedUser = await userRepo.save(user); - const foundUser = await userRepo.findOne({ - where: { id: savedUser.id }, - relations: ['roles'], - }); - - // Assert - expect(foundUser.roles).toBeDefined(); - expect(foundUser.roles.length).toBe(2); - expect(foundUser.roles.map((r) => r.name)).toContain('ROLE_1'); - expect(foundUser.roles.map((r) => r.name)).toContain('ROLE_2'); - }); - }); - - describe('SystemBaseModel Integration', () => { - it('should have created_at and updated_at fields', async () => { - // Arrange - const userRepo = dataSource.getRepository(User); - user.username = 'testuser'; - user.email = 'test@example.com'; - user.password = 'password123'; - - // Act - const savedUser = await userRepo.save(user); - - // Assert - expect(savedUser.createdAt).toBeDefined(); - expect(savedUser.updatedAt).toBeDefined(); - expect(savedUser.createdAt instanceof Date).toBeTruthy(); - expect(savedUser.updatedAt instanceof Date).toBeTruthy(); - }); - - it('should update updated_at on user modification', async () => { - // Arrange - const userRepo = dataSource.getRepository(User); - user.username = 'testuser'; - user.email = 'test@example.com'; - user.password = 'password123'; - - // Act - const savedUser = await userRepo.save(user); - const originalUpdatedAt = savedUser.updatedAt; - - // Wait a bit to ensure different timestamp - await new Promise((resolve) => setTimeout(resolve, 100)); - - savedUser.username = 'newusername'; - const updatedUser = await userRepo.save(savedUser); - - // Assert - expect(updatedUser.updatedAt.getTime()).toBeGreaterThanOrEqual( - originalUpdatedAt.getTime(), - ); - }); - }); - - describe('Data Type Validation', () => { - it('should generate UUID for id field', async () => { - // Arrange - const userRepo = dataSource.getRepository(User); - user.username = 'testuser'; - user.email = 'test@example.com'; - user.password = 'password123'; - - // Act - const savedUser = await userRepo.save(user); - - // Assert - expect(savedUser.id).toBeDefined(); - }); - }); -}); diff --git a/backend/src/user/__tests__/user.resolver.spec.ts b/backend/src/user/__tests__/user.resolver.spec.ts deleted file mode 100644 index 7ddd3d72..00000000 --- a/backend/src/user/__tests__/user.resolver.spec.ts +++ /dev/null @@ -1,104 +0,0 @@ -import { Test, TestingModule } from '@nestjs/testing'; -import { UserResolver } from '../user.resolver'; -import { AuthService } from 'src/auth/auth.service'; -import { UserService } from '../user.service'; -import { RegisterUserInput } from '../dto/register-user.input'; -import { User } from '../user.model'; -import { LoginUserInput } from '../dto/login-user.input'; - -describe('UserResolver', () => { - let resolver: UserResolver; - let authService: AuthService; - - beforeEach(async () => { - const module: TestingModule = await Test.createTestingModule({ - providers: [ - UserResolver, - { - provide: UserService, - useValue: { - findOneByUsername: jest.fn(), - }, - }, - { - provide: AuthService, - useValue: { - register: jest.fn(), - login: jest.fn(), - logout: jest.fn(), - }, - }, - ], - }).compile(); - - resolver = module.get(UserResolver); - authService = module.get(AuthService); - }); - - it('should be defined', () => { - expect(resolver).toBeDefined(); - }); - - describe('registerUser', () => { - it('should successfully register a new user', async () => { - // Arrange - const registerInput: RegisterUserInput = { - username: 'testuser', - email: 'test@example.com', - password: 'password123', - }; - const mockUser: User = { - id: '1', - ...registerInput, - password: 'hashedPassword', - roles: [], - createdAt: undefined, - isActive: false, - isDeleted: false, - updatedAt: undefined, - }; - jest.spyOn(authService, 'register').mockResolvedValue(mockUser); - - // Act - const result = await resolver.registerUser(registerInput); - - // Assert - expect(result).toEqual(mockUser); - expect(authService.register).toHaveBeenCalledWith(registerInput); - }); - }); - - describe('login', () => { - it('should successfully login user', async () => { - // Arrange - const loginInput: LoginUserInput = { - username: 'testuser', - password: 'password123', - }; - const mockResponse = { access_token: 'jwt-token' }; - jest.spyOn(authService, 'login').mockResolvedValue(mockResponse); - - // Act - const result = await resolver.login(loginInput); - - // Assert - expect(result).toEqual(mockResponse); - expect(authService.login).toHaveBeenCalledWith(loginInput); - }); - }); - - describe('logout', () => { - it('should successfully logout user', async () => { - // Arrange - const token = 'valid-token'; - jest.spyOn(authService, 'logout').mockResolvedValue(true); - - // Act - const result = await resolver.logout(token); - - // Assert - expect(result).toBe(true); - expect(authService.logout).toHaveBeenCalledWith(token); - }); - }); -});