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:
Till JS 2026-03-19 14:46:29 +01:00
parent 3da6cf2bd4
commit 8f0c747e08
41 changed files with 4236 additions and 321 deletions

View 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',
},
};

View file

@ -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",

View file

@ -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)

View file

@ -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) => ({

View file

@ -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;
}

View file

@ -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);
});
});
});

View file

@ -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);

View file

@ -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;
}

View file

@ -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, {

View file

@ -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, {

View file

@ -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, {

View file

@ -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),

View file

@ -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, {

View file

@ -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, {

View 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);
});
});
});

View file

@ -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)) {

View file

@ -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()

View 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;
}

View file

@ -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);

View file

@ -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}`);
},

View file

@ -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,
};
}

View 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
);
});
});
});

View file

@ -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);
});
});
});

View file

@ -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);
});
});
});

View 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',
},
};

View file

@ -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",

View file

@ -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) => ({

View file

@ -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
);
});
});
});

View file

@ -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;

View file

@ -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;

View file

@ -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;

View file

@ -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;

View file

@ -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;

View file

@ -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),
})
);

View file

@ -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;

View file

@ -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';

View file

@ -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;

View file

@ -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()

View file

@ -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);
}
}

View file

@ -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);

View file

@ -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();
});
});
});