Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 31 additions & 0 deletions .github/workflows/integration.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
name: Mock API integration

on:
push:
branches: [main]
pull_request:
branches: [main]

permissions:
contents: read

jobs:
integration:
name: Node and Workers against public mock API
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 24.x
- run: corepack enable
- name: Locate Yarn cache
id: yarn-cache
run: echo "dir=$(yarn config get cacheFolder)" >> "$GITHUB_OUTPUT"
- uses: actions/cache@v4
with:
path: ${{ steps.yarn-cache.outputs.dir }}
key: ${{ runner.os }}-node-24.x-yarn-${{ hashFiles('yarn.lock') }}
- run: yarn install --immutable
- run: yarn test-integration
21 changes: 20 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -868,7 +868,26 @@ yarn test-workers --runInBand
yarn lint
```

Tests use local mocked transports and do not call the API. Generated methods,
The default `yarn test`, `yarn test-node`, and `yarn test-workers` commands use
local mocked transports and do not call the API.

Run the live mock integration suite separately:

```sh
yarn test-integration
```

This runs 12 cases through each client: Node (Axios) and Workers (fetch in
Miniflare). They send real HTTP requests to
`https://listen-api-test.listennotes.com/api/v2` without an API key, covering
search, podcast/playlist reads, all five playlist write operations, response
headers, and a missing route. Requests are restricted to that mock URL, redirects
are disabled, and requests have a 15-second timeout. The mock returns fixed
responses; these tests do not verify persistence or production authorization.
CI runs this suite separately on Node.js 24, so a mock service outage can fail
the integration job while the offline jobs still pass.

Generated methods,
`src/api-contract.json`, and marked README sections are maintained by
`devtools/api-sdks/sync.py` in the Listen Notes monorepo. See its
`devtools/api-sdks/README.md` for synchronization and release instructions.
Expand Down
15 changes: 15 additions & 0 deletions jest.config.integration.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
{
"testTimeout": 20000,
"projects": [
{
"displayName": "node",
"testMatch": ["<rootDir>/tests/integration/*ForNodeTest.js"],
"testEnvironment": "node"
},
{
"displayName": "workers",
"testMatch": ["<rootDir>/tests/integration/*ForWorkersTest.js"],
"testEnvironment": "miniflare"
}
]
}
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,8 @@
"lint": "eslint .",
"test": "jest --config jest.config.node.json && jest --config jest.config.minifare.json",
"test-node": "jest --config jest.config.node.json",
"test-workers": "jest --config jest.config.minifare.json"
"test-workers": "jest --config jest.config.minifare.json",
"test-integration": "jest --config jest.config.integration.json --runInBand"
},
"repository": {
"type": "git",
Expand Down
40 changes: 40 additions & 0 deletions tests/integration/MockApiForNodeTest.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
/* global afterEach, AbortController, setTimeout, clearTimeout */
const { ClientForNode } = require('../../src/PodcastApiClient');
const { checkMockRequest, runIntegrationTests } = require('./MockApiTests');
const recorded = [];
const pending = [];

afterEach(() => {
for (const { controller, timer } of pending) {
clearTimeout(timer);
controller.abort();
}
pending.length = 0;
});

runIntegrationTests({
createClient: () => {
const client = ClientForNode();
const http = client.httpClient;
http.defaults.timeout = 15000;
http.defaults.maxRedirects = 0;
http.defaults.proxy = false;
http.interceptors.request.use((config) => {
const url = http.getUri(config);
checkMockRequest(url, config.headers);
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), 15000);
pending.push({ controller, timer });
config.signal = controller.signal;
recorded.push({ url, method: config.method.toUpperCase(), body: config.data });
return config;
});
http.interceptors.response.use((response) => {
recorded[recorded.length - 1].status = response.status;
return response;
});
return client;
},
reset: () => { recorded.length = 0; },
calls: () => recorded,
});
39 changes: 39 additions & 0 deletions tests/integration/MockApiForWorkersTest.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
/* global beforeAll, afterAll, afterEach, jest, AbortController, setTimeout, clearTimeout, getMiniflareFetchMock */
const { ClientForWorkers } = require('../../src/PodcastApiClient');
const { checkMockRequest, runIntegrationTests } = require('./MockApiTests');
const recorded = [];
const pending = [];

beforeAll(() => {
const realFetch = globalThis.fetch.bind(globalThis);
// Observe real fetch calls without replacing server responses with fixtures.
jest.spyOn(globalThis, 'fetch').mockImplementation(async (url, config) => {
checkMockRequest(url, config.headers);
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), 15000);
pending.push({ controller, timer });
const actual = { url, method: config.method, body: config.body };
recorded.push(actual);
const response = await realFetch(url, { ...config, redirect: 'manual', signal: controller.signal });
actual.status = response.status;
return response;
});
});

afterEach(() => {
for (const { controller, timer } of pending) {
clearTimeout(timer);
controller.abort();
}
pending.length = 0;
});
afterAll(async () => {
jest.restoreAllMocks();
await getMiniflareFetchMock().close();
});

runIntegrationTests({
createClient: () => ClientForWorkers(),
reset: () => { recorded.length = 0; },
calls: () => recorded,
});
134 changes: 134 additions & 0 deletions tests/integration/MockApiTests.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
/* global test, expect, beforeEach */

const MOCK_BASE = 'https://listen-api-test.listennotes.com/api/v2';
const PLAYLIST_ID = 'm1pe7z60bsw';
const ITEM_ID = 23;
const EPISODE_ID = 'e73b7e5695b44ab9b6c9ae6b7e0ac6e0';
const PODCAST_ID = '4d3fe717742d4963a85562e9f84d8c79';

// Check the destination before either transport opens a network connection.
const checkMockRequest = (url, headers) => {
expect(url.startsWith(`${MOCK_BASE}/`)).toBe(true);
const normalized = Object.fromEntries(Object.entries(headers).map(([key, value]) => [key.toLowerCase(), value]));
expect(normalized['x-listenapi-key']).toBeFalsy();
expect(normalized.authorization).toBeUndefined();
expect(normalized['user-agent']).toBe('podcast-api-js');
};

const assertPlaylist = (payload) => {
expect(typeof payload.id).toBe('string');
expect(payload.name).toBeTruthy();
expect(['episode_list', 'podcast_list']).toContain(payload.type);
expect(['public', 'unlisted', 'private']).toContain(payload.visibility);
expect(payload.listennotes_url).toMatch(/^https:\/\/www\.listennotes\.com\//);
};

const assertItem = (payload) => {
expect(Number.isInteger(payload.id)).toBe(true);
expect(typeof payload.notes).toBe('string');
expect(payload.data).toEqual(expect.any(Object));
expect(['episode', 'podcast']).toContain(payload.type);
expect(Number.isInteger(payload.added_at_ms)).toBe(true);
};

// Both SDK transports make real requests. The mock's responses are stateless.
const runIntegrationTests = ({ createClient, calls, reset }) => {
let client;
beforeEach(() => {
reset();
client = createClient();
});

const responseData = (response, method, path, status = 200) => {
expect(calls()).toHaveLength(1);
const actual = calls()[0];
expect(actual.status).toBe(status);
expect(actual.method).toBe(method);
expect(new URL(actual.url).pathname).toBe(`/api/v2${path}`);
expect(response.config.method).toBe(method.toLowerCase());
expect(response.config.url).toBe(path);
expect(response.headers['content-type']).toMatch(/^application\/json/);
expect(Number(response.headers['x-listenapi-usage'])).toBeGreaterThanOrEqual(0);
expect(Number(response.headers['x-listenapi-freequota'])).toBeGreaterThan(0);
expect(Number(response.headers['x-listenapi-latency-seconds'])).toBeGreaterThanOrEqual(0);
expect(response.headers['x-listenapi-nextbillingdate']).toBeTruthy();
expect(response.data).toEqual(expect.any(Object));
return response.data;
};

const formFields = () => Object.fromEntries(new URLSearchParams(calls()[0].body));

test('search handles query encoding and returns results', async () => {
const q = 'science & café';
const response = await client.search({ q, sort_by_date: 1 });
const payload = responseData(response, 'GET', '/search');
expect(payload.results.length).toBeGreaterThan(0);
expect(Object.fromEntries(new URL(calls()[0].url).searchParams)).toEqual({ q, sort_by_date: '1' });
});

test('fetch a podcast by its path identifier', async () => {
const response = await client.fetchPodcastById({ id: PODCAST_ID });
const payload = responseData(response, 'GET', `/podcasts/${PODCAST_ID}`);
expect(payload.id).toBeTruthy();
expect(Array.isArray(payload.episodes)).toBe(true);
});

test('list playlists', async () => {
const response = await client.fetchMyPlaylists();
const payload = responseData(response, 'GET', '/playlists');
expect(payload.playlists.length).toBeGreaterThan(0);
assertPlaylist(payload.playlists[0]);
});

test('fetch a playlist with a query parameter', async () => {
const response = await client.fetchPlaylistById({ id: PLAYLIST_ID, type: 'episode_list' });
const payload = responseData(response, 'GET', `/playlists/${PLAYLIST_ID}`);
assertPlaylist(payload);
expect(Array.isArray(payload.items)).toBe(true);
expect(Object.fromEntries(new URL(calls()[0].url).searchParams)).toEqual({ type: 'episode_list' });
});

test('create a playlist with an empty description', async () => {
const params = { name: 'JavaScript SDK integration', description: '', visibility: 'private' };
const response = await client.createPlaylist(params);
assertPlaylist(responseData(response, 'POST', '/playlists', 201));
expect(formFields()).toEqual(params);
});

test('update playlist metadata', async () => {
const response = await client.updatePlaylist({ id: PLAYLIST_ID, description: '', type: 'podcast_list' });
assertPlaylist(responseData(response, 'PUT', `/playlists/${PLAYLIST_ID}`));
expect(formFields()).toEqual({ description: '', type: 'podcast_list' });
});

test.each([
['episode', { episode_id: EPISODE_ID }],
['podcast', { podcast_id: PODCAST_ID }],
])('add a %s to a playlist', async (_, content) => {
const response = await client.addPlaylistItem({ id: PLAYLIST_ID, ...content, notes: 'hello & café' });
assertItem(responseData(response, 'POST', `/playlists/${PLAYLIST_ID}/items`, 201));
expect(formFields()).toEqual({ ...content, notes: 'hello & café' });
});

test.each(['hello & café', ''])('update item notes to %p', async (notes) => {
const response = await client.updatePlaylistItemNotes({ id: PLAYLIST_ID, item_id: ITEM_ID, notes });
assertItem(responseData(response, 'PUT', `/playlists/${PLAYLIST_ID}/items/${ITEM_ID}`));
expect(formFields()).toEqual({ notes });
});

test('delete a playlist item', async () => {
const response = await client.deletePlaylistItem({ id: PLAYLIST_ID, item_id: ITEM_ID });
const payload = responseData(response, 'DELETE', `/playlists/${PLAYLIST_ID}/items/${ITEM_ID}`);
expect(payload.deleted).toBe(true);
expect(Number.isInteger(payload.id)).toBe(true);
expect(calls()[0].body).toBeFalsy();
});

test('missing routes preserve the HTTP 404 error', async () => {
await expect(client.httpClient._get('/sdk-integration-missing-route', {})).rejects.toMatchObject({
response: { status: 404 },
});
});
};

module.exports = { checkMockRequest, runIntegrationTests };
Loading