diff --git a/test/performance/auth/login.stress.js b/test/performance/auth/login.stress.js index a21f56a0..8b46d603 100644 --- a/test/performance/auth/login.stress.js +++ b/test/performance/auth/login.stress.js @@ -5,8 +5,8 @@ export const options = { stages: [ { duration: '30s', target: 20 }, { duration: '1m', target: 50 }, - { duration: '30s', target: 100 }, - { duration: '1m', target: 100 }, + { duration: '30s', target: 600 }, + { duration: '1m', target: 600 }, { duration: '30s', target: 0 }, ], thresholds: { diff --git a/test/performance/auth/report_login.html b/test/performance/auth/report_login.html new file mode 100644 index 00000000..39396a90 --- /dev/null +++ b/test/performance/auth/report_login.html @@ -0,0 +1,44 @@ + + + + + + + + + k6 report + + + + + +
+ + + + diff --git a/test/performance/auth/report_reset-password.html b/test/performance/auth/report_reset-password.html new file mode 100644 index 00000000..3ef8a3bb --- /dev/null +++ b/test/performance/auth/report_reset-password.html @@ -0,0 +1,44 @@ + + + + + + + + + k6 report + + + + + +
+ + + + diff --git a/test/performance/auth/reset-password.stress.js b/test/performance/auth/reset-password.stress.js index 0e927904..0242a07f 100644 --- a/test/performance/auth/reset-password.stress.js +++ b/test/performance/auth/reset-password.stress.js @@ -5,8 +5,8 @@ export const options = { stages: [ { duration: '30s', target: 20 }, { duration: '1m', target: 50 }, - { duration: '30s', target: 100 }, - { duration: '1m', target: 100 }, + { duration: '30s', target: 200 }, + { duration: '1m', target: 400 }, { duration: '30s', target: 0 }, ], thresholds: { @@ -18,16 +18,9 @@ export const options = { }, }; -const STRESS_TEST_URL = __ENV.STRESS_TEST_URL || 'http://localhost:3001'; - -export default function () { - const params = { - headers: { - 'Content-Type': 'application/json', - 'X-Client-Type': 'web' - }, - }; +const STRESS_TEST_URL = __ENV.STRESS_TEST_URL || 'https://test.api.raven.cmp27.space'; +export function setup() { const resCreate = http.post( `${STRESS_TEST_URL}/test/users`, ); @@ -39,6 +32,17 @@ export default function () { const userData = resCreate.json('data'); const userEmail = userData.email; + return { userEmail }; +} + +export default function (data) { + const { userEmail } = data; + const params = { + headers: { + 'Content-Type': 'application/json', + 'X-Client-Type': 'web' + }, + }; const payloadForgot = JSON.stringify({ identifier: userEmail, diff --git a/test/performance/auth/signup.stress.js b/test/performance/auth/signup.stress.js index 6615cc14..d57315d9 100644 --- a/test/performance/auth/signup.stress.js +++ b/test/performance/auth/signup.stress.js @@ -5,8 +5,8 @@ export const options = { stages: [ { duration: '30s', target: 20 }, { duration: '1m', target: 50 }, - { duration: '30s', target: 100 }, - { duration: '1m', target: 100 }, + { duration: '30s', target: 200 }, + { duration: '1m', target: 400 }, { duration: '30s', target: 0 }, ], thresholds: { @@ -22,7 +22,7 @@ export const options = { }, }; -const STRESS_TEST_URL = __ENV.STRESS_TEST_URL || 'http://localhost:3000'; +const STRESS_TEST_URL = __ENV.STRESS_TEST_URL || 'https://test.api.raven.cmp27.space'; export default function () { const uniqueId = `${__VU}-${__ITER}-${Date.now()}`; diff --git a/test/performance/media/media-upload.stress.js b/test/performance/media/media-upload.stress.js new file mode 100644 index 00000000..13b5da15 --- /dev/null +++ b/test/performance/media/media-upload.stress.js @@ -0,0 +1,192 @@ +import http from 'k6/http'; +import { check, sleep, group } from 'k6'; +import encoding from 'k6/encoding'; + +export const options = { + stages: [ + { duration: '30s', target: 50 }, // Ramp up to 50 users + { duration: '1m', target: 100 }, // Ramp up to 100 users + { duration: '30s', target: 200 }, // Ramp up to 200 users + { duration: '2m', target: 200 }, // Sustain at 200 users + { duration: '30s', target: 0 }, // Ramp down + ], + thresholds: { + http_req_failed: ['rate<0.05'], // Allow up to 5% failure rate for media uploads (I/O heavy) + 'http_req_duration{name:01_Login_Action}': ['p(95)<3000'], + 'http_req_duration{name:02_Upload_Image}': ['p(95)<10000'], // Image uploads can take longer + 'http_req_duration{name:03_Upload_Video}': ['p(95)<15000'], // Video uploads can take longer + 'http_req_duration{name:04_Upload_Gif}': ['p(95)<5000'], // GIF (Tenor lookup) should be faster + }, +}; + +const STRESS_TEST_URL = __ENV.STRESS_TEST_URL || 'http://localhost:3000'; + +// Minimal valid PNG file (1x1 red pixel) - properly encoded +const MINIMAL_PNG_BASE64 = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAIAAACQd1PeAAAADklEQVQI12P4z8DwHwAFAAH/q842AAAAAElFTkSuQmCC'; + +// Minimal valid MP4 bytes - ftyp box only (smallest valid MP4 structure) +// We'll create this as raw bytes array instead of base64 to avoid encoding issues +function createMinimalMP4Bytes() { + // Minimal ftyp box: box size (4) + box type (4) + brand (4) + version (4) + const bytes = new Uint8Array([ + 0x00, 0x00, 0x00, 0x14, // box size: 20 bytes + 0x66, 0x74, 0x79, 0x70, // box type: 'ftyp' + 0x69, 0x73, 0x6F, 0x6D, // brand: 'isom' + 0x00, 0x00, 0x00, 0x01, // version: 1 + 0x69, 0x73, 0x6F, 0x6D, // compatible brand: 'isom' + ]); + return bytes.buffer; // Return ArrayBuffer, not Uint8Array +} + +/** + * Helper function to create and login a user + * Returns { accessToken } or null on failure + */ +function createAndLoginUser(tagName) { + const resCreateUser = http.post(`${STRESS_TEST_URL}/test/users`); + + if (!check(resCreateUser, { 'User Created 201': (r) => r.status === 201 })) { + console.error(`Setup Failed (Create User): ${resCreateUser.body}`); + return null; + } + + const userData = resCreateUser.json('data'); + const userEmail = userData.email; + const userPassword = userData.password; + + const loginPayload = JSON.stringify({ + identifier: userEmail, + password: userPassword, + }); + + const loginParams = { + headers: { + 'Content-Type': 'application/json', + 'X-Client-Type': 'web', + }, + tags: { name: tagName }, + }; + + const resLogin = http.post(`${STRESS_TEST_URL}/auth/login`, loginPayload, loginParams); + + if (!check(resLogin, { 'Login 200': (r) => r.status === 200 })) { + console.error(`Setup Failed (Login): ${resLogin.body}`); + return null; + } + + return { + accessToken: resLogin.json('data.accessToken'), + }; +} + +export default function () { + // ───────────────────────────────────────────────────────────────────────────── + // SETUP: Create and login user + // ───────────────────────────────────────────────────────────────────────────── + const user = createAndLoginUser('01_Login_Action'); + if (!user) return; + + const authHeaders = { + 'X-Client-Type': 'web', + Authorization: `Bearer ${user.accessToken}`, + }; + + sleep(0.2); + + // ───────────────────────────────────────────────────────────────────────────── + // STEP 1: Upload Image (POST /media/upload/image) + // ───────────────────────────────────────────────────────────────────────────── + group('Image Upload', function () { + const imageBytes = encoding.b64decode(MINIMAL_PNG_BASE64); + + const formData = { + file: http.file(imageBytes, `test-image-${__VU}-${__ITER}.png`, 'image/png'), + folder: 'tweets', + }; + + const resUploadImage = http.post( + `${STRESS_TEST_URL}/media/upload/image`, + formData, + { + headers: authHeaders, + tags: { name: '02_Upload_Image' }, + } + ); + + const imageUploadOk = check(resUploadImage, { + 'Upload Image 2xx': (r) => r.status >= 200 && r.status < 300, + }); + + if (!imageUploadOk) { + console.error(`Upload Image Failed [VU:${__VU}]: ${resUploadImage.status} - ${resUploadImage.body}`); + } + }); + + sleep(0.3); + + // ───────────────────────────────────────────────────────────────────────────── + // STEP 2: Upload Video (POST /media/upload/video) + // ───────────────────────────────────────────────────────────────────────────── + group('Video Upload', function () { + const videoBytes = createMinimalMP4Bytes(); + + const formData = { + file: http.file(videoBytes, `test-video-${__VU}-${__ITER}.mp4`, 'video/mp4'), + folder: 'tweets', + }; + + const resUploadVideo = http.post( + `${STRESS_TEST_URL}/media/upload/video`, + formData, + { + headers: authHeaders, + tags: { name: '03_Upload_Video' }, + } + ); + + const videoUploadOk = check(resUploadVideo, { + 'Upload Video 2xx': (r) => r.status >= 200 && r.status < 300, + }); + + if (!videoUploadOk) { + console.error(`Upload Video Failed [VU:${__VU}]: ${resUploadVideo.status} - ${resUploadVideo.body}`); + } + }); + + sleep(0.3); + + // ───────────────────────────────────────────────────────────────────────────── + // STEP 3: Upload GIF via Tenor ID (POST /media/upload/gif) + // ───────────────────────────────────────────────────────────────────────────── + group('GIF Upload', function () { + // Note: This requires a valid Tenor GIF ID + // Using a commonly available GIF ID - may need to be updated if Tenor API changes + const tenorId = '16989471141791455574'; + + const gifPayload = JSON.stringify({ + tenorId: tenorId, + }); + + const resUploadGif = http.post( + `${STRESS_TEST_URL}/media/upload/gif`, + gifPayload, + { + headers: { + ...authHeaders, + 'Content-Type': 'application/json', + }, + tags: { name: '04_Upload_Gif' }, + } + ); + + const gifUploadOk = check(resUploadGif, { + 'Upload GIF 2xx': (r) => r.status >= 200 && r.status < 300, + }); + + if (!gifUploadOk) { + console.error(`Upload GIF Failed [VU:${__VU}]: ${resUploadGif.status} - ${resUploadGif.body}`); + } + }); + + sleep(0.2); +} diff --git a/test/performance/media/report.html b/test/performance/media/report.html new file mode 100644 index 00000000..faecda6d --- /dev/null +++ b/test/performance/media/report.html @@ -0,0 +1,44 @@ + + + + + + + + + k6 report + + + + + +
+ + + + diff --git a/test/performance/notifications/notifications.stress.js b/test/performance/notifications/notifications.stress.js new file mode 100644 index 00000000..0da20006 --- /dev/null +++ b/test/performance/notifications/notifications.stress.js @@ -0,0 +1,270 @@ +import http from 'k6/http'; +import { check, sleep, group } from 'k6'; + +export const options = { + stages: [ + { duration: '30s', target: 50 }, // Ramp up to 50 users + { duration: '1m', target: 100 }, // Ramp up to 100 users + { duration: '30s', target: 300 }, // Ramp up to 300 users + { duration: '2m', target: 300 }, // Sustain at 300 users + { duration: '30s', target: 0 }, // Ramp down + ], + thresholds: { + http_req_failed: ['rate<0.01'], + 'http_req_duration{name:01_Login_UserA}': ['p(95)<2000'], + 'http_req_duration{name:02_Login_UserB}': ['p(95)<2000'], + 'http_req_duration{name:03_UserA_Create_Tweet}': ['p(95)<2000'], + 'http_req_duration{name:04_UserB_Follow_UserA}': ['p(95)<1000'], + 'http_req_duration{name:05_UserB_Like_Tweet}': ['p(95)<500'], + 'http_req_duration{name:06_Get_Notifications}': ['p(95)<1000'], + 'http_req_duration{name:07_Get_Notifications_Page2}': ['p(95)<1000'], + 'http_req_duration{name:08_Get_Unseen_Count}': ['p(95)<500'], + 'http_req_duration{name:09_Mark_Single_Seen}': ['p(95)<500'], + 'http_req_duration{name:10_Mark_All_Seen}': ['p(95)<500'], + }, +}; + +const STRESS_TEST_URL = __ENV.STRESS_TEST_URL || 'http://localhost:3000'; + +/** + * Helper function to create and login a user + * Returns { accessToken, userId, username } or null on failure + */ +function createAndLoginUser(userLabel, tagName) { + const resCreateUser = http.post(`${STRESS_TEST_URL}/test/users`); + + if (!check(resCreateUser, { [`${userLabel} Created 201`]: (r) => r.status === 201 })) { + console.error(`Setup Failed (Create ${userLabel}): ${resCreateUser.body}`); + return null; + } + + const userData = resCreateUser.json('data'); + const userEmail = userData.email; + const userPassword = userData.password; + const username = userData.username; // Get username from /test/users response + + const loginPayload = JSON.stringify({ + identifier: userEmail, + password: userPassword, + }); + + const loginParams = { + headers: { + 'Content-Type': 'application/json', + 'X-Client-Type': 'web', + }, + tags: { name: tagName }, + }; + + const resLogin = http.post(`${STRESS_TEST_URL}/auth/login`, loginPayload, loginParams); + + if (!check(resLogin, { [`${userLabel} Login 200`]: (r) => r.status === 200 })) { + console.error(`Setup Failed (Login ${userLabel}): ${resLogin.body}`); + return null; + } + + return { + accessToken: resLogin.json('data.accessToken'), + username: username, // Use username from /test/users response + }; +} + +export default function () { + // ───────────────────────────────────────────────────────────────────────────── + // SETUP: Create and login User A (will receive notifications) + // ───────────────────────────────────────────────────────────────────────────── + const userA = createAndLoginUser('UserA', '01_Login_UserA'); + if (!userA) return; + + const authParamsA = { + headers: { + 'Content-Type': 'application/json', + 'X-Client-Type': 'web', + Authorization: `Bearer ${userA.accessToken}`, + }, + }; + + // ───────────────────────────────────────────────────────────────────────────── + // SETUP: Create and login User B (will trigger notifications for User A) + // ───────────────────────────────────────────────────────────────────────────── + const userB = createAndLoginUser('UserB', '02_Login_UserB'); + if (!userB) return; + + const authParamsB = { + headers: { + 'Content-Type': 'application/json', + 'X-Client-Type': 'web', + Authorization: `Bearer ${userB.accessToken}`, + }, + }; + + sleep(0.2); + + // ───────────────────────────────────────────────────────────────────────────── + // STEP 1: User A creates a tweet (so User B can like it) + // ───────────────────────────────────────────────────────────────────────────── + const uniqueContent = `Notification Test Tweet ${__VU}-${__ITER} - ${Date.now()}`; + + const createPayload = JSON.stringify({ + content: uniqueContent, + }); + + const resCreateTweet = http.post(`${STRESS_TEST_URL}/tweets`, createPayload, { + ...authParamsA, + tags: { name: '03_UserA_Create_Tweet' }, + }); + + if (!check(resCreateTweet, { 'UserA Create Tweet 201': (r) => r.status === 201 })) { + console.error(`UserA Create Tweet Failed: ${resCreateTweet.body}`); + return; + } + + const tweetId = resCreateTweet.json('data.id'); + + sleep(0.2); + + // ───────────────────────────────────────────────────────────────────────────── + // STEP 2: User B follows User A (triggers FOLLOW notification) + // ───────────────────────────────────────────────────────────────────────────── + group('Trigger Notifications', function () { + const resFollow = http.post( + `${STRESS_TEST_URL}/users/${userA.username}/following`, + null, + { ...authParamsB, tags: { name: '04_UserB_Follow_UserA' } } + ); + + check(resFollow, { + 'UserB Follow UserA 2xx': (r) => r.status >= 200 && r.status < 300, + }); + + sleep(0.1); + + // ─────────────────────────────────────────────────────────────────────────── + // STEP 3: User B likes User A's tweet (triggers LIKE notification) + // ─────────────────────────────────────────────────────────────────────────── + const resLike = http.post( + `${STRESS_TEST_URL}/tweets/${tweetId}/like`, + null, + { ...authParamsB, tags: { name: '05_UserB_Like_Tweet' } } + ); + + check(resLike, { + 'UserB Like Tweet 2xx': (r) => r.status >= 200 && r.status < 300, + }); + }); + + // Small delay to allow notifications to be processed + sleep(0.3); + + // ───────────────────────────────────────────────────────────────────────────── + // User A: Test Notification Endpoints + // ───────────────────────────────────────────────────────────────────────────── + group('Notification Endpoints', function () { + // ─────────────────────────────────────────────────────────────────────────── + // STEP 4: GET /notifications (first page) + // ─────────────────────────────────────────────────────────────────────────── + const resNotifications = http.get( + `${STRESS_TEST_URL}/notifications?limit=20`, + { ...authParamsA, tags: { name: '06_Get_Notifications' } } + ); + + const notificationsOk = check(resNotifications, { + 'Get Notifications 200': (r) => r.status === 200, + }); + + if (!notificationsOk) { + console.error(`Get Notifications Failed: ${resNotifications.body}`); + return; + } + + // Get a notification ID for single mark-as-seen test + let notificationId = null; + let cursor = null; + try { + const body = resNotifications.json(); + if (body.items && body.items.length > 0) { + notificationId = body.items[0].id; + } + if (body.pagination && body.pagination.nextCursor) { + cursor = body.pagination.nextCursor; + } + } catch { + // No notifications available + } + + sleep(0.1); + + // ─────────────────────────────────────────────────────────────────────────── + // STEP 5: GET /notifications (pagination - second page if cursor exists) + // ─────────────────────────────────────────────────────────────────────────── + if (cursor) { + const resNotificationsPage2 = http.get( + `${STRESS_TEST_URL}/notifications?limit=20&cursor=${encodeURIComponent(cursor)}`, + { ...authParamsA, tags: { name: '07_Get_Notifications_Page2' } } + ); + + check(resNotificationsPage2, { + 'Get Notifications Page2 200': (r) => r.status === 200, + }); + } else { + // Make a request with different limit to ensure consistent load + const resNotificationsPage2 = http.get( + `${STRESS_TEST_URL}/notifications?limit=10`, + { ...authParamsA, tags: { name: '07_Get_Notifications_Page2' } } + ); + + check(resNotificationsPage2, { + 'Get Notifications Page2 200': (r) => r.status === 200, + }); + } + + sleep(0.1); + + // ─────────────────────────────────────────────────────────────────────────── + // STEP 6: GET /notifications/count + // ─────────────────────────────────────────────────────────────────────────── + const resCount = http.get( + `${STRESS_TEST_URL}/notifications/count`, + { ...authParamsA, tags: { name: '08_Get_Unseen_Count' } } + ); + + check(resCount, { + 'Get Unseen Count 200': (r) => r.status === 200, + }); + + sleep(0.1); + + // ─────────────────────────────────────────────────────────────────────────── + // STEP 7: PATCH /notifications/:notificationId/seen (mark single as seen) + // ─────────────────────────────────────────────────────────────────────────── + if (notificationId) { + const resMarkSingleSeen = http.patch( + `${STRESS_TEST_URL}/notifications/${notificationId}/seen`, + null, + { ...authParamsA, tags: { name: '09_Mark_Single_Seen' } } + ); + + check(resMarkSingleSeen, { + 'Mark Single Seen 200': (r) => r.status === 200, + }); + } + // Skip if no notification ID available (no notifications triggered yet) + + sleep(0.1); + + // ─────────────────────────────────────────────────────────────────────────── + // STEP 8: PATCH /notifications/seen (mark all as seen) + // ─────────────────────────────────────────────────────────────────────────── + const resMarkAllSeen = http.patch( + `${STRESS_TEST_URL}/notifications/seen`, + null, + { ...authParamsA, tags: { name: '10_Mark_All_Seen' } } + ); + + check(resMarkAllSeen, { + 'Mark All Seen 200': (r) => r.status === 200, + }); + }); + + sleep(0.1); +} diff --git a/test/performance/notifications/report.html b/test/performance/notifications/report.html new file mode 100644 index 00000000..686f6c67 --- /dev/null +++ b/test/performance/notifications/report.html @@ -0,0 +1,44 @@ + + + + + + + + + k6 report + + + + + +
+ + + + diff --git a/test/performance/onboarding/onboarding.stress.js b/test/performance/onboarding/onboarding.stress.js new file mode 100644 index 00000000..7c7c88b3 --- /dev/null +++ b/test/performance/onboarding/onboarding.stress.js @@ -0,0 +1,168 @@ +import http from 'k6/http'; +import { check, sleep, group } from 'k6'; + +export const options = { + stages: [ + { duration: '30s', target: 50 }, // Ramp up to 50 users + { duration: '1m', target: 100 }, // Ramp up to 100 users + { duration: '30s', target: 200 }, // Ramp up to 200 users + { duration: '2m', target: 200 }, // Sustain at 200 users + { duration: '30s', target: 0 }, // Ramp down + ], + thresholds: { + http_req_failed: ['rate<0.01'], + 'http_req_duration{name:01_Login_Action}': ['p(95)<3000'], + 'http_req_duration{name:02_Get_Follow_Suggestions}': ['p(95)<2000'], + 'http_req_duration{name:03_Get_Follow_Suggestions_With_Limit}': ['p(95)<2000'], + 'http_req_duration{name:04_Get_Username_Suggestions}': ['p(95)<1500'], + 'http_req_duration{name:05_Get_Username_Suggestions_Typed}': ['p(95)<1500'], + }, +}; + +const STRESS_TEST_URL = __ENV.STRESS_TEST_URL || 'http://localhost:3000'; + +/** + * Helper function to create and login a user + * Returns { accessToken } or null on failure + */ +function createAndLoginUser(tagName) { + const resCreateUser = http.post(`${STRESS_TEST_URL}/test/users`); + + if (!check(resCreateUser, { 'User Created 201': (r) => r.status === 201 })) { + console.error(`Setup Failed (Create User): ${resCreateUser.body}`); + return null; + } + + const userData = resCreateUser.json('data'); + const userEmail = userData.email; + const userPassword = userData.password; + + const loginPayload = JSON.stringify({ + identifier: userEmail, + password: userPassword, + }); + + const loginParams = { + headers: { + 'Content-Type': 'application/json', + 'X-Client-Type': 'web', + }, + tags: { name: tagName }, + }; + + const resLogin = http.post(`${STRESS_TEST_URL}/auth/login`, loginPayload, loginParams); + + if (!check(resLogin, { 'Login 200': (r) => r.status === 200 })) { + console.error(`Setup Failed (Login): ${resLogin.body}`); + return null; + } + + return { + accessToken: resLogin.json('data.accessToken'), + }; +} + +export default function () { + // ───────────────────────────────────────────────────────────────────────────── + // SETUP: Create and login user + // ───────────────────────────────────────────────────────────────────────────── + const user = createAndLoginUser('01_Login_Action'); + if (!user) return; + + const authHeaders = { + 'Content-Type': 'application/json', + 'X-Client-Type': 'web', + Authorization: `Bearer ${user.accessToken}`, + }; + + sleep(0.2); + + // ───────────────────────────────────────────────────────────────────────────── + // STEP 1: Get Follow Suggestions (default limit) + // ───────────────────────────────────────────────────────────────────────────── + group('Follow Suggestions', function () { + const resFollowSuggestions = http.get( + `${STRESS_TEST_URL}/onboarding/follow-suggestions`, + { + headers: authHeaders, + tags: { name: '02_Get_Follow_Suggestions' }, + } + ); + + const followSuggestionsOk = check(resFollowSuggestions, { + 'Get Follow Suggestions 200': (r) => r.status === 200, + }); + + if (!followSuggestionsOk) { + console.error(`Get Follow Suggestions Failed [VU:${__VU}]: ${resFollowSuggestions.status} - ${resFollowSuggestions.body}`); + } + + sleep(0.2); + + // ─────────────────────────────────────────────────────────────────────────── + // STEP 2: Get Follow Suggestions with custom limit + // ─────────────────────────────────────────────────────────────────────────── + const resFollowSuggestionsLimit = http.get( + `${STRESS_TEST_URL}/onboarding/follow-suggestions?limit=10`, + { + headers: authHeaders, + tags: { name: '03_Get_Follow_Suggestions_With_Limit' }, + } + ); + + const followSuggestionsLimitOk = check(resFollowSuggestionsLimit, { + 'Get Follow Suggestions With Limit 200': (r) => r.status === 200, + }); + + if (!followSuggestionsLimitOk) { + console.error(`Get Follow Suggestions With Limit Failed [VU:${__VU}]: ${resFollowSuggestionsLimit.status} - ${resFollowSuggestionsLimit.body}`); + } + }); + + sleep(0.3); + + // ───────────────────────────────────────────────────────────────────────────── + // STEP 3: Get Username Suggestions (based on display name) + // ───────────────────────────────────────────────────────────────────────────── + group('Username Suggestions', function () { + const resUsernameSuggestions = http.get( + `${STRESS_TEST_URL}/onboarding/username-suggestions`, + { + headers: authHeaders, + tags: { name: '04_Get_Username_Suggestions' }, + } + ); + + const usernameSuggestionsOk = check(resUsernameSuggestions, { + 'Get Username Suggestions 200': (r) => r.status === 200, + }); + + if (!usernameSuggestionsOk) { + console.error(`Get Username Suggestions Failed [VU:${__VU}]: ${resUsernameSuggestions.status} - ${resUsernameSuggestions.body}`); + } + + sleep(0.2); + + // ─────────────────────────────────────────────────────────────────────────── + // STEP 4: Get Username Suggestions with typed parameter + // ─────────────────────────────────────────────────────────────────────────── + const typedInput = `user${__VU}${__ITER}`; + const resUsernameSuggestionsTyped = http.get( + `${STRESS_TEST_URL}/onboarding/username-suggestions?typed=${encodeURIComponent(typedInput)}`, + { + headers: authHeaders, + tags: { name: '05_Get_Username_Suggestions_Typed' }, + } + ); + + const usernameSuggestionsTypedOk = check(resUsernameSuggestionsTyped, { + 'Get Username Suggestions Typed 200': (r) => r.status === 200, + }); + + if (!usernameSuggestionsTypedOk) { + console.error(`Get Username Suggestions Typed Failed [VU:${__VU}]: ${resUsernameSuggestionsTyped.status} - ${resUsernameSuggestionsTyped.body}`); + } + }); + + sleep(0.2); +} diff --git a/test/performance/onboarding/report.html b/test/performance/onboarding/report.html new file mode 100644 index 00000000..2b97fe17 --- /dev/null +++ b/test/performance/onboarding/report.html @@ -0,0 +1,44 @@ + + + + + + + + + k6 report + + + + + +
+ + + + diff --git a/test/performance/profile/actions.stress.js b/test/performance/profile/actions.stress.js index 9f0b2603..2208c08d 100644 --- a/test/performance/profile/actions.stress.js +++ b/test/performance/profile/actions.stress.js @@ -19,9 +19,9 @@ export const options = { }, }; -const STRESS_TEST_URL = __ENV.STRESS_TEST_URL || 'http://localhost:3001'; +const STRESS_TEST_URL = __ENV.STRESS_TEST_URL || 'https://test.api.raven.cmp27.space'; -export default function () { +export function setup() { const resCreate = http.post( `${STRESS_TEST_URL}/test/users`, @@ -59,6 +59,11 @@ export default function () { } const accessToken = resLogin.json('data.accessToken'); + return { accessToken }; +} + +export default function (data) { + const { accessToken } = data; const headers = { 'Content-Type': 'application/json', diff --git a/test/performance/profile/report_actions.html b/test/performance/profile/report_actions.html new file mode 100644 index 00000000..c66936ed --- /dev/null +++ b/test/performance/profile/report_actions.html @@ -0,0 +1,44 @@ + + + + + + + + + k6 report + + + + + +
+ + + + diff --git a/test/performance/profile/report_update.html b/test/performance/profile/report_update.html new file mode 100644 index 00000000..cefa564a --- /dev/null +++ b/test/performance/profile/report_update.html @@ -0,0 +1,44 @@ + + + + + + + + + k6 report + + + + + +
+ + + + diff --git a/test/performance/profile/update.stress.js b/test/performance/profile/update.stress.js index c09ed9da..f8d80028 100644 --- a/test/performance/profile/update.stress.js +++ b/test/performance/profile/update.stress.js @@ -20,9 +20,9 @@ export const options = { }, }; -const STRESS_TEST_URL = __ENV.STRESS_TEST_URL || 'http://localhost:3001'; +const STRESS_TEST_URL = __ENV.STRESS_TEST_URL || 'https://test.api.raven.cmp27.space'; -export default function () { +export function setup() { const resCreate = http.post( `${STRESS_TEST_URL}/test/users`, @@ -60,6 +60,11 @@ export default function () { } const accessToken = resLogin.json('data.accessToken'); + return { accessToken }; +} + +export default function (data) { + const { accessToken } = data; const headers = { 'Content-Type': 'application/json', diff --git a/test/performance/search/report.html b/test/performance/search/report.html new file mode 100644 index 00000000..cb56fa8f --- /dev/null +++ b/test/performance/search/report.html @@ -0,0 +1,44 @@ + + + + + + + + + k6 report + + + + + +
+ + + + diff --git a/test/performance/search/search.stress.js b/test/performance/search/search.stress.js new file mode 100644 index 00000000..7bbafacc --- /dev/null +++ b/test/performance/search/search.stress.js @@ -0,0 +1,318 @@ +import http from 'k6/http'; +import { check, sleep, group } from 'k6'; + +export const options = { + stages: [ + { duration: '30s', target: 50 }, // Ramp up to 50 users + { duration: '1m', target: 100 }, // Ramp up to 100 users + { duration: '30s', target: 200 }, // Ramp up to 200 users + { duration: '2m', target: 200 }, // Sustain at 200 users + { duration: '30s', target: 0 }, // Ramp down + ], + thresholds: { + http_req_failed: ['rate<0.01'], + 'http_req_duration{name:01_Login_Action}': ['p(95)<3000'], + 'http_req_duration{name:02_Search_Suggestions}': ['p(95)<1500'], + 'http_req_duration{name:03_Search_Suggestions_Hashtag}': ['p(95)<1500'], + 'http_req_duration{name:04_Search_Tweets}': ['p(95)<2000'], + 'http_req_duration{name:05_Search_Tweets_Page2}': ['p(95)<2000'], + 'http_req_duration{name:06_Search_Users}': ['p(95)<2000'], + 'http_req_duration{name:07_Search_Users_Page2}': ['p(95)<2000'], + 'http_req_duration{name:08_User_Suggestions}': ['p(95)<1500'], + }, +}; + +const STRESS_TEST_URL = __ENV.STRESS_TEST_URL || 'http://localhost:3000'; + +// Sample search queries to use +const SEARCH_QUERIES = [ + 'hello', + 'test', + 'user', + 'tweet', + 'world', + 'stress', + 'search', + 'random', +]; + +const HASHTAG_QUERIES = [ + '#trending', + '#news', + '#tech', + '#hello', + '#test', +]; + +/** + * Get a random search query + */ +function getRandomQuery() { + return SEARCH_QUERIES[Math.floor(Math.random() * SEARCH_QUERIES.length)]; +} + +/** + * Get a random hashtag query + */ +function getRandomHashtagQuery() { + return HASHTAG_QUERIES[Math.floor(Math.random() * HASHTAG_QUERIES.length)]; +} + +/** + * Helper function to create and login a user + * Returns { accessToken } or null on failure + */ +function createAndLoginUser(tagName) { + const resCreateUser = http.post(`${STRESS_TEST_URL}/test/users`); + + if (!check(resCreateUser, { 'User Created 201': (r) => r.status === 201 })) { + console.error(`Setup Failed (Create User): ${resCreateUser.body}`); + return null; + } + + const userData = resCreateUser.json('data'); + const userEmail = userData.email; + const userPassword = userData.password; + + const loginPayload = JSON.stringify({ + identifier: userEmail, + password: userPassword, + }); + + const loginParams = { + headers: { + 'Content-Type': 'application/json', + 'X-Client-Type': 'web', + }, + tags: { name: tagName }, + }; + + const resLogin = http.post(`${STRESS_TEST_URL}/auth/login`, loginPayload, loginParams); + + if (!check(resLogin, { 'Login 200': (r) => r.status === 200 })) { + console.error(`Setup Failed (Login): ${resLogin.body}`); + return null; + } + + return { + accessToken: resLogin.json('data.accessToken'), + }; +} + +export default function () { + // ───────────────────────────────────────────────────────────────────────────── + // SETUP: Create and login user + // ───────────────────────────────────────────────────────────────────────────── + const user = createAndLoginUser('01_Login_Action'); + if (!user) return; + + const authHeaders = { + 'Content-Type': 'application/json', + 'X-Client-Type': 'web', + Authorization: `Bearer ${user.accessToken}`, + }; + + sleep(0.2); + + const searchQuery = getRandomQuery(); + const hashtagQuery = getRandomHashtagQuery(); + + // ───────────────────────────────────────────────────────────────────────────── + // STEP 1: GET /search/suggestions (regular query) + // ───────────────────────────────────────────────────────────────────────────── + group('Search Suggestions', function () { + const resSuggestions = http.get( + `${STRESS_TEST_URL}/search/suggestions?query=${encodeURIComponent(searchQuery)}`, + { + headers: authHeaders, + tags: { name: '02_Search_Suggestions' }, + } + ); + + const suggestionsOk = check(resSuggestions, { + 'Search Suggestions 200': (r) => r.status === 200, + }); + + if (!suggestionsOk) { + console.error(`Search Suggestions Failed [VU:${__VU}]: ${resSuggestions.status} - ${resSuggestions.body}`); + } + + sleep(0.2); + + // ─────────────────────────────────────────────────────────────────────────── + // STEP 2: GET /search/suggestions (hashtag query) + // ─────────────────────────────────────────────────────────────────────────── + const resSuggestionsHashtag = http.get( + `${STRESS_TEST_URL}/search/suggestions?query=${encodeURIComponent(hashtagQuery)}`, + { + headers: authHeaders, + tags: { name: '03_Search_Suggestions_Hashtag' }, + } + ); + + const suggestionsHashtagOk = check(resSuggestionsHashtag, { + 'Search Suggestions Hashtag 200': (r) => r.status === 200, + }); + + if (!suggestionsHashtagOk) { + console.error(`Search Suggestions Hashtag Failed [VU:${__VU}]: ${resSuggestionsHashtag.status} - ${resSuggestionsHashtag.body}`); + } + }); + + sleep(0.3); + + // ───────────────────────────────────────────────────────────────────────────── + // STEP 3: GET /search/tweets (first page) + // ───────────────────────────────────────────────────────────────────────────── + group('Search Tweets', function () { + const resTweets = http.get( + `${STRESS_TEST_URL}/search/tweets?query=${encodeURIComponent(searchQuery)}&limit=20`, + { + headers: authHeaders, + tags: { name: '04_Search_Tweets' }, + } + ); + + const tweetsOk = check(resTweets, { + 'Search Tweets 200': (r) => r.status === 200, + }); + + if (!tweetsOk) { + console.error(`Search Tweets Failed [VU:${__VU}]: ${resTweets.status} - ${resTweets.body}`); + return; + } + + sleep(0.2); + + // ─────────────────────────────────────────────────────────────────────────── + // STEP 4: GET /search/tweets (pagination - second page) + // ─────────────────────────────────────────────────────────────────────────── + let cursor = null; + try { + const body = resTweets.json(); + if (body.pagination && body.pagination.nextCursor) { + cursor = body.pagination.nextCursor; + } + } catch { + // No cursor available + } + + if (cursor) { + const resTweetsPage2 = http.get( + `${STRESS_TEST_URL}/search/tweets?query=${encodeURIComponent(searchQuery)}&limit=20&cursor=${encodeURIComponent(cursor)}`, + { + headers: authHeaders, + tags: { name: '05_Search_Tweets_Page2' }, + } + ); + + check(resTweetsPage2, { + 'Search Tweets Page2 200': (r) => r.status === 200, + }); + } else { + // Make another request with different limit for consistent load + const resTweetsPage2 = http.get( + `${STRESS_TEST_URL}/search/tweets?query=${encodeURIComponent(searchQuery)}&limit=10`, + { + headers: authHeaders, + tags: { name: '05_Search_Tweets_Page2' }, + } + ); + + check(resTweetsPage2, { + 'Search Tweets Page2 200': (r) => r.status === 200, + }); + } + }); + + sleep(0.3); + + // ───────────────────────────────────────────────────────────────────────────── + // STEP 5: GET /search/users (first page) + // ───────────────────────────────────────────────────────────────────────────── + group('Search Users', function () { + const resUsers = http.get( + `${STRESS_TEST_URL}/search/users?query=${encodeURIComponent(searchQuery)}&limit=20`, + { + headers: authHeaders, + tags: { name: '06_Search_Users' }, + } + ); + + const usersOk = check(resUsers, { + 'Search Users 200': (r) => r.status === 200, + }); + + if (!usersOk) { + console.error(`Search Users Failed [VU:${__VU}]: ${resUsers.status} - ${resUsers.body}`); + return; + } + + sleep(0.2); + + // ─────────────────────────────────────────────────────────────────────────── + // STEP 6: GET /search/users (pagination - second page) + // ─────────────────────────────────────────────────────────────────────────── + let cursor = null; + try { + const body = resUsers.json(); + if (body.pagination && body.pagination.nextCursor) { + cursor = body.pagination.nextCursor; + } + } catch { + // No cursor available + } + + if (cursor) { + const resUsersPage2 = http.get( + `${STRESS_TEST_URL}/search/users?query=${encodeURIComponent(searchQuery)}&limit=20&cursor=${encodeURIComponent(cursor)}`, + { + headers: authHeaders, + tags: { name: '07_Search_Users_Page2' }, + } + ); + + check(resUsersPage2, { + 'Search Users Page2 200': (r) => r.status === 200, + }); + } else { + // Make another request with different limit for consistent load + const resUsersPage2 = http.get( + `${STRESS_TEST_URL}/search/users?query=${encodeURIComponent(searchQuery)}&limit=10`, + { + headers: authHeaders, + tags: { name: '07_Search_Users_Page2' }, + } + ); + + check(resUsersPage2, { + 'Search Users Page2 200': (r) => r.status === 200, + }); + } + }); + + sleep(0.3); + + // ───────────────────────────────────────────────────────────────────────────── + // STEP 7: GET /search/users/suggestions (for mentions) + // ───────────────────────────────────────────────────────────────────────────── + group('User Suggestions', function () { + const resUserSuggestions = http.get( + `${STRESS_TEST_URL}/search/users/suggestions?query=${encodeURIComponent(searchQuery)}`, + { + headers: authHeaders, + tags: { name: '08_User_Suggestions' }, + } + ); + + const userSuggestionsOk = check(resUserSuggestions, { + 'User Suggestions 200': (r) => r.status === 200, + }); + + if (!userSuggestionsOk) { + console.error(`User Suggestions Failed [VU:${__VU}]: ${resUserSuggestions.status} - ${resUserSuggestions.body}`); + } + }); + + sleep(0.1); +} diff --git a/test/performance/settings/account/birthdate.stress.js b/test/performance/settings/account/birthdate.stress.js index a310c90c..a0ee28be 100644 --- a/test/performance/settings/account/birthdate.stress.js +++ b/test/performance/settings/account/birthdate.stress.js @@ -17,9 +17,9 @@ export const options = { }, }; -const STRESS_TEST_URL = __ENV.STRESS_TEST_URL || 'http://localhost:3001'; +const STRESS_TEST_URL = __ENV.STRESS_TEST_URL || 'https://test.api.raven.cmp27.space'; -export default function () { +export function setup() { const resCreate = http.post( `${STRESS_TEST_URL}/test/users`, @@ -57,6 +57,11 @@ export default function () { } const accessToken = resLogin.json('data.accessToken'); + return { accessToken }; +} + +export default function (data) { + const { accessToken } = data; const headers = { 'Content-Type': 'application/json', diff --git a/test/performance/settings/account/email.stress.js b/test/performance/settings/account/email.stress.js index 8ce4a57c..64b48c9f 100644 --- a/test/performance/settings/account/email.stress.js +++ b/test/performance/settings/account/email.stress.js @@ -19,9 +19,9 @@ export const options = { }, }; -const STRESS_TEST_URL = __ENV.STRESS_TEST_URL || 'http://localhost:3001'; +const STRESS_TEST_URL = __ENV.STRESS_TEST_URL || 'https://test.api.raven.cmp27.space'; -export default function () { +export function setup() { const resCreate = http.post( `${STRESS_TEST_URL}/test/users`, @@ -59,6 +59,11 @@ export default function () { } const accessToken = resLogin.json('data.accessToken'); + return { accessToken }; +} + +export default function (data) { + const { accessToken } = data; const headers = { 'Content-Type': 'application/json', diff --git a/test/performance/settings/account/password.stress.js b/test/performance/settings/account/password.stress.js index ccf6cb98..29165112 100644 --- a/test/performance/settings/account/password.stress.js +++ b/test/performance/settings/account/password.stress.js @@ -17,10 +17,10 @@ export const options = { }, }; -const STRESS_TEST_URL = __ENV.STRESS_TEST_URL || 'http://localhost:3001'; +const STRESS_TEST_URL = __ENV.STRESS_TEST_URL || 'https://test.api.raven.cmp27.space'; -export default function () { +export function setup() { const resCreate = http.post( `${STRESS_TEST_URL}/test/users`, @@ -58,6 +58,11 @@ export default function () { } const accessToken = resLogin.json('data.accessToken'); + return { accessToken, userPassword }; +} + +export default function (data) { + const { accessToken, userPassword } = data; const headers = { 'Content-Type': 'application/json', diff --git a/test/performance/settings/account/username.stress.js b/test/performance/settings/account/username.stress.js index 41784978..8735abe6 100644 --- a/test/performance/settings/account/username.stress.js +++ b/test/performance/settings/account/username.stress.js @@ -18,9 +18,9 @@ export const options = { }, }; -const STRESS_TEST_URL = __ENV.STRESS_TEST_URL || 'http://localhost:3001'; +const STRESS_TEST_URL = __ENV.STRESS_TEST_URL || 'https://test.api.raven.cmp27.space'; -export default function () { +export function setup() { const resCreate = http.post( `${STRESS_TEST_URL}/test/users`, @@ -58,6 +58,11 @@ export default function () { } const accessToken = resLogin.json('data.accessToken'); + return { accessToken }; +} + +export default function (data) { + const { accessToken } = data; const headers = { 'Content-Type': 'application/json', diff --git a/test/performance/settings/general.stress.js b/test/performance/settings/general.stress.js new file mode 100644 index 00000000..b773924a --- /dev/null +++ b/test/performance/settings/general.stress.js @@ -0,0 +1,180 @@ +import http from 'k6/http'; +import { check, sleep } from 'k6'; + +export const options = { + stages: [ + { duration: '30s', target: 20 }, + { duration: '1m', target: 50 }, + { duration: '30s', target: 500 }, + { duration: '1m', target: 700 }, + { duration: '30s', target: 0 }, + ], + thresholds: { + http_req_failed: ['rate<0.01'], + 'http_req_duration{name:01_Login_Action}': ['p(95)<1800'], + 'http_req_duration{name:02_Get_Me}': ['p(95)<500'], + 'http_req_duration{name:03_Get_Blocks}': ['p(95)<500'], + 'http_req_duration{name:04_Get_Mutes}': ['p(95)<500'], + 'http_req_duration{name:05_Get_Connected_Accounts}': ['p(95)<500'], + 'http_req_duration{name:06_Get_Countries}': ['p(95)<500'], + 'http_req_duration{name:07_Update_Country}': ['p(95)<500'], + 'http_req_duration{name:08_Update_Gender}': ['p(95)<500'], + 'http_req_duration{name:09_Get_Interests}': ['p(95)<500'], + 'http_req_duration{name:10_Update_Language}': ['p(95)<500'], + }, +}; + +const STRESS_TEST_URL = __ENV.STRESS_TEST_URL || 'https://test.api.raven.cmp27.space'; + +export function setup() { + + const resCreate = http.post( + `${STRESS_TEST_URL}/test/users`, + ); + + if (!check(resCreate, { 'User Created 201': (r) => r.status === 201 })) { + console.error(`Failed to create user: ${resCreate.body}`); + return; + } + + const userData = resCreate.json('data'); + const userEmail = userData.email; + const userPassword = userData.password; + + const loginPayload = JSON.stringify({ + identifier: userEmail, + password: userPassword + }); + + const loginParams = { + headers: { + 'Content-Type': 'application/json', + 'X-Client-Type': 'web', + }, + tags: { name: '01_Login_Action' } + }; + + const resLogin = http.post(`${STRESS_TEST_URL}/auth/login`, loginPayload, loginParams); + + if (!check(resLogin, { + 'Login status is 200': (r) => r.status === 200 + })) { + console.error(`Login Failed: ${resLogin.body}`); + return; + } + + const accessToken = resLogin.json('data.accessToken'); + return { accessToken }; +} + +export default function (data) { + const { accessToken } = data; + + const headers = { + 'Content-Type': 'application/json', + 'X-Client-Type': 'web', + 'Authorization': `Bearer ${accessToken}`, + }; + + // GET /me + const resMe = http.get( + `${STRESS_TEST_URL}/me`, + { headers: headers, tags: { name: '02_Get_Me' } } + ); + check(resMe, { + 'Get Me status is 200': (r) => r.status === 200, + }); + sleep(0.1); + + // GET /me/settings/blocks + const resBlocks = http.get( + `${STRESS_TEST_URL}/me/settings/blocks`, + { headers: headers, tags: { name: '03_Get_Blocks' } } + ); + check(resBlocks, { + 'Get Blocks status is 200': (r) => r.status === 200, + }); + sleep(0.1); + + // GET /me/settings/mutes + const resMutes = http.get( + `${STRESS_TEST_URL}/me/settings/mutes`, + { headers: headers, tags: { name: '04_Get_Mutes' } } + ); + check(resMutes, { + 'Get Mutes status is 200': (r) => r.status === 200, + }); + sleep(0.1); + + // GET /me/settings/connected-accounts + const resConnected = http.get( + `${STRESS_TEST_URL}/me/settings/connected-accounts`, + { headers: headers, tags: { name: '05_Get_Connected_Accounts' } } + ); + check(resConnected, { + 'Get Connected Accounts status is 200': (r) => r.status === 200, + }); + sleep(0.1); + + // GET /me/settings/country + const resCountries = http.get( + `${STRESS_TEST_URL}/me/settings/country`, + { headers: headers, tags: { name: '06_Get_Countries' } } + ); + check(resCountries, { + 'Get Countries status is 200': (r) => r.status === 200, + }); + sleep(0.1); + + // PUT /me/settings/country + const payloadCountry = JSON.stringify({ + countryName: "Egypt" + }); + const resUpdateCountry = http.put( + `${STRESS_TEST_URL}/me/settings/country`, + payloadCountry, + { headers: headers, tags: { name: '07_Update_Country' } } + ); + check(resUpdateCountry, { + 'Update Country status is 200': (r) => r.status === 200, + }); + sleep(0.1); + + // PUT /me/settings/gender + const payloadGender = JSON.stringify({ + gender: "Male" + }); + const resUpdateGender = http.put( + `${STRESS_TEST_URL}/me/settings/gender`, + payloadGender, + { headers: headers, tags: { name: '08_Update_Gender' } } + ); + check(resUpdateGender, { + 'Update Gender status is 200': (r) => r.status === 200, + }); + sleep(0.1); + + // GET /me/settings/interests + const resInterests = http.get( + `${STRESS_TEST_URL}/me/settings/interests`, + { headers: headers, tags: { name: '09_Get_Interests' } } + ); + check(resInterests, { + 'Get Interests status is 200': (r) => r.status === 200, + }); + sleep(0.1); + + // PUT /me/settings/language + const payloadLanguage = JSON.stringify({ + language: "en" + }); + const resUpdateLanguage = http.put( + `${STRESS_TEST_URL}/me/settings/language`, + payloadLanguage, + { headers: headers, tags: { name: '10_Update_Language' } } + ); + check(resUpdateLanguage, { + 'Update Language status is 200': (r) => r.status === 200, + }); + sleep(0.1); +} diff --git a/test/performance/settings/report.html b/test/performance/settings/report.html new file mode 100644 index 00000000..ea2561f3 --- /dev/null +++ b/test/performance/settings/report.html @@ -0,0 +1,44 @@ + + + + + + + + + k6 report + + + + + +
+ + + + diff --git a/test/performance/timeline/report.html b/test/performance/timeline/report.html new file mode 100644 index 00000000..3973846f --- /dev/null +++ b/test/performance/timeline/report.html @@ -0,0 +1,44 @@ + + + + + + + + + k6 report + + + + + +
+ + + + diff --git a/test/performance/timeline/timeline.stress.js b/test/performance/timeline/timeline.stress.js new file mode 100644 index 00000000..f2875f50 --- /dev/null +++ b/test/performance/timeline/timeline.stress.js @@ -0,0 +1,219 @@ +import http from 'k6/http'; +import { check, sleep, group } from 'k6'; + +export const options = { + stages: [ + { duration: '30s', target: 20 }, // Ramp up to 20 users + { duration: '1m', target: 50 }, // Ramp up to 50 users + { duration: '30s', target: 100 }, // Ramp up to 100 users + { duration: '1m', target: 100 }, // Stay at 100 users + { duration: '30s', target: 0 }, // Ramp down + ], + thresholds: { + http_req_failed: ['rate<0.01'], + 'http_req_duration{name:01_Login_Action}': ['p(95)<2000'], + 'http_req_duration{name:02_Create_Tweet_For_Timeline}': ['p(95)<2000'], + 'http_req_duration{name:03_Get_Following_Timeline}': ['p(95)<1000'], + 'http_req_duration{name:04_Get_Following_Pagination}': ['p(95)<1000'], + 'http_req_duration{name:05_Get_ForYou_Timeline}': ['p(95)<1000'], + 'http_req_duration{name:06_Get_ForYou_Pagination}': ['p(95)<1000'], + }, +}; + +const STRESS_TEST_URL = __ENV.STRESS_TEST_URL || 'http://localhost:3000'; + +export default function () { + // ───────────────────────────────────────────────────────────────────────────── + // SETUP: Create a test user + // ───────────────────────────────────────────────────────────────────────────── + const resCreateUser = http.post(`${STRESS_TEST_URL}/test/users`); + + if (!check(resCreateUser, { 'User Created 201': (r) => r.status === 201 })) { + console.error(`Setup Failed (Create User): ${resCreateUser.body}`); + return; + } + + const userData = resCreateUser.json('data'); + const userEmail = userData.email; + const userPassword = userData.password; + + // ───────────────────────────────────────────────────────────────────────────── + // STEP 1: Login + // ───────────────────────────────────────────────────────────────────────────── + const loginPayload = JSON.stringify({ + identifier: userEmail, + password: userPassword, + }); + + const loginParams = { + headers: { + 'Content-Type': 'application/json', + 'X-Client-Type': 'web', + }, + tags: { name: '01_Login_Action' }, + }; + + const resLogin = http.post(`${STRESS_TEST_URL}/auth/login`, loginPayload, loginParams); + + if (!check(resLogin, { 'Login 200': (r) => r.status === 200 })) { + console.error(`Setup Failed (Login): ${resLogin.body}`); + return; + } + + const accessToken = resLogin.json('data.accessToken'); + + const authParams = { + headers: { + 'Content-Type': 'application/json', + 'X-Client-Type': 'web', + Authorization: `Bearer ${accessToken}`, + }, + }; + + sleep(0.3); + + // ───────────────────────────────────────────────────────────────────────────── + // STEP 2: Create a tweet so the timeline has content + // ───────────────────────────────────────────────────────────────────────────── + const uniqueContent = `Timeline Stress Test Tweet ${__VU}-${__ITER} - ${Date.now()}`; + + const createPayload = JSON.stringify({ + content: uniqueContent, + }); + + const resCreateTweet = http.post(`${STRESS_TEST_URL}/tweets`, createPayload, { + ...authParams, + tags: { name: '02_Create_Tweet_For_Timeline' }, + }); + + if (!check(resCreateTweet, { 'Create Tweet 201': (r) => r.status === 201 })) { + console.error(`Create Tweet Failed: ${resCreateTweet.body}`); + return; + } + + sleep(0.3); + + // ───────────────────────────────────────────────────────────────────────────── + // STEP 3: Get Following Timeline (first page) + // ───────────────────────────────────────────────────────────────────────────── + group('Following Timeline', function () { + const resFollowingTimeline = http.get( + `${STRESS_TEST_URL}/timeline/following?limit=20`, + { ...authParams, tags: { name: '03_Get_Following_Timeline' } } + ); + + const followingTimelineOk = check(resFollowingTimeline, { + 'Get Following Timeline 200': (r) => r.status === 200 + }); + + if (!followingTimelineOk) { + console.error(`Get Following Timeline Failed: ${resFollowingTimeline.body}`); + return; + } + + // Get cursor for pagination test if available + let followingCursor = null; + try { + const followingBody = resFollowingTimeline.json(); + if (followingBody.pagination && followingBody.pagination.nextCursor) { + followingCursor = followingBody.pagination.nextCursor; + } + } catch { + // No cursor available + } + + sleep(0.2); + + // ─────────────────────────────────────────────────────────────────────────── + // STEP 4: Get Following Timeline (pagination - second page if cursor exists) + // ─────────────────────────────────────────────────────────────────────────── + if (followingCursor) { + const resFollowingPagination = http.get( + `${STRESS_TEST_URL}/timeline/following?limit=20&cursor=${encodeURIComponent(followingCursor)}`, + { ...authParams, tags: { name: '04_Get_Following_Pagination' } } + ); + + check(resFollowingPagination, { + 'Get Following Pagination 200': (r) => r.status === 200, + }); + + if (resFollowingPagination.status !== 200) { + console.error(`Get Following Pagination Failed: ${resFollowingPagination.body}`); + } + } else { + // Make a request anyway to ensure thresholds are met with consistent load + const resFollowingPagination = http.get( + `${STRESS_TEST_URL}/timeline/following?limit=10`, + { ...authParams, tags: { name: '04_Get_Following_Pagination' } } + ); + + check(resFollowingPagination, { + 'Get Following Pagination 200': (r) => r.status === 200, + }); + } + }); + + sleep(0.3); + + // ───────────────────────────────────────────────────────────────────────────── + // STEP 5: Get For You Timeline (first page) + // ───────────────────────────────────────────────────────────────────────────── + group('For You Timeline', function () { + const resForYouTimeline = http.get( + `${STRESS_TEST_URL}/timeline/for-you?limit=20`, + { ...authParams, tags: { name: '05_Get_ForYou_Timeline' } } + ); + + const forYouTimelineOk = check(resForYouTimeline, { + 'Get ForYou Timeline 200': (r) => r.status === 200, + }); + + if (!forYouTimelineOk) { + console.error(`Get ForYou Timeline Failed: ${resForYouTimeline.body}`); + return; + } + + // Get cursor for pagination test if available + let forYouCursor = null; + try { + const forYouBody = resForYouTimeline.json(); + if (forYouBody.pagination && forYouBody.pagination.nextCursor) { + forYouCursor = forYouBody.pagination.nextCursor; + } + } catch { + // No cursor available + } + + sleep(0.2); + + // ─────────────────────────────────────────────────────────────────────────── + // STEP 6: Get For You Timeline (pagination - second page if cursor exists) + // ─────────────────────────────────────────────────────────────────────────── + if (forYouCursor) { + const resForYouPagination = http.get( + `${STRESS_TEST_URL}/timeline/for-you?limit=20&cursor=${encodeURIComponent(forYouCursor)}`, + { ...authParams, tags: { name: '06_Get_ForYou_Pagination' } } + ); + + check(resForYouPagination, { + 'Get ForYou Pagination 200': (r) => r.status === 200, + }); + + if (resForYouPagination.status !== 200) { + console.error(`Get ForYou Pagination Failed: ${resForYouPagination.body}`); + } + } else { + // Make a request anyway to ensure thresholds are met with consistent load + const resForYouPagination = http.get( + `${STRESS_TEST_URL}/timeline/for-you?limit=10`, + { ...authParams, tags: { name: '06_Get_ForYou_Pagination' } } + ); + + check(resForYouPagination, { + 'Get ForYou Pagination 200': (r) => r.status === 200, + }); + } + }); + + sleep(0.1); +} diff --git a/test/performance/tweets/tweet-crud.stress.js b/test/performance/tweets/tweet-crud.stress.js index 865f5474..a9ee4fa5 100644 --- a/test/performance/tweets/tweet-crud.stress.js +++ b/test/performance/tweets/tweet-crud.stress.js @@ -5,8 +5,8 @@ export const options = { stages: [ { duration: '30s', target: 20 }, { duration: '1m', target: 50 }, - { duration: '30s', target: 100 }, - { duration: '1m', target: 100 }, + { duration: '30s', target: 200 }, + { duration: '1m', target: 400 }, { duration: '30s', target: 0 }, ], thresholds: { @@ -18,9 +18,9 @@ export const options = { }, }; -const STRESS_TEST_URL = __ENV.STRESS_TEST_URL || 'http://localhost:3000'; +const STRESS_TEST_URL = __ENV.STRESS_TEST_URL || 'https://test.api.raven.cmp27.space'; -export default function () { +export function setup() { const resCreateUser = http.post( `${STRESS_TEST_URL}/test/users`, ); @@ -55,6 +55,11 @@ export default function () { } const accessToken = resLogin.json('data.accessToken'); + return { accessToken }; +} + +export default function (data) { + const { accessToken } = data; const authParams = { headers: { diff --git a/test/performance/tweets/tweet-engagement.stress.js b/test/performance/tweets/tweet-engagement.stress.js index 7c01e663..f46bc953 100644 --- a/test/performance/tweets/tweet-engagement.stress.js +++ b/test/performance/tweets/tweet-engagement.stress.js @@ -5,8 +5,8 @@ export const options = { stages: [ { duration: '30s', target: 20 }, { duration: '1m', target: 50 }, - { duration: '30s', target: 100 }, - { duration: '1m', target: 100 }, + { duration: '30s', target: 200 }, + { duration: '1m', target: 400 }, { duration: '30s', target: 0 }, ], thresholds: { @@ -22,9 +22,9 @@ export const options = { }, }; -const STRESS_TEST_URL = __ENV.STRESS_TEST_URL || 'http://localhost:3000'; +const STRESS_TEST_URL = __ENV.STRESS_TEST_URL || 'https://test.api.raven.cmp27.space'; -export default function () { +export function setup() { const resCreateUser = http.post( `${STRESS_TEST_URL}/test/users`, @@ -54,6 +54,11 @@ export default function () { } const accessToken = resLogin.json('data.accessToken'); + return { accessToken }; +} + +export default function (data) { + const { accessToken } = data; const authParams = { headers: { 'Content-Type': 'application/json', diff --git a/test/performance/tweets/tweet-threads.stress.js b/test/performance/tweets/tweet-threads.stress.js index 91ab7933..0418f7d4 100644 --- a/test/performance/tweets/tweet-threads.stress.js +++ b/test/performance/tweets/tweet-threads.stress.js @@ -5,8 +5,8 @@ export const options = { stages: [ { duration: '30s', target: 20 }, { duration: '1m', target: 50 }, - { duration: '30s', target: 100 }, - { duration: '1m', target: 100 }, + { duration: '30s', target: 200 }, + { duration: '1m', target: 400 }, { duration: '30s', target: 0 }, ], thresholds: { @@ -20,9 +20,9 @@ export const options = { }, }; -const STRESS_TEST_URL = __ENV.STRESS_TEST_URL || 'http://localhost:3000'; +const STRESS_TEST_URL = __ENV.STRESS_TEST_URL || 'https://test.api.raven.cmp27.space'; -export default function () { +export function setup() { const resCreateUser = http.post( `${STRESS_TEST_URL}/test/users`, ); @@ -51,6 +51,11 @@ export default function () { } const accessToken = resLogin.json('data.accessToken'); + return { accessToken }; +} + +export default function (data) { + const { accessToken } = data; const authParams = { headers: { 'Content-Type': 'application/json', diff --git a/test/performance/users/user-data.stress.js b/test/performance/users/user-data.stress.js new file mode 100644 index 00000000..959bf74b --- /dev/null +++ b/test/performance/users/user-data.stress.js @@ -0,0 +1,133 @@ +import http from 'k6/http'; +import { check, sleep } from 'k6'; + +export const options = { + stages: [ + { duration: '30s', target: 20 }, + { duration: '1m', target: 50 }, + { duration: '30s', target: 200 }, + { duration: '1m', target: 400 }, + { duration: '30s', target: 0 }, + ], + thresholds: { + http_req_failed: ['rate<0.01'], + 'http_req_duration{name:01_Login_Action}': ['p(95)<2000'], + 'http_req_duration{name:02_Get_User_By_Id}': ['p(95)<500'], + 'http_req_duration{name:03_Get_User_Profile}': ['p(95)<500'], + 'http_req_duration{name:04_Get_User_Tweets}': ['p(95)<1000'], + 'http_req_duration{name:05_Get_User_Replies}': ['p(95)<1000'], + 'http_req_duration{name:06_Get_User_Media}': ['p(95)<1000'], + 'http_req_duration{name:07_Get_User_Likes}': ['p(95)<1000'], + }, +}; + +const STRESS_TEST_URL = __ENV.STRESS_TEST_URL || 'https://test.api.raven.cmp27.space'; + +export function setup() { + // 1. Create User + const resCreateUser = http.post(`${STRESS_TEST_URL}/test/users`); + + if (!check(resCreateUser, { 'User Created 201': (r) => r.status === 201 })) { + console.error(`Setup Failed (Create User): ${resCreateUser.body}`); + return; + } + + const userData = resCreateUser.json('data'); + const userEmail = userData.email; + const userPassword = userData.password; + const userId = userData.id; + + // 2. Login + const loginPayload = JSON.stringify({ + identifier: userEmail, + password: userPassword, + }); + + const loginParams = { + headers: { + 'Content-Type': 'application/json', + 'X-Client-Type': 'web', + }, + tags: { name: '01_Login_Action' }, + }; + + const resLogin = http.post(`${STRESS_TEST_URL}/auth/login`, loginPayload, loginParams); + + if (!check(resLogin, { 'Login 200': (r) => r.status === 200 })) { + console.error(`Setup Failed (Login): ${resLogin.body}`); + return; + } + + const accessToken = resLogin.json('data.accessToken'); + return { accessToken, userId }; +} + +export default function (data) { + const { accessToken, userId } = data; + + const authParams = { + headers: { + 'Content-Type': 'application/json', + 'X-Client-Type': 'web', + 'Authorization': `Bearer ${accessToken}`, + }, + }; + + sleep(0.5); + + // 3. Get User By ID + const resGetUserById = http.get( + `${STRESS_TEST_URL}/users/id/${userId}`, + { ...authParams, tags: { name: '02_Get_User_By_Id' } } + ); + + check(resGetUserById, { 'Get User By Id 200': (r) => r.status === 200 }); + sleep(0.5); + + const users = ['notnowomar', 'gelgel']; + const randomUser = () => users[Math.floor(Math.random() * users.length)]; + + // 4. Get User Profile + const resGetUserProfile = http.get( + `${STRESS_TEST_URL}/users/${randomUser()}/profile`, + { ...authParams, tags: { name: '03_Get_User_Profile' } } + ); + + check(resGetUserProfile, { 'Get User Profile 200': (r) => r.status === 200 }); + sleep(0.5); + + // 5. Get User Tweets + const resGetUserTweets = http.get( + `${STRESS_TEST_URL}/users/${randomUser()}/tweets`, + { ...authParams, tags: { name: '04_Get_User_Tweets' } } + ); + + check(resGetUserTweets, { 'Get User Tweets 200': (r) => r.status === 200 }); + sleep(0.5); + + // 6. Get User Replies + const resGetUserReplies = http.get( + `${STRESS_TEST_URL}/users/${randomUser()}/replies`, + { ...authParams, tags: { name: '05_Get_User_Replies' } } + ); + + check(resGetUserReplies, { 'Get User Replies 200': (r) => r.status === 200 }); + sleep(0.5); + + // 7. Get User Media + const resGetUserMedia = http.get( + `${STRESS_TEST_URL}/users/${randomUser()}/media`, + { ...authParams, tags: { name: '06_Get_User_Media' } } + ); + + check(resGetUserMedia, { 'Get User Media 200': (r) => r.status === 200 }); + sleep(0.5); + + // 8. Get User Likes + const resGetUserLikes = http.get( + `${STRESS_TEST_URL}/users/${randomUser()}/likes`, + { ...authParams, tags: { name: '07_Get_User_Likes' } } + ); + + check(resGetUserLikes, { 'Get User Likes 200': (r) => r.status === 200 }); +} diff --git a/test/performance/users/user-social.stress.js b/test/performance/users/user-social.stress.js new file mode 100644 index 00000000..d586222b --- /dev/null +++ b/test/performance/users/user-social.stress.js @@ -0,0 +1,132 @@ +import http from 'k6/http'; +import { check, sleep } from 'k6'; + +export const options = { + stages: [ + { duration: '30s', target: 20 }, + { duration: '1m', target: 50 }, + { duration: '30s', target: 200 }, + { duration: '1m', target: 400 }, + { duration: '30s', target: 0 }, + ], + thresholds: { + http_req_failed: ['rate<0.01'], + 'http_req_duration{name:01_Login_Action}': ['p(95)<2000'], + 'http_req_duration{name:02_Follow_User}': ['p(95)<1000'], + 'http_req_duration{name:03_Get_Following}': ['p(95)<1000'], + 'http_req_duration{name:04_Get_Followers}': ['p(95)<1000'], + 'http_req_duration{name:05_Get_Relationship}': ['p(95)<500'], + 'http_req_duration{name:06_Get_Mutual}': ['p(95)<1000'], + 'http_req_duration{name:07_Unfollow_User}': ['p(95)<1000'], + }, +}; + +const STRESS_TEST_URL = __ENV.STRESS_TEST_URL || 'https://test.api.raven.cmp27.space'; + +export function setup() { + // 1. Create User A (The Actor) + const resCreateUser = http.post(`${STRESS_TEST_URL}/test/users`); + if (!check(resCreateUser, { 'User A Created 201': (r) => r.status === 201 })) { + console.error(`Setup Failed (Create User A): ${resCreateUser.body}`); + return; + } + const userData = resCreateUser.json('data'); + const userEmail = userData.email; + const userPassword = userData.password; + const userUsername = userData.username; + + // 3. Login as User A + const loginPayload = JSON.stringify({ + identifier: userEmail, + password: userPassword, + }); + + const loginParams = { + headers: { + 'Content-Type': 'application/json', + 'X-Client-Type': 'web', + }, + tags: { name: '01_Login_Action' }, + }; + + const resLogin = http.post(`${STRESS_TEST_URL}/auth/login`, loginPayload, loginParams); + + if (!check(resLogin, { 'Login 200': (r) => r.status === 200 })) { + console.error(`Setup Failed (Login): ${resLogin.body}`); + return; + } + + const accessToken = resLogin.json('data.accessToken'); + return { accessToken, userUsername }; +} + +export default function (data) { + const { accessToken, userUsername } = data; + + const authParams = { + headers: { + 'Content-Type': 'application/json', + 'X-Client-Type': 'web', + 'Authorization': `Bearer ${accessToken}`, + }, + }; + + sleep(0.5); + const users = ['notnowomar', 'gelgel']; + const randomUser = users[Math.floor(Math.random() * users.length)]; + + // 4. Follow User B + const resFollow = http.post( + `${STRESS_TEST_URL}/users/${randomUser}/following`, + null, // No body needed for follow usually, or empty object + { ...authParams, tags: { name: '02_Follow_User' } } + ); + + check(resFollow, { 'Follow User 200': (r) => r.status === 200 || r.status === 201 }); + sleep(0.5); + + // 5. Get Following (of User A) + const resGetFollowing = http.get( + `${STRESS_TEST_URL}/users/${userUsername}/following`, + { ...authParams, tags: { name: '03_Get_Following' } } + ); + + check(resGetFollowing, { 'Get Following 200': (r) => r.status === 200 }); + sleep(0.5); + + // 6. Get Followers (of User B) + const resGetFollowers = http.get( + `${STRESS_TEST_URL}/users/${randomUser}/followers`, + { ...authParams, tags: { name: '04_Get_Followers' } } + ); + + check(resGetFollowers, { 'Get Followers 200': (r) => r.status === 200 }); + sleep(0.5); + + // 7. Get Relationship (A with B) + const resGetRelationship = http.get( + `${STRESS_TEST_URL}/users/${randomUser}/relationship`, + { ...authParams, tags: { name: '05_Get_Relationship' } } + ); + + check(resGetRelationship, { 'Get Relationship 200': (r) => r.status === 200 }); + sleep(0.5); + + // 8. Get Mutual (A with B) + const resGetMutual = http.get( + `${STRESS_TEST_URL}/users/${randomUser}/mutual`, + { ...authParams, tags: { name: '06_Get_Mutual' } } + ); + + check(resGetMutual, { 'Get Mutual 200': (r) => r.status === 200 }); + sleep(0.5); + + // 9. Unfollow User B + const resUnfollow = http.del( + `${STRESS_TEST_URL}/users/${randomUser}/following`, + null, + { ...authParams, tags: { name: '07_Unfollow_User' } } + ); + + check(resUnfollow, { 'Unfollow User 200': (r) => r.status === 200 }); +}