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: {} })),