feat: HR Portal - Complete Multi-Tenant System with Redis Session Storage
Major Features: - ✅ Multi-tenant architecture (tenant isolation) - ✅ Employee CRUD with lifecycle management (onboarding/offboarding) - ✅ Department tree structure with email domain management - ✅ Company info management (single-record editing) - ✅ System functions CRUD (permission management) - ✅ Email account management (multi-account per employee) - ✅ Keycloak SSO integration (auth.lab.taipei) - ✅ Redis session storage (10.1.0.254:6379) - Solves Cookie 4KB limitation - Cross-system session sharing - Sliding expiration (8 hours) - Automatic token refresh Technical Stack: Backend: - FastAPI + SQLAlchemy - PostgreSQL 16 (10.1.0.20:5433) - Keycloak Admin API integration - Docker Mailserver integration (SSH) - Alembic migrations Frontend: - Next.js 14 (App Router) - NextAuth 4 with Keycloak Provider - Redis session storage (ioredis) - Tailwind CSS Infrastructure: - Redis 7 (10.1.0.254:6379) - Session + Cache - Keycloak 26.1.0 (auth.lab.taipei) - Docker Mailserver (10.1.0.254) Architecture Highlights: - Session管理由 Keycloak + Redis 統一控制 - 支援多系統 (HR/WebMail/Calendar/Drive/Office) 共享 session - Token 自動刷新,異質服務整合 - 未來可無縫遷移到雲端 Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
0
backend/tests/__init__.py
Normal file
0
backend/tests/__init__.py
Normal file
124
backend/tests/conftest.py
Normal file
124
backend/tests/conftest.py
Normal file
@@ -0,0 +1,124 @@
|
||||
"""
|
||||
pytest 共用 Fixtures
|
||||
提供測試用的資料庫 Session、測試客戶端等
|
||||
"""
|
||||
import sys
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
# 確保 backend 根目錄在 Python path 中
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||
|
||||
# 在導入任何 app 模組前,先將 PostgreSQL JSONB 替換為 JSON (SQLite 相容)
|
||||
from sqlalchemy.dialects.postgresql import JSONB
|
||||
from sqlalchemy import JSON
|
||||
import sqlalchemy.dialects.postgresql as pg_dialect
|
||||
pg_dialect.JSONB = JSON
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
from sqlalchemy.pool import StaticPool
|
||||
|
||||
from app.db.base import Base
|
||||
from app.db.session import get_db
|
||||
from app.main import app
|
||||
|
||||
# ============================================================
|
||||
# 測試資料庫 (使用 SQLite in-memory)
|
||||
# ============================================================
|
||||
SQLALCHEMY_DATABASE_URL = "sqlite://"
|
||||
|
||||
engine = create_engine(
|
||||
SQLALCHEMY_DATABASE_URL,
|
||||
connect_args={"check_same_thread": False},
|
||||
poolclass=StaticPool,
|
||||
)
|
||||
TestingSessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
|
||||
|
||||
|
||||
@pytest.fixture(scope="function")
|
||||
def db():
|
||||
"""提供測試用的資料庫 Session (每個測試獨立)"""
|
||||
Base.metadata.create_all(bind=engine)
|
||||
session = TestingSessionLocal()
|
||||
try:
|
||||
yield session
|
||||
finally:
|
||||
session.close()
|
||||
Base.metadata.drop_all(bind=engine)
|
||||
|
||||
|
||||
@pytest.fixture(scope="function")
|
||||
def client(db):
|
||||
"""提供測試用的 FastAPI TestClient"""
|
||||
def override_get_db():
|
||||
try:
|
||||
yield db
|
||||
finally:
|
||||
pass
|
||||
|
||||
app.dependency_overrides[get_db] = override_get_db
|
||||
with TestClient(app) as c:
|
||||
yield c
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 測試資料 Fixtures
|
||||
# ============================================================
|
||||
|
||||
@pytest.fixture
|
||||
def sample_business_unit(db):
|
||||
"""建立測試用事業部"""
|
||||
from app.models.business_unit import BusinessUnit
|
||||
bu = BusinessUnit(
|
||||
tenant_id=1,
|
||||
name="智能研發服務事業部",
|
||||
code="SMART",
|
||||
email_domain="lab.taipei",
|
||||
description="測試事業部",
|
||||
is_active=True,
|
||||
)
|
||||
db.add(bu)
|
||||
db.commit()
|
||||
db.refresh(bu)
|
||||
return bu
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sample_department(db, sample_business_unit):
|
||||
"""建立測試用部門"""
|
||||
from app.models.department import Department
|
||||
dept = Department(
|
||||
tenant_id=1,
|
||||
business_unit_id=sample_business_unit.id,
|
||||
name="資訊部",
|
||||
code="IT",
|
||||
is_active=True,
|
||||
)
|
||||
db.add(dept)
|
||||
db.commit()
|
||||
db.refresh(dept)
|
||||
return dept
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sample_employee(db, sample_business_unit):
|
||||
"""建立測試用員工"""
|
||||
from app.models.employee import Employee
|
||||
import datetime
|
||||
emp = Employee(
|
||||
tenant_id=1,
|
||||
employee_id="EMP001",
|
||||
username_base="test.user",
|
||||
legal_name="測試用戶",
|
||||
english_name="Test User",
|
||||
hire_date=datetime.date.today(),
|
||||
status="active",
|
||||
)
|
||||
db.add(emp)
|
||||
db.commit()
|
||||
db.refresh(emp)
|
||||
return emp
|
||||
174
backend/tests/test_batch_jobs.py
Normal file
174
backend/tests/test_batch_jobs.py
Normal file
@@ -0,0 +1,174 @@
|
||||
"""
|
||||
批次作業測試 (6.4)
|
||||
測試各批次作業的邏輯
|
||||
"""
|
||||
import datetime
|
||||
import pytest
|
||||
from unittest.mock import patch, MagicMock
|
||||
|
||||
|
||||
class TestBatchLog:
|
||||
"""BatchLog model 測試"""
|
||||
|
||||
def test_create_batch_log(self, db):
|
||||
"""成功建立批次日誌"""
|
||||
from app.models.batch_log import BatchLog
|
||||
log = BatchLog(
|
||||
batch_name="test_batch",
|
||||
status="success",
|
||||
message="測試批次執行成功",
|
||||
started_at=datetime.datetime.utcnow(),
|
||||
finished_at=datetime.datetime.utcnow(),
|
||||
duration_seconds=5,
|
||||
)
|
||||
db.add(log)
|
||||
db.commit()
|
||||
db.refresh(log)
|
||||
|
||||
assert log.id is not None
|
||||
assert log.batch_name == "test_batch"
|
||||
assert log.status == "success"
|
||||
|
||||
def test_batch_log_repr(self, db):
|
||||
"""BatchLog repr 格式"""
|
||||
from app.models.batch_log import BatchLog
|
||||
log = BatchLog(
|
||||
batch_name="daily_quota_check",
|
||||
status="success",
|
||||
started_at=datetime.datetime(2026, 2, 18, 2, 0, 0),
|
||||
)
|
||||
db.add(log)
|
||||
db.commit()
|
||||
assert "daily_quota_check" in repr(log)
|
||||
assert "success" in repr(log)
|
||||
|
||||
|
||||
class TestDailyQuotaCheck:
|
||||
"""每日配額檢查批次測試"""
|
||||
|
||||
def test_run_with_empty_db(self, db):
|
||||
"""空資料庫時應能正常執行"""
|
||||
# side_effect 使每次呼叫 get_db() 都回傳新 iter,避免 iterator 用盡問題
|
||||
with patch("app.db.session.get_db", side_effect=lambda: iter([db])):
|
||||
from app.batch.daily_quota_check import run_daily_quota_check
|
||||
result = run_daily_quota_check()
|
||||
|
||||
assert result["status"] == "success"
|
||||
assert result["summary"]["email_checked"] == 0
|
||||
assert result["summary"]["drive_checked"] == 0
|
||||
|
||||
def test_run_with_email_accounts(self, db, sample_employee):
|
||||
"""有郵件帳號時應正確計數"""
|
||||
from app.models.email_account import EmailAccount
|
||||
# 建立測試郵件帳號
|
||||
account = EmailAccount(
|
||||
tenant_id=1,
|
||||
employee_id=sample_employee.id,
|
||||
email_address="test.user@porscheworld.tw",
|
||||
quota_mb=5120,
|
||||
is_active=True,
|
||||
)
|
||||
db.add(account)
|
||||
db.commit()
|
||||
|
||||
with patch("app.db.session.get_db", side_effect=lambda: iter([db])):
|
||||
from app.batch.daily_quota_check import run_daily_quota_check
|
||||
result = run_daily_quota_check()
|
||||
|
||||
assert result["status"] == "success"
|
||||
assert result["summary"]["email_checked"] == 1
|
||||
|
||||
|
||||
class TestSyncKeycloakUsers:
|
||||
"""Keycloak 同步批次測試"""
|
||||
|
||||
def test_run_with_no_keycloak_connection(self, db, sample_employee):
|
||||
"""Keycloak 無法連線時,batch 應以 failed 狀態記錄"""
|
||||
with patch("app.db.session.get_db", side_effect=lambda: iter([db])):
|
||||
with patch("app.services.keycloak_admin_client.get_keycloak_admin_client") as mock_kc:
|
||||
# 模擬 Keycloak 連線失敗
|
||||
mock_client = MagicMock()
|
||||
mock_client.get_user_by_username.return_value = None
|
||||
mock_kc.return_value = mock_client
|
||||
|
||||
from app.batch.sync_keycloak_users import run_sync_keycloak_users
|
||||
result = run_sync_keycloak_users()
|
||||
|
||||
# 員工在 Keycloak 中不存在 → 記為 not_found_in_keycloak
|
||||
assert result["status"] == "success"
|
||||
assert result["summary"]["not_found_in_keycloak"] >= 0
|
||||
|
||||
def test_sync_disabled_employee(self, db, sample_employee):
|
||||
"""離職員工應同步停用 Keycloak"""
|
||||
# 將員工設為離職
|
||||
sample_employee.status = "terminated"
|
||||
db.commit()
|
||||
|
||||
with patch("app.db.session.get_db", side_effect=lambda: iter([db])):
|
||||
with patch("app.services.keycloak_admin_client.get_keycloak_admin_client") as mock_kc:
|
||||
mock_client = MagicMock()
|
||||
# 模擬 Keycloak 中帳號仍是 enabled
|
||||
mock_client.get_user_by_username.return_value = {
|
||||
"id": "kc-uuid-123",
|
||||
"enabled": True,
|
||||
}
|
||||
mock_client.update_user.return_value = True
|
||||
mock_kc.return_value = mock_client
|
||||
|
||||
from app.batch.sync_keycloak_users import run_sync_keycloak_users
|
||||
result = run_sync_keycloak_users()
|
||||
|
||||
# 應同步停用
|
||||
assert result["summary"]["synced"] == 1
|
||||
mock_client.update_user.assert_called_once_with(
|
||||
"kc-uuid-123", {"enabled": False}
|
||||
)
|
||||
|
||||
|
||||
class TestArchiveAuditLogs:
|
||||
"""審計日誌歸檔批次測試"""
|
||||
|
||||
def test_archive_no_old_logs(self, db):
|
||||
"""沒有舊日誌時應正常執行"""
|
||||
with patch("app.db.session.get_db", side_effect=lambda: iter([db])):
|
||||
from app.batch.archive_audit_logs import run_archive_audit_logs
|
||||
result = run_archive_audit_logs(dry_run=True)
|
||||
|
||||
assert result["status"] == "success"
|
||||
assert result["archived"] == 0
|
||||
|
||||
def test_archive_old_logs_dry_run(self, db, sample_employee):
|
||||
"""dry_run 模式下不刪除資料"""
|
||||
from app.models.audit_log import AuditLog
|
||||
import json
|
||||
# 建立 91 天前的舊日誌
|
||||
old_date = datetime.datetime.utcnow() - datetime.timedelta(days=91)
|
||||
for i in range(3):
|
||||
log = AuditLog(
|
||||
tenant_id=1,
|
||||
action="test_action",
|
||||
resource_type="employee",
|
||||
resource_id=sample_employee.id,
|
||||
performed_by="test_system",
|
||||
performed_at=old_date,
|
||||
details=json.dumps({"test": i}),
|
||||
)
|
||||
db.add(log)
|
||||
db.commit()
|
||||
|
||||
# 確認有 3 筆舊日誌
|
||||
count_before = db.query(AuditLog).count()
|
||||
assert count_before == 3
|
||||
|
||||
with patch("app.db.session.get_db", side_effect=lambda: iter([db])):
|
||||
with patch("os.makedirs"):
|
||||
with patch("builtins.open", MagicMock()):
|
||||
with patch("csv.DictWriter") as mock_writer:
|
||||
mock_writer.return_value.__enter__ = MagicMock()
|
||||
mock_writer.return_value.__exit__ = MagicMock()
|
||||
from app.batch.archive_audit_logs import run_archive_audit_logs
|
||||
result = run_archive_audit_logs(dry_run=True)
|
||||
|
||||
# dry_run 模式下不刪除
|
||||
assert result["archived"] == 3
|
||||
assert result["deleted"] == 0
|
||||
98
backend/tests/test_drive_service.py
Normal file
98
backend/tests/test_drive_service.py
Normal file
@@ -0,0 +1,98 @@
|
||||
"""
|
||||
Drive Service HTTP Client 單元測試 (6.3)
|
||||
使用 mock 測試,不需要實際 Drive Service 上線
|
||||
"""
|
||||
import pytest
|
||||
from unittest.mock import patch, MagicMock
|
||||
from app.services.drive_service import DriveServiceClient, get_drive_quota_by_job_level
|
||||
|
||||
|
||||
class TestGetDriveQuotaByJobLevel:
|
||||
"""職級配額對應測試"""
|
||||
|
||||
def test_junior_quota(self):
|
||||
assert get_drive_quota_by_job_level("Junior") == 50
|
||||
|
||||
def test_mid_quota(self):
|
||||
assert get_drive_quota_by_job_level("Mid") == 100
|
||||
|
||||
def test_senior_quota(self):
|
||||
assert get_drive_quota_by_job_level("Senior") == 200
|
||||
|
||||
def test_manager_quota(self):
|
||||
assert get_drive_quota_by_job_level("Manager") == 500
|
||||
|
||||
def test_unknown_level_defaults_to_junior(self):
|
||||
assert get_drive_quota_by_job_level("Unknown") == 50
|
||||
|
||||
def test_none_defaults_to_junior(self):
|
||||
assert get_drive_quota_by_job_level(None) == 50
|
||||
|
||||
|
||||
class TestDriveServiceClient:
|
||||
"""Drive Service HTTP Client 測試"""
|
||||
|
||||
def setup_method(self):
|
||||
self.client = DriveServiceClient(
|
||||
base_url="https://drive-api.ease.taipei",
|
||||
timeout=5,
|
||||
)
|
||||
|
||||
def test_create_user_service_unavailable(self):
|
||||
"""Drive Service 未上線時回傳 warning 結果"""
|
||||
from requests.exceptions import ConnectionError as RequestsConnectionError
|
||||
with patch.object(self.client.session, "post") as mock_post:
|
||||
mock_post.side_effect = RequestsConnectionError("Connection refused")
|
||||
result = self.client.create_user(
|
||||
tenant_id=1,
|
||||
keycloak_user_id="kc-uuid-123",
|
||||
username="test.user",
|
||||
email="test.user@porscheworld.tw",
|
||||
display_name="Test User",
|
||||
quota_gb=100,
|
||||
)
|
||||
assert result["created"] is False
|
||||
assert result["error"] is not None
|
||||
assert "test.user" in str(result)
|
||||
|
||||
def test_create_user_success(self):
|
||||
"""Drive Service 正常回應時成功建立"""
|
||||
with patch.object(self.client.session, "post") as mock_post:
|
||||
mock_response = MagicMock()
|
||||
mock_response.status_code = 201
|
||||
mock_response.json.return_value = {
|
||||
"id": 42,
|
||||
"username": "test.user",
|
||||
"quota_gb": 100,
|
||||
"drive_url": "https://drive.ease.taipei/test.user",
|
||||
}
|
||||
mock_post.return_value = mock_response
|
||||
|
||||
result = self.client.create_user(
|
||||
tenant_id=1,
|
||||
keycloak_user_id="kc-uuid-123",
|
||||
username="test.user",
|
||||
email="test.user@porscheworld.tw",
|
||||
display_name="Test User",
|
||||
quota_gb=100,
|
||||
)
|
||||
assert result["created"] is True
|
||||
assert result["error"] is None
|
||||
assert result["quota_gb"] == 100
|
||||
|
||||
def test_get_quota_service_unavailable(self):
|
||||
"""Drive Service 未上線時查詢配額回傳 None"""
|
||||
from requests.exceptions import ConnectionError as RequestsConnectionError
|
||||
with patch.object(self.client.session, "get") as mock_get:
|
||||
mock_get.side_effect = RequestsConnectionError("Connection refused")
|
||||
result = self.client.get_quota(drive_user_id=42)
|
||||
assert result is None
|
||||
|
||||
def test_disable_user_service_unavailable(self):
|
||||
"""Drive Service 未上線時停用帳號回傳 False"""
|
||||
from requests.exceptions import ConnectionError as RequestsConnectionError
|
||||
with patch.object(self.client.session, "delete") as mock_delete:
|
||||
mock_delete.side_effect = RequestsConnectionError("Connection refused")
|
||||
result = self.client.disable_user(drive_user_id=42)
|
||||
assert result["disabled"] is False
|
||||
assert result["error"] is not None
|
||||
143
backend/tests/test_employees_api.py
Normal file
143
backend/tests/test_employees_api.py
Normal file
@@ -0,0 +1,143 @@
|
||||
"""
|
||||
員工管理 API 測試 (6.2)
|
||||
測試 /api/v1/employees/ 的 CRUD 操作
|
||||
"""
|
||||
import datetime
|
||||
import pytest
|
||||
|
||||
|
||||
class TestEmployeeListAPI:
|
||||
"""員工列表 API 測試"""
|
||||
|
||||
def test_get_employees_empty(self, client):
|
||||
"""空資料庫回傳空列表"""
|
||||
response = client.get("/api/v1/employees/")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["total"] == 0
|
||||
assert data["items"] == []
|
||||
|
||||
def test_get_employees_with_data(self, client, sample_employee):
|
||||
"""有資料時回傳員工列表"""
|
||||
response = client.get("/api/v1/employees/")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["total"] == 1
|
||||
assert len(data["items"]) == 1
|
||||
assert data["items"][0]["employee_id"] == "EMP001"
|
||||
|
||||
def test_get_employees_pagination(self, client, db, sample_business_unit):
|
||||
"""分頁功能測試"""
|
||||
from app.models.employee import Employee
|
||||
# 建立 5 個員工
|
||||
for i in range(5):
|
||||
emp = Employee(
|
||||
tenant_id=1,
|
||||
employee_id=f"EMP00{i+1}",
|
||||
username_base=f"user{i+1}",
|
||||
legal_name=f"員工{i+1}",
|
||||
hire_date=datetime.date.today(),
|
||||
status="active",
|
||||
)
|
||||
db.add(emp)
|
||||
db.commit()
|
||||
|
||||
# 取第 1 頁,每頁 2 筆
|
||||
response = client.get("/api/v1/employees/?page=1&page_size=2")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["total"] == 5
|
||||
assert len(data["items"]) == 2
|
||||
assert data["total_pages"] == 3
|
||||
|
||||
|
||||
class TestEmployeeCreateAPI:
|
||||
"""員工建立 API 測試"""
|
||||
|
||||
def test_create_employee_success(self, client, sample_business_unit):
|
||||
"""成功建立員工"""
|
||||
payload = {
|
||||
"username_base": "porsche.chen",
|
||||
"legal_name": "陳保時",
|
||||
"english_name": "Porsche Chen",
|
||||
"hire_date": str(datetime.date.today()),
|
||||
"business_unit_id": sample_business_unit.id,
|
||||
"job_title": "軟體工程師",
|
||||
"job_level": "Mid",
|
||||
"email_quota_mb": 5120,
|
||||
}
|
||||
response = client.post("/api/v1/employees/", json=payload)
|
||||
assert response.status_code == 201
|
||||
data = response.json()
|
||||
assert data["username_base"] == "porsche.chen"
|
||||
assert data["legal_name"] == "陳保時"
|
||||
assert data["status"] == "active"
|
||||
assert "employee_id" in data # 自動產生
|
||||
|
||||
def test_create_employee_duplicate_username(self, client, sample_employee, sample_business_unit):
|
||||
"""重複帳號名稱應回傳 400"""
|
||||
payload = {
|
||||
"username_base": "test.user", # 與 sample_employee 相同
|
||||
"legal_name": "另一個用戶",
|
||||
"hire_date": str(datetime.date.today()),
|
||||
"business_unit_id": sample_business_unit.id,
|
||||
"job_title": "工程師",
|
||||
"job_level": "Junior",
|
||||
"email_quota_mb": 1024,
|
||||
}
|
||||
response = client.post("/api/v1/employees/", json=payload)
|
||||
assert response.status_code in [400, 409, 422]
|
||||
|
||||
def test_create_employee_missing_required_fields(self, client):
|
||||
"""缺少必填欄位應回傳 422"""
|
||||
response = client.post("/api/v1/employees/", json={"username_base": "only.this"})
|
||||
assert response.status_code == 422
|
||||
|
||||
|
||||
class TestEmployeeDetailAPI:
|
||||
"""員工詳情 API 測試"""
|
||||
|
||||
def test_get_employee_success(self, client, sample_employee):
|
||||
"""成功取得員工詳情"""
|
||||
response = client.get(f"/api/v1/employees/{sample_employee.id}")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["id"] == sample_employee.id
|
||||
assert data["employee_id"] == "EMP001"
|
||||
|
||||
def test_get_employee_not_found(self, client):
|
||||
"""不存在的員工 ID 應回傳 404"""
|
||||
response = client.get("/api/v1/employees/99999")
|
||||
assert response.status_code == 404
|
||||
|
||||
def test_update_employee_success(self, client, sample_employee):
|
||||
"""成功更新員工資料"""
|
||||
payload = {"legal_name": "更新後姓名", "phone": "02-12345678"}
|
||||
response = client.put(f"/api/v1/employees/{sample_employee.id}", json=payload)
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["legal_name"] == "更新後姓名"
|
||||
assert data["phone"] == "02-12345678"
|
||||
|
||||
def test_delete_employee_success(self, client, sample_employee):
|
||||
"""成功刪除 (停用) 員工"""
|
||||
response = client.delete(f"/api/v1/employees/{sample_employee.id}")
|
||||
assert response.status_code == 200
|
||||
|
||||
|
||||
class TestEmployeeSearchAPI:
|
||||
"""員工搜尋 API 測試"""
|
||||
|
||||
def test_search_by_name(self, client, sample_employee):
|
||||
"""依姓名搜尋"""
|
||||
response = client.get("/api/v1/employees/?search=測試")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["total"] >= 1
|
||||
|
||||
def test_filter_by_status(self, client, sample_employee):
|
||||
"""依狀態篩選"""
|
||||
response = client.get("/api/v1/employees/?status=active")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert all(item["status"] == "active" for item in data["items"])
|
||||
127
backend/tests/test_permissions_api.py
Normal file
127
backend/tests/test_permissions_api.py
Normal file
@@ -0,0 +1,127 @@
|
||||
"""
|
||||
系統權限管理 API 測試 (6.2)
|
||||
測試 /api/v1/permissions/ CRUD 操作
|
||||
"""
|
||||
import pytest
|
||||
|
||||
|
||||
class TestPermissionsAPI:
|
||||
"""系統權限 API 測試"""
|
||||
|
||||
def test_get_permissions_empty(self, client):
|
||||
"""空資料庫回傳空列表"""
|
||||
response = client.get("/api/v1/permissions/")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["total"] == 0
|
||||
|
||||
def test_get_available_systems(self, client):
|
||||
"""取得可授權系統清單"""
|
||||
response = client.get("/api/v1/permissions/systems")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert "systems" in data
|
||||
assert "gitea" in data["systems"]
|
||||
assert "keycloak" in data["systems"]
|
||||
assert "access_levels" in data
|
||||
assert "admin" in data["access_levels"]
|
||||
|
||||
def test_create_permission_success(self, client, sample_employee):
|
||||
"""成功建立權限"""
|
||||
payload = {
|
||||
"employee_id": sample_employee.id,
|
||||
"system_name": "gitea",
|
||||
"access_level": "user",
|
||||
}
|
||||
response = client.post("/api/v1/permissions/", json=payload)
|
||||
assert response.status_code == 201
|
||||
data = response.json()
|
||||
assert data["system_name"] == "gitea"
|
||||
assert data["access_level"] == "user"
|
||||
assert data["employee_id"] == sample_employee.id
|
||||
|
||||
def test_create_permission_duplicate(self, client, sample_employee):
|
||||
"""重複建立同系統權限應回傳 400"""
|
||||
payload = {
|
||||
"employee_id": sample_employee.id,
|
||||
"system_name": "gitea",
|
||||
"access_level": "user",
|
||||
}
|
||||
# 第一次建立
|
||||
client.post("/api/v1/permissions/", json=payload)
|
||||
# 第二次建立同系統
|
||||
response = client.post("/api/v1/permissions/", json=payload)
|
||||
assert response.status_code == 400
|
||||
|
||||
def test_create_permission_invalid_system(self, client, sample_employee):
|
||||
"""無效系統名稱應回傳 422"""
|
||||
payload = {
|
||||
"employee_id": sample_employee.id,
|
||||
"system_name": "invalid_system",
|
||||
"access_level": "user",
|
||||
}
|
||||
response = client.post("/api/v1/permissions/", json=payload)
|
||||
assert response.status_code == 422
|
||||
|
||||
def test_get_employee_permissions(self, client, sample_employee):
|
||||
"""取得員工所有權限"""
|
||||
# 先建立兩個權限
|
||||
for system in ["gitea", "portainer"]:
|
||||
client.post("/api/v1/permissions/", json={
|
||||
"employee_id": sample_employee.id,
|
||||
"system_name": system,
|
||||
"access_level": "user",
|
||||
})
|
||||
|
||||
response = client.get(f"/api/v1/permissions/employees/{sample_employee.id}/permissions")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert len(data) == 2
|
||||
systems = [p["system_name"] for p in data]
|
||||
assert "gitea" in systems
|
||||
assert "portainer" in systems
|
||||
|
||||
def test_update_permission(self, client, sample_employee):
|
||||
"""更新權限層級"""
|
||||
# 建立權限
|
||||
create_resp = client.post("/api/v1/permissions/", json={
|
||||
"employee_id": sample_employee.id,
|
||||
"system_name": "gitea",
|
||||
"access_level": "user",
|
||||
})
|
||||
perm_id = create_resp.json()["id"]
|
||||
|
||||
# 更新為 admin
|
||||
response = client.put(f"/api/v1/permissions/{perm_id}", json={"access_level": "admin"})
|
||||
assert response.status_code == 200
|
||||
assert response.json()["access_level"] == "admin"
|
||||
|
||||
def test_delete_permission(self, client, sample_employee):
|
||||
"""刪除權限"""
|
||||
create_resp = client.post("/api/v1/permissions/", json={
|
||||
"employee_id": sample_employee.id,
|
||||
"system_name": "gitea",
|
||||
"access_level": "user",
|
||||
})
|
||||
perm_id = create_resp.json()["id"]
|
||||
|
||||
response = client.delete(f"/api/v1/permissions/{perm_id}")
|
||||
assert response.status_code == 200
|
||||
|
||||
# 確認已刪除
|
||||
get_resp = client.get(f"/api/v1/permissions/{perm_id}")
|
||||
assert get_resp.status_code == 404
|
||||
|
||||
def test_batch_create_permissions(self, client, sample_employee):
|
||||
"""批次建立權限"""
|
||||
payload = {
|
||||
"employee_id": sample_employee.id,
|
||||
"permissions": [
|
||||
{"system_name": "gitea", "access_level": "user"},
|
||||
{"system_name": "portainer", "access_level": "readonly"},
|
||||
],
|
||||
}
|
||||
response = client.post("/api/v1/permissions/batch", json=payload)
|
||||
assert response.status_code == 201
|
||||
data = response.json()
|
||||
assert len(data) == 2
|
||||
Reference in New Issue
Block a user