fix: bound album-tracks pagination + treat non-object config as corrupt (backup, not crash) [corner-case R2 minors]

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
saopig1andClaude Opus 4.8 committed 2026-07-03 12:44:33 +08:00
1 parent 2e05d276bf
commit 7e58e8a611
4 files changed
+112 -17

No files matched your search

+33
View File
@@ -509,6 +509,39 @@ describe("getAlbumTracks pagination (R2-6)", () => {
expect(out[50].id).toBe("a50"); // page 2 appended in order
expect(out[52].id).toBe("a52");
});
// Robustness: a malformed/proxy response of { items: [], next: <non-null> } never
// grows songs.length nor nulls `next`, so a while-loop bounded ONLY by
// songs.length >= cap spins forever. getAlbumTracks must have a hard offset/page
// bound (mirroring the playlist loop) so it TERMINATES regardless of items/next.
it("terminates on a { items: [], next: <non-null> } response (bounded call count, no hang)", async () => {
const auth = {
post: vi.fn().mockResolvedValue({ data: { access_token: "t", expires_in: 3600 } }),
} as any;
const http = {
get: vi.fn().mockImplementation((path: string) => {
if (path === "/v1/albums/alb1") {
// Even the embedded first page is malformed: empty items, non-null next.
return Promise.resolve({
data: {
name: "Broken",
images: [{ url: "https://i.scdn.co/broken.jpg" }],
tracks: { items: [], next: "http://x/next" },
},
});
}
// Every /albums/{id}/tracks page keeps advertising a further page forever.
return Promise.resolve({ data: { items: [], next: "http://x/next" } });
}),
} as any;
const api = new SpotifyWebApi(() => ({ clientId: "a", clientSecret: "b" }), { http, auth });
const out = await api.getAlbumTracks("alb1");
// Returns what it has (nothing) rather than hanging.
expect(out).toEqual([]);
// Bounded: 1 album GET + at most MAX_ALBUM_TRACKS/ALBUM_PAGE_SIZE (=10) page fetches.
expect(http.get.mock.calls.length).toBeLessThanOrEqual(12);
});
});
// R2-7: search() must null-filter tracks.items like its album/playlist siblings so
+6 -2
View File
@@ -185,7 +185,11 @@ export class SpotifyWebApi {
// Page 1 (up to 50 tracks) is embedded in the album payload; the embedded
// paging object caps at 50, so follow its `next` via the dedicated
// /albums/{id}/tracks endpoint until exhausted or the cap is reached.
// /albums/{id}/tracks endpoint until exhausted or the cap is reached. The
// offset bound is a HARD termination guarantee (mirrors the playlist loop):
// a malformed/proxy response of { items: [], next: <non-null> } never grows
// songs.length nor nulls `next`, so a loop bounded only by songs.length would
// spin forever — the offset cap stops it regardless of items/next.
const songs: Song[] = [];
let page = album?.tracks;
let offset = ALBUM_PAGE_SIZE;
@@ -193,7 +197,7 @@ export class SpotifyWebApi {
for (const t of page.items.filter(Boolean)) {
songs.push({ ...mapSpotifyTrack(t), album: albumName, coverUrl: cover });
}
if (!page.next || songs.length >= MAX_ALBUM_TRACKS) break;
if (!page.next || songs.length >= MAX_ALBUM_TRACKS || offset >= MAX_ALBUM_TRACKS) break;
page = await this.get(`/v1/albums/${albumId}/tracks`, {
limit: ALBUM_PAGE_SIZE,
offset,