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
8 changes: 7 additions & 1 deletion .github/workflows/pipeline.yml
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,13 @@ jobs:
bun-version-file: api/.bun-version
- run: bun install --frozen-lockfile
- run: bun run typecheck
- run: bun test
# A throwaway Postgres: production's image and first-start script, then the migrations.
# Started with docker run, not a services: container: those start before checkout, so
# they can't mount the first-start script from the repo.
- name: Start the test database
run: bun run db:up
- name: Unit and integration tests (logged in as relay)
run: bun run test:db

image:
name: api-image
Expand Down
3 changes: 3 additions & 0 deletions api/bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

9 changes: 7 additions & 2 deletions api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,16 +6,21 @@
"dev": "bun --watch src/server.ts",
"start": "bun src/server.ts",
"test": "bun test",
"test:unit": "bun test src",
"test:db": "bash scripts/test-db.sh test",
"typecheck": "tsc --noEmit",
"db:migrate": "dbmate --wait --wait-timeout 60s --no-dump-schema --migration-lock --migration-lock-timeout 30s migrate"
"db:migrate": "dbmate --wait --wait-timeout 60s --no-dump-schema --migration-lock --migration-lock-timeout 30s migrate",
"db:up": "bash scripts/test-db.sh up",
"db:down": "bash scripts/test-db.sh down"
},
"dependencies": {
"dbmate": "2.37.0",
"fastify": "5.12.7",
"postgres": "3.4.9",
"zod": "4.6.7"
},
"devDependencies": {
"@types/bun": "1.4.4",
"typescript": "6.0.3"
}
}
}
90 changes: 90 additions & 0 deletions api/scripts/test-db.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
#!/usr/bin/env bash
# A throwaway Postgres for the tests, the same on the laptop and in CI (step 2.6b).
# The same image as production (postgres-image.sh reads it from k8s/data/postgres.yaml) and the same
# first-start script, so the tests log in as "relay", never as the superuser.
#
# up start a fresh one on 127.0.0.1:55432, wait until it answers, then run the migrations
# test run every test (unit + integration) against it; extra arguments go to bun test
# down remove it: only the container this script made, which carries our label
#
# Nothing is kept: "down" also removes its storage, so the next "up" starts empty.
set -euo pipefail

NAME=relay-test-pg
PORT=55432
LABEL_KEY=relay.test-db
# Throwaway values for a container that listens on 127.0.0.1 only. Not secrets.
PASSWORD="test" # quoted: unquoted, shellcheck reads it as the `test` command
export DATABASE_URL="postgres://relay:$PASSWORD@127.0.0.1:$PORT/relay?sslmode=disable"

API_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
REPO_DIR="$(cd "$API_DIR/.." && pwd)"
INITDB_DIR="$REPO_DIR/k8s/data/initdb"

fail() { echo "❌ test-db: $*" >&2; exit 1; }

# Step 1: helpers that look, never change anything.
exists() { docker container inspect "$NAME" >/dev/null 2>&1; }
ours() { [ "$(docker container inspect -f "{{index .Config.Labels \"$LABEL_KEY\"}}" "$NAME" 2>/dev/null)" = "1" ]; }
running() { [ "$(docker container inspect -f '{{.State.Running}}' "$NAME" 2>/dev/null)" = "true" ]; }
port_busy() { (exec 3<>"/dev/tcp/127.0.0.1/$PORT") 2>/dev/null; }


remove_ours() {
ours || fail "a container named $NAME exists but this script didn't make it, so it's left alone"
docker container rm --force --volumes "$NAME" >/dev/null # --volumes: its storage too
}

up() {
docker info >/dev/null 2>&1 || fail "Docker isn't running"

# Step 2: always start fresh. An old container of ours may have an older image, an older
# first-start script or other ports, so it's removed first (only ours: remove_ours checks
# the label). It costs a few seconds and rules all of that out.
if exists; then
echo "test-db: removing the old $NAME first, so the new one matches production's setup"
remove_ours
fi

# Step 3: start it, with the first-start script mounted read-only.
port_busy && fail "port $PORT on 127.0.0.1 is already in use by something else"
# Production's image, pinned by digest, from the one script that reads it (the CI smoke test
# uses the same one). On its own line, so a missing digest stops here with its own message.
local image
image=$(bash "$API_DIR/scripts/postgres-image.sh")
docker run --detach --name "$NAME" --label "$LABEL_KEY=1" \
--publish "127.0.0.1:$PORT:5432" \
--env POSTGRES_PASSWORD="$PASSWORD" --env RELAY_DB_PASSWORD="$PASSWORD" \
--volume "$INITDB_DIR:/docker-entrypoint-initdb.d:ro" \
"$image" >/dev/null
echo "test-db: started $NAME on 127.0.0.1:$PORT"

# Step 4: wait for the real server. During the first start a temporary server listens on
# the socket only, so asking over TCP (-h 127.0.0.1) only succeeds once that's finished.
for _ in $(seq 1 60); do
running || { docker logs --tail 30 "$NAME" >&2 || true; fail "$NAME stopped during its first start (log above)"; }
docker exec "$NAME" pg_isready -q -h 127.0.0.1 -U postgres && break
sleep 1
done
docker exec "$NAME" pg_isready -q -h 127.0.0.1 -U postgres || fail "$NAME didn't answer within 60 s"

# Step 5: the migrations, logged in as relay, exactly as the migrate Job does it.
(cd "$API_DIR" && bun run --bun db:migrate)
echo "✅ test-db: ready. Run the tests with: bun run test:db"
}

down() {
if exists; then
remove_ours
echo "✅ test-db: removed $NAME"
else
echo "test-db: nothing to remove"
fi
}

case "${1:-}" in
up) up ;;
down) down ;;
test) shift; cd "$API_DIR" && exec bun test "$@" ;;
*) echo "usage: test-db.sh up | test [bun test arguments] | down" >&2; exit 2 ;;
esac
5 changes: 4 additions & 1 deletion api/src/app.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,10 @@ import { buildApp } from "./app.ts";
import { loadConfig } from "./config.ts";

// A config made from a fixed object, so the test never depends on this machine's environment.
const app = buildApp(loadConfig({ GIT_SHA: "abc123" }), { logger: false });
// DATABASE_URL is required but harmless here: these tests never connect to it.
const app = buildApp(loadConfig({ GIT_SHA: "abc123", DATABASE_URL: "postgres://test@localhost/test" }), {
logger: false,
});
afterAll(() => app.close());

describe("health routes", () => {
Expand Down
38 changes: 30 additions & 8 deletions api/src/config.test.ts
Original file line number Diff line number Diff line change
@@ -1,23 +1,45 @@
import { describe, expect, test } from "bun:test";
import { loadConfig } from "./config.ts";

// DATABASE_URL is required, so every test passes one. It's never connected to here.
const DB = { DATABASE_URL: "postgres://test@localhost/test" };

describe("loadConfig", () => {
test("uses safe defaults when nothing is set", () => {
expect(loadConfig({})).toEqual({ PORT: 8080, GIT_SHA: "dev", LOG_LEVEL: "info" });
test("uses safe defaults when nothing else is set", () => {
expect(loadConfig(DB)).toEqual({
PORT: 8080,
GIT_SHA: "dev",
LOG_LEVEL: "info",
DATABASE_URL: "postgres://test@localhost/test",
DB_POOL_MAX: 5,
});
});

test("turns PORT from text into a number", () => {
expect(loadConfig({ PORT: "3000" }).PORT).toBe(3000);
expect(loadConfig({ ...DB, PORT: "3000" }).PORT).toBe(3000);
});

test("refuses a PORT that is not a valid port", () => {
expect(() => loadConfig({ PORT: "banana" })).toThrow(/PORT/);
expect(() => loadConfig({ PORT: "70000" })).toThrow(/PORT/);
expect(() => loadConfig({ PORT: "3000abc" })).toThrow(/PORT/);
expect(() => loadConfig({ PORT: "80.5" })).toThrow(/PORT/);
expect(() => loadConfig({ ...DB, PORT: "banana" })).toThrow(/PORT/);
expect(() => loadConfig({ ...DB, PORT: "70000" })).toThrow(/PORT/);
expect(() => loadConfig({ ...DB, PORT: "3000abc" })).toThrow(/PORT/);
expect(() => loadConfig({ ...DB, PORT: "80.5" })).toThrow(/PORT/);
});

test("refuses an unknown LOG_LEVEL", () => {
expect(() => loadConfig({ LOG_LEVEL: "loud" })).toThrow(/LOG_LEVEL/);
expect(() => loadConfig({ ...DB, LOG_LEVEL: "loud" })).toThrow(/LOG_LEVEL/);
});

test("stops at once without DATABASE_URL, or with one that isn't postgres://", () => {
expect(() => loadConfig({})).toThrow(/DATABASE_URL/);
expect(() => loadConfig({ DATABASE_URL: "mysql://test@localhost/test" })).toThrow(/DATABASE_URL/);
expect(loadConfig({ DATABASE_URL: "postgresql://test@localhost/test" }).DATABASE_URL).toStartWith("postgresql://");
});

test("DB_POOL_MAX: a whole number from 1 to 20", () => {
expect(loadConfig({ ...DB, DB_POOL_MAX: "8" }).DB_POOL_MAX).toBe(8);
for (const bad of ["0", "21", "2.5", "five"]) {
expect(() => loadConfig({ ...DB, DB_POOL_MAX: bad })).toThrow(/DB_POOL_MAX/);
}
});
});
16 changes: 16 additions & 0 deletions api/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,22 @@ const Env = z.object({
LOG_LEVEL: z
.enum(["fatal", "error", "warn", "info", "debug", "trace"]).default("info"),

// Where Postgres is, with the login and password in it. Required: without a database the
// API can't do its job, so a missing value stops it at once instead of at the first request.
// In Kubernetes it comes from the Secret relay-db (key url), never from a file in git.
DATABASE_URL: z
.string()
.refine((s) => s.startsWith("postgres://") || s.startsWith("postgresql://"), {
message: "must be a postgres:// URL",
}),

// How many connections this pod may hold open. 5 is plenty for one pod with 100m CPU, and
// keeps every pod together under Postgres's max_connections (the budget in the Phase 2 plan).
DB_POOL_MAX: z
.string()
.transform((s) => Number(s))
.refine((n) => Number.isInteger(n) && n >= 1 && n <= 20, { message: "must be a whole number between 1 and 20" })
.default(5),

});

Expand Down
143 changes: 143 additions & 0 deletions api/src/db.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
// Unit tests for db.ts: no real database. A fake "sql" answers from a script (ok, or an error
// code), and a fake clock lets 60 seconds of retries pass in a few milliseconds.
import { describe, expect, test } from "bun:test";
import { isDatabaseUnavailable, pingDatabase, waitForDatabase, type Log, type Sql } from "./db.ts";

type Outcome = "ok" | string; // "ok", or the error code the database "throws"

// A fake sql: each call takes the next outcome from the script; the last one repeats forever.
function fakeDb(script: Outcome[]) {
let calls = 0;
const sql = (() => {
const outcome = script[Math.min(calls, script.length - 1)];
calls++;
if (outcome === "ok") return Promise.resolve([{ "?column?": 1 }]);
return Promise.reject(Object.assign(new Error(`fake ${outcome}`), { code: outcome }));
}) as unknown as Sql;
return { sql, calls: () => calls };
}

// A fake clock: sleep() moves the time forward instead of waiting.
function fakeClock() {
let t = 0;
const pauses: number[] = [];
return {
now: () => t,
sleep: async (ms: number) => { pauses.push(ms); t += ms; },
pauses,
elapsed: () => t,
};
}

// A fake logger that remembers the messages.
function fakeLog() {
const info: string[] = [];
const warn: string[] = [];
const log: Log = { info: (_o, msg) => info.push(msg), warn: (_o, msg) => warn.push(msg) };
return { log, info, warn };
}

describe("waitForDatabase", () => {
test("the database answers at once → returns, no pause, logs ✅", async () => {
const db = fakeDb(["ok"]), clock = fakeClock(), out = fakeLog();
await waitForDatabase(db.sql, out.log, clock);
expect(db.calls()).toBe(1);
expect(clock.pauses).toEqual([]);
expect(out.info.join(" ")).toContain("✅");
});

test("refused twice (Postgres still starting), then answers → 3 tries, 2 pauses of 2 s", async () => {
const db = fakeDb(["ECONNREFUSED", "ECONNREFUSED", "ok"]), clock = fakeClock(), out = fakeLog();
await waitForDatabase(db.sql, out.log, clock);
expect(db.calls()).toBe(3);
expect(clock.pauses).toEqual([2000, 2000]);
expect(out.warn.length).toBe(2);
});

test('"still starting up" (57P03) is retried, not fatal', async () => {
const db = fakeDb(["57P03", "ok"]), clock = fakeClock(), out = fakeLog();
await waitForDatabase(db.sql, out.log, clock);
expect(db.calls()).toBe(2);
});

test("does not start another attempt when the next pause reaches the deadline", async () => {
const db = fakeDb(["ECONNREFUSED", "ok"]), clock = fakeClock(), out = fakeLog();
await expect(waitForDatabase(db.sql, out.log, { ...clock, deadlineMs: 2_000 }))
.rejects.toThrow("Database not reachable within 2000 ms");
expect(db.calls()).toBe(1);
expect(clock.pauses).toEqual([]);
});

test("a thrown null is retried safely", async () => {
let calls = 0;
const sql = (() => ++calls === 1 ? Promise.reject(null) : Promise.resolve([])) as unknown as Sql;
const clock = fakeClock(), out = fakeLog();
await waitForDatabase(sql, out.log, clock);
expect(calls).toBe(2);
expect(clock.pauses).toEqual([2000]);
});

test("a stalled query is limited by the remaining startup deadline", async () => {
const sql = (() => new Promise(() => {})) as unknown as Sql;
const out = fakeLog();
await expect(waitForDatabase(sql, out.log, { deadlineMs: 20 }))
.rejects.toThrow("Database not reachable within 20 ms");
expect(out.warn).toEqual([]);
});

const fatal = [["28P01", "wrong password"], ["28000", "login not allowed"], ["3D000", "no such database"]] as const;
for (const [code, meaning] of fatal) {
test(`${meaning} (${code}) → throws at once, no retry, no pause`, async () => {
const db = fakeDb([code, "ok"]), clock = fakeClock(), out = fakeLog();
await expect(waitForDatabase(db.sql, out.log, clock)).rejects.toMatchObject({ code });
expect(db.calls()).toBe(1);
expect(clock.pauses).toEqual([]);
});
}

test("never reachable → gives up after the 60 s deadline, never sleeps past it", async () => {
const db = fakeDb(["ECONNREFUSED"]), clock = fakeClock(), out = fakeLog();
await expect(waitForDatabase(db.sql, out.log, clock)).rejects.toThrow();
expect(clock.elapsed()).toBeLessThanOrEqual(60_000);
expect(db.calls()).toBeGreaterThanOrEqual(30); // about one try every 2 s for 60 s
expect(db.calls()).toBeLessThanOrEqual(31);
});

test("the deadline and the pause can be set (a shorter wait for tests and the laptop)", async () => {
const db = fakeDb(["ETIMEDOUT"]), clock = fakeClock(), out = fakeLog();
await expect(waitForDatabase(db.sql, out.log, { ...clock, deadlineMs: 5_000, pauseMs: 1_000 })).rejects.toThrow();
expect(clock.elapsed()).toBeLessThanOrEqual(5_000);
expect(db.calls()).toBeGreaterThanOrEqual(5); // a try about every second for 5 s
expect(clock.pauses.every((ms) => ms === 1_000)).toBe(true);
});
});

describe("pingDatabase", () => {
test("a try that times out is abandoned, and its late error is still handled (no crash)", async () => {
// The database answers with an error only after the try has given up.
const sql = (() =>
new Promise((_, reject) =>
setTimeout(() => reject(Object.assign(new Error("late"), { code: "ECONNRESET" })), 30),
)) as unknown as Sql;
await expect(pingDatabase(sql, 10)).rejects.toMatchObject({ code: "PING_TIMEOUT" });
// Let the late error arrive: bun test fails the whole run on an uncaught one.
await Bun.sleep(50);
});
});

describe("isDatabaseUnavailable", () => {
test("connection problems → true (they become 503)", () => {
for (const code of ["ECONNREFUSED", "CONNECT_TIMEOUT", "CONNECTION_CLOSED", "EPIPE", "EHOSTUNREACH", "57P02", "57P03", "08001", "08006"]) {
expect(isDatabaseUnavailable({ code })).toBe(true);
}
});

test("everything else → false (it stays a 500: our bug, not an outage)", () => {
// 23505 duplicate key, 42P01 no such table, 57014 cancelled (slow SQL), 08P01 protocol
// violation (a driver bug), 08007 commit result unknown ("try again" could save it twice)
const notOutages = [{ code: "23505" }, { code: "42P01" }, { code: "57014" }, { code: "08P01" }, { code: "08007" }];
for (const error of [...notOutages, new Error("boom"), null, undefined, "text"]) {
expect(isDatabaseUnavailable(error)).toBe(false);
}
});
});
Loading