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:
2026-02-23 20:12:43 +08:00
commit 360533393f
386 changed files with 70353 additions and 0 deletions

View 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