Skip to content

Commit e800370

Browse files
Copilotrickyrombo
andauthored
Add unit tests for OAuth.refreshAccessToken() (#13857)
The refresh-token exchange logic was moved into `OAuth.refreshAccessToken()` without corresponding unit tests. This adds focused coverage for that method. ## Tests added (`OAuth.test.ts`) - **Success path** — token store is updated with new access + refresh tokens; new access token is returned - **Request shape** — verifies `grant_type`, `refresh_token`, and `client_id` are sent correctly - **Failure paths**: - Non-OK HTTP response → `null`, token store unchanged - Invalid JSON body → `null` - Missing `access_token` or `refresh_token` in response → `null` - Missing `tokenStore` or `basePath` config → `null` - No refresh token in store → `null` - `fetch` throws (network error) → `null` `jsdom` is not installed in this package, so the browser-only constructor guard is satisfied by stubbing `window` via `vi.stubGlobal`. <!-- START COPILOT CODING AGENT TIPS --> --- 💡 You can make Copilot smarter by setting up custom instructions, customizing its development environment and configuring Model Context Protocol (MCP) servers. Learn more [Copilot coding agent tips](https://gh.io/copilot-coding-agent-tips) in the docs. --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: rickyrombo <3690498+rickyrombo@users.noreply.github.com>
1 parent 9535fc8 commit e800370

2 files changed

Lines changed: 206 additions & 5 deletions

File tree

package-lock.json

Lines changed: 0 additions & 5 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Lines changed: 206 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,206 @@
1+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
2+
3+
import { OAuth } from './OAuth'
4+
import { OAuthTokenStore } from './tokenStore'
5+
6+
// The OAuth constructor requires `window` to be defined (it is browser-only).
7+
// Stub just enough of the browser global so we can instantiate OAuth in Node.
8+
vi.stubGlobal('window', {
9+
localStorage: { getItem: vi.fn(), setItem: vi.fn(), removeItem: vi.fn() },
10+
sessionStorage: { getItem: vi.fn(), setItem: vi.fn(), removeItem: vi.fn() },
11+
addEventListener: vi.fn(),
12+
location: { href: '', origin: 'https://example.com' },
13+
open: vi.fn()
14+
})
15+
16+
function makeOAuth(
17+
overrides: {
18+
apiKey?: string | null
19+
basePath?: string
20+
tokenStore?: OAuthTokenStore
21+
} = {}
22+
): OAuth {
23+
const { apiKey = 'test-api-key', basePath, tokenStore } = overrides
24+
return new OAuth({
25+
...(apiKey !== null ? { apiKey } : {}),
26+
...(basePath !== undefined ? { basePath } : {}),
27+
...(tokenStore !== undefined ? { tokenStore } : {})
28+
})
29+
}
30+
31+
describe('OAuth.refreshAccessToken', () => {
32+
let tokenStore: OAuthTokenStore
33+
let oauth: OAuth
34+
35+
beforeEach(() => {
36+
tokenStore = new OAuthTokenStore()
37+
tokenStore.setTokens('old-access', 'old-refresh')
38+
oauth = makeOAuth({
39+
basePath: 'https://api.example.com',
40+
tokenStore
41+
})
42+
})
43+
44+
afterEach(() => {
45+
vi.restoreAllMocks()
46+
})
47+
48+
it('success path: updates token store and returns new access token', async () => {
49+
vi.stubGlobal(
50+
'fetch',
51+
vi.fn().mockResolvedValueOnce(
52+
new Response(
53+
JSON.stringify({
54+
access_token: 'new-access',
55+
refresh_token: 'new-refresh'
56+
}),
57+
{ status: 200, headers: { 'Content-Type': 'application/json' } }
58+
)
59+
)
60+
)
61+
62+
const result = await oauth.refreshAccessToken()
63+
64+
expect(result).toBe('new-access')
65+
expect(tokenStore.accessToken).toBe('new-access')
66+
expect(tokenStore.refreshToken).toBe('new-refresh')
67+
})
68+
69+
it('sends the correct request body', async () => {
70+
const fetchSpy = vi.fn().mockResolvedValueOnce(
71+
new Response(
72+
JSON.stringify({
73+
access_token: 'new-access',
74+
refresh_token: 'new-refresh'
75+
}),
76+
{ status: 200 }
77+
)
78+
)
79+
vi.stubGlobal('fetch', fetchSpy)
80+
81+
await oauth.refreshAccessToken()
82+
83+
expect(fetchSpy).toHaveBeenCalledWith(
84+
'https://api.example.com/oauth/token',
85+
expect.objectContaining({
86+
method: 'POST',
87+
headers: { 'Content-Type': 'application/json' },
88+
body: JSON.stringify({
89+
grant_type: 'refresh_token',
90+
refresh_token: 'old-refresh',
91+
client_id: 'test-api-key'
92+
})
93+
})
94+
)
95+
})
96+
97+
it('returns null and does not update store when response is not OK', async () => {
98+
vi.stubGlobal(
99+
'fetch',
100+
vi.fn().mockResolvedValueOnce(new Response(null, { status: 401 }))
101+
)
102+
103+
const result = await oauth.refreshAccessToken()
104+
105+
expect(result).toBeNull()
106+
// Token store must remain unchanged
107+
expect(tokenStore.accessToken).toBe('old-access')
108+
expect(tokenStore.refreshToken).toBe('old-refresh')
109+
})
110+
111+
it('returns null when response body is invalid JSON', async () => {
112+
vi.stubGlobal(
113+
'fetch',
114+
vi.fn().mockResolvedValueOnce(
115+
new Response('not-json', {
116+
status: 200,
117+
headers: { 'Content-Type': 'application/json' }
118+
})
119+
)
120+
)
121+
122+
const result = await oauth.refreshAccessToken()
123+
124+
expect(result).toBeNull()
125+
})
126+
127+
it('returns null when access_token is missing from response', async () => {
128+
vi.stubGlobal(
129+
'fetch',
130+
vi.fn().mockResolvedValueOnce(
131+
new Response(JSON.stringify({ refresh_token: 'new-refresh' }), {
132+
status: 200
133+
})
134+
)
135+
)
136+
137+
const result = await oauth.refreshAccessToken()
138+
139+
expect(result).toBeNull()
140+
expect(tokenStore.accessToken).toBe('old-access')
141+
})
142+
143+
it('returns null when refresh_token is missing from response', async () => {
144+
vi.stubGlobal(
145+
'fetch',
146+
vi.fn().mockResolvedValueOnce(
147+
new Response(JSON.stringify({ access_token: 'new-access' }), {
148+
status: 200
149+
})
150+
)
151+
)
152+
153+
const result = await oauth.refreshAccessToken()
154+
155+
expect(result).toBeNull()
156+
expect(tokenStore.refreshToken).toBe('old-refresh')
157+
})
158+
159+
it('returns null when neither token field is present in response', async () => {
160+
vi.stubGlobal(
161+
'fetch',
162+
vi.fn().mockResolvedValueOnce(
163+
new Response(JSON.stringify({ error: 'invalid_grant' }), { status: 200 })
164+
)
165+
)
166+
167+
const result = await oauth.refreshAccessToken()
168+
169+
expect(result).toBeNull()
170+
})
171+
172+
it('returns null when tokenStore is not configured', async () => {
173+
const oauthNoStore = makeOAuth({ basePath: 'https://api.example.com' })
174+
175+
const result = await oauthNoStore.refreshAccessToken()
176+
177+
expect(result).toBeNull()
178+
})
179+
180+
it('returns null when basePath is not configured', async () => {
181+
const oauthNoBase = makeOAuth({ tokenStore })
182+
183+
const result = await oauthNoBase.refreshAccessToken()
184+
185+
expect(result).toBeNull()
186+
})
187+
188+
it('returns null when there is no refresh token stored', async () => {
189+
tokenStore.clear()
190+
191+
const result = await oauth.refreshAccessToken()
192+
193+
expect(result).toBeNull()
194+
})
195+
196+
it('returns null when fetch throws a network error', async () => {
197+
vi.stubGlobal(
198+
'fetch',
199+
vi.fn().mockRejectedValueOnce(new Error('network error'))
200+
)
201+
202+
const result = await oauth.refreshAccessToken()
203+
204+
expect(result).toBeNull()
205+
})
206+
})

0 commit comments

Comments
 (0)