Files
OpenFrame/tests/api/stripe-webhook.test.ts
yusufipk 1d099c68f2 test: add unit, API, component and end-to-end test suites
The repo had no automated tests. Every change was verified by hand.

Adds four layers, 2023 tests in total, runnable with one command:

- 1191 unit tests over the pure logic in lib/, including the full
  computeProjectAccess permission matrix and the billing gate
- 167 component and hook tests in jsdom, covering the hooks that hold
  real logic rather than presentational wrappers
- 647 API integration tests against a real Postgres, with only auth()
  mocked, including a data-driven sweep asserting that none of the 60
  route modules answers 2xx to an unauthenticated caller
- 18 Playwright specs driving a real browser against a real build

Infrastructure: vitest.config.ts with three projects, a disposable
Postgres and MinIO in docker-compose.test.yml, factories and helpers
under tests/, scripts/test.sh as the single entry point, a pre-push
hook running bun run verify, and CI split into check, test and e2e jobs.

The test database is built with prisma db push plus a replay of the
hand-written SQL, because prisma migrate deploy cannot build this schema
from empty: the migration history has no captured baseline. This mirrors
what scripts/docker-db-bootstrap.ts already does in production, and
tests/setup/db-global.ts carries a drift guard so a new migration fails
the run until someone reviews it.

Production code is unchanged apart from one pure-function extraction out
of use-video-player.ts, which was too large to test in jsdom.

Several tests pin behaviour that looks wrong, each marked KNOWN BUG in
place. TESTING.md section 12 records where the plan turned out to be
wrong, and AGENTS.md now states which layer a change needs a test in.
2026-07-26 11:17:26 +07:00

494 lines
17 KiB
TypeScript

import { beforeEach, describe, expect, it, vi } from 'vitest';
import { db } from '@/lib/db';
import { getStripe } from '@/lib/stripe';
import { POST as stripeWebhook } from '@/app/api/stripe/webhook/route';
import { apiRequest, callRoute } from '../helpers/request';
import { createUser } from '../factories';
const CUSTOMER_ID = 'cus_test_webhook';
const ENTITLED_PRICE_ID = 'price_test_openframe_dummy';
const HOUR = 60 * 60;
function unix(offsetSeconds: number): number {
return Math.floor(Date.now() / 1000) + offsetSeconds;
}
interface SubscriptionFixture {
id: string;
customer: string;
status: string;
created: number;
current_period_end: number | null;
cancel_at_period_end: boolean;
cancel_at: number | null;
trial_end: number | null;
ended_at?: number | null;
canceled_at?: number | null;
items: { data: Array<{ price: { id: string } }> };
}
function subscription(overrides: Partial<SubscriptionFixture> = {}): SubscriptionFixture {
return {
id: 'sub_test_1',
customer: CUSTOMER_ID,
status: 'active',
created: unix(-24 * HOUR),
current_period_end: unix(30 * 24 * HOUR),
cancel_at_period_end: false,
cancel_at: null,
trial_end: null,
items: { data: [{ price: { id: ENTITLED_PRICE_ID } }] },
...overrides,
};
}
/**
* Installs a Stripe double for one test.
*
* `constructEvent` returning the event is what stands in for a valid signature;
* the default mock in tests/setup/api.ts throws, which is the invalid-signature
* case. `subscriptions.list` is what syncStripeCustomerSubscriptions re-reads,
* so it is the source of truth rather than the event body.
*/
function stubStripe(options: {
event?: unknown;
subscriptions?: SubscriptionFixture[];
constructEventThrows?: boolean;
}): { listCalls: number } {
const counters = { listCalls: 0 };
vi.mocked(getStripe as unknown as () => unknown).mockReturnValue({
webhooks: {
constructEvent: vi.fn(() => {
if (options.constructEventThrows) {
throw new Error('No signatures found matching the expected signature for payload');
}
return options.event;
}),
},
subscriptions: {
list: vi.fn(async () => {
counters.listCalls += 1;
return { data: options.subscriptions ?? [] };
}),
},
});
return counters;
}
function webhookRequest(body: unknown, headers: Record<string, string> = {}) {
return apiRequest('/api/stripe/webhook', {
method: 'POST',
rawBody: JSON.stringify(body),
headers: { 'content-type': 'application/json', ...headers },
});
}
function signed(body: unknown) {
return webhookRequest(body, { 'stripe-signature': 't=1,v1=deadbeef' });
}
describe('POST /api/stripe/webhook', () => {
beforeEach(() => {
vi.mocked(getStripe as unknown as () => unknown).mockReset();
});
it('returns 400 without a stripe-signature header and never calls Stripe', async () => {
const counters = stubStripe({ event: { type: 'customer.subscription.updated' } });
await createUser({ stripeCustomerId: CUSTOMER_ID });
const response = await callRoute(stripeWebhook, webhookRequest({ type: 'anything' }));
expect(response.status).toBe(400);
expect(await response.text()).toBe('Missing Stripe signature');
expect(counters.listCalls).toBe(0);
});
it('returns 400 when the signature does not verify', async () => {
stubStripe({ constructEventThrows: true });
const user = await createUser({
stripeCustomerId: CUSTOMER_ID,
subscriptionStatus: 'FREE',
trialEndsAt: null,
});
const response = await callRoute(
stripeWebhook,
signed({
type: 'customer.subscription.updated',
data: { object: subscription({ status: 'active' }) },
})
);
expect(response.status).toBe(400);
expect(await response.text()).toBe('Invalid webhook signature');
// A forged event must not be able to grant a subscription.
expect((await db.user.findUniqueOrThrow({ where: { id: user.id } })).subscriptionStatus).toBe(
'FREE'
);
});
it('maps an active subscription onto the user', async () => {
const periodEnd = unix(30 * 24 * HOUR);
const user = await createUser({
stripeCustomerId: CUSTOMER_ID,
subscriptionStatus: 'FREE',
trialEndsAt: null,
});
stubStripe({
event: {
id: 'evt_1',
type: 'customer.subscription.updated',
data: { object: subscription({ current_period_end: periodEnd }) },
},
subscriptions: [subscription({ current_period_end: periodEnd })],
});
const response = await callRoute(
stripeWebhook,
signed({ type: 'customer.subscription.updated' })
);
expect(response.status).toBe(200);
const stored = await db.user.findUniqueOrThrow({ where: { id: user.id } });
expect(stored.subscriptionStatus).toBe('ACTIVE');
expect(stored.stripeSubscriptionId).toBe('sub_test_1');
expect(stored.stripePriceId).toBe(ENTITLED_PRICE_ID);
expect(stored.stripeCurrentPeriodEnd?.getTime()).toBe(periodEnd * 1000);
expect(stored.stripeCancelAtPeriodEnd).toBe(false);
expect(stored.billingAccessEndedAt).toBeNull();
});
it('records the trial end and consumes the trial for a trialing subscription', async () => {
const trialEnd = unix(7 * 24 * HOUR);
const user = await createUser({
stripeCustomerId: CUSTOMER_ID,
subscriptionStatus: 'FREE',
trialEndsAt: null,
billingTrialConsumedAt: null,
});
const trialing = subscription({ status: 'trialing', trial_end: trialEnd });
stubStripe({
event: { id: 'evt_2', type: 'customer.subscription.created', data: { object: trialing } },
subscriptions: [trialing],
});
const response = await callRoute(
stripeWebhook,
signed({ type: 'customer.subscription.created' })
);
expect(response.status).toBe(200);
const stored = await db.user.findUniqueOrThrow({ where: { id: user.id } });
expect(stored.subscriptionStatus).toBe('TRIALING');
expect(stored.trialEndsAt?.getTime()).toBe(trialEnd * 1000);
expect(stored.billingTrialConsumedAt).toBeInstanceOf(Date);
});
it('does not grant entitlement for a subscription on a different price', async () => {
const user = await createUser({
stripeCustomerId: CUSTOMER_ID,
subscriptionStatus: 'FREE',
trialEndsAt: null,
});
const unrelated = subscription({
items: { data: [{ price: { id: 'price_some_other_product' } }] },
});
stubStripe({
event: { id: 'evt_3', type: 'customer.subscription.updated', data: { object: unrelated } },
subscriptions: [unrelated],
});
const response = await callRoute(
stripeWebhook,
signed({ type: 'customer.subscription.updated' })
);
expect(response.status).toBe(200);
const stored = await db.user.findUniqueOrThrow({ where: { id: user.id } });
// Active in Stripe, but for a product this app does not sell.
expect(stored.subscriptionStatus).toBe('FREE');
expect(stored.stripeCurrentPeriodEnd).toBeNull();
expect(stored.billingAccessEndedAt).toBeInstanceOf(Date);
expect(stored.stripePriceId).toBe('price_some_other_product');
});
it.each([
['past_due', 'PAST_DUE'],
['unpaid', 'UNPAID'],
['incomplete', 'INCOMPLETE'],
['incomplete_expired', 'INCOMPLETE_EXPIRED'],
['canceled', 'CANCELED'],
])('maps the Stripe status %s onto %s', async (stripeStatus, expected) => {
const user = await createUser({ stripeCustomerId: CUSTOMER_ID, trialEndsAt: null });
const sub = subscription({ status: stripeStatus, current_period_end: unix(-HOUR) });
stubStripe({
event: { id: 'evt_4', type: 'customer.subscription.updated', data: { object: sub } },
subscriptions: [sub],
});
const response = await callRoute(
stripeWebhook,
signed({ type: 'customer.subscription.updated' })
);
expect(response.status).toBe(200);
const stored = await db.user.findUniqueOrThrow({ where: { id: user.id } });
expect(stored.subscriptionStatus).toBe(expected);
// The period already ended, so access is closed off.
expect(stored.billingAccessEndedAt).toBeInstanceOf(Date);
});
it('marks the subscription canceled when Stripe reports none left', async () => {
const user = await createUser({
stripeCustomerId: CUSTOMER_ID,
subscriptionStatus: 'ACTIVE',
stripeSubscriptionId: 'sub_test_1',
stripePriceId: ENTITLED_PRICE_ID,
stripeCurrentPeriodEnd: new Date(Date.now() + 86_400_000),
trialEndsAt: null,
});
stubStripe({
event: {
id: 'evt_5',
type: 'customer.subscription.deleted',
data: { object: subscription({ status: 'canceled' }) },
},
subscriptions: [],
});
const response = await callRoute(
stripeWebhook,
signed({ type: 'customer.subscription.deleted' })
);
expect(response.status).toBe(200);
const stored = await db.user.findUniqueOrThrow({ where: { id: user.id } });
expect(stored.subscriptionStatus).toBe('CANCELED');
expect(stored.stripeSubscriptionId).toBeNull();
expect(stored.stripePriceId).toBeNull();
expect(stored.stripeCurrentPeriodEnd).toBeNull();
expect(stored.trialEndsAt).toBeNull();
expect(stored.billingAccessEndedAt).toBeInstanceOf(Date);
});
// The route deliberately re-lists rather than trusting the event body, so an
// out-of-order delete of an old subscription cannot revoke a newer active one.
it('keeps the newer active subscription when an older one is deleted', async () => {
const periodEnd = unix(30 * 24 * HOUR);
const user = await createUser({ stripeCustomerId: CUSTOMER_ID, trialEndsAt: null });
const stale = subscription({
id: 'sub_old',
status: 'canceled',
created: unix(-90 * 24 * HOUR),
current_period_end: unix(-HOUR),
});
const live = subscription({
id: 'sub_new',
status: 'active',
created: unix(-HOUR),
current_period_end: periodEnd,
});
stubStripe({
event: { id: 'evt_6', type: 'customer.subscription.deleted', data: { object: stale } },
subscriptions: [stale, live],
});
const response = await callRoute(
stripeWebhook,
signed({ type: 'customer.subscription.deleted' })
);
expect(response.status).toBe(200);
const stored = await db.user.findUniqueOrThrow({ where: { id: user.id } });
expect(stored.subscriptionStatus).toBe('ACTIVE');
expect(stored.stripeSubscriptionId).toBe('sub_new');
expect(stored.stripeCurrentPeriodEnd?.getTime()).toBe(periodEnd * 1000);
});
it('syncs on a completed subscription checkout', async () => {
const periodEnd = unix(30 * 24 * HOUR);
const user = await createUser({
stripeCustomerId: CUSTOMER_ID,
subscriptionStatus: 'FREE',
trialEndsAt: null,
});
const counters = stubStripe({
event: {
id: 'evt_7',
type: 'checkout.session.completed',
data: { object: { mode: 'subscription', customer: CUSTOMER_ID } },
},
subscriptions: [subscription({ current_period_end: periodEnd })],
});
const response = await callRoute(stripeWebhook, signed({ type: 'checkout.session.completed' }));
expect(response.status).toBe(200);
expect(counters.listCalls).toBe(1);
expect((await db.user.findUniqueOrThrow({ where: { id: user.id } })).subscriptionStatus).toBe(
'ACTIVE'
);
});
it('ignores a one-off payment checkout', async () => {
const user = await createUser({
stripeCustomerId: CUSTOMER_ID,
subscriptionStatus: 'FREE',
trialEndsAt: null,
});
const counters = stubStripe({
event: {
id: 'evt_8',
type: 'checkout.session.completed',
data: { object: { mode: 'payment', customer: CUSTOMER_ID } },
},
subscriptions: [subscription()],
});
const response = await callRoute(stripeWebhook, signed({ type: 'checkout.session.completed' }));
expect(response.status).toBe(200);
expect(counters.listCalls).toBe(0);
expect((await db.user.findUniqueOrThrow({ where: { id: user.id } })).subscriptionStatus).toBe(
'FREE'
);
});
it('acknowledges an unhandled event type without touching any user', async () => {
const user = await createUser({
stripeCustomerId: CUSTOMER_ID,
subscriptionStatus: 'FREE',
trialEndsAt: null,
});
const counters = stubStripe({
event: { id: 'evt_9', type: 'invoice.payment_succeeded', data: { object: {} } },
subscriptions: [subscription()],
});
const response = await callRoute(stripeWebhook, signed({ type: 'invoice.payment_succeeded' }));
expect(response.status).toBe(200);
expect(await response.json()).toEqual({ received: true });
expect(counters.listCalls).toBe(0);
expect((await db.user.findUniqueOrThrow({ where: { id: user.id } })).subscriptionStatus).toBe(
'FREE'
);
});
it('acknowledges an event for a customer with no local user and writes nothing', async () => {
stubStripe({
event: {
id: 'evt_10',
type: 'customer.subscription.updated',
data: { object: subscription({ customer: 'cus_unknown' }) },
},
subscriptions: [subscription({ customer: 'cus_unknown' })],
});
const response = await callRoute(
stripeWebhook,
signed({ type: 'customer.subscription.updated' })
);
expect(response.status).toBe(200);
expect(await db.user.count()).toBe(0);
});
it('is idempotent when the same event is replayed', async () => {
const periodEnd = unix(30 * 24 * HOUR);
const user = await createUser({
stripeCustomerId: CUSTOMER_ID,
subscriptionStatus: 'FREE',
trialEndsAt: null,
billingTrialConsumedAt: null,
});
const sub = subscription({
status: 'trialing',
trial_end: unix(7 * 24 * HOUR),
current_period_end: periodEnd,
});
stubStripe({
event: { id: 'evt_11', type: 'customer.subscription.updated', data: { object: sub } },
subscriptions: [sub],
});
const first = await callRoute(stripeWebhook, signed({ type: 'customer.subscription.updated' }));
const afterFirst = await db.user.findUniqueOrThrow({ where: { id: user.id } });
const second = await callRoute(
stripeWebhook,
signed({ type: 'customer.subscription.updated' })
);
const afterSecond = await db.user.findUniqueOrThrow({ where: { id: user.id } });
expect(first.status).toBe(200);
expect(second.status).toBe(200);
expect(afterSecond.subscriptionStatus).toBe(afterFirst.subscriptionStatus);
expect(afterSecond.stripeSubscriptionId).toBe(afterFirst.stripeSubscriptionId);
expect(afterSecond.stripeCurrentPeriodEnd?.getTime()).toBe(
afterFirst.stripeCurrentPeriodEnd?.getTime()
);
expect(afterSecond.trialEndsAt?.getTime()).toBe(afterFirst.trialEndsAt?.getTime());
// The first sync stamps the trial as consumed; a replay must not push it
// forward, or a user could win a fresh trial by resending a webhook.
expect(afterSecond.billingTrialConsumedAt?.getTime()).toBe(
afterFirst.billingTrialConsumedAt?.getTime()
);
});
it('records a scheduled cancellation without revoking access', async () => {
const periodEnd = unix(15 * 24 * HOUR);
const user = await createUser({ stripeCustomerId: CUSTOMER_ID, trialEndsAt: null });
const sub = subscription({
cancel_at_period_end: true,
cancel_at: periodEnd,
current_period_end: periodEnd,
});
stubStripe({
event: { id: 'evt_12', type: 'customer.subscription.updated', data: { object: sub } },
subscriptions: [sub],
});
const response = await callRoute(
stripeWebhook,
signed({ type: 'customer.subscription.updated' })
);
expect(response.status).toBe(200);
const stored = await db.user.findUniqueOrThrow({ where: { id: user.id } });
expect(stored.subscriptionStatus).toBe('ACTIVE');
expect(stored.stripeCancelAtPeriodEnd).toBe(true);
expect(stored.stripeCancelAt?.getTime()).toBe(periodEnd * 1000);
expect(stored.billingAccessEndedAt).toBeNull();
});
it('returns 500 when the sync itself fails', async () => {
await createUser({ stripeCustomerId: CUSTOMER_ID });
vi.mocked(getStripe as unknown as () => unknown).mockReturnValue({
webhooks: {
constructEvent: vi.fn(() => ({
id: 'evt_13',
type: 'customer.subscription.updated',
data: { object: subscription() },
})),
},
subscriptions: {
list: vi.fn(async () => {
throw new Error('Stripe is down');
}),
},
});
const response = await callRoute(
stripeWebhook,
signed({ type: 'customer.subscription.updated' })
);
// A 500 tells Stripe to retry, which is the correct behaviour for a
// transient upstream failure.
expect(response.status).toBe(500);
});
});