pubmed-api.test.ts•17.9 kB
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { createPubMedAPI, type PubMedAPI, type PubMedOptions } from '../pubmed-api.js';
// Mock fetch globally
global.fetch = vi.fn();
const mockFetch = global.fetch as any;
describe('PubMed API', () => {
let api: PubMedAPI;
const mockOptions: PubMedOptions = {
email: 'test@example.com',
apiKey: 'test-api-key'
};
beforeEach(() => {
vi.clearAllMocks();
api = createPubMedAPI(mockOptions);
});
describe('createPubMedAPI', () => {
it('should create API instance with email only', () => {
const apiWithoutKey = createPubMedAPI({ email: 'test@example.com' });
expect(apiWithoutKey).toBeDefined();
expect(apiWithoutKey.search).toBeInstanceOf(Function);
expect(apiWithoutKey.fetchArticles).toBeInstanceOf(Function);
expect(apiWithoutKey.searchAndFetch).toBeInstanceOf(Function);
expect(apiWithoutKey.checkFullTextAvailability).toBeInstanceOf(Function);
expect(apiWithoutKey.getFullText).toBeInstanceOf(Function);
});
it('should create API instance with email and API key', () => {
expect(api).toBeDefined();
expect(api.search).toBeInstanceOf(Function);
expect(api.fetchArticles).toBeInstanceOf(Function);
expect(api.searchAndFetch).toBeInstanceOf(Function);
expect(api.checkFullTextAvailability).toBeInstanceOf(Function);
expect(api.getFullText).toBeInstanceOf(Function);
});
});
describe('search', () => {
it('should perform basic search and return results', async () => {
const mockSearchResponse = `<?xml version="1.0" encoding="UTF-8"?>
<eSearchResult>
<Count>2</Count>
<IdList>
<Id>12345678</Id>
<Id>87654321</Id>
</IdList>
</eSearchResult>`;
mockFetch.mockResolvedValueOnce({
ok: true,
text: () => Promise.resolve(mockSearchResponse)
});
const result = await api.search('covid-19');
expect(mockFetch).toHaveBeenCalledWith(
expect.stringContaining('esearch.fcgi')
);
expect(mockFetch).toHaveBeenCalledWith(
expect.stringContaining('email=test%40example.com')
);
expect(mockFetch).toHaveBeenCalledWith(
expect.stringContaining('api_key=test-api-key')
);
expect(mockFetch).toHaveBeenCalledWith(
expect.stringContaining('term=covid-19')
);
expect(result).toEqual({
idList: ['12345678', '87654321'],
count: 2,
retMax: 20,
retStart: 0
});
});
it('should handle search options correctly', async () => {
const mockSearchResponse = `<?xml version="1.0" encoding="UTF-8"?>
<eSearchResult>
<Count>5</Count>
<IdList>
<Id>11111111</Id>
<Id>22222222</Id>
<Id>33333333</Id>
<Id>44444444</Id>
<Id>55555555</Id>
</IdList>
</eSearchResult>`;
mockFetch.mockResolvedValueOnce({
ok: true,
text: () => Promise.resolve(mockSearchResponse)
});
const result = await api.search('machine learning', {
retMax: 5,
retStart: 10,
sort: 'pub_date',
dateFrom: '2023/01/01',
dateTo: '2023/12/31'
});
expect(mockFetch).toHaveBeenCalledWith(
expect.stringContaining('retmax=5')
);
expect(mockFetch).toHaveBeenCalledWith(
expect.stringContaining('retstart=10')
);
expect(mockFetch).toHaveBeenCalledWith(
expect.stringContaining('sort=pub_date')
);
expect(mockFetch).toHaveBeenCalledWith(
expect.stringContaining('machine+learning+AND+%28%222023%2F01%2F01%22%5BDate+-+Publication%5D+%3A+%222023%2F12%2F31%22%5BDate+-+Publication%5D%29')
);
expect(result.idList).toHaveLength(5);
expect(result.count).toBe(5);
});
it('should handle empty search results', async () => {
const mockSearchResponse = `<?xml version="1.0" encoding="UTF-8"?>
<eSearchResult>
<Count>0</Count>
<IdList></IdList>
</eSearchResult>`;
mockFetch.mockResolvedValueOnce({
ok: true,
text: () => Promise.resolve(mockSearchResponse)
});
const result = await api.search('nonexistent query');
expect(result).toEqual({
idList: [],
count: 0,
retMax: 20,
retStart: 0
});
});
it('should handle HTTP errors', async () => {
mockFetch.mockResolvedValueOnce({
ok: false,
status: 500
});
await expect(api.search('test query')).rejects.toThrow('HTTP error! status: 500');
});
});
describe('fetchArticles', () => {
it('should fetch article details', async () => {
const mockFetchResponse = `<?xml version="1.0" encoding="UTF-8"?>
<PubmedArticleSet>
<PubmedArticle>
<MedlineCitation>
<PMID>12345678</PMID>
<Article>
<ArticleTitle>Test Article Title</ArticleTitle>
<Abstract>
<AbstractText>This is a test abstract.</AbstractText>
</Abstract>
<AuthorList>
<Author>
<LastName>Smith</LastName>
<ForeName>John</ForeName>
</Author>
<Author>
<LastName>Doe</LastName>
<ForeName>Jane</ForeName>
</Author>
</AuthorList>
<Journal>
<Title>Test Journal</Title>
<JournalIssue>
<PubDate>
<Year>2023</Year>
<Month>Jan</Month>
<Day>15</Day>
</PubDate>
</JournalIssue>
</Journal>
</Article>
<ELocationID EIdType="doi">10.1234/test.doi</ELocationID>
</MedlineCitation>
<PubmedData>
<ArticleIdList>
<ArticleId IdType="pmc">PMC123456</ArticleId>
</ArticleIdList>
</PubmedData>
</PubmedArticle>
</PubmedArticleSet>`;
mockFetch.mockResolvedValueOnce({
ok: true,
text: () => Promise.resolve(mockFetchResponse)
});
const result = await api.fetchArticles(['12345678']);
expect(mockFetch).toHaveBeenCalledWith(
expect.stringContaining('efetch.fcgi')
);
expect(mockFetch).toHaveBeenCalledWith(
expect.stringContaining('id=12345678')
);
expect(result).toHaveLength(1);
expect(result[0]).toEqual({
pmid: '12345678',
title: 'Test Article Title',
authors: ['Smith, John', 'Doe, Jane'],
abstract: 'This is a test abstract.',
journal: 'Test Journal',
pubDate: '2023-Jan-15',
doi: '10.1234/test.doi',
pmcId: 'PMC123456'
});
});
it('should handle empty PMID list', async () => {
const result = await api.fetchArticles([]);
expect(result).toEqual([]);
expect(mockFetch).not.toHaveBeenCalled();
});
it('should handle multiple articles', async () => {
const mockFetchResponse = `<?xml version="1.0" encoding="UTF-8"?>
<PubmedArticleSet>
<PubmedArticle>
<MedlineCitation>
<PMID>11111111</PMID>
<Article>
<ArticleTitle>First Article</ArticleTitle>
<Journal><Title>Journal One</Title></Journal>
</Article>
</MedlineCitation>
</PubmedArticle>
<PubmedArticle>
<MedlineCitation>
<PMID>22222222</PMID>
<Article>
<ArticleTitle>Second Article</ArticleTitle>
<Journal><Title>Journal Two</Title></Journal>
</Article>
</MedlineCitation>
</PubmedArticle>
</PubmedArticleSet>`;
mockFetch.mockResolvedValueOnce({
ok: true,
text: () => Promise.resolve(mockFetchResponse)
});
const result = await api.fetchArticles(['11111111', '22222222']);
expect(result).toHaveLength(2);
expect(result[0].pmid).toBe('11111111');
expect(result[0].title).toBe('First Article');
expect(result[1].pmid).toBe('22222222');
expect(result[1].title).toBe('Second Article');
});
});
describe('searchAndFetch', () => {
it('should search and fetch articles in one call', async () => {
const mockSearchResponse = `<?xml version="1.0" encoding="UTF-8"?>
<eSearchResult>
<Count>1</Count>
<IdList>
<Id>12345678</Id>
</IdList>
</eSearchResult>`;
const mockFetchResponse = `<?xml version="1.0" encoding="UTF-8"?>
<PubmedArticleSet>
<PubmedArticle>
<MedlineCitation>
<PMID>12345678</PMID>
<Article>
<ArticleTitle>Combined Search and Fetch Test</ArticleTitle>
<Journal><Title>Test Journal</Title></Journal>
</Article>
</MedlineCitation>
</PubmedArticle>
</PubmedArticleSet>`;
mockFetch
.mockResolvedValueOnce({
ok: true,
text: () => Promise.resolve(mockSearchResponse)
})
.mockResolvedValueOnce({
ok: true,
text: () => Promise.resolve(mockFetchResponse)
});
const result = await api.searchAndFetch('test query', { maxResults: 5 });
expect(mockFetch).toHaveBeenCalledTimes(2);
expect(result).toHaveLength(1);
expect(result[0].pmid).toBe('12345678');
expect(result[0].title).toBe('Combined Search and Fetch Test');
});
});
describe('checkFullTextAvailability', () => {
it('should check if full text is available and return PMC ID', async () => {
const mockElinkResponse = `<?xml version="1.0" encoding="UTF-8"?>
<eLinkResult>
<LinkSet>
<LinkSetDb>
<DbTo>pmc</DbTo>
<LinkName>pubmed_pmc</LinkName>
<Link>
<Id>12345</Id>
</Link>
</LinkSetDb>
</LinkSet>
</eLinkResult>`;
mockFetch.mockResolvedValueOnce({
ok: true,
text: () => Promise.resolve(mockElinkResponse)
});
const result = await api.checkFullTextAvailability('33333333');
expect(mockFetch).toHaveBeenCalledWith(
expect.stringContaining('elink.fcgi')
);
expect(mockFetch).toHaveBeenCalledWith(
expect.stringContaining('dbfrom=pubmed')
);
expect(mockFetch).toHaveBeenCalledWith(
expect.stringContaining('db=pmc')
);
expect(mockFetch).toHaveBeenCalledWith(
expect.stringContaining('id=33333333')
);
expect(mockFetch).toHaveBeenCalledWith(
expect.stringContaining('linkname=pubmed_pmc')
);
expect(result).toEqual({
hasFullText: true,
pmcId: '12345'
});
});
it('should return false when no full text is available', async () => {
const mockElinkResponse = `<?xml version="1.0" encoding="UTF-8"?>
<eLinkResult>
<LinkSet>
</LinkSet>
</eLinkResult>`;
mockFetch.mockResolvedValueOnce({
ok: true,
text: () => Promise.resolve(mockElinkResponse)
});
const result = await api.checkFullTextAvailability('44444444');
expect(result).toEqual({
hasFullText: false
});
});
it('should handle elink API errors gracefully', async () => {
mockFetch.mockResolvedValueOnce({
ok: false,
status: 500
});
const result = await api.checkFullTextAvailability('55555555');
expect(result).toEqual({
hasFullText: false
});
});
});
describe('getFullText', () => {
it('should fetch full text when available', async () => {
const mockElinkResponse = `<?xml version="1.0" encoding="UTF-8"?>
<eLinkResult>
<LinkSet>
<LinkSetDb>
<Link>
<Id>12345</Id>
</Link>
</LinkSetDb>
</LinkSet>
</eLinkResult>`;
const mockPmcResponse = `<?xml version="1.0" encoding="UTF-8"?>
<pmc_articleset>
<article>
<front>
<article-meta>
<title-group>
<article-title>Test Full Text Article</article-title>
</title-group>
<abstract>
<p>This is the abstract of the test article.</p>
</abstract>
</article-meta>
</front>
<body>
<sec>
<title>Introduction</title>
<p>This is the introduction section.</p>
</sec>
<sec>
<title>Methods</title>
<p>This is the methods section.</p>
</sec>
</body>
</article>
</pmc_articleset>`;
mockFetch
.mockResolvedValueOnce({
ok: true,
text: () => Promise.resolve(mockElinkResponse)
})
.mockResolvedValueOnce({
ok: true,
text: () => Promise.resolve(mockPmcResponse)
});
const results = await api.getFullText(['66666666']);
const result = results[0];
expect(mockFetch).toHaveBeenCalledTimes(2); // elink call + efetch call for PMC content
expect(mockFetch).toHaveBeenCalledWith(
expect.stringContaining('elink.fcgi')
);
// Only elink call is made in current implementation
expect(result.pmid).toBe('66666666');
// Full text should be successfully extracted with the improved implementation
expect(result.fullText).toContain('Test Full Text Article');
expect(result.fullText).toContain('Introduction');
expect(result.fullText).toContain('Methods');
});
it('should return null when full text is not available', async () => {
const mockElinkResponse = `<?xml version="1.0" encoding="UTF-8"?>
<eLinkResult>
<LinkSet>
</LinkSet>
</eLinkResult>`;
mockFetch.mockResolvedValueOnce({
ok: true,
text: () => Promise.resolve(mockElinkResponse)
});
const results = await api.getFullText(['77777777']);
const result = results[0];
expect(mockFetch).toHaveBeenCalledTimes(1);
expect(result.pmid).toBe('77777777');
expect(result.fullText).toBeNull();
});
it('should return null when PMC article structure is invalid', async () => {
const mockElinkResponse = `<?xml version="1.0" encoding="UTF-8"?>
<eLinkResult>
<LinkSet>
<LinkSetDb>
<Link>
<Id>12345</Id>
</Link>
</LinkSetDb>
</LinkSet>
</eLinkResult>`;
const mockPmcResponse = `<?xml version="1.0" encoding="UTF-8"?>
<pmc_articleset>
<invalid_structure>
</invalid_structure>
</pmc_articleset>`;
mockFetch
.mockResolvedValueOnce({
ok: true,
text: () => Promise.resolve(mockElinkResponse)
})
.mockResolvedValueOnce({
ok: true,
text: () => Promise.resolve(mockPmcResponse)
});
const results = await api.getFullText(['88888888']);
const result = results[0];
expect(result.pmid).toBe('88888888');
expect(result.fullText).toBeNull();
});
it('should handle PMC API errors gracefully', async () => {
const mockElinkResponse = `<?xml version="1.0" encoding="UTF-8"?>
<eLinkResult>
<LinkSet>
<LinkSetDb>
<Link>
<Id>12345</Id>
</Link>
</LinkSetDb>
</LinkSet>
</eLinkResult>`;
mockFetch
.mockResolvedValueOnce({
ok: true,
text: () => Promise.resolve(mockElinkResponse)
})
.mockResolvedValueOnce({
ok: false,
status: 500
});
const results = await api.getFullText(['99999999']);
const result = results[0];
expect(result.pmid).toBe('99999999');
expect(result.fullText).toBeNull();
});
});
describe('rate limiting', () => {
it('should apply rate limiting for requests without API key', async () => {
const apiWithoutKey = createPubMedAPI({ email: 'test@example.com' });
const mockResponse = `<?xml version="1.0" encoding="UTF-8"?>
<eSearchResult><Count>0</Count><IdList></IdList></eSearchResult>`;
mockFetch.mockResolvedValue({
ok: true,
text: () => Promise.resolve(mockResponse)
});
const startTime = Date.now();
await apiWithoutKey.search('test1');
await apiWithoutKey.search('test2');
const endTime = Date.now();
// Should take at least 334ms for the second request (rate limiting)
expect(endTime - startTime).toBeGreaterThan(300);
});
it('should apply faster rate limiting with API key', async () => {
const mockResponse = `<?xml version="1.0" encoding="UTF-8"?>
<eSearchResult><Count>0</Count><IdList></IdList></eSearchResult>`;
mockFetch.mockResolvedValue({
ok: true,
text: () => Promise.resolve(mockResponse)
});
const startTime = Date.now();
await api.search('test1');
await api.search('test2');
const endTime = Date.now();
// Should take at least 100ms for the second request (faster rate limiting with API key)
expect(endTime - startTime).toBeGreaterThan(90);
expect(endTime - startTime).toBeLessThan(300); // But faster than without API key
});
});
});