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
57 changes: 57 additions & 0 deletions src/__tests__/candidate_profile/BaseController.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
/**
* Tests for candidate_profile/BaseController.ts's baseGetAll — specifically
* the page/limit/sort query-string parsing added for issue #73.
*/
import { baseGetAll } from '@/candidate_profile/BaseController';
import * as services from '@/services';

jest.mock('@/services');

const mockedBaseFindDocument = services.baseFindDocument as jest.MockedFunction<typeof services.baseFindDocument>;

function createMocks(query: Record<string, any> = {}) {
const req: any = { body: { candidateId: 'c1', collection: 'experiences' }, query };
const json = jest.fn();
const res: any = { status: jest.fn().mockReturnValue({ json }), json };
const next = jest.fn();
return { req, res, next };
}

describe('baseGetAll', () => {
beforeEach(() => {
jest.clearAllMocks();
mockedBaseFindDocument.mockResolvedValue({ success: true, message: '', errors: null, data: [] });
});

it('passes page/limit/sort through as numbers/string when present', async () => {
const { req, res, next } = createMocks({ page: '2', limit: '10', sort: '-createdAt' });
await baseGetAll(req, res, next);

expect(mockedBaseFindDocument).toHaveBeenCalledWith(
expect.objectContaining({ page: 2, limit: 10, sort: '-createdAt' }),
);
});

it('omits page/limit/sort when the query string has none (backward compatible)', async () => {
const { req, res, next } = createMocks();
await baseGetAll(req, res, next);

expect(mockedBaseFindDocument).toHaveBeenCalledWith(
expect.objectContaining({ page: undefined, limit: undefined, sort: undefined }),
);
});

it('silently drops a sort value that could smuggle a Mongo operator', async () => {
const { req, res, next } = createMocks({ sort: '$where' });
await baseGetAll(req, res, next);

expect(mockedBaseFindDocument).toHaveBeenCalledWith(expect.objectContaining({ sort: undefined }));
});

it('accepts a leading "-" in sort for descending order', async () => {
const { req, res, next } = createMocks({ sort: '-startDate' });
await baseGetAll(req, res, next);

expect(mockedBaseFindDocument).toHaveBeenCalledWith(expect.objectContaining({ sort: '-startDate' }));
});
});
93 changes: 93 additions & 0 deletions src/__tests__/services/baseFindDocument.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
/**
* Tests for services/index.ts's baseFindDocument — specifically the
* pagination/sort support added for issue #73. Uses a fake Mongoose-shaped
* model instead of jest.mock('@/utils/querySafe') since QuerySafe has no
* side effects worth mocking out.
*/
import { baseFindDocument } from '@/services';

function createFakeModel(docs: Record<string, any>[]) {
const query: any = {
sort: jest.fn(),
skip: jest.fn(),
limit: jest.fn(),
exec: jest.fn().mockResolvedValue(docs),
};
// chainable: each call returns the same query object
query.sort.mockReturnValue(query);
query.skip.mockReturnValue(query);
query.limit.mockReturnValue(query);

return {
find: jest.fn().mockReturnValue(query),
findOne: jest.fn().mockReturnValue({ exec: jest.fn().mockResolvedValue(docs[0] ?? null) }),
countDocuments: jest.fn().mockResolvedValue(docs.length),
__query: query,
};
}

describe('baseFindDocument', () => {
it('fails fast when fields is empty', async () => {
const model = createFakeModel([]);
const result = await baseFindDocument({ model, fields: {}, findOne: false });
expect(result.success).toBe(false);
expect(model.find).not.toHaveBeenCalled();
});

it('findOne: true returns a single document via MODEL.findOne, untouched by pagination', async () => {
const model = createFakeModel([{ _id: '1', candidateId: 'c1' }]);
const result = await baseFindDocument({ model, fields: { candidateId: 'c1' }, findOne: true });

expect(model.findOne).toHaveBeenCalledWith({ candidateId: 'c1' });
expect(result).toEqual({ success: true, message: '', errors: null, data: { _id: '1', candidateId: 'c1' } });
});

it('findOne: false, no limit -> returns the full array unchanged (backward compatible)', async () => {
const docs = [{ _id: '1' }, { _id: '2' }];
const model = createFakeModel(docs);

const result = await baseFindDocument({ model, fields: { candidateId: 'c1' }, findOne: false });

expect(model.find).toHaveBeenCalledWith({ candidateId: 'c1' });
expect(model.__query.skip).not.toHaveBeenCalled();
expect(model.__query.limit).not.toHaveBeenCalled();
expect(model.countDocuments).not.toHaveBeenCalled();
expect(result.data).toEqual(docs);
});

it('findOne: false, with a valid limit -> paginates and wraps data as { items, pagination }', async () => {
const docs = [{ _id: '1' }, { _id: '2' }];
const model = createFakeModel(docs);

const result = await baseFindDocument({ model, fields: { candidateId: 'c1' }, findOne: false, page: 2, limit: 2 });

expect(model.__query.skip).toHaveBeenCalledWith(2); // (page 2 - 1) * limit 2
expect(model.__query.limit).toHaveBeenCalledWith(2);
expect(model.countDocuments).toHaveBeenCalledWith({ candidateId: 'c1' });
expect(result.data).toEqual({
items: docs,
pagination: { page: 2, limit: 2, total: 2, totalPages: 1 },
});
});

it('clamps limit to the max page size', async () => {
const model = createFakeModel([]);
await baseFindDocument({ model, fields: { candidateId: 'c1' }, findOne: false, limit: 9999 });

expect(model.__query.limit).toHaveBeenCalledWith(100);
});

it('defaults page to 1 when page is missing or invalid', async () => {
const model = createFakeModel([]);
await baseFindDocument({ model, fields: { candidateId: 'c1' }, findOne: false, limit: 10, page: 0 });

expect(model.__query.skip).toHaveBeenCalledWith(0);
});

it('applies sort when given, with or without pagination', async () => {
const model = createFakeModel([]);
await baseFindDocument({ model, fields: { candidateId: 'c1' }, findOne: false, sort: '-createdAt' });

expect(model.__query.sort).toHaveBeenCalledWith('-createdAt');
});
});
13 changes: 13 additions & 0 deletions src/candidate_profile/BaseController.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,11 @@ interface baseProp {
findOne?: boolean;
}

// Field name used to sort by — no `$`, so this can't smuggle a Mongo
// operator into `.sort()`, and it can only ever reorder rows, never widen
// which rows come back. A leading `-` (Mongoose convention) means desc.
const SORT_FIELD_REGEX = /^-?[a-zA-Z0-9_.]+$/;

const modelObject: { [key: string]: any } = {
generalInformation: MODELS.generalInformation,
experiences: MODELS.Experience,
Expand All @@ -30,12 +35,20 @@ export const baseGetAll = async (req: Request, res: Response, next: NextFunction
if (!candidateId || !collection || !modelObject[collection])
return formatReturn(res, { statusCode: StatusCodes.NOT_FOUND, data: null, message: t('common.notFoundData', (req as any).lang) });

// Optional pagination/sort (issue #73). Omitting page/limit keeps the
// pre-existing "return everything" behavior (`data` stays a plain
// array) — this is purely additive, no existing caller is affected.
const { page, limit, sort } = req.query as Record<string, string | undefined>;

try {
const _result = await baseFindDocument({
fields: { candidateId: candidateId },
model: modelObject[collection],
findOne: false,
lang: (req as any).lang,
page: page !== undefined ? parseInt(page, 10) : undefined,
limit: limit !== undefined ? parseInt(limit, 10) : undefined,
sort: sort && SORT_FIELD_REGEX.test(sort) ? sort : undefined,
});
return formatReturn(res, { ..._result });
} catch (err) {
Expand Down
39 changes: 39 additions & 0 deletions src/config/swagger.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,33 @@ const options: swaggerJsdoc.Options = {
bearerFormat: 'JWT',
},
},
parameters: {
// Pagination (issue #73) — shared by every CV-section `GET /` list
// endpoint via BaseController.baseGetAll. All three are optional;
// omitting `limit` returns the full, unpaginated array exactly as
// before (see ApiResponse vs ApiResponsePaginated).
PageParam: {
in: 'query',
name: 'page',
required: false,
schema: { type: 'integer', minimum: 1, default: 1 },
description: '1-indexed page number. Ignored unless `limit` is also given.',
},
LimitParam: {
in: 'query',
name: 'limit',
required: false,
schema: { type: 'integer', minimum: 1, maximum: 100 },
description: 'Page size (max 100). Providing this switches the response `data` shape from an array to `{ items, pagination }`.',
},
SortParam: {
in: 'query',
name: 'sort',
required: false,
schema: { type: 'string' },
description: "Mongoose sort expression, e.g. `startDate` or `-createdAt` for descending. Invalid values are silently ignored.",
},
},
schemas: {
ApiResponse: {
type: 'object',
Expand All @@ -38,6 +65,18 @@ const options: swaggerJsdoc.Options = {
data: { nullable: true },
},
},
// Shape of `data` when a `GET /` list endpoint is called with
// `?limit=` (issue #73) — otherwise `data` stays a plain array,
// as documented on ApiResponse.
Pagination: {
type: 'object',
properties: {
page: { type: 'integer' },
limit: { type: 'integer' },
total: { type: 'integer' },
totalPages: { type: 'integer' },
},
},
SocialMedia: {
type: 'object',
properties: {
Expand Down
6 changes: 5 additions & 1 deletion src/routers/api/v1/award.route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,9 +19,13 @@ const router = express.Router();
* summary: List all awards for the authenticated candidate
* security:
* - bearerAuth: []
* parameters:
* - $ref: '#/components/parameters/PageParam'
* - $ref: '#/components/parameters/LimitParam'
* - $ref: '#/components/parameters/SortParam'
* responses:
* 200:
* description: List of awards
* description: List of awards. Passing `limit` switches `data` to `{ items, pagination }` instead of a bare array.
* content:
* application/json:
* schema:
Expand Down
6 changes: 5 additions & 1 deletion src/routers/api/v1/certificate.route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,9 +19,13 @@ const router = express.Router();
* summary: List all certificates for the authenticated candidate
* security:
* - bearerAuth: []
* parameters:
* - $ref: '#/components/parameters/PageParam'
* - $ref: '#/components/parameters/LimitParam'
* - $ref: '#/components/parameters/SortParam'
* responses:
* 200:
* description: List of certificates
* description: List of certificates. Passing `limit` switches `data` to `{ items, pagination }` instead of a bare array.
* content:
* application/json:
* schema:
Expand Down
6 changes: 5 additions & 1 deletion src/routers/api/v1/education.route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,9 +19,13 @@ const router = express.Router();
* summary: List all education entries for the authenticated candidate
* security:
* - bearerAuth: []
* parameters:
* - $ref: '#/components/parameters/PageParam'
* - $ref: '#/components/parameters/LimitParam'
* - $ref: '#/components/parameters/SortParam'
* responses:
* 200:
* description: List of education entries
* description: List of education entries. Passing `limit` switches `data` to `{ items, pagination }` instead of a bare array.
* content:
* application/json:
* schema:
Expand Down
6 changes: 5 additions & 1 deletion src/routers/api/v1/experience.route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,9 +19,13 @@ const router = express.Router();
* summary: List all work experience entries for the authenticated candidate
* security:
* - bearerAuth: []
* parameters:
* - $ref: '#/components/parameters/PageParam'
* - $ref: '#/components/parameters/LimitParam'
* - $ref: '#/components/parameters/SortParam'
* responses:
* 200:
* description: List of experience entries
* description: List of experience entries. Passing `limit` switches `data` to `{ items, pagination }` instead of a bare array.
* content:
* application/json:
* schema:
Expand Down
6 changes: 5 additions & 1 deletion src/routers/api/v1/project.route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,9 +19,13 @@ const router = express.Router();
* summary: List all projects for the authenticated candidate
* security:
* - bearerAuth: []
* parameters:
* - $ref: '#/components/parameters/PageParam'
* - $ref: '#/components/parameters/LimitParam'
* - $ref: '#/components/parameters/SortParam'
* responses:
* 200:
* description: List of projects
* description: List of projects. Passing `limit` switches `data` to `{ items, pagination }` instead of a bare array.
* content:
* application/json:
* schema:
Expand Down
6 changes: 5 additions & 1 deletion src/routers/api/v1/reference.route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,9 +19,13 @@ const router = express.Router();
* summary: List all references for the authenticated candidate
* security:
* - bearerAuth: []
* parameters:
* - $ref: '#/components/parameters/PageParam'
* - $ref: '#/components/parameters/LimitParam'
* - $ref: '#/components/parameters/SortParam'
* responses:
* 200:
* description: List of references
* description: List of references. Passing `limit` switches `data` to `{ items, pagination }` instead of a bare array.
* content:
* application/json:
* schema:
Expand Down
Loading
Loading