From 1ca7a1d9373880b827384743f2229616ec01d4a1 Mon Sep 17 00:00:00 2001 From: Andres <143480783+apsantiso@users.noreply.github.com> Date: Tue, 21 Apr 2026 12:11:26 +0200 Subject: [PATCH 1/2] [_] fix/reduce stats limit (#1044) * chore: increase limit folder stats * reduce limit to 1000 --- ...28065637-add-max-upload-file-size-limit.js | 63 ++++++++ .../cache-manager/cache-manager.service.ts | 12 ++ .../exceptions/payment-required.exception.ts | 15 +- .../feature-limit/feature-limit.module.ts | 2 + .../feature-limit.service.spec.ts | 152 ++++++++++++++++++ .../feature-limit/feature-limit.service.ts | 54 +++++++ src/modules/feature-limit/limits.enum.ts | 1 + src/modules/file/file.usecase.ts | 10 ++ src/modules/folder/folder.repository.spec.ts | 40 ++--- src/modules/folder/folder.repository.ts | 80 +++++---- 10 files changed, 357 insertions(+), 72 deletions(-) create mode 100644 migrations/20260428065637-add-max-upload-file-size-limit.js diff --git a/migrations/20260428065637-add-max-upload-file-size-limit.js b/migrations/20260428065637-add-max-upload-file-size-limit.js new file mode 100644 index 000000000..e828f1b81 --- /dev/null +++ b/migrations/20260428065637-add-max-upload-file-size-limit.js @@ -0,0 +1,63 @@ +'use strict'; + +const { v4 } = require('uuid'); + +const LIMIT_LABEL = 'max-upload-file-size'; + +const MB = 1024 * 1024; +const GB = 1024 * MB; + +/** @type {import('sequelize-cli').Migration} */ +module.exports = { + async up(queryInterface) { + await queryInterface.bulkInsert('limits', [ + { + id: v4(), + label: LIMIT_LABEL, + type: 'counter', + value: String(100 * MB), + created_at: new Date(), + updated_at: new Date(), + }, + { + id: v4(), + label: LIMIT_LABEL, + type: 'counter', + value: String(1 * GB), + created_at: new Date(), + updated_at: new Date(), + }, + { + id: v4(), + label: LIMIT_LABEL, + type: 'counter', + value: String(10 * GB), + created_at: new Date(), + updated_at: new Date(), + }, + { + id: v4(), + label: LIMIT_LABEL, + type: 'counter', + value: String(50 * GB), + created_at: new Date(), + updated_at: new Date(), + }, + { + id: v4(), + label: LIMIT_LABEL, + type: 'counter', + value: String(100 * GB), + created_at: new Date(), + updated_at: new Date(), + }, + ]); + }, + + async down(queryInterface) { + await queryInterface.sequelize.query( + `DELETE FROM limits WHERE label = :limitLabel`, + { replacements: { limitLabel: LIMIT_LABEL } }, + ); + }, +}; diff --git a/src/modules/cache-manager/cache-manager.service.ts b/src/modules/cache-manager/cache-manager.service.ts index ea4bdecf1..d8dff6898 100644 --- a/src/modules/cache-manager/cache-manager.service.ts +++ b/src/modules/cache-manager/cache-manager.service.ts @@ -101,6 +101,18 @@ export class CacheManagerService { return this.cacheManager.del(`${this.AVATAR_KEY_PREFIX}${userUuid}`); } + async getTierLimit(tierId: string, label: string) { + return this.cacheManager.get(`tier-limit:${tierId}:${label}`); + } + + async setTierLimit(tierId: string, label: string, value: string) { + await this.cacheManager.set( + `tier-limit:${tierId}:${label}`, + value, + this.TTL_10_MINUTES, + ); + } + async checkHealth(): Promise { const key = 'health_check'; const value = Date.now().toString(); diff --git a/src/modules/feature-limit/exceptions/payment-required.exception.ts b/src/modules/feature-limit/exceptions/payment-required.exception.ts index 4cae9c2de..104d60ea8 100644 --- a/src/modules/feature-limit/exceptions/payment-required.exception.ts +++ b/src/modules/feature-limit/exceptions/payment-required.exception.ts @@ -1,10 +1,19 @@ import { HttpException, HttpStatus } from '@nestjs/common'; +export enum PaymentRequiredErrorCode { + FileUploadSizeExceeded = 'FILE_UPLOAD_SIZE_EXCEEDED', + FeatureNotAvailable = 'FEATURE_NOT_AVAILABLE', +} + export class PaymentRequiredException extends HttpException { - constructor(message?: string) { + constructor(message?: string, code?: PaymentRequiredErrorCode) { super( - message ?? - 'It seems you reached the limit or feature is not available for your current plan tier', + { + message: + message ?? + 'It seems you reached the limit or feature is not available for your current plan tier', + ...(code ? { error: code } : {}), + }, HttpStatus.PAYMENT_REQUIRED, ); } diff --git a/src/modules/feature-limit/feature-limit.module.ts b/src/modules/feature-limit/feature-limit.module.ts index 6d84b0719..992b27460 100644 --- a/src/modules/feature-limit/feature-limit.module.ts +++ b/src/modules/feature-limit/feature-limit.module.ts @@ -16,6 +16,7 @@ import { PaidPlansModel } from './models/paid-plans.model'; import { PaymentsService } from '../../externals/payments/payments.service'; import { FeatureLimitService } from './feature-limit.service'; import { WorkspacesModule } from '../workspaces/workspaces.module'; +import { CacheManagerModule } from '../cache-manager/cache-manager.module'; @Module({ imports: [ @@ -30,6 +31,7 @@ import { WorkspacesModule } from '../workspaces/workspaces.module'; forwardRef(() => SharingModule), forwardRef(() => UserModule), WorkspacesModule, + CacheManagerModule, ], providers: [ SequelizeFeatureLimitsRepository, diff --git a/src/modules/feature-limit/feature-limit.service.spec.ts b/src/modules/feature-limit/feature-limit.service.spec.ts index ea94d9996..dbdff4f54 100644 --- a/src/modules/feature-limit/feature-limit.service.spec.ts +++ b/src/modules/feature-limit/feature-limit.service.spec.ts @@ -14,12 +14,15 @@ import { } from '../../../test/fixtures'; import { SequelizeWorkspaceRepository } from '../workspaces/repositories/workspaces.repository'; import { SequelizeUserRepository } from '../user/user.repository'; +import { CacheManagerService } from '../cache-manager/cache-manager.service'; +import { PaymentRequiredException } from './exceptions/payment-required.exception'; describe('FeatureLimitService', () => { let service: FeatureLimitService; let limitsRepository: DeepMocked; let workspaceRepository: DeepMocked; let userRepository: DeepMocked; + let cacheManagerService: DeepMocked; beforeEach(async () => { const moduleRef = await Test.createTestingModule({ @@ -33,6 +36,7 @@ describe('FeatureLimitService', () => { limitsRepository = moduleRef.get(SequelizeFeatureLimitsRepository); workspaceRepository = moduleRef.get(SequelizeWorkspaceRepository); userRepository = moduleRef.get(SequelizeUserRepository); + cacheManagerService = moduleRef.get(CacheManagerService); }); describe('canUserAccessPlatform', () => { @@ -370,6 +374,154 @@ describe('FeatureLimitService', () => { }); }); + describe('enforceMaxUploadFileSize', () => { + const MB = 1024 * 1024; + const GB = 1024 * MB; + + it('When user has no limit configured, then it should allow the upload', async () => { + const user = newUser({ attributes: { tierId: v4() } }); + + limitsRepository.findUserOverriddenLimit.mockResolvedValueOnce(null); + cacheManagerService.getTierLimit.mockResolvedValueOnce(null); + limitsRepository.findLimitByLabelAndTier.mockResolvedValueOnce(null); + + await expect( + service.enforceMaxUploadFileSize(user, BigInt(500 * MB)), + ).resolves.not.toThrow(); + }); + + it('When file size is under tier limit, then it should allow the upload', async () => { + const user = newUser({ attributes: { tierId: v4() } }); + const limit = newFeatureLimit({ + type: LimitTypes.Counter, + label: LimitLabels.MaxUploadFileSize, + value: String(100 * MB), + }); + + limitsRepository.findUserOverriddenLimit.mockResolvedValueOnce(null); + cacheManagerService.getTierLimit.mockResolvedValueOnce(null); + limitsRepository.findLimitByLabelAndTier.mockResolvedValueOnce(limit); + + await expect( + service.enforceMaxUploadFileSize(user, BigInt(50 * MB)), + ).resolves.not.toThrow(); + }); + + it('When file size exceeds tier limit, then it should throw', async () => { + const user = newUser({ attributes: { tierId: v4() } }); + const limit = newFeatureLimit({ + type: LimitTypes.Counter, + label: LimitLabels.MaxUploadFileSize, + value: String(100 * MB), + }); + + limitsRepository.findUserOverriddenLimit.mockResolvedValueOnce(null); + cacheManagerService.getTierLimit.mockResolvedValueOnce(null); + limitsRepository.findLimitByLabelAndTier.mockResolvedValueOnce(limit); + + await expect( + service.enforceMaxUploadFileSize(user, BigInt(200 * MB)), + ).rejects.toThrow(PaymentRequiredException); + }); + + it('When user has an overridden limit, then it should use it instead of tier limit', async () => { + const user = newUser({ attributes: { tierId: v4() } }); + const override = newFeatureLimit({ + type: LimitTypes.Counter, + label: LimitLabels.MaxUploadFileSize, + value: String(10 * GB), + }); + + limitsRepository.findUserOverriddenLimit.mockResolvedValueOnce(override); + + await expect( + service.enforceMaxUploadFileSize(user, BigInt(5 * GB)), + ).resolves.not.toThrow(); + expect(cacheManagerService.getTierLimit).not.toHaveBeenCalled(); + expect(limitsRepository.findLimitByLabelAndTier).not.toHaveBeenCalled(); + }); + + it('When user overridden limit is exceeded, then it should throw', async () => { + const user = newUser({ attributes: { tierId: v4() } }); + const override = newFeatureLimit({ + type: LimitTypes.Counter, + label: LimitLabels.MaxUploadFileSize, + value: String(100 * MB), + }); + + limitsRepository.findUserOverriddenLimit.mockResolvedValueOnce(override); + + await expect( + service.enforceMaxUploadFileSize(user, BigInt(200 * MB)), + ).rejects.toThrow(PaymentRequiredException); + }); + + it('When tier limit is cached, then it should not hit the DB', async () => { + const user = newUser({ attributes: { tierId: v4() } }); + + limitsRepository.findUserOverriddenLimit.mockResolvedValueOnce(null); + cacheManagerService.getTierLimit.mockResolvedValueOnce(String(1 * GB)); + + await expect( + service.enforceMaxUploadFileSize(user, BigInt(500 * MB)), + ).resolves.not.toThrow(); + expect(limitsRepository.findLimitByLabelAndTier).not.toHaveBeenCalled(); + }); + + it('When tier limit is cached and exceeded, then it should throw without hitting DB', async () => { + const user = newUser({ attributes: { tierId: v4() } }); + + limitsRepository.findUserOverriddenLimit.mockResolvedValueOnce(null); + cacheManagerService.getTierLimit.mockResolvedValueOnce(String(100 * MB)); + + await expect( + service.enforceMaxUploadFileSize(user, BigInt(200 * MB)), + ).rejects.toThrow(PaymentRequiredException); + expect(limitsRepository.findLimitByLabelAndTier).not.toHaveBeenCalled(); + }); + + it('When cache miss occurs, then it should populate the cache from DB', async () => { + const user = newUser({ attributes: { tierId: v4() } }); + const limit = newFeatureLimit({ + type: LimitTypes.Counter, + label: LimitLabels.MaxUploadFileSize, + value: String(1 * GB), + }); + + limitsRepository.findUserOverriddenLimit.mockResolvedValueOnce(null); + cacheManagerService.getTierLimit.mockResolvedValueOnce(null); + limitsRepository.findLimitByLabelAndTier.mockResolvedValueOnce(limit); + + await service.enforceMaxUploadFileSize(user, BigInt(500 * MB)); + + expect(cacheManagerService.setTierLimit).toHaveBeenCalledWith( + user.tierId, + LimitLabels.MaxUploadFileSize, + limit.value, + ); + }); + + it('When cache write fails, then it should still allow the upload', async () => { + const user = newUser({ attributes: { tierId: v4() } }); + const limit = newFeatureLimit({ + type: LimitTypes.Counter, + label: LimitLabels.MaxUploadFileSize, + value: String(1 * GB), + }); + + limitsRepository.findUserOverriddenLimit.mockResolvedValueOnce(null); + cacheManagerService.getTierLimit.mockResolvedValueOnce(null); + limitsRepository.findLimitByLabelAndTier.mockResolvedValueOnce(limit); + cacheManagerService.setTierLimit.mockRejectedValueOnce( + new Error('Redis unavailable'), + ); + + await expect( + service.enforceMaxUploadFileSize(user, BigInt(500 * MB)), + ).resolves.not.toThrow(); + }); + }); + describe('getTier', () => { it('When tier exists, then it should return the tier', async () => { const tierId = v4(); diff --git a/src/modules/feature-limit/feature-limit.service.ts b/src/modules/feature-limit/feature-limit.service.ts index 1c8a4f39b..f7b700cc2 100644 --- a/src/modules/feature-limit/feature-limit.service.ts +++ b/src/modules/feature-limit/feature-limit.service.ts @@ -1,4 +1,8 @@ import { Injectable, Logger, NotFoundException } from '@nestjs/common'; +import { + PaymentRequiredException, + PaymentRequiredErrorCode, +} from './exceptions/payment-required.exception'; import { SequelizeFeatureLimitsRepository } from './feature-limit.repository'; import { LimitLabels } from './limits.enum'; import { PlatformName } from '../../common/constants'; @@ -6,6 +10,7 @@ import { SequelizeWorkspaceRepository } from '../workspaces/repositories/workspa import { SequelizeUserRepository } from '../user/user.repository'; import { type Limit } from './domain/limit.domain'; import { type User } from '../user/user.domain'; +import { CacheManagerService } from '../cache-manager/cache-manager.service'; @Injectable() export class FeatureLimitService { @@ -15,6 +20,7 @@ export class FeatureLimitService { private readonly limitsRepository: SequelizeFeatureLimitsRepository, private readonly workspaceRepository: SequelizeWorkspaceRepository, private readonly userRepository: SequelizeUserRepository, + private readonly cacheManagerService: CacheManagerService, ) {} async canUserAccessPlatform( @@ -170,4 +176,52 @@ export class FeatureLimitService { return userOverriddenLimits ?? tierLimits; } + + async enforceMaxUploadFileSize(user: User, fileSize: bigint): Promise { + const userOverriddenLimit = + await this.limitsRepository.findUserOverriddenLimit( + user.uuid, + LimitLabels.MaxUploadFileSize, + ); + + let limitValue: string | null = null; + + if (userOverriddenLimit) { + limitValue = userOverriddenLimit.value; + } else { + const cached = await this.cacheManagerService.getTierLimit( + user.tierId, + LimitLabels.MaxUploadFileSize, + ); + + if (cached !== null && cached !== undefined) { + limitValue = cached; + } else { + const tierLimit = await this.limitsRepository.findLimitByLabelAndTier( + user.tierId, + LimitLabels.MaxUploadFileSize, + ); + + if (!tierLimit) return; + + limitValue = tierLimit.value; + await this.cacheManagerService + .setTierLimit(user.tierId, LimitLabels.MaxUploadFileSize, limitValue) + .catch((err) => { + this.logger.error( + `Failed to cache tier limit for tierId ${user.tierId}: ${err.message}`, + ); + }); + } + } + + if (limitValue === null) return; + + if (Number(fileSize) > Number(limitValue)) { + throw new PaymentRequiredException( + 'File size exceeds the maximum allowed by your plan', + PaymentRequiredErrorCode.FileUploadSizeExceeded, + ); + } + } } diff --git a/src/modules/feature-limit/limits.enum.ts b/src/modules/feature-limit/limits.enum.ts index a91e71a49..fe2efdad4 100644 --- a/src/modules/feature-limit/limits.enum.ts +++ b/src/modules/feature-limit/limits.enum.ts @@ -11,6 +11,7 @@ export enum LimitLabels { RcloneAccess = 'rclone-access', TrashRetentionDays = 'trash-retention-days', ReferralAccess = 'referral-access', + MaxUploadFileSize = 'max-upload-file-size', } export enum LimitTypes { diff --git a/src/modules/file/file.usecase.ts b/src/modules/file/file.usecase.ts index 6955273de..f96dd3030 100644 --- a/src/modules/file/file.usecase.ts +++ b/src/modules/file/file.usecase.ts @@ -333,6 +333,11 @@ export class FileUseCases { } else { await this.checkEmptyFilesLimit(user); } + } else { + await this.featureLimitService.enforceMaxUploadFileSize( + user, + BigInt(newFileDto.size), + ); } const newFileId = isFileEmpty ? null : newFileDto.fileId; @@ -902,6 +907,11 @@ export class FileUseCases { workspaceOptions.workspace, ); } + } else { + await this.featureLimitService.enforceMaxUploadFileSize( + user, + newFileData.size, + ); } const newFileId = isFileEmpty ? null : newFileData.fileId; diff --git a/src/modules/folder/folder.repository.spec.ts b/src/modules/folder/folder.repository.spec.ts index a555ef67c..c51cbf9ac 100644 --- a/src/modules/folder/folder.repository.spec.ts +++ b/src/modules/folder/folder.repository.spec.ts @@ -1152,7 +1152,7 @@ describe('SequelizeFolderRepository', () => { const mockResult = { file_count: '0', total_size: '0', - total_files_found: '0', + max_depth: null, }; jest @@ -1171,7 +1171,7 @@ describe('SequelizeFolderRepository', () => { const mockResult = { file_count: '500', total_size: '5000000', - total_files_found: '500', + max_depth: '5', }; jest @@ -1190,7 +1190,7 @@ describe('SequelizeFolderRepository', () => { const mockResult = { file_count: '1000', total_size: '10000000', - total_files_found: '1000', + max_depth: '5', }; jest @@ -1205,9 +1205,9 @@ describe('SequelizeFolderRepository', () => { it('When folder exceeds maximum file count, then it should cap count and mark as approximate', async () => { const mockResult = { - file_count: '1500', + file_count: '1001', total_size: '15000000', - total_files_found: '1500', + max_depth: '5', }; jest @@ -1222,9 +1222,9 @@ describe('SequelizeFolderRepository', () => { it('When folder exceeds maximum total items, then it should mark size as approximate', async () => { const mockResult = { - file_count: '12000', + file_count: '1001', total_size: '50000000', - total_files_found: '12000', + max_depth: '5', }; jest @@ -1239,9 +1239,9 @@ describe('SequelizeFolderRepository', () => { it('When folder has deep hierarchy, then it should include files from all nested levels', async () => { const mockResult = { - file_count: '9973', + file_count: '973', total_size: '27634171904', - total_files_found: '9973', + max_depth: '10', }; jest @@ -1250,29 +1250,17 @@ describe('SequelizeFolderRepository', () => { const result = await repository.calculateFolderStats(folder.uuid); - expect(result.fileCount).toBe(1000); + expect(result.fileCount).toBe(973); expect(result.totalSize).toBe(27634171904); - expect(result.isFileCountExact).toBe(false); + expect(result.isFileCountExact).toBe(true); expect(result.isTotalSizeExact).toBe(true); }); - it('When stats calculation times out, then it should throw timeout exception', async () => { - jest.spyOn(FolderModel.sequelize, 'query').mockRejectedValueOnce({ - original: { - code: TIMEOUT_ERROR_CODE, - }, - }); - - await expect( - repository.calculateFolderStats(folder.uuid), - ).rejects.toThrow(CalculateFolderSizeTimeoutException); - }); - it('When folder stats are requested, then only existent files are counted', async () => { const mockResult = { file_count: '100', total_size: '1000000', - total_files_found: '100', + max_depth: '5', }; jest @@ -1286,7 +1274,9 @@ describe('SequelizeFolderRepository', () => { { replacements: { folderUuid: folder.uuid, - fileStatusCondition: [FileStatus.EXISTS], + maxDepth: 50, + fileStatus: FileStatus.EXISTS, + maxFiles: 1001, }, }, ); diff --git a/src/modules/folder/folder.repository.ts b/src/modules/folder/folder.repository.ts index e45a36324..5d4896f0d 100644 --- a/src/modules/folder/folder.repository.ts +++ b/src/modules/folder/folder.repository.ts @@ -939,16 +939,16 @@ export class SequelizeFolderRepository implements FolderRepository { totalSize: number; isTotalSizeExact: boolean; }> { - try { - const fileStatusCondition = [FileStatus.EXISTS]; + const MAX_FILES = 1000; + const MAX_DEPTH = 50; - const calculateStatsQuery = ` + const calculateStatsQuery = ` WITH RECURSIVE folder_recursive AS ( SELECT fl1.uuid, fl1.parent_uuid, - 1 as depth, - fl1.user_id as owner_id + 1 AS depth, + fl1.user_id AS owner_id FROM folders fl1 WHERE fl1.uuid = :folderUuid AND fl1.removed = FALSE @@ -962,57 +962,49 @@ export class SequelizeFolderRepository implements FolderRepository { fr.depth + 1, fr.owner_id FROM folders fl2 - INNER JOIN folder_recursive fr - ON fr.uuid = fl2.parent_uuid - WHERE fr.depth < 100000 + INNER JOIN folder_recursive fr ON fr.uuid = fl2.parent_uuid + WHERE fr.depth < :maxDepth AND fl2.user_id = fr.owner_id AND fl2.removed = FALSE AND fl2.deleted = FALSE ), - ranked_files AS ( - SELECT - f.uuid, - f.size, - ROW_NUMBER() OVER (ORDER BY f.creation_time) as rn + limited_files AS ( + SELECT f.uuid, f.size, fr.depth FROM folder_recursive fr - INNER JOIN files f - ON f.folder_uuid = fr.uuid - AND f.status IN (:fileStatusCondition) + INNER JOIN files f ON f.folder_uuid = fr.uuid + WHERE f.status = :fileStatus + LIMIT :maxFiles ) SELECT - COUNT(uuid) as file_count, - COALESCE(SUM(size), 0) as total_size, - MAX(rn) as total_files_found - FROM ranked_files - WHERE rn <= 10000; + COUNT(*) AS file_count, + COALESCE(SUM(size), 0) AS total_size, + MAX(depth) AS max_depth + FROM limited_files `; - const [[result]]: any = await FolderModel.sequelize.query( - calculateStatsQuery, - { - replacements: { - folderUuid, - fileStatusCondition, - }, + const [[result]]: any = await FolderModel.sequelize.query( + calculateStatsQuery, + { + replacements: { + folderUuid, + maxDepth: MAX_DEPTH, + fileStatus: FileStatus.EXISTS, + maxFiles: MAX_FILES + 1, }, - ); - - const fileCount = Number.parseInt(result.file_count); - const totalFilesFound = Number.parseInt(result.total_files_found || 0); + }, + ); - return { - fileCount: Math.min(fileCount, 1000), - isFileCountExact: totalFilesFound <= 1000, - totalSize: Number.parseInt(result.total_size), - isTotalSizeExact: totalFilesFound < 10000, - }; - } catch (error) { - if (error.original?.code === '57014') { - throw new CalculateFolderSizeTimeoutException(); - } + const rawFileCount = Number.parseInt(result.file_count); + const hitFileLimit = rawFileCount > MAX_FILES; + const hitDepthLimit = Number.parseInt(result.max_depth) >= MAX_DEPTH; + const isExact = !hitFileLimit && !hitDepthLimit; - throw error; - } + return { + fileCount: hitFileLimit ? MAX_FILES : rawFileCount, + totalSize: Number.parseInt(result.total_size), + isFileCountExact: isExact, + isTotalSizeExact: isExact, + }; } async getDeletedFoldersWithNotDeletedChildren(options: { From 297cb2e87fae890322d2d60d60aa295058a438bd Mon Sep 17 00:00:00 2001 From: Andres Pinto <143480783+apsantiso@users.noreply.github.com> Date: Tue, 28 Apr 2026 10:28:31 +0200 Subject: [PATCH 2/2] chore(file-size-limit): added e2e test case --- src/modules/file/file.e2e-spec.ts | 104 +++++++++++++++++++++++++++++- 1 file changed, 103 insertions(+), 1 deletion(-) diff --git a/src/modules/file/file.e2e-spec.ts b/src/modules/file/file.e2e-spec.ts index 0f5a62c0c..708f79965 100644 --- a/src/modules/file/file.e2e-spec.ts +++ b/src/modules/file/file.e2e-spec.ts @@ -13,9 +13,16 @@ import { SequelizeFileRepository } from './file.repository'; import { UsageType } from '../usage/usage.domain'; import { UsageModel } from '../usage/usage.model'; import { FileModel } from './file.model'; -import { newFile } from '../../../test/fixtures'; +import { UserModel } from '../user/user.model'; +import { newFeatureLimit, newFile, newTier } from '../../../test/fixtures'; import { Time } from '../../lib/time'; import { type File, FileStatus } from './file.domain'; +import { type CreateFileDto } from './dto/create-file.dto'; +import { Limitmodel } from '../feature-limit/models/limit.model'; +import { TierModel } from '../feature-limit/models/tier.model'; +import { TierLimitsModel } from '../feature-limit/models/tier-limits.model'; +import { LimitLabels, LimitTypes } from '../feature-limit/limits.enum'; +import { PaymentRequiredException } from '../feature-limit/exceptions/payment-required.exception'; describe('File module', () => { let app: NestExpressApplication; @@ -23,14 +30,22 @@ describe('File module', () => { let fileUseCases: FileUseCases; let fileRepository: SequelizeFileRepository; let usageModel: typeof UsageModel; + let userModel: typeof UserModel; let fileModel: typeof FileModel; + let limitModel: typeof Limitmodel; + let tierModel: typeof TierModel; + let tierLimitsModel: typeof TierLimitsModel; beforeAll(async () => { app = await createTestApp(); fileUseCases = app.get(FileUseCases); fileRepository = app.get(SequelizeFileRepository); usageModel = app.get(getModelToken(UsageModel)); + userModel = app.get(getModelToken(UserModel)); fileModel = app.get(getModelToken(FileModel)); + limitModel = app.get(getModelToken(Limitmodel)); + tierModel = app.get(getModelToken(TierModel)); + tierLimitsModel = app.get(getModelToken(TierLimitsModel)); }); beforeEach(async () => { @@ -366,4 +381,91 @@ describe('File module', () => { }); }); }); + + describe('Max upload file size limit', () => { + const MB = 1024 * 1024; + let tier: TierModel; + let limit: Limitmodel; + + const baseFileDto = (): CreateFileDto => ({ + ...newFile({ attributes: { size: BigInt(50 * MB) } }).toJSON(), + folderUuid: testUser.rootFolder.uuid, + }); + + beforeEach(async () => { + const tierAttrs = newTier(); + const limitAttrs = newFeatureLimit({ + type: LimitTypes.Counter, + label: LimitLabels.MaxUploadFileSize, + value: String(100 * MB), + }); + + tier = await tierModel.create({ + id: tierAttrs.id, + label: tierAttrs.label, + context: tierAttrs.context, + }); + + limit = await limitModel.create({ + id: limitAttrs.id, + label: limitAttrs.label, + type: limitAttrs.type, + value: limitAttrs.value, + }); + + await tierLimitsModel.create({ + id: v4(), + tierId: tier.id, + limitId: limit.id, + }); + + await userModel.update( + { tierId: tier.id }, + { where: { uuid: testUser.user.uuid } }, + ); + testUser.user.tierId = tier.id; + }); + + afterEach(async () => { + await tierLimitsModel.destroy({ where: { tierId: tier.id } }); + await limitModel.destroy({ where: { id: limit.id } }); + await tierModel.destroy({ where: { id: tier.id } }); + }); + + it('When file size exceeds the tier limit, then it should throw', async () => { + const dto: CreateFileDto = { + ...baseFileDto(), + size: BigInt(200 * MB), + fileId: 'oversized-file-12', + }; + + await expect(fileUseCases.createFile(testUser.user, dto)).rejects.toThrow( + PaymentRequiredException, + ); + }); + + it('When file size is within the tier limit, then it should succeed', async () => { + await expect( + fileUseCases.createFile(testUser.user, baseFileDto()), + ).resolves.not.toThrow(); + }); + + it('When user has no upload size limit configured, then it should succeed regardless of size', async () => { + await userModel.update( + { tierId: null }, + { where: { uuid: testUser.user.uuid } }, + ); + testUser.user.tierId = null; + + const dto: CreateFileDto = { + ...baseFileDto(), + size: BigInt(500 * MB), + fileId: 'large-file-nolimit', + }; + + await expect( + fileUseCases.createFile(testUser.user, dto), + ).resolves.not.toThrow(); + }); + }); });