mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 22:41:09 +02:00
fix(chat,picture,mukke): production readiness audit fixes and tests
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 <noreply@anthropic.com>
This commit is contained in:
parent
3da6cf2bd4
commit
8f0c747e08
41 changed files with 4236 additions and 321 deletions
17
apps/chat/apps/backend/jest.config.js
Normal file
17
apps/chat/apps/backend/jest.config.js
Normal file
|
|
@ -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$': '<rootDir>/../../packages/chat-types/src',
|
||||
'^@manacore/shared-nestjs-auth$': '<rootDir>/../../../../../packages/shared-nestjs-auth/src',
|
||||
'^@manacore/shared-errors$': '<rootDir>/../../../../../packages/shared-errors/src',
|
||||
},
|
||||
};
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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) => ({
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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>(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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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<Conversation> {
|
||||
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<Message> {
|
||||
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<Conversation> {
|
||||
const result = await this.conversationService.updateTitle(id, user.userId, body.title);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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, {
|
||||
|
|
|
|||
|
|
@ -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, {
|
||||
|
|
|
|||
|
|
@ -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, {
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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, {
|
||||
|
|
|
|||
|
|
@ -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, {
|
||||
|
|
|
|||
609
apps/chat/apps/backend/src/space/__tests__/space.service.spec.ts
Normal file
609
apps/chat/apps/backend/src/space/__tests__/space.service.spec.ts
Normal file
|
|
@ -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>(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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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<SpaceMember[]> {
|
||||
async getSpaceMembers(
|
||||
@Param('id') id: string,
|
||||
@CurrentUser() user: CurrentUserData
|
||||
): Promise<SpaceMember[]> {
|
||||
// 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)) {
|
||||
|
|
|
|||
|
|
@ -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<SpaceMember> {
|
||||
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()
|
||||
|
|
|
|||
69
apps/chat/apps/backend/src/template/dto/template.dto.ts
Normal file
69
apps/chat/apps/backend/src/template/dto/template.dto.ts
Normal file
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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<Template> {
|
||||
const result = await this.templateService.createTemplate(user.userId, body);
|
||||
|
|
@ -72,16 +64,7 @@ export class TemplateController {
|
|||
@Patch(':id')
|
||||
async updateTemplate(
|
||||
@Param('id') id: string,
|
||||
@Body()
|
||||
body: Partial<{
|
||||
name: string;
|
||||
description: string;
|
||||
systemPrompt: string;
|
||||
initialQuestion: string;
|
||||
modelId: string;
|
||||
color: string;
|
||||
documentMode: boolean;
|
||||
}>,
|
||||
@Body() body: UpdateTemplateDto,
|
||||
@CurrentUser() user: CurrentUserData
|
||||
): Promise<Template> {
|
||||
const result = await this.templateService.updateTemplate(id, user.userId, body);
|
||||
|
|
|
|||
|
|
@ -17,9 +17,10 @@ export const handle: Handle = async ({ event, resolve }) => {
|
|||
transformPageChunk: ({ html }) => {
|
||||
// Inject runtime environment variables into the HTML
|
||||
// These will be available on window.__PUBLIC_*__ for client-side code
|
||||
// Use JSON.stringify to prevent HTML/script injection
|
||||
const envScript = `<script>
|
||||
window.__PUBLIC_MANA_CORE_AUTH_URL__ = "${PUBLIC_MANA_CORE_AUTH_URL_CLIENT}";
|
||||
window.__PUBLIC_BACKEND_URL__ = "${PUBLIC_BACKEND_URL_CLIENT}";
|
||||
window.__PUBLIC_MANA_CORE_AUTH_URL__ = ${JSON.stringify(PUBLIC_MANA_CORE_AUTH_URL_CLIENT)};
|
||||
window.__PUBLIC_BACKEND_URL__ = ${JSON.stringify(PUBLIC_BACKEND_URL_CLIENT)};
|
||||
</script>`;
|
||||
return html.replace('<head>', `<head>${envScript}`);
|
||||
},
|
||||
|
|
|
|||
|
|
@ -1,5 +1,9 @@
|
|||
import type { Song } from '../../db/schema/songs.schema';
|
||||
import type { Playlist, PlaylistSong } from '../../db/schema/playlists.schema';
|
||||
import type { Beat } from '../../db/schema/beats.schema';
|
||||
import type { Marker } from '../../db/schema/markers.schema';
|
||||
import type { Project } from '../../db/schema/projects.schema';
|
||||
import type { LibraryBeat } from '../../db/schema/library-beats.schema';
|
||||
|
||||
export const TEST_USER_ID = 'test-user-123';
|
||||
export const TEST_USER_EMAIL = 'test@example.com';
|
||||
|
|
@ -52,3 +56,67 @@ export function createMockPlaylistSong(overrides?: Partial<PlaylistSong>): Playl
|
|||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
export function createMockBeat(overrides?: Partial<Beat>): Beat {
|
||||
return {
|
||||
id: crypto.randomUUID(),
|
||||
projectId: crypto.randomUUID(),
|
||||
storagePath: 'users/test-user-123/beat.mp3',
|
||||
filename: 'beat.mp3',
|
||||
duration: 180.0,
|
||||
bpm: 120.0,
|
||||
bpmConfidence: 0.95,
|
||||
waveformData: null,
|
||||
transcriptionStatus: 'none',
|
||||
transcriptionError: null,
|
||||
transcribedAt: null,
|
||||
createdAt: new Date(),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
export function createMockMarker(overrides?: Partial<Marker>): Marker {
|
||||
return {
|
||||
id: crypto.randomUUID(),
|
||||
beatId: crypto.randomUUID(),
|
||||
type: 'section',
|
||||
label: 'Verse 1',
|
||||
startTime: 0.0,
|
||||
endTime: 30.0,
|
||||
color: '#FF0000',
|
||||
sortOrder: 0,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
export function createMockProject(overrides?: Partial<Project>): Project {
|
||||
return {
|
||||
id: crypto.randomUUID(),
|
||||
userId: TEST_USER_ID,
|
||||
title: 'Test Project',
|
||||
description: 'A test project',
|
||||
songId: null,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
export function createMockLibraryBeat(overrides?: Partial<LibraryBeat>): LibraryBeat {
|
||||
return {
|
||||
id: crypto.randomUUID(),
|
||||
title: 'Library Beat',
|
||||
artist: 'Beat Artist',
|
||||
genre: 'Hip Hop',
|
||||
bpm: 90.0,
|
||||
duration: 200.0,
|
||||
storagePath: 'library/beats/beat.mp3',
|
||||
previewUrl: null,
|
||||
license: 'free',
|
||||
isActive: true,
|
||||
tags: ['hip-hop', 'chill'],
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
572
apps/mukke/apps/backend/src/beat/__tests__/beat.service.spec.ts
Normal file
572
apps/mukke/apps/backend/src/beat/__tests__/beat.service.spec.ts
Normal file
|
|
@ -0,0 +1,572 @@
|
|||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { NotFoundException, BadRequestException } from '@nestjs/common';
|
||||
import { BeatService } from '../beat.service';
|
||||
import { DATABASE_CONNECTION } from '../../db/database.module';
|
||||
import { SttService } from '../../stt/stt.service';
|
||||
import { LyricsService } from '../../lyrics/lyrics.service';
|
||||
import {
|
||||
createMockBeat,
|
||||
createMockProject,
|
||||
createMockLibraryBeat,
|
||||
TEST_USER_ID,
|
||||
} from '../../__tests__/utils/mock-factories';
|
||||
|
||||
// Mock the storage module
|
||||
jest.mock('@manacore/shared-storage', () => ({
|
||||
createMukkeStorage: jest.fn(() => ({
|
||||
getUploadUrl: jest.fn().mockResolvedValue('https://s3.example.com/upload'),
|
||||
getDownloadUrl: jest.fn().mockResolvedValue('https://s3.example.com/download'),
|
||||
delete: jest.fn().mockResolvedValue(undefined),
|
||||
download: jest.fn().mockResolvedValue(Buffer.from('fake-audio-data')),
|
||||
})),
|
||||
generateUserFileKey: jest.fn((userId: string, filename: string) => `users/${userId}/${filename}`),
|
||||
getContentType: jest.fn((filename: string) => {
|
||||
if (filename.endsWith('.mp3')) return 'audio/mpeg';
|
||||
if (filename.endsWith('.wav')) return 'audio/wav';
|
||||
if (filename.endsWith('.txt')) return 'text/plain';
|
||||
return 'application/octet-stream';
|
||||
}),
|
||||
}));
|
||||
|
||||
describe('BeatService', () => {
|
||||
let service: BeatService;
|
||||
let mockDb: any;
|
||||
let mockSttService: any;
|
||||
let mockLyricsService: any;
|
||||
|
||||
beforeEach(async () => {
|
||||
mockDb = {
|
||||
select: jest.fn().mockReturnThis(),
|
||||
from: jest.fn().mockReturnThis(),
|
||||
where: 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(),
|
||||
transaction: jest.fn(),
|
||||
};
|
||||
|
||||
mockSttService = {
|
||||
isAvailable: jest.fn(),
|
||||
transcribe: jest.fn(),
|
||||
};
|
||||
|
||||
mockLyricsService = {
|
||||
createOrUpdate: jest.fn(),
|
||||
};
|
||||
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
BeatService,
|
||||
{
|
||||
provide: DATABASE_CONNECTION,
|
||||
useValue: mockDb,
|
||||
},
|
||||
{
|
||||
provide: SttService,
|
||||
useValue: mockSttService,
|
||||
},
|
||||
{
|
||||
provide: LyricsService,
|
||||
useValue: mockLyricsService,
|
||||
},
|
||||
],
|
||||
}).compile();
|
||||
|
||||
service = module.get<BeatService>(BeatService);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('findByProjectId', () => {
|
||||
it('should return beat when found', async () => {
|
||||
const beat = createMockBeat();
|
||||
mockDb.where.mockResolvedValueOnce([beat]);
|
||||
|
||||
const result = await service.findByProjectId(beat.projectId);
|
||||
|
||||
expect(result).toEqual(beat);
|
||||
expect(mockDb.select).toHaveBeenCalled();
|
||||
expect(mockDb.from).toHaveBeenCalled();
|
||||
expect(mockDb.where).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should return null when not found', async () => {
|
||||
mockDb.where.mockResolvedValueOnce([]);
|
||||
|
||||
const result = await service.findByProjectId('non-existent-id');
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('findById', () => {
|
||||
it('should return beat when found', async () => {
|
||||
const beat = createMockBeat();
|
||||
mockDb.where.mockResolvedValueOnce([beat]);
|
||||
|
||||
const result = await service.findById(beat.id);
|
||||
|
||||
expect(result).toEqual(beat);
|
||||
});
|
||||
|
||||
it('should return null when not found', async () => {
|
||||
mockDb.where.mockResolvedValueOnce([]);
|
||||
|
||||
const result = await service.findById('non-existent-id');
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('findByIdOrThrow', () => {
|
||||
it('should return beat when found', async () => {
|
||||
const beat = createMockBeat();
|
||||
mockDb.where.mockResolvedValueOnce([beat]);
|
||||
|
||||
const result = await service.findByIdOrThrow(beat.id);
|
||||
|
||||
expect(result).toEqual(beat);
|
||||
});
|
||||
|
||||
it('should throw NotFoundException when not found', async () => {
|
||||
mockDb.where.mockResolvedValueOnce([]);
|
||||
|
||||
await expect(service.findByIdOrThrow('non-existent-id')).rejects.toThrow(NotFoundException);
|
||||
});
|
||||
});
|
||||
|
||||
describe('verifyProjectOwnership', () => {
|
||||
it('should not throw when project belongs to user', async () => {
|
||||
const project = createMockProject();
|
||||
mockDb.where.mockResolvedValueOnce([project]);
|
||||
|
||||
await expect(service.verifyProjectOwnership(project.id, TEST_USER_ID)).resolves.not.toThrow();
|
||||
});
|
||||
|
||||
it('should throw NotFoundException when project not found', async () => {
|
||||
mockDb.where.mockResolvedValueOnce([]);
|
||||
|
||||
await expect(service.verifyProjectOwnership('non-existent-id', TEST_USER_ID)).rejects.toThrow(
|
||||
NotFoundException
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('createUploadUrl', () => {
|
||||
it('should create beat record and return upload URL', async () => {
|
||||
const project = createMockProject();
|
||||
const beat = createMockBeat({ projectId: project.id });
|
||||
|
||||
// verifyProjectOwnership
|
||||
mockDb.where.mockResolvedValueOnce([project]);
|
||||
// findByProjectId (no existing beat)
|
||||
mockDb.where.mockResolvedValueOnce([]);
|
||||
// insert returning
|
||||
mockDb.returning.mockResolvedValueOnce([beat]);
|
||||
|
||||
const result = await service.createUploadUrl(project.id, TEST_USER_ID, 'test.mp3');
|
||||
|
||||
expect(result.beat).toEqual(beat);
|
||||
expect(result.uploadUrl).toBe('https://s3.example.com/upload');
|
||||
expect(mockDb.insert).toHaveBeenCalled();
|
||||
expect(mockDb.values).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should reject non-audio files', async () => {
|
||||
const project = createMockProject();
|
||||
|
||||
// verifyProjectOwnership
|
||||
mockDb.where.mockResolvedValueOnce([project]);
|
||||
// findByProjectId (no existing beat)
|
||||
mockDb.where.mockResolvedValueOnce([]);
|
||||
|
||||
await expect(service.createUploadUrl(project.id, TEST_USER_ID, 'test.txt')).rejects.toThrow(
|
||||
BadRequestException
|
||||
);
|
||||
});
|
||||
|
||||
it('should reject if beat already exists for project', async () => {
|
||||
const project = createMockProject();
|
||||
const existingBeat = createMockBeat({ projectId: project.id });
|
||||
|
||||
// verifyProjectOwnership
|
||||
mockDb.where.mockResolvedValueOnce([project]);
|
||||
// findByProjectId (existing beat found)
|
||||
mockDb.where.mockResolvedValueOnce([existingBeat]);
|
||||
|
||||
await expect(service.createUploadUrl(project.id, TEST_USER_ID, 'test.mp3')).rejects.toThrow(
|
||||
BadRequestException
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw NotFoundException if project not owned by user', async () => {
|
||||
mockDb.where.mockResolvedValueOnce([]);
|
||||
|
||||
await expect(
|
||||
service.createUploadUrl('non-existent-project', TEST_USER_ID, 'test.mp3')
|
||||
).rejects.toThrow(NotFoundException);
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateBeatMetadata', () => {
|
||||
it('should update beat metadata', async () => {
|
||||
const beat = createMockBeat();
|
||||
const project = createMockProject({ id: beat.projectId });
|
||||
const updatedBeat = createMockBeat({
|
||||
...beat,
|
||||
bpm: 140.0,
|
||||
duration: 200.0,
|
||||
});
|
||||
|
||||
// findByIdOrThrow -> findById
|
||||
mockDb.where.mockResolvedValueOnce([beat]);
|
||||
// verifyProjectOwnership
|
||||
mockDb.where.mockResolvedValueOnce([project]);
|
||||
// update returning
|
||||
mockDb.returning.mockResolvedValueOnce([updatedBeat]);
|
||||
|
||||
const result = await service.updateBeatMetadata(beat.id, TEST_USER_ID, {
|
||||
bpm: 140.0,
|
||||
duration: 200.0,
|
||||
});
|
||||
|
||||
expect(result).toEqual(updatedBeat);
|
||||
expect(result.bpm).toBe(140.0);
|
||||
expect(mockDb.update).toHaveBeenCalled();
|
||||
expect(mockDb.set).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should throw NotFoundException for non-existent beat', async () => {
|
||||
mockDb.where.mockResolvedValueOnce([]);
|
||||
|
||||
await expect(
|
||||
service.updateBeatMetadata('non-existent-id', TEST_USER_ID, { bpm: 120 })
|
||||
).rejects.toThrow(NotFoundException);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getDownloadUrl', () => {
|
||||
it('should return presigned download URL', async () => {
|
||||
const beat = createMockBeat();
|
||||
const project = createMockProject({ id: beat.projectId });
|
||||
|
||||
// findByIdOrThrow -> findById
|
||||
mockDb.where.mockResolvedValueOnce([beat]);
|
||||
// verifyProjectOwnership
|
||||
mockDb.where.mockResolvedValueOnce([project]);
|
||||
|
||||
const result = await service.getDownloadUrl(beat.id, TEST_USER_ID);
|
||||
|
||||
expect(result).toBe('https://s3.example.com/download');
|
||||
});
|
||||
|
||||
it('should throw NotFoundException for non-existent beat', async () => {
|
||||
mockDb.where.mockResolvedValueOnce([]);
|
||||
|
||||
await expect(service.getDownloadUrl('non-existent-id', TEST_USER_ID)).rejects.toThrow(
|
||||
NotFoundException
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('delete', () => {
|
||||
it('should delete beat from storage and database', async () => {
|
||||
const beat = createMockBeat();
|
||||
const project = createMockProject({ id: beat.projectId });
|
||||
|
||||
// findByIdOrThrow -> findById
|
||||
mockDb.where.mockResolvedValueOnce([beat]);
|
||||
// verifyProjectOwnership
|
||||
mockDb.where.mockResolvedValueOnce([project]);
|
||||
// db.delete().where()
|
||||
mockDb.where.mockResolvedValueOnce(undefined);
|
||||
|
||||
await service.delete(beat.id, TEST_USER_ID);
|
||||
|
||||
expect(mockDb.delete).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should still delete from DB if storage delete fails', async () => {
|
||||
const { createMukkeStorage } = require('@manacore/shared-storage');
|
||||
const mockStorage = {
|
||||
getUploadUrl: jest.fn().mockResolvedValue('https://s3.example.com/upload'),
|
||||
getDownloadUrl: jest.fn().mockResolvedValue('https://s3.example.com/download'),
|
||||
delete: jest.fn().mockRejectedValue(new Error('Storage error')),
|
||||
download: jest.fn().mockResolvedValue(Buffer.from('fake-audio-data')),
|
||||
};
|
||||
createMukkeStorage.mockReturnValue(mockStorage);
|
||||
|
||||
// Re-create the service to pick up the new mock
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
BeatService,
|
||||
{ provide: DATABASE_CONNECTION, useValue: mockDb },
|
||||
{ provide: SttService, useValue: mockSttService },
|
||||
{ provide: LyricsService, useValue: mockLyricsService },
|
||||
],
|
||||
}).compile();
|
||||
const serviceWithFailingStorage = module.get<BeatService>(BeatService);
|
||||
|
||||
const beat = createMockBeat();
|
||||
const project = createMockProject({ id: beat.projectId });
|
||||
|
||||
// findByIdOrThrow -> findById
|
||||
mockDb.where.mockResolvedValueOnce([beat]);
|
||||
// verifyProjectOwnership
|
||||
mockDb.where.mockResolvedValueOnce([project]);
|
||||
// db.delete().where()
|
||||
mockDb.where.mockResolvedValueOnce(undefined);
|
||||
|
||||
await serviceWithFailingStorage.delete(beat.id, TEST_USER_ID);
|
||||
|
||||
expect(mockDb.delete).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should throw NotFoundException for non-existent beat', async () => {
|
||||
mockDb.where.mockResolvedValueOnce([]);
|
||||
|
||||
await expect(service.delete('non-existent-id', TEST_USER_ID)).rejects.toThrow(
|
||||
NotFoundException
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getMarkersForBeat', () => {
|
||||
it('should return markers for a beat', async () => {
|
||||
const markers = [
|
||||
{ id: '1', beatId: 'beat-1', type: 'section', startTime: 0 },
|
||||
{ id: '2', beatId: 'beat-1', type: 'section', startTime: 30 },
|
||||
];
|
||||
mockDb.where.mockResolvedValueOnce(markers);
|
||||
|
||||
const result = await service.getMarkersForBeat('beat-1');
|
||||
|
||||
expect(result).toEqual(markers);
|
||||
expect(mockDb.select).toHaveBeenCalled();
|
||||
expect(mockDb.from).toHaveBeenCalled();
|
||||
expect(mockDb.where).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should return empty array when no markers', async () => {
|
||||
mockDb.where.mockResolvedValueOnce([]);
|
||||
|
||||
const result = await service.getMarkersForBeat('beat-1');
|
||||
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getLibraryBeats', () => {
|
||||
it('should return active library beats ordered by title', async () => {
|
||||
const libraryBeats = [
|
||||
createMockLibraryBeat({ title: 'A Beat' }),
|
||||
createMockLibraryBeat({ title: 'B Beat' }),
|
||||
];
|
||||
mockDb.orderBy.mockResolvedValueOnce(libraryBeats);
|
||||
|
||||
const result = await service.getLibraryBeats();
|
||||
|
||||
expect(result).toEqual(libraryBeats);
|
||||
expect(mockDb.select).toHaveBeenCalled();
|
||||
expect(mockDb.from).toHaveBeenCalled();
|
||||
expect(mockDb.where).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getLibraryBeatById', () => {
|
||||
it('should return library beat when found', async () => {
|
||||
const libraryBeat = createMockLibraryBeat();
|
||||
mockDb.where.mockResolvedValueOnce([libraryBeat]);
|
||||
|
||||
const result = await service.getLibraryBeatById(libraryBeat.id);
|
||||
|
||||
expect(result).toEqual(libraryBeat);
|
||||
});
|
||||
|
||||
it('should return null when not found', async () => {
|
||||
mockDb.where.mockResolvedValueOnce([]);
|
||||
|
||||
const result = await service.getLibraryBeatById('non-existent-id');
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getLibraryBeatDownloadUrl', () => {
|
||||
it('should return download URL for library beat', async () => {
|
||||
const libraryBeat = createMockLibraryBeat();
|
||||
mockDb.where.mockResolvedValueOnce([libraryBeat]);
|
||||
|
||||
const result = await service.getLibraryBeatDownloadUrl(libraryBeat.id);
|
||||
|
||||
expect(result).toBe('https://s3.example.com/download');
|
||||
});
|
||||
|
||||
it('should throw NotFoundException when library beat not found', async () => {
|
||||
mockDb.where.mockResolvedValueOnce([]);
|
||||
|
||||
await expect(service.getLibraryBeatDownloadUrl('non-existent-id')).rejects.toThrow(
|
||||
NotFoundException
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('useLibraryBeat', () => {
|
||||
it('should create beat from library beat', async () => {
|
||||
const project = createMockProject();
|
||||
const libraryBeat = createMockLibraryBeat();
|
||||
const newBeat = createMockBeat({
|
||||
projectId: project.id,
|
||||
storagePath: libraryBeat.storagePath,
|
||||
});
|
||||
|
||||
// verifyProjectOwnership
|
||||
mockDb.where.mockResolvedValueOnce([project]);
|
||||
// findByProjectId (no existing beat)
|
||||
mockDb.where.mockResolvedValueOnce([]);
|
||||
// getLibraryBeatById
|
||||
mockDb.where.mockResolvedValueOnce([libraryBeat]);
|
||||
// insert returning
|
||||
mockDb.returning.mockResolvedValueOnce([newBeat]);
|
||||
|
||||
const result = await service.useLibraryBeat(libraryBeat.id, project.id, TEST_USER_ID);
|
||||
|
||||
expect(result).toEqual(newBeat);
|
||||
expect(mockDb.insert).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should throw if beat already exists for project', async () => {
|
||||
const project = createMockProject();
|
||||
const existingBeat = createMockBeat({ projectId: project.id });
|
||||
|
||||
// verifyProjectOwnership
|
||||
mockDb.where.mockResolvedValueOnce([project]);
|
||||
// findByProjectId (existing beat found)
|
||||
mockDb.where.mockResolvedValueOnce([existingBeat]);
|
||||
|
||||
await expect(service.useLibraryBeat('lib-beat-id', project.id, TEST_USER_ID)).rejects.toThrow(
|
||||
BadRequestException
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw NotFoundException when library beat not found', async () => {
|
||||
const project = createMockProject();
|
||||
|
||||
// verifyProjectOwnership
|
||||
mockDb.where.mockResolvedValueOnce([project]);
|
||||
// findByProjectId (no existing beat)
|
||||
mockDb.where.mockResolvedValueOnce([]);
|
||||
// getLibraryBeatById (not found)
|
||||
mockDb.where.mockResolvedValueOnce([]);
|
||||
|
||||
await expect(
|
||||
service.useLibraryBeat('non-existent-id', project.id, TEST_USER_ID)
|
||||
).rejects.toThrow(NotFoundException);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isSttAvailable', () => {
|
||||
it('should return true when STT service is available', async () => {
|
||||
mockSttService.isAvailable.mockResolvedValueOnce(true);
|
||||
|
||||
const result = await service.isSttAvailable();
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(mockSttService.isAvailable).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should return false when STT service is not available', async () => {
|
||||
mockSttService.isAvailable.mockResolvedValueOnce(false);
|
||||
|
||||
const result = await service.isSttAvailable();
|
||||
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('transcribeBeat', () => {
|
||||
it('should transcribe beat audio and save lyrics', async () => {
|
||||
const beat = createMockBeat({ projectId: 'proj-1' });
|
||||
const project = createMockProject({ id: 'proj-1' });
|
||||
const updatedBeat = createMockBeat({
|
||||
...beat,
|
||||
transcriptionStatus: 'completed',
|
||||
transcribedAt: new Date(),
|
||||
});
|
||||
|
||||
// findByIdOrThrow -> findById
|
||||
mockDb.where.mockResolvedValueOnce([beat]);
|
||||
// verifyProjectOwnership
|
||||
mockDb.where.mockResolvedValueOnce([project]);
|
||||
// update set status to pending
|
||||
mockDb.where.mockResolvedValueOnce(undefined);
|
||||
|
||||
mockSttService.transcribe.mockResolvedValueOnce({
|
||||
text: 'Hello world lyrics',
|
||||
language: 'en',
|
||||
model: 'whisper',
|
||||
latencyMs: 1000,
|
||||
durationSeconds: 180,
|
||||
});
|
||||
|
||||
mockLyricsService.createOrUpdate.mockResolvedValueOnce({
|
||||
id: 'lyrics-1',
|
||||
projectId: 'proj-1',
|
||||
content: 'Hello world lyrics',
|
||||
});
|
||||
|
||||
// update beat status to completed
|
||||
mockDb.returning.mockResolvedValueOnce([updatedBeat]);
|
||||
|
||||
const result = await service.transcribeBeat(beat.id, TEST_USER_ID);
|
||||
|
||||
expect(result.beat).toEqual(updatedBeat);
|
||||
expect(result.lyrics).toBe('Hello world lyrics');
|
||||
expect(mockSttService.transcribe).toHaveBeenCalled();
|
||||
expect(mockLyricsService.createOrUpdate).toHaveBeenCalledWith(
|
||||
'proj-1',
|
||||
TEST_USER_ID,
|
||||
'Hello world lyrics'
|
||||
);
|
||||
});
|
||||
|
||||
it('should set status to failed when transcription fails', async () => {
|
||||
const beat = createMockBeat({ projectId: 'proj-1' });
|
||||
const project = createMockProject({ id: 'proj-1' });
|
||||
|
||||
// findByIdOrThrow -> findById
|
||||
mockDb.where.mockResolvedValueOnce([beat]);
|
||||
// verifyProjectOwnership
|
||||
mockDb.where.mockResolvedValueOnce([project]);
|
||||
// update set status to pending
|
||||
mockDb.where.mockResolvedValueOnce(undefined);
|
||||
|
||||
mockSttService.transcribe.mockRejectedValueOnce(new Error('STT service unavailable'));
|
||||
|
||||
// update beat status to failed
|
||||
mockDb.where.mockResolvedValueOnce(undefined);
|
||||
|
||||
await expect(service.transcribeBeat(beat.id, TEST_USER_ID)).rejects.toThrow(
|
||||
'STT service unavailable'
|
||||
);
|
||||
|
||||
// Verify update was called to set failed status
|
||||
expect(mockDb.update).toHaveBeenCalled();
|
||||
expect(mockDb.set).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should throw NotFoundException for non-existent beat', async () => {
|
||||
mockDb.where.mockResolvedValueOnce([]);
|
||||
|
||||
await expect(service.transcribeBeat('non-existent-id', TEST_USER_ID)).rejects.toThrow(
|
||||
NotFoundException
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,381 @@
|
|||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { NotFoundException } from '@nestjs/common';
|
||||
import { MarkerService } from '../marker.service';
|
||||
import { DATABASE_CONNECTION } from '../../db/database.module';
|
||||
import {
|
||||
createMockMarker,
|
||||
createMockBeat,
|
||||
createMockProject,
|
||||
TEST_USER_ID,
|
||||
} from '../../__tests__/utils/mock-factories';
|
||||
|
||||
describe('MarkerService', () => {
|
||||
let service: MarkerService;
|
||||
let mockDb: any;
|
||||
|
||||
beforeEach(async () => {
|
||||
mockDb = {
|
||||
select: jest.fn().mockReturnThis(),
|
||||
from: jest.fn().mockReturnThis(),
|
||||
where: 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(),
|
||||
transaction: jest.fn(),
|
||||
};
|
||||
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
MarkerService,
|
||||
{
|
||||
provide: DATABASE_CONNECTION,
|
||||
useValue: mockDb,
|
||||
},
|
||||
],
|
||||
}).compile();
|
||||
|
||||
service = module.get<MarkerService>(MarkerService);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('verifyBeatOwnership', () => {
|
||||
it('should not throw when beat and project belong to user', async () => {
|
||||
const beat = createMockBeat();
|
||||
const project = createMockProject({ id: beat.projectId });
|
||||
|
||||
// find beat
|
||||
mockDb.where.mockResolvedValueOnce([beat]);
|
||||
// find project
|
||||
mockDb.where.mockResolvedValueOnce([project]);
|
||||
|
||||
await expect(service.verifyBeatOwnership(beat.id, TEST_USER_ID)).resolves.not.toThrow();
|
||||
});
|
||||
|
||||
it('should throw NotFoundException when beat not found', async () => {
|
||||
mockDb.where.mockResolvedValueOnce([]);
|
||||
|
||||
await expect(service.verifyBeatOwnership('non-existent-id', TEST_USER_ID)).rejects.toThrow(
|
||||
NotFoundException
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw NotFoundException when project not found for user', async () => {
|
||||
const beat = createMockBeat();
|
||||
|
||||
// find beat
|
||||
mockDb.where.mockResolvedValueOnce([beat]);
|
||||
// find project (not found for user)
|
||||
mockDb.where.mockResolvedValueOnce([]);
|
||||
|
||||
await expect(service.verifyBeatOwnership(beat.id, TEST_USER_ID)).rejects.toThrow(
|
||||
NotFoundException
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('findByBeatId', () => {
|
||||
it('should return markers ordered by start time', async () => {
|
||||
const beatId = 'beat-1';
|
||||
const markerList = [
|
||||
createMockMarker({ beatId, startTime: 0.0, label: 'Intro' }),
|
||||
createMockMarker({ beatId, startTime: 30.0, label: 'Verse 1' }),
|
||||
createMockMarker({ beatId, startTime: 60.0, label: 'Chorus' }),
|
||||
];
|
||||
mockDb.orderBy.mockResolvedValueOnce(markerList);
|
||||
|
||||
const result = await service.findByBeatId(beatId);
|
||||
|
||||
expect(result).toEqual(markerList);
|
||||
expect(result).toHaveLength(3);
|
||||
expect(mockDb.select).toHaveBeenCalled();
|
||||
expect(mockDb.from).toHaveBeenCalled();
|
||||
expect(mockDb.where).toHaveBeenCalled();
|
||||
expect(mockDb.orderBy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should return empty array when no markers', async () => {
|
||||
mockDb.orderBy.mockResolvedValueOnce([]);
|
||||
|
||||
const result = await service.findByBeatId('beat-with-no-markers');
|
||||
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('findById', () => {
|
||||
it('should return marker when found', async () => {
|
||||
const marker = createMockMarker();
|
||||
mockDb.where.mockResolvedValueOnce([marker]);
|
||||
|
||||
const result = await service.findById(marker.id);
|
||||
|
||||
expect(result).toEqual(marker);
|
||||
});
|
||||
|
||||
it('should return null when not found', async () => {
|
||||
mockDb.where.mockResolvedValueOnce([]);
|
||||
|
||||
const result = await service.findById('non-existent-id');
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('findByIdOrThrow', () => {
|
||||
it('should return marker when found', async () => {
|
||||
const marker = createMockMarker();
|
||||
mockDb.where.mockResolvedValueOnce([marker]);
|
||||
|
||||
const result = await service.findByIdOrThrow(marker.id);
|
||||
|
||||
expect(result).toEqual(marker);
|
||||
});
|
||||
|
||||
it('should throw NotFoundException when not found', async () => {
|
||||
mockDb.where.mockResolvedValueOnce([]);
|
||||
|
||||
await expect(service.findByIdOrThrow('non-existent-id')).rejects.toThrow(NotFoundException);
|
||||
});
|
||||
});
|
||||
|
||||
describe('create', () => {
|
||||
it('should create a new marker', async () => {
|
||||
const marker = createMockMarker();
|
||||
mockDb.returning.mockResolvedValueOnce([marker]);
|
||||
|
||||
const result = await service.create({
|
||||
beatId: marker.beatId,
|
||||
type: 'section',
|
||||
label: 'Verse 1',
|
||||
startTime: 0.0,
|
||||
endTime: 30.0,
|
||||
color: '#FF0000',
|
||||
});
|
||||
|
||||
expect(result).toEqual(marker);
|
||||
expect(mockDb.insert).toHaveBeenCalled();
|
||||
expect(mockDb.values).toHaveBeenCalled();
|
||||
expect(mockDb.returning).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('update', () => {
|
||||
it('should update marker fields', async () => {
|
||||
const marker = createMockMarker();
|
||||
const beat = createMockBeat({ id: marker.beatId });
|
||||
const project = createMockProject({ id: beat.projectId });
|
||||
const updatedMarker = createMockMarker({
|
||||
...marker,
|
||||
label: 'Updated Label',
|
||||
startTime: 15.0,
|
||||
});
|
||||
|
||||
// findByIdOrThrow -> findById
|
||||
mockDb.where.mockResolvedValueOnce([marker]);
|
||||
// verifyBeatOwnership -> find beat
|
||||
mockDb.where.mockResolvedValueOnce([beat]);
|
||||
// verifyBeatOwnership -> find project
|
||||
mockDb.where.mockResolvedValueOnce([project]);
|
||||
// update returning
|
||||
mockDb.returning.mockResolvedValueOnce([updatedMarker]);
|
||||
|
||||
const result = await service.update(marker.id, TEST_USER_ID, {
|
||||
label: 'Updated Label',
|
||||
startTime: 15.0,
|
||||
});
|
||||
|
||||
expect(result).toEqual(updatedMarker);
|
||||
expect(result.label).toBe('Updated Label');
|
||||
expect(mockDb.update).toHaveBeenCalled();
|
||||
expect(mockDb.set).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should throw NotFoundException for non-existent marker', async () => {
|
||||
mockDb.where.mockResolvedValueOnce([]);
|
||||
|
||||
await expect(
|
||||
service.update('non-existent-id', TEST_USER_ID, { label: 'New Label' })
|
||||
).rejects.toThrow(NotFoundException);
|
||||
});
|
||||
});
|
||||
|
||||
describe('delete', () => {
|
||||
it('should delete marker', async () => {
|
||||
const marker = createMockMarker();
|
||||
const beat = createMockBeat({ id: marker.beatId });
|
||||
const project = createMockProject({ id: beat.projectId });
|
||||
|
||||
// findByIdOrThrow -> findById
|
||||
mockDb.where.mockResolvedValueOnce([marker]);
|
||||
// verifyBeatOwnership -> find beat
|
||||
mockDb.where.mockResolvedValueOnce([beat]);
|
||||
// verifyBeatOwnership -> find project
|
||||
mockDb.where.mockResolvedValueOnce([project]);
|
||||
// db.delete().where()
|
||||
mockDb.where.mockResolvedValueOnce(undefined);
|
||||
|
||||
await service.delete(marker.id, TEST_USER_ID);
|
||||
|
||||
expect(mockDb.delete).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should throw NotFoundException for non-existent marker', async () => {
|
||||
mockDb.where.mockResolvedValueOnce([]);
|
||||
|
||||
await expect(service.delete('non-existent-id', TEST_USER_ID)).rejects.toThrow(
|
||||
NotFoundException
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('deleteAllForBeat', () => {
|
||||
it('should delete all markers for a beat', async () => {
|
||||
const beat = createMockBeat();
|
||||
const project = createMockProject({ id: beat.projectId });
|
||||
|
||||
// verifyBeatOwnership -> find beat
|
||||
mockDb.where.mockResolvedValueOnce([beat]);
|
||||
// verifyBeatOwnership -> find project
|
||||
mockDb.where.mockResolvedValueOnce([project]);
|
||||
// db.delete().where()
|
||||
mockDb.where.mockResolvedValueOnce(undefined);
|
||||
|
||||
await service.deleteAllForBeat(beat.id, TEST_USER_ID);
|
||||
|
||||
expect(mockDb.delete).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should throw NotFoundException if beat not found', async () => {
|
||||
mockDb.where.mockResolvedValueOnce([]);
|
||||
|
||||
await expect(service.deleteAllForBeat('non-existent-id', TEST_USER_ID)).rejects.toThrow(
|
||||
NotFoundException
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('bulkCreate', () => {
|
||||
it('should create multiple markers for a beat', async () => {
|
||||
const beatId = 'beat-1';
|
||||
const beat = createMockBeat({ id: beatId });
|
||||
const project = createMockProject({ id: beat.projectId });
|
||||
const createdMarkers = [
|
||||
createMockMarker({ beatId, label: 'Intro', startTime: 0.0 }),
|
||||
createMockMarker({ beatId, label: 'Verse', startTime: 30.0 }),
|
||||
];
|
||||
|
||||
// verifyBeatOwnership -> find beat
|
||||
mockDb.where.mockResolvedValueOnce([beat]);
|
||||
// verifyBeatOwnership -> find project
|
||||
mockDb.where.mockResolvedValueOnce([project]);
|
||||
// insert returning
|
||||
mockDb.returning.mockResolvedValueOnce(createdMarkers);
|
||||
|
||||
const result = await service.bulkCreate(beatId, TEST_USER_ID, [
|
||||
{ type: 'section', label: 'Intro', startTime: 0.0 },
|
||||
{ type: 'section', label: 'Verse', startTime: 30.0 },
|
||||
]);
|
||||
|
||||
expect(result).toEqual(createdMarkers);
|
||||
expect(result).toHaveLength(2);
|
||||
expect(mockDb.insert).toHaveBeenCalled();
|
||||
expect(mockDb.values).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should return empty array for empty items list', async () => {
|
||||
const beatId = 'beat-1';
|
||||
const beat = createMockBeat({ id: beatId });
|
||||
const project = createMockProject({ id: beat.projectId });
|
||||
|
||||
// verifyBeatOwnership -> find beat
|
||||
mockDb.where.mockResolvedValueOnce([beat]);
|
||||
// verifyBeatOwnership -> find project
|
||||
mockDb.where.mockResolvedValueOnce([project]);
|
||||
|
||||
const result = await service.bulkCreate(beatId, TEST_USER_ID, []);
|
||||
|
||||
expect(result).toEqual([]);
|
||||
expect(mockDb.insert).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('bulkUpdate', () => {
|
||||
it('should update multiple markers in a transaction', async () => {
|
||||
const beatId = 'beat-1';
|
||||
const marker1 = createMockMarker({ id: 'marker-1', beatId, startTime: 0.0 });
|
||||
const marker2 = createMockMarker({ id: 'marker-2', beatId, startTime: 30.0 });
|
||||
const beat = createMockBeat({ id: beatId });
|
||||
const project = createMockProject({ id: beat.projectId });
|
||||
|
||||
const updatedMarker1 = { ...marker1, startTime: 5.0 };
|
||||
const updatedMarker2 = { ...marker2, startTime: 35.0 };
|
||||
|
||||
// Mock the transaction callback
|
||||
const mockTx = {
|
||||
update: jest.fn().mockReturnThis(),
|
||||
set: jest.fn().mockReturnThis(),
|
||||
where: jest.fn().mockReturnThis(),
|
||||
returning: jest.fn(),
|
||||
};
|
||||
|
||||
mockDb.transaction.mockImplementation(async (callback: any) => {
|
||||
// First marker: findByIdOrThrow -> findById
|
||||
mockDb.where.mockResolvedValueOnce([marker1]);
|
||||
// First marker: verifyBeatOwnership -> find beat
|
||||
mockDb.where.mockResolvedValueOnce([beat]);
|
||||
// First marker: verifyBeatOwnership -> find project
|
||||
mockDb.where.mockResolvedValueOnce([project]);
|
||||
// First marker: tx.update returning
|
||||
mockTx.returning.mockResolvedValueOnce([updatedMarker1]);
|
||||
|
||||
// Second marker: findByIdOrThrow -> findById
|
||||
mockDb.where.mockResolvedValueOnce([marker2]);
|
||||
// Second marker: verifyBeatOwnership -> find beat
|
||||
mockDb.where.mockResolvedValueOnce([beat]);
|
||||
// Second marker: verifyBeatOwnership -> find project
|
||||
mockDb.where.mockResolvedValueOnce([project]);
|
||||
// Second marker: tx.update returning
|
||||
mockTx.returning.mockResolvedValueOnce([updatedMarker2]);
|
||||
|
||||
return callback(mockTx);
|
||||
});
|
||||
|
||||
const result = await service.bulkUpdate(TEST_USER_ID, [
|
||||
{ id: 'marker-1', data: { startTime: 5.0 } },
|
||||
{ id: 'marker-2', data: { startTime: 35.0 } },
|
||||
]);
|
||||
|
||||
expect(result).toEqual([updatedMarker1, updatedMarker2]);
|
||||
expect(result).toHaveLength(2);
|
||||
expect(mockDb.transaction).toHaveBeenCalled();
|
||||
expect(mockTx.update).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('should throw NotFoundException if a marker is not found during bulk update', async () => {
|
||||
mockDb.transaction.mockImplementation(async (callback: any) => {
|
||||
const mockTx = {
|
||||
update: jest.fn().mockReturnThis(),
|
||||
set: jest.fn().mockReturnThis(),
|
||||
where: jest.fn().mockReturnThis(),
|
||||
returning: jest.fn(),
|
||||
};
|
||||
|
||||
// findByIdOrThrow -> findById (not found)
|
||||
mockDb.where.mockResolvedValueOnce([]);
|
||||
|
||||
return callback(mockTx);
|
||||
});
|
||||
|
||||
await expect(
|
||||
service.bulkUpdate(TEST_USER_ID, [{ id: 'non-existent-id', data: { startTime: 5.0 } }])
|
||||
).rejects.toThrow(NotFoundException);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,331 @@
|
|||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { NotFoundException } from '@nestjs/common';
|
||||
import { ProjectService } from '../project.service';
|
||||
import { DATABASE_CONNECTION } from '../../db/database.module';
|
||||
import {
|
||||
createMockProject,
|
||||
createMockSong,
|
||||
createMockBeat,
|
||||
TEST_USER_ID,
|
||||
} from '../../__tests__/utils/mock-factories';
|
||||
|
||||
describe('ProjectService', () => {
|
||||
let service: ProjectService;
|
||||
let mockDb: any;
|
||||
|
||||
beforeEach(async () => {
|
||||
mockDb = {
|
||||
select: jest.fn().mockReturnThis(),
|
||||
from: jest.fn().mockReturnThis(),
|
||||
where: 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(),
|
||||
transaction: jest.fn(),
|
||||
};
|
||||
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
ProjectService,
|
||||
{
|
||||
provide: DATABASE_CONNECTION,
|
||||
useValue: mockDb,
|
||||
},
|
||||
],
|
||||
}).compile();
|
||||
|
||||
service = module.get<ProjectService>(ProjectService);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('findByUserId', () => {
|
||||
it('should return all projects for a user ordered by updatedAt', async () => {
|
||||
const projectList = [
|
||||
createMockProject({ title: 'Project 1', updatedAt: new Date('2025-02-01') }),
|
||||
createMockProject({ title: 'Project 2', updatedAt: new Date('2025-01-01') }),
|
||||
];
|
||||
mockDb.orderBy.mockResolvedValueOnce(projectList);
|
||||
|
||||
const result = await service.findByUserId(TEST_USER_ID);
|
||||
|
||||
expect(result).toEqual(projectList);
|
||||
expect(result).toHaveLength(2);
|
||||
expect(mockDb.select).toHaveBeenCalled();
|
||||
expect(mockDb.from).toHaveBeenCalled();
|
||||
expect(mockDb.where).toHaveBeenCalled();
|
||||
expect(mockDb.orderBy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should return empty array when no projects', async () => {
|
||||
mockDb.orderBy.mockResolvedValueOnce([]);
|
||||
|
||||
const result = await service.findByUserId(TEST_USER_ID);
|
||||
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('findById', () => {
|
||||
it('should return project when found', async () => {
|
||||
const project = createMockProject();
|
||||
mockDb.where.mockResolvedValueOnce([project]);
|
||||
|
||||
const result = await service.findById(project.id, TEST_USER_ID);
|
||||
|
||||
expect(result).toEqual(project);
|
||||
});
|
||||
|
||||
it('should return null when not found', async () => {
|
||||
mockDb.where.mockResolvedValueOnce([]);
|
||||
|
||||
const result = await service.findById('non-existent-id', TEST_USER_ID);
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('findByIdOrThrow', () => {
|
||||
it('should return project when found', async () => {
|
||||
const project = createMockProject();
|
||||
mockDb.where.mockResolvedValueOnce([project]);
|
||||
|
||||
const result = await service.findByIdOrThrow(project.id, TEST_USER_ID);
|
||||
|
||||
expect(result).toEqual(project);
|
||||
});
|
||||
|
||||
it('should throw NotFoundException when not found', async () => {
|
||||
mockDb.where.mockResolvedValueOnce([]);
|
||||
|
||||
await expect(service.findByIdOrThrow('non-existent-id', TEST_USER_ID)).rejects.toThrow(
|
||||
NotFoundException
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('create', () => {
|
||||
it('should create a new project', async () => {
|
||||
const project = createMockProject({ title: 'New Project' });
|
||||
mockDb.returning.mockResolvedValueOnce([project]);
|
||||
|
||||
const result = await service.create({
|
||||
userId: TEST_USER_ID,
|
||||
title: 'New Project',
|
||||
description: 'A new project',
|
||||
});
|
||||
|
||||
expect(result).toEqual(project);
|
||||
expect(result.title).toBe('New Project');
|
||||
expect(mockDb.insert).toHaveBeenCalled();
|
||||
expect(mockDb.values).toHaveBeenCalled();
|
||||
expect(mockDb.returning).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should create a project without description', async () => {
|
||||
const project = createMockProject({ title: 'Minimal Project', description: null });
|
||||
mockDb.returning.mockResolvedValueOnce([project]);
|
||||
|
||||
const result = await service.create({
|
||||
userId: TEST_USER_ID,
|
||||
title: 'Minimal Project',
|
||||
});
|
||||
|
||||
expect(result).toEqual(project);
|
||||
expect(result.description).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('update', () => {
|
||||
it('should update project title', async () => {
|
||||
const project = createMockProject();
|
||||
const updatedProject = createMockProject({ ...project, title: 'Updated Title' });
|
||||
|
||||
// findByIdOrThrow -> findById
|
||||
mockDb.where.mockResolvedValueOnce([project]);
|
||||
// update returning
|
||||
mockDb.returning.mockResolvedValueOnce([updatedProject]);
|
||||
|
||||
const result = await service.update(project.id, TEST_USER_ID, {
|
||||
title: 'Updated Title',
|
||||
});
|
||||
|
||||
expect(result.title).toBe('Updated Title');
|
||||
expect(mockDb.update).toHaveBeenCalled();
|
||||
expect(mockDb.set).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should update project description', async () => {
|
||||
const project = createMockProject();
|
||||
const updatedProject = createMockProject({
|
||||
...project,
|
||||
description: 'Updated description',
|
||||
});
|
||||
|
||||
// findByIdOrThrow -> findById
|
||||
mockDb.where.mockResolvedValueOnce([project]);
|
||||
// update returning
|
||||
mockDb.returning.mockResolvedValueOnce([updatedProject]);
|
||||
|
||||
const result = await service.update(project.id, TEST_USER_ID, {
|
||||
description: 'Updated description',
|
||||
});
|
||||
|
||||
expect(result.description).toBe('Updated description');
|
||||
});
|
||||
|
||||
it('should throw NotFoundException for non-existent project', async () => {
|
||||
mockDb.where.mockResolvedValueOnce([]);
|
||||
|
||||
await expect(
|
||||
service.update('non-existent-id', TEST_USER_ID, { title: 'New Title' })
|
||||
).rejects.toThrow(NotFoundException);
|
||||
});
|
||||
});
|
||||
|
||||
describe('delete', () => {
|
||||
it('should delete project', async () => {
|
||||
const project = createMockProject();
|
||||
|
||||
// findByIdOrThrow -> findById
|
||||
mockDb.where.mockResolvedValueOnce([project]);
|
||||
// db.delete().where()
|
||||
mockDb.where.mockResolvedValueOnce(undefined);
|
||||
|
||||
await service.delete(project.id, TEST_USER_ID);
|
||||
|
||||
expect(mockDb.delete).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should throw NotFoundException for non-existent project', async () => {
|
||||
mockDb.where.mockResolvedValueOnce([]);
|
||||
|
||||
await expect(service.delete('non-existent-id', TEST_USER_ID)).rejects.toThrow(
|
||||
NotFoundException
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('createFromSong', () => {
|
||||
it('should create project from song with artist in title', async () => {
|
||||
const song = createMockSong({ title: 'My Song', artist: 'My Artist' });
|
||||
const project = createMockProject({
|
||||
title: 'My Song - My Artist',
|
||||
songId: song.id,
|
||||
});
|
||||
|
||||
// find song
|
||||
mockDb.where.mockResolvedValueOnce([song]);
|
||||
|
||||
// mock transaction
|
||||
const mockTx = {
|
||||
insert: jest.fn().mockReturnThis(),
|
||||
values: jest.fn().mockReturnThis(),
|
||||
returning: jest.fn(),
|
||||
};
|
||||
|
||||
mockDb.transaction.mockImplementation(async (callback: any) => {
|
||||
// insert project returning
|
||||
mockTx.returning.mockResolvedValueOnce([project]);
|
||||
// insert beat (no returning needed since result isn't used)
|
||||
mockTx.returning.mockResolvedValueOnce([createMockBeat()]);
|
||||
|
||||
return callback(mockTx);
|
||||
});
|
||||
|
||||
const result = await service.createFromSong(song.id, TEST_USER_ID);
|
||||
|
||||
expect(result).toEqual(project);
|
||||
expect(result.title).toBe('My Song - My Artist');
|
||||
expect(mockDb.transaction).toHaveBeenCalled();
|
||||
expect(mockTx.insert).toHaveBeenCalledTimes(2); // project + beat
|
||||
});
|
||||
|
||||
it('should create project from song without artist', async () => {
|
||||
const song = createMockSong({ title: 'Solo Song', artist: null });
|
||||
const project = createMockProject({
|
||||
title: 'Solo Song',
|
||||
songId: song.id,
|
||||
});
|
||||
|
||||
// find song
|
||||
mockDb.where.mockResolvedValueOnce([song]);
|
||||
|
||||
const mockTx = {
|
||||
insert: jest.fn().mockReturnThis(),
|
||||
values: jest.fn().mockReturnThis(),
|
||||
returning: jest.fn(),
|
||||
};
|
||||
|
||||
mockDb.transaction.mockImplementation(async (callback: any) => {
|
||||
mockTx.returning.mockResolvedValueOnce([project]);
|
||||
mockTx.returning.mockResolvedValueOnce([createMockBeat()]);
|
||||
return callback(mockTx);
|
||||
});
|
||||
|
||||
const result = await service.createFromSong(song.id, TEST_USER_ID);
|
||||
|
||||
expect(result.title).toBe('Solo Song');
|
||||
});
|
||||
|
||||
it('should throw NotFoundException when song not found', async () => {
|
||||
mockDb.where.mockResolvedValueOnce([]);
|
||||
|
||||
await expect(service.createFromSong('non-existent-id', TEST_USER_ID)).rejects.toThrow(
|
||||
NotFoundException
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getProjectWithRelations', () => {
|
||||
it('should return project with beat and lyrics', async () => {
|
||||
const project = createMockProject();
|
||||
const beat = createMockBeat({ projectId: project.id });
|
||||
const projectLyrics = { id: 'lyrics-1', projectId: project.id, content: 'Hello world' };
|
||||
|
||||
// findByIdOrThrow -> findById
|
||||
mockDb.where.mockResolvedValueOnce([project]);
|
||||
// find beat
|
||||
mockDb.where.mockResolvedValueOnce([beat]);
|
||||
// find lyrics
|
||||
mockDb.where.mockResolvedValueOnce([projectLyrics]);
|
||||
|
||||
const result = await service.getProjectWithRelations(project.id, TEST_USER_ID);
|
||||
|
||||
expect(result.id).toBe(project.id);
|
||||
expect(result.beat).toEqual(beat);
|
||||
expect(result.lyrics).toEqual(projectLyrics);
|
||||
});
|
||||
|
||||
it('should return project with null beat and lyrics when none exist', async () => {
|
||||
const project = createMockProject();
|
||||
|
||||
// findByIdOrThrow -> findById
|
||||
mockDb.where.mockResolvedValueOnce([project]);
|
||||
// find beat (none)
|
||||
mockDb.where.mockResolvedValueOnce([]);
|
||||
// find lyrics (none)
|
||||
mockDb.where.mockResolvedValueOnce([]);
|
||||
|
||||
const result = await service.getProjectWithRelations(project.id, TEST_USER_ID);
|
||||
|
||||
expect(result.id).toBe(project.id);
|
||||
expect(result.beat).toBeNull();
|
||||
expect(result.lyrics).toBeNull();
|
||||
});
|
||||
|
||||
it('should throw NotFoundException for non-existent project', async () => {
|
||||
mockDb.where.mockResolvedValueOnce([]);
|
||||
|
||||
await expect(
|
||||
service.getProjectWithRelations('non-existent-id', TEST_USER_ID)
|
||||
).rejects.toThrow(NotFoundException);
|
||||
});
|
||||
});
|
||||
});
|
||||
16
apps/picture/apps/backend/jest.config.js
Normal file
16
apps/picture/apps/backend/jest.config.js
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
/** @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: {
|
||||
'^@manacore/shared-nestjs-auth$': '<rootDir>/../../../../../packages/shared-nestjs-auth/src',
|
||||
'^@manacore/shared-storage$': '<rootDir>/../../../../../packages/shared-storage/src',
|
||||
},
|
||||
};
|
||||
|
|
@ -15,7 +15,10 @@
|
|||
"migration:run": "tsx src/db/migrate.ts",
|
||||
"db:push": "drizzle-kit push",
|
||||
"db:studio": "drizzle-kit studio",
|
||||
"db:seed": "tsx src/db/seed.ts"
|
||||
"db:seed": "tsx src/db/seed.ts",
|
||||
"test": "jest",
|
||||
"test:watch": "jest --watch",
|
||||
"test:cov": "jest --coverage"
|
||||
},
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-s3": "^3.700.0",
|
||||
|
|
@ -28,6 +31,7 @@
|
|||
"@nestjs/config": "^3.3.0",
|
||||
"@nestjs/core": "^10.4.15",
|
||||
"@nestjs/platform-express": "^10.4.15",
|
||||
"@nestjs/throttler": "^6.2.1",
|
||||
"class-transformer": "^0.5.1",
|
||||
"class-validator": "^0.14.1",
|
||||
"dotenv": "^16.4.7",
|
||||
|
|
@ -48,6 +52,10 @@
|
|||
"@types/node": "^22.10.2",
|
||||
"@typescript-eslint/eslint-plugin": "^8.18.1",
|
||||
"@typescript-eslint/parser": "^8.18.1",
|
||||
"@nestjs/testing": "^10.4.15",
|
||||
"@types/jest": "^30.0.0",
|
||||
"jest": "^30.2.0",
|
||||
"ts-jest": "^29.2.5",
|
||||
"eslint": "^9.17.0",
|
||||
"eslint-config-prettier": "^9.1.0",
|
||||
"eslint-plugin-prettier": "^5.2.1",
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { Module } from '@nestjs/common';
|
||||
import { ConfigModule, ConfigService } from '@nestjs/config';
|
||||
import { ThrottlerModule } from '@nestjs/throttler';
|
||||
import { ManaCoreModule } from '@manacore/nestjs-integration';
|
||||
import { DatabaseModule } from './db/database.module';
|
||||
import { HealthModule } from '@manacore/shared-nestjs-health';
|
||||
|
|
@ -21,6 +22,7 @@ import { AdminModule } from './admin/admin.module';
|
|||
isGlobal: true,
|
||||
envFilePath: '.env',
|
||||
}),
|
||||
ThrottlerModule.forRoot([{ ttl: 60000, limit: 100 }]),
|
||||
ManaCoreModule.forRootAsync({
|
||||
imports: [ConfigModule],
|
||||
useFactory: (configService: ConfigService) => ({
|
||||
|
|
|
|||
|
|
@ -0,0 +1,477 @@
|
|||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { NotFoundException, ForbiddenException } from '@nestjs/common';
|
||||
import { BoardService } from '../board.service';
|
||||
import { DATABASE_CONNECTION } from '../../db/database.module';
|
||||
import { StorageService } from '../../upload/storage.service';
|
||||
import { boards, boardItems } from '../../db/schema';
|
||||
|
||||
// ── Mock helpers ──────────────────────────────────────────────────────
|
||||
|
||||
const NOW = new Date('2026-01-15T12:00:00Z');
|
||||
|
||||
const makeBoard = (overrides: Record<string, any> = {}) => ({
|
||||
id: 'board-1',
|
||||
userId: 'user-1',
|
||||
name: 'My Board',
|
||||
description: 'A test board',
|
||||
thumbnailUrl: null,
|
||||
canvasWidth: 2000,
|
||||
canvasHeight: 1500,
|
||||
backgroundColor: '#ffffff',
|
||||
isPublic: false,
|
||||
createdAt: NOW,
|
||||
updatedAt: NOW,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const makeBoardItem = (overrides: Record<string, any> = {}) => ({
|
||||
id: 'item-1',
|
||||
boardId: 'board-1',
|
||||
imageId: 'img-1',
|
||||
itemType: 'image' as const,
|
||||
positionX: 100,
|
||||
positionY: 200,
|
||||
scaleX: 1,
|
||||
scaleY: 1,
|
||||
rotation: 0,
|
||||
zIndex: 0,
|
||||
opacity: 1,
|
||||
width: 512,
|
||||
height: 512,
|
||||
textContent: null,
|
||||
fontSize: null,
|
||||
color: null,
|
||||
properties: null,
|
||||
createdAt: NOW,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
// Drizzle fluent chain mock
|
||||
function createChainMock(terminal: jest.Mock) {
|
||||
const chain: any = {};
|
||||
const methods = [
|
||||
'from',
|
||||
'where',
|
||||
'orderBy',
|
||||
'limit',
|
||||
'offset',
|
||||
'groupBy',
|
||||
'having',
|
||||
'set',
|
||||
'values',
|
||||
'returning',
|
||||
];
|
||||
for (const m of methods) {
|
||||
chain[m] = jest.fn().mockReturnValue(chain);
|
||||
}
|
||||
chain.then = (resolve: any, reject: any) => terminal().then(resolve, reject);
|
||||
(chain as any)[Symbol.toStringTag] = 'Promise';
|
||||
return chain;
|
||||
}
|
||||
|
||||
let selectResult: jest.Mock;
|
||||
let selectChain: any;
|
||||
let insertResult: jest.Mock;
|
||||
let insertChain: any;
|
||||
let updateResult: jest.Mock;
|
||||
let updateChain: any;
|
||||
let deleteResult: jest.Mock;
|
||||
let deleteChain: any;
|
||||
let mockDb: any;
|
||||
|
||||
function buildMockDb() {
|
||||
selectResult = jest.fn().mockResolvedValue([]);
|
||||
selectChain = createChainMock(selectResult);
|
||||
|
||||
insertResult = jest.fn().mockResolvedValue([]);
|
||||
insertChain = createChainMock(insertResult);
|
||||
|
||||
updateResult = jest.fn().mockResolvedValue([]);
|
||||
updateChain = createChainMock(updateResult);
|
||||
|
||||
deleteResult = jest.fn().mockResolvedValue([]);
|
||||
deleteChain = createChainMock(deleteResult);
|
||||
|
||||
mockDb = {
|
||||
select: jest.fn().mockReturnValue(selectChain),
|
||||
insert: jest.fn().mockReturnValue(insertChain),
|
||||
update: jest.fn().mockReturnValue(updateChain),
|
||||
delete: jest.fn().mockReturnValue(deleteChain),
|
||||
transaction: jest.fn(),
|
||||
};
|
||||
}
|
||||
|
||||
const mockStorageService = {
|
||||
uploadBoardThumbnail: jest.fn(),
|
||||
};
|
||||
|
||||
// ── Test suite ────────────────────────────────────────────────────────
|
||||
|
||||
describe('BoardService', () => {
|
||||
let service: BoardService;
|
||||
|
||||
beforeEach(async () => {
|
||||
buildMockDb();
|
||||
jest.clearAllMocks();
|
||||
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
BoardService,
|
||||
{ provide: DATABASE_CONNECTION, useValue: mockDb },
|
||||
{ provide: StorageService, useValue: mockStorageService },
|
||||
],
|
||||
}).compile();
|
||||
|
||||
service = module.get<BoardService>(BoardService);
|
||||
});
|
||||
|
||||
it('should be defined', () => {
|
||||
expect(service).toBeDefined();
|
||||
});
|
||||
|
||||
// ── getBoards ───────────────────────────────────────────────────
|
||||
|
||||
describe('getBoards', () => {
|
||||
it('should return boards for a user with item counts', async () => {
|
||||
const board = { ...makeBoard(), itemCount: 3 };
|
||||
selectResult.mockResolvedValue([board]);
|
||||
|
||||
const result = await service.getBoards('user-1', {});
|
||||
|
||||
expect(result).toEqual([board]);
|
||||
expect(mockDb.select).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should respect pagination', async () => {
|
||||
selectResult.mockResolvedValue([]);
|
||||
|
||||
await service.getBoards('user-1', { page: 3, limit: 5 });
|
||||
|
||||
expect(selectChain.limit).toHaveBeenCalledWith(5);
|
||||
expect(selectChain.offset).toHaveBeenCalledWith(10);
|
||||
});
|
||||
|
||||
it('should include public boards when includePublic=true', async () => {
|
||||
selectResult.mockResolvedValue([]);
|
||||
|
||||
await service.getBoards('user-1', { includePublic: true });
|
||||
|
||||
expect(mockDb.select).toHaveBeenCalled();
|
||||
expect(selectChain.where).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
// ── getPublicBoards ─────────────────────────────────────────────
|
||||
|
||||
describe('getPublicBoards', () => {
|
||||
it('should return only public boards', async () => {
|
||||
const board = { ...makeBoard({ isPublic: true }), itemCount: 1 };
|
||||
selectResult.mockResolvedValue([board]);
|
||||
|
||||
const result = await service.getPublicBoards({});
|
||||
|
||||
expect(result).toEqual([board]);
|
||||
});
|
||||
|
||||
it('should respect pagination', async () => {
|
||||
selectResult.mockResolvedValue([]);
|
||||
|
||||
await service.getPublicBoards({ page: 2, limit: 10 });
|
||||
|
||||
expect(selectChain.limit).toHaveBeenCalledWith(10);
|
||||
expect(selectChain.offset).toHaveBeenCalledWith(10);
|
||||
});
|
||||
});
|
||||
|
||||
// ── getBoardById ────────────────────────────────────────────────
|
||||
|
||||
describe('getBoardById', () => {
|
||||
it('should return board when user owns it', async () => {
|
||||
const board = makeBoard();
|
||||
selectResult.mockResolvedValue([board]);
|
||||
|
||||
const result = await service.getBoardById('board-1', 'user-1');
|
||||
|
||||
expect(result).toEqual(board);
|
||||
});
|
||||
|
||||
it('should return public board to non-owner', async () => {
|
||||
const board = makeBoard({ userId: 'user-other', isPublic: true });
|
||||
selectResult.mockResolvedValue([board]);
|
||||
|
||||
const result = await service.getBoardById('board-1', 'user-1');
|
||||
|
||||
expect(result).toEqual(board);
|
||||
});
|
||||
|
||||
it('should throw NotFoundException when board does not exist', async () => {
|
||||
selectResult.mockResolvedValue([]);
|
||||
|
||||
await expect(service.getBoardById('non-existent', 'user-1')).rejects.toThrow(
|
||||
NotFoundException
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw ForbiddenException for private board of another user', async () => {
|
||||
const board = makeBoard({ userId: 'user-other', isPublic: false });
|
||||
selectResult.mockResolvedValue([board]);
|
||||
|
||||
await expect(service.getBoardById('board-1', 'user-1')).rejects.toThrow(ForbiddenException);
|
||||
});
|
||||
});
|
||||
|
||||
// ── createBoard ─────────────────────────────────────────────────
|
||||
|
||||
describe('createBoard', () => {
|
||||
it('should create a board with provided values', async () => {
|
||||
const created = makeBoard({ name: 'New Board', description: 'desc' });
|
||||
insertChain.returning.mockResolvedValueOnce([created]);
|
||||
|
||||
const result = await service.createBoard('user-1', {
|
||||
name: 'New Board',
|
||||
description: 'desc',
|
||||
});
|
||||
|
||||
expect(result.name).toBe('New Board');
|
||||
expect(mockDb.insert).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should use default canvas dimensions', async () => {
|
||||
const created = makeBoard();
|
||||
insertChain.returning.mockResolvedValueOnce([created]);
|
||||
|
||||
const result = await service.createBoard('user-1', { name: 'Board' });
|
||||
|
||||
expect(result.canvasWidth).toBe(2000);
|
||||
expect(result.canvasHeight).toBe(1500);
|
||||
});
|
||||
|
||||
it('should use custom canvas dimensions when provided', async () => {
|
||||
const created = makeBoard({ canvasWidth: 3000, canvasHeight: 2000 });
|
||||
insertChain.returning.mockResolvedValueOnce([created]);
|
||||
|
||||
const result = await service.createBoard('user-1', {
|
||||
name: 'Wide Board',
|
||||
canvasWidth: 3000,
|
||||
canvasHeight: 2000,
|
||||
});
|
||||
|
||||
expect(result.canvasWidth).toBe(3000);
|
||||
expect(result.canvasHeight).toBe(2000);
|
||||
});
|
||||
});
|
||||
|
||||
// ── updateBoard ─────────────────────────────────────────────────
|
||||
|
||||
describe('updateBoard', () => {
|
||||
it('should update board fields', async () => {
|
||||
const updated = makeBoard({ name: 'Updated Name' });
|
||||
// verifyOwnership
|
||||
selectResult.mockResolvedValueOnce([{ userId: 'user-1' }]);
|
||||
updateChain.returning.mockResolvedValueOnce([updated]);
|
||||
|
||||
const result = await service.updateBoard('board-1', 'user-1', { name: 'Updated Name' });
|
||||
|
||||
expect(result.name).toBe('Updated Name');
|
||||
});
|
||||
|
||||
it('should throw NotFoundException for non-existent board', async () => {
|
||||
selectResult.mockResolvedValue([]);
|
||||
|
||||
await expect(service.updateBoard('non-existent', 'user-1', { name: 'Test' })).rejects.toThrow(
|
||||
NotFoundException
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw ForbiddenException for board owned by another user', async () => {
|
||||
selectResult.mockResolvedValueOnce([{ userId: 'user-other' }]);
|
||||
|
||||
await expect(service.updateBoard('board-1', 'user-1', { name: 'Test' })).rejects.toThrow(
|
||||
ForbiddenException
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ── deleteBoard ─────────────────────────────────────────────────
|
||||
|
||||
describe('deleteBoard', () => {
|
||||
it('should delete board items then the board', async () => {
|
||||
selectResult.mockResolvedValueOnce([{ userId: 'user-1' }]);
|
||||
|
||||
await service.deleteBoard('board-1', 'user-1');
|
||||
|
||||
// delete called twice: boardItems then boards
|
||||
expect(mockDb.delete).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('should throw NotFoundException for non-existent board', async () => {
|
||||
selectResult.mockResolvedValue([]);
|
||||
|
||||
await expect(service.deleteBoard('non-existent', 'user-1')).rejects.toThrow(
|
||||
NotFoundException
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw ForbiddenException for board owned by another user', async () => {
|
||||
selectResult.mockResolvedValueOnce([{ userId: 'user-other' }]);
|
||||
|
||||
await expect(service.deleteBoard('board-1', 'user-1')).rejects.toThrow(ForbiddenException);
|
||||
});
|
||||
});
|
||||
|
||||
// ── duplicateBoard ──────────────────────────────────────────────
|
||||
|
||||
describe('duplicateBoard', () => {
|
||||
function buildTxMock(items: any[], newBoard: any) {
|
||||
// Build a fresh tx mock where each chain resolves properly
|
||||
const txInsertReturning = jest
|
||||
.fn()
|
||||
.mockResolvedValueOnce([newBoard]) // board insert returning
|
||||
.mockResolvedValueOnce([]); // items insert returning (if called)
|
||||
|
||||
const txInsertChain: any = {};
|
||||
for (const m of [
|
||||
'from',
|
||||
'where',
|
||||
'orderBy',
|
||||
'limit',
|
||||
'offset',
|
||||
'groupBy',
|
||||
'having',
|
||||
'set',
|
||||
'values',
|
||||
]) {
|
||||
txInsertChain[m] = jest.fn().mockReturnValue(txInsertChain);
|
||||
}
|
||||
txInsertChain.returning = txInsertReturning;
|
||||
|
||||
// For select chain, resolve with items when awaited
|
||||
const txSelectTerminal = jest.fn().mockResolvedValue(items);
|
||||
const txSelectChain: any = {};
|
||||
for (const m of ['from', 'where', 'orderBy', 'limit', 'offset', 'groupBy', 'having']) {
|
||||
txSelectChain[m] = jest.fn().mockReturnValue(txSelectChain);
|
||||
}
|
||||
txSelectChain.then = (resolve: any, reject: any) => txSelectTerminal().then(resolve, reject);
|
||||
|
||||
return {
|
||||
insert: jest.fn().mockReturnValue(txInsertChain),
|
||||
select: jest.fn().mockReturnValue(txSelectChain),
|
||||
};
|
||||
}
|
||||
|
||||
it('should duplicate owned board with items', async () => {
|
||||
const original = makeBoard();
|
||||
const items = [makeBoardItem(), makeBoardItem({ id: 'item-2', positionX: 300 })];
|
||||
const newBoard = makeBoard({ id: 'board-2', name: 'My Board (Copy)' });
|
||||
|
||||
// First select: get original board
|
||||
selectResult.mockResolvedValueOnce([original]);
|
||||
|
||||
const txDb = buildTxMock(items, newBoard);
|
||||
mockDb.transaction.mockImplementation((cb: any) => cb(txDb));
|
||||
|
||||
const result = await service.duplicateBoard('board-1', 'user-1');
|
||||
|
||||
expect(result.id).toBe('board-2');
|
||||
expect(result.name).toBe('My Board (Copy)');
|
||||
// insert called twice inside tx: board + items
|
||||
expect(txDb.insert).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('should duplicate a public board from another user', async () => {
|
||||
const original = makeBoard({ userId: 'user-other', isPublic: true });
|
||||
const newBoard = makeBoard({ id: 'board-2', userId: 'user-1', name: 'My Board (Copy)' });
|
||||
|
||||
selectResult.mockResolvedValueOnce([original]);
|
||||
|
||||
const txDb = buildTxMock([], newBoard);
|
||||
mockDb.transaction.mockImplementation((cb: any) => cb(txDb));
|
||||
|
||||
const result = await service.duplicateBoard('board-1', 'user-1');
|
||||
|
||||
expect(result.userId).toBe('user-1');
|
||||
// Only one insert (board, no items)
|
||||
expect(txDb.insert).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should throw NotFoundException for non-existent board', async () => {
|
||||
selectResult.mockResolvedValue([]);
|
||||
|
||||
await expect(service.duplicateBoard('non-existent', 'user-1')).rejects.toThrow(
|
||||
NotFoundException
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw ForbiddenException for private board of another user', async () => {
|
||||
const original = makeBoard({ userId: 'user-other', isPublic: false });
|
||||
selectResult.mockResolvedValueOnce([original]);
|
||||
|
||||
await expect(service.duplicateBoard('board-1', 'user-1')).rejects.toThrow(ForbiddenException);
|
||||
});
|
||||
});
|
||||
|
||||
// ── generateThumbnail ───────────────────────────────────────────
|
||||
|
||||
describe('generateThumbnail', () => {
|
||||
it('should upload thumbnail and update board', async () => {
|
||||
const thumbnailUrl = 'https://cdn.example.com/boards/board-1/thumb.png';
|
||||
const updated = makeBoard({ thumbnailUrl });
|
||||
|
||||
selectResult.mockResolvedValueOnce([{ userId: 'user-1' }]);
|
||||
mockStorageService.uploadBoardThumbnail.mockResolvedValue(thumbnailUrl);
|
||||
updateChain.returning.mockResolvedValueOnce([updated]);
|
||||
|
||||
const result = await service.generateThumbnail(
|
||||
'board-1',
|
||||
'user-1',
|
||||
'data:image/png;base64,abc'
|
||||
);
|
||||
|
||||
expect(result.thumbnailUrl).toBe(thumbnailUrl);
|
||||
expect(mockStorageService.uploadBoardThumbnail).toHaveBeenCalledWith(
|
||||
'board-1',
|
||||
'data:image/png;base64,abc'
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw NotFoundException for non-existent board', async () => {
|
||||
selectResult.mockResolvedValue([]);
|
||||
|
||||
await expect(
|
||||
service.generateThumbnail('non-existent', 'user-1', 'data:image/png;base64,abc')
|
||||
).rejects.toThrow(NotFoundException);
|
||||
});
|
||||
});
|
||||
|
||||
// ── toggleVisibility ────────────────────────────────────────────
|
||||
|
||||
describe('toggleVisibility', () => {
|
||||
it('should make board public', async () => {
|
||||
const updated = makeBoard({ isPublic: true });
|
||||
selectResult.mockResolvedValueOnce([{ userId: 'user-1' }]);
|
||||
updateChain.returning.mockResolvedValueOnce([updated]);
|
||||
|
||||
const result = await service.toggleVisibility('board-1', 'user-1', true);
|
||||
|
||||
expect(result.isPublic).toBe(true);
|
||||
});
|
||||
|
||||
it('should make board private', async () => {
|
||||
const updated = makeBoard({ isPublic: false });
|
||||
selectResult.mockResolvedValueOnce([{ userId: 'user-1' }]);
|
||||
updateChain.returning.mockResolvedValueOnce([updated]);
|
||||
|
||||
const result = await service.toggleVisibility('board-1', 'user-1', false);
|
||||
|
||||
expect(result.isPublic).toBe(false);
|
||||
});
|
||||
|
||||
it('should throw ForbiddenException for board owned by another user', async () => {
|
||||
selectResult.mockResolvedValueOnce([{ userId: 'user-other' }]);
|
||||
|
||||
await expect(service.toggleVisibility('board-1', 'user-1', true)).rejects.toThrow(
|
||||
ForbiddenException
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -205,47 +205,51 @@ export class BoardService {
|
|||
throw new ForbiddenException('Access denied');
|
||||
}
|
||||
|
||||
// Create new board
|
||||
const newBoard = await this.db
|
||||
.insert(boards)
|
||||
.values({
|
||||
userId,
|
||||
name: `${board.name} (Copy)`,
|
||||
description: board.description,
|
||||
canvasWidth: board.canvasWidth,
|
||||
canvasHeight: board.canvasHeight,
|
||||
backgroundColor: board.backgroundColor,
|
||||
isPublic: false,
|
||||
})
|
||||
.returning();
|
||||
// Use a transaction to ensure board + items are created atomically
|
||||
const newBoard = await this.db.transaction(async (tx) => {
|
||||
const newBoardResult = await tx
|
||||
.insert(boards)
|
||||
.values({
|
||||
userId,
|
||||
name: `${board.name} (Copy)`,
|
||||
description: board.description,
|
||||
canvasWidth: board.canvasWidth,
|
||||
canvasHeight: board.canvasHeight,
|
||||
backgroundColor: board.backgroundColor,
|
||||
isPublic: false,
|
||||
})
|
||||
.returning();
|
||||
|
||||
// Copy board items
|
||||
const items = await this.db.select().from(boardItems).where(eq(boardItems.boardId, id));
|
||||
// Copy board items
|
||||
const items = await tx.select().from(boardItems).where(eq(boardItems.boardId, id));
|
||||
|
||||
if (items.length > 0) {
|
||||
await this.db.insert(boardItems).values(
|
||||
items.map((item) => ({
|
||||
boardId: newBoard[0].id,
|
||||
imageId: item.imageId,
|
||||
itemType: item.itemType,
|
||||
positionX: item.positionX,
|
||||
positionY: item.positionY,
|
||||
scaleX: item.scaleX,
|
||||
scaleY: item.scaleY,
|
||||
rotation: item.rotation,
|
||||
zIndex: item.zIndex,
|
||||
opacity: item.opacity,
|
||||
width: item.width,
|
||||
height: item.height,
|
||||
textContent: item.textContent,
|
||||
fontSize: item.fontSize,
|
||||
color: item.color,
|
||||
properties: item.properties,
|
||||
}))
|
||||
);
|
||||
}
|
||||
if (items.length > 0) {
|
||||
await tx.insert(boardItems).values(
|
||||
items.map((item) => ({
|
||||
boardId: newBoardResult[0].id,
|
||||
imageId: item.imageId,
|
||||
itemType: item.itemType,
|
||||
positionX: item.positionX,
|
||||
positionY: item.positionY,
|
||||
scaleX: item.scaleX,
|
||||
scaleY: item.scaleY,
|
||||
rotation: item.rotation,
|
||||
zIndex: item.zIndex,
|
||||
opacity: item.opacity,
|
||||
width: item.width,
|
||||
height: item.height,
|
||||
textContent: item.textContent,
|
||||
fontSize: item.fontSize,
|
||||
color: item.color,
|
||||
properties: item.properties,
|
||||
}))
|
||||
);
|
||||
}
|
||||
|
||||
return newBoard[0];
|
||||
return newBoardResult[0];
|
||||
});
|
||||
|
||||
return newBoard;
|
||||
} catch (error) {
|
||||
if (error instanceof NotFoundException || error instanceof ForbiddenException) {
|
||||
throw error;
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { pgTable, uuid, text, timestamp, integer, pgEnum } from 'drizzle-orm/pg-core';
|
||||
import { pgTable, uuid, text, timestamp, integer, pgEnum, index } from 'drizzle-orm/pg-core';
|
||||
import { models } from './models.schema';
|
||||
|
||||
export const batchStatusEnum = pgEnum('batch_status', [
|
||||
'pending',
|
||||
|
|
@ -8,30 +9,38 @@ export const batchStatusEnum = pgEnum('batch_status', [
|
|||
'failed',
|
||||
]);
|
||||
|
||||
export const batchGenerations = pgTable('batch_generations', {
|
||||
id: uuid('id').primaryKey().defaultRandom(),
|
||||
userId: text('user_id').notNull(),
|
||||
name: text('name'),
|
||||
export const batchGenerations = pgTable(
|
||||
'batch_generations',
|
||||
{
|
||||
id: uuid('id').primaryKey().defaultRandom(),
|
||||
userId: text('user_id').notNull(),
|
||||
name: text('name'),
|
||||
|
||||
totalCount: integer('total_count').notNull(),
|
||||
completedCount: integer('completed_count').default(0).notNull(),
|
||||
failedCount: integer('failed_count').default(0).notNull(),
|
||||
processingCount: integer('processing_count').default(0).notNull(),
|
||||
pendingCount: integer('pending_count').default(0).notNull(),
|
||||
totalCount: integer('total_count').notNull(),
|
||||
completedCount: integer('completed_count').default(0).notNull(),
|
||||
failedCount: integer('failed_count').default(0).notNull(),
|
||||
processingCount: integer('processing_count').default(0).notNull(),
|
||||
pendingCount: integer('pending_count').default(0).notNull(),
|
||||
|
||||
status: batchStatusEnum('status').default('pending').notNull(),
|
||||
status: batchStatusEnum('status').default('pending').notNull(),
|
||||
|
||||
// Shared settings for all generations in the batch
|
||||
modelId: uuid('model_id'),
|
||||
modelVersion: text('model_version'),
|
||||
width: integer('width'),
|
||||
height: integer('height'),
|
||||
steps: integer('steps'),
|
||||
guidanceScale: integer('guidance_scale'),
|
||||
// Shared settings for all generations in the batch
|
||||
modelId: uuid('model_id').references(() => models.id, { onDelete: 'set null' }),
|
||||
modelVersion: text('model_version'),
|
||||
width: integer('width'),
|
||||
height: integer('height'),
|
||||
steps: integer('steps'),
|
||||
guidanceScale: integer('guidance_scale'),
|
||||
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
|
||||
completedAt: timestamp('completed_at', { withTimezone: true }),
|
||||
});
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
|
||||
completedAt: timestamp('completed_at', { withTimezone: true }),
|
||||
},
|
||||
(table) => ({
|
||||
userIdIdx: index('batch_generations_user_id_idx').on(table.userId),
|
||||
statusIdx: index('batch_generations_status_idx').on(table.status),
|
||||
modelIdIdx: index('batch_generations_model_id_idx').on(table.modelId),
|
||||
})
|
||||
);
|
||||
|
||||
export type BatchGeneration = typeof batchGenerations.$inferSelect;
|
||||
export type NewBatchGeneration = typeof batchGenerations.$inferInsert;
|
||||
|
|
|
|||
|
|
@ -1,4 +1,16 @@
|
|||
import { pgTable, uuid, text, timestamp, integer, real, jsonb, pgEnum } from 'drizzle-orm/pg-core';
|
||||
import {
|
||||
pgTable,
|
||||
uuid,
|
||||
text,
|
||||
timestamp,
|
||||
integer,
|
||||
real,
|
||||
jsonb,
|
||||
pgEnum,
|
||||
index,
|
||||
} from 'drizzle-orm/pg-core';
|
||||
import { boards } from './boards.schema';
|
||||
import { images } from './images.schema';
|
||||
|
||||
export const itemTypeEnum = pgEnum('item_type', ['image', 'text']);
|
||||
|
||||
|
|
@ -10,30 +22,39 @@ export interface TextProperties {
|
|||
lineHeight?: number;
|
||||
}
|
||||
|
||||
export const boardItems = pgTable('board_items', {
|
||||
id: uuid('id').primaryKey().defaultRandom(),
|
||||
boardId: uuid('board_id').notNull(),
|
||||
imageId: uuid('image_id'),
|
||||
export const boardItems = pgTable(
|
||||
'board_items',
|
||||
{
|
||||
id: uuid('id').primaryKey().defaultRandom(),
|
||||
boardId: uuid('board_id')
|
||||
.notNull()
|
||||
.references(() => boards.id, { onDelete: 'cascade' }),
|
||||
imageId: uuid('image_id').references(() => images.id, { onDelete: 'cascade' }),
|
||||
|
||||
itemType: itemTypeEnum('item_type').default('image').notNull(),
|
||||
itemType: itemTypeEnum('item_type').default('image').notNull(),
|
||||
|
||||
positionX: real('position_x').default(0).notNull(),
|
||||
positionY: real('position_y').default(0).notNull(),
|
||||
scaleX: real('scale_x').default(1).notNull(),
|
||||
scaleY: real('scale_y').default(1).notNull(),
|
||||
rotation: real('rotation').default(0).notNull(),
|
||||
zIndex: integer('z_index').default(0).notNull(),
|
||||
opacity: real('opacity').default(1).notNull(),
|
||||
width: integer('width'),
|
||||
height: integer('height'),
|
||||
positionX: real('position_x').default(0).notNull(),
|
||||
positionY: real('position_y').default(0).notNull(),
|
||||
scaleX: real('scale_x').default(1).notNull(),
|
||||
scaleY: real('scale_y').default(1).notNull(),
|
||||
rotation: real('rotation').default(0).notNull(),
|
||||
zIndex: integer('z_index').default(0).notNull(),
|
||||
opacity: real('opacity').default(1).notNull(),
|
||||
width: integer('width'),
|
||||
height: integer('height'),
|
||||
|
||||
textContent: text('text_content'),
|
||||
fontSize: integer('font_size'),
|
||||
color: text('color'),
|
||||
properties: jsonb('properties').$type<TextProperties>(),
|
||||
textContent: text('text_content'),
|
||||
fontSize: integer('font_size'),
|
||||
color: text('color'),
|
||||
properties: jsonb('properties').$type<TextProperties>(),
|
||||
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
|
||||
});
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
|
||||
},
|
||||
(table) => ({
|
||||
boardIdIdx: index('board_items_board_id_idx').on(table.boardId),
|
||||
imageIdIdx: index('board_items_image_id_idx').on(table.imageId),
|
||||
})
|
||||
);
|
||||
|
||||
export type BoardItem = typeof boardItems.$inferSelect;
|
||||
export type NewBoardItem = typeof boardItems.$inferInsert;
|
||||
|
|
|
|||
|
|
@ -1,22 +1,28 @@
|
|||
import { pgTable, uuid, text, timestamp, boolean, integer } from 'drizzle-orm/pg-core';
|
||||
import { pgTable, uuid, text, timestamp, boolean, integer, index } from 'drizzle-orm/pg-core';
|
||||
|
||||
export const boards = pgTable('boards', {
|
||||
id: uuid('id').primaryKey().defaultRandom(),
|
||||
userId: text('user_id').notNull(),
|
||||
export const boards = pgTable(
|
||||
'boards',
|
||||
{
|
||||
id: uuid('id').primaryKey().defaultRandom(),
|
||||
userId: text('user_id').notNull(),
|
||||
|
||||
name: text('name').notNull(),
|
||||
description: text('description'),
|
||||
thumbnailUrl: text('thumbnail_url'),
|
||||
name: text('name').notNull(),
|
||||
description: text('description'),
|
||||
thumbnailUrl: text('thumbnail_url'),
|
||||
|
||||
canvasWidth: integer('canvas_width').default(2000).notNull(),
|
||||
canvasHeight: integer('canvas_height').default(1500).notNull(),
|
||||
backgroundColor: text('background_color').default('#ffffff').notNull(),
|
||||
canvasWidth: integer('canvas_width').default(2000).notNull(),
|
||||
canvasHeight: integer('canvas_height').default(1500).notNull(),
|
||||
backgroundColor: text('background_color').default('#ffffff').notNull(),
|
||||
|
||||
isPublic: boolean('is_public').default(false).notNull(),
|
||||
isPublic: boolean('is_public').default(false).notNull(),
|
||||
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
|
||||
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
|
||||
});
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
|
||||
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
|
||||
},
|
||||
(table) => ({
|
||||
userIdIdx: index('boards_user_id_idx').on(table.userId),
|
||||
})
|
||||
);
|
||||
|
||||
export type Board = typeof boards.$inferSelect;
|
||||
export type NewBoard = typeof boards.$inferInsert;
|
||||
|
|
|
|||
|
|
@ -1,4 +1,6 @@
|
|||
import { pgTable, uuid, text, timestamp, integer, real, pgEnum } from 'drizzle-orm/pg-core';
|
||||
import { pgTable, uuid, text, timestamp, integer, real, pgEnum, index } from 'drizzle-orm/pg-core';
|
||||
import { models } from './models.schema';
|
||||
import { batchGenerations } from './batch-generations.schema';
|
||||
|
||||
export const generationStatusEnum = pgEnum('generation_status', [
|
||||
'pending',
|
||||
|
|
@ -9,35 +11,47 @@ export const generationStatusEnum = pgEnum('generation_status', [
|
|||
'cancelled',
|
||||
]);
|
||||
|
||||
export const imageGenerations = pgTable('image_generations', {
|
||||
id: uuid('id').primaryKey().defaultRandom(),
|
||||
userId: text('user_id').notNull(),
|
||||
modelId: uuid('model_id'),
|
||||
batchId: uuid('batch_id'),
|
||||
export const imageGenerations = pgTable(
|
||||
'image_generations',
|
||||
{
|
||||
id: uuid('id').primaryKey().defaultRandom(),
|
||||
userId: text('user_id').notNull(),
|
||||
modelId: uuid('model_id').references(() => models.id, { onDelete: 'set null' }),
|
||||
batchId: uuid('batch_id').references(() => batchGenerations.id, { onDelete: 'set null' }),
|
||||
|
||||
prompt: text('prompt').notNull(),
|
||||
negativePrompt: text('negative_prompt'),
|
||||
model: text('model'),
|
||||
style: text('style'),
|
||||
sourceImageUrl: text('source_image_url'),
|
||||
prompt: text('prompt').notNull(),
|
||||
negativePrompt: text('negative_prompt'),
|
||||
model: text('model'),
|
||||
style: text('style'),
|
||||
sourceImageUrl: text('source_image_url'),
|
||||
|
||||
width: integer('width'),
|
||||
height: integer('height'),
|
||||
steps: integer('steps'),
|
||||
guidanceScale: real('guidance_scale'),
|
||||
seed: integer('seed'),
|
||||
generationStrength: real('generation_strength'),
|
||||
width: integer('width'),
|
||||
height: integer('height'),
|
||||
steps: integer('steps'),
|
||||
guidanceScale: real('guidance_scale'),
|
||||
seed: integer('seed'),
|
||||
generationStrength: real('generation_strength'),
|
||||
|
||||
status: generationStatusEnum('status').default('pending').notNull(),
|
||||
replicatePredictionId: text('replicate_prediction_id'),
|
||||
errorMessage: text('error_message'),
|
||||
generationTimeSeconds: integer('generation_time_seconds'),
|
||||
retryCount: integer('retry_count').default(0).notNull(),
|
||||
priority: integer('priority').default(0).notNull(),
|
||||
status: generationStatusEnum('status').default('pending').notNull(),
|
||||
replicatePredictionId: text('replicate_prediction_id'),
|
||||
errorMessage: text('error_message'),
|
||||
generationTimeSeconds: integer('generation_time_seconds'),
|
||||
retryCount: integer('retry_count').default(0).notNull(),
|
||||
priority: integer('priority').default(0).notNull(),
|
||||
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
|
||||
completedAt: timestamp('completed_at', { withTimezone: true }),
|
||||
});
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
|
||||
completedAt: timestamp('completed_at', { withTimezone: true }),
|
||||
},
|
||||
(table) => ({
|
||||
userIdIdx: index('image_generations_user_id_idx').on(table.userId),
|
||||
statusIdx: index('image_generations_status_idx').on(table.status),
|
||||
modelIdIdx: index('image_generations_model_id_idx').on(table.modelId),
|
||||
batchIdIdx: index('image_generations_batch_id_idx').on(table.batchId),
|
||||
replicatePredictionIdIdx: index('image_generations_replicate_prediction_id_idx').on(
|
||||
table.replicatePredictionId
|
||||
),
|
||||
})
|
||||
);
|
||||
|
||||
export type ImageGeneration = typeof imageGenerations.$inferSelect;
|
||||
export type NewImageGeneration = typeof imageGenerations.$inferInsert;
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { pgTable, uuid, text, timestamp, unique } from 'drizzle-orm/pg-core';
|
||||
import { pgTable, uuid, text, timestamp, unique, index } from 'drizzle-orm/pg-core';
|
||||
import { images } from './images.schema';
|
||||
|
||||
export const imageLikes = pgTable(
|
||||
|
|
@ -13,6 +13,7 @@ export const imageLikes = pgTable(
|
|||
},
|
||||
(table) => ({
|
||||
uniqueImageUser: unique('unique_image_user').on(table.imageId, table.userId),
|
||||
imageIdIdx: index('image_likes_image_id_idx').on(table.imageId),
|
||||
})
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -1,35 +1,45 @@
|
|||
import { pgTable, uuid, text, timestamp, boolean, integer } from 'drizzle-orm/pg-core';
|
||||
import { pgTable, uuid, text, timestamp, boolean, integer, index } from 'drizzle-orm/pg-core';
|
||||
|
||||
export const images = pgTable('images', {
|
||||
id: uuid('id').primaryKey().defaultRandom(),
|
||||
userId: text('user_id').notNull(),
|
||||
generationId: uuid('generation_id'),
|
||||
sourceImageId: uuid('source_image_id'),
|
||||
export const images = pgTable(
|
||||
'images',
|
||||
{
|
||||
id: uuid('id').primaryKey().defaultRandom(),
|
||||
userId: text('user_id').notNull(),
|
||||
generationId: uuid('generation_id'),
|
||||
sourceImageId: uuid('source_image_id'),
|
||||
|
||||
prompt: text('prompt').notNull(),
|
||||
negativePrompt: text('negative_prompt'),
|
||||
model: text('model'),
|
||||
style: text('style'),
|
||||
prompt: text('prompt').notNull(),
|
||||
negativePrompt: text('negative_prompt'),
|
||||
model: text('model'),
|
||||
style: text('style'),
|
||||
|
||||
publicUrl: text('public_url'),
|
||||
storagePath: text('storage_path').notNull(),
|
||||
filename: text('filename').notNull(),
|
||||
format: text('format'),
|
||||
publicUrl: text('public_url'),
|
||||
storagePath: text('storage_path').notNull(),
|
||||
filename: text('filename').notNull(),
|
||||
format: text('format'),
|
||||
|
||||
width: integer('width'),
|
||||
height: integer('height'),
|
||||
fileSize: integer('file_size'),
|
||||
blurhash: text('blurhash'),
|
||||
width: integer('width'),
|
||||
height: integer('height'),
|
||||
fileSize: integer('file_size'),
|
||||
blurhash: text('blurhash'),
|
||||
|
||||
isPublic: boolean('is_public').default(false).notNull(),
|
||||
isFavorite: boolean('is_favorite').default(false).notNull(),
|
||||
downloadCount: integer('download_count').default(0).notNull(),
|
||||
rating: integer('rating'),
|
||||
isPublic: boolean('is_public').default(false).notNull(),
|
||||
isFavorite: boolean('is_favorite').default(false).notNull(),
|
||||
downloadCount: integer('download_count').default(0).notNull(),
|
||||
rating: integer('rating'),
|
||||
|
||||
archivedAt: timestamp('archived_at', { withTimezone: true }),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
|
||||
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
|
||||
});
|
||||
archivedAt: timestamp('archived_at', { withTimezone: true }),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
|
||||
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
|
||||
},
|
||||
(table) => ({
|
||||
userIdIdx: index('images_user_id_idx').on(table.userId),
|
||||
isPublicIdx: index('images_is_public_idx').on(table.isPublic),
|
||||
createdAtIdx: index('images_created_at_idx').on(table.createdAt),
|
||||
generationIdIdx: index('images_generation_id_idx').on(table.generationId),
|
||||
sourceImageIdIdx: index('images_source_image_id_idx').on(table.sourceImageId),
|
||||
})
|
||||
);
|
||||
|
||||
export type Image = typeof images.$inferSelect;
|
||||
export type NewImage = typeof images.$inferInsert;
|
||||
|
|
|
|||
|
|
@ -1,9 +1,9 @@
|
|||
export * from './images.schema';
|
||||
export * from './image-generations.schema';
|
||||
export * from './image-likes.schema';
|
||||
export * from './batch-generations.schema';
|
||||
export * from './boards.schema';
|
||||
export * from './board-items.schema';
|
||||
export * from './tags.schema';
|
||||
export * from './models.schema';
|
||||
export * from './profiles.schema';
|
||||
export * from './images.schema';
|
||||
export * from './boards.schema';
|
||||
export * from './batch-generations.schema';
|
||||
export * from './image-generations.schema';
|
||||
export * from './image-likes.schema';
|
||||
export * from './board-items.schema';
|
||||
export * from './tags.schema';
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { pgTable, uuid, text, timestamp } from 'drizzle-orm/pg-core';
|
||||
import { pgTable, uuid, text, timestamp, index } from 'drizzle-orm/pg-core';
|
||||
import { images } from './images.schema';
|
||||
|
||||
export const tags = pgTable('tags', {
|
||||
id: uuid('id').primaryKey().defaultRandom(),
|
||||
|
|
@ -10,12 +11,23 @@ export const tags = pgTable('tags', {
|
|||
export type Tag = typeof tags.$inferSelect;
|
||||
export type NewTag = typeof tags.$inferInsert;
|
||||
|
||||
export const imageTags = pgTable('image_tags', {
|
||||
id: uuid('id').primaryKey().defaultRandom(),
|
||||
imageId: uuid('image_id').notNull(),
|
||||
tagId: uuid('tag_id').notNull(),
|
||||
addedAt: timestamp('added_at', { withTimezone: true }).defaultNow().notNull(),
|
||||
});
|
||||
export const imageTags = pgTable(
|
||||
'image_tags',
|
||||
{
|
||||
id: uuid('id').primaryKey().defaultRandom(),
|
||||
imageId: uuid('image_id')
|
||||
.notNull()
|
||||
.references(() => images.id, { onDelete: 'cascade' }),
|
||||
tagId: uuid('tag_id')
|
||||
.notNull()
|
||||
.references(() => tags.id, { onDelete: 'cascade' }),
|
||||
addedAt: timestamp('added_at', { withTimezone: true }).defaultNow().notNull(),
|
||||
},
|
||||
(table) => ({
|
||||
imageIdIdx: index('image_tags_image_id_idx').on(table.imageId),
|
||||
tagIdIdx: index('image_tags_tag_id_idx').on(table.tagId),
|
||||
})
|
||||
);
|
||||
|
||||
export type ImageTag = typeof imageTags.$inferSelect;
|
||||
export type NewImageTag = typeof imageTags.$inferInsert;
|
||||
|
|
|
|||
|
|
@ -1,7 +1,8 @@
|
|||
import { IsString, IsOptional, IsNumber, IsBoolean } from 'class-validator';
|
||||
import { IsString, IsOptional, IsNumber, IsBoolean, Min, Max, MaxLength } from 'class-validator';
|
||||
|
||||
export class GenerateImageDto {
|
||||
@IsString()
|
||||
@MaxLength(10000)
|
||||
prompt: string;
|
||||
|
||||
@IsString()
|
||||
|
|
@ -9,26 +10,36 @@ export class GenerateImageDto {
|
|||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
@MaxLength(500)
|
||||
modelVersion?: string;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
@MaxLength(10000)
|
||||
negativePrompt?: string;
|
||||
|
||||
@IsNumber()
|
||||
@IsOptional()
|
||||
@Min(256)
|
||||
@Max(2048)
|
||||
width?: number;
|
||||
|
||||
@IsNumber()
|
||||
@IsOptional()
|
||||
@Min(256)
|
||||
@Max(2048)
|
||||
height?: number;
|
||||
|
||||
@IsNumber()
|
||||
@IsOptional()
|
||||
@Min(1)
|
||||
@Max(150)
|
||||
steps?: number;
|
||||
|
||||
@IsNumber()
|
||||
@IsOptional()
|
||||
@Min(0)
|
||||
@Max(30)
|
||||
guidanceScale?: number;
|
||||
|
||||
@IsNumber()
|
||||
|
|
@ -37,14 +48,18 @@ export class GenerateImageDto {
|
|||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
@MaxLength(2048)
|
||||
sourceImageUrl?: string;
|
||||
|
||||
@IsNumber()
|
||||
@IsOptional()
|
||||
@Min(0)
|
||||
@Max(1)
|
||||
generationStrength?: number;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
@MaxLength(200)
|
||||
style?: string;
|
||||
|
||||
@IsBoolean()
|
||||
|
|
|
|||
|
|
@ -1,11 +1,31 @@
|
|||
import { Controller, Get, Post, Delete, Param, Body, UseGuards } from '@nestjs/common';
|
||||
import {
|
||||
Controller,
|
||||
Get,
|
||||
Post,
|
||||
Delete,
|
||||
Param,
|
||||
Body,
|
||||
Headers,
|
||||
UseGuards,
|
||||
UnauthorizedException,
|
||||
Logger,
|
||||
} from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { GenerateService } from './generate.service';
|
||||
import { JwtAuthGuard, CurrentUser, CurrentUserData } from '@manacore/shared-nestjs-auth';
|
||||
import { GenerateImageDto } from './dto/generate.dto';
|
||||
|
||||
@Controller('generate')
|
||||
export class GenerateController {
|
||||
constructor(private readonly generateService: GenerateService) {}
|
||||
private readonly logger = new Logger(GenerateController.name);
|
||||
private readonly webhookSecret: string;
|
||||
|
||||
constructor(
|
||||
private readonly generateService: GenerateService,
|
||||
private readonly configService: ConfigService
|
||||
) {
|
||||
this.webhookSecret = this.configService.get<string>('WEBHOOK_SECRET', 'dev-webhook-secret');
|
||||
}
|
||||
|
||||
@Post()
|
||||
@UseGuards(JwtAuthGuard)
|
||||
|
|
@ -25,9 +45,13 @@ export class GenerateController {
|
|||
return this.generateService.cancelGeneration(id, user.userId);
|
||||
}
|
||||
|
||||
// Webhook endpoint for Replicate - no auth required
|
||||
@Post('webhook')
|
||||
async handleWebhook(@Body() body: any) {
|
||||
async handleWebhook(@Body() body: any, @Headers('x-webhook-secret') webhookSecret?: string) {
|
||||
if (!webhookSecret || webhookSecret !== this.webhookSecret) {
|
||||
this.logger.warn('Webhook request with missing or invalid secret');
|
||||
throw new UnauthorizedException('Invalid webhook secret');
|
||||
}
|
||||
|
||||
return this.generateService.handleWebhook(body);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -560,30 +560,31 @@ export class GenerateService {
|
|||
`generated-${generation.id}.${format}`
|
||||
);
|
||||
|
||||
// Create image record
|
||||
await this.db.insert(images).values({
|
||||
userId: generation.userId,
|
||||
generationId: generation.id,
|
||||
prompt: generation.prompt,
|
||||
negativePrompt: generation.negativePrompt,
|
||||
model: generation.model,
|
||||
style: generation.style,
|
||||
storagePath,
|
||||
publicUrl,
|
||||
filename: `generated-${generation.id}.${format}`,
|
||||
width: generation.width,
|
||||
height: generation.height,
|
||||
format,
|
||||
});
|
||||
// Create image record and update generation status atomically
|
||||
await this.db.transaction(async (tx) => {
|
||||
await tx.insert(images).values({
|
||||
userId: generation.userId,
|
||||
generationId: generation.id,
|
||||
prompt: generation.prompt,
|
||||
negativePrompt: generation.negativePrompt,
|
||||
model: generation.model,
|
||||
style: generation.style,
|
||||
storagePath,
|
||||
publicUrl,
|
||||
filename: `generated-${generation.id}.${format}`,
|
||||
width: generation.width,
|
||||
height: generation.height,
|
||||
format,
|
||||
});
|
||||
|
||||
// Update generation as completed
|
||||
await this.db
|
||||
.update(imageGenerations)
|
||||
.set({
|
||||
status: 'completed',
|
||||
completedAt: new Date(),
|
||||
})
|
||||
.where(eq(imageGenerations.id, generation.id));
|
||||
await tx
|
||||
.update(imageGenerations)
|
||||
.set({
|
||||
status: 'completed',
|
||||
completedAt: new Date(),
|
||||
})
|
||||
.where(eq(imageGenerations.id, generation.id));
|
||||
});
|
||||
} catch (error) {
|
||||
this.logger.error(`Error processing completed generation ${generation.id}`, error);
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,581 @@
|
|||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { NotFoundException, ForbiddenException } from '@nestjs/common';
|
||||
import { ImageService } from '../image.service';
|
||||
import { DATABASE_CONNECTION } from '../../db/database.module';
|
||||
import { images, imageTags, imageLikes, imageGenerations } from '../../db/schema';
|
||||
|
||||
// ── Mock helpers ──────────────────────────────────────────────────────
|
||||
|
||||
const NOW = new Date('2026-01-15T12:00:00Z');
|
||||
|
||||
const makeImage = (overrides: Record<string, any> = {}) => ({
|
||||
id: 'img-1',
|
||||
userId: 'user-1',
|
||||
generationId: null,
|
||||
sourceImageId: null,
|
||||
prompt: 'a sunset over mountains',
|
||||
negativePrompt: null,
|
||||
model: 'sdxl',
|
||||
style: null,
|
||||
publicUrl: 'https://cdn.example.com/img-1.png',
|
||||
storagePath: 'user-1/img-1.png',
|
||||
filename: 'img-1.png',
|
||||
format: 'png',
|
||||
width: 1024,
|
||||
height: 1024,
|
||||
fileSize: 204800,
|
||||
blurhash: null,
|
||||
isPublic: false,
|
||||
isFavorite: false,
|
||||
downloadCount: 0,
|
||||
rating: null,
|
||||
archivedAt: null,
|
||||
createdAt: NOW,
|
||||
updatedAt: NOW,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
// Drizzle-style fluent mock: each method returns the chain so callers
|
||||
// can write db.select().from(x).where(y).orderBy(z).limit(n).offset(m)
|
||||
function createChainMock(terminal: jest.Mock) {
|
||||
const chain: any = {};
|
||||
const methods = [
|
||||
'from',
|
||||
'where',
|
||||
'orderBy',
|
||||
'limit',
|
||||
'offset',
|
||||
'groupBy',
|
||||
'having',
|
||||
'set',
|
||||
'values',
|
||||
'returning',
|
||||
];
|
||||
for (const m of methods) {
|
||||
chain[m] = jest.fn().mockReturnValue(chain);
|
||||
}
|
||||
// The terminal mock is what eventually resolves
|
||||
chain.then = (resolve: any, reject: any) => terminal().then(resolve, reject);
|
||||
// Allow awaiting the chain directly
|
||||
(chain as any)[Symbol.toStringTag] = 'Promise';
|
||||
return chain;
|
||||
}
|
||||
|
||||
let selectResult: jest.Mock;
|
||||
let selectChain: any;
|
||||
let insertResult: jest.Mock;
|
||||
let insertChain: any;
|
||||
let updateResult: jest.Mock;
|
||||
let updateChain: any;
|
||||
let deleteResult: jest.Mock;
|
||||
let deleteChain: any;
|
||||
|
||||
function resetChains() {
|
||||
selectResult = jest.fn().mockResolvedValue([]);
|
||||
selectChain = createChainMock(selectResult);
|
||||
|
||||
insertResult = jest.fn().mockResolvedValue([]);
|
||||
insertChain = createChainMock(insertResult);
|
||||
|
||||
updateResult = jest.fn().mockResolvedValue([]);
|
||||
updateChain = createChainMock(updateResult);
|
||||
|
||||
deleteResult = jest.fn().mockResolvedValue([]);
|
||||
deleteChain = createChainMock(deleteResult);
|
||||
}
|
||||
|
||||
let mockDb: any;
|
||||
|
||||
function buildMockDb() {
|
||||
resetChains();
|
||||
|
||||
mockDb = {
|
||||
select: jest.fn().mockReturnValue(selectChain),
|
||||
insert: jest.fn().mockReturnValue(insertChain),
|
||||
update: jest.fn().mockReturnValue(updateChain),
|
||||
delete: jest.fn().mockReturnValue(deleteChain),
|
||||
};
|
||||
}
|
||||
|
||||
// ── Test suite ────────────────────────────────────────────────────────
|
||||
|
||||
describe('ImageService', () => {
|
||||
let service: ImageService;
|
||||
|
||||
beforeEach(async () => {
|
||||
buildMockDb();
|
||||
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [ImageService, { provide: DATABASE_CONNECTION, useValue: mockDb }],
|
||||
}).compile();
|
||||
|
||||
service = module.get<ImageService>(ImageService);
|
||||
});
|
||||
|
||||
it('should be defined', () => {
|
||||
expect(service).toBeDefined();
|
||||
});
|
||||
|
||||
// ── getImages ───────────────────────────────────────────────────
|
||||
|
||||
describe('getImages', () => {
|
||||
it('should return images for a user with default query', async () => {
|
||||
const img = makeImage();
|
||||
selectResult.mockResolvedValue([img]);
|
||||
|
||||
const result = await service.getImages('user-1', {});
|
||||
|
||||
expect(result).toEqual([img]);
|
||||
expect(mockDb.select).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should return archived images when archived=true', async () => {
|
||||
const archivedImg = makeImage({ archivedAt: NOW });
|
||||
selectResult.mockResolvedValue([archivedImg]);
|
||||
|
||||
const result = await service.getImages('user-1', { archived: true });
|
||||
|
||||
expect(result).toEqual([archivedImg]);
|
||||
});
|
||||
|
||||
it('should return only favorites when favoritesOnly=true', async () => {
|
||||
const favImg = makeImage({ isFavorite: true });
|
||||
selectResult.mockResolvedValue([favImg]);
|
||||
|
||||
const result = await service.getImages('user-1', { favoritesOnly: true });
|
||||
|
||||
expect(result).toEqual([favImg]);
|
||||
});
|
||||
|
||||
it('should return empty array when tag filter matches no images', async () => {
|
||||
// First select (tag subquery) returns empty
|
||||
selectResult.mockResolvedValueOnce([]);
|
||||
|
||||
const result = await service.getImages('user-1', { tagIds: ['tag-1', 'tag-2'] });
|
||||
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it('should filter by tagIds when provided', async () => {
|
||||
const img = makeImage();
|
||||
// First call: tag subquery returns matching image IDs
|
||||
selectResult.mockResolvedValueOnce([{ imageId: 'img-1' }]);
|
||||
// Second call: actual images query
|
||||
selectResult.mockResolvedValueOnce([img]);
|
||||
|
||||
const result = await service.getImages('user-1', { tagIds: ['tag-1'] });
|
||||
|
||||
expect(result).toEqual([img]);
|
||||
});
|
||||
|
||||
it('should handle tagIds as comma-separated string', async () => {
|
||||
selectResult.mockResolvedValueOnce([]);
|
||||
|
||||
const result = await service.getImages('user-1', { tagIds: 'tag-1,tag-2' as any });
|
||||
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it('should respect pagination parameters', async () => {
|
||||
selectResult.mockResolvedValue([]);
|
||||
|
||||
await service.getImages('user-1', { page: 2, limit: 10 });
|
||||
|
||||
expect(selectChain.limit).toHaveBeenCalledWith(10);
|
||||
expect(selectChain.offset).toHaveBeenCalledWith(10);
|
||||
});
|
||||
});
|
||||
|
||||
// ── getImageById ────────────────────────────────────────────────
|
||||
|
||||
describe('getImageById', () => {
|
||||
it('should return image when user owns it', async () => {
|
||||
const img = makeImage();
|
||||
selectResult.mockResolvedValue([img]);
|
||||
|
||||
const result = await service.getImageById('img-1', 'user-1');
|
||||
|
||||
expect(result).toEqual(img);
|
||||
});
|
||||
|
||||
it('should return public image to non-owner', async () => {
|
||||
const img = makeImage({ userId: 'user-other', isPublic: true });
|
||||
selectResult.mockResolvedValue([img]);
|
||||
|
||||
const result = await service.getImageById('img-1', 'user-1');
|
||||
|
||||
expect(result).toEqual(img);
|
||||
});
|
||||
|
||||
it('should throw NotFoundException when image does not exist', async () => {
|
||||
selectResult.mockResolvedValue([]);
|
||||
|
||||
await expect(service.getImageById('non-existent', 'user-1')).rejects.toThrow(
|
||||
NotFoundException
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw ForbiddenException for private image owned by another user', async () => {
|
||||
const img = makeImage({ userId: 'user-other', isPublic: false });
|
||||
selectResult.mockResolvedValue([img]);
|
||||
|
||||
await expect(service.getImageById('img-1', 'user-1')).rejects.toThrow(ForbiddenException);
|
||||
});
|
||||
});
|
||||
|
||||
// ── archiveImage ────────────────────────────────────────────────
|
||||
|
||||
describe('archiveImage', () => {
|
||||
it('should archive an image owned by the user', async () => {
|
||||
const img = makeImage();
|
||||
const archived = makeImage({ archivedAt: NOW });
|
||||
|
||||
// verifyOwnership select
|
||||
selectResult.mockResolvedValueOnce([{ userId: 'user-1' }]);
|
||||
// update returning
|
||||
updateChain.returning.mockResolvedValueOnce([archived]);
|
||||
|
||||
const result = await service.archiveImage('img-1', 'user-1');
|
||||
|
||||
expect(result.archivedAt).toEqual(NOW);
|
||||
expect(mockDb.update).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should throw NotFoundException for non-existent image', async () => {
|
||||
selectResult.mockResolvedValue([]);
|
||||
|
||||
await expect(service.archiveImage('non-existent', 'user-1')).rejects.toThrow(
|
||||
NotFoundException
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw ForbiddenException for image owned by another user', async () => {
|
||||
selectResult.mockResolvedValueOnce([{ userId: 'user-other' }]);
|
||||
|
||||
await expect(service.archiveImage('img-1', 'user-1')).rejects.toThrow(ForbiddenException);
|
||||
});
|
||||
});
|
||||
|
||||
// ── unarchiveImage ──────────────────────────────────────────────
|
||||
|
||||
describe('unarchiveImage', () => {
|
||||
it('should unarchive an image', async () => {
|
||||
const unarchived = makeImage({ archivedAt: null });
|
||||
selectResult.mockResolvedValueOnce([{ userId: 'user-1' }]);
|
||||
updateChain.returning.mockResolvedValueOnce([unarchived]);
|
||||
|
||||
const result = await service.unarchiveImage('img-1', 'user-1');
|
||||
|
||||
expect(result.archivedAt).toBeNull();
|
||||
});
|
||||
|
||||
it('should throw NotFoundException for non-existent image', async () => {
|
||||
selectResult.mockResolvedValue([]);
|
||||
|
||||
await expect(service.unarchiveImage('non-existent', 'user-1')).rejects.toThrow(
|
||||
NotFoundException
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ── deleteImage ─────────────────────────────────────────────────
|
||||
|
||||
describe('deleteImage', () => {
|
||||
it('should delete image tags then the image', async () => {
|
||||
selectResult.mockResolvedValueOnce([{ userId: 'user-1' }]);
|
||||
|
||||
await service.deleteImage('img-1', 'user-1');
|
||||
|
||||
// delete called twice: imageTags then images
|
||||
expect(mockDb.delete).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('should throw NotFoundException for non-existent image', async () => {
|
||||
selectResult.mockResolvedValue([]);
|
||||
|
||||
await expect(service.deleteImage('non-existent', 'user-1')).rejects.toThrow(
|
||||
NotFoundException
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw ForbiddenException for image owned by another user', async () => {
|
||||
selectResult.mockResolvedValueOnce([{ userId: 'user-other' }]);
|
||||
|
||||
await expect(service.deleteImage('img-1', 'user-1')).rejects.toThrow(ForbiddenException);
|
||||
});
|
||||
});
|
||||
|
||||
// ── toggleFavorite ──────────────────────────────────────────────
|
||||
|
||||
describe('toggleFavorite', () => {
|
||||
it('should mark image as favorite', async () => {
|
||||
const fav = makeImage({ isFavorite: true });
|
||||
selectResult.mockResolvedValueOnce([{ userId: 'user-1' }]);
|
||||
updateChain.returning.mockResolvedValueOnce([fav]);
|
||||
|
||||
const result = await service.toggleFavorite('img-1', 'user-1', true);
|
||||
|
||||
expect(result.isFavorite).toBe(true);
|
||||
});
|
||||
|
||||
it('should unmark image as favorite', async () => {
|
||||
const notFav = makeImage({ isFavorite: false });
|
||||
selectResult.mockResolvedValueOnce([{ userId: 'user-1' }]);
|
||||
updateChain.returning.mockResolvedValueOnce([notFav]);
|
||||
|
||||
const result = await service.toggleFavorite('img-1', 'user-1', false);
|
||||
|
||||
expect(result.isFavorite).toBe(false);
|
||||
});
|
||||
|
||||
it('should throw NotFoundException for non-existent image', async () => {
|
||||
selectResult.mockResolvedValue([]);
|
||||
|
||||
await expect(service.toggleFavorite('non-existent', 'user-1', true)).rejects.toThrow(
|
||||
NotFoundException
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ── publishImage / unpublishImage ───────────────────────────────
|
||||
|
||||
describe('publishImage', () => {
|
||||
it('should set isPublic to true', async () => {
|
||||
const published = makeImage({ isPublic: true });
|
||||
selectResult.mockResolvedValueOnce([{ userId: 'user-1' }]);
|
||||
updateChain.returning.mockResolvedValueOnce([published]);
|
||||
|
||||
const result = await service.publishImage('img-1', 'user-1');
|
||||
|
||||
expect(result.isPublic).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('unpublishImage', () => {
|
||||
it('should set isPublic to false', async () => {
|
||||
const unpublished = makeImage({ isPublic: false });
|
||||
selectResult.mockResolvedValueOnce([{ userId: 'user-1' }]);
|
||||
updateChain.returning.mockResolvedValueOnce([unpublished]);
|
||||
|
||||
const result = await service.unpublishImage('img-1', 'user-1');
|
||||
|
||||
expect(result.isPublic).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ── likeImage ───────────────────────────────────────────────────
|
||||
|
||||
describe('likeImage', () => {
|
||||
it('should like a public image', async () => {
|
||||
const img = makeImage({ isPublic: true, userId: 'user-other' });
|
||||
|
||||
// image lookup
|
||||
selectResult
|
||||
.mockResolvedValueOnce([img]) // image exists check
|
||||
.mockResolvedValueOnce([]) // no existing like
|
||||
.mockResolvedValueOnce([{ count: 1 }]); // like count after insert
|
||||
|
||||
const result = await service.likeImage('img-1', 'user-1');
|
||||
|
||||
expect(result.liked).toBe(true);
|
||||
expect(result.likeCount).toBe(1);
|
||||
expect(mockDb.insert).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should allow owner to like own image', async () => {
|
||||
const img = makeImage({ userId: 'user-1', isPublic: false });
|
||||
|
||||
selectResult
|
||||
.mockResolvedValueOnce([img])
|
||||
.mockResolvedValueOnce([])
|
||||
.mockResolvedValueOnce([{ count: 1 }]);
|
||||
|
||||
const result = await service.likeImage('img-1', 'user-1');
|
||||
|
||||
expect(result.liked).toBe(true);
|
||||
});
|
||||
|
||||
it('should return current state if already liked', async () => {
|
||||
const img = makeImage({ isPublic: true, userId: 'user-other' });
|
||||
|
||||
selectResult
|
||||
.mockResolvedValueOnce([img])
|
||||
.mockResolvedValueOnce([{ imageId: 'img-1', userId: 'user-1' }]) // existing like
|
||||
.mockResolvedValueOnce([{ count: 3 }]);
|
||||
|
||||
const result = await service.likeImage('img-1', 'user-1');
|
||||
|
||||
expect(result.liked).toBe(true);
|
||||
expect(result.likeCount).toBe(3);
|
||||
// Should NOT have called insert
|
||||
expect(mockDb.insert).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should throw NotFoundException for non-existent image', async () => {
|
||||
selectResult.mockResolvedValue([]);
|
||||
|
||||
await expect(service.likeImage('non-existent', 'user-1')).rejects.toThrow(NotFoundException);
|
||||
});
|
||||
|
||||
it('should throw ForbiddenException for private image of another user', async () => {
|
||||
const img = makeImage({ userId: 'user-other', isPublic: false });
|
||||
selectResult.mockResolvedValueOnce([img]);
|
||||
|
||||
await expect(service.likeImage('img-1', 'user-1')).rejects.toThrow(ForbiddenException);
|
||||
});
|
||||
});
|
||||
|
||||
// ── unlikeImage ─────────────────────────────────────────────────
|
||||
|
||||
describe('unlikeImage', () => {
|
||||
it('should unlike an image', async () => {
|
||||
const img = makeImage({ isPublic: true });
|
||||
|
||||
selectResult
|
||||
.mockResolvedValueOnce([img]) // image exists
|
||||
.mockResolvedValueOnce([{ count: 0 }]); // like count after delete
|
||||
|
||||
const result = await service.unlikeImage('img-1', 'user-1');
|
||||
|
||||
expect(result.liked).toBe(false);
|
||||
expect(result.likeCount).toBe(0);
|
||||
expect(mockDb.delete).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should throw NotFoundException for non-existent image', async () => {
|
||||
selectResult.mockResolvedValue([]);
|
||||
|
||||
await expect(service.unlikeImage('non-existent', 'user-1')).rejects.toThrow(
|
||||
NotFoundException
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ── getLikeStatus ───────────────────────────────────────────────
|
||||
|
||||
describe('getLikeStatus', () => {
|
||||
it('should return liked=true when user has liked the image', async () => {
|
||||
const img = makeImage({ isPublic: true });
|
||||
|
||||
selectResult
|
||||
.mockResolvedValueOnce([img])
|
||||
.mockResolvedValueOnce([{ imageId: 'img-1', userId: 'user-1' }])
|
||||
.mockResolvedValueOnce([{ count: 5 }]);
|
||||
|
||||
const result = await service.getLikeStatus('img-1', 'user-1');
|
||||
|
||||
expect(result.liked).toBe(true);
|
||||
expect(result.likeCount).toBe(5);
|
||||
});
|
||||
|
||||
it('should return liked=false when user has not liked the image', async () => {
|
||||
const img = makeImage({ isPublic: true });
|
||||
|
||||
selectResult
|
||||
.mockResolvedValueOnce([img])
|
||||
.mockResolvedValueOnce([])
|
||||
.mockResolvedValueOnce([{ count: 2 }]);
|
||||
|
||||
const result = await service.getLikeStatus('img-1', 'user-1');
|
||||
|
||||
expect(result.liked).toBe(false);
|
||||
expect(result.likeCount).toBe(2);
|
||||
});
|
||||
|
||||
it('should throw NotFoundException for non-existent image', async () => {
|
||||
selectResult.mockResolvedValue([]);
|
||||
|
||||
await expect(service.getLikeStatus('non-existent', 'user-1')).rejects.toThrow(
|
||||
NotFoundException
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ── getArchivedCount ────────────────────────────────────────────
|
||||
|
||||
describe('getArchivedCount', () => {
|
||||
it('should return count of archived images', async () => {
|
||||
selectResult.mockResolvedValue([{ count: 7 }]);
|
||||
|
||||
const result = await service.getArchivedCount('user-1');
|
||||
|
||||
expect(result).toEqual({ count: 7 });
|
||||
});
|
||||
|
||||
it('should return 0 when no archived images', async () => {
|
||||
selectResult.mockResolvedValue([{ count: 0 }]);
|
||||
|
||||
const result = await service.getArchivedCount('user-1');
|
||||
|
||||
expect(result).toEqual({ count: 0 });
|
||||
});
|
||||
});
|
||||
|
||||
// ── batchArchiveImages ──────────────────────────────────────────
|
||||
|
||||
describe('batchArchiveImages', () => {
|
||||
it('should archive multiple images', async () => {
|
||||
const img1 = makeImage({ id: 'img-1', archivedAt: NOW });
|
||||
const img2 = makeImage({ id: 'img-2', archivedAt: NOW });
|
||||
updateChain.returning.mockResolvedValueOnce([img1, img2]);
|
||||
|
||||
const result = await service.batchArchiveImages(['img-1', 'img-2'], 'user-1');
|
||||
|
||||
expect(result).toEqual({ affected: 2 });
|
||||
});
|
||||
});
|
||||
|
||||
// ── batchRestoreImages ──────────────────────────────────────────
|
||||
|
||||
describe('batchRestoreImages', () => {
|
||||
it('should restore multiple images', async () => {
|
||||
const img1 = makeImage({ id: 'img-1', archivedAt: null });
|
||||
updateChain.returning.mockResolvedValueOnce([img1]);
|
||||
|
||||
const result = await service.batchRestoreImages(['img-1'], 'user-1');
|
||||
|
||||
expect(result).toEqual({ affected: 1 });
|
||||
});
|
||||
});
|
||||
|
||||
// ── batchDeleteImages ───────────────────────────────────────────
|
||||
|
||||
describe('batchDeleteImages', () => {
|
||||
it('should delete tags then images', async () => {
|
||||
const img1 = makeImage({ id: 'img-1' });
|
||||
deleteChain.returning = jest.fn().mockResolvedValueOnce([img1]);
|
||||
|
||||
const result = await service.batchDeleteImages(['img-1'], 'user-1');
|
||||
|
||||
expect(result).toEqual({ affected: 1 });
|
||||
// delete called twice: imageTags then images
|
||||
expect(mockDb.delete).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
||||
// ── getGenerationDetails ────────────────────────────────────────
|
||||
|
||||
describe('getGenerationDetails', () => {
|
||||
it('should return generation details', async () => {
|
||||
const details = {
|
||||
steps: 30,
|
||||
guidanceScale: 7.5,
|
||||
generationTimeSeconds: 12,
|
||||
status: 'completed',
|
||||
};
|
||||
selectResult.mockResolvedValue([details]);
|
||||
|
||||
const result = await service.getGenerationDetails('gen-1', 'user-1');
|
||||
|
||||
expect(result).toEqual(details);
|
||||
});
|
||||
|
||||
it('should return null when generation not found', async () => {
|
||||
selectResult.mockResolvedValue([]);
|
||||
|
||||
const result = await service.getGenerationDetails('non-existent', 'user-1');
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue