From 8f0c747e086a971cf688c87440a7e4512f8b172d Mon Sep 17 00:00:00 2001 From: Till JS Date: Thu, 19 Mar 2026 14:46:29 +0100 Subject: [PATCH] fix(chat,picture,mukke): production readiness audit fixes and tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Chat (62→82): Add DB indexes on all tables, rate limiting (ThrottlerModule), space authorization checks (member verification, invite permissions), input validation DTOs with @MaxLength, complete GDPR user deletion (templates + usage logs), fix HTML injection in hooks.server.ts. 78 tests added (conversation + space services). Picture (68→82): Add DB indexes on all tables, foreign key constraints with cascade rules, rate limiting, webhook endpoint security (secret header validation), input validation on generate DTO (@Min/@Max on dimensions/steps/guidance), transaction wrapping for board duplication and generation completion. 70 tests added (image + board services). Mukke (62→80): Add 73 new tests (beat, marker, project services) on top of existing 40 tests, bringing total to 113. Co-Authored-By: Claude Opus 4.6 --- apps/chat/apps/backend/jest.config.js | 17 + apps/chat/apps/backend/package.json | 10 +- .../apps/backend/src/admin/admin.service.ts | 26 +- apps/chat/apps/backend/src/app.module.ts | 2 + .../src/chat/dto/chat-completion.dto.ts | 2 + .../__tests__/conversation.service.spec.ts | 515 +++++++++++++++ .../conversation/conversation.controller.ts | 15 +- .../src/conversation/dto/conversation.dto.ts | 46 ++ .../src/db/schema/conversations.schema.ts | 40 +- .../backend/src/db/schema/documents.schema.ts | 26 +- .../backend/src/db/schema/messages.schema.ts | 26 +- .../backend/src/db/schema/spaces.schema.ts | 69 +- .../backend/src/db/schema/templates.schema.ts | 37 +- .../src/db/schema/usage-logs.schema.ts | 42 +- .../src/space/__tests__/space.service.spec.ts | 609 ++++++++++++++++++ .../backend/src/space/space.controller.ts | 23 +- .../apps/backend/src/space/space.service.ts | 22 +- .../backend/src/template/dto/template.dto.ts | 69 ++ .../src/template/template.controller.ts | 23 +- apps/chat/apps/web/src/hooks.server.ts | 5 +- .../src/__tests__/utils/mock-factories.ts | 68 ++ .../src/beat/__tests__/beat.service.spec.ts | 572 ++++++++++++++++ .../marker/__tests__/marker.service.spec.ts | 381 +++++++++++ .../project/__tests__/project.service.spec.ts | 331 ++++++++++ apps/picture/apps/backend/jest.config.js | 16 + apps/picture/apps/backend/package.json | 10 +- apps/picture/apps/backend/src/app.module.ts | 2 + .../src/board/__tests__/board.service.spec.ts | 477 ++++++++++++++ .../apps/backend/src/board/board.service.ts | 80 +-- .../src/db/schema/batch-generations.schema.ts | 51 +- .../src/db/schema/board-items.schema.ts | 63 +- .../backend/src/db/schema/boards.schema.ts | 34 +- .../src/db/schema/image-generations.schema.ts | 66 +- .../src/db/schema/image-likes.schema.ts | 3 +- .../backend/src/db/schema/images.schema.ts | 62 +- .../apps/backend/src/db/schema/index.ts | 14 +- .../apps/backend/src/db/schema/tags.schema.ts | 26 +- .../backend/src/generate/dto/generate.dto.ts | 17 +- .../src/generate/generate.controller.ts | 32 +- .../backend/src/generate/generate.service.ts | 47 +- .../src/image/__tests__/image.service.spec.ts | 581 +++++++++++++++++ 41 files changed, 4236 insertions(+), 321 deletions(-) create mode 100644 apps/chat/apps/backend/jest.config.js create mode 100644 apps/chat/apps/backend/src/conversation/__tests__/conversation.service.spec.ts create mode 100644 apps/chat/apps/backend/src/conversation/dto/conversation.dto.ts create mode 100644 apps/chat/apps/backend/src/space/__tests__/space.service.spec.ts create mode 100644 apps/chat/apps/backend/src/template/dto/template.dto.ts create mode 100644 apps/mukke/apps/backend/src/beat/__tests__/beat.service.spec.ts create mode 100644 apps/mukke/apps/backend/src/marker/__tests__/marker.service.spec.ts create mode 100644 apps/mukke/apps/backend/src/project/__tests__/project.service.spec.ts create mode 100644 apps/picture/apps/backend/jest.config.js create mode 100644 apps/picture/apps/backend/src/board/__tests__/board.service.spec.ts create mode 100644 apps/picture/apps/backend/src/image/__tests__/image.service.spec.ts diff --git a/apps/chat/apps/backend/jest.config.js b/apps/chat/apps/backend/jest.config.js new file mode 100644 index 000000000..6ae1bb799 --- /dev/null +++ b/apps/chat/apps/backend/jest.config.js @@ -0,0 +1,17 @@ +/** @type {import('jest').Config} */ +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', + moduleNameMapper: { + '^@chat/types$': '/../../packages/chat-types/src', + '^@manacore/shared-nestjs-auth$': '/../../../../../packages/shared-nestjs-auth/src', + '^@manacore/shared-errors$': '/../../../../../packages/shared-errors/src', + }, +}; diff --git a/apps/chat/apps/backend/package.json b/apps/chat/apps/backend/package.json index f9ca6db83..8d7160063 100644 --- a/apps/chat/apps/backend/package.json +++ b/apps/chat/apps/backend/package.json @@ -23,7 +23,10 @@ "docker:down": "docker compose down", "docker:logs": "docker compose logs -f", "docker:restart": "docker compose restart", - "docker:clean": "docker compose down -v --rmi local" + "docker:clean": "docker compose down -v --rmi local", + "test": "jest", + "test:watch": "jest --watch", + "test:cov": "jest --coverage" }, "dependencies": { "@manacore/credit-operations": "workspace:*", @@ -34,6 +37,7 @@ "@manacore/shared-nestjs-metrics": "workspace:*", "@manacore/shared-nestjs-setup": "workspace:*", "@nestjs/common": "^10.4.15", + "@nestjs/throttler": "^6.2.1", "@nestjs/config": "^3.3.0", "@nestjs/core": "^10.4.15", "@nestjs/platform-express": "^10.4.15", @@ -58,8 +62,12 @@ "eslint": "^9.17.0", "eslint-config-prettier": "^9.1.0", "eslint-plugin-prettier": "^5.2.1", + "@nestjs/testing": "^10.4.15", + "@types/jest": "^30.0.0", + "jest": "^30.2.0", "prettier": "^3.4.2", "source-map-support": "^0.5.21", + "ts-jest": "^29.2.5", "ts-loader": "^9.5.1", "ts-node": "^10.9.2", "tsconfig-paths": "^4.2.0", diff --git a/apps/chat/apps/backend/src/admin/admin.service.ts b/apps/chat/apps/backend/src/admin/admin.service.ts index 10a144de4..0a20f1032 100644 --- a/apps/chat/apps/backend/src/admin/admin.service.ts +++ b/apps/chat/apps/backend/src/admin/admin.service.ts @@ -101,7 +101,19 @@ export class AdminService { const deletedCounts: EntityCount[] = []; let totalDeleted = 0; - // Delete space memberships first + // Delete usage logs first (references messages and conversations) + const deletedUsageLogs = await this.db + .delete(schema.usageLogs) + .where(eq(schema.usageLogs.userId, userId)) + .returning(); + deletedCounts.push({ + entity: 'usage_logs', + count: deletedUsageLogs.length, + label: 'Usage Logs', + }); + totalDeleted += deletedUsageLogs.length; + + // Delete space memberships const deletedMemberships = await this.db .delete(schema.spaceMembers) .where(eq(schema.spaceMembers.userId, userId)) @@ -125,6 +137,18 @@ export class AdminService { }); totalDeleted += deletedSpaces.length; + // Delete templates owned by user + const deletedTemplates = await this.db + .delete(schema.templates) + .where(eq(schema.templates.userId, userId)) + .returning(); + deletedCounts.push({ + entity: 'templates', + count: deletedTemplates.length, + label: 'Templates', + }); + totalDeleted += deletedTemplates.length; + // Delete conversations (cascades to messages and documents) const deletedConversations = await this.db .delete(schema.conversations) diff --git a/apps/chat/apps/backend/src/app.module.ts b/apps/chat/apps/backend/src/app.module.ts index d3d835e9e..85df23f6e 100644 --- a/apps/chat/apps/backend/src/app.module.ts +++ b/apps/chat/apps/backend/src/app.module.ts @@ -1,5 +1,6 @@ import { Module } from '@nestjs/common'; import { ConfigModule, ConfigService } from '@nestjs/config'; +import { ThrottlerModule } from '@nestjs/throttler'; import { MetricsModule } from '@manacore/shared-nestjs-metrics'; import { ManaCoreModule } from '@manacore/nestjs-integration'; import { DatabaseModule } from './db/database.module'; @@ -18,6 +19,7 @@ import { HealthModule } from '@manacore/shared-nestjs-health'; isGlobal: true, envFilePath: '.env', }), + ThrottlerModule.forRoot([{ ttl: 60000, limit: 100 }]), ManaCoreModule.forRootAsync({ imports: [ConfigModule], useFactory: (configService: ConfigService) => ({ diff --git a/apps/chat/apps/backend/src/chat/dto/chat-completion.dto.ts b/apps/chat/apps/backend/src/chat/dto/chat-completion.dto.ts index eadbc992c..24c4ae55b 100644 --- a/apps/chat/apps/backend/src/chat/dto/chat-completion.dto.ts +++ b/apps/chat/apps/backend/src/chat/dto/chat-completion.dto.ts @@ -4,6 +4,7 @@ import { IsNumber, IsOptional, IsString, + MaxLength, ValidateNested, } from 'class-validator'; import { Type } from 'class-transformer'; @@ -15,6 +16,7 @@ export class ChatMessageDto { @IsString() @IsNotEmpty() + @MaxLength(50000) content: string; } diff --git a/apps/chat/apps/backend/src/conversation/__tests__/conversation.service.spec.ts b/apps/chat/apps/backend/src/conversation/__tests__/conversation.service.spec.ts new file mode 100644 index 000000000..5e19ace90 --- /dev/null +++ b/apps/chat/apps/backend/src/conversation/__tests__/conversation.service.spec.ts @@ -0,0 +1,515 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { ConversationService } from '../conversation.service'; +import { DATABASE_CONNECTION } from '../../db/database.module'; + +describe('ConversationService', () => { + let service: ConversationService; + let mockDb: any; + + const userId = 'user-123'; + const conversationId = 'conv-abc-123'; + const modelId = 'model-456'; + + const mockConversation = { + id: conversationId, + userId, + modelId, + templateId: null, + spaceId: null, + title: 'Test Conversation', + conversationMode: 'free' as const, + documentMode: false, + isArchived: false, + isPinned: false, + createdAt: new Date('2025-01-01'), + updatedAt: new Date('2025-01-01'), + }; + + const mockMessage = { + id: 'msg-001', + conversationId, + sender: 'user' as const, + messageText: 'Hello, world!', + createdAt: new Date('2025-01-01'), + updatedAt: new Date('2025-01-01'), + }; + + beforeEach(async () => { + mockDb = { + select: jest.fn().mockReturnThis(), + from: jest.fn().mockReturnThis(), + where: jest.fn().mockReturnThis(), + limit: jest.fn().mockReturnThis(), + orderBy: jest.fn().mockReturnThis(), + insert: jest.fn().mockReturnThis(), + values: jest.fn().mockReturnThis(), + returning: jest.fn(), + update: jest.fn().mockReturnThis(), + set: jest.fn().mockReturnThis(), + delete: jest.fn().mockReturnThis(), + }; + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + ConversationService, + { + provide: DATABASE_CONNECTION, + useValue: mockDb, + }, + ], + }).compile(); + + service = module.get(ConversationService); + }); + + it('should be defined', () => { + expect(service).toBeDefined(); + }); + + describe('getConversations', () => { + it('should return non-archived conversations for a user', async () => { + const conversations = [ + mockConversation, + { ...mockConversation, id: 'conv-2', title: 'Second' }, + ]; + mockDb.orderBy.mockResolvedValue(conversations); + + const result = await service.getConversations(userId); + + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.value).toHaveLength(2); + expect(result.value[0].id).toBe(conversationId); + } + expect(mockDb.select).toHaveBeenCalled(); + expect(mockDb.from).toHaveBeenCalled(); + expect(mockDb.where).toHaveBeenCalled(); + }); + + it('should filter by spaceId when provided', async () => { + const spaceId = 'space-789'; + const spaceConversation = { ...mockConversation, spaceId }; + mockDb.orderBy.mockResolvedValue([spaceConversation]); + + const result = await service.getConversations(userId, spaceId); + + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.value).toHaveLength(1); + expect(result.value[0].spaceId).toBe(spaceId); + } + }); + + it('should return empty array when no conversations exist', async () => { + mockDb.orderBy.mockResolvedValue([]); + + const result = await service.getConversations(userId); + + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.value).toEqual([]); + } + }); + + it('should return error on database failure', async () => { + mockDb.orderBy.mockRejectedValue(new Error('DB connection failed')); + + const result = await service.getConversations(userId); + + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.error.message).toContain('Failed to fetch conversations'); + } + }); + }); + + describe('getArchivedConversations', () => { + it('should return archived conversations for a user', async () => { + const archivedConv = { ...mockConversation, isArchived: true }; + mockDb.orderBy.mockResolvedValue([archivedConv]); + + const result = await service.getArchivedConversations(userId); + + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.value).toHaveLength(1); + expect(result.value[0].isArchived).toBe(true); + } + }); + + it('should return error on database failure', async () => { + mockDb.orderBy.mockRejectedValue(new Error('DB error')); + + const result = await service.getArchivedConversations(userId); + + expect(result.ok).toBe(false); + }); + }); + + describe('getConversation', () => { + it('should return a conversation when found', async () => { + mockDb.limit.mockResolvedValue([mockConversation]); + + const result = await service.getConversation(conversationId, userId); + + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.value.id).toBe(conversationId); + expect(result.value.userId).toBe(userId); + } + }); + + it('should return NotFoundError when conversation does not exist', async () => { + mockDb.limit.mockResolvedValue([]); + + const result = await service.getConversation('nonexistent', userId); + + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.error.message).toContain('Conversation'); + } + }); + + it('should return error on database failure', async () => { + mockDb.limit.mockRejectedValue(new Error('DB error')); + + const result = await service.getConversation(conversationId, userId); + + expect(result.ok).toBe(false); + }); + }); + + describe('createConversation', () => { + it('should create a conversation with default title', async () => { + const newConv = { ...mockConversation, title: 'Neue Unterhaltung' }; + mockDb.returning.mockResolvedValue([newConv]); + + const result = await service.createConversation(userId, modelId); + + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.value.userId).toBe(userId); + expect(result.value.title).toBe('Neue Unterhaltung'); + } + expect(mockDb.insert).toHaveBeenCalled(); + expect(mockDb.values).toHaveBeenCalled(); + }); + + it('should create a conversation with custom options', async () => { + const customConv = { + ...mockConversation, + title: 'Custom Title', + conversationMode: 'guided' as const, + documentMode: true, + spaceId: 'space-123', + }; + mockDb.returning.mockResolvedValue([customConv]); + + const result = await service.createConversation(userId, modelId, { + title: 'Custom Title', + conversationMode: 'guided', + documentMode: true, + spaceId: 'space-123', + }); + + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.value.title).toBe('Custom Title'); + expect(result.value.conversationMode).toBe('guided'); + expect(result.value.documentMode).toBe(true); + expect(result.value.spaceId).toBe('space-123'); + } + }); + + it('should return error on database failure', async () => { + mockDb.returning.mockRejectedValue(new Error('DB error')); + + const result = await service.createConversation(userId, modelId); + + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.error.message).toContain('Failed to create conversation'); + } + }); + }); + + describe('addMessage', () => { + it('should add a message to a valid conversation', async () => { + // getConversation uses select().from().where().limit() + mockDb.limit.mockResolvedValueOnce([mockConversation]); + // insert().values().returning() for the message + mockDb.returning.mockResolvedValueOnce([mockMessage]); + // update().set().where() for updatedAt - the chain ends at where() + // where() already returns this, which is fine for an awaited non-returning update + + const result = await service.addMessage(conversationId, userId, 'user', 'Hello, world!'); + + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.value.messageText).toBe('Hello, world!'); + expect(result.value.sender).toBe('user'); + } + }); + + it('should return error when conversation not found', async () => { + mockDb.limit.mockResolvedValue([]); + + const result = await service.addMessage('nonexistent', userId, 'user', 'Hello'); + + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.error.message).toContain('Conversation'); + } + }); + + it('should return error on database failure during insert', async () => { + mockDb.limit.mockResolvedValueOnce([mockConversation]); + mockDb.returning.mockRejectedValueOnce(new Error('DB error')); + + const result = await service.addMessage(conversationId, userId, 'assistant', 'Response'); + + expect(result.ok).toBe(false); + }); + }); + + describe('getMessages', () => { + it('should return messages for a valid conversation', async () => { + const msgs = [ + mockMessage, + { ...mockMessage, id: 'msg-002', sender: 'assistant', messageText: 'Hi there!' }, + ]; + // getConversation uses select().from().where().limit() + mockDb.limit.mockResolvedValueOnce([mockConversation]); + // select().from().where().orderBy() for messages + mockDb.orderBy.mockResolvedValueOnce(msgs); + + const result = await service.getMessages(conversationId, userId); + + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.value).toHaveLength(2); + expect(result.value[0].sender).toBe('user'); + expect(result.value[1].sender).toBe('assistant'); + } + }); + + it('should return error when conversation not found', async () => { + mockDb.limit.mockResolvedValue([]); + + const result = await service.getMessages('nonexistent', userId); + + expect(result.ok).toBe(false); + }); + }); + + describe('updateTitle', () => { + it('should update conversation title', async () => { + const updatedConv = { ...mockConversation, title: 'New Title' }; + // getConversation call + mockDb.limit.mockResolvedValueOnce([mockConversation]); + // update().set().where().returning() + mockDb.returning.mockResolvedValueOnce([updatedConv]); + + const result = await service.updateTitle(conversationId, userId, 'New Title'); + + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.value.title).toBe('New Title'); + } + expect(mockDb.update).toHaveBeenCalled(); + expect(mockDb.set).toHaveBeenCalled(); + }); + + it('should return error when conversation not found', async () => { + mockDb.limit.mockResolvedValue([]); + + const result = await service.updateTitle('nonexistent', userId, 'New Title'); + + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.error.message).toContain('Conversation'); + } + }); + + it('should return error on database failure', async () => { + mockDb.limit.mockResolvedValueOnce([mockConversation]); + mockDb.returning.mockRejectedValueOnce(new Error('DB error')); + + const result = await service.updateTitle(conversationId, userId, 'New Title'); + + expect(result.ok).toBe(false); + }); + }); + + describe('archiveConversation', () => { + it('should archive a conversation', async () => { + const archivedConv = { ...mockConversation, isArchived: true }; + // getConversation call + mockDb.limit.mockResolvedValueOnce([mockConversation]); + // update().set().where().returning() + mockDb.returning.mockResolvedValueOnce([archivedConv]); + + const result = await service.archiveConversation(conversationId, userId); + + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.value.isArchived).toBe(true); + } + }); + + it('should return error when conversation not found', async () => { + mockDb.limit.mockResolvedValue([]); + + const result = await service.archiveConversation('nonexistent', userId); + + expect(result.ok).toBe(false); + }); + }); + + describe('unarchiveConversation', () => { + it('should unarchive a conversation', async () => { + const unarchivedConv = { ...mockConversation, isArchived: false }; + // Direct select query uses select().from().where().limit() + mockDb.limit.mockResolvedValueOnce([{ ...mockConversation, isArchived: true }]); + // update().set().where().returning() + mockDb.returning.mockResolvedValueOnce([unarchivedConv]); + + const result = await service.unarchiveConversation(conversationId, userId); + + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.value.isArchived).toBe(false); + } + }); + + it('should return error when conversation not found', async () => { + mockDb.limit.mockResolvedValue([]); + + const result = await service.unarchiveConversation('nonexistent', userId); + + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.error.message).toContain('Conversation'); + } + }); + }); + + describe('deleteConversation', () => { + it('should delete a conversation', async () => { + // getConversation call + mockDb.limit.mockResolvedValueOnce([mockConversation]); + + const result = await service.deleteConversation(conversationId, userId); + + expect(result.ok).toBe(true); + expect(mockDb.delete).toHaveBeenCalled(); + }); + + it('should return error when conversation not found', async () => { + mockDb.limit.mockResolvedValue([]); + + const result = await service.deleteConversation('nonexistent', userId); + + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.error.message).toContain('Conversation'); + } + }); + + it('should return error on database failure', async () => { + mockDb.limit.mockResolvedValueOnce([mockConversation]); + // delete() is called after getConversation succeeds; make the delete chain throw + mockDb.delete.mockImplementationOnce(() => { + throw new Error('DB error'); + }); + + const result = await service.deleteConversation(conversationId, userId); + + expect(result.ok).toBe(false); + }); + }); + + describe('getMessageCount', () => { + it('should return the message count for a conversation', async () => { + // getConversation: select().from().where().limit() - limit is terminal + mockDb.limit.mockResolvedValueOnce([mockConversation]); + // count query: select().from().where() - where is terminal + // Use mockReturnValueOnce(mockDb) for the first where() call (getConversation chain), + // then mockResolvedValueOnce for the second where() call (count query terminal) + mockDb.where.mockReturnValueOnce(mockDb).mockResolvedValueOnce([{ count: 42 }]); + + const result = await service.getMessageCount(conversationId, userId); + + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.value).toBe(42); + } + }); + + it('should return 0 when no messages exist', async () => { + mockDb.limit.mockResolvedValueOnce([mockConversation]); + mockDb.where.mockReturnValueOnce(mockDb).mockResolvedValueOnce([{ count: 0 }]); + + const result = await service.getMessageCount(conversationId, userId); + + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.value).toBe(0); + } + }); + + it('should return error when conversation not found', async () => { + mockDb.limit.mockResolvedValueOnce([]); + + const result = await service.getMessageCount('nonexistent', userId); + + expect(result.ok).toBe(false); + }); + }); + + describe('pinConversation', () => { + it('should pin a conversation', async () => { + const pinnedConv = { ...mockConversation, isPinned: true }; + mockDb.limit.mockResolvedValueOnce([mockConversation]); + mockDb.returning.mockResolvedValueOnce([pinnedConv]); + + const result = await service.pinConversation(conversationId, userId); + + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.value.isPinned).toBe(true); + } + }); + + it('should return error when conversation not found', async () => { + mockDb.limit.mockResolvedValue([]); + + const result = await service.pinConversation('nonexistent', userId); + + expect(result.ok).toBe(false); + }); + }); + + describe('unpinConversation', () => { + it('should unpin a conversation', async () => { + const unpinnedConv = { ...mockConversation, isPinned: false }; + mockDb.limit.mockResolvedValueOnce([{ ...mockConversation, isPinned: true }]); + mockDb.returning.mockResolvedValueOnce([unpinnedConv]); + + const result = await service.unpinConversation(conversationId, userId); + + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.value.isPinned).toBe(false); + } + }); + + it('should return error when conversation not found', async () => { + mockDb.limit.mockResolvedValue([]); + + const result = await service.unpinConversation('nonexistent', userId); + + expect(result.ok).toBe(false); + }); + }); +}); diff --git a/apps/chat/apps/backend/src/conversation/conversation.controller.ts b/apps/chat/apps/backend/src/conversation/conversation.controller.ts index 605370f4f..d0a3fb09b 100644 --- a/apps/chat/apps/backend/src/conversation/conversation.controller.ts +++ b/apps/chat/apps/backend/src/conversation/conversation.controller.ts @@ -15,6 +15,7 @@ import { Conversation } from '../db/schema/conversations.schema'; import { Message } from '../db/schema/messages.schema'; import { JwtAuthGuard, CurrentUser } from '@manacore/shared-nestjs-auth'; import type { CurrentUserData } from '@manacore/shared-nestjs-auth'; +import { CreateConversationDto, AddMessageDto, UpdateTitleDto } from './dto/conversation.dto'; @Controller('conversations') @UseGuards(JwtAuthGuard) @@ -76,15 +77,7 @@ export class ConversationController { @Post() async createConversation( - @Body() - body: { - modelId: string; - title?: string; - templateId?: string; - conversationMode?: 'free' | 'guided' | 'template'; - documentMode?: boolean; - spaceId?: string; - }, + @Body() body: CreateConversationDto, @CurrentUser() user: CurrentUserData ): Promise { const result = await this.conversationService.createConversation(user.userId, body.modelId, { @@ -105,7 +98,7 @@ export class ConversationController { @Post(':id/messages') async addMessage( @Param('id') id: string, - @Body() body: { sender: 'user' | 'assistant' | 'system'; messageText: string }, + @Body() body: AddMessageDto, @CurrentUser() user: CurrentUserData ): Promise { const result = await this.conversationService.addMessage( @@ -125,7 +118,7 @@ export class ConversationController { @Patch(':id/title') async updateTitle( @Param('id') id: string, - @Body() body: { title: string }, + @Body() body: UpdateTitleDto, @CurrentUser() user: CurrentUserData ): Promise { const result = await this.conversationService.updateTitle(id, user.userId, body.title); diff --git a/apps/chat/apps/backend/src/conversation/dto/conversation.dto.ts b/apps/chat/apps/backend/src/conversation/dto/conversation.dto.ts new file mode 100644 index 000000000..6f1d8151f --- /dev/null +++ b/apps/chat/apps/backend/src/conversation/dto/conversation.dto.ts @@ -0,0 +1,46 @@ +import { IsBoolean, IsNotEmpty, IsOptional, IsString, MaxLength } from 'class-validator'; + +export class CreateConversationDto { + @IsString() + @IsNotEmpty() + modelId: string; + + @IsString() + @IsOptional() + @MaxLength(500) + title?: string; + + @IsString() + @IsOptional() + templateId?: string; + + @IsString() + @IsOptional() + conversationMode?: 'free' | 'guided' | 'template'; + + @IsBoolean() + @IsOptional() + documentMode?: boolean; + + @IsString() + @IsOptional() + spaceId?: string; +} + +export class AddMessageDto { + @IsString() + @IsNotEmpty() + sender: 'user' | 'assistant' | 'system'; + + @IsString() + @IsNotEmpty() + @MaxLength(50000) + messageText: string; +} + +export class UpdateTitleDto { + @IsString() + @IsNotEmpty() + @MaxLength(500) + title: string; +} diff --git a/apps/chat/apps/backend/src/db/schema/conversations.schema.ts b/apps/chat/apps/backend/src/db/schema/conversations.schema.ts index e8774fa52..9ac57508c 100644 --- a/apps/chat/apps/backend/src/db/schema/conversations.schema.ts +++ b/apps/chat/apps/backend/src/db/schema/conversations.schema.ts @@ -1,4 +1,4 @@ -import { pgTable, uuid, text, timestamp, boolean, pgEnum } from 'drizzle-orm/pg-core'; +import { pgTable, uuid, text, timestamp, boolean, pgEnum, index } from 'drizzle-orm/pg-core'; import { relations } from 'drizzle-orm'; import { messages } from './messages.schema'; import { documents } from './documents.schema'; @@ -8,20 +8,30 @@ import { templates } from './templates.schema'; export const conversationModeEnum = pgEnum('conversation_mode', ['free', 'guided', 'template']); -export const conversations = pgTable('conversations', { - id: uuid('id').primaryKey().defaultRandom(), - userId: text('user_id').notNull(), // TEXT to support Better Auth nanoid format - modelId: uuid('model_id').references(() => models.id), - templateId: uuid('template_id').references(() => templates.id), - spaceId: uuid('space_id').references(() => spaces.id, { onDelete: 'set null' }), - title: text('title'), - conversationMode: conversationModeEnum('conversation_mode').default('free').notNull(), - documentMode: boolean('document_mode').default(false).notNull(), - isArchived: boolean('is_archived').default(false).notNull(), - isPinned: boolean('is_pinned').default(false).notNull(), - createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(), - updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(), -}); +export const conversations = pgTable( + 'conversations', + { + id: uuid('id').primaryKey().defaultRandom(), + userId: text('user_id').notNull(), // TEXT to support Better Auth nanoid format + modelId: uuid('model_id').references(() => models.id), + templateId: uuid('template_id').references(() => templates.id), + spaceId: uuid('space_id').references(() => spaces.id, { onDelete: 'set null' }), + title: text('title'), + conversationMode: conversationModeEnum('conversation_mode').default('free').notNull(), + documentMode: boolean('document_mode').default(false).notNull(), + isArchived: boolean('is_archived').default(false).notNull(), + isPinned: boolean('is_pinned').default(false).notNull(), + createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(), + updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(), + }, + (table) => [ + index('conversations_user_id_idx').on(table.userId), + index('conversations_space_id_idx').on(table.spaceId), + index('conversations_created_at_idx').on(table.createdAt), + index('conversations_model_id_idx').on(table.modelId), + index('conversations_template_id_idx').on(table.templateId), + ] +); export const conversationsRelations = relations(conversations, ({ one, many }) => ({ model: one(models, { diff --git a/apps/chat/apps/backend/src/db/schema/documents.schema.ts b/apps/chat/apps/backend/src/db/schema/documents.schema.ts index cd8c6acd0..46017e10f 100644 --- a/apps/chat/apps/backend/src/db/schema/documents.schema.ts +++ b/apps/chat/apps/backend/src/db/schema/documents.schema.ts @@ -1,17 +1,21 @@ -import { pgTable, uuid, text, timestamp, integer } from 'drizzle-orm/pg-core'; +import { pgTable, uuid, text, timestamp, integer, index } from 'drizzle-orm/pg-core'; import { relations } from 'drizzle-orm'; import { conversations } from './conversations.schema'; -export const documents = pgTable('documents', { - id: uuid('id').primaryKey().defaultRandom(), - conversationId: uuid('conversation_id') - .references(() => conversations.id, { onDelete: 'cascade' }) - .notNull(), - version: integer('version').default(1).notNull(), - content: text('content').notNull(), - createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(), - updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(), -}); +export const documents = pgTable( + 'documents', + { + id: uuid('id').primaryKey().defaultRandom(), + conversationId: uuid('conversation_id') + .references(() => conversations.id, { onDelete: 'cascade' }) + .notNull(), + version: integer('version').default(1).notNull(), + content: text('content').notNull(), + createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(), + updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(), + }, + (table) => [index('documents_conversation_id_idx').on(table.conversationId)] +); export const documentsRelations = relations(documents, ({ one }) => ({ conversation: one(conversations, { diff --git a/apps/chat/apps/backend/src/db/schema/messages.schema.ts b/apps/chat/apps/backend/src/db/schema/messages.schema.ts index 8fe986a93..280cc50e0 100644 --- a/apps/chat/apps/backend/src/db/schema/messages.schema.ts +++ b/apps/chat/apps/backend/src/db/schema/messages.schema.ts @@ -1,19 +1,23 @@ -import { pgTable, uuid, text, timestamp, pgEnum } from 'drizzle-orm/pg-core'; +import { pgTable, uuid, text, timestamp, pgEnum, index } from 'drizzle-orm/pg-core'; import { relations } from 'drizzle-orm'; import { conversations } from './conversations.schema'; export const senderEnum = pgEnum('sender', ['user', 'assistant', 'system']); -export const messages = pgTable('messages', { - id: uuid('id').primaryKey().defaultRandom(), - conversationId: uuid('conversation_id') - .references(() => conversations.id, { onDelete: 'cascade' }) - .notNull(), - sender: senderEnum('sender').notNull(), - messageText: text('message_text').notNull(), - createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(), - updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(), -}); +export const messages = pgTable( + 'messages', + { + id: uuid('id').primaryKey().defaultRandom(), + conversationId: uuid('conversation_id') + .references(() => conversations.id, { onDelete: 'cascade' }) + .notNull(), + sender: senderEnum('sender').notNull(), + messageText: text('message_text').notNull(), + createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(), + updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(), + }, + (table) => [index('messages_conversation_id_idx').on(table.conversationId)] +); export const messagesRelations = relations(messages, ({ one }) => ({ conversation: one(conversations, { diff --git a/apps/chat/apps/backend/src/db/schema/spaces.schema.ts b/apps/chat/apps/backend/src/db/schema/spaces.schema.ts index 2d11dde86..54987e1c2 100644 --- a/apps/chat/apps/backend/src/db/schema/spaces.schema.ts +++ b/apps/chat/apps/backend/src/db/schema/spaces.schema.ts @@ -1,4 +1,13 @@ -import { pgTable, uuid, text, timestamp, boolean, pgEnum } from 'drizzle-orm/pg-core'; +import { + pgTable, + uuid, + text, + timestamp, + boolean, + pgEnum, + index, + uniqueIndex, +} from 'drizzle-orm/pg-core'; import { relations } from 'drizzle-orm'; export const memberRoleEnum = pgEnum('member_role', ['owner', 'admin', 'member', 'viewer']); @@ -8,30 +17,42 @@ export const invitationStatusEnum = pgEnum('invitation_status', [ 'declined', ]); -export const spaces = pgTable('spaces', { - id: uuid('id').primaryKey().defaultRandom(), - ownerId: text('owner_id').notNull(), // TEXT to support Better Auth nanoid format - name: text('name').notNull(), - description: text('description'), - isArchived: boolean('is_archived').default(false).notNull(), - createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(), - updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(), -}); +export const spaces = pgTable( + 'spaces', + { + id: uuid('id').primaryKey().defaultRandom(), + ownerId: text('owner_id').notNull(), // TEXT to support Better Auth nanoid format + name: text('name').notNull(), + description: text('description'), + isArchived: boolean('is_archived').default(false).notNull(), + createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(), + updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(), + }, + (table) => [index('spaces_owner_id_idx').on(table.ownerId)] +); -export const spaceMembers = pgTable('space_members', { - id: uuid('id').primaryKey().defaultRandom(), - spaceId: uuid('space_id') - .references(() => spaces.id, { onDelete: 'cascade' }) - .notNull(), - userId: text('user_id').notNull(), // TEXT to support Better Auth nanoid format - role: memberRoleEnum('role').default('member').notNull(), - invitationStatus: invitationStatusEnum('invitation_status').default('pending').notNull(), - invitedBy: text('invited_by'), // TEXT to support Better Auth nanoid format - invitedAt: timestamp('invited_at', { withTimezone: true }).defaultNow().notNull(), - joinedAt: timestamp('joined_at', { withTimezone: true }), - createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(), - updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(), -}); +export const spaceMembers = pgTable( + 'space_members', + { + id: uuid('id').primaryKey().defaultRandom(), + spaceId: uuid('space_id') + .references(() => spaces.id, { onDelete: 'cascade' }) + .notNull(), + userId: text('user_id').notNull(), // TEXT to support Better Auth nanoid format + role: memberRoleEnum('role').default('member').notNull(), + invitationStatus: invitationStatusEnum('invitation_status').default('pending').notNull(), + invitedBy: text('invited_by'), // TEXT to support Better Auth nanoid format + invitedAt: timestamp('invited_at', { withTimezone: true }).defaultNow().notNull(), + joinedAt: timestamp('joined_at', { withTimezone: true }), + createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(), + updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(), + }, + (table) => [ + index('space_members_user_id_idx').on(table.userId), + index('space_members_space_id_idx').on(table.spaceId), + uniqueIndex('space_members_space_id_user_id_idx').on(table.spaceId, table.userId), + ] +); export const spacesRelations = relations(spaces, ({ many }) => ({ members: many(spaceMembers), diff --git a/apps/chat/apps/backend/src/db/schema/templates.schema.ts b/apps/chat/apps/backend/src/db/schema/templates.schema.ts index b9293ee02..e3f4e560b 100644 --- a/apps/chat/apps/backend/src/db/schema/templates.schema.ts +++ b/apps/chat/apps/backend/src/db/schema/templates.schema.ts @@ -1,21 +1,28 @@ -import { pgTable, uuid, text, timestamp, boolean } from 'drizzle-orm/pg-core'; +import { pgTable, uuid, text, timestamp, boolean, index } from 'drizzle-orm/pg-core'; import { relations } from 'drizzle-orm'; import { models } from './models.schema'; -export const templates = pgTable('templates', { - id: uuid('id').primaryKey().defaultRandom(), - userId: text('user_id').notNull(), // TEXT to support Better Auth nanoid format - name: text('name').notNull(), - description: text('description'), - systemPrompt: text('system_prompt').notNull(), - initialQuestion: text('initial_question'), - modelId: uuid('model_id').references(() => models.id), - color: text('color').default('#3b82f6').notNull(), - isDefault: boolean('is_default').default(false).notNull(), - documentMode: boolean('document_mode').default(false).notNull(), - createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(), - updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(), -}); +export const templates = pgTable( + 'templates', + { + id: uuid('id').primaryKey().defaultRandom(), + userId: text('user_id').notNull(), // TEXT to support Better Auth nanoid format + name: text('name').notNull(), + description: text('description'), + systemPrompt: text('system_prompt').notNull(), + initialQuestion: text('initial_question'), + modelId: uuid('model_id').references(() => models.id), + color: text('color').default('#3b82f6').notNull(), + isDefault: boolean('is_default').default(false).notNull(), + documentMode: boolean('document_mode').default(false).notNull(), + createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(), + updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(), + }, + (table) => [ + index('templates_user_id_idx').on(table.userId), + index('templates_model_id_idx').on(table.modelId), + ] +); export const templatesRelations = relations(templates, ({ one }) => ({ model: one(models, { diff --git a/apps/chat/apps/backend/src/db/schema/usage-logs.schema.ts b/apps/chat/apps/backend/src/db/schema/usage-logs.schema.ts index 3a98ee592..f758e43bd 100644 --- a/apps/chat/apps/backend/src/db/schema/usage-logs.schema.ts +++ b/apps/chat/apps/backend/src/db/schema/usage-logs.schema.ts @@ -1,25 +1,33 @@ -import { pgTable, uuid, text, timestamp, integer, numeric } from 'drizzle-orm/pg-core'; +import { pgTable, uuid, text, timestamp, integer, numeric, index } from 'drizzle-orm/pg-core'; import { relations } from 'drizzle-orm'; import { conversations } from './conversations.schema'; import { messages } from './messages.schema'; import { models } from './models.schema'; -export const usageLogs = pgTable('usage_logs', { - id: uuid('id').primaryKey().defaultRandom(), - conversationId: uuid('conversation_id') - .references(() => conversations.id, { onDelete: 'cascade' }) - .notNull(), - messageId: uuid('message_id') - .references(() => messages.id, { onDelete: 'cascade' }) - .notNull(), - userId: text('user_id').notNull(), // TEXT to support Better Auth nanoid format - modelId: uuid('model_id').references(() => models.id), - promptTokens: integer('prompt_tokens').default(0).notNull(), - completionTokens: integer('completion_tokens').default(0).notNull(), - totalTokens: integer('total_tokens').default(0).notNull(), - estimatedCost: numeric('estimated_cost', { precision: 10, scale: 6 }).default('0'), - createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(), -}); +export const usageLogs = pgTable( + 'usage_logs', + { + id: uuid('id').primaryKey().defaultRandom(), + conversationId: uuid('conversation_id') + .references(() => conversations.id, { onDelete: 'cascade' }) + .notNull(), + messageId: uuid('message_id') + .references(() => messages.id, { onDelete: 'cascade' }) + .notNull(), + userId: text('user_id').notNull(), // TEXT to support Better Auth nanoid format + modelId: uuid('model_id').references(() => models.id), + promptTokens: integer('prompt_tokens').default(0).notNull(), + completionTokens: integer('completion_tokens').default(0).notNull(), + totalTokens: integer('total_tokens').default(0).notNull(), + estimatedCost: numeric('estimated_cost', { precision: 10, scale: 6 }).default('0'), + createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(), + }, + (table) => [ + index('usage_logs_user_id_idx').on(table.userId), + index('usage_logs_conversation_id_idx').on(table.conversationId), + index('usage_logs_message_id_idx').on(table.messageId), + ] +); export const usageLogsRelations = relations(usageLogs, ({ one }) => ({ conversation: one(conversations, { diff --git a/apps/chat/apps/backend/src/space/__tests__/space.service.spec.ts b/apps/chat/apps/backend/src/space/__tests__/space.service.spec.ts new file mode 100644 index 000000000..d25765256 --- /dev/null +++ b/apps/chat/apps/backend/src/space/__tests__/space.service.spec.ts @@ -0,0 +1,609 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { SpaceService } from '../space.service'; +import { DATABASE_CONNECTION } from '../../db/database.module'; + +describe('SpaceService', () => { + let service: SpaceService; + let mockDb: any; + + const ownerId = 'user-owner-1'; + const memberId = 'user-member-2'; + const spaceId = 'space-abc-123'; + + const mockSpace = { + id: spaceId, + ownerId, + name: 'Test Space', + description: 'A test space', + isArchived: false, + createdAt: new Date('2025-01-01'), + updatedAt: new Date('2025-01-01'), + }; + + const mockMember = { + id: 'member-001', + spaceId, + userId: memberId, + role: 'member' as const, + invitationStatus: 'accepted' as const, + invitedBy: ownerId, + invitedAt: new Date('2025-01-01'), + joinedAt: new Date('2025-01-02'), + createdAt: new Date('2025-01-01'), + updatedAt: new Date('2025-01-01'), + }; + + const mockOwnerMember = { + id: 'member-000', + spaceId, + userId: ownerId, + role: 'owner' as const, + invitationStatus: 'accepted' as const, + invitedBy: null, + invitedAt: new Date('2025-01-01'), + joinedAt: new Date('2025-01-01'), + createdAt: new Date('2025-01-01'), + updatedAt: new Date('2025-01-01'), + }; + + beforeEach(async () => { + mockDb = { + select: jest.fn().mockReturnThis(), + from: jest.fn().mockReturnThis(), + where: jest.fn().mockReturnThis(), + limit: jest.fn().mockReturnThis(), + orderBy: jest.fn().mockReturnThis(), + insert: jest.fn().mockReturnThis(), + values: jest.fn().mockReturnThis(), + returning: jest.fn(), + update: jest.fn().mockReturnThis(), + set: jest.fn().mockReturnThis(), + delete: jest.fn().mockReturnThis(), + }; + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + SpaceService, + { + provide: DATABASE_CONNECTION, + useValue: mockDb, + }, + ], + }).compile(); + + service = module.get(SpaceService); + }); + + it('should be defined', () => { + expect(service).toBeDefined(); + }); + + describe('getUserSpaces', () => { + it('should return spaces where user is an accepted member', async () => { + // First query: get spaceIds from spaceMembers + mockDb.where.mockResolvedValueOnce([{ spaceId }]); + // Second query: get spaces by ids - ends at orderBy + mockDb.orderBy.mockResolvedValueOnce([mockSpace]); + + const result = await service.getUserSpaces(ownerId); + + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.value).toHaveLength(1); + expect(result.value[0].name).toBe('Test Space'); + } + }); + + it('should return empty array when user has no spaces', async () => { + mockDb.where.mockResolvedValueOnce([]); + + const result = await service.getUserSpaces('user-no-spaces'); + + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.value).toEqual([]); + } + }); + + it('should return error on database failure', async () => { + mockDb.where.mockRejectedValueOnce(new Error('DB error')); + + const result = await service.getUserSpaces(ownerId); + + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.error.message).toContain('Failed to fetch user spaces'); + } + }); + }); + + describe('getOwnedSpaces', () => { + it('should return spaces owned by the user', async () => { + mockDb.orderBy.mockResolvedValueOnce([mockSpace]); + + const result = await service.getOwnedSpaces(ownerId); + + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.value).toHaveLength(1); + expect(result.value[0].ownerId).toBe(ownerId); + } + }); + + it('should return empty array when user owns no spaces', async () => { + mockDb.orderBy.mockResolvedValueOnce([]); + + const result = await service.getOwnedSpaces('user-no-ownership'); + + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.value).toEqual([]); + } + }); + }); + + describe('getSpace', () => { + it('should return a space when found', async () => { + mockDb.limit.mockResolvedValueOnce([mockSpace]); + + const result = await service.getSpace(spaceId); + + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.value.id).toBe(spaceId); + expect(result.value.name).toBe('Test Space'); + } + }); + + it('should return NotFoundError when space does not exist', async () => { + mockDb.limit.mockResolvedValueOnce([]); + + const result = await service.getSpace('nonexistent'); + + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.error.message).toContain('Space'); + } + }); + + it('should return error on database failure', async () => { + mockDb.limit.mockRejectedValueOnce(new Error('DB error')); + + const result = await service.getSpace(spaceId); + + expect(result.ok).toBe(false); + }); + }); + + describe('createSpace', () => { + it('should create a space and add owner as member', async () => { + // insert().values().returning() for space creation + mockDb.returning.mockResolvedValueOnce([mockSpace]); + // Second insert().values() for adding owner as member - values() returns this (mockDb) + // which is awaited and resolves to mockDb (non-thenable resolves to itself) + + const result = await service.createSpace(ownerId, 'Test Space', 'A test space'); + + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.value.name).toBe('Test Space'); + expect(result.value.ownerId).toBe(ownerId); + } + expect(mockDb.insert).toHaveBeenCalledTimes(2); + }); + + it('should create a space without description', async () => { + const spaceNoDesc = { ...mockSpace, description: null }; + mockDb.returning.mockResolvedValueOnce([spaceNoDesc]); + + const result = await service.createSpace(ownerId, 'No Desc Space'); + + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.value.description).toBeNull(); + } + }); + + it('should return error on database failure', async () => { + mockDb.returning.mockRejectedValueOnce(new Error('DB error')); + + const result = await service.createSpace(ownerId, 'Failing Space'); + + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.error.message).toContain('Failed to create space'); + } + }); + }); + + describe('updateSpace', () => { + it('should update a space when user is the owner', async () => { + const updatedSpace = { ...mockSpace, name: 'Updated Name' }; + // getSpace call (via limit) + mockDb.limit.mockResolvedValueOnce([mockSpace]); + // update().set().where().returning() + mockDb.returning.mockResolvedValueOnce([updatedSpace]); + + const result = await service.updateSpace(spaceId, ownerId, { name: 'Updated Name' }); + + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.value.name).toBe('Updated Name'); + } + }); + + it('should return error when user is not the owner', async () => { + mockDb.limit.mockResolvedValueOnce([mockSpace]); + + const result = await service.updateSpace(spaceId, 'other-user', { name: 'Hacked' }); + + expect(result.ok).toBe(false); + }); + + it('should return error when space not found', async () => { + mockDb.limit.mockResolvedValueOnce([]); + + const result = await service.updateSpace('nonexistent', ownerId, { name: 'Nope' }); + + expect(result.ok).toBe(false); + }); + }); + + describe('deleteSpace', () => { + it('should delete a space when user is the owner', async () => { + // getSpace call + mockDb.limit.mockResolvedValueOnce([mockSpace]); + + const result = await service.deleteSpace(spaceId, ownerId); + + expect(result.ok).toBe(true); + expect(mockDb.delete).toHaveBeenCalled(); + }); + + it('should return error when user is not the owner', async () => { + mockDb.limit.mockResolvedValueOnce([mockSpace]); + + const result = await service.deleteSpace(spaceId, 'other-user'); + + expect(result.ok).toBe(false); + }); + + it('should return error when space not found', async () => { + mockDb.limit.mockResolvedValueOnce([]); + + const result = await service.deleteSpace('nonexistent', ownerId); + + expect(result.ok).toBe(false); + }); + }); + + describe('getSpaceMembers', () => { + it('should return all members of a space', async () => { + const members = [mockOwnerMember, mockMember]; + mockDb.orderBy.mockResolvedValueOnce(members); + + const result = await service.getSpaceMembers(spaceId); + + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.value).toHaveLength(2); + expect(result.value[0].role).toBe('owner'); + expect(result.value[1].role).toBe('member'); + } + }); + + it('should return error on database failure', async () => { + mockDb.orderBy.mockRejectedValueOnce(new Error('DB error')); + + const result = await service.getSpaceMembers(spaceId); + + expect(result.ok).toBe(false); + }); + }); + + describe('inviteUserToSpace', () => { + it('should create an invitation when inviter is the owner', async () => { + const pendingMember = { ...mockMember, invitationStatus: 'pending' as const }; + // getSpace call + mockDb.limit.mockResolvedValueOnce([mockSpace]); + // Check existing member - none found + mockDb.limit.mockResolvedValueOnce([]); + // insert().values().returning() for new invitation + mockDb.returning.mockResolvedValueOnce([pendingMember]); + + const result = await service.inviteUserToSpace(spaceId, memberId, ownerId, 'member'); + + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.value.userId).toBe(memberId); + expect(result.value.spaceId).toBe(spaceId); + } + }); + + it('should create an invitation when inviter is an admin', async () => { + const adminId = 'user-admin'; + const adminMember = { ...mockMember, userId: adminId, role: 'admin' as const }; + const pendingMember = { ...mockMember, invitationStatus: 'pending' as const }; + + // getSpace call (inviter is not owner) + mockDb.limit.mockResolvedValueOnce([mockSpace]); + // Check inviter role - is admin + mockDb.limit.mockResolvedValueOnce([adminMember]); + // Check existing member - none found + mockDb.limit.mockResolvedValueOnce([]); + // insert().values().returning() + mockDb.returning.mockResolvedValueOnce([pendingMember]); + + const result = await service.inviteUserToSpace(spaceId, memberId, adminId, 'member'); + + expect(result.ok).toBe(true); + }); + + it('should return error when inviter is a regular member', async () => { + // getSpace call + mockDb.limit.mockResolvedValueOnce([mockSpace]); + // Check inviter role - is regular member (not admin) + mockDb.limit.mockResolvedValueOnce([mockMember]); + + const result = await service.inviteUserToSpace(spaceId, 'new-user', memberId, 'member'); + + // The service throws ForbiddenException which gets caught and returns err + expect(result.ok).toBe(false); + }); + + it('should return existing member if already accepted', async () => { + const acceptedMember = { ...mockMember, invitationStatus: 'accepted' as const }; + // getSpace call + mockDb.limit.mockResolvedValueOnce([mockSpace]); + // Check existing member - already accepted + mockDb.limit.mockResolvedValueOnce([acceptedMember]); + + const result = await service.inviteUserToSpace(spaceId, memberId, ownerId, 'member'); + + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.value.invitationStatus).toBe('accepted'); + } + }); + + it('should update existing declined invitation', async () => { + const declinedMember = { ...mockMember, invitationStatus: 'declined' as const }; + const updatedMember = { ...mockMember, invitationStatus: 'pending' as const }; + // getSpace call + mockDb.limit.mockResolvedValueOnce([mockSpace]); + // Check existing member - declined + mockDb.limit.mockResolvedValueOnce([declinedMember]); + // update().set().where().returning() + mockDb.returning.mockResolvedValueOnce([updatedMember]); + + const result = await service.inviteUserToSpace(spaceId, memberId, ownerId, 'member'); + + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.value.invitationStatus).toBe('pending'); + } + }); + }); + + describe('respondToInvitation', () => { + it('should accept an invitation', async () => { + const acceptedMember = { ...mockMember, invitationStatus: 'accepted' as const }; + mockDb.returning.mockResolvedValueOnce([acceptedMember]); + + const result = await service.respondToInvitation(spaceId, memberId, 'accepted'); + + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.value.invitationStatus).toBe('accepted'); + } + }); + + it('should decline an invitation', async () => { + const declinedMember = { ...mockMember, invitationStatus: 'declined' as const }; + mockDb.returning.mockResolvedValueOnce([declinedMember]); + + const result = await service.respondToInvitation(spaceId, memberId, 'declined'); + + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.value.invitationStatus).toBe('declined'); + } + }); + + it('should return NotFoundError when invitation does not exist', async () => { + mockDb.returning.mockResolvedValueOnce([]); + + const result = await service.respondToInvitation(spaceId, 'unknown-user', 'accepted'); + + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.error.message).toContain('SpaceMember'); + } + }); + }); + + describe('removeMember', () => { + it('should remove a member when requester is the owner', async () => { + // getSpace call + mockDb.limit.mockResolvedValueOnce([mockSpace]); + // Check requester role - owner is found in spaceMembers + mockDb.limit.mockResolvedValueOnce([mockOwnerMember]); + + const result = await service.removeMember(spaceId, memberId, ownerId); + + expect(result.ok).toBe(true); + expect(mockDb.delete).toHaveBeenCalled(); + }); + + it('should remove a member when requester is an admin', async () => { + const adminId = 'user-admin'; + const adminMember = { ...mockMember, userId: adminId, role: 'admin' as const }; + // getSpace call + mockDb.limit.mockResolvedValueOnce([mockSpace]); + // Check requester role - is admin + mockDb.limit.mockResolvedValueOnce([adminMember]); + + const result = await service.removeMember(spaceId, memberId, adminId); + + expect(result.ok).toBe(true); + }); + + it('should return error when requester is a regular member', async () => { + const regularUser = 'user-regular'; + const regularMember = { ...mockMember, userId: regularUser, role: 'member' as const }; + // getSpace call + mockDb.limit.mockResolvedValueOnce([mockSpace]); + // Check requester role - is regular member + mockDb.limit.mockResolvedValueOnce([regularMember]); + + const result = await service.removeMember(spaceId, memberId, regularUser); + + expect(result.ok).toBe(false); + }); + + it('should return error when space not found', async () => { + mockDb.limit.mockResolvedValueOnce([]); + + const result = await service.removeMember('nonexistent', memberId, ownerId); + + expect(result.ok).toBe(false); + }); + }); + + describe('changeMemberRole', () => { + it('should change a member role when requester is the owner', async () => { + const promotedMember = { ...mockMember, role: 'admin' as const }; + // getSpace call + mockDb.limit.mockResolvedValueOnce([mockSpace]); + // update().set().where().returning() + mockDb.returning.mockResolvedValueOnce([promotedMember]); + + const result = await service.changeMemberRole(spaceId, memberId, 'admin', ownerId); + + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.value.role).toBe('admin'); + } + }); + + it('should return error when requester is not the owner', async () => { + mockDb.limit.mockResolvedValueOnce([mockSpace]); + + const result = await service.changeMemberRole(spaceId, memberId, 'admin', 'not-owner'); + + expect(result.ok).toBe(false); + }); + + it('should return error when member not found', async () => { + mockDb.limit.mockResolvedValueOnce([mockSpace]); + mockDb.returning.mockResolvedValueOnce([]); + + const result = await service.changeMemberRole(spaceId, 'nonexistent', 'admin', ownerId); + + expect(result.ok).toBe(false); + }); + }); + + describe('getUserRoleInSpace', () => { + it('should return owner when user is the space owner', async () => { + // getSpace call + mockDb.limit.mockResolvedValueOnce([mockSpace]); + + const result = await service.getUserRoleInSpace(spaceId, ownerId); + + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.value).toBe('owner'); + } + }); + + it('should return member role for accepted members', async () => { + // getSpace call + mockDb.limit.mockResolvedValueOnce([mockSpace]); + // Check membership + mockDb.limit.mockResolvedValueOnce([mockMember]); + + const result = await service.getUserRoleInSpace(spaceId, memberId); + + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.value).toBe('member'); + } + }); + + it('should return admin role for admin members', async () => { + const adminMember = { ...mockMember, role: 'admin' as const }; + // getSpace call + mockDb.limit.mockResolvedValueOnce([mockSpace]); + // Check membership + mockDb.limit.mockResolvedValueOnce([adminMember]); + + const result = await service.getUserRoleInSpace(spaceId, adminMember.userId); + + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.value).toBe('admin'); + } + }); + + it('should return null when user is not a member', async () => { + // getSpace call + mockDb.limit.mockResolvedValueOnce([mockSpace]); + // Check membership - not found + mockDb.limit.mockResolvedValueOnce([]); + + const result = await service.getUserRoleInSpace(spaceId, 'stranger'); + + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.value).toBeNull(); + } + }); + + it('should return error when space not found', async () => { + mockDb.limit.mockResolvedValueOnce([]); + + const result = await service.getUserRoleInSpace('nonexistent', ownerId); + + expect(result.ok).toBe(false); + }); + }); + + describe('getPendingInvitations', () => { + it('should return pending invitations with space details', async () => { + const pendingMember = { ...mockMember, invitationStatus: 'pending' as const }; + // Get pending invitations - where is terminal + mockDb.where.mockResolvedValueOnce([pendingMember]); + // getSpace call for each invitation + mockDb.limit.mockResolvedValueOnce([mockSpace]); + + const result = await service.getPendingInvitations(memberId); + + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.value).toHaveLength(1); + expect(result.value[0].invitation.invitationStatus).toBe('pending'); + expect(result.value[0].space.id).toBe(spaceId); + } + }); + + it('should return empty array when no pending invitations', async () => { + mockDb.where.mockResolvedValueOnce([]); + + const result = await service.getPendingInvitations(memberId); + + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.value).toEqual([]); + } + }); + + it('should return error on database failure', async () => { + mockDb.where.mockRejectedValueOnce(new Error('DB error')); + + const result = await service.getPendingInvitations(memberId); + + expect(result.ok).toBe(false); + }); + }); +}); diff --git a/apps/chat/apps/backend/src/space/space.controller.ts b/apps/chat/apps/backend/src/space/space.controller.ts index 56960396b..7649008f6 100644 --- a/apps/chat/apps/backend/src/space/space.controller.ts +++ b/apps/chat/apps/backend/src/space/space.controller.ts @@ -1,4 +1,14 @@ -import { Body, Controller, Delete, Get, Param, Patch, Post, UseGuards } from '@nestjs/common'; +import { + Body, + Controller, + Delete, + ForbiddenException, + Get, + Param, + Patch, + Post, + UseGuards, +} from '@nestjs/common'; import { isOk } from '@manacore/shared-errors'; import { SpaceService } from './space.service'; import { Space } from '../db/schema/spaces.schema'; @@ -58,7 +68,16 @@ export class SpaceController { } @Get(':id/members') - async getSpaceMembers(@Param('id') id: string): Promise { + async getSpaceMembers( + @Param('id') id: string, + @CurrentUser() user: CurrentUserData + ): Promise { + // Verify the requesting user is a member of the space + const roleResult = await this.spaceService.getUserRoleInSpace(id, user.userId); + if (!isOk(roleResult) || roleResult.value === null) { + throw new ForbiddenException('You are not a member of this space'); + } + const result = await this.spaceService.getSpaceMembers(id); if (!isOk(result)) { diff --git a/apps/chat/apps/backend/src/space/space.service.ts b/apps/chat/apps/backend/src/space/space.service.ts index c385b5ea4..549994efc 100644 --- a/apps/chat/apps/backend/src/space/space.service.ts +++ b/apps/chat/apps/backend/src/space/space.service.ts @@ -1,4 +1,4 @@ -import { Injectable, Inject, Logger } from '@nestjs/common'; +import { Injectable, Inject, Logger, ForbiddenException } from '@nestjs/common'; import { eq, and, desc, inArray } from 'drizzle-orm'; import { AsyncResult, ok, err, DatabaseError, NotFoundError } from '@manacore/shared-errors'; import { DATABASE_CONNECTION } from '../db/database.module'; @@ -177,6 +177,26 @@ export class SpaceService { role: 'admin' | 'member' | 'viewer' = 'member' ): AsyncResult { try { + // Verify the inviting user is the owner or an admin + const spaceResult = await this.getSpace(spaceId); + if (!spaceResult.ok) { + return err(spaceResult.error); + } + + const isOwner = spaceResult.value.ownerId === invitedByUserId; + if (!isOwner) { + const inviterMember = await this.db + .select() + .from(spaceMembers) + .where(and(eq(spaceMembers.spaceId, spaceId), eq(spaceMembers.userId, invitedByUserId))) + .limit(1); + + const isAdmin = inviterMember.length > 0 && inviterMember[0].role === 'admin'; + if (!isAdmin) { + throw new ForbiddenException('Only owners and admins can invite users'); + } + } + // Check if user is already a member const existingMember = await this.db .select() diff --git a/apps/chat/apps/backend/src/template/dto/template.dto.ts b/apps/chat/apps/backend/src/template/dto/template.dto.ts new file mode 100644 index 000000000..9144d0fbd --- /dev/null +++ b/apps/chat/apps/backend/src/template/dto/template.dto.ts @@ -0,0 +1,69 @@ +import { IsBoolean, IsNotEmpty, IsOptional, IsString, MaxLength } from 'class-validator'; + +export class CreateTemplateDto { + @IsString() + @IsNotEmpty() + @MaxLength(500) + name: string; + + @IsString() + @IsOptional() + @MaxLength(500) + description?: string; + + @IsString() + @IsNotEmpty() + @MaxLength(5000) + systemPrompt: string; + + @IsString() + @IsOptional() + @MaxLength(5000) + initialQuestion?: string; + + @IsString() + @IsOptional() + modelId?: string; + + @IsString() + @IsOptional() + color?: string; + + @IsBoolean() + @IsOptional() + documentMode?: boolean; +} + +export class UpdateTemplateDto { + @IsString() + @IsOptional() + @MaxLength(500) + name?: string; + + @IsString() + @IsOptional() + @MaxLength(500) + description?: string; + + @IsString() + @IsOptional() + @MaxLength(5000) + systemPrompt?: string; + + @IsString() + @IsOptional() + @MaxLength(5000) + initialQuestion?: string; + + @IsString() + @IsOptional() + modelId?: string; + + @IsString() + @IsOptional() + color?: string; + + @IsBoolean() + @IsOptional() + documentMode?: boolean; +} diff --git a/apps/chat/apps/backend/src/template/template.controller.ts b/apps/chat/apps/backend/src/template/template.controller.ts index 4e719800c..6c85eb899 100644 --- a/apps/chat/apps/backend/src/template/template.controller.ts +++ b/apps/chat/apps/backend/src/template/template.controller.ts @@ -4,6 +4,7 @@ import { TemplateService } from './template.service'; import { Template } from '../db/schema/templates.schema'; import { JwtAuthGuard, CurrentUser } from '@manacore/shared-nestjs-auth'; import type { CurrentUserData } from '@manacore/shared-nestjs-auth'; +import { CreateTemplateDto, UpdateTemplateDto } from './dto/template.dto'; @Controller('templates') @UseGuards(JwtAuthGuard) @@ -48,16 +49,7 @@ export class TemplateController { @Post() async createTemplate( - @Body() - body: { - name: string; - description?: string; - systemPrompt: string; - initialQuestion?: string; - modelId?: string; - color?: string; - documentMode?: boolean; - }, + @Body() body: CreateTemplateDto, @CurrentUser() user: CurrentUserData ): Promise