I'm writing a Jest test that simulates a Stripe subscription cancellation using a test clock. The setup creates a subscription, cancels it by setting cancel_at_period_end to true, advances the test clock, and then checks for the cancellation. However, the stripe.subscription.deleted webhook is received almost immediately (roughly 500 ms) after the simulated time advance, regardless of the extra delays added in the test.
Below is the relevant test case:
test("HTTP POST on /invites/:code/accept returns 400 for accepting invite to a cancelled subscription", async () => {
const clock = new StripeTestClock(stripe);
clock.create()
const inviterContext = await generateContext(true); // First account (inviter)
const inviteeContext = await generateContext(true); // Second account (invitee)
// Create a duo subscription for the inviter
const { subscriptionId, customer } = await createActiveCustomer(
inviterContext.nonAdminUser,
inviterContext.axiosAuthHeaders,
createdCustomers,
SubscriptionType.DUO_MONTHLY,
clock.getClockId()
);
await delay(ENDPOINT_DELAY)
const inviteCode = await sendDuoInvite(inviterContext, inviteeContext)
// Cancel sub
await assertEndpointResponse("post", "cancel-subscription", HttpStatus.OK, inviterContext.axiosAuthHeaders);
await clock.advanceDays(40);
await delay(CLOCK_ADVANCE_DELAY);
await delay(ENDPOINT_DELAY)
await verifyGetSubscription(customer.id, SubscriptionType.DUO_MONTHLY, "canceled", inviterContext.axiosAuthHeaders)
// Now, we should get a 400
await assertEndpointResponse(
"post",
`invites/${inviteCode}/accept`,
HttpStatus.BAD_REQUEST,
inviteeContext.axiosAuthHeaders
);
}, JEST_TIMEOUT);
I also use a StripeTestClock, defined below, to control time in my tests:
import Stripe from "stripe";
export default class StripeTestClock {
private stripe: Stripe;
private clockId?: string;
private frozenTime: number=0;
constructor(stripeInstance: Stripe) {
this.stripe = stripeInstance;
}
async create(frozenTime: number = Math.floor(Date.now() / 1000)): Promise<string> {
if (this.clockId) {
console.warn("A test clock already exists. Reusing existing clock.");
return this.clockId;
}
try {
const clock = await this.stripe.testHelpers.testClocks.create({
frozen_time: frozenTime,
});
this.clockId = clock.id;
this.frozenTime = frozenTime;
return this.clockId;
} catch (error) {
console.error("Failed to create Stripe test clock:", (error as Error).message);
throw error;
}
}
async advance(advanceToTime: number): Promise<void> {
if (!this.clockId) {
throw new Error("Cannot advance test clock: Clock ID is not set.");
}
try {
await this.stripe.testHelpers.testClocks.advance(this.clockId, {
frozen_time: advanceToTime,
});
this.frozenTime = advanceToTime; // Update the frozen time
} catch (error) {
console.error("Failed to advance Stripe test clock:", (error as Error).message);
throw error;
}
}
async advanceDays(days: number): Promise<void> {
if (!this.clockId) {
throw new Error("Cannot advance test clock: Clock ID or is not set.");
}
const advanceToTime = this.frozenTime + days * 24 * 60 * 60; // Advance by days in seconds
await this.advance(advanceToTime);
}
async delete(): Promise<void> {
if (!this.clockId) {
console.warn("No test clock to delete. Skipping deletion.");
return;
}
try {
await this.stripe.testHelpers.testClocks.del(this.clockId);
this.clockId = undefined;
this.frozenTime = 0;
} catch (error) {
console.error("Failed to delete Stripe test clock:", (error as Error).message);
throw error;
}
}
getClockId(): string | undefined {
return this.clockId;
}
getFrozenTime(): number {
return this.frozenTime;
}
async refreshFrozenTime(): Promise<void> {
if (!this.clockId) {
throw new Error("Cannot refresh frozen time: Clock ID is not set.");
}
try {
const clock = await this.stripe.testHelpers.testClocks.retrieve(this.clockId);
this.frozenTime = clock.frozen_time;
} catch (error) {
console.error("Failed to refresh frozen time:", (error as Error).message);
throw error;
}
}
}
The cancel endpoint being tested simply does the following:
await stripe.subscriptions.update(subscriptionId, {
cancel_at_period_end: true
})
My question is: Why does the webhook from Stripe appear almost immediately after advancing the clock, regardless of increasing the delay (like CLOCK_ADVANCE_DELAY + ENDPOINT_DELAY) in my test? I'm trying to understand why the webhook timing is unaffected by the extra delays in Jest.