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:
319
frontend/components/__tests__/OnboardingForm.test.tsx
Normal file
319
frontend/components/__tests__/OnboardingForm.test.tsx
Normal file
@@ -0,0 +1,319 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
||||
import { render, screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import OnboardingForm from '../OnboardingForm'
|
||||
import { onboardingService } from '../../services/onboarding.service'
|
||||
import type { OnboardingResponse } from '../../types/onboarding'
|
||||
|
||||
// Mock the onboarding service
|
||||
vi.mock('../../services/onboarding.service', () => ({
|
||||
onboardingService: {
|
||||
onboardEmployee: vi.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
// Mock Next.js navigation
|
||||
vi.mock('next/navigation', () => ({
|
||||
useRouter: () => ({
|
||||
push: vi.fn(),
|
||||
refresh: vi.fn(),
|
||||
}),
|
||||
}))
|
||||
|
||||
describe('OnboardingForm', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('Rendering', () => {
|
||||
it('should render all required fields', () => {
|
||||
render(<OnboardingForm />)
|
||||
|
||||
// 必填欄位
|
||||
expect(screen.getByLabelText(/Resume ID/i)).toBeInTheDocument()
|
||||
expect(screen.getByLabelText(/Keycloak User ID/i)).toBeInTheDocument()
|
||||
expect(screen.getByLabelText(/Keycloak Username/i)).toBeInTheDocument()
|
||||
expect(screen.getByLabelText(/Hire Date/i)).toBeInTheDocument()
|
||||
|
||||
// 選填欄位
|
||||
expect(screen.getByLabelText(/Storage Quota \(GB\)/i)).toBeInTheDocument()
|
||||
expect(screen.getByLabelText(/Email Quota \(MB\)/i)).toBeInTheDocument()
|
||||
|
||||
// 提交按鈕
|
||||
expect(screen.getByRole('button', { name: /Submit/i })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render department assignment section', () => {
|
||||
render(<OnboardingForm />)
|
||||
|
||||
expect(screen.getByText(/Department Assignments/i)).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: /Add Department/i })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render role assignment section', () => {
|
||||
render(<OnboardingForm />)
|
||||
|
||||
expect(screen.getByText(/Role Assignments/i)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Form Validation', () => {
|
||||
it('should show validation errors for required fields', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<OnboardingForm />)
|
||||
|
||||
const submitButton = screen.getByRole('button', { name: /Submit/i })
|
||||
await user.click(submitButton)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/Resume ID is required/i)).toBeInTheDocument()
|
||||
expect(screen.getByText(/Keycloak User ID is required/i)).toBeInTheDocument()
|
||||
expect(screen.getByText(/Keycloak Username is required/i)).toBeInTheDocument()
|
||||
expect(screen.getByText(/Hire Date is required/i)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('should validate Keycloak User ID format (UUID)', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<OnboardingForm />)
|
||||
|
||||
const keycloakUserIdInput = screen.getByLabelText(/Keycloak User ID/i)
|
||||
await user.type(keycloakUserIdInput, 'invalid-uuid')
|
||||
|
||||
const submitButton = screen.getByRole('button', { name: /Submit/i })
|
||||
await user.click(submitButton)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/Invalid UUID format/i)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('should validate storage quota is a positive number', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<OnboardingForm />)
|
||||
|
||||
const storageQuotaInput = screen.getByLabelText(/Storage Quota \(GB\)/i)
|
||||
await user.clear(storageQuotaInput)
|
||||
await user.type(storageQuotaInput, '-10')
|
||||
|
||||
const submitButton = screen.getByRole('button', { name: /Submit/i })
|
||||
await user.click(submitButton)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/Storage quota must be positive/i)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('Department Assignment', () => {
|
||||
it('should add a new department assignment', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<OnboardingForm />)
|
||||
|
||||
const addButton = screen.getByRole('button', { name: /Add Department/i })
|
||||
await user.click(addButton)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByLabelText(/Department ID/i)).toBeInTheDocument()
|
||||
expect(screen.getByLabelText(/Position/i)).toBeInTheDocument()
|
||||
expect(screen.getByLabelText(/Membership Type/i)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('should remove a department assignment', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<OnboardingForm />)
|
||||
|
||||
// 添加部門
|
||||
const addButton = screen.getByRole('button', { name: /Add Department/i })
|
||||
await user.click(addButton)
|
||||
|
||||
// 刪除部門
|
||||
const removeButton = screen.getByRole('button', { name: /Remove/i })
|
||||
await user.click(removeButton)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByLabelText(/Department ID/i)).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('should validate department assignment fields', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<OnboardingForm />)
|
||||
|
||||
const addButton = screen.getByRole('button', { name: /Add Department/i })
|
||||
await user.click(addButton)
|
||||
|
||||
const submitButton = screen.getByRole('button', { name: /Submit/i })
|
||||
await user.click(submitButton)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/Department ID is required/i)).toBeInTheDocument()
|
||||
expect(screen.getByText(/Position is required/i)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('Form Submission', () => {
|
||||
it('should submit form successfully with valid data', async () => {
|
||||
const user = userEvent.setup()
|
||||
const mockResponse: OnboardingResponse = {
|
||||
message: 'Employee onboarded successfully',
|
||||
employee: {
|
||||
tenant_id: 1,
|
||||
seq_no: 1,
|
||||
tenant_emp_code: 'PWD0001',
|
||||
keycloak_user_id: '550e8400-e29b-41d4-a716-446655440000',
|
||||
keycloak_username: 'wang.ming',
|
||||
name: '王明',
|
||||
hire_date: '2026-02-21',
|
||||
},
|
||||
summary: {
|
||||
departments_assigned: 1,
|
||||
roles_assigned: 2,
|
||||
services_enabled: 5,
|
||||
},
|
||||
}
|
||||
|
||||
vi.mocked(onboardingService.onboardEmployee).mockResolvedValueOnce(mockResponse)
|
||||
|
||||
render(<OnboardingForm />)
|
||||
|
||||
// 填寫表單
|
||||
await user.type(screen.getByLabelText(/Resume ID/i), '1')
|
||||
await user.type(
|
||||
screen.getByLabelText(/Keycloak User ID/i),
|
||||
'550e8400-e29b-41d4-a716-446655440000'
|
||||
)
|
||||
await user.type(screen.getByLabelText(/Keycloak Username/i), 'wang.ming')
|
||||
await user.type(screen.getByLabelText(/Hire Date/i), '2026-02-21')
|
||||
|
||||
// 提交表單
|
||||
const submitButton = screen.getByRole('button', { name: /Submit/i })
|
||||
await user.click(submitButton)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onboardingService.onboardEmployee).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
resume_id: 1,
|
||||
keycloak_user_id: '550e8400-e29b-41d4-a716-446655440000',
|
||||
keycloak_username: 'wang.ming',
|
||||
hire_date: '2026-02-21',
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
// 驗證成功訊息
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/Employee onboarded successfully/i)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('should handle API errors gracefully', async () => {
|
||||
const user = userEvent.setup()
|
||||
const errorMessage = 'Resume ID not found'
|
||||
|
||||
vi.mocked(onboardingService.onboardEmployee).mockRejectedValueOnce({
|
||||
response: {
|
||||
data: { detail: errorMessage },
|
||||
status: 404,
|
||||
},
|
||||
})
|
||||
|
||||
render(<OnboardingForm />)
|
||||
|
||||
// 填寫表單
|
||||
await user.type(screen.getByLabelText(/Resume ID/i), '999')
|
||||
await user.type(
|
||||
screen.getByLabelText(/Keycloak User ID/i),
|
||||
'550e8400-e29b-41d4-a716-446655440000'
|
||||
)
|
||||
await user.type(screen.getByLabelText(/Keycloak Username/i), 'test.user')
|
||||
await user.type(screen.getByLabelText(/Hire Date/i), '2026-02-21')
|
||||
|
||||
// 提交表單
|
||||
const submitButton = screen.getByRole('button', { name: /Submit/i })
|
||||
await user.click(submitButton)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(errorMessage)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('should show loading state during submission', async () => {
|
||||
const user = userEvent.setup()
|
||||
|
||||
// 模擬延遲的 API 回應
|
||||
vi.mocked(onboardingService.onboardEmployee).mockImplementation(
|
||||
() => new Promise((resolve) => setTimeout(resolve, 100))
|
||||
)
|
||||
|
||||
render(<OnboardingForm />)
|
||||
|
||||
// 填寫表單
|
||||
await user.type(screen.getByLabelText(/Resume ID/i), '1')
|
||||
await user.type(
|
||||
screen.getByLabelText(/Keycloak User ID/i),
|
||||
'550e8400-e29b-41d4-a716-446655440000'
|
||||
)
|
||||
await user.type(screen.getByLabelText(/Keycloak Username/i), 'wang.ming')
|
||||
await user.type(screen.getByLabelText(/Hire Date/i), '2026-02-21')
|
||||
|
||||
// 提交表單
|
||||
const submitButton = screen.getByRole('button', { name: /Submit/i })
|
||||
await user.click(submitButton)
|
||||
|
||||
// 驗證載入狀態
|
||||
expect(screen.getByText(/Submitting.../i)).toBeInTheDocument()
|
||||
expect(submitButton).toBeDisabled()
|
||||
})
|
||||
|
||||
it('should reset form after successful submission', async () => {
|
||||
const user = userEvent.setup()
|
||||
const mockResponse: OnboardingResponse = {
|
||||
message: 'Employee onboarded successfully',
|
||||
employee: {
|
||||
tenant_id: 1,
|
||||
seq_no: 1,
|
||||
tenant_emp_code: 'PWD0001',
|
||||
keycloak_user_id: '550e8400-e29b-41d4-a716-446655440000',
|
||||
keycloak_username: 'wang.ming',
|
||||
name: '王明',
|
||||
hire_date: '2026-02-21',
|
||||
},
|
||||
summary: {
|
||||
departments_assigned: 0,
|
||||
roles_assigned: 0,
|
||||
services_enabled: 5,
|
||||
},
|
||||
}
|
||||
|
||||
vi.mocked(onboardingService.onboardEmployee).mockResolvedValueOnce(mockResponse)
|
||||
|
||||
render(<OnboardingForm />)
|
||||
|
||||
// 填寫表單
|
||||
const resumeIdInput = screen.getByLabelText(/Resume ID/i) as HTMLInputElement
|
||||
await user.type(resumeIdInput, '1')
|
||||
await user.type(
|
||||
screen.getByLabelText(/Keycloak User ID/i),
|
||||
'550e8400-e29b-41d4-a716-446655440000'
|
||||
)
|
||||
await user.type(screen.getByLabelText(/Keycloak Username/i), 'wang.ming')
|
||||
await user.type(screen.getByLabelText(/Hire Date/i), '2026-02-21')
|
||||
|
||||
// 提交表單
|
||||
const submitButton = screen.getByRole('button', { name: /Submit/i })
|
||||
await user.click(submitButton)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/Employee onboarded successfully/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// 驗證表單已重置
|
||||
await waitFor(() => {
|
||||
expect(resumeIdInput.value).toBe('')
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user