diff --git a/skills/spotify/src/api-client.ts b/skills/spotify/src/api-client.ts new file mode 100644 index 0000000..9c305fd --- /dev/null +++ b/skills/spotify/src/api-client.ts @@ -0,0 +1,174 @@ +import { refreshStoredToken } from "./auth.js"; +import { loadToken, tokenNeedsRefresh } from "./token-store.js"; +import type { SpotifyPlaylist, SpotifyToken, SpotifyTrack } from "./types.js"; + +type FetchLike = typeof fetch; + +export interface SpotifyUser { + id: string; + display_name?: string | null; +} + +export interface SpotifyApiClientOptions { + fetchImpl?: FetchLike; + loadToken?: () => Promise; + refreshToken?: () => Promise; + sleep?: (ms: number) => Promise; + now?: () => number; + baseUrl?: string; +} + +export interface CreatePlaylistOptions { + description?: string; + public?: boolean; +} + +export interface PlaylistMutationResult { + snapshot_id?: string; +} + +const DEFAULT_BASE_URL = "https://api.spotify.com/v1"; + +function chunk(items: T[], size: number): T[][] { + const chunks: T[][] = []; + for (let index = 0; index < items.length; index += size) { + chunks.push(items.slice(index, index + size)); + } + return chunks; +} + +async function noSleep(): Promise { + return undefined; +} + +export class SpotifyApiClient { + private readonly fetchImpl: FetchLike; + private readonly loadStoredToken: () => Promise; + private readonly refreshStoredToken: () => Promise; + private readonly sleep: (ms: number) => Promise; + private readonly now: () => number; + private readonly baseUrl: string; + + constructor(options: SpotifyApiClientOptions = {}) { + this.fetchImpl = options.fetchImpl ?? fetch; + this.loadStoredToken = options.loadToken ?? (() => loadToken()); + this.refreshStoredToken = options.refreshToken ?? (() => refreshStoredToken()); + this.sleep = options.sleep ?? noSleep; + this.now = options.now ?? Date.now; + this.baseUrl = options.baseUrl ?? DEFAULT_BASE_URL; + } + + async getCurrentUser(): Promise { + return this.request("GET", "/me"); + } + + async searchTracks(query: string, limit: number): Promise { + const params = new URLSearchParams({ type: "track", q: query, limit: String(limit) }); + const response = await this.request<{ tracks: { items: SpotifyTrack[] } }>("GET", `/search?${params.toString()}`); + return response.tracks.items; + } + + async listPlaylists(limit: number, offset: number): Promise { + const params = new URLSearchParams({ limit: String(limit), offset: String(offset) }); + const response = await this.request<{ items: SpotifyPlaylist[] }>("GET", `/me/playlists?${params.toString()}`); + return response.items; + } + + async createPlaylist(name: string, options: CreatePlaylistOptions = {}): Promise { + return this.request("POST", "/me/playlists", { + name, + description: options.description, + public: Boolean(options.public) + }); + } + + async addItemsToPlaylist(playlistId: string, uris: string[]): Promise { + const results: PlaylistMutationResult[] = []; + for (const batch of chunk(uris, 100)) { + results.push(await this.request("POST", `/playlists/${encodeURIComponent(playlistId)}/items`, { uris: batch })); + } + return results; + } + + async removeItemsFromPlaylist(playlistId: string, uris: string[]): Promise { + const results: PlaylistMutationResult[] = []; + for (const batch of chunk(uris, 100)) { + results.push(await this.request("DELETE", `/playlists/${encodeURIComponent(playlistId)}/items`, { + tracks: batch.map((uri) => ({ uri })) + })); + } + return results; + } + + private async getAccessToken(): Promise { + const token = await this.loadStoredToken(); + if (!token) { + throw new Error("Spotify auth token not found. Run `scripts/spotify auth` first."); + } + if (tokenNeedsRefresh(token, this.now())) { + return this.refreshAccessTokenSafely(); + } + return token; + } + + private async request(method: string, path: string, body?: unknown, authRetried = false): Promise { + const token = await this.getAccessToken(); + const response = await this.fetchWithTransientRetries(method, path, token.accessToken, body); + if (response.status === 401 && !authRetried) { + const refreshed = await this.refreshAccessTokenSafely(); + const retryResponse = await this.fetchWithTransientRetries(method, path, refreshed.accessToken, body); + return this.parseResponse(retryResponse); + } + return this.parseResponse(response); + } + + private async fetchWithTransientRetries(method: string, path: string, accessToken: string, body: unknown): Promise { + let retried429 = false; + let serverRetries = 0; + while (true) { + const response = await this.fetchImpl(`${this.baseUrl}${path}`, { + method, + headers: { + authorization: `Bearer ${accessToken}`, + ...(body === undefined ? {} : { "content-type": "application/json" }) + }, + body: body === undefined ? undefined : JSON.stringify(body) + }); + + if (response.status === 429 && !retried429) { + retried429 = true; + const retryAfter = Number(response.headers.get("retry-after") ?? "1"); + await this.sleep(Number.isFinite(retryAfter) ? retryAfter * 1000 : 1000); + continue; + } + if (response.status >= 500 && response.status < 600 && serverRetries < 2) { + serverRetries += 1; + await this.sleep(100 * serverRetries); + continue; + } + return response; + } + } + + private async refreshAccessTokenSafely(): Promise { + try { + return await this.refreshStoredToken(); + } catch { + throw new Error("Spotify token refresh failed. Run `scripts/spotify auth` again."); + } + } + + private async parseResponse(response: Response): Promise { + if (!response.ok) { + throw new Error(`Spotify API request failed with status ${response.status}.`); + } + if (response.status === 204) { + return undefined as T; + } + return response.json() as Promise; + } +} + +export function createSpotifyApiClient(options: SpotifyApiClientOptions = {}): SpotifyApiClient { + return new SpotifyApiClient(options); +} diff --git a/skills/spotify/src/cli.ts b/skills/spotify/src/cli.ts index eab8f5e..fa323de 100644 --- a/skills/spotify/src/cli.ts +++ b/skills/spotify/src/cli.ts @@ -4,6 +4,13 @@ import minimist from "minimist"; import { fileURLToPath } from "node:url"; import { getAuthStatus, runAuthorizationFlow } from "./auth.js"; +import { + runAddToPlaylistCommand, + runCreatePlaylistCommand, + runRemoveFromPlaylistCommand, + runSearchAndAddCommand +} from "./playlists.js"; +import { runListPlaylistsCommand, runSearchCommand } from "./search.js"; export interface CliDeps { stdout: Pick; @@ -14,7 +21,7 @@ export interface ParsedCli { command: string; positional: string[]; json: boolean; - public: boolean; + public?: boolean; limit?: string; offset?: string; description?: string; @@ -57,12 +64,12 @@ export function createDefaultHandlers(): CommandHandlers { } return status.configFound && status.tokenFound ? 0 : 1; }, - search: notImplemented("search"), - "list-playlists": notImplemented("list-playlists"), - "create-playlist": notImplemented("create-playlist"), - "add-to-playlist": notImplemented("add-to-playlist"), - "remove-from-playlist": notImplemented("remove-from-playlist"), - "search-and-add": notImplemented("search-and-add"), + search: runSearchCommand, + "list-playlists": runListPlaylistsCommand, + "create-playlist": runCreatePlaylistCommand, + "add-to-playlist": runAddToPlaylistCommand, + "remove-from-playlist": runRemoveFromPlaylistCommand, + "search-and-add": runSearchAndAddCommand, import: notImplemented("import") }; } diff --git a/skills/spotify/src/playlists.ts b/skills/spotify/src/playlists.ts new file mode 100644 index 0000000..d731848 --- /dev/null +++ b/skills/spotify/src/playlists.ts @@ -0,0 +1,118 @@ +import { createSpotifyApiClient, type SpotifyApiClient } from "./api-client.js"; +import type { CliDeps, ParsedCli } from "./cli.js"; +import { mapPlaylist, mapTrack } from "./search.js"; +import type { SpotifyTrack } from "./types.js"; + +type PlaylistClient = Pick; + +export function validateTrackUris(uris: string[]): string[] { + if (uris.length === 0) { + throw new Error("At least one spotify:track URI is required."); + } + for (const uri of uris) { + if (!uri.startsWith("spotify:track:")) { + throw new Error(`Invalid Spotify track URI: ${uri}`); + } + } + return uris; +} + +function snapshotIds(results: Array<{ snapshot_id?: string }>): string[] { + return results.map((result) => result.snapshot_id).filter((id): id is string => Boolean(id)); +} + +export async function runCreatePlaylistCommand( + args: ParsedCli, + deps: CliDeps, + client: PlaylistClient = createSpotifyApiClient() +): Promise { + const name = args.positional.join(" ").trim(); + if (!name) { + throw new Error("Missing playlist name."); + } + const playlist = mapPlaylist(await client.createPlaylist(name, { description: args.description, public: Boolean(args.public) })); + if (args.json) { + deps.stdout.write(`${JSON.stringify({ playlist }, null, 2)}\n`); + } else { + deps.stdout.write(`Created playlist ${playlist.id}: ${playlist.name}\n`); + } + return 0; +} + +export async function runAddToPlaylistCommand( + args: ParsedCli, + deps: CliDeps, + client: PlaylistClient = createSpotifyApiClient() +): Promise { + const [playlistId, ...uris] = args.positional; + if (!playlistId) { + throw new Error("Missing playlist id."); + } + const validUris = validateTrackUris(uris); + const results = await client.addItemsToPlaylist(playlistId, validUris); + const output = { playlistId, count: validUris.length, snapshotIds: snapshotIds(results) }; + deps.stdout.write(args.json ? `${JSON.stringify(output, null, 2)}\n` : `Added ${output.count} track(s) to ${playlistId}.\n`); + return 0; +} + +export async function runRemoveFromPlaylistCommand( + args: ParsedCli, + deps: CliDeps, + client: PlaylistClient = createSpotifyApiClient() +): Promise { + const [playlistId, ...uris] = args.positional; + if (!playlistId) { + throw new Error("Missing playlist id."); + } + const validUris = validateTrackUris(uris); + const results = await client.removeItemsFromPlaylist(playlistId, validUris); + const output = { playlistId, count: validUris.length, snapshotIds: snapshotIds(results) }; + deps.stdout.write(args.json ? `${JSON.stringify(output, null, 2)}\n` : `Removed ${output.count} track(s) from ${playlistId}.\n`); + return 0; +} + +export async function searchAndAdd( + playlistId: string, + queries: string[], + client: Pick +): Promise<{ added: Array<{ query: string; uri: string; name: string; artists: string[] }>; missed: string[]; snapshotIds: string[] }> { + if (!playlistId) { + throw new Error("Missing playlist id."); + } + if (queries.length === 0) { + throw new Error("At least one search query is required."); + } + + const added: Array<{ query: string; uri: string; name: string; artists: string[] }> = []; + const missed: string[] = []; + for (const query of queries) { + const tracks: SpotifyTrack[] = await client.searchTracks(query, 1); + const first = tracks[0]; + if (!first) { + missed.push(query); + continue; + } + const output = mapTrack(first); + added.push({ query, uri: output.uri, name: output.name, artists: output.artists }); + } + + const mutationResults = added.length > 0 + ? await client.addItemsToPlaylist(playlistId, added.map((entry) => entry.uri)) + : []; + return { added, missed, snapshotIds: snapshotIds(mutationResults) }; +} + +export async function runSearchAndAddCommand( + args: ParsedCli, + deps: CliDeps, + client: PlaylistClient = createSpotifyApiClient() +): Promise { + const [playlistId, ...queries] = args.positional; + const output = await searchAndAdd(playlistId, queries, client); + if (args.json) { + deps.stdout.write(`${JSON.stringify(output, null, 2)}\n`); + } else { + deps.stdout.write(`Added ${output.added.length} track(s) to ${playlistId}; missed ${output.missed.length}.\n`); + } + return 0; +} diff --git a/skills/spotify/src/search.ts b/skills/spotify/src/search.ts new file mode 100644 index 0000000..4d7c13f --- /dev/null +++ b/skills/spotify/src/search.ts @@ -0,0 +1,98 @@ +import { createSpotifyApiClient, type SpotifyApiClient } from "./api-client.js"; +import type { CliDeps, ParsedCli } from "./cli.js"; +import type { SpotifyPlaylist, SpotifyTrack } from "./types.js"; + +export interface TrackOutput { + id: string; + uri: string; + name: string; + artists: string[]; + album?: string; + externalUrl?: string; +} + +export interface PlaylistOutput { + id: string; + name: string; + public: boolean | null; + owner: string; + externalUrl?: string; +} + +function numberOption(value: string | undefined, fallback: number): number { + if (!value) { + return fallback; + } + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : fallback; +} + +export function clamp(value: number, min: number, max: number): number { + return Math.min(max, Math.max(min, Math.trunc(value))); +} + +export function mapTrack(track: SpotifyTrack): TrackOutput { + return { + id: track.id, + uri: track.uri, + name: track.name, + artists: track.artists.map((artist) => artist.name), + album: track.album?.name, + externalUrl: track.external_urls?.spotify + }; +} + +export function mapPlaylist(playlist: SpotifyPlaylist): PlaylistOutput { + return { + id: playlist.id, + name: playlist.name, + public: playlist.public, + owner: playlist.owner.display_name ?? playlist.owner.id, + externalUrl: playlist.external_urls?.spotify + }; +} + +export function formatTrack(track: TrackOutput): string { + const album = track.album ? ` (${track.album})` : ""; + return `${track.uri} | ${track.name} | ${track.artists.join(", ")}${album}`; +} + +export function formatPlaylist(playlist: PlaylistOutput): string { + const visibility = playlist.public === true ? "public" : playlist.public === false ? "private" : "unknown"; + return `${playlist.id} | ${visibility} | ${playlist.owner} | ${playlist.name}`; +} + +export async function runSearchCommand( + args: ParsedCli, + deps: CliDeps, + client: Pick = createSpotifyApiClient() +): Promise { + const query = args.positional.join(" ").trim(); + if (!query) { + throw new Error("Missing search query."); + } + const limit = clamp(numberOption(args.limit, 5), 1, 10); + const tracks = (await client.searchTracks(query, limit)).map(mapTrack); + if (args.json) { + deps.stdout.write(`${JSON.stringify({ tracks }, null, 2)}\n`); + } else { + deps.stdout.write(`${tracks.map(formatTrack).join("\n")}${tracks.length ? "\n" : ""}`); + } + return 0; +} + +export async function runListPlaylistsCommand( + args: ParsedCli, + deps: CliDeps, + client: Pick = createSpotifyApiClient() +): Promise { + const limit = clamp(numberOption(args.limit, 50), 1, 50); + const offset = Math.max(0, numberOption(args.offset, 0)); + const playlists = (await client.listPlaylists(limit, offset)).map(mapPlaylist); + if (args.json) { + deps.stdout.write(`${JSON.stringify({ playlists }, null, 2)}\n`); + } else { + deps.stdout.write(`${playlists.map(formatPlaylist).join("\n")}${playlists.length ? "\n" : ""}`); + } + return 0; +} diff --git a/skills/spotify/tests/api-client.test.ts b/skills/spotify/tests/api-client.test.ts new file mode 100644 index 0000000..d77041c --- /dev/null +++ b/skills/spotify/tests/api-client.test.ts @@ -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).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).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); +}); diff --git a/skills/spotify/tests/cli.test.ts b/skills/spotify/tests/cli.test.ts index c1f9dff..d113dff 100644 --- a/skills/spotify/tests/cli.test.ts +++ b/skills/spotify/tests/cli.test.ts @@ -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(), ""); }); diff --git a/skills/spotify/tests/playlists.test.ts b/skills/spotify/tests/playlists.test.ts new file mode 100644 index 0000000..f149387 --- /dev/null +++ b/skills/spotify/tests/playlists.test.ts @@ -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"); +}); diff --git a/skills/spotify/tests/search.test.ts b/skills/spotify/tests/search.test.ts new file mode 100644 index 0000000..842fc93 --- /dev/null +++ b/skills/spotify/tests/search.test.ts @@ -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/); +});