Implement Jest testing framework and add initial test cases for various modules. Update configuration for test environment in config.json and add test results handling in Jenkinsfile. Include .gitignore entry for test results. Enhance package.json with Jest dependencies.
All checks were successful
farmcontrol/farmcontrol-ws/pipeline/head This commit looks good

This commit is contained in:
Tom Butcher 2025-12-29 01:13:01 +00:00
parent 0ed5eb86dd
commit f6e7fb3a41
17 changed files with 3648 additions and 36 deletions

2
.gitignore vendored
View File

@ -132,3 +132,5 @@ dist
*.DS_STORE *.DS_STORE
*.env *.env
test-results.xml

20
Jenkinsfile vendored
View File

@ -31,6 +31,22 @@ pipeline {
} }
} }
stage('Run Tests') {
steps {
nodejs(nodeJSInstallationName: 'Node23') {
sh '''
export NODE_ENV=test
yarn test
'''
}
}
post {
always {
junit 'test-results.xml'
}
}
}
stage('Deploy via SSH') { stage('Deploy via SSH') {
steps { steps {
sshPublisher(publishers: [ sshPublisher(publishers: [
@ -41,9 +57,9 @@ pipeline {
cleanRemote: false, cleanRemote: false,
excludes: 'node_modules/**', excludes: 'node_modules/**',
execCommand: ''' execCommand: '''
cd /opt/farmcontrol-ws cd /home/farmcontrol/farmcontrol-ws
yarn install --production yarn install --production
pm2 restart ecosystem.config.js --env production || pm2 start ecosystem.config.js --env production sudo systemctl restart farmcontrol-ws
''', ''',
execTimeout: 120000, execTimeout: 120000,
flatten: false, flatten: false,

View File

@ -21,6 +21,28 @@
}, },
"otpExpiryMins": 0.5 "otpExpiryMins": 0.5
}, },
"test": {
"server": {
"port": 9091,
"logLevel": "error"
},
"auth": {
"enabled": false,
"keycloak": {
"url": "http://localhost:8080",
"realm": "test",
"clientId": "test-client"
},
"requiredRoles": []
},
"database": {
"mongo": {
"url": "mongodb://127.0.0.1:27017/farmcontrol-test"
},
"redis": { "host": "localhost", "port": 6379, "password": "" }
},
"otpExpiryMins": 0.5
},
"production": { "production": {
"server": { "server": {
"port": 8081, "port": 8081,

23
jest.config.cjs Normal file
View File

@ -0,0 +1,23 @@
module.exports = {
testEnvironment: 'node',
transform: {},
moduleFileExtensions: ['js', 'json', 'jsx', 'node'],
testMatch: ['**/__tests__/**/*.js', '**/?(*.)+(spec|test).js'],
verbose: true,
reporters: [
'default',
[
'jest-junit',
{
outputDirectory: '.',
outputName: 'test-results.xml',
suiteName: 'farmcontrol-ws-tests',
classNameTemplate: '{classname}',
titleTemplate: '{title}',
ancestorSeparator: ' ',
usePathForSuiteName: 'true',
},
],
],
};

View File

@ -5,7 +5,7 @@
"main": "app.js", "main": "app.js",
"type": "module", "type": "module",
"scripts": { "scripts": {
"test": "echo \"Error: no test specified\" && exit 1", "test": "node --experimental-vm-modules ./node_modules/jest/bin/jest.js",
"start": "node src/index.js", "start": "node src/index.js",
"dev": "nodemon src/index.js", "dev": "nodemon src/index.js",
"lint": "eslint src/", "lint": "eslint src/",
@ -43,11 +43,16 @@
"socketio-jwt": "^4.6.2" "socketio-jwt": "^4.6.2"
}, },
"devDependencies": { "devDependencies": {
"@jest/globals": "^30.2.0",
"babel-jest": "^30.2.0",
"eslint": "^9.39.1", "eslint": "^9.39.1",
"eslint-config-prettier": "^10.1.8", "eslint-config-prettier": "^10.1.8",
"eslint-plugin-prettier": "^5.5.4", "eslint-plugin-prettier": "^5.5.4",
"jest": "^30.2.0",
"jest-junit": "^16.0.0",
"nodemon": "^3.1.11", "nodemon": "^3.1.11",
"prettier": "^3.6.2", "prettier": "^3.6.2",
"standard": "^17.1.2" "standard": "^17.1.2",
"supertest": "^7.1.4"
} }
} }

View File

@ -0,0 +1,60 @@
import { jest } from '@jest/globals';
describe('Config Module', () => {
let originalEnv;
beforeEach(() => {
originalEnv = process.env.NODE_ENV;
jest.clearAllMocks();
});
afterEach(() => {
process.env.NODE_ENV = originalEnv;
delete process.env.KEYCLOAK_CLIENT_SECRET;
});
describe('loadConfig', () => {
it('should load test config when NODE_ENV=test', async () => {
process.env.NODE_ENV = 'test';
const { loadConfig } = await import('../config.js');
const config = loadConfig();
expect(config).toBeDefined();
expect(config.server).toBeDefined();
expect(config.server.port).toBe(9091);
expect(config.server.logLevel).toBe('error');
});
it('should override keycloak client secret from environment variable', async () => {
process.env.NODE_ENV = 'test';
process.env.KEYCLOAK_CLIENT_SECRET = 'test-secret';
const { loadConfig } = await import('../config.js');
const config = loadConfig();
expect(config.auth.keycloak.clientSecret).toBe('test-secret');
});
it('should throw error if environment config does not exist', async () => {
process.env.NODE_ENV = 'nonexistent';
const { loadConfig } = await import('../config.js');
// Suppress console.error during this test as it's expected
const consoleSpy = jest.spyOn(console, 'error').mockImplementation(() => {});
expect(() => loadConfig()).toThrow(
"Configuration for environment 'nonexistent' not found in config.json"
);
consoleSpy.mockRestore();
});
});
describe('getEnvironment', () => {
it('should return current NODE_ENV', async () => {
process.env.NODE_ENV = 'test';
const { getEnvironment } = await import('../config.js');
expect(getEnvironment()).toBe('test');
});
});
});

View File

@ -0,0 +1,164 @@
import { jest } from '@jest/globals';
jest.unstable_mockModule('../../database/nats.js', () => ({
natsServer: {
publish: jest.fn().mockResolvedValue({ success: true }),
subscribe: jest.fn().mockResolvedValue({ success: true }),
removeSubscription: jest.fn().mockResolvedValue({ success: true })
}
}));
jest.unstable_mockModule('../../utils.js', () => ({
generateEtcId: jest.fn(() => 'test-etc-id')
}));
jest.unstable_mockModule('log4js', () => ({
default: {
getLogger: () => ({
level: 'info',
debug: jest.fn(),
error: jest.fn(),
warn: jest.fn(),
trace: jest.fn(),
info: jest.fn()
})
}
}));
jest.unstable_mockModule('../../config.js', () => ({
loadConfig: jest.fn(() => ({
server: {
logLevel: 'info'
}
}))
}));
const { ActionManager } = await import('../actionmanager.js');
const { natsServer } = await import('../../database/nats.js');
const { generateEtcId } = await import('../../utils.js');
describe('ActionManager', () => {
let mockSocketClient;
let actionManager;
beforeEach(() => {
jest.clearAllMocks();
mockSocketClient = {
id: 'test-socket-id',
socketId: 'test-socket-id',
socket: {
emit: jest.fn((event, data, cb) => {
if (cb) cb({ status: 'success' });
})
}
};
actionManager = new ActionManager(mockSocketClient);
});
describe('subscribeToObjectActions', () => {
it('should subscribe to object actions and emit when received', async () => {
const id = 'obj-123';
const objectType = 'printer';
await actionManager.subscribeToObjectActions(id, objectType);
expect(natsServer.subscribe).toHaveBeenCalledWith(
'printers.obj-123.actions',
'test-socket-id',
expect.any(Function)
);
// Simulate NATS message
const natsCallback = natsServer.subscribe.mock.calls[0][2];
const actionData = { some: 'action', actionId: 'act-1' };
natsCallback('printers.obj-123.actions', actionData);
expect(mockSocketClient.socket.emit).toHaveBeenCalledWith(
'objectAction',
expect.objectContaining({
_id: id,
objectType: objectType,
action: actionData
}),
expect.any(Function)
);
// Verify that the emit callback publishes back to NATS
expect(natsServer.publish).toHaveBeenCalledWith(
'printers.obj-123.actions.act-1',
expect.objectContaining({
...actionData,
result: { status: 'success' }
})
);
});
});
describe('removeObjectActionsListener', () => {
it('should remove subscription and update internal state', async () => {
const id = 'obj-123';
const objectType = 'printer';
await actionManager.subscribeToObjectActions(id, objectType);
await actionManager.removeObjectActionsListener(id, objectType);
expect(natsServer.removeSubscription).toHaveBeenCalledWith(
'printers.obj-123.actions',
'test-socket-id'
);
expect(actionManager.subscriptions.size).toBe(0);
});
});
describe('sendObjectAction', () => {
it('should publish action and subscribe for response', async () => {
const id = 'obj-123';
const objectType = 'printer';
const action = { type: 'start' };
const callback = jest.fn();
await actionManager.sendObjectAction(id, objectType, action, callback);
expect(natsServer.subscribe).toHaveBeenCalledWith(
'printers.obj-123.actions.test-etc-id',
'test-socket-id',
expect.any(Function)
);
expect(natsServer.publish).toHaveBeenCalledWith(
'printers.obj-123.actions',
expect.objectContaining({
...action,
actionId: 'test-etc-id'
})
);
// Simulate NATS response
const natsCallback = natsServer.subscribe.mock.calls[0][2];
await natsCallback('printers.obj-123.actions.test-etc-id', {
result: 'ok'
});
expect(callback).toHaveBeenCalledWith('ok');
expect(natsServer.removeSubscription).toHaveBeenCalledWith(
'printers.obj-123.actions.test-etc-id',
'test-socket-id'
);
});
});
describe('removeAllListeners', () => {
it('should remove all active subscriptions', async () => {
await actionManager.subscribeToObjectActions('1', 'printer');
await actionManager.subscribeToObjectActions('2', 'printer');
await actionManager.removeAllListeners();
expect(natsServer.removeSubscription).toHaveBeenCalledTimes(2);
expect(actionManager.subscriptions.size).toBe(0);
});
});
});

View File

@ -12,11 +12,9 @@ const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename); const __dirname = path.dirname(__filename);
const CONFIG_PATH = path.resolve(__dirname, '../config.json'); const CONFIG_PATH = path.resolve(__dirname, '../config.json');
// Determine environment
const NODE_ENV = process.env.NODE_ENV || 'development';
// Load config file // Load config file
export function loadConfig() { export function loadConfig() {
const env = process.env.NODE_ENV || 'development';
try { try {
if (!fs.existsSync(CONFIG_PATH)) { if (!fs.existsSync(CONFIG_PATH)) {
throw new Error(`Configuration file not found at ${CONFIG_PATH}`); throw new Error(`Configuration file not found at ${CONFIG_PATH}`);
@ -25,13 +23,13 @@ export function loadConfig() {
const configData = fs.readFileSync(CONFIG_PATH, 'utf8'); const configData = fs.readFileSync(CONFIG_PATH, 'utf8');
const config = JSON.parse(configData); const config = JSON.parse(configData);
if (!config[NODE_ENV]) { if (!config[env]) {
throw new Error( throw new Error(
`Configuration for environment '${NODE_ENV}' not found in config.json` `Configuration for environment '${env}' not found in config.json`
); );
} }
const envConfig = config[NODE_ENV]; const envConfig = config[env];
// Override keycloak client secret with environment variable if available // Override keycloak client secret with environment variable if available
if (process.env.KEYCLOAK_CLIENT_SECRET) { if (process.env.KEYCLOAK_CLIENT_SECRET) {
@ -53,5 +51,5 @@ export function loadConfig() {
// Get current environment // Get current environment
export function getEnvironment() { export function getEnvironment() {
return NODE_ENV; return process.env.NODE_ENV || 'development';
} }

View File

@ -0,0 +1,134 @@
import { jest } from '@jest/globals';
jest.unstable_mockModule('../../database/nats.js', () => ({
natsServer: {
publish: jest.fn().mockResolvedValue({ success: true }),
subscribe: jest.fn().mockResolvedValue({ success: true }),
removeSubscription: jest.fn().mockResolvedValue({ success: true })
}
}));
jest.unstable_mockModule('log4js', () => ({
default: {
getLogger: () => ({
level: 'info',
debug: jest.fn(),
error: jest.fn(),
warn: jest.fn(),
trace: jest.fn(),
info: jest.fn()
})
}
}));
jest.unstable_mockModule('../../config.js', () => ({
loadConfig: jest.fn(() => ({
server: {
logLevel: 'info'
}
}))
}));
const { EventManager } = await import('../eventmanager.js');
const { natsServer } = await import('../../database/nats.js');
describe('EventManager', () => {
let mockSocketClient;
let eventManager;
beforeEach(() => {
jest.clearAllMocks();
mockSocketClient = {
socketId: 'test-socket-id',
socket: {
emit: jest.fn()
}
};
eventManager = new EventManager(mockSocketClient);
});
describe('subscribeToObjectEvent', () => {
it('should subscribe to object events and emit when received', async () => {
const id = 'obj-123';
const objectType = 'printer';
const eventType = 'status';
await eventManager.subscribeToObjectEvent(id, objectType, eventType);
expect(natsServer.subscribe).toHaveBeenCalledWith(
'printers.obj-123.events.status',
'test-socket-id',
expect.any(Function)
);
// Simulate NATS message
const natsCallback = natsServer.subscribe.mock.calls[0][2];
const eventData = { status: 'online' };
natsCallback('printers.obj-123.events.status', eventData);
expect(mockSocketClient.socket.emit).toHaveBeenCalledWith('objectEvent', {
_id: id,
objectType: objectType,
event: eventData
});
});
});
describe('removeObjectEventsListener', () => {
it('should remove specific subscription', async () => {
const id = 'obj-123';
const objectType = 'printer';
const eventType = 'status';
await eventManager.subscribeToObjectEvent(id, objectType, eventType);
await eventManager.removeObjectEventsListener(id, objectType, eventType);
expect(natsServer.removeSubscription).toHaveBeenCalledWith(
'printers.obj-123.events.status',
'test-socket-id'
);
expect(eventManager.subscriptions.size).toBe(0);
});
});
describe('sendObjectEvent', () => {
it('should publish event to NATS', async () => {
const id = 'obj-123';
const objectType = 'printer';
const event = { type: 'alert', message: 'low filament' };
const result = await eventManager.sendObjectEvent(id, objectType, event);
expect(result).toEqual({ success: true });
expect(natsServer.publish).toHaveBeenCalledWith(
'printers.obj-123.events.alert',
event
);
});
it('should handle errors when publishing fails', async () => {
natsServer.publish.mockRejectedValueOnce(new Error('NATS error'));
const result = await eventManager.sendObjectEvent('id', 'type', {
type: 't'
});
expect(result).toHaveProperty('error', 'NATS error');
});
});
describe('removeAllListeners', () => {
it('should remove all subscriptions', async () => {
await eventManager.subscribeToObjectEvent('1', 'printer', 'e1');
await eventManager.subscribeToObjectEvent('2', 'printer', 'e2');
await eventManager.removeAllListeners();
expect(natsServer.removeSubscription).toHaveBeenCalledTimes(2);
expect(eventManager.subscriptions.size).toBe(0);
});
});
});

View File

@ -0,0 +1,226 @@
import { jest } from '@jest/globals';
jest.unstable_mockModule('../../database/nats.js', () => ({
natsServer: {
publish: jest.fn().mockResolvedValue({ success: true }),
subscribe: jest.fn().mockResolvedValue({ success: true }),
},
}));
jest.unstable_mockModule('../../database/redis.js', () => ({
redisServer: {
setKey: jest.fn().mockResolvedValue(undefined),
getKey: jest.fn().mockResolvedValue(null),
deleteKey: jest.fn().mockResolvedValue(undefined),
},
}));
jest.unstable_mockModule('log4js', () => ({
default: {
getLogger: () => ({
level: 'info',
debug: jest.fn(),
error: jest.fn(),
warn: jest.fn(),
trace: jest.fn(),
info: jest.fn(),
}),
},
}));
jest.unstable_mockModule('../../config.js', () => ({
loadConfig: jest.fn(() => ({
server: {
logLevel: 'info',
},
})),
}));
const { LockManager } = await import('../lockmanager.js');
const { natsServer } = await import('../../database/nats.js');
const { redisServer } = await import('../../database/redis.js');
describe('LockManager', () => {
let mockSocketClient;
let lockManager;
beforeEach(() => {
jest.clearAllMocks();
mockSocketClient = {
id: 'test-socket-id',
socket: {
emit: jest.fn(),
},
};
lockManager = new LockManager(mockSocketClient);
});
describe('lockObject', () => {
it('should lock an object and publish via NATS', async () => {
const testObject = {
_id: 'test-id-123',
type: 'product',
user: 'user-123',
};
const result = await lockManager.lockObject(testObject);
expect(result).toBe(true);
expect(redisServer.setKey).toHaveBeenCalledWith(
'locks:products:test-id-123',
expect.objectContaining({
_id: 'test-id-123',
type: 'product',
user: 'user-123',
locked: true,
})
);
expect(natsServer.publish).toHaveBeenCalledWith(
'locks.products.test-id-123',
expect.objectContaining({
locked: true,
})
);
});
it('should handle errors when locking fails', async () => {
const testObject = {
_id: 'test-id-123',
type: 'product',
};
redisServer.setKey.mockRejectedValueOnce(new Error('Redis error'));
await expect(lockManager.lockObject(testObject)).rejects.toThrow('Redis error');
});
});
describe('unlockObject', () => {
it('should unlock an object when user matches', async () => {
const testObject = {
_id: 'test-id-123',
type: 'product',
user: 'user-123',
};
redisServer.getKey.mockResolvedValueOnce({
_id: 'test-id-123',
type: 'product',
user: 'user-123',
locked: true,
});
const result = await lockManager.unlockObject(testObject);
expect(result).toBe(true);
expect(redisServer.deleteKey).toHaveBeenCalledWith('locks:products:test-id-123');
expect(natsServer.publish).toHaveBeenCalledWith(
'locks.products.test-id-123',
expect.objectContaining({
_id: 'test-id-123',
type: 'product',
locked: false,
})
);
});
it('should not unlock when user does not match', async () => {
const testObject = {
_id: 'test-id-123',
type: 'product',
user: 'user-123',
};
redisServer.getKey.mockResolvedValueOnce({
_id: 'test-id-123',
type: 'product',
user: 'different-user',
locked: true,
});
const result = await lockManager.unlockObject(testObject);
expect(result).toBeUndefined();
expect(redisServer.deleteKey).not.toHaveBeenCalled();
expect(natsServer.publish).not.toHaveBeenCalled();
});
it('should handle errors when unlocking fails', async () => {
const testObject = {
_id: 'test-id-123',
type: 'product',
user: 'user-123',
};
redisServer.getKey.mockRejectedValueOnce(new Error('Redis error'));
await expect(lockManager.unlockObject(testObject)).rejects.toThrow('Redis error');
});
});
describe('getObjectLock', () => {
it('should return locked status when object is locked', async () => {
const testObject = {
_id: 'test-id-123',
type: 'product',
};
redisServer.getKey.mockResolvedValueOnce({
_id: 'test-id-123',
type: 'product',
user: 'user-123',
locked: true,
});
const result = await lockManager.getObjectLock(testObject);
expect(result).toEqual({
_id: 'test-id-123',
type: 'product',
user: 'user-123',
locked: true,
});
expect(redisServer.getKey).toHaveBeenCalledWith('locks:products:test-id-123');
});
it('should return unlocked status when object is not locked', async () => {
const testObject = {
_id: 'test-id-123',
type: 'product',
};
redisServer.getKey.mockResolvedValueOnce(null);
const result = await lockManager.getObjectLock(testObject);
expect(result).toEqual({
_id: 'test-id-123',
locked: false,
});
});
it('should handle errors when getting lock status fails', async () => {
const testObject = {
_id: 'test-id-123',
type: 'product',
};
redisServer.getKey.mockRejectedValueOnce(new Error('Redis error'));
await expect(lockManager.getObjectLock(testObject)).rejects.toThrow('Redis error');
});
});
describe('setupLocksListeners', () => {
it('should subscribe to NATS lock changes', () => {
expect(natsServer.subscribe).toHaveBeenCalledWith(
'locks.>',
'test-socket-id',
expect.any(Function)
);
});
});
});

View File

@ -0,0 +1,215 @@
import { jest } from '@jest/globals';
// Mock dependencies
jest.unstable_mockModule('../../config.js', () => ({
loadConfig: jest.fn(() => ({
server: { logLevel: 'info' }
}))
}));
jest.unstable_mockModule('../../auth/auth.js', () => ({
CodeAuth: jest.fn().mockImplementation(() => ({
verifyCode: jest.fn(),
verifyOtp: jest.fn()
})),
createAuthMiddleware: jest.fn(() => (socket, next) => next())
}));
jest.unstable_mockModule('../../database/database.js', () => ({
newObject: jest.fn(),
editObject: jest.fn(),
getObject: jest.fn(),
listObjects: jest.fn()
}));
jest.unstable_mockModule(
'../../database/schemas/management/host.schema.js',
() => ({
hostModel: { modelName: 'host' }
})
);
jest.unstable_mockModule('../../updates/updatemanager.js', () => ({
UpdateManager: jest.fn().mockImplementation(() => ({
subscribeToObjectUpdate: jest.fn(),
unsubscribeToObjectUpdate: jest.fn()
}))
}));
jest.unstable_mockModule('../../actions/actionmanager.js', () => ({
ActionManager: jest.fn().mockImplementation(() => ({
subscribeToObjectActions: jest.fn(),
removeAllListeners: jest.fn()
}))
}));
jest.unstable_mockModule('../../events/eventmanager.js', () => ({
EventManager: jest.fn().mockImplementation(() => ({
sendObjectEvent: jest.fn(),
subscribeToObjectEvent: jest.fn(),
removeObjectEventsListener: jest.fn(),
removeAllListeners: jest.fn()
}))
}));
jest.unstable_mockModule('../../templates/templatemanager.js', () => ({
TemplateManager: jest.fn().mockImplementation(() => ({
renderPDF: jest.fn()
}))
}));
jest.unstable_mockModule('../../utils.js', () => ({
getModelByName: jest.fn(name => ({ modelName: name }))
}));
jest.unstable_mockModule('log4js', () => ({
default: {
getLogger: () => ({
level: 'info',
debug: jest.fn(),
error: jest.fn(),
warn: jest.fn(),
trace: jest.fn(),
info: jest.fn()
})
}
}));
const { SocketHost } = await import('../sockethost.js');
const { editObject, newObject, getObject, listObjects } = await import(
'../../database/database.js'
);
describe('SocketHost', () => {
let mockSocket;
let mockSocketManager;
let socketHost;
beforeEach(() => {
jest.clearAllMocks();
mockSocket = {
id: 'test-socket-id',
use: jest.fn(),
on: jest.fn(),
emit: jest.fn()
};
mockSocketManager = {};
socketHost = new SocketHost(mockSocket, mockSocketManager);
});
it('should initialize correctly and setup event handlers', () => {
expect(mockSocket.use).toHaveBeenCalled();
expect(mockSocket.on).toHaveBeenCalledWith(
'authenticate',
expect.any(Function)
);
expect(mockSocket.on).toHaveBeenCalledWith(
'disconnect',
expect.any(Function)
);
});
describe('handleAuthenticate', () => {
it('should authenticate with id and authCode', async () => {
const data = { id: 'host-1', authCode: 'code-123' };
const callback = jest.fn();
const mockHost = { _id: 'host-id-obj' };
socketHost.codeAuth.verifyCode.mockResolvedValue({
valid: true,
host: mockHost
});
editObject.mockResolvedValue({});
await socketHost.handleAuthenticate(data, callback);
expect(socketHost.codeAuth.verifyCode).toHaveBeenCalledWith(
'host-1',
'code-123'
);
expect(editObject).toHaveBeenCalled();
expect(callback).toHaveBeenCalledWith({ valid: true, host: mockHost });
expect(socketHost.authenticated).toBe(true);
});
it('should authenticate with otp', async () => {
const data = { otp: '123456' };
const callback = jest.fn();
const mockHost = { _id: 'host-id-obj' };
socketHost.codeAuth.verifyOtp.mockResolvedValue({
valid: true,
host: mockHost
});
editObject.mockResolvedValue({});
await socketHost.handleAuthenticate(data, callback);
expect(socketHost.codeAuth.verifyOtp).toHaveBeenCalledWith('123456');
expect(callback).toHaveBeenCalledWith({ valid: true, host: mockHost });
});
it('should return error if params are missing', async () => {
const data = {};
const callback = jest.fn();
await socketHost.handleAuthenticate(data, callback);
expect(callback).toHaveBeenCalledWith({
valid: false,
error: 'Missing params.'
});
});
});
describe('database operations handlers', () => {
beforeEach(() => {
socketHost.host = { _id: 'host-id' };
});
it('handleNewObject should call newObject and callback', async () => {
const data = { objectType: 'printer', newData: { name: 'P1' } };
const callback = jest.fn();
newObject.mockResolvedValue({ _id: 'new-id' });
await socketHost.handleNewObject(data, callback);
expect(newObject).toHaveBeenCalled();
expect(callback).toHaveBeenCalledWith({ _id: 'new-id' });
});
it('handleEditObject should call editObject and callback', async () => {
const data = {
objectType: 'printer',
_id: 'p1',
updateData: { status: 'idle' }
};
const callback = jest.fn();
editObject.mockResolvedValue({ success: true });
await socketHost.handleEditObject(data, callback);
expect(editObject).toHaveBeenCalled();
expect(callback).toHaveBeenCalledWith({ success: true });
});
});
describe('handleDisconnect', () => {
it('should set host offline if authenticated', async () => {
socketHost.authenticated = true;
socketHost.id = 'host-id';
socketHost.host = { _id: 'host-id' };
listObjects.mockResolvedValue([]); // for setDevicesState
await socketHost.handleDisconnect();
expect(editObject).toHaveBeenCalledWith(
expect.objectContaining({
id: 'host-id',
updateData: expect.objectContaining({ online: false })
})
);
expect(socketHost.actionManager.removeAllListeners).toHaveBeenCalled();
});
});
});

View File

@ -0,0 +1,136 @@
import { jest } from '@jest/globals';
jest.unstable_mockModule('socket.io', () => ({
Server: jest.fn(() => ({
on: jest.fn(),
emit: jest.fn(),
})),
}));
jest.unstable_mockModule('../socketuser.js', () => ({
SocketUser: jest.fn((socket, manager) => ({
id: socket.id,
socket,
})),
}));
jest.unstable_mockModule('../sockethost.js', () => ({
SocketHost: jest.fn((socket, manager) => ({
id: socket.id,
socket,
})),
}));
jest.unstable_mockModule('../../database/nats.js', () => ({
natsServer: {
publish: jest.fn().mockResolvedValue({ success: true }),
subscribe: jest.fn().mockResolvedValue({ success: true }),
removeSubscription: jest.fn().mockResolvedValue({ success: true }),
},
}));
jest.unstable_mockModule('../../database/redis.js', () => ({
redisServer: {
setKey: jest.fn().mockResolvedValue(undefined),
getKey: jest.fn().mockResolvedValue(null),
deleteKey: jest.fn().mockResolvedValue(undefined),
getKeysByPattern: jest.fn().mockResolvedValue([]),
connect: jest.fn().mockResolvedValue(undefined),
disconnect: jest.fn().mockResolvedValue(undefined),
},
}));
jest.unstable_mockModule('../../lock/lockmanager.js', () => ({
LockManager: jest.fn(),
}));
jest.unstable_mockModule('../../templates/templatemanager.js', () => ({
TemplateManager: jest.fn(),
}));
jest.unstable_mockModule('../../config.js', () => ({
loadConfig: jest.fn(() => ({
server: {
logLevel: 'info',
corsOrigins: '*',
},
})),
}));
jest.unstable_mockModule('log4js', () => ({
default: {
getLogger: () => ({
level: 'info',
debug: jest.fn(),
error: jest.fn(),
warn: jest.fn(),
trace: jest.fn(),
info: jest.fn(),
}),
},
}));
const { SocketManager } = await import('../socketmanager.js');
const { Server } = await import('socket.io');
describe('SocketManager', () => {
let mockServer;
let socketManager;
beforeEach(() => {
jest.clearAllMocks();
mockServer = {};
socketManager = new SocketManager(mockServer);
});
it('should initialize correctly', () => {
expect(Server).toHaveBeenCalledWith(mockServer, expect.any(Object));
expect(socketManager.io.on).toHaveBeenCalledWith('connection', expect.any(Function));
});
describe('connection handling', () => {
it('should add a user when auth type is user', async () => {
const mockSocket = {
id: 'user-socket',
handshake: { auth: { type: 'user' } },
on: jest.fn(),
};
const connectionHandler = socketManager.io.on.mock.calls[0][1];
await connectionHandler(mockSocket);
expect(socketManager.socketUsers.has('user-socket')).toBe(true);
});
it('should add a host when auth type is host', async () => {
const mockSocket = {
id: 'host-socket',
handshake: { auth: { type: 'host' } },
on: jest.fn(),
};
const connectionHandler = socketManager.io.on.mock.calls[0][1];
await connectionHandler(mockSocket);
expect(socketManager.socketHosts.has('host-socket')).toBe(true);
});
});
describe('sendToUser', () => {
it('should emit event to all matching user connections', () => {
const mockUserSocket = {
id: 's1',
emit: jest.fn(),
};
socketManager.socketUsers.set('s1', {
user: { _id: 'u1' },
socket: mockUserSocket,
});
socketManager.sendToUser('u1', 'testEvent', { data: 'ok' });
expect(mockUserSocket.emit).toHaveBeenCalledWith('testEvent', { data: 'ok' });
});
});
});

View File

@ -0,0 +1,187 @@
import { jest } from '@jest/globals';
// Mock dependencies
jest.unstable_mockModule('../../config.js', () => ({
loadConfig: jest.fn(() => ({
server: { logLevel: 'info' }
}))
}));
jest.unstable_mockModule('../../auth/auth.js', () => ({
KeycloakAuth: jest.fn().mockImplementation(() => ({
verifyToken: jest.fn()
})),
createAuthMiddleware: jest.fn(() => (socket, next) => next())
}));
jest.unstable_mockModule('../../utils.js', () => ({
generateHostOTP: jest.fn()
}));
jest.unstable_mockModule('../../lock/lockmanager.js', () => ({
LockManager: jest.fn().mockImplementation(() => ({
lockObject: jest.fn(),
unlockObject: jest.fn(),
getObjectLock: jest.fn()
}))
}));
jest.unstable_mockModule('../../updates/updatemanager.js', () => ({
UpdateManager: jest.fn().mockImplementation(() => ({
subscribeToObjectNew: jest.fn(),
subscribeToObjectDelete: jest.fn(),
subscribeToObjectUpdate: jest.fn(),
removeObjectNewListener: jest.fn(),
removeObjectDeleteListener: jest.fn(),
removeObjectUpdateListener: jest.fn()
}))
}));
jest.unstable_mockModule('../../actions/actionmanager.js', () => ({
ActionManager: jest.fn().mockImplementation(() => ({
sendObjectAction: jest.fn(),
removeAllListeners: jest.fn()
}))
}));
jest.unstable_mockModule('../../events/eventmanager.js', () => ({
EventManager: jest.fn().mockImplementation(() => ({
subscribeToObjectEvent: jest.fn(),
removeObjectEventsListener: jest.fn(),
removeAllListeners: jest.fn()
}))
}));
jest.unstable_mockModule('../../stats/statsmanager.js', () => ({
StatsManager: jest.fn().mockImplementation(() => ({
subscribeToStats: jest.fn(),
removeStatsListener: jest.fn(),
removeAllListeners: jest.fn()
}))
}));
jest.unstable_mockModule('log4js', () => ({
default: {
getLogger: () => ({
level: 'info',
debug: jest.fn(),
error: jest.fn(),
warn: jest.fn(),
trace: jest.fn(),
info: jest.fn()
})
}
}));
const { SocketUser } = await import('../socketuser.js');
const { generateHostOTP } = await import('../../utils.js');
describe('SocketUser', () => {
let mockSocket;
let mockSocketManager;
let socketUser;
beforeEach(() => {
jest.clearAllMocks();
mockSocket = {
id: 'test-user-socket-id',
use: jest.fn(),
on: jest.fn(),
emit: jest.fn()
};
mockSocketManager = {
templateManager: {
renderTemplate: jest.fn(),
renderPDF: jest.fn()
}
};
socketUser = new SocketUser(mockSocket, mockSocketManager);
});
it('should initialize correctly and setup event handlers', () => {
expect(mockSocket.use).toHaveBeenCalled();
expect(mockSocket.on).toHaveBeenCalledWith('authenticate', expect.any(Function));
expect(mockSocket.on).toHaveBeenCalledWith('lock', expect.any(Function));
expect(mockSocket.on).toHaveBeenCalledWith('disconnect', expect.any(Function));
});
describe('handleAuthenticateEvent', () => {
it('should authenticate user with valid token', async () => {
const data = { token: 'valid-token' };
const callback = jest.fn();
const mockUser = { _id: 'user-id-obj', username: 'testuser' };
socketUser.keycloakAuth.verifyToken.mockResolvedValue({ valid: true, user: mockUser });
await socketUser.handleAuthenticateEvent(data, callback);
expect(socketUser.keycloakAuth.verifyToken).toHaveBeenCalledWith('valid-token');
expect(socketUser.authenticated).toBe(true);
expect(socketUser.user).toEqual(mockUser);
expect(socketUser.id).toBe('user-id-obj');
expect(callback).toHaveBeenCalledWith({ valid: true, user: mockUser });
});
it('should not authenticate user with invalid token', async () => {
const data = { token: 'invalid-token' };
const callback = jest.fn();
socketUser.keycloakAuth.verifyToken.mockResolvedValue({ valid: false });
await socketUser.handleAuthenticateEvent(data, callback);
expect(socketUser.authenticated).toBe(false);
expect(callback).toHaveBeenCalledWith({ valid: false });
});
});
describe('lock event handlers', () => {
beforeEach(() => {
socketUser.user = { _id: 'user-id' };
});
it('handleLockEvent should call lockManager.lockObject', async () => {
const data = { _id: 'obj-1', type: 'printer' };
await socketUser.handleLockEvent(data);
expect(socketUser.lockManager.lockObject).toHaveBeenCalledWith({
...data,
user: 'user-id'
});
});
it('handleUnlockEvent should call lockManager.unlockObject', async () => {
const data = { _id: 'obj-1' };
await socketUser.handleUnlockEvent(data);
expect(socketUser.lockManager.unlockObject).toHaveBeenCalledWith({
...data,
user: 'user-id'
});
});
});
describe('handleGenerateHostOtpEvent', () => {
it('should call generateHostOTP and callback', async () => {
const data = { _id: 'host-id' };
const callback = jest.fn();
generateHostOTP.mockResolvedValue('otp-123');
await socketUser.handleGenerateHostOtpEvent(data, callback);
expect(generateHostOTP).toHaveBeenCalledWith('host-id');
expect(callback).toHaveBeenCalledWith('otp-123');
});
});
describe('handleDisconnect', () => {
it('should remove all listeners', async () => {
await socketUser.handleDisconnect();
expect(socketUser.actionManager.removeAllListeners).toHaveBeenCalled();
expect(socketUser.eventManager.removeAllListeners).toHaveBeenCalled();
expect(socketUser.statsManager.removeAllListeners).toHaveBeenCalled();
});
});
});

View File

@ -0,0 +1,123 @@
import { jest } from '@jest/globals';
jest.unstable_mockModule('../../database/nats.js', () => ({
natsServer: {
publish: jest.fn().mockResolvedValue({ success: true }),
subscribe: jest.fn().mockResolvedValue({ success: true }),
removeSubscription: jest.fn().mockResolvedValue({ success: true })
}
}));
jest.unstable_mockModule('log4js', () => ({
default: {
getLogger: () => ({
level: 'info',
debug: jest.fn(),
error: jest.fn(),
warn: jest.fn(),
trace: jest.fn(),
info: jest.fn()
})
}
}));
jest.unstable_mockModule('../../config.js', () => ({
loadConfig: jest.fn(() => ({
server: {
logLevel: 'info'
}
}))
}));
const { StatsManager } = await import('../statsmanager.js');
const { natsServer } = await import('../../database/nats.js');
describe('StatsManager', () => {
let mockSocketClient;
let statsManager;
beforeEach(() => {
jest.clearAllMocks();
mockSocketClient = {
socketId: 'test-socket-id',
socket: {
emit: jest.fn()
}
};
statsManager = new StatsManager(mockSocketClient);
});
describe('subscribeToStats', () => {
it('should subscribe to stats updates and emit when received', async () => {
const type = 'printer';
await statsManager.subscribeToStats(type);
expect(natsServer.subscribe).toHaveBeenCalledWith(
'printers.stats',
'test-socket-id',
expect.any(Function)
);
// Simulate NATS message
const natsCallback = natsServer.subscribe.mock.calls[0][2];
const statsData = { count: 5, active: 2 };
natsCallback('printers.stats', statsData);
expect(mockSocketClient.socket.emit).toHaveBeenCalledWith('modelStats', {
objectType: type,
stats: statsData
});
});
});
describe('removeStatsListener', () => {
it('should remove stats subscription', async () => {
const type = 'printer';
await statsManager.subscribeToStats(type);
await statsManager.removeStatsListener(type);
expect(natsServer.removeSubscription).toHaveBeenCalledWith(
'printers.stats',
'test-socket-id'
);
expect(statsManager.subscriptions.size).toBe(0);
});
});
describe('sendStats', () => {
it('should publish stats to NATS', async () => {
const type = 'printer';
const stats = { total: 10 };
const result = await statsManager.sendStats(type, stats);
expect(result).toEqual({ success: true });
expect(natsServer.publish).toHaveBeenCalledWith('printers.stats', stats);
});
it('should handle errors when publishing fails', async () => {
natsServer.publish.mockRejectedValueOnce(new Error('NATS error'));
const result = await statsManager.sendStats('type', {});
expect(result).toHaveProperty('error', 'NATS error');
});
});
describe('removeAllListeners', () => {
it('should remove all subscriptions', async () => {
await statsManager.subscribeToStats('type1');
await statsManager.subscribeToStats('type2');
await statsManager.removeAllListeners();
expect(natsServer.removeSubscription).toHaveBeenCalledTimes(2);
expect(statsManager.subscriptions.size).toBe(0);
});
});
});

View File

@ -0,0 +1,184 @@
import { jest } from '@jest/globals';
import path from 'path';
// Mock fs before importing TemplateManager
jest.unstable_mockModule('fs', () => ({
default: {
readFileSync: jest.fn(filePath => {
if (filePath.endsWith('basetemplate.ejs'))
return '<html><%- content %></html>';
if (filePath.endsWith('styles.css')) return 'body { color: red; }';
if (filePath.endsWith('previewtemplate.ejs'))
return '<div class="preview"><%- content %></div>';
if (filePath.endsWith('rendertemplate.ejs'))
return '<div class="render"><%- content %></div>';
if (filePath.endsWith('contentplaceholder.ejs'))
return '<div class="placeholder"></div>';
if (filePath.endsWith('previewpagination.js'))
return 'console.log("pagination");';
return '';
})
}
}));
jest.unstable_mockModule('ejs', () => ({
default: {
render: jest.fn(async (content, data) => `rendered: ${content}`),
compile: jest.fn(() => jest.fn())
}
}));
jest.unstable_mockModule('posthtml', () => ({
default: jest.fn(() => ({
process: jest.fn(async content => ({ html: `transformed: ${content}` }))
}))
}));
jest.unstable_mockModule('../../database/nats.js', () => ({
natsServer: {
publish: jest.fn().mockResolvedValue({ success: true }),
subscribe: jest.fn().mockResolvedValue({ success: true }),
removeSubscription: jest.fn().mockResolvedValue({ success: true })
}
}));
jest.unstable_mockModule('../../database/redis.js', () => ({
redisServer: {
setKey: jest.fn().mockResolvedValue(undefined),
getKey: jest.fn().mockResolvedValue(null),
deleteKey: jest.fn().mockResolvedValue(undefined),
getKeysByPattern: jest.fn().mockResolvedValue([])
}
}));
jest.unstable_mockModule('../../database/database.js', () => ({
getObject: jest.fn(),
listObjects: jest.fn()
}));
jest.unstable_mockModule(
'../../database/schemas/management/documenttemplate.schema.js',
() => ({
documentTemplateModel: { modelName: 'DocumentTemplate' }
})
);
jest.unstable_mockModule('../../utils.js', () => ({
getModelByName: jest.fn(() => ({
schema: {
obj: { name: {}, status: {} }
}
}))
}));
jest.unstable_mockModule('../pdffactory.js', () => ({
generatePDF: jest.fn().mockResolvedValue(Buffer.from('pdf-data'))
}));
jest.unstable_mockModule('log4js', () => ({
default: {
getLogger: () => ({
level: 'info',
debug: jest.fn(),
error: jest.fn(),
warn: jest.fn(),
trace: jest.fn(),
info: jest.fn()
})
}
}));
jest.unstable_mockModule('../../config.js', () => ({
loadConfig: jest.fn(() => ({
server: {
logLevel: 'info'
}
}))
}));
const { TemplateManager } = await import('../templatemanager.js');
const { getObject } = await import('../../database/database.js');
const ejs = (await import('ejs')).default;
const { generatePDF } = await import('../pdffactory.js');
describe('TemplateManager', () => {
let templateManager;
beforeEach(() => {
jest.clearAllMocks();
templateManager = new TemplateManager();
});
describe('renderTemplate', () => {
it('should render a template successfully', async () => {
const mockTemplate = {
documentSize: { width: 100, height: 100, infiniteHeight: false },
global: false,
objectType: 'printer'
};
getObject.mockResolvedValue(mockTemplate);
const result = await templateManager.renderTemplate(
'temp-id',
'some content',
{ name: 'Test' }
);
expect(getObject).toHaveBeenCalledWith(
expect.objectContaining({ id: 'temp-id' })
);
expect(ejs.render).toHaveBeenCalled();
expect(result).toHaveProperty('html');
expect(result.width).toBe(100);
expect(result.height).toBe(100);
});
it('should return error if template not found', async () => {
getObject.mockResolvedValue(null);
const result = await templateManager.renderTemplate('invalid', 'content');
expect(result).toEqual({ error: 'Document template not found.' });
});
});
describe('validateTemplate', () => {
it('should return true for valid EJS', () => {
expect(templateManager.validateTemplate('<div><%= name %></div>')).toBe(
true
);
});
it('should return false for invalid EJS', () => {
ejs.compile.mockImplementationOnce(() => {
throw new Error('syntax error');
});
expect(templateManager.validateTemplate('<% invalid %>')).toBe(false);
});
});
describe('renderPDF', () => {
it('should render a PDF successfully', async () => {
const mockTemplate = {
documentSize: { width: 100, height: 100, infiniteHeight: false },
global: false,
objectType: 'printer'
};
getObject.mockResolvedValue(mockTemplate);
const result = await templateManager.renderPDF('temp-id', 'content');
expect(generatePDF).toHaveBeenCalled();
expect(result.pdf).toBeDefined();
});
});
describe('formatDate', () => {
it('should format date correctly', () => {
const date = new Date('2023-01-01T12:00:00Z');
const formatted = templateManager.formatDate(date, 'YYYY-MM-DD');
expect(formatted).toBe('2023-01-01');
});
});
});

View File

@ -0,0 +1,146 @@
import { jest } from '@jest/globals';
jest.unstable_mockModule('../../database/nats.js', () => ({
natsServer: {
subscribe: jest.fn().mockResolvedValue({ success: true }),
removeSubscription: jest.fn().mockResolvedValue({ success: true })
}
}));
jest.unstable_mockModule('log4js', () => ({
default: {
getLogger: () => ({
level: 'info',
debug: jest.fn(),
error: jest.fn(),
warn: jest.fn(),
trace: jest.fn(),
info: jest.fn()
})
}
}));
jest.unstable_mockModule('../../config.js', () => ({
loadConfig: jest.fn(() => ({
server: {
logLevel: 'info'
}
}))
}));
const { UpdateManager } = await import('../updatemanager.js');
const { natsServer } = await import('../../database/nats.js');
describe('UpdateManager', () => {
let mockSocketClient;
let updateManager;
beforeEach(() => {
jest.clearAllMocks();
mockSocketClient = {
socketId: 'test-socket-id',
socket: {
emit: jest.fn()
}
};
updateManager = new UpdateManager(mockSocketClient);
});
describe('subscribeToObjectNew', () => {
it('should subscribe to new object events and emit', async () => {
await updateManager.subscribeToObjectNew('printer');
expect(natsServer.subscribe).toHaveBeenCalledWith(
'printers.new',
'test-socket-id',
expect.any(Function)
);
const natsCallback = natsServer.subscribe.mock.calls[0][2];
const data = { name: 'New Printer' };
natsCallback('printers.new', data);
expect(mockSocketClient.socket.emit).toHaveBeenCalledWith('objectNew', {
object: data,
objectType: 'printer'
});
});
});
describe('subscribeToObjectDelete', () => {
it('should subscribe to delete events and emit', async () => {
await updateManager.subscribeToObjectDelete('printer');
expect(natsServer.subscribe).toHaveBeenCalledWith(
'printers.delete',
'test-socket-id',
expect.any(Function)
);
const natsCallback = natsServer.subscribe.mock.calls[0][2];
const data = { _id: '123' };
natsCallback('printers.delete', data);
expect(mockSocketClient.socket.emit).toHaveBeenCalledWith(
'objectDelete',
{
object: data,
objectType: 'printer'
}
);
});
});
describe('subscribeToObjectUpdate', () => {
it('should subscribe to update events for specific object', async () => {
await updateManager.subscribeToObjectUpdate('123', 'printer');
expect(natsServer.subscribe).toHaveBeenCalledWith(
'printers.123.object',
'test-socket-id',
expect.any(Function)
);
const natsCallback = natsServer.subscribe.mock.calls[0][2];
const data = { status: 'idle' };
natsCallback('printers.123.object', data);
expect(mockSocketClient.socket.emit).toHaveBeenCalledWith(
'objectUpdate',
{
_id: '123',
objectType: 'printer',
object: data
}
);
});
});
describe('remove methods', () => {
it('should remove new listener', async () => {
await updateManager.removeObjectNewListener('printer');
expect(natsServer.removeSubscription).toHaveBeenCalledWith(
'printers.new',
'test-socket-id'
);
});
it('should remove delete listener', async () => {
await updateManager.removeObjectDeleteListener('printer');
expect(natsServer.removeSubscription).toHaveBeenCalledWith(
'printers.delete',
'test-socket-id'
);
});
it('should remove update listener', async () => {
await updateManager.removeObjectUpdateListener('123', 'printer');
expect(natsServer.removeSubscription).toHaveBeenCalledWith(
'printers.123.object',
'test-socket-id'
);
});
});
});

2021
yarn.lock

File diff suppressed because it is too large Load Diff