Add ActivityManager and integrate activity tracking functionality
All checks were successful
farmcontrol/farmcontrol-ws/pipeline/head This commit looks good
All checks were successful
farmcontrol/farmcontrol-ws/pipeline/head This commit looks good
- Introduced the ActivityManager class to handle object activity tracking, including viewing and editing events. - Implemented methods for setting, clearing, and retrieving activities, as well as subscribing to activity updates via websockets. - Replaced the LockManager with ActivityManager in SocketUser and SocketManager classes to streamline activity management. - Added comprehensive tests for ActivityManager to ensure correct functionality and error handling. - Removed LockManager and its associated tests to eliminate redundancy in functionality.
This commit is contained in:
parent
e80022513f
commit
49226290b6
258
src/activity/__tests__/activitymanager.test.js
Normal file
258
src/activity/__tests__/activitymanager.test.js
Normal file
@ -0,0 +1,258 @@
|
||||
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('../../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().mockResolvedValue({
|
||||
_id: 'user-123',
|
||||
username: 'testuser',
|
||||
name: 'Test User',
|
||||
profileImage: { _id: 'file-123' }
|
||||
})
|
||||
}));
|
||||
|
||||
jest.unstable_mockModule('../../database/utils.js', () => ({
|
||||
expandObjectIds: jest.fn(value => value)
|
||||
}));
|
||||
|
||||
jest.unstable_mockModule('../../database/schemas/management/user.schema.js', () => ({
|
||||
userModel: {}
|
||||
}));
|
||||
|
||||
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 { ActivityManager } = await import('../activitymanager.js');
|
||||
const { natsServer } = await import('../../database/nats.js');
|
||||
const { redisServer } = await import('../../database/redis.js');
|
||||
|
||||
describe('ActivityManager', () => {
|
||||
let mockSocketClient;
|
||||
let activityManager;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
|
||||
mockSocketClient = {
|
||||
socketId: 'test-socket-id',
|
||||
socket: {
|
||||
emit: jest.fn()
|
||||
}
|
||||
};
|
||||
|
||||
activityManager = new ActivityManager(mockSocketClient);
|
||||
});
|
||||
|
||||
describe('setActivity', () => {
|
||||
it('should set activity in Redis and publish via NATS', async () => {
|
||||
const testObject = {
|
||||
_id: 'test-id-123',
|
||||
type: 'printer',
|
||||
user: 'user-123',
|
||||
mode: 'viewing'
|
||||
};
|
||||
|
||||
const result = await activityManager.setActivity(testObject);
|
||||
|
||||
expect(result).toEqual(
|
||||
expect.objectContaining({
|
||||
success: true,
|
||||
activity: expect.objectContaining({
|
||||
_id: 'test-id-123',
|
||||
type: 'printer',
|
||||
mode: 'viewing'
|
||||
})
|
||||
})
|
||||
);
|
||||
expect(redisServer.setKey).toHaveBeenCalledWith(
|
||||
'activity:printers:test-id-123:user-123',
|
||||
expect.objectContaining({
|
||||
_id: 'test-id-123',
|
||||
type: 'printer',
|
||||
user: 'user-123',
|
||||
mode: 'viewing'
|
||||
})
|
||||
);
|
||||
expect(natsServer.publish).toHaveBeenCalledWith(
|
||||
'activity.printers.test-id-123',
|
||||
expect.objectContaining({
|
||||
mode: 'viewing'
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('should reject editing when another user is already editing', async () => {
|
||||
redisServer.getKeysByPattern.mockResolvedValueOnce([
|
||||
'activity:printers:test-id-123:user-456'
|
||||
]);
|
||||
redisServer.getKey.mockResolvedValueOnce({
|
||||
_id: 'test-id-123',
|
||||
type: 'printer',
|
||||
user: 'user-456',
|
||||
mode: 'editing'
|
||||
});
|
||||
|
||||
const result = await activityManager.setActivity({
|
||||
_id: 'test-id-123',
|
||||
type: 'printer',
|
||||
user: 'user-123',
|
||||
mode: 'editing'
|
||||
});
|
||||
|
||||
expect(result).toEqual(
|
||||
expect.objectContaining({
|
||||
success: false,
|
||||
conflict: true
|
||||
})
|
||||
);
|
||||
expect(redisServer.setKey).not.toHaveBeenCalled();
|
||||
expect(natsServer.publish).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('clearActivity', () => {
|
||||
it('should clear activity from Redis and publish via NATS', async () => {
|
||||
const testObject = {
|
||||
_id: 'test-id-123',
|
||||
type: 'printer',
|
||||
user: 'user-123'
|
||||
};
|
||||
|
||||
const result = await activityManager.clearActivity(testObject);
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(redisServer.deleteKey).toHaveBeenCalledWith(
|
||||
'activity:printers:test-id-123:user-123'
|
||||
);
|
||||
expect(natsServer.publish).toHaveBeenCalledWith(
|
||||
'activity.printers.test-id-123',
|
||||
expect.objectContaining({
|
||||
_id: 'test-id-123',
|
||||
type: 'printer',
|
||||
user: 'user-123',
|
||||
mode: null
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getObjectActivities', () => {
|
||||
it('should return hydrated activities for an object', async () => {
|
||||
redisServer.getKeysByPattern.mockResolvedValueOnce([
|
||||
'activity:printers:test-id-123:user-123'
|
||||
]);
|
||||
redisServer.getKey.mockResolvedValueOnce({
|
||||
_id: 'test-id-123',
|
||||
type: 'printer',
|
||||
user: 'user-123',
|
||||
mode: 'editing'
|
||||
});
|
||||
|
||||
const result = await activityManager.getObjectActivities({
|
||||
_id: 'test-id-123',
|
||||
type: 'printer'
|
||||
});
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]).toEqual(
|
||||
expect.objectContaining({
|
||||
mode: 'editing'
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('subscribeToObjectActivity', () => {
|
||||
it('should subscribe to NATS activity updates', async () => {
|
||||
const result = await activityManager.subscribeToObjectActivity(
|
||||
'test-id-123',
|
||||
'printer'
|
||||
);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(natsServer.subscribe).toHaveBeenCalledWith(
|
||||
'activity.printers.test-id-123',
|
||||
'test-socket-id',
|
||||
expect.any(Function)
|
||||
);
|
||||
});
|
||||
|
||||
it('should not create duplicate subscriptions for the same object', async () => {
|
||||
await activityManager.subscribeToObjectActivity('test-id-123', 'printer');
|
||||
await activityManager.subscribeToObjectActivity('test-id-123', 'printer');
|
||||
|
||||
expect(natsServer.subscribe).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should emit activity updates with the full activities list', async () => {
|
||||
let natsCallback;
|
||||
natsServer.subscribe.mockImplementation(async (_subject, _owner, callback) => {
|
||||
natsCallback = callback;
|
||||
return { success: true };
|
||||
});
|
||||
redisServer.getKeysByPattern.mockResolvedValue([
|
||||
'activity:printers:test-id-123:user-123'
|
||||
]);
|
||||
redisServer.getKey.mockResolvedValue({
|
||||
_id: 'test-id-123',
|
||||
type: 'printer',
|
||||
user: 'user-123',
|
||||
mode: 'editing'
|
||||
});
|
||||
|
||||
await activityManager.subscribeToObjectActivity('test-id-123', 'printer');
|
||||
await natsCallback('activity.printers.test-id-123', {
|
||||
_id: 'test-id-123',
|
||||
type: 'printer',
|
||||
user: 'user-123',
|
||||
mode: 'editing'
|
||||
});
|
||||
|
||||
expect(mockSocketClient.socket.emit).toHaveBeenCalledWith(
|
||||
'activityUpdate',
|
||||
expect.objectContaining({
|
||||
_id: 'test-id-123',
|
||||
objectType: 'printer',
|
||||
activities: [
|
||||
expect.objectContaining({
|
||||
mode: 'editing'
|
||||
})
|
||||
]
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
262
src/activity/activitymanager.js
Normal file
262
src/activity/activitymanager.js
Normal file
@ -0,0 +1,262 @@
|
||||
import log4js from 'log4js';
|
||||
import { loadConfig } from '../config.js';
|
||||
import { natsServer } from '../database/nats.js';
|
||||
import { redisServer } from '../database/redis.js';
|
||||
import { getObject } from '../database/database.js';
|
||||
import { expandObjectIds } from '../database/utils.js';
|
||||
import { userModel } from '../database/schemas/management/user.schema.js';
|
||||
|
||||
const config = loadConfig();
|
||||
|
||||
const logger = log4js.getLogger('Activity Manager');
|
||||
logger.level = config.server.logLevel;
|
||||
|
||||
const getSubscriptionKey = (subject, owner) => `${subject}:${owner}`;
|
||||
|
||||
const normalizeId = value => {
|
||||
if (value == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return typeof value === 'string' ? value : value.toString();
|
||||
};
|
||||
|
||||
const normalizeType = type => {
|
||||
if (!type) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return typeof type === 'string' ? type : type.toString();
|
||||
};
|
||||
|
||||
const getActivityRedisKey = (type, id, userId) =>
|
||||
`activity:${normalizeType(type)}s:${normalizeId(id)}:${normalizeId(userId)}`;
|
||||
|
||||
const getActivityPattern = (type, id) =>
|
||||
`activity:${normalizeType(type)}s:${normalizeId(id)}:*`;
|
||||
|
||||
const getActivitySubject = (type, id) =>
|
||||
`activity.${normalizeType(type)}s.${normalizeId(id)}`;
|
||||
|
||||
async function hydrateActivity(activity) {
|
||||
const expanded = expandObjectIds({ ...activity });
|
||||
const userId = expanded.user?._id || expanded.user;
|
||||
|
||||
if (userId) {
|
||||
const user = await getObject({
|
||||
model: userModel,
|
||||
id: userId.toString(),
|
||||
populate: ['profileImage'],
|
||||
cached: true
|
||||
});
|
||||
|
||||
if (user) {
|
||||
expanded.user = expandObjectIds(user);
|
||||
}
|
||||
}
|
||||
|
||||
return expanded;
|
||||
}
|
||||
|
||||
/**
|
||||
* ActivityManager tracks object viewing/editing activity and broadcasts events via websockets.
|
||||
*/
|
||||
export class ActivityManager {
|
||||
constructor(socketClient) {
|
||||
this.socketClient = socketClient;
|
||||
this.subscriptions = new Set();
|
||||
this.objectSubscriptions = new Map();
|
||||
}
|
||||
|
||||
emitActivityUpdate(id, objectType, activity, activities) {
|
||||
this.socketClient.socket.emit('activityUpdate', {
|
||||
_id: normalizeId(id),
|
||||
objectType: normalizeType(objectType),
|
||||
activity,
|
||||
activities
|
||||
});
|
||||
}
|
||||
|
||||
async getObjectActivities(object) {
|
||||
const objectId = normalizeId(object?._id);
|
||||
const objectType = normalizeType(object?.type);
|
||||
|
||||
logger.debug('Getting activities for object:', objectId);
|
||||
try {
|
||||
const pattern = getActivityPattern(objectType, objectId);
|
||||
const keys = await redisServer.getKeysByPattern(pattern);
|
||||
const activities = [];
|
||||
|
||||
for (const key of keys) {
|
||||
const storedActivity = await redisServer.getKey(key);
|
||||
if (storedActivity?.mode) {
|
||||
activities.push(await hydrateActivity(storedActivity));
|
||||
}
|
||||
}
|
||||
|
||||
return activities;
|
||||
} catch (err) {
|
||||
logger.error(
|
||||
`Error getting activities for object ${objectId}:`,
|
||||
err
|
||||
);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
async setActivity(object) {
|
||||
const objectId = normalizeId(object?._id);
|
||||
const objectType = normalizeType(object?.type);
|
||||
const userId = normalizeId(object?.user);
|
||||
|
||||
logger.debug('Setting activity:', objectId, object.mode);
|
||||
try {
|
||||
if (object.mode === 'editing') {
|
||||
const pattern = getActivityPattern(objectType, objectId);
|
||||
const keys = await redisServer.getKeysByPattern(pattern);
|
||||
|
||||
for (const key of keys) {
|
||||
const storedActivity = await redisServer.getKey(key);
|
||||
const activityUserId = normalizeId(storedActivity?.user);
|
||||
|
||||
if (
|
||||
storedActivity?.mode === 'editing' &&
|
||||
activityUserId &&
|
||||
activityUserId !== userId
|
||||
) {
|
||||
logger.info(
|
||||
`Rejected editing activity for user ${userId} on object ${objectId}; already being edited by ${activityUserId}`
|
||||
);
|
||||
return {
|
||||
success: false,
|
||||
conflict: true,
|
||||
activity: await hydrateActivity(storedActivity)
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const redisKey = getActivityRedisKey(objectType, objectId, userId);
|
||||
const activityPayload = {
|
||||
_id: objectId,
|
||||
type: objectType,
|
||||
user: userId,
|
||||
mode: object.mode
|
||||
};
|
||||
|
||||
await redisServer.setKey(redisKey, activityPayload);
|
||||
|
||||
const hydratedPayload = await hydrateActivity(activityPayload);
|
||||
const subject = getActivitySubject(objectType, objectId);
|
||||
await natsServer.publish(subject, hydratedPayload);
|
||||
logger.info(`Activity event published for id: ${objectId}`);
|
||||
return {
|
||||
success: true,
|
||||
activity: hydratedPayload
|
||||
};
|
||||
} catch (err) {
|
||||
logger.error(`Error setting activity for object ${objectId}:`, err);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
async clearActivity(object) {
|
||||
const objectId = normalizeId(object?._id);
|
||||
const objectType = normalizeType(object?.type);
|
||||
const userId = normalizeId(object?.user);
|
||||
const redisKey = getActivityRedisKey(objectType, objectId, userId);
|
||||
|
||||
try {
|
||||
logger.debug('Clearing activity:', objectId);
|
||||
await redisServer.deleteKey(redisKey);
|
||||
|
||||
const clearedPayload = {
|
||||
_id: objectId,
|
||||
type: objectType,
|
||||
user: userId,
|
||||
mode: null
|
||||
};
|
||||
const subject = getActivitySubject(objectType, objectId);
|
||||
await natsServer.publish(subject, clearedPayload);
|
||||
logger.info(`Cleared activity and published event: ${objectId}`);
|
||||
return true;
|
||||
} catch (err) {
|
||||
logger.error(`Error clearing activity for object ${objectId}:`, err);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
async subscribeToObjectActivity(id, objectType) {
|
||||
const objectId = normalizeId(id);
|
||||
const normalizedType = normalizeType(objectType);
|
||||
const subject = getActivitySubject(normalizedType, objectId);
|
||||
const owner = this.socketClient.socketId;
|
||||
const subscriptionKey = getSubscriptionKey(subject, owner);
|
||||
const objectKey = `${normalizedType}:${objectId}`;
|
||||
|
||||
logger.debug('Subscribing to object activity...', objectId, normalizedType);
|
||||
|
||||
if (!this.subscriptions.has(subscriptionKey)) {
|
||||
await natsServer.subscribe(subject, owner, async (_subject, value) => {
|
||||
const expandedValue = expandObjectIds(value);
|
||||
const hydratedValue = value?.mode
|
||||
? await hydrateActivity(expandedValue)
|
||||
: expandedValue;
|
||||
const activities = await this.getObjectActivities({
|
||||
_id: objectId,
|
||||
type: normalizedType
|
||||
});
|
||||
|
||||
logger.trace('Activity update event:', objectId);
|
||||
this.emitActivityUpdate(
|
||||
objectId,
|
||||
normalizedType,
|
||||
hydratedValue,
|
||||
activities
|
||||
);
|
||||
});
|
||||
|
||||
this.subscriptions.add(subscriptionKey);
|
||||
this.objectSubscriptions.set(objectKey, subscriptionKey);
|
||||
}
|
||||
|
||||
const activities = await this.getObjectActivities({
|
||||
_id: objectId,
|
||||
type: normalizedType
|
||||
});
|
||||
|
||||
return { success: true, activities };
|
||||
}
|
||||
|
||||
async removeObjectActivityListener(id, objectType) {
|
||||
const objectId = normalizeId(id);
|
||||
const normalizedType = normalizeType(objectType);
|
||||
const subject = getActivitySubject(normalizedType, objectId);
|
||||
const owner = this.socketClient.socketId;
|
||||
const subscriptionKey = getSubscriptionKey(subject, owner);
|
||||
const objectKey = `${normalizedType}:${objectId}`;
|
||||
|
||||
await natsServer.removeSubscription(subject, owner);
|
||||
this.subscriptions.delete(subscriptionKey);
|
||||
this.objectSubscriptions.delete(objectKey);
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
async removeAllListeners() {
|
||||
logger.debug('Removing all activity listeners...');
|
||||
const removePromises = Array.from(this.subscriptions).map(
|
||||
subscriptionKey => {
|
||||
const separatorIndex = subscriptionKey.indexOf(':');
|
||||
const subject = subscriptionKey.slice(0, separatorIndex);
|
||||
const owner = subscriptionKey.slice(separatorIndex + 1);
|
||||
return natsServer.removeSubscription(subject, owner);
|
||||
}
|
||||
);
|
||||
|
||||
await Promise.all(removePromises);
|
||||
this.subscriptions.clear();
|
||||
this.objectSubscriptions.clear();
|
||||
logger.debug(`Removed ${removePromises.length} activity listener(s)`);
|
||||
return { success: true };
|
||||
}
|
||||
}
|
||||
@ -1,226 +0,0 @@
|
||||
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)
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@ -1,117 +0,0 @@
|
||||
import { natsServer } from '../database/nats.js';
|
||||
import { redisServer } from '../database/redis.js';
|
||||
import log4js from 'log4js';
|
||||
import { loadConfig } from '../config.js';
|
||||
const config = loadConfig();
|
||||
|
||||
// Setup logger
|
||||
const logger = log4js.getLogger('Lock Manager');
|
||||
logger.level = config.server.logLevel;
|
||||
|
||||
/**
|
||||
* LockManager handles distributed locking and broadcasts lock events via websockets.
|
||||
*/
|
||||
export class LockManager {
|
||||
constructor(socketClient) {
|
||||
this.socketClient = socketClient;
|
||||
this.setupLocksListeners();
|
||||
}
|
||||
|
||||
async lockObject(object) {
|
||||
// Persist lock in Redis and publish via NATS
|
||||
logger.debug('Locking object:', object._id);
|
||||
try {
|
||||
const redisKey = `locks:${object.type}s:${object._id}`;
|
||||
const lockPayload = {
|
||||
...object,
|
||||
locked: true
|
||||
};
|
||||
await redisServer.setKey(redisKey, lockPayload);
|
||||
const subject = `locks.${object.type}s.${object._id}`;
|
||||
await natsServer.publish(subject, lockPayload);
|
||||
logger.info(`Lock event published for id: ${object._id}`);
|
||||
return true;
|
||||
} catch (err) {
|
||||
logger.error(`Error locking object ${object._id}:`, err);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
async unlockObject(object) {
|
||||
// Remove lock from Redis (if owned by user) and publish via NATS
|
||||
const redisKey = `locks:${object.type}s:${object._id}`;
|
||||
try {
|
||||
logger.debug('Checking user can unlock:', object._id);
|
||||
|
||||
const lockEvent = await redisServer.getKey(redisKey);
|
||||
|
||||
if (lockEvent?.user === object.user) {
|
||||
logger.debug('Unlocking object:', object._id);
|
||||
await redisServer.deleteKey(redisKey);
|
||||
const subject = `locks.${object.type}s.${object._id}`;
|
||||
await natsServer.publish(subject, {
|
||||
_id: object._id,
|
||||
type: object.type,
|
||||
locked: false
|
||||
});
|
||||
logger.info(`Unlocked object and published event: ${object._id}`);
|
||||
return true;
|
||||
}
|
||||
} catch (err) {
|
||||
logger.error(`Error unlocking object ${object._id}:`, err);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
async getObjectLock(object) {
|
||||
// Get the current lock status of an object
|
||||
logger.info('Getting lock status for object:', object._id);
|
||||
try {
|
||||
const lockKey = `locks:${object.type}s:${object._id}`;
|
||||
const lockValue = await redisServer.getKey(lockKey);
|
||||
|
||||
if (lockValue) {
|
||||
// Object is locked
|
||||
logger.debug(`Object ${object._id} is locked`);
|
||||
return {
|
||||
...lockValue,
|
||||
locked: true
|
||||
};
|
||||
} else {
|
||||
// Object is not locked
|
||||
logger.debug(`Object ${object._id} is not locked`);
|
||||
return {
|
||||
_id: object._id,
|
||||
locked: false
|
||||
};
|
||||
}
|
||||
} catch (err) {
|
||||
logger.error(`Error getting lock status for object ${object._id}:`, err);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
setupLocksListeners() {
|
||||
// Subscribe to NATS subject for lock changes and emit via socket
|
||||
const subject = 'locks.>';
|
||||
natsServer
|
||||
.subscribe(subject, this.socketClient.id, (_subject, value) => {
|
||||
// Expected subjects: locks.{type}s.{id}
|
||||
const parts = _subject.split('.');
|
||||
const last = parts[parts.length - 1];
|
||||
const id = last;
|
||||
const payload =
|
||||
typeof value === 'object'
|
||||
? value
|
||||
: { _id: id, locked: !!value?.locked };
|
||||
logger.debug('Lock event received:', _subject);
|
||||
this.socketClient.socket.emit('lockUpdate', payload);
|
||||
})
|
||||
.then(() => {
|
||||
logger.info('Subscribed to NATS for lock changes.');
|
||||
})
|
||||
.catch(err => {
|
||||
logger.error('Failed to subscribe to NATS lock changes:', err);
|
||||
});
|
||||
}
|
||||
}
|
||||
@ -40,8 +40,8 @@ jest.unstable_mockModule('../../database/redis.js', () => ({
|
||||
},
|
||||
}));
|
||||
|
||||
jest.unstable_mockModule('../../lock/lockmanager.js', () => ({
|
||||
LockManager: jest.fn(),
|
||||
jest.unstable_mockModule('../../activity/activitymanager.js', () => ({
|
||||
ActivityManager: jest.fn()
|
||||
}));
|
||||
|
||||
jest.unstable_mockModule('../../templates/templatemanager.js', () => ({
|
||||
|
||||
@ -18,11 +18,14 @@ 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('../../activity/activitymanager.js', () => ({
|
||||
ActivityManager: jest.fn().mockImplementation(() => ({
|
||||
setActivity: jest.fn(),
|
||||
clearActivity: jest.fn(),
|
||||
getObjectActivities: jest.fn(),
|
||||
subscribeToObjectActivity: jest.fn(),
|
||||
removeObjectActivityListener: jest.fn(),
|
||||
removeAllListeners: jest.fn()
|
||||
}))
|
||||
}));
|
||||
|
||||
@ -128,7 +131,10 @@ describe('SocketUser', () => {
|
||||
'authenticate',
|
||||
expect.any(Function)
|
||||
);
|
||||
expect(mockSocket.on).toHaveBeenCalledWith('lock', expect.any(Function));
|
||||
expect(mockSocket.on).toHaveBeenCalledWith(
|
||||
'setActivity',
|
||||
expect.any(Function)
|
||||
);
|
||||
expect(mockSocket.on).toHaveBeenCalledWith(
|
||||
'disconnect',
|
||||
expect.any(Function)
|
||||
@ -183,26 +189,26 @@ describe('SocketUser', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('lock event handlers', () => {
|
||||
describe('activity 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);
|
||||
it('handleSetActivityEvent should call activityManager.setActivity', async () => {
|
||||
const data = { _id: 'obj-1', type: 'printer', mode: 'viewing' };
|
||||
await socketUser.handleSetActivityEvent(data);
|
||||
|
||||
expect(socketUser.lockManager.lockObject).toHaveBeenCalledWith({
|
||||
expect(socketUser.activityManager.setActivity).toHaveBeenCalledWith({
|
||||
...data,
|
||||
user: 'user-id'
|
||||
});
|
||||
});
|
||||
|
||||
it('handleUnlockEvent should call lockManager.unlockObject', async () => {
|
||||
const data = { _id: 'obj-1' };
|
||||
await socketUser.handleUnlockEvent(data);
|
||||
it('handleClearActivityEvent should call activityManager.clearActivity', async () => {
|
||||
const data = { _id: 'obj-1', type: 'printer' };
|
||||
await socketUser.handleClearActivityEvent(data);
|
||||
|
||||
expect(socketUser.lockManager.unlockObject).toHaveBeenCalledWith({
|
||||
expect(socketUser.activityManager.clearActivity).toHaveBeenCalledWith({
|
||||
...data,
|
||||
user: 'user-id'
|
||||
});
|
||||
|
||||
@ -4,7 +4,6 @@ import log4js from 'log4js';
|
||||
// Load configuration
|
||||
import { loadConfig } from '../config.js';
|
||||
import { SocketUser } from './socketuser.js';
|
||||
import { LockManager } from '../lock/lockmanager.js';
|
||||
import { UpdateManager } from '../updates/updatemanager.js';
|
||||
import { TemplateManager } from '../templates/templatemanager.js';
|
||||
import { SocketHost } from './sockethost.js';
|
||||
@ -53,7 +52,7 @@ export class SocketManager {
|
||||
}
|
||||
|
||||
async addUser(socket) {
|
||||
const socketUser = new SocketUser(socket, this, this.lockManager);
|
||||
const socketUser = new SocketUser(socket, this);
|
||||
this.socketUsers.set(socketUser.id, socketUser);
|
||||
logger.info('External user connected. Socket ID:', socket.id);
|
||||
// Handle disconnection
|
||||
@ -64,7 +63,7 @@ export class SocketManager {
|
||||
}
|
||||
|
||||
async addHost(socket) {
|
||||
const socketHost = new SocketHost(socket, this, this.lockManager);
|
||||
const socketHost = new SocketHost(socket, this);
|
||||
this.socketHosts.set(socketHost.id, socketHost);
|
||||
logger.info('External host connected. Socket ID:', socket.id);
|
||||
// Handle disconnection
|
||||
|
||||
@ -3,7 +3,7 @@ import log4js from 'log4js';
|
||||
import { loadConfig } from '../config.js';
|
||||
import { createAuthMiddleware, KeycloakAuth } from '../auth/auth.js';
|
||||
import { generateHostOTP } from '../utils.js';
|
||||
import { LockManager } from '../lock/lockmanager.js';
|
||||
import { ActivityManager } from '../activity/activitymanager.js';
|
||||
import { UpdateManager } from '../updates/updatemanager.js';
|
||||
import { ActionManager } from '../actions/actionmanager.js';
|
||||
import { EventManager } from '../events/eventmanager.js';
|
||||
@ -25,7 +25,8 @@ export class SocketUser {
|
||||
this.id = null;
|
||||
this.user = null;
|
||||
this.socketManager = socketManager;
|
||||
this.lockManager = new LockManager(this);
|
||||
this.activityManager = new ActivityManager(this);
|
||||
this.trackedActivities = new Set();
|
||||
this.updateManager = new UpdateManager(this);
|
||||
this.actionManager = new ActionManager(this);
|
||||
this.eventManager = new EventManager(this);
|
||||
@ -41,9 +42,17 @@ export class SocketUser {
|
||||
setupSocketEventHandlers() {
|
||||
this.socket.use(createAuthMiddleware(this));
|
||||
this.socket.on('authenticate', this.handleAuthenticateEvent.bind(this));
|
||||
this.socket.on('lock', this.handleLockEvent.bind(this));
|
||||
this.socket.on('unlock', this.handleUnlockEvent.bind(this));
|
||||
this.socket.on('getLock', this.handleGetLockEvent.bind(this));
|
||||
this.socket.on('setActivity', this.handleSetActivityEvent.bind(this));
|
||||
this.socket.on('clearActivity', this.handleClearActivityEvent.bind(this));
|
||||
this.socket.on('getActivities', this.handleGetActivitiesEvent.bind(this));
|
||||
this.socket.on(
|
||||
'subscribeToObjectActivity',
|
||||
this.handleSubscribeToObjectActivityEvent.bind(this)
|
||||
);
|
||||
this.socket.on(
|
||||
'unsubscribeObjectActivity',
|
||||
this.handleUnsubscribeObjectActivityEvent.bind(this)
|
||||
);
|
||||
this.socket.on(
|
||||
'subscribeToObjectTypeUpdate',
|
||||
this.handleSubscribeToObjectTypeUpdateEvent.bind(this)
|
||||
@ -113,6 +122,7 @@ export class SocketUser {
|
||||
logger.info('User authenticated and valid.');
|
||||
|
||||
this.user = result.user;
|
||||
console.log('user', this.user);
|
||||
this.id = this.user._id.toString();
|
||||
this.authenticated = true;
|
||||
await this.notificationManager.subscribe();
|
||||
@ -123,61 +133,115 @@ export class SocketUser {
|
||||
}
|
||||
}
|
||||
|
||||
async handleLockEvent(data) {
|
||||
// data: { _id: string, params?: object }
|
||||
if (!data || !data._id) {
|
||||
this.socket.emit('lock_result', {
|
||||
trackActivity(type, id) {
|
||||
this.trackedActivities.add(`${type}|${id}`);
|
||||
}
|
||||
|
||||
untrackActivity(type, id) {
|
||||
this.trackedActivities.delete(`${type}|${id}`);
|
||||
}
|
||||
|
||||
async handleSetActivityEvent(data, callback) {
|
||||
const respond = result => {
|
||||
if (typeof callback === 'function') {
|
||||
callback(result);
|
||||
} else {
|
||||
this.socket.emit('setActivity_result', result);
|
||||
}
|
||||
};
|
||||
|
||||
if (!data || !data._id || !data.type || !data.mode) {
|
||||
respond({
|
||||
success: false,
|
||||
error: 'Invalid lock event data'
|
||||
error: 'Invalid setActivity event data'
|
||||
});
|
||||
return;
|
||||
}
|
||||
data = { ...data, user: this.user._id.toString() };
|
||||
try {
|
||||
await this.lockManager.lockObject(data);
|
||||
const result = await this.activityManager.setActivity(data);
|
||||
if (result?.success === false) {
|
||||
respond(result);
|
||||
return;
|
||||
}
|
||||
|
||||
this.trackActivity(data.type, data._id);
|
||||
respond(result);
|
||||
} catch (err) {
|
||||
logger.error('Lock event error:', err);
|
||||
this.socket.emit('lock_result', { success: false, error: err.message });
|
||||
logger.error('Set activity event error:', err);
|
||||
respond({
|
||||
success: false,
|
||||
error: err.message
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async handleUnlockEvent(data) {
|
||||
// data: { _id: string }
|
||||
if (!data || !data._id) {
|
||||
this.socket.emit('unlock_result', {
|
||||
async handleClearActivityEvent(data) {
|
||||
if (!data || !data._id || !data.type) {
|
||||
this.socket.emit('clearActivity_result', {
|
||||
success: false,
|
||||
error: 'Invalid unlock event data'
|
||||
error: 'Invalid clearActivity event data'
|
||||
});
|
||||
return;
|
||||
}
|
||||
data = { ...data, user: this.user._id.toString() };
|
||||
try {
|
||||
await this.lockManager.unlockObject(data);
|
||||
await this.activityManager.clearActivity(data);
|
||||
this.untrackActivity(data.type, data._id);
|
||||
} catch (err) {
|
||||
logger.error('Unlock event error:', err);
|
||||
this.socket.emit('unlock_result', { success: false, error: err.message });
|
||||
logger.error('Clear activity event error:', err);
|
||||
this.socket.emit('clearActivity_result', {
|
||||
success: false,
|
||||
error: err.message
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async handleGetLockEvent(data, callback) {
|
||||
// data: { _id: string }
|
||||
if (!data || !data._id) {
|
||||
async handleGetActivitiesEvent(data, callback) {
|
||||
if (!data || !data._id || !data.type) {
|
||||
callback({
|
||||
error: 'Invalid getLock event data'
|
||||
error: 'Invalid getActivities event data'
|
||||
});
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const lockEvent = await this.lockManager.getObjectLock(data);
|
||||
callback(lockEvent);
|
||||
const activities = await this.activityManager.getObjectActivities(data);
|
||||
callback({ activities });
|
||||
} catch (err) {
|
||||
logger.error('GetLock event error:', err);
|
||||
logger.error('GetActivities event error:', err);
|
||||
callback({
|
||||
error: err.message
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async handleSubscribeToObjectActivityEvent(data, callback) {
|
||||
if (!data || !data._id || !data.objectType) {
|
||||
if (typeof callback === 'function') {
|
||||
callback({
|
||||
success: false,
|
||||
error: 'Invalid subscribeToObjectActivity event data'
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await this.activityManager.subscribeToObjectActivity(
|
||||
data._id,
|
||||
data.objectType
|
||||
);
|
||||
if (typeof callback === 'function') {
|
||||
callback(result);
|
||||
}
|
||||
}
|
||||
|
||||
async handleUnsubscribeObjectActivityEvent(data) {
|
||||
await this.activityManager.removeObjectActivityListener(
|
||||
data._id,
|
||||
data.objectType
|
||||
);
|
||||
}
|
||||
|
||||
async handleSubscribeToObjectTypeUpdateEvent(data, callback) {
|
||||
await this.updateManager.subscribeToObjectNew(data.objectType, data.filter);
|
||||
await this.updateManager.subscribeToObjectDelete(
|
||||
@ -305,6 +369,26 @@ export class SocketUser {
|
||||
}
|
||||
|
||||
async handleDisconnect() {
|
||||
if (this.user?._id) {
|
||||
const userId = this.user._id.toString();
|
||||
for (const activityKey of this.trackedActivities) {
|
||||
const separatorIndex = activityKey.indexOf('|');
|
||||
const type = activityKey.slice(0, separatorIndex);
|
||||
const id = activityKey.slice(separatorIndex + 1);
|
||||
try {
|
||||
await this.activityManager.clearActivity({
|
||||
_id: id,
|
||||
type,
|
||||
user: userId
|
||||
});
|
||||
} catch (err) {
|
||||
logger.error('Error clearing activity on disconnect:', err);
|
||||
}
|
||||
}
|
||||
this.trackedActivities.clear();
|
||||
}
|
||||
|
||||
await this.activityManager.removeAllListeners();
|
||||
await this.actionManager.removeAllListeners();
|
||||
await this.updateManager.removeAllListeners();
|
||||
await this.eventManager.removeAllListeners();
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user