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"); });