Skip to content

Commit d14bbdd

Browse files
authored
Simple pagination for embedded boxes on user object (#762)
* Add simple pagination for embedded boxes on user object * add boxes count to response * Add endpoint to retireve single box of a logged in user * Fix typoes in tests
1 parent 5074176 commit d14bbdd

File tree

6 files changed

+303
-6
lines changed

6 files changed

+303
-6
lines changed

.scripts/run-tests.sh

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ only_models_tests=${only_models_tests:-}
2222
git_branch=$(git rev-parse --abbrev-ref HEAD)
2323

2424
function runComposeCommand() {
25-
sudo docker-compose -p osemapitest -f ./tests/docker-compose.yml "$@"
25+
docker-compose -p osemapitest -f ./tests/docker-compose.yml "$@"
2626
}
2727

2828
function cleanup() {

packages/api/lib/controllers/usersController.js

Lines changed: 40 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -247,16 +247,42 @@ const confirmEmailAddress = async function confirmEmailAddress (req, res) {
247247
* @apiName getUserBoxes
248248
* @apiDescription List all boxes and sharedBoxes of the signed in user with secret fields
249249
* @apiGroup Users
250+
* @apiParam {Integer} page the selected page for pagination
250251
* @apiSuccess {String} code `Ok`
251252
* @apiSuccess {String} data A json object with a single `boxes` array field
252253
*/
253254
const getUserBoxes = async function getUserBoxes (req, res) {
255+
const { page } = req._userParams;
254256
try {
255-
const boxes = await req.user.getBoxes();
257+
const boxes = await req.user.getBoxes(page);
256258
const sharedBoxes = await req.user.getSharedBoxes();
257259
res.send(200, {
258260
code: 'Ok',
259-
data: { boxes: boxes, sharedBoxes: sharedBoxes },
261+
data: { boxes: boxes, boxes_count: req.user.boxes.length, sharedBoxes: sharedBoxes },
262+
});
263+
} catch (err) {
264+
return handleError(err);
265+
}
266+
};
267+
268+
/**
269+
* @api {get} /users/me/boxes/:boxId get specific box of the signed in user
270+
* @apiName getUserBox
271+
* @apiDescription Get specific box of the signed in user with secret fields
272+
* @apiGroup Users
273+
* @apiParam {Integer} page the selected page for pagination
274+
* @apiSuccess {String} code `Ok`
275+
* @apiSuccess {String} data A json object with a single `box` object field
276+
*/
277+
const getUserBox = async function getUserBox (req, res) {
278+
const { boxId } = req._userParams;
279+
try {
280+
const box = await req.user.getBox(boxId);
281+
res.send(200, {
282+
code: 'Ok',
283+
data: {
284+
box
285+
},
260286
});
261287
} catch (err) {
262288
return handleError(err);
@@ -410,7 +436,18 @@ module.exports = {
410436
confirmEmailAddress,
411437
],
412438
requestEmailConfirmation,
413-
getUserBoxes,
439+
getUserBox: [
440+
retrieveParameters([
441+
{ predef: 'boxId', required: true }
442+
]),
443+
getUserBox,
444+
],
445+
getUserBoxes: [
446+
retrieveParameters([
447+
{ name: 'page', dataType: 'Integer', defaultValue: 0, min: 0 },
448+
]),
449+
getUserBoxes,
450+
],
414451
updateUser: [
415452
checkContentType,
416453
retrieveParameters([

packages/api/lib/routes.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,7 @@ const routes = {
9898
{ path: `${usersPath}/me`, method: 'get', handler: usersController.getUser, reference: 'api-Users-getUser' },
9999
{ path: `${usersPath}/me`, method: 'put', handler: usersController.updateUser, reference: 'api-Users-updateUser' },
100100
{ path: `${usersPath}/me/boxes`, method: 'get', handler: usersController.getUserBoxes, reference: 'api-Users-getUserBoxes' },
101+
{ path: `${usersPath}/me/boxes/:boxId`, method: 'get', handler: usersController.getUserBox, reference: 'api-Users-getUserBox' },
101102
{ path: `${boxesPath}/:boxId/script`, method: 'get', handler: boxesController.getSketch, reference: 'api-Boxes-getSketch' },
102103
{ path: `${boxesPath}`, method: 'post', handler: boxesController.postNewBox, reference: 'api-Boxes-postNewBox' },
103104
{ path: `${boxesPath}/claim`, method: 'post', handler: boxesController.claimBox, reference: 'api-Boxes-claimBox' },

packages/models/src/user/user.js

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -549,19 +549,34 @@ userSchema.methods.updateUser = function updateUser ({ email, language, name, cu
549549
});
550550
};
551551

552-
userSchema.methods.getBoxes = function getBoxes () {
552+
userSchema.methods.getBoxes = function getBoxes (page) {
553553
return Box.find({ _id: { $in: this.boxes } })
554+
.limit(25)
555+
.skip(page * 25)
554556
.populate(Box.BOX_SUB_PROPS_FOR_POPULATION)
555557
.then(function (boxes) {
556558
return boxes.map(b => b.toJSON({ includeSecrets: true }));
557559
});
558560
};
559561

562+
userSchema.methods.getBox = function getBox (boxId) {
563+
const user = this;
564+
565+
// checkBoxOwner throws ModelError
566+
user.checkBoxOwner(boxId);
567+
568+
return Box.findOne({ _id: boxId })
569+
.populate(Box.BOX_SUB_PROPS_FOR_POPULATION)
570+
.then(function (box) {
571+
return box.toJSON({ includeSecrets: true });
572+
});
573+
};
574+
560575
userSchema.methods.getSharedBoxes = function getSharedBoxes () {
561576
return Box.find({ _id: { $in: this.sharedBoxes } })
562577
.populate(Box.BOX_SUB_PROPS_FOR_POPULATION)
563578
.then(function (boxes) {
564-
return boxes.map(b => b.toJSON({ includeSecrets: true }));
579+
return boxes.map((b) => b.toJSON({ includeSecrets: true }));
565580
});
566581
};
567582

tests/data/getUserBoxSchema.js

Lines changed: 177 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,177 @@
1+
'use strict';
2+
3+
module.exports = {
4+
$schema: 'http://json-schema.org/draft-04/schema#',
5+
type: 'object',
6+
properties: {
7+
code: {
8+
type: 'string',
9+
},
10+
data: {
11+
type: 'object',
12+
properties: {
13+
box: {
14+
type: 'object',
15+
properties: {
16+
createdAt: {
17+
type: 'string',
18+
},
19+
exposure: {
20+
type: 'string',
21+
},
22+
model: {
23+
type: 'string',
24+
},
25+
name: {
26+
type: 'string',
27+
},
28+
updatedAt: {
29+
type: 'string',
30+
},
31+
useAuth: {
32+
type: 'boolean',
33+
},
34+
access_token: {
35+
type: 'string',
36+
},
37+
currentLocation: {
38+
type: 'object',
39+
properties: {
40+
coordinates: {
41+
type: 'array',
42+
items: { type: 'number' },
43+
},
44+
timestamp: { type: 'string' },
45+
type: { type: 'string' },
46+
},
47+
required: ['coordinates', 'timestamp', 'type'],
48+
},
49+
loc: {
50+
type: 'array',
51+
items: {
52+
type: 'object',
53+
properties: {
54+
geometry: {
55+
type: 'object',
56+
properties: {
57+
coordinates: {
58+
type: 'array',
59+
items: {
60+
type: 'number',
61+
},
62+
},
63+
type: {
64+
type: 'string',
65+
},
66+
},
67+
required: ['coordinates', 'type'],
68+
},
69+
type: {
70+
type: 'string',
71+
},
72+
},
73+
required: ['geometry', 'type'],
74+
},
75+
},
76+
sensors: {
77+
type: 'array',
78+
items: {
79+
type: 'object',
80+
properties: {
81+
title: {
82+
type: 'string',
83+
},
84+
unit: {
85+
type: 'string',
86+
},
87+
sensorType: {
88+
type: 'string',
89+
},
90+
icon: {
91+
type: 'string',
92+
},
93+
_id: {
94+
type: 'string',
95+
},
96+
lastMeasurement: {
97+
type: 'object',
98+
properties: {
99+
value: {
100+
type: 'string',
101+
},
102+
createdAt: {
103+
type: 'string',
104+
},
105+
},
106+
required: ['value', 'createdAt'],
107+
},
108+
},
109+
required: ['title', 'unit', 'sensorType', '_id'],
110+
},
111+
},
112+
_id: {
113+
type: 'string',
114+
},
115+
integrations: {
116+
type: 'object',
117+
properties: {
118+
mqtt: {
119+
type: 'object',
120+
properties: {
121+
url: {
122+
type: 'string',
123+
},
124+
topic: {
125+
type: 'string',
126+
},
127+
decodeOptions: {
128+
type: 'string',
129+
},
130+
connectionOptions: {
131+
type: 'string',
132+
},
133+
messageFormat: {
134+
type: 'string',
135+
},
136+
enabled: {
137+
type: 'boolean',
138+
},
139+
},
140+
required: ['enabled'],
141+
},
142+
ttn: {
143+
type: 'object',
144+
properties: {
145+
dev_id: {
146+
type: 'string',
147+
},
148+
app_id: {
149+
type: 'string',
150+
},
151+
profile: {
152+
type: 'string',
153+
},
154+
},
155+
},
156+
},
157+
required: ['mqtt'],
158+
},
159+
},
160+
required: [
161+
'createdAt',
162+
'exposure',
163+
'name',
164+
'updatedAt',
165+
'currentLocation',
166+
'loc',
167+
'sensors',
168+
'_id',
169+
'integrations',
170+
],
171+
},
172+
},
173+
required: ['box'],
174+
},
175+
},
176+
required: ['code', 'data'],
177+
};

tests/tests/005-create-boxes-test.js

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ const BASE_URL = process.env.OSEM_TEST_BASE_URL,
1212
senseBoxSchema = require('../data/senseBoxSchema'),
1313
getUserSchema = require('../data/getUserSchema'),
1414
getUserBoxesSchema = require('../data/getUserBoxesSchema'),
15+
getUserBoxSchema = require('../data/getUserBoxSchema'),
1516
custom_valid_sensebox = require('../data/custom_valid_sensebox');
1617

1718
describe('openSenseMap API Routes: /boxes', function () {
@@ -211,6 +212,32 @@ describe('openSenseMap API Routes: /boxes', function () {
211212
});
212213
});
213214

215+
it('should let users retrieve one of their boxes with all fields', function () {
216+
let boxId;
217+
218+
return chakram
219+
.get(`${BASE_URL}/users/me/boxes`, {
220+
headers: { Authorization: `Bearer ${jwt}` },
221+
})
222+
.then(function (response) {
223+
expect(response).to.have.status(200);
224+
expect(response).to.have.schema(getUserBoxesSchema);
225+
226+
return response;
227+
})
228+
.then(function (response) {
229+
boxId = response.body.data.boxes[0]._id;
230+
231+
return chakram.get(`${BASE_URL}/users/me/boxes/${boxId}`, {
232+
headers: { Authorization: `Bearer ${jwt}` },
233+
});
234+
})
235+
.then(function (response) {
236+
expect(response).to.have.status(200);
237+
expect(response).to.have.schema(getUserBoxSchema);
238+
});
239+
});
240+
214241
it('should return a box as geojson', function () {
215242
return chakram.get(`${BASE_URL}/boxes/${boxId}?format=geojson`)
216243
.then(function (response) {
@@ -707,6 +734,46 @@ describe('openSenseMap API Routes: /boxes', function () {
707734
});
708735
});
709736

737+
it('should deny to retrieve a box of other user', function () {
738+
let otherJwt, otherBoxId;
739+
740+
return chakram
741+
.post(`${BASE_URL}/users/sign-in`, {
742+
name: 'mrtest2',
743+
744+
password: '12345678',
745+
})
746+
.then(function (response) {
747+
expect(response).to.have.status(200);
748+
expect(response).to.have.header(
749+
'content-type',
750+
'application/json; charset=utf-8'
751+
);
752+
753+
expect(response.body.token).to.exist;
754+
755+
otherJwt = response.body.token;
756+
757+
return chakram.get(`${BASE_URL}/users/me/boxes`, {
758+
headers: { Authorization: `Bearer ${otherJwt}` },
759+
});
760+
})
761+
.then(function (response) {
762+
otherBoxId = response.body.data.boxes[0]._id;
763+
764+
return chakram.get(`${BASE_URL}/users/me/boxes/${otherBoxId}`, {
765+
headers: { Authorization: `Bearer ${jwt}` },
766+
});
767+
})
768+
.then(function (response) {
769+
expect(response).to.have.status(403);
770+
expect(response).to.have.json({
771+
code: 'Forbidden',
772+
message: 'User does not own this senseBox',
773+
});
774+
});
775+
});
776+
710777
it('should allow to filter boxes by grouptag', function () {
711778
return chakram.get(`${BASE_URL}/boxes?grouptag=newgroup`)
712779
.then(function (response) {

0 commit comments

Comments
 (0)