diff --git a/test-scripts/common.js b/test-scripts/common.js new file mode 100644 index 00000000..d841534c --- /dev/null +++ b/test-scripts/common.js @@ -0,0 +1,91 @@ +import http from 'k6/http'; + +export const BASE_URL = __ENV.BASE_URL || 'http://localhost:8080'; + +export function generateRandomBase64String(length) { + const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'; + let result = ''; + for (let i = 0; i < length; i++) { + result += chars.charAt(Math.floor(Math.random() * chars.length)); + } + return result; +} + +export function parseIdToString(response) { + try { + const safeJsonString = response.body.replace(/"(\w*Id|id)"\s*:\s*(\d{16,})/g, '"$1":"$2"'); + return JSON.parse(safeJsonString); + } catch (e) { + console.error(`JSON parsing failed for VU ${__VU}: ${e}`); + return null; + } +} + +export function maxIterations() { + const maxIterationsRes = http.get(`${BASE_URL}/tests/max-iterations`) + if (maxIterationsRes.status !== 200) { + throw new Error('max-iterations 조회 실패') + } + + return maxIterationsRes.json('count') +} + +export function fetchUsers() { + const userCount = Math.round(maxIterations() * 1.2) + const userAccountRes = http.get(`${BASE_URL}/tests/users?count=${userCount}`) + + if (userAccountRes.status !== 200) { + throw new Error('users 조회 실패') + } + + return userAccountRes.json('results') +} + +export function fetchStores() { + const storeIdListRes = http.get(`${BASE_URL}/tests/stores`) + + if (storeIdListRes.status !== 200) { + throw new Error('stores 조회 실패') + } + + return parseIdToString(storeIdListRes).results +} + +export function login(account, password, principalType) { + const loginPayload = JSON.stringify({ + account: account, + password: password, + principalType: principalType + }) + const params = { headers: { 'Content-Type': 'application/json' } } + + const loginRes = http.post(`${BASE_URL}/auth/login`, loginPayload, params) + + if (loginRes.status !== 200) { + throw new Error(`로그인 실패: ${__VU}`) + } + + const body = parseIdToString(loginRes).data + if (principalType === 'ADMIN') { + return { + storeId: body.storeId, + accessToken: body.accessToken + } + } else { + return { + accessToken: body.accessToken + } + } +} + +export function getHeaders(token) { + const headers = { + 'Content-Type': 'application/json', + }; + + if (token) { + headers['Authorization'] = `Bearer ${token}`; + } + + return { headers: headers }; +} diff --git a/test-scripts/create-reservation-scripts.js b/test-scripts/create-reservation-scripts.js new file mode 100644 index 00000000..4be7035c --- /dev/null +++ b/test-scripts/create-reservation-scripts.js @@ -0,0 +1,231 @@ +import { + BASE_URL, + fetchStores, + fetchUsers, + generateRandomBase64String, + getHeaders, + login, + parseIdToString +} from "./common.js"; +import {check, group, sleep} from 'k6'; +import {randomIntBetween, randomItem} from 'https://jslib.k6.io/k6-utils/1.2.0/index.js'; +import http from 'k6/http'; + +export const options = { + scenarios: { + user_reservation: { + executor: 'constant-vus', + vus: 15, + duration: '15m' + } + }, + + thresholds: { + http_req_duration: ['p(95)<3000'], + http_req_failed: ['rate<0.15'], + }, +}; + + +function randomDayBetween(startFrom, endAt) { + const random = randomIntBetween(startFrom, endAt) + const targetDate = new Date() + targetDate.setDate(targetDate.getDate() + random) + + return targetDate.toISOString().split('T')[0]; +} + +function countForFetchDetails(themeCount) { + const random = Math.random() + + if (random < 0.5) { + return Math.min(0, themeCount) + } + if (random < 0.75) { + return Math.min(1, themeCount) + } + if (random < 0.9) { + return Math.min(2, themeCount) + } + return Math.min(randomIntBetween(3, themeCount), themeCount) +} + +function extractRandomThemeForFetchDetail(themes) { + const count = countForFetchDetails(themes.length) + const shuffled = [...themes].sort(() => Math.random() - 0.5) + + return shuffled.slice(0, count).map(t => t.id) +} + +export function setup() { + const users = fetchUsers() + const stores = fetchStores() + + console.log(`회원 수 = ${users.length} 조회 완료`) + console.log(`매장 수 = ${stores.length} 조회 완료`) + + return { users, stores } +} + +export default function (data) { + const { users, stores } = data; + + const user = randomItem(users) + const accessToken = login(user.account, user.password, 'USER').accessToken + const storeId = randomItem(stores).storeId + + if (!accessToken) { + console.log(`로그인 실패: token=${accessToken}`) + return + } + const targetDate = randomDayBetween(1, 6) + + let availableScheduleId, selectedThemeId, selectedThemeInfo, reservationId, totalAmount + + group(`매장=${storeId}, 날짜=${targetDate}의 일정 조회`, function () { + const res = http.get(`${BASE_URL}/stores/${storeId}/schedules?date=${targetDate}`) + if (check(res, { '일정 조회 성공': (r) => r.status === 200 })) { + sleep(20) + const schedules = parseIdToString(res).data.schedules + if (schedules) { + group(`일부 테마는 상세 조회`, function () { + const themesByStoreAndDate = schedules.map(s => s.theme) + if (!themesByStoreAndDate && themesByStoreAndDate.length <= 0) { + return + } + const randomThemesForFetchDetail = extractRandomThemeForFetchDetail(themesByStoreAndDate) + + randomThemesForFetchDetail.forEach(id => { + http.get(`${BASE_URL}/themes/${id}`) + sleep(10) + }) + }) + + const availableSchedules = schedules.filter((s) => s.schedule.status === 'AVAILABLE') + if (availableSchedules.length > 0) { + const availableSchedule = randomItem(availableSchedules) + availableScheduleId = availableSchedule.schedule.id + selectedThemeId = availableSchedule.theme.id + } + } + } + }) + + if (!availableScheduleId) { + console.log("이용 가능한 일정 없음.") + return; + } + + group(`일정 Holding 및 테마 정보 조회 -> 예약 과정중 첫 페이지의 작업 완료`, function () { + const holdRes = http.post(`${BASE_URL}/schedules/${availableScheduleId}/hold`, null, getHeaders(accessToken)) + + if (check(holdRes, { '일정 점유 성공': (r) => r.status === 200 })) { + const themeInfoRes = http.get(`${BASE_URL}/themes/${selectedThemeId}`) + if (themeInfoRes.status !== 200) { + throw new Error("테마 상세 조회 실패") + } + selectedThemeInfo = parseIdToString(themeInfoRes).data + } + }) + + let isPendingReservationCreated = false + + group(`예약 정보 입력 페이지`, function () { + let userName, userContact + group(`회원 연락처 조회`, function () { + const userContactRes = http.get(`${BASE_URL}/users/contact`, getHeaders(accessToken)) + + if (!check(userContactRes, { '회원 연락처 조회 성공': (r) => r.status === 200 })) { + throw new Error("회원 연락처 조회 과정에서 예외 발생") + } + + const responseBody = JSON.parse(userContactRes.body).data + userName = responseBody.name + userContact = responseBody.phone + }) + + // 20%의 유저는 예약 정보 입력창에서 나감 => 배치의 자동 활성화 테스트 + if (Math.random() <= 0.2) { + return + } + + sleep(20) + + group(`예약 정보 입력 및 Pending 예약 생성`, function () { + const requirement = `${selectedThemeInfo.name}을 잘부탁드려요!` + const participants = randomIntBetween(selectedThemeInfo.minParticipants, selectedThemeInfo.maxParticipants) + totalAmount = Math.round(participants * selectedThemeInfo.price) + + const payload = JSON.stringify({ + scheduleId: availableScheduleId, + reserverName: userName, + reserverContact: userContact, + participantCount: participants, + requirement: requirement + }) + + const pendingReservationCreateRes = http.post(`${BASE_URL}/reservations/pending`, payload, getHeaders(accessToken)) + const responseBody = parseIdToString(pendingReservationCreateRes) + + if (pendingReservationCreateRes.status !== 200) { + const errorCode = responseBody.code + const errorMessage = responseBody.message + + throw new Error(`Pending 예약 중 실패: code=${errorCode}, message=${errorMessage}`) + } + + reservationId = responseBody.data.id + isPendingReservationCreated = true + }) + }) + + if (!isPendingReservationCreated) { + console.log("회원의 예약 정보 입력 중 페이지 이탈") + return; + } + + group(`결제 및 예약 확정`, function () { + // 20%의 유저는 결제 화면에서 나감 => 배치의 자동 만료 처리 테스트 + if (Math.random() <= 0.2) { + console.log("결제 페이지에서의 이탈") + return + } + + const paymentKey = generateRandomBase64String(64) + const orderId = generateRandomBase64String(25) + + const payload = JSON.stringify({ + paymentKey: paymentKey, + orderId: orderId, + amount: totalAmount, + }) + + let trial = 0 + let isConfirmed = false + while (trial < 2) { + sleep(30) + const confirmOrderRes = http.post(`${BASE_URL}/orders/${reservationId}/confirm`, payload, getHeaders(accessToken)) + + if (check(confirmOrderRes, { '예약 확정 성공': (r) => r.status === 200 })) { + isConfirmed = true + break + } + + const errorResponse = JSON.parse(confirmOrderRes.body) + console.log(`예약 확정 실패: message=${errorResponse.message}`) + trial = errorResponse.trial + 1 + } + + // 예약이 확정되었으면 종료, 아니면 임시 확정 + if (isConfirmed) { + return + } + + sleep(10) + const temporalConfirmRes = http.post(`${BASE_URL}/reservations/${reservationId}/confirm`) + if (check(temporalConfirmRes, { '임시 예약 확정 성공': (r) => r.status === 200})) { + return + } + throw new Error('임시 예약 확정 실패') + }) +} diff --git a/test-scripts/create-schedule-scripts.js b/test-scripts/create-schedule-scripts.js new file mode 100644 index 00000000..28f26774 --- /dev/null +++ b/test-scripts/create-schedule-scripts.js @@ -0,0 +1,137 @@ +import http from 'k6/http'; +import {check, sleep} from 'k6'; +import exec from 'k6/execution'; +import {getHeaders, login, parseIdToString} from "./common.js"; +import {randomIntBetween} from 'https://jslib.k6.io/k6-utils/1.2.0/index.js'; + + +const BASE_URL = __ENV.BASE_URL || 'http://localhost:8080'; + +const TOTAL_ITERATIONS = 85212; + +export const options = { + scenarios: { + schedule_creation: { + executor: 'shared-iterations', + vus: 263, + iterations: TOTAL_ITERATIONS, + maxDuration: '10m', + }, + }, + thresholds: { + 'http_req_duration': ['p(95)<3000'], + 'http_req_failed': ['rate<0.1'], + }, +}; + +export function setup() { + console.log('=== Setup: 테스트 데이터 및 작업 목록 준비 중 ==='); + + const themesRes = http.get(`${BASE_URL}/tests/themes`); + const themes = parseIdToString(themesRes).results + + const accountsRes = http.get(`${BASE_URL}/tests/admin/accounts`); + const accounts = JSON.parse(accountsRes.body).results; + + const dates = generateDates(7); + + console.log(`총 매장 수: ${accounts.length}`); + console.log(`총 테마 수: ${themes.length}`); + console.log(`생성 기간: ${dates.length}일`); + + const tasks = []; + for (const account of accounts) { + const loginResult = login(account.account, account.password, 'ADMIN'); + if (loginResult === null) { + console.error(`[Setup] 로그인 실패: ${account.account}`); + continue; + } + const { storeId, accessToken } = loginResult; + + // 5 ~ ${themes.size} 인 random 숫자 생성 + const selectedThemes = selectRandomThemes(themes, randomIntBetween(5, themes.length)); + + for (const theme of selectedThemes) { + for (const date of dates) { + for (const time of theme.times) { + tasks.push({ + storeId, + accessToken, + date, + time: time.startFrom, + themeId: theme.id, + }); + } + } + } + } + + console.log(`총 생성할 스케줄 수(iterations): ${tasks.length}`); + + return { tasks }; +} + +export default function(data) { + // 👈 3. 현재 반복 횟수가 준비된 작업 수를 초과하는지 확인 + const taskIndex = exec.scenario.iterationInTest; + + if (taskIndex >= data.tasks.length) { + // 첫 번째로 이 조건에 도달한 VU가 테스트를 종료 + if (taskIndex === data.tasks.length) { + console.log('모든 스케쥴 생성 완료. 테스트 종료'); + exec.test.abort(); + } + return; + } + const task = data.tasks[taskIndex]; + + if (!task) { + console.log(`[VU ${__VU}] 알 수 없는 오류: task가 없습니다. (index: ${taskIndex})`); + return; + } + + createSchedule(task.storeId, task.accessToken, task); +} + +function createSchedule(storeId, accessToken, schedule) { + const payload = JSON.stringify({ + date: schedule.date, + time: schedule.time, + themeId: schedule.themeId, + }); + const params = getHeaders(accessToken) + const res = http.post(`${BASE_URL}/admin/stores/${storeId}/schedules`, payload, params); + + const success = check(res, {'일정 생성 성공': (r) => r.status === 200 || r.status === 201}); + + if (!success) { + console.error(`일정 생성 실패 [${res.status}]: 매장=${storeId}, ${schedule.date} ${schedule.time} (테마: ${schedule.themeId}) | 응답: ${res.body}`); + } + sleep(5) + + return success; +} + +function generateDates(days) { + const dates = []; + const today = new Date(); + for (let i = 1; i < days; i++) { + const date = new Date(today); + date.setDate(today.getDate() + i); + dates.push(date.toISOString().split('T')[0]); + } + return dates; +} + +function selectRandomThemes(themes, count) { + const shuffled = [...themes].sort(() => 0.5 - Math.random()); + return shuffled.slice(0, Math.min(count, themes.length)); +} + +export function teardown(data) { + if (data.tasks) { + console.log(`\n=== 테스트 완료: 총 ${data.tasks.length}개의 스케줄 생성을 시도했습니다. ===`); + } else { + console.log('\n=== 테스트 완료: setup 단계에서 오류가 발생하여 작업을 실행하지 못했습니다. ==='); + } +}