feat(storage): add controller tests, Caddy config, and PWA improvements

Controller tests (50 tests, all passing):
- FileController: 12 tests (CRUD, upload, download with headers/URL mode)
- FolderController: 8 tests (CRUD, move, favorite)
- TrashController: 6 tests (restore file/folder, permanent delete, empty)
- SearchController: 6 tests (search, empty query, favorites)
- ShareController: 7 tests (CRUD, expiresInDays conversion, public token)
- TagController: 7 tests (CRUD with optional color)

Total test count now: 159 (133 backend + 26 web)

Deployment:
- Add Caddy reverse proxy entries for storage.mana.how and storage-api.mana.how

PWA:
- Upgrade to 'full' preset for better offline caching (fonts, external resources)
- Add app shortcuts: Dateien, Suche, Favoriten
- Improve offline page with links to cached pages

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-03-21 12:48:11 +01:00
parent fc5dfe2f0f
commit 403b1c7b87
10 changed files with 944 additions and 18 deletions

View file

@ -13,6 +13,8 @@ export const mockFileFactory = {
storageKey: `users/test-user-id/${randomUUID()}-test-file.pdf`,
parentFolderId: null,
currentVersion: 1,
checksum: null,
thumbnailPath: null,
isFavorite: false,
isDeleted: false,
deletedAt: null,
@ -53,9 +55,9 @@ export const mockShareFactory = {
userId: 'test-user-id',
fileId: null,
folderId: null,
shareType: 'file',
shareType: 'file' as const,
shareToken: randomUUID().replace(/-/g, '') + randomUUID().replace(/-/g, ''),
accessLevel: 'view',
accessLevel: 'view' as const,
password: null,
maxDownloads: null,
downloadCount: 0,

View file

@ -0,0 +1,247 @@
import { Test, TestingModule } from '@nestjs/testing';
import { BadRequestException } from '@nestjs/common';
import { JwtAuthGuard } from '@manacore/shared-nestjs-auth';
import { FileController } from './file.controller';
import { FileService } from './file.service';
import { mockFileFactory } from '../__tests__/utils/mock-factories';
describe('FileController', () => {
let controller: FileController;
let fileService: jest.Mocked<FileService>;
const mockUser = { userId: 'test-user-id', email: 'test@example.com', role: 'user' };
beforeEach(async () => {
const mockFileService = {
findAll: jest.fn(),
findOne: jest.fn(),
upload: jest.fn(),
update: jest.fn(),
move: jest.fn(),
delete: jest.fn(),
toggleFavorite: jest.fn(),
download: jest.fn(),
getDownloadUrl: jest.fn(),
getStats: jest.fn(),
};
const module: TestingModule = await Test.createTestingModule({
controllers: [FileController],
providers: [{ provide: FileService, useValue: mockFileService }],
})
.overrideGuard(JwtAuthGuard)
.useValue({ canActivate: () => true })
.compile();
controller = module.get<FileController>(FileController);
fileService = module.get(FileService);
});
describe('findAll', () => {
it('should return files for user at root level', async () => {
const files = mockFileFactory.createMany(3);
fileService.findAll.mockResolvedValue(files);
const result = await controller.findAll(mockUser);
expect(fileService.findAll).toHaveBeenCalledWith('test-user-id', undefined);
expect(result).toEqual(files);
});
it('should return files for user in specific folder', async () => {
const folderId = 'folder-123';
const files = mockFileFactory.createMany(2, { parentFolderId: folderId });
fileService.findAll.mockResolvedValue(files);
const result = await controller.findAll(mockUser, folderId);
expect(fileService.findAll).toHaveBeenCalledWith('test-user-id', folderId);
expect(result).toEqual(files);
});
});
describe('findOne', () => {
it('should return a single file by id', async () => {
const file = mockFileFactory.create({ id: 'file-123' });
fileService.findOne.mockResolvedValue(file);
const result = await controller.findOne(mockUser, 'file-123');
expect(fileService.findOne).toHaveBeenCalledWith('test-user-id', 'file-123');
expect(result).toEqual(file);
});
});
describe('getStats', () => {
it('should return file stats for user', async () => {
const stats = { totalFiles: 10, totalSize: 1024000, byType: {} };
fileService.getStats.mockResolvedValue(stats as any);
const result = await controller.getStats(mockUser);
expect(fileService.getStats).toHaveBeenCalledWith('test-user-id');
expect(result).toEqual(stats);
});
});
describe('upload', () => {
it('should upload a file and return the result', async () => {
const mockFile = {
originalname: 'test.pdf',
mimetype: 'application/pdf',
size: 1024,
buffer: Buffer.from('test'),
} as Express.Multer.File;
const dto = { parentFolderId: 'folder-123' };
const createdFile = mockFileFactory.create({ name: 'test.pdf' });
fileService.upload.mockResolvedValue(createdFile);
const result = await controller.upload(mockUser, mockFile, dto);
expect(fileService.upload).toHaveBeenCalledWith('test-user-id', mockFile, dto);
expect(result).toEqual(createdFile);
});
it('should throw BadRequestException when no file is provided', async () => {
const dto = {};
await expect(controller.upload(mockUser, undefined as any, dto)).rejects.toThrow(
BadRequestException
);
await expect(controller.upload(mockUser, undefined as any, dto)).rejects.toThrow(
'No file provided'
);
expect(fileService.upload).not.toHaveBeenCalled();
});
});
describe('uploadMultiple', () => {
it('should upload multiple files and return results', async () => {
const mockFiles = [
{
originalname: 'file1.pdf',
mimetype: 'application/pdf',
size: 1024,
buffer: Buffer.from('test1'),
},
{
originalname: 'file2.png',
mimetype: 'image/png',
size: 2048,
buffer: Buffer.from('test2'),
},
] as Express.Multer.File[];
const dto = {};
const created1 = mockFileFactory.create({ name: 'file1.pdf' });
const created2 = mockFileFactory.create({ name: 'file2.png' });
fileService.upload.mockResolvedValueOnce(created1).mockResolvedValueOnce(created2);
const result = await controller.uploadMultiple(mockUser, mockFiles, dto);
expect(fileService.upload).toHaveBeenCalledTimes(2);
expect(fileService.upload).toHaveBeenCalledWith('test-user-id', mockFiles[0], dto);
expect(fileService.upload).toHaveBeenCalledWith('test-user-id', mockFiles[1], dto);
expect(result).toEqual([created1, created2]);
});
it('should throw BadRequestException when no files provided', async () => {
await expect(controller.uploadMultiple(mockUser, [], {})).rejects.toThrow(
BadRequestException
);
await expect(controller.uploadMultiple(mockUser, [], {})).rejects.toThrow(
'No files provided'
);
});
it('should throw BadRequestException when files is undefined', async () => {
await expect(controller.uploadMultiple(mockUser, undefined as any, {})).rejects.toThrow(
BadRequestException
);
});
});
describe('update', () => {
it('should update a file and return the result', async () => {
const dto = { name: 'renamed-file.pdf' };
const updatedFile = mockFileFactory.create({ name: 'renamed-file.pdf' });
fileService.update.mockResolvedValue(updatedFile);
const result = await controller.update(mockUser, 'file-123', dto);
expect(fileService.update).toHaveBeenCalledWith('test-user-id', 'file-123', dto);
expect(result).toEqual(updatedFile);
});
});
describe('move', () => {
it('should move a file to a new folder', async () => {
const dto = { parentFolderId: 'new-folder-id' };
const movedFile = mockFileFactory.create({ parentFolderId: 'new-folder-id' });
fileService.move.mockResolvedValue(movedFile);
const result = await controller.move(mockUser, 'file-123', dto);
expect(fileService.move).toHaveBeenCalledWith('test-user-id', 'file-123', dto);
expect(result).toEqual(movedFile);
});
});
describe('delete', () => {
it('should delete a file and return success', async () => {
fileService.delete.mockResolvedValue(undefined);
const result = await controller.delete(mockUser, 'file-123');
expect(fileService.delete).toHaveBeenCalledWith('test-user-id', 'file-123');
expect(result).toEqual({ success: true });
});
});
describe('toggleFavorite', () => {
it('should toggle favorite status and return the file', async () => {
const file = mockFileFactory.create({ isFavorite: true });
fileService.toggleFavorite.mockResolvedValue(file);
const result = await controller.toggleFavorite(mockUser, 'file-123');
expect(fileService.toggleFavorite).toHaveBeenCalledWith('test-user-id', 'file-123');
expect(result).toEqual(file);
});
});
describe('download', () => {
it('should set response headers and send buffer for file download', async () => {
const file = mockFileFactory.create({
name: 'test-file.pdf',
mimeType: 'application/pdf',
});
const buffer = Buffer.from('file-content');
fileService.download.mockResolvedValue({ buffer, file } as any);
const mockRes = { set: jest.fn(), send: jest.fn(), json: jest.fn() } as any;
await controller.download(mockUser, 'file-123', undefined as any, mockRes);
expect(fileService.download).toHaveBeenCalledWith('test-user-id', 'file-123');
expect(mockRes.set).toHaveBeenCalledWith({
'Content-Type': 'application/pdf',
'Content-Disposition': `attachment; filename="${encodeURIComponent('test-file.pdf')}"`,
'Content-Length': buffer.length,
});
expect(mockRes.send).toHaveBeenCalledWith(buffer);
});
it('should return JSON with url when url=true query param is set', async () => {
const downloadUrl = 'https://storage.example.com/presigned-url';
fileService.getDownloadUrl.mockResolvedValue(downloadUrl);
const mockRes = { set: jest.fn(), send: jest.fn(), json: jest.fn() } as any;
await controller.download(mockUser, 'file-123', 'true', mockRes);
expect(fileService.getDownloadUrl).toHaveBeenCalledWith('test-user-id', 'file-123');
expect(mockRes.json).toHaveBeenCalledWith({ url: downloadUrl });
expect(mockRes.send).not.toHaveBeenCalled();
});
});
});

View file

@ -0,0 +1,150 @@
import { Test, TestingModule } from '@nestjs/testing';
import { JwtAuthGuard } from '@manacore/shared-nestjs-auth';
import { FolderController } from './folder.controller';
import { FolderService } from './folder.service';
import { mockFolderFactory } from '../__tests__/utils/mock-factories';
describe('FolderController', () => {
let controller: FolderController;
let folderService: jest.Mocked<FolderService>;
const mockUser = { userId: 'test-user-id', email: 'test@example.com', role: 'user' };
beforeEach(async () => {
const mockFolderService = {
findAll: jest.fn(),
findOne: jest.fn(),
create: jest.fn(),
update: jest.fn(),
move: jest.fn(),
delete: jest.fn(),
toggleFavorite: jest.fn(),
};
const module: TestingModule = await Test.createTestingModule({
controllers: [FolderController],
providers: [{ provide: FolderService, useValue: mockFolderService }],
})
.overrideGuard(JwtAuthGuard)
.useValue({ canActivate: () => true })
.compile();
controller = module.get<FolderController>(FolderController);
folderService = module.get(FolderService);
});
describe('findAll', () => {
it('should return root folders when no parentFolderId given', async () => {
const folders = mockFolderFactory.createMany(3);
folderService.findAll.mockResolvedValue(folders);
const result = await controller.findAll(mockUser);
expect(folderService.findAll).toHaveBeenCalledWith('test-user-id', undefined);
expect(result).toEqual(folders);
});
it('should return child folders when parentFolderId is given', async () => {
const parentId = 'parent-folder-id';
const folders = mockFolderFactory.createMany(2, { parentFolderId: parentId });
folderService.findAll.mockResolvedValue(folders);
const result = await controller.findAll(mockUser, parentId);
expect(folderService.findAll).toHaveBeenCalledWith('test-user-id', parentId);
expect(result).toEqual(folders);
});
});
describe('findOne', () => {
it('should return a single folder by id', async () => {
const folder = mockFolderFactory.create({ id: 'folder-123' });
folderService.findOne.mockResolvedValue(folder);
const result = await controller.findOne(mockUser, 'folder-123');
expect(folderService.findOne).toHaveBeenCalledWith('test-user-id', 'folder-123');
expect(result).toEqual(folder);
});
});
describe('create', () => {
it('should create a folder and return it', async () => {
const dto = { name: 'New Folder', parentFolderId: 'parent-id', color: '#ff0000' };
const created = mockFolderFactory.create({
name: 'New Folder',
parentFolderId: 'parent-id',
color: '#ff0000',
});
folderService.create.mockResolvedValue(created);
const result = await controller.create(mockUser, dto);
expect(folderService.create).toHaveBeenCalledWith('test-user-id', dto);
expect(result).toEqual(created);
});
it('should create a root folder without parentFolderId', async () => {
const dto = { name: 'Root Folder' };
const created = mockFolderFactory.create({ name: 'Root Folder' });
folderService.create.mockResolvedValue(created);
const result = await controller.create(mockUser, dto);
expect(folderService.create).toHaveBeenCalledWith('test-user-id', dto);
expect(result).toEqual(created);
});
});
describe('update', () => {
it('should update a folder and return the result', async () => {
const dto = { name: 'Renamed Folder', color: '#00ff00' };
const updated = mockFolderFactory.create({
name: 'Renamed Folder',
color: '#00ff00',
});
folderService.update.mockResolvedValue(updated);
const result = await controller.update(mockUser, 'folder-123', dto);
expect(folderService.update).toHaveBeenCalledWith('test-user-id', 'folder-123', dto);
expect(result).toEqual(updated);
});
});
describe('move', () => {
it('should move a folder to a new parent', async () => {
const dto = { parentFolderId: 'new-parent-id' };
const moved = mockFolderFactory.create({ parentFolderId: 'new-parent-id' });
folderService.move.mockResolvedValue(moved);
const result = await controller.move(mockUser, 'folder-123', dto);
expect(folderService.move).toHaveBeenCalledWith('test-user-id', 'folder-123', dto);
expect(result).toEqual(moved);
});
});
describe('delete', () => {
it('should delete a folder and return success', async () => {
folderService.delete.mockResolvedValue(undefined);
const result = await controller.delete(mockUser, 'folder-123');
expect(folderService.delete).toHaveBeenCalledWith('test-user-id', 'folder-123');
expect(result).toEqual({ success: true });
});
});
describe('toggleFavorite', () => {
it('should toggle favorite and return the folder', async () => {
const folder = mockFolderFactory.create({ isFavorite: true });
folderService.toggleFavorite.mockResolvedValue(folder);
const result = await controller.toggleFavorite(mockUser, 'folder-123');
expect(folderService.toggleFavorite).toHaveBeenCalledWith('test-user-id', 'folder-123');
expect(result).toEqual(folder);
});
});
});

View file

@ -0,0 +1,90 @@
import { Test, TestingModule } from '@nestjs/testing';
import { JwtAuthGuard } from '@manacore/shared-nestjs-auth';
import { SearchController } from './search.controller';
import { SearchService } from './search.service';
import { mockFileFactory, mockFolderFactory } from '../__tests__/utils/mock-factories';
describe('SearchController', () => {
let controller: SearchController;
let searchService: jest.Mocked<SearchService>;
const mockUser = { userId: 'test-user-id', email: 'test@example.com', role: 'user' };
beforeEach(async () => {
const mockSearchService = {
search: jest.fn(),
getFavorites: jest.fn(),
};
const module: TestingModule = await Test.createTestingModule({
controllers: [SearchController],
providers: [{ provide: SearchService, useValue: mockSearchService }],
})
.overrideGuard(JwtAuthGuard)
.useValue({ canActivate: () => true })
.compile();
controller = module.get<SearchController>(SearchController);
searchService = module.get(SearchService);
});
describe('search', () => {
it('should search and return matching files and folders', async () => {
const searchResults = {
files: mockFileFactory.createMany(2, { name: 'report.pdf' }),
folders: mockFolderFactory.createMany(1, { name: 'Reports' }),
};
searchService.search.mockResolvedValue(searchResults as any);
const result = await controller.search(mockUser, 'report');
expect(searchService.search).toHaveBeenCalledWith('test-user-id', 'report');
expect(result).toEqual(searchResults);
});
it('should return empty arrays when query is empty', async () => {
const result = await controller.search(mockUser, '');
expect(searchService.search).not.toHaveBeenCalled();
expect(result).toEqual({ files: [], folders: [] });
});
it('should return empty arrays when query is undefined', async () => {
const result = await controller.search(mockUser, undefined as any);
expect(searchService.search).not.toHaveBeenCalled();
expect(result).toEqual({ files: [], folders: [] });
});
it('should return empty arrays when query is only whitespace', async () => {
const result = await controller.search(mockUser, ' ');
expect(searchService.search).not.toHaveBeenCalled();
expect(result).toEqual({ files: [], folders: [] });
});
it('should trim the query before passing to service', async () => {
const searchResults = { files: [], folders: [] };
searchService.search.mockResolvedValue(searchResults as any);
await controller.search(mockUser, ' report ');
expect(searchService.search).toHaveBeenCalledWith('test-user-id', 'report');
});
});
describe('getFavorites', () => {
it('should return favorite files and folders', async () => {
const favorites = {
files: mockFileFactory.createMany(2, { isFavorite: true }),
folders: mockFolderFactory.createMany(1, { isFavorite: true }),
};
searchService.getFavorites.mockResolvedValue(favorites as any);
const result = await controller.getFavorites(mockUser);
expect(searchService.getFavorites).toHaveBeenCalledWith('test-user-id');
expect(result).toEqual(favorites);
});
});
});

View file

@ -0,0 +1,161 @@
import { Test, TestingModule } from '@nestjs/testing';
import { JwtAuthGuard } from '@manacore/shared-nestjs-auth';
import { ShareController } from './share.controller';
import { ShareService } from './share.service';
import { mockShareFactory } from '../__tests__/utils/mock-factories';
describe('ShareController', () => {
let controller: ShareController;
let shareService: jest.Mocked<ShareService>;
const mockUser = { userId: 'test-user-id', email: 'test@example.com', role: 'user' };
beforeEach(async () => {
const mockShareService = {
findAll: jest.fn(),
findByToken: jest.fn(),
create: jest.fn(),
delete: jest.fn(),
};
const module: TestingModule = await Test.createTestingModule({
controllers: [ShareController],
providers: [{ provide: ShareService, useValue: mockShareService }],
})
.overrideGuard(JwtAuthGuard)
.useValue({ canActivate: () => true })
.compile();
controller = module.get<ShareController>(ShareController);
shareService = module.get(ShareService);
});
describe('findAll', () => {
it('should return all shares for user', async () => {
const shares = [
mockShareFactory.create({ fileId: 'file-1' }),
mockShareFactory.create({ folderId: 'folder-1', shareType: 'folder' as const }),
];
shareService.findAll.mockResolvedValue(shares as any);
const result = await controller.findAll(mockUser);
expect(shareService.findAll).toHaveBeenCalledWith('test-user-id');
expect(result).toEqual(shares);
});
});
describe('findByToken', () => {
it('should return shared item by token (public, no auth required)', async () => {
const share = mockShareFactory.create({ shareToken: 'abc123token' });
shareService.findByToken.mockResolvedValue(share as any);
const result = await controller.findByToken('abc123token');
expect(shareService.findByToken).toHaveBeenCalledWith('abc123token');
expect(result).toEqual(share);
});
});
describe('create', () => {
it('should create a file share with default settings', async () => {
const dto = { fileId: 'file-123' };
const created = mockShareFactory.create({ fileId: 'file-123' });
shareService.create.mockResolvedValue(created as any);
const result = await controller.create(mockUser, dto);
expect(shareService.create).toHaveBeenCalledWith('test-user-id', {
fileId: 'file-123',
folderId: undefined,
accessLevel: undefined,
password: undefined,
maxDownloads: undefined,
expiresAt: undefined,
});
expect(result).toEqual(created);
});
it('should convert expiresInDays to a Date object', async () => {
const dto = {
fileId: 'file-123',
accessLevel: 'download' as const,
expiresInDays: 7,
};
const created = mockShareFactory.create({ fileId: 'file-123' });
shareService.create.mockResolvedValue(created as any);
const beforeCall = Date.now();
await controller.create(mockUser, dto);
const afterCall = Date.now();
const callArgs = shareService.create.mock.calls[0];
expect(callArgs[0]).toBe('test-user-id');
const expiresAt = callArgs[1].expiresAt as Date;
expect(expiresAt).toBeInstanceOf(Date);
const expectedMin = beforeCall + 7 * 24 * 60 * 60 * 1000;
const expectedMax = afterCall + 7 * 24 * 60 * 60 * 1000;
expect(expiresAt.getTime()).toBeGreaterThanOrEqual(expectedMin);
expect(expiresAt.getTime()).toBeLessThanOrEqual(expectedMax);
});
it('should create a share with password and max downloads', async () => {
const dto = {
fileId: 'file-123',
password: 'secret123',
maxDownloads: 5,
};
const created = mockShareFactory.create({
fileId: 'file-123',
password: 'secret123',
maxDownloads: 5,
});
shareService.create.mockResolvedValue(created as any);
const result = await controller.create(mockUser, dto);
expect(shareService.create).toHaveBeenCalledWith('test-user-id', {
fileId: 'file-123',
folderId: undefined,
accessLevel: undefined,
password: 'secret123',
maxDownloads: 5,
expiresAt: undefined,
});
expect(result).toEqual(created);
});
it('should create a folder share', async () => {
const dto = { folderId: 'folder-123', accessLevel: 'view' as const };
const created = mockShareFactory.create({
folderId: 'folder-123',
shareType: 'folder' as const,
});
shareService.create.mockResolvedValue(created as any);
const result = await controller.create(mockUser, dto);
expect(shareService.create).toHaveBeenCalledWith('test-user-id', {
fileId: undefined,
folderId: 'folder-123',
accessLevel: 'view',
password: undefined,
maxDownloads: undefined,
expiresAt: undefined,
});
expect(result).toEqual(created);
});
});
describe('delete', () => {
it('should delete a share and return success', async () => {
shareService.delete.mockResolvedValue(undefined);
const result = await controller.delete(mockUser, 'share-123');
expect(shareService.delete).toHaveBeenCalledWith('test-user-id', 'share-123');
expect(result).toEqual({ success: true });
});
});
});

View file

@ -0,0 +1,123 @@
import { Test, TestingModule } from '@nestjs/testing';
import { JwtAuthGuard } from '@manacore/shared-nestjs-auth';
import { TagController } from './tag.controller';
import { TagService } from './tag.service';
import { mockTagFactory } from '../__tests__/utils/mock-factories';
describe('TagController', () => {
let controller: TagController;
let tagService: jest.Mocked<TagService>;
const mockUser = { userId: 'test-user-id', email: 'test@example.com', role: 'user' };
beforeEach(async () => {
const mockTagService = {
findAll: jest.fn(),
create: jest.fn(),
update: jest.fn(),
delete: jest.fn(),
};
const module: TestingModule = await Test.createTestingModule({
controllers: [TagController],
providers: [{ provide: TagService, useValue: mockTagService }],
})
.overrideGuard(JwtAuthGuard)
.useValue({ canActivate: () => true })
.compile();
controller = module.get<TagController>(TagController);
tagService = module.get(TagService);
});
describe('findAll', () => {
it('should return all tags for user', async () => {
const tags = [
mockTagFactory.create({ name: 'Work' }),
mockTagFactory.create({ name: 'Personal' }),
mockTagFactory.create({ name: 'Important' }),
];
tagService.findAll.mockResolvedValue(tags as any);
const result = await controller.findAll(mockUser);
expect(tagService.findAll).toHaveBeenCalledWith('test-user-id');
expect(result).toEqual(tags);
expect(result).toHaveLength(3);
});
});
describe('create', () => {
it('should create a tag with name and color', async () => {
const dto = { name: 'Urgent', color: '#ff0000' };
const created = mockTagFactory.create({ name: 'Urgent', color: '#ff0000' });
tagService.create.mockResolvedValue(created as any);
const result = await controller.create(mockUser, dto);
expect(tagService.create).toHaveBeenCalledWith('test-user-id', 'Urgent', '#ff0000');
expect(result).toEqual(created);
});
it('should create a tag with name only (no color)', async () => {
const dto = { name: 'Archive' };
const created = mockTagFactory.create({ name: 'Archive' });
tagService.create.mockResolvedValue(created as any);
const result = await controller.create(mockUser, dto);
expect(tagService.create).toHaveBeenCalledWith('test-user-id', 'Archive', undefined);
expect(result).toEqual(created);
});
});
describe('update', () => {
it('should update a tag with new name and color', async () => {
const dto = { name: 'Updated Tag', color: '#00ff00' };
const updated = mockTagFactory.create({ name: 'Updated Tag', color: '#00ff00' });
tagService.update.mockResolvedValue(updated as any);
const result = await controller.update(mockUser, 'tag-123', dto);
expect(tagService.update).toHaveBeenCalledWith('test-user-id', 'tag-123', dto);
expect(result).toEqual(updated);
});
it('should update a tag with partial data (name only)', async () => {
const dto = { name: 'Renamed' };
const updated = mockTagFactory.create({ name: 'Renamed' });
tagService.update.mockResolvedValue(updated as any);
const result = await controller.update(mockUser, 'tag-123', dto);
expect(tagService.update).toHaveBeenCalledWith('test-user-id', 'tag-123', {
name: 'Renamed',
});
expect(result).toEqual(updated);
});
it('should update a tag with partial data (color only)', async () => {
const dto = { color: '#0000ff' };
const updated = mockTagFactory.create({ color: '#0000ff' });
tagService.update.mockResolvedValue(updated as any);
const result = await controller.update(mockUser, 'tag-123', dto);
expect(tagService.update).toHaveBeenCalledWith('test-user-id', 'tag-123', {
color: '#0000ff',
});
expect(result).toEqual(updated);
});
});
describe('delete', () => {
it('should delete a tag and return success', async () => {
tagService.delete.mockResolvedValue(undefined);
const result = await controller.delete(mockUser, 'tag-123');
expect(tagService.delete).toHaveBeenCalledWith('test-user-id', 'tag-123');
expect(result).toEqual({ success: true });
});
});
});

View file

@ -0,0 +1,109 @@
import { Test, TestingModule } from '@nestjs/testing';
import { JwtAuthGuard } from '@manacore/shared-nestjs-auth';
import { TrashController } from './trash.controller';
import { TrashService } from './trash.service';
import { mockFileFactory, mockFolderFactory } from '../__tests__/utils/mock-factories';
describe('TrashController', () => {
let controller: TrashController;
let trashService: jest.Mocked<TrashService>;
const mockUser = { userId: 'test-user-id', email: 'test@example.com', role: 'user' };
beforeEach(async () => {
const mockTrashService = {
findAll: jest.fn(),
restoreFile: jest.fn(),
restoreFolder: jest.fn(),
permanentlyDeleteFile: jest.fn(),
permanentlyDeleteFolder: jest.fn(),
emptyTrash: jest.fn(),
};
const module: TestingModule = await Test.createTestingModule({
controllers: [TrashController],
providers: [{ provide: TrashService, useValue: mockTrashService }],
})
.overrideGuard(JwtAuthGuard)
.useValue({ canActivate: () => true })
.compile();
controller = module.get<TrashController>(TrashController);
trashService = module.get(TrashService);
});
describe('findAll', () => {
it('should return all trashed items for user', async () => {
const trashedItems = {
files: mockFileFactory.createMany(2, { isDeleted: true }),
folders: mockFolderFactory.createMany(1, { isDeleted: true }),
};
trashService.findAll.mockResolvedValue(trashedItems as any);
const result = await controller.findAll(mockUser);
expect(trashService.findAll).toHaveBeenCalledWith('test-user-id');
expect(result).toEqual(trashedItems);
});
});
describe('restore', () => {
it('should call restoreFile when type is file', async () => {
const restoredFile = mockFileFactory.create({ isDeleted: false });
trashService.restoreFile.mockResolvedValue(restoredFile as any);
const result = await controller.restore(mockUser, 'file-123', 'file');
expect(trashService.restoreFile).toHaveBeenCalledWith('test-user-id', 'file-123');
expect(trashService.restoreFolder).not.toHaveBeenCalled();
expect(result).toEqual(restoredFile);
});
it('should call restoreFolder when type is folder', async () => {
const restoredFolder = mockFolderFactory.create({ isDeleted: false });
trashService.restoreFolder.mockResolvedValue(restoredFolder as any);
const result = await controller.restore(mockUser, 'folder-123', 'folder');
expect(trashService.restoreFolder).toHaveBeenCalledWith('test-user-id', 'folder-123');
expect(trashService.restoreFile).not.toHaveBeenCalled();
expect(result).toEqual(restoredFolder);
});
});
describe('permanentlyDelete', () => {
it('should permanently delete a file and return success', async () => {
trashService.permanentlyDeleteFile.mockResolvedValue(undefined);
const result = await controller.permanentlyDelete(mockUser, 'file-123', 'file');
expect(trashService.permanentlyDeleteFile).toHaveBeenCalledWith('test-user-id', 'file-123');
expect(trashService.permanentlyDeleteFolder).not.toHaveBeenCalled();
expect(result).toEqual({ success: true });
});
it('should permanently delete a folder and return success', async () => {
trashService.permanentlyDeleteFolder.mockResolvedValue(undefined);
const result = await controller.permanentlyDelete(mockUser, 'folder-123', 'folder');
expect(trashService.permanentlyDeleteFolder).toHaveBeenCalledWith(
'test-user-id',
'folder-123'
);
expect(trashService.permanentlyDeleteFile).not.toHaveBeenCalled();
expect(result).toEqual({ success: true });
});
});
describe('emptyTrash', () => {
it('should empty all trash and return success', async () => {
trashService.emptyTrash.mockResolvedValue(undefined);
const result = await controller.emptyTrash(mockUser);
expect(trashService.emptyTrash).toHaveBeenCalledWith('test-user-id');
expect(result).toEqual({ success: true });
});
});
});

View file

@ -59,26 +59,27 @@
{#if isOnline}
Du wirst gleich weitergeleitet...
{:else}
Storage benötigt eine Internetverbindung für Cloud-Dateien.
Storage benötigt eine Internetverbindung für Cloud-Dateien. Kürzlich besuchte Seiten sind
möglicherweise noch verfügbar.
{/if}
</p>
{#if !isOnline}
<div class="space-y-4">
<a
href="/"
class="inline-flex items-center justify-center px-6 py-3 bg-slate-600 hover:bg-slate-700 text-white font-medium rounded-lg transition-colors"
>
<svg class="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6"
/>
</svg>
Zur Startseite
</a>
<div class="flex flex-col gap-3 mb-4">
<a
href="/files"
class="inline-flex items-center justify-center px-6 py-3 bg-slate-700 hover:bg-slate-600 text-white font-medium rounded-lg transition-colors"
>
Meine Dateien (cached)
</a>
<a
href="/favorites"
class="inline-flex items-center justify-center px-6 py-3 bg-slate-700 hover:bg-slate-600 text-white font-medium rounded-lg transition-colors"
>
Favoriten (cached)
</a>
</div>
<button
onclick={() => window.location.reload()}

View file

@ -14,8 +14,29 @@ export default defineConfig({
createPWAConfig({
name: 'Storage - Cloud Speicher',
shortName: 'Storage',
description: 'Cloud-Dateispeicher',
description: 'Cloud-Dateispeicher mit Offline-Unterstützung',
themeColor: '#64748b',
preset: 'full',
shortcuts: [
{
name: 'Meine Dateien',
short_name: 'Dateien',
description: 'Dateien und Ordner öffnen',
url: '/files',
},
{
name: 'Suche',
short_name: 'Suche',
description: 'Dateien durchsuchen',
url: '/search',
},
{
name: 'Favoriten',
short_name: 'Favoriten',
description: 'Favorisierte Dateien anzeigen',
url: '/favorites',
},
],
})
),
],

View file

@ -82,6 +82,17 @@ contacts-api.mana.how {
reverse_proxy localhost:3034
}
# ============================================
# Storage App
# ============================================
storage.mana.how {
reverse_proxy localhost:5015
}
storage-api.mana.how {
reverse_proxy localhost:3035
}
# ============================================
# Skilltree App
# ============================================
@ -104,6 +115,17 @@ lightwrite-api.mana.how {
reverse_proxy localhost:3010
}
# ============================================
# Picture App
# ============================================
picture.mana.how {
reverse_proxy localhost:5021
}
picture-api.mana.how {
reverse_proxy localhost:3040
}
# ============================================
# LLM Playground
# ============================================