在過去的 Day 24-28 中,我們完成了 Kyo System 前端的核心功能:
今天是鐵人賽的倒數第二天,我們將把所有的拼圖整合在一起,完成:
讓我們將 Kyo System 前端推向生產就緒狀態!
┌─────────────────────────────────────────────────────────────────┐
│ Kyo Dashboard (React) │
├─────────────────────────────────────────────────────────────────┤
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │
│ │ Auth Module │ │ OTP Module │ │ Notification │ │
│ │ - Login │ │ - Send OTP │ │ - Real-time │ │
│ │ - JWT Store │ │ - Verify │ │ - Toast │ │
│ └──────┬───────┘ └──────┬───────┘ └──────┬───────┘ │
│ │ │ │ │
│ ┌──────▼──────────────────▼──────────────────▼───────┐ │
│ │ Zustand Store + React Query │ │
│ └──────┬──────────────────┬──────────────────┬───────┘ │
└─────────┼──────────────────┼──────────────────┼─────────────────┘
│ │ │
│ HTTP (REST) │ │ WebSocket
▼ ▼ ▼
┌─────────────────────────────────────────────────────────────────┐
│ Fastify API Gateway │
├─────────────────────────────────────────────────────────────────┤
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │
│ │ Auth Routes │ │ OTP Routes │ │ Socket.io │ │
│ │ /api/auth/* │ │ /api/otp/* │ │ Server │ │
│ └──────┬───────┘ └──────┬───────┘ └──────┬───────┘ │
│ │ │ │ │
│ ┌──────▼──────────────────▼──────────────────▼───────┐ │
│ │ JWT Middleware + Rate Limiting │ │
│ └──────┬──────────────────┬──────────────────┬───────┘ │
└─────────┼──────────────────┼──────────────────┼─────────────────┘
│ │ │
▼ ▼ ▼
┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
│ PostgreSQL │ │ Redis Cache │ │ Redis Pub/Sub │
│ - Users │ │ - Rate Limit │ │ - Events │
│ - OTP Logs │ │ - Sessions │ │ - Notify │
└─────────────────┘ └─────────────────┘ └─────────────────┘
在 Day 27 中,我們完成了前端的 Socket.io 整合,現在讓我們完成後端的實作。
首先,安裝必要的依賴:
pnpm add socket.io @fastify/cors
pnpm add -D @types/socket.io
建立 Socket.io Server 設定檔:
// apps/kyo-otp-service/src/plugins/socket.ts
import { FastifyInstance } from 'fastify';
import { Server as SocketIOServer } from 'socket.io';
import fp from 'fastify-plugin';
import { verifyToken } from '../utils/jwt';
import type { JWTPayload } from '@kyong/kyo-types';
interface SocketData {
userId: string;
tenantId: string;
email: string;
}
interface ServerToClientEvents {
notification: (data: NotificationPayload) => void;
'otp:sent': (data: OTPSentPayload) => void;
'otp:verified': (data: OTPVerifiedPayload) => void;
'rate-limit:exceeded': (data: RateLimitPayload) => void;
system: (data: SystemMessagePayload) => void;
}
interface ClientToServerEvents {
subscribe: (room: string) => void;
unsubscribe: (room: string) => void;
ping: () => void;
}
interface NotificationPayload {
id: string;
type: 'info' | 'success' | 'warning' | 'error';
title: string;
message: string;
timestamp: string;
action?: {
label: string;
url: string;
};
}
interface OTPSentPayload {
requestId: string;
phoneNumber: string;
expiresAt: string;
}
interface OTPVerifiedPayload {
requestId: string;
phoneNumber: string;
verifiedAt: string;
}
interface RateLimitPayload {
endpoint: string;
limit: number;
resetAt: string;
}
interface SystemMessagePayload {
message: string;
level: 'info' | 'warning' | 'error';
}
declare module 'fastify' {
interface FastifyInstance {
io: SocketIOServer<
ClientToServerEvents,
ServerToClientEvents,
{},
SocketData
>;
}
}
async function socketPlugin(fastify: FastifyInstance) {
const io = new SocketIOServer<
ClientToServerEvents,
ServerToClientEvents,
{},
SocketData
>(fastify.server, {
cors: {
origin: process.env.CORS_ORIGIN || 'http://localhost:5173',
credentials: true,
},
path: '/socket.io',
transports: ['websocket', 'polling'],
pingTimeout: 60000,
pingInterval: 25000,
});
// JWT Authentication Middleware
io.use(async (socket, next) => {
try {
const token = socket.handshake.auth.token as string;
if (!token) {
return next(new Error('Authentication token required'));
}
// Verify JWT
const payload = await verifyToken(token) as JWTPayload;
if (!payload || !payload.sub) {
return next(new Error('Invalid token'));
}
// Attach user data to socket
socket.data = {
userId: payload.sub,
tenantId: payload.tenant_id,
email: payload.email,
};
next();
} catch (error) {
fastify.log.error({ error }, 'Socket authentication failed');
next(new Error('Authentication failed'));
}
});
// Connection Handler
io.on('connection', (socket) => {
const { userId, tenantId, email } = socket.data;
fastify.log.info(
{ userId, tenantId, socketId: socket.id },
'Client connected'
);
// Auto-join user's personal room
const userRoom = `user:${userId}`;
const tenantRoom = `tenant:${tenantId}`;
socket.join(userRoom);
socket.join(tenantRoom);
// Send welcome notification
socket.emit('notification', {
id: `welcome-${Date.now()}`,
type: 'info',
title: '連線成功',
message: '即時通知已啟用',
timestamp: new Date().toISOString(),
});
// Subscribe to custom rooms
socket.on('subscribe', (room: string) => {
// Validate room format (tenant:xxx or user:xxx)
if (
room.startsWith(`tenant:${tenantId}`) ||
room.startsWith(`user:${userId}`)
) {
socket.join(room);
fastify.log.info({ userId, room }, 'Subscribed to room');
socket.emit('system', {
message: `已訂閱房間: ${room}`,
level: 'info',
});
} else {
socket.emit('system', {
message: '無權限訂閱此房間',
level: 'error',
});
}
});
// Unsubscribe from rooms
socket.on('unsubscribe', (room: string) => {
socket.leave(room);
fastify.log.info({ userId, room }, 'Unsubscribed from room');
});
// Ping/Pong for connection health
socket.on('ping', () => {
socket.emit('system', {
message: 'pong',
level: 'info',
});
});
// Disconnection Handler
socket.on('disconnect', (reason) => {
fastify.log.info(
{ userId, socketId: socket.id, reason },
'Client disconnected'
);
});
// Error Handler
socket.on('error', (error) => {
fastify.log.error(
{ userId, socketId: socket.id, error },
'Socket error'
);
});
});
// Decorate Fastify instance
fastify.decorate('io', io);
// Graceful shutdown
fastify.addHook('onClose', async () => {
io.close();
});
}
export default fp(socketPlugin, {
name: 'socket-io',
});
建立通知服務,整合 Socket.io 與 Redis Pub/Sub:
// packages/kyo-core/src/services/notification.service.ts
import { Redis } from 'ioredis';
import type { Server as SocketIOServer } from 'socket.io';
import type {
ServerToClientEvents,
ClientToServerEvents,
SocketData,
} from '../types/socket';
export interface NotificationMessage {
userId?: string;
tenantId?: string;
room?: string;
event: keyof ServerToClientEvents;
data: any;
}
export class NotificationService {
private publisher: Redis;
private subscriber: Redis;
private io?: SocketIOServer<ClientToServerEvents, ServerToClientEvents, {}, SocketData>;
constructor(redisUrl: string) {
this.publisher = new Redis(redisUrl);
this.subscriber = new Redis(redisUrl);
}
/**
* Initialize Socket.io server and subscribe to Redis channels
*/
async initialize(io: SocketIOServer) {
this.io = io;
// Subscribe to notification channel
await this.subscriber.subscribe('notifications');
// Handle incoming messages from Redis
this.subscriber.on('message', (channel, message) => {
if (channel === 'notifications') {
try {
const notification: NotificationMessage = JSON.parse(message);
this.emitNotification(notification);
} catch (error) {
console.error('Failed to parse notification:', error);
}
}
});
}
/**
* Publish notification to Redis (for distributed systems)
*/
async publish(notification: NotificationMessage): Promise<void> {
await this.publisher.publish(
'notifications',
JSON.stringify(notification)
);
}
/**
* Emit notification directly to Socket.io clients
*/
private emitNotification(notification: NotificationMessage): void {
if (!this.io) {
throw new Error('Socket.io server not initialized');
}
const { userId, tenantId, room, event, data } = notification;
// Emit to specific room
if (room) {
this.io.to(room).emit(event, data);
return;
}
// Emit to specific user
if (userId) {
this.io.to(`user:${userId}`).emit(event, data);
return;
}
// Emit to tenant
if (tenantId) {
this.io.to(`tenant:${tenantId}`).emit(event, data);
return;
}
// Broadcast to all
this.io.emit(event, data);
}
/**
* Send OTP sent notification
*/
async notifyOTPSent(
userId: string,
tenantId: string,
data: {
requestId: string;
phoneNumber: string;
expiresAt: string;
}
): Promise<void> {
await this.publish({
userId,
tenantId,
event: 'otp:sent',
data,
});
// Also send a user notification
await this.publish({
userId,
tenantId,
event: 'notification',
data: {
id: `otp-sent-${data.requestId}`,
type: 'success',
title: 'OTP 已發送',
message: `驗證碼已發送至 ${data.phoneNumber.replace(
/(\d{4})\d{4}(\d{2})/,
'$1****$2'
)}`,
timestamp: new Date().toISOString(),
},
});
}
/**
* Send OTP verified notification
*/
async notifyOTPVerified(
userId: string,
tenantId: string,
data: {
requestId: string;
phoneNumber: string;
verifiedAt: string;
}
): Promise<void> {
await this.publish({
userId,
tenantId,
event: 'otp:verified',
data,
});
await this.publish({
userId,
tenantId,
event: 'notification',
data: {
id: `otp-verified-${data.requestId}`,
type: 'success',
title: '驗證成功',
message: '手機號碼驗證完成',
timestamp: new Date().toISOString(),
},
});
}
/**
* Send rate limit exceeded notification
*/
async notifyRateLimitExceeded(
userId: string,
tenantId: string,
data: {
endpoint: string;
limit: number;
resetAt: string;
}
): Promise<void> {
await this.publish({
userId,
tenantId,
event: 'rate-limit:exceeded',
data,
});
await this.publish({
userId,
tenantId,
event: 'notification',
data: {
id: `rate-limit-${Date.now()}`,
type: 'warning',
title: '請求超過限制',
message: `已達到 ${data.endpoint} 的請求限制,請稍後再試`,
timestamp: new Date().toISOString(),
},
});
}
/**
* Close Redis connections
*/
async close(): Promise<void> {
await this.publisher.quit();
await this.subscriber.quit();
}
}
將 Socket.io 與通知服務整合到主應用:
// apps/kyo-otp-service/src/app.ts
import Fastify from 'fastify';
import cors from '@fastify/cors';
import socketPlugin from './plugins/socket';
import { NotificationService } from '@kyong/kyo-core';
const app = Fastify({
logger: {
level: process.env.LOG_LEVEL || 'info',
},
});
// Register CORS
await app.register(cors, {
origin: process.env.CORS_ORIGIN || 'http://localhost:5173',
credentials: true,
});
// Register Socket.io
await app.register(socketPlugin);
// Initialize Notification Service
const notificationService = new NotificationService(
process.env.REDIS_URL || 'redis://localhost:6379'
);
await notificationService.initialize(app.io);
// Make notification service available globally
app.decorate('notificationService', notificationService);
// Example: Send notification in OTP route
app.post('/api/otp/send', async (request, reply) => {
const { phoneNumber } = request.body;
const userId = request.user.sub;
const tenantId = request.user.tenant_id;
// Send OTP logic...
const requestId = 'otp-123';
const expiresAt = new Date(Date.now() + 5 * 60 * 1000).toISOString();
// Notify user via Socket.io
await app.notificationService.notifyOTPSent(userId, tenantId, {
requestId,
phoneNumber,
expiresAt,
});
return { success: true, requestId };
});
// Graceful shutdown
app.addHook('onClose', async () => {
await notificationService.close();
});
export default app;
建立測試檔案驗證 Socket.io 整合:
// apps/kyo-otp-service/test/integration/socket.test.ts
import { test } from 'node:test';
import assert from 'node:assert';
import { io as Client, Socket } from 'socket.io-client';
import app from '../../src/app';
import { generateToken } from '../../src/utils/jwt';
test('Socket.io Integration Tests', async (t) => {
const address = await app.listen({ port: 0 });
const port = app.server.address().port;
const baseUrl = `http://localhost:${port}`;
t.after(async () => {
await app.close();
});
await t.test('should connect with valid JWT', async () => {
const token = await generateToken({
sub: 'user-123',
tenant_id: 'tenant-456',
email: 'test@example.com',
});
const client = Client(baseUrl, {
auth: { token },
transports: ['websocket'],
});
await new Promise<void>((resolve, reject) => {
client.on('connect', () => {
assert.ok(client.connected, 'Client should be connected');
resolve();
});
client.on('connect_error', (error) => {
reject(error);
});
setTimeout(() => reject(new Error('Connection timeout')), 5000);
});
client.disconnect();
});
await t.test('should reject connection without JWT', async () => {
const client = Client(baseUrl, {
transports: ['websocket'],
});
await new Promise<void>((resolve, reject) => {
client.on('connect', () => {
reject(new Error('Should not connect without token'));
});
client.on('connect_error', (error) => {
assert.ok(
error.message.includes('Authentication'),
'Should fail with auth error'
);
resolve();
});
setTimeout(() => reject(new Error('Timeout')), 5000);
});
client.disconnect();
});
await t.test('should receive welcome notification', async () => {
const token = await generateToken({
sub: 'user-123',
tenant_id: 'tenant-456',
email: 'test@example.com',
});
const client = Client(baseUrl, {
auth: { token },
transports: ['websocket'],
});
await new Promise<void>((resolve, reject) => {
client.on('notification', (data) => {
assert.strictEqual(data.type, 'info');
assert.strictEqual(data.title, '連線成功');
client.disconnect();
resolve();
});
setTimeout(() => reject(new Error('No notification received')), 5000);
});
});
await t.test('should join and receive room messages', async () => {
const token = await generateToken({
sub: 'user-123',
tenant_id: 'tenant-456',
email: 'test@example.com',
});
const client = Client(baseUrl, {
auth: { token },
transports: ['websocket'],
});
await new Promise<void>((resolve, reject) => {
client.on('connect', () => {
// Subscribe to tenant room
client.emit('subscribe', 'tenant:tenant-456');
// Emit notification to tenant room
app.notificationService.publish({
tenantId: 'tenant-456',
event: 'notification',
data: {
id: 'test-notification',
type: 'info',
title: 'Test',
message: 'Test message',
timestamp: new Date().toISOString(),
},
});
});
client.on('notification', (data) => {
if (data.id === 'test-notification') {
assert.strictEqual(data.title, 'Test');
client.disconnect();
resolve();
}
});
setTimeout(() => reject(new Error('No room message received')), 5000);
});
});
});
測試多個元件協作的場景:
// apps/kyo-dashboard/src/__tests__/integration/otp-flow.test.tsx
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { BrowserRouter } from 'react-router-dom';
import { OTPPage } from '../../pages/OTPPage';
import { useAuthStore } from '../../stores/auth.store';
import { server } from '../mocks/server';
import { http, HttpResponse } from 'msw';
const createWrapper = () => {
const queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false },
mutations: { retry: false },
},
});
return ({ children }: { children: React.ReactNode }) => (
<QueryClientProvider client={queryClient}>
<BrowserRouter>{children}</BrowserRouter>
</QueryClientProvider>
);
};
describe('OTP Flow Integration', () => {
beforeEach(() => {
// Reset auth store
useAuthStore.setState({
token: 'mock-jwt-token',
user: { id: '1', email: 'test@example.com' },
});
});
it('should complete full OTP send and verify flow', async () => {
const user = userEvent.setup();
render(<OTPPage />, { wrapper: createWrapper() });
// Step 1: Enter phone number
const phoneInput = screen.getByLabelText(/手機號碼/i);
await user.type(phoneInput, '0912345678');
// Step 2: Click send OTP
const sendButton = screen.getByRole('button', { name: /發送驗證碼/i });
await user.click(sendButton);
// Step 3: Wait for success message
await waitFor(() => {
expect(
screen.getByText(/驗證碼已發送/i)
).toBeInTheDocument();
});
// Step 4: Verify button is now disabled (countdown)
expect(sendButton).toBeDisabled();
// Step 5: Enter OTP code
const otpInputs = screen.getAllByLabelText(/驗證碼/i);
await user.type(otpInputs[0], '123456');
// Step 6: Click verify
const verifyButton = screen.getByRole('button', { name: /驗證/i });
await user.click(verifyButton);
// Step 7: Wait for success
await waitFor(() => {
expect(screen.getByText(/驗證成功/i)).toBeInTheDocument();
});
});
it('should show error when rate limit exceeded', async () => {
const user = userEvent.setup();
// Mock rate limit error
server.use(
http.post('/api/otp/send', () => {
return HttpResponse.json(
{ error: 'Rate limit exceeded' },
{ status: 429 }
);
})
);
render(<OTPPage />, { wrapper: createWrapper() });
const phoneInput = screen.getByLabelText(/手機號碼/i);
await user.type(phoneInput, '0912345678');
const sendButton = screen.getByRole('button', { name: /發送驗證碼/i });
await user.click(sendButton);
await waitFor(() => {
expect(
screen.getByText(/請求超過限制/i)
).toBeInTheDocument();
});
});
it('should handle Socket.io real-time notifications', async () => {
const { container } = render(<OTPPage />, { wrapper: createWrapper() });
// Simulate Socket.io notification
const mockNotification = {
id: 'notif-1',
type: 'success',
title: 'OTP 已發送',
message: '驗證碼已發送至您的手機',
timestamp: new Date().toISOString(),
};
// Trigger notification via Socket.io mock
const event = new CustomEvent('socket:notification', {
detail: mockNotification,
});
window.dispatchEvent(event);
await waitFor(() => {
expect(screen.getByText(/OTP 已發送/i)).toBeInTheDocument();
});
});
});
測試多個 Context 的協作:
// apps/kyo-dashboard/src/__tests__/integration/auth-context.test.tsx
import { describe, it, expect, beforeEach } from 'vitest';
import { renderHook, waitFor } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { useAuthStore } from '../../stores/auth.store';
import { useAuth } from '../../hooks/useAuth';
import { server } from '../mocks/server';
import { http, HttpResponse } from 'msw';
const createWrapper = () => {
const queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false },
mutations: { retry: false },
},
});
return ({ children }: { children: React.ReactNode }) => (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
);
};
describe('Auth Context Integration', () => {
beforeEach(() => {
useAuthStore.getState().logout();
localStorage.clear();
});
it('should persist auth state across page reloads', async () => {
const { result } = renderHook(() => useAuth(), {
wrapper: createWrapper(),
});
// Login
await result.current.login({
email: 'test@example.com',
password: 'password123',
});
await waitFor(() => {
expect(result.current.isAuthenticated).toBe(true);
});
// Simulate page reload
const token = localStorage.getItem('auth-token');
expect(token).toBeTruthy();
// Create new hook instance (simulating reload)
const { result: reloadedResult } = renderHook(() => useAuth(), {
wrapper: createWrapper(),
});
await waitFor(() => {
expect(reloadedResult.current.isAuthenticated).toBe(true);
expect(reloadedResult.current.user?.email).toBe('test@example.com');
});
});
it('should clear auth state on logout', async () => {
const { result } = renderHook(() => useAuth(), {
wrapper: createWrapper(),
});
// Login first
await result.current.login({
email: 'test@example.com',
password: 'password123',
});
await waitFor(() => {
expect(result.current.isAuthenticated).toBe(true);
});
// Logout
await result.current.logout();
await waitFor(() => {
expect(result.current.isAuthenticated).toBe(false);
expect(result.current.user).toBeNull();
expect(localStorage.getItem('auth-token')).toBeNull();
});
});
it('should handle token expiration', async () => {
const { result } = renderHook(() => useAuth(), {
wrapper: createWrapper(),
});
// Login with expired token
server.use(
http.post('/api/auth/login', () => {
return HttpResponse.json({
token: 'expired-token',
user: { id: '1', email: 'test@example.com' },
});
}),
http.get('/api/auth/me', () => {
return HttpResponse.json(
{ error: 'Token expired' },
{ status: 401 }
);
})
);
await result.current.login({
email: 'test@example.com',
password: 'password123',
});
// Try to fetch user data
await waitFor(() => {
expect(result.current.isAuthenticated).toBe(false);
expect(result.current.user).toBeNull();
});
});
});
安裝與設定 Playwright:
pnpm add -D @playwright/test
pnpm exec playwright install
建立 Playwright 設定檔:
// apps/kyo-dashboard/playwright.config.ts
import { defineConfig, devices } from '@playwright/test';
export default defineConfig({
testDir: './e2e',
fullyParallel: true,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
workers: process.env.CI ? 1 : undefined,
reporter: [
['html'],
['json', { outputFile: 'test-results/results.json' }],
],
use: {
baseURL: process.env.BASE_URL || 'http://localhost:5173',
trace: 'on-first-retry',
screenshot: 'only-on-failure',
video: 'retain-on-failure',
},
projects: [
{
name: 'chromium',
use: { ...devices['Desktop Chrome'] },
},
{
name: 'firefox',
use: { ...devices['Desktop Firefox'] },
},
{
name: 'webkit',
use: { ...devices['Desktop Safari'] },
},
{
name: 'Mobile Chrome',
use: { ...devices['Pixel 5'] },
},
{
name: 'Mobile Safari',
use: { ...devices['iPhone 12'] },
},
],
webServer: {
command: 'pnpm run dev',
url: 'http://localhost:5173',
reuseExistingServer: !process.env.CI,
},
});
// apps/kyo-dashboard/e2e/otp-flow.spec.ts
import { test, expect } from '@playwright/test';
test.describe('OTP Complete User Flow', () => {
test.beforeEach(async ({ page }) => {
// Login
await page.goto('/login');
await page.fill('input[name="email"]', 'test@example.com');
await page.fill('input[name="password"]', 'password123');
await page.click('button[type="submit"]');
await expect(page).toHaveURL('/dashboard');
});
test('should send and verify OTP successfully', async ({ page }) => {
// Navigate to OTP page
await page.click('text=OTP 管理');
await expect(page).toHaveURL('/otp');
// Fill phone number
await page.fill('input[name="phoneNumber"]', '0912345678');
// Send OTP
await page.click('button:has-text("發送驗證碼")');
// Wait for success notification
await expect(page.locator('.toast-success')).toContainText('驗證碼已發送');
// Verify countdown starts
await expect(page.locator('button:has-text("秒後重試")')).toBeVisible();
// Enter OTP code
const otpInputs = page.locator('input[inputmode="numeric"]');
await otpInputs.nth(0).fill('1');
await otpInputs.nth(1).fill('2');
await otpInputs.nth(2).fill('3');
await otpInputs.nth(3).fill('4');
await otpInputs.nth(4).fill('5');
await otpInputs.nth(5).fill('6');
// Click verify
await page.click('button:has-text("驗證")');
// Wait for success
await expect(page.locator('.toast-success')).toContainText('驗證成功');
// Verify log entry appears
await expect(page.locator('table tbody tr').first()).toContainText(
'0912345678'
);
});
test('should show error for invalid OTP', async ({ page }) => {
await page.goto('/otp');
// Send OTP
await page.fill('input[name="phoneNumber"]', '0912345678');
await page.click('button:has-text("發送驗證碼")');
await expect(page.locator('.toast-success')).toBeVisible();
// Enter wrong OTP
const otpInputs = page.locator('input[inputmode="numeric"]');
for (let i = 0; i < 6; i++) {
await otpInputs.nth(i).fill('9');
}
await page.click('button:has-text("驗證")');
// Should show error
await expect(page.locator('.toast-error')).toContainText('驗證碼錯誤');
});
test('should handle rate limiting gracefully', async ({ page }) => {
await page.goto('/otp');
const phoneNumber = '0912345678';
// Send OTP multiple times rapidly
for (let i = 0; i < 6; i++) {
await page.fill('input[name="phoneNumber"]', phoneNumber);
await page.click('button:has-text("發送驗證碼")');
if (i < 5) {
// Wait for countdown to finish
await page.waitForTimeout(60000);
}
}
// Should show rate limit error
await expect(page.locator('.toast-warning')).toContainText(
'請求超過限制'
);
});
test('should receive real-time notifications via Socket.io', async ({
page,
}) => {
await page.goto('/otp');
// Send OTP
await page.fill('input[name="phoneNumber"]', '0912345678');
await page.click('button:has-text("發送驗證碼")');
// Wait for Socket.io notification (not just HTTP response)
await expect(page.locator('.notification-badge')).toHaveText('1');
// Open notification panel
await page.click('button[aria-label="通知"]');
// Verify notification content
await expect(page.locator('.notification-item')).toContainText(
'OTP 已發送'
);
});
});
test.describe('Dashboard E2E', () => {
test('should display OTP statistics correctly', async ({ page }) => {
await page.goto('/login');
await page.fill('input[name="email"]', 'test@example.com');
await page.fill('input[name="password"]', 'password123');
await page.click('button[type="submit"]');
// Navigate to dashboard
await page.goto('/dashboard');
// Wait for charts to load
await page.waitForSelector('canvas');
// Verify stat cards
await expect(page.locator('.stat-card').first()).toContainText(
'本月發送量'
);
// Verify chart is rendered
const canvas = page.locator('canvas').first();
await expect(canvas).toBeVisible();
// Take screenshot for visual regression
await page.screenshot({
path: 'test-results/dashboard-stats.png',
fullPage: true,
});
});
test('should filter logs by date range', async ({ page }) => {
await page.goto('/login');
await page.fill('input[name="email"]', 'test@example.com');
await page.fill('input[name="password"]', 'password123');
await page.click('button[type="submit"]');
await page.goto('/otp');
// Open date picker
await page.click('button:has-text("日期範圍")');
// Select date range (last 7 days)
await page.click('text=最近 7 天');
// Wait for logs to reload
await page.waitForResponse((resp) =>
resp.url().includes('/api/otp/logs')
);
// Verify logs are filtered
const rows = page.locator('table tbody tr');
await expect(rows).not.toHaveCount(0);
// Export logs
await page.click('button:has-text("匯出")');
// Wait for download
const downloadPromise = page.waitForEvent('download');
const download = await downloadPromise;
expect(download.suggestedFilename()).toContain('otp-logs');
});
});
// apps/kyo-dashboard/e2e/a11y.spec.ts
import { test, expect } from '@playwright/test';
import AxeBuilder from '@axe-core/playwright';
test.describe('Accessibility Tests', () => {
test('should not have any automatically detectable accessibility issues', async ({
page,
}) => {
await page.goto('/login');
const accessibilityScanResults = await new AxeBuilder({ page }).analyze();
expect(accessibilityScanResults.violations).toEqual([]);
});
test('should be keyboard navigable', async ({ page }) => {
await page.goto('/login');
// Tab through form
await page.keyboard.press('Tab');
await expect(page.locator('input[name="email"]')).toBeFocused();
await page.keyboard.press('Tab');
await expect(page.locator('input[name="password"]')).toBeFocused();
await page.keyboard.press('Tab');
await expect(page.locator('button[type="submit"]')).toBeFocused();
// Submit with Enter
await page.keyboard.press('Enter');
});
test('should have proper ARIA labels', async ({ page }) => {
await page.goto('/otp');
// Check ARIA labels
await expect(page.locator('[aria-label="手機號碼"]')).toBeVisible();
await expect(page.locator('[role="alert"]')).toBeHidden();
// Trigger error
await page.click('button:has-text("發送驗證碼")');
await expect(page.locator('[role="alert"]')).toBeVisible();
});
test('should support screen reader announcements', async ({ page }) => {
await page.goto('/otp');
// Check live region
const liveRegion = page.locator('[aria-live="polite"]');
await expect(liveRegion).toBeEmpty();
// Trigger notification
await page.fill('input[name="phoneNumber"]', '0912345678');
await page.click('button:has-text("發送驗證碼")');
// Verify announcement
await expect(liveRegion).toContainText('驗證碼已發送');
});
});
安裝 Lighthouse CI:
pnpm add -D @lhci/cli
建立 Lighthouse 設定:
// apps/kyo-dashboard/lighthouserc.js
module.exports = {
ci: {
collect: {
url: [
'http://localhost:5173/',
'http://localhost:5173/login',
'http://localhost:5173/dashboard',
'http://localhost:5173/otp',
],
numberOfRuns: 3,
settings: {
preset: 'desktop',
},
},
assert: {
assertions: {
'categories:performance': ['error', { minScore: 0.9 }],
'categories:accessibility': ['error', { minScore: 0.9 }],
'categories:best-practices': ['error', { minScore: 0.9 }],
'categories:seo': ['error', { minScore: 0.9 }],
'first-contentful-paint': ['error', { maxNumericValue: 2000 }],
'largest-contentful-paint': ['error', { maxNumericValue: 2500 }],
'cumulative-layout-shift': ['error', { maxNumericValue: 0.1 }],
'total-blocking-time': ['error', { maxNumericValue: 300 }],
},
},
upload: {
target: 'temporary-public-storage',
},
},
};
建立 bundle 分析腳本:
// apps/kyo-dashboard/vite.config.ts
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import { visualizer } from 'rollup-plugin-visualizer';
export default defineConfig(({ mode }) => ({
plugins: [
react(),
mode === 'analyze' &&
visualizer({
filename: './dist/stats.html',
open: true,
gzipSize: true,
brotliSize: true,
}),
].filter(Boolean),
build: {
rollupOptions: {
output: {
manualChunks: {
'react-vendor': ['react', 'react-dom', 'react-router-dom'],
'query-vendor': ['@tanstack/react-query'],
'chart-vendor': ['recharts'],
'socket-vendor': ['socket.io-client'],
},
},
},
},
}));
執行分析:
pnpm run build --mode=analyze
建立效能監控 hook:
// apps/kyo-dashboard/src/hooks/usePerformanceMonitoring.ts
import { useEffect } from 'react';
interface PerformanceMetrics {
fcp: number; // First Contentful Paint
lcp: number; // Largest Contentful Paint
fid: number; // First Input Delay
cls: number; // Cumulative Layout Shift
ttfb: number; // Time to First Byte
}
export function usePerformanceMonitoring() {
useEffect(() => {
if (typeof window === 'undefined' || !window.performance) {
return;
}
const metrics: Partial<PerformanceMetrics> = {};
// Measure FCP
const fcpObserver = new PerformanceObserver((list) => {
const entries = list.getEntries();
const fcp = entries.find((entry) => entry.name === 'first-contentful-paint');
if (fcp) {
metrics.fcp = fcp.startTime;
console.log('FCP:', fcp.startTime);
}
});
fcpObserver.observe({ entryTypes: ['paint'] });
// Measure LCP
const lcpObserver = new PerformanceObserver((list) => {
const entries = list.getEntries();
const lastEntry = entries[entries.length - 1];
metrics.lcp = lastEntry.startTime;
console.log('LCP:', lastEntry.startTime);
});
lcpObserver.observe({ entryTypes: ['largest-contentful-paint'] });
// Measure FID
const fidObserver = new PerformanceObserver((list) => {
const entries = list.getEntries();
const firstInput = entries[0] as PerformanceEventTiming;
metrics.fid = firstInput.processingStart - firstInput.startTime;
console.log('FID:', metrics.fid);
});
fidObserver.observe({ entryTypes: ['first-input'] });
// Measure CLS
let clsValue = 0;
const clsObserver = new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
if (!(entry as any).hadRecentInput) {
clsValue += (entry as any).value;
}
}
metrics.cls = clsValue;
console.log('CLS:', clsValue);
});
clsObserver.observe({ entryTypes: ['layout-shift'] });
// Measure TTFB
const navigationTiming = performance.getEntriesByType(
'navigation'
)[0] as PerformanceNavigationTiming;
if (navigationTiming) {
metrics.ttfb = navigationTiming.responseStart - navigationTiming.requestStart;
console.log('TTFB:', metrics.ttfb);
}
// Send metrics to analytics (optional)
window.addEventListener('beforeunload', () => {
if (Object.keys(metrics).length > 0) {
// Send to analytics service
navigator.sendBeacon(
'/api/analytics/performance',
JSON.stringify(metrics)
);
}
});
return () => {
fcpObserver.disconnect();
lcpObserver.disconnect();
fidObserver.disconnect();
clsObserver.disconnect();
};
}, []);
}
## 前端部署檢查清單
### Code Quality
- [ ] 所有 ESLint 警告已解決
- [ ] 所有 TypeScript 型別錯誤已修復
- [ ] 程式碼已通過 Prettier 格式化
- [ ] 無 console.log 或除錯程式碼
### Testing
- [ ] 單元測試通過率 > 80%
- [ ] 整合測試全部通過
- [ ] E2E 測試全部通過
- [ ] A11y 測試無違規項目
- [ ] 跨瀏覽器測試完成 (Chrome, Firefox, Safari)
- [ ] 行動裝置測試完成 (iOS, Android)
### Performance
- [ ] Lighthouse Performance Score > 90
- [ ] FCP < 2s
- [ ] LCP < 2.5s
- [ ] CLS < 0.1
- [ ] Bundle size < 500KB (gzipped)
- [ ] 圖片已優化 (WebP, lazy loading)
- [ ] 字體已優化 (subset, preload)
### Security
- [ ] 所有依賴套件已更新
- [ ] 無高危安全漏洞 (npm audit)
- [ ] XSS 防護已啟用
- [ ] CSRF Token 已實作
- [ ] Content Security Policy 已設定
- [ ] HTTPS 強制轉址
### SEO & Meta
- [ ] Meta tags 完整 (title, description, og)
- [ ] robots.txt 已設定
- [ ] sitemap.xml 已產生
- [ ] Canonical URLs 正確
### Monitoring
- [ ] Error tracking 已設定 (Sentry)
- [ ] Analytics 已設定 (GA4)
- [ ] Performance monitoring 已啟用
- [ ] Log aggregation 已設定
### Environment
- [ ] 環境變數已設定 (.env.production)
- [ ] API endpoints 指向正式環境
- [ ] CDN 已設定
- [ ] Cache headers 已設定
- [ ] Gzip/Brotli 壓縮已啟用
### Documentation
- [ ] README 已更新
- [ ] API 文件已更新
- [ ] Deployment guide 已撰寫
- [ ] Rollback plan 已準備
### Post-Deployment
- [ ] Smoke tests 通過
- [ ] Health check endpoint 正常
- [ ] 監控儀表板正常
- [ ] 備份策略已測試
// apps/kyo-dashboard/scripts/pre-deploy.ts
import { exec } from 'child_process';
import { promisify } from 'util';
import chalk from 'chalk';
const execAsync = promisify(exec);
interface CheckResult {
name: string;
passed: boolean;
message: string;
}
const checks: CheckResult[] = [];
async function runCheck(
name: string,
command: string
): Promise<CheckResult> {
console.log(chalk.blue(`Running ${name}...`));
try {
const { stdout, stderr } = await execAsync(command);
if (stderr && !stderr.includes('warning')) {
return {
name,
passed: false,
message: stderr,
};
}
return {
name,
passed: true,
message: stdout,
};
} catch (error) {
return {
name,
passed: false,
message: error.message,
};
}
}
async function main() {
console.log(chalk.bold.cyan('\n🚀 Pre-Deployment Checks\n'));
// Lint
checks.push(await runCheck('ESLint', 'pnpm run lint'));
// Type Check
checks.push(await runCheck('TypeScript', 'pnpm run type-check'));
// Unit Tests
checks.push(await runCheck('Unit Tests', 'pnpm run test:unit'));
// Integration Tests
checks.push(await runCheck('Integration Tests', 'pnpm run test:integration'));
// E2E Tests
checks.push(await runCheck('E2E Tests', 'pnpm run test:e2e'));
// Security Audit
checks.push(await runCheck('Security Audit', 'pnpm audit --production'));
// Build
checks.push(await runCheck('Production Build', 'pnpm run build'));
// Bundle Size Check
const { stdout } = await execAsync('du -sh dist');
const sizeMatch = stdout.match(/(\d+\.?\d*)([KMG])/);
const size = sizeMatch ? parseFloat(sizeMatch[1]) : 0;
const unit = sizeMatch ? sizeMatch[2] : 'K';
const sizeOk = unit === 'K' || (unit === 'M' && size < 5);
checks.push({
name: 'Bundle Size',
passed: sizeOk,
message: sizeOk
? `Bundle size: ${size}${unit} ✓`
: `Bundle size too large: ${size}${unit}`,
});
// Print Results
console.log(chalk.bold.cyan('\n📊 Check Results\n'));
let allPassed = true;
for (const check of checks) {
const icon = check.passed ? chalk.green('✓') : chalk.red('✗');
console.log(`${icon} ${check.name}`);
if (!check.passed) {
console.log(chalk.red(` ${check.message}`));
allPassed = false;
}
}
console.log('\n');
if (allPassed) {
console.log(chalk.bold.green('✅ All checks passed! Ready to deploy.\n'));
process.exit(0);
} else {
console.log(chalk.bold.red('❌ Some checks failed. Please fix before deploying.\n'));
process.exit(1);
}
}
main().catch((error) => {
console.error(chalk.red('Error running pre-deployment checks:'), error);
process.exit(1);
});
執行部署前檢查:
pnpm run pre-deploy
今天我們完成了 Kyo System 前端的最後拼圖:
Socket.io 完整實作
完整測試策略
生產就緒