Skip to content

Commit c2eb981

Browse files
fix: address PR review feedback on populate robustness, docs, and tests
Agent-Logs-Url: https://github.com/geturbackend/urBackend/sessions/2bf89d6d-c2ca-425b-ae5d-71ce054d8edb Co-authored-by: yash-pouranik <172860064+yash-pouranik@users.noreply.github.com>
1 parent 051fb7c commit c2eb981

4 files changed

Lines changed: 53 additions & 22 deletions

File tree

‎apps/public-api/src/__tests__/data.controller.read.test.js‎

Lines changed: 36 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ const mockAnd = jest.fn();
55
const mockFindOne = jest.fn();
66
const mockQueryLean = jest.fn().mockResolvedValue([]);
77
const mockPopulate = jest.fn();
8+
const mockEnginePopulate = jest.fn();
89

910
// mockAnd returns an object with .lean() so features.query.lean() works after .and()
1011
mockAnd.mockReturnValue({ lean: mockQueryLean });
@@ -14,7 +15,7 @@ const mockQueryEngine = jest.fn((query) => {
1415
query,
1516
filter() { return engine; },
1617
sort() { return engine; },
17-
populate() { return engine; },
18+
populate() { mockEnginePopulate(...arguments); return engine; },
1819
paginate() { return engine; },
1920
};
2021
return engine;
@@ -38,13 +39,14 @@ jest.mock('@urbackend/common', () => ({
3839
},
3940
findOne: (...args) => {
4041
mockFindOne(...args);
41-
return {
42+
const chainable = {
4243
populate: (...pArgs) => {
4344
mockPopulate(...pArgs);
44-
return { lean: jest.fn().mockResolvedValue({ _id: 'doc_1' }) };
45+
return chainable;
4546
},
46-
lean: jest.fn().mockResolvedValue({ _id: 'doc_1' })
47+
lean: jest.fn().mockResolvedValue({ _id: 'doc_1' }),
4748
};
49+
return chainable;
4850
},
4951
})),
5052
QueryEngine: mockQueryEngine,
@@ -89,6 +91,7 @@ function makeRes() {
8991
describe('data.controller read RLS filters', () => {
9092
beforeEach(() => {
9193
jest.clearAllMocks();
94+
mockEnginePopulate.mockClear();
9295
});
9396

9497
test('getAllData applies rlsFilter to find()', async () => {
@@ -117,16 +120,28 @@ describe('data.controller read RLS filters', () => {
117120
expect(res.json).toHaveBeenCalled();
118121
});
119122

120-
test('getAllData passes populate param to QueryEngine', async () => {
123+
test('getAllData calls engine.populate() when populate param is provided', async () => {
121124
const req = makeReq({ query: { populate: 'author,comments' } });
122125
const res = makeRes();
123126

124-
// The real QueryEngine (not mocked) would call .populate() on the query
125-
// But since we mock QueryEngine, we just check if populate() was part of the chain
126-
// In this specific test file, mockQueryEngine is what's used.
127127
await getAllData(req, res);
128-
128+
129129
expect(mockQueryEngine).toHaveBeenCalled();
130+
expect(mockEnginePopulate).toHaveBeenCalled();
131+
});
132+
133+
test('getAllData does not forward populate/expand to Mongo filter', async () => {
134+
const req = makeReq({ query: { populate: 'author', expand: 'category', title: 'hello' } });
135+
const res = makeRes();
136+
137+
await getAllData(req, res);
138+
139+
// mockFind is called with the raw Mongoose query (no args for Model.find())
140+
// The real filter exclusion is tested via the QueryEngine directly,
141+
// but we confirm find() was invoked and the request did not error out.
142+
expect(mockFind).toHaveBeenCalledWith();
143+
expect(res.json).toHaveBeenCalled();
144+
expect(res.status).not.toHaveBeenCalledWith(500);
130145
});
131146

132147
test('getSingleDoc calls populate on the query', async () => {
@@ -138,4 +153,16 @@ describe('data.controller read RLS filters', () => {
138153
expect(mockPopulate).toHaveBeenCalledWith('author');
139154
expect(res.json).toHaveBeenCalled();
140155
});
156+
157+
test('getSingleDoc handles array-format populate param without crashing', async () => {
158+
const req = makeReq({ query: { populate: ['author', 'category'] } });
159+
const res = makeRes();
160+
161+
await getSingleDoc(req, res);
162+
163+
expect(mockPopulate).toHaveBeenCalledWith('author');
164+
expect(mockPopulate).toHaveBeenCalledWith('category');
165+
expect(res.json).toHaveBeenCalled();
166+
expect(res.status).not.toHaveBeenCalledWith(500);
167+
});
141168
});

‎apps/public-api/src/controllers/data.controller.js‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -162,8 +162,9 @@ module.exports.getSingleDoc = async (req, res) => {
162162
let query = Model.findOne({ $and: [{ _id: id }, baseFilter] });
163163

164164
// Handle population for single doc
165-
const populateParam = req.query.populate || req.query.expand;
166-
if (populateParam) {
165+
const rawPopulateParam = req.query.populate || req.query.expand;
166+
if (rawPopulateParam) {
167+
const populateParam = Array.isArray(rawPopulateParam) ? rawPopulateParam.join(',') : String(rawPopulateParam);
167168
const fields = populateParam.split(',').map(f => f.trim()).filter(Boolean);
168169
fields.forEach(f => {
169170
query = query.populate(f);

‎docs/database.md‎

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@ curl "https://api.ub.bitbros.in/api/data/posts" ^
5656
### Fetch Single Document
5757
**Endpoint**: `GET /api/data/:collectionName/:id`
5858

59+
```bash
5960
curl "https://api.ub.bitbros.in/api/data/posts/64fd1234abcd5678ef901234" ^
6061
-H "x-api-key: pk_live_xxx"
6162
```
@@ -69,8 +70,8 @@ You can refine your `GET` requests using the following query parameters:
6970
| `populate` | `String` | `?populate=author,category` | Expand `Ref` fields into full objects. |
7071
| `expand` | `String` | `?expand=author` | Alias for `populate`. |
7172
| `sort` | `String` | `?sort=createdAt:desc` | Sort results by field and order. |
72-
| `limit` | `Number` | `?limit=25` | Number of documents to return (max 100). |
73-
| `page` | `Number` | `?page=2` | Pagination offset. |
73+
| `limit` | `Number` | `?limit=25` | Number of documents to return (default: 100, max: 100). |
74+
| `page` | `Number` | `?page=2` | 1-indexed page number for pagination. |
7475

7576
### Relational Data (Population) 🔗
7677

@@ -87,6 +88,8 @@ curl "https://api.ub.bitbros.in/api/data/posts?populate=author,category" ^
8788
> [!TIP]
8889
> This solves the N+1 query problem, allowing you to fetch complex relational data in a single request.
8990
91+
## 3. Update a Document
92+
9093
**Endpoint**: `PUT /api/data/:collectionName/:id`
9194

9295
urBackend uses `$set` logic, meaning you only need to send the fields you want to change.

‎packages/common/src/utils/queryEngine.js‎

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ class QueryEngine {
66

77
filter() {
88
const queryObj = { ...this.queryString };
9-
const excludedFields = ['page', 'sort', 'limit', 'fields'];
9+
const excludedFields = ['page', 'sort', 'limit', 'fields', 'populate', 'expand'];
1010
excludedFields.forEach(el => delete queryObj[el]);
1111

1212
const mongoQuery = {};
@@ -53,7 +53,7 @@ class QueryEngine {
5353

5454
paginate() {
5555
const page = parseInt(this.queryString.page, 10) || 1;
56-
const limit = parseInt(this.queryString.limit, 10) || 100;
56+
const limit = Math.min(parseInt(this.queryString.limit, 10) || 100, 100);
5757
const skip = (page - 1) * limit;
5858

5959
this.query = this.query.skip(skip).limit(limit);
@@ -62,13 +62,13 @@ class QueryEngine {
6262
}
6363

6464
populate() {
65-
const populateParam = this.queryString.populate || this.queryString.expand;
66-
if (populateParam) {
67-
const fields = populateParam.split(',').map(f => f.trim()).filter(Boolean);
68-
fields.forEach(f => {
69-
this.query = this.query.populate(f);
70-
});
71-
}
65+
const rawParam = this.queryString.populate || this.queryString.expand;
66+
if (!rawParam) return this;
67+
const populateParam = Array.isArray(rawParam) ? rawParam.join(',') : String(rawParam);
68+
const fields = populateParam.split(',').map(f => f.trim()).filter(Boolean);
69+
fields.forEach(f => {
70+
this.query = this.query.populate(f);
71+
});
7272
return this;
7373
}
7474
}

0 commit comments

Comments
 (0)