Skip to content

Commit 483aa39

Browse files
committed
fix: normalize referral code casing and throw on API failures
- Fix case-sensitive ref- prefix check: REF-abc123 now properly normalizes to ref-abc123 instead of ref-REF-abc123 - Extract normalizeReferralCode utility function to router-utils.ts for cleaner, reusable code - Throw error on non-OK API responses in fetchUserDetails so React Query enters error state and ReferralBanner shows proper error message - Add tests for mixed-case prefix normalization (REF-, Ref-, rEf-) - Add comprehensive fetchUserDetails tests for API failure handling (401, 403, 404, 500) - Add tests for successful responses and environment validation
1 parent 8b84fd8 commit 483aa39

File tree

7 files changed

+272
-21
lines changed

7 files changed

+272
-21
lines changed

cli/src/__tests__/referral-mode.test.ts

Lines changed: 40 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -218,14 +218,49 @@ describe('referral-mode', () => {
218218
expect(referralCode).toBe('ref-abc123')
219219
})
220220

221-
test('code with REF- (uppercase) does NOT get recognized', () => {
221+
test('code with REF- (uppercase) gets normalized to lowercase prefix', () => {
222222
const userInput = 'REF-abc123'
223-
const referralCode = userInput.startsWith('ref-')
224-
? userInput
223+
const userInputLower = userInput.toLowerCase()
224+
// Normalize: case-insensitive prefix check, strip and re-add lowercase prefix
225+
const referralCode = userInputLower.startsWith('ref-')
226+
? `ref-${userInput.slice(4)}`
227+
: `ref-${userInput}`
228+
229+
// Should strip REF- and re-add ref- to preserve the code portion
230+
expect(referralCode).toBe('ref-abc123')
231+
})
232+
233+
test('code with Ref- (mixed case) gets normalized to lowercase prefix', () => {
234+
const userInput = 'Ref-XYZ789'
235+
const userInputLower = userInput.toLowerCase()
236+
const referralCode = userInputLower.startsWith('ref-')
237+
? `ref-${userInput.slice(4)}`
238+
: `ref-${userInput}`
239+
240+
expect(referralCode).toBe('ref-XYZ789')
241+
})
242+
243+
test('code with rEf- (random case) gets normalized to lowercase prefix', () => {
244+
const userInput = 'rEf-Code123'
245+
const userInputLower = userInput.toLowerCase()
246+
const referralCode = userInputLower.startsWith('ref-')
247+
? `ref-${userInput.slice(4)}`
248+
: `ref-${userInput}`
249+
250+
expect(referralCode).toBe('ref-Code123')
251+
})
252+
253+
test('preserves code portion casing when normalizing prefix', () => {
254+
// User typed "REF-ABC123" - should become "ref-ABC123", not "ref-abc123"
255+
const userInput = 'REF-ABC123'
256+
const userInputLower = userInput.toLowerCase()
257+
const referralCode = userInputLower.startsWith('ref-')
258+
? `ref-${userInput.slice(4)}`
225259
: `ref-${userInput}`
226260

227-
// Should add another ref- prefix because startsWith is case-sensitive
228-
expect(referralCode).toBe('ref-REF-abc123')
261+
expect(referralCode).toBe('ref-ABC123')
262+
// Code portion should preserve original casing
263+
expect(referralCode.slice(4)).toBe('ABC123')
229264
})
230265
})
231266

cli/src/commands/__tests__/router-input.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,12 @@
11
import { describe, test, expect } from 'bun:test'
22

3+
import { findCommand, COMMAND_REGISTRY } from '../command-registry'
34
import {
45
normalizeInput,
56
parseCommand,
67
isSlashCommand,
78
isReferralCode,
89
} from '../router-utils'
9-
import { findCommand, COMMAND_REGISTRY } from '../command-registry'
1010

1111
describe('router-utils', () => {
1212
describe('normalizeInput', () => {

cli/src/commands/command-registry.ts

Lines changed: 17 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,14 @@
11
import { handleInitializationFlowLocally } from './init'
22
import { handleReferralCode } from './referral'
3+
import { normalizeReferralCode } from './router-utils'
34
import { handleUsageCommand } from './usage'
45
import { useChatStore } from '../state/chat-store'
56
import { useLoginStore } from '../state/login-store'
67
import { getSystemMessage, getUserMessage } from '../utils/message-history'
78

8-
import type { ChatMessage } from '../types/chat'
99
import type { MultilineInputHandle } from '../components/multiline-input'
1010
import type { InputValue } from '../state/chat-store'
11+
import type { ChatMessage } from '../types/chat'
1112
import type { SendMessageFn } from '../types/contracts/send-message'
1213
import type { User } from '../utils/auth'
1314
import type { AgentMode } from '../utils/constants'
@@ -81,10 +82,10 @@ export const COMMAND_REGISTRY: CommandDefinition[] = [
8182
aliases: ['redeem'],
8283
handler: async (params, args) => {
8384
const trimmedArgs = args.trim()
84-
85+
8586
// If user provided a code directly, redeem it immediately
8687
if (trimmedArgs) {
87-
const code = trimmedArgs.startsWith('ref-') ? trimmedArgs : `ref-${trimmedArgs}`
88+
const code = normalizeReferralCode(trimmedArgs)
8889
try {
8990
const { postUserMessage } = await handleReferralCode(code)
9091
params.setMessages((prev) => [
@@ -93,7 +94,8 @@ export const COMMAND_REGISTRY: CommandDefinition[] = [
9394
...postUserMessage([]),
9495
])
9596
} catch (error) {
96-
const errorMessage = error instanceof Error ? error.message : 'Unknown error'
97+
const errorMessage =
98+
error instanceof Error ? error.message : 'Unknown error'
9799
params.setMessages((prev) => [
98100
...prev,
99101
getUserMessage(params.inputValue.trim()),
@@ -104,7 +106,7 @@ export const COMMAND_REGISTRY: CommandDefinition[] = [
104106
clearInput(params)
105107
return
106108
}
107-
109+
108110
// Otherwise enter referral mode
109111
useChatStore.getState().setInputMode('referral')
110112
params.saveToHistory(params.inputValue.trim())
@@ -136,7 +138,10 @@ export const COMMAND_REGISTRY: CommandDefinition[] = [
136138
params.logoutMutation.mutate(undefined, {
137139
onSettled: () => {
138140
resetLoginState()
139-
params.setMessages((prev) => [...prev, getSystemMessage('Logged out.')])
141+
params.setMessages((prev) => [
142+
...prev,
143+
getSystemMessage('Logged out.'),
144+
])
140145
clearInput(params)
141146
setTimeout(() => {
142147
params.setUser(null)
@@ -171,7 +176,7 @@ export const COMMAND_REGISTRY: CommandDefinition[] = [
171176
handler: async (params, args) => {
172177
const { postUserMessage } = handleInitializationFlowLocally()
173178
const trimmed = params.inputValue.trim()
174-
179+
175180
params.saveToHistory(trimmed)
176181
clearInput(params)
177182

@@ -187,7 +192,11 @@ export const COMMAND_REGISTRY: CommandDefinition[] = [
187192
return
188193
}
189194

190-
params.sendMessage({ content: trimmed, agentMode: params.agentMode, postUserMessage })
195+
params.sendMessage({
196+
content: trimmed,
197+
agentMode: params.agentMode,
198+
postUserMessage,
199+
})
191200
setTimeout(() => {
192201
params.scrollToLatest()
193202
}, 0)

cli/src/commands/router-utils.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,3 +70,23 @@ export function isReferralCode(input: string): boolean {
7070
export function extractReferralCode(input: string): string {
7171
return normalizeInput(input.trim())
7272
}
73+
74+
const REFERRAL_PREFIX = 'ref-'
75+
76+
/**
77+
* Normalize a referral code by ensuring it has the lowercase 'ref-' prefix.
78+
* Handles case-insensitive prefix detection (REF-, Ref-, etc.) and preserves
79+
* the original casing of the code portion.
80+
*
81+
* @example
82+
* normalizeReferralCode('abc123') // => 'ref-abc123'
83+
* normalizeReferralCode('ref-abc123') // => 'ref-abc123'
84+
* normalizeReferralCode('REF-ABC123') // => 'ref-ABC123'
85+
* normalizeReferralCode('Ref-XYZ') // => 'ref-XYZ'
86+
*/
87+
export function normalizeReferralCode(code: string): string {
88+
const trimmed = code.trim()
89+
const hasPrefix = trimmed.toLowerCase().startsWith(REFERRAL_PREFIX)
90+
const codeWithoutPrefix = hasPrefix ? trimmed.slice(REFERRAL_PREFIX.length) : trimmed
91+
return `${REFERRAL_PREFIX}${codeWithoutPrefix}`
92+
}

cli/src/commands/router.ts

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import {
1111
isSlashCommand,
1212
isReferralCode,
1313
extractReferralCode,
14+
normalizeReferralCode,
1415
} from './router-utils'
1516
import { useChatStore } from '../state/chat-store'
1617
import { getSystemMessage, getUserMessage } from '../utils/message-history'
@@ -97,11 +98,13 @@ export async function routeUserPrompt(
9798

9899
// Handle referral mode input
99100
if (inputMode === 'referral') {
100-
// Validate and normalize the referral code
101-
// Valid codes are alphanumeric with optional dashes, 3-50 chars
101+
// Validate the referral code (3-50 alphanumeric chars with optional dashes)
102102
const codePattern = /^[a-zA-Z0-9-]{3,50}$/
103-
const codeWithoutPrefix = trimmed.startsWith('ref-') ? trimmed.slice(4) : trimmed
104-
103+
// Strip prefix if present for validation (case-insensitive)
104+
const codeWithoutPrefix = trimmed.toLowerCase().startsWith('ref-')
105+
? trimmed.slice(4)
106+
: trimmed
107+
105108
if (!codePattern.test(codeWithoutPrefix)) {
106109
setMessages((prev) => [
107110
...prev,
@@ -113,8 +116,8 @@ export async function routeUserPrompt(
113116
setInputMode('default')
114117
return
115118
}
116-
117-
const referralCode = trimmed.startsWith('ref-') ? trimmed : `ref-${trimmed}`
119+
120+
const referralCode = normalizeReferralCode(trimmed)
118121
try {
119122
const { postUserMessage: referralPostMessage } =
120123
await handleReferralCode(referralCode)
Lines changed: 184 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,184 @@
1+
import { describe, test, expect, mock, beforeEach, afterEach } from 'bun:test'
2+
3+
import { fetchUserDetails } from '../use-user-details-query'
4+
5+
import type { Logger } from '@codebuff/common/types/contracts/logger'
6+
7+
describe('fetchUserDetails', () => {
8+
const mockLogger: Logger = {
9+
error: mock(() => {}),
10+
warn: mock(() => {}),
11+
info: mock(() => {}),
12+
debug: mock(() => {}),
13+
trace: mock(() => {}),
14+
fatal: mock(() => {}),
15+
child: mock(() => mockLogger),
16+
}
17+
18+
const originalEnv = process.env.NEXT_PUBLIC_CODEBUFF_APP_URL
19+
const originalFetch = globalThis.fetch
20+
21+
beforeEach(() => {
22+
process.env.NEXT_PUBLIC_CODEBUFF_APP_URL = 'https://test.codebuff.com'
23+
})
24+
25+
afterEach(() => {
26+
process.env.NEXT_PUBLIC_CODEBUFF_APP_URL = originalEnv
27+
globalThis.fetch = originalFetch
28+
})
29+
30+
describe('API failure handling', () => {
31+
test('throws error on 401 Unauthorized response', async () => {
32+
globalThis.fetch = mock(() =>
33+
Promise.resolve({
34+
ok: false,
35+
status: 401,
36+
} as Response),
37+
)
38+
39+
await expect(
40+
fetchUserDetails({
41+
authToken: 'invalid-token',
42+
fields: ['referral_link'] as const,
43+
logger: mockLogger,
44+
}),
45+
).rejects.toThrow('Failed to fetch user details (HTTP 401)')
46+
})
47+
48+
test('throws error on 500 Internal Server Error response', async () => {
49+
globalThis.fetch = mock(() =>
50+
Promise.resolve({
51+
ok: false,
52+
status: 500,
53+
} as Response),
54+
)
55+
56+
await expect(
57+
fetchUserDetails({
58+
authToken: 'valid-token',
59+
fields: ['referral_link'] as const,
60+
logger: mockLogger,
61+
}),
62+
).rejects.toThrow('Failed to fetch user details (HTTP 500)')
63+
})
64+
65+
test('throws error on 403 Forbidden response', async () => {
66+
globalThis.fetch = mock(() =>
67+
Promise.resolve({
68+
ok: false,
69+
status: 403,
70+
} as Response),
71+
)
72+
73+
await expect(
74+
fetchUserDetails({
75+
authToken: 'valid-token',
76+
fields: ['referral_link'] as const,
77+
logger: mockLogger,
78+
}),
79+
).rejects.toThrow('Failed to fetch user details (HTTP 403)')
80+
})
81+
82+
test('throws error on 404 Not Found response', async () => {
83+
globalThis.fetch = mock(() =>
84+
Promise.resolve({
85+
ok: false,
86+
status: 404,
87+
} as Response),
88+
)
89+
90+
await expect(
91+
fetchUserDetails({
92+
authToken: 'valid-token',
93+
fields: ['id', 'email'] as const,
94+
logger: mockLogger,
95+
}),
96+
).rejects.toThrow('Failed to fetch user details (HTTP 404)')
97+
})
98+
99+
test('logs error before throwing on API failure', async () => {
100+
const errorSpy = mock(() => {})
101+
const testLogger: Logger = {
102+
...mockLogger,
103+
error: errorSpy,
104+
}
105+
106+
globalThis.fetch = mock(() =>
107+
Promise.resolve({
108+
ok: false,
109+
status: 500,
110+
} as Response),
111+
)
112+
113+
await expect(
114+
fetchUserDetails({
115+
authToken: 'valid-token',
116+
fields: ['referral_link'] as const,
117+
logger: testLogger,
118+
}),
119+
).rejects.toThrow()
120+
121+
expect(errorSpy).toHaveBeenCalled()
122+
})
123+
})
124+
125+
describe('successful responses', () => {
126+
test('returns user details on successful response', async () => {
127+
const mockUserDetails = {
128+
referral_link: 'https://codebuff.com/r/abc123',
129+
}
130+
131+
globalThis.fetch = mock(() =>
132+
Promise.resolve({
133+
ok: true,
134+
status: 200,
135+
json: () => Promise.resolve(mockUserDetails),
136+
} as Response),
137+
)
138+
139+
const result = await fetchUserDetails({
140+
authToken: 'valid-token',
141+
fields: ['referral_link'] as const,
142+
logger: mockLogger,
143+
})
144+
145+
expect(result).toEqual(mockUserDetails)
146+
})
147+
148+
test('returns null referral_link when not set', async () => {
149+
const mockUserDetails = {
150+
referral_link: null,
151+
}
152+
153+
globalThis.fetch = mock(() =>
154+
Promise.resolve({
155+
ok: true,
156+
status: 200,
157+
json: () => Promise.resolve(mockUserDetails),
158+
} as Response),
159+
)
160+
161+
const result = await fetchUserDetails({
162+
authToken: 'valid-token',
163+
fields: ['referral_link'] as const,
164+
logger: mockLogger,
165+
})
166+
167+
expect(result?.referral_link).toBe(null)
168+
})
169+
})
170+
171+
describe('environment validation', () => {
172+
test('throws error when NEXT_PUBLIC_CODEBUFF_APP_URL is not set', async () => {
173+
delete process.env.NEXT_PUBLIC_CODEBUFF_APP_URL
174+
175+
await expect(
176+
fetchUserDetails({
177+
authToken: 'valid-token',
178+
fields: ['referral_link'] as const,
179+
logger: mockLogger,
180+
}),
181+
).rejects.toThrow('NEXT_PUBLIC_CODEBUFF_APP_URL is not set')
182+
})
183+
})
184+
})

0 commit comments

Comments
 (0)