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
271 changes: 271 additions & 0 deletions src/__tests__/SubscriptionService.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,271 @@
/* eslint-disable max-lines-per-function */
import { Repository } from 'typeorm';
import { SubscriptionService } from '../services/SubscriptionService';
import { Subscription, SubscriptionTier, SubscriptionStatus } from '../entities/Subscription';
import AppDataSource from '../config/db';
import { AppError } from '../errors/AppError';

jest.mock('../config/db', () => ({
__esModule: true,
default: {
getRepository: jest.fn(),
},
}));

describe('SubscriptionService', () => {
let subscriptionService: SubscriptionService;
let mockSubscriptionRepo: jest.Mocked<Repository<Subscription>>;

beforeEach(() => {
jest.clearAllMocks();

mockSubscriptionRepo = {
findOne: jest.fn(),
create: jest.fn(),
save: jest.fn(),
} as unknown as jest.Mocked<Repository<Subscription>>;

(AppDataSource.getRepository as jest.Mock).mockReturnValue(mockSubscriptionRepo);

subscriptionService = new SubscriptionService();
});

describe('getUserSubscription', () => {
const userId = 'user-123';

it('should return active subscription for user', async () => {
const mockSubscription: Partial<Subscription> = {
id: 'sub-123',
userId,
tier: SubscriptionTier.ARTIST_PRO,
status: SubscriptionStatus.ACTIVE,
startDate: new Date('2026-01-01'),
endDate: new Date('2027-01-01'),
};

mockSubscriptionRepo.findOne.mockResolvedValue(mockSubscription as Subscription);

const result = await subscriptionService.getUserSubscription(userId);

expect(mockSubscriptionRepo.findOne).toHaveBeenCalledWith({
where: {
userId,
status: SubscriptionStatus.ACTIVE,
},
order: {
createdAt: 'DESC',
},
});
expect(result).toEqual(mockSubscription);
});

it('should return null if no active subscription found', async () => {
mockSubscriptionRepo.findOne.mockResolvedValue(null);

const result = await subscriptionService.getUserSubscription(userId);

expect(result).toBeNull();
});

it('should mark expired subscriptions and return null', async () => {
const expiredDate = new Date('2025-01-01');
const mockSubscription: Partial<Subscription> = {
id: 'sub-123',
userId,
tier: SubscriptionTier.ARTIST_PRO,
status: SubscriptionStatus.ACTIVE,
startDate: new Date('2024-01-01'),
endDate: expiredDate,
};

mockSubscriptionRepo.findOne.mockResolvedValue(mockSubscription as Subscription);
mockSubscriptionRepo.save.mockResolvedValue(mockSubscription as Subscription);

const result = await subscriptionService.getUserSubscription(userId);

expect(mockSubscription.status).toBe(SubscriptionStatus.EXPIRED);
expect(mockSubscriptionRepo.save).toHaveBeenCalled();
expect(result).toBeNull();
});

it('should throw error for missing userId', async () => {
await expect(subscriptionService.getUserSubscription('')).rejects.toThrow(AppError);
});
});

describe('createOrUpgradeSubscription', () => {
const userId = 'user-123';

it('should create new subscription for user without existing subscription', async () => {
const tier = SubscriptionTier.ARTIST_PRO;
const endDate = new Date('2027-01-01');

mockSubscriptionRepo.findOne.mockResolvedValue(null);
mockSubscriptionRepo.create.mockReturnValue({
userId,
tier,
status: SubscriptionStatus.ACTIVE,
startDate: expect.any(Date),
endDate,
} as Subscription);
mockSubscriptionRepo.save.mockResolvedValue({
id: 'sub-123',
userId,
tier,
status: SubscriptionStatus.ACTIVE,
startDate: new Date(),
endDate,
} as Subscription);

const result = await subscriptionService.createOrUpgradeSubscription(userId, tier, endDate);

expect(mockSubscriptionRepo.create).toHaveBeenCalled();
expect(mockSubscriptionRepo.save).toHaveBeenCalled();
expect(result.tier).toBe(tier);
expect(result.status).toBe(SubscriptionStatus.ACTIVE);
});

it('should upgrade existing subscription', async () => {
const existingSubscription: Partial<Subscription> = {
id: 'sub-123',
userId,
tier: SubscriptionTier.FREE,
status: SubscriptionStatus.ACTIVE,
startDate: new Date('2026-01-01'),
};

const newTier = SubscriptionTier.LABEL;

mockSubscriptionRepo.findOne.mockResolvedValue(existingSubscription as Subscription);
mockSubscriptionRepo.save.mockResolvedValue({
...existingSubscription,
tier: newTier,
} as Subscription);

const result = await subscriptionService.createOrUpgradeSubscription(userId, newTier);

expect(result.tier).toBe(newTier);
expect(mockSubscriptionRepo.save).toHaveBeenCalled();
});

it('should throw error for invalid tier', async () => {
mockSubscriptionRepo.findOne.mockResolvedValue(null);

await expect(
subscriptionService.createOrUpgradeSubscription(userId, 'invalid' as SubscriptionTier),
).rejects.toThrow(AppError);
});

it('should throw error for missing userId', async () => {
await expect(
subscriptionService.createOrUpgradeSubscription('', SubscriptionTier.ARTIST_PRO),
).rejects.toThrow(AppError);
});
});

describe('cancelSubscription', () => {
const userId = 'user-123';

it('should cancel active subscription', async () => {
const mockSubscription: Partial<Subscription> = {
id: 'sub-123',
userId,
tier: SubscriptionTier.ARTIST_PRO,
status: SubscriptionStatus.ACTIVE,
startDate: new Date('2026-01-01'),
endDate: new Date('2027-01-01'),
};

mockSubscriptionRepo.findOne.mockResolvedValue(mockSubscription as Subscription);
mockSubscriptionRepo.save.mockResolvedValue({
...mockSubscription,
status: SubscriptionStatus.CANCELLED,
} as Subscription);

const result = await subscriptionService.cancelSubscription(userId);

expect(result.status).toBe(SubscriptionStatus.CANCELLED);
expect(mockSubscriptionRepo.save).toHaveBeenCalled();
});

it('should throw error if no active subscription found', async () => {
mockSubscriptionRepo.findOne.mockResolvedValue(null);

await expect(subscriptionService.cancelSubscription(userId)).rejects.toThrow(AppError);
await expect(subscriptionService.cancelSubscription(userId)).rejects.toThrow(
/No active subscription/,
);
});
});

describe('hasTierAccess', () => {
const userId = 'user-123';

it('should return true for FREE tier when user has no subscription', async () => {
mockSubscriptionRepo.findOne.mockResolvedValue(null);

const result = await subscriptionService.hasTierAccess(userId, SubscriptionTier.FREE);

expect(result).toBe(true);
});

it('should return false for ARTIST_PRO when user has no subscription', async () => {
mockSubscriptionRepo.findOne.mockResolvedValue(null);

const result = await subscriptionService.hasTierAccess(userId, SubscriptionTier.ARTIST_PRO);

expect(result).toBe(false);
});

it('should return true when user has exact required tier', async () => {
const mockSubscription: Partial<Subscription> = {
id: 'sub-123',
userId,
tier: SubscriptionTier.ARTIST_PRO,
status: SubscriptionStatus.ACTIVE,
startDate: new Date('2026-01-01'),
endDate: new Date('2027-01-01'),
};

mockSubscriptionRepo.findOne.mockResolvedValue(mockSubscription as Subscription);

const result = await subscriptionService.hasTierAccess(userId, SubscriptionTier.ARTIST_PRO);

expect(result).toBe(true);
});

it('should return true when user has higher tier than required', async () => {
const mockSubscription: Partial<Subscription> = {
id: 'sub-123',
userId,
tier: SubscriptionTier.LABEL,
status: SubscriptionStatus.ACTIVE,
startDate: new Date('2026-01-01'),
endDate: new Date('2027-01-01'),
};

mockSubscriptionRepo.findOne.mockResolvedValue(mockSubscription as Subscription);

const result = await subscriptionService.hasTierAccess(userId, SubscriptionTier.ARTIST_PRO);

expect(result).toBe(true);
});

it('should return false when user has lower tier than required', async () => {
const mockSubscription: Partial<Subscription> = {
id: 'sub-123',
userId,
tier: SubscriptionTier.ARTIST_PRO,
status: SubscriptionStatus.ACTIVE,
startDate: new Date('2026-01-01'),
endDate: new Date('2027-01-01'),
};

mockSubscriptionRepo.findOne.mockResolvedValue(mockSubscription as Subscription);

const result = await subscriptionService.hasTierAccess(userId, SubscriptionTier.LABEL);

expect(result).toBe(false);
});
});
});
5 changes: 5 additions & 0 deletions src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import royaltyTemplateRoutes from './routes/royaltyTemplateRoutes';
import chartRoutes from './routes/ChartRoutes';
import tagRoutes from './routes/TagRoutes';
import releaseRoutes from './routes/ReleaseRoutes';
import subscriptionRoutes from './routes/subscriptionRoutes';
import { getPoolStats, checkDbHealth } from './services/DbPoolMonitor';
import { dbConnectionState } from './services/DatabaseConnectionManager';
import { JSON_BODY_LIMIT, URLENCODED_BODY_LIMIT } from './config/constants';
Expand Down Expand Up @@ -142,6 +143,10 @@ app.use('/api/admin', adminRoutes);
// User profile routes
app.use('/api/user', userRoutes);

// Subscription management routes (Issue #99)
app.use('/api/users', subscriptionRoutes);
app.use('/api/subscriptions', subscriptionRoutes);

//TWITTER CALLBACK ROUTE
app.use('/api/auth/twitter', twitterRoutes);

Expand Down
2 changes: 2 additions & 0 deletions src/config/db.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import { Tag } from '../entities/Tag';
import { SongTag } from '../entities/SongTag';
import { Release } from '../entities/Release';
import { ReleaseTrack } from '../entities/ReleaseTrack';
import { Subscription } from '../entities/Subscription';

dotenv.config();

Expand Down Expand Up @@ -82,6 +83,7 @@ const AppDataSource = new DataSource({
SongTag,
Release,
ReleaseTrack,
Subscription,
],
migrations: ['src/migrations/*.ts', 'dist/migrations/*.js'],
migrationsTableName: 'migrations',
Expand Down
36 changes: 36 additions & 0 deletions src/controllers/AdminController.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { Request, Response } from 'express';
import { UserService } from '../services/UserService';
import { handleError } from '../utils/helpers';
import { HTTP_STATUS } from '../config/constants';

/**
* Admin-facing user & role management (Issue #100).
*/
export class AdminController {
private static userService = new UserService();

/**
* POST /api/admin/users/:id/role — assign a role to a user.
*
* Authorization is enforced by the route (admin-only). The request body is
* validated against {@link AssignRoleDTO} so `role` is always a valid
* {@link UserRole}.
*/
static assignRole = async (req: Request, res: Response) => {
try {
const id = Array.isArray(req.params.id) ? req.params.id[0] : req.params.id;
const { role } = req.body;

const user = await AdminController.userService.assignRole(id, role);

return res.status(HTTP_STATUS.OK).json({
success: true,
message: 'Role assigned successfully',
id: user.id,
role: user.role,
});
} catch (error) {
handleError(req, res, error);
}
};
}
Loading
Loading