11import React from "react"
22import i18n from "i18n/config"
3- import { render , screen } from "@testing-library/react"
3+ import { render , screen , waitFor } from "@testing-library/react"
44import userEvent from "@testing-library/user-event"
5+ import { setupServer } from "msw/node"
6+ import { rest } from "msw"
7+ import { handlers } from "./mocks/handlers"
58import App from "./App"
9+ import * as auth from "./auth"
610
711const { location } = window
812
13+ // Set up MSW server for mocking Spotify API and token exchange
14+ const server = setupServer ( ...handlers )
15+
916beforeAll ( ( ) => {
17+ server . listen ( { onUnhandledRequest : 'warn' } )
1018 // @ts -ignore
1119 delete window . location
1220} )
1321
1422afterAll ( ( ) => {
23+ server . close ( )
1524 window . location = location
1625} )
1726
@@ -22,6 +31,11 @@ beforeAll(() => {
2231
2332beforeEach ( ( ) => {
2433 i18n . changeLanguage ( "en" )
34+ server . resetHandlers ( )
35+ } )
36+
37+ afterEach ( ( ) => {
38+ localStorage . clear ( )
2539} )
2640
2741describe ( "i18n" , ( ) => {
@@ -54,18 +68,110 @@ describe("logging in", () => {
5468
5569 await userEvent . click ( linkElement )
5670
57- expect ( window . location . href ) . toBe (
58- "https://accounts.spotify.com/authorize?client_id=9950ac751e34487dbbe027c4fd7f8e99&redirect_uri=%2F%2F&scope=playlist-read-private%20playlist-read-collaborative%20user-library-read&response_type=token&show_dialog=false"
59- )
71+ // Now uses PKCE flow with authorization code
72+ expect ( window . location . href ) . toMatch ( / ^ h t t p s : \/ \/ a c c o u n t s \. s p o t i f y \. c o m \/ a u t h o r i z e \? / )
73+ expect ( window . location . href ) . toContain ( 'response_type=code' )
74+ expect ( window . location . href ) . toContain ( 'client_id=9950ac751e34487dbbe027c4fd7f8e99' )
75+ expect ( window . location . href ) . toContain ( 'scope=playlist-read-private' )
76+ expect ( window . location . href ) . toContain ( 'code_challenge_method=S256' )
77+ expect ( window . location . href ) . toContain ( 'code_challenge=' )
78+ expect ( window . location . href ) . toContain ( 'show_dialog=false' )
6079 } )
6180
62- describe ( "post-login state" , ( ) => {
63- beforeAll ( ( ) => {
81+ describe ( "OAuth callback with authorization code" , ( ) => {
82+ let setItemSpy : jest . SpyInstance
83+ let replaceStateSpy : jest . SpyInstance
84+ let tokenRequestBody : URLSearchParams | null = null
85+
86+ beforeEach ( ( ) => {
87+ tokenRequestBody = null
88+
89+ // Mock localStorage
90+ jest . spyOn ( Storage . prototype , 'getItem' ) . mockImplementation ( ( key ) => {
91+ if ( key === 'code_verifier' ) return 'TEST_CODE_VERIFIER'
92+ return null
93+ } )
94+ setItemSpy = jest . spyOn ( Storage . prototype , 'setItem' ) . mockImplementation ( ( ) => { } )
95+ replaceStateSpy = jest . spyOn ( window . history , 'replaceState' ) . mockImplementation ( ( ) => { } )
96+
97+ // Mock window.location with code parameter
6498 // @ts -ignore
65- window . location = { hash : "#access_token=TEST_ACCESS_TOKEN" }
99+ window . location = {
100+ search : "?code=TEST_AUTH_CODE" ,
101+ pathname : "/exportify" ,
102+ protocol : "https:" ,
103+ host : "exportify.app"
104+ }
105+
106+ // Mock Spotify token endpoint and capture request
107+ server . use (
108+ rest . post ( 'https://accounts.spotify.com/api/token' , async ( req , res , ctx ) => {
109+ // Capture the request body for verification
110+ tokenRequestBody = new URLSearchParams ( await req . text ( ) )
111+
112+ return res ( ctx . json ( {
113+ access_token : 'EXCHANGED_ACCESS_TOKEN' ,
114+ token_type : 'Bearer' ,
115+ expires_in : 3600 ,
116+ refresh_token : 'REFRESH_TOKEN' ,
117+ scope : 'playlist-read-private playlist-read-collaborative user-library-read'
118+ } ) )
119+ } )
120+ )
121+ } )
122+
123+ afterEach ( ( ) => {
124+ jest . restoreAllMocks ( )
66125 } )
67126
68- test ( "renders playlist component on return from Spotify with auth token" , ( ) => {
127+ test ( "exchanges authorization code for access token with correct PKCE parameters" , async ( ) => {
128+ render ( < App /> )
129+
130+ // Wait for token exchange to complete
131+ await waitFor ( ( ) => {
132+ expect ( setItemSpy ) . toHaveBeenCalledWith ( 'access_token' , 'EXCHANGED_ACCESS_TOKEN' )
133+ } )
134+
135+ // Verify the token endpoint was called with correct parameters
136+ expect ( tokenRequestBody ) . not . toBeNull ( )
137+ expect ( tokenRequestBody ?. get ( 'client_id' ) ) . toBe ( '9950ac751e34487dbbe027c4fd7f8e99' )
138+ expect ( tokenRequestBody ?. get ( 'grant_type' ) ) . toBe ( 'authorization_code' )
139+ expect ( tokenRequestBody ?. get ( 'code' ) ) . toBe ( 'TEST_AUTH_CODE' )
140+ expect ( tokenRequestBody ?. get ( 'code_verifier' ) ) . toBe ( 'TEST_CODE_VERIFIER' )
141+ expect ( tokenRequestBody ?. get ( 'redirect_uri' ) ) . toBe ( 'https://exportify.app/exportify' )
142+
143+ // Verify code is removed from URL
144+ expect ( replaceStateSpy ) . toHaveBeenCalledWith ( { } , expect . any ( String ) , '/exportify' )
145+
146+ // Verify playlist component is rendered
147+ expect ( screen . getByTestId ( 'playlistTableSpinner' ) ) . toBeInTheDocument ( )
148+ } )
149+ } )
150+
151+ describe ( "post-login state" , ( ) => {
152+ let getItemSpy : jest . SpyInstance
153+
154+ beforeEach ( ( ) => {
155+ // Mock localStorage for access token
156+ getItemSpy = jest . spyOn ( Storage . prototype , 'getItem' ) . mockImplementation ( ( key ) => {
157+ if ( key === 'access_token' ) return 'TEST_ACCESS_TOKEN'
158+ return null
159+ } )
160+
161+ // @ts -ignore - No code parameter in URL
162+ window . location = {
163+ pathname : "/exportify" ,
164+ href : "https://www.example.com/exportify" ,
165+ search : "" ,
166+ hash : ""
167+ }
168+ } )
169+
170+ afterEach ( ( ) => {
171+ getItemSpy . mockRestore ( )
172+ } )
173+
174+ test ( "renders playlist component when access token exists in localStorage" , ( ) => {
69175 render ( < App /> )
70176
71177 expect ( screen . getByTestId ( 'playlistTableSpinner' ) ) . toBeInTheDocument ( )
@@ -74,22 +180,42 @@ describe("logging in", () => {
74180} )
75181
76182describe ( "logging out" , ( ) => {
77- beforeAll ( ( ) => {
78- // @ts -ignore
79- window . location = { hash : "#access_token=TEST_ACCESS_TOKEN" , href : "https://www.example.com/#access_token=TEST_ACCESS_TOKEN" }
183+ let getItemSpy : jest . SpyInstance
184+ let logoutSpy : jest . SpyInstance
185+
186+ beforeEach ( ( ) => {
187+ // Mock localStorage with access token
188+ getItemSpy = jest . spyOn ( Storage . prototype , 'getItem' ) . mockImplementation ( ( key ) => {
189+ if ( key === 'access_token' ) return 'TEST_ACCESS_TOKEN'
190+ return null
191+ } )
192+
193+ // Spy on logout function
194+ logoutSpy = jest . spyOn ( auth , 'logout' ) . mockImplementation ( ( ) => { } )
195+
196+ // @ts -ignore - Simple window.location mock
197+ window . location = {
198+ pathname : "/exportify" ,
199+ href : "https://www.example.com/exportify" ,
200+ search : "" ,
201+ hash : ""
202+ }
203+ } )
204+
205+ afterEach ( ( ) => {
206+ getItemSpy . mockRestore ( )
207+ logoutSpy . mockRestore ( )
80208 } )
81209
82210 test ( "redirects user to login screen which will force a permission request" , async ( ) => {
83- const { rerender } = render ( < App /> )
211+ render ( < App /> )
84212
85213 const changeUserElement = screen . getByTitle ( "Change user" )
86214
87215 expect ( changeUserElement ) . toBeInTheDocument ( )
88216
89217 await userEvent . click ( changeUserElement )
90218
91- expect ( window . location . href ) . toBe ( "https://www.example.com/?change_user=true" )
92-
93-
219+ expect ( logoutSpy ) . toHaveBeenCalledWith ( true )
94220 } )
95221} )
0 commit comments