feat: add expectedBodyContains for HTTP checks (#6)

This commit is contained in:
2026-04-15 20:30:56 +02:00
committed by GitHub
parent 26cc1dac8a
commit 98ef89aa06
12 changed files with 310 additions and 29 deletions

View File

@@ -29,6 +29,7 @@ describe('formatWebhookPayload', () => {
method: 'GET',
expectedStatus: 200,
headers: {},
expectedBodyContains: '',
timeoutMs: 5000,
retries: 3,
retryDelayMs: 1000,
@@ -105,6 +106,7 @@ describe('formatWebhookPayload', () => {
method: 'GET',
expectedStatus: 200,
headers: {},
expectedBodyContains: '',
timeoutMs: 5000,
retries: 3,
retryDelayMs: 1000,

View File

@@ -20,6 +20,7 @@ describe('prepareChecks', () => {
target: 'https://example.com',
method: 'GET',
expectedStatus: 200,
expectedBodyContains: '',
headers: {},
timeoutMs: 5000,
retries: 3,
@@ -103,6 +104,7 @@ describe('prepareChecks', () => {
target: 'https://example.com',
method: 'GET',
expectedStatus: 200,
expectedBodyContains: '',
headers: { Authorization: 'Bearer token' },
timeoutMs: 5000,
retries: 3,
@@ -135,6 +137,7 @@ describe('prepareChecks', () => {
target: 'https://example.com',
method: 'GET',
expectedStatus: 200,
expectedBodyContains: '',
headers: {},
timeoutMs: 5000,
retries: 3,

View File

@@ -19,6 +19,7 @@ export function prepareChecks(config: Config): CheckRequest[] {
type: m.type,
method: m.method || undefined,
expectedStatus: m.expectedStatus || undefined,
expectedBodyContains: m.expectedBodyContains || undefined,
headers: Object.keys(m.headers).length > 0 ? m.headers : undefined,
};
}

View File

@@ -25,6 +25,7 @@ describe('executeHttpCheck', () => {
vi.mocked(fetch).mockResolvedValue({
ok: true,
status: 200,
text: vi.fn().mockResolvedValue(''),
} as unknown as Response);
const check = createCheckRequest();
@@ -41,6 +42,7 @@ describe('executeHttpCheck', () => {
vi.mocked(fetch).mockResolvedValue({
ok: true,
status: 200,
text: vi.fn().mockResolvedValue(''),
} as unknown as Response);
const check = createCheckRequest({ expectedStatus: 201 });
@@ -54,6 +56,7 @@ describe('executeHttpCheck', () => {
vi.mocked(fetch).mockResolvedValue({
ok: true,
status: 201,
text: vi.fn().mockResolvedValue(''),
} as unknown as Response);
const check = createCheckRequest({ expectedStatus: 201 });
@@ -76,7 +79,11 @@ describe('executeHttpCheck', () => {
it('retries on failure and eventually succeeds', async () => {
vi.mocked(fetch)
.mockRejectedValueOnce(new Error('Network error'))
.mockResolvedValueOnce({ ok: true, status: 200 } as unknown as Response);
.mockResolvedValueOnce({
ok: true,
status: 200,
text: vi.fn().mockResolvedValue(''),
} as unknown as Response);
const check = createCheckRequest({ retries: 2, retryDelayMs: 10 });
const result = await executeHttpCheck(check);
@@ -86,7 +93,11 @@ describe('executeHttpCheck', () => {
});
it('uses correct HTTP method', async () => {
vi.mocked(fetch).mockResolvedValue({ ok: true, status: 200 } as unknown as Response);
vi.mocked(fetch).mockResolvedValue({
ok: true,
status: 200,
text: vi.fn().mockResolvedValue(''),
} as unknown as Response);
const check = createCheckRequest({ method: 'POST' });
await executeHttpCheck(check);
@@ -98,7 +109,11 @@ describe('executeHttpCheck', () => {
});
it('defaults to GET method', async () => {
vi.mocked(fetch).mockResolvedValue({ ok: true, status: 200 } as unknown as Response);
vi.mocked(fetch).mockResolvedValue({
ok: true,
status: 200,
text: vi.fn().mockResolvedValue(''),
} as unknown as Response);
const check = createCheckRequest();
await executeHttpCheck(check);
@@ -110,7 +125,11 @@ describe('executeHttpCheck', () => {
});
it('sends default User-Agent header', async () => {
vi.mocked(fetch).mockResolvedValue({ ok: true, status: 200 } as unknown as Response);
vi.mocked(fetch).mockResolvedValue({
ok: true,
status: 200,
text: vi.fn().mockResolvedValue(''),
} as unknown as Response);
const check = createCheckRequest();
await executeHttpCheck(check);
@@ -124,7 +143,11 @@ describe('executeHttpCheck', () => {
});
it('merges custom headers with defaults', async () => {
vi.mocked(fetch).mockResolvedValue({ ok: true, status: 200 } as unknown as Response);
vi.mocked(fetch).mockResolvedValue({
ok: true,
status: 200,
text: vi.fn().mockResolvedValue(''),
} as unknown as Response);
const check = createCheckRequest({
headers: { Authorization: 'Bearer token123' },
@@ -137,7 +160,11 @@ describe('executeHttpCheck', () => {
});
it('allows monitor headers to override defaults', async () => {
vi.mocked(fetch).mockResolvedValue({ ok: true, status: 200 } as unknown as Response);
vi.mocked(fetch).mockResolvedValue({
ok: true,
status: 200,
text: vi.fn().mockResolvedValue(''),
} as unknown as Response);
const check = createCheckRequest({
headers: { 'User-Agent': 'custom-agent' },
@@ -166,8 +193,16 @@ describe('executeHttpCheck', () => {
it('retries on wrong status code', async () => {
vi.mocked(fetch)
.mockResolvedValueOnce({ ok: false, status: 500 } as unknown as Response)
.mockResolvedValueOnce({ ok: true, status: 200 } as unknown as Response);
.mockResolvedValueOnce({
ok: false,
status: 500,
text: vi.fn().mockResolvedValue(''),
} as unknown as Response)
.mockResolvedValueOnce({
ok: true,
status: 200,
text: vi.fn().mockResolvedValue(''),
} as unknown as Response);
const check = createCheckRequest({ expectedStatus: 200, retries: 2, retryDelayMs: 10 });
const result = await executeHttpCheck(check);
@@ -185,4 +220,159 @@ describe('executeHttpCheck', () => {
expect(result.status).toBe('down');
expect(result.error).toBe('Unknown error');
});
it('returns up when body contains expected content', async () => {
vi.mocked(fetch).mockResolvedValue({
ok: true,
status: 200,
text: vi.fn().mockResolvedValue('Welcome to our site'),
} as unknown as Response);
const check = createCheckRequest({ expectedBodyContains: 'Welcome' });
const result = await executeHttpCheck(check);
expect(result.status).toBe('up');
expect(result.error).toBe('');
});
it('returns down when body does not contain expected content', async () => {
vi.mocked(fetch).mockResolvedValue({
ok: true,
status: 200,
text: vi.fn().mockResolvedValue('Error: Not found'),
} as unknown as Response);
const check = createCheckRequest({ expectedBodyContains: 'Welcome' });
const result = await executeHttpCheck(check);
expect(result.status).toBe('down');
expect(result.error).toContain("Expected body to contain 'Welcome'");
});
it('returns up when both status and body match', async () => {
vi.mocked(fetch).mockResolvedValue({
ok: true,
status: 201,
text: vi.fn().mockResolvedValue('User created successfully'),
} as unknown as Response);
const check = createCheckRequest({ expectedStatus: 201, expectedBodyContains: 'created' });
const result = await executeHttpCheck(check);
expect(result.status).toBe('up');
expect(result.error).toBe('');
});
it('returns down when status matches but body does not', async () => {
vi.mocked(fetch).mockResolvedValue({
ok: true,
status: 201,
text: vi.fn().mockResolvedValue('Error: Invalid input'),
} as unknown as Response);
const check = createCheckRequest({ expectedStatus: 201, expectedBodyContains: 'created' });
const result = await executeHttpCheck(check);
expect(result.status).toBe('down');
expect(result.error).toContain("Expected body to contain 'created'");
});
it('returns down when body matches but status does not', async () => {
vi.mocked(fetch).mockResolvedValue({
ok: true,
status: 200,
text: vi.fn().mockResolvedValue('User created successfully'),
} as unknown as Response);
const check = createCheckRequest({ expectedStatus: 201, expectedBodyContains: 'created' });
const result = await executeHttpCheck(check);
expect(result.status).toBe('down');
expect(result.error).toContain('Expected status 201, got 200');
});
it('returns up when expected body is empty string (no body check)', async () => {
vi.mocked(fetch).mockResolvedValue({
ok: true,
status: 200,
text: vi.fn().mockResolvedValue(''),
} as unknown as Response);
const check = createCheckRequest({ expectedBodyContains: '' });
const result = await executeHttpCheck(check);
expect(result.status).toBe('up');
expect(result.error).toBe('');
});
it('returns up when expected body is whitespace only (no body check)', async () => {
vi.mocked(fetch).mockResolvedValue({
ok: true,
status: 200,
text: vi.fn().mockResolvedValue('Some content'),
} as unknown as Response);
const check = createCheckRequest({ expectedBodyContains: ' ' });
const result = await executeHttpCheck(check);
expect(result.status).toBe('up');
expect(result.error).toBe('');
});
it('performs case-sensitive body matching', async () => {
vi.mocked(fetch).mockResolvedValue({
ok: true,
status: 200,
text: vi.fn().mockResolvedValue('HELLO WORLD'),
} as unknown as Response);
const check = createCheckRequest({ expectedBodyContains: 'hello' });
const result = await executeHttpCheck(check);
expect(result.status).toBe('down');
expect(result.error).toContain("Expected body to contain 'hello'");
});
it('retries on body mismatch and eventually succeeds', async () => {
vi.mocked(fetch)
.mockResolvedValueOnce({
ok: true,
status: 200,
text: vi.fn().mockResolvedValue('Loading...'),
} as unknown as Response)
.mockResolvedValueOnce({
ok: true,
status: 200,
text: vi.fn().mockResolvedValue('Ready'),
} as unknown as Response);
const check = createCheckRequest({
expectedBodyContains: 'Ready',
retries: 2,
retryDelayMs: 10,
});
const result = await executeHttpCheck(check);
expect(result.status).toBe('up');
expect(result.attempts).toBe(2);
});
it('retries on body mismatch and eventually fails', async () => {
vi.mocked(fetch).mockResolvedValue({
ok: true,
status: 200,
text: vi.fn().mockResolvedValue('Error'),
} as unknown as Response);
const check = createCheckRequest({
expectedBodyContains: 'Ready',
retries: 2,
retryDelayMs: 10,
});
const result = await executeHttpCheck(check);
expect(result.status).toBe('down');
expect(result.error).toContain("Expected body to contain 'Ready'");
expect(result.attempts).toBe(3);
});
});

View File

@@ -31,28 +31,52 @@ export async function executeHttpCheck(check: HttpCheckRequest): Promise<CheckRe
timeout = undefined;
const responseTime = Date.now() - startTime;
const bodyText = await response.text();
if (check.expectedStatus && response.status !== check.expectedStatus) {
lastError = `Expected status ${check.expectedStatus}, got ${response.status}`;
if (i < check.retries) {
await sleep(check.retryDelayMs);
continue;
let statusOk = true;
let bodyOk = true;
let errorMessages: string[] = [];
if (check.expectedStatus && check.expectedStatus > 0) {
if (response.status !== check.expectedStatus) {
statusOk = false;
errorMessages.push(`Expected status ${check.expectedStatus}, got ${response.status}`);
}
} else if (response.status < 200 || response.status >= 400) {
statusOk = false;
errorMessages.push(`Expected 2xx/3xx status, got ${response.status}`);
}
if (
check.expectedBodyContains &&
check.expectedBodyContains.trim() &&
!bodyText.includes(check.expectedBodyContains)
) {
bodyOk = false;
errorMessages.push(`Expected body to contain '${check.expectedBodyContains}', not found`);
}
if (statusOk && bodyOk) {
return {
name: check.name,
status: 'down',
status: 'up',
responseTimeMs: responseTime,
error: lastError,
error: '',
attempts,
};
}
lastError = errorMessages.join('; ');
if (i < check.retries) {
await sleep(check.retryDelayMs);
continue;
}
return {
name: check.name,
status: 'up',
status: 'down',
responseTimeMs: responseTime,
error: '',
error: lastError,
attempts,
};
} catch (error) {

View File

@@ -226,4 +226,35 @@ monitors:
const config = parseConfig(yaml);
expect(config.settings.title).toBe('Atalaya Uptime Monitor');
});
it('parses expected_body_contains field', () => {
const yaml = `
monitors:
- name: "api-health"
type: http
target: "https://api.example.com/health"
expected_body_contains: "healthy"
`;
const config = parseConfig(yaml);
expect(config.monitors).toHaveLength(1);
if (config.monitors[0].type === 'http') {
expect(config.monitors[0].expectedBodyContains).toBe('healthy');
}
});
it('defaults expected_body_contains to empty string', () => {
const yaml = `
monitors:
- name: "no-body-check"
type: http
target: "https://example.com"
`;
const config = parseConfig(yaml);
expect(config.monitors).toHaveLength(1);
if (config.monitors[0].type === 'http') {
expect(config.monitors[0].expectedBodyContains).toBe('');
}
});
});

View File

@@ -79,6 +79,7 @@ function applyDefaults(raw: RawYamlConfig): Config {
type,
method: m.method ?? '',
expectedStatus: m.expected_status ?? 0,
expectedBodyContains: m.expected_body_contains ?? '',
headers: m.headers ?? {},
};
}

View File

@@ -33,6 +33,7 @@ export interface HttpMonitor extends MonitorBase {
type: 'http';
method: string;
expectedStatus: number;
expectedBodyContains: string;
headers: Record<string, string>;
}
@@ -76,6 +77,7 @@ export type RawYamlConfig = {
target?: string;
method?: string;
expected_status?: number;
expected_body_contains?: string;
headers?: Record<string, string>;
record_type?: string;
expected_values?: string[];

View File

@@ -39,6 +39,7 @@ describe('processResults', () => {
method: 'GET',
expectedStatus: 200,
headers: {},
expectedBodyContains: '',
timeoutMs: 5000,
retries: 3,
retryDelayMs: 1000,
@@ -102,6 +103,7 @@ describe('processResults', () => {
method: 'GET',
expectedStatus: 200,
headers: {},
expectedBodyContains: '',
timeoutMs: 5000,
retries: 3,
retryDelayMs: 1000,
@@ -125,7 +127,7 @@ describe('processResults', () => {
{
monitor_name: 'test',
current_status: 'down',
consecutive_failures: 3,
consecutive_failures: 0,
last_status_change: 0,
last_checked: 0,
},
@@ -145,7 +147,16 @@ describe('processResults', () => {
defaultTimeoutMs: 5000,
defaultFailureThreshold: 3,
},
alerts: [],
alerts: [
{
type: 'webhook' as const,
name: 'alert',
url: 'https://example.com',
method: 'POST',
headers: {},
bodyTemplate: '',
},
],
monitors: [
{
name: 'test',
@@ -154,10 +165,11 @@ describe('processResults', () => {
method: 'GET',
expectedStatus: 200,
headers: {},
expectedBodyContains: '',
timeoutMs: 5000,
retries: 3,
retryDelayMs: 1000,
failureThreshold: 3,
failureThreshold: 2,
alerts: ['alert'],
},
],
@@ -177,7 +189,7 @@ describe('processResults', () => {
{
monitor_name: 'test',
current_status: 'up',
consecutive_failures: 1,
consecutive_failures: 0,
last_status_change: 0,
last_checked: 0,
},
@@ -199,17 +211,18 @@ describe('processResults', () => {
alerts: [],
monitors: [
{
name: 'known',
name: 'test',
type: 'http',
target: 'https://example.com',
method: 'GET',
expectedStatus: 200,
headers: {},
expectedBodyContains: '',
timeoutMs: 5000,
retries: 3,
retryDelayMs: 1000,
failureThreshold: 2,
alerts: [],
alerts: ['alert'],
},
],
};
@@ -260,24 +273,25 @@ describe('processResults', () => {
alerts: [],
monitors: [
{
name: 'new-monitor',
name: 'test',
type: 'http',
target: 'https://example.com',
method: 'GET',
expectedStatus: 200,
headers: {},
expectedBodyContains: '',
timeoutMs: 5000,
retries: 3,
retryDelayMs: 1000,
failureThreshold: 2,
alerts: [],
failureThreshold: 3,
alerts: ['alert'],
},
],
};
const results: CheckResult[] = [
{
name: 'new-monitor',
name: 'test',
status: 'up',
responseTimeMs: 100,
error: '',

View File

@@ -12,6 +12,7 @@ export interface HttpCheckRequest extends CheckRequestBase {
method?: string;
expectedStatus?: number;
headers?: Record<string, string>;
expectedBodyContains?: string;
}
export interface TcpCheckRequest extends CheckRequestBase {