Skip to content
Open
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
65 changes: 65 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
name: Unit Tests

on:
push:
branches: [main]
pull_request:
branches: [main]

permissions:
contents: read

jobs:
test-shared:
name: Test @mono/shared
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4

- uses: pnpm/action-setup@v4
with:
version: 9.1.0

- uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'pnpm'

- run: pnpm install --frozen-lockfile
- run: pnpm --filter @mono/shared test
Comment on lines +13 to +29
Copy link

Copilot AI Feb 10, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The workflow repeats the same checkout/pnpm setup/install steps across three jobs. A matrix job (or a reusable composite action) would reduce duplication and make future changes (Node/pnpm version bumps, install flags) less error-prone.

Copilot uses AI. Check for mistakes.

test-github:
name: Test @mono/github
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4

- uses: pnpm/action-setup@v4
with:
version: 9.1.0

- uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'pnpm'

- run: pnpm install --frozen-lockfile
- run: pnpm --filter @mono/github test

test-api-gateway:
name: Test @mono/api-gateway
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4

- uses: pnpm/action-setup@v4
with:
version: 9.1.0

- uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'pnpm'

- run: pnpm install --frozen-lockfile
- run: pnpm --filter @mono/api-gateway test
108 changes: 108 additions & 0 deletions apps/api-gateway/src/middleware/auth.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
jest.mock('../config/env.js', () => ({
env: {
NODE_ENV: 'development',
PORT: 3000,
DATABASE_URL: 'postgresql://test:test@localhost:5432/test',
GITHUB_APP_ID: 'test-app-id',
GITHUB_APP_PRIVATE_KEY: 'dGVzdA==',
GITHUB_CLIENT_ID: 'test-client-id',
GITHUB_CLIENT_SECRET: 'test-client-secret',
JWT_SECRET: 'test-jwt-secret-that-is-at-least-32-chars-long',
APP_URL: 'http://localhost:3000',
},
getGitHubPrivateKey: () => 'test',
}));

import type { Request, Response, NextFunction } from 'express';

import { requireAuth, signToken, JwtPayload } from './auth';

describe('signToken', () => {
it('should return a JWT string', () => {
const payload: JwtPayload = {
userId: 'user-1',
githubUsername: 'octocat',
role: 'user',
};

const token = signToken(payload);

expect(typeof token).toBe('string');
expect(token.split('.')).toHaveLength(3); // JWT has 3 parts
});
});

describe('requireAuth', () => {
let mockRes: Partial<Response>;
let mockNext: NextFunction;
let jsonMock: jest.Mock;
let statusMock: jest.Mock;

beforeEach(() => {
jsonMock = jest.fn();
statusMock = jest.fn().mockReturnValue({ json: jsonMock });
mockRes = { status: statusMock, json: jsonMock };
mockNext = jest.fn();
});

it('should return 401 when no Authorization header', () => {
const mockReq = { headers: {} } as Request;

requireAuth(mockReq, mockRes as Response, mockNext);

expect(statusMock).toHaveBeenCalledWith(401);
expect(jsonMock).toHaveBeenCalledWith(
expect.objectContaining({
success: false,
error: 'Missing or invalid Authorization header',
})
);
expect(mockNext).not.toHaveBeenCalled();
});

it('should return 401 when Authorization header does not start with Bearer', () => {
const mockReq = { headers: { authorization: 'Basic abc' } } as Request;

requireAuth(mockReq, mockRes as Response, mockNext);

expect(statusMock).toHaveBeenCalledWith(401);
expect(mockNext).not.toHaveBeenCalled();
});

it('should return 401 for invalid token', () => {
const mockReq = {
headers: { authorization: 'Bearer invalid-token' },
} as Request;

requireAuth(mockReq, mockRes as Response, mockNext);

expect(statusMock).toHaveBeenCalledWith(401);
expect(jsonMock).toHaveBeenCalledWith(
expect.objectContaining({
success: false,
error: 'Invalid or expired token',
})
);
expect(mockNext).not.toHaveBeenCalled();
});

it('should call next and attach user for valid token', () => {
const payload: JwtPayload = {
userId: 'user-1',
githubUsername: 'octocat',
role: 'user',
};
const token = signToken(payload);
const mockReq = {
headers: { authorization: `Bearer ${token}` },
} as Request;

requireAuth(mockReq, mockRes as Response, mockNext);

expect(mockNext).toHaveBeenCalled();
expect(mockReq.user).toBeDefined();
expect(mockReq.user!.userId).toBe('user-1');
expect(mockReq.user!.githubUsername).toBe('octocat');
expect(mockReq.user!.role).toBe('user');
});
});
83 changes: 83 additions & 0 deletions apps/api-gateway/src/middleware/error-handler.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
jest.mock('../config/env.js', () => ({
env: {
NODE_ENV: 'development',
PORT: 3000,
DATABASE_URL: 'postgresql://test:test@localhost:5432/test',
GITHUB_APP_ID: 'test-app-id',
GITHUB_APP_PRIVATE_KEY: 'dGVzdA==',
GITHUB_CLIENT_ID: 'test-client-id',
GITHUB_CLIENT_SECRET: 'test-client-secret',
JWT_SECRET: 'test-jwt-secret-that-is-at-least-32-chars-long',
APP_URL: 'http://localhost:3000',
},
getGitHubPrivateKey: () => 'test',
}));

import type { Request, Response, NextFunction } from 'express';

import { errorHandler, AppError } from './error-handler';

describe('errorHandler', () => {
let mockReq: Partial<Request>;
let mockRes: Partial<Response>;
let mockNext: NextFunction;
let jsonMock: jest.Mock;
let statusMock: jest.Mock;

beforeEach(() => {
jsonMock = jest.fn();
statusMock = jest.fn().mockReturnValue({ json: jsonMock });
mockReq = {};
mockRes = { status: statusMock, json: jsonMock };
mockNext = jest.fn();
jest.spyOn(console, 'error').mockImplementation(() => {});
});

afterEach(() => {
jest.restoreAllMocks();
});

it('should respond with 500 and default message when error has no statusCode', () => {
const err: AppError = new Error('Unexpected failure');

errorHandler(err, mockReq as Request, mockRes as Response, mockNext);

expect(statusMock).toHaveBeenCalledWith(500);
expect(jsonMock).toHaveBeenCalledWith(
expect.objectContaining({
success: false,
error: 'Unexpected failure',
})
);
});

it('should use error statusCode when provided', () => {
const err: AppError = new Error('Not found');
err.statusCode = 404;

errorHandler(err, mockReq as Request, mockRes as Response, mockNext);

expect(statusMock).toHaveBeenCalledWith(404);
expect(jsonMock).toHaveBeenCalledWith(
expect.objectContaining({
success: false,
error: 'Not found',
})
);
});

it('should use "Internal Server Error" when error has no message', () => {
const err: AppError = new Error();
err.message = '';

errorHandler(err, mockReq as Request, mockRes as Response, mockNext);

expect(statusMock).toHaveBeenCalledWith(500);
expect(jsonMock).toHaveBeenCalledWith(
expect.objectContaining({
success: false,
error: 'Internal Server Error',
})
);
});
});
17 changes: 17 additions & 0 deletions packages/github/jest.config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
/** @type {import('ts-jest').JestConfigWithTsJest} */
module.exports = {
preset: 'ts-jest',
testEnvironment: 'node',
roots: ['<rootDir>/src'],
testMatch: ['**/*.test.ts'],
moduleFileExtensions: ['ts', 'js', 'json'],
transform: {
'^.+\\.ts$': 'ts-jest',
},
moduleNameMapper: {
'^(\\.{1,2}/.*)\\.js$': '$1',
},
collectCoverageFrom: ['src/**/*.ts', '!src/**/*.test.ts'],
coverageDirectory: 'coverage',
verbose: true,
};
9 changes: 7 additions & 2 deletions packages/github/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,16 +9,21 @@
"clean": "rm -rf dist",
"dev": "tsc --watch",
"lint": "eslint src/",
"test": "jest",
"test:coverage": "jest --coverage",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@octokit/core": "^6.1.0",
"@octokit/auth-app": "^7.1.0"
"@octokit/auth-app": "^7.1.0",
"@octokit/core": "^6.1.0"
},
"devDependencies": {
"@mono/eslint-config": "workspace:*",
"@mono/typescript-config": "workspace:*",
"@types/jest": "^29.5.12",
"@types/node": "^20.11.0",
"jest": "^29.7.0",
"ts-jest": "^29.1.2",
"typescript": "^5.3.3"
}
}
50 changes: 50 additions & 0 deletions packages/github/src/app-auth.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
jest.mock('@octokit/core', () => ({
Octokit: jest.fn().mockImplementation((opts) => ({ auth: opts.auth })),
}));
jest.mock('@octokit/auth-app', () => ({
createAppAuth: jest.fn().mockReturnValue('app-auth-strategy'),
}));

import { createAppOctokit, createInstallationOctokit } from './app-auth';
import { Octokit } from '@octokit/core';

describe('createAppOctokit', () => {
beforeEach(() => {
jest.clearAllMocks();
});

it('should create an Octokit instance with app auth', () => {
const result = createAppOctokit('app-123', 'private-key');

expect(Octokit).toHaveBeenCalledWith(
expect.objectContaining({
auth: {
appId: 'app-123',
privateKey: 'private-key',
},
})
);
expect(result).toBeDefined();
});
});

describe('createInstallationOctokit', () => {
beforeEach(() => {
jest.clearAllMocks();
});

it('should create an Octokit instance with installation auth', () => {
const result = createInstallationOctokit('app-123', 'private-key', 456);

expect(Octokit).toHaveBeenCalledWith(
expect.objectContaining({
auth: {
appId: 'app-123',
privateKey: 'private-key',
installationId: 456,
},
})
);
expect(result).toBeDefined();
});
});
Loading
Loading