@syra.fm/sdk 0.2.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -45,6 +45,29 @@ function makePodcast(overrides: Record<string, unknown> = {}): Record<string, un
45
45
  };
46
46
  }
47
47
 
48
+ function makeEpisode(overrides: Record<string, unknown> = {}): Record<string, unknown> {
49
+ return {
50
+ id: '507f1f77bcf86cd799439031',
51
+ podcastId: '507f1f77bcf86cd799439021',
52
+ podcastTitle: 'Test Show',
53
+ title: 'Test Episode',
54
+ description: 'An episode about testing.',
55
+ guid: 'guid-1',
56
+ enclosureUrl: 'https://api.fastcast.ai/audio/guid-1.mp3',
57
+ enclosureType: 'audio/mpeg',
58
+ enclosureLength: 12_345_678,
59
+ duration: 1800,
60
+ pubDate: '2026-01-01T00:00:00.000Z',
61
+ episodeType: 'full',
62
+ image: '507f1f77bcf86cd799439032',
63
+ imageSourceUrl: 'https://cdn.example.com/episode.jpg',
64
+ status: 'ready',
65
+ createdAt: '2026-01-01T00:00:00.000Z',
66
+ updatedAt: '2026-01-01T00:00:00.000Z',
67
+ ...overrides,
68
+ };
69
+ }
70
+
48
71
  interface FetchCall {
49
72
  url: string;
50
73
  }
@@ -70,7 +93,7 @@ function fakeFetch(
70
93
  // ── searchTracks ──────────────────────────────────────────────────────────────
71
94
 
72
95
  describe('createSyraClient.searchTracks', () => {
73
- it('calls /api/search with category=tracks and the limit, returns preview-available tracks', async () => {
96
+ it('calls /api/search with category=tracks and the limit, returns a page of preview-available tracks', async () => {
74
97
  const { fetch, calls } = fakeFetch(() => ({
75
98
  body: {
76
99
  results: {
@@ -79,14 +102,20 @@ describe('createSyraClient.searchTracks', () => {
79
102
  makeTrack({ id: '507f1f77bcf86cd799439012', previewAvailable: false }),
80
103
  ],
81
104
  },
105
+ hasMore: false,
106
+ limit: 10,
107
+ offset: 0,
82
108
  },
83
109
  }));
84
110
 
85
111
  const client = createSyraClient({ baseURL: 'https://api.example.test', fetch });
86
- const tracks = await client.searchTracks('hello', { limit: 10 });
112
+ const page = await client.searchTracks('hello', { limit: 10 });
87
113
 
88
- expect(tracks).toHaveLength(1);
89
- expect(tracks[0].id).toBe('507f1f77bcf86cd799439011');
114
+ expect(page.items).toHaveLength(1);
115
+ expect(page.items[0].id).toBe('507f1f77bcf86cd799439011');
116
+ expect(page.hasMore).toBe(false);
117
+ expect(page.limit).toBe(10);
118
+ expect(page.offset).toBe(0);
90
119
 
91
120
  expect(calls).toHaveLength(1);
92
121
  const url = new URL(calls[0].url);
@@ -94,6 +123,53 @@ describe('createSyraClient.searchTracks', () => {
94
123
  expect(url.searchParams.get('q')).toBe('hello');
95
124
  expect(url.searchParams.get('category')).toBe('tracks');
96
125
  expect(url.searchParams.get('limit')).toBe('10');
126
+ expect(url.searchParams.has('offset')).toBe(false);
127
+ });
128
+
129
+ it('sends the offset param and reports hasMore/offset/limit from the backend', async () => {
130
+ const { fetch, calls } = fakeFetch(() => ({
131
+ body: {
132
+ results: { tracks: [makeTrack()] },
133
+ hasMore: true,
134
+ limit: 20,
135
+ offset: 40,
136
+ },
137
+ }));
138
+
139
+ const client = createSyraClient({ baseURL: 'https://api.example.test', fetch });
140
+ const page = await client.searchTracks('hello', { limit: 20, offset: 40 });
141
+
142
+ expect(page.items).toHaveLength(1);
143
+ expect(page.hasMore).toBe(true);
144
+ expect(page.limit).toBe(20);
145
+ expect(page.offset).toBe(40);
146
+
147
+ const url = new URL(calls[0].url);
148
+ expect(url.searchParams.get('offset')).toBe('40');
149
+ expect(url.searchParams.get('limit')).toBe('20');
150
+ });
151
+
152
+ it('reports backend hasMore even when client-side preview filtering empties the page', async () => {
153
+ const { fetch } = fakeFetch(() => ({
154
+ body: {
155
+ results: {
156
+ tracks: [
157
+ makeTrack({ previewAvailable: false }),
158
+ makeTrack({ previewAvailable: false }),
159
+ ],
160
+ },
161
+ hasMore: true,
162
+ limit: 2,
163
+ offset: 0,
164
+ },
165
+ }));
166
+
167
+ const client = createSyraClient({ fetch });
168
+ const page = await client.searchTracks('x', { limit: 2 });
169
+
170
+ // All rows were filtered out, but the page is NOT the last one.
171
+ expect(page.items).toHaveLength(0);
172
+ expect(page.hasMore).toBe(true);
97
173
  });
98
174
 
99
175
  it('drops malformed rows without throwing', async () => {
@@ -109,14 +185,17 @@ describe('createSyraClient.searchTracks', () => {
109
185
  }));
110
186
 
111
187
  const client = createSyraClient({ fetch });
112
- const tracks = await client.searchTracks('x');
113
- expect(tracks).toHaveLength(1);
188
+ const page = await client.searchTracks('x');
189
+ expect(page.items).toHaveLength(1);
114
190
  });
115
191
 
116
- it('returns an empty array when results.tracks is absent', async () => {
192
+ it('returns an empty page with hasMore=false when results.tracks is absent', async () => {
117
193
  const { fetch } = fakeFetch(() => ({ body: {} }));
118
194
  const client = createSyraClient({ fetch });
119
- expect(await client.searchTracks('x')).toEqual([]);
195
+ const page = await client.searchTracks('x');
196
+ expect(page.items).toEqual([]);
197
+ expect(page.hasMore).toBe(false);
198
+ expect(page.offset).toBe(0);
120
199
  });
121
200
  });
122
201
 
@@ -222,36 +301,60 @@ describe('createSyraClient.artworkUrl', () => {
222
301
  // ── searchPodcasts ──────────────────────────────────────────────────────────────
223
302
 
224
303
  describe('createSyraClient.searchPodcasts', () => {
225
- it('calls /api/podcasts/search with q and limit, returns parsed shows', async () => {
304
+ it('calls /api/podcasts/search with q and limit, returns a page of parsed shows', async () => {
226
305
  const { fetch, calls } = fakeFetch(() => ({
227
306
  body: {
228
307
  data: [
229
308
  makePodcast({ id: '507f1f77bcf86cd799439021' }),
230
309
  makePodcast({ id: '507f1f77bcf86cd799439023', title: 'Second Show' }),
231
310
  ],
311
+ hasMore: false,
312
+ limit: 5,
313
+ offset: 0,
232
314
  },
233
315
  }));
234
316
 
235
317
  const client = createSyraClient({ baseURL: 'https://api.example.test', fetch });
236
- const podcasts = await client.searchPodcasts('news', { limit: 5 });
318
+ const page = await client.searchPodcasts('news', { limit: 5 });
237
319
 
238
- expect(podcasts).toHaveLength(2);
239
- expect(podcasts[0].id).toBe('507f1f77bcf86cd799439021');
240
- expect(podcasts[0].author).toBe('Test Publisher');
320
+ expect(page.items).toHaveLength(2);
321
+ expect(page.items[0].id).toBe('507f1f77bcf86cd799439021');
322
+ expect(page.items[0].author).toBe('Test Publisher');
323
+ expect(page.hasMore).toBe(false);
324
+ expect(page.limit).toBe(5);
325
+ expect(page.offset).toBe(0);
241
326
 
242
327
  expect(calls).toHaveLength(1);
243
328
  const url = new URL(calls[0].url);
244
329
  expect(url.pathname).toBe('/api/podcasts/search');
245
330
  expect(url.searchParams.get('q')).toBe('news');
246
331
  expect(url.searchParams.get('limit')).toBe('5');
332
+ expect(url.searchParams.has('offset')).toBe(false);
247
333
  });
248
334
 
249
- it('omits the limit param when not provided', async () => {
335
+ it('sends the offset param and reports hasMore/offset from the backend', async () => {
336
+ const { fetch, calls } = fakeFetch(() => ({
337
+ body: { data: [makePodcast()], hasMore: true, limit: 10, offset: 20 },
338
+ }));
339
+
340
+ const client = createSyraClient({ baseURL: 'https://api.example.test', fetch });
341
+ const page = await client.searchPodcasts('news', { limit: 10, offset: 20 });
342
+
343
+ expect(page.hasMore).toBe(true);
344
+ expect(page.limit).toBe(10);
345
+ expect(page.offset).toBe(20);
346
+
347
+ const url = new URL(calls[0].url);
348
+ expect(url.searchParams.get('offset')).toBe('20');
349
+ });
350
+
351
+ it('omits the limit and offset params when not provided', async () => {
250
352
  const { fetch, calls } = fakeFetch(() => ({ body: { data: [makePodcast()] } }));
251
353
  const client = createSyraClient({ fetch });
252
354
  await client.searchPodcasts('news');
253
355
  const url = new URL(calls[0].url);
254
356
  expect(url.searchParams.has('limit')).toBe(false);
357
+ expect(url.searchParams.has('offset')).toBe(false);
255
358
  });
256
359
 
257
360
  it('drops malformed rows without throwing', async () => {
@@ -259,14 +362,17 @@ describe('createSyraClient.searchPodcasts', () => {
259
362
  body: { data: [{ id: 'broken' }, makePodcast()] },
260
363
  }));
261
364
  const client = createSyraClient({ fetch });
262
- const podcasts = await client.searchPodcasts('x');
263
- expect(podcasts).toHaveLength(1);
365
+ const page = await client.searchPodcasts('x');
366
+ expect(page.items).toHaveLength(1);
264
367
  });
265
368
 
266
- it('returns an empty array when data is absent', async () => {
369
+ it('returns an empty page with hasMore=false when data is absent', async () => {
267
370
  const { fetch } = fakeFetch(() => ({ body: {} }));
268
371
  const client = createSyraClient({ fetch });
269
- expect(await client.searchPodcasts('x')).toEqual([]);
372
+ const page = await client.searchPodcasts('x');
373
+ expect(page.items).toEqual([]);
374
+ expect(page.hasMore).toBe(false);
375
+ expect(page.offset).toBe(0);
270
376
  });
271
377
  });
272
378
 
@@ -362,3 +468,165 @@ describe('createSyraClient.podcastArtworkUrl', () => {
362
468
  expect(client.podcastArtworkUrl({ image: 'not-an-id' })).toBeUndefined();
363
469
  });
364
470
  });
471
+
472
+ // ── getPodcastEpisodes ──────────────────────────────────────────────────────────
473
+
474
+ describe('createSyraClient.getPodcastEpisodes', () => {
475
+ it('calls /api/podcasts/:id/episodes with page and limit and returns parsed episodes', async () => {
476
+ const { fetch, calls } = fakeFetch(() => ({
477
+ body: {
478
+ data: [
479
+ makeEpisode({ id: '507f1f77bcf86cd799439031' }),
480
+ makeEpisode({ id: '507f1f77bcf86cd799439033', title: 'Second Episode' }),
481
+ ],
482
+ total: 2,
483
+ page: 1,
484
+ limit: 20,
485
+ },
486
+ }));
487
+
488
+ const client = createSyraClient({ baseURL: 'https://api.example.test', fetch });
489
+ const page = await client.getPodcastEpisodes('507f1f77bcf86cd799439021', { limit: 20 });
490
+
491
+ expect(page.items).toHaveLength(2);
492
+ expect(page.items[0].id).toBe('507f1f77bcf86cd799439031');
493
+ expect(page.items[0].enclosureUrl).toBe('https://api.fastcast.ai/audio/guid-1.mp3');
494
+ expect(page.hasMore).toBe(false);
495
+ expect(page.limit).toBe(20);
496
+ expect(page.offset).toBe(0);
497
+
498
+ expect(calls).toHaveLength(1);
499
+ const url = new URL(calls[0].url);
500
+ expect(url.pathname).toBe('/api/podcasts/507f1f77bcf86cd799439021/episodes');
501
+ expect(url.searchParams.get('page')).toBe('1');
502
+ expect(url.searchParams.get('limit')).toBe('20');
503
+ });
504
+
505
+ it('translates a zero-based offset into a 1-based page and derives hasMore from total', async () => {
506
+ const { fetch, calls } = fakeFetch(() => ({
507
+ body: { data: [makeEpisode()], total: 45, page: 3, limit: 10 },
508
+ }));
509
+
510
+ const client = createSyraClient({ baseURL: 'https://api.example.test', fetch });
511
+ const page = await client.getPodcastEpisodes('507f1f77bcf86cd799439021', {
512
+ limit: 10,
513
+ offset: 20,
514
+ });
515
+
516
+ // offset 20 / limit 10 → page 3; 3 * 10 = 30 < 45 → more remain.
517
+ const url = new URL(calls[0].url);
518
+ expect(url.searchParams.get('page')).toBe('3');
519
+ expect(url.searchParams.get('limit')).toBe('10');
520
+ expect(page.offset).toBe(20);
521
+ expect(page.limit).toBe(10);
522
+ expect(page.hasMore).toBe(true);
523
+ });
524
+
525
+ it('reports hasMore=false when the page reaches the end of the total', async () => {
526
+ const { fetch } = fakeFetch(() => ({
527
+ body: { data: [makeEpisode()], total: 20, page: 2, limit: 10 },
528
+ }));
529
+ const client = createSyraClient({ fetch });
530
+ const page = await client.getPodcastEpisodes('507f1f77bcf86cd799439021', {
531
+ limit: 10,
532
+ offset: 10,
533
+ });
534
+ // page 2 * limit 10 = 20, not < total 20 → this is the last page.
535
+ expect(page.hasMore).toBe(false);
536
+ });
537
+
538
+ it('drops a row missing the required enclosureUrl without throwing', async () => {
539
+ const { fetch } = fakeFetch(() => ({
540
+ body: {
541
+ data: [makeEpisode({ enclosureUrl: undefined }), makeEpisode()],
542
+ total: 2,
543
+ },
544
+ }));
545
+ const client = createSyraClient({ fetch });
546
+ const page = await client.getPodcastEpisodes('507f1f77bcf86cd799439021', { limit: 10 });
547
+ expect(page.items).toHaveLength(1);
548
+ expect(page.items[0].enclosureUrl).toBe('https://api.fastcast.ai/audio/guid-1.mp3');
549
+ });
550
+
551
+ it('defaults the limit and returns an empty page when data is absent', async () => {
552
+ const { fetch, calls } = fakeFetch(() => ({ body: {} }));
553
+ const client = createSyraClient({ fetch });
554
+ const page = await client.getPodcastEpisodes('507f1f77bcf86cd799439021');
555
+ expect(page.items).toEqual([]);
556
+ expect(page.hasMore).toBe(false);
557
+ expect(page.offset).toBe(0);
558
+ expect(page.limit).toBe(20);
559
+ const url = new URL(calls[0].url);
560
+ expect(url.searchParams.get('page')).toBe('1');
561
+ expect(url.searchParams.get('limit')).toBe('20');
562
+ });
563
+ });
564
+
565
+ // ── getEpisode ──────────────────────────────────────────────────────────────────
566
+
567
+ describe('createSyraClient.getEpisode', () => {
568
+ it('fetches /api/episodes/:id and validates data.episode', async () => {
569
+ const { fetch, calls } = fakeFetch(() => ({
570
+ body: { data: { episode: makeEpisode(), persons: [] } },
571
+ }));
572
+ const client = createSyraClient({ baseURL: 'https://api.example.test', fetch });
573
+
574
+ const episode = await client.getEpisode('507f1f77bcf86cd799439031');
575
+ expect(episode.title).toBe('Test Episode');
576
+ expect(episode.enclosureUrl).toBe('https://api.fastcast.ai/audio/guid-1.mp3');
577
+ expect(calls[0].url).toBe('https://api.example.test/api/episodes/507f1f77bcf86cd799439031');
578
+ });
579
+
580
+ it('throws SyraApiError on a non-2xx response', async () => {
581
+ const { fetch } = fakeFetch(() => ({ status: 404, body: { error: 'not found' } }));
582
+ const client = createSyraClient({ fetch });
583
+ await expect(client.getEpisode('507f1f77bcf86cd799439031')).rejects.toBeInstanceOf(SyraApiError);
584
+ });
585
+
586
+ it('throws when data.episode fails schema validation', async () => {
587
+ const { fetch } = fakeFetch(() => ({ body: { data: { episode: { id: 'x' } } } }));
588
+ const client = createSyraClient({ fetch });
589
+ await expect(client.getEpisode('x')).rejects.toThrow();
590
+ });
591
+ });
592
+
593
+ // ── episodeImageUrl ──────────────────────────────────────────────────────────────
594
+
595
+ describe('createSyraClient.episodeImageUrl', () => {
596
+ const client = createSyraClient({ baseURL: 'https://api.example.test' });
597
+
598
+ it('resolves the re-hosted image id to an absolute images URL', () => {
599
+ expect(client.episodeImageUrl({ image: '507f1f77bcf86cd799439032' })).toBe(
600
+ 'https://api.example.test/api/images/507f1f77bcf86cd799439032',
601
+ );
602
+ });
603
+
604
+ it('prefers a named size from imageSizes', () => {
605
+ const url = client.episodeImageUrl(
606
+ {
607
+ image: '507f1f77bcf86cd799439032',
608
+ imageSizes: {
609
+ large: {
610
+ id: '507f1f77bcf86cd799439033',
611
+ url: '/api/images/507f1f77bcf86cd799439033',
612
+ width: 640,
613
+ height: 640,
614
+ },
615
+ },
616
+ },
617
+ 'large',
618
+ );
619
+ expect(url).toBe('https://api.example.test/api/images/507f1f77bcf86cd799439033');
620
+ });
621
+
622
+ it('falls back to imageSourceUrl when no Syra image is present', () => {
623
+ expect(client.episodeImageUrl({ imageSourceUrl: 'https://cdn.example.com/episode.jpg' })).toBe(
624
+ 'https://cdn.example.com/episode.jpg',
625
+ );
626
+ });
627
+
628
+ it('returns undefined when nothing resolvable is present', () => {
629
+ expect(client.episodeImageUrl({})).toBeUndefined();
630
+ expect(client.episodeImageUrl({ image: 'not-an-id' })).toBeUndefined();
631
+ });
632
+ });