musix-box 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,180 @@
1
+ /**
2
+ * Failure reporting for MCP tools.
3
+ *
4
+ * When a tool throws, two different readers need something from it, and
5
+ * they need different things. The agent that made the call needs to know
6
+ * the call failed and roughly why, so it can say so rather than inventing
7
+ * a result. Whoever maintains the server needs the stack, the arguments
8
+ * and the time, later, when they sit down to fix it.
9
+ *
10
+ * A thrown exception serves the first reader badly and the second not at
11
+ * all: the MCP transport reduces it to a message, and once the request is
12
+ * over the context is gone. So failures are captured here instead —
13
+ * turned into a record, handed to a sink, and reported back to the caller
14
+ * as an error result rather than an exception.
15
+ */
16
+
17
+ /** A tool failure, in the form it is stored and read back in. */
18
+ export type ToolFailure = {
19
+ /** When the failure happened, ISO 8601. */
20
+ timestamp: string;
21
+ /** Which tool was called. */
22
+ tool: string;
23
+ /** The authenticated Spotify user, when the request had one. */
24
+ spotifyUserId: string | null;
25
+ /**
26
+ * Arguments the tool was called with, JSON-encoded. Not redacted the way
27
+ * `other-memory`'s equivalent module redacts memory content — no tool
28
+ * here takes free-text personal content as an argument, only queries and
29
+ * ids, so there is nothing in an argument blob that duplicates data the
30
+ * user would not already expect logged.
31
+ */
32
+ arguments: string;
33
+ /** The error message. */
34
+ message: string;
35
+ /** The stack, when the thrown value carried one. */
36
+ stack: string | null;
37
+ };
38
+
39
+ /**
40
+ * Somewhere failures are written.
41
+ *
42
+ * Kept deliberately small so that a deployment can send failures to a
43
+ * database, a log service, or anywhere else, without the library needing
44
+ * to know that such a place exists.
45
+ */
46
+ export type FailureSink = (failure: ToolFailure) => void | Promise<void>;
47
+
48
+ /** Longest argument blob stored, in characters. */
49
+ const MAXIMUM_ARGUMENT_LENGTH = 2000;
50
+
51
+ /**
52
+ * Build a failure record from a thrown value.
53
+ *
54
+ * @param options.tool - Name of the tool that failed.
55
+ * @param options.error - Whatever was thrown. Anything can be thrown in
56
+ * JavaScript, so non-Error values are described rather than assumed.
57
+ * @param options.args - Arguments the tool was called with.
58
+ * @param options.spotifyUserId - Authenticated caller, when there was one.
59
+ * @param options.timestamp - When the failure happened.
60
+ * @returns The record to store.
61
+ */
62
+ export function buildFailure(options: {
63
+ tool: string;
64
+ error: unknown;
65
+ args: unknown;
66
+ spotifyUserId: string | null;
67
+ timestamp: string;
68
+ }): ToolFailure {
69
+ const { tool, error, args, spotifyUserId, timestamp } = options;
70
+ const isError = error instanceof Error;
71
+ let encodedArguments: string;
72
+ try {
73
+ encodedArguments = JSON.stringify(args) ?? "null";
74
+ } catch {
75
+ // Arguments arrive from a remote caller and are not guaranteed to be
76
+ // encodable — a cycle here must not mask the failure being reported.
77
+ encodedArguments = '"[unencodable]"';
78
+ }
79
+ return {
80
+ timestamp,
81
+ tool,
82
+ spotifyUserId,
83
+ arguments: encodedArguments.slice(0, MAXIMUM_ARGUMENT_LENGTH),
84
+ message: isError ? error.message : String(error),
85
+ stack: isError ? (error.stack ?? null) : null,
86
+ };
87
+ }
88
+
89
+ /**
90
+ * Write a failure to the console as one structured line.
91
+ *
92
+ * This is the default sink. On Cloudflare it is not a fallback so much as
93
+ * the ordinary path: Workers observability already captures and retains
94
+ * console output, so a deployment that configures nothing still has its
95
+ * failures recorded and searchable.
96
+ *
97
+ * @param failure - The record to write.
98
+ * @returns Nothing.
99
+ */
100
+ export const consoleFailureSink: FailureSink = (failure: ToolFailure) => {
101
+ console.error(JSON.stringify({ kind: "tool_failure", ...failure }));
102
+ };
103
+
104
+ /**
105
+ * Whether a failure message is Spotify refusing for rate reasons.
106
+ *
107
+ * Kept here rather than imported from a provider module on purpose: this
108
+ * module is what every tool funnels its errors through, and it must not
109
+ * depend on the module whose calls it is reporting on.
110
+ *
111
+ * @param message - The failure message.
112
+ * @returns True when the message describes a rate limit.
113
+ */
114
+ function isRateLimitMessage(message: string): boolean {
115
+ return message.toLowerCase().includes("429");
116
+ }
117
+
118
+ /**
119
+ * Run a tool handler, reporting any failure rather than throwing.
120
+ *
121
+ * @param options.tool - Name of the tool being run.
122
+ * @param options.args - Arguments it was called with.
123
+ * @param options.spotifyUserId - Authenticated caller, when there was one.
124
+ * @param options.sink - Where the failure is written.
125
+ * @param options.run - The handler itself.
126
+ * @returns The handler's result, or an error result describing the
127
+ * failure.
128
+ */
129
+ export async function reportingFailures<Result>(options: {
130
+ tool: string;
131
+ args: unknown;
132
+ spotifyUserId: string | null;
133
+ sink: FailureSink;
134
+ run: () => Promise<Result>;
135
+ }): Promise<Result | { content: [{ type: "text"; text: string }]; isError: true }> {
136
+ const { tool, args, spotifyUserId, sink, run } = options;
137
+ try {
138
+ return await run();
139
+ } catch (error) {
140
+ const failure = buildFailure({
141
+ tool,
142
+ error,
143
+ args,
144
+ spotifyUserId,
145
+ timestamp: new Date().toISOString(),
146
+ });
147
+ try {
148
+ await sink(failure);
149
+ } catch (sinkError) {
150
+ // A sink that cannot write must not replace the failure it was
151
+ // asked to record: the original is what the caller needs to hear
152
+ // about, and losing it to a logging problem is the worst outcome.
153
+ console.error(
154
+ JSON.stringify({
155
+ kind: "failure_sink_error",
156
+ tool,
157
+ message: sinkError instanceof Error ? sinkError.message : String(sinkError),
158
+ }),
159
+ );
160
+ }
161
+
162
+ const retryAfterMatch = /retryAfterSeconds["\s:]+(\d+)/.exec(failure.message);
163
+ const retryNote = retryAfterMatch
164
+ ? ` Try again in about ${retryAfterMatch[1]} seconds.`
165
+ : "";
166
+ const explanation = isRateLimitMessage(failure.message)
167
+ ? `The ${tool} tool could not run: Spotify's API rate limit is `
168
+ + `exhausted.${retryNote} Nothing is wrong with your library — tell `
169
+ + "the user to try again shortly, and do not treat this as an empty "
170
+ + "or missing result."
171
+ : `The ${tool} tool failed: ${failure.message}\n\n`
172
+ + "This has been logged. Tell the user the call failed rather than "
173
+ + "treating the absence of a result as an answer.";
174
+
175
+ return {
176
+ content: [{ type: "text" as const, text: explanation }],
177
+ isError: true,
178
+ };
179
+ }
180
+ }
package/src/types.ts ADDED
@@ -0,0 +1,32 @@
1
+ export type Env = {
2
+ OAUTH_KV: KVNamespace;
3
+ MCP_OBJECT: DurableObjectNamespace;
4
+ SPOTIFY_CLIENT_ID: string;
5
+ COOKIE_ENCRYPTION_KEY: string;
6
+ /**
7
+ * Stores each user's Spotify refresh token, keyed by their Spotify user
8
+ * id. Separate from OAUTH_KV, which belongs to the outer MCP OAuth layer
9
+ * (`@cloudflare/workers-oauth-provider`) and is never touched by this
10
+ * server's own code directly — Spotify tokens are a different concern
11
+ * with a different lifetime (they refresh hourly; the MCP session token
12
+ * does not).
13
+ */
14
+ SPOTIFY_TOKENS: KVNamespace;
15
+ /** The single Spotify account permitted to authenticate. */
16
+ ALLOWED_SPOTIFY_USER_ID: string;
17
+ };
18
+
19
+ /**
20
+ * Identity of the authenticated caller, carried through to the MCP agent.
21
+ *
22
+ * Deliberately does not carry the Spotify access token itself — access
23
+ * tokens expire in an hour and `props` is fixed for the life of the
24
+ * session, so a long session would silently start failing once the token
25
+ * aged out. Tools look the current, possibly-refreshed token up from
26
+ * `SPOTIFY_TOKENS` by `spotifyUserId` on every call instead. See
27
+ * `spotify_session.ts`.
28
+ */
29
+ export type UserProps = {
30
+ spotifyUserId: string;
31
+ displayName: string;
32
+ };
package/src/worker.ts ADDED
@@ -0,0 +1,41 @@
1
+ import OAuthProvider from "@cloudflare/workers-oauth-provider";
2
+
3
+ import { SpotifyHandler } from "./spotify_handler";
4
+ import { MusixBoxMCP } from "./index";
5
+
6
+ /**
7
+ * A ready-to-deploy musix-box server.
8
+ *
9
+ * Assembled the same way `other-memory`'s `worker.ts` is: the MCP agent,
10
+ * Spotify as the OAuth provider, and the endpoints wired together. Kept
11
+ * separate from `index.ts` for the same reason — importing the library must
12
+ * not also hand you a running worker, so a deployment that needs to change
13
+ * something has somewhere to do it without editing the library's own source.
14
+ */
15
+ export default buildWorker(MusixBoxMCP);
16
+
17
+ /**
18
+ * Re-exported by name so `wrangler.jsonc`'s Durable Object binding can find
19
+ * the class. A default export alone is not enough — `other-memory`'s own
20
+ * worker.ts needed this too.
21
+ */
22
+ export { MusixBoxMCP };
23
+
24
+ /**
25
+ * Assemble a worker around a `MusixBoxMCP` class.
26
+ *
27
+ * @param musixBoxMcp - `MusixBoxMCP` or a subclass of it.
28
+ * @returns A worker ready to be a `wrangler.jsonc` `main`.
29
+ */
30
+ export function buildWorker(musixBoxMcp: typeof MusixBoxMCP) {
31
+ return new OAuthProvider({
32
+ apiHandlers: {
33
+ "/sse": musixBoxMcp.serveSSE("/sse"),
34
+ "/mcp": musixBoxMcp.serve("/mcp"),
35
+ },
36
+ defaultHandler: SpotifyHandler as never,
37
+ authorizeEndpoint: "/authorize",
38
+ tokenEndpoint: "/token",
39
+ clientRegistrationEndpoint: "/register",
40
+ });
41
+ }
@@ -0,0 +1,43 @@
1
+ import { describe, expect, test } from "vitest";
2
+
3
+ import { fetchAllPages, type Page } from "../../src/providers/media_provider";
4
+
5
+ function pagesOf(items: number[], pageSize: number): (cursor: string | undefined) => Promise<Page<number>> {
6
+ return async (cursor) => {
7
+ const start = cursor ? Number(cursor) : 0;
8
+ const slice = items.slice(start, start + pageSize);
9
+ const nextStart = start + pageSize;
10
+ return {
11
+ items: slice,
12
+ nextCursor: nextStart < items.length ? String(nextStart) : null,
13
+ total: items.length,
14
+ };
15
+ };
16
+ }
17
+
18
+ describe("fetchAllPages", () => {
19
+ test("follows nextCursor until it is null, returning every item", async () => {
20
+ const items = await fetchAllPages(pagesOf([1, 2, 3, 4, 5], 2), { maxPages: 10 });
21
+ expect(items).toEqual([1, 2, 3, 4, 5]);
22
+ });
23
+
24
+ test("a single page needs no cursor following", async () => {
25
+ const items = await fetchAllPages(pagesOf([1, 2], 5), { maxPages: 10 });
26
+ expect(items).toEqual([1, 2]);
27
+ });
28
+
29
+ test("an empty list returns empty, not an error", async () => {
30
+ const items = await fetchAllPages(pagesOf([], 5), { maxPages: 10 });
31
+ expect(items).toEqual([]);
32
+ });
33
+
34
+ test("a cursor that never terminates throws rather than looping forever", async () => {
35
+ const neverEnds = async (): Promise<Page<number>> => ({
36
+ items: [1],
37
+ nextCursor: "always-more",
38
+ total: null,
39
+ });
40
+
41
+ await expect(fetchAllPages(neverEnds, { maxPages: 5 })).rejects.toThrow(/did not terminate/);
42
+ });
43
+ });
@@ -0,0 +1,106 @@
1
+ import { describe, expect, test } from "vitest";
2
+
3
+ import {
4
+ buildAuthorizeUrl,
5
+ deriveCodeChallenge,
6
+ generateCodeVerifier,
7
+ needsRefresh,
8
+ SPOTIFY_SCOPES,
9
+ } from "../../../src/providers/spotify/spotify_oauth";
10
+
11
+ describe("deriveCodeChallenge", () => {
12
+ test("matches RFC 7636 Appendix B's worked example", async () => {
13
+ // The RFC's own test vector, not a value this code produced itself —
14
+ // a self-referential test proves only that the function is consistent
15
+ // with itself, not that it implements the spec correctly.
16
+ const verifier = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk";
17
+ const expectedChallenge = "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM";
18
+
19
+ expect(await deriveCodeChallenge(verifier)).toBe(expectedChallenge);
20
+ });
21
+
22
+ test("contains no base64 padding or URL-unsafe characters", async () => {
23
+ const challenge = await deriveCodeChallenge(generateCodeVerifier());
24
+ expect(challenge).not.toMatch(/[+/=]/);
25
+ });
26
+ });
27
+
28
+ describe("generateCodeVerifier", () => {
29
+ test("is within RFC 7636's 43-128 character range", () => {
30
+ const verifier = generateCodeVerifier();
31
+ expect(verifier.length).toBeGreaterThanOrEqual(43);
32
+ expect(verifier.length).toBeLessThanOrEqual(128);
33
+ });
34
+
35
+ test("uses only the RFC's allowed alphabet", () => {
36
+ const verifier = generateCodeVerifier();
37
+ expect(verifier).toMatch(/^[A-Za-z0-9\-._~]+$/);
38
+ });
39
+
40
+ test("two calls produce different verifiers", () => {
41
+ // Not a proof of cryptographic quality, just a guard against an
42
+ // accidentally deterministic implementation.
43
+ expect(generateCodeVerifier()).not.toBe(generateCodeVerifier());
44
+ });
45
+ });
46
+
47
+ describe("buildAuthorizeUrl", () => {
48
+ test("includes every required PKCE and OAuth parameter", () => {
49
+ const url = new URL(
50
+ buildAuthorizeUrl({
51
+ clientId: "client-123",
52
+ redirectUri: "https://example.workers.dev/callback",
53
+ codeChallenge: "challenge-abc",
54
+ state: "state-xyz",
55
+ }),
56
+ );
57
+
58
+ expect(url.origin + url.pathname).toBe("https://accounts.spotify.com/authorize");
59
+ expect(url.searchParams.get("client_id")).toBe("client-123");
60
+ expect(url.searchParams.get("response_type")).toBe("code");
61
+ expect(url.searchParams.get("redirect_uri")).toBe(
62
+ "https://example.workers.dev/callback",
63
+ );
64
+ expect(url.searchParams.get("code_challenge_method")).toBe("S256");
65
+ expect(url.searchParams.get("code_challenge")).toBe("challenge-abc");
66
+ expect(url.searchParams.get("state")).toBe("state-xyz");
67
+ });
68
+
69
+ test("requests exactly the scopes this package needs, space-separated", () => {
70
+ const url = new URL(
71
+ buildAuthorizeUrl({
72
+ clientId: "client-123",
73
+ redirectUri: "https://example.workers.dev/callback",
74
+ codeChallenge: "challenge-abc",
75
+ state: "state-xyz",
76
+ }),
77
+ );
78
+ expect(url.searchParams.get("scope")).toBe(SPOTIFY_SCOPES.join(" "));
79
+ });
80
+ });
81
+
82
+ describe("needsRefresh", () => {
83
+ const tokens = {
84
+ accessToken: "a",
85
+ refreshToken: "r",
86
+ expiresAt: 1_000_000,
87
+ };
88
+
89
+ test("false well before expiry", () => {
90
+ expect(
91
+ needsRefresh(tokens, { now: () => 0, marginMs: 60_000 }),
92
+ ).toBe(false);
93
+ });
94
+
95
+ test("true once inside the refresh margin", () => {
96
+ expect(
97
+ needsRefresh(tokens, { now: () => 999_500, marginMs: 60_000 }),
98
+ ).toBe(true);
99
+ });
100
+
101
+ test("true once already past expiry", () => {
102
+ expect(
103
+ needsRefresh(tokens, { now: () => 1_000_001, marginMs: 60_000 }),
104
+ ).toBe(true);
105
+ });
106
+ });
@@ -0,0 +1,218 @@
1
+ import { describe, expect, test, vi } from "vitest";
2
+
3
+ import { SpotifyProvider } from "../../../src/providers/spotify/spotify_provider";
4
+
5
+ /**
6
+ * Fakes `fetch` with real Spotify Web API response shapes, copied from
7
+ * Spotify's own published reference examples rather than invented — what is
8
+ * under test here is `SpotifyProvider`'s mapping from those shapes onto
9
+ * `MediaProvider`, not Spotify's API itself, which is exercised for real
10
+ * once integration credentials exist.
11
+ */
12
+ function fakeSpotifyFetch(responseBody: unknown): typeof fetch {
13
+ return vi.fn(async () => new Response(JSON.stringify(responseBody), { status: 200 })) as unknown as typeof fetch;
14
+ }
15
+
16
+ const SAVED_TRACKS_RESPONSE = {
17
+ href: "https://api.spotify.com/v1/me/tracks?offset=0&limit=1",
18
+ limit: 1,
19
+ next: "https://api.spotify.com/v1/me/tracks?offset=1&limit=1",
20
+ offset: 0,
21
+ previous: null,
22
+ total: 2,
23
+ items: [
24
+ {
25
+ added_at: "2026-01-01T00:00:00Z",
26
+ track: {
27
+ id: "track-1",
28
+ name: "Karma Police",
29
+ artists: [{ id: "artist-1", name: "Radiohead" }],
30
+ album: { id: "album-1", name: "OK Computer" },
31
+ duration_ms: 261973,
32
+ uri: "spotify:track:track-1",
33
+ },
34
+ },
35
+ ],
36
+ };
37
+
38
+ const FOLLOWED_ARTISTS_RESPONSE = {
39
+ artists: {
40
+ href: "https://api.spotify.com/v1/me/following?type=artist",
41
+ limit: 1,
42
+ next: "https://api.spotify.com/v1/me/following?type=artist&after=artist-1",
43
+ cursors: { after: "artist-1" },
44
+ total: 2,
45
+ items: [
46
+ {
47
+ id: "artist-1",
48
+ name: "Rammstein",
49
+ genres: ["neue deutsche härte", "industrial metal"],
50
+ uri: "spotify:artist:artist-1",
51
+ images: [],
52
+ },
53
+ ],
54
+ },
55
+ };
56
+
57
+ describe("SpotifyProvider.getLikedTracks", () => {
58
+ test("maps a Spotify saved-tracks page onto the shared Track shape", async () => {
59
+ globalThis.fetch = fakeSpotifyFetch(SAVED_TRACKS_RESPONSE);
60
+ const provider = new SpotifyProvider("test-token");
61
+
62
+ const page = await provider.getLikedTracks();
63
+
64
+ expect(page.items).toEqual([
65
+ {
66
+ id: "track-1",
67
+ name: "Karma Police",
68
+ artistNames: ["Radiohead"],
69
+ albumName: "OK Computer",
70
+ durationMs: 261973,
71
+ uri: "spotify:track:track-1",
72
+ },
73
+ ]);
74
+ expect(page.total).toBe(2);
75
+ });
76
+
77
+ test("a present next URL becomes a non-null offset cursor", async () => {
78
+ globalThis.fetch = fakeSpotifyFetch(SAVED_TRACKS_RESPONSE);
79
+ const provider = new SpotifyProvider("test-token");
80
+
81
+ const page = await provider.getLikedTracks();
82
+
83
+ // offset 0 + 1 item returned = next offset 1, as a string cursor.
84
+ expect(page.nextCursor).toBe("1");
85
+ });
86
+
87
+ test("a null next URL means no more pages", async () => {
88
+ globalThis.fetch = fakeSpotifyFetch({ ...SAVED_TRACKS_RESPONSE, next: null });
89
+ const provider = new SpotifyProvider("test-token");
90
+
91
+ const page = await provider.getLikedTracks();
92
+
93
+ expect(page.nextCursor).toBeNull();
94
+ });
95
+ });
96
+
97
+ describe("SpotifyProvider.getFollowedArtists", () => {
98
+ test("maps a cursor-paginated response onto the shared Page shape", async () => {
99
+ globalThis.fetch = fakeSpotifyFetch(FOLLOWED_ARTISTS_RESPONSE);
100
+ const provider = new SpotifyProvider("test-token");
101
+
102
+ const page = await provider.getFollowedArtists();
103
+
104
+ expect(page.items).toEqual([
105
+ {
106
+ id: "artist-1",
107
+ name: "Rammstein",
108
+ genres: ["neue deutsche härte", "industrial metal"],
109
+ uri: "spotify:artist:artist-1",
110
+ },
111
+ ]);
112
+ // Cursor-paginated, so the cursor is Spotify's own `after` value, not a
113
+ // derived offset — this is the one place the two pagination styles this
114
+ // package wraps must not be conflated.
115
+ expect(page.nextCursor).toBe("artist-1");
116
+ expect(page.total).toBe(2);
117
+ });
118
+ });
119
+
120
+ describe("SpotifyProvider.getPlaylistTracks", () => {
121
+ const PLAYLIST_ITEMS_RESPONSE = {
122
+ href: "https://api.spotify.com/v1/playlists/playlist-1/items?offset=0&limit=1",
123
+ limit: 1,
124
+ next: null,
125
+ offset: 0,
126
+ previous: null,
127
+ total: 1,
128
+ items: [
129
+ {
130
+ added_at: "2026-01-01T00:00:00Z",
131
+ item: {
132
+ id: "track-1",
133
+ name: "Karma Police",
134
+ artists: [{ id: "artist-1", name: "Radiohead" }],
135
+ album: { id: "album-1", name: "OK Computer" },
136
+ duration_ms: 261973,
137
+ uri: "spotify:track:track-1",
138
+ },
139
+ },
140
+ ],
141
+ };
142
+
143
+ test("requests /playlists/{id}/items, not the deprecated /tracks path", async () => {
144
+ // Spotify's February 2026 migration renamed GET /playlists/{id}/tracks
145
+ // to GET /playlists/{id}/items, and the item field from `track` to
146
+ // `item`. Asserted on the actual request URL, not just the mapped
147
+ // output, because a test that only checks the output can pass against
148
+ // a stale path if the fake response is shaped to match it regardless.
149
+ const fetchSpy = vi.fn(
150
+ async () => new Response(JSON.stringify(PLAYLIST_ITEMS_RESPONSE), { status: 200 }),
151
+ );
152
+ globalThis.fetch = fetchSpy as unknown as typeof fetch;
153
+ const provider = new SpotifyProvider("test-token");
154
+
155
+ await provider.getPlaylistTracks("playlist-1");
156
+
157
+ const requestedUrl = (fetchSpy as ReturnType<typeof vi.fn>).mock.calls[0][0] as URL;
158
+ expect(requestedUrl.pathname).toBe("/v1/playlists/playlist-1/items");
159
+ expect(requestedUrl.pathname).not.toContain("/tracks");
160
+ });
161
+
162
+ test("maps the item field (not the deprecated track field) onto Track", async () => {
163
+ globalThis.fetch = vi.fn(
164
+ async () => new Response(JSON.stringify(PLAYLIST_ITEMS_RESPONSE), { status: 200 }),
165
+ ) as unknown as typeof fetch;
166
+ const provider = new SpotifyProvider("test-token");
167
+
168
+ const page = await provider.getPlaylistTracks("playlist-1");
169
+
170
+ expect(page.items).toEqual([
171
+ {
172
+ id: "track-1",
173
+ name: "Karma Police",
174
+ artistNames: ["Radiohead"],
175
+ albumName: "OK Computer",
176
+ durationMs: 261973,
177
+ uri: "spotify:track:track-1",
178
+ },
179
+ ]);
180
+ });
181
+ });
182
+
183
+ describe("SpotifyProvider.search", () => {
184
+ test("only requested types are present, others come back as empty pages", async () => {
185
+ globalThis.fetch = fakeSpotifyFetch({
186
+ tracks: {
187
+ href: "",
188
+ limit: 10,
189
+ next: null,
190
+ offset: 0,
191
+ previous: null,
192
+ total: 0,
193
+ items: [],
194
+ },
195
+ });
196
+ const provider = new SpotifyProvider("test-token");
197
+
198
+ const result = await provider.search("test query", { types: ["track"] });
199
+
200
+ expect(result.tracks.items).toEqual([]);
201
+ expect(result.artists).toEqual({ items: [], nextCursor: null, total: 0 });
202
+ expect(result.albums).toEqual({ items: [], nextCursor: null, total: 0 });
203
+ expect(result.playlists).toEqual({ items: [], nextCursor: null, total: 0 });
204
+ });
205
+
206
+ test("the query reaches the request", async () => {
207
+ const fetchSpy = fakeSpotifyFetch({
208
+ tracks: { href: "", limit: 10, next: null, offset: 0, previous: null, total: 0, items: [] },
209
+ });
210
+ globalThis.fetch = fetchSpy;
211
+ const provider = new SpotifyProvider("test-token");
212
+
213
+ await provider.search("artist:Radiohead track:Karma Police");
214
+
215
+ const requestedUrl = (fetchSpy as ReturnType<typeof vi.fn>).mock.calls[0][0] as URL;
216
+ expect(requestedUrl.searchParams.get("q")).toBe("artist:Radiohead track:Karma Police");
217
+ });
218
+ });