feat: 회의록 자동 작성 웹 서비스 구현

- 오디오 파일 업로드 + 유효성 검사
- 실시간 음성 인식 (Web Speech API)
- 회의록 생성 (단순 STT→MD 변환 / Gemini AI 요약)
- 마크다운/HTML 내보내기
- PostgreSQL + Prisma ORM
- Docker Compose 배포 지원
- TDD: 31개 테스트, 96% 커버리지
This commit is contained in:
nad4 committed 2026-04-11 23:18:05 +09:00
1 parent b5743fe896
commit adb48c2352
31 files changed
+4288 -82

No files matched your search

+75
View File
@@ -0,0 +1,75 @@
import { describe, it, expect } from 'vitest'
import {
validateAudioFile,
ALLOWED_MIME_TYPES,
MAX_FILE_SIZE_BYTES,
} from '@/lib/audio-validation'
describe('validateAudioFile', () => {
it('허용된 오디오 파일을 통과시킨다', () => {
const file = new File(['audio-data'], 'meeting.mp3', { type: 'audio/mpeg' })
const result = validateAudioFile(file)
expect(result.valid).toBe(true)
expect(result.error).toBeUndefined()
})
it('허용되지 않은 MIME 타입을 거부한다', () => {
const file = new File(['data'], 'meeting.exe', {
type: 'application/x-msdownload',
})
const result = validateAudioFile(file)
expect(result.valid).toBe(false)
expect(result.error).toContain('지원하지 않는 파일 형식')
})
it('파일 크기 초과를 거부한다', () => {
const bigData = new Uint8Array(MAX_FILE_SIZE_BYTES + 1)
const file = new File([bigData], 'huge.mp3', { type: 'audio/mpeg' })
const result = validateAudioFile(file)
expect(result.valid).toBe(false)
expect(result.error).toContain('파일 크기')
})
it('빈 파일을 거부한다', () => {
const file = new File([], 'empty.mp3', { type: 'audio/mpeg' })
const result = validateAudioFile(file)
expect(result.valid).toBe(false)
expect(result.error).toContain('비어 있습니다')
})
it('WAV 파일을 허용한다', () => {
const file = new File(['wav-data'], 'meeting.wav', { type: 'audio/wav' })
const result = validateAudioFile(file)
expect(result.valid).toBe(true)
})
it('WebM 오디오를 허용한다', () => {
const file = new File(['webm-data'], 'meeting.webm', {
type: 'audio/webm',
})
const result = validateAudioFile(file)
expect(result.valid).toBe(true)
})
it('M4A 파일을 허용한다', () => {
const file = new File(['m4a-data'], 'meeting.m4a', { type: 'audio/mp4' })
const result = validateAudioFile(file)
expect(result.valid).toBe(true)
})
})
describe('ALLOWED_MIME_TYPES', () => {
it('주요 오디오 형식을 포함한다', () => {
expect(ALLOWED_MIME_TYPES).toContain('audio/mpeg')
expect(ALLOWED_MIME_TYPES).toContain('audio/wav')
expect(ALLOWED_MIME_TYPES).toContain('audio/webm')
expect(ALLOWED_MIME_TYPES).toContain('audio/mp4')
expect(ALLOWED_MIME_TYPES).toContain('audio/ogg')
})
})
describe('MAX_FILE_SIZE_BYTES', () => {
it('500MB로 설정되어 있다', () => {
expect(MAX_FILE_SIZE_BYTES).toBe(500 * 1024 * 1024)
})
})
+59
View File
@@ -0,0 +1,59 @@
import { describe, it, expect } from 'vitest'
import {
exportAsMarkdown,
exportAsHtml,
generateDownloadFilename,
} from '@/lib/export-minutes'
const sampleMarkdown = `# 주간 회의
**날짜**: 2026-04-11
---
## 회의 내용
[00:00] 안녕하세요
[00:05] 회의를 시작합니다
`
describe('exportAsMarkdown', () => {
it('마크다운 문자열을 Blob으로 변환한다', () => {
const blob = exportAsMarkdown(sampleMarkdown)
expect(blob).toBeInstanceOf(Blob)
expect(blob.type).toBe('text/markdown;charset=utf-8')
})
it('빈 문자열도 유효한 Blob을 반환한다', () => {
const blob = exportAsMarkdown('')
expect(blob).toBeInstanceOf(Blob)
expect(blob.size).toBe(0)
})
})
describe('exportAsHtml', () => {
it('마크다운을 HTML 문서로 변환한다', () => {
const html = exportAsHtml(sampleMarkdown, '주간 회의')
expect(html).toContain('<!DOCTYPE html>')
expect(html).toContain('<title>주간 회의</title>')
expect(html).toContain('주간 회의')
})
it('Google Docs 호환 스타일을 포함한다', () => {
const html = exportAsHtml(sampleMarkdown, '테스트')
expect(html).toContain('<style>')
expect(html).toContain('font-family')
})
})
describe('generateDownloadFilename', () => {
it('제목과 날짜로 파일명을 생성한다', () => {
const filename = generateDownloadFilename('주간 회의', new Date('2026-04-11'))
expect(filename).toBe('주간_회의_2026-04-11')
})
it('특수문자를 언더스코어로 치환한다', () => {
const filename = generateDownloadFilename('회의/미팅 #1', new Date('2026-04-11'))
expect(filename).toBe('회의_미팅__1_2026-04-11')
})
})
+107
View File
@@ -0,0 +1,107 @@
import { describe, it, expect, vi } from 'vitest'
import {
generateSimpleMinutes,
generateGeminiMinutes,
type MinutesInput,
} from '@/lib/minutes-generator'
const sampleInput: MinutesInput = {
title: '주간 스프린트 회의',
date: new Date('2026-04-11T10:00:00'),
transcript: `[00:00] 안녕하세요 오늘 주간 회의를 시작하겠습니다
[00:05] 지난 주 작업 내역을 공유해주세요
[00:15] 프론트엔드 리팩토링을 완료했습니다
[00:30] 백엔드 API 성능 개선 작업 중입니다
[01:00] 다음 주 계획을 논의하겠습니다
[01:15] QA 테스트를 진행할 예정입니다`,
}
describe('generateSimpleMinutes', () => {
it('마크다운 형식의 회의록을 생성한다', () => {
const result = generateSimpleMinutes(sampleInput)
expect(result).toContain('# 주간 스프린트 회의')
expect(result).toContain('2026-04-11')
expect(result).toContain('## 회의 내용')
expect(result).toContain('안녕하세요')
})
it('타임스탬프를 유지한다', () => {
const result = generateSimpleMinutes(sampleInput)
expect(result).toContain('[00:00]')
expect(result).toContain('[01:15]')
})
it('빈 transcript이면 해당 섹션에 안내 문구를 넣는다', () => {
const input: MinutesInput = {
...sampleInput,
transcript: '',
}
const result = generateSimpleMinutes(input)
expect(result).toContain('내용 없음')
})
})
describe('generateGeminiMinutes', () => {
it('Gemini API를 호출하여 요약된 회의록을 반환한다', async () => {
const mockFetch = vi.fn().mockResolvedValue({
ok: true,
json: () =>
Promise.resolve({
candidates: [
{
content: {
parts: [
{
text: '## 요약\n- 프론트엔드 리팩토링 완료\n- 백엔드 API 성능 개선 진행 중\n\n## 액션 아이템\n- QA 테스트 진행',
},
],
},
},
],
}),
})
const result = await generateGeminiMinutes(sampleInput, {
apiKey: 'test-key',
fetchFn: mockFetch,
})
expect(result.success).toBe(true)
if (result.success) {
expect(result.markdown).toContain('# 주간 스프린트 회의')
expect(result.markdown).toContain('요약')
expect(result.markdown).toContain('액션 아이템')
}
})
it('API 호출 실패 시 에러를 반환한다', async () => {
const mockFetch = vi.fn().mockResolvedValue({
ok: false,
status: 429,
statusText: 'Too Many Requests',
})
const result = await generateGeminiMinutes(sampleInput, {
apiKey: 'test-key',
fetchFn: mockFetch,
})
expect(result.success).toBe(false)
if (!result.success) {
expect(result.error).toContain('Gemini API')
}
})
it('API 키가 없으면 에러를 반환한다', async () => {
const result = await generateGeminiMinutes(sampleInput, {
apiKey: '',
fetchFn: vi.fn(),
})
expect(result.success).toBe(false)
if (!result.success) {
expect(result.error).toContain('API 키')
}
})
})
+7
View File
@@ -0,0 +1,7 @@
import { describe, it, expect } from 'vitest'
describe('smoke test', () => {
it('vitest is configured correctly', () => {
expect(1 + 1).toBe(2)
})
})
@@ -0,0 +1,57 @@
import { describe, it, expect } from 'vitest'
import {
formatTranscriptChunks,
mergeAdjacentChunks,
type TranscriptChunk,
} from '@/lib/transcript-formatter'
describe('formatTranscriptChunks', () => {
it('타임스탬프가 있는 청크를 텍스트로 변환한다', () => {
const chunks: TranscriptChunk[] = [
{ text: '안녕하세요', startTime: 0, endTime: 2.5, isFinal: true },
{ text: '오늘 회의를 시작하겠습니다', startTime: 2.5, endTime: 5.0, isFinal: true },
]
const result = formatTranscriptChunks(chunks)
expect(result).toContain('[00:00]')
expect(result).toContain('안녕하세요')
expect(result).toContain('[00:02]')
expect(result).toContain('오늘 회의를 시작하겠습니다')
})
it('빈 배열이면 빈 문자열을 반환한다', () => {
const result = formatTranscriptChunks([])
expect(result).toBe('')
})
it('1시간 이상일 때 시:분:초 형식을 사용한다', () => {
const chunks: TranscriptChunk[] = [
{ text: '긴 회의', startTime: 3661, endTime: 3665, isFinal: true },
]
const result = formatTranscriptChunks(chunks)
expect(result).toContain('[01:01:01]')
})
})
describe('mergeAdjacentChunks', () => {
it('2초 이내의 인접 청크를 병합한다', () => {
const chunks: TranscriptChunk[] = [
{ text: '안녕', startTime: 0, endTime: 1, isFinal: true },
{ text: '하세요', startTime: 1.1, endTime: 2, isFinal: true },
{ text: '다음 주제입니다', startTime: 10, endTime: 12, isFinal: true },
]
const merged = mergeAdjacentChunks(chunks, 2)
expect(merged).toHaveLength(2)
expect(merged[0].text).toBe('안녕 하세요')
expect(merged[1].text).toBe('다음 주제입니다')
})
it('빈 배열이면 빈 배열을 반환한다', () => {
const result = mergeAdjacentChunks([], 2)
expect(result).toEqual([])
})
})
+57
View File
@@ -0,0 +1,57 @@
import { describe, it, expect, vi } from 'vitest'
import { handleUpload } from '@/lib/upload-handler'
describe('handleUpload', () => {
it('유효한 오디오 파일을 받으면 성공 응답을 반환한다', async () => {
const file = new File(['audio-content'], 'meeting.mp3', {
type: 'audio/mpeg',
})
const formData = new FormData()
formData.append('audio', file)
formData.append('title', '주간 회의')
const result = await handleUpload(formData)
expect(result.success).toBe(true)
expect(result.data?.title).toBe('주간 회의')
expect(result.data?.fileName).toBe('meeting.mp3')
expect(result.data?.mimeType).toBe('audio/mpeg')
})
it('오디오 파일이 없으면 에러를 반환한다', async () => {
const formData = new FormData()
formData.append('title', '빈 회의')
const result = await handleUpload(formData)
expect(result.success).toBe(false)
expect(result.error).toContain('오디오 파일')
})
it('유효하지 않은 파일 형식이면 에러를 반환한다', async () => {
const file = new File(['not-audio'], 'doc.pdf', {
type: 'application/pdf',
})
const formData = new FormData()
formData.append('audio', file)
formData.append('title', '잘못된 파일')
const result = await handleUpload(formData)
expect(result.success).toBe(false)
expect(result.error).toContain('지원하지 않는 파일 형식')
})
it('제목이 없으면 파일명을 기본 제목으로 사용한다', async () => {
const file = new File(['audio-content'], 'standup.webm', {
type: 'audio/webm',
})
const formData = new FormData()
formData.append('audio', file)
const result = await handleUpload(formData)
expect(result.success).toBe(true)
expect(result.data?.title).toBe('standup')
})
})