diff --git a/frontend/package.json b/frontend/package.json
index 107fdde45..8a1f78625 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -116,6 +116,7 @@
"@types/is-touch-device": "1.0.3",
"@types/node": "24.12.0",
"@types/sortablejs": "1.15.9",
+ "@types/ws": "^8.18.1",
"@typescript-eslint/eslint-plugin": "8.58.0",
"@typescript-eslint/parser": "8.58.0",
"@vitejs/plugin-vue": "6.0.5",
@@ -154,7 +155,8 @@
"vitest": "4.1.2",
"vue-tsc": "3.2.6",
"wait-on": "9.0.4",
- "workbox-cli": "7.4.0"
+ "workbox-cli": "7.4.0",
+ "ws": "^8.19.0"
},
"pnpm": {
"onlyBuiltDependencies": [
diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml
index a761d5049..0ef564508 100644
--- a/frontend/pnpm-lock.yaml
+++ b/frontend/pnpm-lock.yaml
@@ -205,6 +205,9 @@ importers:
'@types/sortablejs':
specifier: 1.15.9
version: 1.15.9
+ '@types/ws':
+ specifier: ^8.18.1
+ version: 8.18.1
'@typescript-eslint/eslint-plugin':
specifier: 8.58.0
version: 8.58.0(@typescript-eslint/parser@8.58.0(eslint@9.39.4(jiti@2.4.2))(typescript@5.9.3))(eslint@9.39.4(jiti@2.4.2))(typescript@5.9.3)
@@ -322,6 +325,9 @@ importers:
workbox-cli:
specifier: 7.4.0
version: 7.4.0
+ ws:
+ specifier: ^8.19.0
+ version: 8.19.0
packages:
diff --git a/frontend/tests/e2e/websocket/comment-notification.spec.ts b/frontend/tests/e2e/websocket/comment-notification.spec.ts
new file mode 100644
index 000000000..5306e1598
--- /dev/null
+++ b/frontend/tests/e2e/websocket/comment-notification.spec.ts
@@ -0,0 +1,58 @@
+import {test, expect} from '../../support/fixtures'
+import {openWs, waitForMessage, authenticateWs, subscribeWs, closeWs} from '../../support/websocket'
+import {UserFactory} from '../../factories/user'
+import {ProjectFactory} from '../../factories/project'
+import {ProjectViewFactory} from '../../factories/project_view'
+import {TaskFactory} from '../../factories/task'
+import {UserProjectFactory} from '../../factories/users_project'
+import {TEST_PASSWORD} from '../../support/constants'
+import type {APIRequestContext} from '@playwright/test'
+
+async function loginRaw(apiContext: APIRequestContext, user: {username: string}): Promise<{token: string}> {
+ const response = await apiContext.post('login', {
+ data: {username: user.username, password: TEST_PASSWORD},
+ })
+ return response.json()
+}
+
+test.describe('WebSocket Comment Notifications', () => {
+
+ test('receives notification when mentioned in a task comment', async ({apiContext, userToken, currentUser}) => {
+ const ws = await openWs()
+ try {
+ await authenticateWs(ws, userToken)
+ subscribeWs(ws, 'notification.created')
+
+ // Create a second user who will post the comment
+ const [commenter] = await UserFactory.create(1, {id: 100}, false)
+ const {token: commenterToken} = await loginRaw(apiContext, commenter)
+
+ // Seed a project owned by the commenter with a task
+ await ProjectFactory.create(1, {id: 100, owner_id: 100}, false)
+ await ProjectViewFactory.create(1, {id: 100, project_id: 100}, false)
+ await TaskFactory.create(1, {id: 100, project_id: 100, created_by_id: 100}, false)
+
+ // Share the project with currentUser so the mention access check passes
+ await UserProjectFactory.create(1, {id: 100, project_id: 100, user_id: 1}, false)
+
+ // Commenter posts a comment mentioning currentUser
+ const commentBody = `
Hey @${currentUser.username} check this out
`
+ const commentResponse = await apiContext.put('tasks/100/comments', {
+ data: {comment: commentBody},
+ headers: {Authorization: `Bearer ${commenterToken}`},
+ })
+ expect(commentResponse.ok()).toBe(true)
+
+ // currentUser should receive the notification via WebSocket
+ const msg = await waitForMessage(ws, 15000)
+ expect(msg.event).toBe('notification.created')
+ expect(msg.data).toBeDefined()
+
+ // The notification payload must include a valid created timestamp (not zero)
+ const created = new Date(msg.data.created)
+ expect(created.getFullYear()).toBeGreaterThanOrEqual(2020)
+ } finally {
+ closeWs(ws)
+ }
+ })
+})
diff --git a/frontend/tests/e2e/websocket/frontend.spec.ts b/frontend/tests/e2e/websocket/frontend.spec.ts
new file mode 100644
index 000000000..1be6c7424
--- /dev/null
+++ b/frontend/tests/e2e/websocket/frontend.spec.ts
@@ -0,0 +1,97 @@
+import {test, expect} from '../../support/fixtures'
+import {UserFactory} from '../../factories/user'
+import {TEST_PASSWORD} from '../../support/constants'
+
+test.describe('WebSocket Frontend Integration', () => {
+
+ test('notification badge updates in real-time when added to team', async ({
+ authenticatedPage: page,
+ apiContext,
+ currentUser,
+ }) => {
+ // Navigate to the app so WebSocket connects
+ await page.goto('/')
+ await page.waitForLoadState('networkidle')
+
+ // Verify no unread indicator initially
+ await expect(page.locator('.notifications .unread-indicator')).toHaveCount(0)
+
+ // Create a second user who will add currentUser to a team
+ const [userA] = await UserFactory.create(1, {id: 100}, false)
+ const loginResponse = await apiContext.post('login', {
+ data: {username: userA.username, password: TEST_PASSWORD},
+ })
+ const {token: tokenA} = await loginResponse.json()
+
+ // User A creates a team
+ const teamResponse = await apiContext.put('teams', {
+ data: {name: 'Real-Time Test Team'},
+ headers: {Authorization: `Bearer ${tokenA}`},
+ })
+ const team = await teamResponse.json()
+
+ // User A adds currentUser to the team — this triggers a notification
+ await apiContext.put(`teams/${team.id}/members`, {
+ data: {username: currentUser.username},
+ headers: {Authorization: `Bearer ${tokenA}`},
+ })
+
+ // The unread indicator should appear without page refresh
+ await expect(page.locator('.notifications .unread-indicator')).toBeVisible({
+ timeout: 10000,
+ })
+ })
+
+ test('notification appears in dropdown after real-time delivery', async ({
+ authenticatedPage: page,
+ apiContext,
+ currentUser,
+ }) => {
+ await page.goto('/')
+ await page.waitForLoadState('networkidle')
+
+ // Create user A and trigger notification
+ const [userA] = await UserFactory.create(1, {id: 100}, false)
+ const loginResponse = await apiContext.post('login', {
+ data: {username: userA.username, password: TEST_PASSWORD},
+ })
+ const {token: tokenA} = await loginResponse.json()
+
+ const teamResponse = await apiContext.put('teams', {
+ data: {name: 'Dropdown Test Team'},
+ headers: {Authorization: `Bearer ${tokenA}`},
+ })
+ const team = await teamResponse.json()
+
+ await apiContext.put(`teams/${team.id}/members`, {
+ data: {username: currentUser.username},
+ headers: {Authorization: `Bearer ${tokenA}`},
+ })
+
+ // Wait for unread indicator then click the bell
+ await expect(page.locator('.notifications .unread-indicator')).toBeVisible({
+ timeout: 10000,
+ })
+ await page.locator('.notifications .trigger-button').click()
+
+ // Notification dropdown should contain the team notification
+ const notificationsList = page.locator('.notifications .notifications-list')
+ await expect(notificationsList).toBeVisible()
+ await expect(notificationsList.locator('.single-notification')).toHaveCount(1)
+ })
+
+ test('websocket disconnects on logout', async ({authenticatedPage: page}) => {
+ await page.goto('/')
+ await page.waitForLoadState('networkidle')
+
+ // Perform logout — click user menu then logout button
+ await page.locator('.navbar .username-dropdown-trigger').click()
+ await page.locator('.navbar .dropdown-item').filter({hasText: 'Logout'}).click()
+
+ // After logout, should redirect to login page
+ await expect(page).toHaveURL(/\/login/, {timeout: 5000})
+
+ // Verify the notification bell is gone (no authenticated UI)
+ await expect(page.locator('.notifications .trigger-button')).toHaveCount(0)
+ })
+})
diff --git a/frontend/tests/e2e/websocket/protocol.spec.ts b/frontend/tests/e2e/websocket/protocol.spec.ts
new file mode 100644
index 000000000..6ab148634
--- /dev/null
+++ b/frontend/tests/e2e/websocket/protocol.spec.ts
@@ -0,0 +1,247 @@
+import {test, expect} from '../../support/fixtures'
+import {openWs, waitForMessage, sendMessage, authenticateWs, subscribeWs, collectMessages, closeWs} from '../../support/websocket'
+import {UserFactory} from '../../factories/user'
+import {TEST_PASSWORD} from '../../support/constants'
+import type {APIRequestContext} from '@playwright/test'
+
+/** Login without setting page localStorage — just returns the token. */
+async function loginRaw(apiContext: APIRequestContext, user: {username: string}): Promise<{token: string}> {
+ const response = await apiContext.post('login', {
+ data: {username: user.username, password: TEST_PASSWORD},
+ })
+ return response.json()
+}
+
+test.describe('WebSocket Protocol', () => {
+
+ test.describe('Authentication', () => {
+ test('authenticates with valid token', async ({userToken}) => {
+ const ws = await openWs()
+ try {
+ const msg = await authenticateWs(ws, userToken)
+ expect(msg.action).toBe('auth.success')
+ expect(msg.success).toBe(true)
+ } finally {
+ closeWs(ws)
+ }
+ })
+
+ test('rejects invalid token', async () => {
+ const ws = await openWs()
+ try {
+ sendMessage(ws, {action: 'auth', token: 'invalid-token'})
+ const msg = await waitForMessage(ws)
+ expect(msg.error).toBe('invalid_token')
+ } finally {
+ closeWs(ws)
+ }
+ })
+
+ test('closes connection after auth timeout', async () => {
+ test.setTimeout(45000)
+ const ws = await openWs()
+ const closed = new Promise<{code: number; reason: string}>((resolve) => {
+ ws.on('close', (code, reason) => {
+ resolve({code, reason: reason.toString()})
+ })
+ })
+ const result = await closed
+ // websocket StatusPolicyViolation = 1008
+ expect(result.code).toBe(1008)
+ })
+
+ test('rejects double authentication', async ({userToken}) => {
+ const ws = await openWs()
+ try {
+ await authenticateWs(ws, userToken)
+ sendMessage(ws, {action: 'auth', token: userToken})
+ const msg = await waitForMessage(ws)
+ expect(msg.error).toBe('already_authenticated')
+ } finally {
+ closeWs(ws)
+ }
+ })
+ })
+
+ test.describe('Subscribe / Unsubscribe Events', () => {
+ test('subscribes to valid event', async ({userToken}) => {
+ const ws = await openWs()
+ try {
+ await authenticateWs(ws, userToken)
+ sendMessage(ws, {action: 'subscribe', event: 'notification.created'})
+ // No error response means success — verify by collecting messages
+ // for a short window. If there was an error, it would arrive.
+ const messages = await collectMessages(ws, 500)
+ const errors = messages.filter(m => m.error)
+ expect(errors).toHaveLength(0)
+ } finally {
+ closeWs(ws)
+ }
+ })
+
+ test('rejects invalid event', async ({userToken}) => {
+ const ws = await openWs()
+ try {
+ await authenticateWs(ws, userToken)
+ sendMessage(ws, {action: 'subscribe', event: 'nonexistent.event'})
+ const msg = await waitForMessage(ws)
+ expect(msg.error).toBe('invalid_event')
+ expect(msg.event).toBe('nonexistent.event')
+ } finally {
+ closeWs(ws)
+ }
+ })
+
+ test('requires auth before subscribe', async () => {
+ const ws = await openWs()
+ try {
+ sendMessage(ws, {action: 'subscribe', event: 'notification.created'})
+ const msg = await waitForMessage(ws)
+ expect(msg.error).toBe('auth_required')
+ } finally {
+ closeWs(ws)
+ }
+ })
+
+ test('unsubscribe stops receiving events', async ({apiContext, userToken, currentUser}) => {
+ const ws = await openWs()
+ try {
+ await authenticateWs(ws, userToken)
+ subscribeWs(ws, 'notification.created')
+
+ // Create a second user to trigger the notification
+ const [userA] = await UserFactory.create(1, {id: 100}, false)
+ const {token: tokenA} = await loginRaw(apiContext, userA)
+
+ // User A creates a team
+ const teamResponse = await apiContext.put('teams', {
+ data: {name: 'Test Team'},
+ headers: {Authorization: `Bearer ${tokenA}`},
+ })
+ const team = await teamResponse.json()
+
+ // Unsubscribe before the notification is triggered
+ sendMessage(ws, {action: 'unsubscribe', event: 'notification.created'})
+ // Give the server a moment to process the unsubscribe
+ await new Promise(r => setTimeout(r, 200))
+
+ // Now add currentUser to team — should NOT receive WS notification
+ await apiContext.put(`teams/${team.id}/members`, {
+ data: {username: currentUser.username},
+ headers: {Authorization: `Bearer ${tokenA}`},
+ })
+
+ // Collect messages for 2 seconds — should get none
+ const messages = await collectMessages(ws, 2000)
+ const notifications = messages.filter(m => m.event === 'notification.created')
+ expect(notifications).toHaveLength(0)
+ } finally {
+ closeWs(ws)
+ }
+ })
+ })
+
+ test.describe('Message Delivery', () => {
+ test('receives notification when added to team', async ({apiContext, userToken, currentUser}) => {
+ const ws = await openWs()
+ try {
+ await authenticateWs(ws, userToken)
+ subscribeWs(ws, 'notification.created')
+
+ // Create a second user (the doer)
+ const [userA] = await UserFactory.create(1, {id: 100}, false)
+ const {token: tokenA} = await loginRaw(apiContext, userA)
+
+ // User A creates a team
+ const teamResponse = await apiContext.put('teams', {
+ data: {name: 'Notification Test Team'},
+ headers: {Authorization: `Bearer ${tokenA}`},
+ })
+ const team = await teamResponse.json()
+
+ // User A adds currentUser to the team
+ const addResponse = await apiContext.put(`teams/${team.id}/members`, {
+ data: {username: currentUser.username},
+ headers: {Authorization: `Bearer ${tokenA}`},
+ })
+ expect(addResponse.ok()).toBe(true)
+
+ // currentUser should receive the notification via WebSocket
+ const msg = await waitForMessage(ws, 10000)
+ expect(msg.event).toBe('notification.created')
+ expect(msg.data).toBeDefined()
+ } finally {
+ closeWs(ws)
+ }
+ })
+
+ test('doer does not receive own notification', async ({apiContext, userToken}) => {
+ const ws = await openWs()
+ try {
+ await authenticateWs(ws, userToken)
+ subscribeWs(ws, 'notification.created')
+
+ // Create a second user
+ const [otherUser] = await UserFactory.create(1, {id: 100}, false)
+
+ // currentUser creates a team (they are the doer)
+ const teamResponse = await apiContext.put('teams', {
+ data: {name: 'Doer Test Team'},
+ headers: {Authorization: `Bearer ${userToken}`},
+ })
+ const team = await teamResponse.json()
+
+ // currentUser adds otherUser — currentUser is the doer
+ await apiContext.put(`teams/${team.id}/members`, {
+ data: {username: otherUser.username},
+ headers: {Authorization: `Bearer ${userToken}`},
+ })
+
+ // currentUser should NOT receive a notification (they did the action)
+ const messages = await collectMessages(ws, 3000)
+ const notifications = messages.filter(m => m.event === 'notification.created')
+ expect(notifications).toHaveLength(0)
+ } finally {
+ closeWs(ws)
+ }
+ })
+
+ test('multiple connections receive same notification', async ({apiContext, userToken, currentUser}) => {
+ const ws1 = await openWs()
+ const ws2 = await openWs()
+ try {
+ // Both connections authenticate as the same user
+ await authenticateWs(ws1, userToken)
+ await authenticateWs(ws2, userToken)
+ subscribeWs(ws1, 'notification.created')
+ subscribeWs(ws2, 'notification.created')
+
+ // Create a second user to trigger notification
+ const [userA] = await UserFactory.create(1, {id: 100}, false)
+ const {token: tokenA} = await loginRaw(apiContext, userA)
+
+ const teamResponse = await apiContext.put('teams', {
+ data: {name: 'Multi-Connection Team'},
+ headers: {Authorization: `Bearer ${tokenA}`},
+ })
+ const team = await teamResponse.json()
+
+ await apiContext.put(`teams/${team.id}/members`, {
+ data: {username: currentUser.username},
+ headers: {Authorization: `Bearer ${tokenA}`},
+ })
+
+ // Both connections should receive the notification
+ const [msg1, msg2] = await Promise.all([
+ waitForMessage(ws1, 10000),
+ waitForMessage(ws2, 10000),
+ ])
+ expect(msg1.event).toBe('notification.created')
+ expect(msg2.event).toBe('notification.created')
+ } finally {
+ closeWs(ws1)
+ closeWs(ws2)
+ }
+ })
+ })
+})
diff --git a/frontend/tests/support/websocket.ts b/frontend/tests/support/websocket.ts
new file mode 100644
index 000000000..c99ba8cd0
--- /dev/null
+++ b/frontend/tests/support/websocket.ts
@@ -0,0 +1,94 @@
+import WebSocket from 'ws'
+
+const API_URL = process.env.API_URL || 'http://localhost:3456/api/v1'
+
+export interface WsMessage {
+ event?: string
+ action?: string
+ success?: boolean
+ error?: string
+ data?: unknown
+}
+
+/**
+ * Returns the WebSocket URL derived from the API base URL.
+ */
+export function getWsUrl(): string {
+ return API_URL.replace(/\/+$/, '').replace(/^http/, 'ws') + '/ws'
+}
+
+/**
+ * Opens a raw WebSocket connection to the API.
+ */
+export function openWs(): Promise {
+ return new Promise((resolve, reject) => {
+ const ws = new WebSocket(getWsUrl())
+ ws.on('open', () => resolve(ws))
+ ws.on('error', reject)
+ })
+}
+
+/**
+ * Waits for the next message on a WebSocket connection.
+ */
+export function waitForMessage(ws: WebSocket, timeout = 5000): Promise {
+ return new Promise((resolve, reject) => {
+ const timer = setTimeout(() => reject(new Error('WebSocket message timeout')), timeout)
+ ws.once('message', (data) => {
+ clearTimeout(timer)
+ resolve(JSON.parse(data.toString()))
+ })
+ })
+}
+
+/**
+ * Sends a JSON message on the WebSocket.
+ */
+export function sendMessage(ws: WebSocket, msg: object): void {
+ ws.send(JSON.stringify(msg))
+}
+
+/**
+ * Authenticates a WebSocket connection and returns the auth.success message.
+ */
+export async function authenticateWs(ws: WebSocket, token: string): Promise {
+ sendMessage(ws, {action: 'auth', token})
+ const msg = await waitForMessage(ws)
+ if (msg.action !== 'auth.success') {
+ throw new Error(`Expected auth.success, got: ${JSON.stringify(msg)}`)
+ }
+ return msg
+}
+
+/**
+ * Subscribes to an event on an authenticated WebSocket connection.
+ */
+export function subscribeWs(ws: WebSocket, event: string): void {
+ sendMessage(ws, {action: 'subscribe', event})
+}
+
+/**
+ * Collects all messages received within a time window.
+ */
+export function collectMessages(ws: WebSocket, duration: number): Promise {
+ return new Promise((resolve) => {
+ const messages: WsMessage[] = []
+ const handler = (data: WebSocket.Data) => {
+ messages.push(JSON.parse(data.toString()))
+ }
+ ws.on('message', handler)
+ setTimeout(() => {
+ ws.off('message', handler)
+ resolve(messages)
+ }, duration)
+ })
+}
+
+/**
+ * Closes a WebSocket connection safely.
+ */
+export function closeWs(ws: WebSocket): void {
+ if (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING) {
+ ws.close()
+ }
+}
diff --git a/pkg/routes/api/v1/testing.go b/pkg/routes/api/v1/testing.go
index 53631845b..8c624d0ba 100644
--- a/pkg/routes/api/v1/testing.go
+++ b/pkg/routes/api/v1/testing.go
@@ -69,6 +69,24 @@ func HandleTesting(c *echo.Context) error {
truncate := c.QueryParam("truncate")
if truncate == "true" || truncate == "" {
+ // When truncating certain tables, also truncate dependent tables
+ // whose rows reference the truncated table by user/entity ID.
+ // Without foreign key cascades, stale rows would persist and
+ // pollute subsequent tests that reuse the same auto-increment IDs.
+ dependentTables := map[string][]string{
+ "users": {"notifications"},
+ }
+ if deps, ok := dependentTables[table]; ok {
+ for _, dep := range deps {
+ if err = db.RestoreAndTruncate(dep, nil); err != nil {
+ log.Errorf("Error truncating dependent table %s: %v", dep, err)
+ return c.JSON(http.StatusInternalServerError, map[string]interface{}{
+ "error": true,
+ "message": err.Error(),
+ })
+ }
+ }
+ }
err = db.RestoreAndTruncate(table, content)
} else {
err = db.Restore(table, content)