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:
Till JS 2026-03-21 12:16:28 +01:00
parent 481a88d25a
commit a17a3a7f58
19 changed files with 2093 additions and 8 deletions

View file

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

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

View file

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

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

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

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

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

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

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

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

View file

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

View file

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

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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