785 lines
24 KiB
TypeScript
785 lines
24 KiB
TypeScript
import {beforeEach, describe, expect, it, vi} from 'vitest';
|
|
import {EmailSourceType} from '@plunk/db';
|
|
import {BillingLimitService} from '../BillingLimitService';
|
|
import {EmailService} from '../EmailService';
|
|
import {factories, getPrismaClient} from '../../../../../test/helpers';
|
|
import {redis} from '../../database/redis';
|
|
|
|
// Mock STRIPE_ENABLED and STRIPE_SK for free tier tests
|
|
vi.mock('../../app/constants.js', async () => {
|
|
const actual = await vi.importActual('../../app/constants.js');
|
|
return {
|
|
...actual,
|
|
STRIPE_ENABLED: true,
|
|
STRIPE_SK: 'sk_test_mock_key_for_testing',
|
|
};
|
|
});
|
|
|
|
// Mock the stripe client to avoid actual Stripe API calls
|
|
vi.mock('../../app/stripe.js', () => ({
|
|
stripe: {
|
|
customers: {
|
|
retrieve: vi.fn().mockResolvedValue({
|
|
deleted: false,
|
|
currency: 'usd',
|
|
}),
|
|
},
|
|
},
|
|
}));
|
|
|
|
describe('BillingLimitService - Critical Enforcement', () => {
|
|
let projectId: string;
|
|
let contactId: string;
|
|
const prisma = getPrismaClient();
|
|
|
|
beforeEach(async () => {
|
|
const {project} = await factories.createUserWithProject();
|
|
projectId = project.id;
|
|
|
|
const contact = await factories.createContact({projectId});
|
|
contactId = contact.id;
|
|
});
|
|
|
|
describe('Revenue Protection - Limit Enforcement', () => {
|
|
it('should BLOCK transactional emails when limit exceeded', async () => {
|
|
await prisma.project.update({
|
|
where: {id: projectId},
|
|
data: {billingLimitTransactional: 5},
|
|
});
|
|
|
|
for (let i = 0; i < 5; i++) {
|
|
await factories.createEmail({
|
|
projectId,
|
|
contactId,
|
|
sourceType: EmailSourceType.TRANSACTIONAL,
|
|
});
|
|
}
|
|
|
|
await BillingLimitService.invalidateCache(projectId);
|
|
|
|
// Attempt to send 6th email should fail
|
|
await expect(
|
|
EmailService.sendTransactionalEmail({
|
|
projectId,
|
|
contactId,
|
|
subject: 'Test',
|
|
body: 'Test',
|
|
from: 'test@example.com',
|
|
}),
|
|
).rejects.toThrow(/billing limit/i);
|
|
});
|
|
|
|
it('should BLOCK campaign emails when limit exceeded', async () => {
|
|
await prisma.project.update({
|
|
where: {id: projectId},
|
|
data: {billingLimitCampaigns: 3},
|
|
});
|
|
|
|
// Create 3 campaign emails
|
|
for (let i = 0; i < 3; i++) {
|
|
await factories.createEmail({
|
|
projectId,
|
|
contactId,
|
|
sourceType: EmailSourceType.CAMPAIGN,
|
|
});
|
|
}
|
|
|
|
await BillingLimitService.invalidateCache(projectId);
|
|
|
|
// 4th should fail
|
|
const campaign = await factories.createCampaign({projectId});
|
|
await expect(
|
|
EmailService.sendCampaignEmail({
|
|
projectId,
|
|
contactId,
|
|
campaignId: campaign.id,
|
|
subject: 'Test',
|
|
body: 'Test',
|
|
from: 'test@example.com',
|
|
}),
|
|
).rejects.toThrow(/billing limit/i);
|
|
});
|
|
|
|
it('should BLOCK workflow emails when limit exceeded', async () => {
|
|
await prisma.project.update({
|
|
where: {id: projectId},
|
|
data: {billingLimitWorkflows: 2},
|
|
});
|
|
|
|
// Create 2 workflow emails
|
|
for (let i = 0; i < 2; i++) {
|
|
await factories.createEmail({
|
|
projectId,
|
|
contactId,
|
|
sourceType: EmailSourceType.WORKFLOW,
|
|
});
|
|
}
|
|
|
|
await BillingLimitService.invalidateCache(projectId);
|
|
|
|
// 3rd should fail
|
|
await expect(
|
|
EmailService.sendWorkflowEmail({
|
|
projectId,
|
|
contactId,
|
|
subject: 'Test',
|
|
body: 'Test',
|
|
from: 'test@example.com',
|
|
}),
|
|
).rejects.toThrow(/billing limit/i);
|
|
});
|
|
|
|
it('should ALLOW emails when limit is null (unlimited)', async () => {
|
|
await prisma.project.update({
|
|
where: {id: projectId},
|
|
data: {
|
|
billingLimitTransactional: null,
|
|
// Set one other limit to trigger per-category mode (not free tier)
|
|
billingLimitCampaigns: 1000,
|
|
},
|
|
});
|
|
|
|
// Create 100 emails
|
|
for (let i = 0; i < 100; i++) {
|
|
await factories.createEmail({
|
|
projectId,
|
|
contactId,
|
|
sourceType: EmailSourceType.TRANSACTIONAL,
|
|
});
|
|
}
|
|
|
|
await BillingLimitService.invalidateCache(projectId);
|
|
|
|
// Should still allow more
|
|
const result = await BillingLimitService.checkLimit(projectId, EmailSourceType.TRANSACTIONAL);
|
|
|
|
expect(result.allowed).toBe(true);
|
|
expect(result.limit).toBeNull();
|
|
});
|
|
|
|
it('should enforce limits independently per source type', async () => {
|
|
await prisma.project.update({
|
|
where: {id: projectId},
|
|
data: {
|
|
billingLimitTransactional: 5,
|
|
billingLimitCampaigns: 5,
|
|
billingLimitWorkflows: 5,
|
|
},
|
|
});
|
|
|
|
// Max out transactional
|
|
for (let i = 0; i < 5; i++) {
|
|
await factories.createEmail({
|
|
projectId,
|
|
contactId,
|
|
sourceType: EmailSourceType.TRANSACTIONAL,
|
|
});
|
|
}
|
|
|
|
await BillingLimitService.invalidateCache(projectId);
|
|
|
|
// Transactional should be blocked
|
|
const transactionalCheck = await BillingLimitService.checkLimit(projectId, EmailSourceType.TRANSACTIONAL);
|
|
expect(transactionalCheck.allowed).toBe(false);
|
|
|
|
// Campaign should still be allowed
|
|
const campaignCheck = await BillingLimitService.checkLimit(projectId, EmailSourceType.CAMPAIGN);
|
|
expect(campaignCheck.allowed).toBe(true);
|
|
|
|
// Workflow should still be allowed
|
|
const workflowCheck = await BillingLimitService.checkLimit(projectId, EmailSourceType.WORKFLOW);
|
|
expect(workflowCheck.allowed).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe('Warning Threshold (80%)', () => {
|
|
it('should warn when usage reaches 80% of limit', async () => {
|
|
await prisma.project.update({
|
|
where: {id: projectId},
|
|
data: {billingLimitCampaigns: 10},
|
|
});
|
|
|
|
for (let i = 0; i < 8; i++) {
|
|
await factories.createEmail({
|
|
projectId,
|
|
contactId,
|
|
sourceType: EmailSourceType.CAMPAIGN,
|
|
});
|
|
}
|
|
|
|
await BillingLimitService.invalidateCache(projectId);
|
|
|
|
const result = await BillingLimitService.checkLimit(projectId, EmailSourceType.CAMPAIGN);
|
|
|
|
expect(result.allowed).toBe(true);
|
|
expect(result.warning).toBe(true);
|
|
expect(result.percentage).toBeGreaterThanOrEqual(80);
|
|
expect(result.message).toMatch(/warning/i);
|
|
});
|
|
|
|
it('should not warn when usage is below 80%', async () => {
|
|
await prisma.project.update({
|
|
where: {id: projectId},
|
|
data: {billingLimitCampaigns: 10},
|
|
});
|
|
|
|
for (let i = 0; i < 5; i++) {
|
|
await factories.createEmail({
|
|
projectId,
|
|
contactId,
|
|
sourceType: EmailSourceType.CAMPAIGN,
|
|
});
|
|
}
|
|
|
|
await BillingLimitService.invalidateCache(projectId);
|
|
|
|
const result = await BillingLimitService.checkLimit(projectId, EmailSourceType.CAMPAIGN);
|
|
|
|
expect(result.allowed).toBe(true);
|
|
expect(result.warning).toBe(false);
|
|
expect(result.message).toBeUndefined();
|
|
});
|
|
});
|
|
|
|
describe('Cache Performance & Fallback', () => {
|
|
it('should use cached usage counts to avoid DB queries', async () => {
|
|
await prisma.project.update({
|
|
where: {id: projectId},
|
|
data: {billingLimitCampaigns: 100},
|
|
});
|
|
|
|
// First call - should query DB and cache
|
|
const usage1 = await BillingLimitService.getUsage(projectId, EmailSourceType.CAMPAIGN);
|
|
|
|
// Add email directly to DB (bypassing cache increment)
|
|
await factories.createEmail({
|
|
projectId,
|
|
contactId,
|
|
sourceType: EmailSourceType.CAMPAIGN,
|
|
});
|
|
|
|
// Second call within cache TTL - should return cached value (not reflect new email)
|
|
const usage2 = await BillingLimitService.getUsage(projectId, EmailSourceType.CAMPAIGN);
|
|
|
|
expect(usage2).toBe(usage1); // Same as cached value
|
|
});
|
|
|
|
it('should increment cache after successful email send', async () => {
|
|
await prisma.project.update({
|
|
where: {id: projectId},
|
|
data: {billingLimitCampaigns: 100},
|
|
});
|
|
|
|
await BillingLimitService.invalidateCache(projectId);
|
|
|
|
const initialUsage = await BillingLimitService.getUsage(projectId, EmailSourceType.CAMPAIGN);
|
|
|
|
// Send email (should increment cache)
|
|
await EmailService.sendCampaignEmail({
|
|
projectId,
|
|
contactId,
|
|
subject: 'Test',
|
|
body: 'Test',
|
|
from: 'test@example.com',
|
|
});
|
|
|
|
const finalUsage = await BillingLimitService.getUsage(projectId, EmailSourceType.CAMPAIGN);
|
|
|
|
expect(finalUsage).toBe(initialUsage + 1);
|
|
});
|
|
|
|
it('should handle Redis failure gracefully without blocking emails', async () => {
|
|
// Mock Redis failure
|
|
vi.spyOn(redis, 'get').mockRejectedValueOnce(new Error('Redis connection failed'));
|
|
|
|
await prisma.project.update({
|
|
where: {id: projectId},
|
|
data: {billingLimitCampaigns: 100},
|
|
});
|
|
|
|
// Should fall back to DB and still work
|
|
const result = await BillingLimitService.checkLimit(projectId, EmailSourceType.CAMPAIGN);
|
|
|
|
expect(result.allowed).toBe(true);
|
|
});
|
|
|
|
it('should handle invalid project ID gracefully', async () => {
|
|
// Non-existent project should not crash, should allow email (fail-open)
|
|
const result = await BillingLimitService.checkLimit('non-existent-project', EmailSourceType.CAMPAIGN);
|
|
|
|
expect(result.allowed).toBe(true);
|
|
expect(result.usage).toBe(0);
|
|
expect(result.limit).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe('Monthly Reset Behavior', () => {
|
|
it('should only count emails from current calendar month', async () => {
|
|
await prisma.project.update({
|
|
where: {id: projectId},
|
|
data: {billingLimitCampaigns: 10},
|
|
});
|
|
|
|
// Create a date in the previous month
|
|
// Set day to 1 first to avoid month overflow issues (e.g., Jan 31 -> Feb 31 = Mar 3)
|
|
const lastMonth = new Date();
|
|
lastMonth.setDate(1);
|
|
lastMonth.setMonth(lastMonth.getMonth() - 1);
|
|
|
|
await prisma.email.create({
|
|
data: {
|
|
projectId,
|
|
contactId,
|
|
subject: 'Old',
|
|
body: 'Old',
|
|
from: 'test@example.com',
|
|
sourceType: EmailSourceType.CAMPAIGN,
|
|
status: 'SENT',
|
|
createdAt: lastMonth,
|
|
},
|
|
});
|
|
|
|
await factories.createEmail({
|
|
projectId,
|
|
contactId,
|
|
sourceType: EmailSourceType.CAMPAIGN,
|
|
});
|
|
|
|
await BillingLimitService.invalidateCache(projectId);
|
|
|
|
const usage = await BillingLimitService.getUsage(projectId, EmailSourceType.CAMPAIGN);
|
|
|
|
// Should only count this month's email
|
|
expect(usage).toBe(1);
|
|
});
|
|
});
|
|
|
|
describe('Complete Limits Overview', () => {
|
|
it('should return all category limits and usage', async () => {
|
|
await prisma.project.update({
|
|
where: {id: projectId},
|
|
data: {
|
|
billingLimitWorkflows: 100,
|
|
billingLimitCampaigns: 200,
|
|
billingLimitTransactional: null, // Unlimited
|
|
},
|
|
});
|
|
|
|
// Create various emails
|
|
await factories.createEmail({
|
|
projectId,
|
|
contactId,
|
|
sourceType: EmailSourceType.WORKFLOW,
|
|
});
|
|
await factories.createEmail({
|
|
projectId,
|
|
contactId,
|
|
sourceType: EmailSourceType.CAMPAIGN,
|
|
});
|
|
await factories.createEmail({
|
|
projectId,
|
|
contactId,
|
|
sourceType: EmailSourceType.CAMPAIGN,
|
|
});
|
|
|
|
await BillingLimitService.invalidateCache(projectId);
|
|
|
|
const limits = await BillingLimitService.getLimitsAndUsage(projectId);
|
|
|
|
expect(limits.workflows.limit).toBe(100);
|
|
expect(limits.workflows.usage).toBe(1);
|
|
expect(limits.workflows.percentage).toBe(1);
|
|
expect(limits.workflows.isWarning).toBe(false);
|
|
expect(limits.workflows.isBlocked).toBe(false);
|
|
|
|
expect(limits.campaigns.limit).toBe(200);
|
|
expect(limits.campaigns.usage).toBe(2);
|
|
expect(limits.campaigns.percentage).toBe(1);
|
|
|
|
expect(limits.transactional.limit).toBeNull();
|
|
expect(limits.transactional.usage).toBe(0);
|
|
expect(limits.transactional.percentage).toBe(0);
|
|
});
|
|
});
|
|
|
|
describe('Race Condition Behavior', () => {
|
|
it('should check limits before each email send (may allow some concurrent sends)', async () => {
|
|
await prisma.project.update({
|
|
where: {id: projectId},
|
|
data: {billingLimitCampaigns: 10},
|
|
});
|
|
|
|
// Create 8 existing emails (80% of limit)
|
|
for (let i = 0; i < 8; i++) {
|
|
await factories.createEmail({
|
|
projectId,
|
|
contactId,
|
|
sourceType: EmailSourceType.CAMPAIGN,
|
|
});
|
|
}
|
|
|
|
await BillingLimitService.invalidateCache(projectId);
|
|
|
|
// Try to send 5 emails simultaneously
|
|
const promises = Array.from({length: 5}, () =>
|
|
EmailService.sendCampaignEmail({
|
|
projectId,
|
|
contactId,
|
|
subject: 'Test',
|
|
body: 'Test',
|
|
from: 'test@example.com',
|
|
}),
|
|
);
|
|
|
|
const results = await Promise.allSettled(promises);
|
|
const successful = results.filter(r => r.status === 'fulfilled').length;
|
|
|
|
// Note: Due to race conditions, multiple may succeed before limit is enforced
|
|
// The limit check happens at send time, not atomically
|
|
// At least some should succeed (we're under limit when we start)
|
|
expect(successful).toBeGreaterThan(0);
|
|
|
|
// Eventually, some should fail once limit is hit
|
|
// Total emails created should not greatly exceed limit
|
|
const totalEmails = await prisma.email.count({
|
|
where: {projectId, sourceType: EmailSourceType.CAMPAIGN},
|
|
});
|
|
|
|
// Should be close to limit (8 existing + some new <= ~13 due to races)
|
|
expect(totalEmails).toBeLessThanOrEqual(15);
|
|
});
|
|
});
|
|
|
|
describe('Free Tier Total Limit (1000 emails/month across all types)', () => {
|
|
it('should enforce 1000 email total limit for free tier projects', async () => {
|
|
// Create a contact for this test
|
|
const contact = await factories.createContact({projectId});
|
|
|
|
// Free tier project has no subscription and no custom limits set
|
|
await prisma.project.update({
|
|
where: {id: projectId},
|
|
data: {
|
|
billingLimitWorkflows: null,
|
|
billingLimitCampaigns: null,
|
|
billingLimitTransactional: null,
|
|
},
|
|
});
|
|
|
|
// Create 1000 emails total across different types
|
|
for (let i = 0; i < 300; i++) {
|
|
await factories.createEmail({
|
|
projectId,
|
|
contactId: contact.id,
|
|
sourceType: EmailSourceType.WORKFLOW,
|
|
});
|
|
}
|
|
for (let i = 0; i < 400; i++) {
|
|
await factories.createEmail({
|
|
projectId,
|
|
contactId: contact.id,
|
|
sourceType: EmailSourceType.CAMPAIGN,
|
|
});
|
|
}
|
|
for (let i = 0; i < 300; i++) {
|
|
await factories.createEmail({
|
|
projectId,
|
|
contactId: contact.id,
|
|
sourceType: EmailSourceType.TRANSACTIONAL,
|
|
});
|
|
}
|
|
|
|
await BillingLimitService.invalidateCache(projectId);
|
|
|
|
// All three types should now be blocked since total is 1000
|
|
await expect(
|
|
EmailService.sendWorkflowEmail({
|
|
projectId,
|
|
contactId: contact.id,
|
|
subject: 'Test',
|
|
body: 'Test',
|
|
from: 'test@example.com',
|
|
}),
|
|
).rejects.toThrow(/free tier limit/i);
|
|
|
|
await expect(
|
|
EmailService.sendCampaignEmail({
|
|
projectId,
|
|
contactId: contact.id,
|
|
subject: 'Test',
|
|
body: 'Test',
|
|
from: 'test@example.com',
|
|
}),
|
|
).rejects.toThrow(/free tier limit/i);
|
|
|
|
await expect(
|
|
EmailService.sendTransactionalEmail({
|
|
projectId,
|
|
contactId: contact.id,
|
|
subject: 'Test',
|
|
body: 'Test',
|
|
from: 'test@example.com',
|
|
}),
|
|
).rejects.toThrow(/free tier limit/i);
|
|
});
|
|
|
|
it('should count all email types towards free tier total limit', async () => {
|
|
// Create a contact for this test
|
|
const contact = await factories.createContact({projectId});
|
|
|
|
// Free tier project
|
|
await prisma.project.update({
|
|
where: {id: projectId},
|
|
data: {
|
|
billingLimitWorkflows: null,
|
|
billingLimitCampaigns: null,
|
|
billingLimitTransactional: null,
|
|
},
|
|
});
|
|
|
|
// Create 200 of each type (600 total)
|
|
for (let i = 0; i < 200; i++) {
|
|
await factories.createEmail({
|
|
projectId,
|
|
contactId: contact.id,
|
|
sourceType: EmailSourceType.WORKFLOW,
|
|
});
|
|
}
|
|
for (let i = 0; i < 200; i++) {
|
|
await factories.createEmail({
|
|
projectId,
|
|
contactId: contact.id,
|
|
sourceType: EmailSourceType.CAMPAIGN,
|
|
});
|
|
}
|
|
for (let i = 0; i < 200; i++) {
|
|
await factories.createEmail({
|
|
projectId,
|
|
contactId: contact.id,
|
|
sourceType: EmailSourceType.TRANSACTIONAL,
|
|
});
|
|
}
|
|
|
|
await BillingLimitService.invalidateCache(projectId);
|
|
|
|
// Check limits for each type - all should report 600 total usage
|
|
const workflowCheck = await BillingLimitService.checkLimit(projectId, EmailSourceType.WORKFLOW);
|
|
const campaignCheck = await BillingLimitService.checkLimit(projectId, EmailSourceType.CAMPAIGN);
|
|
const transactionalCheck = await BillingLimitService.checkLimit(projectId, EmailSourceType.TRANSACTIONAL);
|
|
|
|
// All types should see the same total usage (600) and same limit (1000)
|
|
expect(workflowCheck.usage).toBe(600);
|
|
expect(workflowCheck.limit).toBe(1000);
|
|
expect(workflowCheck.allowed).toBe(true);
|
|
expect(workflowCheck.percentage).toBe(60);
|
|
|
|
expect(campaignCheck.usage).toBe(600);
|
|
expect(campaignCheck.limit).toBe(1000);
|
|
expect(campaignCheck.allowed).toBe(true);
|
|
|
|
expect(transactionalCheck.usage).toBe(600);
|
|
expect(transactionalCheck.limit).toBe(1000);
|
|
expect(transactionalCheck.allowed).toBe(true);
|
|
});
|
|
|
|
it('should show warning at 80% of free tier total limit (800 emails)', async () => {
|
|
// Create a contact for this test
|
|
const contact = await factories.createContact({projectId});
|
|
|
|
// Free tier project
|
|
await prisma.project.update({
|
|
where: {id: projectId},
|
|
data: {
|
|
billingLimitWorkflows: null,
|
|
billingLimitCampaigns: null,
|
|
billingLimitTransactional: null,
|
|
},
|
|
});
|
|
|
|
// Create 800 emails spread across types
|
|
for (let i = 0; i < 300; i++) {
|
|
await factories.createEmail({
|
|
projectId,
|
|
contactId: contact.id,
|
|
sourceType: EmailSourceType.WORKFLOW,
|
|
});
|
|
}
|
|
for (let i = 0; i < 250; i++) {
|
|
await factories.createEmail({
|
|
projectId,
|
|
contactId: contact.id,
|
|
sourceType: EmailSourceType.CAMPAIGN,
|
|
});
|
|
}
|
|
for (let i = 0; i < 250; i++) {
|
|
await factories.createEmail({
|
|
projectId,
|
|
contactId: contact.id,
|
|
sourceType: EmailSourceType.TRANSACTIONAL,
|
|
});
|
|
}
|
|
|
|
await BillingLimitService.invalidateCache(projectId);
|
|
|
|
// All types should show warning
|
|
const workflowCheck = await BillingLimitService.checkLimit(projectId, EmailSourceType.WORKFLOW);
|
|
expect(workflowCheck.allowed).toBe(true);
|
|
expect(workflowCheck.warning).toBe(true);
|
|
expect(workflowCheck.usage).toBe(800);
|
|
expect(workflowCheck.percentage).toBeGreaterThanOrEqual(80);
|
|
expect(workflowCheck.message).toMatch(/warning/i);
|
|
});
|
|
|
|
it('should allow free tier to send different distribution of email types', async () => {
|
|
// Create a contact for this test
|
|
const contact = await factories.createContact({projectId});
|
|
|
|
// Free tier project
|
|
await prisma.project.update({
|
|
where: {id: projectId},
|
|
data: {
|
|
billingLimitWorkflows: null,
|
|
billingLimitCampaigns: null,
|
|
billingLimitTransactional: null,
|
|
},
|
|
});
|
|
|
|
// Create 900 transactional, 50 campaigns, 49 workflows (999 total - under limit)
|
|
for (let i = 0; i < 900; i++) {
|
|
await factories.createEmail({
|
|
projectId,
|
|
contactId: contact.id,
|
|
sourceType: EmailSourceType.TRANSACTIONAL,
|
|
});
|
|
}
|
|
for (let i = 0; i < 50; i++) {
|
|
await factories.createEmail({
|
|
projectId,
|
|
contactId: contact.id,
|
|
sourceType: EmailSourceType.CAMPAIGN,
|
|
});
|
|
}
|
|
for (let i = 0; i < 49; i++) {
|
|
await factories.createEmail({
|
|
projectId,
|
|
contactId: contact.id,
|
|
sourceType: EmailSourceType.WORKFLOW,
|
|
});
|
|
}
|
|
|
|
await BillingLimitService.invalidateCache(projectId);
|
|
|
|
// Should still allow one more email of any type
|
|
const workflowCheck = await BillingLimitService.checkLimit(projectId, EmailSourceType.WORKFLOW);
|
|
expect(workflowCheck.allowed).toBe(true);
|
|
expect(workflowCheck.usage).toBe(999);
|
|
|
|
// Send the 1000th email (should succeed)
|
|
await EmailService.sendWorkflowEmail({
|
|
projectId,
|
|
contactId: contact.id,
|
|
subject: 'Test',
|
|
body: 'Test',
|
|
from: 'test@example.com',
|
|
});
|
|
|
|
await BillingLimitService.invalidateCache(projectId);
|
|
|
|
// Now all types should be blocked
|
|
const campaignCheck = await BillingLimitService.checkLimit(projectId, EmailSourceType.CAMPAIGN);
|
|
expect(campaignCheck.allowed).toBe(false);
|
|
expect(campaignCheck.usage).toBe(1000);
|
|
});
|
|
|
|
it('should distinguish free tier from paid tier with per-category limits', async () => {
|
|
// Create a contact for this test
|
|
const contact = await factories.createContact({projectId});
|
|
|
|
// Set custom per-category limits (paid tier behavior)
|
|
await prisma.project.update({
|
|
where: {id: projectId},
|
|
data: {
|
|
billingLimitWorkflows: 100,
|
|
billingLimitCampaigns: 200,
|
|
billingLimitTransactional: 300,
|
|
},
|
|
});
|
|
|
|
// Create 150 workflow emails (exceeds workflow limit but under total)
|
|
for (let i = 0; i < 150; i++) {
|
|
await factories.createEmail({
|
|
projectId,
|
|
contactId: contact.id,
|
|
sourceType: EmailSourceType.WORKFLOW,
|
|
});
|
|
}
|
|
|
|
await BillingLimitService.invalidateCache(projectId);
|
|
|
|
// Workflow should be blocked (150 > 100)
|
|
const workflowCheck = await BillingLimitService.checkLimit(projectId, EmailSourceType.WORKFLOW);
|
|
expect(workflowCheck.allowed).toBe(false);
|
|
expect(workflowCheck.usage).toBe(150);
|
|
expect(workflowCheck.limit).toBe(100);
|
|
|
|
// But campaigns should still be allowed (independent limit)
|
|
const campaignCheck = await BillingLimitService.checkLimit(projectId, EmailSourceType.CAMPAIGN);
|
|
expect(campaignCheck.allowed).toBe(true);
|
|
expect(campaignCheck.usage).toBe(0);
|
|
expect(campaignCheck.limit).toBe(200);
|
|
});
|
|
|
|
it('should show shared limit for all categories in getLimitsAndUsage for free tier', async () => {
|
|
// Create a contact for this test
|
|
const contact = await factories.createContact({projectId});
|
|
|
|
// Free tier project
|
|
await prisma.project.update({
|
|
where: {id: projectId},
|
|
data: {
|
|
billingLimitWorkflows: null,
|
|
billingLimitCampaigns: null,
|
|
billingLimitTransactional: null,
|
|
},
|
|
});
|
|
|
|
// Create various emails
|
|
for (let i = 0; i < 100; i++) {
|
|
await factories.createEmail({
|
|
projectId,
|
|
contactId: contact.id,
|
|
sourceType: EmailSourceType.WORKFLOW,
|
|
});
|
|
}
|
|
for (let i = 0; i < 200; i++) {
|
|
await factories.createEmail({
|
|
projectId,
|
|
contactId: contact.id,
|
|
sourceType: EmailSourceType.CAMPAIGN,
|
|
});
|
|
}
|
|
for (let i = 0; i < 150; i++) {
|
|
await factories.createEmail({
|
|
projectId,
|
|
contactId: contact.id,
|
|
sourceType: EmailSourceType.TRANSACTIONAL,
|
|
});
|
|
}
|
|
|
|
await BillingLimitService.invalidateCache(projectId);
|
|
|
|
const limits = await BillingLimitService.getLimitsAndUsage(projectId);
|
|
|
|
// All categories should show the same total usage (450) and limit (1000)
|
|
expect(limits.workflows.limit).toBe(1000);
|
|
expect(limits.workflows.usage).toBe(450);
|
|
expect(limits.workflows.percentage).toBe(45);
|
|
|
|
expect(limits.campaigns.limit).toBe(1000);
|
|
expect(limits.campaigns.usage).toBe(450);
|
|
|
|
expect(limits.transactional.limit).toBe(1000);
|
|
expect(limits.transactional.usage).toBe(450);
|
|
});
|
|
});
|
|
});
|