mirror of
https://github.com/nad4-su/meeting-minutes.git
synced 2026-10-11 15:50:13 +09:00
feat: 회의록 자동 작성 웹 서비스 구현
- 오디오 파일 업로드 + 유효성 검사 - 실시간 음성 인식 (Web Speech API) - 회의록 생성 (단순 STT→MD 변환 / Gemini AI 요약) - 마크다운/HTML 내보내기 - PostgreSQL + Prisma ORM - Docker Compose 배포 지원 - TDD: 31개 테스트, 96% 커버리지
This commit is contained in:
1 parent
b5743fe896
commit
adb48c2352
31 files changed
+4288
-82
No files matched your search
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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 키')
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -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([])
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
Reference in new issue
Block a user