Skip to content

Commit 71c2641

Browse files
committed
test: add unit tests for billing-portal endpoint using dependency injection
1 parent 164abc5 commit 71c2641

File tree

3 files changed

+247
-31
lines changed

3 files changed

+247
-31
lines changed
Lines changed: 177 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,177 @@
1+
import { describe, expect, mock, test } from 'bun:test'
2+
3+
import type { Logger } from '@codebuff/common/types/contracts/logger'
4+
5+
import { postBillingPortal } from '../_post'
6+
7+
import type { CreateBillingPortalSessionFn, GetSessionFn, Session } from '../_post'
8+
9+
const createMockLogger = (errorFn = mock(() => {})): Logger => ({
10+
error: errorFn,
11+
warn: mock(() => {}),
12+
info: mock(() => {}),
13+
debug: mock(() => {}),
14+
})
15+
16+
const createMockGetSession = (session: Session): GetSessionFn => mock(() => Promise.resolve(session))
17+
18+
const createMockCreateBillingPortalSession = (
19+
result: { url: string } | Error = { url: 'https://billing.stripe.com/session/test_123' }
20+
): CreateBillingPortalSessionFn => {
21+
if (result instanceof Error) {
22+
return mock(() => Promise.reject(result))
23+
}
24+
return mock(() => Promise.resolve(result))
25+
}
26+
27+
describe('/api/user/billing-portal POST endpoint', () => {
28+
const returnUrl = 'https://codebuff.com/profile'
29+
30+
describe('Authentication', () => {
31+
test('returns 401 when session is null', async () => {
32+
const response = await postBillingPortal({
33+
getSession: createMockGetSession(null),
34+
createBillingPortalSession: createMockCreateBillingPortalSession(),
35+
logger: createMockLogger(),
36+
returnUrl,
37+
})
38+
39+
expect(response.status).toBe(401)
40+
const body = await response.json()
41+
expect(body).toEqual({ error: 'Unauthorized' })
42+
})
43+
44+
test('returns 401 when session.user is null', async () => {
45+
const response = await postBillingPortal({
46+
getSession: createMockGetSession({ user: null }),
47+
createBillingPortalSession: createMockCreateBillingPortalSession(),
48+
logger: createMockLogger(),
49+
returnUrl,
50+
})
51+
52+
expect(response.status).toBe(401)
53+
const body = await response.json()
54+
expect(body).toEqual({ error: 'Unauthorized' })
55+
})
56+
57+
test('returns 401 when session.user.id is missing', async () => {
58+
const response = await postBillingPortal({
59+
getSession: createMockGetSession({ user: { stripe_customer_id: 'cus_123' } as any }),
60+
createBillingPortalSession: createMockCreateBillingPortalSession(),
61+
logger: createMockLogger(),
62+
returnUrl,
63+
})
64+
65+
expect(response.status).toBe(401)
66+
const body = await response.json()
67+
expect(body).toEqual({ error: 'Unauthorized' })
68+
})
69+
})
70+
71+
describe('Stripe customer validation', () => {
72+
test('returns 400 when stripe_customer_id is null', async () => {
73+
const response = await postBillingPortal({
74+
getSession: createMockGetSession({
75+
user: { id: 'user-123', stripe_customer_id: null },
76+
}),
77+
createBillingPortalSession: createMockCreateBillingPortalSession(),
78+
logger: createMockLogger(),
79+
returnUrl,
80+
})
81+
82+
expect(response.status).toBe(400)
83+
const body = await response.json()
84+
expect(body).toEqual({ error: 'No Stripe customer ID found' })
85+
})
86+
87+
test('returns 400 when stripe_customer_id is undefined', async () => {
88+
const response = await postBillingPortal({
89+
getSession: createMockGetSession({
90+
user: { id: 'user-123' },
91+
}),
92+
createBillingPortalSession: createMockCreateBillingPortalSession(),
93+
logger: createMockLogger(),
94+
returnUrl,
95+
})
96+
97+
expect(response.status).toBe(400)
98+
const body = await response.json()
99+
expect(body).toEqual({ error: 'No Stripe customer ID found' })
100+
})
101+
})
102+
103+
describe('Successful portal session creation', () => {
104+
test('returns 200 with portal URL on success', async () => {
105+
const expectedUrl = 'https://billing.stripe.com/session/abc123'
106+
const response = await postBillingPortal({
107+
getSession: createMockGetSession({
108+
user: { id: 'user-123', stripe_customer_id: 'cus_test_123' },
109+
}),
110+
createBillingPortalSession: createMockCreateBillingPortalSession({ url: expectedUrl }),
111+
logger: createMockLogger(),
112+
returnUrl,
113+
})
114+
115+
expect(response.status).toBe(200)
116+
const body = await response.json()
117+
expect(body).toEqual({ url: expectedUrl })
118+
})
119+
120+
test('calls createBillingPortalSession with correct parameters', async () => {
121+
const mockCreateSession = createMockCreateBillingPortalSession()
122+
await postBillingPortal({
123+
getSession: createMockGetSession({
124+
user: { id: 'user-123', stripe_customer_id: 'cus_test_456' },
125+
}),
126+
createBillingPortalSession: mockCreateSession,
127+
logger: createMockLogger(),
128+
returnUrl: 'https://example.com/return',
129+
})
130+
131+
expect(mockCreateSession).toHaveBeenCalledTimes(1)
132+
expect(mockCreateSession).toHaveBeenCalledWith({
133+
customer: 'cus_test_456',
134+
return_url: 'https://example.com/return',
135+
})
136+
})
137+
})
138+
139+
describe('Error handling', () => {
140+
test('returns 500 when Stripe API throws an error', async () => {
141+
const response = await postBillingPortal({
142+
getSession: createMockGetSession({
143+
user: { id: 'user-123', stripe_customer_id: 'cus_test_123' },
144+
}),
145+
createBillingPortalSession: createMockCreateBillingPortalSession(
146+
new Error('Stripe API error')
147+
),
148+
logger: createMockLogger(),
149+
returnUrl,
150+
})
151+
152+
expect(response.status).toBe(500)
153+
const body = await response.json()
154+
expect(body).toEqual({ error: 'Failed to create billing portal session' })
155+
})
156+
157+
test('logs error when Stripe API fails', async () => {
158+
const mockLoggerError = mock(() => {})
159+
const testError = new Error('Stripe connection failed')
160+
161+
await postBillingPortal({
162+
getSession: createMockGetSession({
163+
user: { id: 'user-123', stripe_customer_id: 'cus_test_123' },
164+
}),
165+
createBillingPortalSession: createMockCreateBillingPortalSession(testError),
166+
logger: createMockLogger(mockLoggerError),
167+
returnUrl,
168+
})
169+
170+
expect(mockLoggerError).toHaveBeenCalledTimes(1)
171+
expect(mockLoggerError).toHaveBeenCalledWith(
172+
{ userId: 'user-123', error: testError },
173+
'Failed to create billing portal session'
174+
)
175+
})
176+
})
177+
})
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
import { NextResponse } from 'next/server'
2+
3+
import type { Logger } from '@codebuff/common/types/contracts/logger'
4+
5+
export type SessionUser = {
6+
id: string
7+
stripe_customer_id?: string | null
8+
}
9+
10+
export type Session = {
11+
user?: SessionUser | null
12+
} | null
13+
14+
export type GetSessionFn = () => Promise<Session>
15+
16+
export type CreateBillingPortalSessionFn = (params: {
17+
customer: string
18+
return_url: string
19+
}) => Promise<{ url: string }>
20+
21+
export type PostBillingPortalParams = {
22+
getSession: GetSessionFn
23+
createBillingPortalSession: CreateBillingPortalSessionFn
24+
logger: Logger
25+
returnUrl: string
26+
}
27+
28+
export async function postBillingPortal(params: PostBillingPortalParams) {
29+
const { getSession, createBillingPortalSession, logger, returnUrl } = params
30+
31+
const session = await getSession()
32+
if (!session?.user?.id) {
33+
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
34+
}
35+
36+
const stripeCustomerId = session.user.stripe_customer_id
37+
if (!stripeCustomerId) {
38+
return NextResponse.json(
39+
{ error: 'No Stripe customer ID found' },
40+
{ status: 400 }
41+
)
42+
}
43+
44+
try {
45+
const portalSession = await createBillingPortalSession({
46+
customer: stripeCustomerId,
47+
return_url: returnUrl,
48+
})
49+
50+
return NextResponse.json({ url: portalSession.url })
51+
} catch (error) {
52+
logger.error(
53+
{ userId: session.user.id, error },
54+
'Failed to create billing portal session'
55+
)
56+
return NextResponse.json(
57+
{ error: 'Failed to create billing portal session' },
58+
{ status: 500 }
59+
)
60+
}
61+
}
Lines changed: 9 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -1,40 +1,18 @@
11
import { env } from '@codebuff/internal/env'
22
import { stripeServer } from '@codebuff/internal/util/stripe'
3-
import { NextResponse } from 'next/server'
43
import { getServerSession } from 'next-auth'
54

65
import { authOptions } from '@/app/api/auth/[...nextauth]/auth-options'
76
import { logger } from '@/util/logger'
87

9-
export async function POST() {
10-
const session = await getServerSession(authOptions)
11-
if (!session?.user?.id) {
12-
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
13-
}
14-
15-
const stripeCustomerId = session.user.stripe_customer_id
16-
if (!stripeCustomerId) {
17-
return NextResponse.json(
18-
{ error: 'No Stripe customer ID found' },
19-
{ status: 400 }
20-
)
21-
}
8+
import { postBillingPortal } from './_post'
229

23-
try {
24-
const portalSession = await stripeServer.billingPortal.sessions.create({
25-
customer: stripeCustomerId,
26-
return_url: `${env.NEXT_PUBLIC_CODEBUFF_APP_URL}/profile`,
27-
})
28-
29-
return NextResponse.json({ url: portalSession.url })
30-
} catch (error) {
31-
logger.error(
32-
{ userId: session.user.id, error },
33-
'Failed to create billing portal session'
34-
)
35-
return NextResponse.json(
36-
{ error: 'Failed to create billing portal session' },
37-
{ status: 500 }
38-
)
39-
}
10+
export async function POST() {
11+
return postBillingPortal({
12+
getSession: () => getServerSession(authOptions),
13+
createBillingPortalSession: (params) =>
14+
stripeServer.billingPortal.sessions.create(params),
15+
logger,
16+
returnUrl: `${env.NEXT_PUBLIC_CODEBUFF_APP_URL}/profile`,
17+
})
4018
}

0 commit comments

Comments
 (0)