182 lines
6.4 KiB
TypeScript
182 lines
6.4 KiB
TypeScript
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);
|
|
});
|