From b8f17da83a32b7e5fa2b9a30160c5afb8d979b2c Mon Sep 17 00:00:00 2001 From: YaraSenousy <102993483+YaraSenousy@users.noreply.github.com> Date: Sun, 3 May 2026 01:39:08 +0300 Subject: [PATCH 1/4] fix: set fixed durationSeconds value in track factory --- src/database/factories/track.factory.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/database/factories/track.factory.ts b/src/database/factories/track.factory.ts index bd4a89b0..6e548e8f 100644 --- a/src/database/factories/track.factory.ts +++ b/src/database/factories/track.factory.ts @@ -22,7 +22,7 @@ export default setSeederFactory(Track, async () => { track.coverImage = faker.image.url({ width: 500, height: 500 }); // Metadata - track.durationSeconds = faker.number.int({ min: 60, max: 600 }); + track.durationSeconds = 248; track.playCount = 0; track.likesCount = 0; track.repostsCount = 0; From 7b11991d18d52c89708210e2bd1367fce4797418 Mon Sep 17 00:00:00 2001 From: YaraSenousy <102993483+YaraSenousy@users.noreply.github.com> Date: Sun, 3 May 2026 01:45:55 +0300 Subject: [PATCH 2/4] refactor: remove obsolete database configuration tests --- src/config/database.config.spec.ts | 69 ------------------------------ 1 file changed, 69 deletions(-) delete mode 100644 src/config/database.config.spec.ts diff --git a/src/config/database.config.spec.ts b/src/config/database.config.spec.ts deleted file mode 100644 index a6c798e5..00000000 --- a/src/config/database.config.spec.ts +++ /dev/null @@ -1,69 +0,0 @@ -import { ConfigService } from '@nestjs/config'; -import { getDatabaseConfig } from './database.config'; - -const mockConfigService = { - get: jest.fn((key: string) => { - const values: Record = { - DB_HOST: 'localhost', - DB_PORT: 5432, - DB_USERNAME: 'postgres', - DB_PASSWORD: 'secret', - DB_DATABASE: 'harmonica', - NODE_ENV: 'development', - }; - return values[key]; - }), -} as unknown as ConfigService; - -describe('getDatabaseConfig', () => { - it('should return correct connection fields from ConfigService', () => { - const config = getDatabaseConfig(mockConfigService) as Record; - - expect(config.type).toBe('postgres'); - expect(config.host).toBe('localhost'); - expect(config.port).toBe(5432); - expect(config.username).toBe('postgres'); - expect(config.password).toBe('secret'); - expect(config.database).toBe('harmonica'); - }); - - it('should set synchronize to false', () => { - const config = getDatabaseConfig(mockConfigService) as Record; - - expect(config.synchronize).toBe(false); - }); - - it('should set migrationsRun to false', () => { - const config = getDatabaseConfig(mockConfigService) as Record; - - expect(config.migrationsRun).toBe(false); - }); - - it('should enable logging in development', () => { - const config = getDatabaseConfig(mockConfigService) as Record; - - expect(config.logging).toBe(true); - }); - - it('should disable logging in production', () => { - const prodConfigService = { - get: jest.fn((key: string) => { - if (key === 'NODE_ENV') return 'production'; - return 'value'; - }), - } as unknown as ConfigService; - - const config = getDatabaseConfig(prodConfigService) as Record; - - expect(config.logging).toBe(false); - }); - - it('should include entities and migrations path patterns', () => { - const config = getDatabaseConfig(mockConfigService) as Record; - - expect(Array.isArray(config.entities)).toBe(true); - expect(Array.isArray(config.migrations)).toBe(true); - expect((config.entities as string[])[0]).toContain('.entity'); - expect((config.migrations as string[])[0]).toContain('migrations'); - }); -}); From 25cf96e7d5dccbf2a7e30116d17e5e47086c0228 Mon Sep 17 00:00:00 2001 From: YaraSenousy <102993483+YaraSenousy@users.noreply.github.com> Date: Sun, 3 May 2026 01:46:50 +0300 Subject: [PATCH 3/4] feat: add CreateCheckoutSessionDto with billing cycle, plan type, and payment method ID --- ...Dto.ts => createCheckOutSessionDto.dto.ts} | 0 .../subscription.controller.spec.ts | 2 +- src/subscription/subscription.controller.ts | 2 +- src/subscription/subscription.service.spec.ts | 161 +++++++++++++++++- src/subscription/subscription.service.ts | 2 +- 5 files changed, 163 insertions(+), 4 deletions(-) rename src/subscription/dto/{createCheckOutSessionDto.ts => createCheckOutSessionDto.dto.ts} (100%) diff --git a/src/subscription/dto/createCheckOutSessionDto.ts b/src/subscription/dto/createCheckOutSessionDto.dto.ts similarity index 100% rename from src/subscription/dto/createCheckOutSessionDto.ts rename to src/subscription/dto/createCheckOutSessionDto.dto.ts diff --git a/src/subscription/subscription.controller.spec.ts b/src/subscription/subscription.controller.spec.ts index 22bea8ac..4bce541c 100644 --- a/src/subscription/subscription.controller.spec.ts +++ b/src/subscription/subscription.controller.spec.ts @@ -3,7 +3,7 @@ import { BadRequestException } from '@nestjs/common'; import { SubscriptionController } from './subscription.controller'; import { SubscriptionService } from './subscription.service'; import { SubscriptionPlan, SubscriptionStatus } from './entities/subscription.entity'; -import { PlanType, BillingCycle } from './dto/createCheckOutSessionDto'; +import { PlanType, BillingCycle } from './dto/createCheckOutSessionDto.dto'; // ─── Constants ──────────────────────────────────────────────────────────────── diff --git a/src/subscription/subscription.controller.ts b/src/subscription/subscription.controller.ts index f0865e62..b3db73a5 100644 --- a/src/subscription/subscription.controller.ts +++ b/src/subscription/subscription.controller.ts @@ -1,7 +1,7 @@ import { Controller, Post, Get, Patch, Body } from '@nestjs/common'; import { ApiTags } from '@nestjs/swagger'; import { SubscriptionService } from './subscription.service'; -import { CreateCheckoutSessionDto } from './dto/createCheckOutSessionDto'; +import { CreateCheckoutSessionDto } from './dto/createCheckOutSessionDto.dto'; import { CurrentUser } from '../authentication/decorators/current-user.decorator'; import { ApiCreateCheckout, diff --git a/src/subscription/subscription.service.spec.ts b/src/subscription/subscription.service.spec.ts index 4b1d6d6d..c18b9fa3 100644 --- a/src/subscription/subscription.service.spec.ts +++ b/src/subscription/subscription.service.spec.ts @@ -1,12 +1,13 @@ import { Test, TestingModule } from '@nestjs/testing'; import { BadRequestException } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; +import Stripe from 'stripe'; import { SubscriptionService } from './subscription.service'; import { SubscriptionRepository } from './subscription.repository'; import { StripeService } from './stripe.service'; import { UserRepository } from '../user/user.repository'; import { SubscriptionPlan, SubscriptionStatus } from './entities/subscription.entity'; -import { PlanType, BillingCycle } from './dto/createCheckOutSessionDto'; +import { PlanType, BillingCycle } from './dto/createCheckOutSessionDto.dto'; // ─── UUIDs ──────────────────────────────────────────────────────────────────── @@ -543,4 +544,162 @@ describe('SubscriptionService', () => { expect(result).toEqual({ message: 'Subscription resumed successfully' }); }); }); + + // ─── handleWebhookEvent ──────────────────────────────────────────────────── + + describe('handleWebhookEvent', () => { + const makeInvoiceSucceededEvent = (priceId: string | null, subscriptionId = 'sub_123') => + ({ + type: 'invoice.payment_succeeded' as const, + data: { + object: { + customer: MOCK_STRIPE_CUSTOMER_ID, + subscription: subscriptionId, + lines: { + data: [ + { + price: priceId ? { id: priceId } : null, + period: { start: 1700000000, end: 1702600000 }, + }, + ], + }, + }, + }, + }) as unknown as Stripe.Event; + + const makeSubUpdatedEvent = (cancelAtPeriodEnd: boolean) => + ({ + type: 'customer.subscription.updated' as const, + data: { + object: { + customer: MOCK_STRIPE_CUSTOMER_ID, + cancel_at_period_end: cancelAtPeriodEnd, + current_period_start: 1700000000, + current_period_end: 1702600000, + }, + }, + }) as unknown as Stripe.Event; + + const makeSubDeletedEvent = () => + ({ + type: 'customer.subscription.deleted' as const, + data: { object: { customer: MOCK_STRIPE_CUSTOMER_ID } }, + }) as unknown as Stripe.Event; + + const makeInvoiceFailedEvent = () => + ({ + type: 'invoice.payment_failed' as const, + data: { object: { customer: MOCK_STRIPE_CUSTOMER_ID } }, + }) as unknown as Stripe.Event; + + describe('invoice.payment_succeeded', () => { + it('should update subscription plan to PRO_MONTHLY when priceId matches, and update user plan', async () => { + mockSubscriptionRepository.updateSubscription.mockResolvedValue(undefined); + mockSubscriptionRepository.findOneByCustomerId.mockResolvedValue( + mockSubscription({ user: { userId: MOCK_USER_ID } }) + ); + mockUserRepository.update.mockResolvedValue(undefined); + + await service.handleWebhookEvent(makeInvoiceSucceededEvent(MOCK_PRICE_ID_PRO_MONTHLY)); + + expect(mockSubscriptionRepository.updateSubscription).toHaveBeenCalledWith( + MOCK_STRIPE_CUSTOMER_ID, + expect.objectContaining({ + plan: SubscriptionPlan.PRO_MONTHLY, + status: SubscriptionStatus.ACTIVE, + }) + ); + expect(mockUserRepository.update).toHaveBeenCalledWith(MOCK_USER_ID, { + plan: SubscriptionPlan.PRO_MONTHLY, + }); + }); + + it('should skip update when priceId is missing (no lines data)', async () => { + await service.handleWebhookEvent(makeInvoiceSucceededEvent(null)); + + expect(mockSubscriptionRepository.updateSubscription).not.toHaveBeenCalled(); + }); + + it('should skip update when priceId does not match any plan (unknown price ID)', async () => { + await service.handleWebhookEvent(makeInvoiceSucceededEvent('price_unknown_xyz')); + + expect(mockSubscriptionRepository.updateSubscription).not.toHaveBeenCalled(); + }); + }); + + describe('customer.subscription.updated', () => { + it('should call updateSubscription with correct cancelAtPeriodEnd and period dates', async () => { + mockSubscriptionRepository.updateSubscription.mockResolvedValue(undefined); + + await service.handleWebhookEvent(makeSubUpdatedEvent(true)); + + expect(mockSubscriptionRepository.updateSubscription).toHaveBeenCalledWith( + MOCK_STRIPE_CUSTOMER_ID, + expect.objectContaining({ + cancelAtPeriodEnd: true, + currentPeriodStart: expect.any(Date), + currentPeriodEnd: expect.any(Date), + }) + ); + }); + }); + + describe('customer.subscription.deleted', () => { + it('should reset plan to FREE and status to ACTIVE and update user plan', async () => { + mockSubscriptionRepository.updateSubscription.mockResolvedValue(undefined); + mockSubscriptionRepository.findOneByCustomerId.mockResolvedValue( + mockSubscription({ user: { userId: MOCK_USER_ID } }) + ); + mockUserRepository.update.mockResolvedValue(undefined); + + await service.handleWebhookEvent(makeSubDeletedEvent()); + + expect(mockSubscriptionRepository.updateSubscription).toHaveBeenCalledWith( + MOCK_STRIPE_CUSTOMER_ID, + expect.objectContaining({ + plan: SubscriptionPlan.FREE, + status: SubscriptionStatus.ACTIVE, + cancelAtPeriodEnd: false, + }) + ); + expect(mockUserRepository.update).toHaveBeenCalledWith(MOCK_USER_ID, { + plan: SubscriptionPlan.FREE, + }); + }); + }); + + describe('invoice.payment_failed', () => { + it('should set status to PAST_DUE and reset user plan to FREE', async () => { + mockSubscriptionRepository.updateSubscription.mockResolvedValue(undefined); + mockSubscriptionRepository.findOneByCustomerId.mockResolvedValue( + mockSubscription({ user: { userId: MOCK_USER_ID } }) + ); + mockUserRepository.update.mockResolvedValue(undefined); + + await service.handleWebhookEvent(makeInvoiceFailedEvent()); + + expect(mockSubscriptionRepository.updateSubscription).toHaveBeenCalledWith( + MOCK_STRIPE_CUSTOMER_ID, + expect.objectContaining({ status: SubscriptionStatus.PAST_DUE }) + ); + expect(mockUserRepository.update).toHaveBeenCalledWith(MOCK_USER_ID, { + plan: SubscriptionPlan.FREE, + }); + }); + }); + + describe('unknown event type', () => { + it('should do nothing for unknown event type', async () => { + const unknownEvent = { + type: 'some.unknown.event', + data: { object: {} }, + } as unknown as Stripe.Event; + + await service.handleWebhookEvent(unknownEvent); + + expect(mockSubscriptionRepository.updateSubscription).not.toHaveBeenCalled(); + expect(mockUserRepository.update).not.toHaveBeenCalled(); + }); + }); + }); }); diff --git a/src/subscription/subscription.service.ts b/src/subscription/subscription.service.ts index ab464ea3..d8711ffd 100644 --- a/src/subscription/subscription.service.ts +++ b/src/subscription/subscription.service.ts @@ -6,7 +6,7 @@ import { SubscriptionRepository } from './subscription.repository'; import { StripeService } from './stripe.service'; import { Subscription, SubscriptionStatus, SubscriptionPlan } from './entities/subscription.entity'; -import { PlanType } from './dto/createCheckOutSessionDto'; +import { PlanType } from './dto/createCheckOutSessionDto.dto'; @Injectable() export class SubscriptionService { From 6e0f92dae24e5884494ef356f16a56a70b23c264 Mon Sep 17 00:00:00 2001 From: YaraSenousy <102993483+YaraSenousy@users.noreply.github.com> Date: Sun, 3 May 2026 02:01:39 +0300 Subject: [PATCH 4/4] feat: add 'com.harmonica.app://' to allowed mobile deep-link schemes --- src/authentication/guards/oauth.constants.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/authentication/guards/oauth.constants.ts b/src/authentication/guards/oauth.constants.ts index 076f28f2..d9a7c5d0 100644 --- a/src/authentication/guards/oauth.constants.ts +++ b/src/authentication/guards/oauth.constants.ts @@ -1,5 +1,5 @@ /** Deep-link schemes that are allowed as OAuth redirect_uri values. */ -export const ALLOWED_MOBILE_SCHEMES = ['harmonica://']; +export const ALLOWED_MOBILE_SCHEMES = ['harmonica://', 'com.harmonica.app://']; /** * Validates an OAuth redirect_uri coming from a client.