Refactor UpdateManager to enhance filter matching logic, replacing listObjects calls with direct filter checks, and improve event emission for object updates. Added tests for reference filter matching.

This commit is contained in:
Tom Butcher 2026-06-21 22:32:17 +01:00
parent 3481b07a55
commit b1245595d9
2 changed files with 89 additions and 53 deletions

View File

@ -7,18 +7,6 @@ jest.unstable_mockModule('../../database/nats.js', () => ({
} }
})); }));
jest.unstable_mockModule('../../database/database.js', () => ({
listObjects: jest.fn()
}));
jest.unstable_mockModule('../../database/schemas/models.js', () => ({
models: {
PRN: {
model: { modelName: 'printer' }
}
}
}));
jest.unstable_mockModule('log4js', () => ({ jest.unstable_mockModule('log4js', () => ({
default: { default: {
getLogger: () => ({ getLogger: () => ({
@ -42,7 +30,6 @@ jest.unstable_mockModule('../../config.js', () => ({
const { UpdateManager } = await import('../updatemanager.js'); const { UpdateManager } = await import('../updatemanager.js');
const { natsServer } = await import('../../database/nats.js'); const { natsServer } = await import('../../database/nats.js');
const { listObjects } = await import('../../database/database.js');
describe('UpdateManager', () => { describe('UpdateManager', () => {
let mockSocketClient; let mockSocketClient;
@ -84,8 +71,11 @@ describe('UpdateManager', () => {
it('should emit filtered new object events when the list filter matches', async () => { it('should emit filtered new object events when the list filter matches', async () => {
const filter = { 'state.type': 'ready' }; const filter = { 'state.type': 'ready' };
const data = { _id: '123', name: 'New Printer' }; const data = {
listObjects.mockResolvedValueOnce([data]); _id: '123',
name: 'New Printer',
state: { type: 'ready' }
};
await updateManager.subscribeToObjectNew('printer', filter); await updateManager.subscribeToObjectNew('printer', filter);
@ -98,10 +88,6 @@ describe('UpdateManager', () => {
const natsCallback = natsServer.subscribe.mock.calls[0][2]; const natsCallback = natsServer.subscribe.mock.calls[0][2];
await natsCallback('printers.new', data); await natsCallback('printers.new', data);
expect(listObjects).toHaveBeenCalledWith({
model: { modelName: 'printer' },
filter: { 'state.type': 'ready', _id: '123' }
});
expect(mockSocketClient.socket.emit).toHaveBeenCalledWith('objectNew', { expect(mockSocketClient.socket.emit).toHaveBeenCalledWith('objectNew', {
object: data, object: data,
objectType: 'printer', objectType: 'printer',
@ -111,7 +97,6 @@ describe('UpdateManager', () => {
it('should skip filtered new object events when the list filter misses', async () => { it('should skip filtered new object events when the list filter misses', async () => {
const filter = { 'state.type': 'ready' }; const filter = { 'state.type': 'ready' };
listObjects.mockResolvedValueOnce([]);
await updateManager.subscribeToObjectNew('printer', filter); await updateManager.subscribeToObjectNew('printer', filter);
@ -120,6 +105,35 @@ describe('UpdateManager', () => {
expect(mockSocketClient.socket.emit).not.toHaveBeenCalled(); expect(mockSocketClient.socket.emit).not.toHaveBeenCalled();
}); });
it('should match reference filters when the id is populated or flat', async () => {
const filter = { 'parent._id': 'parent-id' };
await updateManager.subscribeToObjectNew('note', filter);
const natsCallback = natsServer.subscribe.mock.calls[0][2];
await natsCallback('notes.new', {
_id: 'note-1',
parent: { _id: 'parent-id' }
});
expect(mockSocketClient.socket.emit).toHaveBeenCalledTimes(1);
mockSocketClient.socket.emit.mockClear();
await natsCallback('notes.new', {
_id: 'note-2',
parent: 'parent-id'
});
expect(mockSocketClient.socket.emit).toHaveBeenCalledTimes(1);
mockSocketClient.socket.emit.mockClear();
await natsCallback('notes.new', {
_id: 'note-3',
parent: 'other-parent'
});
expect(mockSocketClient.socket.emit).not.toHaveBeenCalled();
});
}); });
describe('subscribeToObjectDelete', () => { describe('subscribeToObjectDelete', () => {

View File

@ -1,21 +1,64 @@
import log4js from 'log4js'; import log4js from 'log4js';
import _ from 'lodash';
import { loadConfig } from '../config.js'; import { loadConfig } from '../config.js';
import { natsServer } from '../database/nats.js'; import { natsServer } from '../database/nats.js';
import { listObjects } from '../database/database.js';
import { models } from '../database/schemas/models.js';
const config = loadConfig(); const config = loadConfig();
// Setup logger // Setup logger
const logger = log4js.getLogger('Update Manager'); const logger = log4js.getLogger('Update Manager');
logger.level = config.server.logLevel; logger.level = config.server.logLevel;
const modelList = Object.values(models)
.map(model => model.model)
.filter(model => model != null);
const normalizeFilter = filter => const normalizeFilter = filter =>
filter && typeof filter === 'object' && !Array.isArray(filter) ? filter : {}; filter && typeof filter === 'object' && !Array.isArray(filter) ? filter : {};
const getFilterValue = (object, key) => {
if (key.endsWith('._id')) {
const refPath = key.slice(0, -4);
const ref = _.get(object, refPath);
if (ref && typeof ref === 'object' && ref._id) {
return ref._id;
}
return ref;
}
return _.get(object, key);
};
const valuesMatch = (actual, expected) => {
if (actual == expected) {
return true;
}
if (actual != null && expected != null) {
return String(actual) === String(expected);
}
return false;
};
const matchesFilter = (object, filter) => {
if (!filter || Object.keys(filter).length === 0) {
return true;
}
if (object == null) {
return false;
}
const normalizedObject =
typeof object === 'object' && !Array.isArray(object)
? object
: { _id: object };
for (const [key, expectedValue] of Object.entries(filter)) {
if (!valuesMatch(getFilterValue(normalizedObject, key), expectedValue)) {
return false;
}
}
return true;
};
const stableStringify = value => { const stableStringify = value => {
if (Array.isArray(value)) { if (Array.isArray(value)) {
return `[${value.map(stableStringify).join(',')}]`; return `[${value.map(stableStringify).join(',')}]`;
@ -34,9 +77,6 @@ const stableStringify = value => {
const getSubscriptionOwner = (socketId, filter) => const getSubscriptionOwner = (socketId, filter) =>
`${socketId}:${stableStringify(normalizeFilter(filter))}`; `${socketId}:${stableStringify(normalizeFilter(filter))}`;
const getModelByName = modelName =>
modelList.filter(model => model.modelName == modelName)[0];
/** /**
* UpdateManager handles tracking object updates and broadcasts update events via websockets. * UpdateManager handles tracking object updates and broadcasts update events via websockets.
*/ */
@ -45,31 +85,13 @@ export class UpdateManager {
this.socketClient = socketClient; this.socketClient = socketClient;
} }
async matchesObjectTypeFilter(objectType, filter, value) { matchesObjectTypeFilter(objectType, filter, value) {
const normalizedFilter = normalizeFilter(filter); return matchesFilter(value, normalizeFilter(filter));
if (Object.keys(normalizedFilter).length === 0) {
return true;
}
const model = getModelByName(objectType);
const objectId = value?._id || value;
if (!model || !objectId) {
return false;
}
const objects = await listObjects({
model,
filter: { ...normalizedFilter, _id: objectId }
});
return Array.isArray(objects) && objects.length > 0;
} }
async emitObjectTypeEvent(eventName, objectType, filter, value) { emitObjectTypeEvent(eventName, objectType, filter, value) {
const normalizedFilter = normalizeFilter(filter); const normalizedFilter = normalizeFilter(filter);
const matches = await this.matchesObjectTypeFilter( const matches = this.matchesObjectTypeFilter(
objectType, objectType,
normalizedFilter, normalizedFilter,
value value
@ -98,7 +120,7 @@ export class UpdateManager {
getSubscriptionOwner(this.socketClient.socketId, normalizedFilter), getSubscriptionOwner(this.socketClient.socketId, normalizedFilter),
async (key, value) => { async (key, value) => {
logger.trace('Object new event:', value); logger.trace('Object new event:', value);
await this.emitObjectTypeEvent( this.emitObjectTypeEvent(
'objectNew', 'objectNew',
objectType, objectType,
normalizedFilter, normalizedFilter,
@ -116,7 +138,7 @@ export class UpdateManager {
getSubscriptionOwner(this.socketClient.socketId, normalizedFilter), getSubscriptionOwner(this.socketClient.socketId, normalizedFilter),
async (key, value) => { async (key, value) => {
logger.trace('Object delete event:', value); logger.trace('Object delete event:', value);
await this.emitObjectTypeEvent( this.emitObjectTypeEvent(
'objectDelete', 'objectDelete',
objectType, objectType,
normalizedFilter, normalizedFilter,