feat: add entity analytics, pluggable cache, and pitfalls docs
- Add Cache interface with MemoryCache and NoopCache implementations - Make SocialhoseClient accept injectable cache option - Remove Next.js next.revalidate coupling from transport - Add entity analytics: getEntityBrief, getEntityStats, getEntityBriefs, getCampaignIdByMatch with exact sentiment/platform faceting and cumulative-differenced timeline - Add 23 new tests (29 total) covering entity analytics, cache injection, sentiment reconciliation, bounded concurrency, and timeline differencing - Update README with entity analytics, custom caching, and pitfalls sections - Fix CI branch: main -> master
This commit is contained in:
@@ -1,9 +1,42 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { SocialhoseClient, SocialhoseError } from '../src/index';
|
||||
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> = {}): 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: [] }));
|
||||
@@ -80,3 +113,381 @@ describe('SocialhoseClient', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// ---------- 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<string, number> = {
|
||||
[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);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user