@thalorlabs/api-test-suite 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/.prettierrc +1 -0
- package/CLAUDE.md +22 -0
- package/README.md +43 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +5 -0
- package/dist/standardTests.d.ts +40 -0
- package/dist/standardTests.js +116 -0
- package/eslint.config.mjs +3 -0
- package/package.json +41 -0
- package/src/index.ts +2 -0
- package/src/standardTests.ts +177 -0
- package/tsconfig.json +8 -0
package/.prettierrc
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"@thalorlabs/eslint-plugin-dev-config/prettier"
|
package/CLAUDE.md
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# @thalorlabs/api-test-suite
|
|
2
|
+
|
|
3
|
+
## What this repo is
|
|
4
|
+
|
|
5
|
+
Standardised integration test suite for ThalorLabs microservices. Provides `runStandardApiTests()` which generates the 90% of tests every MS needs — routing, auth, health, error contract.
|
|
6
|
+
|
|
7
|
+
## Build & publish
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
npm run build # tsc → dist/
|
|
11
|
+
npm version patch # or minor/major
|
|
12
|
+
npm publish --access public
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
## What it tests
|
|
16
|
+
|
|
17
|
+
- Route exists and returns expected status with valid auth
|
|
18
|
+
- Unknown routes return 404
|
|
19
|
+
- Missing/wrong/empty API key returns 401
|
|
20
|
+
- GET /health and GET /alive return 200 without auth
|
|
21
|
+
- Error responses include status field, no stack traces leaked
|
|
22
|
+
- Response is JSON
|
package/README.md
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# @thalorlabs/api-test-suite
|
|
2
|
+
|
|
3
|
+
Standardised integration tests for ThalorLabs microservices. One function call gives you the 90% of tests every MS needs.
|
|
4
|
+
|
|
5
|
+
## Installation
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npm install -D @thalorlabs/api-test-suite
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Usage
|
|
12
|
+
|
|
13
|
+
```typescript
|
|
14
|
+
import { runStandardApiTests } from '@thalorlabs/api-test-suite';
|
|
15
|
+
import { app } from '../src/index';
|
|
16
|
+
|
|
17
|
+
runStandardApiTests({
|
|
18
|
+
app,
|
|
19
|
+
route: '/api/v1/jokes',
|
|
20
|
+
method: 'GET',
|
|
21
|
+
validApiKey: 'test-api-key',
|
|
22
|
+
});
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
## What it tests
|
|
26
|
+
|
|
27
|
+
| Category | Tests |
|
|
28
|
+
|----------|-------|
|
|
29
|
+
| **Routing** | Route returns expected status, unknown routes return 404, response is JSON |
|
|
30
|
+
| **Authentication** | Missing key → 401, wrong key → 401, empty key → 401 |
|
|
31
|
+
| **Health** | GET /health returns 200 without auth, GET /alive returns 200 |
|
|
32
|
+
| **Error contract** | Error responses include status field, no stack traces leaked |
|
|
33
|
+
|
|
34
|
+
## Config
|
|
35
|
+
|
|
36
|
+
| Option | Type | Default | Description |
|
|
37
|
+
|--------|------|---------|-------------|
|
|
38
|
+
| `app` | Express | required | The Express app instance |
|
|
39
|
+
| `route` | string | required | Route to test (e.g. '/api/v1/jokes') |
|
|
40
|
+
| `method` | string | 'GET' | HTTP method |
|
|
41
|
+
| `validApiKey` | string | required | Valid API key for auth tests |
|
|
42
|
+
| `requiresAuth` | boolean | true | Whether the route requires auth |
|
|
43
|
+
| `expectedStatus` | number | 200 | Expected success status code |
|
package/dist/index.d.ts
ADDED
package/dist/index.js
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.runStandardApiTests = void 0;
|
|
4
|
+
var standardTests_1 = require("./standardTests");
|
|
5
|
+
Object.defineProperty(exports, "runStandardApiTests", { enumerable: true, get: function () { return standardTests_1.runStandardApiTests; } });
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { Express } from 'express';
|
|
2
|
+
export interface StandardApiTestConfig {
|
|
3
|
+
/** The Express app instance */
|
|
4
|
+
app: Express;
|
|
5
|
+
/** The route to test (e.g. '/api/v1/jokes') */
|
|
6
|
+
route: string;
|
|
7
|
+
/** HTTP method (default: 'GET') */
|
|
8
|
+
method?: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE';
|
|
9
|
+
/** Valid API key for auth tests */
|
|
10
|
+
validApiKey: string;
|
|
11
|
+
/** Whether the route requires auth (default: true) */
|
|
12
|
+
requiresAuth?: boolean;
|
|
13
|
+
/** Expected success status code (default: 200) */
|
|
14
|
+
expectedStatus?: number;
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* Runs standardised integration tests for a ThalorLabs microservice route.
|
|
18
|
+
*
|
|
19
|
+
* Covers:
|
|
20
|
+
* - Route exists and returns expected status
|
|
21
|
+
* - Authentication (missing key, wrong key)
|
|
22
|
+
* - Unknown routes return 404
|
|
23
|
+
* - Health endpoint returns 200 without auth
|
|
24
|
+
* - Response is JSON
|
|
25
|
+
* - Error responses don't leak stack traces
|
|
26
|
+
*
|
|
27
|
+
* @example
|
|
28
|
+
* ```typescript
|
|
29
|
+
* import { runStandardApiTests } from '@thalorlabs/api-test-suite';
|
|
30
|
+
* import { app } from '../src/index';
|
|
31
|
+
*
|
|
32
|
+
* runStandardApiTests({
|
|
33
|
+
* app,
|
|
34
|
+
* route: '/api/v1/jokes',
|
|
35
|
+
* method: 'GET',
|
|
36
|
+
* validApiKey: 'test-key',
|
|
37
|
+
* });
|
|
38
|
+
* ```
|
|
39
|
+
*/
|
|
40
|
+
export declare function runStandardApiTests(config: StandardApiTestConfig): void;
|
|
@@ -0,0 +1,116 @@
|
|
|
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.runStandardApiTests = runStandardApiTests;
|
|
7
|
+
const supertest_1 = __importDefault(require("supertest"));
|
|
8
|
+
function makeRequest(app, method, route) {
|
|
9
|
+
switch (method) {
|
|
10
|
+
case 'POST':
|
|
11
|
+
return (0, supertest_1.default)(app).post(route);
|
|
12
|
+
case 'PUT':
|
|
13
|
+
return (0, supertest_1.default)(app).put(route);
|
|
14
|
+
case 'PATCH':
|
|
15
|
+
return (0, supertest_1.default)(app).patch(route);
|
|
16
|
+
case 'DELETE':
|
|
17
|
+
return (0, supertest_1.default)(app).delete(route);
|
|
18
|
+
default:
|
|
19
|
+
return (0, supertest_1.default)(app).get(route);
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Runs standardised integration tests for a ThalorLabs microservice route.
|
|
24
|
+
*
|
|
25
|
+
* Covers:
|
|
26
|
+
* - Route exists and returns expected status
|
|
27
|
+
* - Authentication (missing key, wrong key)
|
|
28
|
+
* - Unknown routes return 404
|
|
29
|
+
* - Health endpoint returns 200 without auth
|
|
30
|
+
* - Response is JSON
|
|
31
|
+
* - Error responses don't leak stack traces
|
|
32
|
+
*
|
|
33
|
+
* @example
|
|
34
|
+
* ```typescript
|
|
35
|
+
* import { runStandardApiTests } from '@thalorlabs/api-test-suite';
|
|
36
|
+
* import { app } from '../src/index';
|
|
37
|
+
*
|
|
38
|
+
* runStandardApiTests({
|
|
39
|
+
* app,
|
|
40
|
+
* route: '/api/v1/jokes',
|
|
41
|
+
* method: 'GET',
|
|
42
|
+
* validApiKey: 'test-key',
|
|
43
|
+
* });
|
|
44
|
+
* ```
|
|
45
|
+
*/
|
|
46
|
+
function runStandardApiTests(config) {
|
|
47
|
+
const { app, route, method = 'GET', validApiKey, requiresAuth = true, expectedStatus = 200, } = config;
|
|
48
|
+
describe(`Standard API Tests — ${method} ${route}`, () => {
|
|
49
|
+
// Routing
|
|
50
|
+
describe('Routing', () => {
|
|
51
|
+
it(`${method} ${route} returns ${expectedStatus} with valid auth`, async () => {
|
|
52
|
+
const res = await makeRequest(app, method, route).set('x-api-key', validApiKey);
|
|
53
|
+
expect(res.status).toBe(expectedStatus);
|
|
54
|
+
});
|
|
55
|
+
it('unknown route returns 404', async () => {
|
|
56
|
+
const res = await (0, supertest_1.default)(app)
|
|
57
|
+
.get('/api/v1/this-route-does-not-exist')
|
|
58
|
+
.set('x-api-key', validApiKey);
|
|
59
|
+
expect(res.status).toBe(404);
|
|
60
|
+
});
|
|
61
|
+
it('response is JSON', async () => {
|
|
62
|
+
const res = await makeRequest(app, method, route).set('x-api-key', validApiKey);
|
|
63
|
+
expect(res.headers['content-type']).toMatch(/json/);
|
|
64
|
+
});
|
|
65
|
+
});
|
|
66
|
+
// Authentication
|
|
67
|
+
if (requiresAuth) {
|
|
68
|
+
describe('Authentication', () => {
|
|
69
|
+
it('returns 401 without API key', async () => {
|
|
70
|
+
const res = await makeRequest(app, method, route);
|
|
71
|
+
expect(res.status).toBe(401);
|
|
72
|
+
});
|
|
73
|
+
it('returns 401 with wrong API key', async () => {
|
|
74
|
+
const res = await makeRequest(app, method, route).set('x-api-key', 'wrong-key-that-should-fail');
|
|
75
|
+
expect(res.status).toBe(401);
|
|
76
|
+
});
|
|
77
|
+
it('returns 401 with empty API key', async () => {
|
|
78
|
+
const res = await makeRequest(app, method, route).set('x-api-key', '');
|
|
79
|
+
expect(res.status).toBe(401);
|
|
80
|
+
});
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
// Health endpoint
|
|
84
|
+
describe('Health', () => {
|
|
85
|
+
it('GET /health returns 200 without auth', async () => {
|
|
86
|
+
const res = await (0, supertest_1.default)(app).get('/health');
|
|
87
|
+
expect(res.status).toBe(200);
|
|
88
|
+
expect(res.body).toHaveProperty('status');
|
|
89
|
+
});
|
|
90
|
+
it('GET /alive returns 200 without auth', async () => {
|
|
91
|
+
const res = await (0, supertest_1.default)(app).get('/alive');
|
|
92
|
+
expect(res.status).toBe(200);
|
|
93
|
+
expect(res.body).toHaveProperty('status', 'alive');
|
|
94
|
+
});
|
|
95
|
+
});
|
|
96
|
+
// Error response contract
|
|
97
|
+
describe('Error response contract', () => {
|
|
98
|
+
it('error responses include status field', async () => {
|
|
99
|
+
const res = await makeRequest(app, method, route);
|
|
100
|
+
if (res.status >= 400) {
|
|
101
|
+
expect(res.body).toHaveProperty('status');
|
|
102
|
+
}
|
|
103
|
+
});
|
|
104
|
+
it('error responses do not leak stack traces', async () => {
|
|
105
|
+
const res = await makeRequest(app, method, route);
|
|
106
|
+
if (res.status >= 400) {
|
|
107
|
+
const body = JSON.stringify(res.body);
|
|
108
|
+
expect(body).not.toContain('at Object.');
|
|
109
|
+
expect(body).not.toContain('at Module.');
|
|
110
|
+
expect(body).not.toContain('node_modules');
|
|
111
|
+
expect(body).not.toContain('.ts:');
|
|
112
|
+
}
|
|
113
|
+
});
|
|
114
|
+
});
|
|
115
|
+
});
|
|
116
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@thalorlabs/api-test-suite",
|
|
3
|
+
"author": "ThalorLabs",
|
|
4
|
+
"private": false,
|
|
5
|
+
"version": "1.0.0",
|
|
6
|
+
"description": "Standardised integration test suite for ThalorLabs microservices",
|
|
7
|
+
"main": "dist/index.js",
|
|
8
|
+
"types": "dist/index.d.ts",
|
|
9
|
+
"scripts": {
|
|
10
|
+
"build": "tsc",
|
|
11
|
+
"clean": "rimraf dist",
|
|
12
|
+
"prebuild": "npm run clean",
|
|
13
|
+
"prepublishOnly": "npm run build",
|
|
14
|
+
"test": "vitest run",
|
|
15
|
+
"test:watch": "vitest",
|
|
16
|
+
"lint": "eslint src/",
|
|
17
|
+
"lint:fix": "eslint src/ --fix",
|
|
18
|
+
"format": "prettier --write \"src/**/*.ts\"",
|
|
19
|
+
"format:check": "prettier --check \"src/**/*.ts\""
|
|
20
|
+
},
|
|
21
|
+
"devDependencies": {
|
|
22
|
+
"@thalorlabs/eslint-plugin-dev-config": "^2.1.3",
|
|
23
|
+
"@types/express": "^5.0.0",
|
|
24
|
+
"@types/node": "^25.0.0",
|
|
25
|
+
"@types/supertest": "^6.0.0",
|
|
26
|
+
"express": "^4.21.0",
|
|
27
|
+
"prettier": "^3.0.0",
|
|
28
|
+
"rimraf": "^5.0.0",
|
|
29
|
+
"typescript": "^5.0.0"
|
|
30
|
+
},
|
|
31
|
+
"dependencies": {
|
|
32
|
+
"supertest": "^7.0.0"
|
|
33
|
+
},
|
|
34
|
+
"peerDependencies": {
|
|
35
|
+
"express": "^4.0.0"
|
|
36
|
+
},
|
|
37
|
+
"prettier": "@thalorlabs/eslint-plugin-dev-config/prettier",
|
|
38
|
+
"publishConfig": {
|
|
39
|
+
"access": "public"
|
|
40
|
+
}
|
|
41
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
import { Express } from 'express';
|
|
2
|
+
import request from 'supertest';
|
|
3
|
+
|
|
4
|
+
// vitest globals (describe, it, expect) are injected by the test runner.
|
|
5
|
+
// This package is consumed inside vitest test files — no explicit import needed.
|
|
6
|
+
declare const describe: (name: string, fn: () => void) => void;
|
|
7
|
+
declare const it: (name: string, fn: () => Promise<void> | void) => void;
|
|
8
|
+
declare const expect: (value: unknown) => any;
|
|
9
|
+
|
|
10
|
+
export interface StandardApiTestConfig {
|
|
11
|
+
/** The Express app instance */
|
|
12
|
+
app: Express;
|
|
13
|
+
/** The route to test (e.g. '/api/v1/jokes') */
|
|
14
|
+
route: string;
|
|
15
|
+
/** HTTP method (default: 'GET') */
|
|
16
|
+
method?: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE';
|
|
17
|
+
/** Valid API key for auth tests */
|
|
18
|
+
validApiKey: string;
|
|
19
|
+
/** Whether the route requires auth (default: true) */
|
|
20
|
+
requiresAuth?: boolean;
|
|
21
|
+
/** Expected success status code (default: 200) */
|
|
22
|
+
expectedStatus?: number;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function makeRequest(
|
|
26
|
+
app: Express,
|
|
27
|
+
method: string,
|
|
28
|
+
route: string
|
|
29
|
+
): request.Test {
|
|
30
|
+
switch (method) {
|
|
31
|
+
case 'POST':
|
|
32
|
+
return request(app).post(route);
|
|
33
|
+
case 'PUT':
|
|
34
|
+
return request(app).put(route);
|
|
35
|
+
case 'PATCH':
|
|
36
|
+
return request(app).patch(route);
|
|
37
|
+
case 'DELETE':
|
|
38
|
+
return request(app).delete(route);
|
|
39
|
+
default:
|
|
40
|
+
return request(app).get(route);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Runs standardised integration tests for a ThalorLabs microservice route.
|
|
46
|
+
*
|
|
47
|
+
* Covers:
|
|
48
|
+
* - Route exists and returns expected status
|
|
49
|
+
* - Authentication (missing key, wrong key)
|
|
50
|
+
* - Unknown routes return 404
|
|
51
|
+
* - Health endpoint returns 200 without auth
|
|
52
|
+
* - Response is JSON
|
|
53
|
+
* - Error responses don't leak stack traces
|
|
54
|
+
*
|
|
55
|
+
* @example
|
|
56
|
+
* ```typescript
|
|
57
|
+
* import { runStandardApiTests } from '@thalorlabs/api-test-suite';
|
|
58
|
+
* import { app } from '../src/index';
|
|
59
|
+
*
|
|
60
|
+
* runStandardApiTests({
|
|
61
|
+
* app,
|
|
62
|
+
* route: '/api/v1/jokes',
|
|
63
|
+
* method: 'GET',
|
|
64
|
+
* validApiKey: 'test-key',
|
|
65
|
+
* });
|
|
66
|
+
* ```
|
|
67
|
+
*/
|
|
68
|
+
export function runStandardApiTests(config: StandardApiTestConfig): void {
|
|
69
|
+
const {
|
|
70
|
+
app,
|
|
71
|
+
route,
|
|
72
|
+
method = 'GET',
|
|
73
|
+
validApiKey,
|
|
74
|
+
requiresAuth = true,
|
|
75
|
+
expectedStatus = 200,
|
|
76
|
+
} = config;
|
|
77
|
+
|
|
78
|
+
describe(`Standard API Tests — ${method} ${route}`, () => {
|
|
79
|
+
// Routing
|
|
80
|
+
describe('Routing', () => {
|
|
81
|
+
it(`${method} ${route} returns ${expectedStatus} with valid auth`, async () => {
|
|
82
|
+
const res = await makeRequest(app, method, route).set(
|
|
83
|
+
'x-api-key',
|
|
84
|
+
validApiKey
|
|
85
|
+
);
|
|
86
|
+
|
|
87
|
+
expect(res.status).toBe(expectedStatus);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it('unknown route returns 404', async () => {
|
|
91
|
+
const res = await request(app)
|
|
92
|
+
.get('/api/v1/this-route-does-not-exist')
|
|
93
|
+
.set('x-api-key', validApiKey);
|
|
94
|
+
|
|
95
|
+
expect(res.status).toBe(404);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it('response is JSON', async () => {
|
|
99
|
+
const res = await makeRequest(app, method, route).set(
|
|
100
|
+
'x-api-key',
|
|
101
|
+
validApiKey
|
|
102
|
+
);
|
|
103
|
+
|
|
104
|
+
expect(res.headers['content-type']).toMatch(/json/);
|
|
105
|
+
});
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
// Authentication
|
|
109
|
+
if (requiresAuth) {
|
|
110
|
+
describe('Authentication', () => {
|
|
111
|
+
it('returns 401 without API key', async () => {
|
|
112
|
+
const res = await makeRequest(app, method, route);
|
|
113
|
+
|
|
114
|
+
expect(res.status).toBe(401);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it('returns 401 with wrong API key', async () => {
|
|
118
|
+
const res = await makeRequest(app, method, route).set(
|
|
119
|
+
'x-api-key',
|
|
120
|
+
'wrong-key-that-should-fail'
|
|
121
|
+
);
|
|
122
|
+
|
|
123
|
+
expect(res.status).toBe(401);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it('returns 401 with empty API key', async () => {
|
|
127
|
+
const res = await makeRequest(app, method, route).set(
|
|
128
|
+
'x-api-key',
|
|
129
|
+
''
|
|
130
|
+
);
|
|
131
|
+
|
|
132
|
+
expect(res.status).toBe(401);
|
|
133
|
+
});
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// Health endpoint
|
|
138
|
+
describe('Health', () => {
|
|
139
|
+
it('GET /health returns 200 without auth', async () => {
|
|
140
|
+
const res = await request(app).get('/health');
|
|
141
|
+
|
|
142
|
+
expect(res.status).toBe(200);
|
|
143
|
+
expect(res.body).toHaveProperty('status');
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
it('GET /alive returns 200 without auth', async () => {
|
|
147
|
+
const res = await request(app).get('/alive');
|
|
148
|
+
|
|
149
|
+
expect(res.status).toBe(200);
|
|
150
|
+
expect(res.body).toHaveProperty('status', 'alive');
|
|
151
|
+
});
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
// Error response contract
|
|
155
|
+
describe('Error response contract', () => {
|
|
156
|
+
it('error responses include status field', async () => {
|
|
157
|
+
const res = await makeRequest(app, method, route);
|
|
158
|
+
|
|
159
|
+
if (res.status >= 400) {
|
|
160
|
+
expect(res.body).toHaveProperty('status');
|
|
161
|
+
}
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
it('error responses do not leak stack traces', async () => {
|
|
165
|
+
const res = await makeRequest(app, method, route);
|
|
166
|
+
|
|
167
|
+
if (res.status >= 400) {
|
|
168
|
+
const body = JSON.stringify(res.body);
|
|
169
|
+
expect(body).not.toContain('at Object.');
|
|
170
|
+
expect(body).not.toContain('at Module.');
|
|
171
|
+
expect(body).not.toContain('node_modules');
|
|
172
|
+
expect(body).not.toContain('.ts:');
|
|
173
|
+
}
|
|
174
|
+
});
|
|
175
|
+
});
|
|
176
|
+
});
|
|
177
|
+
}
|