Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
121 changes: 101 additions & 20 deletions src/container/isReachable.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,15 @@ import type { ServerResponse } from 'node:http';
import { afterEach, beforeEach, suite, test } from 'node:test';
import type { FakeRequest } from '../testing/FakeServer.js';
import { FakeServer } from '../testing/FakeServer.js';
import { isReachable } from './isReachable.js';
import { isReachable, pingTimeout } from './isReachable.js';

const v2PingPath = '/api/v2/system/ping';
const v1PingPath = '/api/v1/ping';

interface Answer {
status: number;
delay?: number;
}

suite('isReachable', () => {
let server: FakeServer;
Expand All @@ -17,36 +25,109 @@ suite('isReachable', () => {
await server.stop();
});

const answerWith = (status: number): void => {
server.respond = (_request: FakeRequest, response: ServerResponse): void => {
response.writeHead(status);
response.end();
// Answers each path with its answer, after its delay, and leaves a path
// without an answer unanswered.
const answerWith = (answers: Record<string, Answer>): void => {
server.respond = (request: FakeRequest, response: ServerResponse): void => {
const answer = answers[request.path];
if (answer === undefined) {
return;
}

setTimeout(() => {
response.writeHead(answer.status);
response.end();
}, answer.delay ?? 0);
};
};

test('pings the database.', async (): Promise<void> => {
answerWith(200);

const isServerReachable = async (): Promise<boolean> =>
await isReachable(server.url, new AbortController().signal);

assert.equal(server.requests.length, 1);
assert.equal(server.requests[0]?.method, 'GET');
assert.equal(server.requests[0]?.path, '/api/v1/ping');
const getRequests = (): string[] =>
server.requests.map(({ method, path }) => `${method} ${path}`);

test('pings the database through the v2 API, and accepts the status 200 without asking the v1 API.', async (): Promise<void> => {
answerWith({ [v2PingPath]: { status: 200 }, [v1PingPath]: { status: 200 } });

assert.equal(await isServerReachable(), true);
assert.deepEqual(getRequests(), [`GET ${v2PingPath}`]);
});

test('accepts the status 200 of the v2 API from a server without the v1 API.', async (): Promise<void> => {
answerWith({ [v2PingPath]: { status: 200 }, [v1PingPath]: { status: 404 } });

assert.equal(await isServerReachable(), true);
});

const defineV1FallbackTest = (status: number): void => {
test(`asks the v1 API if the v2 API answers with ${status}, as a server without the v2 API does, and accepts its status 200.`, async (): Promise<void> => {
answerWith({ [v2PingPath]: { status }, [v1PingPath]: { status: 200 } });

assert.equal(await isServerReachable(), true);
assert.deepEqual(getRequests(), [`GET ${v2PingPath}`, `GET ${v1PingPath}`]);
});
};

for (const status of [404, 405]) {
defineV1FallbackTest(status);
}

const defineOtherV2StatusTest = (status: number): void => {
test(`does not accept the status ${status} of the v2 API, and does not ask the v1 API then.`, async (): Promise<void> => {
answerWith({ [v2PingPath]: { status }, [v1PingPath]: { status: 200 } });

assert.equal(await isServerReachable(), false);
assert.deepEqual(getRequests(), [`GET ${v2PingPath}`]);
});
};

// Only the status 200 counts, not any other status of success.
for (const status of [201, 202, 204, 299, 400, 500, 503]) {
defineOtherV2StatusTest(status);
}

const defineOtherV1StatusTest = (status: number): void => {
test(`does not accept the status ${status} of the v1 API.`, async (): Promise<void> => {
answerWith({ [v2PingPath]: { status: 404 }, [v1PingPath]: { status } });

assert.equal(await isServerReachable(), false);
});
};

for (const status of [201, 202, 204, 299, 404, 405, 500, 503]) {
defineOtherV1StatusTest(status);
}

test('does not ask the v1 API if the ping of the v2 API gets no answer in time.', async (): Promise<void> => {
answerWith({ [v1PingPath]: { status: 200 } });

assert.equal(await isServerReachable(), false);
assert.deepEqual(getRequests(), [`GET ${v2PingPath}`]);
});

test('accepts the status 200.', async (): Promise<void> => {
answerWith(200);
test('gives each ping the full time to answer.', async (): Promise<void> => {
// Each ping answers in time, but both together take longer than one may.
const delay = pingTimeout * 0.6;
answerWith({
[v2PingPath]: { status: 404, delay },
[v1PingPath]: { status: 200, delay },
});

assert.equal(await isReachable(server.url, new AbortController().signal), true);
assert.equal(await isServerReachable(), true);
});

test('accepts no other status, not even another one of success.', async (): Promise<void> => {
for (const status of [201, 202, 204, 299, 404, 500, 503]) {
answerWith(status);
test('asks the v2 API first on every call, without remembering an earlier answer.', async (): Promise<void> => {
answerWith({ [v2PingPath]: { status: 404 }, [v1PingPath]: { status: 200 } });

// biome-ignore lint/performance/noAwaitInLoops: Each status is checked on its own.
assert.equal(await isReachable(server.url, new AbortController().signal), false, `${status}`);
}
assert.equal(await isServerReachable(), true);
assert.equal(await isServerReachable(), true);
assert.deepEqual(getRequests(), [
`GET ${v2PingPath}`,
`GET ${v1PingPath}`,
`GET ${v2PingPath}`,
`GET ${v1PingPath}`,
]);
});

test('does not accept a server that cannot be reached.', async (): Promise<void> => {
Expand Down
31 changes: 23 additions & 8 deletions src/container/isReachable.ts
Original file line number Diff line number Diff line change
@@ -1,21 +1,36 @@
import { isV2NotOffered } from '../v2/isV2NotOffered.js';

const pingTimeout = 1000;
const statusOk = 200;

// Pings the database. Only the status 200 counts, not any other status of
// success.
// Pings the database at the path, within its own time, and returns the answer
// without its body.
const ping = async (url: URL, path: string, signal: AbortSignal): Promise<Response> => {
const response = await fetch(new URL(path, url), {
signal: AbortSignal.any([signal, AbortSignal.timeout(pingTimeout)]),
});
await response.body?.cancel();

return response;
};

// Pings the database through the v2 API, and through the v1 API only if the
// server answers that ping as one without the v2 API does, so that a server is
// reachable whichever of the two APIs it offers. Only the status 200 counts,
// not any other status of success.
const isReachable = async (url: URL, signal: AbortSignal): Promise<boolean> => {
try {
const response = await fetch(new URL('/api/v1/ping', url), {
signal: AbortSignal.any([signal, AbortSignal.timeout(pingTimeout)]),
});
await response.body?.cancel();
const v2Response = await ping(url, '/api/v2/system/ping', signal);
if (!isV2NotOffered(v2Response)) {
return v2Response.status === statusOk;
}

return response.status === statusOk;
return (await ping(url, '/api/v1/ping', signal)).status === statusOk;
} catch {
// While the database starts, a ping fails, for example since nothing
// listens on the port yet, so this is no error, but a reason to ask again.
return false;
}
};

export { isReachable };
export { isReachable, pingTimeout };
10 changes: 5 additions & 5 deletions src/container/startContainer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { suite, test } from 'node:test';
import { setTimeout as sleep } from 'node:timers/promises';
import type { StartedTestContainer } from 'testcontainers';
import { getContainerRuntimeClient, ImageName } from 'testcontainers';
import { Client } from '../Client.js';
import { getImageTag } from '../getImageTag.js';
import { failPortLookups } from '../testing/failPortLookups.js';
import { integrationTimeout } from '../testing/integrationTimeout.js';
Expand Down Expand Up @@ -553,11 +554,10 @@ suite('startContainer', () => {
});

try {
const response = await fetch(
`http://${container.getHost()}:${container.getMappedPort(3000)}/api/v1/ping`,
);
assert.equal(response.status, 200);
await response.body?.cancel();
// A client checks the database without the code of the wait, and through
// whichever API the database offers.
const url = new URL(`http://${container.getHost()}:${container.getMappedPort(3000)}`);
await new Client(url, 'secret').ping();

assert.equal((await stopWatching()).length, 1);
} finally {
Expand Down
Loading