@thalorlabs/jokeapi 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/.env.example ADDED
@@ -0,0 +1,6 @@
1
+ # @thalorlabs/jokeapi
2
+ # No environment variables required — v2.jokeapi.dev is free and unauthenticated.
3
+ # This file exists for consistency with other provider packages.
4
+
5
+ # Optional: Override the API base URL (e.g. for testing with a mock server)
6
+ # JOKE_API_BASE_URL=https://v2.jokeapi.dev
package/.prettierrc ADDED
@@ -0,0 +1,7 @@
1
+ {
2
+ "semi": true,
3
+ "singleQuote": true,
4
+ "trailingComma": "es5",
5
+ "printWidth": 80,
6
+ "tabWidth": 2
7
+ }
package/CLAUDE.md ADDED
@@ -0,0 +1,53 @@
1
+ # @thalorlabs/jokeapi
2
+
3
+ ## What this repo is
4
+
5
+ Provider adapter package for v2.jokeapi.dev. Calls one external API, normalises the response to `TLJoke`, and returns it. No DB, no cache, no logging.
6
+
7
+ ## Knowledge base
8
+
9
+ Before writing any code, read:
10
+ - TL-Coding vault: `knowledge/multi-provider-pattern.md`
11
+ - TL-Coding vault: `examples/jokes/layer-provider-package.md`
12
+ - TL-Coding vault: `context/packages.md`
13
+
14
+ ## External API
15
+
16
+ - **API:** v2.jokeapi.dev
17
+ - **Auth:** None
18
+ - **Billable:** No
19
+ - **Joke types:** SINGLE and TWOPART
20
+ - **Categories:** Programming, Dark, Pun, Spooky, Christmas, Misc
21
+ - **Safe mode:** Supported via `safe` query param
22
+
23
+ ## Build & publish
24
+
25
+ ```bash
26
+ npm run build # tsc → dist/
27
+ npm test # vitest
28
+ npm version patch # or minor/major
29
+ npm publish --access public
30
+ ```
31
+
32
+ ## Folder structure
33
+
34
+ ```
35
+ src/
36
+ index.ts ← exports JokeApiClient, JOKE_API_CONFIG
37
+ JokeApiClient.ts ← HTTP adapter, normalises to TLJoke
38
+ JokeApiTypes.ts ← raw API types, local only, never exported
39
+ tests/
40
+ JokeApiClient.test.ts
41
+ ```
42
+
43
+ ## What this package does NOT do
44
+
45
+ - No database access
46
+ - No caching (orchestrator handles this)
47
+ - No logging (orchestrator handles this)
48
+ - Raw types never exported, never in `@thalorlabs/types`
49
+ - No provider selection logic — that's the orchestrator's job
50
+
51
+ ## Migration note
52
+
53
+ When `@thalorlabs/api` is published, `JokeApiClient` should extend `BaseHttpClient` instead of managing its own axios instance.
package/README.md ADDED
@@ -0,0 +1,62 @@
1
+ # @thalorlabs/jokeapi
2
+
3
+ Provider adapter for [v2.jokeapi.dev](https://v2.jokeapi.dev). Returns normalised `TLJoke` from `@thalorlabs/types`. Supports both single and twopart jokes with category filtering and safe mode.
4
+
5
+ ## Installation
6
+
7
+ ```bash
8
+ npm install @thalorlabs/jokeapi
9
+ ```
10
+
11
+ ## Usage
12
+
13
+ ```typescript
14
+ import { JokeApiClient, JOKE_API_CONFIG } from '@thalorlabs/jokeapi';
15
+ import { EJokeCategory, EJokeType } from '@thalorlabs/types';
16
+
17
+ const client = new JokeApiClient();
18
+
19
+ // Random joke
20
+ const joke = await client.getJoke();
21
+
22
+ // Filtered by category and type
23
+ const progJoke = await client.getJoke({
24
+ category: EJokeCategory.PROGRAMMING,
25
+ type: EJokeType.TWOPART,
26
+ safe: true,
27
+ });
28
+ // → TLJoke { id, type: 'TWOPART', setup: '...', delivery: '...', category: 'PROGRAMMING', safe: true, provider: 'jokeapi' }
29
+ ```
30
+
31
+ ## Configuration
32
+
33
+ | Option | Default | Description |
34
+ |--------|---------|-------------|
35
+ | `baseURL` | `https://v2.jokeapi.dev` | API base URL |
36
+ | `timeout` | `10000` | Request timeout (ms) |
37
+
38
+ ## Query Parameters
39
+
40
+ | Param | Type | Description |
41
+ |-------|------|-------------|
42
+ | `category` | `EJokeCategory` | Filter by category (PROGRAMMING, DARK, PUN, SPOOKY, CHRISTMAS, GENERAL) |
43
+ | `type` | `EJokeType` | Filter by type (SINGLE, TWOPART) |
44
+ | `safe` | `boolean` | Only return safe jokes |
45
+
46
+ ## Exported Config
47
+
48
+ ```typescript
49
+ JOKE_API_CONFIG = {
50
+ cacheTtlMs: 3600000, // 1h
51
+ isBillable: false,
52
+ }
53
+ ```
54
+
55
+ ## Scripts
56
+
57
+ ```bash
58
+ npm run build # tsc → dist/
59
+ npm test # vitest
60
+ npm run lint # eslint
61
+ npm run format:check # prettier
62
+ ```
@@ -0,0 +1,27 @@
1
+ import { TLJoke, EJokeType, EJokeCategory } from '@thalorlabs/types';
2
+ export interface JokeApiClientConfig {
3
+ baseURL?: string;
4
+ timeout?: number;
5
+ }
6
+ export interface JokeApiQueryParams {
7
+ category?: EJokeCategory;
8
+ type?: EJokeType;
9
+ safe?: boolean;
10
+ }
11
+ /**
12
+ * HTTP adapter for v2.jokeapi.dev.
13
+ *
14
+ * Calls the external API and normalises the raw response to TLJoke.
15
+ * Supports both single and twopart jokes, category filtering, and safe mode.
16
+ * No DB, no cache, no logging — pure HTTP adapter.
17
+ *
18
+ * When @thalorlabs/api is available, this should extend BaseHttpClient
19
+ * instead of managing its own axios instance.
20
+ */
21
+ export declare class JokeApiClient {
22
+ readonly serviceName = "jokeapi";
23
+ private readonly axiosInstance;
24
+ constructor(config?: JokeApiClientConfig);
25
+ getJoke(params?: JokeApiQueryParams): Promise<TLJoke>;
26
+ private normalise;
27
+ }
@@ -0,0 +1,93 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.JokeApiClient = void 0;
7
+ const axios_1 = __importDefault(require("axios"));
8
+ const crypto_1 = require("crypto");
9
+ const types_1 = require("@thalorlabs/types");
10
+ /** Maps TL categories to JokeAPI v2 category strings. */
11
+ const CATEGORY_MAP = {
12
+ [types_1.EJokeCategory.PROGRAMMING]: 'Programming',
13
+ [types_1.EJokeCategory.DARK]: 'Dark',
14
+ [types_1.EJokeCategory.PUN]: 'Pun',
15
+ [types_1.EJokeCategory.SPOOKY]: 'Spooky',
16
+ [types_1.EJokeCategory.CHRISTMAS]: 'Christmas',
17
+ [types_1.EJokeCategory.GENERAL]: 'Misc',
18
+ [types_1.EJokeCategory.DAD]: 'Misc',
19
+ };
20
+ /** Maps JokeAPI v2 category strings back to TL categories. */
21
+ const REVERSE_CATEGORY_MAP = {
22
+ Programming: types_1.EJokeCategory.PROGRAMMING,
23
+ Dark: types_1.EJokeCategory.DARK,
24
+ Pun: types_1.EJokeCategory.PUN,
25
+ Spooky: types_1.EJokeCategory.SPOOKY,
26
+ Christmas: types_1.EJokeCategory.CHRISTMAS,
27
+ Misc: types_1.EJokeCategory.GENERAL,
28
+ };
29
+ /**
30
+ * HTTP adapter for v2.jokeapi.dev.
31
+ *
32
+ * Calls the external API and normalises the raw response to TLJoke.
33
+ * Supports both single and twopart jokes, category filtering, and safe mode.
34
+ * No DB, no cache, no logging — pure HTTP adapter.
35
+ *
36
+ * When @thalorlabs/api is available, this should extend BaseHttpClient
37
+ * instead of managing its own axios instance.
38
+ */
39
+ class JokeApiClient {
40
+ constructor(config = {}) {
41
+ this.serviceName = 'jokeapi';
42
+ this.axiosInstance = axios_1.default.create({
43
+ baseURL: config.baseURL ?? 'https://v2.jokeapi.dev',
44
+ timeout: config.timeout ?? 10000,
45
+ headers: {
46
+ Accept: 'application/json',
47
+ },
48
+ });
49
+ }
50
+ async getJoke(params = {}) {
51
+ const category = params.category
52
+ ? (CATEGORY_MAP[params.category] ?? 'Any')
53
+ : 'Any';
54
+ const queryParams = {};
55
+ if (params.type) {
56
+ queryParams.type =
57
+ params.type === types_1.EJokeType.SINGLE ? 'single' : 'twopart';
58
+ }
59
+ if (params.safe) {
60
+ queryParams.safe = 'true';
61
+ }
62
+ const { data } = await this.axiosInstance.get(`/joke/${category}`, { params: queryParams });
63
+ if (data.error) {
64
+ throw new Error(`JokeAPI error: ${data.message}`);
65
+ }
66
+ return this.normalise(data);
67
+ }
68
+ normalise(raw) {
69
+ const resolvedCategory = REVERSE_CATEGORY_MAP[raw.category] ?? types_1.EJokeCategory.GENERAL;
70
+ if (raw.type === 'twopart') {
71
+ return types_1.TLJoke.parse({
72
+ id: (0, crypto_1.createHash)('md5')
73
+ .update(raw.setup + raw.delivery)
74
+ .digest('hex'),
75
+ type: types_1.EJokeType.TWOPART,
76
+ setup: raw.setup,
77
+ delivery: raw.delivery,
78
+ category: resolvedCategory,
79
+ safe: raw.safe,
80
+ provider: this.serviceName,
81
+ });
82
+ }
83
+ return types_1.TLJoke.parse({
84
+ id: (0, crypto_1.createHash)('md5').update(raw.joke).digest('hex'),
85
+ type: types_1.EJokeType.SINGLE,
86
+ joke: raw.joke,
87
+ category: resolvedCategory,
88
+ safe: raw.safe,
89
+ provider: this.serviceName,
90
+ });
91
+ }
92
+ }
93
+ exports.JokeApiClient = JokeApiClient;
@@ -0,0 +1,45 @@
1
+ /**
2
+ * Raw response types from v2.jokeapi.dev.
3
+ *
4
+ * Internal to this package only — never exported from index.ts,
5
+ * never added to @thalorlabs/types.
6
+ */
7
+ export interface JokeApiFlags {
8
+ nsfw: boolean;
9
+ religious: boolean;
10
+ political: boolean;
11
+ racist: boolean;
12
+ sexist: boolean;
13
+ explicit: boolean;
14
+ }
15
+ export interface JokeApiSingleRaw {
16
+ error: false;
17
+ category: string;
18
+ type: 'single';
19
+ joke: string;
20
+ flags: JokeApiFlags;
21
+ id: number;
22
+ safe: boolean;
23
+ lang: string;
24
+ }
25
+ export interface JokeApiTwoPartRaw {
26
+ error: false;
27
+ category: string;
28
+ type: 'twopart';
29
+ setup: string;
30
+ delivery: string;
31
+ flags: JokeApiFlags;
32
+ id: number;
33
+ safe: boolean;
34
+ lang: string;
35
+ }
36
+ export type JokeApiRawResponse = JokeApiSingleRaw | JokeApiTwoPartRaw;
37
+ export interface JokeApiErrorResponse {
38
+ error: true;
39
+ internalError: boolean;
40
+ code: number;
41
+ message: string;
42
+ causedBy: string[];
43
+ additionalInfo: string;
44
+ timestamp: number;
45
+ }
@@ -0,0 +1,8 @@
1
+ "use strict";
2
+ /**
3
+ * Raw response types from v2.jokeapi.dev.
4
+ *
5
+ * Internal to this package only — never exported from index.ts,
6
+ * never added to @thalorlabs/types.
7
+ */
8
+ Object.defineProperty(exports, "__esModule", { value: true });
@@ -0,0 +1,5 @@
1
+ export { JokeApiClient, JokeApiClientConfig, JokeApiQueryParams, } from './JokeApiClient';
2
+ export declare const JOKE_API_CONFIG: {
3
+ readonly cacheTtlMs: number;
4
+ readonly isBillable: false;
5
+ };
package/dist/index.js ADDED
@@ -0,0 +1,9 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.JOKE_API_CONFIG = exports.JokeApiClient = void 0;
4
+ var JokeApiClient_1 = require("./JokeApiClient");
5
+ Object.defineProperty(exports, "JokeApiClient", { enumerable: true, get: function () { return JokeApiClient_1.JokeApiClient; } });
6
+ exports.JOKE_API_CONFIG = {
7
+ cacheTtlMs: 1000 * 60 * 60, // 1h
8
+ isBillable: false, // jokeapi.dev is free
9
+ };
@@ -0,0 +1,21 @@
1
+ import eslint from '@eslint/js';
2
+ import tseslint from 'typescript-eslint';
3
+ import prettier from 'eslint-config-prettier';
4
+ import nodePlugin from 'eslint-plugin-n';
5
+
6
+ export default tseslint.config(
7
+ eslint.configs.recommended,
8
+ ...tseslint.configs.recommended,
9
+ nodePlugin.configs['flat/recommended-module'],
10
+ prettier,
11
+ {
12
+ rules: {
13
+ '@typescript-eslint/no-explicit-any': 'error',
14
+ 'n/no-missing-import': 'off',
15
+ 'n/no-unpublished-import': 'off',
16
+ },
17
+ },
18
+ {
19
+ ignores: ['dist/', 'node_modules/'],
20
+ }
21
+ );
package/package.json ADDED
@@ -0,0 +1,46 @@
1
+ {
2
+ "name": "@thalorlabs/jokeapi",
3
+ "author": "ThalorLabs",
4
+ "private": false,
5
+ "version": "1.0.0",
6
+ "description": "Provider adapter for v2.jokeapi.dev — returns TLJoke",
7
+ "homepage": "https://github.com/ThalorLabs/jokeapi#readme",
8
+ "bugs": {
9
+ "url": "https://github.com/ThalorLabs/jokeapi/issues"
10
+ },
11
+ "repository": {
12
+ "type": "git",
13
+ "url": "git+https://github.com/ThalorLabs/jokeapi.git"
14
+ },
15
+ "license": "ISC",
16
+ "main": "dist/index.js",
17
+ "types": "dist/index.d.ts",
18
+ "scripts": {
19
+ "test": "vitest run",
20
+ "test:watch": "vitest",
21
+ "build": "tsc",
22
+ "clean": "rimraf dist",
23
+ "prebuild": "npm run clean",
24
+ "prepublishOnly": "npm run build",
25
+ "lint": "eslint src/",
26
+ "lint:fix": "eslint src/ --fix",
27
+ "format": "prettier --write \"src/**/*.ts\"",
28
+ "format:check": "prettier --check \"src/**/*.ts\""
29
+ },
30
+ "devDependencies": {
31
+ "@eslint/js": "^10.0.1",
32
+ "@types/node": "^25.5.2",
33
+ "eslint": "^10.1.0",
34
+ "eslint-config-prettier": "^10.1.8",
35
+ "eslint-plugin-n": "^17.24.0",
36
+ "prettier": "^3.8.1",
37
+ "rimraf": "^5.0.0",
38
+ "typescript": "^5.0.0",
39
+ "typescript-eslint": "^8.58.0",
40
+ "vitest": "^3.0.0"
41
+ },
42
+ "dependencies": {
43
+ "@thalorlabs/types": "^1.7.0",
44
+ "axios": "^1.7.0"
45
+ }
46
+ }
@@ -0,0 +1,118 @@
1
+ import axios, { AxiosInstance } from 'axios';
2
+ import { createHash } from 'crypto';
3
+ import { TLJoke, EJokeType, EJokeCategory } from '@thalorlabs/types';
4
+ import { JokeApiRawResponse, JokeApiErrorResponse } from './JokeApiTypes';
5
+
6
+ export interface JokeApiClientConfig {
7
+ baseURL?: string;
8
+ timeout?: number;
9
+ }
10
+
11
+ export interface JokeApiQueryParams {
12
+ category?: EJokeCategory;
13
+ type?: EJokeType;
14
+ safe?: boolean;
15
+ }
16
+
17
+ /** Maps TL categories to JokeAPI v2 category strings. */
18
+ const CATEGORY_MAP: Partial<Record<EJokeCategory, string>> = {
19
+ [EJokeCategory.PROGRAMMING]: 'Programming',
20
+ [EJokeCategory.DARK]: 'Dark',
21
+ [EJokeCategory.PUN]: 'Pun',
22
+ [EJokeCategory.SPOOKY]: 'Spooky',
23
+ [EJokeCategory.CHRISTMAS]: 'Christmas',
24
+ [EJokeCategory.GENERAL]: 'Misc',
25
+ [EJokeCategory.DAD]: 'Misc',
26
+ };
27
+
28
+ /** Maps JokeAPI v2 category strings back to TL categories. */
29
+ const REVERSE_CATEGORY_MAP: Record<string, EJokeCategory> = {
30
+ Programming: EJokeCategory.PROGRAMMING,
31
+ Dark: EJokeCategory.DARK,
32
+ Pun: EJokeCategory.PUN,
33
+ Spooky: EJokeCategory.SPOOKY,
34
+ Christmas: EJokeCategory.CHRISTMAS,
35
+ Misc: EJokeCategory.GENERAL,
36
+ };
37
+
38
+ /**
39
+ * HTTP adapter for v2.jokeapi.dev.
40
+ *
41
+ * Calls the external API and normalises the raw response to TLJoke.
42
+ * Supports both single and twopart jokes, category filtering, and safe mode.
43
+ * No DB, no cache, no logging — pure HTTP adapter.
44
+ *
45
+ * When @thalorlabs/api is available, this should extend BaseHttpClient
46
+ * instead of managing its own axios instance.
47
+ */
48
+ export class JokeApiClient {
49
+ public readonly serviceName = 'jokeapi';
50
+ private readonly axiosInstance: AxiosInstance;
51
+
52
+ constructor(config: JokeApiClientConfig = {}) {
53
+ this.axiosInstance = axios.create({
54
+ baseURL: config.baseURL ?? 'https://v2.jokeapi.dev',
55
+ timeout: config.timeout ?? 10000,
56
+ headers: {
57
+ Accept: 'application/json',
58
+ },
59
+ });
60
+ }
61
+
62
+ async getJoke(params: JokeApiQueryParams = {}): Promise<TLJoke> {
63
+ const category = params.category
64
+ ? (CATEGORY_MAP[params.category] ?? 'Any')
65
+ : 'Any';
66
+
67
+ const queryParams: Record<string, string> = {};
68
+
69
+ if (params.type) {
70
+ queryParams.type =
71
+ params.type === EJokeType.SINGLE ? 'single' : 'twopart';
72
+ }
73
+
74
+ if (params.safe) {
75
+ queryParams.safe = 'true';
76
+ }
77
+
78
+ const { data } = await this.axiosInstance.get<
79
+ JokeApiRawResponse | JokeApiErrorResponse
80
+ >(`/joke/${category}`, { params: queryParams });
81
+
82
+ if (data.error) {
83
+ throw new Error(
84
+ `JokeAPI error: ${(data as JokeApiErrorResponse).message}`
85
+ );
86
+ }
87
+
88
+ return this.normalise(data as JokeApiRawResponse);
89
+ }
90
+
91
+ private normalise(raw: JokeApiRawResponse): TLJoke {
92
+ const resolvedCategory =
93
+ REVERSE_CATEGORY_MAP[raw.category] ?? EJokeCategory.GENERAL;
94
+
95
+ if (raw.type === 'twopart') {
96
+ return TLJoke.parse({
97
+ id: createHash('md5')
98
+ .update(raw.setup + raw.delivery)
99
+ .digest('hex'),
100
+ type: EJokeType.TWOPART,
101
+ setup: raw.setup,
102
+ delivery: raw.delivery,
103
+ category: resolvedCategory,
104
+ safe: raw.safe,
105
+ provider: this.serviceName,
106
+ });
107
+ }
108
+
109
+ return TLJoke.parse({
110
+ id: createHash('md5').update(raw.joke).digest('hex'),
111
+ type: EJokeType.SINGLE,
112
+ joke: raw.joke,
113
+ category: resolvedCategory,
114
+ safe: raw.safe,
115
+ provider: this.serviceName,
116
+ });
117
+ }
118
+ }
@@ -0,0 +1,50 @@
1
+ /**
2
+ * Raw response types from v2.jokeapi.dev.
3
+ *
4
+ * Internal to this package only — never exported from index.ts,
5
+ * never added to @thalorlabs/types.
6
+ */
7
+
8
+ export interface JokeApiFlags {
9
+ nsfw: boolean;
10
+ religious: boolean;
11
+ political: boolean;
12
+ racist: boolean;
13
+ sexist: boolean;
14
+ explicit: boolean;
15
+ }
16
+
17
+ export interface JokeApiSingleRaw {
18
+ error: false;
19
+ category: string;
20
+ type: 'single';
21
+ joke: string;
22
+ flags: JokeApiFlags;
23
+ id: number;
24
+ safe: boolean;
25
+ lang: string;
26
+ }
27
+
28
+ export interface JokeApiTwoPartRaw {
29
+ error: false;
30
+ category: string;
31
+ type: 'twopart';
32
+ setup: string;
33
+ delivery: string;
34
+ flags: JokeApiFlags;
35
+ id: number;
36
+ safe: boolean;
37
+ lang: string;
38
+ }
39
+
40
+ export type JokeApiRawResponse = JokeApiSingleRaw | JokeApiTwoPartRaw;
41
+
42
+ export interface JokeApiErrorResponse {
43
+ error: true;
44
+ internalError: boolean;
45
+ code: number;
46
+ message: string;
47
+ causedBy: string[];
48
+ additionalInfo: string;
49
+ timestamp: number;
50
+ }
package/src/index.ts ADDED
@@ -0,0 +1,10 @@
1
+ export {
2
+ JokeApiClient,
3
+ JokeApiClientConfig,
4
+ JokeApiQueryParams,
5
+ } from './JokeApiClient';
6
+
7
+ export const JOKE_API_CONFIG = {
8
+ cacheTtlMs: 1000 * 60 * 60, // 1h
9
+ isBillable: false, // jokeapi.dev is free
10
+ } as const;
@@ -0,0 +1,229 @@
1
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
2
+ import { JokeApiClient } from '../src/JokeApiClient';
3
+ import { EJokeType, EJokeCategory } from '@thalorlabs/types';
4
+ import axios from 'axios';
5
+
6
+ vi.mock('axios', () => {
7
+ const mockAxiosInstance = {
8
+ get: vi.fn(),
9
+ };
10
+ return {
11
+ default: {
12
+ create: vi.fn(() => mockAxiosInstance),
13
+ },
14
+ };
15
+ });
16
+
17
+ function getMockAxios() {
18
+ const instance = (axios.create as ReturnType<typeof vi.fn>).mock.results[0]
19
+ .value;
20
+ return instance.get as ReturnType<typeof vi.fn>;
21
+ }
22
+
23
+ describe('JokeApiClient', () => {
24
+ let client: JokeApiClient;
25
+
26
+ beforeEach(() => {
27
+ vi.clearAllMocks();
28
+ client = new JokeApiClient();
29
+ });
30
+
31
+ describe('constructor', () => {
32
+ it('sets serviceName to jokeapi', () => {
33
+ expect(client.serviceName).toBe('jokeapi');
34
+ });
35
+
36
+ it('creates axios instance with default config', () => {
37
+ expect(axios.create).toHaveBeenCalledWith({
38
+ baseURL: 'https://v2.jokeapi.dev',
39
+ timeout: 10000,
40
+ headers: { Accept: 'application/json' },
41
+ });
42
+ });
43
+ });
44
+
45
+ describe('getJoke — single joke', () => {
46
+ it('normalises a single joke to TLJoke', async () => {
47
+ const mockGet = getMockAxios();
48
+ mockGet.mockResolvedValueOnce({
49
+ data: {
50
+ error: false,
51
+ category: 'Programming',
52
+ type: 'single',
53
+ joke: 'A SQL query walks into a bar, walks up to two tables and asks... can I join you?',
54
+ flags: {
55
+ nsfw: false,
56
+ religious: false,
57
+ political: false,
58
+ racist: false,
59
+ sexist: false,
60
+ explicit: false,
61
+ },
62
+ id: 42,
63
+ safe: true,
64
+ lang: 'en',
65
+ },
66
+ });
67
+
68
+ const joke = await client.getJoke();
69
+
70
+ expect(joke.type).toBe(EJokeType.SINGLE);
71
+ expect(joke.category).toBe(EJokeCategory.PROGRAMMING);
72
+ expect(joke.joke).toBeDefined();
73
+ expect(joke.setup).toBeUndefined();
74
+ expect(joke.delivery).toBeUndefined();
75
+ expect(joke.safe).toBe(true);
76
+ expect(joke.provider).toBe('jokeapi');
77
+ });
78
+ });
79
+
80
+ describe('getJoke — twopart joke', () => {
81
+ it('normalises a twopart joke to TLJoke', async () => {
82
+ const mockGet = getMockAxios();
83
+ mockGet.mockResolvedValueOnce({
84
+ data: {
85
+ error: false,
86
+ category: 'Programming',
87
+ type: 'twopart',
88
+ setup: 'Why do programmers prefer dark mode?',
89
+ delivery: 'Because light attracts bugs.',
90
+ flags: {
91
+ nsfw: false,
92
+ religious: false,
93
+ political: false,
94
+ racist: false,
95
+ sexist: false,
96
+ explicit: false,
97
+ },
98
+ id: 58,
99
+ safe: true,
100
+ lang: 'en',
101
+ },
102
+ });
103
+
104
+ const joke = await client.getJoke();
105
+
106
+ expect(joke.type).toBe(EJokeType.TWOPART);
107
+ expect(joke.setup).toBe('Why do programmers prefer dark mode?');
108
+ expect(joke.delivery).toBe('Because light attracts bugs.');
109
+ expect(joke.joke).toBeUndefined();
110
+ expect(joke.category).toBe(EJokeCategory.PROGRAMMING);
111
+ expect(joke.provider).toBe('jokeapi');
112
+ });
113
+ });
114
+
115
+ describe('getJoke — category mapping', () => {
116
+ it('maps PROGRAMMING category to Programming endpoint', async () => {
117
+ const mockGet = getMockAxios();
118
+ mockGet.mockResolvedValueOnce({
119
+ data: {
120
+ error: false,
121
+ category: 'Programming',
122
+ type: 'single',
123
+ joke: 'A joke',
124
+ flags: {
125
+ nsfw: false,
126
+ religious: false,
127
+ political: false,
128
+ racist: false,
129
+ sexist: false,
130
+ explicit: false,
131
+ },
132
+ id: 1,
133
+ safe: true,
134
+ lang: 'en',
135
+ },
136
+ });
137
+
138
+ await client.getJoke({ category: EJokeCategory.PROGRAMMING });
139
+
140
+ expect(mockGet).toHaveBeenCalledWith('/joke/Programming', { params: {} });
141
+ });
142
+
143
+ it('uses Any when no category specified', async () => {
144
+ const mockGet = getMockAxios();
145
+ mockGet.mockResolvedValueOnce({
146
+ data: {
147
+ error: false,
148
+ category: 'Misc',
149
+ type: 'single',
150
+ joke: 'A joke',
151
+ flags: {
152
+ nsfw: false,
153
+ religious: false,
154
+ political: false,
155
+ racist: false,
156
+ sexist: false,
157
+ explicit: false,
158
+ },
159
+ id: 1,
160
+ safe: true,
161
+ lang: 'en',
162
+ },
163
+ });
164
+
165
+ await client.getJoke();
166
+
167
+ expect(mockGet).toHaveBeenCalledWith('/joke/Any', { params: {} });
168
+ });
169
+ });
170
+
171
+ describe('getJoke — safe mode', () => {
172
+ it('passes safe=true query param when requested', async () => {
173
+ const mockGet = getMockAxios();
174
+ mockGet.mockResolvedValueOnce({
175
+ data: {
176
+ error: false,
177
+ category: 'Misc',
178
+ type: 'single',
179
+ joke: 'A joke',
180
+ flags: {
181
+ nsfw: false,
182
+ religious: false,
183
+ political: false,
184
+ racist: false,
185
+ sexist: false,
186
+ explicit: false,
187
+ },
188
+ id: 1,
189
+ safe: true,
190
+ lang: 'en',
191
+ },
192
+ });
193
+
194
+ await client.getJoke({ safe: true });
195
+
196
+ expect(mockGet).toHaveBeenCalledWith('/joke/Any', {
197
+ params: { safe: 'true' },
198
+ });
199
+ });
200
+ });
201
+
202
+ describe('getJoke — error handling', () => {
203
+ it('throws on JokeAPI error response', async () => {
204
+ const mockGet = getMockAxios();
205
+ mockGet.mockResolvedValueOnce({
206
+ data: {
207
+ error: true,
208
+ internalError: false,
209
+ code: 106,
210
+ message: 'No matching joke found',
211
+ causedBy: ['No jokes found'],
212
+ additionalInfo: '',
213
+ timestamp: Date.now(),
214
+ },
215
+ });
216
+
217
+ await expect(client.getJoke()).rejects.toThrow(
218
+ 'JokeAPI error: No matching joke found'
219
+ );
220
+ });
221
+
222
+ it('propagates network errors', async () => {
223
+ const mockGet = getMockAxios();
224
+ mockGet.mockRejectedValueOnce(new Error('Network error'));
225
+
226
+ await expect(client.getJoke()).rejects.toThrow('Network error');
227
+ });
228
+ });
229
+ });
package/tsconfig.json ADDED
@@ -0,0 +1,14 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2020",
4
+ "module": "CommonJS",
5
+ "declaration": true,
6
+ "outDir": "./dist",
7
+ "strict": true,
8
+ "esModuleInterop": true,
9
+ "skipLibCheck": true,
10
+ "moduleResolution": "node"
11
+ },
12
+ "include": ["src"],
13
+ "exclude": ["node_modules", "dist"]
14
+ }