From b1245595d972e4512d05cdc445400d98401a3c91 Mon Sep 17 00:00:00 2001 From: Tom Butcher Date: Sun, 21 Jun 2026 22:32:17 +0100 Subject: [PATCH] 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. --- src/updates/__tests__/updatemanager.test.js | 54 ++++++++----- src/updates/updatemanager.js | 88 +++++++++++++-------- 2 files changed, 89 insertions(+), 53 deletions(-) diff --git a/src/updates/__tests__/updatemanager.test.js b/src/updates/__tests__/updatemanager.test.js index f8822b4..cd93e39 100644 --- a/src/updates/__tests__/updatemanager.test.js +++ b/src/updates/__tests__/updatemanager.test.js @@ -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', () => ({ default: { getLogger: () => ({ @@ -42,7 +30,6 @@ jest.unstable_mockModule('../../config.js', () => ({ const { UpdateManager } = await import('../updatemanager.js'); const { natsServer } = await import('../../database/nats.js'); -const { listObjects } = await import('../../database/database.js'); describe('UpdateManager', () => { let mockSocketClient; @@ -84,8 +71,11 @@ describe('UpdateManager', () => { it('should emit filtered new object events when the list filter matches', async () => { const filter = { 'state.type': 'ready' }; - const data = { _id: '123', name: 'New Printer' }; - listObjects.mockResolvedValueOnce([data]); + const data = { + _id: '123', + name: 'New Printer', + state: { type: 'ready' } + }; await updateManager.subscribeToObjectNew('printer', filter); @@ -98,10 +88,6 @@ describe('UpdateManager', () => { const natsCallback = natsServer.subscribe.mock.calls[0][2]; await natsCallback('printers.new', data); - expect(listObjects).toHaveBeenCalledWith({ - model: { modelName: 'printer' }, - filter: { 'state.type': 'ready', _id: '123' } - }); expect(mockSocketClient.socket.emit).toHaveBeenCalledWith('objectNew', { object: data, objectType: 'printer', @@ -111,7 +97,6 @@ describe('UpdateManager', () => { it('should skip filtered new object events when the list filter misses', async () => { const filter = { 'state.type': 'ready' }; - listObjects.mockResolvedValueOnce([]); await updateManager.subscribeToObjectNew('printer', filter); @@ -120,6 +105,35 @@ describe('UpdateManager', () => { 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', () => { diff --git a/src/updates/updatemanager.js b/src/updates/updatemanager.js index 2afc180..5c26fef 100644 --- a/src/updates/updatemanager.js +++ b/src/updates/updatemanager.js @@ -1,21 +1,64 @@ import log4js from 'log4js'; +import _ from 'lodash'; import { loadConfig } from '../config.js'; import { natsServer } from '../database/nats.js'; -import { listObjects } from '../database/database.js'; -import { models } from '../database/schemas/models.js'; const config = loadConfig(); // Setup logger const logger = log4js.getLogger('Update Manager'); logger.level = config.server.logLevel; -const modelList = Object.values(models) - .map(model => model.model) - .filter(model => model != null); - const normalizeFilter = 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 => { if (Array.isArray(value)) { return `[${value.map(stableStringify).join(',')}]`; @@ -34,9 +77,6 @@ const stableStringify = value => { const getSubscriptionOwner = (socketId, 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. */ @@ -45,31 +85,13 @@ export class UpdateManager { this.socketClient = socketClient; } - async matchesObjectTypeFilter(objectType, filter, value) { - const normalizedFilter = 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; + matchesObjectTypeFilter(objectType, filter, value) { + return matchesFilter(value, normalizeFilter(filter)); } - async emitObjectTypeEvent(eventName, objectType, filter, value) { + emitObjectTypeEvent(eventName, objectType, filter, value) { const normalizedFilter = normalizeFilter(filter); - const matches = await this.matchesObjectTypeFilter( + const matches = this.matchesObjectTypeFilter( objectType, normalizedFilter, value @@ -98,7 +120,7 @@ export class UpdateManager { getSubscriptionOwner(this.socketClient.socketId, normalizedFilter), async (key, value) => { logger.trace('Object new event:', value); - await this.emitObjectTypeEvent( + this.emitObjectTypeEvent( 'objectNew', objectType, normalizedFilter, @@ -116,7 +138,7 @@ export class UpdateManager { getSubscriptionOwner(this.socketClient.socketId, normalizedFilter), async (key, value) => { logger.trace('Object delete event:', value); - await this.emitObjectTypeEvent( + this.emitObjectTypeEvent( 'objectDelete', objectType, normalizedFilter,