From d4192d4437575723adc21d97fe4e3fb8e0e3ba89 Mon Sep 17 00:00:00 2001 From: Andres Pinto Date: Thu, 21 Nov 2024 11:04:13 -0400 Subject: [PATCH 1/7] feat: calculate usage incrementally --- .../20241120002037-create-usages-table.js | 47 +++++++++++++++ ...1120003823-create-daily-usage-procedure.js | 56 +++++++++++++++++ ...20004033-create-monthly-usage-procedure.js | 60 +++++++++++++++++++ ...120010831-create-yearly-usage-procedure.js | 60 +++++++++++++++++++ ...eate-updated-at-index-non-deleted-files.js | 19 ++++++ 5 files changed, 242 insertions(+) create mode 100644 migrations/20241120002037-create-usages-table.js create mode 100644 migrations/20241120003823-create-daily-usage-procedure.js create mode 100644 migrations/20241120004033-create-monthly-usage-procedure.js create mode 100644 migrations/20241120010831-create-yearly-usage-procedure.js create mode 100644 migrations/20241120160129-create-updated-at-index-non-deleted-files.js diff --git a/migrations/20241120002037-create-usages-table.js b/migrations/20241120002037-create-usages-table.js new file mode 100644 index 00000000..84703623 --- /dev/null +++ b/migrations/20241120002037-create-usages-table.js @@ -0,0 +1,47 @@ +'use strict'; + +const tableName = 'usages'; + +/** @type {import('sequelize-cli').Migration} */ +module.exports = { + async up(queryInterface, Sequelize) { + await queryInterface.createTable(tableName, { + id: { + type: Sequelize.UUID, + primaryKey: true, + defaultValue: Sequelize.UUIDV4, + }, + user_id: { + type: Sequelize.UUID, + allowNull: false, + }, + delta: { + type: Sequelize.BIGINT, + allowNull: false, + defaultValue: 0, + }, + period: { + type: Sequelize.DATEONLY, + allowNull: false, + }, + type: { + type: Sequelize.STRING, + allowNull: false, + }, + created_at: { + type: Sequelize.DATE, + allowNull: false, + defaultValue: Sequelize.NOW, + }, + updated_at: { + type: Sequelize.DATE, + allowNull: false, + defaultValue: Sequelize.NOW, + }, + }); + }, + + async down(queryInterface) { + await queryInterface.dropTable(tableName); + }, +}; diff --git a/migrations/20241120003823-create-daily-usage-procedure.js b/migrations/20241120003823-create-daily-usage-procedure.js new file mode 100644 index 00000000..1b89eaaf --- /dev/null +++ b/migrations/20241120003823-create-daily-usage-procedure.js @@ -0,0 +1,56 @@ +'use strict'; + +/** @type {import('sequelize-cli').Migration} */ +module.exports = { + async up(queryInterface) { + await queryInterface.sequelize.query(` + CREATE OR REPLACE PROCEDURE generate_daily_usage() + LANGUAGE plpgsql + AS $$ + DECLARE + last_millisecond_of_yesterday TIMESTAMP; + BEGIN + last_millisecond_of_yesterday := ((CURRENT_DATE - INTERVAL '1 day') + INTERVAL '23 hours 59 minutes 59.999999 seconds')::timestamp; + + INSERT INTO public.usages (id, user_id, delta, period, type, created_at, updated_at) + SELECT + uuid_generate_v4() AS new_uuid, + u.uuid::uuid AS user_id, + SUM( + CASE + /* Files created and deleted on the same day are omitted */ + WHEN status = 'DELETED' AND date_trunc('day', f.created_at) = date_trunc('day', f.updated_at) THEN 0 + WHEN f.status = 'DELETED' THEN -f.size + ELSE f.size + END + ) AS delta, + CURRENT_DATE - INTERVAL '1 day' AS period, + 'daily' AS type, + NOW() AS created_at, + NOW() AS updated_at + FROM + files f + JOIN + users u ON u.id = f.user_id + WHERE + ( + (f.status != 'DELETED' AND f.created_at BETWEEN CURRENT_DATE - INTERVAL '1 day' AND last_millisecond_of_yesterday) + OR + (f.status = 'DELETED' AND f.updated_at BETWEEN CURRENT_DATE - INTERVAL '1 day' AND last_millisecond_of_yesterday) + OR + -- Remember to remove/modify this when we index also not deleted files using updated_at. Macos modify the size of the files. + (f.status != 'DELETED' AND f.modification_time BETWEEN CURRENT_DATE - INTERVAL '1 day' AND last_millisecond_of_yesterday) + ) + GROUP BY + u.uuid; + END; + $$; + `); + }, + + async down(queryInterface) { + await queryInterface.sequelize.query(` + DROP PROCEDURE IF EXISTS generate_daily_usage; + `); + }, +}; diff --git a/migrations/20241120004033-create-monthly-usage-procedure.js b/migrations/20241120004033-create-monthly-usage-procedure.js new file mode 100644 index 00000000..d11b4c29 --- /dev/null +++ b/migrations/20241120004033-create-monthly-usage-procedure.js @@ -0,0 +1,60 @@ +'use strict'; + +/** @type {import('sequelize-cli').Migration} */ +module.exports = { + async up(queryInterface) { + await queryInterface.sequelize.query(` + CREATE OR REPLACE PROCEDURE generate_monthly_usage() + LANGUAGE plpgsql + AS $$ + BEGIN + WITH daily_rows AS ( + SELECT + user_id, + SUM(delta) AS delta, + date_trunc('month', CURRENT_DATE) - INTERVAL '1 month' AS period + FROM + public.usages + WHERE + period >= date_trunc('month', CURRENT_DATE) - INTERVAL '1 month' + AND period < date_trunc('month', CURRENT_DATE) + AND type = 'daily' + GROUP BY + user_id + ) + INSERT INTO + public.usages ( + id, + user_id, + delta, + period, + type, + created_at, + updated_at + ) + SELECT + uuid_generate_v4(), + user_id, + delta, + period, + 'monthly' AS type, + NOW() AS created_at, + NOW() AS updated_at + FROM + daily_rows; + DELETE FROM public.usages + WHERE + period >= date_trunc('month', CURRENT_DATE) - INTERVAL '1 month' + AND period < date_trunc('month', CURRENT_DATE) + AND type = 'daily'; + END; + $$; + `); + }, + + async down(queryInterface) { + await queryInterface.sequelize.query(` + DROP PROCEDURE IF EXISTS generate_monthly_usage; + `); + }, +}; diff --git a/migrations/20241120010831-create-yearly-usage-procedure.js b/migrations/20241120010831-create-yearly-usage-procedure.js new file mode 100644 index 00000000..6ffc3d04 --- /dev/null +++ b/migrations/20241120010831-create-yearly-usage-procedure.js @@ -0,0 +1,60 @@ +'use strict'; + +/** @type {import('sequelize-cli').Migration} */ +module.exports = { + async up(queryInterface) { + await queryInterface.sequelize.query(` + CREATE OR REPLACE PROCEDURE generate_yearly_usage() + LANGUAGE plpgsql + AS $$ + BEGIN + WITH monthly_rows AS ( + SELECT + user_id, + SUM(delta) AS delta, + date_trunc('year', CURRENT_DATE) - INTERVAL '1 year' AS period + FROM + public.usages + WHERE + period >= date_trunc('year', CURRENT_DATE) - INTERVAL '1 year' + AND period < date_trunc('year', CURRENT_DATE) + AND type = 'monthly' + GROUP BY + user_id + ) + INSERT INTO + public.usages ( + id, + user_id, + delta, + period, + type, + created_at, + updated_at + ) + SELECT + uuid_generate_v4(), + user_id, + delta, + period, + 'yearly' AS type, + NOW() AS created_at, + NOW() AS updated_at + FROM + monthly_rows; + DELETE FROM public.usages + WHERE + period >= date_trunc('year', CURRENT_DATE) - INTERVAL '1 year' + AND period < date_trunc('year', CURRENT_DATE) + AND type = 'monthly'; + END; + $$; + `); + }, + + async down(queryInterface) { + await queryInterface.sequelize.query(` + DROP PROCEDURE IF EXISTS generate_yearly_usage; + `); + }, +}; diff --git a/migrations/20241120160129-create-updated-at-index-non-deleted-files.js b/migrations/20241120160129-create-updated-at-index-non-deleted-files.js new file mode 100644 index 00000000..19f7a912 --- /dev/null +++ b/migrations/20241120160129-create-updated-at-index-non-deleted-files.js @@ -0,0 +1,19 @@ +'use strict'; + +/** @type {import('sequelize-cli').Migration} */ +module.exports = { + async up(queryInterface) { + await queryInterface.sequelize.query( + ` + CREATE INDEX CONCURRENTLY files_updated_at_index + ON files USING btree (status, updated_at) + `, + ); + }, + + async down(queryInterface) { + await queryInterface.sequelize.query( + `DROP INDEX CONCURRENTLY files_updated_at_index`, + ); + }, +}; From 6ca376145eb73845d5923f577049d56fd312b209 Mon Sep 17 00:00:00 2001 From: Andres Pinto Date: Mon, 25 Nov 2024 08:53:00 -0400 Subject: [PATCH 2/7] feat: added index to usage table and modified daily cronjob to prevent duplicates --- .../20241120002050-create-usages-index.js | 17 +++ ...1120003823-create-daily-usage-procedure.js | 56 ---------- ...eate-calculate-last-day-usage-procedure.js | 56 ++++++++++ ...20004033-create-monthly-usage-procedure.js | 60 ---------- src/modules/usage/usage.domain.ts | 61 +++++++++++ src/modules/usage/usage.model.ts | 54 +++++++++ src/modules/usage/usage.module.ts | 11 ++ src/modules/usage/usage.repository.ts | 103 ++++++++++++++++++ 8 files changed, 302 insertions(+), 116 deletions(-) create mode 100644 migrations/20241120002050-create-usages-index.js delete mode 100644 migrations/20241120003823-create-daily-usage-procedure.js create mode 100644 migrations/20241120004033-create-calculate-last-day-usage-procedure.js delete mode 100644 migrations/20241120004033-create-monthly-usage-procedure.js create mode 100644 src/modules/usage/usage.domain.ts create mode 100644 src/modules/usage/usage.model.ts create mode 100644 src/modules/usage/usage.module.ts create mode 100644 src/modules/usage/usage.repository.ts diff --git a/migrations/20241120002050-create-usages-index.js b/migrations/20241120002050-create-usages-index.js new file mode 100644 index 00000000..8436f7cd --- /dev/null +++ b/migrations/20241120002050-create-usages-index.js @@ -0,0 +1,17 @@ +'use strict'; + +const indexName = 'usage_user_type_period_index'; + +module.exports = { + async up(queryInterface, Sequelize) { + await queryInterface.sequelize.query( + `CREATE INDEX CONCURRENTLY ${indexName} ON usages (user_id, type, period)`, + ); + }, + + async down(queryInterface, Sequelize) { + await queryInterface.sequelize.query( + `DROP INDEX CONCURRENTLY ${indexName}`, + ); + }, +}; diff --git a/migrations/20241120003823-create-daily-usage-procedure.js b/migrations/20241120003823-create-daily-usage-procedure.js deleted file mode 100644 index 1b89eaaf..00000000 --- a/migrations/20241120003823-create-daily-usage-procedure.js +++ /dev/null @@ -1,56 +0,0 @@ -'use strict'; - -/** @type {import('sequelize-cli').Migration} */ -module.exports = { - async up(queryInterface) { - await queryInterface.sequelize.query(` - CREATE OR REPLACE PROCEDURE generate_daily_usage() - LANGUAGE plpgsql - AS $$ - DECLARE - last_millisecond_of_yesterday TIMESTAMP; - BEGIN - last_millisecond_of_yesterday := ((CURRENT_DATE - INTERVAL '1 day') + INTERVAL '23 hours 59 minutes 59.999999 seconds')::timestamp; - - INSERT INTO public.usages (id, user_id, delta, period, type, created_at, updated_at) - SELECT - uuid_generate_v4() AS new_uuid, - u.uuid::uuid AS user_id, - SUM( - CASE - /* Files created and deleted on the same day are omitted */ - WHEN status = 'DELETED' AND date_trunc('day', f.created_at) = date_trunc('day', f.updated_at) THEN 0 - WHEN f.status = 'DELETED' THEN -f.size - ELSE f.size - END - ) AS delta, - CURRENT_DATE - INTERVAL '1 day' AS period, - 'daily' AS type, - NOW() AS created_at, - NOW() AS updated_at - FROM - files f - JOIN - users u ON u.id = f.user_id - WHERE - ( - (f.status != 'DELETED' AND f.created_at BETWEEN CURRENT_DATE - INTERVAL '1 day' AND last_millisecond_of_yesterday) - OR - (f.status = 'DELETED' AND f.updated_at BETWEEN CURRENT_DATE - INTERVAL '1 day' AND last_millisecond_of_yesterday) - OR - -- Remember to remove/modify this when we index also not deleted files using updated_at. Macos modify the size of the files. - (f.status != 'DELETED' AND f.modification_time BETWEEN CURRENT_DATE - INTERVAL '1 day' AND last_millisecond_of_yesterday) - ) - GROUP BY - u.uuid; - END; - $$; - `); - }, - - async down(queryInterface) { - await queryInterface.sequelize.query(` - DROP PROCEDURE IF EXISTS generate_daily_usage; - `); - }, -}; diff --git a/migrations/20241120004033-create-calculate-last-day-usage-procedure.js b/migrations/20241120004033-create-calculate-last-day-usage-procedure.js new file mode 100644 index 00000000..6bcc0971 --- /dev/null +++ b/migrations/20241120004033-create-calculate-last-day-usage-procedure.js @@ -0,0 +1,56 @@ +'use strict'; + +/** @type {import('sequelize-cli').Migration} */ +module.exports = { + async up(queryInterface) { + await queryInterface.sequelize.query(` + CREATE OR REPLACE PROCEDURE calculate_last_day_usage() + LANGUAGE plpgsql + AS $$ + BEGIN + INSERT INTO public.usages (id, user_id, delta, period, type, created_at, updated_at) + SELECT + uuid_generate_v4() AS id, + u.uuid::uuid AS user_id, + SUM( + CASE + WHEN f.status = 'DELETED' AND date_trunc('day', f.created_at) = date_trunc('day', f.updated_at) THEN 0 + WHEN f.status = 'DELETED' THEN -f.size + ELSE f.size + END + ) AS delta, + CURRENT_DATE - INTERVAL '1 day' AS period, + 'monthly' AS type, + NOW() AS created_at, + NOW() AS updated_at + FROM + files f + JOIN users u ON u.id = f.user_id + LEFT JOIN ( + SELECT user_id, MAX(period) AS last_period + FROM public.usages + GROUP BY user_id + ) mru + ON u.uuid::uuid = mru.user_id::uuid + WHERE + (mru.last_period IS NOT NULL AND mru.last_period != CURRENT_DATE - INTERVAL '1 day') + AND ( + (f.status != 'DELETED' AND f.created_at BETWEEN CURRENT_DATE - INTERVAL '1 day' AND CURRENT_DATE - INTERVAL '1 millisecond') + OR + (f.status = 'DELETED' AND f.updated_at BETWEEN CURRENT_DATE - INTERVAL '1 day' AND CURRENT_DATE - INTERVAL '1 millisecond') + OR + (f.status != 'DELETED' AND f.modification_time BETWEEN CURRENT_DATE - INTERVAL '1 day' AND CURRENT_DATE - INTERVAL '1 millisecond') + ) + GROUP BY + u.uuid; + END; + $$; + `); + }, + + async down(queryInterface) { + await queryInterface.sequelize.query(` + DROP PROCEDURE IF EXISTS calculate_last_day_usage; + `); + }, +}; diff --git a/migrations/20241120004033-create-monthly-usage-procedure.js b/migrations/20241120004033-create-monthly-usage-procedure.js deleted file mode 100644 index d11b4c29..00000000 --- a/migrations/20241120004033-create-monthly-usage-procedure.js +++ /dev/null @@ -1,60 +0,0 @@ -'use strict'; - -/** @type {import('sequelize-cli').Migration} */ -module.exports = { - async up(queryInterface) { - await queryInterface.sequelize.query(` - CREATE OR REPLACE PROCEDURE generate_monthly_usage() - LANGUAGE plpgsql - AS $$ - BEGIN - WITH daily_rows AS ( - SELECT - user_id, - SUM(delta) AS delta, - date_trunc('month', CURRENT_DATE) - INTERVAL '1 month' AS period - FROM - public.usages - WHERE - period >= date_trunc('month', CURRENT_DATE) - INTERVAL '1 month' - AND period < date_trunc('month', CURRENT_DATE) - AND type = 'daily' - GROUP BY - user_id - ) - INSERT INTO - public.usages ( - id, - user_id, - delta, - period, - type, - created_at, - updated_at - ) - SELECT - uuid_generate_v4(), - user_id, - delta, - period, - 'monthly' AS type, - NOW() AS created_at, - NOW() AS updated_at - FROM - daily_rows; - DELETE FROM public.usages - WHERE - period >= date_trunc('month', CURRENT_DATE) - INTERVAL '1 month' - AND period < date_trunc('month', CURRENT_DATE) - AND type = 'daily'; - END; - $$; - `); - }, - - async down(queryInterface) { - await queryInterface.sequelize.query(` - DROP PROCEDURE IF EXISTS generate_monthly_usage; - `); - }, -}; diff --git a/src/modules/usage/usage.domain.ts b/src/modules/usage/usage.domain.ts new file mode 100644 index 00000000..65f023c9 --- /dev/null +++ b/src/modules/usage/usage.domain.ts @@ -0,0 +1,61 @@ +export interface UsageAttributes { + id: string; + userId: string; + delta: number; + period: Date; + type: string; + createdAt: Date; + updatedAt: Date; +} + +export class Usage implements UsageAttributes { + id: string; + userId: string; + delta: number; + period: Date; + type: string; + createdAt: Date; + updatedAt: Date; + + constructor({ + id, + userId, + delta, + period, + type, + createdAt, + updatedAt, + }: UsageAttributes) { + this.id = id; + this.userId = userId; + this.delta = delta; + this.period = period; + this.type = type; + this.createdAt = createdAt; + this.updatedAt = updatedAt; + } + + static build(usage: UsageAttributes): Usage { + return new Usage(usage); + } + + isYearly(): boolean { + return this.type === 'yearly'; + } + + isMonthly(): boolean { + return this.type === 'monthly'; + } + + toJSON(): Partial { + return { + id: this.id, + userId: this.userId, + delta: this.delta, + period: this.period, + type: this.type, + createdAt: this.createdAt, + updatedAt: this.updatedAt, + }; + } +} diff --git a/src/modules/usage/usage.model.ts b/src/modules/usage/usage.model.ts new file mode 100644 index 00000000..f879470c --- /dev/null +++ b/src/modules/usage/usage.model.ts @@ -0,0 +1,54 @@ +import { + Column, + Model, + Table, + PrimaryKey, + DataType, + Default, + AllowNull, + ForeignKey, + BelongsTo, +} from 'sequelize-typescript'; +import { UserModel } from '../user/user.model'; + +@Table({ + underscored: true, + timestamps: true, + tableName: 'usages', +}) +export class UsageModel extends Model { + @PrimaryKey + @Column(DataType.UUID) + id: string; + + @ForeignKey(() => UserModel) + @AllowNull(false) + @Column(DataType.UUID) + userId: string; + + @Default(0) + @AllowNull(false) + @Column(DataType.BIGINT) + delta: number; + + @AllowNull(false) + @Column(DataType.DATEONLY) + period: Date; + + @AllowNull(false) + @Column(DataType.STRING) + type: string; + + @AllowNull(false) + @Default(DataType.NOW) + @Column(DataType.DATE) + createdAt: Date; + + @AllowNull(false) + @Default(DataType.NOW) + @Column(DataType.DATE) + updatedAt: Date; + + @BelongsTo(() => UserModel) + user: UserModel; +} diff --git a/src/modules/usage/usage.module.ts b/src/modules/usage/usage.module.ts new file mode 100644 index 00000000..823d2fd5 --- /dev/null +++ b/src/modules/usage/usage.module.ts @@ -0,0 +1,11 @@ +import { Module } from '@nestjs/common'; +import { SequelizeModule } from '@nestjs/sequelize'; +import { UsageModel } from './usage.model'; +import { SequelizeUsageRepository } from './usage.repository'; + +@Module({ + imports: [SequelizeModule.forFeature([UsageModel])], + providers: [SequelizeUsageRepository], + exports: [SequelizeUsageRepository], +}) +export class PlanModule {} diff --git a/src/modules/usage/usage.repository.ts b/src/modules/usage/usage.repository.ts new file mode 100644 index 00000000..5a7cfd26 --- /dev/null +++ b/src/modules/usage/usage.repository.ts @@ -0,0 +1,103 @@ +import { InjectModel } from '@nestjs/sequelize'; +import { Injectable } from '@nestjs/common'; +import { UsageModel } from './usage.model'; +import { Usage } from './usage.domain'; + +@Injectable() +export class SequelizeUsageRepository { + constructor( + @InjectModel(UsageModel) + private readonly usageModel: typeof UsageModel, + ) {} + + public async getUserUsages(userUuid: string) { + const usages = await this.usageModel.findAll({ + where: { userId: userUuid }, + }); + + return usages.map((usage) => this.toDomain(usage)); + } + + public async getMostRecentUsage(userUuid: string): Promise { + const mostRecentUsage = await this.usageModel.findOne({ + where: { userId: userUuid }, + order: [['period', 'DESC']], + }); + + return mostRecentUsage ? this.toDomain(mostRecentUsage) : null; + } + + public async addFirstDailyUsage(userUuid: string): Promise { + const query = ` + INSERT INTO public.usages (id, user_id, delta, period, type, created_at, updated_at) + SELECT + uuid_generate_v4(), + u.uuid::uuid AS user_id, + COALESCE(SUM(f.size), 0) AS delta, + (CURRENT_DATE - INTERVAL '1 day')::DATE AS period, + 'monthly' AS type, + NOW() AS created_at, + NOW() AS updated_at + FROM + users u + LEFT JOIN public.files f ON u.id = f.user_id + AND f.status != 'DELETED' + AND ( + f.created_at < CURRENT_DATE + AND f.updated_at < CURRENT_DATE + ) + WHERE + u.uuid = :userUuid + GROUP BY + u.uuid + RETURNING *; + `; + + const result = await this.usageModel.sequelize.query(query, { + replacements: { userUuid }, + model: UsageModel, + }); + + return result.map((result) => this.toDomain(result)); + } + + public async getUserUsage(userUuid: string): Promise { + const query = ` + INSERT INTO public.usages (id, user_id, delta, period, type, created_at, updated_at) + SELECT + uuid_generate_v4(), + u.uuid::uuid AS user_id, + COALESCE(SUM(f.size), 0) AS delta, + (CURRENT_DATE - INTERVAL '2 day')::DATE AS period, + 'monthly' AS type, + NOW() AS created_at, + NOW() AS updated_at + FROM + users u + LEFT JOIN public.files f ON u.id = f.user_id + AND f.status != 'DELETED' + AND ( + f.created_at < CURRENT_DATE + AND f.updated_at < CURRENT_DATE + ) + WHERE + u.uuid = :userUuid + GROUP BY + u.uuid + RETURNING *; + `; + + const result = await this.usageModel.sequelize.query(query, { + replacements: { userUuid }, + model: UsageModel, + }); + + return result.map((result) => this.toDomain(result)); + } + + toDomain(model: UsageModel): Usage { + return Usage.build({ + ...model.toJSON(), + }); + } +} From 1edc9eda0dc04adc07f0daeb5be01b33459ec169 Mon Sep 17 00:00:00 2001 From: Andres Pinto Date: Tue, 26 Nov 2024 02:07:22 -0400 Subject: [PATCH 3/7] feat: create usage module --- src/modules/file/file.repository.ts | 48 ++++++++++++ src/modules/usage/usage.module.ts | 15 ++-- src/modules/usage/usage.repository.ts | 109 ++++++++++++++++++-------- src/modules/usage/usage.usecase.ts | 41 ++++++++++ src/modules/user/user.controller.ts | 5 ++ src/modules/user/user.module.ts | 2 + src/modules/user/user.usecase.ts | 6 ++ 7 files changed, 188 insertions(+), 38 deletions(-) create mode 100644 src/modules/usage/usage.usecase.ts diff --git a/src/modules/file/file.repository.ts b/src/modules/file/file.repository.ts index 254969c2..63580efe 100644 --- a/src/modules/file/file.repository.ts +++ b/src/modules/file/file.repository.ts @@ -101,6 +101,10 @@ export interface FileRepository { userId: User['id'], fileIds: FileAttributes['fileId'][], ): Promise; + sumFileSizesSinceDate( + userId: FileAttributes['userId'], + sinceDate: Date, + ): Promise; } @Injectable() @@ -631,6 +635,50 @@ export class SequelizeFileRepository implements FileRepository { }); } + async sumFileSizesSinceDate( + userId: FileAttributes['userId'], + sinceDate: Date, + ): Promise { + const result = await this.fileModel.findAll({ + attributes: [ + [ + Sequelize.literal(` + SUM( + CASE + WHEN status = 'DELETED' AND date_trunc('day', created_at) = date_trunc('day', updated_at) THEN 0 + WHEN status = 'DELETED' THEN -size + ELSE size + END + ) + `), + 'total', + ], + ], + where: { + userId, + [Op.or]: [ + { + status: { + [Op.ne]: 'DELETED', + }, + createdAt: { + [Op.gte]: sinceDate, + }, + }, + { + status: 'DELETED', + updatedAt: { + [Op.gte]: sinceDate, + }, + }, + ], + }, + raw: true, + }); + + return Number(result[0]['total']) as unknown as number; + } + async getFilesWhoseFolderIdDoesNotExist( userId: File['userId'], ): Promise { diff --git a/src/modules/usage/usage.module.ts b/src/modules/usage/usage.module.ts index 823d2fd5..f98e0ee8 100644 --- a/src/modules/usage/usage.module.ts +++ b/src/modules/usage/usage.module.ts @@ -1,11 +1,16 @@ -import { Module } from '@nestjs/common'; +import { forwardRef, Module } from '@nestjs/common'; import { SequelizeModule } from '@nestjs/sequelize'; import { UsageModel } from './usage.model'; import { SequelizeUsageRepository } from './usage.repository'; +import { UsageUseCases } from './usage.usecase'; +import { FileModule } from '../file/file.module'; @Module({ - imports: [SequelizeModule.forFeature([UsageModel])], - providers: [SequelizeUsageRepository], - exports: [SequelizeUsageRepository], + imports: [ + SequelizeModule.forFeature([UsageModel]), + forwardRef(() => FileModule), + ], + providers: [SequelizeUsageRepository, UsageUseCases], + exports: [SequelizeUsageRepository, UsageUseCases, SequelizeModule], }) -export class PlanModule {} +export class UsageModule {} diff --git a/src/modules/usage/usage.repository.ts b/src/modules/usage/usage.repository.ts index 5a7cfd26..01fbe171 100644 --- a/src/modules/usage/usage.repository.ts +++ b/src/modules/usage/usage.repository.ts @@ -27,7 +27,7 @@ export class SequelizeUsageRepository { return mostRecentUsage ? this.toDomain(mostRecentUsage) : null; } - public async addFirstDailyUsage(userUuid: string): Promise { + public async addFirstDailyUsage(userUuid: string): Promise { const query = ` INSERT INTO public.usages (id, user_id, delta, period, type, created_at, updated_at) SELECT @@ -42,10 +42,7 @@ export class SequelizeUsageRepository { users u LEFT JOIN public.files f ON u.id = f.user_id AND f.status != 'DELETED' - AND ( - f.created_at < CURRENT_DATE - AND f.updated_at < CURRENT_DATE - ) + AND f.created_at < CURRENT_DATE WHERE u.uuid = :userUuid GROUP BY @@ -58,41 +55,87 @@ export class SequelizeUsageRepository { model: UsageModel, }); - return result.map((result) => this.toDomain(result)); + return result.length > 0 ? this.toDomain(result[0]) : null; } - public async getUserUsage(userUuid: string): Promise { + public async getUserUsage(userUuid: string) { const query = ` - INSERT INTO public.usages (id, user_id, delta, period, type, created_at, updated_at) - SELECT - uuid_generate_v4(), - u.uuid::uuid AS user_id, - COALESCE(SUM(f.size), 0) AS delta, - (CURRENT_DATE - INTERVAL '2 day')::DATE AS period, - 'monthly' AS type, - NOW() AS created_at, - NOW() AS updated_at - FROM - users u - LEFT JOIN public.files f ON u.id = f.user_id - AND f.status != 'DELETED' - AND ( - f.created_at < CURRENT_DATE - AND f.updated_at < CURRENT_DATE - ) - WHERE - u.uuid = :userUuid - GROUP BY - u.uuid - RETURNING *; + WITH yearly_sums AS ( + SELECT + date_trunc('year', period) AS year, + SUM(delta) AS total_delta + FROM + public.usages + WHERE + type = 'yearly' + AND user_id = :userUuid + GROUP BY + date_trunc('year', period) + ), + monthly_sums AS ( + SELECT + date_trunc('year', period) AS year, + SUM(delta) AS total_delta + FROM + public.usages + WHERE + type = 'monthly' + AND user_id = :userUuid + GROUP BY + date_trunc('year', period) + ), + filtered_monthly_sums AS ( + SELECT + m.year, + m.total_delta + FROM + monthly_sums m + LEFT JOIN yearly_sums y ON m.year = y.year + WHERE y.year IS NULL + ), + combined_sums AS ( + SELECT + year, + total_delta + FROM + yearly_sums + UNION ALL + SELECT + year, + total_delta + FROM + filtered_monthly_sums + ) + SELECT + SUM( + CASE + WHEN year < date_trunc('year', CURRENT_DATE) THEN total_delta + ELSE 0 + END + ) AS total_yearly_delta, + SUM( + CASE + WHEN year = date_trunc('year', CURRENT_DATE) THEN total_delta + ELSE 0 + END + ) AS total_monthly_delta + FROM + combined_sums; `; - const result = await this.usageModel.sequelize.query(query, { + const [result] = (await this.usageModel.sequelize.query(query, { replacements: { userUuid }, - model: UsageModel, - }); + })) as unknown as [ + { + total_yearly_delta: number; + total_monthly_delta: number; + }[], + ]; - return result.map((result) => this.toDomain(result)); + return { + total_yearly_delta: Number(result[0].total_yearly_delta || 0), + total_monthly_delta: Number(result[0].total_monthly_delta || 0), + }; } toDomain(model: UsageModel): Usage { diff --git a/src/modules/usage/usage.usecase.ts b/src/modules/usage/usage.usecase.ts new file mode 100644 index 00000000..65043227 --- /dev/null +++ b/src/modules/usage/usage.usecase.ts @@ -0,0 +1,41 @@ +import { Injectable } from '@nestjs/common'; +import { SequelizeUsageRepository } from './usage.repository'; +import { SequelizeFileRepository } from '../file/file.repository'; +import { User } from '../user/user.domain'; + +@Injectable() +export class UsageUseCases { + constructor( + private readonly usageRepository: SequelizeUsageRepository, + private readonly fileRepository: SequelizeFileRepository, + ) {} + + async getUserUsage(user: User) { + const userUuid = user.uuid; + + let mostRecentUsage = + await this.usageRepository.getMostRecentUsage(userUuid); + + if (!mostRecentUsage) { + mostRecentUsage = await this.usageRepository.addFirstDailyUsage(userUuid); + } + + const mostRecentUsageNextDay = new Date(mostRecentUsage.period); + mostRecentUsageNextDay.setUTCDate(mostRecentUsageNextDay.getUTCDate() + 1); + + const totalStorageChanged = await this.fileRepository.sumFileSizesSinceDate( + user.id, + mostRecentUsageNextDay, + ); + + const totalUsage = await this.usageRepository.getUserUsage(userUuid); + + return { + drive: + totalUsage.total_yearly_delta + + totalUsage.total_monthly_delta + + totalStorageChanged, + id: user.email, + }; + } +} diff --git a/src/modules/user/user.controller.ts b/src/modules/user/user.controller.ts index 81f02464..6636a7d5 100644 --- a/src/modules/user/user.controller.ts +++ b/src/modules/user/user.controller.ts @@ -420,6 +420,11 @@ export class UserController { return this.userUseCases.getAuthTokens(user); } + @Get('/usage') + getUser(@UserDecorator() user: User) { + return this.userUseCases.getUserUsage(user); + } + @Patch('password') @ApiBearerAuth() async updatePassword( diff --git a/src/modules/user/user.module.ts b/src/modules/user/user.module.ts index c51a385f..87566b3a 100644 --- a/src/modules/user/user.module.ts +++ b/src/modules/user/user.module.ts @@ -47,6 +47,7 @@ import { FeatureLimitModule } from '../feature-limit/feature-limit.module'; import { WorkspacesModule } from '../workspaces/workspaces.module'; import { SequelizeWorkspaceRepository } from '../workspaces/repositories/workspaces.repository'; import { UserNotificationTokensModel } from './user-notification-tokens.model'; +import { UsageModule } from '../usage/usage.module'; @Module({ imports: [ @@ -74,6 +75,7 @@ import { UserNotificationTokensModel } from './user-notification-tokens.model'; SecurityModule, forwardRef(() => FeatureLimitModule), forwardRef(() => WorkspacesModule), + UsageModule, ], controllers: [UserController], providers: [ diff --git a/src/modules/user/user.usecase.ts b/src/modules/user/user.usecase.ts index b3371b37..f675e94b 100644 --- a/src/modules/user/user.usecase.ts +++ b/src/modules/user/user.usecase.ts @@ -72,6 +72,7 @@ import { SequelizeFeatureLimitsRepository } from '../feature-limit/feature-limit import { SequelizeWorkspaceRepository } from '../workspaces/repositories/workspaces.repository'; import { UserNotificationTokens } from './user-notification-tokens.domain'; import { RegisterNotificationTokenDto } from './dto/register-notification-token.dto'; +import { UsageUseCases } from '../usage/usage.usecase'; class ReferralsNotAvailableError extends Error { constructor() { @@ -150,6 +151,7 @@ export class UserUseCases { private readonly mailerService: MailerService, private readonly mailLimitRepository: SequelizeMailLimitRepository, private readonly featureLimitRepository: SequelizeFeatureLimitsRepository, + private readonly usageUseCases: UsageUseCases, ) {} findByEmail(email: User['email']): Promise { @@ -198,6 +200,10 @@ export class UserUseCases { }); } + async getUserUsage(user: User) { + return this.usageUseCases.getUserUsage(user); + } + async applyReferral( userId: UserAttributes['id'], referralKey: ReferralAttributes['key'], From 2c8906af07c2809f8164da0b8a9321eca433e929 Mon Sep 17 00:00:00 2001 From: Andres Pinto Date: Tue, 3 Dec 2024 10:42:38 -0400 Subject: [PATCH 4/7] added daily delta on file size change --- src/modules/file/file.module.ts | 2 + src/modules/file/file.repository.ts | 15 +++-- src/modules/file/file.usecase.ts | 16 +++++- src/modules/usage/usage.domain.ts | 19 +++++-- src/modules/usage/usage.repository.ts | 18 ++++++ src/modules/usage/usage.usecase.ts | 81 +++++++++++++++++++++++++++ 6 files changed, 138 insertions(+), 13 deletions(-) diff --git a/src/modules/file/file.module.ts b/src/modules/file/file.module.ts index ad13ae9d..b8ce918e 100644 --- a/src/modules/file/file.module.ts +++ b/src/modules/file/file.module.ts @@ -15,6 +15,7 @@ import { SharingModule } from '../sharing/sharing.module'; import { WorkspacesModule } from '../workspaces/workspaces.module'; import { UserModule } from '../user/user.module'; import { NotificationModule } from '../../externals/notifications/notifications.module'; +import { UsageModule } from '../usage/usage.module'; @Module({ imports: [ @@ -28,6 +29,7 @@ import { NotificationModule } from '../../externals/notifications/notifications. CryptoModule, UserModule, NotificationModule, + UsageModule, ], controllers: [FileController], providers: [SequelizeFileRepository, FileUseCases], diff --git a/src/modules/file/file.repository.ts b/src/modules/file/file.repository.ts index 63580efe..6a547ff9 100644 --- a/src/modules/file/file.repository.ts +++ b/src/modules/file/file.repository.ts @@ -638,7 +638,13 @@ export class SequelizeFileRepository implements FileRepository { async sumFileSizesSinceDate( userId: FileAttributes['userId'], sinceDate: Date, + untilDate?: Date, ): Promise { + const timeCondition = { + [Op.gte]: sinceDate, + ...(untilDate ? { [Op.lte]: untilDate } : null), + }; + const result = await this.fileModel.findAll({ attributes: [ [ @@ -661,19 +667,16 @@ export class SequelizeFileRepository implements FileRepository { status: { [Op.ne]: 'DELETED', }, - createdAt: { - [Op.gte]: sinceDate, - }, + createdAt: timeCondition, }, { status: 'DELETED', - updatedAt: { - [Op.gte]: sinceDate, - }, + updatedAt: timeCondition, }, ], }, raw: true, + logging: console.log, }); return Number(result[0]['total']) as unknown as number; diff --git a/src/modules/file/file.usecase.ts b/src/modules/file/file.usecase.ts index b9c19dbe..f755273a 100644 --- a/src/modules/file/file.usecase.ts +++ b/src/modules/file/file.usecase.ts @@ -37,6 +37,7 @@ import { Folder } from '../folder/folder.domain'; import { getPathFileData } from '../../lib/path'; import { isStringEmpty } from '../../lib/validators'; import { FileModel } from './file.model'; +import { UsageUseCases } from '../usage/usage.usecase'; export type SortParamsFile = Array<[SortableFileAttributes, 'ASC' | 'DESC']>; @@ -50,6 +51,7 @@ export class FileUseCases { private sharingUsecases: SharingService, private network: BridgeService, private cryptoService: CryptoService, + private usageUsecases: UsageUseCases, ) {} getByUuid(uuid: FileAttributes['uuid']): Promise { @@ -570,7 +572,7 @@ export class FileUseCases { ): Promise { const file = await this.fileRepository.findByUuid(fileUuid, user.id); - if (!file) { + if (!file || file?.status != FileStatus.EXISTS) { throw new NotFoundException(`File ${fileUuid} not found`); } @@ -581,7 +583,17 @@ export class FileUseCases { fileId, size, }); - await this.network.deleteFile(user, bucket, oldFileId); + + const newFile = File.build({ ...file, size, fileId }); + + await Promise.all([ + this.network.deleteFile(user, bucket, oldFileId), + this.usageUsecases.addDailyUsageChangeOnFileSizeChange( + user, + file, + newFile, + ), + ]); return { ...file.toJSON(), diff --git a/src/modules/usage/usage.domain.ts b/src/modules/usage/usage.domain.ts index 65f023c9..aef0a7a7 100644 --- a/src/modules/usage/usage.domain.ts +++ b/src/modules/usage/usage.domain.ts @@ -1,9 +1,14 @@ +export enum UsageType { + Daily = 'daily', + Monthly = 'monthly', + Yearly = 'yearly', +} export interface UsageAttributes { id: string; userId: string; delta: number; period: Date; - type: string; + type: UsageType; createdAt: Date; updatedAt: Date; } @@ -13,7 +18,7 @@ export class Usage implements UsageAttributes { userId: string; delta: number; period: Date; - type: string; + type: UsageType; createdAt: Date; updatedAt: Date; @@ -29,7 +34,7 @@ export class Usage implements UsageAttributes { this.id = id; this.userId = userId; this.delta = delta; - this.period = period; + this.period = new Date(period); this.type = type; this.createdAt = createdAt; this.updatedAt = updatedAt; @@ -40,11 +45,15 @@ export class Usage implements UsageAttributes { } isYearly(): boolean { - return this.type === 'yearly'; + return this.type === UsageType.Yearly; } isMonthly(): boolean { - return this.type === 'monthly'; + return this.type === UsageType.Monthly; + } + + isDaily(): boolean { + return this.type === UsageType.Daily; } toJSON(): Partial { diff --git a/src/modules/usage/usage.repository.ts b/src/modules/usage/usage.repository.ts index 01fbe171..1870842c 100644 --- a/src/modules/usage/usage.repository.ts +++ b/src/modules/usage/usage.repository.ts @@ -18,6 +18,12 @@ export class SequelizeUsageRepository { return usages.map((usage) => this.toDomain(usage)); } + public async create(usage: Omit) { + const newUsage = await this.usageModel.create(usage); + + return this.toDomain(newUsage); + } + public async getMostRecentUsage(userUuid: string): Promise { const mostRecentUsage = await this.usageModel.findOne({ where: { userId: userUuid }, @@ -27,6 +33,18 @@ export class SequelizeUsageRepository { return mostRecentUsage ? this.toDomain(mostRecentUsage) : null; } + public async getUsage( + where: Partial, + order?: Array<[keyof Usage, 'ASC' | 'DESC']>, + ): Promise { + const mostRecentUsage = await this.usageModel.findOne({ + where: { ...where }, + order: order, + }); + + return mostRecentUsage ? this.toDomain(mostRecentUsage) : null; + } + public async addFirstDailyUsage(userUuid: string): Promise { const query = ` INSERT INTO public.usages (id, user_id, delta, period, type, created_at, updated_at) diff --git a/src/modules/usage/usage.usecase.ts b/src/modules/usage/usage.usecase.ts index 65043227..b8da4e03 100644 --- a/src/modules/usage/usage.usecase.ts +++ b/src/modules/usage/usage.usecase.ts @@ -1,7 +1,11 @@ import { Injectable } from '@nestjs/common'; import { SequelizeUsageRepository } from './usage.repository'; +import { File } from '../file/file.domain'; import { SequelizeFileRepository } from '../file/file.repository'; import { User } from '../user/user.domain'; +import { Usage, UsageType } from './usage.domain'; +import { v4 } from 'uuid'; +import { Time } from '../../lib/time'; @Injectable() export class UsageUseCases { @@ -38,4 +42,81 @@ export class UsageUseCases { id: user.email, }; } + + async createDailyUsage(userUuid: User['uuid'], period: Date, delta: number) { + const dailyUsage = Usage.build({ + id: v4(), + userId: userUuid, + period, + delta, + type: UsageType.Daily, + createdAt: new Date(), + updatedAt: new Date(), + }); + + const createdDailyUsage = await this.usageRepository.create(dailyUsage); + + return createdDailyUsage; + } + + /* async addDailyUsageChangeOnFileSizeChange( + user: User, + oldFileData: File, + newFileData: File, + ) { + const mostRecentDailyUsage = await this.usageRepository.getUsage( + { + type: UsageType.Daily, + userId: user.uuid, + }, + [['createdAt', 'DESC']], + ); + + let calculateChangesSince: Date = mostRecentDailyUsage?.createdAt; + const now = new Date(); + + if ( + !calculateChangesSince || + new Date(calculateChangesSince).toDateString() !== now.toDateString() + ) { + calculateChangesSince = new Date(now); + calculateChangesSince.setUTCHours(0, 0, 0, 0); + } + + const totalStorageChanged = await this.fileRepository.sumFileSizesSinceDate( + user.id, + calculateChangesSince, + ); + + if (newFileData.createdAt.toDateString() !== now.toDateString()) { + const delta = + Number(newFileData.size) - + Number(oldFileData.size) + + totalStorageChanged; + + return this.createDailyUsage(user.uuid, new Date(), delta); + } + + const delta = totalStorageChanged; + + console.log({ totalStorageChanged, delta }); + + return this.createDailyUsage(user.uuid, new Date(), delta); + } */ + + async addDailyUsageChangeOnFileSizeChange( + user: User, + oldFileData: File, + newFileData: File, + ) { + const isFileCreatedToday = Time.isToday(newFileData.createdAt); + + if (isFileCreatedToday) { + return; + } + + const delta = Number(newFileData.size) - Number(oldFileData.size); + + return this.createDailyUsage(user.uuid, new Date(), delta); + } } From bce6245d55083cff2deab67198d0c38c2d37d508 Mon Sep 17 00:00:00 2001 From: Andres Pinto Date: Wed, 4 Dec 2024 02:14:55 -0400 Subject: [PATCH 5/7] feat: added daily usage rows on file size change, added daily usage count towards usage calculation --- ...eate-calculate-last-day-usage-procedure.js | 4 +- ...120010831-create-yearly-usage-procedure.js | 4 +- ...eate-updated-at-index-non-deleted-files.js | 19 --- src/modules/usage/usage.domain.spec.ts | 44 +++++ src/modules/usage/usage.domain.ts | 12 -- src/modules/usage/usage.repository.ts | 153 ++++++++++-------- src/modules/usage/usage.usecase.ts | 36 +++-- 7 files changed, 163 insertions(+), 109 deletions(-) delete mode 100644 migrations/20241120160129-create-updated-at-index-non-deleted-files.js create mode 100644 src/modules/usage/usage.domain.spec.ts diff --git a/migrations/20241120004033-create-calculate-last-day-usage-procedure.js b/migrations/20241120004033-create-calculate-last-day-usage-procedure.js index 6bcc0971..f7af1f34 100644 --- a/migrations/20241120004033-create-calculate-last-day-usage-procedure.js +++ b/migrations/20241120004033-create-calculate-last-day-usage-procedure.js @@ -28,7 +28,7 @@ module.exports = { JOIN users u ON u.id = f.user_id LEFT JOIN ( SELECT user_id, MAX(period) AS last_period - FROM public.usages + FROM public.usages WHERE type = 'monthly' GROUP BY user_id ) mru ON u.uuid::uuid = mru.user_id::uuid @@ -38,8 +38,6 @@ module.exports = { (f.status != 'DELETED' AND f.created_at BETWEEN CURRENT_DATE - INTERVAL '1 day' AND CURRENT_DATE - INTERVAL '1 millisecond') OR (f.status = 'DELETED' AND f.updated_at BETWEEN CURRENT_DATE - INTERVAL '1 day' AND CURRENT_DATE - INTERVAL '1 millisecond') - OR - (f.status != 'DELETED' AND f.modification_time BETWEEN CURRENT_DATE - INTERVAL '1 day' AND CURRENT_DATE - INTERVAL '1 millisecond') ) GROUP BY u.uuid; diff --git a/migrations/20241120010831-create-yearly-usage-procedure.js b/migrations/20241120010831-create-yearly-usage-procedure.js index 6ffc3d04..087b286e 100644 --- a/migrations/20241120010831-create-yearly-usage-procedure.js +++ b/migrations/20241120010831-create-yearly-usage-procedure.js @@ -18,7 +18,7 @@ module.exports = { WHERE period >= date_trunc('year', CURRENT_DATE) - INTERVAL '1 year' AND period < date_trunc('year', CURRENT_DATE) - AND type = 'monthly' + AND type IN ('monthly', 'daily') GROUP BY user_id ) @@ -46,7 +46,7 @@ module.exports = { WHERE period >= date_trunc('year', CURRENT_DATE) - INTERVAL '1 year' AND period < date_trunc('year', CURRENT_DATE) - AND type = 'monthly'; + AND type IN ('monthly', 'daily'); END; $$; `); diff --git a/migrations/20241120160129-create-updated-at-index-non-deleted-files.js b/migrations/20241120160129-create-updated-at-index-non-deleted-files.js deleted file mode 100644 index 19f7a912..00000000 --- a/migrations/20241120160129-create-updated-at-index-non-deleted-files.js +++ /dev/null @@ -1,19 +0,0 @@ -'use strict'; - -/** @type {import('sequelize-cli').Migration} */ -module.exports = { - async up(queryInterface) { - await queryInterface.sequelize.query( - ` - CREATE INDEX CONCURRENTLY files_updated_at_index - ON files USING btree (status, updated_at) - `, - ); - }, - - async down(queryInterface) { - await queryInterface.sequelize.query( - `DROP INDEX CONCURRENTLY files_updated_at_index`, - ); - }, -}; diff --git a/src/modules/usage/usage.domain.spec.ts b/src/modules/usage/usage.domain.spec.ts new file mode 100644 index 00000000..ad794450 --- /dev/null +++ b/src/modules/usage/usage.domain.spec.ts @@ -0,0 +1,44 @@ +import { v4 } from 'uuid'; +import { Usage, UsageType } from './usage.domain'; + +describe('Usage Domain', () => { + const usageAttributes = { + id: v4(), + userId: v4(), + delta: 100, + period: new Date(), + type: UsageType.Daily, + createdAt: new Date(), + updatedAt: new Date(), + }; + + it('When Usage type is Yearly, then isYearly should return true', () => { + const usage = Usage.build({ ...usageAttributes, type: UsageType.Yearly }); + + expect(usage.isYearly()).toBe(true); + expect(usage.isMonthly()).toBe(false); + expect(usage.isDaily()).toBe(false); + }); + + it('When Usage type is Monthly, then isMonthly should return true', () => { + const usage = Usage.build({ ...usageAttributes, type: UsageType.Monthly }); + + expect(usage.isYearly()).toBe(false); + expect(usage.isMonthly()).toBe(true); + expect(usage.isDaily()).toBe(false); + }); + + it('When Usage type is Daily, then isDaily should return true', () => { + const usage = Usage.build({ ...usageAttributes, type: UsageType.Daily }); + + expect(usage.isYearly()).toBe(false); + expect(usage.isMonthly()).toBe(false); + expect(usage.isDaily()).toBe(true); + }); + + it('When instance is created, then period should be parsed to date', () => { + const usage = Usage.build(usageAttributes); + + expect(usage.period).toBeInstanceOf(Date); + }); +}); diff --git a/src/modules/usage/usage.domain.ts b/src/modules/usage/usage.domain.ts index aef0a7a7..218170de 100644 --- a/src/modules/usage/usage.domain.ts +++ b/src/modules/usage/usage.domain.ts @@ -55,16 +55,4 @@ export class Usage implements UsageAttributes { isDaily(): boolean { return this.type === UsageType.Daily; } - - toJSON(): Partial { - return { - id: this.id, - userId: this.userId, - delta: this.delta, - period: this.period, - type: this.type, - createdAt: this.createdAt, - updatedAt: this.updatedAt, - }; - } } diff --git a/src/modules/usage/usage.repository.ts b/src/modules/usage/usage.repository.ts index 1870842c..47194889 100644 --- a/src/modules/usage/usage.repository.ts +++ b/src/modules/usage/usage.repository.ts @@ -1,7 +1,8 @@ import { InjectModel } from '@nestjs/sequelize'; import { Injectable } from '@nestjs/common'; import { UsageModel } from './usage.model'; -import { Usage } from './usage.domain'; +import { Usage, UsageType } from './usage.domain'; +import { Op } from 'sequelize'; @Injectable() export class SequelizeUsageRepository { @@ -24,9 +25,14 @@ export class SequelizeUsageRepository { return this.toDomain(newUsage); } - public async getMostRecentUsage(userUuid: string): Promise { + public async getMostRecentMonthlyOrYearlyUsage( + userUuid: string, + ): Promise { const mostRecentUsage = await this.usageModel.findOne({ - where: { userId: userUuid }, + where: { + userId: userUuid, + [Op.or]: [{ type: UsageType.Monthly }, { type: UsageType.Yearly }], + }, order: [['period', 'DESC']], }); @@ -45,7 +51,7 @@ export class SequelizeUsageRepository { return mostRecentUsage ? this.toDomain(mostRecentUsage) : null; } - public async addFirstDailyUsage(userUuid: string): Promise { + public async addFirstMonthlyUsage(userUuid: string): Promise { const query = ` INSERT INTO public.usages (id, user_id, delta, period, type, created_at, updated_at) SELECT @@ -79,66 +85,85 @@ export class SequelizeUsageRepository { public async getUserUsage(userUuid: string) { const query = ` WITH yearly_sums AS ( - SELECT - date_trunc('year', period) AS year, - SUM(delta) AS total_delta - FROM - public.usages - WHERE - type = 'yearly' - AND user_id = :userUuid - GROUP BY - date_trunc('year', period) - ), - monthly_sums AS ( - SELECT - date_trunc('year', period) AS year, - SUM(delta) AS total_delta - FROM - public.usages - WHERE - type = 'monthly' - AND user_id = :userUuid - GROUP BY - date_trunc('year', period) - ), - filtered_monthly_sums AS ( - SELECT - m.year, - m.total_delta - FROM - monthly_sums m - LEFT JOIN yearly_sums y ON m.year = y.year - WHERE y.year IS NULL - ), - combined_sums AS ( - SELECT - year, - total_delta - FROM - yearly_sums - UNION ALL - SELECT - year, - total_delta - FROM - filtered_monthly_sums - ) - SELECT - SUM( - CASE - WHEN year < date_trunc('year', CURRENT_DATE) THEN total_delta - ELSE 0 - END - ) AS total_yearly_delta, - SUM( - CASE - WHEN year = date_trunc('year', CURRENT_DATE) THEN total_delta - ELSE 0 - END - ) AS total_monthly_delta - FROM - combined_sums; + SELECT + date_trunc('year', period) AS year, + SUM(delta) AS total_delta + FROM + public.usages + WHERE + type = 'yearly' + AND user_id = :userUuid + GROUP BY + date_trunc('year', period) + ), + monthly_sums AS ( + SELECT + date_trunc('year', period) AS year, + date_trunc('month', period) AS month, + SUM(delta) AS total_delta + FROM + public.usages + WHERE + type = 'monthly' + AND user_id = :userUuid + GROUP BY + date_trunc('year', period), date_trunc('month', period) + ), + daily_sums AS ( + SELECT + date_trunc('year', period) AS year, + date_trunc('month', period) AS month, + SUM(delta) AS total_delta + FROM + public.usages + WHERE + type = 'daily' + AND user_id = :userUuid + GROUP BY + date_trunc('year', period), date_trunc('month', period) + ), + combined_monthly_and_daily AS ( + SELECT + COALESCE(m.year, d.year) AS year, + COALESCE(m.month, d.month) AS month, + COALESCE(m.total_delta, 0) + COALESCE(d.total_delta, 0) AS total_delta + FROM + monthly_sums m + FULL JOIN daily_sums d ON m.year = d.year AND m.month = d.month + ), + combined_sums AS ( + SELECT + y.year, + NULL AS month, + y.total_delta AS total_delta + FROM + yearly_sums y + UNION ALL + SELECT + cmd.year, + cmd.month, + cmd.total_delta + FROM + combined_monthly_and_daily cmd + LEFT JOIN yearly_sums ys ON cmd.year = ys.year + WHERE + ys.year IS NULL -- Exclude months and days where a yearly row exists + ) + SELECT + SUM( + CASE + WHEN year < date_trunc('year', CURRENT_DATE) THEN total_delta + ELSE 0 + END + ) AS total_yearly_delta, + SUM( + CASE + WHEN year = date_trunc('year', CURRENT_DATE) THEN total_delta + ELSE 0 + END + ) AS total_monthly_delta + FROM + combined_sums; `; const [result] = (await this.usageModel.sequelize.query(query, { diff --git a/src/modules/usage/usage.usecase.ts b/src/modules/usage/usage.usecase.ts index b8da4e03..29b2fe85 100644 --- a/src/modules/usage/usage.usecase.ts +++ b/src/modules/usage/usage.usecase.ts @@ -18,18 +18,28 @@ export class UsageUseCases { const userUuid = user.uuid; let mostRecentUsage = - await this.usageRepository.getMostRecentUsage(userUuid); + await this.usageRepository.getMostRecentMonthlyOrYearlyUsage(userUuid); if (!mostRecentUsage) { - mostRecentUsage = await this.usageRepository.addFirstDailyUsage(userUuid); + mostRecentUsage = + await this.usageRepository.addFirstMonthlyUsage(userUuid); } - const mostRecentUsageNextDay = new Date(mostRecentUsage.period); - mostRecentUsageNextDay.setUTCDate(mostRecentUsageNextDay.getUTCDate() + 1); + const calculateSizeChangesSince = new Date(mostRecentUsage.period); + + if (mostRecentUsage.isYearly()) { + calculateSizeChangesSince.setUTCFullYear( + calculateSizeChangesSince.getUTCFullYear() + 1, + ); + } else { + calculateSizeChangesSince.setUTCDate( + calculateSizeChangesSince.getUTCDate() + 1, + ); + } const totalStorageChanged = await this.fileRepository.sumFileSizesSinceDate( user.id, - mostRecentUsageNextDay, + calculateSizeChangesSince, ); const totalUsage = await this.usageRepository.getUserUsage(userUuid); @@ -108,15 +118,23 @@ export class UsageUseCases { user: User, oldFileData: File, newFileData: File, - ) { - const isFileCreatedToday = Time.isToday(newFileData.createdAt); + ): Promise { + const maybeExistentUsage = + await this.usageRepository.getMostRecentMonthlyOrYearlyUsage(user.uuid); - if (isFileCreatedToday) { - return; + if (!maybeExistentUsage) { + return null; } const delta = Number(newFileData.size) - Number(oldFileData.size); + // Files created the same day are going to be included in the next cronjob run + const isFileCreatedToday = Time.isToday(newFileData.createdAt); + + if (delta === 0 || isFileCreatedToday) { + return null; + } + return this.createDailyUsage(user.uuid, new Date(), delta); } } From b3100c6dbc0e208aa5b37cbf67aa2309040283cf Mon Sep 17 00:00:00 2001 From: Andres Pinto Date: Mon, 9 Dec 2024 14:58:51 -0400 Subject: [PATCH 6/7] chore: added test cases for usage --- src/modules/usage/usage.usecase.spec.ts | 275 ++++++++++++++++++++++++ src/modules/usage/usage.usecase.ts | 45 ---- test/fixtures.spec.ts | 56 +++++ test/fixtures.ts | 27 +++ 4 files changed, 358 insertions(+), 45 deletions(-) create mode 100644 src/modules/usage/usage.usecase.spec.ts diff --git a/src/modules/usage/usage.usecase.spec.ts b/src/modules/usage/usage.usecase.spec.ts new file mode 100644 index 00000000..03316414 --- /dev/null +++ b/src/modules/usage/usage.usecase.spec.ts @@ -0,0 +1,275 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { createMock } from '@golevelup/ts-jest'; +import { UsageUseCases } from './usage.usecase'; +import { SequelizeUsageRepository } from './usage.repository'; +import { SequelizeFileRepository } from '../file/file.repository'; +import { Usage, UsageType } from './usage.domain'; +import { newFile, newUsage, newUser } from '../../../test/fixtures'; +import { v4 } from 'uuid'; + +describe('UsageUseCases', () => { + let usageUseCases: UsageUseCases; + let usageRepository: SequelizeUsageRepository; + let fileRepository: SequelizeFileRepository; + + const userMocked = newUser({ + attributes: { uuid: v4(), id: 1, email: 'test@example.com' }, + }); + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [UsageUseCases], + }) + .useMocker(createMock) + .compile(); + + usageUseCases = module.get(UsageUseCases); + usageRepository = module.get( + SequelizeUsageRepository, + ); + fileRepository = module.get( + SequelizeFileRepository, + ); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('getUserUsage', () => { + it('When user has existing usage, it calculates usage correctly', async () => { + const existingUsage = newUsage({ + attributes: { + delta: 100, + type: UsageType.Monthly, + userId: userMocked.uuid, + }, + }); + + jest + .spyOn(usageRepository, 'getMostRecentMonthlyOrYearlyUsage') + .mockResolvedValueOnce(existingUsage); + jest + .spyOn(fileRepository, 'sumFileSizesSinceDate') + .mockResolvedValueOnce(200); + jest.spyOn(usageRepository, 'getUserUsage').mockResolvedValueOnce({ + total_monthly_delta: 300, + total_yearly_delta: 500, + }); + + const result = await usageUseCases.getUserUsage(userMocked); + + expect(result).toEqual({ drive: 1000, id: userMocked.email }); + }); + + it('When user has no existing usage, it creates first monhtly usage and calculates correctly', async () => { + const firstMonthlyUsage = newUsage({ + attributes: { + delta: 0, + type: UsageType.Monthly, + userId: userMocked.uuid, + }, + }); + + jest + .spyOn(usageRepository, 'getMostRecentMonthlyOrYearlyUsage') + .mockResolvedValueOnce(null); + jest + .spyOn(usageRepository, 'addFirstMonthlyUsage') + .mockResolvedValueOnce(firstMonthlyUsage); + jest + .spyOn(fileRepository, 'sumFileSizesSinceDate') + .mockResolvedValueOnce(100); + jest.spyOn(usageRepository, 'getUserUsage').mockResolvedValueOnce({ + total_monthly_delta: 200, + total_yearly_delta: 300, + }); + + const result = await usageUseCases.getUserUsage(userMocked); + + expect(result).toEqual({ drive: 600, id: userMocked.email }); + expect(usageRepository.addFirstMonthlyUsage).toHaveBeenCalledWith( + userMocked.uuid, + ); + }); + + it('When user has yearly usage, it calculates usage correctly', async () => { + const yearlyUsage = newUsage({ + attributes: { + delta: 500, + type: UsageType.Yearly, + userId: userMocked.uuid, + period: new Date(), + }, + }); + + jest + .spyOn(usageRepository, 'getMostRecentMonthlyOrYearlyUsage') + .mockResolvedValueOnce(yearlyUsage); + jest + .spyOn(fileRepository, 'sumFileSizesSinceDate') + .mockResolvedValueOnce(400); + jest.spyOn(usageRepository, 'getUserUsage').mockResolvedValueOnce({ + total_monthly_delta: 200, + total_yearly_delta: 500, + }); + + const result = await usageUseCases.getUserUsage(userMocked); + + expect(result).toEqual({ drive: 1100, id: userMocked.email }); + }); + + it('When user has monthly usage, it calculates usage correctly and calculates file changes since next day', async () => { + const monthlyUsage = newUsage({ + attributes: { + delta: 200, + type: UsageType.Monthly, + userId: userMocked.uuid, + period: new Date('2023-01-01T00:00:00Z'), + }, + }); + const expectedCalculateDate = new Date('2023-01-02T00:00:00Z'); + + jest + .spyOn(usageRepository, 'getMostRecentMonthlyOrYearlyUsage') + .mockResolvedValueOnce(monthlyUsage); + const sumFileSizesSinceSpy = jest + .spyOn(fileRepository, 'sumFileSizesSinceDate') + .mockResolvedValueOnce(300); + jest.spyOn(usageRepository, 'getUserUsage').mockResolvedValueOnce({ + total_monthly_delta: 200, + total_yearly_delta: 500, + }); + + const result = await usageUseCases.getUserUsage(userMocked); + + expect(result).toEqual({ drive: 1000, id: userMocked.email }); + expect(sumFileSizesSinceSpy).toHaveBeenCalledWith( + userMocked.id, + expectedCalculateDate, + ); + }); + + it('When user has yearly usage, it calculates usage correctly and calculates file changes since next year', async () => { + const yearlyUsage = newUsage({ + attributes: { + delta: 500, + type: UsageType.Yearly, + userId: userMocked.uuid, + period: new Date('2023-01-01T00:00:00Z'), + }, + }); + const expectedCalculateDate = new Date('2024-01-01T00:00:00Z'); + + jest + .spyOn(usageRepository, 'getMostRecentMonthlyOrYearlyUsage') + .mockResolvedValueOnce(yearlyUsage); + const sumFileSizesSinceSpy = jest + .spyOn(fileRepository, 'sumFileSizesSinceDate') + .mockResolvedValueOnce(400); + jest.spyOn(usageRepository, 'getUserUsage').mockResolvedValueOnce({ + total_monthly_delta: 200, + total_yearly_delta: 500, + }); + + const result = await usageUseCases.getUserUsage(userMocked); + + expect(result).toEqual({ drive: 1100, id: userMocked.email }); + expect(sumFileSizesSinceSpy).toHaveBeenCalledWith( + userMocked.id, + expectedCalculateDate, + ); + }); + }); + + describe('createDailyUsage', () => { + it('When daily usage is created, it should return the created usage', async () => { + const dailyUsage = newUsage({ + attributes: { + delta: 50, + type: UsageType.Daily, + userId: userMocked.uuid, + }, + }); + + jest.spyOn(usageRepository, 'create').mockResolvedValueOnce(dailyUsage); + + const result = await usageUseCases.createDailyUsage( + userMocked.uuid, + new Date(), + 50, + ); + + expect(result).toEqual(dailyUsage); + }); + }); + + describe('addDailyUsageChangeOnFileSizeChange', () => { + it('When file size changes and delta is non-zero, it creates a daily usage', async () => { + const oldFile = newFile({ attributes: { size: BigInt(100) } }); + const fileChanged = newFile({ attributes: { size: BigInt(200) } }); + const dailyUsage = newUsage({ + attributes: { + delta: 100, + type: UsageType.Daily, + userId: userMocked.uuid, + }, + }); + const monthlyUsage = newUsage({ + attributes: { + delta: 100, + type: UsageType.Monthly, + userId: userMocked.uuid, + }, + }); + + jest + .spyOn(usageRepository, 'getMostRecentMonthlyOrYearlyUsage') + .mockResolvedValueOnce(monthlyUsage); + jest + .spyOn(usageUseCases, 'createDailyUsage') + .mockResolvedValueOnce(dailyUsage); + + await usageUseCases.addDailyUsageChangeOnFileSizeChange( + userMocked, + oldFile, + fileChanged, + ); + + expect(usageUseCases.createDailyUsage).toHaveBeenCalledWith( + userMocked.uuid, + expect.any(Date), + 100, + ); + }); + + it('When file size does not change, it should not create daily usage', async () => { + const oldFile = newFile({ attributes: { size: BigInt(100) } }); + const fileChanged = newFile({ attributes: { size: BigInt(100) } }); + + jest + .spyOn(usageRepository, 'getMostRecentMonthlyOrYearlyUsage') + .mockResolvedValueOnce( + Usage.build({ + id: v4(), + userId: userMocked.uuid, + delta: 0, + period: new Date(), + type: UsageType.Daily, + createdAt: new Date(), + updatedAt: new Date(), + }), + ); + jest.spyOn(usageUseCases, 'createDailyUsage'); + + const result = await usageUseCases.addDailyUsageChangeOnFileSizeChange( + userMocked, + oldFile, + fileChanged, + ); + + expect(result).toBeNull(); + expect(usageUseCases.createDailyUsage).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/src/modules/usage/usage.usecase.ts b/src/modules/usage/usage.usecase.ts index 29b2fe85..71525b6a 100644 --- a/src/modules/usage/usage.usecase.ts +++ b/src/modules/usage/usage.usecase.ts @@ -69,51 +69,6 @@ export class UsageUseCases { return createdDailyUsage; } - /* async addDailyUsageChangeOnFileSizeChange( - user: User, - oldFileData: File, - newFileData: File, - ) { - const mostRecentDailyUsage = await this.usageRepository.getUsage( - { - type: UsageType.Daily, - userId: user.uuid, - }, - [['createdAt', 'DESC']], - ); - - let calculateChangesSince: Date = mostRecentDailyUsage?.createdAt; - const now = new Date(); - - if ( - !calculateChangesSince || - new Date(calculateChangesSince).toDateString() !== now.toDateString() - ) { - calculateChangesSince = new Date(now); - calculateChangesSince.setUTCHours(0, 0, 0, 0); - } - - const totalStorageChanged = await this.fileRepository.sumFileSizesSinceDate( - user.id, - calculateChangesSince, - ); - - if (newFileData.createdAt.toDateString() !== now.toDateString()) { - const delta = - Number(newFileData.size) - - Number(oldFileData.size) + - totalStorageChanged; - - return this.createDailyUsage(user.uuid, new Date(), delta); - } - - const delta = totalStorageChanged; - - console.log({ totalStorageChanged, delta }); - - return this.createDailyUsage(user.uuid, new Date(), delta); - } */ - async addDailyUsageChangeOnFileSizeChange( user: User, oldFileData: File, diff --git a/test/fixtures.spec.ts b/test/fixtures.spec.ts index 2677738e..f0134f5c 100644 --- a/test/fixtures.spec.ts +++ b/test/fixtures.spec.ts @@ -10,6 +10,8 @@ import { } from '../src/modules/workspaces/attributes/workspace-items-users.attributes'; import * as fixtures from './fixtures'; import { SharingActionName } from '../src/modules/sharing/sharing.domain'; +import { newUsage } from './fixtures'; +import { UsageType } from '../src/modules/usage/usage.domain'; describe('Testing fixtures tests', () => { describe("User's fixture", () => { @@ -582,4 +584,58 @@ describe('Testing fixtures tests', () => { expect(role.name).toBe(customName); }); }); + + describe('Usage Fixture', () => { + it('When it generates a usage, then it should be random', () => { + const usage = newUsage(); + const otherUsage = newUsage(); + + expect(usage.id).toBeTruthy(); + expect(usage.id).not.toBe(otherUsage.id); + expect(usage.userId).not.toBe(otherUsage.userId); + }); + + it('When it generates a usage, then the delta should be within the range', () => { + const usage = newUsage(); + + expect(usage.delta).toBeGreaterThanOrEqual(0); + expect(usage.delta).toBeLessThanOrEqual(1000); + }); + + it('When it generates a usage and a type is provided, then that type should be set', () => { + const usage = newUsage({ attributes: { type: UsageType.Monthly } }); + + expect(usage.type).toBe(UsageType.Monthly); + }); + + it('When it generates a usage, then the period should be populated', () => { + const usage = newUsage(); + + expect(usage.period).toBeInstanceOf(Date); + expect(usage.period).toBeTruthy(); + }); + + it('When it generates a usage and custom attributes are provided, then those attributes should be set correctly', () => { + const customAttributes = { + delta: 500, + type: UsageType.Yearly, + period: new Date('2023-01-01T00:00:00Z'), + }; + + const usage = newUsage({ attributes: customAttributes }); + + expect(usage.delta).toBe(customAttributes.delta); + expect(usage.type).toBe(customAttributes.type); + expect(usage.period).toEqual(customAttributes.period); + }); + + it('When it generates multiple usages, then all their identifiers should be unique', () => { + const usages = Array.from({ length: 10 }, () => newUsage()); + + const ids = usages.map((usage) => usage.id); + const uniqueIds = new Set(ids); + + expect(uniqueIds.size).toBe(usages.length); + }); + }); }); diff --git a/test/fixtures.ts b/test/fixtures.ts index f97023fb..d1e3e57b 100644 --- a/test/fixtures.ts +++ b/test/fixtures.ts @@ -34,6 +34,7 @@ import { WorkspaceItemUser } from '../src/modules/workspaces/domains/workspace-i import { PreCreatedUser } from '../src/modules/user/pre-created-user.domain'; import { UserNotificationTokens } from '../src/modules/user/user-notification-tokens.domain'; import { UserNotificationTokenAttributes } from '../src/modules/user/user-notification-tokens.attribute'; +import { Usage, UsageType } from '../src/modules/usage/usage.domain'; export const constants = { BUCKET_ID_LENGTH: 24, @@ -540,3 +541,29 @@ export const newNotificationToken = ( }); return token; }; + +export const newUsage = (params?: { attributes?: Partial }): Usage => { + const randomCreatedAt = randomDataGenerator.date(); + const randomPeriod = randomDataGenerator.date(); + + const usage = Usage.build({ + id: v4(), + userId: v4(), + delta: randomDataGenerator.integer({ min: 0, max: 1000 }), + period: randomPeriod, + type: UsageType.Daily, + createdAt: randomCreatedAt, + updatedAt: new Date( + randomDataGenerator.date({ + min: randomCreatedAt, + }), + ), + }); + + params?.attributes && + Object.keys(params.attributes).forEach((key) => { + usage[key] = params.attributes[key]; + }); + + return usage; +}; From f968a38e95d86a59208b34198a167ea5be1e56bc Mon Sep 17 00:00:00 2001 From: Andres Pinto Date: Tue, 10 Dec 2024 01:09:36 -0400 Subject: [PATCH 7/7] chore: added tests for usage repository --- src/modules/file/file.usecase.spec.ts | 67 +++++++++ src/modules/usage/usage.repository.spec.ts | 154 +++++++++++++++++++++ src/modules/usage/usage.usecase.spec.ts | 2 +- 3 files changed, 222 insertions(+), 1 deletion(-) create mode 100644 src/modules/usage/usage.repository.spec.ts diff --git a/src/modules/file/file.usecase.spec.ts b/src/modules/file/file.usecase.spec.ts index 161d97bf..62604902 100644 --- a/src/modules/file/file.usecase.spec.ts +++ b/src/modules/file/file.usecase.spec.ts @@ -36,6 +36,7 @@ import { SharingService } from '../sharing/sharing.service'; import { SharingItemType } from '../sharing/sharing.domain'; import { CreateFileDto } from './dto/create-file.dto'; import { UpdateFileMetaDto } from './dto/update-file-meta.dto'; +import { UsageUseCases } from '../usage/usage.usecase'; const fileId = '6295c99a241bb000083f1c6a'; const userId = 1; @@ -48,6 +49,8 @@ describe('FileUseCases', () => { let sharingService: SharingService; let bridgeService: BridgeService; let cryptoService: CryptoService; + let usageUsecases: UsageUseCases; + let networkService: BridgeService; const userMocked = User.build({ id: 1, @@ -94,6 +97,8 @@ describe('FileUseCases', () => { bridgeService = module.get(BridgeService); cryptoService = module.get(CryptoService); sharingService = module.get(SharingService); + usageUsecases = module.get(UsageUseCases); + networkService = module.get(BridgeService); }); afterEach(() => { @@ -1114,4 +1119,66 @@ describe('FileUseCases', () => { ); }); }); + + describe('replaceFile', () => { + const originalFile = newFile({ + attributes: { + size: BigInt(500), + status: FileStatus.EXISTS, + }, + }); + + const newFileData = { + fileId: v4(), + size: BigInt(1000), + }; + + it('When the file does not exist, it should throw', async () => { + jest.spyOn(fileRepository, 'findByUuid').mockResolvedValueOnce(null); + + await expect( + service.replaceFile(userMocked, originalFile.uuid, newFileData), + ).rejects.toThrow(NotFoundException); + }); + + it('When the file was deleted or trashed, it should throw', async () => { + const trashedFile = newFile({ + attributes: { ...originalFile, status: FileStatus.DELETED }, + }); + + jest + .spyOn(fileRepository, 'findByUuid') + .mockResolvedValueOnce(trashedFile); + + await expect( + service.replaceFile(userMocked, originalFile.uuid, newFileData), + ).rejects.toThrow(NotFoundException); + }); + + it('When the file exists and is valid, it updates the file data correctly', async () => { + jest + .spyOn(fileRepository, 'findByUuid') + .mockResolvedValueOnce(originalFile); + jest.spyOn(networkService, 'deleteFile').mockResolvedValueOnce(null); + jest.spyOn(fileRepository, 'updateByUuidAndUserId'); + jest + .spyOn(usageUsecases, 'addDailyUsageChangeOnFileSizeChange') + .mockResolvedValueOnce(null); + + await service.replaceFile(userMocked, originalFile.uuid, newFileData); + + expect(fileRepository.updateByUuidAndUserId).toHaveBeenCalledWith( + originalFile.uuid, + userMocked.id, + newFileData, + ); + expect( + usageUsecases.addDailyUsageChangeOnFileSizeChange, + ).toHaveBeenCalledWith( + userMocked, + originalFile, + expect.objectContaining(newFileData), + ); + }); + }); }); diff --git a/src/modules/usage/usage.repository.spec.ts b/src/modules/usage/usage.repository.spec.ts new file mode 100644 index 00000000..dd0ca1bc --- /dev/null +++ b/src/modules/usage/usage.repository.spec.ts @@ -0,0 +1,154 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { SequelizeUsageRepository } from './usage.repository'; +import { UsageModel } from './usage.model'; +import { getModelToken } from '@nestjs/sequelize'; +import { createMock } from '@golevelup/ts-jest'; +import { Usage, UsageType } from './usage.domain'; +import { newUsage, newUser } from '../../../test/fixtures'; + +const mockUsageInstance = (usageData): Partial => ({ + ...usageData, + toJSON: jest.fn().mockReturnValue(usageData), +}); + +const mockedUser = newUser(); +const mockSequelizeQuery = jest.fn(); + +describe('SequelizeUsageRepository', () => { + let repository: SequelizeUsageRepository; + let usageModel: typeof UsageModel; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [SequelizeUsageRepository], + }) + .useMocker((token) => { + if (token === getModelToken(UsageModel)) { + return { + findAll: jest.fn(), + findOne: jest.fn(), + create: jest.fn(), + sequelize: { + query: mockSequelizeQuery, + }, + }; + } + return createMock(); + }) + .compile(); + + repository = module.get(SequelizeUsageRepository); + usageModel = module.get(getModelToken(UsageModel)); + }); + + describe('getUserUsages', () => { + it('When usages are found for a user, it should return successfully', async () => { + const mockUsages = [ + mockUsageInstance(newUsage()), + mockUsageInstance(newUsage()), + ]; + jest + .spyOn(usageModel, 'findAll') + .mockResolvedValueOnce(mockUsages as any); + + const result = await repository.getUserUsages(mockedUser.uuid); + + expect(result).toHaveLength(mockUsages.length); + expect(result[0]).toBeInstanceOf(Usage); + }); + + it('When no usages are found for a user, it should return nothing', async () => { + jest.spyOn(usageModel, 'findAll').mockResolvedValueOnce([]); + + const result = await repository.getUserUsages(mockedUser.uuid); + + expect(result).toEqual([]); + }); + }); + + describe('create', () => { + it('When usage is created, it should return successfully', async () => { + const usage = newUsage(); + const mockUsage = mockUsageInstance(usage); + jest.spyOn(usageModel, 'create').mockResolvedValueOnce(mockUsage as any); + + const result = await repository.create(usage); + + expect(result).toBeInstanceOf(Usage); + expect(result.id).toEqual(mockUsage.id); + }); + }); + + describe('getMostRecentMonthlyOrYearlyUsage', () => { + it('When the most recent monthly or yearly usage exists, it should return successfully', async () => { + const mockUsage = mockUsageInstance( + newUsage({ attributes: { type: UsageType.Monthly } }), + ); + jest.spyOn(usageModel, 'findOne').mockResolvedValueOnce(mockUsage as any); + + const result = await repository.getMostRecentMonthlyOrYearlyUsage( + mockedUser.uuid, + ); + + expect(result).toBeInstanceOf(Usage); + expect(result.type).toBe(UsageType.Monthly); + }); + + it('When no recent monthly or yearly usage exists, it should return nothing', async () => { + jest.spyOn(usageModel, 'findOne').mockResolvedValueOnce(null); + + const result = await repository.getMostRecentMonthlyOrYearlyUsage( + mockedUser.uuid, + ); + + expect(result).toBeNull(); + }); + }); + + describe('getUsage', () => { + it('When a usage is found, it should return successfully', async () => { + const mockUsage = mockUsageInstance(newUsage()); + jest.spyOn(usageModel, 'findOne').mockResolvedValueOnce(mockUsage as any); + + const result = await repository.getUsage({ type: UsageType.Daily }); + + expect(result).toBeInstanceOf(Usage); + expect(result.type).toBe(UsageType.Daily); + }); + + it('When no usage is found, it should return null', async () => { + jest.spyOn(usageModel, 'findOne').mockResolvedValueOnce(null); + + const result = await repository.getUsage({ type: UsageType.Daily }); + + expect(result).toBeNull(); + expect(usageModel.findOne).toHaveBeenCalledWith({ + where: { type: UsageType.Daily }, + order: undefined, + }); + }); + }); + + describe('getUserUsage', () => { + it('When user usage is requested, it should return the aggregated totals', async () => { + mockSequelizeQuery.mockResolvedValueOnce([ + [ + { + total_yearly_delta: 500, + total_monthly_delta: 200, + }, + ], + ]); + + const result = await repository.getUserUsage(mockedUser.uuid); + + expect(mockSequelizeQuery).toHaveBeenCalledWith(expect.any(String), { + replacements: { userUuid: mockedUser.uuid }, + }); + expect(result).toEqual({ + total_yearly_delta: 500, + total_monthly_delta: 200, + }); + }); + }); +}); diff --git a/src/modules/usage/usage.usecase.spec.ts b/src/modules/usage/usage.usecase.spec.ts index 03316414..56f1a31c 100644 --- a/src/modules/usage/usage.usecase.spec.ts +++ b/src/modules/usage/usage.usecase.spec.ts @@ -20,7 +20,7 @@ describe('UsageUseCases', () => { const module: TestingModule = await Test.createTestingModule({ providers: [UsageUseCases], }) - .useMocker(createMock) + .useMocker(() => createMock()) .compile(); usageUseCases = module.get(UsageUseCases);