Initial commit: KPI Management System Backend
Features: - FastAPI backend with JWT authentication - MySQL database with SQLAlchemy ORM - KPI workflow: draft → pending → approved → evaluation → completed - Ollama LLM API integration for AI features - Gitea API integration for version control - Complete API endpoints for KPI, dashboard, notifications Tables: KPI_D_* prefix naming convention 🤖 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
tests/__init__.py
Normal file
1
tests/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
# Tests
|
||||
126
tests/conftest.py
Normal file
126
tests/conftest.py
Normal file
@@ -0,0 +1,126 @@
|
||||
"""
|
||||
共用測試 Fixture
|
||||
"""
|
||||
import pytest
|
||||
from typing import Generator
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import sessionmaker, Session
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app.main import app
|
||||
from app.core.database import Base, get_db
|
||||
from app.core.security import create_access_token, get_password_hash
|
||||
from app.models.employee import Employee
|
||||
from app.models.department import Department
|
||||
|
||||
# 測試資料庫 URL (使用 SQLite 進行測試)
|
||||
TEST_DATABASE_URL = "sqlite:///./test.db"
|
||||
|
||||
# 建立測試引擎
|
||||
engine = create_engine(TEST_DATABASE_URL, connect_args={"check_same_thread": False})
|
||||
TestingSessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
|
||||
|
||||
|
||||
@pytest.fixture(scope="session", autouse=True)
|
||||
def setup_database():
|
||||
"""建立測試資料庫"""
|
||||
Base.metadata.create_all(bind=engine)
|
||||
yield
|
||||
Base.metadata.drop_all(bind=engine)
|
||||
|
||||
|
||||
@pytest.fixture(scope="function")
|
||||
def db() -> Generator[Session, None, None]:
|
||||
"""
|
||||
每個測試函式使用獨立的資料庫 Session
|
||||
測試結束後自動 rollback
|
||||
"""
|
||||
connection = engine.connect()
|
||||
transaction = connection.begin()
|
||||
|
||||
session = TestingSessionLocal(bind=connection)
|
||||
|
||||
yield session
|
||||
|
||||
session.close()
|
||||
transaction.rollback()
|
||||
connection.close()
|
||||
|
||||
|
||||
@pytest.fixture(scope="function")
|
||||
def client(db: Session) -> Generator[TestClient, None, None]:
|
||||
"""FastAPI 測試客戶端"""
|
||||
|
||||
def override_get_db():
|
||||
yield db
|
||||
|
||||
app.dependency_overrides[get_db] = override_get_db
|
||||
|
||||
with TestClient(app) as c:
|
||||
yield c
|
||||
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def test_department(db: Session) -> Department:
|
||||
"""建立測試部門"""
|
||||
dept = Department(
|
||||
code="TEST_DEPT",
|
||||
name="測試部門",
|
||||
level="DEPT",
|
||||
is_active=True,
|
||||
)
|
||||
db.add(dept)
|
||||
db.flush()
|
||||
return dept
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def test_employee(db: Session, test_department: Department) -> Employee:
|
||||
"""建立測試員工"""
|
||||
employee = Employee(
|
||||
employee_no="TEST001",
|
||||
name="測試員工",
|
||||
email="test@example.com",
|
||||
password_hash=get_password_hash("password123"),
|
||||
department_id=test_department.id,
|
||||
job_title="工程師",
|
||||
role="employee",
|
||||
status="active",
|
||||
)
|
||||
db.add(employee)
|
||||
db.flush()
|
||||
return employee
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def test_manager(db: Session, test_department: Department) -> Employee:
|
||||
"""建立測試主管"""
|
||||
manager = Employee(
|
||||
employee_no="MGR001",
|
||||
name="測試主管",
|
||||
email="manager@example.com",
|
||||
password_hash=get_password_hash("password123"),
|
||||
department_id=test_department.id,
|
||||
job_title="經理",
|
||||
role="manager",
|
||||
status="active",
|
||||
)
|
||||
db.add(manager)
|
||||
db.flush()
|
||||
return manager
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def auth_headers(test_employee: Employee) -> dict:
|
||||
"""取得認證 Headers"""
|
||||
token = create_access_token({"sub": str(test_employee.id)})
|
||||
return {"Authorization": f"Bearer {token}"}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def manager_headers(test_manager: Employee) -> dict:
|
||||
"""取得主管認證 Headers"""
|
||||
token = create_access_token({"sub": str(test_manager.id)})
|
||||
return {"Authorization": f"Bearer {token}"}
|
||||
204
tests/factories.py
Normal file
204
tests/factories.py
Normal file
@@ -0,0 +1,204 @@
|
||||
"""
|
||||
測試資料工廠
|
||||
"""
|
||||
from datetime import date
|
||||
from typing import Optional
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.security import get_password_hash
|
||||
from app.models.department import Department
|
||||
from app.models.employee import Employee
|
||||
from app.models.kpi_period import KPIPeriod
|
||||
from app.models.kpi_template import KPITemplate
|
||||
from app.models.kpi_sheet import KPISheet
|
||||
from app.models.kpi_item import KPIItem
|
||||
|
||||
|
||||
class DepartmentFactory:
|
||||
"""部門工廠"""
|
||||
|
||||
_counter = 0
|
||||
|
||||
@classmethod
|
||||
def create(
|
||||
cls,
|
||||
db: Session,
|
||||
code: Optional[str] = None,
|
||||
name: Optional[str] = None,
|
||||
level: str = "DEPT",
|
||||
parent_id: Optional[int] = None,
|
||||
) -> Department:
|
||||
cls._counter += 1
|
||||
|
||||
dept = Department(
|
||||
code=code or f"DEPT{cls._counter:03d}",
|
||||
name=name or f"測試部門{cls._counter}",
|
||||
level=level,
|
||||
parent_id=parent_id,
|
||||
is_active=True,
|
||||
)
|
||||
db.add(dept)
|
||||
db.flush()
|
||||
return dept
|
||||
|
||||
|
||||
class EmployeeFactory:
|
||||
"""員工工廠"""
|
||||
|
||||
_counter = 0
|
||||
|
||||
@classmethod
|
||||
def create(
|
||||
cls,
|
||||
db: Session,
|
||||
employee_no: Optional[str] = None,
|
||||
name: Optional[str] = None,
|
||||
department_id: Optional[int] = None,
|
||||
manager_id: Optional[int] = None,
|
||||
role: str = "employee",
|
||||
status: str = "active",
|
||||
) -> Employee:
|
||||
cls._counter += 1
|
||||
|
||||
# 如果沒有指定部門,建立一個
|
||||
if not department_id:
|
||||
dept = DepartmentFactory.create(db)
|
||||
department_id = dept.id
|
||||
|
||||
employee = Employee(
|
||||
employee_no=employee_no or f"EMP{cls._counter:05d}",
|
||||
name=name or f"測試員工{cls._counter}",
|
||||
email=f"test{cls._counter}@example.com",
|
||||
password_hash=get_password_hash("password123"),
|
||||
department_id=department_id,
|
||||
manager_id=manager_id,
|
||||
job_title="工程師",
|
||||
status=status,
|
||||
role=role,
|
||||
)
|
||||
db.add(employee)
|
||||
db.flush()
|
||||
return employee
|
||||
|
||||
|
||||
class KPIPeriodFactory:
|
||||
"""KPI 期間工廠"""
|
||||
|
||||
_counter = 0
|
||||
|
||||
@classmethod
|
||||
def create(
|
||||
cls,
|
||||
db: Session,
|
||||
code: Optional[str] = None,
|
||||
status: str = "setting",
|
||||
year: int = 2024,
|
||||
half: int = 2,
|
||||
) -> KPIPeriod:
|
||||
cls._counter += 1
|
||||
|
||||
if half == 1:
|
||||
start = date(year, 1, 1)
|
||||
end = date(year, 6, 30)
|
||||
setting_end = date(year, 1, 14)
|
||||
else:
|
||||
start = date(year, 7, 1)
|
||||
end = date(year, 12, 31)
|
||||
setting_end = date(year, 7, 14)
|
||||
|
||||
period = KPIPeriod(
|
||||
code=code or f"{year}H{half}_{cls._counter}",
|
||||
name=f"{year}年{'上' if half == 1 else '下'}半年",
|
||||
start_date=start,
|
||||
end_date=end,
|
||||
setting_start=start,
|
||||
setting_end=setting_end,
|
||||
status=status,
|
||||
)
|
||||
db.add(period)
|
||||
db.flush()
|
||||
return period
|
||||
|
||||
|
||||
class KPITemplateFactory:
|
||||
"""KPI 範本工廠"""
|
||||
|
||||
_counter = 0
|
||||
|
||||
@classmethod
|
||||
def create(
|
||||
cls,
|
||||
db: Session,
|
||||
code: Optional[str] = None,
|
||||
name: Optional[str] = None,
|
||||
category: str = "financial",
|
||||
) -> KPITemplate:
|
||||
cls._counter += 1
|
||||
|
||||
template = KPITemplate(
|
||||
code=code or f"TPL{cls._counter:03d}",
|
||||
name=name or f"測試 KPI 範本 {cls._counter}",
|
||||
category=category,
|
||||
default_weight=20,
|
||||
level0_desc="未達標",
|
||||
level1_desc="基本達成",
|
||||
level2_desc="達成目標",
|
||||
level3_desc="挑戰目標",
|
||||
level4_desc="超越目標",
|
||||
is_active=True,
|
||||
)
|
||||
db.add(template)
|
||||
db.flush()
|
||||
return template
|
||||
|
||||
|
||||
class KPISheetFactory:
|
||||
"""KPI 表單工廠"""
|
||||
|
||||
@classmethod
|
||||
def create(
|
||||
cls,
|
||||
db: Session,
|
||||
employee: Optional[Employee] = None,
|
||||
period: Optional[KPIPeriod] = None,
|
||||
status: str = "draft",
|
||||
with_items: bool = True,
|
||||
) -> KPISheet:
|
||||
if not employee:
|
||||
employee = EmployeeFactory.create(db)
|
||||
if not period:
|
||||
period = KPIPeriodFactory.create(db)
|
||||
|
||||
sheet = KPISheet(
|
||||
employee_id=employee.id,
|
||||
period_id=period.id,
|
||||
department_id=employee.department_id,
|
||||
status=status,
|
||||
)
|
||||
db.add(sheet)
|
||||
db.flush()
|
||||
|
||||
# 建立 KPI 項目(權重總和 100%)
|
||||
if with_items:
|
||||
weights = [30, 25, 25, 20]
|
||||
categories = ["financial", "customer", "internal", "learning"]
|
||||
|
||||
for i, (weight, category) in enumerate(zip(weights, categories)):
|
||||
template = KPITemplateFactory.create(db, category=category)
|
||||
item = KPIItem(
|
||||
sheet_id=sheet.id,
|
||||
template_id=template.id,
|
||||
sort_order=i,
|
||||
name=f"KPI 項目 {i + 1}",
|
||||
category=category,
|
||||
weight=weight,
|
||||
level0_criteria="未達標",
|
||||
level1_criteria="基本達成",
|
||||
level2_criteria="達成目標",
|
||||
level3_criteria="挑戰目標",
|
||||
level4_criteria="超越目標",
|
||||
)
|
||||
db.add(item)
|
||||
|
||||
db.flush()
|
||||
return sheet
|
||||
97
tests/test_auth.py
Normal file
97
tests/test_auth.py
Normal file
@@ -0,0 +1,97 @@
|
||||
"""
|
||||
認證 API 測試
|
||||
"""
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.security import get_password_hash
|
||||
from tests.factories import EmployeeFactory
|
||||
|
||||
|
||||
class TestAuthAPI:
|
||||
"""認證 API 測試"""
|
||||
|
||||
def test_login_success(self, client: TestClient, db: Session):
|
||||
"""測試:登入成功"""
|
||||
# Arrange
|
||||
employee = EmployeeFactory.create(db)
|
||||
|
||||
# Act
|
||||
response = client.post(
|
||||
"/api/auth/login",
|
||||
json={
|
||||
"employee_no": employee.employee_no,
|
||||
"password": "password123",
|
||||
},
|
||||
)
|
||||
|
||||
# Assert
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert "access_token" in data
|
||||
assert "refresh_token" in data
|
||||
assert data["token_type"] == "bearer"
|
||||
|
||||
def test_login_wrong_password(self, client: TestClient, db: Session):
|
||||
"""測試:密碼錯誤"""
|
||||
# Arrange
|
||||
employee = EmployeeFactory.create(db)
|
||||
|
||||
# Act
|
||||
response = client.post(
|
||||
"/api/auth/login",
|
||||
json={
|
||||
"employee_no": employee.employee_no,
|
||||
"password": "wrong_password",
|
||||
},
|
||||
)
|
||||
|
||||
# Assert
|
||||
assert response.status_code == 401
|
||||
|
||||
def test_login_user_not_found(self, client: TestClient):
|
||||
"""測試:使用者不存在"""
|
||||
response = client.post(
|
||||
"/api/auth/login",
|
||||
json={
|
||||
"employee_no": "NOTEXIST",
|
||||
"password": "password123",
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 401
|
||||
|
||||
def test_login_inactive_user(self, client: TestClient, db: Session):
|
||||
"""測試:帳號停用"""
|
||||
# Arrange
|
||||
employee = EmployeeFactory.create(db, status="inactive")
|
||||
|
||||
# Act
|
||||
response = client.post(
|
||||
"/api/auth/login",
|
||||
json={
|
||||
"employee_no": employee.employee_no,
|
||||
"password": "password123",
|
||||
},
|
||||
)
|
||||
|
||||
# Assert
|
||||
assert response.status_code == 401
|
||||
|
||||
def test_get_me_success(
|
||||
self, client: TestClient, auth_headers: dict, test_employee
|
||||
):
|
||||
"""測試:取得當前使用者"""
|
||||
response = client.get("/api/auth/me", headers=auth_headers)
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["id"] == test_employee.id
|
||||
assert data["employee_no"] == test_employee.employee_no
|
||||
|
||||
def test_get_me_unauthorized(self, client: TestClient):
|
||||
"""測試:未認證"""
|
||||
response = client.get("/api/auth/me")
|
||||
|
||||
assert response.status_code == 403
|
||||
140
tests/test_kpi.py
Normal file
140
tests/test_kpi.py
Normal file
@@ -0,0 +1,140 @@
|
||||
"""
|
||||
KPI API 測試
|
||||
"""
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from tests.factories import (
|
||||
EmployeeFactory,
|
||||
KPIPeriodFactory,
|
||||
KPISheetFactory,
|
||||
)
|
||||
|
||||
|
||||
class TestKPISheetAPI:
|
||||
"""KPI 表單 API 測試"""
|
||||
|
||||
def test_create_sheet_success(
|
||||
self, client: TestClient, db: Session, auth_headers: dict
|
||||
):
|
||||
"""測試:建立 KPI 表單"""
|
||||
# Arrange
|
||||
period = KPIPeriodFactory.create(db, status="setting")
|
||||
db.commit()
|
||||
|
||||
payload = {
|
||||
"period_id": period.id,
|
||||
"items": [
|
||||
{
|
||||
"name": "營收達成率",
|
||||
"category": "financial",
|
||||
"weight": 50,
|
||||
},
|
||||
{
|
||||
"name": "客戶滿意度",
|
||||
"category": "customer",
|
||||
"weight": 50,
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
# Act
|
||||
response = client.post(
|
||||
"/api/kpi/sheets",
|
||||
json=payload,
|
||||
headers=auth_headers,
|
||||
)
|
||||
|
||||
# Assert
|
||||
assert response.status_code == 201
|
||||
data = response.json()
|
||||
assert data["status"] == "draft"
|
||||
assert len(data["items"]) == 2
|
||||
|
||||
def test_get_my_sheets(
|
||||
self, client: TestClient, db: Session, auth_headers: dict, test_employee
|
||||
):
|
||||
"""測試:取得我的 KPI 表單"""
|
||||
# Arrange
|
||||
period = KPIPeriodFactory.create(db)
|
||||
KPISheetFactory.create(db, employee=test_employee, period=period)
|
||||
db.commit()
|
||||
|
||||
# Act
|
||||
response = client.get("/api/kpi/sheets/my", headers=auth_headers)
|
||||
|
||||
# Assert
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert len(data) >= 1
|
||||
|
||||
def test_get_sheet_not_found(self, client: TestClient, auth_headers: dict):
|
||||
"""測試:查詢不存在的表單"""
|
||||
response = client.get("/api/kpi/sheets/99999", headers=auth_headers)
|
||||
|
||||
assert response.status_code == 404
|
||||
|
||||
def test_submit_sheet_success(
|
||||
self, client: TestClient, db: Session, auth_headers: dict, test_employee
|
||||
):
|
||||
"""測試:提交 KPI 審核"""
|
||||
# Arrange
|
||||
period = KPIPeriodFactory.create(db, status="setting")
|
||||
sheet = KPISheetFactory.create(
|
||||
db, employee=test_employee, period=period, status="draft", with_items=True
|
||||
)
|
||||
db.commit()
|
||||
|
||||
# Act
|
||||
response = client.post(
|
||||
f"/api/kpi/sheets/{sheet.id}/submit",
|
||||
headers=auth_headers,
|
||||
)
|
||||
|
||||
# Assert
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["status"] == "pending"
|
||||
|
||||
def test_submit_sheet_weight_invalid(
|
||||
self, client: TestClient, db: Session, auth_headers: dict, test_employee
|
||||
):
|
||||
"""測試:權重不正確提交應失敗"""
|
||||
# Arrange
|
||||
period = KPIPeriodFactory.create(db, status="setting")
|
||||
sheet = KPISheetFactory.create(
|
||||
db,
|
||||
employee=test_employee,
|
||||
period=period,
|
||||
status="draft",
|
||||
with_items=False, # 不建立項目
|
||||
)
|
||||
db.commit()
|
||||
|
||||
# Act
|
||||
response = client.post(
|
||||
f"/api/kpi/sheets/{sheet.id}/submit",
|
||||
headers=auth_headers,
|
||||
)
|
||||
|
||||
# Assert
|
||||
assert response.status_code == 400
|
||||
|
||||
|
||||
class TestKPIPeriodAPI:
|
||||
"""KPI 期間 API 測試"""
|
||||
|
||||
def test_list_periods(self, client: TestClient, db: Session, auth_headers: dict):
|
||||
"""測試:取得期間列表"""
|
||||
# Arrange
|
||||
KPIPeriodFactory.create(db)
|
||||
db.commit()
|
||||
|
||||
# Act
|
||||
response = client.get("/api/kpi/periods", headers=auth_headers)
|
||||
|
||||
# Assert
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert len(data) >= 1
|
||||
Reference in New Issue
Block a user