From a17a3a7f58ae6165f43d9d75c927c128276e8192 Mon Sep 17 00:00:00 2001 From: Till JS Date: Sat, 21 Mar 2026 12:16:28 +0100 Subject: [PATCH] feat(storage): add tests, file preview modal, and fix Dockerfile ports MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tests (109 total, all passing): - Backend (Jest, 83 tests): file, folder, trash, search, share, tag services - Web (Vitest, 26 tests): API client coverage for all endpoints - Mock factories for File, Folder, Share, Tag entities File Preview Modal: - Image preview for image/* MIME types, file info display - Action buttons: download, rename, share, favorite, delete - Full ARIA accessibility, responsive, escape/click-outside close - Integrated in /files, /files/[folderId], /favorites, /search pages Dockerfiles: - Fix incorrect port 3019 → 3016 in backend Dockerfile and web build arg Co-Authored-By: Claude Opus 4.6 (1M context) --- apps/storage/apps/backend/Dockerfile | 4 +- apps/storage/apps/backend/jest.config.js | 12 + apps/storage/apps/backend/package.json | 7 + .../src/__tests__/utils/mock-factories.ts | 109 +++++ .../backend/src/file/file.service.spec.ts | 301 ++++++++++++++ .../backend/src/folder/folder.service.spec.ts | 264 ++++++++++++ .../backend/src/search/search.service.spec.ts | 114 ++++++ .../backend/src/share/share.service.spec.ts | 200 ++++++++++ .../apps/backend/src/tag/tag.service.spec.ts | 170 ++++++++ .../backend/src/trash/trash.service.spec.ts | 183 +++++++++ apps/storage/apps/web/Dockerfile | 2 +- apps/storage/apps/web/package.json | 7 +- .../apps/web/src/lib/api/client.test.ts | 290 ++++++++++++++ .../components/files/FilePreviewModal.svelte | 377 ++++++++++++++++++ .../web/src/routes/favorites/+page.svelte | 14 +- .../apps/web/src/routes/files/+page.svelte | 14 +- .../src/routes/files/[folderId]/+page.svelte | 14 +- .../apps/web/src/routes/search/+page.svelte | 13 +- apps/storage/apps/web/vite.config.ts | 6 + 19 files changed, 2093 insertions(+), 8 deletions(-) create mode 100644 apps/storage/apps/backend/jest.config.js create mode 100644 apps/storage/apps/backend/src/__tests__/utils/mock-factories.ts create mode 100644 apps/storage/apps/backend/src/file/file.service.spec.ts create mode 100644 apps/storage/apps/backend/src/folder/folder.service.spec.ts create mode 100644 apps/storage/apps/backend/src/search/search.service.spec.ts create mode 100644 apps/storage/apps/backend/src/share/share.service.spec.ts create mode 100644 apps/storage/apps/backend/src/tag/tag.service.spec.ts create mode 100644 apps/storage/apps/backend/src/trash/trash.service.spec.ts create mode 100644 apps/storage/apps/web/src/lib/api/client.test.ts create mode 100644 apps/storage/apps/web/src/lib/components/files/FilePreviewModal.svelte diff --git a/apps/storage/apps/backend/Dockerfile b/apps/storage/apps/backend/Dockerfile index 97e0ab653..84caa87b1 100644 --- a/apps/storage/apps/backend/Dockerfile +++ b/apps/storage/apps/backend/Dockerfile @@ -77,11 +77,11 @@ RUN chmod +x /usr/local/bin/docker-entrypoint.sh WORKDIR /app/apps/storage/apps/backend # Expose port -EXPOSE 3019 +EXPOSE 3016 # Health check HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \ - CMD wget --no-verbose --tries=1 --spider http://localhost:3019/api/v1/health || exit 1 + CMD wget --no-verbose --tries=1 --spider http://localhost:3016/api/v1/health || exit 1 # Run entrypoint script ENTRYPOINT ["docker-entrypoint.sh"] diff --git a/apps/storage/apps/backend/jest.config.js b/apps/storage/apps/backend/jest.config.js new file mode 100644 index 000000000..f32d9b332 --- /dev/null +++ b/apps/storage/apps/backend/jest.config.js @@ -0,0 +1,12 @@ +/** @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', '!**/*.spec.ts', '!**/index.ts', '!main.ts'], + coverageDirectory: '../coverage', + testEnvironment: 'node', + moduleNameMapper: { '^@storage/shared$': '/../../packages/shared/src' }, + transformIgnorePatterns: ['node_modules/(?!(@storage|@manacore)/)'], +}; diff --git a/apps/storage/apps/backend/package.json b/apps/storage/apps/backend/package.json index b03b999fa..9ff68e0d3 100644 --- a/apps/storage/apps/backend/package.json +++ b/apps/storage/apps/backend/package.json @@ -9,6 +9,9 @@ "start:dev": "nest start --watch", "start:debug": "nest start --debug --watch", "start:prod": "node dist/main", + "test": "node ../../../../node_modules/jest/bin/jest.js", + "test:watch": "node ../../../../node_modules/jest/bin/jest.js --watch", + "test:cov": "node ../../../../node_modules/jest/bin/jest.js --coverage", "lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix", "type-check": "tsc --noEmit", "migration:generate": "drizzle-kit generate", @@ -36,6 +39,10 @@ "rxjs": "^7.8.1" }, "devDependencies": { + "@nestjs/testing": "^10.4.15", + "@types/jest": "^29.5.0", + "jest": "^30.2.0", + "ts-jest": "^29.4.0", "@nestjs/cli": "^10.4.9", "@nestjs/schematics": "^10.2.3", "@types/express": "^5.0.0", diff --git a/apps/storage/apps/backend/src/__tests__/utils/mock-factories.ts b/apps/storage/apps/backend/src/__tests__/utils/mock-factories.ts new file mode 100644 index 000000000..dbec53d87 --- /dev/null +++ b/apps/storage/apps/backend/src/__tests__/utils/mock-factories.ts @@ -0,0 +1,109 @@ +import { randomUUID } from 'crypto'; + +// File mock factory +export const mockFileFactory = { + create: (overrides: Record = {}) => ({ + id: randomUUID(), + userId: 'test-user-id', + name: 'test-file.pdf', + originalName: 'test-file.pdf', + mimeType: 'application/pdf', + size: 1024, + storagePath: 'users/test-user-id/test-file.pdf', + storageKey: `users/test-user-id/${randomUUID()}-test-file.pdf`, + parentFolderId: null, + currentVersion: 1, + isFavorite: false, + isDeleted: false, + deletedAt: null, + createdAt: new Date(), + updatedAt: new Date(), + ...overrides, + }), + createMany: (count: number, overrides: Record = {}) => + Array.from({ length: count }, () => mockFileFactory.create(overrides)), +}; + +// Folder mock factory +export const mockFolderFactory = { + create: (overrides: Record = {}) => ({ + id: randomUUID(), + userId: 'test-user-id', + name: 'Test Folder', + description: null, + color: null, + parentFolderId: null, + path: '/Test Folder', + depth: 0, + isFavorite: false, + isDeleted: false, + deletedAt: null, + createdAt: new Date(), + updatedAt: new Date(), + ...overrides, + }), + createMany: (count: number, overrides: Record = {}) => + Array.from({ length: count }, () => mockFolderFactory.create(overrides)), +}; + +// Share mock factory +export const mockShareFactory = { + create: (overrides: Record = {}) => ({ + id: randomUUID(), + userId: 'test-user-id', + fileId: null, + folderId: null, + shareType: 'file', + shareToken: randomUUID().replace(/-/g, '') + randomUUID().replace(/-/g, ''), + accessLevel: 'view', + password: null, + maxDownloads: null, + downloadCount: 0, + expiresAt: null, + isActive: true, + createdAt: new Date(), + lastAccessedAt: null, + ...overrides, + }), +}; + +// Tag mock factory +export const mockTagFactory = { + create: (overrides: Record = {}) => ({ + id: randomUUID(), + userId: 'test-user-id', + name: 'Test Tag', + color: '#3b82f6', + createdAt: new Date(), + ...overrides, + }), +}; + +// Database mock helper - chainable query builder +export function createMockDb() { + const chain: any = {}; + const methods = [ + 'select', + 'from', + 'where', + 'insert', + 'values', + 'returning', + 'update', + 'set', + 'delete', + 'innerJoin', + 'limit', + 'orderBy', + 'onConflictDoNothing', + ]; + + for (const method of methods) { + chain[method] = jest.fn().mockReturnValue(chain); + } + + // Make the chain thenable so await works on any terminal method + chain.then = undefined; + + return chain; +} diff --git a/apps/storage/apps/backend/src/file/file.service.spec.ts b/apps/storage/apps/backend/src/file/file.service.spec.ts new file mode 100644 index 000000000..54046472d --- /dev/null +++ b/apps/storage/apps/backend/src/file/file.service.spec.ts @@ -0,0 +1,301 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { NotFoundException, BadRequestException } from '@nestjs/common'; +import { DATABASE_CONNECTION } from '../db/database.module'; +import { FileService } from './file.service'; +import { StorageService } from '../storage/storage.service'; +import { createMockDb, mockFileFactory } from '../__tests__/utils/mock-factories'; + +describe('FileService', () => { + let service: FileService; + let mockDb: ReturnType; + let mockStorageService: Record; + + beforeEach(async () => { + mockDb = createMockDb(); + mockStorageService = { + uploadFile: jest.fn(), + downloadFile: jest.fn(), + deleteFile: jest.fn(), + deleteFiles: jest.fn(), + getDownloadUrl: jest.fn(), + }; + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + FileService, + { provide: DATABASE_CONNECTION, useValue: mockDb }, + { provide: StorageService, useValue: mockStorageService }, + ], + }).compile(); + + service = module.get(FileService); + }); + + describe('findAll', () => { + it('should return root files when no parentFolderId is provided', async () => { + const rootFiles = mockFileFactory.createMany(3); + mockDb.where.mockResolvedValueOnce(rootFiles); + + const result = await service.findAll('test-user-id'); + + expect(result).toEqual(rootFiles); + expect(mockDb.select).toHaveBeenCalled(); + expect(mockDb.from).toHaveBeenCalled(); + expect(mockDb.where).toHaveBeenCalled(); + }); + + it('should return files in a specific folder', async () => { + const folderId = 'folder-123'; + const folderFiles = mockFileFactory.createMany(2, { parentFolderId: folderId }); + mockDb.where.mockResolvedValueOnce(folderFiles); + + const result = await service.findAll('test-user-id', folderId); + + expect(result).toEqual(folderFiles); + expect(result.every((f) => f.parentFolderId === folderId)).toBe(true); + }); + + it('should return empty array when no files exist', async () => { + mockDb.where.mockResolvedValueOnce([]); + + const result = await service.findAll('test-user-id'); + + expect(result).toEqual([]); + }); + }); + + describe('findOne', () => { + it('should return a file by id', async () => { + const file = mockFileFactory.create(); + mockDb.where.mockResolvedValueOnce([file]); + + const result = await service.findOne('test-user-id', file.id); + + expect(result).toEqual(file); + }); + + it('should throw NotFoundException when file does not exist', async () => { + mockDb.where.mockResolvedValueOnce([]); + + await expect(service.findOne('test-user-id', 'nonexistent-id')).rejects.toThrow( + NotFoundException + ); + }); + }); + + describe('upload', () => { + it('should upload file to S3 and create DB record', async () => { + const multerFile = { + buffer: Buffer.from('test content'), + originalname: 'document.pdf', + mimetype: 'application/pdf', + size: 12, + } as Express.Multer.File; + + const uploadResult = { + storageKey: 'users/test-user-id/abc-document.pdf', + storagePath: 'users/test-user-id/abc-document.pdf', + }; + mockStorageService.uploadFile.mockResolvedValueOnce(uploadResult); + + const createdFile = mockFileFactory.create({ + name: 'document.pdf', + originalName: 'document.pdf', + storageKey: uploadResult.storageKey, + storagePath: uploadResult.storagePath, + }); + // insert().values().returning() for file record + mockDb.returning.mockResolvedValueOnce([createdFile]); + // insert().values() for version record (no returning) - default chain return is fine + + const result = await service.upload('test-user-id', multerFile, {}); + + expect(mockStorageService.uploadFile).toHaveBeenCalledWith( + 'test-user-id', + multerFile.buffer, + 'document.pdf', + 'application/pdf' + ); + expect(result).toEqual(createdFile); + }); + + it('should throw BadRequestException when no file is provided', async () => { + await expect(service.upload('test-user-id', undefined as any, {})).rejects.toThrow( + BadRequestException + ); + }); + + it('should set parentFolderId when provided in dto', async () => { + const multerFile = { + buffer: Buffer.from('test'), + originalname: 'file.txt', + mimetype: 'text/plain', + size: 4, + } as Express.Multer.File; + + const uploadResult = { + storageKey: 'users/test-user-id/abc-file.txt', + storagePath: 'users/test-user-id/abc-file.txt', + }; + mockStorageService.uploadFile.mockResolvedValueOnce(uploadResult); + + const createdFile = mockFileFactory.create({ parentFolderId: 'folder-123' }); + // insert().values().returning() for file record + mockDb.returning.mockResolvedValueOnce([createdFile]); + // insert().values() for version record (no returning) - default chain return is fine + + const result = await service.upload('test-user-id', multerFile, { + parentFolderId: 'folder-123', + }); + + expect(result.parentFolderId).toBe('folder-123'); + }); + }); + + describe('update', () => { + it('should update file name', async () => { + const file = mockFileFactory.create(); + // findOne query + mockDb.where.mockResolvedValueOnce([file]); + + const updatedFile = { ...file, name: 'renamed-file.pdf' }; + // update returning + mockDb.returning.mockResolvedValueOnce([updatedFile]); + + const result = await service.update('test-user-id', file.id, { name: 'renamed-file.pdf' }); + + expect(result.name).toBe('renamed-file.pdf'); + }); + + it('should throw NotFoundException when updating nonexistent file', async () => { + mockDb.where.mockResolvedValueOnce([]); + + await expect( + service.update('test-user-id', 'nonexistent', { name: 'new-name' }) + ).rejects.toThrow(NotFoundException); + }); + }); + + describe('move', () => { + it('should move file to a different folder', async () => { + const file = mockFileFactory.create(); + mockDb.where.mockResolvedValueOnce([file]); + + const movedFile = { ...file, parentFolderId: 'new-folder-id' }; + mockDb.returning.mockResolvedValueOnce([movedFile]); + + const result = await service.move('test-user-id', file.id, { + parentFolderId: 'new-folder-id', + }); + + expect(result.parentFolderId).toBe('new-folder-id'); + }); + + it('should move file to root when parentFolderId is empty', async () => { + const file = mockFileFactory.create({ parentFolderId: 'some-folder' }); + mockDb.where.mockResolvedValueOnce([file]); + + const movedFile = { ...file, parentFolderId: null }; + mockDb.returning.mockResolvedValueOnce([movedFile]); + + const result = await service.move('test-user-id', file.id, { parentFolderId: '' }); + + expect(result.parentFolderId).toBeNull(); + }); + }); + + describe('delete', () => { + it('should soft delete a file', async () => { + const file = mockFileFactory.create(); + // findOne query + mockDb.where.mockResolvedValueOnce([file]); + // update query (no returning) + mockDb.where.mockResolvedValueOnce(undefined); + + await service.delete('test-user-id', file.id); + + expect(mockDb.update).toHaveBeenCalled(); + expect(mockDb.set).toHaveBeenCalledWith( + expect.objectContaining({ + isDeleted: true, + deletedAt: expect.any(Date), + }) + ); + }); + + it('should throw NotFoundException when deleting nonexistent file', async () => { + mockDb.where.mockResolvedValueOnce([]); + + await expect(service.delete('test-user-id', 'nonexistent')).rejects.toThrow( + NotFoundException + ); + }); + }); + + describe('toggleFavorite', () => { + it('should toggle isFavorite from false to true', async () => { + const file = mockFileFactory.create({ isFavorite: false }); + mockDb.where.mockResolvedValueOnce([file]); + + const toggledFile = { ...file, isFavorite: true }; + mockDb.returning.mockResolvedValueOnce([toggledFile]); + + const result = await service.toggleFavorite('test-user-id', file.id); + + expect(result.isFavorite).toBe(true); + expect(mockDb.set).toHaveBeenCalledWith(expect.objectContaining({ isFavorite: true })); + }); + + it('should toggle isFavorite from true to false', async () => { + const file = mockFileFactory.create({ isFavorite: true }); + mockDb.where.mockResolvedValueOnce([file]); + + const toggledFile = { ...file, isFavorite: false }; + mockDb.returning.mockResolvedValueOnce([toggledFile]); + + const result = await service.toggleFavorite('test-user-id', file.id); + + expect(result.isFavorite).toBe(false); + expect(mockDb.set).toHaveBeenCalledWith(expect.objectContaining({ isFavorite: false })); + }); + }); + + describe('download', () => { + it('should download file from S3', async () => { + const file = mockFileFactory.create(); + mockDb.where.mockResolvedValueOnce([file]); + + const fileBuffer = Buffer.from('file content'); + mockStorageService.downloadFile.mockResolvedValueOnce(fileBuffer); + + const result = await service.download('test-user-id', file.id); + + expect(result.buffer).toEqual(fileBuffer); + expect(result.file).toEqual(file); + expect(mockStorageService.downloadFile).toHaveBeenCalledWith(file.storageKey); + }); + + it('should throw NotFoundException when downloading nonexistent file', async () => { + mockDb.where.mockResolvedValueOnce([]); + + await expect(service.download('test-user-id', 'nonexistent')).rejects.toThrow( + NotFoundException + ); + }); + }); + + describe('getDownloadUrl', () => { + it('should return presigned download URL', async () => { + const file = mockFileFactory.create(); + mockDb.where.mockResolvedValueOnce([file]); + + const url = 'https://s3.example.com/presigned-url'; + mockStorageService.getDownloadUrl.mockResolvedValueOnce(url); + + const result = await service.getDownloadUrl('test-user-id', file.id); + + expect(result).toBe(url); + expect(mockStorageService.getDownloadUrl).toHaveBeenCalledWith(file.storageKey); + }); + }); +}); diff --git a/apps/storage/apps/backend/src/folder/folder.service.spec.ts b/apps/storage/apps/backend/src/folder/folder.service.spec.ts new file mode 100644 index 000000000..445f9bdaf --- /dev/null +++ b/apps/storage/apps/backend/src/folder/folder.service.spec.ts @@ -0,0 +1,264 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { NotFoundException } from '@nestjs/common'; +import { DATABASE_CONNECTION } from '../db/database.module'; +import { FolderService } from './folder.service'; +import { createMockDb, mockFolderFactory } from '../__tests__/utils/mock-factories'; + +describe('FolderService', () => { + let service: FolderService; + let mockDb: ReturnType; + + beforeEach(async () => { + mockDb = createMockDb(); + + const module: TestingModule = await Test.createTestingModule({ + providers: [FolderService, { provide: DATABASE_CONNECTION, useValue: mockDb }], + }).compile(); + + service = module.get(FolderService); + }); + + describe('findAll', () => { + it('should return root folders when no parentFolderId is provided', async () => { + const rootFolders = mockFolderFactory.createMany(3); + mockDb.where.mockResolvedValueOnce(rootFolders); + + const result = await service.findAll('test-user-id'); + + expect(result).toEqual(rootFolders); + expect(mockDb.select).toHaveBeenCalled(); + expect(mockDb.from).toHaveBeenCalled(); + }); + + it('should return child folders when parentFolderId is provided', async () => { + const parentId = 'parent-folder-id'; + const childFolders = mockFolderFactory.createMany(2, { parentFolderId: parentId }); + mockDb.where.mockResolvedValueOnce(childFolders); + + const result = await service.findAll('test-user-id', parentId); + + expect(result).toEqual(childFolders); + expect(result.every((f) => f.parentFolderId === parentId)).toBe(true); + }); + + it('should return empty array when no folders exist', async () => { + mockDb.where.mockResolvedValueOnce([]); + + const result = await service.findAll('test-user-id'); + + expect(result).toEqual([]); + }); + }); + + describe('findOne', () => { + it('should return a folder by id', async () => { + const folder = mockFolderFactory.create(); + mockDb.where.mockResolvedValueOnce([folder]); + + const result = await service.findOne('test-user-id', folder.id); + + expect(result).toEqual(folder); + }); + + it('should throw NotFoundException when folder does not exist', async () => { + mockDb.where.mockResolvedValueOnce([]); + + await expect(service.findOne('test-user-id', 'nonexistent')).rejects.toThrow( + NotFoundException + ); + }); + }); + + describe('create', () => { + it('should create a root folder with correct path and depth', async () => { + const created = mockFolderFactory.create({ name: 'Documents', path: '/Documents', depth: 0 }); + mockDb.returning.mockResolvedValueOnce([created]); + + const result = await service.create('test-user-id', { name: 'Documents' }); + + expect(result.name).toBe('Documents'); + expect(result.path).toBe('/Documents'); + expect(result.depth).toBe(0); + }); + + it('should create a nested folder with correct path and depth', async () => { + const parentFolder = mockFolderFactory.create({ + name: 'Documents', + path: '/Documents', + depth: 0, + }); + // findOne for parent + mockDb.where.mockResolvedValueOnce([parentFolder]); + + const childFolder = mockFolderFactory.create({ + name: 'Work', + path: '/Documents/Work', + depth: 1, + parentFolderId: parentFolder.id, + }); + mockDb.returning.mockResolvedValueOnce([childFolder]); + + const result = await service.create('test-user-id', { + name: 'Work', + parentFolderId: parentFolder.id, + }); + + expect(result.path).toBe('/Documents/Work'); + expect(result.depth).toBe(1); + expect(result.parentFolderId).toBe(parentFolder.id); + }); + + it('should throw NotFoundException when parent folder does not exist', async () => { + mockDb.where.mockResolvedValueOnce([]); + + await expect( + service.create('test-user-id', { name: 'Child', parentFolderId: 'nonexistent' }) + ).rejects.toThrow(NotFoundException); + }); + + it('should set optional color and description', async () => { + const created = mockFolderFactory.create({ + name: 'Projects', + color: '#ff0000', + description: 'My projects', + }); + mockDb.returning.mockResolvedValueOnce([created]); + + const result = await service.create('test-user-id', { + name: 'Projects', + color: '#ff0000', + description: 'My projects', + }); + + expect(result.color).toBe('#ff0000'); + expect(result.description).toBe('My projects'); + }); + }); + + describe('update', () => { + it('should update folder properties', async () => { + const folder = mockFolderFactory.create(); + mockDb.where.mockResolvedValueOnce([folder]); + + const updated = { ...folder, name: 'Renamed Folder' }; + mockDb.returning.mockResolvedValueOnce([updated]); + + const result = await service.update('test-user-id', folder.id, { name: 'Renamed Folder' }); + + expect(result.name).toBe('Renamed Folder'); + }); + + it('should throw NotFoundException when updating nonexistent folder', async () => { + mockDb.where.mockResolvedValueOnce([]); + + await expect( + service.update('test-user-id', 'nonexistent', { name: 'New Name' }) + ).rejects.toThrow(NotFoundException); + }); + }); + + describe('move', () => { + it('should move folder to a new parent and update path/depth', async () => { + const folder = mockFolderFactory.create({ name: 'Work', path: '/Work', depth: 0 }); + // findOne for the folder being moved + mockDb.where.mockResolvedValueOnce([folder]); + + const newParent = mockFolderFactory.create({ + name: 'Documents', + path: '/Documents', + depth: 0, + }); + // findOne for the new parent + mockDb.where.mockResolvedValueOnce([newParent]); + + const movedFolder = { + ...folder, + parentFolderId: newParent.id, + path: '/Documents/Work', + depth: 1, + }; + mockDb.returning.mockResolvedValueOnce([movedFolder]); + + const result = await service.move('test-user-id', folder.id, { + parentFolderId: newParent.id, + }); + + expect(result.path).toBe('/Documents/Work'); + expect(result.depth).toBe(1); + expect(result.parentFolderId).toBe(newParent.id); + }); + + it('should move folder to root when parentFolderId is empty', async () => { + const folder = mockFolderFactory.create({ + name: 'Work', + path: '/Documents/Work', + depth: 1, + parentFolderId: 'some-parent', + }); + mockDb.where.mockResolvedValueOnce([folder]); + + const movedFolder = { ...folder, parentFolderId: null, path: '/Work', depth: 0 }; + mockDb.returning.mockResolvedValueOnce([movedFolder]); + + const result = await service.move('test-user-id', folder.id, { parentFolderId: '' }); + + expect(result.path).toBe('/Work'); + expect(result.depth).toBe(0); + expect(result.parentFolderId).toBeNull(); + }); + }); + + describe('delete', () => { + it('should soft delete a folder', async () => { + const folder = mockFolderFactory.create(); + mockDb.where.mockResolvedValueOnce([folder]); + mockDb.where.mockResolvedValueOnce(undefined); + + await service.delete('test-user-id', folder.id); + + expect(mockDb.update).toHaveBeenCalled(); + expect(mockDb.set).toHaveBeenCalledWith( + expect.objectContaining({ + isDeleted: true, + deletedAt: expect.any(Date), + }) + ); + }); + + it('should throw NotFoundException when deleting nonexistent folder', async () => { + mockDb.where.mockResolvedValueOnce([]); + + await expect(service.delete('test-user-id', 'nonexistent')).rejects.toThrow( + NotFoundException + ); + }); + }); + + describe('toggleFavorite', () => { + it('should toggle isFavorite from false to true', async () => { + const folder = mockFolderFactory.create({ isFavorite: false }); + mockDb.where.mockResolvedValueOnce([folder]); + + const toggled = { ...folder, isFavorite: true }; + mockDb.returning.mockResolvedValueOnce([toggled]); + + const result = await service.toggleFavorite('test-user-id', folder.id); + + expect(result.isFavorite).toBe(true); + expect(mockDb.set).toHaveBeenCalledWith(expect.objectContaining({ isFavorite: true })); + }); + + it('should toggle isFavorite from true to false', async () => { + const folder = mockFolderFactory.create({ isFavorite: true }); + mockDb.where.mockResolvedValueOnce([folder]); + + const toggled = { ...folder, isFavorite: false }; + mockDb.returning.mockResolvedValueOnce([toggled]); + + const result = await service.toggleFavorite('test-user-id', folder.id); + + expect(result.isFavorite).toBe(false); + expect(mockDb.set).toHaveBeenCalledWith(expect.objectContaining({ isFavorite: false })); + }); + }); +}); diff --git a/apps/storage/apps/backend/src/search/search.service.spec.ts b/apps/storage/apps/backend/src/search/search.service.spec.ts new file mode 100644 index 000000000..0d73a049d --- /dev/null +++ b/apps/storage/apps/backend/src/search/search.service.spec.ts @@ -0,0 +1,114 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { DATABASE_CONNECTION } from '../db/database.module'; +import { SearchService } from './search.service'; +import { + createMockDb, + mockFileFactory, + mockFolderFactory, +} from '../__tests__/utils/mock-factories'; + +describe('SearchService', () => { + let service: SearchService; + let mockDb: ReturnType; + + beforeEach(async () => { + mockDb = createMockDb(); + + const module: TestingModule = await Test.createTestingModule({ + providers: [SearchService, { provide: DATABASE_CONNECTION, useValue: mockDb }], + }).compile(); + + service = module.get(SearchService); + }); + + describe('search', () => { + it('should return matching files and folders', async () => { + const matchingFiles = mockFileFactory.createMany(2, { name: 'report.pdf' }); + const matchingFolders = mockFolderFactory.createMany(1, { name: 'Reports' }); + + // First query: files search (select -> from -> where -> limit) + mockDb.limit.mockResolvedValueOnce(matchingFiles); + // Second query: folders search + mockDb.limit.mockResolvedValueOnce(matchingFolders); + + const result = await service.search('test-user-id', 'report'); + + expect(result.files).toEqual(matchingFiles); + expect(result.folders).toEqual(matchingFolders); + }); + + it('should return empty results when nothing matches', async () => { + mockDb.limit.mockResolvedValueOnce([]); + mockDb.limit.mockResolvedValueOnce([]); + + const result = await service.search('test-user-id', 'nonexistent-query'); + + expect(result.files).toEqual([]); + expect(result.folders).toEqual([]); + }); + + it('should search with partial matches', async () => { + const files = mockFileFactory.createMany(1, { name: 'my-document.pdf' }); + mockDb.limit.mockResolvedValueOnce(files); + mockDb.limit.mockResolvedValueOnce([]); + + const result = await service.search('test-user-id', 'doc'); + + expect(result.files).toHaveLength(1); + }); + + it('should limit results to 50', async () => { + const manyFiles = mockFileFactory.createMany(50); + mockDb.limit.mockResolvedValueOnce(manyFiles); + mockDb.limit.mockResolvedValueOnce([]); + + const result = await service.search('test-user-id', 'test'); + + expect(result.files).toHaveLength(50); + expect(mockDb.limit).toHaveBeenCalledWith(50); + }); + }); + + describe('getFavorites', () => { + it('should return favorite files and folders', async () => { + const favoriteFiles = mockFileFactory.createMany(2, { isFavorite: true }); + const favoriteFolders = mockFolderFactory.createMany(1, { isFavorite: true }); + + // First where call: favorite files + mockDb.where.mockResolvedValueOnce(favoriteFiles); + // Second where call: favorite folders + mockDb.where.mockResolvedValueOnce(favoriteFolders); + + const result = await service.getFavorites('test-user-id'); + + expect(result.files).toEqual(favoriteFiles); + expect(result.folders).toEqual(favoriteFolders); + expect(result.files.every((f) => f.isFavorite)).toBe(true); + expect(result.folders.every((f) => f.isFavorite)).toBe(true); + }); + + it('should return empty arrays when no favorites exist', async () => { + mockDb.where.mockResolvedValueOnce([]); + mockDb.where.mockResolvedValueOnce([]); + + const result = await service.getFavorites('test-user-id'); + + expect(result.files).toEqual([]); + expect(result.folders).toEqual([]); + }); + + it('should only return non-deleted favorites', async () => { + const favoriteFiles = mockFileFactory.createMany(3, { + isFavorite: true, + isDeleted: false, + }); + mockDb.where.mockResolvedValueOnce(favoriteFiles); + mockDb.where.mockResolvedValueOnce([]); + + const result = await service.getFavorites('test-user-id'); + + expect(result.files).toHaveLength(3); + expect(result.files.every((f) => !f.isDeleted)).toBe(true); + }); + }); +}); diff --git a/apps/storage/apps/backend/src/share/share.service.spec.ts b/apps/storage/apps/backend/src/share/share.service.spec.ts new file mode 100644 index 000000000..2ea368599 --- /dev/null +++ b/apps/storage/apps/backend/src/share/share.service.spec.ts @@ -0,0 +1,200 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { NotFoundException } from '@nestjs/common'; +import { DATABASE_CONNECTION } from '../db/database.module'; +import { ShareService } from './share.service'; +import { createMockDb, mockShareFactory } from '../__tests__/utils/mock-factories'; + +describe('ShareService', () => { + let service: ShareService; + let mockDb: ReturnType; + + beforeEach(async () => { + mockDb = createMockDb(); + + const module: TestingModule = await Test.createTestingModule({ + providers: [ShareService, { provide: DATABASE_CONNECTION, useValue: mockDb }], + }).compile(); + + service = module.get(ShareService); + }); + + describe('findAll', () => { + it('should return active shares for user', async () => { + const shares = [ + mockShareFactory.create({ isActive: true }), + mockShareFactory.create({ isActive: true }), + ]; + mockDb.where.mockResolvedValueOnce(shares); + + const result = await service.findAll('test-user-id'); + + expect(result).toEqual(shares); + expect(result).toHaveLength(2); + }); + + it('should return empty array when user has no shares', async () => { + mockDb.where.mockResolvedValueOnce([]); + + const result = await service.findAll('test-user-id'); + + expect(result).toEqual([]); + }); + }); + + describe('findByToken', () => { + it('should return share by token', async () => { + const share = mockShareFactory.create(); + mockDb.where.mockResolvedValueOnce([share]); + + const result = await service.findByToken(share.shareToken); + + expect(result).toEqual(share); + }); + + it('should throw NotFoundException when share does not exist', async () => { + mockDb.where.mockResolvedValueOnce([]); + + await expect(service.findByToken('invalid-token')).rejects.toThrow(NotFoundException); + }); + + it('should throw NotFoundException when share has expired', async () => { + const expiredShare = mockShareFactory.create({ + expiresAt: new Date(Date.now() - 86400000), // expired yesterday + }); + mockDb.where.mockResolvedValueOnce([expiredShare]); + + await expect(service.findByToken(expiredShare.shareToken)).rejects.toThrow(NotFoundException); + }); + + it('should throw NotFoundException when download limit is reached', async () => { + const maxedShare = mockShareFactory.create({ + maxDownloads: 5, + downloadCount: 5, + }); + mockDb.where.mockResolvedValueOnce([maxedShare]); + + await expect(service.findByToken(maxedShare.shareToken)).rejects.toThrow(NotFoundException); + }); + + it('should return share when download count is below limit', async () => { + const share = mockShareFactory.create({ + maxDownloads: 10, + downloadCount: 3, + }); + mockDb.where.mockResolvedValueOnce([share]); + + const result = await service.findByToken(share.shareToken); + + expect(result).toEqual(share); + }); + + it('should return share when it has no expiration', async () => { + const share = mockShareFactory.create({ expiresAt: null }); + mockDb.where.mockResolvedValueOnce([share]); + + const result = await service.findByToken(share.shareToken); + + expect(result).toEqual(share); + }); + }); + + describe('create', () => { + it('should create a file share with random token', async () => { + const fileId = 'file-123'; + const created = mockShareFactory.create({ fileId, shareType: 'file' }); + mockDb.returning.mockResolvedValueOnce([created]); + + const result = await service.create('test-user-id', { fileId }); + + expect(result.fileId).toBe(fileId); + expect(result.shareType).toBe('file'); + expect(mockDb.insert).toHaveBeenCalled(); + expect(mockDb.values).toHaveBeenCalled(); + }); + + it('should create a folder share', async () => { + const folderId = 'folder-123'; + const created = mockShareFactory.create({ folderId, shareType: 'folder' }); + mockDb.returning.mockResolvedValueOnce([created]); + + const result = await service.create('test-user-id', { folderId }); + + expect(result.folderId).toBe(folderId); + expect(result.shareType).toBe('folder'); + }); + + it('should create a share with access level and password', async () => { + const created = mockShareFactory.create({ + fileId: 'file-123', + accessLevel: 'download', + password: 'hashed-password', + }); + mockDb.returning.mockResolvedValueOnce([created]); + + const result = await service.create('test-user-id', { + fileId: 'file-123', + accessLevel: 'download', + password: 'hashed-password', + }); + + expect(result.accessLevel).toBe('download'); + expect(result.password).toBe('hashed-password'); + }); + + it('should create a share with maxDownloads and expiresAt', async () => { + const expiresAt = new Date(Date.now() + 86400000); + const created = mockShareFactory.create({ + fileId: 'file-123', + maxDownloads: 100, + expiresAt, + }); + mockDb.returning.mockResolvedValueOnce([created]); + + const result = await service.create('test-user-id', { + fileId: 'file-123', + maxDownloads: 100, + expiresAt, + }); + + expect(result.maxDownloads).toBe(100); + expect(result.expiresAt).toEqual(expiresAt); + }); + }); + + describe('delete', () => { + it('should deactivate the share', async () => { + mockDb.where.mockResolvedValueOnce(undefined); + + await service.delete('test-user-id', 'share-id'); + + expect(mockDb.update).toHaveBeenCalled(); + expect(mockDb.set).toHaveBeenCalledWith({ isActive: false }); + }); + }); + + describe('incrementDownloadCount', () => { + it('should increment download count and update lastAccessedAt', async () => { + const share = mockShareFactory.create({ downloadCount: 3 }); + mockDb.where.mockResolvedValueOnce([share]); + mockDb.where.mockResolvedValueOnce(undefined); + + await service.incrementDownloadCount(share.id); + + expect(mockDb.set).toHaveBeenCalledWith( + expect.objectContaining({ + downloadCount: 4, + lastAccessedAt: expect.any(Date), + }) + ); + }); + + it('should do nothing when share does not exist', async () => { + mockDb.where.mockResolvedValueOnce([]); + + await service.incrementDownloadCount('nonexistent'); + + // update should not be called (only select was called) + expect(mockDb.update).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/apps/storage/apps/backend/src/tag/tag.service.spec.ts b/apps/storage/apps/backend/src/tag/tag.service.spec.ts new file mode 100644 index 000000000..1c7c2cf8d --- /dev/null +++ b/apps/storage/apps/backend/src/tag/tag.service.spec.ts @@ -0,0 +1,170 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { NotFoundException } from '@nestjs/common'; +import { DATABASE_CONNECTION } from '../db/database.module'; +import { TagService } from './tag.service'; +import { createMockDb, mockTagFactory } from '../__tests__/utils/mock-factories'; + +describe('TagService', () => { + let service: TagService; + let mockDb: ReturnType; + + beforeEach(async () => { + mockDb = createMockDb(); + + const module: TestingModule = await Test.createTestingModule({ + providers: [TagService, { provide: DATABASE_CONNECTION, useValue: mockDb }], + }).compile(); + + service = module.get(TagService); + }); + + describe('findAll', () => { + it('should return all tags for user', async () => { + const userTags = [ + mockTagFactory.create({ name: 'Work' }), + mockTagFactory.create({ name: 'Personal' }), + mockTagFactory.create({ name: 'Important' }), + ]; + mockDb.where.mockResolvedValueOnce(userTags); + + const result = await service.findAll('test-user-id'); + + expect(result).toEqual(userTags); + expect(result).toHaveLength(3); + }); + + it('should return empty array when user has no tags', async () => { + mockDb.where.mockResolvedValueOnce([]); + + const result = await service.findAll('test-user-id'); + + expect(result).toEqual([]); + }); + }); + + describe('create', () => { + it('should create a tag with name and default color', async () => { + const tag = mockTagFactory.create({ name: 'Work' }); + mockDb.returning.mockResolvedValueOnce([tag]); + + const result = await service.create('test-user-id', 'Work'); + + expect(result.name).toBe('Work'); + expect(mockDb.insert).toHaveBeenCalled(); + expect(mockDb.values).toHaveBeenCalled(); + }); + + it('should create a tag with custom color', async () => { + const tag = mockTagFactory.create({ name: 'Urgent', color: '#ef4444' }); + mockDb.returning.mockResolvedValueOnce([tag]); + + const result = await service.create('test-user-id', 'Urgent', '#ef4444'); + + expect(result.name).toBe('Urgent'); + expect(result.color).toBe('#ef4444'); + }); + }); + + describe('update', () => { + it('should update tag name', async () => { + const updated = mockTagFactory.create({ name: 'Updated Tag' }); + mockDb.returning.mockResolvedValueOnce([updated]); + + const result = await service.update('test-user-id', updated.id, { name: 'Updated Tag' }); + + expect(result.name).toBe('Updated Tag'); + }); + + it('should update tag color', async () => { + const updated = mockTagFactory.create({ color: '#22c55e' }); + mockDb.returning.mockResolvedValueOnce([updated]); + + const result = await service.update('test-user-id', updated.id, { color: '#22c55e' }); + + expect(result.color).toBe('#22c55e'); + }); + + it('should throw NotFoundException when tag does not exist', async () => { + mockDb.returning.mockResolvedValueOnce([]); + + await expect( + service.update('test-user-id', 'nonexistent', { name: 'New Name' }) + ).rejects.toThrow(NotFoundException); + }); + + it('should update both name and color', async () => { + const updated = mockTagFactory.create({ name: 'New Name', color: '#a855f7' }); + mockDb.returning.mockResolvedValueOnce([updated]); + + const result = await service.update('test-user-id', updated.id, { + name: 'New Name', + color: '#a855f7', + }); + + expect(result.name).toBe('New Name'); + expect(result.color).toBe('#a855f7'); + }); + }); + + describe('delete', () => { + it('should delete a tag', async () => { + mockDb.where.mockResolvedValueOnce(undefined); + + await service.delete('test-user-id', 'tag-id'); + + expect(mockDb.delete).toHaveBeenCalled(); + }); + }); + + describe('addTagToFile', () => { + it('should add a tag to a file', async () => { + mockDb.onConflictDoNothing.mockResolvedValueOnce(undefined); + + await service.addTagToFile('file-id', 'tag-id'); + + expect(mockDb.insert).toHaveBeenCalled(); + expect(mockDb.values).toHaveBeenCalledWith({ fileId: 'file-id', tagId: 'tag-id' }); + expect(mockDb.onConflictDoNothing).toHaveBeenCalled(); + }); + + it('should not throw when tag is already on file (onConflictDoNothing)', async () => { + mockDb.onConflictDoNothing.mockResolvedValueOnce(undefined); + + await expect(service.addTagToFile('file-id', 'tag-id')).resolves.not.toThrow(); + }); + }); + + describe('removeTagFromFile', () => { + it('should remove a tag from a file', async () => { + mockDb.where.mockResolvedValueOnce(undefined); + + await service.removeTagFromFile('file-id', 'tag-id'); + + expect(mockDb.delete).toHaveBeenCalled(); + }); + }); + + describe('getFileTags', () => { + it('should return tags for a file', async () => { + const fileTags = [ + { tag: mockTagFactory.create({ name: 'Work' }) }, + { tag: mockTagFactory.create({ name: 'Important' }) }, + ]; + mockDb.where.mockResolvedValueOnce(fileTags); + + const result = await service.getFileTags('file-id'); + + expect(result).toHaveLength(2); + expect(result[0].name).toBe('Work'); + expect(result[1].name).toBe('Important'); + }); + + it('should return empty array when file has no tags', async () => { + mockDb.where.mockResolvedValueOnce([]); + + const result = await service.getFileTags('file-id'); + + expect(result).toEqual([]); + }); + }); +}); diff --git a/apps/storage/apps/backend/src/trash/trash.service.spec.ts b/apps/storage/apps/backend/src/trash/trash.service.spec.ts new file mode 100644 index 000000000..08e4ef4de --- /dev/null +++ b/apps/storage/apps/backend/src/trash/trash.service.spec.ts @@ -0,0 +1,183 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { NotFoundException } from '@nestjs/common'; +import { DATABASE_CONNECTION } from '../db/database.module'; +import { TrashService } from './trash.service'; +import { StorageService } from '../storage/storage.service'; +import { + createMockDb, + mockFileFactory, + mockFolderFactory, +} from '../__tests__/utils/mock-factories'; + +describe('TrashService', () => { + let service: TrashService; + let mockDb: ReturnType; + let mockStorageService: Record; + + beforeEach(async () => { + mockDb = createMockDb(); + mockStorageService = { + uploadFile: jest.fn(), + downloadFile: jest.fn(), + deleteFile: jest.fn(), + deleteFiles: jest.fn(), + getDownloadUrl: jest.fn(), + }; + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + TrashService, + { provide: DATABASE_CONNECTION, useValue: mockDb }, + { provide: StorageService, useValue: mockStorageService }, + ], + }).compile(); + + service = module.get(TrashService); + }); + + describe('findAll', () => { + it('should return trashed files and folders', async () => { + const trashedFiles = mockFileFactory.createMany(2, { isDeleted: true }); + const trashedFolders = mockFolderFactory.createMany(1, { isDeleted: true }); + + // First where call: trashed files + mockDb.where.mockResolvedValueOnce(trashedFiles); + // Second where call: trashed folders + mockDb.where.mockResolvedValueOnce(trashedFolders); + + const result = await service.findAll('test-user-id'); + + expect(result.files).toEqual(trashedFiles); + expect(result.folders).toEqual(trashedFolders); + expect(result.files).toHaveLength(2); + expect(result.folders).toHaveLength(1); + }); + + it('should return empty arrays when trash is empty', async () => { + mockDb.where.mockResolvedValueOnce([]); + mockDb.where.mockResolvedValueOnce([]); + + const result = await service.findAll('test-user-id'); + + expect(result.files).toEqual([]); + expect(result.folders).toEqual([]); + }); + }); + + describe('restoreFile', () => { + it('should restore a trashed file', async () => { + const file = mockFileFactory.create({ isDeleted: false, deletedAt: null }); + mockDb.returning.mockResolvedValueOnce([file]); + + const result = await service.restoreFile('test-user-id', file.id); + + expect(result).toEqual(file); + expect(mockDb.update).toHaveBeenCalled(); + expect(mockDb.set).toHaveBeenCalledWith( + expect.objectContaining({ + isDeleted: false, + deletedAt: null, + }) + ); + }); + + it('should throw NotFoundException when file is not in trash', async () => { + mockDb.returning.mockResolvedValueOnce([]); + + await expect(service.restoreFile('test-user-id', 'nonexistent')).rejects.toThrow( + NotFoundException + ); + }); + }); + + describe('restoreFolder', () => { + it('should restore a trashed folder', async () => { + const folder = mockFolderFactory.create({ isDeleted: false, deletedAt: null }); + mockDb.returning.mockResolvedValueOnce([folder]); + + const result = await service.restoreFolder('test-user-id', folder.id); + + expect(result).toEqual(folder); + expect(mockDb.set).toHaveBeenCalledWith( + expect.objectContaining({ + isDeleted: false, + deletedAt: null, + }) + ); + }); + + it('should throw NotFoundException when folder is not in trash', async () => { + mockDb.returning.mockResolvedValueOnce([]); + + await expect(service.restoreFolder('test-user-id', 'nonexistent')).rejects.toThrow( + NotFoundException + ); + }); + }); + + describe('permanentlyDeleteFile', () => { + it('should delete file from S3 and database', async () => { + const file = mockFileFactory.create({ isDeleted: true }); + // select query to find the file + mockDb.where.mockResolvedValueOnce([file]); + // deleteFile from S3 + mockStorageService.deleteFile.mockResolvedValueOnce(undefined); + // delete from DB + mockDb.where.mockResolvedValueOnce(undefined); + + await service.permanentlyDeleteFile('test-user-id', file.id); + + expect(mockStorageService.deleteFile).toHaveBeenCalledWith(file.storageKey); + expect(mockDb.delete).toHaveBeenCalled(); + }); + + it('should throw NotFoundException when file is not in trash', async () => { + mockDb.where.mockResolvedValueOnce([]); + + await expect(service.permanentlyDeleteFile('test-user-id', 'nonexistent')).rejects.toThrow( + NotFoundException + ); + }); + }); + + describe('permanentlyDeleteFolder', () => { + it('should delete folder from database', async () => { + mockDb.where.mockResolvedValueOnce(undefined); + + await service.permanentlyDeleteFolder('test-user-id', 'folder-id'); + + expect(mockDb.delete).toHaveBeenCalled(); + }); + }); + + describe('emptyTrash', () => { + it('should delete all trashed files from S3 and database', async () => { + const trashedFiles = mockFileFactory.createMany(3, { isDeleted: true }); + // select trashed files + mockDb.where.mockResolvedValueOnce(trashedFiles); + // deleteFile calls for each file + mockStorageService.deleteFile.mockResolvedValue(undefined); + // delete files from DB + mockDb.where.mockResolvedValueOnce(undefined); + // delete folders from DB + mockDb.where.mockResolvedValueOnce(undefined); + + await service.emptyTrash('test-user-id'); + + expect(mockStorageService.deleteFile).toHaveBeenCalledTimes(3); + for (const file of trashedFiles) { + expect(mockStorageService.deleteFile).toHaveBeenCalledWith(file.storageKey); + } + }); + + it('should handle empty trash gracefully', async () => { + mockDb.where.mockResolvedValueOnce([]); + mockDb.where.mockResolvedValueOnce(undefined); + mockDb.where.mockResolvedValueOnce(undefined); + + await service.emptyTrash('test-user-id'); + + expect(mockStorageService.deleteFile).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/apps/storage/apps/web/Dockerfile b/apps/storage/apps/web/Dockerfile index 276e086ee..e6e8d1cb3 100644 --- a/apps/storage/apps/web/Dockerfile +++ b/apps/storage/apps/web/Dockerfile @@ -3,7 +3,7 @@ FROM node:20-alpine AS builder # Build arguments for SvelteKit static env vars -ARG PUBLIC_BACKEND_URL=http://storage-backend:3019 +ARG PUBLIC_BACKEND_URL=http://storage-backend:3016 ARG PUBLIC_MANA_CORE_AUTH_URL=http://mana-core-auth:3001 # Set as environment variables for build diff --git a/apps/storage/apps/web/package.json b/apps/storage/apps/web/package.json index 63f6f1e12..bbe3cf5a6 100644 --- a/apps/storage/apps/web/package.json +++ b/apps/storage/apps/web/package.json @@ -9,9 +9,14 @@ "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch", "lint": "eslint .", - "format": "prettier --write ." + "format": "prettier --write .", + "test": "vitest run", + "test:watch": "vitest" }, "devDependencies": { + "@testing-library/svelte": "^5.2.0", + "jsdom": "^26.1.0", + "vitest": "^3.2.1", "@manacore/shared-pwa": "workspace:*", "@manacore/shared-vite-config": "workspace:*", "@sveltejs/adapter-node": "^5.0.0", diff --git a/apps/storage/apps/web/src/lib/api/client.test.ts b/apps/storage/apps/web/src/lib/api/client.test.ts new file mode 100644 index 000000000..9c39ca571 --- /dev/null +++ b/apps/storage/apps/web/src/lib/api/client.test.ts @@ -0,0 +1,290 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +// Mock shared-api-client +const mockGet = vi.fn(); +const mockPost = vi.fn(); +const mockPatch = vi.fn(); +const mockDelete = vi.fn(); +const mockUpload = vi.fn(); + +vi.mock('@manacore/shared-api-client', () => ({ + createApiClient: () => ({ + get: mockGet, + post: mockPost, + patch: mockPatch, + delete: mockDelete, + put: vi.fn(), + upload: mockUpload, + }), +})); + +vi.mock('$lib/stores/auth.svelte', () => ({ + authStore: { + getAccessToken: vi.fn().mockResolvedValue('test-token'), + }, +})); + +// Import after mocks are set up +const { filesApi, foldersApi, sharesApi, tagsApi, trashApi, searchApi } = await import('./client'); + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe('filesApi', () => { + it('list() calls GET /files', async () => { + const mockFiles = [{ id: '1', name: 'test.txt' }]; + mockGet.mockResolvedValue({ data: mockFiles }); + + const result = await filesApi.list(); + + expect(mockGet).toHaveBeenCalledWith('/files'); + expect(result.data).toEqual(mockFiles); + }); + + it('list(folderId) calls GET /files?folderId=folder-id', async () => { + const mockFiles = [{ id: '2', name: 'nested.txt' }]; + mockGet.mockResolvedValue({ data: mockFiles }); + + const result = await filesApi.list('folder-id'); + + expect(mockGet).toHaveBeenCalledWith('/files?folderId=folder-id'); + expect(result.data).toEqual(mockFiles); + }); + + it('rename(id, name) calls PATCH /files/:id', async () => { + const updatedFile = { id: 'file-1', name: 'renamed.txt' }; + mockPatch.mockResolvedValue({ data: updatedFile }); + + const result = await filesApi.rename('file-1', 'renamed.txt'); + + expect(mockPatch).toHaveBeenCalledWith('/files/file-1', { name: 'renamed.txt' }); + expect(result.data).toEqual(updatedFile); + }); + + it('delete(id) calls DELETE /files/:id', async () => { + mockDelete.mockResolvedValue({ data: { success: true } }); + + const result = await filesApi.delete('file-1'); + + expect(mockDelete).toHaveBeenCalledWith('/files/file-1'); + expect(result.data).toEqual({ success: true }); + }); + + it('toggleFavorite(id) calls POST /files/:id/favorite', async () => { + const updatedFile = { id: 'file-1', isFavorite: true }; + mockPost.mockResolvedValue({ data: updatedFile }); + + const result = await filesApi.toggleFavorite('file-1'); + + expect(mockPost).toHaveBeenCalledWith('/files/file-1/favorite', undefined); + expect(result.data).toEqual(updatedFile); + }); + + it('returns error on API failure', async () => { + mockGet.mockResolvedValue({ error: { message: 'Not found' } }); + + const result = await filesApi.list(); + + expect(result.error).toBe('Not found'); + expect(result.data).toBeUndefined(); + }); +}); + +describe('foldersApi', () => { + it('create(name) calls POST /folders', async () => { + const newFolder = { id: 'folder-1', name: 'New Folder' }; + mockPost.mockResolvedValue({ data: newFolder }); + + const result = await foldersApi.create('New Folder'); + + expect(mockPost).toHaveBeenCalledWith('/folders', { + name: 'New Folder', + parentFolderId: undefined, + color: undefined, + }); + expect(result.data).toEqual(newFolder); + }); + + it('create(name, parentId, color) passes all params', async () => { + mockPost.mockResolvedValue({ data: { id: 'folder-2' } }); + + await foldersApi.create('Sub Folder', 'parent-1', 'blue'); + + expect(mockPost).toHaveBeenCalledWith('/folders', { + name: 'Sub Folder', + parentFolderId: 'parent-1', + color: 'blue', + }); + }); + + it('list() calls GET /folders', async () => { + mockGet.mockResolvedValue({ data: [] }); + + await foldersApi.list(); + + expect(mockGet).toHaveBeenCalledWith('/folders'); + }); + + it('list(parentId) calls GET /folders?parentId=...', async () => { + mockGet.mockResolvedValue({ data: [] }); + + await foldersApi.list('parent-1'); + + expect(mockGet).toHaveBeenCalledWith('/folders?parentId=parent-1'); + }); + + it('delete(id) calls DELETE /folders/:id', async () => { + mockDelete.mockResolvedValue({ data: { success: true } }); + + const result = await foldersApi.delete('folder-1'); + + expect(mockDelete).toHaveBeenCalledWith('/folders/folder-1'); + expect(result.data).toEqual({ success: true }); + }); +}); + +describe('sharesApi', () => { + it('list() calls GET /shares', async () => { + const mockShares = [{ id: 'share-1', shareToken: 'abc' }]; + mockGet.mockResolvedValue({ data: mockShares }); + + const result = await sharesApi.list(); + + expect(mockGet).toHaveBeenCalledWith('/shares'); + expect(result.data).toEqual(mockShares); + }); + + it('create(data) calls POST /shares', async () => { + const shareData = { fileId: 'file-1', accessLevel: 'view' as const }; + mockPost.mockResolvedValue({ data: { id: 'share-1', shareToken: 'xyz' } }); + + const result = await sharesApi.create(shareData); + + expect(mockPost).toHaveBeenCalledWith('/shares', shareData); + expect(result.data).toEqual({ id: 'share-1', shareToken: 'xyz' }); + }); + + it('delete(id) calls DELETE /shares/:id', async () => { + mockDelete.mockResolvedValue({ data: { success: true } }); + + await sharesApi.delete('share-1'); + + expect(mockDelete).toHaveBeenCalledWith('/shares/share-1'); + }); +}); + +describe('tagsApi', () => { + it('create(name, color) calls POST /tags', async () => { + const newTag = { id: 'tag-1', name: 'Important', color: 'red' }; + mockPost.mockResolvedValue({ data: newTag }); + + const result = await tagsApi.create('Important', 'red'); + + expect(mockPost).toHaveBeenCalledWith('/tags', { name: 'Important', color: 'red' }); + expect(result.data).toEqual(newTag); + }); + + it('create(name) works without color', async () => { + mockPost.mockResolvedValue({ data: { id: 'tag-2', name: 'Work' } }); + + await tagsApi.create('Work'); + + expect(mockPost).toHaveBeenCalledWith('/tags', { name: 'Work', color: undefined }); + }); + + it('list() calls GET /tags', async () => { + mockGet.mockResolvedValue({ data: [] }); + + await tagsApi.list(); + + expect(mockGet).toHaveBeenCalledWith('/tags'); + }); + + it('delete(id) calls DELETE /tags/:id', async () => { + mockDelete.mockResolvedValue({ data: { success: true } }); + + await tagsApi.delete('tag-1'); + + expect(mockDelete).toHaveBeenCalledWith('/tags/tag-1'); + }); +}); + +describe('trashApi', () => { + it('list() calls GET /trash', async () => { + const trashItems = { files: [], folders: [] }; + mockGet.mockResolvedValue({ data: trashItems }); + + const result = await trashApi.list(); + + expect(mockGet).toHaveBeenCalledWith('/trash'); + expect(result.data).toEqual(trashItems); + }); + + it('restore(id, type) calls POST /trash/:id/restore', async () => { + mockPost.mockResolvedValue({ data: { id: 'file-1' } }); + + await trashApi.restore('file-1', 'file'); + + expect(mockPost).toHaveBeenCalledWith('/trash/file-1/restore?type=file', undefined); + }); + + it('empty() calls DELETE /trash', async () => { + mockDelete.mockResolvedValue({ data: { success: true } }); + + await trashApi.empty(); + + expect(mockDelete).toHaveBeenCalledWith('/trash'); + }); +}); + +describe('searchApi', () => { + it('search(query) calls GET /search?q=...', async () => { + const searchResults = { files: [{ id: '1', name: 'match.txt' }], folders: [] }; + mockGet.mockResolvedValue({ data: searchResults }); + + const result = await searchApi.search('match'); + + expect(mockGet).toHaveBeenCalledWith('/search?q=match'); + expect(result.data).toEqual(searchResults); + }); + + it('search(query) encodes special characters', async () => { + mockGet.mockResolvedValue({ data: { files: [], folders: [] } }); + + await searchApi.search('hello world & more'); + + expect(mockGet).toHaveBeenCalledWith('/search?q=hello%20world%20%26%20more'); + }); + + it('favorites() calls GET /favorites', async () => { + const favs = { files: [{ id: '1', isFavorite: true }], folders: [] }; + mockGet.mockResolvedValue({ data: favs }); + + const result = await searchApi.favorites(); + + expect(mockGet).toHaveBeenCalledWith('/favorites'); + expect(result.data).toEqual(favs); + }); +}); + +describe('error handling', () => { + it('returns error message when API returns error', async () => { + mockGet.mockResolvedValue({ error: { message: 'Unauthorized' } }); + + const result = await filesApi.get('non-existent'); + + expect(result.error).toBe('Unauthorized'); + expect(result.data).toBeUndefined(); + }); + + it('returns data when API succeeds', async () => { + const file = { id: 'file-1', name: 'test.txt' }; + mockGet.mockResolvedValue({ data: file }); + + const result = await filesApi.get('file-1'); + + expect(result.data).toEqual(file); + expect(result.error).toBeUndefined(); + }); +}); diff --git a/apps/storage/apps/web/src/lib/components/files/FilePreviewModal.svelte b/apps/storage/apps/web/src/lib/components/files/FilePreviewModal.svelte new file mode 100644 index 000000000..bfce2ac16 --- /dev/null +++ b/apps/storage/apps/web/src/lib/components/files/FilePreviewModal.svelte @@ -0,0 +1,377 @@ + + +{#if open && file} + {@const Icon = getFileIcon(file.mimeType)} + +{/if} + + diff --git a/apps/storage/apps/web/src/routes/favorites/+page.svelte b/apps/storage/apps/web/src/routes/favorites/+page.svelte index 3e986765a..07532cb43 100644 --- a/apps/storage/apps/web/src/routes/favorites/+page.svelte +++ b/apps/storage/apps/web/src/routes/favorites/+page.svelte @@ -8,7 +8,9 @@ import { toastStore } from '@manacore/shared-ui'; import FileGrid from '$lib/components/files/FileGrid.svelte'; import FileList from '$lib/components/files/FileList.svelte'; + import FilePreviewModal from '$lib/components/files/FilePreviewModal.svelte'; + let previewFile = $state(null); let files = $state([]); let folders = $state([]); let loading = $state(true); @@ -39,7 +41,7 @@ } function handleFileClick(file: StorageFile) { - // TODO: Open file preview + previewFile = file; } async function handleFileAction(action: string, file: StorageFile) { @@ -132,6 +134,16 @@ {/if} + (previewFile = null)} + onAction={(action, file) => { + handleFileAction(action, file); + previewFile = null; + }} +/> +