Spaces:
Runtime error
Runtime error
| import { ScopeBindingService } from './scope-binding.service'; | |
| import { PluginInstanceService } from './plugin-instance.service'; | |
| import { PluginLoaderService } from '../../core/plugins/plugin-loader.service'; | |
| import { AuditService } from '../audit/audit.service'; | |
| // The boot-time reconciler re-derives each ENABLED instance's runtime scope binding from the persisted | |
| // plugin_instances rows, so a binding lost at provisioning time (plugin momentarily unloaded) is | |
| // restored on the next boot without an operator re-PATCH. | |
| describe('ScopeBindingService.onApplicationBootstrap reconciliation', () => { | |
| function build(loaded = true) { | |
| const setPluginSessionConfig = jest.fn(); | |
| const setPluginSessions = jest.fn(); | |
| const updatePluginConfig = jest.fn(); | |
| const loader = { | |
| getPlugin: jest.fn().mockReturnValue(loaded ? { manifest: { id: 'chatwoot' }, activeSessions: [] } : undefined), | |
| setPluginSessionConfig, | |
| setPluginSessions, | |
| updatePluginConfig, | |
| } as unknown as PluginLoaderService; | |
| const audit = { logInfo: jest.fn() } as unknown as AuditService; | |
| return { loader, audit, setPluginSessionConfig, setPluginSessions, updatePluginConfig }; | |
| } | |
| it('restores an enabled concrete-scope instance (sessionConfig + activeSessions) on boot', async () => { | |
| const { loader, audit, setPluginSessionConfig, setPluginSessions } = build(); | |
| const instances = { | |
| listAll: jest | |
| .fn() | |
| .mockResolvedValue([ | |
| { pluginId: 'chatwoot', instanceId: 'a', sessionScope: 'sess-1', config: { baseUrl: 'x' }, enabled: true }, | |
| ]), | |
| } as unknown as PluginInstanceService; | |
| await new ScopeBindingService(instances, loader, audit).onApplicationBootstrap(); | |
| expect(setPluginSessionConfig).toHaveBeenCalledWith('chatwoot', 'sess-1', { baseUrl: 'x' }); | |
| expect(setPluginSessions).toHaveBeenCalledWith('chatwoot', ['sess-1']); | |
| }); | |
| it('restores an enabled wildcard/null-scope instance as base config + ["*"]', async () => { | |
| const { loader, audit, updatePluginConfig, setPluginSessions } = build(); | |
| const instances = { | |
| listAll: jest | |
| .fn() | |
| .mockResolvedValue([ | |
| { pluginId: 'chatwoot', instanceId: 'a', sessionScope: null, config: { token: 't' }, enabled: true }, | |
| ]), | |
| } as unknown as PluginInstanceService; | |
| await new ScopeBindingService(instances, loader, audit).onApplicationBootstrap(); | |
| expect(updatePluginConfig).toHaveBeenCalledWith('chatwoot', { token: 't' }); | |
| expect(setPluginSessions).toHaveBeenCalledWith('chatwoot', ['*']); | |
| }); | |
| it('does NOT activate a disabled instance (honors the real enabled flag, never force-activates)', async () => { | |
| const { loader, audit, setPluginSessionConfig, setPluginSessions } = build(); | |
| const instances = { | |
| listAll: jest | |
| .fn() | |
| .mockResolvedValue([ | |
| { pluginId: 'chatwoot', instanceId: 'a', sessionScope: 'sess-1', config: {}, enabled: false }, | |
| ]), | |
| } as unknown as PluginInstanceService; | |
| await new ScopeBindingService(instances, loader, audit).onApplicationBootstrap(); | |
| expect(setPluginSessionConfig).not.toHaveBeenCalled(); | |
| expect(setPluginSessions).not.toHaveBeenCalled(); | |
| }); | |
| it('skips an instance whose plugin is not loaded', async () => { | |
| const { loader, audit, setPluginSessions } = build(/* loaded */ false); | |
| const instances = { | |
| listAll: jest | |
| .fn() | |
| .mockResolvedValue([{ pluginId: 'ghost', instanceId: 'a', sessionScope: 'sess-1', config: {}, enabled: true }]), | |
| } as unknown as PluginInstanceService; | |
| await new ScopeBindingService(instances, loader, audit).onApplicationBootstrap(); | |
| expect(setPluginSessions).not.toHaveBeenCalled(); | |
| }); | |
| it('does not throw when listing instances fails (reconciliation is best-effort)', async () => { | |
| const { loader, audit } = build(); | |
| const instances = { | |
| listAll: jest.fn().mockRejectedValue(new Error('db down')), | |
| } as unknown as PluginInstanceService; | |
| await expect(new ScopeBindingService(instances, loader, audit).onApplicationBootstrap()).resolves.toBeUndefined(); | |
| }); | |
| it('ends at ["*"] for a plugin with a wildcard + concrete instance regardless of DB row order (order-independent)', async () => { | |
| // Simulate the real loader, where setPluginSessions MUTATES the plugin's activeSessions so a later | |
| // applyScopeBinding reads the prior write — the exact shared-state mutation that made the old | |
| // unordered loop order-dependent (a concrete scope processed after a wildcard used to strip '*'). | |
| const plugin = { manifest: { id: 'chatwoot' }, activeSessions: [] as string[] }; | |
| const loader = { | |
| getPlugin: jest.fn(() => plugin), | |
| setPluginSessionConfig: jest.fn(), | |
| setPluginSessions: jest.fn((_id: string, sessions: string[]) => { | |
| plugin.activeSessions = sessions; | |
| }), | |
| updatePluginConfig: jest.fn(), | |
| } as unknown as PluginLoaderService; | |
| const audit = { logInfo: jest.fn() } as unknown as AuditService; | |
| const wildcard = { pluginId: 'chatwoot', instanceId: 'wild', sessionScope: null, config: {}, enabled: true }; | |
| const concrete = { pluginId: 'chatwoot', instanceId: 'conc', sessionScope: 'sess-1', config: {}, enabled: true }; | |
| for (const rowOrder of [ | |
| [wildcard, concrete], // the order that used to lose '*' | |
| [concrete, wildcard], | |
| ] as const) { | |
| plugin.activeSessions = []; | |
| const instances = { listAll: jest.fn().mockResolvedValue(rowOrder) } as unknown as PluginInstanceService; | |
| await new ScopeBindingService(instances, loader, audit).onApplicationBootstrap(); | |
| // The wildcard activation must survive in both row orders ('*' subsumes the concrete scope). | |
| expect(plugin.activeSessions).toContain('*'); | |
| } | |
| }); | |
| }); | |