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
21 changes: 14 additions & 7 deletions .github/workflows/test-web.yml
Original file line number Diff line number Diff line change
@@ -1,17 +1,21 @@
name: Web Tests
name: Production Gate

on:
pull_request:
paths:
- "apps/web/**"
branches:
- growthpath-mail
push:
branches:
- main
paths:
- "apps/web/**"
- growthpath-mail
workflow_dispatch:

concurrency:
group: production-gate-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true

jobs:
web-tests:
production-gate:
name: Production gate
runs-on: ubuntu-latest
env:
NODE_ENV: test
Expand Down Expand Up @@ -87,3 +91,6 @@ jobs:

- name: Run integration tests
run: pnpm --filter=web test:integration

- name: Build production image
run: docker build --file docker/Dockerfile --tag usesend:${{ github.sha }} .
66 changes: 63 additions & 3 deletions apps/web/src/app/api/health/route.api.test.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,72 @@
import { describe, expect, it } from "vitest";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";

const mocks = vi.hoisted(() => ({
ping: vi.fn(),
queryRaw: vi.fn(),
}));

vi.mock("~/server/db", () => ({
db: { $queryRaw: mocks.queryRaw },
}));

vi.mock("~/server/redis", () => ({
getRedis: () => ({ ping: mocks.ping }),
}));

import { GET } from "~/app/api/health/route";

describe("health route", () => {
it("returns healthy response", async () => {
beforeEach(() => {
mocks.ping.mockResolvedValue("PONG");
mocks.queryRaw.mockResolvedValue([{ "?column?": 1 }]);
vi.stubEnv("RAILWAY_GIT_COMMIT_SHA", "abc123");
});

afterEach(() => {
vi.clearAllMocks();
vi.unstubAllEnvs();
vi.useRealTimers();
});

it("returns the deployed SHA when dependencies are ready", async () => {
const response = await GET();
const body = await response.json();

expect(response.status).toBe(200);
expect(body).toEqual({ data: "Healthy" });
expect(response.headers.get("cache-control")).toBe("no-store");
expect(body).toEqual({ data: "Healthy", commitSha: "abc123" });
expect(mocks.queryRaw).toHaveBeenCalledOnce();
expect(mocks.ping).toHaveBeenCalledOnce();
});

it("fails readiness when Postgres is unavailable", async () => {
mocks.queryRaw.mockRejectedValue(new Error("database unavailable"));

const response = await GET();

expect(response.status).toBe(503);
await expect(response.json()).resolves.toEqual({
data: "Unhealthy",
commitSha: "abc123",
});
});

it("fails readiness when Redis is unavailable", async () => {
mocks.ping.mockRejectedValue(new Error("redis unavailable"));

const response = await GET();

expect(response.status).toBe(503);
});

it("bounds dependency checks", async () => {
vi.useFakeTimers();
mocks.queryRaw.mockReturnValue(new Promise(() => undefined));

const responsePromise = GET();
await vi.advanceTimersByTimeAsync(2_000);
const response = await responsePromise;

expect(response.status).toBe(503);
});
});
42 changes: 41 additions & 1 deletion apps/web/src/app/api/health/route.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,45 @@
import { db } from "~/server/db";
import { getRedis } from "~/server/redis";

export const dynamic = "force-dynamic";

const HEALTHCHECK_TIMEOUT_MS = 2_000;

async function waitForDependencies() {
let timeout: ReturnType<typeof setTimeout> | undefined;

try {
await Promise.race([
Promise.all([db.$queryRaw`SELECT 1`, getRedis().ping()]),
new Promise<never>((_, reject) => {
timeout = setTimeout(
() => reject(new Error("Healthcheck timed out")),
HEALTHCHECK_TIMEOUT_MS,
);
}),
]);
} finally {
if (timeout) clearTimeout(timeout);
}
}

export async function GET() {
return Response.json({ data: "Healthy" });
const commitSha = process.env.RAILWAY_GIT_COMMIT_SHA ?? "unknown";

try {
await waitForDependencies();

return Response.json(
{ data: "Healthy", commitSha },
{ headers: { "Cache-Control": "no-store" } },
);
} catch {
return Response.json(
{ data: "Unhealthy", commitSha },
{
status: 503,
headers: { "Cache-Control": "no-store" },
},
);
}
}
3 changes: 1 addition & 2 deletions docker/start.sh
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
#!/bin/sh

set -x
set -eu

echo "Deploying prisma migrations"

Expand All @@ -9,4 +9,3 @@ pnpx prisma@6.6.0 migrate deploy --schema ./apps/web/prisma/schema.prisma
echo "Starting web server"

node apps/web/server.js

3 changes: 2 additions & 1 deletion turbo.json
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,8 @@
"S3_COMPATIBLE_ACCESS_KEY",
"S3_COMPATIBLE_SECRET_KEY",
"S3_COMPATIBLE_API_URL",
"S3_COMPATIBLE_PUBLIC_URL"
"S3_COMPATIBLE_PUBLIC_URL",
"RAILWAY_GIT_COMMIT_SHA"
]
},
"lint": {
Expand Down
Loading