Add Integration and unit tests on Billing (#9317)

Solves [ https://github.com/twentyhq/private-issues/issues/214 ]

**TLDR**
Add unit and integration tests to Billing. First approach to run jest
integration tests directly from VSCode.

**In order to run the unit tests:**
Run unit test using the CLI or with the jest extension directly from
VSCode.

**In order to run the integration tests:**
Ensure that your database has the billingTables. If that's not the case,
migrate the database with IS_BILLING_ENABLED set to true:
` npx nx run twenty-server:test:integration
test/integration/billing/suites/billing-controller.integration-spec.ts`

**Doing:**
- Unit test on transformSubscriptionEventToSubscriptionItem
- More tests cases in billingController integration tests.

---------

Co-authored-by: Félix Malfait <felix.malfait@gmail.com>
Co-authored-by: Weiko <corentin@twenty.com>
Co-authored-by: Charles Bochet <charlesBochet@users.noreply.github.com>
This commit is contained in:
Ana Sofia Marin Alexandre
2025-01-09 14:30:41 -03:00
committed by GitHub
parent 4ed1db3845
commit c39af5f063
49 changed files with 2157 additions and 335 deletions

View File

@ -0,0 +1,112 @@
import request from 'supertest';
import { createMockStripeEntitlementUpdatedData } from 'test/integration/billing/utils/create-mock-stripe-entitlement-updated-data.util';
import { createMockStripePriceCreatedData } from 'test/integration/billing/utils/create-mock-stripe-price-created-data.util';
import { createMockStripeProductUpdatedData } from 'test/integration/billing/utils/create-mock-stripe-product-updated-data.util';
import { createMockStripeSubscriptionCreatedData } from 'test/integration/billing/utils/create-mock-stripe-subscription-created-data.util';
const client = request(`http://localhost:${APP_PORT}`);
describe('BillingController (integration)', () => {
it('should handle product.updated and price.created webhook events', async () => {
const productUpdatedPayload = {
type: 'product.updated',
data: createMockStripeProductUpdatedData(),
};
const priceCreatedPayload = {
type: 'price.created',
data: createMockStripePriceCreatedData(),
};
await client
.post('/billing/webhooks')
.set('Authorization', `Bearer ${ACCESS_TOKEN}`)
.set('stripe-signature', 'correct-signature')
.set('Content-Type', 'application/json')
.send(JSON.stringify(productUpdatedPayload))
.expect(200)
.then((res) => {
expect(res.body.stripeProductId).toBeDefined();
});
await client
.post('/billing/webhooks')
.set('Authorization', `Bearer ${ACCESS_TOKEN}`)
.set('stripe-signature', 'correct-signature')
.set('Content-Type', 'application/json')
.send(JSON.stringify(priceCreatedPayload))
.expect(200)
.then((res) => {
expect(res.body.stripePriceId).toBeDefined();
expect(res.body.stripeMeterId).toBeDefined();
});
});
it('should handle subscription.created webhook event', async () => {
const subscriptionCreatedPayload = {
type: 'customer.subscription.created',
data: createMockStripeSubscriptionCreatedData(),
};
const entitlementUpdatedPayload = {
type: 'entitlements.active_entitlement_summary.updated',
data: createMockStripeEntitlementUpdatedData(),
};
await client
.post('/billing/webhooks')
.set('Authorization', `Bearer ${ACCESS_TOKEN}`)
.set('stripe-signature', 'correct-signature')
.set('Content-Type', 'application/json')
.send(JSON.stringify(subscriptionCreatedPayload))
.expect(200)
.then((res) => {
expect(res.body.stripeSubscriptionId).toBeDefined();
expect(res.body.stripeCustomerId).toBeDefined();
});
await client
.post('/billing/webhooks')
.set('Authorization', `Bearer ${ACCESS_TOKEN}`)
.set('stripe-signature', 'correct-signature')
.set('Content-Type', 'application/json')
.send(JSON.stringify(entitlementUpdatedPayload))
.expect(200)
.then((res) => {
expect(res.body.stripeEntitlementCustomerId).toBeDefined();
});
});
it('should handle entitlements.active_entitlement_summary.updated when the subscription is not found', async () => {
const entitlementUpdatedPayload = {
type: 'entitlements.active_entitlement_summary.updated',
data: createMockStripeEntitlementUpdatedData({
customer: 'new_customer',
}),
};
await client
.post('/billing/webhooks')
.set('Authorization', `Bearer ${ACCESS_TOKEN}`)
.set('stripe-signature', 'correct-signature')
.set('Content-Type', 'application/json')
.send(JSON.stringify(entitlementUpdatedPayload))
.expect(404);
});
it('should reject webhook with invalid signature', async () => {
const entitlementUpdatedPayload = {
type: 'customer.entitlement.created',
data: {
object: {
id: 'ent_test123',
},
},
};
await client
.post('/billing/webhooks')
.set('Authorization', `Bearer ${ACCESS_TOKEN}`)
.set('stripe-signature', 'invalid-signature')
.set('Content-Type', 'application/json')
.send(JSON.stringify(entitlementUpdatedPayload))
.expect(500);
});
});

View File

@ -0,0 +1,25 @@
import Stripe from 'stripe';
export const createMockStripeEntitlementUpdatedData = (
overrides = {},
): Stripe.EntitlementsActiveEntitlementSummaryUpdatedEvent.Data => ({
object: {
object: 'entitlements.active_entitlement_summary',
customer: 'cus_default1',
livemode: false,
entitlements: {
object: 'list',
data: [
{
id: 'ent_test_61',
object: 'entitlements.active_entitlement',
feature: 'feat_test_61',
livemode: false,
lookup_key: 'SSO',
},
],
has_more: false,
url: '/v1/customer/cus_Q/entitlements',
},
...overrides,
},
});

View File

@ -0,0 +1,34 @@
import Stripe from 'stripe';
export const createMockStripePriceCreatedData = (
overrides = {},
): Stripe.PriceCreatedEvent.Data => ({
object: {
id: 'price_1Q',
object: 'price',
active: true,
billing_scheme: 'per_unit',
created: 1733734326,
currency: 'usd',
custom_unit_amount: null,
livemode: false,
lookup_key: null,
metadata: {},
nickname: null,
product: 'prod_RLN',
recurring: {
aggregate_usage: null,
interval: 'month',
interval_count: 1,
meter: null,
trial_period_days: null,
usage_type: 'licensed',
},
tax_behavior: 'unspecified',
tiers_mode: null,
transform_quantity: null,
type: 'recurring',
unit_amount: 0,
unit_amount_decimal: '0',
...overrides,
},
});

View File

@ -0,0 +1,32 @@
import Stripe from 'stripe';
export const createMockStripeProductUpdatedData = (
overrides = {},
): Stripe.ProductUpdatedEvent.Data => ({
object: {
id: 'prod_RLN',
object: 'product',
active: true,
created: 1733410584,
default_price: null,
description: null,
images: [],
livemode: false,
marketing_features: [],
metadata: {},
name: 'kjnnjkjknkjnjkn',
package_dimensions: null,
shippable: null,
statement_descriptor: null,
tax_code: 'txcd_10103001',
type: 'service',
unit_label: null,
updated: 1734694649,
url: null,
},
previous_attributes: {
default_price: 'price_1Q',
updated: 1733410585,
},
...overrides,
});

View File

@ -0,0 +1,130 @@
import Stripe from 'stripe';
export const createMockStripeSubscriptionCreatedData = (
overrides = {},
): Stripe.CustomerSubscriptionCreatedEvent.Data => ({
object: {
object: 'subscription',
id: 'sub_default',
customer: 'cus_default1',
status: 'active',
items: {
data: [
{
plan: {
id: 'plan_default',
object: 'plan',
active: true,
aggregate_usage: null,
amount_decimal: '0',
billing_scheme: 'per_unit',
interval_count: 1,
livemode: false,
nickname: null,
tiers_mode: null,
transform_usage: null,
trial_period_days: null,
interval: 'month',
currency: 'usd',
amount: 0,
created: 1672531200,
product: 'prod_default',
usage_type: 'licensed',
metadata: {},
meter: null,
},
id: '',
object: 'subscription_item',
billing_thresholds: null,
created: 0,
discounts: [],
metadata: {},
price: {
id: 'price_default',
object: 'price',
active: true,
billing_scheme: 'per_unit',
created: 1672531200,
currency: 'usd',
custom_unit_amount: null,
livemode: false,
lookup_key: null,
metadata: {},
nickname: null,
product: 'prod_default',
recurring: {
aggregate_usage: null,
interval: 'month',
interval_count: 1,
meter: null,
trial_period_days: null,
usage_type: 'licensed',
},
tax_behavior: null,
tiers_mode: null,
transform_quantity: null,
type: 'recurring',
unit_amount: 1000,
unit_amount_decimal: '1000',
},
subscription: '',
tax_rates: null,
},
],
object: 'list',
has_more: false,
url: '',
},
cancel_at_period_end: false,
currency: 'usd',
current_period_end: 1672531200,
current_period_start: 1672531200,
metadata: { workspaceId: '3b8e6458-5fc1-4e63-8563-008ccddaa6db' },
trial_end: null,
trial_start: null,
canceled_at: null,
...overrides,
application: null,
application_fee_percent: null,
automatic_tax: {
enabled: true,
liability: {
type: 'self',
},
},
billing_cycle_anchor: 0,
billing_cycle_anchor_config: null,
billing_thresholds: null,
cancel_at: null,
cancellation_details: null,
collection_method: 'charge_automatically',
created: 0,
days_until_due: null,
default_payment_method: null,
default_source: null,
description: null,
discount: null,
discounts: [],
ended_at: null,
invoice_settings: {
account_tax_ids: null,
issuer: {
type: 'self',
},
},
latest_invoice: null,
livemode: false,
next_pending_invoice_item_invoice: null,
on_behalf_of: null,
pause_collection: null,
payment_settings: null,
pending_invoice_item_interval: null,
pending_setup_intent: null,
pending_update: null,
schedule: null,
start_date: 0,
test_clock: null,
transfer_data: null,
trial_settings: null,
},
});

View File

@ -2,6 +2,8 @@ import { NestExpressApplication } from '@nestjs/platform-express';
import { Test, TestingModule, TestingModuleBuilder } from '@nestjs/testing';
import { AppModule } from 'src/app.module';
import { StripeSDKMockService } from 'src/engine/core-modules/billing/stripe/stripe-sdk/mocks/stripe-sdk-mock.service';
import { StripeSDKService } from 'src/engine/core-modules/billing/stripe/stripe-sdk/services/stripe-sdk.service';
interface TestingModuleCreatePreHook {
(moduleBuilder: TestingModuleBuilder): TestingModuleBuilder;
@ -23,9 +25,12 @@ export const createApp = async (
appInitHook?: TestingAppCreatePreHook;
} = {},
): Promise<NestExpressApplication> => {
const stripeSDKMockService = new StripeSDKMockService();
let moduleBuilder: TestingModuleBuilder = Test.createTestingModule({
imports: [AppModule],
});
})
.overrideProvider(StripeSDKService)
.useValue(stripeSDKMockService);
if (config.moduleBuilderHook) {
moduleBuilder = config.moduleBuilderHook(moduleBuilder);
@ -33,7 +38,10 @@ export const createApp = async (
const moduleFixture: TestingModule = await moduleBuilder.compile();
const app = moduleFixture.createNestApplication<NestExpressApplication>();
const app = moduleFixture.createNestApplication<NestExpressApplication>({
rawBody: true,
cors: true,
});
if (config.appInitHook) {
await config.appInitHook(app);