Skip to content

Commit

Permalink
PB-845: Calculate folder size and expose it via shared & Drive
Browse files Browse the repository at this point in the history
  • Loading branch information
edisonjpadilla committed Jan 16, 2024
1 parent e027d9f commit 9775e53
Show file tree
Hide file tree
Showing 12 changed files with 276 additions and 36 deletions.
20 changes: 3 additions & 17 deletions src/common/base-http.exception.ts
Original file line number Diff line number Diff line change
@@ -1,25 +1,11 @@
import { HttpStatus } from '@nestjs/common';

export class BaseHttpException extends Error {
private readonly _statusCode: number;
private readonly _code: string;

constructor(
message: string,
statusCode = HttpStatus.INTERNAL_SERVER_ERROR,
code?: string,
public readonly message: string,
public readonly statusCode = HttpStatus.INTERNAL_SERVER_ERROR,
public readonly code?: string,
) {
super(message);
this.message = message;
this._statusCode = statusCode;
this._code = code;
}

get statusCode(): number {
return this._statusCode;
}

get code(): string {
return this._code;
}
}
6 changes: 2 additions & 4 deletions src/modules/file/file.usecase.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,10 +15,8 @@ import {
SequelizeShareRepository,
ShareModel,
} from '../share/share.repository';
import {
FolderModel,
SequelizeFolderRepository,
} from '../folder/folder.repository';
import { SequelizeFolderRepository } from '../folder/folder.repository';
import { FolderModel } from '../folder/folder.model';
import { SequelizeUserRepository, UserModel } from '../user/user.repository';
import { BridgeModule } from '../../externals/bridge/bridge.module';
import { BridgeService } from '../../externals/bridge/bridge.service';
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { HttpStatus } from '@nestjs/common';
import { BaseHttpException } from '../../../common/base-http.exception';

export class CalculateFolderSizeTimeoutException extends BaseHttpException {
constructor(
message = 'Calculate folder size timeout',
code = 'CALCULATE_FOLDER_SIZE_TIMEOUT',
statusCode = HttpStatus.UNPROCESSABLE_ENTITY,
) {
super(message, statusCode, code);
}
}
50 changes: 50 additions & 0 deletions src/modules/folder/folder.controller.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import { createMock } from '@golevelup/ts-jest';
import { Test, TestingModule } from '@nestjs/testing';
import { newFolder } from '../../../test/fixtures';
import { FileUseCases } from '../file/file.usecase';
import { FolderController } from './folder.controller';
import { Folder } from './folder.domain';
import { FolderUseCases } from './folder.usecase';
import { CalculateFolderSizeTimeoutException } from './exception/calculate-folder-size-timeout.exception';

describe('FolderController', () => {
let folderController: FolderController;
let folderUseCases: FolderUseCases;
let folder: Folder;

beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
controllers: [FolderController],
providers: [
{ provide: FolderUseCases, useValue: createMock() },
{ provide: FileUseCases, useValue: createMock() },
],
}).compile();

folderController = module.get<FolderController>(FolderController);
folderUseCases = module.get<FolderUseCases>(FolderUseCases);
folder = newFolder();
});

describe('get folder size', () => {
it('When get folder size is requested, then return the folder size', async () => {
const expectedSize = 100;
jest
.spyOn(folderUseCases, 'getFolderSizeByUuid')
.mockResolvedValue(expectedSize);

const result = await folderController.getFolderSize(folder.uuid);
expect(result).toEqual({ size: expectedSize });
});

it('When get folder size times out, then throw an exception', async () => {
jest
.spyOn(folderUseCases, 'getFolderSizeByUuid')
.mockRejectedValue(new CalculateFolderSizeTimeoutException());

await expect(folderController.getFolderSize(folder.uuid)).rejects.toThrow(
CalculateFolderSizeTimeoutException,
);
});
});
});
12 changes: 11 additions & 1 deletion src/modules/folder/folder.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
NotImplementedException,
Param,
Query,
UseFilters,
} from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger';
import { FolderUseCases } from './folder.usecase';
Expand All @@ -19,6 +20,7 @@ import { Folder, SortableFolderAttributes } from './folder.domain';
import { FileStatus, SortableFileAttributes } from '../file/file.domain';
import logger from '../../externals/logger';
import { validate } from 'uuid';
import { HttpExceptionFilter } from '../../lib/http/http-exception.filter';

const foldersStatuses = ['ALL', 'EXISTS', 'TRASHED', 'DELETED'] as const;

Expand Down Expand Up @@ -255,7 +257,7 @@ export class FolderController {
@UserDecorator() user: User,
@Query('limit') limit: number,
@Query('offset') offset: number,
@Query('status') status: typeof foldersStatuses[number],
@Query('status') status: (typeof foldersStatuses)[number],
@Query('updatedAt') updatedAt?: string,
) {
if (!status) {
Expand Down Expand Up @@ -418,4 +420,12 @@ export class FolderController {
});
}
}

@UseFilters(new HttpExceptionFilter())
@Get(':uuid/size')
async getFolderSize(@Param('uuid') folderUuid: Folder['uuid']) {
const size = await this.folderUseCases.getFolderSizeByUuid(folderUuid);

return { size };
}
}
95 changes: 95 additions & 0 deletions src/modules/folder/folder.repository.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
import { createMock } from '@golevelup/ts-jest';
import { CalculateFolderSizeTimeoutException } from './exception/calculate-folder-size-timeout.exception';
import { SequelizeFolderRepository } from './folder.repository';
import { FolderModel } from './folder.model';
import { Folder } from './folder.domain';
import { newFolder } from '../../../test/fixtures';

jest.mock('./folder.model', () => ({
FolderModel: {
sequelize: {
query: jest.fn(() => Promise.resolve([[{ totalsize: 100 }]])),
},
},
}));

describe('SequelizeFolderRepository', () => {
const TIMEOUT_SECONDS = 15;
const TIMEOUT_ERROR_CODE = '57014';

let repository: SequelizeFolderRepository;
let folderModel: typeof FolderModel;
let folder: Folder;

beforeEach(async () => {
folderModel = createMock<typeof FolderModel>();

repository = new SequelizeFolderRepository(folderModel);

folder = newFolder();
});

describe('calculate folder size', () => {
it('When calculate folder size is requested, then it works', async () => {
const calculateSizeQuery = `
BEGIN;
SET LOCAL statement_timeout TO :timeoutSeconds;
WITH RECURSIVE folder_recursive AS (
SELECT
fl1.uuid,
fl1.parent_uuid,
f1.size AS filesize
FROM folders fl1
LEFT JOIN files f1 ON f1.folder_uuid = fl1.uuid
WHERE fl1.uuid = :folderUuid
UNION ALL
SELECT
fl2.uuid,
fl2.parent_uuid,
f2.size AS filesize
FROM folders fl2
INNER JOIN files f2 ON f2.folder_uuid = fl2.uuid
INNER JOIN folder_recursive fr ON fr.uuid = fl2.parent_uuid
)
SELECT COALESCE(SUM(filesize), 0) AS total_size FROM folder_recursive;
COMMIT;
`;

jest
.spyOn(FolderModel.sequelize, 'query')
.mockResolvedValue([[{ totalsize: 100 }]] as any);

const size = await repository.calculateFolderSize(
folder.uuid,
TIMEOUT_SECONDS,
);

expect(size).toBeGreaterThanOrEqual(0);
expect(FolderModel.sequelize.query).toHaveBeenCalledWith(
calculateSizeQuery,
{
replacements: {
folderUuid: folder.uuid,
timeoutSeconds: TIMEOUT_SECONDS * 1000,
},
},
);
});

it('When the folder size calculation times out, then throw an exception', async () => {
jest.spyOn(FolderModel.sequelize, 'query').mockRejectedValue({
original: {
code: TIMEOUT_ERROR_CODE,
},
});

await expect(
repository.calculateFolderSize(folder.uuid, TIMEOUT_SECONDS),
).rejects.toThrow(CalculateFolderSizeTimeoutException);
});
});
});
62 changes: 58 additions & 4 deletions src/modules/folder/folder.repository.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,12 @@ import { v4 } from 'uuid';
import { Folder } from './folder.domain';
import { FolderAttributes } from './folder.attributes';

import { UserModel } from '../user/user.model';
import { User } from '../user/user.domain';
import { UserAttributes } from '../user/user.attributes';
import { Pagination } from '../../lib/pagination';
import { FolderModel } from './folder.model';
import { SharingModel } from '../sharing/models';
import { CalculateFolderSizeTimeoutException } from './exception/calculate-folder-size-timeout.exception';

function mapSnakeCaseToCamelCase(data) {
const camelCasedObject = {};
Expand Down Expand Up @@ -59,15 +59,17 @@ export interface FolderRepository {
): Promise<void>;
deleteById(folderId: FolderAttributes['id']): Promise<void>;
clearOrphansFolders(userId: FolderAttributes['userId']): Promise<number>;
calculateFolderSize(
folderUuid: string,
timeoutSeconds: number,
): Promise<number>;
}

@Injectable()
export class SequelizeFolderRepository implements FolderRepository {
constructor(
@InjectModel(FolderModel)
private folderModel: typeof FolderModel,
@InjectModel(UserModel)
private userModel: typeof UserModel,
) {}

async findAllCursor(
Expand Down Expand Up @@ -447,6 +449,59 @@ export class SequelizeFolderRepository implements FolderRepository {
return folders.map((folder) => this.toDomain(folder));
}

async calculateFolderSize(
folderUuid: string,
timeoutSeconds = 15,
): Promise<number> {
try {
const calculateSizeQuery = `
BEGIN;
SET LOCAL statement_timeout TO :timeoutSeconds;
WITH RECURSIVE folder_recursive AS (
SELECT
fl1.uuid,
fl1.parent_uuid,
f1.size AS filesize
FROM folders fl1
LEFT JOIN files f1 ON f1.folder_uuid = fl1.uuid
WHERE fl1.uuid = :folderUuid
UNION ALL
SELECT
fl2.uuid,
fl2.parent_uuid,
f2.size AS filesize
FROM folders fl2
INNER JOIN files f2 ON f2.folder_uuid = fl2.uuid
INNER JOIN folder_recursive fr ON fr.uuid = fl2.parent_uuid
)
SELECT COALESCE(SUM(filesize), 0) AS total_size FROM folder_recursive;
COMMIT;
`;

const [[{ totalsize }]]: any = await FolderModel.sequelize.query(
calculateSizeQuery,
{
replacements: {
folderUuid,
timeoutSeconds: timeoutSeconds * 1000,
},
},
);

return +totalsize;
} catch (error) {
if (error.original?.code === '57014') {
throw new CalculateFolderSizeTimeoutException();
}

throw error;
}
}

private toDomain(model: FolderModel): Folder {
return Folder.build({
...model.toJSON(),
Expand All @@ -459,4 +514,3 @@ export class SequelizeFolderRepository implements FolderRepository {
return domain.toJSON();
}
}
export { FolderModel };
32 changes: 32 additions & 0 deletions src/modules/folder/folder.usecase.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@ import { FolderModel } from './folder.model';
import { FileModel } from '../file/file.model';
import { SequelizeThumbnailRepository } from '../thumbnail/thumbnail.repository';
import { ThumbnailModel } from '../thumbnail/thumbnail.model';
import { CalculateFolderSizeTimeoutException } from './exception/calculate-folder-size-timeout.exception';
import { newFolder } from '../../../test/fixtures';

const folderId = 4;
const userId = 1;
Expand Down Expand Up @@ -492,4 +494,34 @@ describe('FolderUseCases', () => {
}
});
});

describe('get folder size', () => {
const folder = newFolder();

it('When the folder size is requested to be calculated, then it works', async () => {
const mockSize = 123456789;

jest
.spyOn(folderRepository, 'calculateFolderSize')
.mockResolvedValueOnce(mockSize);

const result = await service.getFolderSizeByUuid(folder.uuid);

expect(result).toBe(mockSize);
expect(folderRepository.calculateFolderSize).toHaveBeenCalledTimes(1);
expect(folderRepository.calculateFolderSize).toHaveBeenCalledWith(
folder.uuid,
);
});

it('When the folder size times out, then throw an exception', async () => {
jest
.spyOn(folderRepository, 'calculateFolderSize')
.mockRejectedValueOnce(new CalculateFolderSizeTimeoutException());

await expect(service.getFolderSizeByUuid(folder.uuid)).rejects.toThrow(
CalculateFolderSizeTimeoutException,
);
});
});
});
Loading

0 comments on commit 9775e53

Please sign in to comment.