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>
472 lines
15 KiB
TypeScript
472 lines
15 KiB
TypeScript
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
|
|
import { render, screen, waitFor } from '@testing-library/react'
|
|
import userEvent from '@testing-library/user-event'
|
|
import EmployeeStatusCard from '../EmployeeStatusCard'
|
|
import { onboardingService } from '../../services/onboarding.service'
|
|
import type { EmployeeStatusResponse } from '../../types/onboarding'
|
|
|
|
// Mock the onboarding service
|
|
vi.mock('../../services/onboarding.service', () => ({
|
|
onboardingService: {
|
|
getEmployeeStatus: vi.fn(),
|
|
offboardEmployee: vi.fn(),
|
|
},
|
|
}))
|
|
|
|
// Mock Next.js navigation
|
|
vi.mock('next/navigation', () => ({
|
|
useRouter: () => ({
|
|
push: vi.fn(),
|
|
refresh: vi.fn(),
|
|
}),
|
|
}))
|
|
|
|
describe('EmployeeStatusCard', () => {
|
|
// Mock window.confirm
|
|
const originalConfirm = window.confirm
|
|
|
|
beforeEach(() => {
|
|
// Mock window.confirm to return true by default
|
|
window.confirm = vi.fn(() => true)
|
|
})
|
|
|
|
afterEach(() => {
|
|
// Restore original confirm
|
|
window.confirm = originalConfirm
|
|
})
|
|
|
|
const mockEmployeeStatus: EmployeeStatusResponse = {
|
|
employee: {
|
|
tenant_id: 1,
|
|
seq_no: 1,
|
|
tenant_emp_code: 'PWD0001',
|
|
name: '王明',
|
|
keycloak_user_id: '550e8400-e29b-41d4-a716-446655440000',
|
|
keycloak_username: 'wang.ming',
|
|
hire_date: '2026-02-21',
|
|
resign_date: null,
|
|
employment_status: 'active',
|
|
storage_quota_gb: 20,
|
|
email_quota_mb: 5120,
|
|
},
|
|
departments: [
|
|
{
|
|
department_id: 9,
|
|
department_name: '玄鐵風能',
|
|
position: '資深工程師',
|
|
membership_type: 'permanent',
|
|
joined_at: '2026-02-21T10:00:00',
|
|
},
|
|
{
|
|
department_id: 10,
|
|
department_name: '國際碳權',
|
|
position: '顧問',
|
|
membership_type: 'concurrent',
|
|
joined_at: '2026-02-21T10:00:00',
|
|
},
|
|
],
|
|
roles: [
|
|
{
|
|
role_id: 1,
|
|
role_name: 'HR管理員',
|
|
role_code: 'HR_ADMIN',
|
|
assigned_at: '2026-02-21T10:00:00',
|
|
},
|
|
{
|
|
role_id: 2,
|
|
role_name: '系統管理員',
|
|
role_code: 'SYS_ADMIN',
|
|
assigned_at: '2026-02-21T10:00:00',
|
|
},
|
|
],
|
|
services: [
|
|
{
|
|
service_id: 1,
|
|
service_name: '單一簽入',
|
|
service_code: 'SSO',
|
|
quota_gb: null,
|
|
quota_mb: null,
|
|
enabled_at: '2026-02-21T10:00:00',
|
|
},
|
|
{
|
|
service_id: 2,
|
|
service_name: '雲端硬碟',
|
|
service_code: 'DRIVE',
|
|
quota_gb: 20,
|
|
quota_mb: null,
|
|
enabled_at: '2026-02-21T10:00:00',
|
|
},
|
|
{
|
|
service_id: 3,
|
|
service_name: '電子郵件',
|
|
service_code: 'EMAIL',
|
|
quota_gb: null,
|
|
quota_mb: 5120,
|
|
enabled_at: '2026-02-21T10:00:00',
|
|
},
|
|
],
|
|
}
|
|
|
|
beforeEach(() => {
|
|
vi.clearAllMocks()
|
|
})
|
|
|
|
describe('Loading State', () => {
|
|
it('should show loading spinner while fetching data', () => {
|
|
vi.mocked(onboardingService.getEmployeeStatus).mockImplementation(
|
|
() => new Promise(() => {}) // Never resolves
|
|
)
|
|
|
|
render(<EmployeeStatusCard tenantId={1} seqNo={1} />)
|
|
|
|
expect(screen.getByText(/Loading/i)).toBeInTheDocument()
|
|
})
|
|
})
|
|
|
|
describe('Employee Basic Information', () => {
|
|
it('should display employee basic information', async () => {
|
|
vi.mocked(onboardingService.getEmployeeStatus).mockResolvedValueOnce(mockEmployeeStatus)
|
|
|
|
render(<EmployeeStatusCard tenantId={1} seqNo={1} />)
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByText('王明')).toBeInTheDocument()
|
|
expect(screen.getByText('PWD0001')).toBeInTheDocument()
|
|
expect(screen.getByText('wang.ming')).toBeInTheDocument()
|
|
expect(screen.getByText(/2026-02-21/)).toBeInTheDocument()
|
|
})
|
|
})
|
|
|
|
it('should display employment status badge', async () => {
|
|
vi.mocked(onboardingService.getEmployeeStatus).mockResolvedValueOnce(mockEmployeeStatus)
|
|
|
|
render(<EmployeeStatusCard tenantId={1} seqNo={1} />)
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByText(/Active/i)).toBeInTheDocument()
|
|
})
|
|
})
|
|
|
|
it('should display storage and email quotas', async () => {
|
|
vi.mocked(onboardingService.getEmployeeStatus).mockResolvedValueOnce(mockEmployeeStatus)
|
|
|
|
render(<EmployeeStatusCard tenantId={1} seqNo={1} />)
|
|
|
|
await waitFor(() => {
|
|
const storageQuotas = screen.getAllByText(/20 GB/i)
|
|
expect(storageQuotas.length).toBeGreaterThan(0)
|
|
|
|
const emailQuotas = screen.getAllByText(/5120 MB/i)
|
|
expect(emailQuotas.length).toBeGreaterThan(0)
|
|
})
|
|
})
|
|
|
|
it('should display Keycloak information', async () => {
|
|
vi.mocked(onboardingService.getEmployeeStatus).mockResolvedValueOnce(mockEmployeeStatus)
|
|
|
|
render(<EmployeeStatusCard tenantId={1} seqNo={1} />)
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByText(/550e8400-e29b-41d4-a716-446655440000/i)).toBeInTheDocument()
|
|
})
|
|
})
|
|
})
|
|
|
|
describe('Department List', () => {
|
|
it('should display all departments', async () => {
|
|
vi.mocked(onboardingService.getEmployeeStatus).mockResolvedValueOnce(mockEmployeeStatus)
|
|
|
|
render(<EmployeeStatusCard tenantId={1} seqNo={1} />)
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByText('玄鐵風能')).toBeInTheDocument()
|
|
expect(screen.getByText('國際碳權')).toBeInTheDocument()
|
|
})
|
|
})
|
|
|
|
it('should display department positions', async () => {
|
|
vi.mocked(onboardingService.getEmployeeStatus).mockResolvedValueOnce(mockEmployeeStatus)
|
|
|
|
render(<EmployeeStatusCard tenantId={1} seqNo={1} />)
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByText('資深工程師')).toBeInTheDocument()
|
|
expect(screen.getByText('顧問')).toBeInTheDocument()
|
|
})
|
|
})
|
|
|
|
it('should display membership types', async () => {
|
|
vi.mocked(onboardingService.getEmployeeStatus).mockResolvedValueOnce(mockEmployeeStatus)
|
|
|
|
render(<EmployeeStatusCard tenantId={1} seqNo={1} />)
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByText(/Permanent/i)).toBeInTheDocument()
|
|
expect(screen.getByText(/Concurrent/i)).toBeInTheDocument()
|
|
})
|
|
})
|
|
|
|
it('should show message when no departments assigned', async () => {
|
|
const statusWithNoDepts: EmployeeStatusResponse = {
|
|
...mockEmployeeStatus,
|
|
departments: [],
|
|
}
|
|
|
|
vi.mocked(onboardingService.getEmployeeStatus).mockResolvedValueOnce(statusWithNoDepts)
|
|
|
|
render(<EmployeeStatusCard tenantId={1} seqNo={1} />)
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByText(/No departments assigned/i)).toBeInTheDocument()
|
|
})
|
|
})
|
|
})
|
|
|
|
describe('Role List', () => {
|
|
it('should display all roles', async () => {
|
|
vi.mocked(onboardingService.getEmployeeStatus).mockResolvedValueOnce(mockEmployeeStatus)
|
|
|
|
render(<EmployeeStatusCard tenantId={1} seqNo={1} />)
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByText('HR管理員')).toBeInTheDocument()
|
|
expect(screen.getByText('系統管理員')).toBeInTheDocument()
|
|
})
|
|
})
|
|
|
|
it('should display role codes', async () => {
|
|
vi.mocked(onboardingService.getEmployeeStatus).mockResolvedValueOnce(mockEmployeeStatus)
|
|
|
|
render(<EmployeeStatusCard tenantId={1} seqNo={1} />)
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByText('HR_ADMIN')).toBeInTheDocument()
|
|
expect(screen.getByText('SYS_ADMIN')).toBeInTheDocument()
|
|
})
|
|
})
|
|
|
|
it('should show message when no roles assigned', async () => {
|
|
const statusWithNoRoles: EmployeeStatusResponse = {
|
|
...mockEmployeeStatus,
|
|
roles: [],
|
|
}
|
|
|
|
vi.mocked(onboardingService.getEmployeeStatus).mockResolvedValueOnce(statusWithNoRoles)
|
|
|
|
render(<EmployeeStatusCard tenantId={1} seqNo={1} />)
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByText(/No roles assigned/i)).toBeInTheDocument()
|
|
})
|
|
})
|
|
})
|
|
|
|
describe('Service List', () => {
|
|
it('should display all enabled services', async () => {
|
|
vi.mocked(onboardingService.getEmployeeStatus).mockResolvedValueOnce(mockEmployeeStatus)
|
|
|
|
render(<EmployeeStatusCard tenantId={1} seqNo={1} />)
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByText('單一簽入')).toBeInTheDocument()
|
|
expect(screen.getByText('雲端硬碟')).toBeInTheDocument()
|
|
expect(screen.getByText('電子郵件')).toBeInTheDocument()
|
|
})
|
|
})
|
|
|
|
it('should display service codes', async () => {
|
|
vi.mocked(onboardingService.getEmployeeStatus).mockResolvedValueOnce(mockEmployeeStatus)
|
|
|
|
render(<EmployeeStatusCard tenantId={1} seqNo={1} />)
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByText('SSO')).toBeInTheDocument()
|
|
expect(screen.getByText('DRIVE')).toBeInTheDocument()
|
|
expect(screen.getByText('EMAIL')).toBeInTheDocument()
|
|
})
|
|
})
|
|
|
|
it('should display service quotas when available', async () => {
|
|
vi.mocked(onboardingService.getEmployeeStatus).mockResolvedValueOnce(mockEmployeeStatus)
|
|
|
|
render(<EmployeeStatusCard tenantId={1} seqNo={1} />)
|
|
|
|
await waitFor(() => {
|
|
// Drive quota
|
|
const driveQuota = screen.getAllByText(/20 GB/i)
|
|
expect(driveQuota.length).toBeGreaterThan(0)
|
|
|
|
// Email quota
|
|
const emailQuota = screen.getAllByText(/5120 MB/i)
|
|
expect(emailQuota.length).toBeGreaterThan(0)
|
|
})
|
|
})
|
|
|
|
it('should show message when no services enabled', async () => {
|
|
const statusWithNoServices: EmployeeStatusResponse = {
|
|
...mockEmployeeStatus,
|
|
services: [],
|
|
}
|
|
|
|
vi.mocked(onboardingService.getEmployeeStatus).mockResolvedValueOnce(statusWithNoServices)
|
|
|
|
render(<EmployeeStatusCard tenantId={1} seqNo={1} />)
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByText(/No services enabled/i)).toBeInTheDocument()
|
|
})
|
|
})
|
|
})
|
|
|
|
describe('Error Handling', () => {
|
|
it('should display error message when API fails', async () => {
|
|
const errorMessage = 'Employee not found'
|
|
vi.mocked(onboardingService.getEmployeeStatus).mockRejectedValueOnce({
|
|
response: {
|
|
data: { detail: errorMessage },
|
|
status: 404,
|
|
},
|
|
})
|
|
|
|
render(<EmployeeStatusCard tenantId={1} seqNo={999} />)
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByText(errorMessage)).toBeInTheDocument()
|
|
})
|
|
})
|
|
|
|
it('should display generic error message for unknown errors', async () => {
|
|
vi.mocked(onboardingService.getEmployeeStatus).mockRejectedValueOnce(new Error('Network error'))
|
|
|
|
render(<EmployeeStatusCard tenantId={1} seqNo={1} />)
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByText(/Failed to load employee status/i)).toBeInTheDocument()
|
|
})
|
|
})
|
|
})
|
|
|
|
describe('Offboard Action', () => {
|
|
it('should display offboard button for active employees', async () => {
|
|
vi.mocked(onboardingService.getEmployeeStatus).mockResolvedValueOnce(mockEmployeeStatus)
|
|
|
|
render(<EmployeeStatusCard tenantId={1} seqNo={1} showActions={true} />)
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByRole('button', { name: /Offboard/i })).toBeInTheDocument()
|
|
})
|
|
})
|
|
|
|
it('should not display offboard button when showActions is false', async () => {
|
|
vi.mocked(onboardingService.getEmployeeStatus).mockResolvedValueOnce(mockEmployeeStatus)
|
|
|
|
render(<EmployeeStatusCard tenantId={1} seqNo={1} showActions={false} />)
|
|
|
|
await waitFor(() => {
|
|
expect(screen.queryByRole('button', { name: /Offboard/i })).not.toBeInTheDocument()
|
|
})
|
|
})
|
|
|
|
it('should call offboardEmployee when offboard button is clicked', async () => {
|
|
const user = userEvent.setup()
|
|
vi.mocked(onboardingService.getEmployeeStatus).mockResolvedValueOnce(mockEmployeeStatus)
|
|
vi.mocked(onboardingService.offboardEmployee).mockResolvedValueOnce({
|
|
message: 'Employee offboarded successfully',
|
|
employee: { tenant_emp_code: 'PWD0001', resign_date: '2026-02-21' },
|
|
summary: { departments_removed: 2, roles_revoked: 2, services_disabled: 3 },
|
|
})
|
|
|
|
render(<EmployeeStatusCard tenantId={1} seqNo={1} showActions={true} />)
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByRole('button', { name: /Offboard/i })).toBeInTheDocument()
|
|
})
|
|
|
|
const offboardButton = screen.getByRole('button', { name: /Offboard/i })
|
|
await user.click(offboardButton)
|
|
|
|
await waitFor(() => {
|
|
expect(onboardingService.offboardEmployee).toHaveBeenCalledWith(1, 1)
|
|
})
|
|
})
|
|
|
|
it('should show success message after offboarding', async () => {
|
|
const user = userEvent.setup()
|
|
const offboardedStatus = {
|
|
...mockEmployeeStatus,
|
|
employee: { ...mockEmployeeStatus.employee, employment_status: 'resigned' as const, resign_date: '2026-02-21' },
|
|
}
|
|
|
|
vi.mocked(onboardingService.getEmployeeStatus)
|
|
.mockResolvedValueOnce(mockEmployeeStatus) // Initial load
|
|
.mockResolvedValueOnce(offboardedStatus) // After offboard refresh
|
|
|
|
vi.mocked(onboardingService.offboardEmployee).mockResolvedValueOnce({
|
|
message: 'Employee offboarded successfully',
|
|
employee: { tenant_emp_code: 'PWD0001', resign_date: '2026-02-21' },
|
|
summary: { departments_removed: 2, roles_revoked: 2, services_disabled: 3 },
|
|
})
|
|
|
|
render(<EmployeeStatusCard tenantId={1} seqNo={1} showActions={true} />)
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByRole('button', { name: /Offboard/i })).toBeInTheDocument()
|
|
})
|
|
|
|
const offboardButton = screen.getByRole('button', { name: /Offboard/i })
|
|
await user.click(offboardButton)
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByText(/Employee offboarded successfully/i)).toBeInTheDocument()
|
|
})
|
|
})
|
|
|
|
it('should handle offboard errors gracefully', async () => {
|
|
const user = userEvent.setup()
|
|
const errorMessage = 'Failed to offboard employee'
|
|
|
|
vi.mocked(onboardingService.getEmployeeStatus).mockResolvedValueOnce(mockEmployeeStatus)
|
|
vi.mocked(onboardingService.offboardEmployee).mockRejectedValueOnce({
|
|
response: {
|
|
data: { detail: errorMessage },
|
|
status: 500,
|
|
},
|
|
})
|
|
|
|
render(<EmployeeStatusCard tenantId={1} seqNo={1} showActions={true} />)
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByRole('button', { name: /Offboard/i })).toBeInTheDocument()
|
|
})
|
|
|
|
const offboardButton = screen.getByRole('button', { name: /Offboard/i })
|
|
await user.click(offboardButton)
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByText(errorMessage)).toBeInTheDocument()
|
|
})
|
|
})
|
|
})
|
|
|
|
describe('Refresh Functionality', () => {
|
|
it('should refresh employee status when refresh is called', async () => {
|
|
vi.mocked(onboardingService.getEmployeeStatus).mockResolvedValue(mockEmployeeStatus)
|
|
|
|
const { rerender } = render(<EmployeeStatusCard tenantId={1} seqNo={1} />)
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByText('王明')).toBeInTheDocument()
|
|
})
|
|
|
|
expect(onboardingService.getEmployeeStatus).toHaveBeenCalledTimes(1)
|
|
|
|
// Trigger refresh by rerendering with key change
|
|
rerender(<EmployeeStatusCard tenantId={1} seqNo={1} key="refresh" />)
|
|
|
|
await waitFor(() => {
|
|
expect(onboardingService.getEmployeeStatus).toHaveBeenCalledTimes(2)
|
|
})
|
|
})
|
|
})
|
|
})
|