Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion src/authentication/guards/oauth.constants.ts
Original file line number Diff line number Diff line change
@@ -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.
Expand Down
69 changes: 0 additions & 69 deletions src/config/database.config.spec.ts

This file was deleted.

2 changes: 1 addition & 1 deletion src/database/factories/track.factory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
2 changes: 1 addition & 1 deletion src/subscription/subscription.controller.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 ────────────────────────────────────────────────────────────────

Expand Down
2 changes: 1 addition & 1 deletion src/subscription/subscription.controller.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down
161 changes: 160 additions & 1 deletion src/subscription/subscription.service.spec.ts
Original file line number Diff line number Diff line change
@@ -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 ────────────────────────────────────────────────────────────────────

Expand Down Expand Up @@ -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();
});
});
});
});
2 changes: 1 addition & 1 deletion src/subscription/subscription.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Loading