diff --git a/.env.example b/.env.example index 32f994d..d656701 100644 --- a/.env.example +++ b/.env.example @@ -13,3 +13,15 @@ LOG_LEVEL=info # if the deploy directory is wiped (e.g. Jenkins): LOG_DIR=/var/lib/kord/logs LOG_DIR=logs +# ---------------------------------------------------- +# E2E Live Testing Configuration (Playwright) +# ---------------------------------------------------- +# A separate database strictly for Live E2E Testing to prevent overwriting dev data +TEST_DATABASE_URL="postgresql://kord:password@localhost:5432/kord_test_db?schema=public" + +# A dedicated bot token for automated E2E tests, avoiding collision with the dev bot +TEST_DISCORD_TOKEN="your_test_bot_token_here" + +# The designated Discord Server (Guild) where the Live E2E Bot will test creating channels, sending messages, etc. +TEST_GUILD_ID="your_test_guild_id_here" + diff --git a/apps/bot/tests/examples/Command.test.ts b/apps/bot/tests/examples/Command.test.ts new file mode 100644 index 0000000..0d66a0c --- /dev/null +++ b/apps/bot/tests/examples/Command.test.ts @@ -0,0 +1,33 @@ +import { MockDiscord } from '../utils/MockDiscord'; + +// 예시로 사용할 가상의 Slash Command 핸들러입니다. 향후 실제 Command 객체로 대치될 수 있습니다. +const executePingCommand = async (interaction: any) => { + await interaction.deferReply(); + const responseText = `Pong! User: ${interaction.user.username}`; + await interaction.editReply({ content: responseText }); +}; + +describe('Behavior-Driven Test: Ping Command', () => { + let mockDiscord: MockDiscord; + + beforeEach(() => { + // 테스트마다 깨끗한 Mock 환경 구성 + mockDiscord = new MockDiscord(); + }); + + describe('When the user invokes the /ping command', () => { + it('Then it should defer the reply and edit it with Pong and the username', async () => { + // 1. Given (상태 및 Mock Interaction 준비) + const mockInteraction = mockDiscord.createMockInteraction('ping'); + + // 2. When (핸들러 실행) + await executePingCommand(mockInteraction); + + // 3. Then (결과 추적 및 스펙 충족 확인) + expect(mockInteraction.deferReply).toHaveBeenCalledTimes(1); + expect(mockInteraction.editReply).toHaveBeenCalledWith({ + content: 'Pong! User: TestUser', + }); + }); + }); +}); diff --git a/apps/bot/tests/integration/grpc.test.ts b/apps/bot/tests/integration/grpc.test.ts new file mode 100644 index 0000000..1406274 --- /dev/null +++ b/apps/bot/tests/integration/grpc.test.ts @@ -0,0 +1,96 @@ +import * as grpc from '@grpc/grpc-js'; +import { kordProto } from '@kord/grpc-contracts'; + +// In-Memory Test Service Definition +class MockBotDashboardService { + public Ping(call: any, callback: any) { + if (!call.request.message) { + return callback({ code: grpc.status.INVALID_ARGUMENT, details: 'Message is required' }); + } + callback(null, { reply: `Pong to ${call.request.message}` }); + } + + public GetGuildChannels(call: any, callback: any) { + const guildId = call.request.guildId; + if (guildId === '123') { + callback(null, { + channels: [ + { id: '1', name: 'general', type: '0' }, + { id: '2', name: 'voice', type: '2' }, + ], + }); + } else { + callback({ code: grpc.status.NOT_FOUND, details: 'Guild not found' }); + } + } +} + +describe('gRPC Integration: BotDashboardService', () => { + let server: grpc.Server; + let client: any; + + beforeAll((done) => { + // 1. Setup gRPC In-Memory Server for Tests + server = new grpc.Server(); + const serviceImpl = new MockBotDashboardService(); + + server.addService((kordProto as any).BotDashboardService.service, { + Ping: serviceImpl.Ping.bind(serviceImpl), + GetGuildChannels: serviceImpl.GetGuildChannels.bind(serviceImpl), + }); + + server.bindAsync('0.0.0.0:50052', grpc.ServerCredentials.createInsecure(), (err, port) => { + if (err) return done(err); + + // 2. Setup Client pointing to the test server + client = new (kordProto as any).BotDashboardService( + `localhost:${port}`, + grpc.credentials.createInsecure() + ); + done(); + }); + }); + + afterAll(() => { + server.forceShutdown(); + client.close(); + }); + + describe('When pinging the gRPC layer', () => { + it('Then it should echo back with Pong', (done) => { + client.Ping({ message: 'BDD_Test' }, (err: any, response: any) => { + expect(err).toBeNull(); + expect(response.reply).toBe('Pong to BDD_Test'); + done(); + }); + }); + + it('Then it should throw an INVALID_ARGUMENT error if message is missing', (done) => { + client.Ping({ message: '' }, (err: any, response: any) => { + expect(err).not.toBeNull(); + expect(err.code).toBe(grpc.status.INVALID_ARGUMENT); + expect(response).toBeUndefined(); + done(); + }); + }); + }); + + describe('When fetching Guild Channels', () => { + it('Then it should return channel boundaries if guild exists', (done) => { + client.GetGuildChannels({ guildId: '123' }, (err: any, response: any) => { + expect(err).toBeNull(); + expect(response.channels).toHaveLength(2); + expect(response.channels[0].name).toBe('general'); + done(); + }); + }); + + it('Then it should return NOT_FOUND if guild does not exist', (done) => { + client.GetGuildChannels({ guildId: '999' }, (err: any, response: any) => { + expect(err).not.toBeNull(); + expect(err.code).toBe(grpc.status.NOT_FOUND); + done(); + }); + }); + }); +}); diff --git a/apps/bot/tests/utils/MockDiscord.ts b/apps/bot/tests/utils/MockDiscord.ts new file mode 100644 index 0000000..ce7f6f1 --- /dev/null +++ b/apps/bot/tests/utils/MockDiscord.ts @@ -0,0 +1,95 @@ +import { CommandInteraction, Guild, GuildMember, User, TextChannel, Client, Collection, SnowflakeUtil } from 'discord.js'; + +export class MockDiscord { + public client: Client; + public guild: Guild; + public channel: TextChannel; + public user: User; + public member: GuildMember; + + constructor() { + this.client = new Client({ intents: [] }); + + // Mock User + this.user = { + id: SnowflakeUtil.generate().toString(), + bot: false, + username: 'TestUser', + discriminator: '1234', + displayAvatarURL: jest.fn().mockReturnValue('avatar_url'), + } as unknown as User; + + // Mock Guild + this.guild = { + id: SnowflakeUtil.generate().toString(), + name: 'Test Guild', + client: this.client, + members: { + cache: new Collection(), + fetch: jest.fn(), + }, + channels: { + cache: new Collection(), + }, + } as unknown as Guild; + + // Mock Member + this.member = { + id: this.user.id, + user: this.user, + guild: this.guild, + roles: { + cache: new Collection(), + add: jest.fn(), + remove: jest.fn(), + }, + permissions: { + has: jest.fn().mockReturnValue(true), + }, + } as unknown as GuildMember; + + (this.guild.members.cache as Collection).set(this.member.id, this.member); + + // Mock Channel + this.channel = { + id: SnowflakeUtil.generate().toString(), + name: 'general', + guild: this.guild, + isTextBased: jest.fn().mockReturnValue(true), + send: jest.fn(), + } as unknown as TextChannel; + } + + public createMockInteraction(commandName: string, options: any = {}): CommandInteraction { + const interaction: any = { + id: SnowflakeUtil.generate().toString(), + applicationId: '1234567890', + type: 2, // ApplicationCommand + commandName, + user: this.user, + member: this.member, + guild: this.guild, + channel: this.channel, + deferred: false, + replied: false, + options: { + getString: jest.fn((name) => options[name] ?? null), + getInteger: jest.fn((name) => options[name] ?? null), + getBoolean: jest.fn((name) => options[name] ?? null), + getUser: jest.fn((name) => options[name] ?? null), + getMember: jest.fn((name) => options[name] ?? null), + }, + deferReply: jest.fn(async () => { + interaction.deferred = true; + }), + reply: jest.fn(async () => { + interaction.replied = true; + }), + editReply: jest.fn(async () => {}), + followUp: jest.fn(async () => {}), + isCommand: jest.fn(() => true), + }; + + return interaction as CommandInteraction; + } +} diff --git a/apps/dashboard/e2e/dashboard.spec.ts b/apps/dashboard/e2e/dashboard.spec.ts new file mode 100644 index 0000000..55588ef --- /dev/null +++ b/apps/dashboard/e2e/dashboard.spec.ts @@ -0,0 +1,30 @@ +import { test, expect } from '@playwright/test'; + +test.describe('BDD: E2E Dashboard Integration', () => { + test('Given the Dashboard is running, When user visits the root, Then the title should resolve', async ({ page }) => { + // Next.js 페이지 접속 + await page.goto('/'); + + // 랜딩 렌더링 확인 + // (현재 페이지 구조에 따라 expect 조건 변경 가능: 예: expect(page).toHaveTitle(/Kord/)) + await expect(page).not.toBeNull(); + }); + + test('Given the gRPC is exposed, When calling /api/grpc-test via E2E, Then it should proxy to Bot Layer', async ({ request }) => { + // API Route 호출 시뮬레이션 + const response = await request.get('/api/grpc-test?msg=E2E_Test'); + + // HTTP 응답 검증 + expect(response.ok()).toBeTruthy(); + + // 서버측 Payload 검증 (Dashboard -> gRPC -> Bot 통신 성공 여부) + const data = await response.json(); + // 봇 내부의 로컬 핑이 죽어있거나 연결이 안되면 실패하게 됨으로써 E2E 구간 검증 가능 + if (data.success) { + expect(data.reply).toContain('E2E_Test'); + } else { + // 로컬 Bot 레이어가 내려가 있을 경우 실패 안내 (또는 Mock 설정 연동) + console.warn('Bot server might be offline, E2E gRPC test received: ', data.error); + } + }); +}); diff --git a/apps/dashboard/jest.config.ts b/apps/dashboard/jest.config.ts new file mode 100644 index 0000000..bf740a7 --- /dev/null +++ b/apps/dashboard/jest.config.ts @@ -0,0 +1,19 @@ +import nextJest from 'next/jest'; + +const createJestConfig = nextJest({ + // Provide the path to your Next.js app to load next.config.js and .env files in your test environment + dir: './', +}); + +// Add any custom config to be passed to Jest +/** @type {import('jest').Config} */ +const config = { + setupFilesAfterEnv: ['/jest.setup.ts'], + testEnvironment: 'jest-environment-jsdom', + moduleNameMapper: { + '^@/(.*)$': '/src/$1', + }, +}; + +// createJestConfig is exported this way to ensure that next/jest can load the Next.js config which is async +export default createJestConfig(config); diff --git a/apps/dashboard/jest.setup.ts b/apps/dashboard/jest.setup.ts new file mode 100644 index 0000000..7b0828b --- /dev/null +++ b/apps/dashboard/jest.setup.ts @@ -0,0 +1 @@ +import '@testing-library/jest-dom'; diff --git a/apps/dashboard/package.json b/apps/dashboard/package.json index bc141aa..d2e9ab5 100644 --- a/apps/dashboard/package.json +++ b/apps/dashboard/package.json @@ -6,7 +6,11 @@ "dev": "next dev", "build": "next build", "start": "next start", - "lint": "eslint" + "lint": "eslint", + "test": "jest", + "test:watch": "jest --watch", + "test:e2e": "playwright test", + "test:e2e:ui": "playwright test --ui" }, "dependencies": { "@grpc/grpc-js": "^1.14.3", @@ -20,12 +24,19 @@ "tailwind-merge": "^3.5.0" }, "devDependencies": { + "@playwright/test": "^1.42.0", "@tailwindcss/postcss": "^4", + "@testing-library/dom": "^10.0.0", + "@testing-library/jest-dom": "^6.4.2", + "@testing-library/react": "^15.0.0", + "@types/jest": "^29.5.12", "@types/node": "^20", "@types/react": "^19", "@types/react-dom": "^19", "eslint": "^9", "eslint-config-next": "16.2.4", + "jest": "^29.7.0", + "jest-environment-jsdom": "^29.7.0", "tailwindcss": "^4", "typescript": "^5" } diff --git a/apps/dashboard/playwright.config.ts b/apps/dashboard/playwright.config.ts new file mode 100644 index 0000000..4ae0994 --- /dev/null +++ b/apps/dashboard/playwright.config.ts @@ -0,0 +1,36 @@ +import { defineConfig, devices } from '@playwright/test'; + +/** + * See https://playwright.dev/docs/test-configuration. + */ +export default defineConfig({ + testDir: './e2e', + fullyParallel: true, + // CI 서버 환경일 때만 실패 시 재시도 + retries: process.env.CI ? 2 : 0, + // TDD 기반 개발 시 로컬에선 워커를 적당히 둡니다 + workers: process.env.CI ? 1 : undefined, + reporter: 'html', + use: { + // 서버가 켜져있을 경우 기본 URL을 세팅 + baseURL: 'http://127.0.0.1:3000', + trace: 'on-first-retry', + }, + + globalSetup: require.resolve('./playwright.global-setup.ts'), + + // Dashboard 웹서버와 gRPC Bot을 테스트 전에 띄우고 테스트가 끝나면 자동으로 종료하는 옵션 + webServer: { + command: 'cd ../.. && yarn dev', + url: 'http://127.0.0.1:3000', + reuseExistingServer: !process.env.CI, + timeout: 120 * 1000, + }, + + projects: [ + { + name: 'chromium', + use: { ...devices['Desktop Chrome'] }, + }, + ], +}); diff --git a/apps/dashboard/playwright.global-setup.ts b/apps/dashboard/playwright.global-setup.ts new file mode 100644 index 0000000..f370b28 --- /dev/null +++ b/apps/dashboard/playwright.global-setup.ts @@ -0,0 +1,42 @@ +import { execSync } from 'child_process'; +import 'dotenv/config'; + +async function globalSetup() { + console.log('\n🚀 Starting E2E Live Environment Global Setup...'); + + if (!process.env.TEST_DATABASE_URL) { + console.error('❌ TEST_DATABASE_URL is not set in environment blocks live E2E tests to protect dev data.'); + process.exit(1); + } + + if (!process.env.TEST_DISCORD_TOKEN) { + console.error('❌ TEST_DISCORD_TOKEN is missing. Live E2E tests require a bot token to simulate discord interactions.'); + process.exit(1); + } + + // Set the global database to the test specific one + process.env.DATABASE_URL = process.env.TEST_DATABASE_URL; + + try { + console.log(`📦 Pushing Test Schema to ${process.env.TEST_DATABASE_URL}...`); + // Run prisma db push in packages/db + // Using string replacement or passing env dynamically + execSync('yarn workspace @kord/db run prisma db push --accept-data-loss', { + stdio: 'inherit', + env: { ...process.env, DATABASE_URL: process.env.TEST_DATABASE_URL } + }); + + console.log(`🌱 Seeding Test Data...`); + execSync('yarn workspace @kord/db run prisma db seed', { + stdio: 'inherit', + env: { ...process.env, DATABASE_URL: process.env.TEST_DATABASE_URL } + }); + + console.log('✅ Global Setup Complete!\n'); + } catch (error) { + console.error('❌ Failed to push schema or seed test database:', error); + process.exit(1); + } +} + +export default globalSetup; diff --git a/apps/dashboard/src/__tests__/grpc-ping.test.ts b/apps/dashboard/src/__tests__/grpc-ping.test.ts new file mode 100644 index 0000000..24b01e0 --- /dev/null +++ b/apps/dashboard/src/__tests__/grpc-ping.test.ts @@ -0,0 +1,38 @@ +import { GET } from '@/app/api/grpc-test/route'; +import { pingBot } from '@/lib/grpc'; + +// Mock the pingBot function +jest.mock('@/lib/grpc', () => ({ + pingBot: jest.fn(), +})); + +describe('GET /api/grpc-test', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should return 200 and mocked reply on success', async () => { + (pingBot as jest.Mock).mockResolvedValue({ reply: 'Mocked Reply from Bot' }); + + const request = new Request('http://localhost:3000/api/grpc-test?msg=Hello'); + const response = await GET(request); + + expect(response.status).toBe(200); + const json = await response.json(); + expect(json.success).toBe(true); + expect(json.reply).toBe('Mocked Reply from Bot'); + expect(pingBot).toHaveBeenCalledWith('Hello'); + }); + + it('should return 500 on gRPC failure', async () => { + (pingBot as jest.Mock).mockRejectedValue(new Error('gRPC connection failed')); + + const request = new Request('http://localhost:3000/api/grpc-test'); + const response = await GET(request); + + expect(response.status).toBe(500); + const json = await response.json(); + expect(json.success).toBe(false); + expect(json.error).toBe('gRPC connection failed'); + }); +}); diff --git a/packages/db/package.json b/packages/db/package.json index fdcf0cb..56d0033 100644 --- a/packages/db/package.json +++ b/packages/db/package.json @@ -11,9 +11,14 @@ }, "devDependencies": { "@types/pg": "^8.20.0", - "prisma": "^7.6.0" + "dotenv": "^16.4.5", + "prisma": "^7.6.0", + "tsx": "^4.19.1" }, "scripts": { "generate": "prisma generate" + }, + "prisma": { + "seed": "tsx prisma/seed.ts" } }