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>
346 lines
13 KiB
TypeScript
346 lines
13 KiB
TypeScript
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
|
import { render, screen, waitFor } from '@testing-library/react'
|
|
import userEvent from '@testing-library/user-event'
|
|
import { tenantService } from '../../services/tenant.service'
|
|
import TenantCreateForm from '../TenantCreateForm'
|
|
|
|
// Mock tenant service
|
|
vi.mock('../../services/tenant.service', () => ({
|
|
tenantService: {
|
|
createTenant: vi.fn(),
|
|
},
|
|
}))
|
|
|
|
describe('TenantCreateForm', () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks()
|
|
})
|
|
|
|
describe('Form Rendering', () => {
|
|
it('should render all required form fields', () => {
|
|
// Act
|
|
render(<TenantCreateForm onSuccess={vi.fn()} onCancel={vi.fn()} />)
|
|
|
|
// Assert - Basic Info Section
|
|
expect(screen.getByLabelText(/^tenant code/i)).toBeInTheDocument()
|
|
expect(screen.getByLabelText(/^company name \*$/i)).toBeInTheDocument()
|
|
expect(screen.getByLabelText(/company name \(english\)/i)).toBeInTheDocument()
|
|
expect(screen.getByLabelText(/^tax id$/i)).toBeInTheDocument()
|
|
expect(screen.getByLabelText(/employee prefix/i)).toBeInTheDocument()
|
|
|
|
// Assert - Contact Info Section
|
|
expect(screen.getByLabelText(/phone/i)).toBeInTheDocument()
|
|
expect(screen.getByLabelText(/address/i)).toBeInTheDocument()
|
|
expect(screen.getByLabelText(/website/i)).toBeInTheDocument()
|
|
|
|
// Assert - Plan Settings Section
|
|
expect(screen.getByLabelText(/plan id/i)).toBeInTheDocument()
|
|
expect(screen.getByLabelText(/max users/i)).toBeInTheDocument()
|
|
expect(screen.getByLabelText(/storage quota/i)).toBeInTheDocument()
|
|
|
|
// Assert - Admin Account Section
|
|
expect(screen.getByLabelText(/admin username/i)).toBeInTheDocument()
|
|
expect(screen.getByLabelText(/admin email/i)).toBeInTheDocument()
|
|
expect(screen.getByLabelText(/admin name/i)).toBeInTheDocument()
|
|
expect(screen.getByLabelText(/admin password/i)).toBeInTheDocument()
|
|
|
|
// Assert - Buttons
|
|
expect(screen.getByRole('button', { name: /create tenant/i })).toBeInTheDocument()
|
|
expect(screen.getByRole('button', { name: /cancel/i })).toBeInTheDocument()
|
|
})
|
|
|
|
it('should have default values for plan settings', () => {
|
|
// Act
|
|
render(<TenantCreateForm onSuccess={vi.fn()} />)
|
|
|
|
// Assert
|
|
const planIdInput = screen.getByLabelText(/plan id/i) as HTMLInputElement
|
|
const maxUsersInput = screen.getByLabelText(/max users/i) as HTMLInputElement
|
|
const storageQuotaInput = screen.getByLabelText(/storage quota/i) as HTMLInputElement
|
|
|
|
expect(planIdInput.value).toBe('starter')
|
|
expect(maxUsersInput.value).toBe('5')
|
|
expect(storageQuotaInput.value).toBe('100')
|
|
})
|
|
})
|
|
|
|
describe('Form Validation', () => {
|
|
it('should show error when tenant code is empty', async () => {
|
|
// Arrange
|
|
const user = userEvent.setup()
|
|
render(<TenantCreateForm onSuccess={vi.fn()} />)
|
|
|
|
// Act
|
|
const submitButton = screen.getByRole('button', { name: /create tenant/i })
|
|
await user.click(submitButton)
|
|
|
|
// Assert
|
|
await waitFor(() => {
|
|
expect(screen.getByText(/tenant code is required/i)).toBeInTheDocument()
|
|
})
|
|
})
|
|
|
|
it('should show error when company name is empty', async () => {
|
|
// Arrange
|
|
const user = userEvent.setup()
|
|
render(<TenantCreateForm onSuccess={vi.fn()} />)
|
|
|
|
// Act
|
|
const submitButton = screen.getByRole('button', { name: /create tenant/i })
|
|
await user.click(submitButton)
|
|
|
|
// Assert
|
|
await waitFor(() => {
|
|
expect(screen.getByText(/company name is required/i)).toBeInTheDocument()
|
|
})
|
|
})
|
|
|
|
it('should validate tax id format (8 digits)', async () => {
|
|
// Arrange
|
|
const user = userEvent.setup()
|
|
render(<TenantCreateForm onSuccess={vi.fn()} />)
|
|
|
|
// Act
|
|
const taxIdInput = screen.getByLabelText(/tax id/i)
|
|
await user.type(taxIdInput, '123') // Invalid: less than 8 digits
|
|
|
|
const submitButton = screen.getByRole('button', { name: /create tenant/i })
|
|
await user.click(submitButton)
|
|
|
|
// Assert
|
|
await waitFor(() => {
|
|
expect(screen.getByText(/tax id must be 8 digits/i)).toBeInTheDocument()
|
|
})
|
|
})
|
|
|
|
it.skip('should validate email format', async () => {
|
|
// Arrange
|
|
const user = userEvent.setup()
|
|
render(<TenantCreateForm onSuccess={vi.fn()} />)
|
|
|
|
// Act - Fill required fields first
|
|
await user.type(screen.getByLabelText(/^tenant code/i), 'TEST')
|
|
await user.type(screen.getByLabelText(/^company name \*$/i), '測試公司')
|
|
await user.type(screen.getByLabelText(/employee prefix/i), 'T')
|
|
await user.type(screen.getByLabelText(/admin username/i), 'admin')
|
|
await user.type(screen.getByLabelText(/admin email/i), 'invalidemail') // Missing @
|
|
await user.type(screen.getByLabelText(/admin name/i), 'Admin User')
|
|
await user.type(screen.getByLabelText(/admin password/i), 'TempPass123!')
|
|
|
|
const submitButton = screen.getByRole('button', { name: /create tenant/i })
|
|
await user.click(submitButton)
|
|
|
|
// Assert
|
|
await waitFor(() => {
|
|
const errorElement = screen.queryByText(/invalid email format/i)
|
|
expect(errorElement).toBeInTheDocument()
|
|
}, { timeout: 3000 })
|
|
})
|
|
|
|
it('should validate password length (minimum 8 characters)', async () => {
|
|
// Arrange
|
|
const user = userEvent.setup()
|
|
render(<TenantCreateForm onSuccess={vi.fn()} />)
|
|
|
|
// Act
|
|
const passwordInput = screen.getByLabelText(/admin password/i)
|
|
await user.type(passwordInput, '12345') // Less than 8 characters
|
|
|
|
const submitButton = screen.getByRole('button', { name: /create tenant/i })
|
|
await user.click(submitButton)
|
|
|
|
// Assert
|
|
await waitFor(() => {
|
|
expect(screen.getByText(/password must be at least 8 characters/i)).toBeInTheDocument()
|
|
})
|
|
})
|
|
|
|
it('should validate employee prefix is required', async () => {
|
|
// Arrange
|
|
const user = userEvent.setup()
|
|
render(<TenantCreateForm onSuccess={vi.fn()} />)
|
|
|
|
// Act
|
|
const submitButton = screen.getByRole('button', { name: /create tenant/i })
|
|
await user.click(submitButton)
|
|
|
|
// Assert
|
|
await waitFor(() => {
|
|
expect(screen.getByText(/employee prefix is required/i)).toBeInTheDocument()
|
|
})
|
|
})
|
|
})
|
|
|
|
describe('Form Submission', () => {
|
|
it('should submit form with valid data', async () => {
|
|
// Arrange
|
|
const user = userEvent.setup()
|
|
const onSuccess = vi.fn()
|
|
const mockResponse = {
|
|
message: 'Tenant created successfully',
|
|
tenant: {
|
|
id: 1,
|
|
code: 'TEST',
|
|
name: '測試公司',
|
|
keycloak_realm: 'porscheworld-test',
|
|
},
|
|
admin_user: {
|
|
id: 1,
|
|
username: 'admin',
|
|
email: 'admin@test.com',
|
|
},
|
|
keycloak_realm: 'porscheworld-test',
|
|
temporary_password: 'TempPass123!',
|
|
}
|
|
|
|
vi.mocked(tenantService.createTenant).mockResolvedValueOnce(mockResponse)
|
|
|
|
render(<TenantCreateForm onSuccess={onSuccess} />)
|
|
|
|
// Act - Fill in form
|
|
await user.type(screen.getByLabelText(/tenant code/i), 'TEST')
|
|
await user.type(screen.getByLabelText(/^company name \*$/i), '測試公司')
|
|
await user.type(screen.getByLabelText(/tax id/i), '12345678')
|
|
await user.type(screen.getByLabelText(/employee prefix/i), 'T')
|
|
await user.type(screen.getByLabelText(/admin username/i), 'admin')
|
|
await user.type(screen.getByLabelText(/admin email/i), 'admin@test.com')
|
|
await user.type(screen.getByLabelText(/admin name/i), 'Admin User')
|
|
await user.type(screen.getByLabelText(/admin password/i), 'TempPass123!')
|
|
|
|
const submitButton = screen.getByRole('button', { name: /create tenant/i })
|
|
await user.click(submitButton)
|
|
|
|
// Assert
|
|
await waitFor(() => {
|
|
expect(tenantService.createTenant).toHaveBeenCalledWith({
|
|
code: 'TEST',
|
|
name: '測試公司',
|
|
name_eng: '',
|
|
tax_id: '12345678',
|
|
prefix: 'T',
|
|
tel: '',
|
|
add: '',
|
|
url: '',
|
|
plan_id: 'starter',
|
|
max_users: 5,
|
|
storage_quota_gb: 100,
|
|
admin_username: 'admin',
|
|
admin_email: 'admin@test.com',
|
|
admin_name: 'Admin User',
|
|
admin_temp_password: 'TempPass123!',
|
|
})
|
|
expect(onSuccess).toHaveBeenCalledWith(mockResponse)
|
|
})
|
|
})
|
|
|
|
it('should handle API error gracefully', async () => {
|
|
// Arrange
|
|
const user = userEvent.setup()
|
|
vi.mocked(tenantService.createTenant).mockRejectedValueOnce({
|
|
response: {
|
|
data: {
|
|
detail: "Tenant code 'TEST' already exists",
|
|
},
|
|
},
|
|
})
|
|
|
|
render(<TenantCreateForm onSuccess={vi.fn()} />)
|
|
|
|
// Act - Fill in minimum required fields
|
|
await user.type(screen.getByLabelText(/tenant code/i), 'TEST')
|
|
await user.type(screen.getByLabelText(/^company name \*$/i), '測試公司')
|
|
await user.type(screen.getByLabelText(/employee prefix/i), 'T')
|
|
await user.type(screen.getByLabelText(/admin username/i), 'admin')
|
|
await user.type(screen.getByLabelText(/admin email/i), 'admin@test.com')
|
|
await user.type(screen.getByLabelText(/admin name/i), 'Admin User')
|
|
await user.type(screen.getByLabelText(/admin password/i), 'TempPass123!')
|
|
|
|
const submitButton = screen.getByRole('button', { name: /create tenant/i })
|
|
await user.click(submitButton)
|
|
|
|
// Assert
|
|
await waitFor(() => {
|
|
expect(screen.getByText(/tenant code 'TEST' already exists/i)).toBeInTheDocument()
|
|
})
|
|
})
|
|
|
|
it('should show loading state during submission', async () => {
|
|
// Arrange
|
|
const user = userEvent.setup()
|
|
vi.mocked(tenantService.createTenant).mockImplementation(
|
|
() => new Promise((resolve) => setTimeout(resolve, 100))
|
|
)
|
|
|
|
render(<TenantCreateForm onSuccess={vi.fn()} />)
|
|
|
|
// Act - Fill in minimum required fields
|
|
await user.type(screen.getByLabelText(/tenant code/i), 'TEST')
|
|
await user.type(screen.getByLabelText(/^company name \*$/i), '測試公司')
|
|
await user.type(screen.getByLabelText(/employee prefix/i), 'T')
|
|
await user.type(screen.getByLabelText(/admin username/i), 'admin')
|
|
await user.type(screen.getByLabelText(/admin email/i), 'admin@test.com')
|
|
await user.type(screen.getByLabelText(/admin name/i), 'Admin User')
|
|
await user.type(screen.getByLabelText(/admin password/i), 'TempPass123!')
|
|
|
|
const submitButton = screen.getByRole('button', { name: /create tenant/i })
|
|
await user.click(submitButton)
|
|
|
|
// Assert
|
|
expect(screen.getByText(/creating/i)).toBeInTheDocument()
|
|
expect(submitButton).toBeDisabled()
|
|
})
|
|
|
|
it('should reset form after successful submission', async () => {
|
|
// Arrange
|
|
const user = userEvent.setup()
|
|
const mockResponse = {
|
|
message: 'Tenant created successfully',
|
|
tenant: { id: 1, code: 'TEST', name: '測試公司' },
|
|
admin_user: { id: 1, username: 'admin' },
|
|
keycloak_realm: 'porscheworld-test',
|
|
temporary_password: 'TempPass123!',
|
|
}
|
|
|
|
vi.mocked(tenantService.createTenant).mockResolvedValueOnce(mockResponse)
|
|
|
|
render(<TenantCreateForm onSuccess={vi.fn()} />)
|
|
|
|
// Act - Fill and submit form
|
|
await user.type(screen.getByLabelText(/tenant code/i), 'TEST')
|
|
await user.type(screen.getByLabelText(/^company name \*$/i), '測試公司')
|
|
await user.type(screen.getByLabelText(/employee prefix/i), 'T')
|
|
await user.type(screen.getByLabelText(/admin username/i), 'admin')
|
|
await user.type(screen.getByLabelText(/admin email/i), 'admin@test.com')
|
|
await user.type(screen.getByLabelText(/admin name/i), 'Admin User')
|
|
await user.type(screen.getByLabelText(/admin password/i), 'TempPass123!')
|
|
|
|
const submitButton = screen.getByRole('button', { name: /create tenant/i })
|
|
await user.click(submitButton)
|
|
|
|
// Assert - Form should be reset
|
|
await waitFor(() => {
|
|
const codeInput = screen.getByLabelText(/tenant code/i) as HTMLInputElement
|
|
const nameInput = screen.getByLabelText(/^company name \*$/i) as HTMLInputElement
|
|
expect(codeInput.value).toBe('')
|
|
expect(nameInput.value).toBe('')
|
|
})
|
|
})
|
|
})
|
|
|
|
describe('Cancel Functionality', () => {
|
|
it('should call onCancel when cancel button is clicked', async () => {
|
|
// Arrange
|
|
const user = userEvent.setup()
|
|
const onCancel = vi.fn()
|
|
render(<TenantCreateForm onSuccess={vi.fn()} onCancel={onCancel} />)
|
|
|
|
// Act
|
|
const cancelButton = screen.getByRole('button', { name: /cancel/i })
|
|
await user.click(cancelButton)
|
|
|
|
// Assert
|
|
expect(onCancel).toHaveBeenCalledTimes(1)
|
|
})
|
|
})
|
|
})
|