mirror of
https://github.com/yusufipk/OpenFrame.git
synced 2026-09-11 17:46:06 +00:00
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.
494 lines
17 KiB
TypeScript
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);
|
|
});
|
|
});
|