Add user settings management functionality
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:
Tom Butcher 2026-07-18 14:48:46 +01:00
parent 1cb6efdb2d
commit 15132fefa0
5 changed files with 376 additions and 7 deletions

View 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
);

View File

@ -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', () => ({ jest.unstable_mockModule('log4js', () => ({
default: { default: {
getLogger: () => ({ getLogger: () => ({
@ -130,6 +137,14 @@ describe('SocketUser', () => {
'getServerVersion', 'getServerVersion',
expect.any(Function) expect.any(Function)
); );
expect(mockSocket.on).toHaveBeenCalledWith(
'getUserSettings',
expect.any(Function)
);
expect(mockSocket.on).toHaveBeenCalledWith(
'updateUserSettings',
expect.any(Function)
);
}); });
describe('handleAuthenticateEvent', () => { describe('handleAuthenticateEvent', () => {
@ -196,15 +211,17 @@ describe('SocketUser', () => {
describe('object type subscription handlers', () => { describe('object type subscription handlers', () => {
it('should pass filters to type subscription methods', async () => { 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(); const callback = jest.fn();
await socketUser.handleSubscribeToObjectTypeUpdateEvent(data, callback); await socketUser.handleSubscribeToObjectTypeUpdateEvent(data, callback);
expect(socketUser.updateManager.subscribeToObjectNew).toHaveBeenCalledWith( expect(
'note', socketUser.updateManager.subscribeToObjectNew
data.filter ).toHaveBeenCalledWith('note', data.filter);
);
expect( expect(
socketUser.updateManager.subscribeToObjectDelete socketUser.updateManager.subscribeToObjectDelete
).toHaveBeenCalledWith('note', data.filter); ).toHaveBeenCalledWith('note', data.filter);
@ -212,7 +229,10 @@ describe('SocketUser', () => {
}); });
it('should pass filters to type unsubscribe methods', async () => { 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); 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', () => { describe('handleDisconnect', () => {
it('should remove all listeners', async () => { it('should remove all listeners', async () => {
await socketUser.handleDisconnect(); await socketUser.handleDisconnect();
@ -260,7 +328,9 @@ describe('SocketUser', () => {
expect(socketUser.updateManager.removeAllListeners).toHaveBeenCalled(); expect(socketUser.updateManager.removeAllListeners).toHaveBeenCalled();
expect(socketUser.eventManager.removeAllListeners).toHaveBeenCalled(); expect(socketUser.eventManager.removeAllListeners).toHaveBeenCalled();
expect(socketUser.statsManager.removeAllListeners).toHaveBeenCalled(); expect(socketUser.statsManager.removeAllListeners).toHaveBeenCalled();
expect(socketUser.notificationManager.removeAllListeners).toHaveBeenCalled(); expect(
socketUser.notificationManager.removeAllListeners
).toHaveBeenCalled();
}); });
}); });
}); });

View File

@ -10,6 +10,7 @@ import { EventManager } from '../events/eventmanager.js';
import { StatsManager } from '../stats/statsmanager.js'; import { StatsManager } from '../stats/statsmanager.js';
import { NotificationManager } from '../notification/notificationmanager.js'; import { NotificationManager } from '../notification/notificationmanager.js';
import { ServerManager } from '../server/servermanager.js'; import { ServerManager } from '../server/servermanager.js';
import { UserSettingsManager } from '../usersettings/usersettingsmanager.js';
const config = loadConfig(); const config = loadConfig();
@ -31,6 +32,7 @@ export class SocketUser {
this.statsManager = new StatsManager(this); this.statsManager = new StatsManager(this);
this.notificationManager = new NotificationManager(this); this.notificationManager = new NotificationManager(this);
this.serverManager = new ServerManager(this); this.serverManager = new ServerManager(this);
this.userSettingsManager = new UserSettingsManager(this);
this.templateManager = socketManager.templateManager; this.templateManager = socketManager.templateManager;
this.keycloakAuth = new KeycloakAuth(); this.keycloakAuth = new KeycloakAuth();
this.setupSocketEventHandlers(); this.setupSocketEventHandlers();
@ -90,6 +92,14 @@ export class SocketUser {
'getServerVersion', 'getServerVersion',
this.handleGetServerVersionEvent.bind(this) 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('objectAction', this.handleObjectActionEvent.bind(this));
this.socket.on('disconnect', this.handleDisconnect.bind(this)); this.socket.on('disconnect', this.handleDisconnect.bind(this));
} }
@ -260,6 +270,31 @@ export class SocketUser {
responseCallback(this.serverManager.getServerVersion()); 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) { async handleObjectActionEvent(data, callback) {
await this.actionManager.sendObjectAction( await this.actionManager.sendObjectAction(
data._id, data._id,

View 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 };

View 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'
);
});
});