diff --git a/.github/workflows/integration.yml b/.github/workflows/integration.yml new file mode 100644 index 0000000..06714e6 --- /dev/null +++ b/.github/workflows/integration.yml @@ -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 diff --git a/README.md b/README.md index 5510e6f..38d5d57 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/jest.config.integration.json b/jest.config.integration.json new file mode 100644 index 0000000..05ccef4 --- /dev/null +++ b/jest.config.integration.json @@ -0,0 +1,15 @@ +{ + "testTimeout": 20000, + "projects": [ + { + "displayName": "node", + "testMatch": ["/tests/integration/*ForNodeTest.js"], + "testEnvironment": "node" + }, + { + "displayName": "workers", + "testMatch": ["/tests/integration/*ForWorkersTest.js"], + "testEnvironment": "miniflare" + } + ] +} diff --git a/package.json b/package.json index d728f52..5d3f03f 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/tests/integration/MockApiForNodeTest.js b/tests/integration/MockApiForNodeTest.js new file mode 100644 index 0000000..ea02d01 --- /dev/null +++ b/tests/integration/MockApiForNodeTest.js @@ -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, +}); diff --git a/tests/integration/MockApiForWorkersTest.js b/tests/integration/MockApiForWorkersTest.js new file mode 100644 index 0000000..f1351bd --- /dev/null +++ b/tests/integration/MockApiForWorkersTest.js @@ -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, +}); diff --git a/tests/integration/MockApiTests.js b/tests/integration/MockApiTests.js new file mode 100644 index 0000000..5fdeea4 --- /dev/null +++ b/tests/integration/MockApiTests.js @@ -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 };