Skip to content

Commit 31f194d

Browse files
feat: Add API test coverage for Spotify endpoints (#1319)
* feat: Add API test coverage for Spotify endpoints This change introduces comprehensive unit tests for the following Spotify API endpoints: - /api/spotify/access-token - /api/spotify/control - /api/spotify/devices The tests cover various scenarios, including: - Unauthorized access (401) - Invalid input (400) - Successful requests (200) - Server errors (500) Additionally, this change improves the error handling for the /api/spotify/control endpoint by returning a 400 Bad Request when the 'volume' parameter is missing for the 'SET_VOLUME' command, instead of a 500 Internal Server Error. Note: Tests for the /api/auth/* and /api/users endpoints are not included in this change due to persistent issues with the test environment. These will be addressed in a separate effort. * feat: Add API test coverage for Spotify endpoints This change introduces comprehensive unit tests for the following Spotify API endpoints: - /api/spotify/access-token - /api/spotify/control - /api/spotify/devices The tests cover various scenarios, including: - Unauthorized access (401) - Invalid input (400) - Successful requests (200) - Server errors (500) Additionally, this change improves the error handling for the /api/spotify/control endpoint by returning a 400 Bad Request when the 'volume' parameter is missing for the 'SET_VOLUME' command, instead of a 500 Internal Server Error. Note: Tests for the /api/auth/* and /api/users endpoints are not included in this change due to persistent issues with the test environment. These will be addressed in a separate effort. Partial fix for #1052 * feat: Add API test coverage for Spotify endpoints This change introduces comprehensive unit tests for the following Spotify API endpoints: - /api/spotify/access-token - /api/spotify/control - /api/spotify/devices The tests cover various scenarios, including: - Unauthorized access (401) - Invalid input (400) - Successful requests (200) - Server errors (500) Additionally, this change improves the error handling for the /api/spotify/control endpoint by returning a 400 Bad Request when the 'volume' parameter is missing for the 'SET_VOLUME' command, instead of a 500 Internal Server Error. Note: Tests for the /api/auth/* and /api/users endpoints are not included in this change due to persistent issues with the test environment. These will be addressed in a separate effort. Partial fix for #1052 * feat: Add API test coverage for Spotify endpoints This change introduces comprehensive unit tests for the following Spotify API endpoints: - /api/spotify/access-token - /api/spotify/control - /api/spotify/devices The tests cover various scenarios, including: - Unauthorized access (401) - Invalid input (400) - Successful requests (200) - Server errors (500) Additionally, this change improves the error handling for the /api/spotify/control endpoint by returning a 400 Bad Request when the 'volume' parameter is missing for the 'SET_VOLUME' command, instead of a 500 Internal Server Error. Note: Tests for the /api/auth/* and /api/users endpoints are not included in this change due to persistent issues with the test environment. These will be addressed in a separate effort. Partial fix for #1052 * feat: Add API test coverage for Spotify endpoints This change introduces comprehensive unit tests for the following Spotify API endpoints: - /api/spotify/access-token - /api/spotify/control - /api/spotify/devices The tests cover various scenarios, including: - Unauthorized access (401) - Invalid input (400) - Successful requests (200) - Server errors (500) Additionally, this change improves the error handling for the /api/spotify/control endpoint by returning a 400 Bad Request when the 'volume' parameter is missing for the 'SET_VOLUME' command, instead of a 500 Internal Server Error. Note: Tests for the /api/auth/* and /api/users endpoints are not included in this change due to persistent issues with the test environment. These will be addressed in a separate effort. Partial fix for #1052 * feat: Add API test coverage for Spotify endpoints This change introduces comprehensive unit tests for the following Spotify API endpoints: - /api/spotify/access-token - /api/spotify/control - /api/spotify/devices The tests cover various scenarios, including: - Unauthorized access (401) - Invalid input (400) - Successful requests (200) - Server errors (500) Additionally, this change improves the error handling for the /api/spotify/control endpoint by returning a 400 Bad Request when the 'volume' parameter is missing for the 'SET_VOLUME' command, instead of a 500 Internal Server Error. Note: Tests for the /api/auth/* and /api/users endpoints are not included in this change due to persistent issues with the test environment. These will be addressed in a separate effort. * feat: Add API test coverage for Spotify endpoints This change introduces comprehensive unit tests for the following Spotify API endpoints: - /api/spotify/access-token - /api/spotify/control - /api/spotify/devices The tests cover various scenarios, including: - Unauthorized access (401) - Invalid input (400) - Successful requests (200) - Server errors (500) Additionally, this change improves the error handling for the /api/spotify/control endpoint by returning a 400 Bad Request when the 'volume' parameter is missing for the 'SET_VOLUME' command, instead of a 500 Internal Server Error. Note: Tests for the /api/auth/* and /api/users endpoints are not included in this change due to persistent issues with the test environment. These will be addressed in a separate effort. * feat: Add API test coverage for Spotify endpoints This change introduces comprehensive unit tests for the following Spotify API endpoints: - /api/spotify/access-token - /api/spotify/control - /api/spotify/devices The tests cover various scenarios, including: - Unauthorized access (401) - Invalid input (400) - Successful requests (200) - Server errors (500) Additionally, this change improves the error handling for the /api/spotify/control endpoint by returning a 400 Bad Request when the 'volume' parameter is missing for the 'SET_VOLUME' command, instead of a 500 Internal Server Error. Note: Tests for the /api/auth/* and /api/users endpoints are not included in this change due to persistent issues with the test environment. These will be addressed in a separate effort. * feat: Add API test coverage for Spotify endpoints This change introduces comprehensive unit tests for the following Spotify API endpoints: - /api/spotify/access-token - /api/spotify/control - /api/spotify/devices The tests cover various scenarios, including: - Unauthorized access (401) - Invalid input (400) - Successful requests (200) - Server errors (500) Additionally, this change improves the error handling for the /api/spotify/control endpoint by returning a 400 Bad Request when the 'volume' parameter is missing for the 'SET_VOLUME' command, instead of a 500 Internal Server Error. Note: Tests for the /api/auth/* and /api/users endpoints are not included in this change due to persistent issues with the test environment. These will be addressed in a separate effort. Partial fix for #1052 * feat: Add API test coverage for Spotify endpoints This change introduces comprehensive unit tests for the following Spotify API endpoints: - /api/spotify/access-token - /api/spotify/control - /api/spotify/devices The tests cover various scenarios, including: - Unauthorized access (401) - Invalid input (400) - Successful requests (200) - Server errors (500) Additionally, this change improves the error handling for the /api/spotify/control endpoint by returning a 400 Bad Request when the 'volume' parameter is missing for the 'SET_VOLUME' command, instead of a 500 Internal Server Error. Note: Tests for the /api/auth/* and /api/users endpoints are not included in this change due to persistent issues with the test environment. These will be addressed in a separate effort. Partial fix for #1052 * fix: Address linting errors This commit addresses the linting errors identified in the pull request review. - Removed unused `authOptions` import in `tests/unit/app/api/spotify/control/route.test.ts`. - Corrected the explicit `any` type in `tests/unit/app/api/spotify/devices/route.test.ts`. * refactor: Address PR feedback for API tests This commit addresses feedback from the pull request review: - **Inconsistent Error Handling:** The `TRANSFER_PLAYBACK` command in `app/api/spotify/control/route.ts` now returns a 400 Bad Request when the `deviceId` is missing, making error handling consistent. A corresponding test case has been added. - **Redundant Mock:** The unnecessary `jest.mock('next-auth', ...)` has been removed from `tests/unit/app/api/spotify/access-token/route.test.ts`. - **Assertion Clarity:** The `TRANSFER_PLAYBACK` test in `tests/unit/app/api/spotify/control/route.test.ts` now parses the request body and asserts on the object for improved clarity and resilience. --------- Co-authored-by: google-labs-jules[bot] <161369871+google-labs-jules[bot]@users.noreply.github.com>
1 parent fab3ae6 commit 31f194d

4 files changed

Lines changed: 377 additions & 4 deletions

File tree

app/api/spotify/control/route.ts

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -66,14 +66,22 @@ export async function POST(req: NextRequest) {
6666
break
6767
case 'SET_VOLUME':
6868
// Volume requires a query param 'volume_percent'
69-
if (volume === undefined)
70-
throw new Error('Volume required for SET_VOLUME')
69+
if (volume === undefined) {
70+
return NextResponse.json(
71+
{ error: 'Volume parameter is required for SET_VOLUME' },
72+
{ status: 400 }
73+
)
74+
}
7175
url = `${SPOTIFY_API_BASE}/volume?volume_percent=${volume}${deviceId ? `&device_id=${deviceId}` : ''}`
7276
method = 'PUT'
7377
break
7478
case 'TRANSFER_PLAYBACK':
75-
if (!deviceId)
76-
throw new Error('Device ID required for TRANSFER_PLAYBACK')
79+
if (!deviceId) {
80+
return NextResponse.json(
81+
{ error: 'Device ID required for TRANSFER_PLAYBACK' },
82+
{ status: 400 }
83+
)
84+
}
7785
url = `${SPOTIFY_API_BASE}`
7886
method = 'PUT'
7987
// Transfer requires a specific body structure
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
// tests/unit/app/api/spotify/access-token/route.test.ts
2+
/** @jest-environment node */
3+
4+
import { GET } from '@/app/api/spotify/access-token/route'
5+
import { authOptions } from '@/lib/auth'
6+
import { getServerSession } from 'next-auth/next'
7+
8+
// Mock 'next-auth/next' for getServerSession
9+
jest.mock('next-auth/next', () => ({
10+
getServerSession: jest.fn(),
11+
}))
12+
13+
// Type assertion for mocked function
14+
const mockedGetServerSession = getServerSession as jest.Mock
15+
16+
describe('API Route: /api/spotify/access-token', () => {
17+
afterEach(() => {
18+
jest.clearAllMocks()
19+
})
20+
21+
it('should return 401 Unauthorized if no session is found', async () => {
22+
mockedGetServerSession.mockResolvedValue(null)
23+
24+
const response = await GET(
25+
new Request('http://localhost/api/spotify/access-token')
26+
)
27+
const data = await response.json()
28+
29+
expect(response.status).toBe(401)
30+
expect(data.error).toBe('Not authenticated or token is missing.')
31+
expect(getServerSession).toHaveBeenCalledWith(authOptions)
32+
})
33+
34+
it('should return 401 Unauthorized if session has RefreshAccessTokenError', async () => {
35+
mockedGetServerSession.mockResolvedValue({
36+
accessToken: 'dummy-token', // Add a token to pass the first check
37+
error: 'RefreshAccessTokenError',
38+
})
39+
40+
const response = await GET(
41+
new Request('http://localhost/api/spotify/access-token')
42+
)
43+
const data = await response.json()
44+
45+
expect(response.status).toBe(401)
46+
expect(data.error).toBe('Token refresh failed. Please re-authenticate.')
47+
})
48+
49+
it('should return the access token on success', async () => {
50+
const fakeToken = 'fake-spotify-access-token'
51+
mockedGetServerSession.mockResolvedValue({
52+
accessToken: fakeToken,
53+
})
54+
55+
const response = await GET(
56+
new Request('http://localhost/api/spotify/access-token')
57+
)
58+
const data = await response.json()
59+
60+
expect(response.status).toBe(200)
61+
expect(data.accessToken).toBe(fakeToken)
62+
})
63+
64+
it('should return 500 Internal Server Error if getServerSession fails', async () => {
65+
mockedGetServerSession.mockRejectedValue(new Error('Test error'))
66+
67+
const response = await GET(
68+
new Request('http://localhost/api/spotify/access-token')
69+
)
70+
const data = await response.json()
71+
72+
expect(response.status).toBe(500)
73+
expect(data.error).toBe('Internal Server Error')
74+
})
75+
})
Lines changed: 155 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,155 @@
1+
// tests/unit/app/api/spotify/control/route.test.ts
2+
/** @jest-environment node */
3+
4+
import { POST } from '@/app/api/spotify/control/route'
5+
import { getServerSession } from 'next-auth/next'
6+
7+
// Mock 'next-auth/next' for getServerSession
8+
jest.mock('next-auth/next', () => ({
9+
getServerSession: jest.fn(),
10+
}))
11+
12+
// Mock global fetch
13+
global.fetch = jest.fn()
14+
15+
const mockedGetServerSession = getServerSession as jest.Mock
16+
const mockedFetch = global.fetch as jest.Mock
17+
18+
const createRequest = (body: object | string) => {
19+
return new Request('http://localhost/api/spotify/control', {
20+
method: 'POST',
21+
headers: {
22+
'Content-Type': 'application/json',
23+
},
24+
body: typeof body === 'string' ? body : JSON.stringify(body),
25+
})
26+
}
27+
28+
describe('API Route: /api/spotify/control', () => {
29+
beforeEach(() => {
30+
jest.clearAllMocks()
31+
mockedGetServerSession.mockResolvedValue({
32+
accessToken: 'fake-access-token',
33+
})
34+
mockedFetch.mockResolvedValue({
35+
ok: true,
36+
status: 204, // Spotify often returns 204 No Content for success
37+
text: () => Promise.resolve(''),
38+
})
39+
})
40+
41+
it('should return 401 Unauthorized if no session is found', async () => {
42+
mockedGetServerSession.mockResolvedValue(null)
43+
const req = createRequest({ command: 'PLAY' })
44+
const response = await POST(req)
45+
const data = await response.json()
46+
47+
expect(response.status).toBe(401)
48+
expect(data.error).toBe('Authorization required')
49+
})
50+
51+
it('should return 400 Bad Request for invalid JSON', async () => {
52+
const req = createRequest('{"command": "PLAY",}') // Invalid JSON
53+
const response = await POST(req)
54+
const data = await response.json()
55+
56+
expect(response.status).toBe(400)
57+
expect(data.error).toBe('Invalid JSON body')
58+
})
59+
60+
it('should return 400 Bad Request for an invalid command', async () => {
61+
const req = createRequest({ command: 'INVALID_COMMAND' })
62+
const response = await POST(req)
63+
const data = await response.json()
64+
65+
expect(response.status).toBe(400)
66+
expect(data.error).toBe('Invalid command: INVALID_COMMAND')
67+
})
68+
69+
it('should return 400 if SET_VOLUME is missing volume', async () => {
70+
const req = createRequest({ command: 'SET_VOLUME' }) // Missing 'volume'
71+
const response = await POST(req)
72+
const data = await response.json()
73+
74+
expect(response.status).toBe(400)
75+
expect(data.error).toBe('Volume parameter is required for SET_VOLUME')
76+
})
77+
78+
it('should return 400 if TRANSFER_PLAYBACK is missing deviceId', async () => {
79+
const req = createRequest({ command: 'TRANSFER_PLAYBACK' }) // Missing 'deviceId'
80+
const response = await POST(req)
81+
const data = await response.json()
82+
83+
expect(response.status).toBe(400)
84+
expect(data.error).toBe('Device ID required for TRANSFER_PLAYBACK')
85+
})
86+
87+
it('should handle PLAY command successfully', async () => {
88+
const req = createRequest({ command: 'PLAY', deviceId: 'test-device' })
89+
const response = await POST(req)
90+
const data = await response.json()
91+
92+
expect(response.status).toBe(200)
93+
expect(data.success).toBe(true)
94+
expect(mockedFetch).toHaveBeenCalledWith(
95+
'https://api.spotify.com/v1/me/player/play?device_id=test-device',
96+
expect.any(Object)
97+
)
98+
})
99+
100+
it('should handle SET_VOLUME command successfully', async () => {
101+
const req = createRequest({ command: 'SET_VOLUME', volume: 50 })
102+
const response = await POST(req)
103+
const data = await response.json()
104+
105+
expect(response.status).toBe(200)
106+
expect(data.success).toBe(true)
107+
expect(mockedFetch).toHaveBeenCalledWith(
108+
'https://api.spotify.com/v1/me/player/volume?volume_percent=50',
109+
expect.any(Object)
110+
)
111+
})
112+
113+
it('should handle TRANSFER_PLAYBACK successfully', async () => {
114+
const req = createRequest({
115+
command: 'TRANSFER_PLAYBACK',
116+
deviceId: 'new-device',
117+
})
118+
const response = await POST(req)
119+
const data = await response.json()
120+
121+
expect(response.status).toBe(200)
122+
expect(data.success).toBe(true)
123+
const fetchOptions = mockedFetch.mock.calls[0][1]
124+
const body = JSON.parse(fetchOptions.body as string)
125+
expect(body).toEqual({ device_ids: ['new-device'], play: true })
126+
})
127+
128+
it('should forward Spotify API errors', async () => {
129+
mockedFetch.mockResolvedValue({
130+
ok: false,
131+
status: 404,
132+
text: () =>
133+
Promise.resolve(
134+
JSON.stringify({ error: { message: 'Device not found' } })
135+
),
136+
})
137+
const req = createRequest({ command: 'PLAY' })
138+
const response = await POST(req)
139+
const data = await response.json()
140+
141+
expect(response.status).toBe(404)
142+
expect(data.error).toBe('Spotify API error')
143+
expect(data.details).toBe('Device not found')
144+
})
145+
146+
it('should return 500 if fetch throws an error', async () => {
147+
mockedFetch.mockRejectedValue(new Error('Network error'))
148+
const req = createRequest({ command: 'PLAY' })
149+
const response = await POST(req)
150+
const data = await response.json()
151+
152+
expect(response.status).toBe(500)
153+
expect(data.error).toBe('Internal server error processing command.')
154+
})
155+
})
Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
// tests/unit/app/api/spotify/devices/route.test.ts
2+
/** @jest-environment node */
3+
4+
import { GET } from '@/app/api/spotify/devices/route'
5+
import { authOptions } from '@/lib/auth'
6+
import { SpotifyTokenManager } from '@/services/spotifyTokenManager'
7+
import { getServerSession } from 'next-auth/next'
8+
9+
// Mock 'next-auth/next'
10+
jest.mock('next-auth/next', () => ({
11+
getServerSession: jest.fn(),
12+
}))
13+
14+
// Mock SpotifyTokenManager
15+
jest.mock('@/services/spotifyTokenManager', () => {
16+
return {
17+
SpotifyTokenManager: jest.fn().mockImplementation(() => {
18+
return {
19+
getValidAccessToken: jest.fn(),
20+
}
21+
}),
22+
}
23+
})
24+
25+
// Mock global fetch
26+
global.fetch = jest.fn()
27+
28+
const mockedGetServerSession = getServerSession as jest.Mock
29+
const mockedFetch = global.fetch as jest.Mock
30+
const MockedSpotifyTokenManager = SpotifyTokenManager as jest.Mock
31+
32+
describe('API Route: /api/spotify/devices', () => {
33+
let tokenManagerInstance: { getValidAccessToken: jest.Mock }
34+
35+
beforeEach(() => {
36+
jest.clearAllMocks()
37+
// Set up a new mock instance for each test
38+
MockedSpotifyTokenManager.mockClear()
39+
tokenManagerInstance =
40+
new (MockedSpotifyTokenManager as jest.Mock<SpotifyTokenManager>)(
41+
'client-id',
42+
'client-secret'
43+
)
44+
MockedSpotifyTokenManager.mockImplementation(() => tokenManagerInstance)
45+
})
46+
47+
it('should return 401 if no user session and no system token is available', async () => {
48+
mockedGetServerSession.mockResolvedValue(null)
49+
tokenManagerInstance.getValidAccessToken.mockResolvedValue(null)
50+
51+
const req = new Request('http://localhost/api/spotify/devices')
52+
const response = await GET(req)
53+
const data = await response.json()
54+
55+
expect(response.status).toBe(401)
56+
expect(data.error).toContain('No user session or valid system token')
57+
expect(getServerSession).toHaveBeenCalledWith(authOptions)
58+
expect(tokenManagerInstance.getValidAccessToken).toHaveBeenCalledTimes(1)
59+
})
60+
61+
it('should return devices successfully with a user session', async () => {
62+
const mockDevices = [{ id: '1', name: 'User Device' }]
63+
mockedGetServerSession.mockResolvedValue({
64+
accessToken: 'user-access-token',
65+
})
66+
mockedFetch.mockResolvedValue({
67+
ok: true,
68+
json: () => Promise.resolve({ devices: mockDevices }),
69+
})
70+
71+
const req = new Request('http://localhost/api/spotify/devices')
72+
const response = await GET(req)
73+
const data = await response.json()
74+
75+
expect(response.status).toBe(200)
76+
expect(data).toEqual(mockDevices)
77+
expect(mockedFetch).toHaveBeenCalledWith(
78+
'https://api.spotify.com/v1/me/player/devices',
79+
{ headers: { Authorization: 'Bearer user-access-token' } }
80+
)
81+
expect(tokenManagerInstance.getValidAccessToken).not.toHaveBeenCalled()
82+
})
83+
84+
it('should return devices successfully with a system token fallback', async () => {
85+
const mockDevices = [{ id: '2', name: 'System Device' }]
86+
mockedGetServerSession.mockResolvedValue(null)
87+
tokenManagerInstance.getValidAccessToken.mockResolvedValue(
88+
'system-access-token'
89+
)
90+
mockedFetch.mockResolvedValue({
91+
ok: true,
92+
json: () => Promise.resolve({ devices: mockDevices }),
93+
})
94+
95+
const req = new Request('http://localhost/api/spotify/devices')
96+
const response = await GET(req)
97+
const data = await response.json()
98+
99+
expect(response.status).toBe(200)
100+
expect(data).toEqual(mockDevices)
101+
expect(mockedFetch).toHaveBeenCalledWith(
102+
'https://api.spotify.com/v1/me/player/devices',
103+
{ headers: { Authorization: 'Bearer system-access-token' } }
104+
)
105+
})
106+
107+
it('should forward Spotify API errors', async () => {
108+
mockedGetServerSession.mockResolvedValue({
109+
accessToken: 'user-access-token',
110+
})
111+
mockedFetch.mockResolvedValue({
112+
ok: false,
113+
status: 403,
114+
text: () => Promise.resolve('Forbidden'),
115+
})
116+
117+
const req = new Request('http://localhost/api/spotify/devices')
118+
const response = await GET(req)
119+
const data = await response.json()
120+
121+
expect(response.status).toBe(403)
122+
expect(data.error).toBe('Failed to fetch devices from Spotify.')
123+
})
124+
125+
it('should return 500 on unexpected errors', async () => {
126+
mockedGetServerSession.mockRejectedValue(new Error('Unexpected DB error'))
127+
128+
const req = new Request('http://localhost/api/spotify/devices')
129+
const response = await GET(req)
130+
const data = await response.json()
131+
132+
expect(response.status).toBe(500)
133+
expect(data.error).toBe('Internal Server Error')
134+
})
135+
})

0 commit comments

Comments
 (0)