feat: add JavaScript Socialhose API SDK

This commit is contained in:
Mo Elzubeir
2026-05-29 12:46:42 -05:00
commit e34552ac33
12 changed files with 2560 additions and 0 deletions
+61
View File
@@ -0,0 +1,61 @@
name: JavaScript SDK
on:
pull_request:
paths:
- 'sdks/javascript/**'
- 'package.json'
- 'pnpm-lock.yaml'
- 'pnpm-workspace.yaml'
- '.github/workflows/javascript.yml'
push:
branches: [main]
paths:
- 'sdks/javascript/**'
- 'package.json'
- 'pnpm-lock.yaml'
- 'pnpm-workspace.yaml'
- '.github/workflows/javascript.yml'
release:
types: [published]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
with:
version: 9.15.0
- uses: actions/setup-node@v4
with:
node-version: '22.13.0'
cache: pnpm
- run: pnpm install --frozen-lockfile
- run: pnpm --filter @socialhose/api test
- run: pnpm --filter @socialhose/api typecheck
- run: pnpm --filter @socialhose/api build
- run: npm pack --pack-destination /tmp
working-directory: sdks/javascript
publish-js:
if: github.event_name == 'release'
needs: test
runs-on: ubuntu-latest
permissions:
contents: read
id-token: write
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
with:
version: 9.15.0
- uses: actions/setup-node@v4
with:
node-version: '22.13.0'
registry-url: https://registry.npmjs.org
cache: pnpm
- run: pnpm install --frozen-lockfile
- run: pnpm --filter @socialhose/api publish --access public --provenance --no-git-checks
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
+8
View File
@@ -0,0 +1,8 @@
node_modules/
dist/
coverage/
*.tgz
.DS_Store
.env
.env.*
!.env.example
+60
View File
@@ -0,0 +1,60 @@
# Socialhose SDKs
Official SDKs for the Socialhose Public API.
## SDKs
- `sdks/javascript` — npm package `@socialhose/api`
Future SDKs can live beside it, e.g. `sdks/python`, `sdks/go`, or `sdks/php`.
## JavaScript / TypeScript
```bash
npm install @socialhose/api
```
```ts
import { SocialhoseClient } from '@socialhose/api';
const socialhose = new SocialhoseClient({
apiKey: process.env.SOCIALHOSE_API_KEY!,
});
const mentions = await socialhose.getMentions({
content_search: 'hospital',
ordering: '-published_at',
});
console.log(mentions.count);
```
## Development
Use pnpm 9.x.
```bash
pnpm install
pnpm test
pnpm typecheck
pnpm build
```
On machines where Corepack's pnpm shim is broken, use:
```bash
npx --yes pnpm@9.15.0 install
npx --yes pnpm@9.15.0 test
```
## Publishing JavaScript SDK
```bash
cd sdks/javascript
pnpm test
pnpm typecheck
pnpm build
npm publish --access public --provenance
```
Do not publish `1.0.0` until the API endpoint inventory is complete and at least two real consumers have migrated.
+15
View File
@@ -0,0 +1,15 @@
{
"name": "socialhose-sdk",
"version": "0.1.0",
"private": true,
"description": "SDKs for the Socialhose Public API.",
"packageManager": "pnpm@9.15.0",
"engines": { "node": ">=18" },
"scripts": {
"build": "pnpm -r build",
"test": "pnpm -r test",
"typecheck": "pnpm -r typecheck",
"pack:js": "pnpm --filter @socialhose/api build && npm pack --pack-destination /tmp ./sdks/javascript"
},
"devDependencies": {}
}
+1662
View File
File diff suppressed because it is too large Load Diff
+2
View File
@@ -0,0 +1,2 @@
packages:
- sdks/*
+22
View File
@@ -0,0 +1,22 @@
MIT License
Copyright (c) Socialhose
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+112
View File
@@ -0,0 +1,112 @@
# @socialhose/api
TypeScript SDK for the Socialhose Public API.
## Install
```bash
npm install @socialhose/api
```
Node 18+ is required because the SDK uses the built-in `fetch`, `Response`, and `AbortSignal.timeout` APIs. You can pass a custom `fetch` implementation if needed.
## Quickstart
```ts
import { SocialhoseClient } from '@socialhose/api';
const socialhose = new SocialhoseClient({
apiKey: process.env.SOCIALHOSE_API_KEY!,
});
const mentions = await socialhose.getMentions({
content_search: 'hospital',
platforms: 'twitter',
ordering: '-published_at',
});
console.log(mentions.count, mentions.results[0]?.content);
```
## Configuration
```ts
const socialhose = new SocialhoseClient({
apiKey: process.env.SOCIALHOSE_API_KEY!,
baseUrl: 'https://socialhose.net/api/public/v1',
timeoutMs: 8_000,
retries: 3,
cacheTtlMs: 60_000,
});
```
The SDK sends `Authorization: Api-Key <key>` and a browser-like `User-Agent` by default. The user-agent is intentional: the current Socialhose API edge rejects some non-browser requests.
## Endpoints
- `getCampaigns()`
- `getCampaign(id)`
- `getOverview(filters)`
- `getTimeline(filters)`
- `getSentiment(filters)`
- `getShareOfVoice(filters)`
- `getPlatforms(filters)`
- `getTopKeywords(filters)`
- `getTrending(filters)`
- `getTopMentions(filters)`
- `getMentions(filters)`
- `getMailingLists()`
- `inviteMailingListMember(listId, invite)`
- `get(path, params)` for lower-level GET access
- `post(path, body)` for lower-level POST access
## Filtering examples
```ts
await socialhose.getOverview({
campaign_ids: 'campaign-id',
date_from: '2026-05-01',
date_to: '2026-05-29',
platforms: 'twitter,reddit',
sentiments: 'negative',
});
await socialhose.getTimeline({
campaign_ids: 'campaign-id',
interval: 'day',
});
```
## Next.js cache integration
Pass `revalidateSeconds` per request. In Next.js this is forwarded as `fetch(..., { next: { revalidate } })`; outside Next.js it is harmless.
```ts
await socialhose.getMentions({ content_search: 'ozempic' }, { revalidateSeconds: 3600 });
```
## Errors
Failed requests throw `SocialhoseError` with `status`, `path`, and response `body` when available.
```ts
import { SocialhoseError } from '@socialhose/api';
try {
await socialhose.getCampaign('missing');
} catch (error) {
if (error instanceof SocialhoseError) {
console.error(error.status, error.path, error.body);
}
}
```
## Publishing
```bash
pnpm test
pnpm typecheck
pnpm build
npm publish --access public --provenance
```
+35
View File
@@ -0,0 +1,35 @@
{
"name": "@socialhose/api",
"version": "0.1.0",
"description": "TypeScript SDK for the Socialhose Public API.",
"license": "MIT",
"type": "module",
"sideEffects": false,
"files": ["dist", "README.md", "LICENSE"],
"main": "./dist/index.cjs",
"module": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js",
"require": "./dist/index.cjs"
}
},
"engines": { "node": ">=18" },
"scripts": {
"build": "tsup src/index.ts --format esm,cjs --dts --sourcemap --clean",
"test": "vitest run",
"typecheck": "tsc --noEmit",
"prepublishOnly": "pnpm test && pnpm typecheck && pnpm build"
},
"keywords": ["socialhose", "social-listening", "social-media", "public-api", "monitoring"],
"publishConfig": { "access": "public", "provenance": true },
"devDependencies": {
"@types/node": "20.16.0",
"tsup": "8.3.0",
"typescript": "5.6.2",
"vitest": "2.1.1"
}
}
+485
View File
@@ -0,0 +1,485 @@
export type Sentiment = 'positive' | 'negative' | 'neutral';
export type SentimentSplit = { positive: number; negative: number; neutral: number };
export type QueryValue = string | number | boolean | null | undefined;
export type QueryParams = Record<string, QueryValue>;
export interface SocialhoseClientOptions {
apiKey: string;
baseUrl?: string;
userAgent?: string;
fetch?: typeof fetch;
timeoutMs?: number;
retries?: number;
retryDelayMs?: (attempt: number) => number;
cacheTtlMs?: number;
defaultHeaders?: Record<string, string>;
}
export interface RequestOptions {
revalidateSeconds?: number;
signal?: AbortSignal;
headers?: Record<string, string>;
}
export interface Campaign {
id: string;
name: string;
description: string;
status: string;
campaign_type: string;
platforms: string[];
tags: string[];
}
export interface Overview {
total_mentions: number;
total_authors: number;
estimated_reach: number;
sentiment_distribution: SentimentSplit;
platform_breakdown: Record<string, number>;
engagement: {
total: number;
average: number;
max: number;
likes: number;
shares: number;
comments: number;
views: number;
};
growth: {
mentions_pct: number;
engagement_pct: number;
positive_sentiment_pct_delta: number;
};
}
export interface TimelinePoint {
date: string;
count: number;
sentiment: SentimentSplit;
engagement: number;
}
export interface ShareOfVoiceItem {
campaign_id: string;
name: string;
count: number;
share_pct: number;
engagement: number;
sentiment: SentimentSplit;
}
export interface PlatformStat {
platform: string;
count: number;
engagement: number;
}
export interface KeywordStat {
keyword: string;
count: number;
sentiment: SentimentSplit;
}
export interface TrendingItem {
keyword: string;
count: number;
previous_count: number;
change_pct: number;
}
export interface TopMention {
id: string;
platform: string;
author: string;
reach: number;
engagement: number;
likes: number;
shares: number;
comments: number;
sentiment: Sentiment;
url: string;
content_preview: string;
}
export interface Mention {
id: string;
platform: string;
campaign_id: string;
campaign_name: string;
classification: string;
content: string;
title: string | null;
url: string;
sentiment: Sentiment;
sentiment_score: number;
engagement_count: number;
likes: number;
shares: number;
comments: number;
views_count: number;
has_media: boolean;
hashtags: string[];
keywords_matched: string[];
language: string | null;
country: string | null;
published_at: string;
author: {
name: string | null;
handle: string | null;
avatar_url: string | null;
url: string | null;
followers: number;
verified: boolean;
};
}
export interface MailingList {
id: string;
campaign_id: string;
name: string;
description: string;
is_active: boolean;
alert_frequency: string;
email_template: string;
}
export interface MailingListInvitation {
id: string;
email: string;
first_name: string;
last_name: string;
role: string;
status: 'pending' | 'accepted' | 'declined' | 'expired' | 'cancelled';
sent_at: string;
expires_at: string;
accepted_at: string | null;
}
export type InviteOutcome = 'invited' | 'already' | 'error';
export interface Paginated<T> {
count: number;
next: string | null;
previous: string | null;
results: T[];
}
export interface AnalyticsFilters {
campaign_ids?: string;
date_from?: string;
date_to?: string;
platforms?: string;
sentiments?: string;
}
export interface MentionFilters extends AnalyticsFilters {
page?: number;
content_search?: string;
ordering?: string;
}
export class SocialhoseError extends Error {
readonly status?: number;
readonly path: string;
readonly body?: string;
readonly cause?: unknown;
constructor(message: string, args: { status?: number; path: string; body?: string; cause?: unknown }) {
super(message);
this.name = 'SocialhoseError';
this.status = args.status;
this.path = args.path;
this.body = args.body;
this.cause = args.cause;
}
}
type CacheEntry = { at: number; value: unknown };
type FetchInitWithNext = RequestInit & { next?: { revalidate?: number } };
const DEFAULT_BASE_URL = 'https://socialhose.net/api/public/v1';
const DEFAULT_UA =
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0 Safari/537.36';
function sleep(ms: number): Promise<void> {
if (ms <= 0) return Promise.resolve();
return new Promise((resolve) => setTimeout(resolve, ms));
}
function num(v: unknown): number {
if (typeof v === 'number') return v;
if (typeof v === 'string' && v.trim() !== '' && !Number.isNaN(Number(v))) return Number(v);
return 0;
}
function joinUrl(baseUrl: string, path: string): string {
return `${baseUrl.replace(/\/+$/, '')}/${path.replace(/^\/+/, '')}`;
}
function encodeParams(params: QueryParams): string {
const qs = new URLSearchParams();
for (const [key, value] of Object.entries(params)) {
if (value !== undefined && value !== null && value !== '') qs.set(key, String(value));
}
return qs.toString();
}
function timeoutSignal(timeoutMs: number, upstream?: AbortSignal): AbortSignal {
if (upstream) {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(new DOMException('Timeout', 'TimeoutError')), timeoutMs);
const abort = () => controller.abort(upstream.reason);
upstream.addEventListener('abort', abort, { once: true });
controller.signal.addEventListener(
'abort',
() => {
clearTimeout(timeout);
upstream.removeEventListener('abort', abort);
},
{ once: true },
);
return controller.signal;
}
return AbortSignal.timeout(timeoutMs);
}
export class SocialhoseClient {
readonly apiKey: string;
readonly baseUrl: string;
readonly userAgent: string;
readonly timeoutMs: number;
readonly retries: number;
readonly cacheTtlMs: number;
private readonly fetchImpl: typeof fetch;
private readonly retryDelayMs: (attempt: number) => number;
private readonly defaultHeaders: Record<string, string>;
private readonly cache = new Map<string, CacheEntry>();
constructor(options: SocialhoseClientOptions) {
if (!options.fetch && typeof fetch === 'undefined') throw new Error('A fetch implementation is required');
this.apiKey = options.apiKey;
this.baseUrl = options.baseUrl ?? DEFAULT_BASE_URL;
this.userAgent = options.userAgent ?? DEFAULT_UA;
this.fetchImpl = options.fetch ?? fetch;
this.timeoutMs = options.timeoutMs ?? 8_000;
this.retries = options.retries ?? 3;
this.retryDelayMs = options.retryDelayMs ?? ((attempt) => 400 * 2 ** attempt + Math.random() * 200);
this.cacheTtlMs = options.cacheTtlMs ?? 60_000;
this.defaultHeaders = options.defaultHeaders ?? {};
}
clearCache(): void {
this.cache.clear();
}
async get<T>(path: string, params: QueryParams = {}, options: RequestOptions = {}): Promise<T> {
const query = encodeParams(params);
const url = `${joinUrl(this.baseUrl, path)}${query ? `?${query}` : ''}`;
const hit = this.cache.get(url);
if (this.cacheTtlMs > 0 && hit && Date.now() - hit.at < this.cacheTtlMs) return hit.value as T;
const value = await this.request<T>('GET', path, url, undefined, options);
if (this.cacheTtlMs > 0) this.cache.set(url, { at: Date.now(), value });
return value;
}
async post<T>(path: string, body: unknown, options: RequestOptions = {}): Promise<{ status: number; data: T | null }> {
const url = joinUrl(this.baseUrl, path);
return this.requestWithStatus<T>('POST', path, url, body, options);
}
async getCampaigns(options: RequestOptions = {}): Promise<Campaign[]> {
const d = await this.get<Paginated<Campaign>>('/campaigns/', { page: 1 }, options);
return d.results;
}
getCampaign(id: string, options: RequestOptions = {}): Promise<Campaign> {
return this.get<Campaign>(`/campaigns/${id}/`, {}, options);
}
async getOverview(filters: AnalyticsFilters = {}, options: RequestOptions = {}): Promise<Overview> {
const d = await this.get<Overview>('/analytics/overview/', filters as QueryParams, options);
return { ...d, total_mentions: num(d.total_mentions), total_authors: num(d.total_authors) };
}
async getTimeline(
filters: AnalyticsFilters & { interval?: 'day' | 'week' | 'month' } = {},
options: RequestOptions = {},
): Promise<TimelinePoint[]> {
const d = await this.get<{ series?: TimelinePoint[] }>(
'/analytics/timeline/',
{ interval: 'day', ...filters },
options,
);
return d.series ?? [];
}
getSentiment(
filters: AnalyticsFilters = {},
options: RequestOptions = {},
): Promise<{ distribution: SentimentSplit; by_platform: Record<string, SentimentSplit> }> {
return this.get('/analytics/sentiment/', filters as QueryParams, options);
}
getShareOfVoice(
filters: AnalyticsFilters = {},
options: RequestOptions = {},
): Promise<{ total_mentions: number; campaigns: ShareOfVoiceItem[] }> {
return this.get('/analytics/share-of-voice/', filters as QueryParams, options);
}
async getPlatforms(filters: AnalyticsFilters = {}, options: RequestOptions = {}): Promise<PlatformStat[]> {
const d = await this.get<{ platforms?: PlatformStat[] }>('/analytics/platforms/', filters as QueryParams, options);
return d.platforms ?? [];
}
async getTopKeywords(
filters: AnalyticsFilters & { limit?: number } = {},
options: RequestOptions = {},
): Promise<KeywordStat[]> {
const d = await this.get<{ keywords?: KeywordStat[] }>(
'/analytics/top-keywords/',
{ limit: 12, ...filters },
options,
);
return d.keywords ?? [];
}
async getTrending(
filters: AnalyticsFilters & { limit?: number } = {},
options: RequestOptions = {},
): Promise<TrendingItem[]> {
const d = await this.get<{ trending?: TrendingItem[] }>(
'/analytics/trending/',
{ limit: 8, ...filters },
options,
);
return d.trending ?? [];
}
async getTopMentions(
filters: AnalyticsFilters & { limit?: number } = {},
options: RequestOptions = {},
): Promise<TopMention[]> {
const d = await this.get<{ mentions?: TopMention[] }>(
'/analytics/top-mentions/',
{ limit: 6, ...filters },
options,
);
return d.mentions ?? [];
}
getMentions(
filters: MentionFilters = {},
optionsOrRevalidate: RequestOptions | number = {},
): Promise<Paginated<Mention>> {
const options = typeof optionsOrRevalidate === 'number' ? { revalidateSeconds: optionsOrRevalidate } : optionsOrRevalidate;
return this.get<Paginated<Mention>>('/mentions/', { page: 1, ...filters }, options);
}
async getMailingLists(options: RequestOptions = {}): Promise<MailingList[]> {
const d = await this.get<Paginated<MailingList>>('/mailing-lists/', { page: 1 }, options);
return d.results;
}
async inviteMailingListMember(
listId: string,
invite: { email: string; first_name?: string; last_name?: string; invitation_message?: string },
options: RequestOptions = {},
): Promise<{ outcome: InviteOutcome; invitation?: MailingListInvitation; detail?: string }> {
const { status, data } = await this.post<{
status?: string;
invitation?: MailingListInvitation;
detail?: string;
}>(`/mailing-lists/${listId}/members/`, invite, options);
if (status === 201 && data?.status === 'invited') return { outcome: 'invited', invitation: data.invitation };
if (status === 409) return { outcome: 'already', detail: data?.detail };
return { outcome: 'error', detail: data?.detail ?? `Socialhose returned HTTP ${status}` };
}
private async request<T>(
method: 'GET' | 'POST',
path: string,
url: string,
body: unknown,
options: RequestOptions,
): Promise<T> {
const { data } = await this.requestWithStatus<T>(method, path, url, body, options);
return data as T;
}
private async requestWithStatus<T>(
method: 'GET' | 'POST',
path: string,
url: string,
body: unknown,
options: RequestOptions,
): Promise<{ status: number; data: T | null }> {
if (!this.apiKey) throw new Error('Socialhose apiKey is required');
let res: Response | null = null;
let lastErr: unknown = null;
for (let attempt = 0; attempt <= this.retries; attempt++) {
try {
const init: FetchInitWithNext = {
method,
headers: {
...this.defaultHeaders,
...options.headers,
Authorization: `Api-Key ${this.apiKey}`,
Accept: 'application/json',
'User-Agent': this.userAgent,
...(method === 'POST' ? { 'Content-Type': 'application/json' } : {}),
},
body: method === 'POST' ? JSON.stringify(body) : undefined,
signal: timeoutSignal(this.timeoutMs, options.signal),
};
if (method === 'GET' && options.revalidateSeconds !== undefined) {
init.next = { revalidate: options.revalidateSeconds };
}
if (method === 'POST') init.cache = 'no-store';
res = await this.fetchImpl(url, init);
} catch (err) {
lastErr = err;
res = null;
if (attempt === this.retries) break;
await sleep(this.retryDelayMs(attempt));
continue;
}
if (res.status !== 429 && res.status < 500) break;
if (attempt === this.retries) break;
await sleep(this.retryDelayMs(attempt));
}
if (!res) {
throw new SocialhoseError(`Socialhose request failed on ${path}: ${String(lastErr)}`, {
path,
cause: lastErr,
});
}
const text = await res.text().catch(() => '');
const data = text ? (JSON.parse(text) as T) : null;
if (!res.ok && !(method === 'POST' && res.status === 409)) {
throw new SocialhoseError(`Socialhose ${res.status} on ${path}: ${text.slice(0, 200)}`, {
status: res.status,
path,
body: text,
});
}
return { status: res.status, data };
}
}
export function createSocialhoseClient(options: SocialhoseClientOptions): SocialhoseClient {
return new SocialhoseClient(options);
}
+82
View File
@@ -0,0 +1,82 @@
import { describe, expect, it, vi } from 'vitest';
import { SocialhoseClient, SocialhoseError } from '../src/index';
const ok = (body: unknown, status = 200) =>
new Response(JSON.stringify(body), { status, headers: { 'content-type': 'application/json' } });
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<string, string>)['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<SocialhoseError>);
});
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');
});
});
+16
View File
@@ -0,0 +1,16 @@
{
"compilerOptions": {
"target": "ES2021",
"lib": ["ES2021", "DOM", "DOM.Iterable"],
"module": "ESNext",
"moduleResolution": "Bundler",
"strict": true,
"declaration": true,
"declarationMap": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true
},
"include": ["src", "test"]
}