aboutsummaryrefslogtreecommitdiffhomepage
path: root/packages/tests/src/shared
diff options
context:
space:
mode:
Diffstat (limited to 'packages/tests/src/shared')
-rw-r--r--packages/tests/src/shared/actors.ts70
-rw-r--r--packages/tests/src/shared/captions.ts21
-rw-r--r--packages/tests/src/shared/checks.ts177
-rw-r--r--packages/tests/src/shared/directories.ts44
-rw-r--r--packages/tests/src/shared/generate.ts79
-rw-r--r--packages/tests/src/shared/live.ts186
-rw-r--r--packages/tests/src/shared/mock-servers/index.ts8
-rw-r--r--packages/tests/src/shared/mock-servers/mock-429.ts33
-rw-r--r--packages/tests/src/shared/mock-servers/mock-email.ts62
-rw-r--r--packages/tests/src/shared/mock-servers/mock-http.ts23
-rw-r--r--packages/tests/src/shared/mock-servers/mock-instances-index.ts46
-rw-r--r--packages/tests/src/shared/mock-servers/mock-joinpeertube-versions.ts34
-rw-r--r--packages/tests/src/shared/mock-servers/mock-object-storage.ts41
-rw-r--r--packages/tests/src/shared/mock-servers/mock-plugin-blocklist.ts36
-rw-r--r--packages/tests/src/shared/mock-servers/mock-proxy.ts24
-rw-r--r--packages/tests/src/shared/mock-servers/shared.ts33
-rw-r--r--packages/tests/src/shared/notifications.ts891
-rw-r--r--packages/tests/src/shared/peertube-runner-process.ts104
-rw-r--r--packages/tests/src/shared/plugins.ts18
-rw-r--r--packages/tests/src/shared/requests.ts12
-rw-r--r--packages/tests/src/shared/sql-command.ts150
-rw-r--r--packages/tests/src/shared/streaming-playlists.ts302
-rw-r--r--packages/tests/src/shared/tests.ts40
-rw-r--r--packages/tests/src/shared/tracker.ts27
-rw-r--r--packages/tests/src/shared/video-playlists.ts22
-rw-r--r--packages/tests/src/shared/videos.ts323
-rw-r--r--packages/tests/src/shared/views.ts93
-rw-r--r--packages/tests/src/shared/webtorrent.ts58
28 files changed, 2957 insertions, 0 deletions
diff --git a/packages/tests/src/shared/actors.ts b/packages/tests/src/shared/actors.ts
new file mode 100644
index 000000000..02d507a49
--- /dev/null
+++ b/packages/tests/src/shared/actors.ts
@@ -0,0 +1,70 @@
1/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */
2
3import { expect } from 'chai'
4import { pathExists } from 'fs-extra/esm'
5import { readdir } from 'fs/promises'
6import { Account, VideoChannel } from '@peertube/peertube-models'
7import { PeerTubeServer } from '@peertube/peertube-server-commands'
8
9async function expectChannelsFollows (options: {
10 server: PeerTubeServer
11 handle: string
12 followers: number
13 following: number
14}) {
15 const { server } = options
16 const { data } = await server.channels.list()
17
18 return expectActorFollow({ ...options, data })
19}
20
21async function expectAccountFollows (options: {
22 server: PeerTubeServer
23 handle: string
24 followers: number
25 following: number
26}) {
27 const { server } = options
28 const { data } = await server.accounts.list()
29
30 return expectActorFollow({ ...options, data })
31}
32
33async function checkActorFilesWereRemoved (filename: string, server: PeerTubeServer) {
34 for (const directory of [ 'avatars' ]) {
35 const directoryPath = server.getDirectoryPath(directory)
36
37 const directoryExists = await pathExists(directoryPath)
38 expect(directoryExists).to.be.true
39
40 const files = await readdir(directoryPath)
41 for (const file of files) {
42 expect(file).to.not.contain(filename)
43 }
44 }
45}
46
47export {
48 expectAccountFollows,
49 expectChannelsFollows,
50 checkActorFilesWereRemoved
51}
52
53// ---------------------------------------------------------------------------
54
55function expectActorFollow (options: {
56 server: PeerTubeServer
57 data: (Account | VideoChannel)[]
58 handle: string
59 followers: number
60 following: number
61}) {
62 const { server, data, handle, followers, following } = options
63
64 const actor = data.find(a => a.name + '@' + a.host === handle)
65 const message = `${handle} on ${server.url}`
66
67 expect(actor, message).to.exist
68 expect(actor.followersCount).to.equal(followers, message)
69 expect(actor.followingCount).to.equal(following, message)
70}
diff --git a/packages/tests/src/shared/captions.ts b/packages/tests/src/shared/captions.ts
new file mode 100644
index 000000000..436cf8dcc
--- /dev/null
+++ b/packages/tests/src/shared/captions.ts
@@ -0,0 +1,21 @@
1import { expect } from 'chai'
2import request from 'supertest'
3import { HttpStatusCode } from '@peertube/peertube-models'
4
5async function testCaptionFile (url: string, captionPath: string, toTest: RegExp | string) {
6 const res = await request(url)
7 .get(captionPath)
8 .expect(HttpStatusCode.OK_200)
9
10 if (toTest instanceof RegExp) {
11 expect(res.text).to.match(toTest)
12 } else {
13 expect(res.text).to.contain(toTest)
14 }
15}
16
17// ---------------------------------------------------------------------------
18
19export {
20 testCaptionFile
21}
diff --git a/packages/tests/src/shared/checks.ts b/packages/tests/src/shared/checks.ts
new file mode 100644
index 000000000..fea618a30
--- /dev/null
+++ b/packages/tests/src/shared/checks.ts
@@ -0,0 +1,177 @@
1/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/no-floating-promises */
2
3import { expect } from 'chai'
4import { pathExists } from 'fs-extra/esm'
5import { readFile } from 'fs/promises'
6import { join } from 'path'
7import { HttpStatusCode } from '@peertube/peertube-models'
8import { buildAbsoluteFixturePath } from '@peertube/peertube-node-utils'
9import { makeGetRequest, PeerTubeServer } from '@peertube/peertube-server-commands'
10
11// Default interval -> 5 minutes
12function dateIsValid (dateString: string | Date, interval = 300000) {
13 const dateToCheck = new Date(dateString)
14 const now = new Date()
15
16 return Math.abs(now.getTime() - dateToCheck.getTime()) <= interval
17}
18
19function expectStartWith (str: string, start: string) {
20 expect(str.startsWith(start), `${str} does not start with ${start}`).to.be.true
21}
22
23function expectNotStartWith (str: string, start: string) {
24 expect(str.startsWith(start), `${str} does not start with ${start}`).to.be.false
25}
26
27function expectEndWith (str: string, end: string) {
28 expect(str.endsWith(end), `${str} does not end with ${end}`).to.be.true
29}
30
31// ---------------------------------------------------------------------------
32
33async function expectLogDoesNotContain (server: PeerTubeServer, str: string) {
34 const content = await server.servers.getLogContent()
35
36 expect(content.toString()).to.not.contain(str)
37}
38
39async function expectLogContain (server: PeerTubeServer, str: string) {
40 const content = await server.servers.getLogContent()
41
42 expect(content.toString()).to.contain(str)
43}
44
45async function testImageSize (url: string, imageName: string, imageHTTPPath: string, extension = '.jpg') {
46 const res = await makeGetRequest({
47 url,
48 path: imageHTTPPath,
49 expectedStatus: HttpStatusCode.OK_200
50 })
51
52 const body = res.body
53
54 const data = await readFile(buildAbsoluteFixturePath(imageName + extension))
55 const minLength = data.length - ((40 * data.length) / 100)
56 const maxLength = data.length + ((40 * data.length) / 100)
57
58 expect(body.length).to.be.above(minLength, 'the generated image is way smaller than the recorded fixture')
59 expect(body.length).to.be.below(maxLength, 'the generated image is way larger than the recorded fixture')
60}
61
62async function testImageGeneratedByFFmpeg (url: string, imageName: string, imageHTTPPath: string, extension = '.jpg') {
63 if (process.env.ENABLE_FFMPEG_THUMBNAIL_PIXEL_COMPARISON_TESTS !== 'true') {
64 console.log(
65 'Pixel comparison of image generated by ffmpeg is disabled. ' +
66 'You can enable it using `ENABLE_FFMPEG_THUMBNAIL_PIXEL_COMPARISON_TESTS=true env variable')
67 return
68 }
69
70 return testImage(url, imageName, imageHTTPPath, extension)
71}
72
73async function testImage (url: string, imageName: string, imageHTTPPath: string, extension = '.jpg') {
74 const res = await makeGetRequest({
75 url,
76 path: imageHTTPPath,
77 expectedStatus: HttpStatusCode.OK_200
78 })
79
80 const body = res.body
81 const data = await readFile(buildAbsoluteFixturePath(imageName + extension))
82
83 const { PNG } = await import('pngjs')
84 const JPEG = await import('jpeg-js')
85 const pixelmatch = (await import('pixelmatch')).default
86
87 const img1 = imageHTTPPath.endsWith('.png')
88 ? PNG.sync.read(body)
89 : JPEG.decode(body)
90
91 const img2 = extension === '.png'
92 ? PNG.sync.read(data)
93 : JPEG.decode(data)
94
95 const result = pixelmatch(img1.data, img2.data, null, img1.width, img1.height, { threshold: 0.1 })
96
97 expect(result).to.equal(0, `${imageHTTPPath} image is not the same as ${imageName}${extension}`)
98}
99
100async function testFileExistsOrNot (server: PeerTubeServer, directory: string, filePath: string, exist: boolean) {
101 const base = server.servers.buildDirectory(directory)
102
103 expect(await pathExists(join(base, filePath))).to.equal(exist)
104}
105
106// ---------------------------------------------------------------------------
107
108function checkBadStartPagination (url: string, path: string, token?: string, query = {}) {
109 return makeGetRequest({
110 url,
111 path,
112 token,
113 query: { ...query, start: 'hello' },
114 expectedStatus: HttpStatusCode.BAD_REQUEST_400
115 })
116}
117
118async function checkBadCountPagination (url: string, path: string, token?: string, query = {}) {
119 await makeGetRequest({
120 url,
121 path,
122 token,
123 query: { ...query, count: 'hello' },
124 expectedStatus: HttpStatusCode.BAD_REQUEST_400
125 })
126
127 await makeGetRequest({
128 url,
129 path,
130 token,
131 query: { ...query, count: 2000 },
132 expectedStatus: HttpStatusCode.BAD_REQUEST_400
133 })
134}
135
136function checkBadSortPagination (url: string, path: string, token?: string, query = {}) {
137 return makeGetRequest({
138 url,
139 path,
140 token,
141 query: { ...query, sort: 'hello' },
142 expectedStatus: HttpStatusCode.BAD_REQUEST_400
143 })
144}
145
146// ---------------------------------------------------------------------------
147
148async function checkVideoDuration (server: PeerTubeServer, videoUUID: string, duration: number) {
149 const video = await server.videos.get({ id: videoUUID })
150
151 expect(video.duration).to.be.approximately(duration, 1)
152
153 for (const file of video.files) {
154 const metadata = await server.videos.getFileMetadata({ url: file.metadataUrl })
155
156 for (const stream of metadata.streams) {
157 expect(Math.round(stream.duration)).to.be.approximately(duration, 1)
158 }
159 }
160}
161
162export {
163 dateIsValid,
164 testImageGeneratedByFFmpeg,
165 testImageSize,
166 testImage,
167 expectLogDoesNotContain,
168 testFileExistsOrNot,
169 expectStartWith,
170 expectNotStartWith,
171 expectEndWith,
172 checkBadStartPagination,
173 checkBadCountPagination,
174 checkBadSortPagination,
175 checkVideoDuration,
176 expectLogContain
177}
diff --git a/packages/tests/src/shared/directories.ts b/packages/tests/src/shared/directories.ts
new file mode 100644
index 000000000..f21e7b7c6
--- /dev/null
+++ b/packages/tests/src/shared/directories.ts
@@ -0,0 +1,44 @@
1/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */
2
3import { expect } from 'chai'
4import { pathExists } from 'fs-extra/esm'
5import { readdir } from 'fs/promises'
6import { homedir } from 'os'
7import { join } from 'path'
8import { PeerTubeServer } from '@peertube/peertube-server-commands'
9import { PeerTubeRunnerProcess } from './peertube-runner-process.js'
10
11export async function checkTmpIsEmpty (server: PeerTubeServer) {
12 await checkDirectoryIsEmpty(server, 'tmp', [ 'plugins-global.css', 'hls', 'resumable-uploads' ])
13
14 if (await pathExists(server.getDirectoryPath('tmp/hls'))) {
15 await checkDirectoryIsEmpty(server, 'tmp/hls')
16 }
17}
18
19export async function checkPersistentTmpIsEmpty (server: PeerTubeServer) {
20 await checkDirectoryIsEmpty(server, 'tmp-persistent')
21}
22
23export async function checkDirectoryIsEmpty (server: PeerTubeServer, directory: string, exceptions: string[] = []) {
24 const directoryPath = server.getDirectoryPath(directory)
25
26 const directoryExists = await pathExists(directoryPath)
27 expect(directoryExists).to.be.true
28
29 const files = await readdir(directoryPath)
30 const filtered = files.filter(f => exceptions.includes(f) === false)
31
32 expect(filtered).to.have.lengthOf(0)
33}
34
35export async function checkPeerTubeRunnerCacheIsEmpty (runner: PeerTubeRunnerProcess) {
36 const directoryPath = join(homedir(), '.cache', 'peertube-runner-nodejs', runner.getId(), 'transcoding')
37
38 const directoryExists = await pathExists(directoryPath)
39 expect(directoryExists).to.be.true
40
41 const files = await readdir(directoryPath)
42
43 expect(files, 'Directory content: ' + files.join(', ')).to.have.lengthOf(0)
44}
diff --git a/packages/tests/src/shared/generate.ts b/packages/tests/src/shared/generate.ts
new file mode 100644
index 000000000..ab2ecaf40
--- /dev/null
+++ b/packages/tests/src/shared/generate.ts
@@ -0,0 +1,79 @@
1import { expect } from 'chai'
2import { ensureDir, pathExists } from 'fs-extra/esm'
3import { dirname } from 'path'
4import { getMaxTheoreticalBitrate } from '@peertube/peertube-core-utils'
5import { buildAbsoluteFixturePath } from '@peertube/peertube-node-utils'
6import { getVideoStreamBitrate, getVideoStreamDimensionsInfo, getVideoStreamFPS } from '@peertube/peertube-ffmpeg'
7
8async function ensureHasTooBigBitrate (fixturePath: string) {
9 const bitrate = await getVideoStreamBitrate(fixturePath)
10 const dataResolution = await getVideoStreamDimensionsInfo(fixturePath)
11 const fps = await getVideoStreamFPS(fixturePath)
12
13 const maxBitrate = getMaxTheoreticalBitrate({ ...dataResolution, fps })
14 expect(bitrate).to.be.above(maxBitrate)
15}
16
17async function generateHighBitrateVideo () {
18 const tempFixturePath = buildAbsoluteFixturePath('video_high_bitrate_1080p.mp4', true)
19
20 await ensureDir(dirname(tempFixturePath))
21
22 const exists = await pathExists(tempFixturePath)
23
24 if (!exists) {
25 const ffmpeg = (await import('fluent-ffmpeg')).default
26
27 console.log('Generating high bitrate video.')
28
29 // Generate a random, high bitrate video on the fly, so we don't have to include
30 // a large file in the repo. The video needs to have a certain minimum length so
31 // that FFmpeg properly applies bitrate limits.
32 // https://stackoverflow.com/a/15795112
33 return new Promise<string>((res, rej) => {
34 ffmpeg()
35 .outputOptions([ '-f rawvideo', '-video_size 1920x1080', '-i /dev/urandom' ])
36 .outputOptions([ '-ac 2', '-f s16le', '-i /dev/urandom', '-t 10' ])
37 .outputOptions([ '-maxrate 10M', '-bufsize 10M' ])
38 .output(tempFixturePath)
39 .on('error', rej)
40 .on('end', () => res(tempFixturePath))
41 .run()
42 })
43 }
44
45 await ensureHasTooBigBitrate(tempFixturePath)
46
47 return tempFixturePath
48}
49
50async function generateVideoWithFramerate (fps = 60) {
51 const tempFixturePath = buildAbsoluteFixturePath(`video_${fps}fps.mp4`, true)
52
53 await ensureDir(dirname(tempFixturePath))
54
55 const exists = await pathExists(tempFixturePath)
56 if (!exists) {
57 const ffmpeg = (await import('fluent-ffmpeg')).default
58
59 console.log('Generating video with framerate %d.', fps)
60
61 return new Promise<string>((res, rej) => {
62 ffmpeg()
63 .outputOptions([ '-f rawvideo', '-video_size 1280x720', '-i /dev/urandom' ])
64 .outputOptions([ '-ac 2', '-f s16le', '-i /dev/urandom', '-t 10' ])
65 .outputOptions([ `-r ${fps}` ])
66 .output(tempFixturePath)
67 .on('error', rej)
68 .on('end', () => res(tempFixturePath))
69 .run()
70 })
71 }
72
73 return tempFixturePath
74}
75
76export {
77 generateHighBitrateVideo,
78 generateVideoWithFramerate
79}
diff --git a/packages/tests/src/shared/live.ts b/packages/tests/src/shared/live.ts
new file mode 100644
index 000000000..9c7991b0d
--- /dev/null
+++ b/packages/tests/src/shared/live.ts
@@ -0,0 +1,186 @@
1/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */
2
3import { expect } from 'chai'
4import { pathExists } from 'fs-extra/esm'
5import { readdir } from 'fs/promises'
6import { join } from 'path'
7import { sha1 } from '@peertube/peertube-node-utils'
8import { LiveVideo, VideoStreamingPlaylistType } from '@peertube/peertube-models'
9import { ObjectStorageCommand, PeerTubeServer } from '@peertube/peertube-server-commands'
10import { SQLCommand } from './sql-command.js'
11import { checkLiveSegmentHash, checkResolutionsInMasterPlaylist } from './streaming-playlists.js'
12
13async function checkLiveCleanup (options: {
14 server: PeerTubeServer
15 videoUUID: string
16 permanent: boolean
17 savedResolutions?: number[]
18}) {
19 const { server, videoUUID, permanent, savedResolutions = [] } = options
20
21 const basePath = server.servers.buildDirectory('streaming-playlists')
22 const hlsPath = join(basePath, 'hls', videoUUID)
23
24 if (permanent) {
25 if (!await pathExists(hlsPath)) return
26
27 const files = await readdir(hlsPath)
28 expect(files).to.have.lengthOf(0)
29 return
30 }
31
32 if (savedResolutions.length === 0) {
33 return checkUnsavedLiveCleanup(server, videoUUID, hlsPath)
34 }
35
36 return checkSavedLiveCleanup(hlsPath, savedResolutions)
37}
38
39// ---------------------------------------------------------------------------
40
41async function testLiveVideoResolutions (options: {
42 sqlCommand: SQLCommand
43 originServer: PeerTubeServer
44
45 servers: PeerTubeServer[]
46 liveVideoId: string
47 resolutions: number[]
48 transcoded: boolean
49
50 objectStorage?: ObjectStorageCommand
51 objectStorageBaseUrl?: string
52}) {
53 const {
54 originServer,
55 sqlCommand,
56 servers,
57 liveVideoId,
58 resolutions,
59 transcoded,
60 objectStorage,
61 objectStorageBaseUrl = objectStorage?.getMockPlaylistBaseUrl()
62 } = options
63
64 for (const server of servers) {
65 const { data } = await server.videos.list()
66 expect(data.find(v => v.uuid === liveVideoId)).to.exist
67
68 const video = await server.videos.get({ id: liveVideoId })
69 expect(video.streamingPlaylists).to.have.lengthOf(1)
70
71 const hlsPlaylist = video.streamingPlaylists.find(s => s.type === VideoStreamingPlaylistType.HLS)
72 expect(hlsPlaylist).to.exist
73 expect(hlsPlaylist.files).to.have.lengthOf(0) // Only fragmented mp4 files are displayed
74
75 await checkResolutionsInMasterPlaylist({
76 server,
77 playlistUrl: hlsPlaylist.playlistUrl,
78 resolutions,
79 transcoded,
80 withRetry: !!objectStorage
81 })
82
83 if (objectStorage) {
84 expect(hlsPlaylist.playlistUrl).to.contain(objectStorageBaseUrl)
85 }
86
87 for (let i = 0; i < resolutions.length; i++) {
88 const segmentNum = 3
89 const segmentName = `${i}-00000${segmentNum}.ts`
90 await originServer.live.waitUntilSegmentGeneration({
91 server: originServer,
92 videoUUID: video.uuid,
93 playlistNumber: i,
94 segment: segmentNum,
95 objectStorage,
96 objectStorageBaseUrl
97 })
98
99 const baseUrl = objectStorage
100 ? join(objectStorageBaseUrl, 'hls')
101 : originServer.url + '/static/streaming-playlists/hls'
102
103 if (objectStorage) {
104 expect(hlsPlaylist.segmentsSha256Url).to.contain(objectStorageBaseUrl)
105 }
106
107 const subPlaylist = await originServer.streamingPlaylists.get({
108 url: `${baseUrl}/${video.uuid}/${i}.m3u8`,
109 withRetry: !!objectStorage // With object storage, the request may fail because of inconsistent data in S3
110 })
111
112 expect(subPlaylist).to.contain(segmentName)
113
114 await checkLiveSegmentHash({
115 server,
116 baseUrlSegment: baseUrl,
117 videoUUID: video.uuid,
118 segmentName,
119 hlsPlaylist,
120 withRetry: !!objectStorage // With object storage, the request may fail because of inconsistent data in S3
121 })
122
123 if (originServer.internalServerNumber === server.internalServerNumber) {
124 const infohash = sha1(`${2 + hlsPlaylist.playlistUrl}+V${i}`)
125 const dbInfohashes = await sqlCommand.getPlaylistInfohash(hlsPlaylist.id)
126
127 expect(dbInfohashes).to.include(infohash)
128 }
129 }
130 }
131}
132
133// ---------------------------------------------------------------------------
134
135export {
136 checkLiveCleanup,
137 testLiveVideoResolutions
138}
139
140// ---------------------------------------------------------------------------
141
142async function checkSavedLiveCleanup (hlsPath: string, savedResolutions: number[] = []) {
143 const files = await readdir(hlsPath)
144
145 // fragmented file and playlist per resolution + master playlist + segments sha256 json file
146 expect(files, `Directory content: ${files.join(', ')}`).to.have.lengthOf(savedResolutions.length * 2 + 2)
147
148 for (const resolution of savedResolutions) {
149 const fragmentedFile = files.find(f => f.endsWith(`-${resolution}-fragmented.mp4`))
150 expect(fragmentedFile).to.exist
151
152 const playlistFile = files.find(f => f.endsWith(`${resolution}.m3u8`))
153 expect(playlistFile).to.exist
154 }
155
156 const masterPlaylistFile = files.find(f => f.endsWith('-master.m3u8'))
157 expect(masterPlaylistFile).to.exist
158
159 const shaFile = files.find(f => f.endsWith('-segments-sha256.json'))
160 expect(shaFile).to.exist
161}
162
163async function checkUnsavedLiveCleanup (server: PeerTubeServer, videoUUID: string, hlsPath: string) {
164 let live: LiveVideo
165
166 try {
167 live = await server.live.get({ videoId: videoUUID })
168 } catch {}
169
170 if (live?.permanentLive) {
171 expect(await pathExists(hlsPath)).to.be.true
172
173 const hlsFiles = await readdir(hlsPath)
174 expect(hlsFiles).to.have.lengthOf(1) // Only replays directory
175
176 const replayDir = join(hlsPath, 'replay')
177 expect(await pathExists(replayDir)).to.be.true
178
179 const replayFiles = await readdir(join(hlsPath, 'replay'))
180 expect(replayFiles).to.have.lengthOf(0)
181
182 return
183 }
184
185 expect(await pathExists(hlsPath)).to.be.false
186}
diff --git a/packages/tests/src/shared/mock-servers/index.ts b/packages/tests/src/shared/mock-servers/index.ts
new file mode 100644
index 000000000..9d1c63c67
--- /dev/null
+++ b/packages/tests/src/shared/mock-servers/index.ts
@@ -0,0 +1,8 @@
1export * from './mock-429.js'
2export * from './mock-email.js'
3export * from './mock-http.js'
4export * from './mock-instances-index.js'
5export * from './mock-joinpeertube-versions.js'
6export * from './mock-object-storage.js'
7export * from './mock-plugin-blocklist.js'
8export * from './mock-proxy.js'
diff --git a/packages/tests/src/shared/mock-servers/mock-429.ts b/packages/tests/src/shared/mock-servers/mock-429.ts
new file mode 100644
index 000000000..5fcb1447d
--- /dev/null
+++ b/packages/tests/src/shared/mock-servers/mock-429.ts
@@ -0,0 +1,33 @@
1import express from 'express'
2import { Server } from 'http'
3import { getPort, randomListen, terminateServer } from './shared.js'
4
5export class Mock429 {
6 private server: Server
7 private responseSent = false
8
9 async initialize () {
10 const app = express()
11
12 app.get('/', (req: express.Request, res: express.Response, next: express.NextFunction) => {
13
14 if (!this.responseSent) {
15 this.responseSent = true
16
17 // Retry after 5 seconds
18 res.header('retry-after', '2')
19 return res.sendStatus(429)
20 }
21
22 return res.sendStatus(200)
23 })
24
25 this.server = await randomListen(app)
26
27 return getPort(this.server)
28 }
29
30 terminate () {
31 return terminateServer(this.server)
32 }
33}
diff --git a/packages/tests/src/shared/mock-servers/mock-email.ts b/packages/tests/src/shared/mock-servers/mock-email.ts
new file mode 100644
index 000000000..7c618e57f
--- /dev/null
+++ b/packages/tests/src/shared/mock-servers/mock-email.ts
@@ -0,0 +1,62 @@
1import MailDev from '@peertube/maildev'
2import { randomInt } from '@peertube/peertube-core-utils'
3import { parallelTests } from '@peertube/peertube-node-utils'
4
5class MockSmtpServer {
6
7 private static instance: MockSmtpServer
8 private started = false
9 private maildev: any
10 private emails: object[]
11
12 private constructor () { }
13
14 collectEmails (emailsCollection: object[]) {
15 return new Promise<number>((res, rej) => {
16 const port = parallelTests() ? randomInt(1025, 2000) : 1025
17 this.emails = emailsCollection
18
19 if (this.started) {
20 return res(undefined)
21 }
22
23 this.maildev = new MailDev({
24 ip: '127.0.0.1',
25 smtp: port,
26 disableWeb: true,
27 silent: true
28 })
29
30 this.maildev.on('new', email => {
31 this.emails.push(email)
32 })
33
34 this.maildev.listen(err => {
35 if (err) return rej(err)
36
37 this.started = true
38
39 return res(port)
40 })
41 })
42 }
43
44 kill () {
45 if (!this.maildev) return
46
47 this.maildev.close()
48
49 this.maildev = null
50 MockSmtpServer.instance = null
51 }
52
53 static get Instance () {
54 return this.instance || (this.instance = new this())
55 }
56}
57
58// ---------------------------------------------------------------------------
59
60export {
61 MockSmtpServer
62}
diff --git a/packages/tests/src/shared/mock-servers/mock-http.ts b/packages/tests/src/shared/mock-servers/mock-http.ts
new file mode 100644
index 000000000..bc1a9ce91
--- /dev/null
+++ b/packages/tests/src/shared/mock-servers/mock-http.ts
@@ -0,0 +1,23 @@
1import express from 'express'
2import { Server } from 'http'
3import { getPort, randomListen, terminateServer } from './shared.js'
4
5export class MockHTTP {
6 private server: Server
7
8 async initialize () {
9 const app = express()
10
11 app.get('/*', (req: express.Request, res: express.Response, next: express.NextFunction) => {
12 return res.sendStatus(200)
13 })
14
15 this.server = await randomListen(app)
16
17 return getPort(this.server)
18 }
19
20 terminate () {
21 return terminateServer(this.server)
22 }
23}
diff --git a/packages/tests/src/shared/mock-servers/mock-instances-index.ts b/packages/tests/src/shared/mock-servers/mock-instances-index.ts
new file mode 100644
index 000000000..a21367358
--- /dev/null
+++ b/packages/tests/src/shared/mock-servers/mock-instances-index.ts
@@ -0,0 +1,46 @@
1import express from 'express'
2import { Server } from 'http'
3import { getPort, randomListen, terminateServer } from './shared.js'
4
5export class MockInstancesIndex {
6 private server: Server
7
8 private readonly indexInstances: { host: string, createdAt: string }[] = []
9
10 async initialize () {
11 const app = express()
12
13 app.use('/', (req: express.Request, res: express.Response, next: express.NextFunction) => {
14 if (process.env.DEBUG) console.log('Receiving request on mocked server %s.', req.url)
15
16 return next()
17 })
18
19 app.get('/api/v1/instances/hosts', (req: express.Request, res: express.Response) => {
20 const since = req.query.since
21
22 const filtered = this.indexInstances.filter(i => {
23 if (!since) return true
24
25 return i.createdAt > since
26 })
27
28 return res.json({
29 total: filtered.length,
30 data: filtered
31 })
32 })
33
34 this.server = await randomListen(app)
35
36 return getPort(this.server)
37 }
38
39 addInstance (host: string) {
40 this.indexInstances.push({ host, createdAt: new Date().toISOString() })
41 }
42
43 terminate () {
44 return terminateServer(this.server)
45 }
46}
diff --git a/packages/tests/src/shared/mock-servers/mock-joinpeertube-versions.ts b/packages/tests/src/shared/mock-servers/mock-joinpeertube-versions.ts
new file mode 100644
index 000000000..0783165e4
--- /dev/null
+++ b/packages/tests/src/shared/mock-servers/mock-joinpeertube-versions.ts
@@ -0,0 +1,34 @@
1import express from 'express'
2import { Server } from 'http'
3import { getPort, randomListen } from './shared.js'
4
5export class MockJoinPeerTubeVersions {
6 private server: Server
7 private latestVersion: string
8
9 async initialize () {
10 const app = express()
11
12 app.use('/', (req: express.Request, res: express.Response, next: express.NextFunction) => {
13 if (process.env.DEBUG) console.log('Receiving request on mocked server %s.', req.url)
14
15 return next()
16 })
17
18 app.get('/versions.json', (req: express.Request, res: express.Response) => {
19 return res.json({
20 peertube: {
21 latestVersion: this.latestVersion
22 }
23 })
24 })
25
26 this.server = await randomListen(app)
27
28 return getPort(this.server)
29 }
30
31 setLatestVersion (latestVersion: string) {
32 this.latestVersion = latestVersion
33 }
34}
diff --git a/packages/tests/src/shared/mock-servers/mock-object-storage.ts b/packages/tests/src/shared/mock-servers/mock-object-storage.ts
new file mode 100644
index 000000000..f97c57fd7
--- /dev/null
+++ b/packages/tests/src/shared/mock-servers/mock-object-storage.ts
@@ -0,0 +1,41 @@
1import express from 'express'
2import got, { RequestError } from 'got'
3import { Server } from 'http'
4import { pipeline } from 'stream'
5import { ObjectStorageCommand } from '@peertube/peertube-server-commands'
6import { getPort, randomListen, terminateServer } from './shared.js'
7
8export class MockObjectStorageProxy {
9 private server: Server
10
11 async initialize () {
12 const app = express()
13
14 app.get('/:bucketName/:path(*)', (req: express.Request, res: express.Response, next: express.NextFunction) => {
15 const url = `http://${req.params.bucketName}.${ObjectStorageCommand.getMockEndpointHost()}/${req.params.path}`
16
17 if (process.env.DEBUG) {
18 console.log('Receiving request on mocked server %s.', req.url)
19 console.log('Proxifying request to %s', url)
20 }
21
22 return pipeline(
23 got.stream(url, { throwHttpErrors: false }),
24 res,
25 (err: RequestError) => {
26 if (!err) return
27
28 console.error('Pipeline failed.', err)
29 }
30 )
31 })
32
33 this.server = await randomListen(app)
34
35 return getPort(this.server)
36 }
37
38 terminate () {
39 return terminateServer(this.server)
40 }
41}
diff --git a/packages/tests/src/shared/mock-servers/mock-plugin-blocklist.ts b/packages/tests/src/shared/mock-servers/mock-plugin-blocklist.ts
new file mode 100644
index 000000000..c0b6518ba
--- /dev/null
+++ b/packages/tests/src/shared/mock-servers/mock-plugin-blocklist.ts
@@ -0,0 +1,36 @@
1import express, { Request, Response } from 'express'
2import { Server } from 'http'
3import { getPort, randomListen, terminateServer } from './shared.js'
4
5type BlocklistResponse = {
6 data: {
7 value: string
8 action?: 'add' | 'remove'
9 updatedAt?: string
10 }[]
11}
12
13export class MockBlocklist {
14 private body: BlocklistResponse
15 private server: Server
16
17 async initialize () {
18 const app = express()
19
20 app.get('/blocklist', (req: Request, res: Response) => {
21 return res.json(this.body)
22 })
23
24 this.server = await randomListen(app)
25
26 return getPort(this.server)
27 }
28
29 replace (body: BlocklistResponse) {
30 this.body = body
31 }
32
33 terminate () {
34 return terminateServer(this.server)
35 }
36}
diff --git a/packages/tests/src/shared/mock-servers/mock-proxy.ts b/packages/tests/src/shared/mock-servers/mock-proxy.ts
new file mode 100644
index 000000000..e731670d8
--- /dev/null
+++ b/packages/tests/src/shared/mock-servers/mock-proxy.ts
@@ -0,0 +1,24 @@
1import { createServer, Server } from 'http'
2import { createProxy } from 'proxy'
3import { getPort, terminateServer } from './shared.js'
4
5class MockProxy {
6 private server: Server
7
8 initialize () {
9 return new Promise<number>(res => {
10 this.server = createProxy(createServer())
11 this.server.listen(0, () => res(getPort(this.server)))
12 })
13 }
14
15 terminate () {
16 return terminateServer(this.server)
17 }
18}
19
20// ---------------------------------------------------------------------------
21
22export {
23 MockProxy
24}
diff --git a/packages/tests/src/shared/mock-servers/shared.ts b/packages/tests/src/shared/mock-servers/shared.ts
new file mode 100644
index 000000000..235642439
--- /dev/null
+++ b/packages/tests/src/shared/mock-servers/shared.ts
@@ -0,0 +1,33 @@
1import { Express } from 'express'
2import { Server } from 'http'
3import { AddressInfo } from 'net'
4
5function randomListen (app: Express) {
6 return new Promise<Server>(res => {
7 const server = app.listen(0, () => res(server))
8 })
9}
10
11function getPort (server: Server) {
12 const address = server.address() as AddressInfo
13
14 return address.port
15}
16
17function terminateServer (server: Server) {
18 if (!server) return Promise.resolve()
19
20 return new Promise<void>((res, rej) => {
21 server.close(err => {
22 if (err) return rej(err)
23
24 return res()
25 })
26 })
27}
28
29export {
30 randomListen,
31 getPort,
32 terminateServer
33}
diff --git a/packages/tests/src/shared/notifications.ts b/packages/tests/src/shared/notifications.ts
new file mode 100644
index 000000000..3accd7322
--- /dev/null
+++ b/packages/tests/src/shared/notifications.ts
@@ -0,0 +1,891 @@
1/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */
2
3import {
4 AbuseState,
5 AbuseStateType,
6 PluginType_Type,
7 UserNotification,
8 UserNotificationSetting,
9 UserNotificationSettingValue,
10 UserNotificationType
11} from '@peertube/peertube-models'
12import {
13 ConfigCommand,
14 PeerTubeServer,
15 createMultipleServers,
16 doubleFollow,
17 setAccessTokensToServers,
18 setDefaultAccountAvatar,
19 setDefaultChannelAvatar,
20 setDefaultVideoChannel
21} from '@peertube/peertube-server-commands'
22import { expect } from 'chai'
23import { inspect } from 'util'
24import { MockSmtpServer } from './mock-servers/index.js'
25
26type CheckerBaseParams = {
27 server: PeerTubeServer
28 emails: any[]
29 socketNotifications: UserNotification[]
30 token: string
31 check?: { web: boolean, mail: boolean }
32}
33
34type CheckerType = 'presence' | 'absence'
35
36function getAllNotificationsSettings (): UserNotificationSetting {
37 return {
38 newVideoFromSubscription: UserNotificationSettingValue.WEB | UserNotificationSettingValue.EMAIL,
39 newCommentOnMyVideo: UserNotificationSettingValue.WEB | UserNotificationSettingValue.EMAIL,
40 abuseAsModerator: UserNotificationSettingValue.WEB | UserNotificationSettingValue.EMAIL,
41 videoAutoBlacklistAsModerator: UserNotificationSettingValue.WEB | UserNotificationSettingValue.EMAIL,
42 blacklistOnMyVideo: UserNotificationSettingValue.WEB | UserNotificationSettingValue.EMAIL,
43 myVideoImportFinished: UserNotificationSettingValue.WEB | UserNotificationSettingValue.EMAIL,
44 myVideoPublished: UserNotificationSettingValue.WEB | UserNotificationSettingValue.EMAIL,
45 commentMention: UserNotificationSettingValue.WEB | UserNotificationSettingValue.EMAIL,
46 newFollow: UserNotificationSettingValue.WEB | UserNotificationSettingValue.EMAIL,
47 newUserRegistration: UserNotificationSettingValue.WEB | UserNotificationSettingValue.EMAIL,
48 newInstanceFollower: UserNotificationSettingValue.WEB | UserNotificationSettingValue.EMAIL,
49 abuseNewMessage: UserNotificationSettingValue.WEB | UserNotificationSettingValue.EMAIL,
50 abuseStateChange: UserNotificationSettingValue.WEB | UserNotificationSettingValue.EMAIL,
51 autoInstanceFollowing: UserNotificationSettingValue.WEB | UserNotificationSettingValue.EMAIL,
52 newPeerTubeVersion: UserNotificationSettingValue.WEB | UserNotificationSettingValue.EMAIL,
53 myVideoStudioEditionFinished: UserNotificationSettingValue.WEB | UserNotificationSettingValue.EMAIL,
54 newPluginVersion: UserNotificationSettingValue.WEB | UserNotificationSettingValue.EMAIL
55 }
56}
57
58async function checkNewVideoFromSubscription (options: CheckerBaseParams & {
59 videoName: string
60 shortUUID: string
61 checkType: CheckerType
62}) {
63 const { videoName, shortUUID } = options
64 const notificationType = UserNotificationType.NEW_VIDEO_FROM_SUBSCRIPTION
65
66 function notificationChecker (notification: UserNotification, checkType: CheckerType) {
67 if (checkType === 'presence') {
68 expect(notification).to.not.be.undefined
69 expect(notification.type).to.equal(notificationType)
70
71 checkVideo(notification.video, videoName, shortUUID)
72 checkActor(notification.video.channel)
73 } else {
74 expect(notification).to.satisfy((n: UserNotification) => {
75 return n === undefined || n.type !== UserNotificationType.NEW_VIDEO_FROM_SUBSCRIPTION || n.video.name !== videoName
76 })
77 }
78 }
79
80 function emailNotificationFinder (email: object) {
81 const text = email['text']
82 return text.indexOf(shortUUID) !== -1 && text.indexOf('Your subscription') !== -1
83 }
84
85 await checkNotification({ ...options, notificationChecker, emailNotificationFinder })
86}
87
88async function checkVideoIsPublished (options: CheckerBaseParams & {
89 videoName: string
90 shortUUID: string
91 checkType: CheckerType
92}) {
93 const { videoName, shortUUID } = options
94 const notificationType = UserNotificationType.MY_VIDEO_PUBLISHED
95
96 function notificationChecker (notification: UserNotification, checkType: CheckerType) {
97 if (checkType === 'presence') {
98 expect(notification).to.not.be.undefined
99 expect(notification.type).to.equal(notificationType)
100
101 checkVideo(notification.video, videoName, shortUUID)
102 checkActor(notification.video.channel)
103 } else {
104 expect(notification.video).to.satisfy(v => v === undefined || v.name !== videoName)
105 }
106 }
107
108 function emailNotificationFinder (email: object) {
109 const text: string = email['text']
110 return text.includes(shortUUID) && text.includes('Your video')
111 }
112
113 await checkNotification({ ...options, notificationChecker, emailNotificationFinder })
114}
115
116async function checkVideoStudioEditionIsFinished (options: CheckerBaseParams & {
117 videoName: string
118 shortUUID: string
119 checkType: CheckerType
120}) {
121 const { videoName, shortUUID } = options
122 const notificationType = UserNotificationType.MY_VIDEO_STUDIO_EDITION_FINISHED
123
124 function notificationChecker (notification: UserNotification, checkType: CheckerType) {
125 if (checkType === 'presence') {
126 expect(notification).to.not.be.undefined
127 expect(notification.type).to.equal(notificationType)
128
129 checkVideo(notification.video, videoName, shortUUID)
130 checkActor(notification.video.channel)
131 } else {
132 expect(notification.video).to.satisfy(v => v === undefined || v.name !== videoName)
133 }
134 }
135
136 function emailNotificationFinder (email: object) {
137 const text: string = email['text']
138 return text.includes(shortUUID) && text.includes('Edition of your video')
139 }
140
141 await checkNotification({ ...options, notificationChecker, emailNotificationFinder })
142}
143
144async function checkMyVideoImportIsFinished (options: CheckerBaseParams & {
145 videoName: string
146 shortUUID: string
147 url: string
148 success: boolean
149 checkType: CheckerType
150}) {
151 const { videoName, shortUUID, url, success } = options
152
153 const notificationType = success ? UserNotificationType.MY_VIDEO_IMPORT_SUCCESS : UserNotificationType.MY_VIDEO_IMPORT_ERROR
154
155 function notificationChecker (notification: UserNotification, checkType: CheckerType) {
156 if (checkType === 'presence') {
157 expect(notification).to.not.be.undefined
158 expect(notification.type).to.equal(notificationType)
159
160 expect(notification.videoImport.targetUrl).to.equal(url)
161
162 if (success) checkVideo(notification.videoImport.video, videoName, shortUUID)
163 } else {
164 expect(notification.videoImport).to.satisfy(i => i === undefined || i.targetUrl !== url)
165 }
166 }
167
168 function emailNotificationFinder (email: object) {
169 const text: string = email['text']
170 const toFind = success ? ' finished' : ' error'
171
172 return text.includes(url) && text.includes(toFind)
173 }
174
175 await checkNotification({ ...options, notificationChecker, emailNotificationFinder })
176}
177
178// ---------------------------------------------------------------------------
179
180async function checkUserRegistered (options: CheckerBaseParams & {
181 username: string
182 checkType: CheckerType
183}) {
184 const { username } = options
185 const notificationType = UserNotificationType.NEW_USER_REGISTRATION
186
187 function notificationChecker (notification: UserNotification, checkType: CheckerType) {
188 if (checkType === 'presence') {
189 expect(notification).to.not.be.undefined
190 expect(notification.type).to.equal(notificationType)
191
192 checkActor(notification.account, { withAvatar: false })
193 expect(notification.account.name).to.equal(username)
194 } else {
195 expect(notification).to.satisfy(n => n.type !== notificationType || n.account.name !== username)
196 }
197 }
198
199 function emailNotificationFinder (email: object) {
200 const text: string = email['text']
201
202 return text.includes(' registered.') && text.includes(username)
203 }
204
205 await checkNotification({ ...options, notificationChecker, emailNotificationFinder })
206}
207
208async function checkRegistrationRequest (options: CheckerBaseParams & {
209 username: string
210 registrationReason: string
211 checkType: CheckerType
212}) {
213 const { username, registrationReason } = options
214 const notificationType = UserNotificationType.NEW_USER_REGISTRATION_REQUEST
215
216 function notificationChecker (notification: UserNotification, checkType: CheckerType) {
217 if (checkType === 'presence') {
218 expect(notification).to.not.be.undefined
219 expect(notification.type).to.equal(notificationType)
220
221 expect(notification.registration.username).to.equal(username)
222 } else {
223 expect(notification).to.satisfy(n => n.type !== notificationType || n.registration.username !== username)
224 }
225 }
226
227 function emailNotificationFinder (email: object) {
228 const text: string = email['text']
229
230 return text.includes(' wants to register ') && text.includes(username) && text.includes(registrationReason)
231 }
232
233 await checkNotification({ ...options, notificationChecker, emailNotificationFinder })
234}
235
236// ---------------------------------------------------------------------------
237
238async function checkNewActorFollow (options: CheckerBaseParams & {
239 followType: 'channel' | 'account'
240 followerName: string
241 followerDisplayName: string
242 followingDisplayName: string
243 checkType: CheckerType
244}) {
245 const { followType, followerName, followerDisplayName, followingDisplayName } = options
246 const notificationType = UserNotificationType.NEW_FOLLOW
247
248 function notificationChecker (notification: UserNotification, checkType: CheckerType) {
249 if (checkType === 'presence') {
250 expect(notification).to.not.be.undefined
251 expect(notification.type).to.equal(notificationType)
252
253 checkActor(notification.actorFollow.follower)
254 expect(notification.actorFollow.follower.displayName).to.equal(followerDisplayName)
255 expect(notification.actorFollow.follower.name).to.equal(followerName)
256 expect(notification.actorFollow.follower.host).to.not.be.undefined
257
258 const following = notification.actorFollow.following
259 expect(following.displayName).to.equal(followingDisplayName)
260 expect(following.type).to.equal(followType)
261 } else {
262 expect(notification).to.satisfy(n => {
263 return n.type !== notificationType ||
264 (n.actorFollow.follower.name !== followerName && n.actorFollow.following !== followingDisplayName)
265 })
266 }
267 }
268
269 function emailNotificationFinder (email: object) {
270 const text: string = email['text']
271
272 return text.includes(followType) && text.includes(followingDisplayName) && text.includes(followerDisplayName)
273 }
274
275 await checkNotification({ ...options, notificationChecker, emailNotificationFinder })
276}
277
278async function checkNewInstanceFollower (options: CheckerBaseParams & {
279 followerHost: string
280 checkType: CheckerType
281}) {
282 const { followerHost } = options
283 const notificationType = UserNotificationType.NEW_INSTANCE_FOLLOWER
284
285 function notificationChecker (notification: UserNotification, checkType: CheckerType) {
286 if (checkType === 'presence') {
287 expect(notification).to.not.be.undefined
288 expect(notification.type).to.equal(notificationType)
289
290 checkActor(notification.actorFollow.follower, { withAvatar: false })
291 expect(notification.actorFollow.follower.name).to.equal('peertube')
292 expect(notification.actorFollow.follower.host).to.equal(followerHost)
293
294 expect(notification.actorFollow.following.name).to.equal('peertube')
295 } else {
296 expect(notification).to.satisfy(n => {
297 return n.type !== notificationType || n.actorFollow.follower.host !== followerHost
298 })
299 }
300 }
301
302 function emailNotificationFinder (email: object) {
303 const text: string = email['text']
304
305 return text.includes('instance has a new follower') && text.includes(followerHost)
306 }
307
308 await checkNotification({ ...options, notificationChecker, emailNotificationFinder })
309}
310
311async function checkAutoInstanceFollowing (options: CheckerBaseParams & {
312 followerHost: string
313 followingHost: string
314 checkType: CheckerType
315}) {
316 const { followerHost, followingHost } = options
317 const notificationType = UserNotificationType.AUTO_INSTANCE_FOLLOWING
318
319 function notificationChecker (notification: UserNotification, checkType: CheckerType) {
320 if (checkType === 'presence') {
321 expect(notification).to.not.be.undefined
322 expect(notification.type).to.equal(notificationType)
323
324 const following = notification.actorFollow.following
325
326 checkActor(following, { withAvatar: false })
327 expect(following.name).to.equal('peertube')
328 expect(following.host).to.equal(followingHost)
329
330 expect(notification.actorFollow.follower.name).to.equal('peertube')
331 expect(notification.actorFollow.follower.host).to.equal(followerHost)
332 } else {
333 expect(notification).to.satisfy(n => {
334 return n.type !== notificationType || n.actorFollow.following.host !== followingHost
335 })
336 }
337 }
338
339 function emailNotificationFinder (email: object) {
340 const text: string = email['text']
341
342 return text.includes(' automatically followed a new instance') && text.includes(followingHost)
343 }
344
345 await checkNotification({ ...options, notificationChecker, emailNotificationFinder })
346}
347
348async function checkCommentMention (options: CheckerBaseParams & {
349 shortUUID: string
350 commentId: number
351 threadId: number
352 byAccountDisplayName: string
353 checkType: CheckerType
354}) {
355 const { shortUUID, commentId, threadId, byAccountDisplayName } = options
356 const notificationType = UserNotificationType.COMMENT_MENTION
357
358 function notificationChecker (notification: UserNotification, checkType: CheckerType) {
359 if (checkType === 'presence') {
360 expect(notification).to.not.be.undefined
361 expect(notification.type).to.equal(notificationType)
362
363 checkComment(notification.comment, commentId, threadId)
364 checkActor(notification.comment.account)
365 expect(notification.comment.account.displayName).to.equal(byAccountDisplayName)
366
367 checkVideo(notification.comment.video, undefined, shortUUID)
368 } else {
369 expect(notification).to.satisfy(n => n.type !== notificationType || n.comment.id !== commentId)
370 }
371 }
372
373 function emailNotificationFinder (email: object) {
374 const text: string = email['text']
375
376 return text.includes(' mentioned ') && text.includes(shortUUID) && text.includes(byAccountDisplayName)
377 }
378
379 await checkNotification({ ...options, notificationChecker, emailNotificationFinder })
380}
381
382let lastEmailCount = 0
383
384async function checkNewCommentOnMyVideo (options: CheckerBaseParams & {
385 shortUUID: string
386 commentId: number
387 threadId: number
388 checkType: CheckerType
389}) {
390 const { server, shortUUID, commentId, threadId, checkType, emails } = options
391 const notificationType = UserNotificationType.NEW_COMMENT_ON_MY_VIDEO
392
393 function notificationChecker (notification: UserNotification, checkType: CheckerType) {
394 if (checkType === 'presence') {
395 expect(notification).to.not.be.undefined
396 expect(notification.type).to.equal(notificationType)
397
398 checkComment(notification.comment, commentId, threadId)
399 checkActor(notification.comment.account)
400 checkVideo(notification.comment.video, undefined, shortUUID)
401 } else {
402 expect(notification).to.satisfy((n: UserNotification) => {
403 return n === undefined || n.comment === undefined || n.comment.id !== commentId
404 })
405 }
406 }
407
408 const commentUrl = `${server.url}/w/${shortUUID};threadId=${threadId}`
409
410 function emailNotificationFinder (email: object) {
411 return email['text'].indexOf(commentUrl) !== -1
412 }
413
414 await checkNotification({ ...options, notificationChecker, emailNotificationFinder })
415
416 if (checkType === 'presence') {
417 // We cannot detect email duplicates, so check we received another email
418 expect(emails).to.have.length.above(lastEmailCount)
419 lastEmailCount = emails.length
420 }
421}
422
423async function checkNewVideoAbuseForModerators (options: CheckerBaseParams & {
424 shortUUID: string
425 videoName: string
426 checkType: CheckerType
427}) {
428 const { shortUUID, videoName } = options
429 const notificationType = UserNotificationType.NEW_ABUSE_FOR_MODERATORS
430
431 function notificationChecker (notification: UserNotification, checkType: CheckerType) {
432 if (checkType === 'presence') {
433 expect(notification).to.not.be.undefined
434 expect(notification.type).to.equal(notificationType)
435
436 expect(notification.abuse.id).to.be.a('number')
437 checkVideo(notification.abuse.video, videoName, shortUUID)
438 } else {
439 expect(notification).to.satisfy((n: UserNotification) => {
440 return n === undefined || n.abuse === undefined || n.abuse.video.shortUUID !== shortUUID
441 })
442 }
443 }
444
445 function emailNotificationFinder (email: object) {
446 const text = email['text']
447 return text.indexOf(shortUUID) !== -1 && text.indexOf('abuse') !== -1
448 }
449
450 await checkNotification({ ...options, notificationChecker, emailNotificationFinder })
451}
452
453async function checkNewAbuseMessage (options: CheckerBaseParams & {
454 abuseId: number
455 message: string
456 toEmail: string
457 checkType: CheckerType
458}) {
459 const { abuseId, message, toEmail } = options
460 const notificationType = UserNotificationType.ABUSE_NEW_MESSAGE
461
462 function notificationChecker (notification: UserNotification, checkType: CheckerType) {
463 if (checkType === 'presence') {
464 expect(notification).to.not.be.undefined
465 expect(notification.type).to.equal(notificationType)
466
467 expect(notification.abuse.id).to.equal(abuseId)
468 } else {
469 expect(notification).to.satisfy((n: UserNotification) => {
470 return n === undefined || n.type !== notificationType || n.abuse === undefined || n.abuse.id !== abuseId
471 })
472 }
473 }
474
475 function emailNotificationFinder (email: object) {
476 const text = email['text']
477 const to = email['to'].filter(t => t.address === toEmail)
478
479 return text.indexOf(message) !== -1 && to.length !== 0
480 }
481
482 await checkNotification({ ...options, notificationChecker, emailNotificationFinder })
483}
484
485async function checkAbuseStateChange (options: CheckerBaseParams & {
486 abuseId: number
487 state: AbuseStateType
488 checkType: CheckerType
489}) {
490 const { abuseId, state } = options
491 const notificationType = UserNotificationType.ABUSE_STATE_CHANGE
492
493 function notificationChecker (notification: UserNotification, checkType: CheckerType) {
494 if (checkType === 'presence') {
495 expect(notification).to.not.be.undefined
496 expect(notification.type).to.equal(notificationType)
497
498 expect(notification.abuse.id).to.equal(abuseId)
499 expect(notification.abuse.state).to.equal(state)
500 } else {
501 expect(notification).to.satisfy((n: UserNotification) => {
502 return n === undefined || n.abuse === undefined || n.abuse.id !== abuseId
503 })
504 }
505 }
506
507 function emailNotificationFinder (email: object) {
508 const text = email['text']
509
510 const contains = state === AbuseState.ACCEPTED
511 ? ' accepted'
512 : ' rejected'
513
514 return text.indexOf(contains) !== -1
515 }
516
517 await checkNotification({ ...options, notificationChecker, emailNotificationFinder })
518}
519
520async function checkNewCommentAbuseForModerators (options: CheckerBaseParams & {
521 shortUUID: string
522 videoName: string
523 checkType: CheckerType
524}) {
525 const { shortUUID, videoName } = options
526 const notificationType = UserNotificationType.NEW_ABUSE_FOR_MODERATORS
527
528 function notificationChecker (notification: UserNotification, checkType: CheckerType) {
529 if (checkType === 'presence') {
530 expect(notification).to.not.be.undefined
531 expect(notification.type).to.equal(notificationType)
532
533 expect(notification.abuse.id).to.be.a('number')
534 checkVideo(notification.abuse.comment.video, videoName, shortUUID)
535 } else {
536 expect(notification).to.satisfy((n: UserNotification) => {
537 return n === undefined || n.abuse === undefined || n.abuse.comment.video.shortUUID !== shortUUID
538 })
539 }
540 }
541
542 function emailNotificationFinder (email: object) {
543 const text = email['text']
544 return text.indexOf(shortUUID) !== -1 && text.indexOf('abuse') !== -1
545 }
546
547 await checkNotification({ ...options, notificationChecker, emailNotificationFinder })
548}
549
550async function checkNewAccountAbuseForModerators (options: CheckerBaseParams & {
551 displayName: string
552 checkType: CheckerType
553}) {
554 const { displayName } = options
555 const notificationType = UserNotificationType.NEW_ABUSE_FOR_MODERATORS
556
557 function notificationChecker (notification: UserNotification, checkType: CheckerType) {
558 if (checkType === 'presence') {
559 expect(notification).to.not.be.undefined
560 expect(notification.type).to.equal(notificationType)
561
562 expect(notification.abuse.id).to.be.a('number')
563 expect(notification.abuse.account.displayName).to.equal(displayName)
564 } else {
565 expect(notification).to.satisfy((n: UserNotification) => {
566 return n === undefined || n.abuse === undefined || n.abuse.account.displayName !== displayName
567 })
568 }
569 }
570
571 function emailNotificationFinder (email: object) {
572 const text = email['text']
573 return text.indexOf(displayName) !== -1 && text.indexOf('abuse') !== -1
574 }
575
576 await checkNotification({ ...options, notificationChecker, emailNotificationFinder })
577}
578
579async function checkVideoAutoBlacklistForModerators (options: CheckerBaseParams & {
580 shortUUID: string
581 videoName: string
582 checkType: CheckerType
583}) {
584 const { shortUUID, videoName } = options
585 const notificationType = UserNotificationType.VIDEO_AUTO_BLACKLIST_FOR_MODERATORS
586
587 function notificationChecker (notification: UserNotification, checkType: CheckerType) {
588 if (checkType === 'presence') {
589 expect(notification).to.not.be.undefined
590 expect(notification.type).to.equal(notificationType)
591
592 expect(notification.videoBlacklist.video.id).to.be.a('number')
593 checkVideo(notification.videoBlacklist.video, videoName, shortUUID)
594 } else {
595 expect(notification).to.satisfy((n: UserNotification) => {
596 return n === undefined || n.video === undefined || n.video.shortUUID !== shortUUID
597 })
598 }
599 }
600
601 function emailNotificationFinder (email: object) {
602 const text = email['text']
603 return text.indexOf(shortUUID) !== -1 && email['text'].indexOf('video-auto-blacklist/list') !== -1
604 }
605
606 await checkNotification({ ...options, notificationChecker, emailNotificationFinder })
607}
608
609async function checkNewBlacklistOnMyVideo (options: CheckerBaseParams & {
610 shortUUID: string
611 videoName: string
612 blacklistType: 'blacklist' | 'unblacklist'
613}) {
614 const { videoName, shortUUID, blacklistType } = options
615 const notificationType = blacklistType === 'blacklist'
616 ? UserNotificationType.BLACKLIST_ON_MY_VIDEO
617 : UserNotificationType.UNBLACKLIST_ON_MY_VIDEO
618
619 function notificationChecker (notification: UserNotification) {
620 expect(notification).to.not.be.undefined
621 expect(notification.type).to.equal(notificationType)
622
623 const video = blacklistType === 'blacklist' ? notification.videoBlacklist.video : notification.video
624
625 checkVideo(video, videoName, shortUUID)
626 }
627
628 function emailNotificationFinder (email: object) {
629 const text = email['text']
630 const blacklistText = blacklistType === 'blacklist'
631 ? 'blacklisted'
632 : 'unblacklisted'
633
634 return text.includes(shortUUID) && text.includes(blacklistText)
635 }
636
637 await checkNotification({ ...options, notificationChecker, emailNotificationFinder, checkType: 'presence' })
638}
639
640async function checkNewPeerTubeVersion (options: CheckerBaseParams & {
641 latestVersion: string
642 checkType: CheckerType
643}) {
644 const { latestVersion } = options
645 const notificationType = UserNotificationType.NEW_PEERTUBE_VERSION
646
647 function notificationChecker (notification: UserNotification, checkType: CheckerType) {
648 if (checkType === 'presence') {
649 expect(notification).to.not.be.undefined
650 expect(notification.type).to.equal(notificationType)
651
652 expect(notification.peertube).to.exist
653 expect(notification.peertube.latestVersion).to.equal(latestVersion)
654 } else {
655 expect(notification).to.satisfy((n: UserNotification) => {
656 return n === undefined || n.peertube === undefined || n.peertube.latestVersion !== latestVersion
657 })
658 }
659 }
660
661 function emailNotificationFinder (email: object) {
662 const text = email['text']
663
664 return text.includes(latestVersion)
665 }
666
667 await checkNotification({ ...options, notificationChecker, emailNotificationFinder })
668}
669
670async function checkNewPluginVersion (options: CheckerBaseParams & {
671 pluginType: PluginType_Type
672 pluginName: string
673 checkType: CheckerType
674}) {
675 const { pluginName, pluginType } = options
676 const notificationType = UserNotificationType.NEW_PLUGIN_VERSION
677
678 function notificationChecker (notification: UserNotification, checkType: CheckerType) {
679 if (checkType === 'presence') {
680 expect(notification).to.not.be.undefined
681 expect(notification.type).to.equal(notificationType)
682
683 expect(notification.plugin.name).to.equal(pluginName)
684 expect(notification.plugin.type).to.equal(pluginType)
685 } else {
686 expect(notification).to.satisfy((n: UserNotification) => {
687 return n === undefined || n.plugin === undefined || n.plugin.name !== pluginName
688 })
689 }
690 }
691
692 function emailNotificationFinder (email: object) {
693 const text = email['text']
694
695 return text.includes(pluginName)
696 }
697
698 await checkNotification({ ...options, notificationChecker, emailNotificationFinder })
699}
700
701async function prepareNotificationsTest (serversCount = 3, overrideConfigArg: any = {}) {
702 const userNotifications: UserNotification[] = []
703 const adminNotifications: UserNotification[] = []
704 const adminNotificationsServer2: UserNotification[] = []
705 const emails: object[] = []
706
707 const port = await MockSmtpServer.Instance.collectEmails(emails)
708
709 const overrideConfig = {
710 ...ConfigCommand.getEmailOverrideConfig(port),
711
712 signup: {
713 limit: 20
714 }
715 }
716 const servers = await createMultipleServers(serversCount, Object.assign(overrideConfig, overrideConfigArg))
717
718 await setAccessTokensToServers(servers)
719 await setDefaultVideoChannel(servers)
720 await setDefaultChannelAvatar(servers)
721 await setDefaultAccountAvatar(servers)
722
723 if (servers[1]) {
724 await servers[1].config.enableStudio()
725 await servers[1].config.enableLive({ allowReplay: true, transcoding: false })
726 }
727
728 if (serversCount > 1) {
729 await doubleFollow(servers[0], servers[1])
730 }
731
732 const user = { username: 'user_1', password: 'super password' }
733 await servers[0].users.create({ ...user, videoQuota: 10 * 1000 * 1000 })
734 const userAccessToken = await servers[0].login.getAccessToken(user)
735
736 await servers[0].notifications.updateMySettings({ token: userAccessToken, settings: getAllNotificationsSettings() })
737 await servers[0].users.updateMyAvatar({ token: userAccessToken, fixture: 'avatar.png' })
738 await servers[0].channels.updateImage({ channelName: 'user_1_channel', token: userAccessToken, fixture: 'avatar.png', type: 'avatar' })
739
740 await servers[0].notifications.updateMySettings({ settings: getAllNotificationsSettings() })
741
742 if (serversCount > 1) {
743 await servers[1].notifications.updateMySettings({ settings: getAllNotificationsSettings() })
744 }
745
746 {
747 const socket = servers[0].socketIO.getUserNotificationSocket({ token: userAccessToken })
748 socket.on('new-notification', n => userNotifications.push(n))
749 }
750 {
751 const socket = servers[0].socketIO.getUserNotificationSocket()
752 socket.on('new-notification', n => adminNotifications.push(n))
753 }
754
755 if (serversCount > 1) {
756 const socket = servers[1].socketIO.getUserNotificationSocket()
757 socket.on('new-notification', n => adminNotificationsServer2.push(n))
758 }
759
760 const { videoChannels } = await servers[0].users.getMyInfo()
761 const channelId = videoChannels[0].id
762
763 return {
764 userNotifications,
765 adminNotifications,
766 adminNotificationsServer2,
767 userAccessToken,
768 emails,
769 servers,
770 channelId,
771 baseOverrideConfig: overrideConfig
772 }
773}
774
775// ---------------------------------------------------------------------------
776
777export {
778 type CheckerType,
779 type CheckerBaseParams,
780
781 getAllNotificationsSettings,
782
783 checkMyVideoImportIsFinished,
784 checkUserRegistered,
785 checkAutoInstanceFollowing,
786 checkVideoIsPublished,
787 checkNewVideoFromSubscription,
788 checkNewActorFollow,
789 checkNewCommentOnMyVideo,
790 checkNewBlacklistOnMyVideo,
791 checkCommentMention,
792 checkNewVideoAbuseForModerators,
793 checkVideoAutoBlacklistForModerators,
794 checkNewAbuseMessage,
795 checkAbuseStateChange,
796 checkNewInstanceFollower,
797 prepareNotificationsTest,
798 checkNewCommentAbuseForModerators,
799 checkNewAccountAbuseForModerators,
800 checkNewPeerTubeVersion,
801 checkNewPluginVersion,
802 checkVideoStudioEditionIsFinished,
803 checkRegistrationRequest
804}
805
806// ---------------------------------------------------------------------------
807
808async function checkNotification (options: CheckerBaseParams & {
809 notificationChecker: (notification: UserNotification, checkType: CheckerType) => void
810 emailNotificationFinder: (email: object) => boolean
811 checkType: CheckerType
812}) {
813 const { server, token, checkType, notificationChecker, emailNotificationFinder, socketNotifications, emails } = options
814
815 const check = options.check || { web: true, mail: true }
816
817 if (check.web) {
818 const notification = await server.notifications.getLatest({ token })
819
820 if (notification || checkType !== 'absence') {
821 notificationChecker(notification, checkType)
822 }
823
824 const socketNotification = socketNotifications.find(n => {
825 try {
826 notificationChecker(n, 'presence')
827 return true
828 } catch {
829 return false
830 }
831 })
832
833 if (checkType === 'presence') {
834 const obj = inspect(socketNotifications, { depth: 5 })
835 expect(socketNotification, 'The socket notification is absent when it should be present. ' + obj).to.not.be.undefined
836 } else {
837 const obj = inspect(socketNotification, { depth: 5 })
838 expect(socketNotification, 'The socket notification is present when it should not be present. ' + obj).to.be.undefined
839 }
840 }
841
842 if (check.mail) {
843 // Last email
844 const email = emails
845 .slice()
846 .reverse()
847 .find(e => emailNotificationFinder(e))
848
849 if (checkType === 'presence') {
850 const texts = emails.map(e => e.text)
851 expect(email, 'The email is absent when is should be present. ' + inspect(texts)).to.not.be.undefined
852 } else {
853 expect(email, 'The email is present when is should not be present. ' + inspect(email)).to.be.undefined
854 }
855 }
856}
857
858function checkVideo (video: any, videoName?: string, shortUUID?: string) {
859 if (videoName) {
860 expect(video.name).to.be.a('string')
861 expect(video.name).to.not.be.empty
862 expect(video.name).to.equal(videoName)
863 }
864
865 if (shortUUID) {
866 expect(video.shortUUID).to.be.a('string')
867 expect(video.shortUUID).to.not.be.empty
868 expect(video.shortUUID).to.equal(shortUUID)
869 }
870
871 expect(video.id).to.be.a('number')
872}
873
874function checkActor (actor: any, options: { withAvatar?: boolean } = {}) {
875 const { withAvatar = true } = options
876
877 expect(actor.displayName).to.be.a('string')
878 expect(actor.displayName).to.not.be.empty
879 expect(actor.host).to.not.be.undefined
880
881 if (withAvatar) {
882 expect(actor.avatars).to.be.an('array')
883 expect(actor.avatars).to.have.lengthOf(2)
884 expect(actor.avatars[0].path).to.exist.and.not.empty
885 }
886}
887
888function checkComment (comment: any, commentId: number, threadId: number) {
889 expect(comment.id).to.equal(commentId)
890 expect(comment.threadId).to.equal(threadId)
891}
diff --git a/packages/tests/src/shared/peertube-runner-process.ts b/packages/tests/src/shared/peertube-runner-process.ts
new file mode 100644
index 000000000..3d1f299f2
--- /dev/null
+++ b/packages/tests/src/shared/peertube-runner-process.ts
@@ -0,0 +1,104 @@
1import { ChildProcess, fork, ForkOptions } from 'child_process'
2import execa from 'execa'
3import { join } from 'path'
4import { root } from '@peertube/peertube-node-utils'
5import { PeerTubeServer } from '@peertube/peertube-server-commands'
6
7export class PeerTubeRunnerProcess {
8 private app?: ChildProcess
9
10 constructor (private readonly server: PeerTubeServer) {
11
12 }
13
14 runServer (options: {
15 hideLogs?: boolean // default true
16 } = {}) {
17 const { hideLogs = true } = options
18
19 return new Promise<void>((res, rej) => {
20 const args = [ 'server', '--verbose', ...this.buildIdArg() ]
21
22 const forkOptions: ForkOptions = {
23 detached: false,
24 silent: true,
25 execArgv: [] // Don't inject parent node options
26 }
27
28 this.app = fork(this.getRunnerPath(), args, forkOptions)
29
30 this.app.stdout.on('data', data => {
31 const str = data.toString() as string
32
33 if (!hideLogs) {
34 console.log(str)
35 }
36 })
37
38 res()
39 })
40 }
41
42 registerPeerTubeInstance (options: {
43 registrationToken: string
44 runnerName: string
45 runnerDescription?: string
46 }) {
47 const { registrationToken, runnerName, runnerDescription } = options
48
49 const args = [
50 'register',
51 '--url', this.server.url,
52 '--registration-token', registrationToken,
53 '--runner-name', runnerName,
54 ...this.buildIdArg()
55 ]
56
57 if (runnerDescription) {
58 args.push('--runner-description')
59 args.push(runnerDescription)
60 }
61
62 return this.runCommand(this.getRunnerPath(), args)
63 }
64
65 unregisterPeerTubeInstance (options: {
66 runnerName: string
67 }) {
68 const { runnerName } = options
69
70 const args = [ 'unregister', '--url', this.server.url, '--runner-name', runnerName, ...this.buildIdArg() ]
71 return this.runCommand(this.getRunnerPath(), args)
72 }
73
74 async listRegisteredPeerTubeInstances () {
75 const args = [ 'list-registered', ...this.buildIdArg() ]
76 const { stdout } = await this.runCommand(this.getRunnerPath(), args)
77
78 return stdout
79 }
80
81 kill () {
82 if (!this.app) return
83
84 process.kill(this.app.pid)
85
86 this.app = null
87 }
88
89 getId () {
90 return 'test-' + this.server.internalServerNumber
91 }
92
93 private getRunnerPath () {
94 return join(root(), 'apps', 'peertube-runner', 'dist', 'peertube-runner.js')
95 }
96
97 private buildIdArg () {
98 return [ '--id', this.getId() ]
99 }
100
101 private runCommand (path: string, args: string[]) {
102 return execa.node(path, args, { env: { ...process.env, NODE_OPTIONS: '' } })
103 }
104}
diff --git a/packages/tests/src/shared/plugins.ts b/packages/tests/src/shared/plugins.ts
new file mode 100644
index 000000000..c2afcbcbf
--- /dev/null
+++ b/packages/tests/src/shared/plugins.ts
@@ -0,0 +1,18 @@
1/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */
2
3import { expect } from 'chai'
4import { PeerTubeServer } from '@peertube/peertube-server-commands'
5
6async function testHelloWorldRegisteredSettings (server: PeerTubeServer) {
7 const body = await server.plugins.getRegisteredSettings({ npmName: 'peertube-plugin-hello-world' })
8
9 const registeredSettings = body.registeredSettings
10 expect(registeredSettings).to.have.length.at.least(1)
11
12 const adminNameSettings = registeredSettings.find(s => s.name === 'admin-name')
13 expect(adminNameSettings).to.not.be.undefined
14}
15
16export {
17 testHelloWorldRegisteredSettings
18}
diff --git a/packages/tests/src/shared/requests.ts b/packages/tests/src/shared/requests.ts
new file mode 100644
index 000000000..fc70ad6ed
--- /dev/null
+++ b/packages/tests/src/shared/requests.ts
@@ -0,0 +1,12 @@
1import { doRequest } from '@peertube/peertube-server/server/helpers/requests.js'
2
3export function makePOSTAPRequest (url: string, body: any, httpSignature: any, headers: any) {
4 const options = {
5 method: 'POST' as 'POST',
6 json: body,
7 httpSignature,
8 headers
9 }
10
11 return doRequest(url, options)
12}
diff --git a/packages/tests/src/shared/sql-command.ts b/packages/tests/src/shared/sql-command.ts
new file mode 100644
index 000000000..1c4f89351
--- /dev/null
+++ b/packages/tests/src/shared/sql-command.ts
@@ -0,0 +1,150 @@
1import { QueryTypes, Sequelize } from 'sequelize'
2import { forceNumber } from '@peertube/peertube-core-utils'
3import { PeerTubeServer } from '@peertube/peertube-server-commands'
4
5export class SQLCommand {
6 private sequelize: Sequelize
7
8 constructor (private readonly server: PeerTubeServer) {
9
10 }
11
12 deleteAll (table: string) {
13 const seq = this.getSequelize()
14
15 const options = { type: QueryTypes.DELETE }
16
17 return seq.query(`DELETE FROM "${table}"`, options)
18 }
19
20 async getVideoShareCount () {
21 const [ { total } ] = await this.selectQuery<{ total: string }>(`SELECT COUNT(*) as total FROM "videoShare"`)
22 if (total === null) return 0
23
24 return parseInt(total, 10)
25 }
26
27 async getInternalFileUrl (fileId: number) {
28 return this.selectQuery<{ fileUrl: string }>(`SELECT "fileUrl" FROM "videoFile" WHERE id = :fileId`, { fileId })
29 .then(rows => rows[0].fileUrl)
30 }
31
32 setActorField (to: string, field: string, value: string) {
33 return this.updateQuery(`UPDATE actor SET ${this.escapeColumnName(field)} = :value WHERE url = :to`, { value, to })
34 }
35
36 setVideoField (uuid: string, field: string, value: string) {
37 return this.updateQuery(`UPDATE video SET ${this.escapeColumnName(field)} = :value WHERE uuid = :uuid`, { value, uuid })
38 }
39
40 setPlaylistField (uuid: string, field: string, value: string) {
41 return this.updateQuery(`UPDATE "videoPlaylist" SET ${this.escapeColumnName(field)} = :value WHERE uuid = :uuid`, { value, uuid })
42 }
43
44 async countVideoViewsOf (uuid: string) {
45 const query = 'SELECT SUM("videoView"."views") AS "total" FROM "videoView" ' +
46 `INNER JOIN "video" ON "video"."id" = "videoView"."videoId" WHERE "video"."uuid" = :uuid`
47
48 const [ { total } ] = await this.selectQuery<{ total: number }>(query, { uuid })
49 if (!total) return 0
50
51 return forceNumber(total)
52 }
53
54 getActorImage (filename: string) {
55 return this.selectQuery<{ width: number, height: number }>(`SELECT * FROM "actorImage" WHERE filename = :filename`, { filename })
56 .then(rows => rows[0])
57 }
58
59 // ---------------------------------------------------------------------------
60
61 setPluginVersion (pluginName: string, newVersion: string) {
62 return this.setPluginField(pluginName, 'version', newVersion)
63 }
64
65 setPluginLatestVersion (pluginName: string, newVersion: string) {
66 return this.setPluginField(pluginName, 'latestVersion', newVersion)
67 }
68
69 setPluginField (pluginName: string, field: string, value: string) {
70 return this.updateQuery(
71 `UPDATE "plugin" SET ${this.escapeColumnName(field)} = :value WHERE "name" = :pluginName`,
72 { pluginName, value }
73 )
74 }
75
76 // ---------------------------------------------------------------------------
77
78 selectQuery <T extends object> (query: string, replacements: { [id: string]: string | number } = {}) {
79 const seq = this.getSequelize()
80 const options = {
81 type: QueryTypes.SELECT as QueryTypes.SELECT,
82 replacements
83 }
84
85 return seq.query<T>(query, options)
86 }
87
88 updateQuery (query: string, replacements: { [id: string]: string | number } = {}) {
89 const seq = this.getSequelize()
90 const options = { type: QueryTypes.UPDATE as QueryTypes.UPDATE, replacements }
91
92 return seq.query(query, options)
93 }
94
95 // ---------------------------------------------------------------------------
96
97 async getPlaylistInfohash (playlistId: number) {
98 const query = 'SELECT "p2pMediaLoaderInfohashes" FROM "videoStreamingPlaylist" WHERE id = :playlistId'
99
100 const result = await this.selectQuery<{ p2pMediaLoaderInfohashes: string }>(query, { playlistId })
101 if (!result || result.length === 0) return []
102
103 return result[0].p2pMediaLoaderInfohashes
104 }
105
106 // ---------------------------------------------------------------------------
107
108 setActorFollowScores (newScore: number) {
109 return this.updateQuery(`UPDATE "actorFollow" SET "score" = :newScore`, { newScore })
110 }
111
112 setTokenField (accessToken: string, field: string, value: string) {
113 return this.updateQuery(
114 `UPDATE "oAuthToken" SET ${this.escapeColumnName(field)} = :value WHERE "accessToken" = :accessToken`,
115 { value, accessToken }
116 )
117 }
118
119 async cleanup () {
120 if (!this.sequelize) return
121
122 await this.sequelize.close()
123 this.sequelize = undefined
124 }
125
126 private getSequelize () {
127 if (this.sequelize) return this.sequelize
128
129 const dbname = 'peertube_test' + this.server.internalServerNumber
130 const username = 'peertube'
131 const password = 'peertube'
132 const host = '127.0.0.1'
133 const port = 5432
134
135 this.sequelize = new Sequelize(dbname, username, password, {
136 dialect: 'postgres',
137 host,
138 port,
139 logging: false
140 })
141
142 return this.sequelize
143 }
144
145 private escapeColumnName (columnName: string) {
146 return this.getSequelize().escape(columnName)
147 .replace(/^'/, '"')
148 .replace(/'$/, '"')
149 }
150}
diff --git a/packages/tests/src/shared/streaming-playlists.ts b/packages/tests/src/shared/streaming-playlists.ts
new file mode 100644
index 000000000..f2f0fbe85
--- /dev/null
+++ b/packages/tests/src/shared/streaming-playlists.ts
@@ -0,0 +1,302 @@
1/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */
2
3import { expect } from 'chai'
4import { basename, dirname, join } from 'path'
5import { removeFragmentedMP4Ext, uuidRegex } from '@peertube/peertube-core-utils'
6import {
7 HttpStatusCode,
8 VideoPrivacy,
9 VideoResolution,
10 VideoStreamingPlaylist,
11 VideoStreamingPlaylistType
12} from '@peertube/peertube-models'
13import { sha256 } from '@peertube/peertube-node-utils'
14import { makeRawRequest, PeerTubeServer } from '@peertube/peertube-server-commands'
15import { expectStartWith } from './checks.js'
16import { hlsInfohashExist } from './tracker.js'
17import { checkWebTorrentWorks } from './webtorrent.js'
18
19async function checkSegmentHash (options: {
20 server: PeerTubeServer
21 baseUrlPlaylist: string
22 baseUrlSegment: string
23 resolution: number
24 hlsPlaylist: VideoStreamingPlaylist
25 token?: string
26}) {
27 const { server, baseUrlPlaylist, baseUrlSegment, resolution, hlsPlaylist, token } = options
28 const command = server.streamingPlaylists
29
30 const file = hlsPlaylist.files.find(f => f.resolution.id === resolution)
31 const videoName = basename(file.fileUrl)
32
33 const playlist = await command.get({ url: `${baseUrlPlaylist}/${removeFragmentedMP4Ext(videoName)}.m3u8`, token })
34
35 const matches = /#EXT-X-BYTERANGE:(\d+)@(\d+)/.exec(playlist)
36
37 const length = parseInt(matches[1], 10)
38 const offset = parseInt(matches[2], 10)
39 const range = `${offset}-${offset + length - 1}`
40
41 const segmentBody = await command.getFragmentedSegment({
42 url: `${baseUrlSegment}/${videoName}`,
43 expectedStatus: HttpStatusCode.PARTIAL_CONTENT_206,
44 range: `bytes=${range}`,
45 token
46 })
47
48 const shaBody = await command.getSegmentSha256({ url: hlsPlaylist.segmentsSha256Url, token })
49 expect(sha256(segmentBody)).to.equal(shaBody[videoName][range], `Invalid sha256 result for ${videoName} range ${range}`)
50}
51
52// ---------------------------------------------------------------------------
53
54async function checkLiveSegmentHash (options: {
55 server: PeerTubeServer
56 baseUrlSegment: string
57 videoUUID: string
58 segmentName: string
59 hlsPlaylist: VideoStreamingPlaylist
60 withRetry?: boolean
61}) {
62 const { server, baseUrlSegment, videoUUID, segmentName, hlsPlaylist, withRetry = false } = options
63 const command = server.streamingPlaylists
64
65 const segmentBody = await command.getFragmentedSegment({ url: `${baseUrlSegment}/${videoUUID}/${segmentName}`, withRetry })
66 const shaBody = await command.getSegmentSha256({ url: hlsPlaylist.segmentsSha256Url, withRetry })
67
68 expect(sha256(segmentBody)).to.equal(shaBody[segmentName])
69}
70
71// ---------------------------------------------------------------------------
72
73async function checkResolutionsInMasterPlaylist (options: {
74 server: PeerTubeServer
75 playlistUrl: string
76 resolutions: number[]
77 token?: string
78 transcoded?: boolean // default true
79 withRetry?: boolean // default false
80}) {
81 const { server, playlistUrl, resolutions, token, withRetry = false, transcoded = true } = options
82
83 const masterPlaylist = await server.streamingPlaylists.get({ url: playlistUrl, token, withRetry })
84
85 for (const resolution of resolutions) {
86 const base = '#EXT-X-STREAM-INF:BANDWIDTH=\\d+,RESOLUTION=\\d+x' + resolution
87
88 if (resolution === VideoResolution.H_NOVIDEO) {
89 expect(masterPlaylist).to.match(new RegExp(`${base},CODECS="mp4a.40.2"`))
90 } else if (transcoded) {
91 expect(masterPlaylist).to.match(new RegExp(`${base},(FRAME-RATE=\\d+,)?CODECS="avc1.64001f,mp4a.40.2"`))
92 } else {
93 expect(masterPlaylist).to.match(new RegExp(`${base}`))
94 }
95 }
96
97 const playlistsLength = masterPlaylist.split('\n').filter(line => line.startsWith('#EXT-X-STREAM-INF:BANDWIDTH='))
98 expect(playlistsLength).to.have.lengthOf(resolutions.length)
99}
100
101async function completeCheckHlsPlaylist (options: {
102 servers: PeerTubeServer[]
103 videoUUID: string
104 hlsOnly: boolean
105
106 resolutions?: number[]
107 objectStorageBaseUrl?: string
108}) {
109 const { videoUUID, hlsOnly, objectStorageBaseUrl } = options
110
111 const resolutions = options.resolutions ?? [ 240, 360, 480, 720 ]
112
113 for (const server of options.servers) {
114 const videoDetails = await server.videos.getWithToken({ id: videoUUID })
115 const requiresAuth = videoDetails.privacy.id === VideoPrivacy.PRIVATE || videoDetails.privacy.id === VideoPrivacy.INTERNAL
116
117 const privatePath = requiresAuth
118 ? 'private/'
119 : ''
120 const token = requiresAuth
121 ? server.accessToken
122 : undefined
123
124 const baseUrl = `http://${videoDetails.account.host}`
125
126 expect(videoDetails.streamingPlaylists).to.have.lengthOf(1)
127
128 const hlsPlaylist = videoDetails.streamingPlaylists.find(p => p.type === VideoStreamingPlaylistType.HLS)
129 expect(hlsPlaylist).to.not.be.undefined
130
131 const hlsFiles = hlsPlaylist.files
132 expect(hlsFiles).to.have.lengthOf(resolutions.length)
133
134 if (hlsOnly) expect(videoDetails.files).to.have.lengthOf(0)
135 else expect(videoDetails.files).to.have.lengthOf(resolutions.length)
136
137 // Check JSON files
138 for (const resolution of resolutions) {
139 const file = hlsFiles.find(f => f.resolution.id === resolution)
140 expect(file).to.not.be.undefined
141
142 if (file.resolution.id === VideoResolution.H_NOVIDEO) {
143 expect(file.resolution.label).to.equal('Audio')
144 } else {
145 expect(file.resolution.label).to.equal(resolution + 'p')
146 }
147
148 expect(file.magnetUri).to.have.lengthOf.above(2)
149 await checkWebTorrentWorks(file.magnetUri)
150
151 {
152 const nameReg = `${uuidRegex}-${file.resolution.id}`
153
154 expect(file.torrentUrl).to.match(new RegExp(`${server.url}/lazy-static/torrents/${nameReg}-hls.torrent`))
155
156 if (objectStorageBaseUrl && requiresAuth) {
157 // eslint-disable-next-line max-len
158 expect(file.fileUrl).to.match(new RegExp(`${server.url}/object-storage-proxy/streaming-playlists/hls/${privatePath}${videoDetails.uuid}/${nameReg}-fragmented.mp4`))
159 } else if (objectStorageBaseUrl) {
160 expectStartWith(file.fileUrl, objectStorageBaseUrl)
161 } else {
162 expect(file.fileUrl).to.match(
163 new RegExp(`${baseUrl}/static/streaming-playlists/hls/${privatePath}${videoDetails.uuid}/${nameReg}-fragmented.mp4`)
164 )
165 }
166 }
167
168 {
169 await Promise.all([
170 makeRawRequest({ url: file.torrentUrl, token, expectedStatus: HttpStatusCode.OK_200 }),
171 makeRawRequest({ url: file.torrentDownloadUrl, token, expectedStatus: HttpStatusCode.OK_200 }),
172 makeRawRequest({ url: file.metadataUrl, token, expectedStatus: HttpStatusCode.OK_200 }),
173 makeRawRequest({ url: file.fileUrl, token, expectedStatus: HttpStatusCode.OK_200 }),
174
175 makeRawRequest({
176 url: file.fileDownloadUrl,
177 token,
178 expectedStatus: objectStorageBaseUrl
179 ? HttpStatusCode.FOUND_302
180 : HttpStatusCode.OK_200
181 })
182 ])
183 }
184 }
185
186 // Check master playlist
187 {
188 await checkResolutionsInMasterPlaylist({ server, token, playlistUrl: hlsPlaylist.playlistUrl, resolutions })
189
190 const masterPlaylist = await server.streamingPlaylists.get({ url: hlsPlaylist.playlistUrl, token })
191
192 let i = 0
193 for (const resolution of resolutions) {
194 expect(masterPlaylist).to.contain(`${resolution}.m3u8`)
195 expect(masterPlaylist).to.contain(`${resolution}.m3u8`)
196
197 const url = 'http://' + videoDetails.account.host
198 await hlsInfohashExist(url, hlsPlaylist.playlistUrl, i)
199
200 i++
201 }
202 }
203
204 // Check resolution playlists
205 {
206 for (const resolution of resolutions) {
207 const file = hlsFiles.find(f => f.resolution.id === resolution)
208 const playlistName = removeFragmentedMP4Ext(basename(file.fileUrl)) + '.m3u8'
209
210 let url: string
211 if (objectStorageBaseUrl && requiresAuth) {
212 url = `${baseUrl}/object-storage-proxy/streaming-playlists/hls/${privatePath}${videoUUID}/${playlistName}`
213 } else if (objectStorageBaseUrl) {
214 url = `${objectStorageBaseUrl}hls/${videoUUID}/${playlistName}`
215 } else {
216 url = `${baseUrl}/static/streaming-playlists/hls/${privatePath}${videoUUID}/${playlistName}`
217 }
218
219 const subPlaylist = await server.streamingPlaylists.get({ url, token })
220
221 expect(subPlaylist).to.match(new RegExp(`${uuidRegex}-${resolution}-fragmented.mp4`))
222 expect(subPlaylist).to.contain(basename(file.fileUrl))
223 }
224 }
225
226 {
227 let baseUrlAndPath: string
228 if (objectStorageBaseUrl && requiresAuth) {
229 baseUrlAndPath = `${baseUrl}/object-storage-proxy/streaming-playlists/hls/${privatePath}${videoUUID}`
230 } else if (objectStorageBaseUrl) {
231 baseUrlAndPath = `${objectStorageBaseUrl}hls/${videoUUID}`
232 } else {
233 baseUrlAndPath = `${baseUrl}/static/streaming-playlists/hls/${privatePath}${videoUUID}`
234 }
235
236 for (const resolution of resolutions) {
237 await checkSegmentHash({
238 server,
239 token,
240 baseUrlPlaylist: baseUrlAndPath,
241 baseUrlSegment: baseUrlAndPath,
242 resolution,
243 hlsPlaylist
244 })
245 }
246 }
247 }
248}
249
250async function checkVideoFileTokenReinjection (options: {
251 server: PeerTubeServer
252 videoUUID: string
253 videoFileToken: string
254 resolutions: number[]
255 isLive: boolean
256}) {
257 const { server, resolutions, videoFileToken, videoUUID, isLive } = options
258
259 const video = await server.videos.getWithToken({ id: videoUUID })
260 const hls = video.streamingPlaylists[0]
261
262 const query = { videoFileToken, reinjectVideoFileToken: 'true' }
263 const { text } = await makeRawRequest({ url: hls.playlistUrl, query, expectedStatus: HttpStatusCode.OK_200 })
264
265 for (let i = 0; i < resolutions.length; i++) {
266 const resolution = resolutions[i]
267
268 const suffix = isLive
269 ? i
270 : `-${resolution}`
271
272 expect(text).to.contain(`${suffix}.m3u8?videoFileToken=${videoFileToken}&reinjectVideoFileToken=true`)
273 }
274
275 const resolutionPlaylists = extractResolutionPlaylistUrls(hls.playlistUrl, text)
276 expect(resolutionPlaylists).to.have.lengthOf(resolutions.length)
277
278 for (const url of resolutionPlaylists) {
279 const { text } = await makeRawRequest({ url, query, expectedStatus: HttpStatusCode.OK_200 })
280
281 const extension = isLive
282 ? '.ts'
283 : '.mp4'
284
285 expect(text).to.contain(`${extension}?videoFileToken=${videoFileToken}`)
286 expect(text).not.to.contain(`reinjectVideoFileToken=true`)
287 }
288}
289
290function extractResolutionPlaylistUrls (masterPath: string, masterContent: string) {
291 return masterContent.match(/^([^.]+\.m3u8.*)/mg)
292 .map(filename => join(dirname(masterPath), filename))
293}
294
295export {
296 checkSegmentHash,
297 checkLiveSegmentHash,
298 checkResolutionsInMasterPlaylist,
299 completeCheckHlsPlaylist,
300 extractResolutionPlaylistUrls,
301 checkVideoFileTokenReinjection
302}
diff --git a/packages/tests/src/shared/tests.ts b/packages/tests/src/shared/tests.ts
new file mode 100644
index 000000000..d2cb040fb
--- /dev/null
+++ b/packages/tests/src/shared/tests.ts
@@ -0,0 +1,40 @@
1const FIXTURE_URLS = {
2 peertube_long: 'https://peertube2.cpy.re/videos/watch/122d093a-1ede-43bd-bd34-59d2931ffc5e',
3 peertube_short: 'https://peertube2.cpy.re/w/3fbif9S3WmtTP8gGsC5HBd',
4
5 youtube: 'https://www.youtube.com/watch?v=msX3jv1XdvM',
6
7 /**
8 * The video is used to check format-selection correctness wrt. HDR,
9 * which brings its own set of oddities outside of a MediaSource.
10 *
11 * The video needs to have the following format_ids:
12 * (which you can check by using `youtube-dl <url> -F`):
13 * - (webm vp9)
14 * - (mp4 avc1)
15 * - (webm vp9.2 HDR)
16 */
17 youtubeHDR: 'https://www.youtube.com/watch?v=RQgnBB9z_N4',
18
19 youtubeChannel: 'https://youtube.com/channel/UCtnlZdXv3-xQzxiqfn6cjIA',
20 youtubePlaylist: 'https://youtube.com/playlist?list=PLRGXHPrcPd2yc2KdswlAWOxIJ8G3vgy4h',
21
22 // eslint-disable-next-line max-len
23 magnet: 'magnet:?xs=https%3A%2F%2Fpeertube2.cpy.re%2Flazy-static%2Ftorrents%2Fb209ca00-c8bb-4b2b-b421-1ede169f3dbc-720.torrent&xt=urn:btih:0f498834733e8057ed5c6f2ee2b4efd8d84a76ee&dn=super+peertube2+video&tr=https%3A%2F%2Fpeertube2.cpy.re%2Ftracker%2Fannounce&tr=wss%3A%2F%2Fpeertube2.cpy.re%3A443%2Ftracker%2Fsocket&ws=https%3A%2F%2Fpeertube2.cpy.re%2Fstatic%2Fwebseed%2Fb209ca00-c8bb-4b2b-b421-1ede169f3dbc-720.mp4',
24
25 badVideo: 'https://download.cpy.re/peertube/bad_video.mp4',
26 goodVideo: 'https://download.cpy.re/peertube/good_video.mp4',
27 goodVideo720: 'https://download.cpy.re/peertube/good_video_720.mp4',
28
29 file4K: 'https://download.cpy.re/peertube/4k_file.txt'
30}
31
32function buildRequestStub (): any {
33 return { }
34}
35
36export {
37 FIXTURE_URLS,
38
39 buildRequestStub
40}
diff --git a/packages/tests/src/shared/tracker.ts b/packages/tests/src/shared/tracker.ts
new file mode 100644
index 000000000..6ab430456
--- /dev/null
+++ b/packages/tests/src/shared/tracker.ts
@@ -0,0 +1,27 @@
1import { expect } from 'chai'
2import { sha1 } from '@peertube/peertube-node-utils'
3import { makeGetRequest } from '@peertube/peertube-server-commands'
4
5async function hlsInfohashExist (serverUrl: string, masterPlaylistUrl: string, fileNumber: number) {
6 const path = '/tracker/announce'
7
8 const infohash = sha1(`2${masterPlaylistUrl}+V${fileNumber}`)
9
10 // From bittorrent-tracker
11 const infohashBinary = escape(Buffer.from(infohash, 'hex').toString('binary')).replace(/[@*/+]/g, function (char) {
12 return '%' + char.charCodeAt(0).toString(16).toUpperCase()
13 })
14
15 const res = await makeGetRequest({
16 url: serverUrl,
17 path,
18 rawQuery: `peer_id=-WW0105-NkvYO/egUAr4&info_hash=${infohashBinary}&port=42100`,
19 expectedStatus: 200
20 })
21
22 expect(res.text).to.not.contain('failure')
23}
24
25export {
26 hlsInfohashExist
27}
diff --git a/packages/tests/src/shared/video-playlists.ts b/packages/tests/src/shared/video-playlists.ts
new file mode 100644
index 000000000..81dc43ed6
--- /dev/null
+++ b/packages/tests/src/shared/video-playlists.ts
@@ -0,0 +1,22 @@
1import { expect } from 'chai'
2import { readdir } from 'fs/promises'
3import { PeerTubeServer } from '@peertube/peertube-server-commands'
4
5async function checkPlaylistFilesWereRemoved (
6 playlistUUID: string,
7 server: PeerTubeServer,
8 directories = [ 'thumbnails' ]
9) {
10 for (const directory of directories) {
11 const directoryPath = server.getDirectoryPath(directory)
12
13 const files = await readdir(directoryPath)
14 for (const file of files) {
15 expect(file).to.not.contain(playlistUUID)
16 }
17 }
18}
19
20export {
21 checkPlaylistFilesWereRemoved
22}
diff --git a/packages/tests/src/shared/videos.ts b/packages/tests/src/shared/videos.ts
new file mode 100644
index 000000000..9bdcbf058
--- /dev/null
+++ b/packages/tests/src/shared/videos.ts
@@ -0,0 +1,323 @@
1/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/no-floating-promises */
2
3import { expect } from 'chai'
4import { pathExists } from 'fs-extra/esm'
5import { readdir } from 'fs/promises'
6import { basename, join } from 'path'
7import { pick, uuidRegex } from '@peertube/peertube-core-utils'
8import { HttpStatusCode, HttpStatusCodeType, VideoCaption, VideoDetails, VideoPrivacy, VideoResolution } from '@peertube/peertube-models'
9import {
10 loadLanguages,
11 VIDEO_CATEGORIES,
12 VIDEO_LANGUAGES,
13 VIDEO_LICENCES,
14 VIDEO_PRIVACIES
15} from '@peertube/peertube-server/server/initializers/constants.js'
16import { getLowercaseExtension } from '@peertube/peertube-node-utils'
17import { makeRawRequest, PeerTubeServer, VideoEdit, waitJobs } from '@peertube/peertube-server-commands'
18import { dateIsValid, expectStartWith, testImageGeneratedByFFmpeg } from './checks.js'
19import { checkWebTorrentWorks } from './webtorrent.js'
20
21async function completeWebVideoFilesCheck (options: {
22 server: PeerTubeServer
23 originServer: PeerTubeServer
24 videoUUID: string
25 fixture: string
26 files: {
27 resolution: number
28 size?: number
29 }[]
30 objectStorageBaseUrl?: string
31}) {
32 const { originServer, server, videoUUID, files, fixture, objectStorageBaseUrl } = options
33 const video = await server.videos.getWithToken({ id: videoUUID })
34 const serverConfig = await originServer.config.getConfig()
35 const requiresAuth = video.privacy.id === VideoPrivacy.PRIVATE || video.privacy.id === VideoPrivacy.INTERNAL
36
37 const transcodingEnabled = serverConfig.transcoding.web_videos.enabled
38
39 for (const attributeFile of files) {
40 const file = video.files.find(f => f.resolution.id === attributeFile.resolution)
41 expect(file, `resolution ${attributeFile.resolution} does not exist`).not.to.be.undefined
42
43 let extension = getLowercaseExtension(fixture)
44 // Transcoding enabled: extension will always be .mp4
45 if (transcodingEnabled) extension = '.mp4'
46
47 expect(file.id).to.exist
48 expect(file.magnetUri).to.have.lengthOf.above(2)
49
50 {
51 const privatePath = requiresAuth
52 ? 'private/'
53 : ''
54 const nameReg = `${uuidRegex}-${file.resolution.id}`
55
56 expect(file.torrentDownloadUrl).to.match(new RegExp(`${server.url}/download/torrents/${nameReg}.torrent`))
57 expect(file.torrentUrl).to.match(new RegExp(`${server.url}/lazy-static/torrents/${nameReg}.torrent`))
58
59 if (objectStorageBaseUrl && requiresAuth) {
60 const regexp = new RegExp(`${originServer.url}/object-storage-proxy/web-videos/${privatePath}${nameReg}${extension}`)
61 expect(file.fileUrl).to.match(regexp)
62 } else if (objectStorageBaseUrl) {
63 expectStartWith(file.fileUrl, objectStorageBaseUrl)
64 } else {
65 expect(file.fileUrl).to.match(new RegExp(`${originServer.url}/static/web-videos/${privatePath}${nameReg}${extension}`))
66 }
67
68 expect(file.fileDownloadUrl).to.match(new RegExp(`${originServer.url}/download/videos/${nameReg}${extension}`))
69 }
70
71 {
72 const token = requiresAuth
73 ? server.accessToken
74 : undefined
75
76 await Promise.all([
77 makeRawRequest({ url: file.torrentUrl, token, expectedStatus: HttpStatusCode.OK_200 }),
78 makeRawRequest({ url: file.torrentDownloadUrl, token, expectedStatus: HttpStatusCode.OK_200 }),
79 makeRawRequest({ url: file.metadataUrl, token, expectedStatus: HttpStatusCode.OK_200 }),
80 makeRawRequest({ url: file.fileUrl, token, expectedStatus: HttpStatusCode.OK_200 }),
81 makeRawRequest({
82 url: file.fileDownloadUrl,
83 token,
84 expectedStatus: objectStorageBaseUrl ? HttpStatusCode.FOUND_302 : HttpStatusCode.OK_200
85 })
86 ])
87 }
88
89 expect(file.resolution.id).to.equal(attributeFile.resolution)
90
91 if (file.resolution.id === VideoResolution.H_NOVIDEO) {
92 expect(file.resolution.label).to.equal('Audio')
93 } else {
94 expect(file.resolution.label).to.equal(attributeFile.resolution + 'p')
95 }
96
97 if (attributeFile.size) {
98 const minSize = attributeFile.size - ((10 * attributeFile.size) / 100)
99 const maxSize = attributeFile.size + ((10 * attributeFile.size) / 100)
100 expect(
101 file.size,
102 'File size for resolution ' + file.resolution.label + ' outside confidence interval (' + minSize + '> size <' + maxSize + ')'
103 ).to.be.above(minSize).and.below(maxSize)
104 }
105
106 await checkWebTorrentWorks(file.magnetUri)
107 }
108}
109
110async function completeVideoCheck (options: {
111 server: PeerTubeServer
112 originServer: PeerTubeServer
113 videoUUID: string
114 attributes: {
115 name: string
116 category: number
117 licence: number
118 language: string
119 nsfw: boolean
120 commentsEnabled: boolean
121 downloadEnabled: boolean
122 description: string
123 publishedAt?: string
124 support: string
125 originallyPublishedAt?: string
126 account: {
127 name: string
128 host: string
129 }
130 isLocal: boolean
131 tags: string[]
132 privacy: number
133 likes?: number
134 dislikes?: number
135 duration: number
136 channel: {
137 displayName: string
138 name: string
139 description: string
140 isLocal: boolean
141 }
142 fixture: string
143 files: {
144 resolution: number
145 size: number
146 }[]
147 thumbnailfile?: string
148 previewfile?: string
149 }
150}) {
151 const { attributes, originServer, server, videoUUID } = options
152
153 await loadLanguages()
154
155 const video = await server.videos.get({ id: videoUUID })
156
157 if (!attributes.likes) attributes.likes = 0
158 if (!attributes.dislikes) attributes.dislikes = 0
159
160 expect(video.name).to.equal(attributes.name)
161 expect(video.category.id).to.equal(attributes.category)
162 expect(video.category.label).to.equal(attributes.category !== null ? VIDEO_CATEGORIES[attributes.category] : 'Unknown')
163 expect(video.licence.id).to.equal(attributes.licence)
164 expect(video.licence.label).to.equal(attributes.licence !== null ? VIDEO_LICENCES[attributes.licence] : 'Unknown')
165 expect(video.language.id).to.equal(attributes.language)
166 expect(video.language.label).to.equal(attributes.language !== null ? VIDEO_LANGUAGES[attributes.language] : 'Unknown')
167 expect(video.privacy.id).to.deep.equal(attributes.privacy)
168 expect(video.privacy.label).to.deep.equal(VIDEO_PRIVACIES[attributes.privacy])
169 expect(video.nsfw).to.equal(attributes.nsfw)
170 expect(video.description).to.equal(attributes.description)
171 expect(video.account.id).to.be.a('number')
172 expect(video.account.host).to.equal(attributes.account.host)
173 expect(video.account.name).to.equal(attributes.account.name)
174 expect(video.channel.displayName).to.equal(attributes.channel.displayName)
175 expect(video.channel.name).to.equal(attributes.channel.name)
176 expect(video.likes).to.equal(attributes.likes)
177 expect(video.dislikes).to.equal(attributes.dislikes)
178 expect(video.isLocal).to.equal(attributes.isLocal)
179 expect(video.duration).to.equal(attributes.duration)
180 expect(video.url).to.contain(originServer.host)
181 expect(dateIsValid(video.createdAt)).to.be.true
182 expect(dateIsValid(video.publishedAt)).to.be.true
183 expect(dateIsValid(video.updatedAt)).to.be.true
184
185 if (attributes.publishedAt) {
186 expect(video.publishedAt).to.equal(attributes.publishedAt)
187 }
188
189 if (attributes.originallyPublishedAt) {
190 expect(video.originallyPublishedAt).to.equal(attributes.originallyPublishedAt)
191 } else {
192 expect(video.originallyPublishedAt).to.be.null
193 }
194
195 expect(video.files).to.have.lengthOf(attributes.files.length)
196 expect(video.tags).to.deep.equal(attributes.tags)
197 expect(video.account.name).to.equal(attributes.account.name)
198 expect(video.account.host).to.equal(attributes.account.host)
199 expect(video.channel.displayName).to.equal(attributes.channel.displayName)
200 expect(video.channel.name).to.equal(attributes.channel.name)
201 expect(video.channel.host).to.equal(attributes.account.host)
202 expect(video.channel.isLocal).to.equal(attributes.channel.isLocal)
203 expect(video.channel.createdAt).to.exist
204 expect(dateIsValid(video.channel.updatedAt.toString())).to.be.true
205 expect(video.commentsEnabled).to.equal(attributes.commentsEnabled)
206 expect(video.downloadEnabled).to.equal(attributes.downloadEnabled)
207
208 expect(video.thumbnailPath).to.exist
209 await testImageGeneratedByFFmpeg(server.url, attributes.thumbnailfile || attributes.fixture, video.thumbnailPath)
210
211 if (attributes.previewfile) {
212 expect(video.previewPath).to.exist
213 await testImageGeneratedByFFmpeg(server.url, attributes.previewfile, video.previewPath)
214 }
215
216 await completeWebVideoFilesCheck({ server, originServer, videoUUID: video.uuid, ...pick(attributes, [ 'fixture', 'files' ]) })
217}
218
219async function checkVideoFilesWereRemoved (options: {
220 server: PeerTubeServer
221 video: VideoDetails
222 captions?: VideoCaption[]
223 onlyVideoFiles?: boolean // default false
224}) {
225 const { video, server, captions = [], onlyVideoFiles = false } = options
226
227 const webVideoFiles = video.files || []
228 const hlsFiles = video.streamingPlaylists[0]?.files || []
229
230 const thumbnailName = basename(video.thumbnailPath)
231 const previewName = basename(video.previewPath)
232
233 const torrentNames = webVideoFiles.concat(hlsFiles).map(f => basename(f.torrentUrl))
234
235 const captionNames = captions.map(c => basename(c.captionPath))
236
237 const webVideoFilenames = webVideoFiles.map(f => basename(f.fileUrl))
238 const hlsFilenames = hlsFiles.map(f => basename(f.fileUrl))
239
240 let directories: { [ directory: string ]: string[] } = {
241 videos: webVideoFilenames,
242 redundancy: webVideoFilenames,
243 [join('playlists', 'hls')]: hlsFilenames,
244 [join('redundancy', 'hls')]: hlsFilenames
245 }
246
247 if (onlyVideoFiles !== true) {
248 directories = {
249 ...directories,
250
251 thumbnails: [ thumbnailName ],
252 previews: [ previewName ],
253 torrents: torrentNames,
254 captions: captionNames
255 }
256 }
257
258 for (const directory of Object.keys(directories)) {
259 const directoryPath = server.servers.buildDirectory(directory)
260
261 const directoryExists = await pathExists(directoryPath)
262 if (directoryExists === false) continue
263
264 const existingFiles = await readdir(directoryPath)
265 for (const existingFile of existingFiles) {
266 for (const shouldNotExist of directories[directory]) {
267 expect(existingFile, `File ${existingFile} should not exist in ${directoryPath}`).to.not.contain(shouldNotExist)
268 }
269 }
270 }
271}
272
273async function saveVideoInServers (servers: PeerTubeServer[], uuid: string) {
274 for (const server of servers) {
275 server.store.videoDetails = await server.videos.get({ id: uuid })
276 }
277}
278
279function checkUploadVideoParam (options: {
280 server: PeerTubeServer
281 token: string
282 attributes: Partial<VideoEdit>
283 expectedStatus?: HttpStatusCodeType
284 completedExpectedStatus?: HttpStatusCodeType
285 mode?: 'legacy' | 'resumable'
286}) {
287 const { server, token, attributes, completedExpectedStatus, expectedStatus, mode = 'legacy' } = options
288
289 return mode === 'legacy'
290 ? server.videos.buildLegacyUpload({ token, attributes, expectedStatus: expectedStatus || completedExpectedStatus })
291 : server.videos.buildResumeUpload({
292 token,
293 attributes,
294 expectedStatus,
295 completedExpectedStatus,
296 path: '/api/v1/videos/upload-resumable'
297 })
298}
299
300// serverNumber starts from 1
301async function uploadRandomVideoOnServers (
302 servers: PeerTubeServer[],
303 serverNumber: number,
304 additionalParams?: VideoEdit & { prefixName?: string }
305) {
306 const server = servers.find(s => s.serverNumber === serverNumber)
307 const res = await server.videos.randomUpload({ wait: false, additionalParams })
308
309 await waitJobs(servers)
310
311 return res
312}
313
314// ---------------------------------------------------------------------------
315
316export {
317 completeVideoCheck,
318 completeWebVideoFilesCheck,
319 checkUploadVideoParam,
320 uploadRandomVideoOnServers,
321 checkVideoFilesWereRemoved,
322 saveVideoInServers
323}
diff --git a/packages/tests/src/shared/views.ts b/packages/tests/src/shared/views.ts
new file mode 100644
index 000000000..b791eff25
--- /dev/null
+++ b/packages/tests/src/shared/views.ts
@@ -0,0 +1,93 @@
1import type { FfmpegCommand } from 'fluent-ffmpeg'
2import { wait } from '@peertube/peertube-core-utils'
3import { VideoCreateResult, VideoPrivacy } from '@peertube/peertube-models'
4import {
5 createMultipleServers,
6 doubleFollow,
7 PeerTubeServer,
8 setAccessTokensToServers,
9 setDefaultVideoChannel,
10 waitJobs,
11 waitUntilLivePublishedOnAllServers
12} from '@peertube/peertube-server-commands'
13
14async function processViewersStats (servers: PeerTubeServer[]) {
15 await wait(6000)
16
17 for (const server of servers) {
18 await server.debug.sendCommand({ body: { command: 'process-video-views-buffer' } })
19 await server.debug.sendCommand({ body: { command: 'process-video-viewers' } })
20 }
21
22 await waitJobs(servers)
23}
24
25async function processViewsBuffer (servers: PeerTubeServer[]) {
26 for (const server of servers) {
27 await server.debug.sendCommand({ body: { command: 'process-video-views-buffer' } })
28 }
29
30 await waitJobs(servers)
31}
32
33async function prepareViewsServers () {
34 const servers = await createMultipleServers(2)
35 await setAccessTokensToServers(servers)
36 await setDefaultVideoChannel(servers)
37
38 await servers[0].config.updateCustomSubConfig({
39 newConfig: {
40 live: {
41 enabled: true,
42 allowReplay: true,
43 transcoding: {
44 enabled: false
45 }
46 }
47 }
48 })
49
50 await doubleFollow(servers[0], servers[1])
51
52 return servers
53}
54
55async function prepareViewsVideos (options: {
56 servers: PeerTubeServer[]
57 live: boolean
58 vod: boolean
59}) {
60 const { servers } = options
61
62 const liveAttributes = {
63 name: 'live video',
64 channelId: servers[0].store.channel.id,
65 privacy: VideoPrivacy.PUBLIC
66 }
67
68 let ffmpegCommand: FfmpegCommand
69 let live: VideoCreateResult
70 let vod: VideoCreateResult
71
72 if (options.live) {
73 live = await servers[0].live.create({ fields: liveAttributes })
74
75 ffmpegCommand = await servers[0].live.sendRTMPStreamInVideo({ videoId: live.uuid })
76 await waitUntilLivePublishedOnAllServers(servers, live.uuid)
77 }
78
79 if (options.vod) {
80 vod = await servers[0].videos.quickUpload({ name: 'video' })
81 }
82
83 await waitJobs(servers)
84
85 return { liveVideoId: live?.uuid, vodVideoId: vod?.uuid, ffmpegCommand }
86}
87
88export {
89 processViewersStats,
90 prepareViewsServers,
91 processViewsBuffer,
92 prepareViewsVideos
93}
diff --git a/packages/tests/src/shared/webtorrent.ts b/packages/tests/src/shared/webtorrent.ts
new file mode 100644
index 000000000..1be54426a
--- /dev/null
+++ b/packages/tests/src/shared/webtorrent.ts
@@ -0,0 +1,58 @@
1import { expect } from 'chai'
2import { readFile } from 'fs/promises'
3import parseTorrent from 'parse-torrent'
4import { basename, join } from 'path'
5import type { Instance, Torrent } from 'webtorrent'
6import { VideoFile } from '@peertube/peertube-models'
7import { PeerTubeServer } from '@peertube/peertube-server-commands'
8
9let webtorrent: Instance
10
11export async function checkWebTorrentWorks (magnetUri: string, pathMatch?: RegExp) {
12 const torrent = await webtorrentAdd(magnetUri, true)
13
14 expect(torrent.files).to.be.an('array')
15 expect(torrent.files.length).to.equal(1)
16 expect(torrent.files[0].path).to.exist.and.to.not.equal('')
17
18 if (pathMatch) {
19 expect(torrent.files[0].path).match(pathMatch)
20 }
21}
22
23export async function parseTorrentVideo (server: PeerTubeServer, file: VideoFile) {
24 const torrentName = basename(file.torrentUrl)
25 const torrentPath = server.servers.buildDirectory(join('torrents', torrentName))
26
27 const data = await readFile(torrentPath)
28
29 return parseTorrent(data)
30}
31
32// ---------------------------------------------------------------------------
33// Private
34// ---------------------------------------------------------------------------
35
36async function webtorrentAdd (torrentId: string, refreshWebTorrent = false) {
37 const WebTorrent = (await import('webtorrent')).default
38
39 if (webtorrent && refreshWebTorrent) webtorrent.destroy()
40 if (!webtorrent || refreshWebTorrent) webtorrent = new WebTorrent()
41
42 webtorrent.on('error', err => console.error('Error in webtorrent', err))
43
44 return new Promise<Torrent>(res => {
45 const torrent = webtorrent.add(torrentId, res)
46
47 torrent.on('error', err => console.error('Error in webtorrent torrent', err))
48 torrent.on('warning', warn => {
49 const msg = typeof warn === 'string'
50 ? warn
51 : warn.message
52
53 if (msg.includes('Unsupported')) return
54
55 console.error('Warning in webtorrent torrent', warn)
56 })
57 })
58}