From e07260fbb587af00b5369b6e559e1a2c28b6e020 Mon Sep 17 00:00:00 2001 From: GPlay97 Date: Sat, 26 Apr 2025 23:20:45 +0200 Subject: [PATCH 1/2] =?UTF-8?q?=E2=9C=A8=20Premium=20handling=20with=20dif?= =?UTF-8?q?ferent=20prepared=20mechanisms=20and=20tests?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/account/account.controller.ts | 3 +- src/account/schemas/account.schema.ts | 3 + src/app.module.ts | 2 + src/migration/dto/migrate-soc.dto.ts | 2 +- .../migration-account-not-found.exception.ts | 2 +- src/migration/migration.module.ts | 6 +- src/migration/migration.service.ts | 4 +- .../schemas/migration-account.schema.ts | 2 +- src/premium/dto/premium-status.dto.ts | 7 + .../entities/premium-duration.entity.ts | 6 + .../exceptions/ad-not-redeemable.exception.ts | 7 + .../voucher-already-redeemed.exception.ts | 7 + .../voucher-not-exists.exception.ts | 7 + src/premium/premium.controller.spec.ts | 172 ++++++++++++++++++ src/premium/premium.controller.ts | 100 ++++++++++ src/premium/premium.module.ts | 19 ++ src/premium/premium.service.spec.ts | 30 +++ src/premium/premium.service.ts | 90 +++++++++ src/premium/schemas/voucher.schema.ts | 39 ++++ 19 files changed, 499 insertions(+), 9 deletions(-) create mode 100644 src/premium/dto/premium-status.dto.ts create mode 100644 src/premium/entities/premium-duration.entity.ts create mode 100644 src/premium/exceptions/ad-not-redeemable.exception.ts create mode 100644 src/premium/exceptions/voucher-already-redeemed.exception.ts create mode 100644 src/premium/exceptions/voucher-not-exists.exception.ts create mode 100644 src/premium/premium.controller.spec.ts create mode 100644 src/premium/premium.controller.ts create mode 100644 src/premium/premium.module.ts create mode 100644 src/premium/premium.service.spec.ts create mode 100644 src/premium/premium.service.ts create mode 100644 src/premium/schemas/voucher.schema.ts diff --git a/src/account/account.controller.ts b/src/account/account.controller.ts index 8d3a27e..54f2779 100644 --- a/src/account/account.controller.ts +++ b/src/account/account.controller.ts @@ -12,6 +12,7 @@ import { UnauthorizedException, BadRequestException, HttpCode, + HttpStatus, } from '@nestjs/common'; import { ApiBearerAuth, ApiTags } from '@nestjs/swagger'; import { Exception } from '../utils/exception'; @@ -79,7 +80,7 @@ export class AccountController { @Post(':akey/login') @Guest() - @HttpCode(200) + @HttpCode(HttpStatus.OK) async login(@Param('akey') akey: string, @Body() loginDto: LoginPasswordDto) { try { const account = await this.accountService.loginWithPassword( diff --git a/src/account/schemas/account.schema.ts b/src/account/schemas/account.schema.ts index f548cea..8a9d4ca 100644 --- a/src/account/schemas/account.schema.ts +++ b/src/account/schemas/account.schema.ts @@ -19,6 +19,9 @@ export class Account { @Prop({ required: true, minlength: TOKEN_LENGTH, maxlength: TOKEN_LENGTH }) token: string; + + @Prop({ type: Date, default: null }) + premiumUntil?: Date; } export const AccountSchema = SchemaFactory.createForClass(Account); diff --git a/src/app.module.ts b/src/app.module.ts index 1c72feb..ff84186 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -7,6 +7,7 @@ import { SettingsModule } from './settings/settings.module'; import { LogsModule } from './logs/logs.module'; import { ScheduleModule } from '@nestjs/schedule'; import { MigrationModule } from './migration/migration.module'; +import { PremiumModule } from './premium/premium.module'; @Module({ imports: [ @@ -18,6 +19,7 @@ import { MigrationModule } from './migration/migration.module'; SettingsModule, LogsModule, MigrationModule, + PremiumModule, ], controllers: [], providers: [], diff --git a/src/migration/dto/migrate-soc.dto.ts b/src/migration/dto/migrate-soc.dto.ts index ce6c26c..d295579 100644 --- a/src/migration/dto/migrate-soc.dto.ts +++ b/src/migration/dto/migrate-soc.dto.ts @@ -1,6 +1,6 @@ import { Type } from "class-transformer"; import { IsNotEmptyObject, IsNumber, IsOptional, Length, Max, Min, ValidateNested } from "class-validator"; -import { AKEY_LENGTH, TOKEN_LENGTH } from "src/account/dto/account.dto"; +import { AKEY_LENGTH, TOKEN_LENGTH } from "../../account/dto/account.dto"; class SocData { @IsOptional() diff --git a/src/migration/exceptions/migration-account-not-found.exception.ts b/src/migration/exceptions/migration-account-not-found.exception.ts index c974067..2177d63 100644 --- a/src/migration/exceptions/migration-account-not-found.exception.ts +++ b/src/migration/exceptions/migration-account-not-found.exception.ts @@ -1,4 +1,4 @@ -import { Exception } from "src/utils/exception"; +import { Exception } from "../../utils/exception"; export class MigrationAccountNotFound extends Exception { constructor() { diff --git a/src/migration/migration.module.ts b/src/migration/migration.module.ts index e9f4754..5b48a7f 100644 --- a/src/migration/migration.module.ts +++ b/src/migration/migration.module.ts @@ -1,9 +1,9 @@ import { Module } from "@nestjs/common"; import { MigrationController } from "./migration.controller"; -import { LogsService } from "src/logs/logs.service"; +import { LogsService } from "../logs/logs.service"; import { MongooseModule } from "@nestjs/mongoose"; -import { Log, LogSchema } from "src/logs/schemas/log.schema"; -import { LastSync, LastSyncSchema } from "src/logs/schemas/last-sync.schema"; +import { Log, LogSchema } from "../logs/schemas/log.schema"; +import { LastSync, LastSyncSchema } from "../logs/schemas/last-sync.schema"; import { MigrationService } from "./migration.service"; import { MigrationAccount, MigrationAccountSchema } from "./schemas/migration-account.schema"; diff --git a/src/migration/migration.service.ts b/src/migration/migration.service.ts index 74aa839..5d56f7c 100644 --- a/src/migration/migration.service.ts +++ b/src/migration/migration.service.ts @@ -3,9 +3,9 @@ import { MigrationAccount } from "./schemas/migration-account.schema"; import { InjectModel } from "@nestjs/mongoose"; import { Model } from "mongoose"; import { MigrationAccountNotFound } from "./exceptions/migration-account-not-found.exception"; -import { LogsService } from "src/logs/logs.service"; +import { LogsService } from "../logs/logs.service"; import { MigrateSocDto } from "./dto/migrate-soc.dto"; -import { SyncDto } from "src/logs/dto/sync.dto"; +import { SyncDto } from "../logs/dto/sync.dto"; @Injectable() export class MigrationService { diff --git a/src/migration/schemas/migration-account.schema.ts b/src/migration/schemas/migration-account.schema.ts index 0f158a2..e78f9a9 100644 --- a/src/migration/schemas/migration-account.schema.ts +++ b/src/migration/schemas/migration-account.schema.ts @@ -1,5 +1,5 @@ import { Prop, Schema, SchemaFactory } from "@nestjs/mongoose"; -import { AKEY_LENGTH, TOKEN_LENGTH } from "src/account/dto/account.dto"; +import { AKEY_LENGTH, TOKEN_LENGTH } from "../../account/dto/account.dto"; @Schema() export class MigrationAccount { diff --git a/src/premium/dto/premium-status.dto.ts b/src/premium/dto/premium-status.dto.ts new file mode 100644 index 0000000..120cff9 --- /dev/null +++ b/src/premium/dto/premium-status.dto.ts @@ -0,0 +1,7 @@ +export class PremiumStatusDto { + constructor(premiumUntil: Date | null) { + this.premiumUntil = premiumUntil; + } + + premiumUntil: Date | null; +} \ No newline at end of file diff --git a/src/premium/entities/premium-duration.entity.ts b/src/premium/entities/premium-duration.entity.ts new file mode 100644 index 0000000..a281075 --- /dev/null +++ b/src/premium/entities/premium-duration.entity.ts @@ -0,0 +1,6 @@ +export enum PREMIUM_DURATION { + FIVE_MINUTES = 5, + ONE_WEEK = 60 * 24 * 7, + ONE_MONTH = 60 * 24 * 30, + ONE_YEAR = 60 * 24 * 365, +} diff --git a/src/premium/exceptions/ad-not-redeemable.exception.ts b/src/premium/exceptions/ad-not-redeemable.exception.ts new file mode 100644 index 0000000..8a347c7 --- /dev/null +++ b/src/premium/exceptions/ad-not-redeemable.exception.ts @@ -0,0 +1,7 @@ +import { Exception } from '../../utils/exception'; + +export class AdNotRedeemableException extends Exception { + constructor() { + super('Ad could not be redeemed. Most likely because you are already premium or ad was not able to be verified.'); + } +} diff --git a/src/premium/exceptions/voucher-already-redeemed.exception.ts b/src/premium/exceptions/voucher-already-redeemed.exception.ts new file mode 100644 index 0000000..e28938b --- /dev/null +++ b/src/premium/exceptions/voucher-already-redeemed.exception.ts @@ -0,0 +1,7 @@ +import { Exception } from '../../utils/exception'; + +export class VoucherAlreadyRedeemedException extends Exception { + constructor() { + super('Entered voucher code was already redeemed.'); + } +} diff --git a/src/premium/exceptions/voucher-not-exists.exception.ts b/src/premium/exceptions/voucher-not-exists.exception.ts new file mode 100644 index 0000000..d4d5515 --- /dev/null +++ b/src/premium/exceptions/voucher-not-exists.exception.ts @@ -0,0 +1,7 @@ +import { Exception } from '../../utils/exception'; + +export class VoucherNotExistsException extends Exception { + constructor() { + super('Voucher code does not exists.'); + } +} diff --git a/src/premium/premium.controller.spec.ts b/src/premium/premium.controller.spec.ts new file mode 100644 index 0000000..010c8a4 --- /dev/null +++ b/src/premium/premium.controller.spec.ts @@ -0,0 +1,172 @@ +import { Test, TestingModule } from "@nestjs/testing"; +import { PremiumModule } from "./premium.module"; +import { ConfigModule } from "@nestjs/config"; +import { MongooseModule } from "@nestjs/mongoose"; +import { PremiumService } from "./premium.service"; +import { PremiumController } from "./premium.controller"; +import { AccountDto } from "../account/dto/account.dto"; +import { CreateAccountDto } from "../account/dto/create-account.dto"; +import { AccountService } from "../account/account.service"; +import mongoose from "mongoose"; +import { PremiumStatusDto } from "./dto/premium-status.dto"; +import { ConflictException, NotFoundException } from "@nestjs/common"; +import { PREMIUM_DURATION } from "./entities/premium-duration.entity"; +import { randomBytes } from "crypto"; +import { Voucher, VOUCHER_LENGTH } from "./schemas/voucher.schema"; + +describe('PremiumController', () => { + let premiumService: PremiumService; + let accountService: AccountService; + let controller: PremiumController; + let testAccount: AccountDto; + let voucher: Voucher; + + async function createAccount() { + const dto = new CreateAccountDto(); + + dto.akey = accountService.akey(); + dto.password = 'password'; + + testAccount = await accountService.create(dto); + } + + beforeAll(async () => { + const module: TestingModule = await Test.createTestingModule({ + imports: [ + PremiumModule, + ConfigModule.forRoot(), + MongooseModule.forRoot(process.env.DATABASE_URI), + ], + }).compile(); + + accountService = module.get(AccountService); + premiumService = module.get(PremiumService); + controller = module.get(PremiumController); + + voucher = await premiumService.generateVoucher(PREMIUM_DURATION.ONE_WEEK); + + await createAccount(); + }); + + afterAll(async () => { + const timer = setTimeout(async () => await mongoose.disconnect(), 1000); + + timer.unref(); + }); + + it('should be defined', () => { + expect(controller).toBeDefined(); + }); + + it('should not be able to retrieve status of non-existing account', async () => { + await expect(async () => { + await controller.status( + testAccount.akey.replace(/.$/, "1"), + ); + }).rejects.toThrow(NotFoundException); + }); + + it('should be able to retrieve status of existing account', async () => { + const response = await controller.status(testAccount.akey); + + expect(response).toBeInstanceOf(PremiumStatusDto); + expect(response).toHaveProperty('premiumUntil', null); + }); + + it('should be able to extend premium of account by watching an ad', async () => { + const response = await controller.redeemAd(testAccount.akey); + + const now = new Date(); + const expectedDate = new Date(now.getTime() + PREMIUM_DURATION.FIVE_MINUTES * 60000); + const toleranceInMs = 2000; + + expect(response).toBeInstanceOf(PremiumStatusDto); + expect(response).toHaveProperty('premiumUntil'); + + expect(response.premiumUntil.getTime()).toBeGreaterThan(expectedDate.getTime() - toleranceInMs); + expect(response.premiumUntil.getTime()).toBeLessThan(expectedDate.getTime() + toleranceInMs); + }); + + it('should not be able to extend premium by watching an ad again', async () => { + await expect(async () => { + await controller.redeemAd( + testAccount.akey, + ); + }).rejects.toThrow(ConflictException); + }); + + it('should be able to retrieve premium status of account', async () => { + const response = await controller.status(testAccount.akey); + + const now = new Date(); + const expectedDate = new Date(now.getTime() + PREMIUM_DURATION.FIVE_MINUTES * 60000); + + const toleranceInMs = 2000; + + expect(response).toBeInstanceOf(PremiumStatusDto); + expect(response).toHaveProperty('premiumUntil'); + + expect(response.premiumUntil.getTime()).toBeGreaterThan(expectedDate.getTime() - toleranceInMs); + expect(response.premiumUntil.getTime()).toBeLessThan(expectedDate.getTime() + toleranceInMs); + }); + + it('should be able to extend premium of account by subscribing', async () => { + const response = await controller.redeemSubscription(testAccount.akey); + + const now = new Date(); + const expectedDate = new Date(now.getTime() + PREMIUM_DURATION.FIVE_MINUTES * 60000 + PREMIUM_DURATION.ONE_MONTH * 60000); + const toleranceInMs = 2000; + + expect(response).toBeInstanceOf(PremiumStatusDto); + expect(response).toHaveProperty('premiumUntil'); + + expect(response.premiumUntil.getTime()).toBeGreaterThan(expectedDate.getTime() - toleranceInMs); + expect(response.premiumUntil.getTime()).toBeLessThan(expectedDate.getTime() + toleranceInMs); + }); + + it('should be able to extend premium of account by subscribing again', async () => { + const response = await controller.redeemSubscription(testAccount.akey); + + const now = new Date(); + const expectedDate = new Date(now.getTime() + PREMIUM_DURATION.FIVE_MINUTES * 60000 + (PREMIUM_DURATION.ONE_MONTH * 2) * 60000); + const toleranceInMs = 2000; + + expect(response).toBeInstanceOf(PremiumStatusDto); + expect(response).toHaveProperty('premiumUntil'); + + expect(response.premiumUntil.getTime()).toBeGreaterThan(expectedDate.getTime() - toleranceInMs); + expect(response.premiumUntil.getTime()).toBeLessThan(expectedDate.getTime() + toleranceInMs); + }); + + it('should not be able to redeem non-existing voucher', async () => { + await expect(async () => { + await controller.redeemVoucher( + testAccount.akey, + randomBytes(VOUCHER_LENGTH / 2).toString('hex'), + ); + }).rejects.toThrow(NotFoundException); + }); + + it('should be able to redeem existing voucher', async () => { + const response = await controller.redeemVoucher(testAccount.akey, voucher.code); + + const now = new Date(); + const expectedDate = new Date(now.getTime() + PREMIUM_DURATION.FIVE_MINUTES * 60000 + (PREMIUM_DURATION.ONE_MONTH * 2) * 60000 + voucher.durationInMinutes * 60000); + const toleranceInMs = 2000; + + expect(response).toBeInstanceOf(PremiumStatusDto); + expect(response).toHaveProperty('premiumUntil'); + + expect(response.premiumUntil.getTime()).toBeGreaterThan(expectedDate.getTime() - toleranceInMs); + expect(response.premiumUntil.getTime()).toBeLessThan(expectedDate.getTime() + toleranceInMs); + }); + + it('should not be able to redeem already used voucher', async () => { + await expect(async () => { + await controller.redeemVoucher( + testAccount.akey, + voucher.code, + ); + }).rejects.toThrow(ConflictException); + }); +}); \ No newline at end of file diff --git a/src/premium/premium.controller.ts b/src/premium/premium.controller.ts new file mode 100644 index 0000000..c479b4d --- /dev/null +++ b/src/premium/premium.controller.ts @@ -0,0 +1,100 @@ +import { ConflictException, Controller, Get, HttpCode, HttpStatus, InternalServerErrorException, NotFoundException, Param, Post, UseGuards } from "@nestjs/common"; +import { ApiBearerAuth, ApiTags } from "@nestjs/swagger"; +import { AuthGuard } from "../account/account.guard"; +import { PremiumService } from "./premium.service"; +import { PremiumStatusDto } from "./dto/premium-status.dto"; +import { AccountNotExistsException } from "../account/exceptions/account-not-exists.exception"; +import { PREMIUM_DURATION } from "./entities/premium-duration.entity"; +import { AdNotRedeemableException } from "./exceptions/ad-not-redeemable.exception"; +import { VoucherNotExistsException } from "./exceptions/voucher-not-exists.exception"; +import { VoucherAlreadyRedeemedException } from "./exceptions/voucher-already-redeemed.exception"; + +@Controller('premium') +@UseGuards(AuthGuard) +@ApiTags('Premium') +export class PremiumController { + constructor( + private readonly premiumService: PremiumService, + ) { } + + @Get(':akey') + @ApiBearerAuth() + async status(@Param('akey') akey: string): Promise { + try { + const expiryDate = await this.premiumService.getExpiryDate(akey); + + return new PremiumStatusDto(expiryDate); + } catch (error) { + if (error instanceof AccountNotExistsException) { + throw new NotFoundException(error.message); + } + + throw new InternalServerErrorException(); + } + } + + @Post(':akey/redeem/ad') + @ApiBearerAuth() + @HttpCode(HttpStatus.OK) + async redeemAd(@Param('akey') akey: string): Promise { + try { + const isPremium = (await this.premiumService.getExpiryDate(akey)) != null; + + if (isPremium) { + throw new AdNotRedeemableException(); + } + + // TODO verify if ad was really watched + const newExpiryDate = await this.premiumService.extendPremium(akey, PREMIUM_DURATION.FIVE_MINUTES); + + return new PremiumStatusDto(newExpiryDate); + } catch (error) { + if (error instanceof AccountNotExistsException) { + throw new NotFoundException(error.message); + } else if (error instanceof AdNotRedeemableException) { + throw new ConflictException(error.message); + } + + throw new InternalServerErrorException(); + } + } + + @Post(':akey/redeem/voucher/:code') + @ApiBearerAuth() + @HttpCode(HttpStatus.OK) + async redeemVoucher(@Param('akey') akey: string, @Param('code') code: string): Promise { + try { + const voucher = await this.premiumService.findVoucher(code); + + const newExpiryDate = await this.premiumService.redeemVoucher(akey, voucher); + + return new PremiumStatusDto(newExpiryDate); + } catch (error) { + if (error instanceof VoucherNotExistsException) { + throw new NotFoundException(error.message); + } else if (error instanceof VoucherAlreadyRedeemedException) { + throw new ConflictException(error.message); + } + + throw new InternalServerErrorException(); + } + } + + @Post(':akey/redeem/subscription') + @ApiBearerAuth() + @HttpCode(HttpStatus.OK) + async redeemSubscription(@Param('akey') akey: string) { + try { + // TODO verify if subscription was really paid + const newExpiryDate = await this.premiumService.extendPremium(akey, PREMIUM_DURATION.ONE_MONTH); + + return new PremiumStatusDto(newExpiryDate); + } catch (error) { + if (error instanceof AccountNotExistsException) { + throw new NotFoundException(error.message); + } + + throw new InternalServerErrorException(); + } + } +} \ No newline at end of file diff --git a/src/premium/premium.module.ts b/src/premium/premium.module.ts new file mode 100644 index 0000000..a28e7f5 --- /dev/null +++ b/src/premium/premium.module.ts @@ -0,0 +1,19 @@ +import { Module } from '@nestjs/common'; +import { MongooseModule } from '@nestjs/mongoose'; +import { PremiumController } from './premium.controller'; +import { PremiumService } from './premium.service'; +import { Account, AccountSchema } from '../account/schemas/account.schema'; +import { AccountModule } from '../account/account.module'; +import { Voucher, VoucherSchema } from './schemas/voucher.schema'; + +@Module({ + imports: [ + MongooseModule.forFeature([{ name: Account.name, schema: AccountSchema }]), + MongooseModule.forFeature([{ name: Voucher.name, schema: VoucherSchema }]), + AccountModule, + ], + controllers: [PremiumController], + providers: [PremiumService], + exports: [PremiumService], +}) +export class PremiumModule {} diff --git a/src/premium/premium.service.spec.ts b/src/premium/premium.service.spec.ts new file mode 100644 index 0000000..fe71ca3 --- /dev/null +++ b/src/premium/premium.service.spec.ts @@ -0,0 +1,30 @@ +import { ConfigModule } from '@nestjs/config'; +import { MongooseModule } from '@nestjs/mongoose'; +import { Test, TestingModule } from '@nestjs/testing'; +import mongoose from 'mongoose'; +import { PremiumService } from './premium.service'; +import { PremiumModule } from './premium.module'; + +describe('PremiumService', () => { + let service: PremiumService; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + imports: [ + PremiumModule, + ConfigModule.forRoot(), + MongooseModule.forRoot(process.env.DATABASE_URI), + ], + }).compile(); + + service = module.get(PremiumService); + }); + + afterAll(async () => { + await mongoose.disconnect(); + }); + + it('should be defined', () => { + expect(service).toBeDefined(); + }); +}); diff --git a/src/premium/premium.service.ts b/src/premium/premium.service.ts new file mode 100644 index 0000000..0f37d24 --- /dev/null +++ b/src/premium/premium.service.ts @@ -0,0 +1,90 @@ +import { Injectable } from "@nestjs/common"; +import { InjectModel } from "@nestjs/mongoose"; +import { Model } from "mongoose"; +import { PREMIUM_DURATION } from "./entities/premium-duration.entity"; +import { Account } from "../account/schemas/account.schema"; +import { AccountNotExistsException } from "../account/exceptions/account-not-exists.exception"; +import { Voucher, VOUCHER_LENGTH } from "./schemas/voucher.schema"; +import { VoucherNotExistsException } from "./exceptions/voucher-not-exists.exception"; +import { VoucherAlreadyRedeemedException } from "./exceptions/voucher-already-redeemed.exception"; +import { randomBytes } from "crypto"; + +@Injectable() +export class PremiumService { + constructor( + @InjectModel(Account.name) private accountModel: Model, + @InjectModel(Voucher.name) private voucherModel: Model, + ) {} + + async getExpiryDate(akey: string): Promise { + const account = await this.accountModel.findOne({ akey }); + + if (!account) { + throw new AccountNotExistsException(); + } + + if (!account.premiumUntil || account.premiumUntil <= new Date()) { + return null; + } + + return account.premiumUntil; + } + + async extendPremium(akey: string, duration: number): Promise { + const currentExpiryDate = await this.getExpiryDate(akey); + const baseDate = currentExpiryDate || new Date(); + const newExpiryDate = new Date(baseDate.getTime() + duration * 60000); + + await this.accountModel.updateOne({ + akey, + }, { + $set: { + premiumUntil: newExpiryDate, + } + }); + + return newExpiryDate; + } + + async generateVoucher(duration: number): Promise { + const voucher = new Voucher(); + + voucher.code = randomBytes(VOUCHER_LENGTH / 2).toString('hex'); + voucher.durationInMinutes = duration; + + return await new this.voucherModel(voucher).save(); + } + + async findVoucher(code: string): Promise { + const voucher = await this.voucherModel.findOne({ code }); + + if (!voucher) { + throw new VoucherNotExistsException(); + } + + if (voucher.redeemedAt != null || voucher.redeemedBy != null) { + throw new VoucherAlreadyRedeemedException(); + } + + return voucher; + } + + async redeemVoucher(akey: string, voucher: Voucher): Promise { + if (voucher.redeemedAt != null || voucher.redeemedBy != null) { + throw new VoucherAlreadyRedeemedException(); + } + + const newExpiryDate = await this.extendPremium(akey, voucher.durationInMinutes); + + await this.voucherModel.updateOne({ + code: voucher.code, + }, { + $set: { + redeemedAt: new Date(), + redeemedBy: akey, + } + }); + + return newExpiryDate; + } +} \ No newline at end of file diff --git a/src/premium/schemas/voucher.schema.ts b/src/premium/schemas/voucher.schema.ts new file mode 100644 index 0000000..16c09d5 --- /dev/null +++ b/src/premium/schemas/voucher.schema.ts @@ -0,0 +1,39 @@ +import { Prop, Schema, SchemaFactory } from "@nestjs/mongoose"; +import { IsNotEmpty, Max, Min } from "class-validator"; +import { PREMIUM_DURATION } from "../entities/premium-duration.entity"; +import { AKEY_LENGTH } from "../../account/dto/account.dto"; + +export const VOUCHER_LENGTH = 10; + +export type VoucherDocument = Voucher & Document; + +@Schema() +export class Voucher { + @Prop({ + required: true, + unique: true, + minlength: VOUCHER_LENGTH, + maxlength: VOUCHER_LENGTH, + }) + code: string; + + @Prop() + @Min(PREMIUM_DURATION.FIVE_MINUTES) + @Max(PREMIUM_DURATION.ONE_YEAR) + @IsNotEmpty() + durationInMinutes: number; + + @Prop({ + required: false, + minlength: AKEY_LENGTH, + maxlength: AKEY_LENGTH, + default: null, + ref: 'Account', + }) + redeemedBy?: string; + + @Prop() + redeemedAt?: Date; +} + +export const VoucherSchema = SchemaFactory.createForClass(Voucher); \ No newline at end of file From ea6fc6dca25a5512e18e48142e291fcd8b337831 Mon Sep 17 00:00:00 2001 From: GPlay97 Date: Sun, 4 May 2025 12:55:31 +0200 Subject: [PATCH 2/2] =?UTF-8?q?=E2=9C=A8=20Premium=20guard?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/premium/decorators/premium.decorator.ts | 5 ++ .../exceptions/premium-required.exception.ts | 7 ++ src/premium/premium.guard.spec.ts | 72 +++++++++++++++++++ src/premium/premium.guard.ts | 35 +++++++++ 4 files changed, 119 insertions(+) create mode 100644 src/premium/decorators/premium.decorator.ts create mode 100644 src/premium/exceptions/premium-required.exception.ts create mode 100644 src/premium/premium.guard.spec.ts create mode 100644 src/premium/premium.guard.ts diff --git a/src/premium/decorators/premium.decorator.ts b/src/premium/decorators/premium.decorator.ts new file mode 100644 index 0000000..62d15e6 --- /dev/null +++ b/src/premium/decorators/premium.decorator.ts @@ -0,0 +1,5 @@ +import { SetMetadata } from '@nestjs/common'; + +export const PREMIUM_ROLE_NAME = 'premium'; + +export const Premium = () => SetMetadata('role', PREMIUM_ROLE_NAME); diff --git a/src/premium/exceptions/premium-required.exception.ts b/src/premium/exceptions/premium-required.exception.ts new file mode 100644 index 0000000..d27474c --- /dev/null +++ b/src/premium/exceptions/premium-required.exception.ts @@ -0,0 +1,7 @@ +import { HttpException, HttpStatus } from '@nestjs/common'; + +export class PremiumRequiredException extends HttpException { + constructor() { + super('Active premium status required.', HttpStatus.PAYMENT_REQUIRED); + } +} diff --git a/src/premium/premium.guard.spec.ts b/src/premium/premium.guard.spec.ts new file mode 100644 index 0000000..29044a8 --- /dev/null +++ b/src/premium/premium.guard.spec.ts @@ -0,0 +1,72 @@ +import { ExecutionContext, ForbiddenException } from '@nestjs/common'; +import { Reflector } from '@nestjs/core'; +import { PremiumGuard } from './premium.guard'; +import { PremiumService } from './premium.service'; +import { PremiumRequiredException } from './exceptions/premium-required.exception'; +import { PREMIUM_ROLE_NAME } from './decorators/premium.decorator'; + +describe('PremiumGuard', () => { + let guard: PremiumGuard; + let reflector: Reflector; + let premiumService: PremiumService; + + const mockReflector = { + get: jest.fn(), + }; + + const mockPremiumService = { + getExpiryDate: jest.fn(), + }; + + const createMockContext = (akey?: string): ExecutionContext => ({ + switchToHttp: () => ({ + getRequest: () => ({ + params: akey ? { akey } : {}, + }), + }), + getHandler: () => ({}), + } as unknown as ExecutionContext); + + beforeEach(() => { + reflector = mockReflector as unknown as Reflector; + premiumService = mockPremiumService as unknown as PremiumService; + guard = new PremiumGuard(reflector, premiumService); + jest.clearAllMocks(); + }); + + it('should allow access if no role is required', async () => { + mockReflector.get.mockReturnValue(undefined); + const context = createMockContext(); + await expect(guard.canActivate(context)).resolves.toBe(true); + }); + + it('should allow access if role is not PREMIUM', async () => { + mockReflector.get.mockReturnValue('user'); + const context = createMockContext(); + await expect(guard.canActivate(context)).resolves.toBe(true); + }); + + it('should throw ForbiddenException if akey is missing', async () => { + mockReflector.get.mockReturnValue(PREMIUM_ROLE_NAME); + const context = createMockContext(); + await expect(guard.canActivate(context)).rejects.toThrow(ForbiddenException); + }); + + it('should throw PremiumRequiredException if expiryDate is null', async () => { + mockReflector.get.mockReturnValue(PREMIUM_ROLE_NAME); + mockPremiumService.getExpiryDate.mockResolvedValue(null); + const context = createMockContext('test-key'); + + await expect(guard.canActivate(context)).rejects.toThrow(PremiumRequiredException); + expect(mockPremiumService.getExpiryDate).toHaveBeenCalledWith('test-key'); + }); + + it('should allow access if expiryDate is valid', async () => { + mockReflector.get.mockReturnValue(PREMIUM_ROLE_NAME); + mockPremiumService.getExpiryDate.mockResolvedValue(new Date()); + const context = createMockContext('valid-key'); + + await expect(guard.canActivate(context)).resolves.toBe(true); + expect(mockPremiumService.getExpiryDate).toHaveBeenCalledWith('valid-key'); + }); +}); diff --git a/src/premium/premium.guard.ts b/src/premium/premium.guard.ts new file mode 100644 index 0000000..c855b89 --- /dev/null +++ b/src/premium/premium.guard.ts @@ -0,0 +1,35 @@ +import { CanActivate, ExecutionContext, ForbiddenException, Injectable } from "@nestjs/common"; +import { Reflector } from "@nestjs/core"; +import { PremiumService } from "./premium.service"; +import { PREMIUM_ROLE_NAME } from "./decorators/premium.decorator"; +import { PremiumRequiredException } from "./exceptions/premium-required.exception"; + +@Injectable() +export class PremiumGuard implements CanActivate { + constructor( + private reflector: Reflector, + private readonly premiumService: PremiumService, + ) {} + + async canActivate(context: ExecutionContext): Promise { + const role = this.reflector.get('role', context.getHandler()); + + if (role !== PREMIUM_ROLE_NAME) { + return Promise.resolve(true); + } + + const request = context.switchToHttp().getRequest(); + + if (!request.params || !request.params.akey) { + throw new ForbiddenException('Missing AKey'); + } + + const expiryDate = await this.premiumService.getExpiryDate(request.params.akey); + + if (expiryDate == null) { + throw new PremiumRequiredException(); + } + + return Promise.resolve(true); + } +} \ No newline at end of file