Skip to content

Commit 192d425

Browse files
Copilothotlong
andcommitted
test: add comprehensive expand/populate tests across protocol, dispatcher, and REST layers
- Add 17 protocol-data tests: findData expand/populate/$expand normalization, getData expand/select forwarding - Add 6 http-dispatcher handleData tests: expand/select on GET by ID, list with populate/expand, parameter allowlisting - Add 2 REST findData list tests: expand and populate query param forwarding Co-authored-by: hotlong <50353452+hotlong@users.noreply.github.com>
1 parent 1dfa795 commit 192d425

3 files changed

Lines changed: 405 additions & 0 deletions

File tree

Lines changed: 242 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,242 @@
1+
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
import { describe, it, expect, vi, beforeEach } from 'vitest';
4+
import { ObjectStackProtocolImplementation } from './protocol.js';
5+
6+
/**
7+
* Tests for the Protocol Implementation's data methods (findData, getData).
8+
* Validates that expand/populate/select parameters are correctly normalized
9+
* and forwarded to the underlying engine.
10+
*/
11+
describe('ObjectStackProtocolImplementation - Data Operations', () => {
12+
let protocol: ObjectStackProtocolImplementation;
13+
let mockEngine: any;
14+
15+
beforeEach(() => {
16+
mockEngine = {
17+
find: vi.fn().mockResolvedValue([]),
18+
findOne: vi.fn().mockResolvedValue(null),
19+
};
20+
protocol = new ObjectStackProtocolImplementation(mockEngine);
21+
});
22+
23+
// ═══════════════════════════════════════════════════════════════
24+
// findData — expand/populate normalization
25+
// ═══════════════════════════════════════════════════════════════
26+
27+
describe('findData', () => {
28+
it('should normalize expand string to populate array', async () => {
29+
await protocol.findData({ object: 'order_item', query: { expand: 'order,product' } });
30+
31+
expect(mockEngine.find).toHaveBeenCalledWith(
32+
'order_item',
33+
expect.objectContaining({
34+
populate: ['order', 'product'],
35+
}),
36+
);
37+
// expand should be deleted from options
38+
const callArgs = mockEngine.find.mock.calls[0][1];
39+
expect(callArgs.expand).toBeUndefined();
40+
expect(callArgs.$expand).toBeUndefined();
41+
});
42+
43+
it('should normalize $expand (OData) to populate array', async () => {
44+
await protocol.findData({ object: 'task', query: { $expand: 'assignee,project' } });
45+
46+
expect(mockEngine.find).toHaveBeenCalledWith(
47+
'task',
48+
expect.objectContaining({
49+
populate: ['assignee', 'project'],
50+
}),
51+
);
52+
});
53+
54+
it('should pass populate array as-is if already an array', async () => {
55+
await protocol.findData({ object: 'task', query: { populate: ['assignee'] } });
56+
57+
expect(mockEngine.find).toHaveBeenCalledWith(
58+
'task',
59+
expect.objectContaining({
60+
populate: ['assignee'],
61+
}),
62+
);
63+
});
64+
65+
it('should normalize populate string to array', async () => {
66+
await protocol.findData({ object: 'task', query: { populate: 'assignee,project' } });
67+
68+
expect(mockEngine.find).toHaveBeenCalledWith(
69+
'task',
70+
expect.objectContaining({
71+
populate: ['assignee', 'project'],
72+
}),
73+
);
74+
});
75+
76+
it('should prefer explicit populate over expand', async () => {
77+
await protocol.findData({
78+
object: 'task',
79+
query: { populate: ['assignee'], expand: 'project' },
80+
});
81+
82+
// populate takes precedence; expand is not converted
83+
expect(mockEngine.find).toHaveBeenCalledWith(
84+
'task',
85+
expect.objectContaining({
86+
populate: ['assignee'],
87+
}),
88+
);
89+
});
90+
91+
it('should normalize expand array to populate array', async () => {
92+
await protocol.findData({ object: 'task', query: { expand: ['owner', 'team'] } });
93+
94+
expect(mockEngine.find).toHaveBeenCalledWith(
95+
'task',
96+
expect.objectContaining({
97+
populate: ['owner', 'team'],
98+
}),
99+
);
100+
});
101+
102+
it('should normalize select string to array', async () => {
103+
await protocol.findData({ object: 'task', query: { select: 'name,status,assignee' } });
104+
105+
expect(mockEngine.find).toHaveBeenCalledWith(
106+
'task',
107+
expect.objectContaining({
108+
select: ['name', 'status', 'assignee'],
109+
}),
110+
);
111+
});
112+
113+
it('should pass numeric pagination params correctly', async () => {
114+
await protocol.findData({ object: 'task', query: { top: '10', skip: '20' } });
115+
116+
expect(mockEngine.find).toHaveBeenCalledWith(
117+
'task',
118+
expect.objectContaining({
119+
top: 10,
120+
skip: 20,
121+
}),
122+
);
123+
});
124+
125+
it('should work with no query options', async () => {
126+
await protocol.findData({ object: 'task' });
127+
128+
expect(mockEngine.find).toHaveBeenCalledWith('task', {});
129+
});
130+
131+
it('should return records and standard response shape', async () => {
132+
mockEngine.find.mockResolvedValue([{ _id: 't1', name: 'Task 1' }]);
133+
134+
const result = await protocol.findData({ object: 'task', query: {} });
135+
136+
expect(result).toEqual(
137+
expect.objectContaining({
138+
object: 'task',
139+
records: [{ _id: 't1', name: 'Task 1' }],
140+
total: 1,
141+
}),
142+
);
143+
});
144+
});
145+
146+
// ═══════════════════════════════════════════════════════════════
147+
// getData — expand/select normalization
148+
// ═══════════════════════════════════════════════════════════════
149+
150+
describe('getData', () => {
151+
it('should convert expand string to populate array', async () => {
152+
mockEngine.findOne.mockResolvedValue({ _id: 'oi_1', name: 'Item 1' });
153+
154+
await protocol.getData({ object: 'order_item', id: 'oi_1', expand: 'order,product' });
155+
156+
expect(mockEngine.findOne).toHaveBeenCalledWith(
157+
'order_item',
158+
expect.objectContaining({
159+
filter: { _id: 'oi_1' },
160+
populate: ['order', 'product'],
161+
}),
162+
);
163+
});
164+
165+
it('should convert expand array to populate array', async () => {
166+
mockEngine.findOne.mockResolvedValue({ _id: 't1' });
167+
168+
await protocol.getData({ object: 'task', id: 't1', expand: ['assignee', 'project'] });
169+
170+
expect(mockEngine.findOne).toHaveBeenCalledWith(
171+
'task',
172+
expect.objectContaining({
173+
populate: ['assignee', 'project'],
174+
}),
175+
);
176+
});
177+
178+
it('should convert select string to array', async () => {
179+
mockEngine.findOne.mockResolvedValue({ _id: 't1', name: 'Test' });
180+
181+
await protocol.getData({ object: 'task', id: 't1', select: 'name,status' });
182+
183+
expect(mockEngine.findOne).toHaveBeenCalledWith(
184+
'task',
185+
expect.objectContaining({
186+
select: ['name', 'status'],
187+
}),
188+
);
189+
});
190+
191+
it('should pass both expand and select together', async () => {
192+
mockEngine.findOne.mockResolvedValue({ _id: 'oi_1' });
193+
194+
await protocol.getData({
195+
object: 'order_item',
196+
id: 'oi_1',
197+
expand: 'order',
198+
select: ['name', 'total'],
199+
});
200+
201+
expect(mockEngine.findOne).toHaveBeenCalledWith(
202+
'order_item',
203+
expect.objectContaining({
204+
filter: { _id: 'oi_1' },
205+
populate: ['order'],
206+
select: ['name', 'total'],
207+
}),
208+
);
209+
});
210+
211+
it('should work without expand or select', async () => {
212+
mockEngine.findOne.mockResolvedValue({ _id: 't1' });
213+
214+
await protocol.getData({ object: 'task', id: 't1' });
215+
216+
expect(mockEngine.findOne).toHaveBeenCalledWith(
217+
'task',
218+
{ filter: { _id: 't1' } },
219+
);
220+
});
221+
222+
it('should return standard GetDataResponse shape', async () => {
223+
mockEngine.findOne.mockResolvedValue({ _id: 'oi_1', name: 'Item 1' });
224+
225+
const result = await protocol.getData({ object: 'order_item', id: 'oi_1' });
226+
227+
expect(result).toEqual({
228+
object: 'order_item',
229+
id: 'oi_1',
230+
record: { _id: 'oi_1', name: 'Item 1' },
231+
});
232+
});
233+
234+
it('should throw when record not found', async () => {
235+
mockEngine.findOne.mockResolvedValue(null);
236+
237+
await expect(
238+
protocol.getData({ object: 'task', id: 'missing_id' })
239+
).rejects.toThrow('not found');
240+
});
241+
});
242+
});

packages/rest/src/rest.test.ts

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -488,6 +488,74 @@ describe('RestServer', () => {
488488
expect(callArg).toEqual({ object: 'contact', id: 'c_1' });
489489
});
490490
});
491+
492+
describe('findData handler expand/populate forwarding', () => {
493+
it('should pass query params including expand to protocol.findData', async () => {
494+
const rest = new RestServer(server as any, protocol as any);
495+
rest.registerRoutes();
496+
497+
// Find the GET /data/:object route handler (list endpoint, not the :id one)
498+
const routes = rest.getRoutes();
499+
const listRoute = routes.find(
500+
(r) => r.method === 'GET' && r.path.endsWith(':object'),
501+
);
502+
expect(listRoute).toBeDefined();
503+
504+
const mockReq = {
505+
params: { object: 'order_item' },
506+
query: { expand: 'order,product', top: '10' },
507+
};
508+
const mockRes = {
509+
json: vi.fn(),
510+
status: vi.fn().mockReturnThis(),
511+
};
512+
513+
protocol.findData.mockResolvedValue({
514+
object: 'order_item',
515+
records: [],
516+
total: 0,
517+
});
518+
519+
await listRoute!.handler(mockReq, mockRes);
520+
521+
expect(protocol.findData).toHaveBeenCalledWith({
522+
object: 'order_item',
523+
query: { expand: 'order,product', top: '10' },
524+
});
525+
});
526+
527+
it('should pass populate query param to protocol.findData', async () => {
528+
const rest = new RestServer(server as any, protocol as any);
529+
rest.registerRoutes();
530+
531+
const routes = rest.getRoutes();
532+
const listRoute = routes.find(
533+
(r) => r.method === 'GET' && r.path.endsWith(':object'),
534+
);
535+
536+
const mockReq = {
537+
params: { object: 'task' },
538+
query: { populate: 'assignee,project' },
539+
};
540+
const mockRes = {
541+
json: vi.fn(),
542+
status: vi.fn().mockReturnThis(),
543+
};
544+
545+
protocol.findData.mockResolvedValue({
546+
object: 'task',
547+
records: [],
548+
total: 0,
549+
});
550+
551+
await listRoute!.handler(mockReq, mockRes);
552+
553+
expect(protocol.findData).toHaveBeenCalledWith({
554+
object: 'task',
555+
query: { populate: 'assignee,project' },
556+
});
557+
});
558+
});
491559
});
492560

493561
// ---------------------------------------------------------------------------

0 commit comments

Comments
 (0)