From b8b917dfab84ca4065e69891d1d999c661ae6bfd Mon Sep 17 00:00:00 2001 From: Golo Roden Date: Fri, 23 Oct 2026 18:15:44 +0200 Subject: [PATCH 1/4] fix: Wait for the database in the test container through the v2 API, and through the v1 API only where the server has no v2 API. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01CiuYmw4N6SSzfFyrwftUEj --- src/container/isReachable.test.ts | 121 +++++++++++++++++++++++++----- src/container/isReachable.ts | 34 +++++++-- 2 files changed, 127 insertions(+), 28 deletions(-) diff --git a/src/container/isReachable.test.ts b/src/container/isReachable.test.ts index 8691af0..a58f6e7 100644 --- a/src/container/isReachable.test.ts +++ b/src/container/isReachable.test.ts @@ -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; @@ -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): 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 => { - answerWith(200); - + const isServerReachable = async (): Promise => 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 => { + 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 => { + 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 => { + 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 => { + 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 => { + 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 => { + answerWith({ [v1PingPath]: { status: 200 } }); + + assert.equal(await isServerReachable(), false); + assert.deepEqual(getRequests(), [`GET ${v2PingPath}`]); }); - test('accepts the status 200.', async (): Promise => { - answerWith(200); + test('gives each ping the full time to answer.', async (): Promise => { + // 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 => { - 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 => { + 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 => { diff --git a/src/container/isReachable.ts b/src/container/isReachable.ts index 69dd2f4..0091939 100644 --- a/src/container/isReachable.ts +++ b/src/container/isReachable.ts @@ -1,16 +1,34 @@ const pingTimeout = 1000; const statusOk = 200; +const statusNotFound = 404; +const statusMethodNotAllowed = 405; -// Pings the database. Only the status 200 counts, not any other status of -// success. +// A server without the v2 API answers its paths with one of these. +const statusesWithoutV2Api = new Set([statusNotFound, statusMethodNotAllowed]); + +// Pings the database at the path, within its own time, and returns the status +// of the answer. +const ping = async (url: URL, path: string, signal: AbortSignal): Promise => { + const response = await fetch(new URL(path, url), { + signal: AbortSignal.any([signal, AbortSignal.timeout(pingTimeout)]), + }); + await response.body?.cancel(); + + return response.status; +}; + +// 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 => { try { - const response = await fetch(new URL('/api/v1/ping', url), { - signal: AbortSignal.any([signal, AbortSignal.timeout(pingTimeout)]), - }); - await response.body?.cancel(); + const v2Status = await ping(url, '/api/v2/system/ping', signal); + if (!statusesWithoutV2Api.has(v2Status)) { + return v2Status === statusOk; + } - return response.status === statusOk; + return (await ping(url, '/api/v1/ping', signal)) === 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. @@ -18,4 +36,4 @@ const isReachable = async (url: URL, signal: AbortSignal): Promise => { } }; -export { isReachable }; +export { isReachable, pingTimeout }; From c4118f500a71dbf7393decda670df1541329f01d Mon Sep 17 00:00:00 2001 From: Golo Roden Date: Fri, 23 Oct 2026 18:22:43 +0200 Subject: [PATCH 2/4] chore: Check that a started test container is reachable by the pings of its wait, instead of by the ping of the v1 API. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01CiuYmw4N6SSzfFyrwftUEj --- src/container/startContainer.test.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/container/startContainer.test.ts b/src/container/startContainer.test.ts index f1558c4..f4ef1e2 100644 --- a/src/container/startContainer.test.ts +++ b/src/container/startContainer.test.ts @@ -10,6 +10,7 @@ import { getImageTag } from '../getImageTag.js'; import { failPortLookups } from '../testing/failPortLookups.js'; import { integrationTimeout } from '../testing/integrationTimeout.js'; import type { ContainerInfo } from './containersOfAttempt.js'; +import { isReachable } from './isReachable.js'; import { startContainer } from './startContainer.js'; const startLabel = 'io.eventsourcingdb.container-start'; @@ -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(); + // The database counts as reachable by the same pings as during the + // wait, so that the test holds whichever API the database offers. + const url = new URL(`http://${container.getHost()}:${container.getMappedPort(3000)}`); + assert.equal(await isReachable(url, new AbortController().signal), true); assert.equal((await stopWatching()).length, 1); } finally { From 52860be43ebecc0c55a365dd256f7b4ecb87767e Mon Sep 17 00:00:00 2001 From: Golo Roden Date: Fri, 23 Oct 2026 18:28:25 +0200 Subject: [PATCH 3/4] fix: Tell a server without the v2 API in the wait of the test container by the same definition as the client does. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01CiuYmw4N6SSzfFyrwftUEj --- src/container/isReachable.ts | 23 ++++++++++------------- 1 file changed, 10 insertions(+), 13 deletions(-) diff --git a/src/container/isReachable.ts b/src/container/isReachable.ts index 0091939..2d6e013 100644 --- a/src/container/isReachable.ts +++ b/src/container/isReachable.ts @@ -1,20 +1,17 @@ +import { isV2NotOffered } from '../v2/isV2NotOffered.js'; + const pingTimeout = 1000; const statusOk = 200; -const statusNotFound = 404; -const statusMethodNotAllowed = 405; - -// A server without the v2 API answers its paths with one of these. -const statusesWithoutV2Api = new Set([statusNotFound, statusMethodNotAllowed]); -// Pings the database at the path, within its own time, and returns the status -// of the answer. -const ping = async (url: URL, path: string, signal: AbortSignal): Promise => { +// 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 => { const response = await fetch(new URL(path, url), { signal: AbortSignal.any([signal, AbortSignal.timeout(pingTimeout)]), }); await response.body?.cancel(); - return response.status; + return response; }; // Pings the database through the v2 API, and through the v1 API only if the @@ -23,12 +20,12 @@ const ping = async (url: URL, path: string, signal: AbortSignal): Promise => { try { - const v2Status = await ping(url, '/api/v2/system/ping', signal); - if (!statusesWithoutV2Api.has(v2Status)) { - return v2Status === statusOk; + const v2Response = await ping(url, '/api/v2/system/ping', signal); + if (!isV2NotOffered(v2Response)) { + return v2Response.status === statusOk; } - return (await ping(url, '/api/v1/ping', signal)) === 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. From 353b6ff2e0cea1b6a6e641b193559099aff52492 Mon Sep 17 00:00:00 2001 From: Golo Roden Date: Fri, 23 Oct 2026 18:28:25 +0200 Subject: [PATCH 4/4] chore: Check that a started test container is reachable through a client, instead of through the code of the wait. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01CiuYmw4N6SSzfFyrwftUEj --- src/container/startContainer.test.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/container/startContainer.test.ts b/src/container/startContainer.test.ts index f4ef1e2..a7277bb 100644 --- a/src/container/startContainer.test.ts +++ b/src/container/startContainer.test.ts @@ -6,11 +6,11 @@ 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'; import type { ContainerInfo } from './containersOfAttempt.js'; -import { isReachable } from './isReachable.js'; import { startContainer } from './startContainer.js'; const startLabel = 'io.eventsourcingdb.container-start'; @@ -554,10 +554,10 @@ suite('startContainer', () => { }); try { - // The database counts as reachable by the same pings as during the - // wait, so that the test holds whichever API the database offers. + // 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)}`); - assert.equal(await isReachable(url, new AbortController().signal), true); + await new Client(url, 'secret').ping(); assert.equal((await stopWatching()).length, 1); } finally {