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)