Add user settings management 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 a new UserSettingsManager class to handle user-specific settings, including retrieval and updates. - Implemented user settings schema to define defaults and manage user-specific configurations. - Enhanced SocketUser class to integrate user settings event handlers for getting and updating settings. - Added comprehensive tests for UserSettingsManager to ensure correct functionality and error handling.
This commit is contained in:
parent
1cb6efdb2d
commit
15132fefa0
44
src/database/schemas/misc/usersettings.schema.js
Normal file
44
src/database/schemas/misc/usersettings.schema.js
Normal file
@ -0,0 +1,44 @@
|
||||
import mongoose from 'mongoose';
|
||||
import { generateId } from '../../utils.js';
|
||||
const { Schema } = mongoose;
|
||||
|
||||
const userSettingsSchema = new mongoose.Schema({
|
||||
_reference: { type: String, default: () => generateId()() },
|
||||
user: {
|
||||
type: Schema.Types.ObjectId,
|
||||
ref: 'user',
|
||||
required: true,
|
||||
unique: true
|
||||
},
|
||||
defaults: {
|
||||
viewMode: { type: Schema.Types.Mixed, default: () => ({}) },
|
||||
filterSidebarVisibility: {
|
||||
type: Schema.Types.Mixed,
|
||||
default: () => ({})
|
||||
},
|
||||
columnVisibility: { type: Schema.Types.Mixed, default: () => ({}) },
|
||||
collapseState: { type: Schema.Types.Mixed, default: () => ({}) }
|
||||
},
|
||||
|
||||
createdAt: {
|
||||
type: Date,
|
||||
required: true,
|
||||
default: Date.now
|
||||
},
|
||||
updatedAt: {
|
||||
type: Date,
|
||||
required: true,
|
||||
default: Date.now
|
||||
}
|
||||
});
|
||||
|
||||
userSettingsSchema.virtual('id').get(function () {
|
||||
return this._id;
|
||||
});
|
||||
|
||||
userSettingsSchema.set('toJSON', { virtuals: true });
|
||||
|
||||
export const userSettingsModel = mongoose.model(
|
||||
'userSettings',
|
||||
userSettingsSchema
|
||||
);
|
||||
@ -77,6 +77,13 @@ jest.unstable_mockModule('../../server/servermanager.js', () => ({
|
||||
}))
|
||||
}));
|
||||
|
||||
jest.unstable_mockModule('../../usersettings/usersettingsmanager.js', () => ({
|
||||
UserSettingsManager: jest.fn().mockImplementation(() => ({
|
||||
getUserSettings: jest.fn(),
|
||||
updateUserSettings: jest.fn()
|
||||
}))
|
||||
}));
|
||||
|
||||
jest.unstable_mockModule('log4js', () => ({
|
||||
default: {
|
||||
getLogger: () => ({
|
||||
@ -130,6 +137,14 @@ describe('SocketUser', () => {
|
||||
'getServerVersion',
|
||||
expect.any(Function)
|
||||
);
|
||||
expect(mockSocket.on).toHaveBeenCalledWith(
|
||||
'getUserSettings',
|
||||
expect.any(Function)
|
||||
);
|
||||
expect(mockSocket.on).toHaveBeenCalledWith(
|
||||
'updateUserSettings',
|
||||
expect.any(Function)
|
||||
);
|
||||
});
|
||||
|
||||
describe('handleAuthenticateEvent', () => {
|
||||
@ -196,15 +211,17 @@ describe('SocketUser', () => {
|
||||
|
||||
describe('object type subscription handlers', () => {
|
||||
it('should pass filters to type subscription methods', async () => {
|
||||
const data = { objectType: 'note', filter: { 'parent._id': 'parent-id' } };
|
||||
const data = {
|
||||
objectType: 'note',
|
||||
filter: { 'parent._id': 'parent-id' }
|
||||
};
|
||||
const callback = jest.fn();
|
||||
|
||||
await socketUser.handleSubscribeToObjectTypeUpdateEvent(data, callback);
|
||||
|
||||
expect(socketUser.updateManager.subscribeToObjectNew).toHaveBeenCalledWith(
|
||||
'note',
|
||||
data.filter
|
||||
);
|
||||
expect(
|
||||
socketUser.updateManager.subscribeToObjectNew
|
||||
).toHaveBeenCalledWith('note', data.filter);
|
||||
expect(
|
||||
socketUser.updateManager.subscribeToObjectDelete
|
||||
).toHaveBeenCalledWith('note', data.filter);
|
||||
@ -212,7 +229,10 @@ describe('SocketUser', () => {
|
||||
});
|
||||
|
||||
it('should pass filters to type unsubscribe methods', async () => {
|
||||
const data = { objectType: 'note', filter: { 'parent._id': 'parent-id' } };
|
||||
const data = {
|
||||
objectType: 'note',
|
||||
filter: { 'parent._id': 'parent-id' }
|
||||
};
|
||||
|
||||
await socketUser.handleUnsubscribeToObjectTypeUpdateEvent(data);
|
||||
|
||||
@ -252,6 +272,54 @@ describe('SocketUser', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('user settings event handlers', () => {
|
||||
it('returns user settings through the acknowledgement callback', async () => {
|
||||
const callback = jest.fn();
|
||||
const settings = { viewMode: { payments: 'cards' } };
|
||||
socketUser.userSettingsManager.getUserSettings.mockResolvedValue(
|
||||
settings
|
||||
);
|
||||
|
||||
await socketUser.handleGetUserSettingsEvent({}, callback);
|
||||
|
||||
expect(callback).toHaveBeenCalledWith({ success: true, settings });
|
||||
});
|
||||
|
||||
it('updates user settings through the acknowledgement callback', async () => {
|
||||
const callback = jest.fn();
|
||||
const data = {
|
||||
category: 'viewMode',
|
||||
key: 'payments',
|
||||
value: 'cards'
|
||||
};
|
||||
const settings = { viewMode: { payments: 'cards' } };
|
||||
socketUser.userSettingsManager.updateUserSettings.mockResolvedValue(
|
||||
settings
|
||||
);
|
||||
|
||||
await socketUser.handleUpdateUserSettingsEvent(data, callback);
|
||||
|
||||
expect(
|
||||
socketUser.userSettingsManager.updateUserSettings
|
||||
).toHaveBeenCalledWith(data);
|
||||
expect(callback).toHaveBeenCalledWith({ success: true, settings });
|
||||
});
|
||||
|
||||
it('returns user settings errors through the acknowledgement callback', async () => {
|
||||
const callback = jest.fn();
|
||||
socketUser.userSettingsManager.getUserSettings.mockRejectedValue(
|
||||
new Error('database unavailable')
|
||||
);
|
||||
|
||||
await socketUser.handleGetUserSettingsEvent({}, callback);
|
||||
|
||||
expect(callback).toHaveBeenCalledWith({
|
||||
success: false,
|
||||
error: 'database unavailable'
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('handleDisconnect', () => {
|
||||
it('should remove all listeners', async () => {
|
||||
await socketUser.handleDisconnect();
|
||||
@ -260,7 +328,9 @@ describe('SocketUser', () => {
|
||||
expect(socketUser.updateManager.removeAllListeners).toHaveBeenCalled();
|
||||
expect(socketUser.eventManager.removeAllListeners).toHaveBeenCalled();
|
||||
expect(socketUser.statsManager.removeAllListeners).toHaveBeenCalled();
|
||||
expect(socketUser.notificationManager.removeAllListeners).toHaveBeenCalled();
|
||||
expect(
|
||||
socketUser.notificationManager.removeAllListeners
|
||||
).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@ -10,6 +10,7 @@ import { EventManager } from '../events/eventmanager.js';
|
||||
import { StatsManager } from '../stats/statsmanager.js';
|
||||
import { NotificationManager } from '../notification/notificationmanager.js';
|
||||
import { ServerManager } from '../server/servermanager.js';
|
||||
import { UserSettingsManager } from '../usersettings/usersettingsmanager.js';
|
||||
|
||||
const config = loadConfig();
|
||||
|
||||
@ -31,6 +32,7 @@ export class SocketUser {
|
||||
this.statsManager = new StatsManager(this);
|
||||
this.notificationManager = new NotificationManager(this);
|
||||
this.serverManager = new ServerManager(this);
|
||||
this.userSettingsManager = new UserSettingsManager(this);
|
||||
this.templateManager = socketManager.templateManager;
|
||||
this.keycloakAuth = new KeycloakAuth();
|
||||
this.setupSocketEventHandlers();
|
||||
@ -90,6 +92,14 @@ export class SocketUser {
|
||||
'getServerVersion',
|
||||
this.handleGetServerVersionEvent.bind(this)
|
||||
);
|
||||
this.socket.on(
|
||||
'getUserSettings',
|
||||
this.handleGetUserSettingsEvent.bind(this)
|
||||
);
|
||||
this.socket.on(
|
||||
'updateUserSettings',
|
||||
this.handleUpdateUserSettingsEvent.bind(this)
|
||||
);
|
||||
this.socket.on('objectAction', this.handleObjectActionEvent.bind(this));
|
||||
this.socket.on('disconnect', this.handleDisconnect.bind(this));
|
||||
}
|
||||
@ -260,6 +270,31 @@ export class SocketUser {
|
||||
responseCallback(this.serverManager.getServerVersion());
|
||||
}
|
||||
|
||||
async handleGetUserSettingsEvent(data, callback) {
|
||||
const responseCallback = typeof callback === 'function' ? callback : data;
|
||||
try {
|
||||
const settings = await this.userSettingsManager.getUserSettings();
|
||||
responseCallback({ success: true, settings });
|
||||
} catch (error) {
|
||||
logger.error('Get user settings error:', error);
|
||||
responseCallback({ success: false, error: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
async handleUpdateUserSettingsEvent(data, callback) {
|
||||
try {
|
||||
const settings = await this.userSettingsManager.updateUserSettings(data);
|
||||
if (typeof callback === 'function') {
|
||||
callback({ success: true, settings });
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Update user settings error:', error);
|
||||
if (typeof callback === 'function') {
|
||||
callback({ success: false, error: error.message });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async handleObjectActionEvent(data, callback) {
|
||||
await this.actionManager.sendObjectAction(
|
||||
data._id,
|
||||
|
||||
113
src/usersettings/usersettingsmanager.js
Normal file
113
src/usersettings/usersettingsmanager.js
Normal file
@ -0,0 +1,113 @@
|
||||
import { userSettingsModel } from '../database/schemas/misc/usersettings.schema.js';
|
||||
|
||||
const SETTINGS_CATEGORIES = [
|
||||
'viewMode',
|
||||
'filterSidebarVisibility',
|
||||
'columnVisibility',
|
||||
'collapseState'
|
||||
];
|
||||
|
||||
const createEmptySettings = () => ({
|
||||
viewMode: {},
|
||||
filterSidebarVisibility: {},
|
||||
columnVisibility: {},
|
||||
collapseState: {}
|
||||
});
|
||||
|
||||
const normalizeCategory = category =>
|
||||
category && typeof category === 'object' && !Array.isArray(category)
|
||||
? category
|
||||
: {};
|
||||
|
||||
const normalizeSettings = (defaults = {}) => ({
|
||||
viewMode: normalizeCategory(defaults?.viewMode),
|
||||
filterSidebarVisibility: normalizeCategory(defaults?.filterSidebarVisibility),
|
||||
columnVisibility: normalizeCategory(defaults?.columnVisibility),
|
||||
collapseState: normalizeCategory(defaults?.collapseState)
|
||||
});
|
||||
|
||||
const isSafeSettingsKey = key =>
|
||||
typeof key === 'string' &&
|
||||
key.length > 0 &&
|
||||
key.length <= 128 &&
|
||||
!key.includes('.') &&
|
||||
!key.includes('$') &&
|
||||
!key.includes('\0');
|
||||
|
||||
export class UserSettingsManager {
|
||||
constructor(socketClient) {
|
||||
this.socketClient = socketClient;
|
||||
}
|
||||
|
||||
getUserId() {
|
||||
const userId = this.socketClient.user?._id;
|
||||
if (!this.socketClient.authenticated || !userId) {
|
||||
throw new Error('User is not authenticated');
|
||||
}
|
||||
return userId;
|
||||
}
|
||||
|
||||
async getUserSettings() {
|
||||
const userId = this.getUserId();
|
||||
const userSettings = await userSettingsModel
|
||||
.findOne({ user: userId })
|
||||
.select('defaults')
|
||||
.lean();
|
||||
|
||||
return normalizeSettings(userSettings?.defaults);
|
||||
}
|
||||
|
||||
async updateUserSettings({ category, key, value } = {}) {
|
||||
const userId = this.getUserId();
|
||||
|
||||
if (!SETTINGS_CATEGORIES.includes(category)) {
|
||||
throw new Error('Invalid user settings category');
|
||||
}
|
||||
if (!isSafeSettingsKey(key)) {
|
||||
throw new Error('Invalid user settings key');
|
||||
}
|
||||
if (value === undefined) {
|
||||
throw new Error('User settings value is required');
|
||||
}
|
||||
|
||||
const update = {
|
||||
$set: {
|
||||
[`defaults.${category}.${key}`]: value,
|
||||
updatedAt: new Date()
|
||||
},
|
||||
$setOnInsert: {
|
||||
user: userId,
|
||||
createdAt: new Date()
|
||||
}
|
||||
};
|
||||
|
||||
let userSettings;
|
||||
try {
|
||||
userSettings = await userSettingsModel
|
||||
.findOneAndUpdate({ user: userId }, update, {
|
||||
new: true,
|
||||
upsert: true,
|
||||
setDefaultsOnInsert: true
|
||||
})
|
||||
.select('defaults')
|
||||
.lean();
|
||||
} catch (error) {
|
||||
if (error?.code !== 11000) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
userSettings = await userSettingsModel
|
||||
.findOneAndUpdate(
|
||||
{ user: userId },
|
||||
{ $set: update.$set },
|
||||
{ new: true }
|
||||
)
|
||||
.select('defaults')
|
||||
.lean();
|
||||
}
|
||||
|
||||
return normalizeSettings(userSettings?.defaults);
|
||||
}
|
||||
}
|
||||
|
||||
export { SETTINGS_CATEGORIES, createEmptySettings, isSafeSettingsKey };
|
||||
107
src/usersettings/usersettingsmanager.test.js
Normal file
107
src/usersettings/usersettingsmanager.test.js
Normal file
@ -0,0 +1,107 @@
|
||||
import { jest } from '@jest/globals';
|
||||
|
||||
const findOne = jest.fn();
|
||||
const findOneAndUpdate = jest.fn();
|
||||
|
||||
jest.unstable_mockModule(
|
||||
'../database/schemas/misc/usersettings.schema.js',
|
||||
() => ({
|
||||
userSettingsModel: {
|
||||
findOne,
|
||||
findOneAndUpdate
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
const { UserSettingsManager, createEmptySettings, isSafeSettingsKey } =
|
||||
await import('./usersettingsmanager.js');
|
||||
|
||||
const queryResult = value => ({
|
||||
select: jest.fn().mockReturnValue({
|
||||
lean: jest.fn().mockResolvedValue(value)
|
||||
})
|
||||
});
|
||||
|
||||
describe('UserSettingsManager', () => {
|
||||
let manager;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
manager = new UserSettingsManager({
|
||||
authenticated: true,
|
||||
user: { _id: 'user-id' }
|
||||
});
|
||||
});
|
||||
|
||||
it('returns empty defaults when the user has no settings document', async () => {
|
||||
findOne.mockReturnValue(queryResult(null));
|
||||
|
||||
await expect(manager.getUserSettings()).resolves.toEqual(
|
||||
createEmptySettings()
|
||||
);
|
||||
expect(findOne).toHaveBeenCalledWith({ user: 'user-id' });
|
||||
});
|
||||
|
||||
it('atomically upserts only the requested category and key', async () => {
|
||||
const defaults = {
|
||||
...createEmptySettings(),
|
||||
viewMode: { payments: 'cards' }
|
||||
};
|
||||
findOneAndUpdate.mockReturnValue(queryResult({ defaults }));
|
||||
|
||||
await expect(
|
||||
manager.updateUserSettings({
|
||||
category: 'viewMode',
|
||||
key: 'payments',
|
||||
value: 'cards'
|
||||
})
|
||||
).resolves.toEqual(defaults);
|
||||
|
||||
expect(findOneAndUpdate).toHaveBeenCalledWith(
|
||||
{ user: 'user-id' },
|
||||
expect.objectContaining({
|
||||
$set: expect.objectContaining({
|
||||
'defaults.viewMode.payments': 'cards'
|
||||
}),
|
||||
$setOnInsert: expect.objectContaining({ user: 'user-id' })
|
||||
}),
|
||||
{
|
||||
new: true,
|
||||
upsert: true,
|
||||
setDefaultsOnInsert: true
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects invalid categories and unsafe keys', async () => {
|
||||
await expect(
|
||||
manager.updateUserSettings({
|
||||
category: 'unknown',
|
||||
key: 'payments',
|
||||
value: true
|
||||
})
|
||||
).rejects.toThrow('Invalid user settings category');
|
||||
|
||||
await expect(
|
||||
manager.updateUserSettings({
|
||||
category: 'viewMode',
|
||||
key: 'payments.value',
|
||||
value: true
|
||||
})
|
||||
).rejects.toThrow('Invalid user settings key');
|
||||
|
||||
expect(isSafeSettingsKey('$payments')).toBe(false);
|
||||
expect(findOneAndUpdate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects unauthenticated access', async () => {
|
||||
manager = new UserSettingsManager({
|
||||
authenticated: false,
|
||||
user: { _id: 'user-id' }
|
||||
});
|
||||
|
||||
await expect(manager.getUserSettings()).rejects.toThrow(
|
||||
'User is not authenticated'
|
||||
);
|
||||
});
|
||||
});
|
||||
Loading…
x
Reference in New Issue
Block a user