Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions migrations/20260527141812-add-photos-bucket-to-users.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
'use strict';

module.exports = {
up: async (queryInterface, Sequelize) => {
await queryInterface.addColumn('users', 'photos_bucket', {
type: Sequelize.STRING,
allowNull: true,
Comment thread
sg-gs marked this conversation as resolved.
});
},

down: async (queryInterface) => {
await queryInterface.removeColumn('users', 'photos_bucket');
},
};
15 changes: 15 additions & 0 deletions migrations/20260527141826-recreate-index-folders-bucket.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
'use strict';

module.exports = {
async up(queryInterface) {
await queryInterface.sequelize.query(
`CREATE INDEX CONCURRENTLY IF NOT EXISTS bucket_index ON folders (bucket) WHERE deleted = false AND removed = false AND parent_id IS NULL`,
);
},

async down(queryInterface) {
await queryInterface.sequelize.query(
`DROP INDEX CONCURRENTLY IF EXISTS bucket_index`,
);
},
};
9 changes: 2 additions & 7 deletions src/modules/auth/conditional-captcha.guard.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -197,10 +197,5 @@ const createMockExecutionContext = (request: Partial<any>): ExecutionContext =>
}),
}) as unknown as ExecutionContext;

const buildUserWithErrorLoginCount = (count: number) => ({
...newUser(),
errorLoginCount: count,
isGuestOnSharedWorkspace: () => false,
toJSON: undefined,
hasBackupsEnabled: () => false,
});
const buildUserWithErrorLoginCount = (count: number) =>
newUser({ attributes: { errorLoginCount: count } });
3 changes: 2 additions & 1 deletion src/modules/backups/backup.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import { UserNotificationTokensModel } from '../user/user-notification-tokens.mo
import { ShareModule } from '../share/share.module';
import { ThumbnailModule } from '../thumbnail/thumbnail.module';
import { ThumbnailModel } from '../thumbnail/thumbnail.model';
import { PhotosController } from './photos/photos.controller';

@Module({
imports: [
Expand All @@ -40,7 +41,7 @@ import { ThumbnailModel } from '../thumbnail/thumbnail.model';
BridgeModule,
NotificationModule,
],
controllers: [BackupController],
controllers: [BackupController, PhotosController],
providers: [
SequelizeBackupRepository,
BackupUseCase,
Expand Down
260 changes: 260 additions & 0 deletions src/modules/backups/backup.usecase.spec.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { newDevice, newFolder, newUser } from './../../../test/fixtures';
import { v4 } from 'uuid';
import { Test, type TestingModule } from '@nestjs/testing';
import { createMock } from '@golevelup/ts-jest';
import { BackupUseCase } from './backup.usecase';
Expand Down Expand Up @@ -953,6 +954,265 @@ describe('BackupUseCase', () => {
});
});

describe('activatePhotos', () => {
it('When photos bucket already exists, then it should return it', async () => {
const existingBucket = v4();
const user = newUser({ attributes: { photosBucket: existingBucket } });
const result = await backupUseCase.activatePhotos(user);
expect(result).toEqual({ photosBucket: existingBucket });
});

it('When photos bucket does not exist, then it should create one', async () => {
const newBucket = v4();
const user = newUser({ attributes: { photosBucket: null } });
jest
.spyOn(bridgeService, 'createBucket')
.mockResolvedValue({ id: newBucket } as any);

const result = await backupUseCase.activatePhotos(user);
expect(result).toEqual({ photosBucket: newBucket });
});
});

describe('createPhotoDeviceAsFolder', () => {
const userWithPhotos = newUser({
attributes: { photosBucket: v4() },
});

it('When folder with same name exists, then it should throw ConflictException', async () => {
jest.spyOn(folderRepository, 'findOne').mockResolvedValue(newFolder());

await expect(
backupUseCase.createPhotoDeviceAsFolder(userWithPhotos, 'My Phone'),
).rejects.toThrow(ConflictException);
});

it('When no folder with same name exists, then it should create the folder', async () => {
const mockFolder = newFolder();
jest.spyOn(folderRepository, 'findOne').mockResolvedValue(null);
jest
.spyOn(folderRepository, 'createFolder')
.mockResolvedValue(mockFolder);
jest.spyOn(backupUseCase, 'isFolderEmpty').mockResolvedValue(true);

const result = await backupUseCase.createPhotoDeviceAsFolder(
userWithPhotos,
'My Phone',
);
expect(result).toEqual(newBackupFolder(mockFolder));
});

it('When user has no photos bucket, then it should activate photos first', async () => {
const userWithoutPhotos = newUser({ attributes: { photosBucket: null } });
const mockFolder = newFolder();

jest
.spyOn(backupUseCase, 'activatePhotos')
.mockResolvedValue({ photosBucket: v4() });
jest.spyOn(folderRepository, 'findOne').mockResolvedValue(null);
jest
.spyOn(folderRepository, 'createFolder')
.mockResolvedValue(mockFolder);
jest.spyOn(backupUseCase, 'isFolderEmpty').mockResolvedValue(true);

await backupUseCase.createPhotoDeviceAsFolder(userWithoutPhotos, 'Phone');

expect(backupUseCase.activatePhotos).toHaveBeenCalledWith(
userWithoutPhotos,
);
});
});

describe('getPhotoDevicesAsFolder', () => {
const userWithPhotos = newUser({
attributes: { photosBucket: v4() },
});

it('When photos are not activated, then it should throw BadRequestException', async () => {
const userWithoutPhotos = newUser({ attributes: { photosBucket: null } });

await expect(
backupUseCase.getPhotoDevicesAsFolder(userWithoutPhotos),
).rejects.toThrow(BadRequestException);
});

it('When photos are activated, then it should return all photo devices as folders', async () => {
const mockFolder = newFolder();
jest
.spyOn(folderUseCases, 'getFoldersByUserId')
.mockResolvedValue([mockFolder]);
jest.spyOn(backupUseCase, 'isFolderEmpty').mockResolvedValue(true);

const result =
await backupUseCase.getPhotoDevicesAsFolder(userWithPhotos);

expect(result[0]).toEqual({
...newBackupFolder(mockFolder),
plainName: mockFolder.plainName,
});
});

it('When folder has no plainName, then it should decrypt using bucket', async () => {
const mockFolder = newFolder({ attributes: { plainName: null } });
jest
.spyOn(folderUseCases, 'getFoldersByUserId')
.mockResolvedValue([mockFolder]);
jest
.spyOn(cryptoService, 'decryptName')
.mockReturnValueOnce('Decrypted Phone');

const result =
await backupUseCase.getPhotoDevicesAsFolder(userWithPhotos);

expect(cryptoService.decryptName).toHaveBeenCalledWith(
mockFolder.name,
mockFolder.bucket,
);
expect(result[0].plainName).toBe('Decrypted Phone');
});
});

describe('getPhotoDeviceAsFolder', () => {
const userWithPhotos = newUser({
attributes: { photosBucket: v4() },
});

it('When folder does not exist, then it should throw NotFoundException', async () => {
jest.spyOn(folderUseCases, 'getFolderByUuid').mockResolvedValue(null);

await expect(
backupUseCase.getPhotoDeviceAsFolder(userWithPhotos, 'folder-uuid'),
).rejects.toThrow(NotFoundException);
});

it('When folder is not in the photos bucket, then it should throw', async () => {
const mockFolder = newFolder({ attributes: { bucket: v4() } });
jest
.spyOn(folderUseCases, 'getFolderByUuid')
.mockResolvedValue(mockFolder);

await expect(
backupUseCase.getPhotoDeviceAsFolder(userWithPhotos, 'folder-uuid'),
).rejects.toThrow(BadRequestException);
});

it('When folder exists and is in the photos bucket, then it should return it', async () => {
const mockFolder = newFolder({
attributes: { bucket: userWithPhotos.photosBucket },
});
jest
.spyOn(folderUseCases, 'getFolderByUuid')
.mockResolvedValue(mockFolder);
jest.spyOn(backupUseCase, 'isFolderEmpty').mockResolvedValue(true);

const result = await backupUseCase.getPhotoDeviceAsFolder(
userWithPhotos,
'folder-uuid',
);
expect(result).toEqual(newBackupFolder(mockFolder));
});
});

describe('deletePhotoDeviceAsFolder', () => {
const userWithPhotos = newUser({
attributes: { photosBucket: v4() },
});

it('When folder does not exist, then it should throw NotFoundException', async () => {
jest.spyOn(folderUseCases, 'getFolderByUuid').mockResolvedValue(null);

await expect(
backupUseCase.deletePhotoDeviceAsFolder(userWithPhotos, 'folder-uuid'),
).rejects.toThrow(NotFoundException);
});

it('When folder is not in the photos bucket, then it should throw BadRequestException', async () => {
const mockFolder = newFolder({ attributes: { bucket: 'other-bucket' } });
jest
.spyOn(folderUseCases, 'getFolderByUuid')
.mockResolvedValue(mockFolder);

await expect(
backupUseCase.deletePhotoDeviceAsFolder(userWithPhotos, 'folder-uuid'),
).rejects.toThrow(BadRequestException);
});

it('When folder is in the photos bucket, then it should delete it', async () => {
const mockFolder = newFolder({
attributes: { bucket: userWithPhotos.photosBucket },
});
jest
.spyOn(folderUseCases, 'getFolderByUuid')
.mockResolvedValue(mockFolder);
jest.spyOn(folderUseCases, 'deleteByUser').mockResolvedValue(undefined);

await backupUseCase.deletePhotoDeviceAsFolder(
userWithPhotos,
'folder-uuid',
);

expect(folderUseCases.deleteByUser).toHaveBeenCalledWith(userWithPhotos, [
mockFolder,
]);
});
});

describe('updatePhotoDeviceAsFolder', () => {
const userWithPhotos = newUser({
attributes: { photosBucket: v4() },
});

it('When folder does not exist, then it should throw NotFoundException', async () => {
jest.spyOn(folderUseCases, 'getFolderByUuid').mockResolvedValue(null);

await expect(
backupUseCase.updatePhotoDeviceAsFolder(
userWithPhotos,
'folder-uuid',
'New Name',
),
).rejects.toThrow(NotFoundException);
});

it('When folder is not in the photos bucket, then it should throw', async () => {
const mockFolder = newFolder({ attributes: { bucket: v4() } });
jest
.spyOn(folderUseCases, 'getFolderByUuid')
.mockResolvedValue(mockFolder);

await expect(
backupUseCase.updatePhotoDeviceAsFolder(
userWithPhotos,
'folder-uuid',
'New Name',
),
).rejects.toThrow(BadRequestException);
});

it('When folder exists and is in the photos bucket, then it should update and return it', async () => {
const mockFolder = newFolder({
attributes: { bucket: userWithPhotos.photosBucket },
});
const updatedFolder = newFolder({
attributes: { ...mockFolder, plainName: 'New Name' },
});
jest
.spyOn(folderUseCases, 'getFolderByUuid')
.mockResolvedValue(mockFolder);
jest
.spyOn(folderUseCases, 'updateByFolderIdAndForceUpdatedAt')
.mockResolvedValue(updatedFolder);
jest.spyOn(backupUseCase, 'isFolderEmpty').mockResolvedValue(true);

const result = await backupUseCase.updatePhotoDeviceAsFolder(
userWithPhotos,
'folder-uuid',
'New Name',
);
expect(result).toEqual(newBackupFolder(updatedFolder));
});
});

describe('updateDeviceAndFolderName', () => {
const updateDeviceDto = { name: 'New Device Name' };

Expand Down
Loading
Loading