mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-18 02:29:41 +02:00
feat(storage): add tests, file preview modal, and fix Dockerfile ports
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) <noreply@anthropic.com>
This commit is contained in:
parent
481a88d25a
commit
a17a3a7f58
19 changed files with 2093 additions and 8 deletions
|
|
@ -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"]
|
||||
|
|
|
|||
12
apps/storage/apps/backend/jest.config.js
Normal file
12
apps/storage/apps/backend/jest.config.js
Normal file
|
|
@ -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$': '<rootDir>/../../packages/shared/src' },
|
||||
transformIgnorePatterns: ['node_modules/(?!(@storage|@manacore)/)'],
|
||||
};
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
109
apps/storage/apps/backend/src/__tests__/utils/mock-factories.ts
Normal file
109
apps/storage/apps/backend/src/__tests__/utils/mock-factories.ts
Normal file
|
|
@ -0,0 +1,109 @@
|
|||
import { randomUUID } from 'crypto';
|
||||
|
||||
// File mock factory
|
||||
export const mockFileFactory = {
|
||||
create: (overrides: Record<string, any> = {}) => ({
|
||||
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<string, any> = {}) =>
|
||||
Array.from({ length: count }, () => mockFileFactory.create(overrides)),
|
||||
};
|
||||
|
||||
// Folder mock factory
|
||||
export const mockFolderFactory = {
|
||||
create: (overrides: Record<string, any> = {}) => ({
|
||||
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<string, any> = {}) =>
|
||||
Array.from({ length: count }, () => mockFolderFactory.create(overrides)),
|
||||
};
|
||||
|
||||
// Share mock factory
|
||||
export const mockShareFactory = {
|
||||
create: (overrides: Record<string, any> = {}) => ({
|
||||
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<string, any> = {}) => ({
|
||||
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;
|
||||
}
|
||||
301
apps/storage/apps/backend/src/file/file.service.spec.ts
Normal file
301
apps/storage/apps/backend/src/file/file.service.spec.ts
Normal file
|
|
@ -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<typeof createMockDb>;
|
||||
let mockStorageService: Record<string, jest.Mock>;
|
||||
|
||||
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>(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);
|
||||
});
|
||||
});
|
||||
});
|
||||
264
apps/storage/apps/backend/src/folder/folder.service.spec.ts
Normal file
264
apps/storage/apps/backend/src/folder/folder.service.spec.ts
Normal file
|
|
@ -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<typeof createMockDb>;
|
||||
|
||||
beforeEach(async () => {
|
||||
mockDb = createMockDb();
|
||||
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [FolderService, { provide: DATABASE_CONNECTION, useValue: mockDb }],
|
||||
}).compile();
|
||||
|
||||
service = module.get<FolderService>(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 }));
|
||||
});
|
||||
});
|
||||
});
|
||||
114
apps/storage/apps/backend/src/search/search.service.spec.ts
Normal file
114
apps/storage/apps/backend/src/search/search.service.spec.ts
Normal file
|
|
@ -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<typeof createMockDb>;
|
||||
|
||||
beforeEach(async () => {
|
||||
mockDb = createMockDb();
|
||||
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [SearchService, { provide: DATABASE_CONNECTION, useValue: mockDb }],
|
||||
}).compile();
|
||||
|
||||
service = module.get<SearchService>(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);
|
||||
});
|
||||
});
|
||||
});
|
||||
200
apps/storage/apps/backend/src/share/share.service.spec.ts
Normal file
200
apps/storage/apps/backend/src/share/share.service.spec.ts
Normal file
|
|
@ -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<typeof createMockDb>;
|
||||
|
||||
beforeEach(async () => {
|
||||
mockDb = createMockDb();
|
||||
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [ShareService, { provide: DATABASE_CONNECTION, useValue: mockDb }],
|
||||
}).compile();
|
||||
|
||||
service = module.get<ShareService>(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();
|
||||
});
|
||||
});
|
||||
});
|
||||
170
apps/storage/apps/backend/src/tag/tag.service.spec.ts
Normal file
170
apps/storage/apps/backend/src/tag/tag.service.spec.ts
Normal file
|
|
@ -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<typeof createMockDb>;
|
||||
|
||||
beforeEach(async () => {
|
||||
mockDb = createMockDb();
|
||||
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [TagService, { provide: DATABASE_CONNECTION, useValue: mockDb }],
|
||||
}).compile();
|
||||
|
||||
service = module.get<TagService>(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([]);
|
||||
});
|
||||
});
|
||||
});
|
||||
183
apps/storage/apps/backend/src/trash/trash.service.spec.ts
Normal file
183
apps/storage/apps/backend/src/trash/trash.service.spec.ts
Normal file
|
|
@ -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<typeof createMockDb>;
|
||||
let mockStorageService: Record<string, jest.Mock>;
|
||||
|
||||
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>(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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
290
apps/storage/apps/web/src/lib/api/client.test.ts
Normal file
290
apps/storage/apps/web/src/lib/api/client.test.ts
Normal file
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,377 @@
|
|||
<script lang="ts">
|
||||
import {
|
||||
X,
|
||||
DownloadSimple,
|
||||
PencilSimple,
|
||||
ShareNetwork,
|
||||
Heart,
|
||||
Trash,
|
||||
File,
|
||||
FileImage,
|
||||
FileText,
|
||||
FileVideo,
|
||||
FileAudio,
|
||||
FileZip,
|
||||
} from '@manacore/shared-icons';
|
||||
import type { StorageFile } from '$lib/api/client';
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
file: StorageFile | null;
|
||||
onClose: () => void;
|
||||
onAction: (action: string, file: StorageFile) => void;
|
||||
}
|
||||
|
||||
let { open, file, onClose, onAction }: Props = $props();
|
||||
|
||||
let isImage = $derived(file?.mimeType.startsWith('image/') ?? false);
|
||||
let isTextOrCode = $derived(
|
||||
file?.mimeType.startsWith('text/') ||
|
||||
file?.mimeType.includes('javascript') ||
|
||||
file?.mimeType.includes('json') ||
|
||||
file?.mimeType.includes('xml') ||
|
||||
false
|
||||
);
|
||||
|
||||
let imageUrl = $derived(
|
||||
isImage && file ? `http://localhost:3016/api/v1/files/${file.id}/download` : null
|
||||
);
|
||||
|
||||
function getFileIcon(mimeType: string) {
|
||||
if (mimeType.startsWith('image/')) return FileImage;
|
||||
if (mimeType.startsWith('video/')) return FileVideo;
|
||||
if (mimeType.startsWith('audio/')) return FileAudio;
|
||||
if (mimeType.startsWith('text/')) return FileText;
|
||||
if (mimeType.includes('zip') || mimeType.includes('archive')) return FileZip;
|
||||
return File;
|
||||
}
|
||||
|
||||
function formatFileSize(bytes: number): string {
|
||||
if (bytes === 0) return '0 B';
|
||||
const k = 1024;
|
||||
const sizes = ['B', 'KB', 'MB', 'GB'];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i];
|
||||
}
|
||||
|
||||
function formatDate(dateStr: string): string {
|
||||
return new Date(dateStr).toLocaleDateString('de-DE', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
}
|
||||
|
||||
function handleKeydown(e: KeyboardEvent) {
|
||||
if (e.key === 'Escape') {
|
||||
onClose();
|
||||
}
|
||||
}
|
||||
|
||||
function handleAction(action: string) {
|
||||
if (file) {
|
||||
onAction(action, file);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if open && file}
|
||||
{@const Icon = getFileIcon(file.mimeType)}
|
||||
<div
|
||||
class="modal-overlay"
|
||||
onclick={onClose}
|
||||
onkeydown={handleKeydown}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="preview-modal-title"
|
||||
tabindex="-1"
|
||||
>
|
||||
<div class="modal-content" onclick={(e) => e.stopPropagation()}>
|
||||
<div class="modal-header">
|
||||
<h2 id="preview-modal-title">{file.name}</h2>
|
||||
<button class="close-button" onclick={onClose} aria-label="Schließen">
|
||||
<X size={20} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="modal-body">
|
||||
<div class="preview-area">
|
||||
{#if isImage && imageUrl}
|
||||
<img src={imageUrl} alt={file.name} class="image-preview" />
|
||||
{:else if isTextOrCode}
|
||||
<div class="no-preview">
|
||||
<Icon size={64} strokeWidth={1} />
|
||||
<p>Vorschau nicht verfügbar</p>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="no-preview">
|
||||
<Icon size={64} strokeWidth={1} />
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="file-details">
|
||||
<div class="detail-row">
|
||||
<span class="detail-label">Name</span>
|
||||
<span class="detail-value">{file.name}</span>
|
||||
</div>
|
||||
<div class="detail-row">
|
||||
<span class="detail-label">Typ</span>
|
||||
<span class="detail-value">{file.mimeType}</span>
|
||||
</div>
|
||||
<div class="detail-row">
|
||||
<span class="detail-label">Größe</span>
|
||||
<span class="detail-value">{formatFileSize(file.size)}</span>
|
||||
</div>
|
||||
<div class="detail-row">
|
||||
<span class="detail-label">Erstellt</span>
|
||||
<span class="detail-value">{formatDate(file.createdAt)}</span>
|
||||
</div>
|
||||
<div class="detail-row">
|
||||
<span class="detail-label">Geändert</span>
|
||||
<span class="detail-value">{formatDate(file.updatedAt)}</span>
|
||||
</div>
|
||||
<div class="detail-row">
|
||||
<span class="detail-label">Favorit</span>
|
||||
<span class="detail-value">{file.isFavorite ? 'Ja' : 'Nein'}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="modal-actions">
|
||||
<button
|
||||
class="action-btn"
|
||||
onclick={() => handleAction('download')}
|
||||
aria-label="Herunterladen"
|
||||
>
|
||||
<DownloadSimple size={18} />
|
||||
<span>Herunterladen</span>
|
||||
</button>
|
||||
<button class="action-btn" onclick={() => handleAction('rename')} aria-label="Umbenennen">
|
||||
<PencilSimple size={18} />
|
||||
<span>Umbenennen</span>
|
||||
</button>
|
||||
<button class="action-btn" onclick={() => handleAction('share')} aria-label="Teilen">
|
||||
<ShareNetwork size={18} />
|
||||
<span>Teilen</span>
|
||||
</button>
|
||||
<button
|
||||
class="action-btn"
|
||||
class:favorited={file.isFavorite}
|
||||
onclick={() => handleAction('favorite')}
|
||||
aria-label={file.isFavorite ? 'Favorit entfernen' : 'Als Favorit markieren'}
|
||||
>
|
||||
<Heart size={18} fill={file.isFavorite ? 'currentColor' : 'none'} />
|
||||
<span>{file.isFavorite ? 'Favorit entfernen' : 'Favorit'}</span>
|
||||
</button>
|
||||
<button
|
||||
class="action-btn danger"
|
||||
onclick={() => handleAction('delete')}
|
||||
aria-label="Löschen"
|
||||
>
|
||||
<Trash size={18} />
|
||||
<span>Löschen</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.modal-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
backdrop-filter: blur(4px);
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
background: rgb(var(--color-surface-elevated));
|
||||
border-radius: var(--radius-xl);
|
||||
box-shadow: var(--shadow-xl);
|
||||
width: 100%;
|
||||
max-width: 600px;
|
||||
max-height: 90vh;
|
||||
margin: 1rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 1rem 1.5rem;
|
||||
border-bottom: 1px solid rgb(var(--color-border));
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.modal-header h2 {
|
||||
margin: 0;
|
||||
font-size: 1.125rem;
|
||||
font-weight: 600;
|
||||
color: rgb(var(--color-text-primary));
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
min-width: 0;
|
||||
padding-right: 1rem;
|
||||
}
|
||||
|
||||
.close-button {
|
||||
padding: 0.25rem;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
color: rgb(var(--color-text-secondary));
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.close-button:hover {
|
||||
background: rgb(var(--color-surface));
|
||||
color: rgb(var(--color-text-primary));
|
||||
}
|
||||
|
||||
.modal-body {
|
||||
padding: 1.5rem;
|
||||
overflow-y: auto;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.preview-area {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 200px;
|
||||
margin-bottom: 1.5rem;
|
||||
background: rgb(var(--color-surface));
|
||||
border-radius: var(--radius-lg);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.image-preview {
|
||||
max-width: 100%;
|
||||
max-height: 400px;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.no-preview {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.75rem;
|
||||
padding: 2rem;
|
||||
color: rgb(var(--color-text-secondary));
|
||||
}
|
||||
|
||||
.no-preview p {
|
||||
margin: 0;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.file-details {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.detail-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 0.375rem 0;
|
||||
border-bottom: 1px solid rgb(var(--color-border));
|
||||
}
|
||||
|
||||
.detail-row:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.detail-label {
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
color: rgb(var(--color-text-secondary));
|
||||
}
|
||||
|
||||
.detail-value {
|
||||
font-size: 0.875rem;
|
||||
color: rgb(var(--color-text-primary));
|
||||
text-align: right;
|
||||
word-break: break-all;
|
||||
max-width: 60%;
|
||||
}
|
||||
|
||||
.modal-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
padding: 1rem 1.5rem;
|
||||
border-top: 1px solid rgb(var(--color-border));
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
padding: 0.5rem 0.75rem;
|
||||
background: rgb(var(--color-surface));
|
||||
border: 1px solid rgb(var(--color-border));
|
||||
border-radius: var(--radius-md);
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 500;
|
||||
color: rgb(var(--color-text-primary));
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
|
||||
.action-btn:hover {
|
||||
background: rgb(var(--color-surface-elevated));
|
||||
border-color: rgb(var(--color-text-secondary));
|
||||
}
|
||||
|
||||
.action-btn.favorited {
|
||||
color: rgb(var(--color-warning));
|
||||
}
|
||||
|
||||
.action-btn.danger {
|
||||
color: rgb(var(--color-error));
|
||||
}
|
||||
|
||||
.action-btn.danger:hover {
|
||||
background: rgb(var(--color-error));
|
||||
border-color: rgb(var(--color-error));
|
||||
color: white;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.modal-content {
|
||||
max-width: 100%;
|
||||
max-height: 100vh;
|
||||
margin: 0;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.modal-actions {
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.action-btn span {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
padding: 0.625rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
@ -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<StorageFile | null>(null);
|
||||
let files = $state<StorageFile[]>([]);
|
||||
let folders = $state<StorageFolder[]>([]);
|
||||
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}
|
||||
</div>
|
||||
|
||||
<FilePreviewModal
|
||||
open={previewFile !== null}
|
||||
file={previewFile}
|
||||
onClose={() => (previewFile = null)}
|
||||
onAction={(action, file) => {
|
||||
handleFileAction(action, file);
|
||||
previewFile = null;
|
||||
}}
|
||||
/>
|
||||
|
||||
<style>
|
||||
.favorites-page {
|
||||
min-height: 100%;
|
||||
|
|
|
|||
|
|
@ -10,7 +10,9 @@
|
|||
import Breadcrumb from '$lib/components/files/Breadcrumb.svelte';
|
||||
import UploadZone from '$lib/components/files/UploadZone.svelte';
|
||||
import NewFolderModal from '$lib/components/files/NewFolderModal.svelte';
|
||||
import FilePreviewModal from '$lib/components/files/FilePreviewModal.svelte';
|
||||
|
||||
let previewFile = $state<StorageFile | null>(null);
|
||||
let showUploadZone = $state(false);
|
||||
let showNewFolderModal = $state(false);
|
||||
let uploading = $state(false);
|
||||
|
|
@ -33,7 +35,7 @@
|
|||
}
|
||||
|
||||
function handleFileClick(file: StorageFile) {
|
||||
// TODO: Open file preview
|
||||
previewFile = file;
|
||||
}
|
||||
|
||||
async function handleFileAction(action: string, file: StorageFile) {
|
||||
|
|
@ -262,6 +264,16 @@
|
|||
onCreate={handleCreateFolder}
|
||||
/>
|
||||
|
||||
<FilePreviewModal
|
||||
open={previewFile !== null}
|
||||
file={previewFile}
|
||||
onClose={() => (previewFile = null)}
|
||||
onAction={(action, file) => {
|
||||
handleFileAction(action, file);
|
||||
previewFile = null;
|
||||
}}
|
||||
/>
|
||||
|
||||
<style>
|
||||
.files-page {
|
||||
min-height: 100%;
|
||||
|
|
|
|||
|
|
@ -11,7 +11,9 @@
|
|||
import Breadcrumb from '$lib/components/files/Breadcrumb.svelte';
|
||||
import UploadZone from '$lib/components/files/UploadZone.svelte';
|
||||
import NewFolderModal from '$lib/components/files/NewFolderModal.svelte';
|
||||
import FilePreviewModal from '$lib/components/files/FilePreviewModal.svelte';
|
||||
|
||||
let previewFile = $state<StorageFile | null>(null);
|
||||
let showUploadZone = $state(false);
|
||||
let showNewFolderModal = $state(false);
|
||||
let uploading = $state(false);
|
||||
|
|
@ -41,7 +43,7 @@
|
|||
}
|
||||
|
||||
function handleFileClick(file: StorageFile) {
|
||||
// TODO: Open file preview
|
||||
previewFile = file;
|
||||
}
|
||||
|
||||
async function handleFileAction(action: string, file: StorageFile) {
|
||||
|
|
@ -278,6 +280,16 @@
|
|||
onCreate={handleCreateFolder}
|
||||
/>
|
||||
|
||||
<FilePreviewModal
|
||||
open={previewFile !== null}
|
||||
file={previewFile}
|
||||
onClose={() => (previewFile = null)}
|
||||
onAction={(action, file) => {
|
||||
handleFileAction(action, file);
|
||||
previewFile = null;
|
||||
}}
|
||||
/>
|
||||
|
||||
<style>
|
||||
.files-page {
|
||||
min-height: 100%;
|
||||
|
|
|
|||
|
|
@ -8,7 +8,9 @@
|
|||
import { filesStore } from '$lib/stores/files.svelte';
|
||||
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<StorageFile | null>(null);
|
||||
let query = $state('');
|
||||
let files = $state<StorageFile[]>([]);
|
||||
let folders = $state<StorageFolder[]>([]);
|
||||
|
|
@ -57,7 +59,7 @@
|
|||
}
|
||||
|
||||
function handleFileClick(file: StorageFile) {
|
||||
// TODO: Open file preview
|
||||
previewFile = file;
|
||||
}
|
||||
</script>
|
||||
|
||||
|
|
@ -138,6 +140,15 @@
|
|||
{/if}
|
||||
</div>
|
||||
|
||||
<FilePreviewModal
|
||||
open={previewFile !== null}
|
||||
file={previewFile}
|
||||
onClose={() => (previewFile = null)}
|
||||
onAction={() => {
|
||||
previewFile = null;
|
||||
}}
|
||||
/>
|
||||
|
||||
<style>
|
||||
.search-page {
|
||||
min-height: 100%;
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
/// <reference types="vitest/config" />
|
||||
import { sveltekit } from '@sveltejs/kit/vite';
|
||||
import tailwindcss from '@tailwindcss/vite';
|
||||
import { defineConfig } from 'vite';
|
||||
|
|
@ -28,4 +29,9 @@ export default defineConfig({
|
|||
optimizeDeps: {
|
||||
exclude: [...MANACORE_SHARED_PACKAGES, 'lucide-svelte'],
|
||||
},
|
||||
test: {
|
||||
environment: 'jsdom',
|
||||
include: ['src/**/*.test.ts'],
|
||||
globals: true,
|
||||
},
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue