├── client ├── postcss.config.js ├── src │ ├── vite-env.d.ts │ ├── main.tsx │ ├── components │ │ ├── ui │ │ │ ├── input.tsx │ │ │ ├── avatar.tsx │ │ │ ├── scroll-area.tsx │ │ │ ├── button.tsx │ │ │ └── card.tsx │ │ └── chat │ │ │ ├── ChatRoom.tsx │ │ │ ├── MessageBubble.tsx │ │ │ ├── MessageList.tsx │ │ │ ├── MessageInput.tsx │ │ │ ├── LoginForm.tsx │ │ │ ├── ChatHeader.tsx │ │ │ ├── MessageBubble.test.tsx │ │ │ ├── LoginForm.test.tsx │ │ │ └── MessageInput.test.tsx │ ├── App.tsx │ ├── test │ │ └── setup.ts │ ├── lib │ │ ├── utils.ts │ │ └── utils.test.ts │ ├── types │ │ └── chat.ts │ ├── index.css │ ├── hooks │ │ ├── useWebSocket.test.ts │ │ └── useWebSocket.ts │ └── context │ │ └── ChatContext.tsx ├── public │ └── chat.svg ├── vite.config.ts ├── index.html ├── vitest.config.ts ├── tsconfig.json ├── package.json └── tailwind.config.js ├── .env.example ├── src ├── bootstrap.php ├── Chat │ ├── MessageRepositoryInterface.php │ ├── Client.php │ ├── Message.php │ ├── MessageRepository.php │ └── ChatServer.php └── Database │ └── Connection.php ├── .gitignore ├── composer.json ├── phpunit.xml ├── database └── schema.sql ├── bin └── server.php ├── .github └── workflows │ └── ci.yml ├── tests └── Unit │ └── Chat │ ├── ClientTest.php │ ├── MessageTest.php │ └── ChatServerTest.php └── README.md /client/postcss.config.js: -------------------------------------------------------------------------------- 1 | export default { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | } 7 | -------------------------------------------------------------------------------- /client/src/vite-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | interface ImportMetaEnv { 4 | readonly VITE_WS_URL: string 5 | } 6 | 7 | interface ImportMeta { 8 | readonly env: ImportMetaEnv 9 | } 10 | -------------------------------------------------------------------------------- /client/public/chat.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | APP_ENV=development 2 | APP_DEBUG=true 3 | 4 | # WebSocket Server 5 | WS_HOST=0.0.0.0 6 | WS_PORT=8080 7 | 8 | # Database 9 | DB_HOST=localhost 10 | DB_PORT=3306 11 | DB_NAME=websocket_chat 12 | DB_USER=root 13 | DB_PASS= 14 | 15 | # Timezone 16 | APP_TIMEZONE=UTC 17 | -------------------------------------------------------------------------------- /client/src/main.tsx: -------------------------------------------------------------------------------- 1 | import { StrictMode } from 'react' 2 | import { createRoot } from 'react-dom/client' 3 | import { App } from './App' 4 | import './index.css' 5 | 6 | createRoot(document.getElementById('root')!).render( 7 | 8 | 9 | , 10 | ) 11 | -------------------------------------------------------------------------------- /src/bootstrap.php: -------------------------------------------------------------------------------- 1 | safeLoad(); 11 | 12 | date_default_timezone_set($_ENV['APP_TIMEZONE'] ?? 'UTC'); 13 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Dependencies 2 | /vendor/ 3 | /client/node_modules/ 4 | 5 | # Environment 6 | .env 7 | .env.local 8 | 9 | # IDE 10 | .idea/ 11 | .vscode/ 12 | *.swp 13 | *.swo 14 | 15 | # OS 16 | .DS_Store 17 | Thumbs.db 18 | 19 | # Build 20 | /client/dist/ 21 | 22 | # Logs 23 | *.log 24 | logs/ 25 | 26 | # Cache 27 | .phpunit.cache/ 28 | .php-cs-fixer.cache 29 | -------------------------------------------------------------------------------- /client/vite.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vite' 2 | import react from '@vitejs/plugin-react' 3 | import path from 'path' 4 | 5 | export default defineConfig({ 6 | plugins: [react()], 7 | resolve: { 8 | alias: { 9 | '@': path.resolve(__dirname, './src'), 10 | }, 11 | }, 12 | server: { 13 | port: 3000, 14 | open: true, 15 | }, 16 | }) 17 | -------------------------------------------------------------------------------- /src/Chat/MessageRepositoryInterface.php: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | WebSocket Chat 8 | 9 | 10 |
11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /client/vitest.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vitest/config' 2 | import react from '@vitejs/plugin-react' 3 | import path from 'path' 4 | 5 | export default defineConfig({ 6 | plugins: [react()], 7 | test: { 8 | globals: true, 9 | environment: 'jsdom', 10 | setupFiles: ['./src/test/setup.ts'], 11 | include: ['src/**/*.{test,spec}.{js,mjs,cjs,ts,mts,cts,jsx,tsx}'], 12 | coverage: { 13 | provider: 'v8', 14 | reporter: ['text', 'json', 'html'], 15 | exclude: [ 16 | 'node_modules/', 17 | 'src/test/', 18 | '**/*.d.ts', 19 | '**/*.config.*', 20 | '**/components/ui/**', 21 | ], 22 | }, 23 | }, 24 | resolve: { 25 | alias: { 26 | '@': path.resolve(__dirname, './src'), 27 | }, 28 | }, 29 | }) 30 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "andrefigueira/websocket-chat", 3 | "description": "Modern real-time chat application using WebSockets", 4 | "type": "project", 5 | "license": "MIT", 6 | "autoload": { 7 | "psr-4": { 8 | "App\\": "src/" 9 | } 10 | }, 11 | "require": { 12 | "php": "^8.2", 13 | "cboden/ratchet": "^0.4", 14 | "vlucas/phpdotenv": "^5.6", 15 | "monolog/monolog": "^3.0", 16 | "ramsey/uuid": "^4.7" 17 | }, 18 | "require-dev": { 19 | "phpstan/phpstan": "^1.10", 20 | "phpunit/phpunit": "^11.0", 21 | "mockery/mockery": "^1.6" 22 | }, 23 | "scripts": { 24 | "serve": "php bin/server.php", 25 | "analyse": "phpstan analyse src --level=6" 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /client/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", 4 | "target": "ES2020", 5 | "useDefineForClassFields": true, 6 | "lib": ["ES2020", "DOM", "DOM.Iterable"], 7 | "module": "ESNext", 8 | "skipLibCheck": true, 9 | "moduleResolution": "bundler", 10 | "allowImportingTsExtensions": true, 11 | "isolatedModules": true, 12 | "moduleDetection": "force", 13 | "noEmit": true, 14 | "jsx": "react-jsx", 15 | "strict": true, 16 | "noUnusedLocals": true, 17 | "noUnusedParameters": true, 18 | "noFallthroughCasesInSwitch": true, 19 | "noUncheckedSideEffectImports": true, 20 | "baseUrl": ".", 21 | "paths": { 22 | "@/*": ["./src/*"] 23 | } 24 | }, 25 | "include": ["src"] 26 | } 27 | -------------------------------------------------------------------------------- /src/Chat/Client.php: -------------------------------------------------------------------------------- 1 | id = Uuid::uuid4()->toString(); 21 | $this->userId = ''; 22 | $this->username = 'Anonymous'; 23 | $this->conversationId = 'general'; 24 | } 25 | 26 | public function isIdentified(): bool 27 | { 28 | return $this->userId !== ''; 29 | } 30 | 31 | public function send(array|Message $data): void 32 | { 33 | $json = json_encode($data, JSON_THROW_ON_ERROR); 34 | $this->connection->send($json); 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /client/src/components/ui/input.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import { cn } from '@/lib/utils' 3 | 4 | export interface InputProps 5 | extends React.InputHTMLAttributes {} 6 | 7 | const Input = React.forwardRef( 8 | ({ className, type, ...props }, ref) => { 9 | return ( 10 | 19 | ) 20 | } 21 | ) 22 | Input.displayName = 'Input' 23 | 24 | export { Input } 25 | -------------------------------------------------------------------------------- /phpunit.xml: -------------------------------------------------------------------------------- 1 | 2 | 13 | 14 | 15 | tests/Unit 16 | 17 | 18 | 19 | 20 | 21 | src 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | -------------------------------------------------------------------------------- /client/src/App.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from 'react' 2 | import { ChatProvider, useChat } from '@/context/ChatContext' 3 | import { LoginForm } from '@/components/chat/LoginForm' 4 | import { ChatRoom } from '@/components/chat/ChatRoom' 5 | 6 | function ChatApp() { 7 | const { state, status, identify, join } = useChat() 8 | const [isLoggingIn, setIsLoggingIn] = useState(false) 9 | 10 | useEffect(() => { 11 | if (state.isIdentified && !state.isJoined) { 12 | join('general') 13 | } 14 | }, [state.isIdentified, state.isJoined, join]) 15 | 16 | useEffect(() => { 17 | if (state.isJoined) { 18 | setIsLoggingIn(false) 19 | } 20 | }, [state.isJoined]) 21 | 22 | const handleLogin = (username: string) => { 23 | setIsLoggingIn(true) 24 | identify(username) 25 | } 26 | 27 | if (!state.isJoined) { 28 | return ( 29 | 33 | ) 34 | } 35 | 36 | return 37 | } 38 | 39 | export function App() { 40 | return ( 41 | 42 | 43 | 44 | ) 45 | } 46 | -------------------------------------------------------------------------------- /client/src/components/chat/ChatRoom.tsx: -------------------------------------------------------------------------------- 1 | import { useMemo } from 'react' 2 | import { useChat } from '@/context/ChatContext' 3 | import { ChatHeader } from './ChatHeader' 4 | import { MessageList } from './MessageList' 5 | import { MessageInput } from './MessageInput' 6 | 7 | export function ChatRoom() { 8 | const { state, status, sendMessage, sendTyping, disconnect } = useChat() 9 | 10 | const typingUsernames = useMemo(() => { 11 | return Array.from(state.typingUsers.values()) 12 | .filter((t) => t.userId !== state.user?.id) 13 | .map((t) => t.username) 14 | }, [state.typingUsers, state.user?.id]) 15 | 16 | if (!state.user) { 17 | return null 18 | } 19 | 20 | return ( 21 |
22 | 28 | 29 | 34 | 35 | 40 |
41 | ) 42 | } 43 | -------------------------------------------------------------------------------- /client/src/test/setup.ts: -------------------------------------------------------------------------------- 1 | import '@testing-library/jest-dom' 2 | import { afterEach, vi } from 'vitest' 3 | import { cleanup } from '@testing-library/react' 4 | 5 | afterEach(() => { 6 | cleanup() 7 | }) 8 | 9 | class MockWebSocket { 10 | static CONNECTING = 0 11 | static OPEN = 1 12 | static CLOSING = 2 13 | static CLOSED = 3 14 | 15 | readyState = MockWebSocket.CONNECTING 16 | onopen: ((event: Event) => void) | null = null 17 | onclose: ((event: CloseEvent) => void) | null = null 18 | onmessage: ((event: MessageEvent) => void) | null = null 19 | onerror: ((event: Event) => void) | null = null 20 | 21 | constructor(public url: string) { 22 | setTimeout(() => { 23 | this.readyState = MockWebSocket.OPEN 24 | this.onopen?.(new Event('open')) 25 | }, 0) 26 | } 27 | 28 | send = vi.fn() 29 | close = vi.fn(() => { 30 | this.readyState = MockWebSocket.CLOSED 31 | this.onclose?.(new CloseEvent('close')) 32 | }) 33 | 34 | simulateMessage(data: unknown) { 35 | this.onmessage?.(new MessageEvent('message', { data: JSON.stringify(data) })) 36 | } 37 | 38 | simulateError() { 39 | this.onerror?.(new Event('error')) 40 | } 41 | } 42 | 43 | vi.stubGlobal('WebSocket', MockWebSocket) 44 | 45 | Object.defineProperty(window, 'crypto', { 46 | value: { 47 | randomUUID: () => 'test-uuid-' + Math.random().toString(36).substring(2, 9), 48 | }, 49 | }) 50 | -------------------------------------------------------------------------------- /database/schema.sql: -------------------------------------------------------------------------------- 1 | -- Database schema for WebSocket Chat 2 | -- Run this to set up your database 3 | 4 | CREATE DATABASE IF NOT EXISTS websocket_chat 5 | CHARACTER SET utf8mb4 6 | COLLATE utf8mb4_unicode_ci; 7 | 8 | USE websocket_chat; 9 | 10 | CREATE TABLE IF NOT EXISTS messages ( 11 | id VARCHAR(36) PRIMARY KEY, 12 | conversation_id VARCHAR(100) NOT NULL DEFAULT 'general', 13 | user_id VARCHAR(100) NOT NULL, 14 | username VARCHAR(100) NOT NULL, 15 | content TEXT NOT NULL, 16 | type ENUM('message', 'system') NOT NULL DEFAULT 'message', 17 | created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, 18 | INDEX idx_conversation_created (conversation_id, created_at), 19 | INDEX idx_user_id (user_id) 20 | ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; 21 | 22 | CREATE TABLE IF NOT EXISTS conversations ( 23 | id VARCHAR(36) PRIMARY KEY, 24 | name VARCHAR(255) NOT NULL, 25 | created_by VARCHAR(100) NOT NULL, 26 | created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, 27 | updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, 28 | INDEX idx_created_by (created_by) 29 | ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; 30 | 31 | -- Insert default conversation 32 | INSERT INTO conversations (id, name, created_by) VALUES 33 | ('general', 'General Chat', 'system') 34 | ON DUPLICATE KEY UPDATE name = name; 35 | -------------------------------------------------------------------------------- /client/src/lib/utils.ts: -------------------------------------------------------------------------------- 1 | import { clsx, type ClassValue } from 'clsx' 2 | import { twMerge } from 'tailwind-merge' 3 | 4 | export function cn(...inputs: ClassValue[]) { 5 | return twMerge(clsx(inputs)) 6 | } 7 | 8 | export function formatTime(date: Date | string): string { 9 | const d = typeof date === 'string' ? new Date(date) : date 10 | return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) 11 | } 12 | 13 | export function generateUserId(): string { 14 | return `user_${Math.random().toString(36).substring(2, 11)}` 15 | } 16 | 17 | export function getInitials(name: string): string { 18 | return name 19 | .split(' ') 20 | .map((n) => n[0]) 21 | .join('') 22 | .toUpperCase() 23 | .slice(0, 2) 24 | } 25 | 26 | export function getAvatarColor(userId: string): string { 27 | const colors = [ 28 | 'bg-red-500', 29 | 'bg-orange-500', 30 | 'bg-amber-500', 31 | 'bg-yellow-500', 32 | 'bg-lime-500', 33 | 'bg-green-500', 34 | 'bg-emerald-500', 35 | 'bg-teal-500', 36 | 'bg-cyan-500', 37 | 'bg-sky-500', 38 | 'bg-blue-500', 39 | 'bg-indigo-500', 40 | 'bg-violet-500', 41 | 'bg-purple-500', 42 | 'bg-fuchsia-500', 43 | 'bg-pink-500', 44 | 'bg-rose-500', 45 | ] 46 | 47 | const hash = userId.split('').reduce((acc, char) => { 48 | return char.charCodeAt(0) + ((acc << 5) - acc) 49 | }, 0) 50 | 51 | return colors[Math.abs(hash) % colors.length] 52 | } 53 | -------------------------------------------------------------------------------- /bin/server.php: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env php 2 | pushHandler(new StreamHandler('php://stdout', $logLevel)); 21 | $logger->pushHandler(new StreamHandler(dirname(__DIR__) . '/logs/chat.log', $logLevel)); 22 | 23 | $host = $_ENV['WS_HOST'] ?? '0.0.0.0'; 24 | $port = (int) ($_ENV['WS_PORT'] ?? 8080); 25 | 26 | $repository = new MessageRepository($logger); 27 | $chatServer = new ChatServer($repository, $logger); 28 | 29 | $server = IoServer::factory( 30 | new HttpServer( 31 | new WsServer($chatServer) 32 | ), 33 | $port, 34 | $host 35 | ); 36 | 37 | $logger->info("WebSocket server started", [ 38 | 'host' => $host, 39 | 'port' => $port, 40 | ]); 41 | 42 | echo <<run(); 52 | -------------------------------------------------------------------------------- /client/src/types/chat.ts: -------------------------------------------------------------------------------- 1 | export interface Message { 2 | id: string 3 | conversationId: string 4 | userId: string 5 | username: string 6 | content: string 7 | type: 'message' | 'system' 8 | timestamp: string 9 | } 10 | 11 | export interface User { 12 | id: string 13 | username: string 14 | } 15 | 16 | export interface TypingIndicator { 17 | userId: string 18 | username: string 19 | isTyping: boolean 20 | } 21 | 22 | export type ConnectionStatus = 'connecting' | 'connected' | 'disconnected' | 'error' 23 | 24 | export interface WebSocketMessage { 25 | type: string 26 | [key: string]: unknown 27 | } 28 | 29 | export interface ConnectedMessage extends WebSocketMessage { 30 | type: 'connected' 31 | clientId: string 32 | message: string 33 | } 34 | 35 | export interface IdentifiedMessage extends WebSocketMessage { 36 | type: 'identified' 37 | userId: string 38 | username: string 39 | } 40 | 41 | export interface JoinedMessage extends WebSocketMessage { 42 | type: 'joined' 43 | conversationId: string 44 | } 45 | 46 | export interface HistoryMessage extends WebSocketMessage { 47 | type: 'history' 48 | conversationId: string 49 | messages: Message[] 50 | } 51 | 52 | export interface TypingMessage extends WebSocketMessage { 53 | type: 'typing' 54 | userId: string 55 | username: string 56 | isTyping: boolean 57 | } 58 | 59 | export interface ErrorMessage extends WebSocketMessage { 60 | type: 'error' 61 | message: string 62 | } 63 | -------------------------------------------------------------------------------- /client/src/components/ui/avatar.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import * as AvatarPrimitive from '@radix-ui/react-avatar' 3 | import { cn } from '@/lib/utils' 4 | 5 | const Avatar = React.forwardRef< 6 | React.ElementRef, 7 | React.ComponentPropsWithoutRef 8 | >(({ className, ...props }, ref) => ( 9 | 17 | )) 18 | Avatar.displayName = AvatarPrimitive.Root.displayName 19 | 20 | const AvatarImage = React.forwardRef< 21 | React.ElementRef, 22 | React.ComponentPropsWithoutRef 23 | >(({ className, ...props }, ref) => ( 24 | 29 | )) 30 | AvatarImage.displayName = AvatarPrimitive.Image.displayName 31 | 32 | const AvatarFallback = React.forwardRef< 33 | React.ElementRef, 34 | React.ComponentPropsWithoutRef 35 | >(({ className, ...props }, ref) => ( 36 | 44 | )) 45 | AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName 46 | 47 | export { Avatar, AvatarImage, AvatarFallback } 48 | -------------------------------------------------------------------------------- /src/Database/Connection.php: -------------------------------------------------------------------------------- 1 | PDO::ERRMODE_EXCEPTION, 32 | PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC, 33 | PDO::ATTR_EMULATE_PREPARES => false, 34 | ] 35 | ); 36 | 37 | $logger?->info('Database connection established'); 38 | } catch (PDOException $e) { 39 | $logger?->error('Database connection failed: ' . $e->getMessage()); 40 | throw $e; 41 | } 42 | } 43 | 44 | return self::$instance; 45 | } 46 | 47 | public static function close(): void 48 | { 49 | self::$instance = null; 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /client/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "websocket-chat-client", 3 | "private": true, 4 | "version": "1.0.0", 5 | "type": "module", 6 | "scripts": { 7 | "dev": "vite", 8 | "build": "tsc -b && vite build", 9 | "lint": "eslint .", 10 | "preview": "vite preview", 11 | "test": "vitest", 12 | "test:run": "vitest run", 13 | "test:coverage": "vitest run --coverage" 14 | }, 15 | "dependencies": { 16 | "@radix-ui/react-avatar": "^1.1.1", 17 | "@radix-ui/react-scroll-area": "^1.2.0", 18 | "@radix-ui/react-slot": "^1.1.0", 19 | "@radix-ui/react-tooltip": "^1.1.3", 20 | "class-variance-authority": "^0.7.0", 21 | "clsx": "^2.1.1", 22 | "lucide-react": "^0.460.0", 23 | "react": "^18.3.1", 24 | "react-dom": "^18.3.1", 25 | "tailwind-merge": "^2.5.4", 26 | "tailwindcss-animate": "^1.0.7" 27 | }, 28 | "devDependencies": { 29 | "@eslint/js": "^9.13.0", 30 | "@testing-library/jest-dom": "^6.6.2", 31 | "@testing-library/react": "^16.0.1", 32 | "@testing-library/user-event": "^14.5.2", 33 | "@types/node": "^22.9.0", 34 | "@types/react": "^18.3.12", 35 | "@types/react-dom": "^18.3.1", 36 | "@vitejs/plugin-react": "^4.3.3", 37 | "@vitest/coverage-v8": "^2.1.4", 38 | "autoprefixer": "^10.4.20", 39 | "eslint": "^9.13.0", 40 | "eslint-plugin-react-hooks": "^5.0.0", 41 | "eslint-plugin-react-refresh": "^0.4.14", 42 | "globals": "^15.11.0", 43 | "jsdom": "^25.0.1", 44 | "postcss": "^8.4.47", 45 | "tailwindcss": "^3.4.14", 46 | "typescript": "~5.6.2", 47 | "typescript-eslint": "^8.11.0", 48 | "vite": "^5.4.10", 49 | "vitest": "^2.1.4" 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /client/tailwind.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('tailwindcss').Config} */ 2 | export default { 3 | darkMode: ["class"], 4 | content: [ 5 | "./index.html", 6 | "./src/**/*.{js,ts,jsx,tsx}", 7 | ], 8 | theme: { 9 | extend: { 10 | colors: { 11 | border: "hsl(var(--border))", 12 | input: "hsl(var(--input))", 13 | ring: "hsl(var(--ring))", 14 | background: "hsl(var(--background))", 15 | foreground: "hsl(var(--foreground))", 16 | primary: { 17 | DEFAULT: "hsl(var(--primary))", 18 | foreground: "hsl(var(--primary-foreground))", 19 | }, 20 | secondary: { 21 | DEFAULT: "hsl(var(--secondary))", 22 | foreground: "hsl(var(--secondary-foreground))", 23 | }, 24 | destructive: { 25 | DEFAULT: "hsl(var(--destructive))", 26 | foreground: "hsl(var(--destructive-foreground))", 27 | }, 28 | muted: { 29 | DEFAULT: "hsl(var(--muted))", 30 | foreground: "hsl(var(--muted-foreground))", 31 | }, 32 | accent: { 33 | DEFAULT: "hsl(var(--accent))", 34 | foreground: "hsl(var(--accent-foreground))", 35 | }, 36 | popover: { 37 | DEFAULT: "hsl(var(--popover))", 38 | foreground: "hsl(var(--popover-foreground))", 39 | }, 40 | card: { 41 | DEFAULT: "hsl(var(--card))", 42 | foreground: "hsl(var(--card-foreground))", 43 | }, 44 | }, 45 | borderRadius: { 46 | lg: "var(--radius)", 47 | md: "calc(var(--radius) - 2px)", 48 | sm: "calc(var(--radius) - 4px)", 49 | }, 50 | }, 51 | }, 52 | plugins: [require("tailwindcss-animate")], 53 | } 54 | -------------------------------------------------------------------------------- /client/src/index.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | @layer base { 6 | :root { 7 | --background: 0 0% 100%; 8 | --foreground: 240 10% 3.9%; 9 | --card: 0 0% 100%; 10 | --card-foreground: 240 10% 3.9%; 11 | --popover: 0 0% 100%; 12 | --popover-foreground: 240 10% 3.9%; 13 | --primary: 240 5.9% 10%; 14 | --primary-foreground: 0 0% 98%; 15 | --secondary: 240 4.8% 95.9%; 16 | --secondary-foreground: 240 5.9% 10%; 17 | --muted: 240 4.8% 95.9%; 18 | --muted-foreground: 240 3.8% 46.1%; 19 | --accent: 240 4.8% 95.9%; 20 | --accent-foreground: 240 5.9% 10%; 21 | --destructive: 0 84.2% 60.2%; 22 | --destructive-foreground: 0 0% 98%; 23 | --border: 240 5.9% 90%; 24 | --input: 240 5.9% 90%; 25 | --ring: 240 5.9% 10%; 26 | --radius: 0.5rem; 27 | } 28 | 29 | .dark { 30 | --background: 240 10% 3.9%; 31 | --foreground: 0 0% 98%; 32 | --card: 240 10% 3.9%; 33 | --card-foreground: 0 0% 98%; 34 | --popover: 240 10% 3.9%; 35 | --popover-foreground: 0 0% 98%; 36 | --primary: 0 0% 98%; 37 | --primary-foreground: 240 5.9% 10%; 38 | --secondary: 240 3.7% 15.9%; 39 | --secondary-foreground: 0 0% 98%; 40 | --muted: 240 3.7% 15.9%; 41 | --muted-foreground: 240 5% 64.9%; 42 | --accent: 240 3.7% 15.9%; 43 | --accent-foreground: 0 0% 98%; 44 | --destructive: 0 62.8% 30.6%; 45 | --destructive-foreground: 0 0% 98%; 46 | --border: 240 3.7% 15.9%; 47 | --input: 240 3.7% 15.9%; 48 | --ring: 240 4.9% 83.9%; 49 | } 50 | } 51 | 52 | @layer base { 53 | * { 54 | @apply border-border; 55 | } 56 | body { 57 | @apply bg-background text-foreground; 58 | font-feature-settings: "rlig" 1, "calt" 1; 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /client/src/components/ui/scroll-area.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import * as ScrollAreaPrimitive from '@radix-ui/react-scroll-area' 3 | import { cn } from '@/lib/utils' 4 | 5 | const ScrollArea = React.forwardRef< 6 | React.ElementRef, 7 | React.ComponentPropsWithoutRef 8 | >(({ className, children, ...props }, ref) => ( 9 | 14 | 15 | {children} 16 | 17 | 18 | 19 | 20 | )) 21 | ScrollArea.displayName = ScrollAreaPrimitive.Root.displayName 22 | 23 | const ScrollBar = React.forwardRef< 24 | React.ElementRef, 25 | React.ComponentPropsWithoutRef 26 | >(({ className, orientation = 'vertical', ...props }, ref) => ( 27 | 40 | 41 | 42 | )) 43 | ScrollBar.displayName = ScrollAreaPrimitive.ScrollAreaScrollbar.displayName 44 | 45 | export { ScrollArea, ScrollBar } 46 | -------------------------------------------------------------------------------- /client/src/components/chat/MessageBubble.tsx: -------------------------------------------------------------------------------- 1 | import { memo } from 'react' 2 | import { Avatar, AvatarFallback } from '@/components/ui/avatar' 3 | import { cn, formatTime, getAvatarColor, getInitials } from '@/lib/utils' 4 | import type { Message } from '@/types/chat' 5 | 6 | interface MessageBubbleProps { 7 | message: Message 8 | isOwn: boolean 9 | } 10 | 11 | export const MessageBubble = memo(function MessageBubble({ 12 | message, 13 | isOwn, 14 | }: MessageBubbleProps) { 15 | if (message.type === 'system') { 16 | return ( 17 |
18 | 19 | {message.content} 20 | 21 |
22 | ) 23 | } 24 | 25 | return ( 26 |
29 | {!isOwn && ( 30 | 31 | 34 | {getInitials(message.username)} 35 | 36 | 37 | )} 38 | 39 |
45 | {!isOwn && ( 46 | 47 | {message.username} 48 | 49 | )} 50 | 51 |
59 |

{message.content}

60 |
61 | 62 | 63 | {formatTime(message.timestamp)} 64 | 65 |
66 |
67 | ) 68 | }) 69 | -------------------------------------------------------------------------------- /client/src/components/chat/MessageList.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useRef } from 'react' 2 | import { ScrollArea } from '@/components/ui/scroll-area' 3 | import { MessageBubble } from './MessageBubble' 4 | import type { Message } from '@/types/chat' 5 | 6 | interface MessageListProps { 7 | messages: Message[] 8 | currentUserId: string 9 | typingUsers: string[] 10 | } 11 | 12 | export function MessageList({ 13 | messages, 14 | currentUserId, 15 | typingUsers, 16 | }: MessageListProps) { 17 | const bottomRef = useRef(null) 18 | 19 | useEffect(() => { 20 | bottomRef.current?.scrollIntoView({ behavior: 'smooth' }) 21 | }, [messages, typingUsers]) 22 | 23 | return ( 24 | 25 |
26 | {messages.length === 0 ? ( 27 |
28 |

29 | No messages yet. Start the conversation! 30 |

31 |
32 | ) : ( 33 | messages.map((message) => ( 34 | 39 | )) 40 | )} 41 | 42 | {typingUsers.length > 0 && ( 43 |
44 |
45 | 46 | 47 | 48 |
49 | 50 | {typingUsers.join(', ')}{' '} 51 | {typingUsers.length === 1 ? 'is' : 'are'} typing... 52 | 53 |
54 | )} 55 | 56 |
57 |
58 | 59 | ) 60 | } 61 | -------------------------------------------------------------------------------- /client/src/components/ui/button.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import { Slot } from '@radix-ui/react-slot' 3 | import { cva, type VariantProps } from 'class-variance-authority' 4 | import { cn } from '@/lib/utils' 5 | 6 | const buttonVariants = cva( 7 | 'inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0', 8 | { 9 | variants: { 10 | variant: { 11 | default: 'bg-primary text-primary-foreground hover:bg-primary/90', 12 | destructive: 13 | 'bg-destructive text-destructive-foreground hover:bg-destructive/90', 14 | outline: 15 | 'border border-input bg-background hover:bg-accent hover:text-accent-foreground', 16 | secondary: 17 | 'bg-secondary text-secondary-foreground hover:bg-secondary/80', 18 | ghost: 'hover:bg-accent hover:text-accent-foreground', 19 | link: 'text-primary underline-offset-4 hover:underline', 20 | }, 21 | size: { 22 | default: 'h-10 px-4 py-2', 23 | sm: 'h-9 rounded-md px-3', 24 | lg: 'h-11 rounded-md px-8', 25 | icon: 'h-10 w-10', 26 | }, 27 | }, 28 | defaultVariants: { 29 | variant: 'default', 30 | size: 'default', 31 | }, 32 | } 33 | ) 34 | 35 | export interface ButtonProps 36 | extends React.ButtonHTMLAttributes, 37 | VariantProps { 38 | asChild?: boolean 39 | } 40 | 41 | const Button = React.forwardRef( 42 | ({ className, variant, size, asChild = false, ...props }, ref) => { 43 | const Comp = asChild ? Slot : 'button' 44 | return ( 45 | 50 | ) 51 | } 52 | ) 53 | Button.displayName = 'Button' 54 | 55 | export { Button, buttonVariants } 56 | -------------------------------------------------------------------------------- /client/src/components/ui/card.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import { cn } from '@/lib/utils' 3 | 4 | const Card = React.forwardRef< 5 | HTMLDivElement, 6 | React.HTMLAttributes 7 | >(({ className, ...props }, ref) => ( 8 |
16 | )) 17 | Card.displayName = 'Card' 18 | 19 | const CardHeader = React.forwardRef< 20 | HTMLDivElement, 21 | React.HTMLAttributes 22 | >(({ className, ...props }, ref) => ( 23 |
28 | )) 29 | CardHeader.displayName = 'CardHeader' 30 | 31 | const CardTitle = React.forwardRef< 32 | HTMLParagraphElement, 33 | React.HTMLAttributes 34 | >(({ className, ...props }, ref) => ( 35 |

43 | )) 44 | CardTitle.displayName = 'CardTitle' 45 | 46 | const CardDescription = React.forwardRef< 47 | HTMLParagraphElement, 48 | React.HTMLAttributes 49 | >(({ className, ...props }, ref) => ( 50 |

55 | )) 56 | CardDescription.displayName = 'CardDescription' 57 | 58 | const CardContent = React.forwardRef< 59 | HTMLDivElement, 60 | React.HTMLAttributes 61 | >(({ className, ...props }, ref) => ( 62 |

63 | )) 64 | CardContent.displayName = 'CardContent' 65 | 66 | const CardFooter = React.forwardRef< 67 | HTMLDivElement, 68 | React.HTMLAttributes 69 | >(({ className, ...props }, ref) => ( 70 |
75 | )) 76 | CardFooter.displayName = 'CardFooter' 77 | 78 | export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent } 79 | -------------------------------------------------------------------------------- /client/src/components/chat/MessageInput.tsx: -------------------------------------------------------------------------------- 1 | import { useCallback, useRef, useState, type FormEvent, type KeyboardEvent } from 'react' 2 | import { Button } from '@/components/ui/button' 3 | import { Input } from '@/components/ui/input' 4 | import { Send } from 'lucide-react' 5 | 6 | interface MessageInputProps { 7 | onSend: (message: string) => void 8 | onTyping: (isTyping: boolean) => void 9 | disabled?: boolean 10 | } 11 | 12 | export function MessageInput({ 13 | onSend, 14 | onTyping, 15 | disabled = false, 16 | }: MessageInputProps) { 17 | const [message, setMessage] = useState('') 18 | const typingTimeoutRef = useRef>() 19 | 20 | const handleTyping = useCallback(() => { 21 | onTyping(true) 22 | 23 | if (typingTimeoutRef.current) { 24 | clearTimeout(typingTimeoutRef.current) 25 | } 26 | 27 | typingTimeoutRef.current = setTimeout(() => { 28 | onTyping(false) 29 | }, 2000) 30 | }, [onTyping]) 31 | 32 | const handleSubmit = (e: FormEvent) => { 33 | e.preventDefault() 34 | const trimmed = message.trim() 35 | if (trimmed) { 36 | onSend(trimmed) 37 | setMessage('') 38 | onTyping(false) 39 | if (typingTimeoutRef.current) { 40 | clearTimeout(typingTimeoutRef.current) 41 | } 42 | } 43 | } 44 | 45 | const handleKeyDown = (e: KeyboardEvent) => { 46 | if (e.key === 'Enter' && !e.shiftKey) { 47 | e.preventDefault() 48 | handleSubmit(e) 49 | } 50 | } 51 | 52 | return ( 53 |
57 | { 62 | setMessage(e.target.value) 63 | handleTyping() 64 | }} 65 | onKeyDown={handleKeyDown} 66 | disabled={disabled} 67 | autoComplete="off" 68 | className="flex-1 border-slate-600 bg-slate-700/50 text-white placeholder:text-slate-400" 69 | /> 70 | 78 |
79 | ) 80 | } 81 | -------------------------------------------------------------------------------- /client/src/components/chat/LoginForm.tsx: -------------------------------------------------------------------------------- 1 | import { useState, type FormEvent } from 'react' 2 | import { Button } from '@/components/ui/button' 3 | import { Input } from '@/components/ui/input' 4 | import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card' 5 | import { MessageCircle } from 'lucide-react' 6 | 7 | interface LoginFormProps { 8 | onSubmit: (username: string) => void 9 | isLoading?: boolean 10 | } 11 | 12 | export function LoginForm({ onSubmit, isLoading = false }: LoginFormProps) { 13 | const [username, setUsername] = useState('') 14 | 15 | const handleSubmit = (e: FormEvent) => { 16 | e.preventDefault() 17 | const trimmed = username.trim() 18 | if (trimmed) { 19 | onSubmit(trimmed) 20 | } 21 | } 22 | 23 | return ( 24 |
25 | 26 | 27 |
28 | 29 |
30 | 31 | WebSocket Chat 32 | 33 | 34 | Enter your username to join the conversation 35 | 36 |
37 | 38 |
39 | setUsername(e.target.value)} 44 | disabled={isLoading} 45 | autoFocus 46 | maxLength={50} 47 | className="border-slate-600 bg-slate-700/50 text-white placeholder:text-slate-400" 48 | /> 49 | 56 |
57 |
58 |
59 |
60 | ) 61 | } 62 | -------------------------------------------------------------------------------- /src/Chat/Message.php: -------------------------------------------------------------------------------- 1 | toString(), 32 | conversationId: $conversationId, 33 | userId: $userId, 34 | username: $username, 35 | content: $content, 36 | type: $type, 37 | timestamp: new DateTimeImmutable(), 38 | ); 39 | } 40 | 41 | public static function fromArray(array $data): self 42 | { 43 | return new self( 44 | id: $data['id'] ?? Uuid::uuid4()->toString(), 45 | conversationId: $data['conversationId'] ?? 'general', 46 | userId: $data['userId'] ?? '', 47 | username: $data['username'] ?? 'Anonymous', 48 | content: $data['content'] ?? '', 49 | type: $data['type'] ?? 'message', 50 | timestamp: isset($data['timestamp']) 51 | ? new DateTimeImmutable($data['timestamp']) 52 | : new DateTimeImmutable(), 53 | ); 54 | } 55 | 56 | public static function system(string $content, string $conversationId = 'general'): self 57 | { 58 | return self::create( 59 | conversationId: $conversationId, 60 | userId: 'system', 61 | username: 'System', 62 | content: $content, 63 | type: 'system', 64 | ); 65 | } 66 | 67 | public function jsonSerialize(): array 68 | { 69 | return [ 70 | 'id' => $this->id, 71 | 'conversationId' => $this->conversationId, 72 | 'userId' => $this->userId, 73 | 'username' => $this->username, 74 | 'content' => $this->content, 75 | 'type' => $this->type, 76 | 'timestamp' => $this->timestamp->format('c'), 77 | ]; 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /client/src/components/chat/ChatHeader.tsx: -------------------------------------------------------------------------------- 1 | import { Avatar, AvatarFallback } from '@/components/ui/avatar' 2 | import { Button } from '@/components/ui/button' 3 | import { cn, getAvatarColor, getInitials } from '@/lib/utils' 4 | import type { ConnectionStatus, User } from '@/types/chat' 5 | import { LogOut, MessageCircle } from 'lucide-react' 6 | 7 | interface ChatHeaderProps { 8 | user: User 9 | conversationId: string 10 | status: ConnectionStatus 11 | onDisconnect: () => void 12 | } 13 | 14 | const statusConfig: Record = { 15 | connected: { label: 'Connected', color: 'bg-green-500' }, 16 | connecting: { label: 'Connecting...', color: 'bg-yellow-500' }, 17 | disconnected: { label: 'Disconnected', color: 'bg-red-500' }, 18 | error: { label: 'Error', color: 'bg-red-500' }, 19 | } 20 | 21 | export function ChatHeader({ 22 | user, 23 | conversationId, 24 | status, 25 | onDisconnect, 26 | }: ChatHeaderProps) { 27 | const { label, color } = statusConfig[status] 28 | 29 | return ( 30 |
31 |
32 |
33 | 34 |
35 |
36 |

37 | #{conversationId} 38 |

39 |
40 | 41 | {label} 42 |
43 |
44 |
45 | 46 |
47 |
48 | 49 | 52 | {getInitials(user.username)} 53 | 54 | 55 | 56 | {user.username} 57 | 58 |
59 | 60 | 68 |
69 |
70 | ) 71 | } 72 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: [master, main] 6 | pull_request: 7 | branches: [master, main] 8 | 9 | jobs: 10 | php-tests: 11 | name: PHP Tests 12 | runs-on: ubuntu-latest 13 | 14 | services: 15 | mysql: 16 | image: mysql:8.0 17 | env: 18 | MYSQL_ROOT_PASSWORD: root 19 | MYSQL_DATABASE: websocket_chat_test 20 | ports: 21 | - 3306:3306 22 | options: >- 23 | --health-cmd="mysqladmin ping" 24 | --health-interval=10s 25 | --health-timeout=5s 26 | --health-retries=3 27 | 28 | steps: 29 | - name: Checkout code 30 | uses: actions/checkout@v4 31 | 32 | - name: Setup PHP 33 | uses: shivammathur/setup-php@v2 34 | with: 35 | php-version: '8.2' 36 | extensions: pdo, pdo_mysql, mbstring 37 | coverage: xdebug 38 | 39 | - name: Get Composer cache directory 40 | id: composer-cache 41 | run: echo "dir=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT 42 | 43 | - name: Cache Composer dependencies 44 | uses: actions/cache@v4 45 | with: 46 | path: ${{ steps.composer-cache.outputs.dir }} 47 | key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.lock') }} 48 | restore-keys: ${{ runner.os }}-composer- 49 | 50 | - name: Install dependencies 51 | run: composer install --prefer-dist --no-progress 52 | 53 | - name: Create .env file 54 | run: | 55 | cp .env.example .env 56 | echo "DB_HOST=127.0.0.1" >> .env 57 | echo "DB_USER=root" >> .env 58 | echo "DB_PASS=root" >> .env 59 | echo "DB_NAME=websocket_chat_test" >> .env 60 | 61 | - name: Run PHPUnit tests 62 | run: vendor/bin/phpunit --testdox 63 | 64 | - name: Run PHPStan 65 | run: vendor/bin/phpstan analyse src --level=6 --no-progress 66 | 67 | frontend-tests: 68 | name: Frontend Tests 69 | runs-on: ubuntu-latest 70 | 71 | defaults: 72 | run: 73 | working-directory: client 74 | 75 | steps: 76 | - name: Checkout code 77 | uses: actions/checkout@v4 78 | 79 | - name: Setup Node.js 80 | uses: actions/setup-node@v4 81 | with: 82 | node-version: '20' 83 | cache: 'npm' 84 | cache-dependency-path: client/package-lock.json 85 | 86 | - name: Install dependencies 87 | run: npm ci || npm install 88 | 89 | - name: Run TypeScript check 90 | run: npx tsc --noEmit 91 | 92 | - name: Run Vitest tests 93 | run: npm run test:run 94 | 95 | - name: Build production bundle 96 | run: npm run build 97 | -------------------------------------------------------------------------------- /client/src/lib/utils.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it } from 'vitest' 2 | import { cn, formatTime, generateUserId, getAvatarColor, getInitials } from './utils' 3 | 4 | describe('cn', () => { 5 | it('merges class names correctly', () => { 6 | expect(cn('foo', 'bar')).toBe('foo bar') 7 | }) 8 | 9 | it('handles conditional classes', () => { 10 | expect(cn('base', false && 'hidden', true && 'visible')).toBe('base visible') 11 | }) 12 | 13 | it('merges tailwind classes correctly', () => { 14 | expect(cn('px-2 py-1', 'px-4')).toBe('py-1 px-4') 15 | }) 16 | 17 | it('handles arrays', () => { 18 | expect(cn(['foo', 'bar'], 'baz')).toBe('foo bar baz') 19 | }) 20 | }) 21 | 22 | describe('formatTime', () => { 23 | it('formats Date object correctly', () => { 24 | const date = new Date('2024-01-15T10:30:00') 25 | const result = formatTime(date) 26 | expect(result).toMatch(/\d{1,2}:\d{2}/) 27 | }) 28 | 29 | it('formats string date correctly', () => { 30 | const result = formatTime('2024-01-15T14:45:00') 31 | expect(result).toMatch(/\d{1,2}:\d{2}/) 32 | }) 33 | }) 34 | 35 | describe('generateUserId', () => { 36 | it('generates a user ID with correct prefix', () => { 37 | const userId = generateUserId() 38 | expect(userId).toMatch(/^user_[a-z0-9]+$/) 39 | }) 40 | 41 | it('generates unique IDs', () => { 42 | const ids = new Set(Array.from({ length: 100 }, () => generateUserId())) 43 | expect(ids.size).toBe(100) 44 | }) 45 | }) 46 | 47 | describe('getInitials', () => { 48 | it('returns initials for single word', () => { 49 | expect(getInitials('John')).toBe('J') 50 | }) 51 | 52 | it('returns initials for two words', () => { 53 | expect(getInitials('John Doe')).toBe('JD') 54 | }) 55 | 56 | it('limits to two characters', () => { 57 | expect(getInitials('John Michael Doe')).toBe('JM') 58 | }) 59 | 60 | it('handles empty string', () => { 61 | expect(getInitials('')).toBe('') 62 | }) 63 | 64 | it('returns uppercase initials', () => { 65 | expect(getInitials('john doe')).toBe('JD') 66 | }) 67 | }) 68 | 69 | describe('getAvatarColor', () => { 70 | it('returns a valid tailwind color class', () => { 71 | const color = getAvatarColor('user-123') 72 | expect(color).toMatch(/^bg-\w+-500$/) 73 | }) 74 | 75 | it('returns consistent color for same userId', () => { 76 | const color1 = getAvatarColor('user-abc') 77 | const color2 = getAvatarColor('user-abc') 78 | expect(color1).toBe(color2) 79 | }) 80 | 81 | it('returns different colors for different userIds', () => { 82 | const colors = new Set( 83 | ['user-1', 'user-2', 'user-3', 'user-4', 'user-5'].map(getAvatarColor) 84 | ) 85 | expect(colors.size).toBeGreaterThan(1) 86 | }) 87 | }) 88 | -------------------------------------------------------------------------------- /client/src/hooks/useWebSocket.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest' 2 | import { renderHook, act } from '@testing-library/react' 3 | import { useWebSocket } from './useWebSocket' 4 | 5 | describe('useWebSocket', () => { 6 | beforeEach(() => { 7 | vi.useFakeTimers() 8 | }) 9 | 10 | afterEach(() => { 11 | vi.runOnlyPendingTimers() 12 | vi.useRealTimers() 13 | }) 14 | 15 | it('starts in connecting state', () => { 16 | const { result } = renderHook(() => 17 | useWebSocket({ url: 'ws://localhost:8080' }) 18 | ) 19 | 20 | expect(result.current.status).toBe('connecting') 21 | }) 22 | 23 | it('transitions to connected after websocket opens', async () => { 24 | const { result } = renderHook(() => 25 | useWebSocket({ url: 'ws://localhost:8080' }) 26 | ) 27 | 28 | expect(result.current.status).toBe('connecting') 29 | 30 | await act(async () => { 31 | await vi.runAllTimersAsync() 32 | }) 33 | 34 | expect(result.current.status).toBe('connected') 35 | }) 36 | 37 | it('calls onOpen callback when connected', async () => { 38 | const onOpen = vi.fn() 39 | 40 | renderHook(() => 41 | useWebSocket({ 42 | url: 'ws://localhost:8080', 43 | onOpen, 44 | }) 45 | ) 46 | 47 | await act(async () => { 48 | await vi.runAllTimersAsync() 49 | }) 50 | 51 | expect(onOpen).toHaveBeenCalledTimes(1) 52 | }) 53 | 54 | it('provides send function', async () => { 55 | const { result } = renderHook(() => 56 | useWebSocket({ url: 'ws://localhost:8080' }) 57 | ) 58 | 59 | await act(async () => { 60 | await vi.runAllTimersAsync() 61 | }) 62 | 63 | expect(typeof result.current.send).toBe('function') 64 | }) 65 | 66 | it('provides disconnect function', async () => { 67 | const { result } = renderHook(() => 68 | useWebSocket({ url: 'ws://localhost:8080' }) 69 | ) 70 | 71 | await act(async () => { 72 | await vi.runAllTimersAsync() 73 | }) 74 | 75 | expect(typeof result.current.disconnect).toBe('function') 76 | 77 | act(() => { 78 | result.current.disconnect() 79 | }) 80 | 81 | expect(result.current.status).toBe('disconnected') 82 | }) 83 | 84 | it('provides reconnect function', async () => { 85 | const { result } = renderHook(() => 86 | useWebSocket({ url: 'ws://localhost:8080' }) 87 | ) 88 | 89 | await act(async () => { 90 | await vi.runAllTimersAsync() 91 | }) 92 | 93 | expect(result.current.status).toBe('connected') 94 | 95 | act(() => { 96 | result.current.reconnect() 97 | }) 98 | 99 | expect(result.current.status).toBe('connecting') 100 | }) 101 | }) 102 | -------------------------------------------------------------------------------- /src/Chat/MessageRepository.php: -------------------------------------------------------------------------------- 1 | db = Connection::getInstance($this->logger); 19 | } 20 | 21 | public function save(Message $message): bool 22 | { 23 | try { 24 | $stmt = $this->db->prepare(' 25 | INSERT INTO messages (id, conversation_id, user_id, username, content, type, created_at) 26 | VALUES (:id, :conversation_id, :user_id, :username, :content, :type, :created_at) 27 | '); 28 | 29 | $result = $stmt->execute([ 30 | 'id' => $message->id, 31 | 'conversation_id' => $message->conversationId, 32 | 'user_id' => $message->userId, 33 | 'username' => $message->username, 34 | 'content' => $message->content, 35 | 'type' => $message->type, 36 | 'created_at' => $message->timestamp->format('Y-m-d H:i:s'), 37 | ]); 38 | 39 | $this->logger?->debug('Message saved', ['id' => $message->id]); 40 | 41 | return $result; 42 | } catch (\PDOException $e) { 43 | $this->logger?->error('Failed to save message', [ 44 | 'error' => $e->getMessage(), 45 | 'message_id' => $message->id, 46 | ]); 47 | return false; 48 | } 49 | } 50 | 51 | public function getByConversation(string $conversationId, int $limit = 50, int $offset = 0): array 52 | { 53 | try { 54 | $stmt = $this->db->prepare(' 55 | SELECT * FROM messages 56 | WHERE conversation_id = :conversation_id 57 | ORDER BY created_at DESC 58 | LIMIT :limit OFFSET :offset 59 | '); 60 | 61 | $stmt->bindValue('conversation_id', $conversationId); 62 | $stmt->bindValue('limit', $limit, PDO::PARAM_INT); 63 | $stmt->bindValue('offset', $offset, PDO::PARAM_INT); 64 | $stmt->execute(); 65 | 66 | $rows = $stmt->fetchAll(); 67 | 68 | return array_map( 69 | fn(array $row) => Message::fromArray([ 70 | 'id' => $row['id'], 71 | 'conversationId' => $row['conversation_id'], 72 | 'userId' => $row['user_id'], 73 | 'username' => $row['username'], 74 | 'content' => $row['content'], 75 | 'type' => $row['type'], 76 | 'timestamp' => $row['created_at'], 77 | ]), 78 | array_reverse($rows) 79 | ); 80 | } catch (\PDOException $e) { 81 | $this->logger?->error('Failed to fetch messages', [ 82 | 'error' => $e->getMessage(), 83 | 'conversation_id' => $conversationId, 84 | ]); 85 | return []; 86 | } 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /client/src/components/chat/MessageBubble.test.tsx: -------------------------------------------------------------------------------- 1 | import { describe, expect, it } from 'vitest' 2 | import { render, screen } from '@testing-library/react' 3 | import { MessageBubble } from './MessageBubble' 4 | import type { Message } from '@/types/chat' 5 | 6 | const createMessage = (overrides: Partial = {}): Message => ({ 7 | id: 'msg-1', 8 | conversationId: 'general', 9 | userId: 'user-1', 10 | username: 'TestUser', 11 | content: 'Hello, World!', 12 | type: 'message', 13 | timestamp: '2024-01-15T10:30:00Z', 14 | ...overrides, 15 | }) 16 | 17 | describe('MessageBubble', () => { 18 | it('renders message content', () => { 19 | const message = createMessage({ content: 'Test message content' }) 20 | render() 21 | 22 | expect(screen.getByText('Test message content')).toBeInTheDocument() 23 | }) 24 | 25 | it('renders username for non-own messages', () => { 26 | const message = createMessage({ username: 'JohnDoe' }) 27 | render() 28 | 29 | expect(screen.getByText('JohnDoe')).toBeInTheDocument() 30 | }) 31 | 32 | it('does not render username for own messages', () => { 33 | const message = createMessage({ username: 'JohnDoe' }) 34 | render() 35 | 36 | expect(screen.queryByText('JohnDoe')).not.toBeInTheDocument() 37 | }) 38 | 39 | it('renders avatar for non-own messages', () => { 40 | const message = createMessage({ username: 'John Doe' }) 41 | render() 42 | 43 | expect(screen.getByText('JD')).toBeInTheDocument() 44 | }) 45 | 46 | it('does not render avatar for own messages', () => { 47 | const message = createMessage({ username: 'John Doe' }) 48 | render() 49 | 50 | expect(screen.queryByText('JD')).not.toBeInTheDocument() 51 | }) 52 | 53 | it('renders system message differently', () => { 54 | const message = createMessage({ 55 | type: 'system', 56 | content: 'User joined the chat', 57 | }) 58 | render() 59 | 60 | expect(screen.getByText('User joined the chat')).toBeInTheDocument() 61 | expect(screen.queryByText('TestUser')).not.toBeInTheDocument() 62 | }) 63 | 64 | it('renders timestamp', () => { 65 | const message = createMessage() 66 | render() 67 | 68 | const timeElements = screen.getAllByText(/\d{1,2}:\d{2}/) 69 | expect(timeElements.length).toBeGreaterThan(0) 70 | }) 71 | 72 | it('handles long messages', () => { 73 | const longContent = 'A'.repeat(500) 74 | const message = createMessage({ content: longContent }) 75 | render() 76 | 77 | expect(screen.getByText(longContent)).toBeInTheDocument() 78 | }) 79 | 80 | it('handles special characters in content', () => { 81 | const message = createMessage({ content: '' }) 82 | render() 83 | 84 | expect(screen.getByText('')).toBeInTheDocument() 85 | }) 86 | }) 87 | -------------------------------------------------------------------------------- /client/src/components/chat/LoginForm.test.tsx: -------------------------------------------------------------------------------- 1 | import { describe, expect, it, vi } from 'vitest' 2 | import { render, screen } from '@testing-library/react' 3 | import userEvent from '@testing-library/user-event' 4 | import { LoginForm } from './LoginForm' 5 | 6 | describe('LoginForm', () => { 7 | it('renders login form with input and button', () => { 8 | render() 9 | 10 | expect(screen.getByPlaceholderText('Username')).toBeInTheDocument() 11 | expect(screen.getByRole('button', { name: 'Join Chat' })).toBeInTheDocument() 12 | }) 13 | 14 | it('displays app title', () => { 15 | render() 16 | 17 | expect(screen.getByText('WebSocket Chat')).toBeInTheDocument() 18 | }) 19 | 20 | it('submit button is disabled when input is empty', () => { 21 | render() 22 | 23 | const button = screen.getByRole('button', { name: 'Join Chat' }) 24 | expect(button).toBeDisabled() 25 | }) 26 | 27 | it('submit button is enabled when input has value', async () => { 28 | const user = userEvent.setup() 29 | render() 30 | 31 | const input = screen.getByPlaceholderText('Username') 32 | await user.type(input, 'TestUser') 33 | 34 | const button = screen.getByRole('button', { name: 'Join Chat' }) 35 | expect(button).toBeEnabled() 36 | }) 37 | 38 | it('calls onSubmit with username when form is submitted', async () => { 39 | const user = userEvent.setup() 40 | const onSubmit = vi.fn() 41 | render() 42 | 43 | const input = screen.getByPlaceholderText('Username') 44 | await user.type(input, 'TestUser') 45 | 46 | const button = screen.getByRole('button', { name: 'Join Chat' }) 47 | await user.click(button) 48 | 49 | expect(onSubmit).toHaveBeenCalledWith('TestUser') 50 | }) 51 | 52 | it('trims whitespace from username', async () => { 53 | const user = userEvent.setup() 54 | const onSubmit = vi.fn() 55 | render() 56 | 57 | const input = screen.getByPlaceholderText('Username') 58 | await user.type(input, ' TestUser ') 59 | 60 | const button = screen.getByRole('button', { name: 'Join Chat' }) 61 | await user.click(button) 62 | 63 | expect(onSubmit).toHaveBeenCalledWith('TestUser') 64 | }) 65 | 66 | it('does not submit when username is only whitespace', async () => { 67 | const user = userEvent.setup() 68 | const onSubmit = vi.fn() 69 | render() 70 | 71 | const input = screen.getByPlaceholderText('Username') 72 | await user.type(input, ' ') 73 | 74 | const button = screen.getByRole('button', { name: 'Join Chat' }) 75 | expect(button).toBeDisabled() 76 | }) 77 | 78 | it('shows loading state when isLoading is true', () => { 79 | render() 80 | 81 | expect(screen.getByRole('button', { name: 'Connecting...' })).toBeInTheDocument() 82 | expect(screen.getByPlaceholderText('Username')).toBeDisabled() 83 | }) 84 | 85 | it('disables button when loading', () => { 86 | render() 87 | 88 | const button = screen.getByRole('button', { name: 'Connecting...' }) 89 | expect(button).toBeDisabled() 90 | }) 91 | }) 92 | -------------------------------------------------------------------------------- /client/src/hooks/useWebSocket.ts: -------------------------------------------------------------------------------- 1 | import { useCallback, useEffect, useRef, useState } from 'react' 2 | import type { ConnectionStatus, WebSocketMessage } from '@/types/chat' 3 | 4 | interface UseWebSocketOptions { 5 | url: string 6 | onMessage?: (message: WebSocketMessage) => void 7 | onOpen?: () => void 8 | onClose?: () => void 9 | onError?: (error: Event) => void 10 | reconnectAttempts?: number 11 | reconnectInterval?: number 12 | } 13 | 14 | interface UseWebSocketReturn { 15 | status: ConnectionStatus 16 | send: (data: object) => void 17 | disconnect: () => void 18 | reconnect: () => void 19 | } 20 | 21 | export function useWebSocket({ 22 | url, 23 | onMessage, 24 | onOpen, 25 | onClose, 26 | onError, 27 | reconnectAttempts = 5, 28 | reconnectInterval = 3000, 29 | }: UseWebSocketOptions): UseWebSocketReturn { 30 | const [status, setStatus] = useState('disconnected') 31 | const wsRef = useRef(null) 32 | const reconnectCountRef = useRef(0) 33 | const reconnectTimeoutRef = useRef>() 34 | 35 | const clearReconnectTimeout = useCallback(() => { 36 | if (reconnectTimeoutRef.current) { 37 | clearTimeout(reconnectTimeoutRef.current) 38 | reconnectTimeoutRef.current = undefined 39 | } 40 | }, []) 41 | 42 | const connect = useCallback(() => { 43 | if (wsRef.current?.readyState === WebSocket.OPEN) { 44 | return 45 | } 46 | 47 | clearReconnectTimeout() 48 | setStatus('connecting') 49 | 50 | const ws = new WebSocket(url) 51 | 52 | ws.onopen = () => { 53 | setStatus('connected') 54 | reconnectCountRef.current = 0 55 | onOpen?.() 56 | } 57 | 58 | ws.onmessage = (event) => { 59 | try { 60 | const data = JSON.parse(event.data) as WebSocketMessage 61 | onMessage?.(data) 62 | } catch (error) { 63 | console.error('Failed to parse WebSocket message:', error) 64 | } 65 | } 66 | 67 | ws.onclose = () => { 68 | setStatus('disconnected') 69 | wsRef.current = null 70 | onClose?.() 71 | 72 | if (reconnectCountRef.current < reconnectAttempts) { 73 | reconnectCountRef.current += 1 74 | reconnectTimeoutRef.current = setTimeout(() => { 75 | connect() 76 | }, reconnectInterval) 77 | } 78 | } 79 | 80 | ws.onerror = (error) => { 81 | setStatus('error') 82 | onError?.(error) 83 | } 84 | 85 | wsRef.current = ws 86 | }, [url, onMessage, onOpen, onClose, onError, reconnectAttempts, reconnectInterval, clearReconnectTimeout]) 87 | 88 | const disconnect = useCallback(() => { 89 | clearReconnectTimeout() 90 | reconnectCountRef.current = reconnectAttempts 91 | wsRef.current?.close() 92 | wsRef.current = null 93 | setStatus('disconnected') 94 | }, [clearReconnectTimeout, reconnectAttempts]) 95 | 96 | const reconnect = useCallback(() => { 97 | disconnect() 98 | reconnectCountRef.current = 0 99 | connect() 100 | }, [disconnect, connect]) 101 | 102 | const send = useCallback((data: object) => { 103 | if (wsRef.current?.readyState === WebSocket.OPEN) { 104 | wsRef.current.send(JSON.stringify(data)) 105 | } else { 106 | console.warn('WebSocket is not connected') 107 | } 108 | }, []) 109 | 110 | useEffect(() => { 111 | connect() 112 | return () => { 113 | disconnect() 114 | } 115 | }, [connect, disconnect]) 116 | 117 | return { status, send, disconnect, reconnect } 118 | } 119 | -------------------------------------------------------------------------------- /tests/Unit/Chat/ClientTest.php: -------------------------------------------------------------------------------- 1 | mockConnection = $this->createMock(ConnectionInterface::class); 19 | } 20 | 21 | public function testConstructorGeneratesUniqueId(): void 22 | { 23 | $client1 = new Client($this->mockConnection); 24 | $client2 = new Client($this->mockConnection); 25 | 26 | $this->assertNotEmpty($client1->id); 27 | $this->assertNotEmpty($client2->id); 28 | $this->assertNotSame($client1->id, $client2->id); 29 | } 30 | 31 | public function testDefaultValues(): void 32 | { 33 | $client = new Client($this->mockConnection); 34 | 35 | $this->assertSame('', $client->userId); 36 | $this->assertSame('Anonymous', $client->username); 37 | $this->assertSame('general', $client->conversationId); 38 | } 39 | 40 | public function testIsIdentifiedReturnsFalseByDefault(): void 41 | { 42 | $client = new Client($this->mockConnection); 43 | 44 | $this->assertFalse($client->isIdentified()); 45 | } 46 | 47 | public function testIsIdentifiedReturnsTrueWhenUserIdSet(): void 48 | { 49 | $client = new Client($this->mockConnection); 50 | $client->userId = 'user-123'; 51 | 52 | $this->assertTrue($client->isIdentified()); 53 | } 54 | 55 | public function testSendWithArray(): void 56 | { 57 | $this->mockConnection 58 | ->expects($this->once()) 59 | ->method('send') 60 | ->with('{"type":"test","data":"value"}'); 61 | 62 | $client = new Client($this->mockConnection); 63 | $client->send(['type' => 'test', 'data' => 'value']); 64 | } 65 | 66 | public function testSendWithMessage(): void 67 | { 68 | $message = Message::create( 69 | conversationId: 'conv', 70 | userId: 'user', 71 | username: 'Test', 72 | content: 'Hello', 73 | ); 74 | 75 | $this->mockConnection 76 | ->expects($this->once()) 77 | ->method('send') 78 | ->with($this->callback(function ($json) use ($message) { 79 | $data = json_decode($json, true); 80 | return $data['id'] === $message->id 81 | && $data['content'] === 'Hello' 82 | && $data['username'] === 'Test'; 83 | })); 84 | 85 | $client = new Client($this->mockConnection); 86 | $client->send($message); 87 | } 88 | 89 | public function testConnectionPropertyIsReadonly(): void 90 | { 91 | $client = new Client($this->mockConnection); 92 | 93 | $this->assertSame($this->mockConnection, $client->connection); 94 | 95 | $reflection = new \ReflectionProperty($client, 'connection'); 96 | $this->assertTrue($reflection->isReadOnly()); 97 | } 98 | 99 | public function testPropertiesCanBeModified(): void 100 | { 101 | $client = new Client($this->mockConnection); 102 | 103 | $client->userId = 'new-user-id'; 104 | $client->username = 'NewUsername'; 105 | $client->conversationId = 'new-room'; 106 | 107 | $this->assertSame('new-user-id', $client->userId); 108 | $this->assertSame('NewUsername', $client->username); 109 | $this->assertSame('new-room', $client->conversationId); 110 | } 111 | } 112 | -------------------------------------------------------------------------------- /client/src/components/chat/MessageInput.test.tsx: -------------------------------------------------------------------------------- 1 | import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest' 2 | import { render, screen } from '@testing-library/react' 3 | import userEvent from '@testing-library/user-event' 4 | import { MessageInput } from './MessageInput' 5 | 6 | describe('MessageInput', () => { 7 | beforeEach(() => { 8 | vi.useFakeTimers({ shouldAdvanceTime: true }) 9 | }) 10 | 11 | afterEach(() => { 12 | vi.useRealTimers() 13 | }) 14 | 15 | it('renders input and send button', () => { 16 | render() 17 | 18 | expect(screen.getByPlaceholderText('Type a message...')).toBeInTheDocument() 19 | expect(screen.getByRole('button')).toBeInTheDocument() 20 | }) 21 | 22 | it('send button is disabled when input is empty', () => { 23 | render() 24 | 25 | const button = screen.getByRole('button') 26 | expect(button).toBeDisabled() 27 | }) 28 | 29 | it('send button is enabled when input has value', async () => { 30 | const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime }) 31 | render() 32 | 33 | const input = screen.getByPlaceholderText('Type a message...') 34 | await user.type(input, 'Hello') 35 | 36 | const button = screen.getByRole('button') 37 | expect(button).toBeEnabled() 38 | }) 39 | 40 | it('calls onSend when form is submitted', async () => { 41 | const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime }) 42 | const onSend = vi.fn() 43 | render() 44 | 45 | const input = screen.getByPlaceholderText('Type a message...') 46 | await user.type(input, 'Hello') 47 | 48 | const button = screen.getByRole('button') 49 | await user.click(button) 50 | 51 | expect(onSend).toHaveBeenCalledWith('Hello') 52 | }) 53 | 54 | it('clears input after sending', async () => { 55 | const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime }) 56 | render() 57 | 58 | const input = screen.getByPlaceholderText('Type a message...') 59 | await user.type(input, 'Hello') 60 | 61 | const button = screen.getByRole('button') 62 | await user.click(button) 63 | 64 | expect(input).toHaveValue('') 65 | }) 66 | 67 | it('trims whitespace from message', async () => { 68 | const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime }) 69 | const onSend = vi.fn() 70 | render() 71 | 72 | const input = screen.getByPlaceholderText('Type a message...') 73 | await user.type(input, ' Hello ') 74 | 75 | const button = screen.getByRole('button') 76 | await user.click(button) 77 | 78 | expect(onSend).toHaveBeenCalledWith('Hello') 79 | }) 80 | 81 | it('calls onTyping when user types', async () => { 82 | const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime }) 83 | const onTyping = vi.fn() 84 | render() 85 | 86 | const input = screen.getByPlaceholderText('Type a message...') 87 | await user.type(input, 'H') 88 | 89 | expect(onTyping).toHaveBeenCalledWith(true) 90 | }) 91 | 92 | it('disables input and button when disabled prop is true', () => { 93 | render() 94 | 95 | expect(screen.getByPlaceholderText('Type a message...')).toBeDisabled() 96 | expect(screen.getByRole('button')).toBeDisabled() 97 | }) 98 | 99 | it('sends message on Enter key press', async () => { 100 | const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime }) 101 | const onSend = vi.fn() 102 | render() 103 | 104 | const input = screen.getByPlaceholderText('Type a message...') 105 | await user.type(input, 'Hello{Enter}') 106 | 107 | expect(onSend).toHaveBeenCalledWith('Hello') 108 | }) 109 | }) 110 | -------------------------------------------------------------------------------- /tests/Unit/Chat/MessageTest.php: -------------------------------------------------------------------------------- 1 | assertNotEmpty($message->id); 23 | $this->assertSame('test-conv', $message->conversationId); 24 | $this->assertSame('user-123', $message->userId); 25 | $this->assertSame('John', $message->username); 26 | $this->assertSame('Hello, World!', $message->content); 27 | $this->assertSame('message', $message->type); 28 | $this->assertInstanceOf(DateTimeImmutable::class, $message->timestamp); 29 | } 30 | 31 | public function testCreateWithCustomType(): void 32 | { 33 | $message = Message::create( 34 | conversationId: 'test-conv', 35 | userId: 'user-123', 36 | username: 'John', 37 | content: 'Hello!', 38 | type: 'system', 39 | ); 40 | 41 | $this->assertSame('system', $message->type); 42 | } 43 | 44 | public function testSystemCreatesSystemMessage(): void 45 | { 46 | $message = Message::system('User joined the chat', 'my-room'); 47 | 48 | $this->assertSame('system', $message->userId); 49 | $this->assertSame('System', $message->username); 50 | $this->assertSame('User joined the chat', $message->content); 51 | $this->assertSame('system', $message->type); 52 | $this->assertSame('my-room', $message->conversationId); 53 | } 54 | 55 | public function testSystemDefaultsToGeneralConversation(): void 56 | { 57 | $message = Message::system('Hello'); 58 | 59 | $this->assertSame('general', $message->conversationId); 60 | } 61 | 62 | public function testFromArrayCreatesMessageFromData(): void 63 | { 64 | $data = [ 65 | 'id' => 'msg-456', 66 | 'conversationId' => 'room-1', 67 | 'userId' => 'user-789', 68 | 'username' => 'Alice', 69 | 'content' => 'Test message', 70 | 'type' => 'message', 71 | 'timestamp' => '2024-01-15T10:30:00+00:00', 72 | ]; 73 | 74 | $message = Message::fromArray($data); 75 | 76 | $this->assertSame('msg-456', $message->id); 77 | $this->assertSame('room-1', $message->conversationId); 78 | $this->assertSame('user-789', $message->userId); 79 | $this->assertSame('Alice', $message->username); 80 | $this->assertSame('Test message', $message->content); 81 | $this->assertSame('message', $message->type); 82 | } 83 | 84 | public function testFromArrayUsesDefaults(): void 85 | { 86 | $message = Message::fromArray([]); 87 | 88 | $this->assertNotEmpty($message->id); 89 | $this->assertSame('general', $message->conversationId); 90 | $this->assertSame('', $message->userId); 91 | $this->assertSame('Anonymous', $message->username); 92 | $this->assertSame('', $message->content); 93 | $this->assertSame('message', $message->type); 94 | } 95 | 96 | public function testJsonSerializeReturnsCorrectFormat(): void 97 | { 98 | $message = Message::create( 99 | conversationId: 'conv-1', 100 | userId: 'user-1', 101 | username: 'Bob', 102 | content: 'Serialized message', 103 | ); 104 | 105 | $json = $message->jsonSerialize(); 106 | 107 | $this->assertArrayHasKey('id', $json); 108 | $this->assertArrayHasKey('conversationId', $json); 109 | $this->assertArrayHasKey('userId', $json); 110 | $this->assertArrayHasKey('username', $json); 111 | $this->assertArrayHasKey('content', $json); 112 | $this->assertArrayHasKey('type', $json); 113 | $this->assertArrayHasKey('timestamp', $json); 114 | 115 | $this->assertSame('conv-1', $json['conversationId']); 116 | $this->assertSame('user-1', $json['userId']); 117 | $this->assertSame('Bob', $json['username']); 118 | $this->assertSame('Serialized message', $json['content']); 119 | $this->assertSame('message', $json['type']); 120 | $this->assertMatchesRegularExpression('/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/', $json['timestamp']); 121 | } 122 | 123 | public function testMessageIsImmutable(): void 124 | { 125 | $message = Message::create( 126 | conversationId: 'conv', 127 | userId: 'user', 128 | username: 'Test', 129 | content: 'Content', 130 | ); 131 | 132 | $reflection = new \ReflectionClass($message); 133 | $this->assertTrue($reflection->isReadOnly()); 134 | } 135 | } 136 | -------------------------------------------------------------------------------- /client/src/context/ChatContext.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | createContext, 3 | useCallback, 4 | useContext, 5 | useMemo, 6 | useReducer, 7 | type ReactNode, 8 | } from 'react' 9 | import { useWebSocket } from '@/hooks/useWebSocket' 10 | import type { 11 | ConnectionStatus, 12 | Message, 13 | TypingIndicator, 14 | User, 15 | WebSocketMessage, 16 | } from '@/types/chat' 17 | 18 | const WS_URL = import.meta.env.VITE_WS_URL || 'ws://localhost:8080' 19 | 20 | interface ChatState { 21 | user: User | null 22 | messages: Message[] 23 | typingUsers: Map 24 | conversationId: string 25 | isIdentified: boolean 26 | isJoined: boolean 27 | } 28 | 29 | type ChatAction = 30 | | { type: 'SET_USER'; payload: User } 31 | | { type: 'SET_IDENTIFIED'; payload: boolean } 32 | | { type: 'SET_JOINED'; payload: { conversationId: string } } 33 | | { type: 'ADD_MESSAGE'; payload: Message } 34 | | { type: 'SET_MESSAGES'; payload: Message[] } 35 | | { type: 'SET_TYPING'; payload: TypingIndicator } 36 | | { type: 'CLEAR_TYPING'; payload: string } 37 | | { type: 'RESET' } 38 | 39 | function chatReducer(state: ChatState, action: ChatAction): ChatState { 40 | switch (action.type) { 41 | case 'SET_USER': 42 | return { ...state, user: action.payload } 43 | case 'SET_IDENTIFIED': 44 | return { ...state, isIdentified: action.payload } 45 | case 'SET_JOINED': 46 | return { 47 | ...state, 48 | isJoined: true, 49 | conversationId: action.payload.conversationId, 50 | } 51 | case 'ADD_MESSAGE': 52 | return { ...state, messages: [...state.messages, action.payload] } 53 | case 'SET_MESSAGES': 54 | return { ...state, messages: action.payload } 55 | case 'SET_TYPING': { 56 | const newTypingUsers = new Map(state.typingUsers) 57 | if (action.payload.isTyping) { 58 | newTypingUsers.set(action.payload.userId, action.payload) 59 | } else { 60 | newTypingUsers.delete(action.payload.userId) 61 | } 62 | return { ...state, typingUsers: newTypingUsers } 63 | } 64 | case 'CLEAR_TYPING': { 65 | const newTypingUsers = new Map(state.typingUsers) 66 | newTypingUsers.delete(action.payload) 67 | return { ...state, typingUsers: newTypingUsers } 68 | } 69 | case 'RESET': 70 | return initialState 71 | default: 72 | return state 73 | } 74 | } 75 | 76 | const initialState: ChatState = { 77 | user: null, 78 | messages: [], 79 | typingUsers: new Map(), 80 | conversationId: 'general', 81 | isIdentified: false, 82 | isJoined: false, 83 | } 84 | 85 | interface ChatContextValue { 86 | state: ChatState 87 | status: ConnectionStatus 88 | identify: (username: string) => void 89 | join: (conversationId?: string) => void 90 | sendMessage: (content: string) => void 91 | sendTyping: (isTyping: boolean) => void 92 | disconnect: () => void 93 | reconnect: () => void 94 | } 95 | 96 | const ChatContext = createContext(null) 97 | 98 | interface ChatProviderProps { 99 | children: ReactNode 100 | } 101 | 102 | export function ChatProvider({ children }: ChatProviderProps) { 103 | const [state, dispatch] = useReducer(chatReducer, initialState) 104 | 105 | const handleMessage = useCallback((data: WebSocketMessage) => { 106 | switch (data.type) { 107 | case 'connected': 108 | break 109 | 110 | case 'identified': 111 | dispatch({ 112 | type: 'SET_USER', 113 | payload: { 114 | id: data.userId as string, 115 | username: data.username as string, 116 | }, 117 | }) 118 | dispatch({ type: 'SET_IDENTIFIED', payload: true }) 119 | break 120 | 121 | case 'joined': 122 | dispatch({ 123 | type: 'SET_JOINED', 124 | payload: { conversationId: data.conversationId as string }, 125 | }) 126 | break 127 | 128 | case 'history': 129 | dispatch({ 130 | type: 'SET_MESSAGES', 131 | payload: data.messages as Message[], 132 | }) 133 | break 134 | 135 | case 'message': 136 | case 'system': 137 | dispatch({ 138 | type: 'ADD_MESSAGE', 139 | payload: data as unknown as Message, 140 | }) 141 | break 142 | 143 | case 'typing': 144 | dispatch({ 145 | type: 'SET_TYPING', 146 | payload: { 147 | userId: data.userId as string, 148 | username: data.username as string, 149 | isTyping: data.isTyping as boolean, 150 | }, 151 | }) 152 | break 153 | 154 | case 'error': 155 | console.error('Server error:', data.message) 156 | break 157 | } 158 | }, []) 159 | 160 | const handleClose = useCallback(() => { 161 | dispatch({ type: 'RESET' }) 162 | }, []) 163 | 164 | const { status, send, disconnect, reconnect } = useWebSocket({ 165 | url: WS_URL, 166 | onMessage: handleMessage, 167 | onClose: handleClose, 168 | }) 169 | 170 | const identify = useCallback( 171 | (username: string) => { 172 | send({ 173 | action: 'identify', 174 | username, 175 | }) 176 | }, 177 | [send] 178 | ) 179 | 180 | const join = useCallback( 181 | (conversationId = 'general') => { 182 | send({ 183 | action: 'join', 184 | conversationId, 185 | }) 186 | }, 187 | [send] 188 | ) 189 | 190 | const sendMessage = useCallback( 191 | (content: string) => { 192 | if (!state.user) return 193 | 194 | const message: Message = { 195 | id: crypto.randomUUID(), 196 | conversationId: state.conversationId, 197 | userId: state.user.id, 198 | username: state.user.username, 199 | content, 200 | type: 'message', 201 | timestamp: new Date().toISOString(), 202 | } 203 | 204 | dispatch({ type: 'ADD_MESSAGE', payload: message }) 205 | 206 | send({ 207 | action: 'message', 208 | content, 209 | }) 210 | }, 211 | [send, state.user, state.conversationId] 212 | ) 213 | 214 | const sendTyping = useCallback( 215 | (isTyping: boolean) => { 216 | send({ 217 | action: 'typing', 218 | isTyping, 219 | }) 220 | }, 221 | [send] 222 | ) 223 | 224 | const value = useMemo( 225 | () => ({ 226 | state, 227 | status, 228 | identify, 229 | join, 230 | sendMessage, 231 | sendTyping, 232 | disconnect, 233 | reconnect, 234 | }), 235 | [state, status, identify, join, sendMessage, sendTyping, disconnect, reconnect] 236 | ) 237 | 238 | return {children} 239 | } 240 | 241 | export function useChat(): ChatContextValue { 242 | const context = useContext(ChatContext) 243 | if (!context) { 244 | throw new Error('useChat must be used within a ChatProvider') 245 | } 246 | return context 247 | } 248 | -------------------------------------------------------------------------------- /src/Chat/ChatServer.php: -------------------------------------------------------------------------------- 1 | */ 15 | private SplObjectStorage $clients; 16 | 17 | public function __construct( 18 | private readonly MessageRepositoryInterface $repository, 19 | private readonly LoggerInterface $logger, 20 | ) { 21 | $this->clients = new SplObjectStorage(); 22 | $this->logger->info('Chat server initialized'); 23 | } 24 | 25 | public function onOpen(ConnectionInterface $conn): void 26 | { 27 | $client = new Client($conn); 28 | $this->clients->attach($conn, $client); 29 | 30 | $this->logger->info('New connection', [ 31 | 'client_id' => $client->id, 32 | 'resource_id' => $conn->resourceId, 33 | ]); 34 | 35 | $client->send([ 36 | 'type' => 'connected', 37 | 'clientId' => $client->id, 38 | 'message' => 'Connected to chat server', 39 | ]); 40 | } 41 | 42 | public function onMessage(ConnectionInterface $from, $msg): void 43 | { 44 | try { 45 | $data = json_decode($msg, true, 512, JSON_THROW_ON_ERROR); 46 | } catch (\JsonException $e) { 47 | $this->logger->warning('Invalid JSON received', ['error' => $e->getMessage()]); 48 | return; 49 | } 50 | 51 | $client = $this->clients[$from]; 52 | $action = $data['action'] ?? 'message'; 53 | 54 | match ($action) { 55 | 'identify' => $this->handleIdentify($client, $data), 56 | 'join' => $this->handleJoin($client, $data), 57 | 'message' => $this->handleMessage($client, $data), 58 | 'typing' => $this->handleTyping($client, $data), 59 | 'history' => $this->handleHistory($client, $data), 60 | default => $this->logger->warning('Unknown action', ['action' => $action]), 61 | }; 62 | } 63 | 64 | public function onClose(ConnectionInterface $conn): void 65 | { 66 | if (!$this->clients->contains($conn)) { 67 | return; 68 | } 69 | 70 | $client = $this->clients[$conn]; 71 | 72 | if ($client->isIdentified()) { 73 | $this->broadcast( 74 | Message::system("{$client->username} has left the chat", $client->conversationId), 75 | $client->conversationId, 76 | exclude: $conn 77 | ); 78 | } 79 | 80 | $this->clients->detach($conn); 81 | 82 | $this->logger->info('Connection closed', [ 83 | 'client_id' => $client->id, 84 | 'username' => $client->username, 85 | ]); 86 | } 87 | 88 | public function onError(ConnectionInterface $conn, \Exception $e): void 89 | { 90 | $this->logger->error('Connection error', [ 91 | 'error' => $e->getMessage(), 92 | 'trace' => $e->getTraceAsString(), 93 | ]); 94 | 95 | $conn->close(); 96 | } 97 | 98 | private function handleIdentify(Client $client, array $data): void 99 | { 100 | $client->userId = $data['userId'] ?? $client->id; 101 | $client->username = $data['username'] ?? 'Anonymous'; 102 | 103 | $this->logger->info('Client identified', [ 104 | 'client_id' => $client->id, 105 | 'user_id' => $client->userId, 106 | 'username' => $client->username, 107 | ]); 108 | 109 | $client->send([ 110 | 'type' => 'identified', 111 | 'userId' => $client->userId, 112 | 'username' => $client->username, 113 | ]); 114 | } 115 | 116 | private function handleJoin(Client $client, array $data): void 117 | { 118 | $conversationId = $data['conversationId'] ?? 'general'; 119 | $client->conversationId = $conversationId; 120 | 121 | $this->logger->info('Client joined conversation', [ 122 | 'client_id' => $client->id, 123 | 'conversation_id' => $conversationId, 124 | ]); 125 | 126 | $client->send([ 127 | 'type' => 'joined', 128 | 'conversationId' => $conversationId, 129 | ]); 130 | 131 | $this->broadcast( 132 | Message::system("{$client->username} has joined the chat", $conversationId), 133 | $conversationId, 134 | exclude: $client->connection 135 | ); 136 | 137 | $this->sendHistory($client, $conversationId); 138 | } 139 | 140 | private function handleMessage(Client $client, array $data): void 141 | { 142 | if (!$client->isIdentified()) { 143 | $client->send([ 144 | 'type' => 'error', 145 | 'message' => 'Please identify yourself first', 146 | ]); 147 | return; 148 | } 149 | 150 | $content = trim($data['content'] ?? ''); 151 | if ($content === '') { 152 | return; 153 | } 154 | 155 | $message = Message::create( 156 | conversationId: $client->conversationId, 157 | userId: $client->userId, 158 | username: $client->username, 159 | content: $content, 160 | ); 161 | 162 | $this->repository->save($message); 163 | 164 | $this->broadcast($message, $client->conversationId); 165 | 166 | $this->logger->debug('Message broadcast', [ 167 | 'message_id' => $message->id, 168 | 'conversation_id' => $message->conversationId, 169 | 'from' => $client->username, 170 | ]); 171 | } 172 | 173 | private function handleTyping(Client $client, array $data): void 174 | { 175 | $isTyping = $data['isTyping'] ?? false; 176 | 177 | $this->broadcast( 178 | [ 179 | 'type' => 'typing', 180 | 'userId' => $client->userId, 181 | 'username' => $client->username, 182 | 'isTyping' => $isTyping, 183 | ], 184 | $client->conversationId, 185 | exclude: $client->connection 186 | ); 187 | } 188 | 189 | private function handleHistory(Client $client, array $data): void 190 | { 191 | $conversationId = $data['conversationId'] ?? $client->conversationId; 192 | $limit = min($data['limit'] ?? 50, 100); 193 | $offset = $data['offset'] ?? 0; 194 | 195 | $this->sendHistory($client, $conversationId, $limit, $offset); 196 | } 197 | 198 | private function sendHistory(Client $client, string $conversationId, int $limit = 50, int $offset = 0): void 199 | { 200 | $messages = $this->repository->getByConversation($conversationId, $limit, $offset); 201 | 202 | $client->send([ 203 | 'type' => 'history', 204 | 'conversationId' => $conversationId, 205 | 'messages' => array_map(fn(Message $m) => $m->jsonSerialize(), $messages), 206 | ]); 207 | } 208 | 209 | private function broadcast(array|Message $data, string $conversationId, ?ConnectionInterface $exclude = null): void 210 | { 211 | foreach ($this->clients as $conn) { 212 | $client = $this->clients[$conn]; 213 | 214 | if ($client->conversationId !== $conversationId) { 215 | continue; 216 | } 217 | 218 | if ($exclude !== null && $conn === $exclude) { 219 | continue; 220 | } 221 | 222 | $client->send($data); 223 | } 224 | } 225 | } 226 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # WebSocket Chat 2 | 3 | A modern, real-time chat application built with PHP WebSockets and React. Originally created 12 years ago, now completely rewritten with cutting-edge technologies and best practices. 4 | 5 | [![PHP Version](https://img.shields.io/badge/PHP-8.2%2B-777BB4?style=flat-square&logo=php)](https://php.net) 6 | [![React](https://img.shields.io/badge/React-18-61DAFB?style=flat-square&logo=react)](https://react.dev) 7 | [![TypeScript](https://img.shields.io/badge/TypeScript-5.6-3178C6?style=flat-square&logo=typescript)](https://typescriptlang.org) 8 | [![Tailwind CSS](https://img.shields.io/badge/Tailwind-3.4-06B6D4?style=flat-square&logo=tailwindcss)](https://tailwindcss.com) 9 | [![License](https://img.shields.io/badge/License-MIT-green?style=flat-square)](LICENSE) 10 | 11 | --- 12 | 13 | ## Features 14 | 15 | - **Real-time messaging** with WebSocket connections 16 | - **Typing indicators** so you know when someone is writing 17 | - **Message history** persisted to MySQL database 18 | - **Beautiful UI** with shadcn/ui components and dark mode 19 | - **Fully typed** with TypeScript and PHP strict types 20 | - **Comprehensive tests** for both frontend and backend 21 | - **React Context** for elegant state management 22 | - **Auto-reconnection** with exponential backoff 23 | 24 | ## Tech Stack 25 | 26 | ### Backend 27 | - PHP 8.2+ with strict types 28 | - Ratchet WebSocket library 29 | - PDO with prepared statements 30 | - Monolog for logging 31 | - PHPUnit for testing 32 | - PSR-4 autoloading 33 | 34 | ### Frontend 35 | - React 18 with hooks 36 | - TypeScript for type safety 37 | - Vite for blazing fast builds 38 | - Tailwind CSS for styling 39 | - shadcn/ui components 40 | - Vitest for testing 41 | 42 | ## Quick Start 43 | 44 | ### Prerequisites 45 | 46 | - PHP 8.2+ 47 | - Composer 48 | - Node.js 18+ 49 | - MySQL 8.0+ 50 | 51 | ### Installation 52 | 53 | 1. **Clone the repository** 54 | ```bash 55 | git clone https://github.com/yourusername/websocket-chat.git 56 | cd websocket-chat 57 | ``` 58 | 59 | 2. **Set up the backend** 60 | ```bash 61 | # Install PHP dependencies 62 | composer install 63 | 64 | # Copy environment file 65 | cp .env.example .env 66 | 67 | # Edit .env with your database credentials 68 | nano .env 69 | 70 | # Create the database 71 | mysql -u root -p < database/schema.sql 72 | ``` 73 | 74 | 3. **Set up the frontend** 75 | ```bash 76 | cd client 77 | 78 | # Install dependencies 79 | npm install 80 | 81 | # Create environment file (optional) 82 | echo "VITE_WS_URL=ws://localhost:8080" > .env.local 83 | ``` 84 | 85 | 4. **Start the servers** 86 | 87 | Terminal 1 - WebSocket Server: 88 | ```bash 89 | composer serve 90 | # or 91 | php bin/server.php 92 | ``` 93 | 94 | Terminal 2 - Frontend Dev Server: 95 | ```bash 96 | cd client 97 | npm run dev 98 | ``` 99 | 100 | 5. **Open your browser** 101 | 102 | Navigate to `http://localhost:3000` and start chatting! 103 | 104 | ## Project Structure 105 | 106 | ``` 107 | . 108 | ├── bin/ 109 | │ └── server.php # WebSocket server entry point 110 | ├── src/ 111 | │ ├── Chat/ 112 | │ │ ├── ChatServer.php # Main WebSocket handler 113 | │ │ ├── Client.php # Connected client representation 114 | │ │ ├── Message.php # Message value object 115 | │ │ └── MessageRepository.php 116 | │ ├── Database/ 117 | │ │ └── Connection.php # PDO singleton 118 | │ └── bootstrap.php # Application bootstrap 119 | ├── tests/ 120 | │ └── Unit/ 121 | │ └── Chat/ # PHPUnit tests 122 | ├── client/ 123 | │ ├── src/ 124 | │ │ ├── components/ 125 | │ │ │ ├── chat/ # Chat components 126 | │ │ │ └── ui/ # shadcn/ui components 127 | │ │ ├── context/ 128 | │ │ │ └── ChatContext.tsx # React Context 129 | │ │ ├── hooks/ 130 | │ │ │ └── useWebSocket.ts # WebSocket hook 131 | │ │ ├── lib/ 132 | │ │ │ └── utils.ts # Utility functions 133 | │ │ └── types/ 134 | │ │ └── chat.ts # TypeScript types 135 | │ └── ...config files 136 | ├── database/ 137 | │ └── schema.sql # Database schema 138 | ├── composer.json 139 | └── README.md 140 | ``` 141 | 142 | ## Configuration 143 | 144 | ### Environment Variables 145 | 146 | Create a `.env` file in the root directory: 147 | 148 | ```env 149 | APP_ENV=development 150 | APP_DEBUG=true 151 | 152 | # WebSocket Server 153 | WS_HOST=0.0.0.0 154 | WS_PORT=8080 155 | 156 | # Database 157 | DB_HOST=localhost 158 | DB_PORT=3306 159 | DB_NAME=websocket_chat 160 | DB_USER=root 161 | DB_PASS=your_password 162 | 163 | # Timezone 164 | APP_TIMEZONE=UTC 165 | ``` 166 | 167 | ### Frontend Configuration 168 | 169 | Create `client/.env.local`: 170 | 171 | ```env 172 | VITE_WS_URL=ws://localhost:8080 173 | ``` 174 | 175 | ## WebSocket Protocol 176 | 177 | ### Client Actions 178 | 179 | ```json 180 | // Identify yourself 181 | { "action": "identify", "username": "John" } 182 | 183 | // Join a conversation 184 | { "action": "join", "conversationId": "general" } 185 | 186 | // Send a message 187 | { "action": "message", "content": "Hello, World!" } 188 | 189 | // Typing indicator 190 | { "action": "typing", "isTyping": true } 191 | 192 | // Request message history 193 | { "action": "history", "conversationId": "general", "limit": 50 } 194 | ``` 195 | 196 | ### Server Events 197 | 198 | ```json 199 | // Connection established 200 | { "type": "connected", "clientId": "uuid", "message": "..." } 201 | 202 | // Identity confirmed 203 | { "type": "identified", "userId": "...", "username": "..." } 204 | 205 | // Joined conversation 206 | { "type": "joined", "conversationId": "general" } 207 | 208 | // New message 209 | { "type": "message", "id": "...", "content": "...", ... } 210 | 211 | // System message 212 | { "type": "system", "content": "User joined the chat", ... } 213 | 214 | // Typing indicator 215 | { "type": "typing", "userId": "...", "username": "...", "isTyping": true } 216 | 217 | // Message history 218 | { "type": "history", "messages": [...] } 219 | ``` 220 | 221 | ## Testing 222 | 223 | ### Backend Tests 224 | ```bash 225 | # Run all tests 226 | ./vendor/bin/phpunit 227 | 228 | # Run with coverage 229 | ./vendor/bin/phpunit --coverage-html coverage 230 | ``` 231 | 232 | ### Frontend Tests 233 | ```bash 234 | cd client 235 | 236 | # Run tests in watch mode 237 | npm test 238 | 239 | # Run tests once 240 | npm run test:run 241 | 242 | # Run with coverage 243 | npm run test:coverage 244 | ``` 245 | 246 | ## Static Analysis 247 | 248 | ```bash 249 | # PHP static analysis 250 | composer analyse 251 | # or 252 | ./vendor/bin/phpstan analyse src --level=6 253 | ``` 254 | 255 | ## Architecture Decisions 256 | 257 | ### Why Ratchet? 258 | Ratchet remains the most mature and battle-tested WebSocket library for PHP. It's built on ReactPHP and provides excellent performance for real-time applications. 259 | 260 | ### Why React Context? 261 | For a chat application of this scope, React Context provides the perfect balance of simplicity and power. It allows us to share WebSocket state across components without the overhead of a full state management library. 262 | 263 | ### Why shadcn/ui? 264 | shadcn/ui provides beautifully designed, accessible components that are copy-pasteable into your project. Unlike traditional component libraries, you own the code and can customize it however you want. 265 | 266 | ## Production Deployment 267 | 268 | 1. Build the frontend: 269 | ```bash 270 | cd client && npm run build 271 | ``` 272 | 273 | 2. Configure your web server to serve the `client/dist` directory 274 | 275 | 3. Run the WebSocket server with a process manager: 276 | ```bash 277 | # Using supervisord 278 | [program:websocket-chat] 279 | command=php /path/to/bin/server.php 280 | autostart=true 281 | autorestart=true 282 | ``` 283 | 284 | 4. Use a reverse proxy (nginx) for WebSocket connections: 285 | ```nginx 286 | location /ws { 287 | proxy_pass http://localhost:8080; 288 | proxy_http_version 1.1; 289 | proxy_set_header Upgrade $http_upgrade; 290 | proxy_set_header Connection "upgrade"; 291 | } 292 | ``` 293 | 294 | --- 295 | 296 | ## Connect With Me 297 | 298 | **Follow me on X:** [@voidmode_](https://x.com/voidmode_) 299 | 300 | **Check out my company:** [Polyx Media](https://polyxmedia.com) - We build amazing digital experiences. 301 | 302 | --- 303 | 304 | ## License 305 | 306 | MIT License - feel free to use this project however you'd like. 307 | 308 | --- 309 | 310 | Built with love, rebuilt with modern tools. 311 | -------------------------------------------------------------------------------- /tests/Unit/Chat/ChatServerTest.php: -------------------------------------------------------------------------------- 1 | mockRepository = $this->createMock(MessageRepositoryInterface::class); 22 | $this->mockLogger = $this->createMock(LoggerInterface::class); 23 | $this->server = new ChatServer($this->mockRepository, $this->mockLogger); 24 | } 25 | 26 | private function createMockConnection(): ConnectionInterface 27 | { 28 | $mock = $this->createMock(ConnectionInterface::class); 29 | $mock->resourceId = random_int(1, 10000); 30 | return $mock; 31 | } 32 | 33 | public function testOnOpenSendsConnectedMessage(): void 34 | { 35 | $conn = $this->createMockConnection(); 36 | 37 | $conn->expects($this->once()) 38 | ->method('send') 39 | ->with($this->callback(function ($json) { 40 | $data = json_decode($json, true); 41 | return $data['type'] === 'connected' 42 | && isset($data['clientId']) 43 | && $data['message'] === 'Connected to chat server'; 44 | })); 45 | 46 | $this->mockLogger->expects($this->atLeastOnce())->method('info'); 47 | 48 | $this->server->onOpen($conn); 49 | } 50 | 51 | public function testOnCloseLogsDisconnection(): void 52 | { 53 | $conn = $this->createMockConnection(); 54 | 55 | $this->mockLogger->expects($this->atLeast(2)) 56 | ->method('info'); 57 | 58 | $this->server->onOpen($conn); 59 | $this->server->onClose($conn); 60 | } 61 | 62 | public function testOnMessageWithInvalidJsonLogsWarning(): void 63 | { 64 | $conn = $this->createMockConnection(); 65 | 66 | $this->mockLogger->expects($this->once()) 67 | ->method('warning') 68 | ->with('Invalid JSON received', $this->anything()); 69 | 70 | $this->server->onOpen($conn); 71 | $this->server->onMessage($conn, 'not valid json'); 72 | } 73 | 74 | public function testOnMessageIdentifyAction(): void 75 | { 76 | $conn = $this->createMockConnection(); 77 | 78 | $sentMessages = []; 79 | $conn->method('send') 80 | ->willReturnCallback(function ($json) use (&$sentMessages) { 81 | $sentMessages[] = json_decode($json, true); 82 | }); 83 | 84 | $this->server->onOpen($conn); 85 | $this->server->onMessage($conn, json_encode([ 86 | 'action' => 'identify', 87 | 'userId' => 'user-123', 88 | 'username' => 'TestUser', 89 | ])); 90 | 91 | $identifiedMessage = array_filter($sentMessages, fn($m) => $m['type'] === 'identified'); 92 | $this->assertNotEmpty($identifiedMessage); 93 | 94 | $identified = array_values($identifiedMessage)[0]; 95 | $this->assertSame('user-123', $identified['userId']); 96 | $this->assertSame('TestUser', $identified['username']); 97 | } 98 | 99 | public function testOnMessageWithoutIdentificationReturnsError(): void 100 | { 101 | $conn = $this->createMockConnection(); 102 | 103 | $sentMessages = []; 104 | $conn->method('send') 105 | ->willReturnCallback(function ($json) use (&$sentMessages) { 106 | $sentMessages[] = json_decode($json, true); 107 | }); 108 | 109 | $this->server->onOpen($conn); 110 | $this->server->onMessage($conn, json_encode([ 111 | 'action' => 'message', 112 | 'content' => 'Hello', 113 | ])); 114 | 115 | $errorMessage = array_filter($sentMessages, fn($m) => $m['type'] === 'error'); 116 | $this->assertNotEmpty($errorMessage); 117 | } 118 | 119 | public function testOnMessageSavesToRepository(): void 120 | { 121 | $conn = $this->createMockConnection(); 122 | 123 | $conn->method('send')->willReturn(null); 124 | 125 | $this->mockRepository->expects($this->once()) 126 | ->method('save') 127 | ->with($this->callback(function ($message) { 128 | return $message->content === 'Hello, World!' 129 | && $message->username === 'TestUser'; 130 | })) 131 | ->willReturn(true); 132 | 133 | $this->mockRepository->method('getByConversation')->willReturn([]); 134 | 135 | $this->server->onOpen($conn); 136 | $this->server->onMessage($conn, json_encode([ 137 | 'action' => 'identify', 138 | 'userId' => 'user-1', 139 | 'username' => 'TestUser', 140 | ])); 141 | $this->server->onMessage($conn, json_encode([ 142 | 'action' => 'join', 143 | 'conversationId' => 'general', 144 | ])); 145 | $this->server->onMessage($conn, json_encode([ 146 | 'action' => 'message', 147 | 'content' => 'Hello, World!', 148 | ])); 149 | } 150 | 151 | public function testOnErrorClosesConnection(): void 152 | { 153 | $conn = $this->createMockConnection(); 154 | $exception = new \Exception('Test error'); 155 | 156 | $conn->expects($this->once())->method('close'); 157 | 158 | $this->mockLogger->expects($this->once()) 159 | ->method('error') 160 | ->with('Connection error', $this->anything()); 161 | 162 | $this->server->onError($conn, $exception); 163 | } 164 | 165 | public function testMessageBroadcastsToOtherClientsInSameConversation(): void 166 | { 167 | $conn1 = $this->createMockConnection(); 168 | $conn2 = $this->createMockConnection(); 169 | 170 | $conn1Messages = []; 171 | $conn2Messages = []; 172 | 173 | $conn1->method('send') 174 | ->willReturnCallback(function ($json) use (&$conn1Messages) { 175 | $conn1Messages[] = json_decode($json, true); 176 | }); 177 | 178 | $conn2->method('send') 179 | ->willReturnCallback(function ($json) use (&$conn2Messages) { 180 | $conn2Messages[] = json_decode($json, true); 181 | }); 182 | 183 | $this->mockRepository->method('save')->willReturn(true); 184 | $this->mockRepository->method('getByConversation')->willReturn([]); 185 | 186 | $this->server->onOpen($conn1); 187 | $this->server->onOpen($conn2); 188 | 189 | $this->server->onMessage($conn1, json_encode(['action' => 'identify', 'userId' => 'u1', 'username' => 'User1'])); 190 | $this->server->onMessage($conn2, json_encode(['action' => 'identify', 'userId' => 'u2', 'username' => 'User2'])); 191 | 192 | $this->server->onMessage($conn1, json_encode(['action' => 'join', 'conversationId' => 'room'])); 193 | $this->server->onMessage($conn2, json_encode(['action' => 'join', 'conversationId' => 'room'])); 194 | 195 | $conn2Messages = []; 196 | 197 | $this->server->onMessage($conn1, json_encode(['action' => 'message', 'content' => 'Hello from User1'])); 198 | 199 | $receivedMessage = array_filter($conn2Messages, fn($m) => ($m['type'] ?? '') === 'message' || ($m['content'] ?? '') === 'Hello from User1'); 200 | $this->assertNotEmpty($receivedMessage); 201 | } 202 | 203 | public function testTypingIndicatorBroadcastsToOthers(): void 204 | { 205 | $conn1 = $this->createMockConnection(); 206 | $conn2 = $this->createMockConnection(); 207 | 208 | $conn2Messages = []; 209 | 210 | $conn1->method('send')->willReturn(null); 211 | $conn2->method('send') 212 | ->willReturnCallback(function ($json) use (&$conn2Messages) { 213 | $conn2Messages[] = json_decode($json, true); 214 | }); 215 | 216 | $this->mockRepository->method('getByConversation')->willReturn([]); 217 | 218 | $this->server->onOpen($conn1); 219 | $this->server->onOpen($conn2); 220 | 221 | $this->server->onMessage($conn1, json_encode(['action' => 'identify', 'userId' => 'u1', 'username' => 'User1'])); 222 | $this->server->onMessage($conn2, json_encode(['action' => 'identify', 'userId' => 'u2', 'username' => 'User2'])); 223 | 224 | $this->server->onMessage($conn1, json_encode(['action' => 'join', 'conversationId' => 'room'])); 225 | $this->server->onMessage($conn2, json_encode(['action' => 'join', 'conversationId' => 'room'])); 226 | 227 | $conn2Messages = []; 228 | 229 | $this->server->onMessage($conn1, json_encode(['action' => 'typing', 'isTyping' => true])); 230 | 231 | $typingMessage = array_filter($conn2Messages, fn($m) => ($m['type'] ?? '') === 'typing'); 232 | $this->assertNotEmpty($typingMessage); 233 | 234 | $typing = array_values($typingMessage)[0]; 235 | $this->assertTrue($typing['isTyping']); 236 | $this->assertSame('User1', $typing['username']); 237 | } 238 | } 239 | --------------------------------------------------------------------------------