Browse Source

upload avatar: test + implement

removed package-lock because of rebasing problems
mrkvon 3 years ago
parent
commit
d9108c0f1a

+ 1
- 1
.travis.yml View File

@@ -1,6 +1,6 @@
1 1
 language: node_js
2 2
 node_js:
3
-  - 7
3
+  - 8
4 4
 env:
5 5
   - NODE_ENV=test CXX="g++-4.8" CC="gcc-4.8"
6 6
 services:

+ 1
- 1
README.md View File

@@ -8,7 +8,7 @@ Follows [JSON API](http://jsonapi.org) specification.
8 8
 
9 9
 ## Prerequisities
10 10
 
11
-- Node.js v7.0.1 or later. We use cutting-edge EcmaScript features like [async functions](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/async_function), which are supported since [v7.0.1](http://node.green/#async-functions).
11
+- Node.js 8.0.0+. We use cutting-edge EcmaScript features like [async functions](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/async_function), which are supported since [v7.0.1](http://node.green/#async-functions). We use util.promisify, which is supported since node 8.0.0.
12 12
 - npm v?
13 13
 - Arangodb v3.0 or later
14 14
 - maildev

+ 31
- 13
controllers/avatar.js View File

@@ -1,29 +1,40 @@
1 1
 const sharp = require('sharp'),
2 2
       fs = require('fs'),
3 3
       path = require('path'),
4
-      q = require('q');
4
+      { promisify } = require('util');
5 5
 
6 6
 const multer = require('multer');
7 7
 const upload = multer({ dest: 'uploads/' });
8 8
 
9 9
 // convert fs callback functions to Promises
10
-const readFile = q.denodeify(fs.readFile),
11
-      mkdir = q.denodeify(fs.mkdir);
12
-
13
-async function patch(req, res) {
14
-  // read the uploaded file
15
-  const buffer = await readFile(path.resolve(`./${req.file.path}`));
10
+const readFile = promisify(fs.readFile),
11
+      mkdir = promisify(fs.mkdir),
12
+      unlink = promisify(fs.unlink);
16 13
 
14
+async function patch(req, res, next) {
15
+  const temporaryFilePath = path.resolve(`./${req.file.path}`);
17 16
   const { username } = req.auth;
18
-
17
+  // sizes of the profile pictures
19 18
   const sizes = [512, 256, 128, 64, 32, 16];
20 19
 
21
-  const imgPromises = sizes.map(size => resizeSaveAvatar(buffer, size, username));
22
-  await Promise.all(imgPromises);
20
+  try {
21
+    // read the uploaded file
22
+    const buffer = await readFile(temporaryFilePath);
23
+
24
+    const imgPromises = sizes.map(size => resizeSaveAvatar(buffer, size, username));
25
+    await Promise.all(imgPromises);
23 26
 
24
-  return res.status(204).end();
25
-}
27
+    // remove the temporary file
28
+    await unlink(temporaryFilePath);
26 29
 
30
+    return res.status(204).end();
31
+  } catch (e) {
32
+    // remove the temporary file
33
+    await unlink(temporaryFilePath);
34
+
35
+    return next(e);
36
+  }
37
+}
27 38
 
28 39
 async function resizeSaveAvatar(buffer, size, username) {
29 40
   // create the user directory if not exist
@@ -45,4 +56,11 @@ async function resizeSaveAvatar(buffer, size, username) {
45 56
 
46 57
 const parseAvatar = upload.single('avatar');
47 58
 
48
-module.exports = { patch, parseAvatar };
59
+async function removeTemporaryFileOnError(err, req, res, next) {
60
+  const temporaryFilePath = path.resolve(`./${req.file.path}`);
61
+  await unlink(temporaryFilePath);
62
+
63
+  return next(err);
64
+}
65
+
66
+module.exports = { patch, parseAvatar, removeTemporaryFileOnError };

+ 24
- 0
controllers/validators/schema/avatar.js View File

@@ -0,0 +1,24 @@
1
+/**
2
+ * The first validation on receiving image
3
+ *
4
+ */
5
+const patchAvatarHeaders = {};
6
+
7
+/**
8
+ * The validation after processing the file with multer library
9
+ */
10
+const patchAvatarFile = {
11
+  properties: {
12
+    file: {
13
+      properties: {
14
+        fieldname: { enum: ['avatar'] },
15
+        mimetype: { enum: ['image/jpeg', 'image/png'] },
16
+        size: { type: 'integer', maximum: 2*2**20 } // 2 MB
17
+      },
18
+      required: ['fieldname', 'mimetype', 'size']
19
+    }
20
+  },
21
+  required: ['file']
22
+};
23
+
24
+module.exports = { patchAvatarHeaders, patchAvatarFile };

+ 2
- 1
controllers/validators/schema/index.js View File

@@ -6,7 +6,8 @@ const users = require('./users');
6 6
 const account = require('./account');
7 7
 const contacts = require('./contacts');
8 8
 const messages = require('./messages');
9
+const avatar = require('./avatar');
9 10
 
10 11
 const definitions = require('./definitions');
11 12
 
12
-module.exports = Object.assign({ definitions }, account, contacts, messages, tags, userTags, users);
13
+module.exports = Object.assign({ definitions }, account, contacts, messages, tags, userTags, users, avatar);

+ 42
- 1
controllers/validators/users.js View File

@@ -2,6 +2,13 @@
2 2
 
3 3
 const validate = require('./validate-by-schema');
4 4
 
5
+const { promisify } = require('util');
6
+const typeOf = require('image-type');
7
+const fs = require('fs');
8
+const path = require('path');
9
+
10
+const readFile = promisify(fs.readFile);
11
+
5 12
 const getUsersWithTags = validate('getUsersWithTags');
6 13
 const getNewUsersWithMyTags = validate('newUsersWithMyTags');
7 14
 const getUsersWithLocation = validate('getUsersWithLocation', [['query.filter.location[0]', 'query.filter.location[1]', ([loc00, loc01], [loc10, loc11]) => {
@@ -13,6 +20,37 @@ const patch = validate('patchUser', [['params.username', 'body.id']]);
13 20
 const getUsersWithMyTags = validate('getUsersWithMyTags');
14 21
 const getNewUsers = validate('newUsers');
15 22
 
23
+
24
+/*
25
+ * Patching avatar
26
+ */
27
+const patchAvatarHeaders = validate('patchAvatarHeaders');
28
+const patchAvatarFile = validate('patchAvatarFile');
29
+
30
+/**
31
+ * Express middleware to check whether image has a supported mime-type
32
+ */
33
+const patchAvatarFileType = async function (req, res, next) {
34
+  const supportedMimes = ['image/png', 'image/jpeg'];
35
+
36
+  // validate file type
37
+  const filePath = path.resolve(`./${req.file.path}`);
38
+  // - read the image
39
+  const fileBuffer = await readFile(filePath);
40
+  // - check the image mime type
41
+  const type = typeOf(fileBuffer);
42
+
43
+  if (type && supportedMimes.includes(type.mime)) {
44
+    return next();
45
+  }
46
+
47
+  return next([{
48
+    param: 'mime type',
49
+    msg: `unsupported image mime type (supports only ${supportedMimes.join(', ')})`
50
+  }]);
51
+
52
+};
53
+
16 54
 module.exports = {
17 55
   get,
18 56
   patch,
@@ -21,5 +59,8 @@ module.exports = {
21 59
   getUsersWithTags,
22 60
   getNewUsers,
23 61
   getNewUsersWithMyTags,
24
-  getUsersWithLocation
62
+  getUsersWithLocation,
63
+  patchAvatarHeaders,
64
+  patchAvatarFile,
65
+  patchAvatarFileType
25 66
 };

+ 27
- 0
jobs/files.js View File

@@ -0,0 +1,27 @@
1
+'use strict';
2
+
3
+const path = require('path'),
4
+      { promisify } = require('util'),
5
+      rimraf = promisify(require('rimraf')),
6
+      fs = require('fs');
7
+
8
+// fs promisified
9
+const fsp = {
10
+  mkdir: promisify(fs.mkdir)
11
+};
12
+
13
+/**
14
+ * clear the ./uploads folder (temporary files for uploading avatar)
15
+ * the files are cleared automatically after success or catched error, but on an uncaught error they'll stay.
16
+ * @returns Promise<void>
17
+ */
18
+async function clearTemporary() {
19
+  const temp = path.resolve('./uploads');
20
+
21
+  // rm -rf the temp folder
22
+  await rimraf(temp);
23
+  // recreate the temp folder
24
+  await fsp.mkdir(temp);
25
+}
26
+
27
+module.exports = { clearTemporary };

+ 7
- 3
jobs/index.js View File

@@ -7,7 +7,7 @@
7 7
  */
8 8
 
9 9
 const cron = require('node-cron'),
10
-      _ = require('lodash'),
10
+      files = require('./files'),
11 11
       tags = require('./tags'),
12 12
       users = require('./users'),
13 13
       notifications = require('./notifications');
@@ -19,10 +19,14 @@ exports.start = function () {
19 19
   // every day at 4 am delete all abandoned tags
20 20
   tasks.push(cron.schedule('0 0 4 * * *', tags.deleteAbandoned));
21 21
 
22
+  // every day at 3 am delete everything in ./uploads
23
+  // TODO only older than 1 minute
24
+  tasks.push(cron.schedule('0 0 3 * * *', files.clearTemporary));
25
+
22 26
   // every 5 minutes send notifications about unread messages
23 27
   tasks.push(cron.schedule('0 */5 * * * *', notifications.messages));
24 28
 
25
-  // every 2 minutes send notifications about unread messages
29
+  // every 2 minutes send notifications about contact requests
26 30
   tasks.push(cron.schedule('0 */2 * * * *', notifications.contactRequests));
27 31
 
28 32
   // every 30 minutes delete unverified users
@@ -30,5 +34,5 @@ exports.start = function () {
30 34
 };
31 35
 
32 36
 exports.stop = function () {
33
-  _.each(tasks, task => { task.destroy(); });
37
+  tasks.forEach(task => { task.destroy(); });
34 38
 };

+ 0
- 10896
package-lock.json
File diff suppressed because it is too large
View File


+ 3
- 0
package.json View File

@@ -23,6 +23,7 @@
23 23
     "gulp": "^3.9.1",
24 24
     "helmet": "^3.1.0",
25 25
     "identicon.js": "^2.1.0",
26
+    "image-type": "^3.0.0",
26 27
     "jsonapi-serializer": "^3.4.1",
27 28
     "lodash": "^4.16.4",
28 29
     "multer": "^1.3.0",
@@ -34,12 +35,14 @@
34 35
     "passport-oauth2": "^1.3.0",
35 36
     "prompt": "^1.0.0",
36 37
     "q": "^1.4.1",
38
+    "rimraf": "^2.6.1",
37 39
     "serve-favicon": "~2.3.0",
38 40
     "sharp": "^0.18.2"
39 41
   },
40 42
   "devDependencies": {
41 43
     "eslint": "^3.13.0",
42 44
     "gulp-eslint": "^3.0.1",
45
+    "image-size": "^0.6.1",
43 46
     "maildev": "^0.14.0",
44 47
     "mocha": "^3.1.2",
45 48
     "should": "^11.1.1",

+ 1
- 1
routes/users.js View File

@@ -56,6 +56,6 @@ router.route('/:username/tags/:tagname')
56 56
 
57 57
 router.route('/:username/avatar')
58 58
   .get(authorize.onlyLogged, userController.getAvatar)
59
-  .patch(authorize.onlyLoggedMe, avatarController.parseAvatar, avatarController.patch);
59
+  .patch(authorize.onlyLoggedMe, validators.users.patchAvatarHeaders, avatarController.parseAvatar, validators.users.patchAvatarFile, validators.users.patchAvatarFileType, avatarController.patch, avatarController.removeTemporaryFileOnError);
60 60
 
61 61
 module.exports = router;

+ 311
- 0
test/avatar.js View File

@@ -0,0 +1,311 @@
1
+const supertest = require('supertest'),
2
+      should = require('should'),
3
+      fs = require('fs'),
4
+      crypto = require('crypto'),
5
+      { promisify } = require('util'),
6
+      path = require('path'),
7
+      rimraf = promisify(require('rimraf')),
8
+      sizeOf = promisify(require('image-size')),
9
+      typeOf = require('image-type');
10
+
11
+const app = require(path.resolve('./app')),
12
+      dbHandle = require(path.resolve('./test/handleDatabase')),
13
+      { clearTemporary } = require(path.resolve('./jobs/files'));
14
+
15
+const agent = supertest.agent(app);
16
+
17
+describe('/users/:username/avatar', function () {
18
+
19
+  let dbData;
20
+
21
+  function beforeEachPopulate(data) {
22
+    // put pre-data into database
23
+    beforeEach(async function () {
24
+      // create data in database
25
+      dbData = await dbHandle.fill(data);
26
+    });
27
+
28
+    afterEach(async function () {
29
+      await dbHandle.clear();
30
+    });
31
+  }
32
+
33
+  let loggedUser, otherUser;
34
+
35
+  beforeEachPopulate({
36
+    users: 2, // how many users to make
37
+    verifiedUsers: [0, 1] // which  users to make verified
38
+  });
39
+
40
+  beforeEach(function () {
41
+    [loggedUser, otherUser] = dbData.users;
42
+  });
43
+
44
+  describe('GET', function () {
45
+
46
+    context('logged', function () {
47
+
48
+      context(':username exists', function () {
49
+
50
+        it('[nothing uploaded] responds with 200 and a default identicon (identicon.js)', async function () {
51
+          const response = await agent
52
+            .get(`/users/${otherUser.username}/avatar`)
53
+            .set('Content-Type', 'application/vnd.api+json')
54
+            .auth(loggedUser.username, loggedUser.password)
55
+            .expect(200)
56
+            .expect('Content-Type', /^application\/vnd\.api\+json/);
57
+
58
+          // the request returns a json:
59
+          // {
60
+          //    data: {
61
+          //      type: 'user-avatars'
62
+          //      id: username
63
+          //      attributes: {
64
+          //        format,
65
+          //        base64
66
+          //      }
67
+          //    }
68
+          // }
69
+          // check the proper format attribute
70
+
71
+          should(response).have.propertyByPath('body', 'data', 'type').eql('user-avatars');
72
+          should(response).have.propertyByPath('body', 'data', 'id').eql('user1');
73
+          should(response).have.propertyByPath('body', 'data', 'attributes', 'format').eql('png');
74
+          should(response).have.propertyByPath('body', 'data', 'attributes', 'base64');
75
+
76
+          // compare hash of the base64 image representation
77
+          const data = response.body.data.attributes.base64;
78
+          const hash = crypto.createHash('sha256').update(data).digest('hex');
79
+
80
+          should(hash).equal('7d76d24aee7faf11a3494ae91b577d94cbb5320cec1b2fd04187fff1197915bb');
81
+
82
+        });
83
+
84
+        it('[png uploaded] responds with 200 and a jpeg image');
85
+
86
+        it('[jpeg uploaded] responds with 200 and a jpeg image');
87
+
88
+      });
89
+
90
+      context(':username doesn\'t exist', function () {
91
+
92
+        it('responds with 404', async function () {
93
+          await agent
94
+            .get('/users/nonexistent-user/avatar')
95
+            .set('Content-Type', 'application/vnd.api+json')
96
+            .auth(loggedUser.username, loggedUser.password)
97
+            .expect(404)
98
+            .expect('Content-Type', /^application\/vnd\.api\+json/);
99
+        });
100
+
101
+      });
102
+    });
103
+
104
+    context('not logged', function () {
105
+
106
+      it('responds with 403', async function () {
107
+        await agent
108
+          .get('/users/nonexistent-user/avatar')
109
+          .set('Content-Type', 'application/vnd.api+json')
110
+          .expect(403)
111
+          .expect('Content-Type', /^application\/vnd\.api\+json/);
112
+      });
113
+
114
+    });
115
+  });
116
+
117
+  describe('PATCH', function () {
118
+
119
+
120
+    // fs functions changed to promise
121
+    const stat = promisify(fs.stat);
122
+    const readdir = promisify(fs.readdir);
123
+    const unlink = promisify(fs.unlink);
124
+    const readFile = promisify(fs.readFile);
125
+    const fsp = { // fs promisified
126
+      open: promisify(fs.open),
127
+      close: promisify(fs.close)
128
+    };
129
+
130
+    // clear ./uploads/ folder
131
+    afterEach(async () => {
132
+      const files = await readdir(path.resolve('./uploads'));
133
+      const filePromises = files.map(file => unlink(path.resolve(`./uploads/${file}`)));
134
+      await Promise.all(filePromises);
135
+      const filesAfter = await readdir(path.resolve('./uploads'));
136
+
137
+      // check that the /uploads folder is empty
138
+      should(filesAfter).Array().length(0);
139
+    });
140
+
141
+    // clear ./files/avatars/
142
+    afterEach(async () => {
143
+
144
+      const avatarsDir = path.resolve('./files/avatars');
145
+      const folders = await readdir(avatarsDir);
146
+      const delPromises = folders.map(folder => rimraf(`./files/avatars/${folder}`));
147
+      await Promise.all(delPromises);
148
+
149
+      // check that the /files/avatars folder is empty
150
+      should(await readdir(avatarsDir)).Array().length(0);
151
+    });
152
+
153
+    context('logged as :username', function () {
154
+
155
+      context('good data type (png, jpeg)', function () {
156
+
157
+        it('[png] responds with 204 and saves the image as jpg', async () => {
158
+          await agent
159
+            .patch(`/users/${loggedUser.username}/avatar`)
160
+            .attach('avatar', './test/img/avatar.png')
161
+            .auth(loggedUser.username, loggedUser.password)
162
+            .set('Content-Type', 'image/jpeg')
163
+            .expect(204);
164
+
165
+          // check images' existence
166
+          const expectedSizes = [16, 32, 64, 128, 256, 512];
167
+          const imagePromises = expectedSizes.map(size => stat(path.resolve(`./files/avatars/${loggedUser.username}/${size}`)));
168
+
169
+          await Promise.all(imagePromises);
170
+
171
+          const buffer512 = await readFile(path.resolve(`./files/avatars/${loggedUser.username}/512`));
172
+          should(typeOf(buffer512)).deepEqual({ ext: 'jpg', mime: 'image/jpeg' });
173
+        });
174
+
175
+        it('[jpeg] responds with 204 and saves the image', async () => {
176
+          await agent
177
+            .patch(`/users/${loggedUser.username}/avatar`)
178
+            .attach('avatar', './test/img/avatar.jpg')
179
+            .auth(loggedUser.username, loggedUser.password)
180
+            .set('Content-Type', 'image/jpeg')
181
+            .expect(204);
182
+
183
+          // check images' existence
184
+          const expectedSizes = [16, 32, 64, 128, 256, 512];
185
+          const imagePromises = expectedSizes.map(size => stat(path.resolve(`./files/avatars/${loggedUser.username}/${size}`)));
186
+
187
+          await Promise.all(imagePromises);
188
+        });
189
+
190
+        it('delete the temporary file from ./uploads', async () => {
191
+          await agent
192
+            .patch(`/users/${loggedUser.username}/avatar`)
193
+            .attach('avatar', './test/img/avatar.jpg')
194
+            .auth(loggedUser.username, loggedUser.password)
195
+            .set('Content-Type', 'image/jpeg')
196
+            .expect(204);
197
+
198
+          const files = await readdir(path.resolve('./uploads'));
199
+          should(files).Array().length(0);
200
+
201
+        });
202
+
203
+        it('crops and resizes the image to square 512x512px before saving', async () => {
204
+          await agent
205
+            .patch(`/users/${loggedUser.username}/avatar`)
206
+            .attach('avatar', './test/img/avatar.jpg')
207
+            .auth(loggedUser.username, loggedUser.password)
208
+            .set('Content-Type', 'image/jpeg')
209
+            .expect(204);
210
+
211
+          const { width, height } = await sizeOf(path.resolve(`./files/avatars/${loggedUser.username}/512`));
212
+          should(width).eql(512);
213
+          should(height).eql(512);
214
+        });
215
+      });
216
+
217
+      context('bad data', function () {
218
+
219
+        it('[unsupported mime type] 400', async () => {
220
+          await agent
221
+            .patch(`/users/${loggedUser.username}/avatar`)
222
+            .attach('avatar', './test/img/bad-avatar.jpg')
223
+            .auth(loggedUser.username, loggedUser.password)
224
+            .set('Content-Type', 'image/jpeg')
225
+            .expect(400);
226
+        });
227
+
228
+        it('[too large image (over 2MB)] 400', async () => {
229
+          await agent
230
+            .patch(`/users/${loggedUser.username}/avatar`)
231
+            .attach('avatar', './test/img/large-avatar.jpg')
232
+            .auth(loggedUser.username, loggedUser.password)
233
+            .set('Content-Type', 'image/jpeg')
234
+            .expect(400);
235
+        });
236
+
237
+        it('deletes the temporary file from ./uploads', async () => {
238
+          await agent
239
+            .patch(`/users/${loggedUser.username}/avatar`)
240
+            .attach('avatar', './test/img/bad-avatar.jpg')
241
+            .auth(loggedUser.username, loggedUser.password)
242
+            .set('Content-Type', 'image/jpeg')
243
+            .expect(400);
244
+
245
+          await agent
246
+            .patch(`/users/${loggedUser.username}/avatar`)
247
+            .attach('avatar', './test/img/large-avatar.jpg')
248
+            .auth(loggedUser.username, loggedUser.password)
249
+            .set('Content-Type', 'image/jpeg')
250
+            .expect(400);
251
+
252
+          const files = await readdir(path.resolve('./uploads'));
253
+          should(files).Array().length(0);
254
+        });
255
+
256
+      });
257
+    });
258
+
259
+    context('not logged as :username', function () {
260
+
261
+      it('responds with 403', async () => {
262
+        await agent
263
+          .patch(`/users/${otherUser.username}/avatar`)
264
+          .attach('avatar', './test/img/avatar.jpg')
265
+          .auth(loggedUser.username, loggedUser.password)
266
+          .set('Content-Type', 'image/jpeg')
267
+          .expect(403);
268
+      });
269
+
270
+    });
271
+
272
+    describe('job: regularly clear all temp files older than 1 minute', () => {
273
+      it('should leave the /uploads folder empty', async () => {
274
+        const uploads = path.resolve('./uploads');
275
+
276
+        // first there should be some files
277
+        const filePromises = [0, 1, 2, 3, 4].map(async name => fsp.close(await fsp.open(path.resolve(`./uploads/${name}`), 'w')));
278
+        await Promise.all(filePromises);
279
+
280
+        const files = await readdir(uploads);
281
+        should(files).Array().length(5);
282
+
283
+        // then we run the job
284
+        await clearTemporary();
285
+
286
+        // then there should be no files
287
+        const filesAfter = await readdir(uploads);
288
+        should(filesAfter).Array().length(0);
289
+      });
290
+    });
291
+
292
+  });
293
+
294
+  describe('DELETE', function () {
295
+
296
+    context('logged as :username', function () {
297
+
298
+      it('[data on server] responds with 204 and deletes the avatar from server');
299
+
300
+      it('[no user image] responds with 204');
301
+
302
+    });
303
+
304
+    context('not logged as :username', function () {
305
+
306
+      it('responds with 403');
307
+
308
+    });
309
+
310
+  });
311
+});

BIN
test/img/avatar.png View File


+ 1
- 0
test/img/bad-avatar.jpg View File

@@ -0,0 +1 @@
1
+this is not a jpeg image

BIN
test/img/large-avatar.jpg View File


+ 0
- 186
test/users.username.avatar.js View File

@@ -1,186 +0,0 @@
1
-const supertest = require('supertest'),
2
-      should = require('should'),
3
-      fs = require('fs'),
4
-      crypto = require('crypto'),
5
-      q = require('q'),
6
-      path = require('path');
7
-
8
-const app = require(path.resolve('./app')),
9
-      dbHandle = require(path.resolve('./test/handleDatabase'));
10
-
11
-const agent = supertest.agent(app);
12
-
13
-describe('/users/:username/avatar', function () {
14
-
15
-  let dbData;
16
-
17
-  function beforeEachPopulate(data) {
18
-    // put pre-data into database
19
-    beforeEach(async function () {
20
-      // create data in database
21
-      dbData = await dbHandle.fill(data);
22
-    });
23
-
24
-    afterEach(async function () {
25
-      await dbHandle.clear();
26
-    });
27
-  }
28
-
29
-  let loggedUser, otherUser;
30
-
31
-  beforeEachPopulate({
32
-    users: 2, // how many users to make
33
-    verifiedUsers: [0, 1] // which  users to make verified
34
-  });
35
-
36
-  beforeEach(function () {
37
-    [loggedUser, otherUser] = dbData.users;
38
-  });
39
-
40
-  describe('GET', function () {
41
-
42
-    context('logged', function () {
43
-
44
-      context(':username exists', function () {
45
-
46
-        it('[nothing uploaded] responds with 200 and a default identicon (identicon.js)', async function () {
47
-          const response = await agent
48
-            .get(`/users/${otherUser.username}/avatar`)
49
-            .set('Content-Type', 'application/vnd.api+json')
50
-            .auth(loggedUser.username, loggedUser.password)
51
-            .expect(200)
52
-            .expect('Content-Type', /^application\/vnd\.api\+json/);
53
-
54
-          // the request returns a json:
55
-          // {
56
-          //    data: {
57
-          //      type: 'user-avatars'
58
-          //      id: username
59
-          //      attributes: {
60
-          //        format,
61
-          //        base64
62
-          //      }
63
-          //    }
64
-          // }
65
-          // check the proper format attribute
66
-
67
-          should(response).have.propertyByPath('body', 'data', 'type').eql('user-avatars');
68
-          should(response).have.propertyByPath('body', 'data', 'id').eql('user1');
69
-          should(response).have.propertyByPath('body', 'data', 'attributes', 'format').eql('png');
70
-          should(response).have.propertyByPath('body', 'data', 'attributes', 'base64');
71
-
72
-          // compare hash of the base64 image representation
73
-          const data = response.body.data.attributes.base64;
74
-          const hash = crypto.createHash('sha256').update(data).digest('hex');
75
-
76
-          should(hash).equal('7d76d24aee7faf11a3494ae91b577d94cbb5320cec1b2fd04187fff1197915bb');
77
-
78
-        });
79
-
80
-        it('[png uploaded] responds with 200 and a jpeg image');
81
-
82
-        it('[jpeg uploaded] responds with 200 and a jpeg image');
83
-
84
-      });
85
-
86
-      context(':username doesn\'t exist', function () {
87
-
88
-        it('responds with 404', async function () {
89
-          await agent
90
-            .get('/users/nonexistent-user/avatar')
91
-            .set('Content-Type', 'application/vnd.api+json')
92
-            .auth(loggedUser.username, loggedUser.password)
93
-            .expect(404)
94
-            .expect('Content-Type', /^application\/vnd\.api\+json/);
95
-        });
96
-
97
-      });
98
-    });
99
-
100
-    context('not logged', function () {
101
-
102
-      it('responds with 403', async function () {
103
-        await agent
104
-          .get('/users/nonexistent-user/avatar')
105
-          .set('Content-Type', 'application/vnd.api+json')
106
-          .expect(403)
107
-          .expect('Content-Type', /^application\/vnd\.api\+json/);
108
-      });
109
-
110
-    });
111
-  });
112
-
113
-  describe('PATCH', function () {
114
-
115
-    context('logged as :username', function () {
116
-
117
-      context('good data type (png, jpeg)', function () {
118
-
119
-        it('[png] responds with 200 and saves the image as jpg');
120
-
121
-        it('[jpeg] responds with 200 and saves the image', async () => {
122
-          await agent
123
-            .patch(`/users/${loggedUser.username}/avatar`)
124
-            .attach('avatar', './test/img/avatar.jpg')
125
-            .auth(loggedUser.username, loggedUser.password)
126
-            .set('Content-Type', 'image/jpeg')
127
-            .expect(204);
128
-
129
-          const stat = q.denodeify(fs.stat);
130
-
131
-          const expectedSizes = [16, 32, 64, 128, 256, 512];
132
-          const imagePromises = expectedSizes.map(size => stat(path.resolve(`./files/avatars/${loggedUser.username}/${size}`)));
133
-
134
-          await Promise.all(imagePromises);
135
-        });
136
-
137
-        it('deletes the temporary file from ./uploads');
138
-
139
-        it('crops and resizes the image to square 512x512px before saving');
140
-
141
-        it('gets rid of any previous avatar of the user');
142
-
143
-      });
144
-
145
-      context('bad data', function () {
146
-
147
-        it('[bad data type] 400');
148
-
149
-        it('[too large image] 400');
150
-
151
-      });
152
-    });
153
-
154
-    context('not logged as :username', function () {
155
-
156
-      it('responds with 403', async () => {
157
-        await agent
158
-          .patch(`/users/${otherUser.username}/avatar`)
159
-          .attach('avatar', './test/img/avatar.jpg')
160
-          .auth(loggedUser.username, loggedUser.password)
161
-          .set('Content-Type', 'image/jpeg')
162
-          .expect(403);
163
-      });
164
-
165
-    });
166
-
167
-  });
168
-
169
-  describe('DELETE', function () {
170
-
171
-    context('logged as :username', function () {
172
-
173
-      it('[data on server] responds with 204 and deletes the avatar from server');
174
-
175
-      it('[no user image] responds with 204');
176
-
177
-    });
178
-
179
-    context('not logged as :username', function () {
180
-
181
-      it('responds with 403');
182
-
183
-    });
184
-
185
-  });
186
-});