diff options
author | Chocobozzz <me@florianbigard.com> | 2022-07-18 14:53:50 +0200 |
---|---|---|
committer | Chocobozzz <me@florianbigard.com> | 2022-07-18 14:53:50 +0200 |
commit | 58c44687f748a3ea5feafe6335fe962aea8186bd (patch) | |
tree | 1d0a1846f439c15edfbf67327623fc3bcca4e92a | |
parent | 1efad362ef0b65118d1d79d802ffc928994c0ef6 (diff) | |
download | PeerTube-58c44687f748a3ea5feafe6335fe962aea8186bd.tar.gz PeerTube-58c44687f748a3ea5feafe6335fe962aea8186bd.tar.zst PeerTube-58c44687f748a3ea5feafe6335fe962aea8186bd.zip |
Fix my videos counter
-rw-r--r-- | server/models/video/video.ts | 4 | ||||
-rw-r--r-- | server/tests/api/users/index.ts | 1 | ||||
-rw-r--r-- | server/tests/api/users/user-videos.ts | 222 | ||||
-rw-r--r-- | server/tests/api/users/users.ts | 196 |
4 files changed, 248 insertions, 175 deletions
diff --git a/server/models/video/video.ts b/server/models/video/video.ts index 53328d311..55da53058 100644 --- a/server/models/video/video.ts +++ b/server/models/video/video.ts | |||
@@ -1005,7 +1005,9 @@ export class VideoModel extends Model<Partial<AttributesOnly<VideoModel>>> { | |||
1005 | order: getVideoSort(sort), | 1005 | order: getVideoSort(sort), |
1006 | include: [ | 1006 | include: [ |
1007 | { | 1007 | { |
1008 | model: VideoChannelModel, | 1008 | model: forCount |
1009 | ? VideoChannelModel.unscoped() | ||
1010 | : VideoChannelModel, | ||
1009 | required: true, | 1011 | required: true, |
1010 | where: channelWhere, | 1012 | where: channelWhere, |
1011 | include: [ | 1013 | include: [ |
diff --git a/server/tests/api/users/index.ts b/server/tests/api/users/index.ts index a244a6edb..c65152c6f 100644 --- a/server/tests/api/users/index.ts +++ b/server/tests/api/users/index.ts | |||
@@ -1,4 +1,5 @@ | |||
1 | import './user-subscriptions' | 1 | import './user-subscriptions' |
2 | import './user-videos' | ||
2 | import './users' | 3 | import './users' |
3 | import './users-multiple-servers' | 4 | import './users-multiple-servers' |
4 | import './users-verification' | 5 | import './users-verification' |
diff --git a/server/tests/api/users/user-videos.ts b/server/tests/api/users/user-videos.ts new file mode 100644 index 000000000..2f5dd1c3e --- /dev/null +++ b/server/tests/api/users/user-videos.ts | |||
@@ -0,0 +1,222 @@ | |||
1 | /* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */ | ||
2 | |||
3 | import 'mocha' | ||
4 | import * as chai from 'chai' | ||
5 | import { HttpStatusCode } from '@shared/models' | ||
6 | import { | ||
7 | cleanupTests, | ||
8 | createSingleServer, | ||
9 | PeerTubeServer, | ||
10 | setAccessTokensToServers, | ||
11 | setDefaultAccountAvatar, | ||
12 | setDefaultChannelAvatar, | ||
13 | waitJobs | ||
14 | } from '@shared/server-commands' | ||
15 | |||
16 | const expect = chai.expect | ||
17 | |||
18 | describe('Test user videos', function () { | ||
19 | let server: PeerTubeServer | ||
20 | let videoId: number | ||
21 | let videoId2: number | ||
22 | let token: string | ||
23 | let anotherUserToken: string | ||
24 | |||
25 | before(async function () { | ||
26 | this.timeout(30000) | ||
27 | |||
28 | server = await createSingleServer(1) | ||
29 | |||
30 | await setAccessTokensToServers([ server ]) | ||
31 | await setDefaultChannelAvatar([ server ]) | ||
32 | await setDefaultAccountAvatar([ server ]) | ||
33 | |||
34 | await server.videos.quickUpload({ name: 'root video' }) | ||
35 | await server.videos.quickUpload({ name: 'root video 2' }) | ||
36 | |||
37 | token = await server.users.generateUserAndToken('user') | ||
38 | anotherUserToken = await server.users.generateUserAndToken('user2') | ||
39 | }) | ||
40 | |||
41 | describe('List my videos', function () { | ||
42 | |||
43 | it('Should list my videos', async function () { | ||
44 | const { data, total } = await server.videos.listMyVideos() | ||
45 | |||
46 | expect(total).to.equal(2) | ||
47 | expect(data).to.have.lengthOf(2) | ||
48 | }) | ||
49 | }) | ||
50 | |||
51 | describe('Upload', function () { | ||
52 | |||
53 | it('Should upload the video with the correct token', async function () { | ||
54 | await server.videos.upload({ token }) | ||
55 | const { data } = await server.videos.list() | ||
56 | const video = data[0] | ||
57 | |||
58 | expect(video.account.name).to.equal('user') | ||
59 | videoId = video.id | ||
60 | }) | ||
61 | |||
62 | it('Should upload the video again with the correct token', async function () { | ||
63 | const { id } = await server.videos.upload({ token }) | ||
64 | videoId2 = id | ||
65 | }) | ||
66 | }) | ||
67 | |||
68 | describe('Ratings', function () { | ||
69 | |||
70 | it('Should retrieve a video rating', async function () { | ||
71 | await server.videos.rate({ id: videoId, token, rating: 'like' }) | ||
72 | const rating = await server.users.getMyRating({ token, videoId }) | ||
73 | |||
74 | expect(rating.videoId).to.equal(videoId) | ||
75 | expect(rating.rating).to.equal('like') | ||
76 | }) | ||
77 | |||
78 | it('Should retrieve ratings list', async function () { | ||
79 | await server.videos.rate({ id: videoId, token, rating: 'like' }) | ||
80 | |||
81 | const body = await server.accounts.listRatings({ accountName: 'user', token }) | ||
82 | |||
83 | expect(body.total).to.equal(1) | ||
84 | expect(body.data[0].video.id).to.equal(videoId) | ||
85 | expect(body.data[0].rating).to.equal('like') | ||
86 | }) | ||
87 | |||
88 | it('Should retrieve ratings list by rating type', async function () { | ||
89 | { | ||
90 | const body = await server.accounts.listRatings({ accountName: 'user', token, rating: 'like' }) | ||
91 | expect(body.data.length).to.equal(1) | ||
92 | } | ||
93 | |||
94 | { | ||
95 | const body = await server.accounts.listRatings({ accountName: 'user', token, rating: 'dislike' }) | ||
96 | expect(body.data.length).to.equal(0) | ||
97 | } | ||
98 | }) | ||
99 | }) | ||
100 | |||
101 | describe('Remove video', function () { | ||
102 | |||
103 | it('Should not be able to remove the video with an incorrect token', async function () { | ||
104 | await server.videos.remove({ token: 'bad_token', id: videoId, expectedStatus: HttpStatusCode.UNAUTHORIZED_401 }) | ||
105 | }) | ||
106 | |||
107 | it('Should not be able to remove the video with the token of another account', async function () { | ||
108 | await server.videos.remove({ token: anotherUserToken, id: videoId, expectedStatus: HttpStatusCode.FORBIDDEN_403 }) | ||
109 | }) | ||
110 | |||
111 | it('Should be able to remove the video with the correct token', async function () { | ||
112 | await server.videos.remove({ token, id: videoId }) | ||
113 | await server.videos.remove({ token, id: videoId2 }) | ||
114 | }) | ||
115 | }) | ||
116 | |||
117 | describe('My videos & quotas', function () { | ||
118 | |||
119 | it('Should be able to upload a video with a user', async function () { | ||
120 | this.timeout(10000) | ||
121 | |||
122 | const attributes = { | ||
123 | name: 'super user video', | ||
124 | fixture: 'video_short.webm' | ||
125 | } | ||
126 | await server.videos.upload({ token, attributes }) | ||
127 | |||
128 | await server.channels.create({ token, attributes: { name: 'other_channel' } }) | ||
129 | }) | ||
130 | |||
131 | it('Should have video quota updated', async function () { | ||
132 | const quota = await server.users.getMyQuotaUsed({ token }) | ||
133 | expect(quota.videoQuotaUsed).to.equal(218910) | ||
134 | expect(quota.videoQuotaUsedDaily).to.equal(218910) | ||
135 | |||
136 | const { data } = await server.users.list() | ||
137 | const tmpUser = data.find(u => u.username === 'user') | ||
138 | expect(tmpUser.videoQuotaUsed).to.equal(218910) | ||
139 | expect(tmpUser.videoQuotaUsedDaily).to.equal(218910) | ||
140 | }) | ||
141 | |||
142 | it('Should be able to list my videos', async function () { | ||
143 | const { total, data } = await server.videos.listMyVideos({ token }) | ||
144 | expect(total).to.equal(1) | ||
145 | expect(data).to.have.lengthOf(1) | ||
146 | |||
147 | const video = data[0] | ||
148 | expect(video.name).to.equal('super user video') | ||
149 | expect(video.thumbnailPath).to.not.be.null | ||
150 | expect(video.previewPath).to.not.be.null | ||
151 | }) | ||
152 | |||
153 | it('Should be able to filter by channel in my videos', async function () { | ||
154 | const myInfo = await server.users.getMyInfo({ token }) | ||
155 | const mainChannel = myInfo.videoChannels.find(c => c.name !== 'other_channel') | ||
156 | const otherChannel = myInfo.videoChannels.find(c => c.name === 'other_channel') | ||
157 | |||
158 | { | ||
159 | const { total, data } = await server.videos.listMyVideos({ token, channelId: mainChannel.id }) | ||
160 | expect(total).to.equal(1) | ||
161 | expect(data).to.have.lengthOf(1) | ||
162 | |||
163 | const video = data[0] | ||
164 | expect(video.name).to.equal('super user video') | ||
165 | expect(video.thumbnailPath).to.not.be.null | ||
166 | expect(video.previewPath).to.not.be.null | ||
167 | } | ||
168 | |||
169 | { | ||
170 | const { total, data } = await server.videos.listMyVideos({ token, channelId: otherChannel.id }) | ||
171 | expect(total).to.equal(0) | ||
172 | expect(data).to.have.lengthOf(0) | ||
173 | } | ||
174 | }) | ||
175 | |||
176 | it('Should be able to search in my videos', async function () { | ||
177 | { | ||
178 | const { total, data } = await server.videos.listMyVideos({ token, sort: '-createdAt', search: 'user video' }) | ||
179 | expect(total).to.equal(1) | ||
180 | expect(data).to.have.lengthOf(1) | ||
181 | } | ||
182 | |||
183 | { | ||
184 | const { total, data } = await server.videos.listMyVideos({ token, sort: '-createdAt', search: 'toto' }) | ||
185 | expect(total).to.equal(0) | ||
186 | expect(data).to.have.lengthOf(0) | ||
187 | } | ||
188 | }) | ||
189 | |||
190 | it('Should disable webtorrent, enable HLS, and update my quota', async function () { | ||
191 | this.timeout(160000) | ||
192 | |||
193 | { | ||
194 | const config = await server.config.getCustomConfig() | ||
195 | config.transcoding.webtorrent.enabled = false | ||
196 | config.transcoding.hls.enabled = true | ||
197 | config.transcoding.enabled = true | ||
198 | await server.config.updateCustomSubConfig({ newConfig: config }) | ||
199 | } | ||
200 | |||
201 | { | ||
202 | const attributes = { | ||
203 | name: 'super user video 2', | ||
204 | fixture: 'video_short.webm' | ||
205 | } | ||
206 | await server.videos.upload({ token, attributes }) | ||
207 | |||
208 | await waitJobs([ server ]) | ||
209 | } | ||
210 | |||
211 | { | ||
212 | const data = await server.users.getMyQuotaUsed({ token }) | ||
213 | expect(data.videoQuotaUsed).to.be.greaterThan(220000) | ||
214 | expect(data.videoQuotaUsedDaily).to.be.greaterThan(220000) | ||
215 | } | ||
216 | }) | ||
217 | }) | ||
218 | |||
219 | after(async function () { | ||
220 | await cleanupTests([ server ]) | ||
221 | }) | ||
222 | }) | ||
diff --git a/server/tests/api/users/users.ts b/server/tests/api/users/users.ts index d15daeba5..1edbb371a 100644 --- a/server/tests/api/users/users.ts +++ b/server/tests/api/users/users.ts | |||
@@ -3,15 +3,14 @@ | |||
3 | import 'mocha' | 3 | import 'mocha' |
4 | import * as chai from 'chai' | 4 | import * as chai from 'chai' |
5 | import { testImage } from '@server/tests/shared' | 5 | import { testImage } from '@server/tests/shared' |
6 | import { AbuseState, HttpStatusCode, OAuth2ErrorCode, UserAdminFlag, UserRole, Video, VideoPlaylistType } from '@shared/models' | 6 | import { AbuseState, HttpStatusCode, OAuth2ErrorCode, UserAdminFlag, UserRole, VideoPlaylistType } from '@shared/models' |
7 | import { | 7 | import { |
8 | cleanupTests, | 8 | cleanupTests, |
9 | createSingleServer, | 9 | createSingleServer, |
10 | killallServers, | 10 | killallServers, |
11 | makePutBodyRequest, | 11 | makePutBodyRequest, |
12 | PeerTubeServer, | 12 | PeerTubeServer, |
13 | setAccessTokensToServers, | 13 | setAccessTokensToServers |
14 | waitJobs | ||
15 | } from '@shared/server-commands' | 14 | } from '@shared/server-commands' |
16 | 15 | ||
17 | const expect = chai.expect | 16 | const expect = chai.expect |
@@ -129,67 +128,6 @@ describe('Test users', function () { | |||
129 | }) | 128 | }) |
130 | }) | 129 | }) |
131 | 130 | ||
132 | describe('Upload', function () { | ||
133 | |||
134 | it('Should upload the video with the correct token', async function () { | ||
135 | await server.videos.upload({ token }) | ||
136 | const { data } = await server.videos.list() | ||
137 | const video = data[0] | ||
138 | |||
139 | expect(video.account.name).to.equal('root') | ||
140 | videoId = video.id | ||
141 | }) | ||
142 | |||
143 | it('Should upload the video again with the correct token', async function () { | ||
144 | await server.videos.upload({ token }) | ||
145 | }) | ||
146 | }) | ||
147 | |||
148 | describe('Ratings', function () { | ||
149 | |||
150 | it('Should retrieve a video rating', async function () { | ||
151 | await server.videos.rate({ id: videoId, rating: 'like' }) | ||
152 | const rating = await server.users.getMyRating({ token, videoId }) | ||
153 | |||
154 | expect(rating.videoId).to.equal(videoId) | ||
155 | expect(rating.rating).to.equal('like') | ||
156 | }) | ||
157 | |||
158 | it('Should retrieve ratings list', async function () { | ||
159 | await server.videos.rate({ id: videoId, rating: 'like' }) | ||
160 | |||
161 | const body = await server.accounts.listRatings({ accountName: server.store.user.username }) | ||
162 | |||
163 | expect(body.total).to.equal(1) | ||
164 | expect(body.data[0].video.id).to.equal(videoId) | ||
165 | expect(body.data[0].rating).to.equal('like') | ||
166 | }) | ||
167 | |||
168 | it('Should retrieve ratings list by rating type', async function () { | ||
169 | { | ||
170 | const body = await server.accounts.listRatings({ accountName: server.store.user.username, rating: 'like' }) | ||
171 | expect(body.data.length).to.equal(1) | ||
172 | } | ||
173 | |||
174 | { | ||
175 | const body = await server.accounts.listRatings({ accountName: server.store.user.username, rating: 'dislike' }) | ||
176 | expect(body.data.length).to.equal(0) | ||
177 | } | ||
178 | }) | ||
179 | }) | ||
180 | |||
181 | describe('Remove video', function () { | ||
182 | it('Should not be able to remove the video with an incorrect token', async function () { | ||
183 | await server.videos.remove({ token: 'bad_token', id: videoId, expectedStatus: HttpStatusCode.UNAUTHORIZED_401 }) | ||
184 | }) | ||
185 | |||
186 | it('Should not be able to remove the video with the token of another account') | ||
187 | |||
188 | it('Should be able to remove the video with the correct token', async function () { | ||
189 | await server.videos.remove({ token, id: videoId }) | ||
190 | }) | ||
191 | }) | ||
192 | |||
193 | describe('Logout', function () { | 131 | describe('Logout', function () { |
194 | it('Should logout (revoke token)', async function () { | 132 | it('Should logout (revoke token)', async function () { |
195 | await server.login.logout({ token: server.accessToken }) | 133 | await server.login.logout({ token: server.accessToken }) |
@@ -308,105 +246,6 @@ describe('Test users', function () { | |||
308 | }) | 246 | }) |
309 | }) | 247 | }) |
310 | 248 | ||
311 | describe('My videos & quotas', function () { | ||
312 | |||
313 | it('Should be able to upload a video with this user', async function () { | ||
314 | this.timeout(10000) | ||
315 | |||
316 | const attributes = { | ||
317 | name: 'super user video', | ||
318 | fixture: 'video_short.webm' | ||
319 | } | ||
320 | await server.videos.upload({ token: userToken, attributes }) | ||
321 | |||
322 | await server.channels.create({ token: userToken, attributes: { name: 'other_channel' } }) | ||
323 | }) | ||
324 | |||
325 | it('Should have video quota updated', async function () { | ||
326 | const quota = await server.users.getMyQuotaUsed({ token: userToken }) | ||
327 | expect(quota.videoQuotaUsed).to.equal(218910) | ||
328 | |||
329 | const { data } = await server.users.list() | ||
330 | const tmpUser = data.find(u => u.username === user.username) | ||
331 | expect(tmpUser.videoQuotaUsed).to.equal(218910) | ||
332 | }) | ||
333 | |||
334 | it('Should be able to list my videos', async function () { | ||
335 | const { total, data } = await server.videos.listMyVideos({ token: userToken }) | ||
336 | expect(total).to.equal(1) | ||
337 | expect(data).to.have.lengthOf(1) | ||
338 | |||
339 | const video: Video = data[0] | ||
340 | expect(video.name).to.equal('super user video') | ||
341 | expect(video.thumbnailPath).to.not.be.null | ||
342 | expect(video.previewPath).to.not.be.null | ||
343 | }) | ||
344 | |||
345 | it('Should be able to filter by channel in my videos', async function () { | ||
346 | const myInfo = await server.users.getMyInfo({ token: userToken }) | ||
347 | const mainChannel = myInfo.videoChannels.find(c => c.name !== 'other_channel') | ||
348 | const otherChannel = myInfo.videoChannels.find(c => c.name === 'other_channel') | ||
349 | |||
350 | { | ||
351 | const { total, data } = await server.videos.listMyVideos({ token: userToken, channelId: mainChannel.id }) | ||
352 | expect(total).to.equal(1) | ||
353 | expect(data).to.have.lengthOf(1) | ||
354 | |||
355 | const video: Video = data[0] | ||
356 | expect(video.name).to.equal('super user video') | ||
357 | expect(video.thumbnailPath).to.not.be.null | ||
358 | expect(video.previewPath).to.not.be.null | ||
359 | } | ||
360 | |||
361 | { | ||
362 | const { total, data } = await server.videos.listMyVideos({ token: userToken, channelId: otherChannel.id }) | ||
363 | expect(total).to.equal(0) | ||
364 | expect(data).to.have.lengthOf(0) | ||
365 | } | ||
366 | }) | ||
367 | |||
368 | it('Should be able to search in my videos', async function () { | ||
369 | { | ||
370 | const { total, data } = await server.videos.listMyVideos({ token: userToken, sort: '-createdAt', search: 'user video' }) | ||
371 | expect(total).to.equal(1) | ||
372 | expect(data).to.have.lengthOf(1) | ||
373 | } | ||
374 | |||
375 | { | ||
376 | const { total, data } = await server.videos.listMyVideos({ token: userToken, sort: '-createdAt', search: 'toto' }) | ||
377 | expect(total).to.equal(0) | ||
378 | expect(data).to.have.lengthOf(0) | ||
379 | } | ||
380 | }) | ||
381 | |||
382 | it('Should disable webtorrent, enable HLS, and update my quota', async function () { | ||
383 | this.timeout(160000) | ||
384 | |||
385 | { | ||
386 | const config = await server.config.getCustomConfig() | ||
387 | config.transcoding.webtorrent.enabled = false | ||
388 | config.transcoding.hls.enabled = true | ||
389 | config.transcoding.enabled = true | ||
390 | await server.config.updateCustomSubConfig({ newConfig: config }) | ||
391 | } | ||
392 | |||
393 | { | ||
394 | const attributes = { | ||
395 | name: 'super user video 2', | ||
396 | fixture: 'video_short.webm' | ||
397 | } | ||
398 | await server.videos.upload({ token: userToken, attributes }) | ||
399 | |||
400 | await waitJobs([ server ]) | ||
401 | } | ||
402 | |||
403 | { | ||
404 | const data = await server.users.getMyQuotaUsed({ token: userToken }) | ||
405 | expect(data.videoQuotaUsed).to.be.greaterThan(220000) | ||
406 | } | ||
407 | }) | ||
408 | }) | ||
409 | |||
410 | describe('Users listing', function () { | 249 | describe('Users listing', function () { |
411 | 250 | ||
412 | it('Should list all the users', async function () { | 251 | it('Should list all the users', async function () { |
@@ -622,13 +461,6 @@ describe('Test users', function () { | |||
622 | } | 461 | } |
623 | }) | 462 | }) |
624 | 463 | ||
625 | it('Should still have the same amount of videos in my account', async function () { | ||
626 | const { total, data } = await server.videos.listMyVideos({ token: userToken }) | ||
627 | |||
628 | expect(total).to.equal(2) | ||
629 | expect(data).to.have.lengthOf(2) | ||
630 | }) | ||
631 | |||
632 | it('Should be able to update my display name', async function () { | 464 | it('Should be able to update my display name', async function () { |
633 | await server.users.updateMe({ token: userToken, displayName: 'new display name' }) | 465 | await server.users.updateMe({ token: userToken, displayName: 'new display name' }) |
634 | 466 | ||
@@ -734,12 +566,28 @@ describe('Test users', function () { | |||
734 | }) | 566 | }) |
735 | 567 | ||
736 | describe('Video blacklists', function () { | 568 | describe('Video blacklists', function () { |
737 | it('Should be able to list video blacklist by a moderator', async function () { | 569 | |
570 | it('Should be able to list my video blacklist', async function () { | ||
738 | await server.blacklist.list({ token: userToken }) | 571 | await server.blacklist.list({ token: userToken }) |
739 | }) | 572 | }) |
740 | }) | 573 | }) |
741 | 574 | ||
742 | describe('Remove a user', function () { | 575 | describe('Remove a user', function () { |
576 | |||
577 | before(async function () { | ||
578 | await server.users.update({ | ||
579 | userId, | ||
580 | token, | ||
581 | videoQuota: 2 * 1024 * 1024 | ||
582 | }) | ||
583 | |||
584 | await server.videos.quickUpload({ name: 'user video', token: userToken, fixture: 'video_short.webm' }) | ||
585 | await server.videos.quickUpload({ name: 'root video' }) | ||
586 | |||
587 | const { total } = await server.videos.list() | ||
588 | expect(total).to.equal(2) | ||
589 | }) | ||
590 | |||
743 | it('Should be able to remove this user', async function () { | 591 | it('Should be able to remove this user', async function () { |
744 | await server.users.remove({ userId, token }) | 592 | await server.users.remove({ userId, token }) |
745 | }) | 593 | }) |
@@ -758,7 +606,7 @@ describe('Test users', function () { | |||
758 | }) | 606 | }) |
759 | 607 | ||
760 | describe('Registering a new user', function () { | 608 | describe('Registering a new user', function () { |
761 | let user15AccessToken | 609 | let user15AccessToken: string |
762 | 610 | ||
763 | it('Should register a new user', async function () { | 611 | it('Should register a new user', async function () { |
764 | const user = { displayName: 'super user 15', username: 'user_15', password: 'my super password' } | 612 | const user = { displayName: 'super user 15', username: 'user_15', password: 'my super password' } |
@@ -854,8 +702,8 @@ describe('Test users', function () { | |||
854 | }) | 702 | }) |
855 | 703 | ||
856 | describe('User stats', function () { | 704 | describe('User stats', function () { |
857 | let user17Id | 705 | let user17Id: number |
858 | let user17AccessToken | 706 | let user17AccessToken: string |
859 | 707 | ||
860 | it('Should report correct initial statistics about a user', async function () { | 708 | it('Should report correct initial statistics about a user', async function () { |
861 | const user17 = { | 709 | const user17 = { |