11import { describe , it , expect , vi , beforeEach } from 'vitest'
22
3- import { OAuthTokenStore } from '../oauth/tokenStore '
3+ import type { OAuth } from '../oauth/OAuth '
44
55import { addTokenRefreshMiddleware } from './addTokenRefreshMiddleware'
66
7- // Mock cross-fetch used by the middleware
8- vi . mock ( 'cross-fetch' , ( ) => ( {
9- default : vi . fn ( )
10- } ) )
11-
12- import crossFetch from 'cross-fetch'
13- const mockCrossFetch = vi . mocked ( crossFetch )
14-
157// Minimal fetch mock helper
168function mockResponse ( status : number , body ?: object ) : Response {
179 return new Response ( body ? JSON . stringify ( body ) : null , {
@@ -20,18 +12,24 @@ function mockResponse(status: number, body?: object): Response {
2012 } )
2113}
2214
23- describe ( 'addTokenRefreshMiddleware' , ( ) => {
24- let tokenStore : OAuthTokenStore
25- const apiKey = 'test-api-key'
26- const basePath = 'https://api.example.com/v1'
15+ function createMockOAuth (
16+ refreshBehaviour : ( ) => Promise < string | null > ,
17+ hasRefreshToken = true
18+ ) : OAuth {
19+ return {
20+ hasRefreshToken,
21+ refreshAccessToken : refreshBehaviour
22+ } as unknown as OAuth
23+ }
2724
25+ describe ( 'addTokenRefreshMiddleware' , ( ) => {
2826 beforeEach ( ( ) => {
29- tokenStore = new OAuthTokenStore ( )
3027 vi . restoreAllMocks ( )
3128 } )
3229
3330 it ( 'passes through non-401 responses unchanged' , async ( ) => {
34- const mw = addTokenRefreshMiddleware ( { tokenStore, apiKey, basePath } )
31+ const oauth = createMockOAuth ( async ( ) => 'token' )
32+ const mw = addTokenRefreshMiddleware ( { oauth } )
3533 const response = mockResponse ( 200 , { data : 'ok' } )
3634
3735 const result = await mw . post ! ( {
@@ -44,8 +42,10 @@ describe('addTokenRefreshMiddleware', () => {
4442 expect ( result ) . toBe ( response )
4543 } )
4644
47- it ( 'passes through 401 when no refresh token is available' , async ( ) => {
48- const mw = addTokenRefreshMiddleware ( { tokenStore, apiKey, basePath } )
45+ it ( 'passes through 401 without calling refreshAccessToken when unauthenticated' , async ( ) => {
46+ const refreshFn = vi . fn ( )
47+ const oauth = createMockOAuth ( refreshFn , false )
48+ const mw = addTokenRefreshMiddleware ( { oauth } )
4949 const response = mockResponse ( 401 )
5050
5151 const result = await mw . post ! ( {
@@ -56,28 +56,30 @@ describe('addTokenRefreshMiddleware', () => {
5656 } )
5757
5858 expect ( result ) . toBe ( response )
59+ expect ( refreshFn ) . not . toHaveBeenCalled ( )
5960 } )
6061
61- it ( 'refreshes and retries on 401 when refresh token exists' , async ( ) => {
62- tokenStore . setTokens ( 'expired-access' , 'valid-refresh' )
62+ it ( 'passes through 401 when refresh returns null (no refresh token)' , async ( ) => {
63+ const oauth = createMockOAuth ( async ( ) => null )
64+ const mw = addTokenRefreshMiddleware ( { oauth } )
65+ const response = mockResponse ( 401 )
6366
64- const refreshResponse = mockResponse ( 200 , {
65- access_token : 'new-access' ,
66- refresh_token : 'new-refresh' ,
67- token_type : 'Bearer' ,
68- expires_in : 3600 ,
69- scope : 'write'
67+ const result = await mw . post ! ( {
68+ fetch,
69+ url : 'https://api.example.com/v1/users/me' ,
70+ init : { } ,
71+ response
7072 } )
7173
72- const retryResponse = mockResponse ( 200 , { data : 'success' } )
73-
74- // Mock cross-fetch for the refresh call
75- mockCrossFetch . mockResolvedValueOnce ( refreshResponse )
74+ expect ( result ) . toBe ( response )
75+ } )
7676
77- // The retry fetch provided in context
77+ it ( 'refreshes and retries on 401 when refresh succeeds' , async ( ) => {
78+ const oauth = createMockOAuth ( async ( ) => 'new-access' )
79+ const retryResponse = mockResponse ( 200 , { data : 'success' } )
7880 const contextFetch = vi . fn ( ) . mockResolvedValueOnce ( retryResponse )
7981
80- const mw = addTokenRefreshMiddleware ( { tokenStore , apiKey , basePath } )
82+ const mw = addTokenRefreshMiddleware ( { oauth } )
8183
8284 const result = await mw . post ! ( {
8385 fetch : contextFetch ,
@@ -89,19 +91,7 @@ describe('addTokenRefreshMiddleware', () => {
8991 response : mockResponse ( 401 )
9092 } )
9193
92- // Refresh endpoint was called
93- expect ( mockCrossFetch ) . toHaveBeenCalledWith (
94- `${ basePath } /oauth/token` ,
95- expect . objectContaining ( {
96- method : 'POST'
97- } )
98- )
99-
100- // Token store was updated
101- expect ( tokenStore . accessToken ) . toBe ( 'new-access' )
102- expect ( tokenStore . refreshToken ) . toBe ( 'new-refresh' )
103-
104- // Original request was retried
94+ // Original request was retried with new token
10595 expect ( contextFetch ) . toHaveBeenCalledWith (
10696 'https://api.example.com/v1/tracks/123' ,
10797 expect . objectContaining ( {
@@ -115,12 +105,8 @@ describe('addTokenRefreshMiddleware', () => {
115105 } )
116106
117107 it ( 'surfaces 401 when refresh fails' , async ( ) => {
118- tokenStore . setTokens ( 'expired-access' , 'revoked-refresh' )
119-
120- // Refresh returns 400 (invalid_grant)
121- mockCrossFetch . mockResolvedValueOnce ( mockResponse ( 400 ) )
122-
123- const mw = addTokenRefreshMiddleware ( { tokenStore, apiKey, basePath } )
108+ const oauth = createMockOAuth ( async ( ) => null )
109+ const mw = addTokenRefreshMiddleware ( { oauth } )
124110 const original401 = mockResponse ( 401 )
125111
126112 const result = await mw . post ! ( {
@@ -130,80 +116,14 @@ describe('addTokenRefreshMiddleware', () => {
130116 response : original401
131117 } )
132118
133- // Returns the original 401 — doesn't swallow it
134119 expect ( result ) . toBe ( original401 )
135120 } )
136121
137- it ( 'surfaces 401 when refresh endpoint returns invalid JSON' , async ( ) => {
138- tokenStore . setTokens ( 'expired-access' , 'valid-refresh' )
139-
140- // Return 200 but with garbage body
141- mockCrossFetch . mockResolvedValueOnce (
142- new Response ( 'not json' , { status : 200 } )
143- )
144-
145- const mw = addTokenRefreshMiddleware ( { tokenStore, apiKey, basePath } )
146- const original401 = mockResponse ( 401 )
147-
148- const result = await mw . post ! ( {
149- fetch,
150- url : 'https://api.example.com/v1/tracks/123' ,
151- init : { } ,
152- response : original401
122+ it ( 'surfaces 401 when refreshAccessToken throws' , async ( ) => {
123+ const oauth = createMockOAuth ( async ( ) => {
124+ throw new Error ( 'network failure' )
153125 } )
154-
155- expect ( result ) . toBe ( original401 )
156- } )
157-
158- it ( 'surfaces 401 when refresh response is missing required fields' , async ( ) => {
159- tokenStore . setTokens ( 'expired-access' , 'valid-refresh' )
160-
161- // Return 200 but only access_token (no refresh_token)
162- mockCrossFetch . mockResolvedValueOnce (
163- mockResponse ( 200 , { access_token : 'new-access' } )
164- )
165-
166- const mw = addTokenRefreshMiddleware ( { tokenStore, apiKey, basePath } )
167- const original401 = mockResponse ( 401 )
168-
169- const result = await mw . post ! ( {
170- fetch,
171- url : 'https://api.example.com/v1/tracks/123' ,
172- init : { } ,
173- response : original401
174- } )
175-
176- expect ( result ) . toBe ( original401 )
177- } )
178-
179- it ( 'surfaces 401 when network error occurs during refresh' , async ( ) => {
180- tokenStore . setTokens ( 'expired-access' , 'valid-refresh' )
181-
182- mockCrossFetch . mockRejectedValueOnce ( new Error ( 'network failure' ) )
183-
184- const mw = addTokenRefreshMiddleware ( { tokenStore, apiKey, basePath } )
185- const original401 = mockResponse ( 401 )
186-
187- const result = await mw . post ! ( {
188- fetch,
189- url : 'https://api.example.com/v1/tracks/123' ,
190- init : { } ,
191- response : original401
192- } )
193-
194- expect ( result ) . toBe ( original401 )
195- } )
196-
197- it ( 'surfaces 401 when refresh token is cleared between check and exchange' , async ( ) => {
198- tokenStore . setTokens ( 'expired-access' , 'about-to-be-cleared' )
199-
200- // Simulate: refresh token exists at guard check, but the exchange returns
201- // empty strings (server rejected it). The middleware should not store empty tokens.
202- mockCrossFetch . mockResolvedValueOnce (
203- mockResponse ( 200 , { access_token : '' , refresh_token : '' } )
204- )
205-
206- const mw = addTokenRefreshMiddleware ( { tokenStore, apiKey, basePath } )
126+ const mw = addTokenRefreshMiddleware ( { oauth } )
207127 const original401 = mockResponse ( 401 )
208128
209129 const result = await mw . post ! ( {
@@ -213,7 +133,6 @@ describe('addTokenRefreshMiddleware', () => {
213133 response : original401
214134 } )
215135
216- // Empty tokens are rejected by the validation
217136 expect ( result ) . toBe ( original401 )
218137 } )
219138} )
0 commit comments