feat(spotify): implement milestone M3 api commands
This commit is contained in:
181
skills/spotify/tests/api-client.test.ts
Normal file
181
skills/spotify/tests/api-client.test.ts
Normal file
@@ -0,0 +1,181 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { test } from "node:test";
|
||||
|
||||
import { createSpotifyApiClient } from "../src/api-client.js";
|
||||
import type { SpotifyToken } from "../src/types.js";
|
||||
|
||||
function jsonResponse(body: unknown, status = 200, headers?: HeadersInit): Response {
|
||||
return new Response(JSON.stringify(body), { status, headers });
|
||||
}
|
||||
|
||||
function token(expiresAt = Date.now() + 3_600_000): SpotifyToken {
|
||||
return { accessToken: "access-token", refreshToken: "refresh-token", expiresAt };
|
||||
}
|
||||
|
||||
test("searchTracks uses current search endpoint", async () => {
|
||||
const urls: string[] = [];
|
||||
const client = createSpotifyApiClient({
|
||||
loadToken: async () => token(),
|
||||
fetchImpl: (async (url) => {
|
||||
urls.push(String(url));
|
||||
return jsonResponse({ tracks: { items: [{ id: "1", uri: "spotify:track:1", name: "Song", artists: [] }] } });
|
||||
}) as typeof fetch
|
||||
});
|
||||
|
||||
const tracks = await client.searchTracks("Karma Police", 3);
|
||||
|
||||
assert.equal(tracks[0].uri, "spotify:track:1");
|
||||
assert.equal(urls[0], "https://api.spotify.com/v1/search?type=track&q=Karma+Police&limit=3");
|
||||
});
|
||||
|
||||
test("proactively refreshes expired token before request", async () => {
|
||||
const accessTokens: string[] = [];
|
||||
const client = createSpotifyApiClient({
|
||||
now: () => 1_000,
|
||||
loadToken: async () => token(1_000),
|
||||
refreshToken: async () => ({ accessToken: "fresh-token", refreshToken: "refresh-token", expiresAt: 10_000 }),
|
||||
fetchImpl: (async (_url, init) => {
|
||||
accessTokens.push(String(init?.headers && (init.headers as Record<string, string>).authorization));
|
||||
return jsonResponse({ id: "me" });
|
||||
}) as typeof fetch
|
||||
});
|
||||
|
||||
await client.getCurrentUser();
|
||||
|
||||
assert.deepEqual(accessTokens, ["Bearer fresh-token"]);
|
||||
});
|
||||
|
||||
test("reactively refreshes once on 401 and retries original request", async () => {
|
||||
const accessTokens: string[] = [];
|
||||
const client = createSpotifyApiClient({
|
||||
loadToken: async () => token(),
|
||||
refreshToken: async () => ({ accessToken: "fresh-token", refreshToken: "refresh-token", expiresAt: 20_000 }),
|
||||
fetchImpl: (async (_url, init) => {
|
||||
accessTokens.push(String(init?.headers && (init.headers as Record<string, string>).authorization));
|
||||
if (accessTokens.length === 1) {
|
||||
return jsonResponse({ error: "expired" }, 401);
|
||||
}
|
||||
return jsonResponse({ id: "me" });
|
||||
}) as typeof fetch
|
||||
});
|
||||
|
||||
await client.getCurrentUser();
|
||||
|
||||
assert.deepEqual(accessTokens, ["Bearer access-token", "Bearer fresh-token"]);
|
||||
});
|
||||
|
||||
test("proactive refresh failure is sanitized", async () => {
|
||||
const client = createSpotifyApiClient({
|
||||
now: () => 1_000,
|
||||
loadToken: async () => ({ accessToken: "access-secret", refreshToken: "refresh-secret", expiresAt: 1_000 }),
|
||||
refreshToken: async () => {
|
||||
throw new Error("refresh-secret access-secret credential-path");
|
||||
},
|
||||
fetchImpl: (async () => jsonResponse({ id: "me" })) as typeof fetch
|
||||
});
|
||||
|
||||
await assert.rejects(
|
||||
() => client.getCurrentUser(),
|
||||
(error) => error instanceof Error &&
|
||||
error.message === "Spotify token refresh failed. Run `scripts/spotify auth` again." &&
|
||||
!error.message.includes("secret") &&
|
||||
!error.message.includes("credential-path")
|
||||
);
|
||||
});
|
||||
|
||||
test("reactive refresh failure is sanitized", async () => {
|
||||
const client = createSpotifyApiClient({
|
||||
loadToken: async () => token(),
|
||||
refreshToken: async () => {
|
||||
throw new Error("refresh-secret access-secret credential-path");
|
||||
},
|
||||
fetchImpl: (async () => jsonResponse({ error: "expired" }, 401)) as typeof fetch
|
||||
});
|
||||
|
||||
await assert.rejects(
|
||||
() => client.getCurrentUser(),
|
||||
(error) => error instanceof Error &&
|
||||
error.message === "Spotify token refresh failed. Run `scripts/spotify auth` again." &&
|
||||
!error.message.includes("secret") &&
|
||||
!error.message.includes("credential-path")
|
||||
);
|
||||
});
|
||||
|
||||
test("retries 429 using retry-after", async () => {
|
||||
const sleeps: number[] = [];
|
||||
let attempts = 0;
|
||||
const client = createSpotifyApiClient({
|
||||
loadToken: async () => token(),
|
||||
sleep: async (ms) => { sleeps.push(ms); },
|
||||
fetchImpl: (async () => {
|
||||
attempts += 1;
|
||||
if (attempts === 1) {
|
||||
return jsonResponse({ error: "rate" }, 429, { "retry-after": "2" });
|
||||
}
|
||||
return jsonResponse({ id: "me" });
|
||||
}) as typeof fetch
|
||||
});
|
||||
|
||||
await client.getCurrentUser();
|
||||
|
||||
assert.equal(attempts, 2);
|
||||
assert.deepEqual(sleeps, [2000]);
|
||||
});
|
||||
|
||||
test("429 retry has a separate budget from 5xx retry", async () => {
|
||||
const statuses = [500, 429, 200];
|
||||
const client = createSpotifyApiClient({
|
||||
loadToken: async () => token(),
|
||||
sleep: async () => undefined,
|
||||
fetchImpl: (async () => {
|
||||
const status = statuses.shift() ?? 200;
|
||||
return status === 200 ? jsonResponse({ id: "me" }) : jsonResponse({ error: status }, status);
|
||||
}) as typeof fetch
|
||||
});
|
||||
|
||||
await client.getCurrentUser();
|
||||
|
||||
assert.deepEqual(statuses, []);
|
||||
});
|
||||
|
||||
test("retries 5xx up to bounded attempts", async () => {
|
||||
let attempts = 0;
|
||||
const client = createSpotifyApiClient({
|
||||
loadToken: async () => token(),
|
||||
sleep: async () => undefined,
|
||||
fetchImpl: (async () => {
|
||||
attempts += 1;
|
||||
if (attempts < 3) {
|
||||
return jsonResponse({ error: "server" }, 500);
|
||||
}
|
||||
return jsonResponse({ id: "me" });
|
||||
}) as typeof fetch
|
||||
});
|
||||
|
||||
await client.getCurrentUser();
|
||||
|
||||
assert.equal(attempts, 3);
|
||||
});
|
||||
|
||||
test("playlist mutations use current items endpoints and chunk batches", async () => {
|
||||
const calls: Array<{ url: string; method?: string; body?: string }> = [];
|
||||
const client = createSpotifyApiClient({
|
||||
loadToken: async () => token(),
|
||||
fetchImpl: (async (url, init) => {
|
||||
calls.push({ url: String(url), method: init?.method, body: String(init?.body) });
|
||||
return jsonResponse({ snapshot_id: `snap-${calls.length}` });
|
||||
}) as typeof fetch
|
||||
});
|
||||
const uris = Array.from({ length: 101 }, (_, index) => `spotify:track:${index}`);
|
||||
|
||||
const addResults = await client.addItemsToPlaylist("playlist id", uris);
|
||||
const removeResults = await client.removeItemsFromPlaylist("playlist id", uris);
|
||||
|
||||
assert.deepEqual(addResults.map((result) => result.snapshot_id), ["snap-1", "snap-2"]);
|
||||
assert.deepEqual(removeResults.map((result) => result.snapshot_id), ["snap-3", "snap-4"]);
|
||||
assert.equal(calls[0].url, "https://api.spotify.com/v1/playlists/playlist%20id/items");
|
||||
assert.equal(calls[0].method, "POST");
|
||||
assert.equal(JSON.parse(calls[0].body ?? "{}").uris.length, 100);
|
||||
assert.equal(calls[2].method, "DELETE");
|
||||
assert.equal(JSON.parse(calls[2].body ?? "{}").tracks.length, 100);
|
||||
});
|
||||
@@ -74,12 +74,12 @@ test("dispatches known command with json flag", async () => {
|
||||
test("known unimplemented commands return structured placeholder errors in json mode", async () => {
|
||||
const stdout = createBuffer();
|
||||
const stderr = createBuffer();
|
||||
const code = await runCli(["search", "--json"], { stdout: stdout.stream, stderr: stderr.stream });
|
||||
const code = await runCli(["import", "--json"], { stdout: stdout.stream, stderr: stderr.stream });
|
||||
|
||||
assert.equal(code, 2);
|
||||
assert.deepEqual(JSON.parse(stdout.output()), {
|
||||
ok: false,
|
||||
error: "Command not implemented yet: search"
|
||||
error: "Command not implemented yet: import"
|
||||
});
|
||||
assert.equal(stderr.output(), "");
|
||||
});
|
||||
|
||||
158
skills/spotify/tests/playlists.test.ts
Normal file
158
skills/spotify/tests/playlists.test.ts
Normal file
@@ -0,0 +1,158 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { test } from "node:test";
|
||||
|
||||
import {
|
||||
runAddToPlaylistCommand,
|
||||
runCreatePlaylistCommand,
|
||||
runRemoveFromPlaylistCommand,
|
||||
runSearchAndAddCommand,
|
||||
searchAndAdd,
|
||||
validateTrackUris
|
||||
} from "../src/playlists.js";
|
||||
import type { CliDeps } from "../src/cli.js";
|
||||
import type { SpotifyPlaylist } from "../src/types.js";
|
||||
|
||||
function createDeps(): { deps: CliDeps; stdout: () => string; stderr: () => string } {
|
||||
let stdout = "";
|
||||
let stderr = "";
|
||||
return {
|
||||
deps: {
|
||||
stdout: { write: (chunk: string) => { stdout += chunk; return true; } },
|
||||
stderr: { write: (chunk: string) => { stderr += chunk; return true; } }
|
||||
},
|
||||
stdout: () => stdout,
|
||||
stderr: () => stderr
|
||||
};
|
||||
}
|
||||
|
||||
function playlist(name: string): SpotifyPlaylist {
|
||||
return {
|
||||
id: "playlist-id",
|
||||
uri: "spotify:playlist:playlist-id",
|
||||
name,
|
||||
public: false,
|
||||
owner: { id: "owner-id" },
|
||||
external_urls: { spotify: "https://open.spotify.com/playlist/playlist-id" }
|
||||
};
|
||||
}
|
||||
|
||||
test("create playlist defaults private unless public flag is present", async () => {
|
||||
const io = createDeps();
|
||||
let observed: { name: string; public?: boolean; description?: string } | undefined;
|
||||
|
||||
await runCreatePlaylistCommand(
|
||||
{ command: "create-playlist", positional: ["My", "Mix"], json: true, public: false, description: "desc" },
|
||||
io.deps,
|
||||
{
|
||||
createPlaylist: async (name, options) => {
|
||||
observed = { name, ...options };
|
||||
return playlist(name);
|
||||
},
|
||||
addItemsToPlaylist: async () => [],
|
||||
removeItemsFromPlaylist: async () => [],
|
||||
searchTracks: async () => []
|
||||
}
|
||||
);
|
||||
|
||||
assert.deepEqual(observed, { name: "My Mix", public: false, description: "desc" });
|
||||
assert.equal(JSON.parse(io.stdout()).playlist.id, "playlist-id");
|
||||
});
|
||||
|
||||
test("create playlist treats omitted public flag as private", async () => {
|
||||
const io = createDeps();
|
||||
let observedPublic: boolean | undefined;
|
||||
|
||||
await runCreatePlaylistCommand(
|
||||
{ command: "create-playlist", positional: ["My Mix"], json: true },
|
||||
io.deps,
|
||||
{
|
||||
createPlaylist: async (name, options) => {
|
||||
observedPublic = options?.public;
|
||||
return playlist(name);
|
||||
},
|
||||
addItemsToPlaylist: async () => [],
|
||||
removeItemsFromPlaylist: async () => [],
|
||||
searchTracks: async () => []
|
||||
}
|
||||
);
|
||||
|
||||
assert.equal(observedPublic, false);
|
||||
});
|
||||
|
||||
test("validates spotify track uris", () => {
|
||||
assert.deepEqual(validateTrackUris(["spotify:track:1"]), ["spotify:track:1"]);
|
||||
assert.throws(() => validateTrackUris(["spotify:album:1"]), /Invalid Spotify track URI/);
|
||||
});
|
||||
|
||||
test("add to playlist outputs mutation summary", async () => {
|
||||
const io = createDeps();
|
||||
let observedUris: string[] = [];
|
||||
await runAddToPlaylistCommand(
|
||||
{ command: "add-to-playlist", positional: ["playlist-id", "spotify:track:1"], json: true, public: false },
|
||||
io.deps,
|
||||
{
|
||||
createPlaylist: async () => playlist("unused"),
|
||||
addItemsToPlaylist: async (_playlistId, uris) => {
|
||||
observedUris = uris;
|
||||
return [{ snapshot_id: "snap" }];
|
||||
},
|
||||
removeItemsFromPlaylist: async () => [],
|
||||
searchTracks: async () => []
|
||||
}
|
||||
);
|
||||
|
||||
assert.deepEqual(observedUris, ["spotify:track:1"]);
|
||||
assert.deepEqual(JSON.parse(io.stdout()), { playlistId: "playlist-id", count: 1, snapshotIds: ["snap"] });
|
||||
});
|
||||
|
||||
test("remove from playlist outputs mutation summary", async () => {
|
||||
const io = createDeps();
|
||||
await runRemoveFromPlaylistCommand(
|
||||
{ command: "remove-from-playlist", positional: ["playlist-id", "spotify:track:1"], json: false, public: false },
|
||||
io.deps,
|
||||
{
|
||||
createPlaylist: async () => playlist("unused"),
|
||||
addItemsToPlaylist: async () => [],
|
||||
removeItemsFromPlaylist: async () => [{ snapshot_id: "snap" }],
|
||||
searchTracks: async () => []
|
||||
}
|
||||
);
|
||||
|
||||
assert.equal(io.stdout(), "Removed 1 track(s) from playlist-id.\n");
|
||||
});
|
||||
|
||||
test("searchAndAdd adds first matches and records misses", async () => {
|
||||
let observedUris: string[] = [];
|
||||
const result = await searchAndAdd("playlist-id", ["found", "missing"], {
|
||||
searchTracks: async (query) => query === "found"
|
||||
? [{ id: "track-id", uri: "spotify:track:track-id", name: "Song", artists: [{ name: "Artist" }] }]
|
||||
: [],
|
||||
addItemsToPlaylist: async (_playlistId, uris) => {
|
||||
observedUris = uris;
|
||||
return [{ snapshot_id: "snap" }];
|
||||
}
|
||||
});
|
||||
|
||||
assert.deepEqual(observedUris, ["spotify:track:track-id"]);
|
||||
assert.deepEqual(result, {
|
||||
added: [{ query: "found", uri: "spotify:track:track-id", name: "Song", artists: ["Artist"] }],
|
||||
missed: ["missing"],
|
||||
snapshotIds: ["snap"]
|
||||
});
|
||||
});
|
||||
|
||||
test("search-and-add command writes JSON summary", async () => {
|
||||
const io = createDeps();
|
||||
await runSearchAndAddCommand(
|
||||
{ command: "search-and-add", positional: ["playlist-id", "found"], json: true, public: false },
|
||||
io.deps,
|
||||
{
|
||||
createPlaylist: async () => playlist("unused"),
|
||||
addItemsToPlaylist: async () => [{ snapshot_id: "snap" }],
|
||||
removeItemsFromPlaylist: async () => [],
|
||||
searchTracks: async () => [{ id: "track-id", uri: "spotify:track:track-id", name: "Song", artists: [{ name: "Artist" }] }]
|
||||
}
|
||||
);
|
||||
|
||||
assert.equal(JSON.parse(io.stdout()).added[0].uri, "spotify:track:track-id");
|
||||
});
|
||||
97
skills/spotify/tests/search.test.ts
Normal file
97
skills/spotify/tests/search.test.ts
Normal file
@@ -0,0 +1,97 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { test } from "node:test";
|
||||
|
||||
import { mapPlaylist, mapTrack, runListPlaylistsCommand, runSearchCommand } from "../src/search.js";
|
||||
import type { CliDeps } from "../src/cli.js";
|
||||
import type { SpotifyPlaylist, SpotifyTrack } from "../src/types.js";
|
||||
|
||||
function createDeps(): { deps: CliDeps; stdout: () => string; stderr: () => string } {
|
||||
let stdout = "";
|
||||
let stderr = "";
|
||||
return {
|
||||
deps: {
|
||||
stdout: { write: (chunk: string) => { stdout += chunk; return true; } },
|
||||
stderr: { write: (chunk: string) => { stderr += chunk; return true; } }
|
||||
},
|
||||
stdout: () => stdout,
|
||||
stderr: () => stderr
|
||||
};
|
||||
}
|
||||
|
||||
const track: SpotifyTrack = {
|
||||
id: "track-id",
|
||||
uri: "spotify:track:track-id",
|
||||
name: "Karma Police",
|
||||
artists: [{ name: "Radiohead" }],
|
||||
album: { name: "OK Computer" },
|
||||
external_urls: { spotify: "https://open.spotify.com/track/track-id" }
|
||||
};
|
||||
|
||||
const playlist: SpotifyPlaylist = {
|
||||
id: "playlist-id",
|
||||
uri: "spotify:playlist:playlist-id",
|
||||
name: "Private Mix",
|
||||
public: false,
|
||||
owner: { id: "owner-id", display_name: "Owner" },
|
||||
external_urls: { spotify: "https://open.spotify.com/playlist/playlist-id" }
|
||||
};
|
||||
|
||||
test("maps raw track to flattened output DTO", () => {
|
||||
assert.deepEqual(mapTrack(track), {
|
||||
id: "track-id",
|
||||
uri: "spotify:track:track-id",
|
||||
name: "Karma Police",
|
||||
artists: ["Radiohead"],
|
||||
album: "OK Computer",
|
||||
externalUrl: "https://open.spotify.com/track/track-id"
|
||||
});
|
||||
});
|
||||
|
||||
test("search command clamps limit and writes JSON", async () => {
|
||||
const io = createDeps();
|
||||
let observedLimit = 0;
|
||||
const code = await runSearchCommand(
|
||||
{ command: "search", positional: ["Karma", "Police"], json: true, public: false, limit: "99" },
|
||||
io.deps,
|
||||
{
|
||||
searchTracks: async (_query, limit) => {
|
||||
observedLimit = limit;
|
||||
return [track];
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
assert.equal(code, 0);
|
||||
assert.equal(observedLimit, 10);
|
||||
assert.equal(JSON.parse(io.stdout()).tracks[0].externalUrl, "https://open.spotify.com/track/track-id");
|
||||
assert.equal(io.stderr(), "");
|
||||
});
|
||||
|
||||
test("maps playlist to flattened output DTO", () => {
|
||||
assert.deepEqual(mapPlaylist(playlist), {
|
||||
id: "playlist-id",
|
||||
name: "Private Mix",
|
||||
public: false,
|
||||
owner: "Owner",
|
||||
externalUrl: "https://open.spotify.com/playlist/playlist-id"
|
||||
});
|
||||
});
|
||||
|
||||
test("list playlists command clamps limit and writes human output", async () => {
|
||||
const io = createDeps();
|
||||
let observed = { limit: 0, offset: -1 };
|
||||
const code = await runListPlaylistsCommand(
|
||||
{ command: "list-playlists", positional: [], json: false, public: false, limit: "200", offset: "-5" },
|
||||
io.deps,
|
||||
{
|
||||
listPlaylists: async (limit, offset) => {
|
||||
observed = { limit, offset };
|
||||
return [playlist];
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
assert.equal(code, 0);
|
||||
assert.deepEqual(observed, { limit: 50, offset: 0 });
|
||||
assert.match(io.stdout(), /playlist-id \\| private \\| Owner \\| Private Mix/);
|
||||
});
|
||||
Reference in New Issue
Block a user