diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..ed55ed6 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,36 @@ +name: Test and build +on: + push: + branches: [main] + pull_request: + workflow_dispatch: + +jobs: + backend: + runs-on: ubuntu-latest + defaults: + run: + working-directory: backend + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: '20' + cache: npm + cache-dependency-path: backend/package-lock.json + - run: npm ci + - run: npm test + frontend: + runs-on: ubuntu-latest + defaults: + run: + working-directory: frontend + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: '20' + cache: npm + cache-dependency-path: frontend/package-lock.json + - run: npm ci + - run: npm run build diff --git a/backend/package.json b/backend/package.json index 5336eca..236a919 100644 --- a/backend/package.json +++ b/backend/package.json @@ -10,7 +10,9 @@ "prebuild": "npm install", "build": "tsc", "start": "node dist/server.js", - "dev": "ts-node-dev --respawn --transpile-only src/server.ts" + "dev": "ts-node-dev --respawn --transpile-only src/server.ts", + "test": "npm run build:ci && node --test tests/*.test.cjs", + "build:ci": "tsc" }, "dependencies": { "@google/generative-ai": "^0.21.0", diff --git a/backend/tests/utils.test.cjs b/backend/tests/utils.test.cjs new file mode 100644 index 0000000..e4cd82a --- /dev/null +++ b/backend/tests/utils.test.cjs @@ -0,0 +1,29 @@ +// Run against compiled backend code. No database, API key or server required. +const { test } = require('node:test'); +const assert = require('node:assert/strict'); +const { AppError } = require('../dist/utils/AppError.js'); +const { sendSuccess } = require('../dist/utils/response.js'); +const { asyncHandler } = require('../dist/utils/asyncHandler.js'); + +test('AppError retains status and operational details', () => { + const err = new AppError('Invalid input', 422, [{ field: 'title' }]); + assert.equal(err.message, 'Invalid input'); + assert.equal(err.statusCode, 422); + assert.equal(err.isOperational, true); + assert.deepEqual(err.errors, [{ field: 'title' }]); +}); + +test('sendSuccess sends the public API envelope and status', () => { + const res = { status(code) { this.code = code; return this; }, json(body) { this.body = body; return this; } }; + sendSuccess(res, { id: 't1' }, 'Created', 201); + assert.equal(res.code, 201); + assert.deepEqual(res.body, { status: 'success', message: 'Created', data: { id: 't1' } }); +}); + +test('asyncHandler passes rejected route errors to next', async () => { + const error = new Error('route failed'); + const forwarded = new Promise((resolve) => { + asyncHandler(async () => { throw error; })({}, {}, resolve); + }); + assert.equal(await forwarded, error); +});