├── 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 |
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 |
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 |
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 | [](https://php.net)
6 | [](https://react.dev)
7 | [](https://typescriptlang.org)
8 | [](https://tailwindcss.com)
9 | [](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 |
--------------------------------------------------------------------------------