Phase 0 - Base modules (100% copy): - shared/ (errors, middleware, services, utils, types) - auth, users, tenants (multi-tenancy) - ai, audit, notifications, mcp, payment-terminals - billing-usage, branches, companies, core Phase 1 - Modules to adapt (70-95%): - partners (for shippers/consignees) - inventory (for refacciones) - financial (for transport costing) Phase 2 - Pattern modules (50-70%): - ordenes-transporte (from sales) - gestion-flota (from products) - viajes (from projects) Phase 3 - New transport-specific modules: - tracking (GPS, events, alerts) - tarifas-transporte (pricing, surcharges) - combustible-gastos (fuel, tolls, expenses) - carta-porte (CFDI complement 3.1) Estimated token savings: ~65% (~10,675 lines) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
308 lines
11 KiB
TypeScript
308 lines
11 KiB
TypeScript
import { Test, TestingModule } from '@nestjs/testing';
|
|
import { DataSource, Repository } from 'typeorm';
|
|
import { getRepositoryToken } from '@nestjs/typeorm';
|
|
import { SubscriptionsService } from '../services/subscriptions.service';
|
|
import { TenantSubscription, SubscriptionPlan, BillingCycle, SubscriptionStatus } from '../entities';
|
|
import { CreateTenantSubscriptionDto, UpdateTenantSubscriptionDto, CancelSubscriptionDto, ChangePlanDto } from '../dto';
|
|
|
|
describe('SubscriptionsService', () => {
|
|
let service: SubscriptionsService;
|
|
let subscriptionRepository: Repository<TenantSubscription>;
|
|
let planRepository: Repository<SubscriptionPlan>;
|
|
let dataSource: DataSource;
|
|
|
|
const mockSubscription = {
|
|
id: 'uuid-1',
|
|
tenantId: 'tenant-1',
|
|
planId: 'plan-1',
|
|
status: SubscriptionStatus.ACTIVE,
|
|
billingCycle: BillingCycle.MONTHLY,
|
|
currentPeriodStart: new Date('2024-01-01'),
|
|
currentPeriodEnd: new Date('2024-02-01'),
|
|
trialEnd: null,
|
|
cancelledAt: null,
|
|
paymentMethodId: 'pm-1',
|
|
metadata: {},
|
|
createdAt: new Date(),
|
|
updatedAt: new Date(),
|
|
};
|
|
|
|
const mockPlan = {
|
|
id: 'plan-1',
|
|
name: 'Basic Plan',
|
|
description: 'Basic subscription plan',
|
|
price: 9.99,
|
|
billingCycle: BillingCycle.MONTHLY,
|
|
features: ['feature1', 'feature2'],
|
|
isActive: true,
|
|
createdAt: new Date(),
|
|
updatedAt: new Date(),
|
|
};
|
|
|
|
beforeEach(async () => {
|
|
const module: TestingModule = await Test.createTestingModule({
|
|
providers: [
|
|
SubscriptionsService,
|
|
{
|
|
provide: DataSource,
|
|
useValue: {
|
|
getRepository: jest.fn(),
|
|
},
|
|
},
|
|
],
|
|
}).compile();
|
|
|
|
service = module.get<SubscriptionsService>(SubscriptionsService);
|
|
dataSource = module.get<DataSource>(DataSource);
|
|
subscriptionRepository = module.get<Repository<TenantSubscription>>(
|
|
getRepositoryToken(TenantSubscription),
|
|
);
|
|
planRepository = module.get<Repository<SubscriptionPlan>>(
|
|
getRepositoryToken(SubscriptionPlan),
|
|
);
|
|
});
|
|
|
|
it('should be defined', () => {
|
|
expect(service).toBeDefined();
|
|
});
|
|
|
|
describe('create', () => {
|
|
it('should create a new subscription successfully', async () => {
|
|
const dto: CreateTenantSubscriptionDto = {
|
|
tenantId: 'tenant-1',
|
|
planId: 'plan-1',
|
|
billingCycle: BillingCycle.MONTHLY,
|
|
paymentMethodId: 'pm-1',
|
|
};
|
|
|
|
jest.spyOn(subscriptionRepository, 'findOne').mockResolvedValue(null);
|
|
jest.spyOn(planRepository, 'findOne').mockResolvedValue(mockPlan as any);
|
|
jest.spyOn(subscriptionRepository, 'create').mockReturnValue(mockSubscription as any);
|
|
jest.spyOn(subscriptionRepository, 'save').mockResolvedValue(mockSubscription);
|
|
|
|
const result = await service.create(dto);
|
|
|
|
expect(subscriptionRepository.findOne).toHaveBeenCalledWith({
|
|
where: { tenantId: dto.tenantId },
|
|
});
|
|
expect(planRepository.findOne).toHaveBeenCalledWith({ where: { id: dto.planId } });
|
|
expect(subscriptionRepository.create).toHaveBeenCalled();
|
|
expect(subscriptionRepository.save).toHaveBeenCalled();
|
|
expect(result).toEqual(mockSubscription);
|
|
});
|
|
|
|
it('should throw error if tenant already has subscription', async () => {
|
|
const dto: CreateTenantSubscriptionDto = {
|
|
tenantId: 'tenant-1',
|
|
planId: 'plan-1',
|
|
billingCycle: BillingCycle.MONTHLY,
|
|
paymentMethodId: 'pm-1',
|
|
};
|
|
|
|
jest.spyOn(subscriptionRepository, 'findOne').mockResolvedValue(mockSubscription as any);
|
|
|
|
await expect(service.create(dto)).rejects.toThrow('Tenant already has a subscription');
|
|
});
|
|
|
|
it('should throw error if plan not found', async () => {
|
|
const dto: CreateTenantSubscriptionDto = {
|
|
tenantId: 'tenant-1',
|
|
planId: 'invalid-plan',
|
|
billingCycle: BillingCycle.MONTHLY,
|
|
paymentMethodId: 'pm-1',
|
|
};
|
|
|
|
jest.spyOn(subscriptionRepository, 'findOne').mockResolvedValue(null);
|
|
jest.spyOn(planRepository, 'findOne').mockResolvedValue(null);
|
|
|
|
await expect(service.create(dto)).rejects.toThrow('Plan not found');
|
|
});
|
|
});
|
|
|
|
describe('findByTenant', () => {
|
|
it('should find subscription by tenant id', async () => {
|
|
jest.spyOn(subscriptionRepository, 'findOne').mockResolvedValue(mockSubscription as any);
|
|
|
|
const result = await service.findByTenant('tenant-1');
|
|
|
|
expect(subscriptionRepository.findOne).toHaveBeenCalledWith({
|
|
where: { tenantId: 'tenant-1' },
|
|
});
|
|
expect(result).toEqual(mockSubscription);
|
|
});
|
|
|
|
it('should return null if no subscription found', async () => {
|
|
jest.spyOn(subscriptionRepository, 'findOne').mockResolvedValue(null);
|
|
|
|
const result = await service.findByTenant('invalid-tenant');
|
|
|
|
expect(result).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe('update', () => {
|
|
it('should update subscription successfully', async () => {
|
|
const dto: UpdateTenantSubscriptionDto = {
|
|
paymentMethodId: 'pm-2',
|
|
};
|
|
|
|
const updatedSubscription = { ...mockSubscription, paymentMethodId: 'pm-2' };
|
|
|
|
jest.spyOn(subscriptionRepository, 'findOne').mockResolvedValue(mockSubscription as any);
|
|
jest.spyOn(subscriptionRepository, 'save').mockResolvedValue(updatedSubscription as any);
|
|
|
|
const result = await service.update('uuid-1', dto);
|
|
|
|
expect(subscriptionRepository.findOne).toHaveBeenCalledWith({ where: { id: 'uuid-1' } });
|
|
expect(subscriptionRepository.save).toHaveBeenCalled();
|
|
expect(result).toEqual(updatedSubscription);
|
|
});
|
|
|
|
it('should throw error if subscription not found', async () => {
|
|
const dto: UpdateTenantSubscriptionDto = {
|
|
paymentMethodId: 'pm-2',
|
|
};
|
|
|
|
jest.spyOn(subscriptionRepository, 'findOne').mockResolvedValue(null);
|
|
|
|
await expect(service.update('invalid-id', dto)).rejects.toThrow('Subscription not found');
|
|
});
|
|
});
|
|
|
|
describe('cancel', () => {
|
|
it('should cancel subscription successfully', async () => {
|
|
const dto: CancelSubscriptionDto = {
|
|
reason: 'Customer request',
|
|
effectiveImmediately: false,
|
|
};
|
|
|
|
jest.spyOn(subscriptionRepository, 'findOne').mockResolvedValue(mockSubscription as any);
|
|
jest.spyOn(subscriptionRepository, 'save').mockResolvedValue({
|
|
...mockSubscription,
|
|
status: SubscriptionStatus.CANCELLED,
|
|
cancelledAt: new Date(),
|
|
} as any);
|
|
|
|
const result = await service.cancel('uuid-1', dto);
|
|
|
|
expect(subscriptionRepository.findOne).toHaveBeenCalledWith({ where: { id: 'uuid-1' } });
|
|
expect(subscriptionRepository.save).toHaveBeenCalled();
|
|
expect(result.status).toBe(SubscriptionStatus.CANCELLED);
|
|
expect(result.cancelledAt).toBeDefined();
|
|
});
|
|
|
|
it('should cancel subscription immediately if requested', async () => {
|
|
const dto: CancelSubscriptionDto = {
|
|
reason: 'Customer request',
|
|
effectiveImmediately: true,
|
|
};
|
|
|
|
jest.spyOn(subscriptionRepository, 'findOne').mockResolvedValue(mockSubscription as any);
|
|
jest.spyOn(subscriptionRepository, 'save').mockResolvedValue({
|
|
...mockSubscription,
|
|
status: SubscriptionStatus.CANCELLED,
|
|
cancelledAt: new Date(),
|
|
currentPeriodEnd: new Date(),
|
|
} as any);
|
|
|
|
const result = await service.cancel('uuid-1', dto);
|
|
|
|
expect(result.status).toBe(SubscriptionStatus.CANCELLED);
|
|
expect(result.cancelledAt).toBeDefined();
|
|
});
|
|
});
|
|
|
|
describe('changePlan', () => {
|
|
it('should change subscription plan successfully', async () => {
|
|
const newPlan = { ...mockPlan, id: 'plan-2', price: 19.99 };
|
|
const dto: ChangePlanDto = {
|
|
newPlanId: 'plan-2',
|
|
billingCycle: BillingCycle.YEARLY,
|
|
prorate: true,
|
|
};
|
|
|
|
jest.spyOn(subscriptionRepository, 'findOne').mockResolvedValue(mockSubscription as any);
|
|
jest.spyOn(planRepository, 'findOne').mockResolvedValue(newPlan as any);
|
|
jest.spyOn(subscriptionRepository, 'save').mockResolvedValue({
|
|
...mockSubscription,
|
|
planId: 'plan-2',
|
|
billingCycle: BillingCycle.YEARLY,
|
|
} as any);
|
|
|
|
const result = await service.changePlan('uuid-1', dto);
|
|
|
|
expect(subscriptionRepository.findOne).toHaveBeenCalledWith({ where: { id: 'uuid-1' } });
|
|
expect(planRepository.findOne).toHaveBeenCalledWith({ where: { id: 'plan-2' } });
|
|
expect(subscriptionRepository.save).toHaveBeenCalled();
|
|
expect(result.planId).toBe('plan-2');
|
|
expect(result.billingCycle).toBe(BillingCycle.YEARLY);
|
|
});
|
|
|
|
it('should throw error if new plan not found', async () => {
|
|
const dto: ChangePlanDto = {
|
|
newPlanId: 'invalid-plan',
|
|
billingCycle: BillingCycle.MONTHLY,
|
|
prorate: false,
|
|
};
|
|
|
|
jest.spyOn(subscriptionRepository, 'findOne').mockResolvedValue(mockSubscription as any);
|
|
jest.spyOn(planRepository, 'findOne').mockResolvedValue(null);
|
|
|
|
await expect(service.changePlan('uuid-1', dto)).rejects.toThrow('New plan not found');
|
|
});
|
|
});
|
|
|
|
describe('getUsage', () => {
|
|
it('should get subscription usage', async () => {
|
|
const mockUsage = {
|
|
currentUsage: 850,
|
|
limits: {
|
|
apiCalls: 1000,
|
|
storage: 5368709120, // 5GB in bytes
|
|
users: 10,
|
|
},
|
|
periodStart: new Date('2024-01-01'),
|
|
periodEnd: new Date('2024-02-01'),
|
|
};
|
|
|
|
jest.spyOn(subscriptionRepository, 'findOne').mockResolvedValue(mockSubscription as any);
|
|
jest.spyOn(dataSource, 'query').mockResolvedValue([{ current_usage: 850 }]);
|
|
|
|
const result = await service.getUsage('uuid-1');
|
|
|
|
expect(result.currentUsage).toBe(850);
|
|
expect(result.limits).toBeDefined();
|
|
});
|
|
});
|
|
|
|
describe('reactivate', () => {
|
|
it('should reactivate cancelled subscription', async () => {
|
|
const cancelledSubscription = {
|
|
...mockSubscription,
|
|
status: SubscriptionStatus.CANCELLED,
|
|
cancelledAt: new Date(),
|
|
};
|
|
|
|
jest.spyOn(subscriptionRepository, 'findOne').mockResolvedValue(cancelledSubscription as any);
|
|
jest.spyOn(subscriptionRepository, 'save').mockResolvedValue({
|
|
...cancelledSubscription,
|
|
status: SubscriptionStatus.ACTIVE,
|
|
cancelledAt: null,
|
|
} as any);
|
|
|
|
const result = await service.reactivate('uuid-1');
|
|
|
|
expect(subscriptionRepository.findOne).toHaveBeenCalledWith({ where: { id: 'uuid-1' } });
|
|
expect(subscriptionRepository.save).toHaveBeenCalled();
|
|
expect(result.status).toBe(SubscriptionStatus.ACTIVE);
|
|
expect(result.cancelledAt).toBeNull();
|
|
});
|
|
|
|
it('should throw error if subscription is not cancelled', async () => {
|
|
jest.spyOn(subscriptionRepository, 'findOne').mockResolvedValue(mockSubscription as any);
|
|
|
|
await expect(service.reactivate('uuid-1')).rejects.toThrow('Cannot reactivate active subscription');
|
|
});
|
|
});
|
|
});
|