import { describe, expect, it, vi } from 'vitest'; import { type Cache, MemoryCache, NoopCache, SocialhoseClient, SocialhoseError, type Mention } from '../src/index'; const ok = (body: unknown, status = 200) => new Response(JSON.stringify(body), { status, headers: { 'content-type': 'application/json' } }); const paginated = (results: unknown[], count?: number) => ok({ count: count ?? results.length, next: null, previous: null, results }); function makeMention(overrides: Partial = {}): Mention { return { id: 'mid', platform: 'twitter', campaign_id: 'cid', campaign_name: 'test', classification: 'news', content: 'hello', title: null, url: 'https://example.com', sentiment: 'positive', sentiment_score: 0.8, engagement_count: 10, likes: 5, shares: 3, comments: 2, views_count: 100, has_media: false, hashtags: [], keywords_matched: [], language: 'en', country: null, published_at: '2026-05-29T00:00:00Z', author: { name: 'Alice', handle: '@alice', avatar_url: null, url: null, followers: 100, verified: false }, ...overrides, }; } // ---------- existing tests ---------- describe('SocialhoseClient', () => { it('sends Api-Key auth, browser-like user-agent, and query params', async () => { const fetchMock = vi.fn(async () => ok({ count: 0, next: null, previous: null, results: [] })); const client = new SocialhoseClient({ apiKey: 'test-key', fetch: fetchMock, cacheTtlMs: 0 }); await client.getMentions({ page: 2, platforms: 'twitter', content_search: 'hospital' }); expect(fetchMock).toHaveBeenCalledTimes(1); const [url, init] = fetchMock.mock.calls[0] as unknown as [string, RequestInit]; expect(url).toBe( 'https://socialhose.net/api/public/v1/mentions/?page=2&platforms=twitter&content_search=hospital', ); expect(init.headers).toMatchObject({ Authorization: 'Api-Key test-key', Accept: 'application/json', }); expect((init.headers as Record)['User-Agent']).toContain('Mozilla/5.0'); }); it('caches identical GET requests inside the configured TTL', async () => { const fetchMock = vi.fn(async () => ok({ count: 1, next: null, previous: null, results: [] })); const client = new SocialhoseClient({ apiKey: 'test-key', fetch: fetchMock, cacheTtlMs: 60_000 }); await client.getMentions({ page: 1 }); await client.getMentions({ page: 1 }); expect(fetchMock).toHaveBeenCalledTimes(1); }); it('retries rate limits and transient server failures', async () => { const fetchMock = vi .fn() .mockResolvedValueOnce(new Response('rate limited', { status: 429 })) .mockResolvedValueOnce(new Response('bad gateway', { status: 502 })) .mockResolvedValueOnce(ok({ results: [] })); const client = new SocialhoseClient({ apiKey: 'test-key', fetch: fetchMock, cacheTtlMs: 0, retryDelayMs: () => 0, }); const campaigns = await client.getCampaigns(); expect(campaigns).toEqual([]); expect(fetchMock).toHaveBeenCalledTimes(3); }); it('throws a structured SocialhoseError for non-ok responses', async () => { const fetchMock = vi.fn(async () => new Response('{"detail":"forbidden"}', { status: 403 })); const client = new SocialhoseClient({ apiKey: 'test-key', fetch: fetchMock, cacheTtlMs: 0 }); await expect(client.getCampaign('abc')).rejects.toMatchObject({ name: 'SocialhoseError', status: 403, path: '/campaigns/abc/', } satisfies Partial); }); it('normalizes mailing-list invite outcomes', async () => { const fetchMock = vi.fn(async () => ok({ detail: 'already invited' }, 409)); const client = new SocialhoseClient({ apiKey: 'test-key', fetch: fetchMock }); await expect(client.inviteMailingListMember('list-1', { email: 'a@example.com' })).resolves.toEqual({ outcome: 'already', detail: 'already invited', }); }); it('defers missing apiKey errors until the first request', async () => { const client = new SocialhoseClient({ apiKey: '', fetch: vi.fn() }); await expect(client.getCampaigns()).rejects.toThrow('Socialhose apiKey is required'); }); }); // ---------- MemoryCache ---------- describe('MemoryCache', () => { it('returns undefined for missing key', async () => { const cache = new MemoryCache(); expect(await cache.get('k')).toBeUndefined(); }); it('returns stored value within TTL', async () => { const cache = new MemoryCache(); await cache.set('k', { data: 42 }, 60_000); expect(await cache.get('k')).toEqual({ data: 42 }); }); it('returns undefined after TTL expires', async () => { const cache = new MemoryCache(); await cache.set('k', 'v', 1); // 1 ms TTL await new Promise((r) => setTimeout(r, 10)); expect(await cache.get('k')).toBeUndefined(); }); it('deletes a stored entry', async () => { const cache = new MemoryCache(); await cache.set('k', 'v', 60_000); await cache.delete('k'); expect(await cache.get('k')).toBeUndefined(); }); }); // ---------- NoopCache ---------- describe('NoopCache', () => { it('always returns undefined regardless of what was set', async () => { const cache = new NoopCache(); await cache.set('k', 'v', 60_000); expect(await cache.get('k')).toBeUndefined(); }); }); // ---------- injectable cache ---------- describe('injectable cache', () => { it('uses provided cache instead of internal map', async () => { const mockCache: Cache = { get: vi.fn().mockResolvedValue(undefined), set: vi.fn().mockResolvedValue(undefined), delete: vi.fn().mockResolvedValue(undefined), }; const fetchMock = vi.fn(async () => paginated([])); const client = new SocialhoseClient({ apiKey: 'key', fetch: fetchMock, cache: mockCache }); await client.getMentions({}); expect(mockCache.get).toHaveBeenCalled(); expect(mockCache.set).toHaveBeenCalled(); expect(fetchMock).toHaveBeenCalledTimes(1); }); it('returns cached value from injected cache without fetching', async () => { const cached = { count: 99, next: null, previous: null, results: [] }; const mockCache: Cache = { get: vi.fn().mockResolvedValue(cached), set: vi.fn().mockResolvedValue(undefined), delete: vi.fn().mockResolvedValue(undefined), }; const fetchMock = vi.fn(); const client = new SocialhoseClient({ apiKey: 'key', fetch: fetchMock, cache: mockCache }); const result = await client.getMentions({}); expect(result).toEqual(cached); expect(fetchMock).not.toHaveBeenCalled(); }); }); // ---------- getEntityBrief ---------- describe('getEntityBrief', () => { it('derives sentiment and platform mix from sample', async () => { const results = [ makeMention({ sentiment: 'positive', platform: 'twitter' }), makeMention({ sentiment: 'negative', platform: 'reddit' }), makeMention({ sentiment: 'positive', platform: 'twitter' }), ]; const fetchMock = vi.fn(async () => paginated(results, 3)); const client = new SocialhoseClient({ apiKey: 'key', fetch: fetchMock, cacheTtlMs: 0 }); const brief = await client.getEntityBrief('ozempic'); expect(brief.term).toBe('ozempic'); expect(brief.total).toBe(3); expect(brief.exact).toBe(true); // count (3) <= sample.length (3) expect(brief.sentiment).toEqual({ positive: 2, negative: 1, neutral: 0 }); expect(brief.platformMix[0]).toEqual({ platform: 'twitter', count: 2 }); expect(brief.platformMix[1]).toEqual({ platform: 'reddit', count: 1 }); expect(brief.sample).toHaveLength(3); }); it('marks exact=false when total exceeds sample length', async () => { const results = [makeMention()]; const fetchMock = vi.fn(async () => paginated(results, 500)); const client = new SocialhoseClient({ apiKey: 'key', fetch: fetchMock, cacheTtlMs: 0 }); const brief = await client.getEntityBrief('hospital'); expect(brief.total).toBe(500); expect(brief.exact).toBe(false); }); }); // ---------- getEntityStats best-effort ---------- describe('getEntityStats', () => { it('sub-request failures do not zero out the entity', async () => { const briefResults = [makeMention({ sentiment: 'positive', platform: 'twitter' })]; let callCount = 0; const fetchMock = vi.fn(async () => { callCount++; if (callCount === 1) return paginated(briefResults, 1); return new Response('error', { status: 502 }); }); const client = new SocialhoseClient({ apiKey: 'key', fetch: fetchMock, cacheTtlMs: 0, retries: 0, }); const stats = await client.getEntityStats('ozempic'); // Brief data is always present expect(stats.term).toBe('ozempic'); expect(stats.total).toBe(1); // Falls back to brief's sample sentiment expect(stats.sentiment).toEqual({ positive: 1, negative: 0, neutral: 0 }); // Sub-requests failed: recent is empty, sparkline is empty expect(stats.recent).toEqual([]); expect(stats.sparkline).toEqual([]); expect(stats.momentumPct).toBeNull(); }); it('uses exact sentiment when facets reconcile with total', async () => { const briefResults = [ makeMention({ sentiment: 'positive' }), makeMention({ sentiment: 'negative' }), ]; const fetchMock = vi.fn(async (url) => { if ((url as string).includes('ordering=-engagement_count')) return paginated(briefResults, 2); if ((url as string).includes('ordering=-published_at')) return paginated([], 2); if ((url as string).includes('date_from=')) return paginated([], 0); if ((url as string).includes('sentiments=positive')) return paginated([], 1); if ((url as string).includes('sentiments=negative')) return paginated([], 1); if ((url as string).includes('sentiments=neutral')) return paginated([], 0); if ((url as string).includes('platforms=')) return paginated([], 0); return paginated([], 0); }); const client = new SocialhoseClient({ apiKey: 'key', fetch: fetchMock, cacheTtlMs: 0, retries: 0 }); const stats = await client.getEntityStats('flu'); // Exact split: 1+1+0=2 == total(2) → use exact expect(stats.sentiment).toEqual({ positive: 1, negative: 1, neutral: 0 }); expect(stats.exact).toBe(true); }); it('falls back to brief sentiment when facets do not reconcile', async () => { const briefResults = [ makeMention({ sentiment: 'positive' }), makeMention({ sentiment: 'positive' }), ]; const fetchMock = vi.fn(async (url) => { if ((url as string).includes('ordering=-engagement_count')) return paginated(briefResults, 2); if ((url as string).includes('ordering=-published_at')) return paginated([], 2); if ((url as string).includes('date_from=')) return paginated([], 0); // Facets sum to 5, total is 2 → mismatch if ((url as string).includes('sentiments=positive')) return paginated([], 3); if ((url as string).includes('sentiments=negative')) return paginated([], 1); if ((url as string).includes('sentiments=neutral')) return paginated([], 1); if ((url as string).includes('platforms=')) return paginated([], 0); return paginated([], 0); }); const client = new SocialhoseClient({ apiKey: 'key', fetch: fetchMock, cacheTtlMs: 0, retries: 0 }); const stats = await client.getEntityStats('flu'); // Facets don't reconcile → fall back to brief's sample sentiment expect(stats.sentiment).toEqual({ positive: 2, negative: 0, neutral: 0 }); }); }); // ---------- exact sentiment reconciliation ---------- describe('getExactSentiment (via private access)', () => { it('returns null when facets sum exceeds total', async () => { const fetchMock = vi.fn() .mockResolvedValueOnce(paginated([], 6)) // positive .mockResolvedValueOnce(paginated([], 3)) // negative .mockResolvedValueOnce(paginated([], 4)); // neutral — sum=13 ≠ 10 const client = new SocialhoseClient({ apiKey: 'key', fetch: fetchMock, cacheTtlMs: 0, retries: 0 }); // eslint-disable-next-line @typescript-eslint/no-explicit-any const result = await (client as any).getExactSentiment('term', undefined, 10); expect(result).toBeNull(); }); it('returns the split when facets sum exactly equals total', async () => { const fetchMock = vi.fn() .mockResolvedValueOnce(paginated([], 4)) // positive .mockResolvedValueOnce(paginated([], 3)) // negative .mockResolvedValueOnce(paginated([], 3)); // neutral — sum=10 == 10 const client = new SocialhoseClient({ apiKey: 'key', fetch: fetchMock, cacheTtlMs: 0, retries: 0 }); // eslint-disable-next-line @typescript-eslint/no-explicit-any const result = await (client as any).getExactSentiment('term', undefined, 10); expect(result).toEqual({ positive: 4, negative: 3, neutral: 3 }); }); it('returns null when a facet request fails', async () => { const fetchMock = vi.fn() .mockResolvedValueOnce(paginated([], 4)) .mockResolvedValueOnce(new Response('error', { status: 500 })) .mockResolvedValueOnce(paginated([], 3)); const client = new SocialhoseClient({ apiKey: 'key', fetch: fetchMock, cacheTtlMs: 0, retries: 0 }); // eslint-disable-next-line @typescript-eslint/no-explicit-any const result = await (client as any).getExactSentiment('term', undefined, 10); expect(result).toBeNull(); }); }); // ---------- cumulative timeline differencing ---------- describe('getEntityTimeline (via private access)', () => { it('produces correct daily counts via cumulative differencing', async () => { // days=3 → offsets = [-2, -1, 0, 1], 4 boundary requests const today = new Date(); const computeDay = (offset: number) => { const d = new Date(today); d.setUTCDate(d.getUTCDate() + offset); return d.toISOString().slice(0, 10); }; const cumCounts: Record = { [computeDay(-2)]: 30, [computeDay(-1)]: 20, [computeDay(0)]: 5, [computeDay(1)]: 0, }; const fetchMock = vi.fn(async (url) => { const dateFrom = new URL(url).searchParams.get('date_from') ?? ''; return paginated([], cumCounts[dateFrom] ?? 0); }); const client = new SocialhoseClient({ apiKey: 'key', fetch: fetchMock, cacheTtlMs: 0, retries: 0 }); // eslint-disable-next-line @typescript-eslint/no-explicit-any const timeline = await (client as any).getEntityTimeline('term', undefined, 100, 3); expect(timeline).toHaveLength(3); // day -2: 30 - 20 = 10 // day -1: 20 - 5 = 15 // day 0: 5 - 0 = 5 const find = (offset: number) => (timeline as { date: string; count: number }[]).find((p) => p.date === computeDay(offset)); expect(find(-2)?.count).toBe(10); expect(find(-1)?.count).toBe(15); expect(find(0)?.count).toBe(5); }); it('returns [] when earliest cumulative exceeds total (filter was ignored)', async () => { const today = new Date(); const computeDay = (offset: number) => { const d = new Date(today); d.setUTCDate(d.getUTCDate() + offset); return d.toISOString().slice(0, 10); }; // Earliest boundary returns 200, total is 50 → filter was ignored const fetchMock = vi.fn(async (url) => { const dateFrom = new URL(url).searchParams.get('date_from'); const count = dateFrom === computeDay(-2) ? 200 : 50; return paginated([], count); }); const client = new SocialhoseClient({ apiKey: 'key', fetch: fetchMock, cacheTtlMs: 0, retries: 0 }); // eslint-disable-next-line @typescript-eslint/no-explicit-any const timeline = await (client as any).getEntityTimeline('term', undefined, 50, 3); expect(timeline).toEqual([]); }); }); // ---------- getCampaignIdByMatch ---------- describe('getCampaignIdByMatch', () => { it('returns the id of the first campaign whose name contains the match', async () => { const campaigns = [ { id: 'c1', name: 'Sudan Watch', description: '', status: 'active', campaign_type: 'monitoring', platforms: [], tags: [] }, { id: 'c2', name: 'Health Monitor', description: '', status: 'active', campaign_type: 'monitoring', platforms: [], tags: [] }, ]; const fetchMock = vi.fn(async () => paginated(campaigns, 2)); const client = new SocialhoseClient({ apiKey: 'key', fetch: fetchMock, cacheTtlMs: 0 }); expect(await client.getCampaignIdByMatch('health')).toBe('c2'); expect(await client.getCampaignIdByMatch('sudan')).toBe('c1'); }); it('returns undefined when no campaign matches', async () => { const campaigns = [ { id: 'c1', name: 'Sudan Watch', description: '', status: 'active', campaign_type: 'monitoring', platforms: [], tags: [] }, ]; const fetchMock = vi.fn(async () => paginated(campaigns, 1)); const client = new SocialhoseClient({ apiKey: 'key', fetch: fetchMock, cacheTtlMs: 0 }); expect(await client.getCampaignIdByMatch('nonexistent')).toBeUndefined(); }); it('returns undefined when getCampaigns fails', async () => { const fetchMock = vi.fn(async () => new Response('error', { status: 500 })); const client = new SocialhoseClient({ apiKey: 'key', fetch: fetchMock, cacheTtlMs: 0, retries: 0 }); expect(await client.getCampaignIdByMatch('health')).toBeUndefined(); }); }); // ---------- getEntityBriefs bounded concurrency ---------- describe('getEntityBriefs', () => { it('processes all terms and returns a map', async () => { const fetchMock = vi.fn(async () => paginated([makeMention()], 1)); const client = new SocialhoseClient({ apiKey: 'key', fetch: fetchMock, cacheTtlMs: 0, retries: 0 }); const result = await client.getEntityBriefs(['a', 'b', 'c', 'd', 'e']); expect(result.size).toBe(5); for (const t of ['a', 'b', 'c', 'd', 'e']) expect(result.has(t)).toBe(true); }); it('silently skips terms that fail', async () => { let callCount = 0; const fetchMock = vi.fn(async () => { callCount++; if (callCount === 2) return new Response('error', { status: 500 }); return paginated([makeMention()], 1); }); // concurrency=1 makes order deterministic: a, b (fails), c const client = new SocialhoseClient({ apiKey: 'key', fetch: fetchMock, cacheTtlMs: 0, retries: 0 }); const result = await client.getEntityBriefs(['a', 'b', 'c'], undefined, 1); expect(result.size).toBe(2); expect(result.has('a')).toBe(true); expect(result.has('b')).toBe(false); expect(result.has('c')).toBe(true); }); it('respects bounded concurrency', async () => { let inFlight = 0; let maxInFlight = 0; const fetchMock = vi.fn(async () => { inFlight++; maxInFlight = Math.max(maxInFlight, inFlight); // Yield to allow other workers to start await new Promise((r) => setTimeout(r, 5)); inFlight--; return paginated([makeMention()], 1); }); const terms = Array.from({ length: 10 }, (_, i) => `term${i}`); const client = new SocialhoseClient({ apiKey: 'key', fetch: fetchMock, cacheTtlMs: 0, retries: 0 }); await client.getEntityBriefs(terms, undefined, 3); expect(maxInFlight).toBeLessThanOrEqual(3); expect(fetchMock).toHaveBeenCalledTimes(10); }); });