Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ services:
ports:
- "${POSTGRES_PORT}:5432"
volumes:
- postgres_data:/var/lib/postgresql/data
- postgres_data:/var/lib/postgresql

# MinIO Object Storage
minio:
Expand Down
153 changes: 153 additions & 0 deletions scripts/issue-chat.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<title>Issue Namespace Chat (test)</title>
<style>
body{font-family:system-ui, -apple-system,Segoe UI, Roboto, "Helvetica Neue", Arial;margin:20px}
input,button,select{padding:8px;margin:4px}
#messages{border:1px solid #ddd;padding:8px;height:300px;overflow:auto;background:#fafafa}
.msg{padding:6px;border-bottom:1px solid #eee}
.meta{font-size:0.85em;color:#666}
</style>
</head>
<body>
<h2>Issue Namespace Chat — Test Page</h2>
<div>
<label>Server URL: <input id="serverUrl" value="http://localhost:5050" size="30"></label>
<label>Issue ID: <input id="issueId" value="30" size="6"></label>
<label>User ID: <input id="userId" value="1" size="6"></label>
<label>Role: <input id="role" value="building_manager" size="14"></label>
<button id="connectBtn">Connect</button>
<button id="disconnectBtn" disabled>Disconnect</button>
</div>

<hr>
<div id="status">Not connected</div>

<div style="margin-top:10px">
<div id="messages"></div>
</div>

<div style="margin-top:10px">
<input id="messageText" placeholder="Message text" size="60">
<button id="sendAllBtn" disabled>Send to all (issue)</button>
<input id="targetUser" placeholder="target user id" size="6">
<button id="sendUserBtn" disabled>Send to user</button>
</div>

<script src="https://cdn.socket.io/4.6.1/socket.io.min.js"></script>
<script>
let socket = null;
const connectBtn = document.getElementById('connectBtn');
const disconnectBtn = document.getElementById('disconnectBtn');
const serverUrlInput = document.getElementById('serverUrl');
const issueIdInput = document.getElementById('issueId');
const userIdInput = document.getElementById('userId');
const roleInput = document.getElementById('role');
const messagesEl = document.getElementById('messages');
const statusEl = document.getElementById('status');
const sendAllBtn = document.getElementById('sendAllBtn');
const sendUserBtn = document.getElementById('sendUserBtn');
const messageText = document.getElementById('messageText');
const targetUser = document.getElementById('targetUser');

function appendMessage(text, meta) {
const div = document.createElement('div');
div.className = 'msg';
div.innerHTML = '<div>' + text + '</div>' + (meta ? ('<div class="meta">' + meta + '</div>') : '');
messagesEl.appendChild(div);
messagesEl.scrollTop = messagesEl.scrollHeight;
}

connectBtn.addEventListener('click', () => {
if (socket) return;
const server = serverUrlInput.value.trim();
const issueId = issueIdInput.value.trim();
const userId = userIdInput.value.trim();
const role = roleInput.value.trim() || 'user';

if (!server || !issueId) { alert('server and issue id required'); return }

const namespace = '/issue-' + issueId;
const url = server + namespace;

appendMessage('Connecting to ' + url + ' as user ' + userId);
statusEl.textContent = 'Connecting...';

socket = io(url, {
auth: { userId: String(userId), role }
});

socket.on('connect', () => {
appendMessage('Connected (socket id: ' + socket.id + ')');
statusEl.textContent = 'Connected: ' + socket.id;
connectBtn.disabled = true;
disconnectBtn.disabled = false;
sendAllBtn.disabled = false;
sendUserBtn.disabled = false;
});

socket.on('receive_message', (msg) => {
appendMessage(JSON.stringify(msg), 'received');
});

// Listen for issue realtime updates (emitted by server via issueRealtimeUpdate)
socket.on('issue_update', (update) => {
appendMessage(JSON.stringify(update), 'issue_update');
});

socket.on('disconnect', (reason) => {
appendMessage('Disconnected: ' + reason);
statusEl.textContent = 'Disconnected';
socket = null;
connectBtn.disabled = false;
disconnectBtn.disabled = true;
sendAllBtn.disabled = true;
sendUserBtn.disabled = true;
});

socket.on('connect_error', (err) => {
appendMessage('Connect error: ' + err.message, 'error');
statusEl.textContent = 'Connect error';
});

});

disconnectBtn.addEventListener('click', () => {
if (!socket) return;
socket.disconnect();
socket = null;
connectBtn.disabled = false;
disconnectBtn.disabled = true;
sendAllBtn.disabled = true;
sendUserBtn.disabled = true;
});

sendAllBtn.addEventListener('click', () => {
if (!socket) return alert('Not connected');
const text = messageText.value.trim();
if (!text) return;
const payload = { text, from: userIdInput.value };
// server expects "send_message_to_all"
socket.emit('send_message_to_all', payload);
appendMessage(JSON.stringify(payload), 'sent to all');
messageText.value = '';
});

sendUserBtn.addEventListener('click', () => {
if (!socket) return alert('Not connected');
const text = messageText.value.trim();
const to = targetUser.value.trim();
if (!text || !to) return alert('message and target user id required');
const payload = { text, from: userIdInput.value, to };
// send both forms: message object and receiverId as second arg (server supports both patterns)
socket.emit('send_message_to_user', payload, to);
appendMessage(JSON.stringify(payload), 'sent to user ' + to);
messageText.value = '';
});

</script>
</body>
</html>
21 changes: 21 additions & 0 deletions scripts/issue-client1.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { io } from "socket.io-client";

const socket = io("http://localhost:5050/issue-30", {
auth: { userId: 1, role: "building_manager" }
});

socket.on("connect", () => {
console.log("connected", socket.id);

// broadcast to everyone in the issue
socket.emit("send_message_to_all", { text: "hello everyone from user 1" });

// private message to user with id 2
socket.emit("send_message_to_user", { to: 2, text: "private hello to user 2 from user 1" });
});

socket.on("receive_message", (msg) => {
console.log("receive_message:", msg);
});

socket.on("disconnect", () => console.log("disconnected"));
18 changes: 18 additions & 0 deletions scripts/socket-client-ME.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { io } from "socket.io-client";

const socket = io("http://localhost:5050/new-issue", {
// auth: { token: "YOUR_JWT" }, // uncomment if server uses token
});

socket.on("connect", () => {
console.log("connected", socket.id);
socket.emit("send_message", { text: "hello from ME" });
});

socket.on("new_issue", (issue) => {
console.log("new_issue received:", issue);
});

socket.on("disconnect", () => {
console.log("disconnected");
});
16 changes: 16 additions & 0 deletions scripts/socket-client-Technician.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { io } from "socket.io-client";

const socket = io("http://localhost:5050/assign", {
//auth: { userId: "TECHNICIAN_ID_HERE", role: "technician" } // replace with real id or use query
});

socket.on("connect", () => {
console.log("connected", socket.id);
socket.emit("send_message", { text: "hello from Technician" });
});

socket.on("assigned_issue", (issue) => {
console.log("assigned_issue received:", issue);
});

socket.on("disconnect", () => console.log("disconnected"));
107 changes: 87 additions & 20 deletions src/__tests__/socket.test.js
Original file line number Diff line number Diff line change
@@ -1,15 +1,19 @@
import { createServer } from "http";
import { io as Client } from "socket.io-client";
import { setupSocket } from "../socket/socket.js";
import { setupSocket, makeDynamicNamespace } from "../socket/socket.js";
import { jest } from '@jest/globals';

describe("Socket.IO Basic Communication", () => {
let httpServer, socketServer, port;
let client1, client2;
let issueClient1, issueClient2;

// Set a timeout for the entire test suite
jest.setTimeout(10000);

beforeAll(() => {
// Suppress console logs during tests
jest.spyOn(console, "log").mockImplementation(() => { });
jest.spyOn(console, "error").mockImplementation(() => { });
});

beforeAll((done) => {
Expand All @@ -19,52 +23,115 @@ describe("Socket.IO Basic Communication", () => {
httpServer.listen(() => {
port = httpServer.address().port;

client1 = new Client(`http://localhost:${port}`);
client2 = new Client(`http://localhost:${port}`);
// Create a dynamic namespace first
const testIssueId = 123;
makeDynamicNamespace(testIssueId);

// Connect clients to the dynamic namespace
issueClient1 = new Client(`http://localhost:${port}/issue-${testIssueId}`, {
forceNew: true,
timeout: 5000,
transports: ['websocket'],
query: { userId: 'user1' }
});
issueClient2 = new Client(`http://localhost:${port}/issue-${testIssueId}`, {
forceNew: true,
timeout: 5000,
transports: ['websocket'],
query: { userId: 'user2' }
});

let connectedCount = 0;
const checkDone = () => {
connectedCount++;
if (connectedCount === 2) done();
};

client1.on("connect", checkDone);
client2.on("connect", checkDone);
const handleError = (error) => {
done(error);
};

issueClient1.on("connect", checkDone);
issueClient2.on("connect", checkDone);
issueClient1.on("connect_error", handleError);
issueClient2.on("connect_error", handleError);
});
});

afterAll(async () => {
if (client1.connected) client1.disconnect();
if (client2.connected) client2.disconnect();
afterEach(() => {
// Clean up event listeners after each test
if (issueClient1) {
issueClient1.removeAllListeners("receive_message");
}
if (issueClient2) {
issueClient2.removeAllListeners("receive_message");
}
});

await new Promise(resolve => socketServer.close(resolve));
await new Promise(resolve => httpServer.close(resolve));
afterAll(async () => {
// Close clients first
if (issueClient1?.connected) {
issueClient1.disconnect();
}
if (issueClient2?.connected) {
issueClient2.disconnect();
}

// Wait a bit for disconnections to process
await new Promise(resolve => setTimeout(resolve, 100));

// Close socket server
if (socketServer) {
await new Promise(resolve => {
socketServer.close(() => {
resolve();
});
});
}

// Close HTTP server
if (httpServer && httpServer.listening) {
await new Promise(resolve => {
httpServer.close(() => {
resolve();
});
});
}
});

test("should broadcast message to other clients", (done) => {
test("should broadcast message to all clients in issue room", (done) => {
const testMessage = { text: "Hello world" };

client2.once("receive_message", (msg) => {
issueClient2.once("receive_message", (msg) => {
expect(msg).toEqual(testMessage);
done();
});

client1.emit("send_message", testMessage);
issueClient1.emit("send_message_to_all", testMessage);
});

test("sender should not receive its own message", (done) => {
const testMessage = { text: "Sender check" };
let messageReceived = false;

// If sender receives a message, fail
client2.once("receive_message", () => {
const messageHandler = () => {
messageReceived = true;
done(new Error("Sender received its own message!"));
});
};

// Emit from client2
client2.emit("send_message", testMessage);
issueClient2.once("receive_message", messageHandler);

// Wait a bit to ensure no message arrives
setTimeout(done, 500);
// Emit from client2
issueClient2.emit("send_message_to_all", testMessage);

// Wait a bit to ensure no message arrives, then clean up
setTimeout(() => {
issueClient2.removeListener("receive_message", messageHandler);
if (!messageReceived) {
done();
}
}, 300);
});

});
Loading