208 lines
8.2 KiB
JavaScript
208 lines
8.2 KiB
JavaScript
process.env.STRIPE_SECRET_KEY = 'sk_test_dummy';
|
|
|
|
// Default mock for the founder-code / price-id tests (no DB interaction).
|
|
// Webhook tests below replace the implementation per-test.
|
|
const mockSupabaseClient = { current: { from: jest.fn() } };
|
|
jest.mock('../../src/utils/supabase', () => ({
|
|
getSupabaseServiceClient: () => mockSupabaseClient.current,
|
|
}));
|
|
|
|
const { isFounderCodeValid, getPriceId, handleWebhookEvent } = require('../../src/services/stripeService');
|
|
|
|
describe('stripeService', () => {
|
|
describe('isFounderCodeValid', () => {
|
|
test('valid founder code returns true', () => {
|
|
expect(isFounderCodeValid('FOUNDER2026')).toBe(true);
|
|
expect(isFounderCodeValid('VYNDR')).toBe(true);
|
|
expect(isFounderCodeValid('BETONBLK')).toBe(true); // legacy promo, still honored
|
|
});
|
|
|
|
test('case insensitive', () => {
|
|
expect(isFounderCodeValid('founder2026')).toBe(true);
|
|
});
|
|
|
|
test('invalid code returns false', () => {
|
|
expect(isFounderCodeValid('INVALID')).toBe(false);
|
|
});
|
|
|
|
test('null/empty returns false', () => {
|
|
expect(isFounderCodeValid(null)).toBe(false);
|
|
expect(isFounderCodeValid('')).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('getPriceId', () => {
|
|
test('analyst without founder code returns standard price', () => {
|
|
const id = getPriceId('analyst', null);
|
|
expect(id).toContain('analyst');
|
|
expect(id).not.toContain('founder');
|
|
});
|
|
|
|
test('analyst with valid founder code returns founder price', () => {
|
|
const id = getPriceId('analyst', 'FOUNDER2026');
|
|
expect(id).toContain('founder');
|
|
});
|
|
|
|
test('desk without founder code returns standard price', () => {
|
|
const id = getPriceId('desk', null);
|
|
expect(id).toContain('desk');
|
|
expect(id).not.toContain('founder');
|
|
});
|
|
|
|
test('desk with valid founder code returns founder price', () => {
|
|
const id = getPriceId('desk', 'BETONBLK');
|
|
expect(id).toContain('founder');
|
|
});
|
|
|
|
test('invalid tier throws', () => {
|
|
expect(() => getPriceId('gold', null)).toThrow('Invalid tier');
|
|
});
|
|
|
|
describe('Session 14 — africa tier', () => {
|
|
const original = process.env.STRIPE_PRICE_AFRICA;
|
|
afterAll(() => {
|
|
if (original == null) delete process.env.STRIPE_PRICE_AFRICA;
|
|
else process.env.STRIPE_PRICE_AFRICA = original;
|
|
});
|
|
|
|
test('africa returns the configured price ID when set', () => {
|
|
// We can't re-import to pick up the env change after the
|
|
// module loaded its PRICE_MAP at require-time, so this test
|
|
// asserts the contract: getPriceId('africa') returns either
|
|
// a price ID OR the sentinel. The route-layer integration
|
|
// test covers the env-flip → 503 path end-to-end.
|
|
const result = getPriceId('africa', null);
|
|
expect(typeof result).toBe('string');
|
|
});
|
|
|
|
test("africa never returns a founder-discounted variant (the tier IS the discount)", () => {
|
|
const a = getPriceId('africa', null);
|
|
const b = getPriceId('africa', 'FOUNDER2026');
|
|
expect(a).toBe(b);
|
|
});
|
|
|
|
test('exports PRICE_UNCONFIGURED sentinel', () => {
|
|
const { PRICE_UNCONFIGURED } = require('../../src/services/stripeService');
|
|
expect(typeof PRICE_UNCONFIGURED).toBe('string');
|
|
expect(PRICE_UNCONFIGURED.length).toBeGreaterThan(0);
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('handleWebhookEvent', () => {
|
|
// A chainable supabase fake whose final-chain return is configurable per call.
|
|
// Records every update payload so tests can assert grace_period_until etc.
|
|
function makeFake({ findUserById = 'user-1' } = {}) {
|
|
const updates = [];
|
|
const fake = {
|
|
updates,
|
|
from(table) {
|
|
const ctx = { table, filters: [] };
|
|
const proxy = {
|
|
update(patch) {
|
|
ctx.patch = patch;
|
|
ctx.action = 'update';
|
|
updates.push(ctx);
|
|
return proxy;
|
|
},
|
|
select() {
|
|
ctx.action = 'select';
|
|
return proxy;
|
|
},
|
|
eq(col, val) {
|
|
ctx.filters.push([col, val]);
|
|
if (ctx.action === 'update') return Promise.resolve({ error: null });
|
|
return proxy;
|
|
},
|
|
single() {
|
|
return Promise.resolve({ data: findUserById ? { id: findUserById } : null });
|
|
},
|
|
};
|
|
return proxy;
|
|
},
|
|
};
|
|
return fake;
|
|
}
|
|
|
|
test('checkout.session.completed updates users + mirrors to user_profiles, clears grace', async () => {
|
|
const fake = makeFake();
|
|
mockSupabaseClient.current = fake;
|
|
await handleWebhookEvent({
|
|
type: 'checkout.session.completed',
|
|
data: { object: { metadata: { user_id: 'u1', tier: 'analyst', is_founder: 'true' }, customer: 'cus_1' } },
|
|
});
|
|
const usersUpdate = fake.updates.find((u) => u.table === 'users');
|
|
const profilesUpdate = fake.updates.find((u) => u.table === 'user_profiles');
|
|
expect(usersUpdate.patch.tier).toBe('analyst');
|
|
expect(usersUpdate.patch.grace_period_until).toBeNull();
|
|
expect(usersUpdate.patch.mfa_setup_prompted).toBe(false);
|
|
expect(profilesUpdate.patch.tier).toBe('analyst');
|
|
expect(profilesUpdate.patch.subscription_status).toBe('active');
|
|
expect(profilesUpdate.patch.founder_pricing).toBe(true);
|
|
});
|
|
|
|
test('invoice.payment_failed sets a ~48h grace window', async () => {
|
|
const fake = makeFake();
|
|
mockSupabaseClient.current = fake;
|
|
const before = Date.now();
|
|
await handleWebhookEvent({
|
|
type: 'invoice.payment_failed',
|
|
data: { object: { customer: 'cus_2' } },
|
|
});
|
|
const usersUpdate = fake.updates.find((u) => u.table === 'users');
|
|
const profilesUpdate = fake.updates.find((u) => u.table === 'user_profiles');
|
|
const graceTs = new Date(usersUpdate.patch.grace_period_until).getTime();
|
|
const expected = before + 48 * 60 * 60 * 1000;
|
|
expect(Math.abs(graceTs - expected)).toBeLessThan(60_000); // within a minute of 48h
|
|
expect(profilesUpdate.patch.subscription_status).toBe('grace_period');
|
|
});
|
|
|
|
test('customer.subscription.updated active → flips tier to the new plan + clears grace', async () => {
|
|
// Plan-change flow (portal-driven upgrade/downgrade). Stripe sends
|
|
// the new price on items.data[0].price.id; the service must map it
|
|
// back to a tier via PRICE_MAP and reflect that on the user.
|
|
const fake = makeFake();
|
|
mockSupabaseClient.current = fake;
|
|
const newPriceId = process.env.STRIPE_PRICE_DESK || 'price_desk_monthly';
|
|
await handleWebhookEvent({
|
|
type: 'customer.subscription.updated',
|
|
data: {
|
|
object: {
|
|
customer: 'cus_active',
|
|
status: 'active',
|
|
items: { data: [{ price: { id: newPriceId } }] },
|
|
},
|
|
},
|
|
});
|
|
const usersUpdate = fake.updates.find((u) => u.table === 'users');
|
|
const profilesUpdate = fake.updates.find((u) => u.table === 'user_profiles');
|
|
expect(usersUpdate.patch.tier).toBe('desk');
|
|
expect(usersUpdate.patch.grace_period_until).toBeNull();
|
|
expect(profilesUpdate.patch.tier).toBe('desk');
|
|
expect(profilesUpdate.patch.subscription_status).toBe('active');
|
|
});
|
|
|
|
test('customer.subscription.deleted sets grace, does not flip tier immediately', async () => {
|
|
const fake = makeFake();
|
|
mockSupabaseClient.current = fake;
|
|
await handleWebhookEvent({
|
|
type: 'customer.subscription.deleted',
|
|
data: { object: { customer: 'cus_3' } },
|
|
});
|
|
const usersUpdate = fake.updates.find((u) => u.table === 'users');
|
|
expect(usersUpdate.patch.grace_period_until).toBeTruthy();
|
|
// tier is intentionally NOT downgraded here — the grace period gate
|
|
// handles read-time enforcement.
|
|
expect(usersUpdate.patch.tier).toBeUndefined();
|
|
});
|
|
|
|
test('payment_failed with unknown customer logs but does not throw', async () => {
|
|
const fake = makeFake({ findUserById: null });
|
|
mockSupabaseClient.current = fake;
|
|
await expect(
|
|
handleWebhookEvent({ type: 'invoice.payment_failed', data: { object: { customer: 'cus_ghost' } } })
|
|
).resolves.toBeUndefined();
|
|
});
|
|
});
|
|
});
|