feat: Meeting Assistant MVP - Complete implementation
Enterprise Meeting Knowledge Management System with: Backend (FastAPI): - Authentication proxy with JWT (pj-auth-api integration) - MySQL database with 4 tables (users, meetings, conclusions, actions) - Meeting CRUD with system code generation (C-YYYYMMDD-XX, A-YYYYMMDD-XX) - Dify LLM integration for AI summarization - Excel export with openpyxl - 20 unit tests (all passing) Client (Electron): - Login page with company auth - Meeting list with create/delete - Meeting detail with real-time transcription - Editable transcript textarea (single block, easy editing) - AI summarization with conclusions/action items - 5-second segment recording (efficient for long meetings) Sidecar (Python): - faster-whisper medium model with int8 quantization - ONNX Runtime VAD (lightweight, ~20MB vs PyTorch ~2GB) - Chinese punctuation processing - OpenCC for Traditional Chinese conversion - Anti-hallucination parameters - Auto-cleanup of temp audio files OpenSpec: - add-meeting-assistant-mvp (47 tasks, archived) - add-realtime-transcription (29 tasks, archived) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
1
backend/tests/__init__.py
Normal file
1
backend/tests/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
# Tests package
|
||||
48
backend/tests/conftest.py
Normal file
48
backend/tests/conftest.py
Normal file
@@ -0,0 +1,48 @@
|
||||
"""
|
||||
Pytest configuration and fixtures.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
import sys
|
||||
import os
|
||||
|
||||
# Add the backend directory to the path
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def mock_env(monkeypatch):
|
||||
"""Set up mock environment variables for all tests."""
|
||||
monkeypatch.setenv("DB_HOST", "localhost")
|
||||
monkeypatch.setenv("DB_PORT", "3306")
|
||||
monkeypatch.setenv("DB_USER", "test")
|
||||
monkeypatch.setenv("DB_PASS", "test")
|
||||
monkeypatch.setenv("DB_NAME", "test_db")
|
||||
monkeypatch.setenv("AUTH_API_URL", "https://auth.test.com/login")
|
||||
monkeypatch.setenv("DIFY_API_URL", "https://dify.test.com/v1")
|
||||
monkeypatch.setenv("DIFY_API_KEY", "test-api-key")
|
||||
monkeypatch.setenv("ADMIN_EMAIL", "admin@test.com")
|
||||
monkeypatch.setenv("JWT_SECRET", "test-jwt-secret")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sample_meeting():
|
||||
"""Sample meeting data for tests."""
|
||||
return {
|
||||
"subject": "Test Meeting",
|
||||
"meeting_time": "2025-01-15T10:00:00",
|
||||
"location": "Conference Room A",
|
||||
"chairperson": "John Doe",
|
||||
"recorder": "Jane Smith",
|
||||
"attendees": "alice@test.com, bob@test.com",
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sample_transcript():
|
||||
"""Sample transcript for AI tests."""
|
||||
return """
|
||||
今天的會議主要討論了Q1預算和新員工招聘計劃。
|
||||
決定將行銷預算增加10%。
|
||||
小明負責在下週五前提交最終報告。
|
||||
"""
|
||||
191
backend/tests/test_ai.py
Normal file
191
backend/tests/test_ai.py
Normal file
@@ -0,0 +1,191 @@
|
||||
"""
|
||||
Unit tests for AI summarization with mock Dify responses.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from unittest.mock import patch, MagicMock, AsyncMock
|
||||
import json
|
||||
|
||||
pytestmark = pytest.mark.asyncio
|
||||
|
||||
|
||||
class TestDifyResponseParsing:
|
||||
"""Tests for parsing Dify LLM responses."""
|
||||
|
||||
def test_parse_json_response(self):
|
||||
"""Test parsing valid JSON response from Dify."""
|
||||
from app.routers.ai import parse_dify_response
|
||||
|
||||
response = '''Here is the summary:
|
||||
```json
|
||||
{
|
||||
"conclusions": ["Agreed on Q1 budget", "New hire approved"],
|
||||
"action_items": [
|
||||
{"content": "Submit budget report", "owner": "John", "due_date": "2025-01-15"},
|
||||
{"content": "Post job listing", "owner": "", "due_date": null}
|
||||
]
|
||||
}
|
||||
```
|
||||
'''
|
||||
result = parse_dify_response(response)
|
||||
|
||||
assert len(result["conclusions"]) == 2
|
||||
assert "Q1 budget" in result["conclusions"][0]
|
||||
assert len(result["action_items"]) == 2
|
||||
assert result["action_items"][0]["owner"] == "John"
|
||||
|
||||
def test_parse_inline_json_response(self):
|
||||
"""Test parsing inline JSON without code blocks."""
|
||||
from app.routers.ai import parse_dify_response
|
||||
|
||||
response = '{"conclusions": ["Budget approved"], "action_items": []}'
|
||||
result = parse_dify_response(response)
|
||||
|
||||
assert len(result["conclusions"]) == 1
|
||||
assert result["conclusions"][0] == "Budget approved"
|
||||
|
||||
def test_parse_non_json_response(self):
|
||||
"""Test fallback when response is not JSON."""
|
||||
from app.routers.ai import parse_dify_response
|
||||
|
||||
response = "The meeting discussed Q1 budget and hiring plans."
|
||||
result = parse_dify_response(response)
|
||||
|
||||
# Should return the raw response as a single conclusion
|
||||
assert len(result["conclusions"]) == 1
|
||||
assert "Q1 budget" in result["conclusions"][0]
|
||||
assert len(result["action_items"]) == 0
|
||||
|
||||
def test_parse_empty_response(self):
|
||||
"""Test handling empty response."""
|
||||
from app.routers.ai import parse_dify_response
|
||||
|
||||
result = parse_dify_response("")
|
||||
|
||||
assert result["conclusions"] == []
|
||||
assert result["action_items"] == []
|
||||
|
||||
|
||||
class TestSummarizeEndpoint:
|
||||
"""Tests for the AI summarization endpoint."""
|
||||
|
||||
@patch("app.routers.ai.httpx.AsyncClient")
|
||||
@patch("app.routers.ai.settings")
|
||||
async def test_summarize_success(self, mock_settings, mock_client_class):
|
||||
"""Test successful summarization."""
|
||||
mock_settings.DIFY_API_URL = "https://dify.test.com/v1"
|
||||
mock_settings.DIFY_API_KEY = "test-key"
|
||||
|
||||
mock_response = MagicMock()
|
||||
mock_response.status_code = 200
|
||||
mock_response.json.return_value = {
|
||||
"answer": json.dumps({
|
||||
"conclusions": ["Decision made"],
|
||||
"action_items": [{"content": "Follow up", "owner": "Alice", "due_date": "2025-01-20"}]
|
||||
})
|
||||
}
|
||||
|
||||
mock_client = AsyncMock()
|
||||
mock_client.post.return_value = mock_response
|
||||
mock_client.__aenter__.return_value = mock_client
|
||||
mock_client.__aexit__.return_value = None
|
||||
mock_client_class.return_value = mock_client
|
||||
|
||||
from app.routers.ai import summarize_transcript
|
||||
from app.models import SummarizeRequest, TokenPayload
|
||||
|
||||
mock_user = TokenPayload(email="test@test.com", role="user")
|
||||
result = await summarize_transcript(
|
||||
SummarizeRequest(transcript="Test meeting transcript"),
|
||||
current_user=mock_user
|
||||
)
|
||||
|
||||
assert len(result.conclusions) == 1
|
||||
assert len(result.action_items) == 1
|
||||
assert result.action_items[0].owner == "Alice"
|
||||
|
||||
@patch("app.routers.ai.httpx.AsyncClient")
|
||||
@patch("app.routers.ai.settings")
|
||||
async def test_summarize_handles_timeout(self, mock_settings, mock_client_class):
|
||||
"""Test handling Dify timeout."""
|
||||
import httpx
|
||||
from fastapi import HTTPException
|
||||
|
||||
mock_settings.DIFY_API_URL = "https://dify.test.com/v1"
|
||||
mock_settings.DIFY_API_KEY = "test-key"
|
||||
|
||||
mock_client = AsyncMock()
|
||||
mock_client.post.side_effect = httpx.TimeoutException("Timeout")
|
||||
mock_client.__aenter__.return_value = mock_client
|
||||
mock_client.__aexit__.return_value = None
|
||||
mock_client_class.return_value = mock_client
|
||||
|
||||
from app.routers.ai import summarize_transcript
|
||||
from app.models import SummarizeRequest, TokenPayload
|
||||
|
||||
mock_user = TokenPayload(email="test@test.com", role="user")
|
||||
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await summarize_transcript(
|
||||
SummarizeRequest(transcript="Test"),
|
||||
current_user=mock_user
|
||||
)
|
||||
|
||||
assert exc_info.value.status_code == 504
|
||||
|
||||
@patch("app.routers.ai.settings")
|
||||
async def test_summarize_no_api_key(self, mock_settings):
|
||||
"""Test error when Dify API key is not configured."""
|
||||
from fastapi import HTTPException
|
||||
|
||||
mock_settings.DIFY_API_KEY = ""
|
||||
|
||||
from app.routers.ai import summarize_transcript
|
||||
from app.models import SummarizeRequest, TokenPayload
|
||||
|
||||
mock_user = TokenPayload(email="test@test.com", role="user")
|
||||
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await summarize_transcript(
|
||||
SummarizeRequest(transcript="Test"),
|
||||
current_user=mock_user
|
||||
)
|
||||
|
||||
assert exc_info.value.status_code == 503
|
||||
|
||||
|
||||
class TestPartialDataHandling:
|
||||
"""Tests for handling partial data from AI."""
|
||||
|
||||
def test_action_item_with_empty_owner(self):
|
||||
"""Test action items with empty owner are handled."""
|
||||
from app.routers.ai import parse_dify_response
|
||||
|
||||
response = json.dumps({
|
||||
"conclusions": [],
|
||||
"action_items": [
|
||||
{"content": "Task 1", "owner": "", "due_date": None},
|
||||
{"content": "Task 2", "owner": "Bob", "due_date": "2025-02-01"}
|
||||
]
|
||||
})
|
||||
|
||||
result = parse_dify_response(response)
|
||||
|
||||
assert result["action_items"][0]["owner"] == ""
|
||||
assert result["action_items"][1]["owner"] == "Bob"
|
||||
|
||||
def test_action_item_with_missing_fields(self):
|
||||
"""Test action items with missing fields."""
|
||||
from app.routers.ai import parse_dify_response
|
||||
|
||||
response = json.dumps({
|
||||
"conclusions": ["Done"],
|
||||
"action_items": [
|
||||
{"content": "Task only"}
|
||||
]
|
||||
})
|
||||
|
||||
result = parse_dify_response(response)
|
||||
|
||||
# Should have content but other fields may be missing
|
||||
assert result["action_items"][0]["content"] == "Task only"
|
||||
138
backend/tests/test_auth.py
Normal file
138
backend/tests/test_auth.py
Normal file
@@ -0,0 +1,138 @@
|
||||
"""
|
||||
Unit tests for authentication functionality.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from unittest.mock import patch, MagicMock, AsyncMock
|
||||
from fastapi.testclient import TestClient
|
||||
from jose import jwt
|
||||
|
||||
pytestmark = pytest.mark.asyncio
|
||||
|
||||
|
||||
class TestAdminRoleDetection:
|
||||
"""Tests for admin role detection."""
|
||||
|
||||
def test_admin_email_gets_admin_role(self):
|
||||
"""Test that admin email is correctly identified."""
|
||||
from app.config import settings
|
||||
|
||||
admin_email = settings.ADMIN_EMAIL
|
||||
test_email = "regular@example.com"
|
||||
|
||||
# Admin email should be set (either from env or default)
|
||||
assert admin_email is not None
|
||||
assert len(admin_email) > 0
|
||||
assert test_email != admin_email
|
||||
|
||||
@patch("app.routers.auth.settings")
|
||||
def test_create_token_includes_role(self, mock_settings):
|
||||
"""Test that created tokens include the role."""
|
||||
mock_settings.JWT_SECRET = "test-secret"
|
||||
mock_settings.ADMIN_EMAIL = "admin@test.com"
|
||||
|
||||
from app.routers.auth import create_token
|
||||
|
||||
# Test admin token
|
||||
admin_token = create_token("admin@test.com", "admin")
|
||||
admin_payload = jwt.decode(admin_token, "test-secret", algorithms=["HS256"])
|
||||
assert admin_payload["role"] == "admin"
|
||||
|
||||
# Test user token
|
||||
user_token = create_token("user@test.com", "user")
|
||||
user_payload = jwt.decode(user_token, "test-secret", algorithms=["HS256"])
|
||||
assert user_payload["role"] == "user"
|
||||
|
||||
|
||||
class TestTokenValidation:
|
||||
"""Tests for JWT token validation."""
|
||||
|
||||
@patch("app.routers.auth.settings")
|
||||
def test_decode_valid_token(self, mock_settings):
|
||||
"""Test decoding a valid token."""
|
||||
mock_settings.JWT_SECRET = "test-secret"
|
||||
|
||||
from app.routers.auth import create_token, decode_token
|
||||
|
||||
token = create_token("test@example.com", "user")
|
||||
payload = decode_token(token)
|
||||
|
||||
assert payload.email == "test@example.com"
|
||||
assert payload.role == "user"
|
||||
|
||||
@patch("app.routers.auth.settings")
|
||||
def test_decode_invalid_token_raises_error(self, mock_settings):
|
||||
"""Test that invalid tokens raise an error."""
|
||||
mock_settings.JWT_SECRET = "test-secret"
|
||||
|
||||
from app.routers.auth import decode_token
|
||||
from fastapi import HTTPException
|
||||
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
decode_token("invalid-token")
|
||||
|
||||
assert exc_info.value.status_code == 401
|
||||
|
||||
|
||||
class TestLoginEndpoint:
|
||||
"""Tests for the login endpoint."""
|
||||
|
||||
@pytest.fixture
|
||||
def client(self):
|
||||
"""Create test client."""
|
||||
from app.main import app
|
||||
|
||||
# Skip lifespan for tests
|
||||
app.router.lifespan_context = None
|
||||
return TestClient(app, raise_server_exceptions=False)
|
||||
|
||||
@patch("app.routers.auth.httpx.AsyncClient")
|
||||
@patch("app.routers.auth.settings")
|
||||
async def test_login_success(self, mock_settings, mock_client_class):
|
||||
"""Test successful login."""
|
||||
mock_settings.AUTH_API_URL = "https://auth.test.com/login"
|
||||
mock_settings.ADMIN_EMAIL = "admin@test.com"
|
||||
mock_settings.JWT_SECRET = "test-secret"
|
||||
|
||||
mock_response = MagicMock()
|
||||
mock_response.status_code = 200
|
||||
mock_response.json.return_value = {"token": "external-token"}
|
||||
|
||||
mock_client = AsyncMock()
|
||||
mock_client.post.return_value = mock_response
|
||||
mock_client.__aenter__.return_value = mock_client
|
||||
mock_client.__aexit__.return_value = None
|
||||
mock_client_class.return_value = mock_client
|
||||
|
||||
from app.routers.auth import login
|
||||
from app.models import LoginRequest
|
||||
|
||||
result = await login(LoginRequest(email="user@test.com", password="password"))
|
||||
|
||||
assert result.email == "user@test.com"
|
||||
assert result.role == "user"
|
||||
assert result.token is not None
|
||||
|
||||
@patch("app.routers.auth.httpx.AsyncClient")
|
||||
@patch("app.routers.auth.settings")
|
||||
async def test_login_admin_gets_admin_role(self, mock_settings, mock_client_class):
|
||||
"""Test that admin email gets admin role."""
|
||||
mock_settings.AUTH_API_URL = "https://auth.test.com/login"
|
||||
mock_settings.ADMIN_EMAIL = "admin@test.com"
|
||||
mock_settings.JWT_SECRET = "test-secret"
|
||||
|
||||
mock_response = MagicMock()
|
||||
mock_response.status_code = 200
|
||||
|
||||
mock_client = AsyncMock()
|
||||
mock_client.post.return_value = mock_response
|
||||
mock_client.__aenter__.return_value = mock_client
|
||||
mock_client.__aexit__.return_value = None
|
||||
mock_client_class.return_value = mock_client
|
||||
|
||||
from app.routers.auth import login
|
||||
from app.models import LoginRequest
|
||||
|
||||
result = await login(LoginRequest(email="admin@test.com", password="password"))
|
||||
|
||||
assert result.role == "admin"
|
||||
95
backend/tests/test_database.py
Normal file
95
backend/tests/test_database.py
Normal file
@@ -0,0 +1,95 @@
|
||||
"""
|
||||
Unit tests for database connection and table initialization.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from unittest.mock import patch, MagicMock
|
||||
|
||||
|
||||
class TestDatabaseConnection:
|
||||
"""Tests for database connectivity."""
|
||||
|
||||
@patch("mysql.connector.pooling.MySQLConnectionPool")
|
||||
def test_init_db_pool_success(self, mock_pool):
|
||||
"""Test successful database pool initialization."""
|
||||
mock_pool.return_value = MagicMock()
|
||||
|
||||
from app.database import init_db_pool
|
||||
|
||||
pool = init_db_pool()
|
||||
assert pool is not None
|
||||
mock_pool.assert_called_once()
|
||||
|
||||
@patch("mysql.connector.pooling.MySQLConnectionPool")
|
||||
def test_init_db_pool_with_correct_config(self, mock_pool):
|
||||
"""Test database pool is created with correct configuration."""
|
||||
from app.database import init_db_pool
|
||||
from app.config import settings
|
||||
|
||||
init_db_pool()
|
||||
|
||||
call_args = mock_pool.call_args
|
||||
assert call_args.kwargs["host"] == settings.DB_HOST
|
||||
assert call_args.kwargs["port"] == settings.DB_PORT
|
||||
assert call_args.kwargs["user"] == settings.DB_USER
|
||||
assert call_args.kwargs["database"] == settings.DB_NAME
|
||||
|
||||
|
||||
class TestTableInitialization:
|
||||
"""Tests for table creation."""
|
||||
|
||||
@patch("app.database.get_db_cursor")
|
||||
def test_init_tables_creates_required_tables(self, mock_cursor_context):
|
||||
"""Test that all required tables are created."""
|
||||
mock_cursor = MagicMock()
|
||||
mock_cursor_context.return_value.__enter__ = MagicMock(return_value=mock_cursor)
|
||||
mock_cursor_context.return_value.__exit__ = MagicMock(return_value=False)
|
||||
|
||||
from app.database import init_tables
|
||||
|
||||
init_tables()
|
||||
|
||||
# Verify execute was called for each table
|
||||
assert mock_cursor.execute.call_count == 4
|
||||
|
||||
# Check table names in SQL
|
||||
calls = mock_cursor.execute.call_args_list
|
||||
sql_statements = [call[0][0] for call in calls]
|
||||
|
||||
assert any("meeting_users" in sql for sql in sql_statements)
|
||||
assert any("meeting_records" in sql for sql in sql_statements)
|
||||
assert any("meeting_conclusions" in sql for sql in sql_statements)
|
||||
assert any("meeting_action_items" in sql for sql in sql_statements)
|
||||
|
||||
|
||||
class TestDatabaseHelpers:
|
||||
"""Tests for database helper functions."""
|
||||
|
||||
@patch("app.database.connection_pool")
|
||||
def test_get_db_connection_returns_connection(self, mock_pool):
|
||||
"""Test that get_db_connection returns a valid connection."""
|
||||
mock_conn = MagicMock()
|
||||
mock_pool.get_connection.return_value = mock_conn
|
||||
|
||||
from app.database import get_db_connection
|
||||
|
||||
with get_db_connection() as conn:
|
||||
assert conn == mock_conn
|
||||
|
||||
mock_conn.close.assert_called_once()
|
||||
|
||||
@patch("app.database.connection_pool")
|
||||
def test_get_db_cursor_with_commit(self, mock_pool):
|
||||
"""Test that get_db_cursor commits when specified."""
|
||||
mock_conn = MagicMock()
|
||||
mock_cursor = MagicMock()
|
||||
mock_pool.get_connection.return_value = mock_conn
|
||||
mock_conn.cursor.return_value = mock_cursor
|
||||
|
||||
from app.database import get_db_cursor
|
||||
|
||||
with get_db_cursor(commit=True) as cursor:
|
||||
cursor.execute("SELECT 1")
|
||||
|
||||
mock_conn.commit.assert_called_once()
|
||||
mock_cursor.close.assert_called_once()
|
||||
Reference in New Issue
Block a user