diff --git a/.github/workflows/apply-issue-template.yml b/.github/workflows/apply-issue-template.yml deleted file mode 100644 index 17f59e95..00000000 --- a/.github/workflows/apply-issue-template.yml +++ /dev/null @@ -1,25 +0,0 @@ -name: Apply Issue Template -on: - issues: - types: [opened] -jobs: - apply-template: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v3 - - - uses: actions/github-script@v6 - with: - github-token: ${{ secrets.GITHUB_TOKEN }} - script: | - const fs = require('fs'); - const issue = context.payload.issue; - const fullTemplate = fs.readFileSync('.github/ISSUE_TEMPLATE/feature-template.md', 'utf8'); - const templateContent = fullTemplate.split('---').slice(2).join('---').trim(); - - await github.rest.issues.update({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: issue.number, - body: templateContent - }); diff --git a/.github/workflows/client-ci-cd.yml b/.github/workflows/client-ci-cd.yml index 7edc0bdd..b91f7c01 100644 --- a/.github/workflows/client-ci-cd.yml +++ b/.github/workflows/client-ci-cd.yml @@ -19,7 +19,7 @@ jobs: - name: Setup Node.js uses: actions/setup-node@v4 with: - node-version: '20' + node-version: '22' - name: Setup pnpm uses: pnpm/action-setup@v3 @@ -61,10 +61,10 @@ jobs: uses: appleboy/ssh-action@v1.0.0 with: host: ${{ secrets.SSH_HOST }} - username: mira + username: root key: ${{ secrets.SSH_PRIVATE_KEY }} script: | - cd /home/mira/web30-stop-troublepainter + cd /root/deploy export DOCKERHUB_USERNAME=${{ secrets.DOCKERHUB_USERNAME }} docker pull ${{ secrets.DOCKERHUB_USERNAME }}/troublepainter-nginx:latest docker compose up -d nginx \ No newline at end of file diff --git a/.github/workflows/server-ci-cd.yml b/.github/workflows/server-ci-cd.yml index fdf15419..24d55196 100644 --- a/.github/workflows/server-ci-cd.yml +++ b/.github/workflows/server-ci-cd.yml @@ -19,7 +19,7 @@ jobs: - name: Setup Node.js uses: actions/setup-node@v4 with: - node-version: '20' + node-version: '22' - name: Setup pnpm uses: pnpm/action-setup@v3 with: @@ -69,10 +69,10 @@ jobs: uses: appleboy/ssh-action@v1.0.0 with: host: ${{ secrets.SSH_HOST }} - username: mira + username: root key: ${{ secrets.SSH_PRIVATE_KEY }} script: | - cd /home/mira/web30-stop-troublepainter + cd /root/deploy export DOCKERHUB_USERNAME=${{ secrets.DOCKERHUB_USERNAME }} docker pull ${{ secrets.DOCKERHUB_USERNAME }}/troublepainter-server:latest docker compose up -d server \ No newline at end of file diff --git a/.gitignore b/.gitignore index c1d7f412..1d0962fd 100644 --- a/.gitignore +++ b/.gitignore @@ -12,6 +12,15 @@ dist dist-ssr *.local +# playwright +playwright-report +test-results +test-user-data-1 +test-user-data-2 +test-user-data-3 +test-user-data-4 +test-user-data-5 + # Editor directories and files .vscode/* !.vscode/extensions.json diff --git a/Dockerfile.nginx b/Dockerfile.nginx index 55efa50a..5c4bf8ae 100644 --- a/Dockerfile.nginx +++ b/Dockerfile.nginx @@ -1,4 +1,4 @@ -FROM node:20-alpine AS builder +FROM node:22-alpine AS builder RUN corepack enable && corepack prepare pnpm@9.12.3 --activate diff --git a/Dockerfile.server b/Dockerfile.server index 8971ea5e..3e7619c7 100644 --- a/Dockerfile.server +++ b/Dockerfile.server @@ -15,7 +15,7 @@ COPY . . RUN pnpm --filter @troublepainter/core build RUN pnpm --filter server build -FROM node:20-alpine AS production +FROM node:22-alpine AS production WORKDIR /app COPY --from=builder /app/pnpm-workspace.yaml ./ diff --git a/README.md b/README.md index 970adbf4..5dee0b2b 100644 --- a/README.md +++ b/README.md @@ -307,24 +307,21 @@ - - - - - + + + + - - - - - + + + + - - - - - + + + +
곽수정윤태연유미라정다솔최선아김성환김준기김진서산
FE
👑 팀장
FE
부팀장
FE, BE
BE 팀장
FE
캔버스 팀장
FE
FE 팀장
BEFEBEFE
diff --git a/client/index.html b/client/index.html index a64e8420..2342cf41 100644 --- a/client/index.html +++ b/client/index.html @@ -1,105 +1,126 @@ - - - - - - - - - - + + + + + + + + + + + - - + + + + - - - - + + + + + - - - - - + + + + + + + - - - - - - - + + + + + + - - - - - - + + + + + + + + + + + + - - - - - - - - - - - - + - + 방해꾼은 못말려 : 그림꾼들의 역습 - 방해꾼은 못말려 : 그림꾼들의 역습 - + + + - + gtag('config', 'G-L747FWX3VY'); + + - \ No newline at end of file + +
+ + + + diff --git a/core/crdt/test/drawing-utils.ts b/core/crdt/test/drawing-utils.ts index 14cadc61..4feb879a 100644 --- a/core/crdt/test/drawing-utils.ts +++ b/core/crdt/test/drawing-utils.ts @@ -1,6 +1,17 @@ import { Page } from '@playwright/test'; import { DrawingFunction } from './test-types'; +let initialSeed = 825347; +const seedMap = new Map(); +function seedRandom(key: any) { + if (!seedMap.has(key)) seedMap.set(key, initialSeed); + let seed = seedMap.get(key); + initialSeed *= 6807; + seed = (seed * 16807) % 2147483647; + seedMap.set(key, seed); + return (seed - 1) / 2147483646; +} + export async function clearCanvas(page: Page): Promise { await page.evaluate(() => { const canvas = document.querySelector('canvas'); @@ -33,146 +44,17 @@ async function drawWithFillMode(page: Page, point: { x: number; y: number }): Pr } export const drawingPatterns: Record = { - // 동일한 사각형 그리기 (fillRect) - identicalByRect: async (page: Page) => { - await page.evaluate(() => { - const canvas = document.querySelector('canvas'); - if (!canvas) throw new Error('Canvas not found'); - const ctx = canvas.getContext('2d'); - if (!ctx) throw new Error('Cannot get 2d context'); - - ctx.beginPath(); - ctx.fillStyle = 'black'; - ctx.fillRect(0, 0, canvas.width, canvas.height); - ctx.stroke(); - }); - }, - - // 전체 50% 채워지는 줄무늬 그리기 (fillRect) - differentByRect: async (page: Page, clientIndex?: number) => { - if (!clientIndex) return; - - await page.evaluate( - ({ index }) => { - const canvas = document.querySelector('canvas'); - if (!canvas) throw new Error('Canvas not found'); - const ctx = canvas.getContext('2d'); - if (!ctx) throw new Error('Cannot get 2d context'); - - const isEvenClient = index % 2 === 0; - ctx.beginPath(); - - if (isEvenClient) { - for (let y = 0; y < canvas.height; y += 20) { - ctx.fillStyle = 'black'; - ctx.fillRect(0, y, canvas.width, 10); - } - } else { - for (let x = 0; x < canvas.width; x += 20) { - ctx.fillStyle = 'black'; - ctx.fillRect(x, 0, 10, canvas.height); - } - } - - ctx.stroke(); - }, - { index: clientIndex }, - ); - }, - - // 동일한 사각형 그리기 (Mouse events) - identicalByMouse: async (page: Page) => { - const canvas = await page.locator('canvas'); - const box = await canvas.boundingBox(); - if (!box) throw new Error('Canvas not found'); - - await page.dispatchEvent('canvas', 'mousedown', { - bubbles: true, - cancelable: true, - clientX: box.x, - clientY: box.y, - }); - - for (let x = 0; x < box.width; x += 10) { - for (let y = 0; y < box.height; y += 10) { - await page.dispatchEvent('canvas', 'mousemove', { - bubbles: true, - cancelable: true, - clientX: box.x + x, - clientY: box.y + y, - }); - await page.waitForTimeout(10); - } - } - - await page.dispatchEvent('canvas', 'mouseup', { - bubbles: true, - cancelable: true, - clientX: box.x + box.width, - clientY: box.y + box.height, - }); - }, - - // 전체 50% 채워지는 줄무늬 그리기 (Mouse events) - differentByMouse: async (page: Page, clientIndex?: number) => { - if (!clientIndex) return; - const canvas = await page.locator('canvas'); - const box = await canvas.boundingBox(); - if (!box) throw new Error('Canvas not found'); - - const isEvenClient = clientIndex % 2 === 0; - - await page.dispatchEvent('canvas', 'mousedown', { - bubbles: true, - cancelable: true, - clientX: box.x, - clientY: box.y, - }); - - if (isEvenClient) { - for (let y = 0; y < box.height; y += 20) { - for (let x = 0; x < box.width; x += 10) { - await page.dispatchEvent('canvas', 'mousemove', { - bubbles: true, - cancelable: true, - clientX: box.x + x, - clientY: box.y + y, - }); - await page.waitForTimeout(10); - } - } - } else { - for (let x = 0; x < box.width; x += 20) { - for (let y = 0; y < box.height; y += 10) { - await page.dispatchEvent('canvas', 'mousemove', { - bubbles: true, - cancelable: true, - clientX: box.x + x, - clientY: box.y + y, - }); - await page.waitForTimeout(10); - } - } - } - - await page.dispatchEvent('canvas', 'mouseup', { - bubbles: true, - cancelable: true, - clientX: box.x + (isEvenClient ? box.width : 20), - clientY: box.y + (isEvenClient ? 20 : box.height), - }); - }, - // 랜덤 드로잉 (Mouse events) randomByMouse: async (page: Page) => { - const canvas = await page.locator('canvas'); + const CANVAS_SELECTOR = 'canvas + canvas'; + const canvas = page.locator(CANVAS_SELECTOR); const box = await canvas.boundingBox(); if (!box) throw new Error('Canvas not found'); // 1. 랜덤 설정 적용 - if (Math.random() > 0.5) await selectRandomColor(page); - if (Math.random() > 0.7) await setRandomLineWidth(page); - if (Math.random() > 0.8) await toggleFillMode(page); + if (seedRandom(page) > 0.5) await selectRandomColor(page); + if (seedRandom(page) > 0.7) await setRandomLineWidth(page); + // if (seedRandom(page) > 0.8) await toggleFillMode(page); const margin = { x: box.width * 0.05, @@ -187,35 +69,35 @@ export const drawingPatterns: Record = { }; // 2. 랜덤 드로잉 패턴 선택 - const patternType = Math.floor(Math.random() * 4); // 0-3까지로 확장 + const patternType = Math.floor(seedRandom(page) * 4); // 0-3까지로 확장 switch (patternType) { case 0: // 단일 선 { const startPoint = { - x: safeArea.x + Math.random() * safeArea.width, - y: safeArea.y + Math.random() * safeArea.height, + x: safeArea.x + seedRandom(page) * safeArea.width, + y: safeArea.y + seedRandom(page) * safeArea.height, }; const endPoint = { - x: safeArea.x + Math.random() * safeArea.width, - y: safeArea.y + Math.random() * safeArea.height, + x: safeArea.x + seedRandom(page) * safeArea.width, + y: safeArea.y + seedRandom(page) * safeArea.height, }; - await page.dispatchEvent('canvas', 'mousedown', { + await canvas.dispatchEvent('mousedown', { bubbles: true, cancelable: true, clientX: startPoint.x, clientY: startPoint.y, }); - await page.dispatchEvent('canvas', 'mousemove', { + await canvas.dispatchEvent('mousemove', { bubbles: true, cancelable: true, clientX: endPoint.x, clientY: endPoint.y, }); - await page.dispatchEvent('canvas', 'mouseup', { + await canvas.dispatchEvent('mouseup', { bubbles: true, cancelable: true, clientX: endPoint.x, @@ -226,12 +108,12 @@ export const drawingPatterns: Record = { case 1: // 여러 점 연결 { - const points = Array.from({ length: Math.floor(Math.random() * 5) + 3 }, () => ({ - x: safeArea.x + Math.random() * safeArea.width, - y: safeArea.y + Math.random() * safeArea.height, + const points = Array.from({ length: Math.floor(seedRandom(page) * 5) + 3 }, () => ({ + x: safeArea.x + seedRandom(page) * safeArea.width, + y: safeArea.y + seedRandom(page) * safeArea.height, })); - await page.dispatchEvent('canvas', 'mousedown', { + await canvas.dispatchEvent('mousedown', { bubbles: true, cancelable: true, clientX: points[0].x, @@ -239,7 +121,7 @@ export const drawingPatterns: Record = { }); for (let i = 1; i < points.length; i++) { - await page.dispatchEvent('canvas', 'mousemove', { + await canvas.dispatchEvent('mousemove', { bubbles: true, cancelable: true, clientX: points[i].x, @@ -248,7 +130,7 @@ export const drawingPatterns: Record = { await page.waitForTimeout(50); } - await page.dispatchEvent('canvas', 'mouseup', { + await canvas.dispatchEvent('mouseup', { bubbles: true, cancelable: true, clientX: points[points.length - 1].x, @@ -265,12 +147,12 @@ export const drawingPatterns: Record = { const points = Array.from({ length: 20 }, (_, i) => { const angle = (i / 20) * Math.PI * 2; return { - x: centerX + Math.cos(angle) * radius * (0.8 + Math.random() * 0.4), - y: centerY + Math.sin(angle) * radius * (0.8 + Math.random() * 0.4), + x: centerX + Math.cos(angle) * radius * (0.8 + seedRandom(page) * 0.4), + y: centerY + Math.sin(angle) * radius * (0.8 + seedRandom(page) * 0.4), }; }); - await page.dispatchEvent('canvas', 'mousedown', { + await canvas.dispatchEvent('mousedown', { bubbles: true, cancelable: true, clientX: points[0].x, @@ -278,7 +160,7 @@ export const drawingPatterns: Record = { }); for (const point of points) { - await page.dispatchEvent('canvas', 'mousemove', { + await canvas.dispatchEvent('mousemove', { bubbles: true, cancelable: true, clientX: point.x, @@ -287,7 +169,7 @@ export const drawingPatterns: Record = { await page.waitForTimeout(20); } - await page.dispatchEvent('canvas', 'mouseup', { + await canvas.dispatchEvent('mouseup', { bubbles: true, cancelable: true, clientX: points[points.length - 1].x, @@ -297,10 +179,11 @@ export const drawingPatterns: Record = { break; case 3: // 채우기 모드로 랜덤한 위치 채우기 + return; { const fillPoint = { - x: safeArea.x + Math.random() * safeArea.width, - y: safeArea.y + Math.random() * safeArea.height, + x: safeArea.x + seedRandom(page) * safeArea.width, + y: safeArea.y + seedRandom(page) * safeArea.height, }; // 채우기 모드 활성화 @@ -313,26 +196,26 @@ export const drawingPatterns: Record = { } // 3. 랜덤하게 되돌리기/다시실행 수행 - if (Math.random() > 0.7) { - await performUndoRedo(page); - } + // if (seedRandom(page) > 0.9) { + // await performUndoRedo(page); + // } }, }; async function selectRandomColor(page: Page): Promise { const colors = ['검정', '분홍', '노랑', '하늘', '회색']; - const randomColor = colors[Math.floor(Math.random() * colors.length)]; - await page.getByLabel(`${randomColor} 색상 선택`).click(); + const randomColor = colors[Math.floor(seedRandom(page) * colors.length)]; + await page.getByLabel(`${randomColor} 색상 선택`).click({ force: true }); } async function setRandomLineWidth(page: Page): Promise { - await page.getByLabel('펜 모드').click(); - const lineWidth = Math.floor(Math.random() * 9) * 2 + 4; // 4-20 사이의 짝수 값 + await page.getByLabel('펜 모드').click({ force: true }); + const lineWidth = Math.floor(seedRandom(page) * 9) * 2 + 4; // 4-20 사이의 짝수 값 await page.getByLabel('선 굵기 조절').fill(lineWidth.toString()); } async function toggleFillMode(page: Page): Promise { - await page.getByLabel('채우기 모드').click(); + await page.getByLabel('채우기 모드').click({ force: true }); } async function performUndoRedo(page: Page): Promise { @@ -345,7 +228,7 @@ async function performUndoRedo(page: Page): Promise { const redoButton = page.getByLabel('다시실행'); const isRedoEnabled = await redoButton.isEnabled(); - if (isRedoEnabled && Math.random() > 0.5) { + if (isRedoEnabled && seedRandom(page) > 0.5) { await redoButton.click(); } } diff --git a/core/crdt/test/random-drawing-performance.spec.ts b/core/crdt/test/random-drawing-performance.spec.ts new file mode 100644 index 00000000..979de627 --- /dev/null +++ b/core/crdt/test/random-drawing-performance.spec.ts @@ -0,0 +1,204 @@ +import { test as base, Page, chromium, BrowserContext } from '@playwright/test'; +import { drawingPatterns } from './drawing-utils'; + +interface TestClient { + page: Page; + context: BrowserContext; + role?: string; + isHost: boolean; +} + +const test = base.extend({}); + +async function setupTestRoom(baseUrl: string): Promise { + const clients: TestClient[] = []; + + const browser = await chromium.launch(); + const contexts = await Promise.all( + Array(5) + .fill(browser) + .map((e) => e.newContext()), + ); + + // 호스트 설정 + const hostPage = await contexts[0].newPage(); + await hostPage.goto(baseUrl); + await hostPage.getByRole('button', { name: '방 만들기' }).click(); + await hostPage.waitForURL('**/lobby/*'); + const roomUrl = hostPage.url(); + + clients.push({ + page: hostPage, + context: contexts[0], + isHost: true, + }); + + // 나머지 클라이언트 접속 + clients.push( + ...(await Promise.all( + contexts.slice(1).map(async (context) => { + const page = await context.newPage(); + await page.goto(roomUrl); + return { + page, + context, + isHost: false, + }; + }), + )), + ); + + // 호스트가 게임 시작 + await clients[0].page.getByRole('button', { name: '게임 시작' }).click(); + await clients[0].page.getByText('곧 게임이 시작됩니다!').waitFor({ state: 'visible' }); + + // 게임 화면으로 전환 + await Promise.all(clients.map((client) => client.page.waitForURL((url) => url.toString().includes('/game/')))); + + // 각 클라이언트의 역할 모달 대기 및 역할 확인 + await Promise.all( + clients.map(async (client) => { + try { + await client.page.waitForSelector('#modal-root > *', { + timeout: 30000, + state: 'visible', + }); + + const painterRole = client.page.locator('#modal-root').getByText('그림꾼', { exact: true }); + const devilRole = client.page.locator('#modal-root').getByText('방해꾼', { exact: true }); + const guesserRole = client.page.locator('#modal-root').getByText('구경꾼', { exact: true }); + + const isPainter = (await painterRole.count()) > 0; + const isDevil = (await devilRole.count()) > 0; + const isGuesser = (await guesserRole.count()) > 0; + + if (isPainter) { + client.role = 'PAINTER'; + await painterRole.click(); + } else if (isDevil) { + client.role = 'DEVIL'; + await devilRole.click(); + } else if (isGuesser) { + client.role = 'GUESSER'; + await guesserRole.click(); + } + + console.log(`Client assigned role: ${client.role}`); + } catch (error) { + console.error(`Modal detection failed for client:`, error); + throw error; + } + }), + ); + + return clients; +} + +test.describe('Game Room Drawing Test', () => { + let clients: TestClient[] = []; + + test.afterEach(async () => { + for (const client of clients) { + try { + if (!client.page.isClosed()) { + await client.page.close(); + } + await client.context.close(); + } catch (error) { + console.error('Cleanup failed:', error); + } + } + clients = []; + }); + + test('Drawing performance test with multiple browsers', async () => { + try { + // 셋업 및 모달 처리 + const TEST_URL = 'http://localhost:5173'; + clients = await setupTestRoom(TEST_URL); + const drawers = clients.filter((client) => ['PAINTER', 'DEVIL'].includes(client.role || '')); + + // 모달 닫힌 후 시작 시간 기록 + const testStartTime = performance.now(); + + // 1단계: 처음 5초 대기 + const waitEndTime = testStartTime + 1000; + console.log('Waiting 5 seconds before drawing...'); + while (performance.now() < waitEndTime) { + await new Promise((resolve) => setTimeout(resolve, 100)); + } + + // 성능 측정 시작 + const cdpSessions = await Promise.all(clients.map((e) => e.context.newCDPSession(e.page))); + await Promise.all(cdpSessions.map((e) => e.send('Emulation.setCPUThrottlingRate', { rate: 2 }))); + console.log('Emulation.setCPUThrottlingRate'); + await Promise.all(cdpSessions.map((e) => e.send('Performance.enable'))); + console.log('Performance.enable'); + + // 2단계: 30초 동안 드로잉 + const drawingTime = 20000; + const drawingStartTime = performance.now(); + console.log('Starting 30 seconds drawing phase...'); + const DRAW_COUNT = 20; + let curDrawCount = 0; + while (curDrawCount < DRAW_COUNT) { + if (performance.now() - drawingStartTime < drawingTime * (curDrawCount / DRAW_COUNT)) continue; + console.log(curDrawCount++); + await Promise.all( + drawers.map(async (drawer) => { + try { + if (!drawer.page.isClosed()) { + await drawingPatterns.randomByMouse(drawer.page); + } + } catch (error) { + console.error(`Drawing failed for ${drawer.role}:`, error); + } + }), + ); + } + + // 성능 측정 종료 + const METRIC_FILTER = [ + 'LayoutCount', + 'RecalcStyleCount', + 'LayoutDuration', + 'RecalcStyleDuration', + 'ScriptDuration', + 'V8CompileDuration', + 'TaskDuration', + 'TaskOtherDuration', + 'DevToolsCommandDuration', + 'ThreadTime', + 'ProcessTime', + 'JSHeapUsedSize', + 'JSHeapTotalSize', + ]; + const compareMetric = METRIC_FILTER.reduce( + (acc, cur) => { + acc[cur] = [0, 0]; + return acc; + }, + {} as Record, + ); + const performanceMetrics = await Promise.all(cdpSessions.map((e) => e.send('Performance.getMetrics'))); + performanceMetrics.forEach((e, index) => + e.metrics.forEach((metric) => { + if (METRIC_FILTER.includes(metric.name)) { + compareMetric[metric.name][clients[index].role === 'GUESSER' ? 1 : 0] += metric.value; + } + }), + ); + Object.values(compareMetric).forEach((e) => { + e[0] /= drawers.length; //DRAWER 3명 + e[1] /= clients.length - drawers.length; //GUESSER 2명 + }); + console.log(JSON.stringify(compareMetric, null, 4)); + + // 테스트 종료 + await Promise.all(clients.map((e) => e.context.close())); + } catch (error) { + console.error('Test failed:', error); + throw error; + } + }); +}); diff --git a/core/crdt/test/random-drawing.test.ts b/core/crdt/test/random-drawing.spec.ts similarity index 100% rename from core/crdt/test/random-drawing.test.ts rename to core/crdt/test/random-drawing.spec.ts diff --git a/core/crdt/test/test-utils.ts b/core/crdt/test/test-utils.ts index 3c8c035e..baf7c0ec 100644 --- a/core/crdt/test/test-utils.ts +++ b/core/crdt/test/test-utils.ts @@ -1,80 +1,13 @@ -import { Browser, expect, Page } from '@playwright/test'; -import { DrawingClient, TEST_CONFIG } from './test-types'; +import { Page } from '@playwright/test'; import { PNG } from 'pngjs'; -import { clearCanvas } from './drawing-utils'; - -export const compareCanvasPixels = async (basePage: Page, targetPage: Page): Promise => { - const getCanvasData = async (page: Page) => { - return await page.evaluate(() => { - const canvas = document.querySelector('canvas'); - if (!canvas) throw new Error('Canvas not found'); - const ctx = canvas.getContext('2d'); - if (!ctx) throw new Error('Could not get 2d context'); - - const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height); - return { - data: Array.from(imageData.data), - width: canvas.width, - height: canvas.height, - }; - }); - }; - - // Promise.all을 사용하여 두 페이지의 데이터를 병렬로 가져오기 - const [pixels1, pixels2] = await Promise.all([getCanvasData(basePage), getCanvasData(targetPage)]); - - // 다른 픽셀 수 계산 - const differentPixels = pixels1.data.reduce((count, pixel, index) => { - if (pixel !== pixels2.data[index]) { - return count + 1; - } - return count; - }, 0); - - const totalPixels = pixels1.data.length; - const diffRatio = differentPixels / totalPixels; - - return diffRatio; -}; - -// 1. 픽셀 데이터 직접 비교 -export const compareByPixelData = async (basePage: Page, targetPage: Page): Promise => { - const getPixelData = async (page: Page) => { - return page.evaluate(() => { - const canvas = document.querySelector('canvas'); - if (!canvas) throw new Error('Canvas not found'); - const ctx = canvas.getContext('2d'); - if (!ctx) throw new Error('Cannot get 2d context'); - - const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height); - return Array.from(imageData.data); - }); - }; - - const [basePixels, targetPixels] = await Promise.all([getPixelData(basePage), getPixelData(targetPage)]); - - let differentPixels = 0; - for (let i = 0; i < basePixels.length; i += 4) { - if ( - basePixels[i] !== targetPixels[i] || - basePixels[i + 1] !== targetPixels[i + 1] || - basePixels[i + 2] !== targetPixels[i + 2] || - basePixels[i + 3] !== targetPixels[i + 3] - ) { - differentPixels++; - } - } - - return differentPixels / (basePixels.length / 4); -}; // 2. PNG 해시값 비교 export const compareByPng = async (basePage: Page, targetPage: Page): Promise => { // 임시 대기 시간 추가 await Promise.all([basePage.waitForTimeout(100), targetPage.waitForTimeout(100)]); - const screenshot1 = await basePage.locator('canvas').screenshot(); - const screenshot2 = await targetPage.locator('canvas').screenshot(); + const screenshot1 = await basePage.locator('canvas:first-child').screenshot(); + const screenshot2 = await targetPage.locator('canvas:first-child').screenshot(); const img1 = PNG.sync.read(screenshot1); const img2 = PNG.sync.read(screenshot2); @@ -103,185 +36,5 @@ export const compareByPng = async (basePage: Page, targetPage: Page): Promise => { - try { - // 기준 스냅샷 생성 및 저장 - await expect(basePage.locator('canvas')).toHaveScreenshot('base-canvas.png'); - // 비교 스냅샷 생성 및 저장 - await expect(targetPage.locator('canvas')).toHaveScreenshot('target-canvas.png'); - - // Playwright의 스냅샷 비교는 자동으로 처리되므로, catch로 결과 분석 - return 0; // 두 스크린샷이 일치 - // eslint-disable-next-line @typescript-eslint/no-explicit-any - } catch (error: any) { - // 에러 메시지에서 차이점 퍼센트 추출 - const match = error.message.match(/(\d+\.\d+)% of all pixels/); - return match ? parseFloat(match[1]) : 100; // 차이점 반환 - } -}; - // 공통 비교 함수 타입 정의 export type CompareFunction = (basePage: Page, targetPage: Page) => Promise; - -// 공통 테스트 실행 함수 -export const runCanvasComparisonTest = async ( - clients: DrawingClient[], - drawingFunction: (page: Page, clientIndex?: number) => Promise, - compareFunction: CompareFunction, - acceptanceCriteria: number, - testMode: 'identical' | 'different', -): Promise => { - console.log(`Starting sequential test with ${clients.length} clients`); - - const diffResults = []; - for (let i = 1; i < clients.length; i++) { - // 1. 첫 번째 클라이언트 그리기 및 결과 저장 - await clearCanvas(clients[0].page); - await drawingFunction(clients[0].page, 0); - const baseCanvas = await clients[0].page.evaluate(() => { - const canvas = document.querySelector('canvas'); - if (!canvas) throw new Error('Canvas not found'); - // 현재 캔버스 상태를 새로운 캔버스에 복사 - const tempCanvas = document.createElement('canvas'); - tempCanvas.width = canvas.width; - tempCanvas.height = canvas.height; - const ctx = tempCanvas.getContext('2d'); - if (!ctx) throw new Error('Canvas context not found'); - ctx.drawImage(canvas, 0, 0); - return tempCanvas.toDataURL(); // base64로 변환 - }); - - // 2. 두 번째 클라이언트 그리기 및 결과 저장 - await clearCanvas(clients[i].page); - await drawingFunction(clients[i].page, i); - const compareCanvas = await clients[i].page.evaluate(() => { - const canvas = document.querySelector('canvas'); - if (!canvas) throw new Error('Canvas not found'); - const tempCanvas = document.createElement('canvas'); - tempCanvas.width = canvas.width; - tempCanvas.height = canvas.height; - const ctx = tempCanvas.getContext('2d'); - if (!ctx) throw new Error('Canvas context not found'); - ctx.drawImage(canvas, 0, 0); - return tempCanvas.toDataURL(); - }); - - // 3. 저장된 결과를 임시 캔버스에 그리고 비교 - await clients[0].page.evaluate((base64Image: string) => { - const canvas = document.querySelector('canvas'); - if (!canvas) throw new Error('Canvas not found'); - const ctx = canvas.getContext('2d'); - if (!ctx) throw new Error('Canvas context not found'); - const img = new Image(); - return new Promise((resolve) => { - img.onload = () => { - ctx.clearRect(0, 0, canvas.width, canvas.height); - ctx.drawImage(img, 0, 0); - resolve(Promise); - }; - img.src = base64Image; - }); - }, baseCanvas); - - await clients[i].page.evaluate((base64Image: string) => { - const canvas = document.querySelector('canvas'); - if (!canvas) throw new Error('Canvas not found'); - const ctx = canvas.getContext('2d'); - if (!ctx) throw new Error('Canvas context not found'); - const img = new Image(); - return new Promise((resolve) => { - img.onload = () => { - ctx.clearRect(0, 0, canvas.width, canvas.height); - ctx.drawImage(img, 0, 0); - resolve(Promise); - }; - img.src = base64Image; - }); - }, compareCanvas); - - const diffRatio = await compareFunction(clients[0].page, clients[i].page); - diffResults.push({ - clientIndex: i, - diffRatio, - }); - } - - // 결과 검증 - diffResults.forEach(({ clientIndex, diffRatio }) => { - console.log(`Client ${clientIndex} comparison:`, { - diffRatio, - acceptanceCriteria, - passes: testMode === 'identical' ? diffRatio <= acceptanceCriteria : diffRatio >= acceptanceCriteria, - }); - - if (testMode === 'identical') { - expect(diffRatio).toBeLessThanOrEqual(acceptanceCriteria); - } else { - expect(diffRatio).toBeGreaterThanOrEqual(acceptanceCriteria); - } - }); -}; - -// 테스트 설정 초기화 함수 -export async function setupSameURL(clientCount: number, browser: Browser): Promise { - const clients: DrawingClient[] = []; - - for (let i = 0; i < clientCount; i++) { - const context = await browser.newContext({ - viewport: TEST_CONFIG.viewport, - }); - const page = await context.newPage(); - - // 테스트 페이지로 접속 - await page.goto(TEST_CONFIG.url); - - // 캔버스 요소가 로드될 때까지 대기 - await page.waitForSelector('canvas'); - - clients.push({ context, page }); - console.log(`Client ${i} connected to test page`); - } - - return clients; -} - -export async function setupDifferentURL(clientCount: number, browser: Browser): Promise { - const clients: DrawingClient[] = []; - - for (let i = 0; i < clientCount; i++) { - const context = await browser.newContext({ - viewport: TEST_CONFIG.viewport, - }); - const page = await context.newPage(); - - // 각 클라이언트마다 다른 URL로 접속 - const uniqueUrl = `${TEST_CONFIG.url}/canvas?id=test-${i}`; - // 또는 완전히 다른 경로도 가능 - // const uniqueUrl = `${TEST_CONFIG.baseUrl}/canvas-${i}`; - - await page.goto(uniqueUrl); - await page.waitForSelector('canvas'); - - clients.push({ context, page }); - console.log(`Client ${i} connected to independent canvas at ${uniqueUrl}`); - } - - return clients; -} - -// 리소스 정리 함수 -export async function cleanupClients(clients: DrawingClient[]) { - for (const client of clients) { - try { - if (client.page && !client.page.isClosed()) { - await client.page.close().catch(() => {}); - } - if (client.context) { - await client.context.close().catch(() => {}); - } - } catch (error) { - console.error('Error closing client:', error); - } - } -} diff --git a/core/playwright.config.ts b/core/playwright.config.ts index ceabbca7..b5f388f6 100644 --- a/core/playwright.config.ts +++ b/core/playwright.config.ts @@ -2,6 +2,7 @@ import { PlaywrightTestConfig, devices } from '@playwright/test'; const config: PlaywrightTestConfig = { testDir: './crdt/test', + testMatch: '**/*.spec.ts', workers: 5, fullyParallel: true, timeout: 180000, diff --git a/nginx.conf b/nginx.conf index 78cbbc02..21fbd542 100644 --- a/nginx.conf +++ b/nginx.conf @@ -1,14 +1,14 @@ server { listen 80; - server_name www.troublepainter.site; + server_name re-troublepainter.kro.kr; return 301 https://$server_name$request_uri; } server { listen 443 ssl; - server_name www.troublepainter.site; + server_name re-troublepainter.kro.kr; - ssl_certificate /etc/letsencrypt/live/www.troublepainter.site/fullchain.pem; - ssl_certificate_key /etc/letsencrypt/live/www.troublepainter.site/privkey.pem; + ssl_certificate /etc/letsencrypt/live/re-troublepainter.kro.kr/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/re-troublepainter.kro.kr/privkey.pem; gzip on; gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 790d0233..edc526dc 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -223,6 +223,9 @@ importers: ioredis: specifier: ^5.4.1 version: 5.4.1 + jest-mock: + specifier: ^29.7.0 + version: 29.7.0 reflect-metadata: specifier: ^0.2.0 version: 0.2.2 @@ -260,6 +263,9 @@ importers: '@types/socket.io': specifier: ^3.0.2 version: 3.0.2 + '@types/socket.io-client': + specifier: ^3.0.0 + version: 3.0.0 '@types/supertest': specifier: ^6.0.0 version: 6.0.2 @@ -287,6 +293,9 @@ importers: prettier: specifier: ^3.0.0 version: 3.3.3 + socket.io-client: + specifier: ^4.8.1 + version: 4.8.1 source-map-support: specifier: ^0.5.21 version: 0.5.21 @@ -5779,7 +5788,7 @@ snapshots: '@jest/console@29.7.0': dependencies: '@jest/types': 29.6.3 - '@types/node': 20.17.6 + '@types/node': 22.9.1 chalk: 4.1.2 jest-message-util: 29.7.0 jest-util: 29.7.0 @@ -5792,14 +5801,14 @@ snapshots: '@jest/test-result': 29.7.0 '@jest/transform': 29.7.0 '@jest/types': 29.6.3 - '@types/node': 20.17.6 + '@types/node': 22.9.1 ansi-escapes: 4.3.2 chalk: 4.1.2 ci-info: 3.9.0 exit: 0.1.2 graceful-fs: 4.2.11 jest-changed-files: 29.7.0 - jest-config: 29.7.0(@types/node@20.17.6)(ts-node@10.9.2(@types/node@20.17.6)(typescript@5.6.3)) + jest-config: 29.7.0(@types/node@22.9.1)(ts-node@10.9.2(@types/node@20.17.6)(typescript@5.6.3)) jest-haste-map: 29.7.0 jest-message-util: 29.7.0 jest-regex-util: 29.6.3 @@ -5864,7 +5873,7 @@ snapshots: '@jest/transform': 29.7.0 '@jest/types': 29.6.3 '@jridgewell/trace-mapping': 0.3.25 - '@types/node': 20.17.6 + '@types/node': 22.9.1 chalk: 4.1.2 collect-v8-coverage: 1.0.2 exit: 0.1.2 @@ -5934,7 +5943,7 @@ snapshots: '@jest/schemas': 29.6.3 '@types/istanbul-lib-coverage': 2.0.6 '@types/istanbul-reports': 3.0.4 - '@types/node': 20.17.6 + '@types/node': 22.9.1 '@types/yargs': 17.0.33 chalk: 4.1.2 @@ -6541,7 +6550,7 @@ snapshots: '@types/connect@3.4.38': dependencies: - '@types/node': 20.17.6 + '@types/node': 22.9.1 '@types/cookie@0.4.1': {} @@ -6549,7 +6558,7 @@ snapshots: '@types/cors@2.8.17': dependencies: - '@types/node': 20.17.6 + '@types/node': 22.9.1 '@types/doctrine@0.0.9': {} @@ -6664,7 +6673,7 @@ snapshots: '@types/send@0.17.4': dependencies: '@types/mime': 1.3.5 - '@types/node': 20.17.6 + '@types/node': 22.9.1 '@types/serve-static@1.15.7': dependencies: @@ -7690,7 +7699,7 @@ snapshots: dependencies: '@types/cookie': 0.4.1 '@types/cors': 2.8.17 - '@types/node': 20.17.6 + '@types/node': 22.9.1 accepts: 1.3.8 base64id: 2.0.0 cookie: 0.7.2 @@ -8930,6 +8939,37 @@ snapshots: - babel-plugin-macros - supports-color + jest-config@29.7.0(@types/node@22.9.1)(ts-node@10.9.2(@types/node@20.17.6)(typescript@5.6.3)): + dependencies: + '@babel/core': 7.26.0 + '@jest/test-sequencer': 29.7.0 + '@jest/types': 29.6.3 + babel-jest: 29.7.0(@babel/core@7.26.0) + chalk: 4.1.2 + ci-info: 3.9.0 + deepmerge: 4.3.1 + glob: 7.2.3 + graceful-fs: 4.2.11 + jest-circus: 29.7.0 + jest-environment-node: 29.7.0 + jest-get-type: 29.6.3 + jest-regex-util: 29.6.3 + jest-resolve: 29.7.0 + jest-runner: 29.7.0 + jest-util: 29.7.0 + jest-validate: 29.7.0 + micromatch: 4.0.8 + parse-json: 5.2.0 + pretty-format: 29.7.0 + slash: 3.0.0 + strip-json-comments: 3.1.1 + optionalDependencies: + '@types/node': 22.9.1 + ts-node: 10.9.2(@types/node@20.17.6)(typescript@5.6.3) + transitivePeerDependencies: + - babel-plugin-macros + - supports-color + jest-diff@29.7.0: dependencies: chalk: 4.1.2 @@ -8964,7 +9004,7 @@ snapshots: dependencies: '@jest/types': 29.6.3 '@types/graceful-fs': 4.1.9 - '@types/node': 20.17.6 + '@types/node': 22.9.1 anymatch: 3.1.3 fb-watchman: 2.0.2 graceful-fs: 4.2.11 @@ -9038,7 +9078,7 @@ snapshots: '@jest/test-result': 29.7.0 '@jest/transform': 29.7.0 '@jest/types': 29.6.3 - '@types/node': 20.17.6 + '@types/node': 22.9.1 chalk: 4.1.2 emittery: 0.13.1 graceful-fs: 4.2.11 @@ -9066,7 +9106,7 @@ snapshots: '@jest/test-result': 29.7.0 '@jest/transform': 29.7.0 '@jest/types': 29.6.3 - '@types/node': 20.17.6 + '@types/node': 22.9.1 chalk: 4.1.2 cjs-module-lexer: 1.4.1 collect-v8-coverage: 1.0.2 @@ -9112,7 +9152,7 @@ snapshots: jest-util@29.7.0: dependencies: '@jest/types': 29.6.3 - '@types/node': 20.17.6 + '@types/node': 22.9.1 chalk: 4.1.2 ci-info: 3.9.0 graceful-fs: 4.2.11 @@ -9131,7 +9171,7 @@ snapshots: dependencies: '@jest/test-result': 29.7.0 '@jest/types': 29.6.3 - '@types/node': 20.17.6 + '@types/node': 22.9.1 ansi-escapes: 4.3.2 chalk: 4.1.2 emittery: 0.13.1 diff --git a/server/docker-compose.yml b/server/docker-compose.yml new file mode 100644 index 00000000..cc43120e --- /dev/null +++ b/server/docker-compose.yml @@ -0,0 +1,6 @@ +services: + redis: + image: redis:latest + container_name: redis_test + ports: + - "6379:6379" \ No newline at end of file diff --git a/server/package.json b/server/package.json index 93f97d4f..a4514173 100644 --- a/server/package.json +++ b/server/package.json @@ -29,6 +29,7 @@ "@troublepainter/core": "workspace:*", "axios": "^1.7.7", "ioredis": "^5.4.1", + "jest-mock": "^29.7.0", "reflect-metadata": "^0.2.0", "rxjs": "^7.8.1", "socket.io": "^4.8.1", @@ -43,6 +44,7 @@ "@types/jest": "^29.5.2", "@types/node": "^20.3.1", "@types/socket.io": "^3.0.2", + "@types/socket.io-client": "^3.0.0", "@types/supertest": "^6.0.0", "@typescript-eslint/eslint-plugin": "^8.0.0", "@typescript-eslint/parser": "^8.0.0", @@ -52,6 +54,7 @@ "ioredis-mock": "^8.9.0", "jest": "^29.5.0", "prettier": "^3.0.0", + "socket.io-client": "^4.8.1", "source-map-support": "^0.5.21", "supertest": "^7.0.0", "ts-jest": "^29.1.0", @@ -75,6 +78,9 @@ "**/*.(t|j)s" ], "coverageDirectory": "../coverage", - "testEnvironment": "node" + "testEnvironment": "node", + "moduleNameMapper": { + "^src/(.*)$": "/$1" + } } } diff --git a/server/src/chat/chat.gateway.spec.ts b/server/src/chat/chat.gateway.spec.ts index 34daca94..9c3cf3a0 100644 --- a/server/src/chat/chat.gateway.spec.ts +++ b/server/src/chat/chat.gateway.spec.ts @@ -1,18 +1,99 @@ import { Test, TestingModule } from '@nestjs/testing'; import { ChatGateway } from './chat.gateway'; +import { ChatService } from './chat.service'; +import { BadRequestException, PlayerNotFoundException, RoomNotFoundException } from 'src/exceptions/game.exception'; +import { Socket } from 'socket.io'; describe('ChatGateway', () => { let gateway: ChatGateway; + let mockSocket: Partial; + + const mockChatService = { + existsRoom: jest.fn(), + existsPlayer: jest.fn(), + sendMessage: jest.fn(), + }; beforeEach(async () => { const module: TestingModule = await Test.createTestingModule({ - providers: [ChatGateway], + providers: [ChatGateway, { provide: ChatService, useValue: mockChatService }], }).compile(); - gateway = module.get(ChatGateway); + gateway = module.get(ChatGateway); + + /** + * as unknown as 를 이용해 타입을 먼저 unknown으로 바꾼 다음, + * 원하는 타입에 type assertion을 한다. + */ + mockSocket = { + handshake: { auth: { roomId: 'room1', playerId: 'player1' } }, + data: {}, + join: jest.fn(), + to: jest.fn().mockReturnThis(), + emit: jest.fn(), + } as unknown as Socket; + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('handleConnection 테스트', () => { + it('roomId가 null일 때 BadRequestException을 발생', () => { + mockSocket.handshake.auth = { roomId: null }; + + expect(() => gateway.handleConnection(mockSocket as Socket)).toThrow(BadRequestException); + }); + + it('playerId가 null일 때 BadRequestException을 발생', () => { + mockSocket.handshake.auth = { playerId: null }; + + expect(() => gateway.handleConnection(mockSocket as Socket)).toThrow(BadRequestException); + }); + + it('room이 존재하지 않을 때 RoomNotFoundException을 발생', () => { + mockChatService.existsRoom.mockReturnValue(false); + + expect(() => gateway.handleConnection(mockSocket as Socket)).toThrow(RoomNotFoundException); + expect(mockChatService.existsRoom).toHaveBeenCalled(); + }); + + it('플레이어가 룸에 존재하지 않을 때 PlayerNotFoundException을 발생', () => { + mockChatService.existsRoom.mockReturnValue(true); + mockChatService.existsPlayer.mockReturnValue(false); + + expect(() => gateway.handleConnection(mockSocket as Socket)).toThrow(PlayerNotFoundException); + expect(mockChatService.existsPlayer).toHaveBeenCalled(); + }); + + it('플레이어와 방이 정상적으로 할당되어 있을 때', () => { + mockChatService.existsRoom.mockReturnValue(true); + mockChatService.existsPlayer.mockReturnValue(true); + + gateway.handleConnection(mockSocket as Socket); + + expect(mockSocket.join).toHaveBeenCalled(); + expect(mockSocket.data).toEqual({ roomId: 'room1', playerId: 'player1' }); + }); }); - it('should be defined', () => { - expect(gateway).toBeDefined(); + describe('handleSendMessage 테스트', () => { + it('데이터가 없을 때 BadRequestException을 발생', async () => { + mockSocket.data = {}; + + await expect(gateway.handleSendMessage(mockSocket as Socket, { message: 'hello world' })).rejects.toThrow( + BadRequestException, + ); + }); + + it('정상적으로 메시지를 발신할 수 있을 때', async () => { + mockSocket.data = { roomId: 'room1', playerId: 'player1' }; + mockChatService.sendMessage.mockResolvedValue({ message: 'hello world', sender: 'player1' }); + + await gateway.handleSendMessage(mockSocket as Socket, { message: 'hello world' }); + + expect(mockChatService.sendMessage).toHaveBeenCalled(); + expect(mockSocket.to('room1').emit).toHaveBeenCalled(); + }); }); }); diff --git a/server/src/chat/chat.gateway.ts b/server/src/chat/chat.gateway.ts index 08c7f10a..a7f26d83 100644 --- a/server/src/chat/chat.gateway.ts +++ b/server/src/chat/chat.gateway.ts @@ -3,7 +3,7 @@ import { Server, Socket } from 'socket.io'; import { ChatService } from './chat.service'; import { UseFilters } from '@nestjs/common'; import { WsExceptionFilter } from 'src/filters/ws-exception.filter'; -import { BadRequestException, PlayerNotFoundException, RoomNotFoundException } from 'src/exceptions/game.exception'; +import { BadRequestException } from 'src/exceptions/game.exception'; @WebSocketGateway({ cors: '*', @@ -16,16 +16,37 @@ export class ChatGateway { @WebSocketServer() server: Server; - handleConnection(client: Socket) { + async handleConnection(client: Socket) { const roomId = client.handshake.auth.roomId; const playerId = client.handshake.auth.playerId; - if (!roomId || !playerId) throw new BadRequestException('Room ID and Player ID are required'); + if (!roomId || !playerId) { + client.emit('error', { + code: 4000, + message: 'Room ID and Player ID are required', + }); + client.disconnect(); + return; + } - const roomExists = this.chatService.existsRoom(roomId); - if (!roomExists) throw new RoomNotFoundException('Room not found'); - const playerExists = this.chatService.existsPlayer(roomId, playerId); - if (!playerExists) throw new PlayerNotFoundException('Player not found in room'); + const roomExists = await this.chatService.existsRoom(roomId); + if (!roomExists) { + client.emit('error', { + code: 6005, + message: 'Room not found', + }); + client.disconnect(); + return; + } + const playerExists = await this.chatService.existsPlayer(roomId, playerId); + if (!playerExists) { + client.emit('error', { + code: 6006, + message: 'Player not found in room', + }); + client.disconnect(); + return; + } client.data.roomId = roomId; client.data.playerId = playerId; diff --git a/server/src/chat/chat.module.spec.ts b/server/src/chat/chat.module.spec.ts new file mode 100644 index 00000000..bc9367f1 --- /dev/null +++ b/server/src/chat/chat.module.spec.ts @@ -0,0 +1,20 @@ +import { ChatModule } from './chat.module'; +import { Test } from '@nestjs/testing'; +import { ChatService } from './chat.service'; +import { ChatRepository } from './chat.repository'; +import { RedisModule } from '../redis/redis.module'; +import { ChatGateway } from './chat.gateway'; + +describe('ChatModule', () => { + it('컴파일 확인', async () => { + const module = await Test.createTestingModule({ + imports: [ChatModule], + }).compile(); + + expect(module).toBeDefined(); + expect(module.get(ChatService)).toBeInstanceOf(ChatService); + expect(module.get(ChatRepository)).toBeInstanceOf(ChatRepository); + expect(module.get(ChatGateway)).toBeInstanceOf(ChatGateway); + expect(module.get(RedisModule)).toBeInstanceOf(RedisModule); + }); +}); diff --git a/server/src/chat/chat.repository.spec.ts b/server/src/chat/chat.repository.spec.ts new file mode 100644 index 00000000..a3d5fe17 --- /dev/null +++ b/server/src/chat/chat.repository.spec.ts @@ -0,0 +1,154 @@ +import { ChatRepository } from './chat.repository'; +import { Test, TestingModule } from '@nestjs/testing'; +import { RedisService } from '../redis/redis.service'; +import { describe } from 'node:test'; +import { PlayerRole } from '../common/enums/game.status.enum'; + +describe('ChatRepository', () => { + let chatRepository: ChatRepository; + + const mockRedisService = { + hgetall: jest.fn(), + exists: jest.fn(), + }; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + ChatRepository, + { + provide: RedisService, + useValue: mockRedisService, + }, + ], + }).compile(); + + chatRepository = module.get(ChatRepository); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('getPlayer 테스트', () => { + it('플레이어 데이터가 없을 때 null을 리턴', async () => { + mockRedisService.hgetall.mockResolvedValue(null); + + const result = await chatRepository.getPlayer('room1', 'player1'); + expect(mockRedisService.hgetall).toHaveBeenCalled(); + expect(result).toBeNull(); + }); + + it('플레이어 데이터 중 role이 없을 때 null을 리턴', async () => { + const player = { + role: '', + userImg: 'test', + score: 15, + }; + + mockRedisService.hgetall.mockResolvedValue(player); + + const result = await chatRepository.getPlayer('room1', 'player1'); + expect(result).toEqual({ + ...player, + role: null, + profileImage: player.userImg, + }); + }); + + it('플레이어 데이터 중 이미지가 없을 때 profileImage가 null을 리턴', async () => { + const player = { + role: PlayerRole.GUESSER, + userImg: '', + score: 15, + }; + + mockRedisService.hgetall.mockResolvedValue(player); + + const result = await chatRepository.getPlayer('room1', 'player1'); + expect(result).toEqual({ + ...player, + profileImage: null, + }); + }); + + it('플레이어 데이터 중 이미지가 있을 때 profileImage가 userImg와 동일하게 리턴', async () => { + const player = { + role: PlayerRole.GUESSER, + userImg: 'test', + score: 15, + }; + + mockRedisService.hgetall.mockResolvedValue(player); + + const result = await chatRepository.getPlayer('room1', 'player1'); + expect(result).toEqual({ + ...player, + profileImage: player.userImg, + }); + }); + + it('플레이어 데이터에서 score 임계값을 처리', async () => { + const testCases = [ + { inputScore: '15', expectedScore: 15 }, + { inputScore: '-5', expectedScore: -5 }, + { inputScore: '', expectedScore: 0 }, + { inputScore: null, expectedScore: 0 }, + { inputScore: 'abc', expectedScore: 0 }, + ]; + + for (const testCase of testCases) { + const player = { + role: PlayerRole.GUESSER, + userImg: 'test', + score: testCase.inputScore, + }; + + mockRedisService.hgetall.mockResolvedValue(player); + + const result = await chatRepository.getPlayer('room1', 'player1'); + expect(result).toEqual({ + ...player, + profileImage: player.userImg, + score: testCase.expectedScore, + }); + } + }); + }); + + describe('existsRoom 테스트', () => { + it('방이 존재하지 않을 때 false를 리턴', async () => { + mockRedisService.exists.mockResolvedValue(0); + + const result = await chatRepository.existsRoom('room1'); + expect(mockRedisService.exists).toHaveBeenCalled(); + expect(result).toBe(false); + }); + }); + + it('방이 존재할 때 true를 리턴', async () => { + mockRedisService.exists.mockResolvedValue(1); + + const result = await chatRepository.existsRoom('room1'); + expect(mockRedisService.exists).toHaveBeenCalled(); + expect(result).toBe(true); + }); + + describe('existsPlayer 테스트', () => { + it('플레이어가 존재하지 않을 때 false를 리턴', async () => { + mockRedisService.exists.mockResolvedValue(0); + + const result = await chatRepository.existsPlayer('room1', 'player1'); + expect(mockRedisService.exists).toHaveBeenCalled(); + expect(result).toBe(false); + }); + + it('플레이어가 존재할 때 true를 리턴한다', async () => { + mockRedisService.exists.mockResolvedValue(1); + + const result = await chatRepository.existsPlayer('room1', 'player1'); + expect(mockRedisService.exists).toHaveBeenCalled(); + expect(result).toBe(true); + }); + }); +}); diff --git a/server/src/chat/chat.repository.ts b/server/src/chat/chat.repository.ts index 3b3a4a8e..04a74aec 100644 --- a/server/src/chat/chat.repository.ts +++ b/server/src/chat/chat.repository.ts @@ -24,7 +24,7 @@ export class ChatRepository { } async existsPlayer(roomId: string, playerId: string) { - const exists = await this.redisService.exists(`room:${roomId}:player:${playerId}`); + const exists = await this.redisService.exists(`room:${roomId}:players:${playerId}`); return exists === 1; } } diff --git a/server/src/chat/chat.service.spec.ts b/server/src/chat/chat.service.spec.ts index 110cd7d3..a14b32c2 100644 --- a/server/src/chat/chat.service.spec.ts +++ b/server/src/chat/chat.service.spec.ts @@ -1,18 +1,101 @@ import { Test, TestingModule } from '@nestjs/testing'; import { ChatService } from './chat.service'; +import { ChatRepository } from './chat.repository'; +import { describe } from 'node:test'; +import { BadRequestException, PlayerNotFoundException } from '../exceptions/game.exception'; +import { Player } from '../common/types/game.types'; +import { PlayerRole, PlayerStatus } from '../common/enums/game.status.enum'; describe('ChatService', () => { - let service: ChatService; + let chatService: ChatService; + + const mockChatRepository = { + getPlayer: jest.fn(), + existsRoom: jest.fn(), + existsPlayer: jest.fn(), + }; beforeEach(async () => { const module: TestingModule = await Test.createTestingModule({ - providers: [ChatService], + providers: [ChatService, { provide: ChatRepository, useValue: mockChatRepository }], }).compile(); - service = module.get(ChatService); + chatService = module.get(ChatService); + }); + + afterEach(() => { + jest.clearAllMocks(); }); - it('should be defined', () => { - expect(service).toBeDefined(); + describe('sendMessage 테스트', async () => { + it('메시지가 공백일 때 BadRequestException 발생', async () => { + await expect(async () => { + await chatService.sendMessage('room1', 'player1', ''); + }).rejects.toThrow(BadRequestException); + }); + + it('플레이어가 존재하지 않을 때 PlayerNotFoundException 발생', async () => { + mockChatRepository.getPlayer.mockResolvedValue(null); + + await expect(async () => { + await chatService.sendMessage('room1', 'player1', 'hello world'); + }).rejects.toThrow(PlayerNotFoundException); + + // 에러 캐치로 인해 순서를 바꿔서 배치 + expect(mockChatRepository.getPlayer).toHaveBeenCalled(); + }); + + it('플레이어가 정상적으로 존재할 때', async () => { + const player: Player = { + playerId: 'player1', + role: PlayerRole.GUESSER, + status: PlayerStatus.PLAYING, + nickname: 'player', + profileImage: null, + score: 10, + }; + mockChatRepository.getPlayer.mockResolvedValue(player); + + const result = await chatService.sendMessage('room1', 'player1', 'hello world'); + + expect(result).toBeDefined(); + expect(mockChatRepository.getPlayer).toHaveBeenCalled(); + }); + }); + + describe('existsRoom 테스트', () => { + it('존재하는 방일 때 true를 리턴', async () => { + mockChatRepository.existsRoom.mockResolvedValue(true); + + const result = await chatService.existsRoom('room1'); + expect(result).toBe(true); + expect(mockChatRepository.existsRoom).toHaveBeenCalledWith('room1'); + }); + + it('존재하지 않는 방일 때 false를 리턴', async () => { + mockChatRepository.existsRoom.mockResolvedValue(false); + + const result = await chatService.existsRoom('room2'); + expect(result).toBe(false); + expect(mockChatRepository.existsRoom).toHaveBeenCalledWith('room2'); + }); + }); + + describe('existsPlayer 테스트', () => { + it('존재하는 플레이어일 때 true를 리턴', async () => { + mockChatRepository.existsPlayer.mockResolvedValue(true); + + const result = await chatService.existsPlayer('room1', 'player1'); + expect(result).toBe(true); + expect(mockChatRepository.existsPlayer).toHaveBeenCalledWith('room1', 'player1'); + }); + + it('존재하지 않는 플레이어일 때 false를 리턴', async () => { + mockChatRepository.existsPlayer.mockResolvedValue(false); + + const result = await chatService.existsPlayer('room1', 'player2'); + expect(result).toBe(false); + expect(mockChatRepository.existsPlayer).toHaveBeenCalledWith('room1', 'player2'); + }); }); }); diff --git a/server/src/drawing/drawing.gateway.e2e.spec.ts b/server/src/drawing/drawing.gateway.e2e.spec.ts new file mode 100644 index 00000000..229c4e46 --- /dev/null +++ b/server/src/drawing/drawing.gateway.e2e.spec.ts @@ -0,0 +1,192 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { ConfigService } from '@nestjs/config'; +import { INestApplication } from '@nestjs/common'; +import { io, Socket } from 'socket.io-client'; +import { RedisService } from '../redis/redis.service'; +import { DrawingGateway } from './drawing.gateway'; +import { DrawingService } from './drawing.service'; +import { DrawingRepository } from './drawing.repository'; + +describe('DrawingGateway e2e 테스트', () => { + let app: INestApplication; + let redisService: RedisService; + let clientA: Socket; + + const URL = 'http://localhost:3001/socket.io/drawing'; + + const mockConfigService = { + provide: ConfigService, + useValue: { + get: jest.fn().mockImplementation((key: string) => { + if (key === 'REDIS_HOST') return 'localhost'; + if (key === 'REDIS_PORT') return '6379'; + return null; + }), + }, + }; + + beforeAll(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [DrawingGateway, DrawingService, DrawingRepository, RedisService, mockConfigService], + }).compile(); + + app = module.createNestApplication(); + redisService = module.get(RedisService); + + // 테스트용 서버 실행 + await app.listen(3001); + }); + + beforeEach(async () => { + await redisService.hset('room:room1', { roomId: 'room1' }); + await redisService.hset('room:room1:player:player1', { playerId: 'player1' }); + + clientA = io(URL, { + auth: { + roomId: 'room1', + playerId: 'player1', + }, + }); + + await new Promise((resolve) => { + clientA.on('connect', resolve); + }); + }); + + afterEach(async () => { + clientA.close(); + await redisService.flushAll(); + }); + + afterAll(async () => { + await app.close(); + redisService.quit(); + }); + + describe('handleConnection', () => { + it('roomId 또는 playerId의 값이 존재하지 않는 경우 "Room ID and Player ID are required" 에러가 발생한다.', async () => { + const authInfo = [ + { roomId: '', playerId: '' }, + { roomId: 'room1', playerId: '' }, + { roomId: '', playerId: 'player1' }, + ]; + + for (const auth of authInfo) { + const socket = io(URL, { + auth, + }); + + await new Promise((resolve) => { + socket.on('error', (e) => { + expect(e.code).toBe(4000); + expect(e.message).toBe('Room ID and Player ID are required'); + resolve(); + }); + }); + + socket.close(); + } + }); + + it('room이 redis 내에 존재하지 않는 경우 "Room not found" 에러가 발생한다.', async () => { + const invalidRoomClient = io(URL, { + auth: { + roomId: 'failed-room', + playerId: 'player1', + }, + }); + + await new Promise((resolve) => { + invalidRoomClient.on('error', (e) => { + expect(e.code).toBe(6005); + expect(e.message).toBe('Room not found'); + resolve(); + }); + }); + + invalidRoomClient.close(); + }); + + it('player가 redis 내에 존재하지 않는 경우 "Player not found in room" 에러가 발생한다.', async () => { + const invalidPlayerClient = io(URL, { + auth: { + roomId: 'room1', + playerId: 'failed-player', + }, + }); + + await new Promise((resolve) => { + invalidPlayerClient.on('error', (e) => { + expect(e.code).toBe(6006); + expect(e.message).toBe('Player not found in room'); + resolve(); + }); + }); + + invalidPlayerClient.close(); + }); + + it('room과 player가 정상적으로 존재하는 경우 정상적으로 연결된다.', async () => { + expect(clientA.connected).toBe(true); + }); + }); + + describe('handleDraw', () => { + it('roomId 값이 존재하지 않는 경우 "Room ID is required" 에러가 발생한다.', async () => { + const invalidRoomClient = io(URL, { + auth: { + roomId: 'failed-room', + playerId: 'player1', + }, + }); + + invalidRoomClient.on('connect_error', (e) => { + expect(e.message).toBe('Room ID is required'); + invalidRoomClient.close(); + }); + }); + + it('정상적으로 그림이 그려지는 경우', async () => { + const drawingData = { + pos: 56, + fillColor: { R: 0, G: 0, B: 0, A: 0 }, + }; + + /** + * client.to(roomId).emit('drawUpdated') 이므로 + * 그림 그린 사람을 제외하고 다른 사람이 존재해야 이벤트를 제대로 수신받는지 확인이 가능함 + * 이를 위해 clientB를 생성 + */ + await redisService.hset('room:room1:player:player2', { playerId: 'player2' }); + + const clientB = io(URL, { + auth: { + roomId: 'room1', + playerId: 'player2', + }, + }); + + // clientB가 연결이 완료될 때까지 기다림 + await new Promise((resolve) => { + clientB.on('connect', resolve); + }); + + // drawUpdated 이벤트 수신을 위한 Promise 생성 + const drawUpdatePromise = new Promise((resolve) => { + clientB.on('drawUpdated', (data) => { + expect(data).toEqual({ + playerId: 'player1', + drawingData: drawingData, + }); + resolve(); + }); + }); + + // clientA가 실제로 이벤트를 발생시킴 + clientA.emit('draw', { drawingData }); + + await drawUpdatePromise; + clientB.close(); + }); + }); +}); diff --git a/server/src/drawing/drawing.gateway.integration.spec.ts b/server/src/drawing/drawing.gateway.integration.spec.ts new file mode 100644 index 00000000..729026d0 --- /dev/null +++ b/server/src/drawing/drawing.gateway.integration.spec.ts @@ -0,0 +1,130 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { ConfigService } from '@nestjs/config'; +import { Socket } from 'socket.io'; +import { RedisService } from '../redis/redis.service'; +import { DrawingGateway } from './drawing.gateway'; +import { DrawingService } from './drawing.service'; +import { DrawingRepository } from './drawing.repository'; +import { BadRequestException, PlayerNotFoundException, RoomNotFoundException } from '../exceptions/game.exception'; + +describe('DrawingGateway 통합 테스트', () => { + let gateway: DrawingGateway; + let service: DrawingService; + let redisService: RedisService; + let client: Socket; + + beforeAll(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + DrawingGateway, + DrawingService, + DrawingRepository, + RedisService, + { + provide: ConfigService, + useValue: { + get: jest.fn().mockImplementation((key: string) => { + if (key === 'REDIS_HOST') return 'localhost'; + if (key === 'REDIS_PORT') return '6379'; + return null; + }), + }, + }, + ], + }).compile(); + + gateway = module.get(DrawingGateway); + service = module.get(DrawingService); + redisService = module.get(RedisService); + }); + + beforeEach(async () => { + client = { + handshake: { + auth: { roomId: '', playerId: '' }, + }, + data: {}, + join: jest.fn(), + to: jest.fn().mockReturnValue({ + emit: jest.fn(), + }), + } as any; + + await redisService.hset('room:room1', { roomId: 'room1' }); + await redisService.hset('room:room1:player:player1', { playerId: 'player1' }); + }); + + // 테스트가 수행될 때마다 DB를 비워줌 + afterEach(async () => { + await redisService.flushAll(); + }); + + // 테스트가 종료되면 Redis를 종료 + afterAll(() => { + redisService.quit(); + }); + + describe('handleConnection', () => { + it('roomId 또는 playerId의 값이 존재하지 않는 경우', async () => { + const authInfo = [ + { roomId: '', playerId: '' }, + { roomId: 'room1', playerId: '' }, + { roomId: '', playerId: 'player1' }, + ]; + + for (const auth of authInfo) { + client.handshake.auth = auth; + await expect(gateway.handleConnection(client)).rejects.toThrowError( + new BadRequestException('Room ID and Player ID are required'), + ); + } + }); + + it('room이 redis 내에 존재하지 않는 경우', async () => { + client.handshake.auth = { roomId: 'failed-room', playerId: 'player1' }; + + await expect(gateway.handleConnection(client)).rejects.toThrowError(new RoomNotFoundException('Room not found')); + }); + + it('player가 redis 내에 존재하지 않는 경우', async () => { + client.handshake.auth = { roomId: 'room1', playerId: 'failed-player' }; + + await expect(gateway.handleConnection(client)).rejects.toThrowError( + new PlayerNotFoundException('Player not found in room'), + ); + }); + + it('room과 player가 정상적으로 존재하는 경우', async () => { + client.handshake.auth = { roomId: 'room1', playerId: 'player1' }; + await gateway.handleConnection(client); + + expect(client.join).toHaveBeenCalledWith('room1'); + expect(client.data.roomId).toBe('room1'); + expect(client.data.playerId).toBe('player1'); + }); + }); + + describe('handleDraw', () => { + it('roomId 값이 존재하지 않는 경우', async () => { + client.data = {}; + const data = { drawingData: {} }; + + await expect(gateway.handleDraw(client, data)).rejects.toThrowError( + new BadRequestException('Room ID is required'), + ); + }); + + it('정상적으로 그림이 그려지는 경우', async () => { + client.data = { roomId: 'room1', playerId: 'player1' }; + const data = { drawingData: { pos: 56, fillColor: { R: 0, G: 0, B: 0, A: 0 } } }; + + await gateway.handleDraw(client, data); + + expect(client.to).toHaveBeenCalledWith('room1'); + expect(client.to('room1').emit).toHaveBeenCalledWith('drawUpdated', { + playerId: 'player1', + drawingData: data.drawingData, + }); + }); + }); +}); diff --git a/server/src/drawing/drawing.gateway.spec.ts b/server/src/drawing/drawing.gateway.spec.ts index ee5ae08a..024bd979 100644 --- a/server/src/drawing/drawing.gateway.spec.ts +++ b/server/src/drawing/drawing.gateway.spec.ts @@ -1,18 +1,110 @@ import { Test, TestingModule } from '@nestjs/testing'; import { DrawingGateway } from './drawing.gateway'; +import { DrawingService } from './drawing.service'; +import { BadRequestException, PlayerNotFoundException, RoomNotFoundException } from '../exceptions/game.exception'; +import { Socket } from 'socket.io'; -describe('DrawingGateway', () => { +describe('DrawingGateway 단위 테스트', () => { let gateway: DrawingGateway; + let service: DrawingService; + let client: Socket; beforeEach(async () => { const module: TestingModule = await Test.createTestingModule({ - providers: [DrawingGateway], + providers: [ + DrawingGateway, + { + provide: DrawingService, + useValue: { + existsRoom: jest.fn(), + existsPlayer: jest.fn(), + }, + }, + ], }).compile(); gateway = module.get(DrawingGateway); + service = module.get(DrawingService); + client = { + handshake: { + auth: { roomId: '', playerId: '' }, + }, + data: {}, + join: jest.fn(), + to: jest.fn().mockReturnValue({ + emit: jest.fn(), + }), + } as any; }); - it('should be defined', () => { - expect(gateway).toBeDefined(); + describe('handleConnection', () => { + it('roomId 또는 playerId의 값이 존재하지 않는 경우', async () => { + const authInfo = [ + { roomId: '', playerId: '' }, + { roomId: 'room1', playerId: '' }, + { roomId: '', playerId: 'player1' }, + ]; + + for (const auth of authInfo) { + client.handshake.auth = auth; + await expect(gateway.handleConnection(client)).rejects.toThrowError( + new BadRequestException('Room ID and Player ID are required'), + ); + } + }); + + it('room이 redis 내에 존재하지 않는 경우', async () => { + client.handshake.auth = { roomId: 'room1', playerId: 'player1' }; + jest.spyOn(service, 'existsRoom').mockResolvedValue(false); + jest.spyOn(service, 'existsPlayer').mockResolvedValue(true); + + await expect(gateway.handleConnection(client)).rejects.toThrowError(new RoomNotFoundException('Room not found')); + }); + + it('player가 redis 내에 존재하지 않는 경우', async () => { + client.handshake.auth = { roomId: 'room1', playerId: 'player1' }; + jest.spyOn(service, 'existsRoom').mockResolvedValue(true); + jest.spyOn(service, 'existsPlayer').mockResolvedValue(false); + + await expect(gateway.handleConnection(client)).rejects.toThrowError( + new PlayerNotFoundException('Player not found in room'), + ); + }); + + it('room과 player가 정상적으로 존재하는 경우', async () => { + client.handshake.auth = { roomId: 'room1', playerId: 'player1' }; + jest.spyOn(service, 'existsRoom').mockResolvedValue(true); + jest.spyOn(service, 'existsPlayer').mockResolvedValue(true); + + await gateway.handleConnection(client); + + expect(client.join).toHaveBeenCalledWith('room1'); + expect(client.data.roomId).toBe('room1'); + expect(client.data.playerId).toBe('player1'); + }); + }); + + describe('handleDraw', () => { + it('roomId 값이 존재하지 않는 경우', async () => { + client.data = {}; + const data = { drawingData: {} }; + + await expect(gateway.handleDraw(client, data)).rejects.toThrowError( + new BadRequestException('Room ID is required'), + ); + }); + + it('정상적으로 그림이 그려지는 경우', async () => { + client.data = { roomId: 'room1', playerId: 'player1' }; + const data = { drawingData: { pos: 56, fillColor: { R: 0, G: 0, B: 0, A: 0 } } }; + + await gateway.handleDraw(client, data); + + expect(client.to).toHaveBeenCalledWith('room1'); + expect(client.to('room1').emit).toHaveBeenCalledWith('drawUpdated', { + playerId: 'player1', + drawingData: data.drawingData, + }); + }); }); }); diff --git a/server/src/drawing/drawing.gateway.ts b/server/src/drawing/drawing.gateway.ts index c369c519..dcb627ce 100644 --- a/server/src/drawing/drawing.gateway.ts +++ b/server/src/drawing/drawing.gateway.ts @@ -8,8 +8,8 @@ import { WebSocketServer, } from '@nestjs/websockets'; import { Server, Socket } from 'socket.io'; -import { BadRequestException, PlayerNotFoundException, RoomNotFoundException } from 'src/exceptions/game.exception'; -import { WsExceptionFilter } from 'src/filters/ws-exception.filter'; +import { BadRequestException } from '../exceptions/game.exception'; +import { WsExceptionFilter } from '../filters/ws-exception.filter'; import { DrawingService } from './drawing.service'; @WebSocketGateway({ @@ -23,16 +23,37 @@ export class DrawingGateway implements OnGatewayConnection { constructor(private readonly drawingService: DrawingService) {} - handleConnection(client: Socket) { + async handleConnection(client: Socket) { const roomId = client.handshake.auth.roomId; const playerId = client.handshake.auth.playerId; - if (!roomId || !playerId) throw new BadRequestException('Room ID and Player ID are required'); + if (!roomId || !playerId) { + client.emit('error', { + code: 4000, + message: 'Room ID and Player ID are required', + }); + client.disconnect(); + return; + } - const roomExists = this.drawingService.existsRoom(roomId); - if (!roomExists) throw new RoomNotFoundException('Room not found'); - const playerExists = this.drawingService.existsPlayer(roomId, playerId); - if (!playerExists) throw new PlayerNotFoundException('Player not found in room'); + const roomExists = await this.drawingService.existsRoom(roomId); + if (!roomExists) { + client.emit('error', { + code: 6005, + message: 'Room not found', + }); + client.disconnect(); + return; + } + const playerExists = await this.drawingService.existsPlayer(roomId, playerId); + if (!playerExists) { + client.emit('error', { + code: 6006, + message: 'Player not found in room', + }); + client.disconnect(); + return; + } client.data.roomId = roomId; client.data.playerId = playerId; diff --git a/server/src/drawing/drawing.repository.spec.ts b/server/src/drawing/drawing.repository.spec.ts new file mode 100644 index 00000000..dea542e1 --- /dev/null +++ b/server/src/drawing/drawing.repository.spec.ts @@ -0,0 +1,63 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { DrawingRepository } from './drawing.repository'; +import { RedisService } from '../redis/redis.service'; + +describe('DrawingRepository 단위 테스트', () => { + let repository: DrawingRepository; + let redisService: RedisService; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + DrawingRepository, + { + provide: RedisService, + useValue: { + exists: jest.fn(), + }, + }, + ], + }).compile(); + + repository = module.get(DrawingRepository); + redisService = module.get(RedisService); + }); + + describe('existsRoom', () => { + it('room이 존재한다면 true 반환', async () => { + jest.spyOn(redisService, 'exists').mockResolvedValue(1); + + const exists = await repository.existsRoom('success'); + + expect(exists).toBe(true); + expect(redisService.exists).toHaveBeenCalledWith('room:success'); + }); + + it('room이 존재하지 않는다면 false 반환', async () => { + jest.spyOn(redisService, 'exists').mockResolvedValue(0); + + const exists = await repository.existsRoom('failed'); + + expect(exists).toBe(false); + }); + }); + + describe('existsPlayer', () => { + it('player가 존재한다면 true 반환', async () => { + jest.spyOn(redisService, 'exists').mockResolvedValue(1); + + const exists = await repository.existsPlayer('success', 'player'); + + expect(exists).toBe(true); + expect(redisService.exists).toHaveBeenCalledWith('room:success:player:player'); + }); + + it('player가 존재하지 않는다면 false 반환', async () => { + jest.spyOn(redisService, 'exists').mockResolvedValue(0); + + const exists = await repository.existsPlayer('failed', 'non'); + + expect(exists).toBe(false); + }); + }); +}); diff --git a/server/src/drawing/drawing.repository.ts b/server/src/drawing/drawing.repository.ts index 817cf9be..4c19baa0 100644 --- a/server/src/drawing/drawing.repository.ts +++ b/server/src/drawing/drawing.repository.ts @@ -1,5 +1,5 @@ import { Injectable } from '@nestjs/common'; -import { RedisService } from 'src/redis/redis.service'; +import { RedisService } from '../redis/redis.service'; @Injectable() export class DrawingRepository { @@ -11,7 +11,7 @@ export class DrawingRepository { } async existsPlayer(roomId: string, playerId: string) { - const exists = await this.redisService.exists(`room:${roomId}:player:${playerId}`); + const exists = await this.redisService.exists(`room:${roomId}:players:${playerId}`); return exists === 1; } } diff --git a/server/src/drawing/drawing.service.spec.ts b/server/src/drawing/drawing.service.spec.ts new file mode 100644 index 00000000..332d8614 --- /dev/null +++ b/server/src/drawing/drawing.service.spec.ts @@ -0,0 +1,62 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { DrawingRepository } from './drawing.repository'; +import { DrawingService } from './drawing.service'; + +describe('DrawingService 단위 테스트', () => { + let repository: DrawingRepository; + let service: DrawingService; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + DrawingService, + { + provide: DrawingRepository, + useValue: { + existsRoom: jest.fn(), + existsPlayer: jest.fn(), + }, + }, + ], + }).compile(); + + repository = module.get(DrawingRepository); + service = module.get(DrawingService); + }); + + describe('existsRoom', () => { + it('room이 존재한다면 true 반환', async () => { + jest.spyOn(repository, 'existsRoom').mockResolvedValue(true); + + const exists = await service.existsRoom('success'); + + expect(exists).toBe(true); + }); + + it('room이 존재하지 않는다면 false 반환', async () => { + jest.spyOn(repository, 'existsRoom').mockResolvedValue(false); + + const exists = await service.existsRoom('failed'); + + expect(exists).toBe(false); + }); + }); + + describe('existsPlayer', () => { + it('player가 존재한다면 true 반환', async () => { + jest.spyOn(repository, 'existsPlayer').mockResolvedValue(true); + + const exists = await service.existsPlayer('success', 'player'); + + expect(exists).toBe(true); + }); + + it('player가 존재하지 않는다면 false 반환', async () => { + jest.spyOn(repository, 'existsPlayer').mockResolvedValue(false); + + const exists = await service.existsPlayer('failed', 'non'); + + expect(exists).toBe(false); + }); + }); +}); diff --git a/server/src/redis/redis.service.ts b/server/src/redis/redis.service.ts index 6059fb53..3611ade4 100644 --- a/server/src/redis/redis.service.ts +++ b/server/src/redis/redis.service.ts @@ -55,4 +55,13 @@ export class RedisService { multi() { return this.redis.multi(); } + + // 원활한 테스트 진행을 위해 redis 내 저장된 값을 지워주는 코드 추가 + async flushAll() { + await this.redis.flushall(); + } + + quit() { + this.redis.quit(); + } } diff --git a/server/src/test-docker.sh b/server/src/test-docker.sh new file mode 100644 index 00000000..e2845a73 --- /dev/null +++ b/server/src/test-docker.sh @@ -0,0 +1,13 @@ +#!/bin/bash + +# Redis Container 실행 +docker-compose up -d + +# Redis 내 데이터 초기화 +docker exec redis_test redis-cli FLUSHALL + +# 테스트 실행 +npx jest drawing.gateway.integration.spec.ts + +# 테스트 종료 후 Docker Container 삭제 +docker-compose down \ No newline at end of file diff --git a/server/tsconfig.json b/server/tsconfig.json index 95f5641c..1efd6fec 100644 --- a/server/tsconfig.json +++ b/server/tsconfig.json @@ -17,5 +17,8 @@ "strictBindCallApply": false, "forceConsistentCasingInFileNames": false, "noFallthroughCasesInSwitch": false + }, + "paths": { + "src/*" : ["./src/*"] } }