test: add WebSocket e2e tests

Add comprehensive end-to-end tests for the WebSocket system:

- Protocol tests: auth (valid/invalid token, timeout, double auth),
  subscriptions (valid/invalid event, auth required, unsubscribe),
  message delivery (notification on team add, doer exclusion,
  multi-connection)
- Frontend integration tests: notification badge update, dropdown
  rendering, and logout cleanup via browser-level Playwright tests
- Comment notification test: full flow where user B mentions user A
  in a task comment and user A receives real-time WebSocket notification

Includes ws test dependency, shared test helper utilities, and
cascade-truncation of notifications when truncating users to prevent
test pollution.
This commit is contained in:
kolaente 2026-04-02 18:19:10 +02:00 committed by kolaente
parent 09232ed880
commit 4cd79088d1
7 changed files with 523 additions and 1 deletions

View File

@ -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": [

View File

@ -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:

View File

@ -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 = `<p>Hey <mention-user data-id="${currentUser.username}">@${currentUser.username}</mention-user> check this out</p>`
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)
}
})
})

View File

@ -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)
})
})

View File

@ -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)
}
})
})
})

View File

@ -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<WebSocket> {
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<WsMessage> {
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<WsMessage> {
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<WsMessage[]> {
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()
}
}

View File

@ -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)