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
39 changes: 39 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
name: CI

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

jobs:
build-and-test:
name: Build & Test
runs-on: ubuntu-latest

steps:
- name: Checkout code
uses: actions/checkout@v4

- name: Setup pnpm
uses: pnpm/action-setup@v4

- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'pnpm'

- name: Install dependencies
run: pnpm install --frozen-lockfile

- name: Build common package
run: pnpm --filter common build

- name: Typecheck
run: pnpm --filter api-gateway exec tsc --noEmit

- name: Run unit tests
run: pnpm --filter api-gateway test
env:
NODE_ENV: test
1 change: 0 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@ node_modules/
package-lock.json
.env
/prisma/generated/prisma
pnpm-lock.yaml
.prettierrc
generated/
dist/
Expand Down
8 changes: 6 additions & 2 deletions apps/api-gateway/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,9 @@
"description": "",
"main": "index.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1",
"test": "vitest run",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage",
"build": "tsc -p tsconfig.json",
"dev": "tsx watch src/index.ts",
"start": "node ./dist/index.js"
Expand All @@ -21,12 +23,14 @@
"@types/express-pino-logger": "^4.0.6",
"@types/jsonwebtoken": "^9.0.10",
"@types/node": "^24.5.2",
"@vitest/coverage-v8": "^4.0.18",
"nodemon": "^3.1.10",
"pino-pretty": "^13.1.2",
"prisma": "^6.16.2",
"ts-node": "^10.9.2",
"tsx": "^4.20.6",
"typescript": "^5.9.2"
"typescript": "^5.9.2",
"vitest": "^4.0.18"
},
"dependencies": {
"@logtail/pino": "^0.5.6",
Expand Down
100 changes: 100 additions & 0 deletions apps/api-gateway/src/schemas/schemas.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
import { describe, it, expect } from 'vitest';
import { driverStatusSchema, driverLocationSchema, rideSchema } from 'common';

describe('Zod Schemas', () => {

// ── driverStatusSchema ────────────────────────────────────────

describe('driverStatusSchema', () => {
it('accepts going offline without location', () => {
const result = driverStatusSchema.safeParse({ body: { isActive: false } });
expect(result.success).toBe(true);
});

it('accepts going online with location', () => {
const result = driverStatusSchema.safeParse({
body: { isActive: true, location: [77.59, 12.97] },
});
expect(result.success).toBe(true);
});

it('rejects going online without location (refine)', () => {
const result = driverStatusSchema.safeParse({
body: { isActive: true },
});
expect(result.success).toBe(false);
if (!result.success) {
const paths = result.error.issues.map((i) => i.path.join('.'));
expect(paths).toContain('body.location');
}
});

it('rejects non-boolean isActive', () => {
const result = driverStatusSchema.safeParse({
body: { isActive: 'yes' },
});
expect(result.success).toBe(false);
});

it('rejects location with wrong length', () => {
const result = driverStatusSchema.safeParse({
body: { isActive: true, location: [77.59] },
});
expect(result.success).toBe(false);
});
});

// ── driverLocationSchema ──────────────────────────────────────

describe('driverLocationSchema', () => {
it('accepts valid [lon, lat] tuple', () => {
const result = driverLocationSchema.safeParse({
body: { location: [77.5946, 12.9716] },
});
expect(result.success).toBe(true);
});

it('rejects string coordinates', () => {
const result = driverLocationSchema.safeParse({
body: { location: ['77.59', '12.97'] },
});
expect(result.success).toBe(false);
});

it('rejects missing location', () => {
const result = driverLocationSchema.safeParse({ body: {} });
expect(result.success).toBe(false);
});
});

// ── rideSchema ────────────────────────────────────────────────

describe('rideSchema', () => {
it('accepts valid pickup and dropoff coordinates', () => {
const result = rideSchema.safeParse({
body: {
pickupLocation: [77.5946, 12.9716],
dropoffLocation: [77.6046, 12.9816],
},
});
expect(result.success).toBe(true);
});

it('rejects locations with wrong number of elements', () => {
const result = rideSchema.safeParse({
body: {
pickupLocation: [77.5946],
dropoffLocation: [77.6046, 12.9816],
},
});
expect(result.success).toBe(false);
});

it('rejects missing dropoffLocation', () => {
const result = rideSchema.safeParse({
body: { pickupLocation: [77.5946, 12.9716] },
});
expect(result.success).toBe(false);
});
});
});
172 changes: 172 additions & 0 deletions apps/api-gateway/src/services/__tests__/auth.service.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,172 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';

// vi.hoisted ensures these are created before vi.mock hoisting
const prismaMock = vi.hoisted(() => ({
user: { findUnique: vi.fn(), create: vi.fn(), update: vi.fn() },
driver: { findUnique: vi.fn(), create: vi.fn(), update: vi.fn() },
rider: { findUnique: vi.fn(), create: vi.fn() },
trip: { findFirst: vi.fn(), update: vi.fn() },
$transaction: vi.fn(),
$queryRaw: vi.fn(),
}));

vi.mock('../../utils/db.js', () => ({ default: prismaMock }));
vi.mock('../../utils/password.js', () => ({
hashPassword: vi.fn().mockResolvedValue('hashed_password'),
comparePassword: vi.fn(),
}));
vi.mock('../../utils/jwt.js', () => ({
generateToken: vi.fn().mockReturnValue('mock.jwt.token'),
}));
vi.mock('../../logger.js', () => ({ default: { info: vi.fn(), error: vi.fn(), warn: vi.fn() } }));

import { comparePassword } from '../../utils/password.js';
import { registerUser, loginUser, getUserProfile } from '../auth.service.js';

const mockUser = {
id: 'user-1',
name: 'Test User',
number: '1234567890',
password: 'hashed_password',
createdAt: new Date('2024-01-01'),
updatedAt: new Date('2024-01-01'),
};

describe('auth.service', () => {
beforeEach(() => {
vi.clearAllMocks();
});

// ── registerUser ──────────────────────────────────────────────

describe('registerUser', () => {
it('returns error if user already exists', async () => {
prismaMock.user.findUnique.mockResolvedValue(mockUser);

const result = await registerUser({
name: 'Test',
number: '1234567890',
password: 'password',
role: 'rider',
});

expect(result.success).toBe(false);
expect(result.message).toMatch(/already exists/i);
});

it('creates rider user successfully', async () => {
prismaMock.user.findUnique.mockResolvedValue(null);
prismaMock.$transaction.mockImplementation(async (fn: (tx: typeof prismaMock) => Promise<typeof mockUser>) => fn(prismaMock));
prismaMock.user.create.mockResolvedValue(mockUser);
prismaMock.rider.create.mockResolvedValue({ id: 'rider-1', userId: 'user-1' });

const result = await registerUser({
name: 'Test User',
number: '1234567890',
password: 'password123',
role: 'rider',
});

expect(result.success).toBe(true);
expect(result.data?.token).toBe('mock.jwt.token');
expect(result.data?.user.role).toBe('rider');
expect(prismaMock.rider.create).toHaveBeenCalledOnce();
expect(prismaMock.driver.create).not.toHaveBeenCalled();
});

it('creates driver user successfully', async () => {
prismaMock.user.findUnique.mockResolvedValue(null);
prismaMock.$transaction.mockImplementation(async (fn: (tx: typeof prismaMock) => Promise<typeof mockUser>) => fn(prismaMock));
prismaMock.user.create.mockResolvedValue(mockUser);
prismaMock.driver.create.mockResolvedValue({ id: 'driver-1', userId: 'user-1', isActive: false });

const result = await registerUser({
name: 'Test Driver',
number: '9999999999',
password: 'password123',
role: 'driver',
});

expect(result.success).toBe(true);
expect(result.data?.user.role).toBe('driver');
expect(prismaMock.driver.create).toHaveBeenCalledOnce();
expect(prismaMock.rider.create).not.toHaveBeenCalled();
});
});

// ── loginUser ─────────────────────────────────────────────────

describe('loginUser', () => {
it('returns error if user not found', async () => {
prismaMock.user.findUnique.mockResolvedValue(null);

const result = await loginUser({ number: '0000000000', password: 'pw' });

expect(result.success).toBe(false);
expect(result.message).toMatch(/invalid credentials/i);
});

it('returns error if password is wrong', async () => {
prismaMock.user.findUnique.mockResolvedValue({ ...mockUser, driver: null, rider: { id: 'r1' } });
vi.mocked(comparePassword).mockResolvedValue(false);

const result = await loginUser({ number: '1234567890', password: 'wrong' });

expect(result.success).toBe(false);
expect(result.message).toMatch(/invalid credentials/i);
});

it('logs in rider successfully', async () => {
prismaMock.user.findUnique.mockResolvedValue({
...mockUser,
driver: null,
rider: { id: 'rider-1', userId: 'user-1' },
});
vi.mocked(comparePassword).mockResolvedValue(true);

const result = await loginUser({ number: '1234567890', password: 'correct' });

expect(result.success).toBe(true);
expect(result.data?.user.role).toBe('rider');
expect(result.data?.token).toBe('mock.jwt.token');
});

it('logs in driver successfully', async () => {
prismaMock.user.findUnique.mockResolvedValue({
...mockUser,
driver: { id: 'driver-1', userId: 'user-1', isActive: true },
rider: null,
});
vi.mocked(comparePassword).mockResolvedValue(true);

const result = await loginUser({ number: '1234567890', password: 'correct' });

expect(result.success).toBe(true);
expect(result.data?.user.role).toBe('driver');
});
});

// ── getUserProfile ────────────────────────────────────────────

describe('getUserProfile', () => {
it('returns null if user not found', async () => {
prismaMock.user.findUnique.mockResolvedValue(null);

const result = await getUserProfile('non-existent-id');
expect(result).toBeNull();
});

it('returns profile with correct role', async () => {
prismaMock.user.findUnique.mockResolvedValue({
...mockUser,
driver: { id: 'driver-1' },
rider: null,
});

const profile = await getUserProfile('user-1');

expect(profile?.role).toBe('driver');
expect(profile?.number).toBe('1234567890');
});
});
});
Loading