From 677ac44e70e3f113ec162ce1380f8b3a995d8390 Mon Sep 17 00:00:00 2001 From: okekefrancis112 Date: Wed, 25 Feb 2026 14:19:05 +0100 Subject: [PATCH 1/3] feat: added Pagination Helper and Consistent Response Shape --- jest.config.js => jest.config.cjs | 7 +++-- src/index.test.ts | 29 ++++++++++++++++- src/index.ts | 11 ++++--- src/lib/__tests__/pagination.test.ts | 47 ++++++++++++++++++++++++++++ src/lib/pagination.ts | 41 ++++++++++++++++++++++++ src/repositories/userRepository.ts | 26 ++++++--------- src/routes/admin.ts | 9 +++--- 7 files changed, 141 insertions(+), 29 deletions(-) rename jest.config.js => jest.config.cjs (53%) create mode 100644 src/lib/__tests__/pagination.test.ts create mode 100644 src/lib/pagination.ts diff --git a/jest.config.js b/jest.config.cjs similarity index 53% rename from jest.config.js rename to jest.config.cjs index 779b71c..4db4391 100644 --- a/jest.config.js +++ b/jest.config.cjs @@ -2,5 +2,8 @@ module.exports = { preset: 'ts-jest', testEnvironment: 'node', - testMatch: ['**/?(*.)+(spec|test).ts'] -}; \ No newline at end of file + testMatch: ['**/?(*.)+(spec|test).ts'], + moduleNameMapper: { + '^(\\.{1,2}/.*)\\.js$': '$1', + }, +}; diff --git a/src/index.test.ts b/src/index.test.ts index 98e571a..bd65f03 100644 --- a/src/index.test.ts +++ b/src/index.test.ts @@ -1,5 +1,7 @@ import request from 'supertest'; -import express from 'express'; + +jest.mock('./lib/prisma.js', () => ({ default: {} })); + import app from './index.js'; describe('Health API', () => { @@ -8,4 +10,29 @@ describe('Health API', () => { expect(response.status).toBe(200); expect(response.body.status).toBe('ok'); }); +}); + +describe('API list endpoints', () => { + it('GET /api/apis returns paginated response shape', async () => { + const response = await request(app).get('/api/apis'); + expect(response.status).toBe(200); + expect(response.body).toHaveProperty('data'); + expect(response.body).toHaveProperty('meta'); + expect(response.body.data).toEqual([]); + expect(response.body.meta).toMatchObject({ limit: 20, offset: 0 }); + }); + + it('GET /api/usage returns paginated response shape', async () => { + const response = await request(app).get('/api/usage'); + expect(response.status).toBe(200); + expect(response.body).toHaveProperty('data'); + expect(response.body).toHaveProperty('meta'); + expect(response.body.data).toEqual([]); + expect(response.body.meta).toMatchObject({ limit: 20, offset: 0 }); + }); + + it('respects limit and offset query params', async () => { + const response = await request(app).get('/api/apis?limit=5&offset=10'); + expect(response.body.meta).toMatchObject({ limit: 5, offset: 10 }); + }); }); \ No newline at end of file diff --git a/src/index.ts b/src/index.ts index e081239..ec3d580 100644 --- a/src/index.ts +++ b/src/index.ts @@ -2,6 +2,7 @@ import 'dotenv/config'; import express from 'express'; import cors from 'cors'; import adminRouter from './routes/admin.js'; +import { parsePagination, paginatedResponse } from './lib/pagination.js'; const app = express(); const PORT = process.env.PORT ?? 3000; @@ -32,12 +33,14 @@ app.get('/api/health', (_req, res) => { res.json({ status: 'ok', service: 'callora-backend' }); }); -app.get('/api/apis', (_req, res) => { - res.json({ apis: [] }); +app.get('/api/apis', (req, res) => { + const { limit, offset } = parsePagination(req.query as { limit?: string; offset?: string }); + res.json(paginatedResponse([], { limit, offset })); }); -app.get('/api/usage', (_req, res) => { - res.json({ calls: 0, period: 'current' }); +app.get('/api/usage', (req, res) => { + const { limit, offset } = parsePagination(req.query as { limit?: string; offset?: string }); + res.json(paginatedResponse([], { limit, offset })); }); if (process.env.NODE_ENV !== 'test') { diff --git a/src/lib/__tests__/pagination.test.ts b/src/lib/__tests__/pagination.test.ts new file mode 100644 index 0000000..fc7e17e --- /dev/null +++ b/src/lib/__tests__/pagination.test.ts @@ -0,0 +1,47 @@ +import { parsePagination, paginatedResponse } from '../pagination.js'; + +describe('parsePagination', () => { + it('returns defaults when no query params given', () => { + expect(parsePagination({})).toEqual({ limit: 20, offset: 0 }); + }); + + it('parses valid limit and offset', () => { + expect(parsePagination({ limit: '10', offset: '30' })).toEqual({ limit: 10, offset: 30 }); + }); + + it('clamps limit to max 100', () => { + expect(parsePagination({ limit: '500' })).toEqual({ limit: 100, offset: 0 }); + }); + + it('clamps limit to min 1', () => { + expect(parsePagination({ limit: '0' })).toEqual({ limit: 1, offset: 0 }); + expect(parsePagination({ limit: '-5' })).toEqual({ limit: 1, offset: 0 }); + }); + + it('clamps offset to min 0', () => { + expect(parsePagination({ offset: '-10' })).toEqual({ limit: 20, offset: 0 }); + }); + + it('handles non-numeric strings gracefully', () => { + expect(parsePagination({ limit: 'abc', offset: 'xyz' })).toEqual({ limit: 20, offset: 0 }); + }); +}); + +describe('paginatedResponse', () => { + it('wraps data and meta into the envelope', () => { + const result = paginatedResponse([{ id: '1' }], { total: 1, limit: 20, offset: 0 }); + expect(result).toEqual({ + data: [{ id: '1' }], + meta: { total: 1, limit: 20, offset: 0 }, + }); + }); + + it('works without total in meta', () => { + const result = paginatedResponse([], { limit: 20, offset: 0 }); + expect(result).toEqual({ + data: [], + meta: { limit: 20, offset: 0 }, + }); + expect(result.meta).not.toHaveProperty('total'); + }); +}); diff --git a/src/lib/pagination.ts b/src/lib/pagination.ts new file mode 100644 index 0000000..fcd34d8 --- /dev/null +++ b/src/lib/pagination.ts @@ -0,0 +1,41 @@ +export interface PaginationParams { + limit: number; + offset: number; +} + +export interface PaginationMeta { + total?: number; + limit: number; + offset: number; +} + +export interface PaginatedResponse { + data: T[]; + meta: PaginationMeta; +} + +const DEFAULT_LIMIT = 20; +const MAX_LIMIT = 100; + +export function parsePagination(query: { + limit?: string; + offset?: string; +}): PaginationParams { + const parsedLimit = parseInt(query.limit ?? '', 10); + const limit = Math.min( + MAX_LIMIT, + Math.max(1, Number.isNaN(parsedLimit) ? DEFAULT_LIMIT : parsedLimit), + ); + + const parsedOffset = parseInt(query.offset ?? '', 10); + const offset = Math.max(0, Number.isNaN(parsedOffset) ? 0 : parsedOffset); + + return { limit, offset }; +} + +export function paginatedResponse( + data: T[], + meta: PaginationMeta, +): PaginatedResponse { + return { data, meta }; +} diff --git a/src/repositories/userRepository.ts b/src/repositories/userRepository.ts index 7bfa845..2b2888f 100644 --- a/src/repositories/userRepository.ts +++ b/src/repositories/userRepository.ts @@ -1,17 +1,15 @@ import prisma from '../lib/prisma.js'; import type { User } from '../generated/prisma/client.js'; +import type { PaginationParams } from '../lib/pagination.js'; -interface PaginatedUsers { - users: Pick[]; +export type UserListItem = Pick; + +interface FindUsersResult { + users: UserListItem[]; total: number; - page: number; - limit: number; - totalPages: number; } -export async function findUsers(page: number, limit: number): Promise { - const skip = (page - 1) * limit; - +export async function findUsers(params: PaginationParams): Promise { const [users, total] = await prisma.$transaction([ prisma.user.findMany({ select: { @@ -20,17 +18,11 @@ export async function findUsers(page: number, limit: number): Promise { try { - const page = Math.max(1, parseInt(req.query.page as string, 10) || 1); - const limit = Math.min(100, Math.max(1, parseInt(req.query.limit as string, 10) || 20)); - - const result = await findUsers(page, limit); - res.json(result); + const { limit, offset } = parsePagination(req.query as { limit?: string; offset?: string }); + const { users, total } = await findUsers({ limit, offset }); + res.json(paginatedResponse(users, { total, limit, offset })); } catch (error) { console.error('Failed to list users:', error); res.status(500).json({ error: 'Internal server error' }); From 23a973aebdb0f7ad015e445ea6f4e5c915c50c8e Mon Sep 17 00:00:00 2001 From: okekefrancis112 Date: Thu, 26 Feb 2026 13:11:12 +0100 Subject: [PATCH 2/3] fix: resolve CI failures on Linux - Remove case-conflicting ApiRepository.ts (uppercase) that causes TS1261 on case-sensitive Linux filesystems - Remove obsolete vitest test file for the removed pool-based repository - Add prisma generate step to CI workflow so the generated client exists before typecheck Co-Authored-By: Claude Opus 4.6 --- .github/workflows/ci.yml | 3 + src/repositories/ApiRepository.test.vitest.ts | 78 ------------------- src/repositories/ApiRepository.ts | 35 --------- 3 files changed, 3 insertions(+), 113 deletions(-) delete mode 100644 src/repositories/ApiRepository.test.vitest.ts delete mode 100644 src/repositories/ApiRepository.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 07a0135..192d047 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -27,6 +27,9 @@ jobs: - name: Install dependencies run: npm ci + - name: Generate Prisma client + run: npx prisma generate + - name: Run ESLint run: npm run lint diff --git a/src/repositories/ApiRepository.test.vitest.ts b/src/repositories/ApiRepository.test.vitest.ts deleted file mode 100644 index 1891de6..0000000 --- a/src/repositories/ApiRepository.test.vitest.ts +++ /dev/null @@ -1,78 +0,0 @@ -/* eslint-disable @typescript-eslint/ban-ts-comment */ -// @ts-nocheck — This test file requires vitest and a separate database setup. -import { describe, it, expect, beforeAll, beforeEach, afterAll } from 'vitest'; -import { pool } from '../db'; -import { ApiRepository } from './ApiRepository'; - -const repo = new ApiRepository(); - -describe('ApiRepository', () => { - // 1. Setup Phase: Create the table before any tests run - beforeAll(async () => { - await pool.query(` - CREATE TABLE IF NOT EXISTS apis ( - id SERIAL PRIMARY KEY, - name VARCHAR(255) NOT NULL, - endpoint VARCHAR(255) UNIQUE NOT NULL - ); - `); - }); - - // 2. Isolation Phase: Truncate the table before EACH test so data doesn't bleed - beforeEach(async () => { - await pool.query('TRUNCATE TABLE apis RESTART IDENTITY CASCADE;'); - }); - - // 3. Teardown Phase: Close the connection pool - afterAll(async () => { - await pool.end(); - }); - - it('should create a new API record', async () => { - const api = await repo.create({ name: 'Auth API', endpoint: '/auth' }); - expect(api).toHaveProperty('id'); - expect(api.name).toBe('Auth API'); - expect(api.endpoint).toBe('/auth'); - }); - - it('should throw an error on duplicate endpoint (Edge Case)', async () => { - await repo.create({ name: 'Billing API', endpoint: '/billing' }); - - // Attempting to create another API with the exact same endpoint should fail the UNIQUE constraint - await expect(repo.create({ name: 'Duplicate API', endpoint: '/billing' })) - .rejects.toThrow(/duplicate key value/); - }); - - it('should read an existing API by ID', async () => { - const created = await repo.create({ name: 'Usage API', endpoint: '/usage' }); - const found = await repo.findById(created.id); - expect(found).toEqual(created); - }); - - it('should return null for a non-existent ID (Edge Case)', async () => { - const found = await repo.findById(9999); - expect(found).toBeNull(); - }); - - it('should list all APIs', async () => { - await repo.create({ name: 'API 1', endpoint: '/api1' }); - await repo.create({ name: 'API 2', endpoint: '/api2' }); - - const list = await repo.findAll(); - expect(list).toHaveLength(2); - expect(list[0].name).toBe('API 1'); - }); - - it('should update an API successfully', async () => { - const created = await repo.create({ name: 'Old Name', endpoint: '/old' }); - const updated = await repo.update(created.id, { name: 'New Name' }); - - expect(updated?.name).toBe('New Name'); - expect(updated?.endpoint).toBe('/old'); // Ensure un-updated fields stay the same - }); - - it('should return null when updating a non-existent ID', async () => { - const updated = await repo.update(9999, { name: 'Ghost Name' }); - expect(updated).toBeNull(); - }); -}); \ No newline at end of file diff --git a/src/repositories/ApiRepository.ts b/src/repositories/ApiRepository.ts deleted file mode 100644 index e9c85bf..0000000 --- a/src/repositories/ApiRepository.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { pool } from '../db'; - -export interface ApiRecord { -id: number; -name: string; -endpoint: string; -} - -export class ApiRepository { -async create(data: Omit): Promise { - const result = await pool.query( - 'INSERT INTO apis (name, endpoint) VALUES ($1, $2) RETURNING *', - [data.name, data.endpoint] - ); - return result.rows[0]; - } - - async findById(id: number): Promise { - const result = await pool.query('SELECT * FROM apis WHERE id = $1', [id]); - return result.rows[0] || null; - } - - async findAll(): Promise { - const result = await pool.query('SELECT * FROM apis ORDER BY id ASC'); - return result.rows; - } - - async update(id: number, data: Partial): Promise { - const result = await pool.query( - 'UPDATE apis SET name = COALESCE($1, name), endpoint = COALESCE($2, endpoint) WHERE id = $3 RETURNING *', - [data.name, data.endpoint, id] - ); - return result.rows[0] || null; - } -} \ No newline at end of file From 926dec7f65b3f0122dc96c80bb67180f3e085177 Mon Sep 17 00:00:00 2001 From: okekefrancis112 Date: Thu, 26 Feb 2026 13:15:04 +0100 Subject: [PATCH 3/3] fix: use POSIX find for test file discovery in npm scripts Replace shell glob "src/**/*.test.ts" with $(find src -name '*.test.ts') since dash (default sh on Ubuntu CI) does not support ** globstar. Co-Authored-By: Claude Opus 4.6 --- package.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index 17b129b..5b0cae8 100644 --- a/package.json +++ b/package.json @@ -11,8 +11,8 @@ "db:migrate": "drizzle-kit migrate", "db:studio": "drizzle-kit studio", "typecheck": "tsc --noEmit", - "test": "node --import tsx --test \"src/**/*.test.ts\"", - "test:coverage": "node --import tsx --test --experimental-test-coverage \"src/**/*.test.ts\"", + "test": "node --import tsx --test $(find src -name '*.test.ts')", + "test:coverage": "node --import tsx --test --experimental-test-coverage $(find src -name '*.test.ts')", "validate:issue-9": "node scripts/validate-issue-9.mjs" }, "dependencies": {