(frontend) switch to vitest and enhance testability

Migrated from jest to vitest for server/y-provider, gaining faster runs,
esm-native support and cleaner mocking.

Signed-off-by: Stephan Meijer <me@stephanmeijer.com>
This commit is contained in:
Stephan Meijer
2025-07-02 16:39:46 +02:00
parent 58bf5071c2
commit f3c9c41b86
18 changed files with 684 additions and 333 deletions

View File

@@ -1,66 +1,64 @@
import request from 'supertest';
import { describe, expect, test, vi } from 'vitest';
const port = 5555;
const origin = 'http://localhost:3000';
jest.mock('../src/env', () => {
vi.mock('../src/env', async (importOriginal) => {
return {
PORT: port,
COLLABORATION_SERVER_ORIGIN: origin,
...(await importOriginal()),
PORT: 5555,
COLLABORATION_SERVER_ORIGIN: 'http://localhost:3000',
COLLABORATION_SERVER_SECRET: 'test-secret-api-key',
};
});
console.error = jest.fn();
console.error = vi.fn();
import { hocusPocusServer } from '@/servers/hocusPocusServer';
import { initServer } from '../src/servers/appServer';
const { app, server } = initServer();
import { COLLABORATION_SERVER_ORIGIN as origin } from '@/env';
import { hocuspocusServer, initApp } from '@/servers';
describe('Server Tests', () => {
afterAll(() => {
server.close();
});
test('POST /collaboration/api/reset-connections?room=[ROOM_ID] with incorrect API key should return 403', async () => {
const response = await request(app as any)
const app = initApp();
const response = await request(app)
.post('/collaboration/api/reset-connections/?room=test-room')
.set('Origin', origin)
.set('Authorization', 'wrong-api-key');
expect(response.status).toBe(403);
expect(response.body.error).toBe('Forbidden: Invalid API Key');
expect(response.body).toStrictEqual({
error: 'Forbidden: Invalid API Key',
});
});
test('POST /collaboration/api/reset-connections?room=[ROOM_ID] failed if room not indicated', async () => {
const response = await request(app as any)
const app = initApp();
const response = await request(app)
.post('/collaboration/api/reset-connections/')
.set('Origin', origin)
.set('Authorization', 'test-secret-api-key')
.send({ document_id: 'test-document' });
expect(response.status).toBe(400);
expect(response.body.error).toBe('Room name not provided');
expect(response.body).toStrictEqual({ error: 'Room name not provided' });
});
test('POST /collaboration/api/reset-connections?room=[ROOM_ID] with correct API key should reset connections', async () => {
// eslint-disable-next-line jest/unbound-method
const { closeConnections } = hocusPocusServer;
const mockHandleConnection = jest.fn();
(hocusPocusServer.closeConnections as jest.Mock) = mockHandleConnection;
const closeConnectionsMock = vi
.spyOn(hocuspocusServer, 'closeConnections')
.mockResolvedValue();
const response = await request(app as any)
const app = initApp();
const response = await request(app)
.post('/collaboration/api/reset-connections?room=test-room')
.set('Origin', origin)
.set('Authorization', 'test-secret-api-key');
expect(response.status).toBe(200);
expect(response.body.message).toBe('Connections reset');
expect(response.body).toStrictEqual({ message: 'Connections reset' });
expect(mockHandleConnection).toHaveBeenCalled();
mockHandleConnection.mockClear();
hocusPocusServer.closeConnections = closeConnections;
// eslint-disable-next-line jest/unbound-method
expect(closeConnectionsMock).toHaveBeenCalledOnce();
});
});

View File

@@ -1,66 +1,93 @@
import { Hocuspocus } from '@hocuspocus/server';
import request from 'supertest';
import { describe, expect, test, vi } from 'vitest';
import { mock } from 'vitest-mock-extended';
const port = 5556;
const origin = 'http://localhost:3000';
jest.mock('../src/env', () => {
vi.mock('../src/env', async (importOriginal) => {
return {
PORT: port,
COLLABORATION_SERVER_ORIGIN: origin,
...(await importOriginal()),
COLLABORATION_SERVER_ORIGIN: 'http://localhost:3000',
Y_PROVIDER_API_KEY: 'yprovider-api-key',
};
});
import { initServer } from '../src/servers/appServer';
import { initApp } from '@/servers';
console.error = jest.fn();
const { app, server } = initServer();
import {
Y_PROVIDER_API_KEY as apiKey,
COLLABORATION_SERVER_ORIGIN as origin,
} from '../src/env';
console.error = vi.fn();
const mockOpts = {
fallbackMockImplementation: () => {
throw new Error('Unexpected call.');
},
};
describe('Server Tests', () => {
afterAll(() => {
server.close();
});
test('POST /api/convert with incorrect API key should responds with 403', async () => {
const hocuspocus = mock<Hocuspocus>({}, mockOpts);
const app = initApp(hocuspocus);
test('POST /api/convert with incorrect API key should return 403', async () => {
const response = await request(app as any)
const response = await request(app)
.post('/api/convert')
.set('Origin', origin)
.set('Authorization', 'wrong-api-key');
expect(response.status).toBe(403);
expect(response.body.error).toBe('Forbidden: Invalid API Key');
expect(response.body).toStrictEqual({
error: 'Forbidden: Invalid API Key',
});
});
test('POST /api/convert with a Bearer token', async () => {
const response = await request(app as any)
const hocuspocus = mock<Hocuspocus>({}, mockOpts);
const app = initApp(hocuspocus);
const response = await request(app)
.post('/api/convert')
.set('Origin', origin)
.set('Authorization', 'Bearer test-secret-api-key');
// Warning: Changing the authorization header to Bearer token format will break backend compatibility with this microservice.
expect(response.status).toBe(403);
expect(response.body).toStrictEqual({
error: 'Forbidden: Invalid API Key',
});
});
test('POST /api/convert with missing body param content', async () => {
const response = await request(app as any)
const hocuspocus = mock<Hocuspocus>({}, mockOpts);
const app = initApp(hocuspocus);
const response = await request(app)
.post('/api/convert')
.set('Origin', origin)
.set('Authorization', 'yprovider-api-key');
.set('Authorization', apiKey);
expect(response.status).toBe(400);
expect(response.body.error).toBe('Invalid request: missing content');
expect(response.body).toStrictEqual({
error: 'Invalid request: missing content',
});
});
test('POST /api/convert with body param content being an empty string', async () => {
const response = await request(app as any)
const hocuspocus = mock<Hocuspocus>({}, mockOpts);
const app = initApp(hocuspocus);
const response = await request(app)
.post('/api/convert')
.set('Origin', origin)
.set('Authorization', 'yprovider-api-key')
.set('Authorization', apiKey)
.send({
content: '',
});
expect(response.status).toBe(400);
expect(response.body.error).toBe('Invalid request: missing content');
expect(response.body).toStrictEqual({
error: 'Invalid request: missing content',
});
});
});

View File

@@ -1,56 +1,62 @@
import { Server } from 'node:net';
import {
HocuspocusProvider,
HocuspocusProviderWebsocket,
} from '@hocuspocus/provider';
import { v1 as uuidv1, v4 as uuidv4 } from 'uuid';
import {
afterAll,
beforeAll,
beforeEach,
describe,
expect,
test,
vi,
} from 'vitest';
import WebSocket from 'ws';
const port = 5559;
const portWS = 6666;
const origin = 'http://localhost:3000';
jest.mock('../src/env', () => {
vi.mock('../src/env', async (importOriginal) => {
return {
PORT: port,
COLLABORATION_SERVER_ORIGIN: origin,
...(await importOriginal()),
PORT: 5559,
COLLABORATION_SERVER_ORIGIN: 'http://localhost:3000',
COLLABORATION_SERVER_SECRET: 'test-secret-api-key',
COLLABORATION_BACKEND_BASE_URL: 'http://app-dev:8000',
COLLABORATION_LOGGING: 'true',
};
});
console.error = jest.fn();
console.log = jest.fn();
const mockDocFetch = jest.fn();
jest.mock('@/api/getDoc', () => ({
fetchDocument: mockDocFetch,
vi.mock('../src/api/collaborationBackend', () => ({
fetchCurrentUser: vi.fn(),
fetchDocument: vi.fn(),
}));
const mockGetMe = jest.fn();
jest.mock('@/api/getMe', () => ({
getMe: mockGetMe,
}));
console.error = vi.fn();
console.log = vi.fn();
import { hocusPocusServer } from '@/servers/hocusPocusServer';
import { promiseDone } from '../src/helpers';
import { initServer } from '../src/servers/appServer';
const { server } = initServer();
import * as CollaborationBackend from '@/api/collaborationBackend';
import { COLLABORATION_SERVER_ORIGIN as origin, PORT as port } from '@/env';
import { promiseDone } from '@/helpers';
import { hocuspocusServer, initApp } from '@/servers';
describe('Server Tests', () => {
beforeAll(async () => {
await hocusPocusServer.configure({ port: portWS }).listen();
let server: Server;
beforeEach(() => {
vi.restoreAllMocks();
});
afterEach(() => {
jest.clearAllMocks();
beforeAll(async () => {
server = initApp().listen(port);
await hocuspocusServer.configure({ port: portWS }).listen();
});
afterAll(() => {
void hocuspocusServer.destroy();
server.close();
void hocusPocusServer.destroy();
});
test('WebSocket connection with bad origin should be closed', () => {
@@ -209,7 +215,9 @@ describe('Server Tests', () => {
test('WebSocket connection fails if user can not access document', () => {
const { promise, done } = promiseDone();
mockDocFetch.mockRejectedValue('');
const fetchDocumentMock = vi
.spyOn(CollaborationBackend, 'fetchDocument')
.mockRejectedValue(new Error('some error'));
const room = uuidv4();
const wsHocus = new HocuspocusProviderWebsocket({
@@ -233,7 +241,10 @@ describe('Server Tests', () => {
wsHocus.stopConnectionAttempt();
expect(data.event.reason).toBe('Forbidden');
expect(mockDocFetch).toHaveBeenCalledTimes(1);
expect(fetchDocumentMock).toHaveBeenCalledExactlyOnceWith(
room,
expect.any(Object),
);
wsHocus.webSocket?.close();
wsHocus.disconnect();
provider.destroy();
@@ -249,11 +260,10 @@ describe('Server Tests', () => {
const { promise, done } = promiseDone();
const room = uuidv4();
mockDocFetch.mockResolvedValue({
abilities: {
retrieve: false,
},
});
const fetchDocumentMock = vi
.spyOn(CollaborationBackend, 'fetchDocument')
.mockResolvedValue({ abilities: { retrieve: false } } as any);
const wsHocus = new HocuspocusProviderWebsocket({
url: `ws://localhost:${portWS}/?room=${room}`,
@@ -278,7 +288,10 @@ describe('Server Tests', () => {
wsHocus.stopConnectionAttempt();
expect(data.event.reason).toBe('Forbidden');
expect(mockDocFetch).toHaveBeenCalledTimes(1);
expect(fetchDocumentMock).toHaveBeenCalledExactlyOnceWith(
room,
expect.any(Object),
);
wsHocus.webSocket?.close();
wsHocus.disconnect();
provider.destroy();
@@ -294,12 +307,11 @@ describe('Server Tests', () => {
test(`WebSocket connection ${canEdit ? 'can' : 'can not'} edit document`, () => {
const { promise, done } = promiseDone();
mockDocFetch.mockResolvedValue({
abilities: {
retrieve: true,
update: canEdit,
},
});
const fetchDocumentMock = vi
.spyOn(CollaborationBackend, 'fetchDocument')
.mockResolvedValue({
abilities: { retrieve: true, update: canEdit },
} as any);
const room = uuidv4();
const wsHocus = new HocuspocusProviderWebsocket({
@@ -313,7 +325,7 @@ describe('Server Tests', () => {
broadcast: false,
quiet: true,
onConnect: () => {
void hocusPocusServer
void hocuspocusServer
.openDirectConnection(room)
.then((connection) => {
connection.document?.getConnections().forEach((connection) => {
@@ -324,6 +336,12 @@ describe('Server Tests', () => {
provider.destroy();
wsHocus.destroy();
expect(fetchDocumentMock).toHaveBeenCalledWith(
room,
expect.any(Object),
);
done();
});
},
@@ -336,16 +354,15 @@ describe('Server Tests', () => {
test('Add request header x-user-id if found', () => {
const { promise, done } = promiseDone();
mockDocFetch.mockResolvedValue({
abilities: {
retrieve: true,
update: true,
},
});
const fetchDocumentMock = vi
.spyOn(CollaborationBackend, 'fetchDocument')
.mockResolvedValue({
abilities: { retrieve: true, update: true },
} as any);
mockGetMe.mockResolvedValue({
id: 'test-user-id',
});
const fetchCurrentUserMock = vi
.spyOn(CollaborationBackend, 'fetchCurrentUser')
.mockResolvedValue({ id: 'test-user-id' } as any);
const room = uuidv4();
const wsHocus = new HocuspocusProviderWebsocket({
@@ -359,7 +376,7 @@ describe('Server Tests', () => {
broadcast: false,
quiet: true,
onConnect: () => {
void hocusPocusServer.openDirectConnection(room).then((connection) => {
void hocuspocusServer.openDirectConnection(room).then((connection) => {
connection.document?.getConnections().forEach((connection) => {
expect(connection.context.userId).toBe('test-user-id');
});
@@ -367,6 +384,14 @@ describe('Server Tests', () => {
void connection.disconnect();
provider.destroy();
wsHocus.destroy();
expect(fetchDocumentMock).toHaveBeenCalledWith(
room,
expect.any(Object),
);
expect(fetchCurrentUserMock).toHaveBeenCalled();
done();
});
},

View File

@@ -1,45 +1,42 @@
import request from 'supertest';
import { describe, expect, it, test, vi } from 'vitest';
import { routes } from '@/routes';
import { initApp } from '@/servers';
const port = 5557;
const origin = 'http://localhost:3000';
jest.mock('../src/env', () => {
vi.mock('../src/env', async (importOriginal) => {
return {
PORT: port,
COLLABORATION_SERVER_ORIGIN: origin,
...(await importOriginal()),
COLLABORATION_SERVER_ORIGIN: 'http://localhost:3000',
};
});
console.error = jest.fn();
import { initServer } from '../src/servers/appServer';
const { app, server } = initServer();
console.error = vi.fn();
describe('Server Tests', () => {
afterAll(() => {
server.close();
});
test('Ping Pong', async () => {
const response = await request(app as any).get('/ping');
const app = initApp();
const response = await request(app).get('/ping');
expect(response.status).toBe(200);
expect(response.body.message).toBe('pong');
expect(response.body).toStrictEqual({ message: 'pong' });
});
['/collaboration/api/anything/', '/', '/anything'].forEach((path) => {
test(`"${path}" endpoint should be forbidden`, async () => {
const response = await request(app as any).post(path);
const app = initApp();
const response = await request(app).post(path);
expect(response.status).toBe(403);
expect(response.body.error).toBe('Forbidden');
});
});
it('should allow JSON payloads up to 500kb for the CONVERT route', async () => {
it('allows JSON payloads up to 500kb for the CONVERT route', async () => {
const app = initApp();
const largePayload = 'a'.repeat(400 * 1024); // 400kb payload
const response = await request(app)
.post(routes.CONVERT)
@@ -49,7 +46,9 @@ describe('Server Tests', () => {
expect(response.status).not.toBe(413);
});
it('should reject JSON payloads larger than 500kb for the CONVERT route', async () => {
it('rejects JSON payloads larger than 500kb for the CONVERT route', async () => {
const app = initApp();
const oversizedPayload = 'a'.repeat(501 * 1024); // 501kb payload
const response = await request(app)
.post(routes.CONVERT)
@@ -59,7 +58,9 @@ describe('Server Tests', () => {
expect(response.status).toBe(413);
});
it('should use the default JSON limit for other routes', async () => {
it('uses the default JSON limit for other routes', async () => {
const app = initApp();
const largePayload = 'a'.repeat(150 * 1024);
const response = await request(app)
.post('/some-other-route')