From 508fa39835ed5f01bf0bf11e7cbcc8863b28e8c8 Mon Sep 17 00:00:00 2001 From: Jackson Roberts Date: Fri, 27 Feb 2026 16:11:28 -0600 Subject: [PATCH] fix: crash loop recovery, auto-migrations, CI test pipeline - Wrap startup syncs in safeStartup() to prevent crash on external service failure - Add migrate-entrypoint.sh for auto-generating migrations from schema diff - Update Dockerfile migration stage to use entrypoint script - Add test job to build-and-publish workflow (runs before build) - Add tests.yaml workflow to run tests on every push - Fix test setup to use real RSA key pair instead of plain strings - Add test script to package.json --- .github/workflows/build-and-publish.yaml | 20 ++++++++++++ .github/workflows/tests.yaml | 25 +++++++++++++++ .vscode/settings.json | 5 +++ Dockerfile | 5 ++- package.json | 1 + prisma/migrate-entrypoint.sh | 23 ++++++++++++++ src/index.ts | 39 ++++++++++++++++++------ tests/setup.ts | 22 ++++++++++--- 8 files changed, 124 insertions(+), 16 deletions(-) create mode 100644 .github/workflows/tests.yaml create mode 100644 .vscode/settings.json create mode 100755 prisma/migrate-entrypoint.sh diff --git a/.github/workflows/build-and-publish.yaml b/.github/workflows/build-and-publish.yaml index 52f518d..7c5a46a 100644 --- a/.github/workflows/build-and-publish.yaml +++ b/.github/workflows/build-and-publish.yaml @@ -5,8 +5,28 @@ on: types: [created] jobs: + test: + name: Test + runs-on: ubuntu-latest + steps: + - name: Checkout source code + uses: actions/checkout@v4 + + - name: Setup Bun + uses: oven-sh/setup-bun@v2 + + - name: Install dependencies + run: bun install --frozen-lockfile + + - name: Generate Prisma client + run: DATABASE_URL="postgresql://dummy:dummy@localhost:5432/dummy" bunx prisma generate + + - name: Run tests + run: bun test --preload ./tests/setup.ts + build: name: Build + needs: [test] runs-on: ubuntu-latest permissions: contents: read diff --git a/.github/workflows/tests.yaml b/.github/workflows/tests.yaml new file mode 100644 index 0000000..706e3af --- /dev/null +++ b/.github/workflows/tests.yaml @@ -0,0 +1,25 @@ +name: Tests + +on: + push: + branches: ["**"] + +jobs: + test: + name: Test + runs-on: ubuntu-latest + steps: + - name: Checkout source code + uses: actions/checkout@v4 + + - name: Setup Bun + uses: oven-sh/setup-bun@v2 + + - name: Install dependencies + run: bun install --frozen-lockfile + + - name: Generate Prisma client + run: DATABASE_URL="postgresql://dummy:dummy@localhost:5432/dummy" bunx prisma generate + + - name: Run tests + run: bun test --preload ./tests/setup.ts diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..3c5f7cf --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,5 @@ +{ + "chat.tools.terminal.autoApprove": { + "bun": true + } +} diff --git a/Dockerfile b/Dockerfile index a81094a..3c057de 100644 --- a/Dockerfile +++ b/Dockerfile @@ -67,4 +67,7 @@ RUN bun install --frozen-lockfile COPY prisma/ prisma/ COPY prisma.config.ts ./ -CMD ["bunx", "prisma", "migrate", "deploy"] \ No newline at end of file +COPY prisma/migrate-entrypoint.sh ./prisma/migrate-entrypoint.sh +RUN chmod +x prisma/migrate-entrypoint.sh + +CMD ["sh", "prisma/migrate-entrypoint.sh"] \ No newline at end of file diff --git a/package.json b/package.json index 4a515ec..911cb3a 100644 --- a/package.json +++ b/package.json @@ -19,6 +19,7 @@ }, "scripts": { "dev": "NODE_ENV=development bun --watch src/index.ts", + "test": "bun test --preload ./tests/setup.ts", "db:gen": "prisma generate", "db:push": "prisma migrate dev --skip-generate", "db:deploy": "prisma migrate deploy", diff --git a/prisma/migrate-entrypoint.sh b/prisma/migrate-entrypoint.sh new file mode 100755 index 0000000..91ebb5b --- /dev/null +++ b/prisma/migrate-entrypoint.sh @@ -0,0 +1,23 @@ +#!/bin/sh +set -e + +# Generate diff SQL between current migrations and the Prisma schema +DIFF_SQL=$(bunx prisma migrate diff \ + --from-migrations prisma/migrations \ + --to-schema-datamodel prisma/schema.prisma \ + --script 2>/dev/null || true) + +# If there's a meaningful diff (not just empty/comments), create a migration +if [ -n "$DIFF_SQL" ] && echo "$DIFF_SQL" | grep -qvE '^\s*$|^--'; then + TIMESTAMP=$(date -u +"%Y%m%d%H%M%S") + MIGRATION_DIR="prisma/migrations/${TIMESTAMP}_auto_generated" + mkdir -p "$MIGRATION_DIR" + echo "$DIFF_SQL" > "$MIGRATION_DIR/migration.sql" + echo "[migrate] Created migration: $MIGRATION_DIR" +else + echo "[migrate] Schema and migrations are in sync — no migration needed." +fi + +# Deploy all pending migrations +echo "[migrate] Running prisma migrate deploy..." +bunx prisma migrate deploy diff --git a/src/index.ts b/src/index.ts index e7e6ec2..c9a73f2 100644 --- a/src/index.ts +++ b/src/index.ts @@ -45,36 +45,55 @@ if (!existingAdmin) { events.emit("role:created", new RoleController(created)); } +// Helper to run a startup sync safely — failures are logged but never crash the process. +const safeStartup = async (label: string, fn: () => Promise) => { + try { + await fn(); + } catch (err) { + console.error(`[startup] ${label} failed — will retry on next interval`, err); + } +}; + // Refresh the internal list of companies every minute -await refreshCompanies(); +await safeStartup("refreshCompanies", refreshCompanies); setInterval(() => { - return refreshCompanies(); + return refreshCompanies().catch((err) => + console.error("[interval] refreshCompanies failed", err), + ); }, 60 * 1000); // Refresh the internal catalog every minute -await refreshCatalog(); +await safeStartup("refreshCatalog", refreshCatalog); setInterval(() => { - return refreshCatalog(); + return refreshCatalog().catch((err) => + console.error("[interval] refreshCatalog failed", err), + ); }, 60 * 1000); // Refresh inventory on hand every 2 minutes -await refreshInventory(); +await safeStartup("refreshInventory", refreshInventory); setInterval( () => { - return refreshInventory(); + return refreshInventory().catch((err) => + console.error("[interval] refreshInventory failed", err), + ); }, 2 * 60 * 1000, ); // Refresh opportunities every minute -await refreshOpportunities(); +await safeStartup("refreshOpportunities", refreshOpportunities); setInterval(() => { - return refreshOpportunities(); + return refreshOpportunities().catch((err) => + console.error("[interval] refreshOpportunities failed", err), + ); }, 60 * 1000); -await unifiSites.syncSites(); +await safeStartup("syncSites", () => unifiSites.syncSites()); setInterval(() => { - return unifiSites.syncSites(); + return unifiSites.syncSites().catch((err) => + console.error("[interval] syncSites failed", err), + ); }, 60 * 1000); Bun.serve({ diff --git a/tests/setup.ts b/tests/setup.ts index c14e48b..ba9f6cb 100644 --- a/tests/setup.ts +++ b/tests/setup.ts @@ -4,6 +4,18 @@ */ import { mock } from "bun:test"; +import crypto from "crypto"; + +// --------------------------------------------------------------------------- +// Generate a real RSA key pair for modules that call crypto.createPrivateKey() +// at import time (e.g. readSecureValue.ts). +// --------------------------------------------------------------------------- +const { privateKey: _testPrivateKey, publicKey: _testPublicKey } = + crypto.generateKeyPairSync("rsa", { + modulusLength: 2048, + privateKeyEncoding: { type: "pkcs8", format: "pem" }, + publicKeyEncoding: { type: "spki", format: "pem" }, + }); // --------------------------------------------------------------------------- // Mock the constants module — almost every source file imports from here. @@ -17,11 +29,11 @@ mock.module("../src/constants", () => ({ sessionDuration: 30 * 24 * 60 * 60_000, accessTokenDuration: "10min", refreshTokenDuration: "30d", - accessTokenPrivateKey: "mock-access-private-key", - refreshTokenPrivateKey: "mock-refresh-private-key", - permissionsPrivateKey: "mock-permissions-private-key", - secureValuesPrivateKey: "mock-secure-values-private-key", - secureValuesPublicKey: "mock-secure-values-public-key", + accessTokenPrivateKey: _testPrivateKey, + refreshTokenPrivateKey: _testPrivateKey, + permissionsPrivateKey: _testPrivateKey, + secureValuesPrivateKey: _testPrivateKey, + secureValuesPublicKey: _testPublicKey, msalClient: { acquireTokenByCode: mock(() => Promise.resolve({})) }, connectWiseApi: { get: mock(() => Promise.resolve({ data: {} })),