@camstack/server 0.1.6 → 0.1.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +3 -3
- package/src/__tests__/addon-upload.spec.ts +58 -0
- package/src/__tests__/bulk-update-coordinator.spec.ts +286 -0
- package/src/__tests__/cap-ownership-authority.spec.ts +400 -0
- package/src/__tests__/cap-providers/cap-providers-location-import.spec.ts +186 -0
- package/src/__tests__/cap-providers/integrations-delete-cascade.spec.ts +243 -0
- package/src/__tests__/cap-providers-bulk-update.spec.ts +388 -0
- package/src/__tests__/cap-route-adapter.spec.ts +289 -0
- package/src/__tests__/cap-routers/broker-routing.router.spec.ts +169 -0
- package/src/__tests__/cap-routers/cap-route-error-formatter.spec.ts +123 -0
- package/src/__tests__/cap-routers/capabilities-node.spec.ts +55 -0
- package/src/__tests__/cap-routers/device-link-overlay.spec.ts +132 -0
- package/src/__tests__/dev-bootstrap-shm-ring.spec.ts +30 -0
- package/src/__tests__/device-settings-contribution-dispatch.spec.ts +249 -0
- package/src/__tests__/framework-installer-defer-restart.spec.ts +165 -0
- package/src/__tests__/moleculer/uds-readiness.spec.ts +143 -0
- package/src/__tests__/moleculer/uds-topology.spec.ts +390 -0
- package/src/__tests__/moleculer/uds-unowned-call.spec.ts +329 -0
- package/src/__tests__/moleculer-register-node-idempotency.spec.ts +39 -4
- package/src/__tests__/native-cap-route.spec.ts +404 -0
- package/src/__tests__/oauth2-account-linking.spec.ts +85 -0
- package/src/__tests__/uds-addon-call-wiring.spec.ts +237 -0
- package/src/__tests__/uds-log-ingest.spec.ts +183 -0
- package/src/api/addon-upload.ts +27 -1
- package/src/api/capabilities.router.ts +1 -1
- package/src/api/core/__tests__/integration-markers.spec.ts +10 -0
- package/src/api/core/bulk-update-coordinator.ts +302 -0
- package/src/api/core/cap-providers.ts +211 -9
- package/src/api/core/capabilities.router.ts +26 -3
- package/src/api/core/logs.router.ts +4 -0
- package/src/api/oauth2/oauth2-routes.ts +5 -1
- package/src/api/trpc/__tests__/client-ip.spec.ts +146 -0
- package/src/api/trpc/__tests__/webrtc-session-ua-enrich.spec.ts +128 -0
- package/src/api/trpc/cap-mount-helpers.ts +12 -1
- package/src/api/trpc/cap-route-error-formatter.ts +163 -0
- package/src/api/trpc/client-ip.ts +147 -0
- package/src/api/trpc/generated-cap-mounts.ts +299 -8
- package/src/api/trpc/generated-cap-routers.ts +2384 -302
- package/src/api/trpc/trpc.middleware.ts +5 -1
- package/src/api/trpc/trpc.router.ts +84 -3
- package/src/boot/__tests__/integration-id-backfill.spec.ts +116 -0
- package/src/boot/integration-id-backfill.ts +109 -0
- package/src/core/addon/__tests__/addon-row-manifest.spec.ts +62 -0
- package/src/core/addon/addon-call-gateway.ts +157 -0
- package/src/core/addon/addon-package.service.ts +9 -0
- package/src/core/addon/addon-registry.service.ts +453 -107
- package/src/core/addon/addon-row-manifest.ts +29 -0
- package/src/core/addon/addon-settings-provider.ts +40 -116
- package/src/core/capability/capability.service.ts +9 -0
- package/src/core/logging/logging.service.ts +7 -2
- package/src/core/moleculer/cap-call-fn.spec.ts +166 -0
- package/src/core/moleculer/cap-call-fn.ts +103 -0
- package/src/core/moleculer/cap-route-authority.ts +182 -0
- package/src/core/moleculer/moleculer.service.ts +408 -36
- package/src/core/network/network-quality.service.spec.ts +2 -1
- package/src/main.ts +137 -12
- package/src/core/storage/settings-store.spec.ts +0 -213
- package/src/core/storage/settings-store.ts +0 -2
- package/src/core/storage/sql-schema.spec.ts +0 -140
- package/src/core/storage/sql-schema.ts +0 -3
|
@@ -0,0 +1,329 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* F0 (slice-5 outbound) — hub-side `onUnownedCall` wiring.
|
|
3
|
+
*
|
|
4
|
+
* When a forked hub child issues `ctx.api.<cap>.<method>` for a cap NO local
|
|
5
|
+
* sibling owns, the hub's `LocalChildRegistry` must route it via the parent's
|
|
6
|
+
* `onUnownedCall` handler and return the result over UDS — instead of falling
|
|
7
|
+
* through `UDS_NO_ROUTE` to the child's own broker (which F1+F2 removes).
|
|
8
|
+
*
|
|
9
|
+
* The hub wires the handler in `onModuleInit` as:
|
|
10
|
+
* createParentUnownedCallHandler({ getResolver: () => this.resolver, broker: this.broker, logger })
|
|
11
|
+
*
|
|
12
|
+
* This test stands up the REAL production handler against:
|
|
13
|
+
* - a REAL `CapRouteResolver` configured with a hub-in-process provider, to
|
|
14
|
+
* prove the resolver-first branch resolves a hub cap, AND
|
|
15
|
+
* - a broker fake exposing a `$`-infra core service (`$core-caps`), to prove
|
|
16
|
+
* the broker-fallback branch reaches a core service the resolver can't see.
|
|
17
|
+
*
|
|
18
|
+
* We do NOT boot the full MoleculerService (it requires a real broker + DI
|
|
19
|
+
* graph); instead we exercise the exact handler the hub constructs, with the
|
|
20
|
+
* same resolver shape the hub injects, so both branches are covered.
|
|
21
|
+
*/
|
|
22
|
+
import { describe, it, expect, vi } from 'vitest'
|
|
23
|
+
import {
|
|
24
|
+
createParentUnownedCallHandler,
|
|
25
|
+
CapRouteResolver,
|
|
26
|
+
CapRouteError,
|
|
27
|
+
HubNodeRegistry,
|
|
28
|
+
} from '@camstack/kernel'
|
|
29
|
+
import type {
|
|
30
|
+
NodeCapAuthority,
|
|
31
|
+
InProcessProviderLookup,
|
|
32
|
+
CapRouteResolverDeps,
|
|
33
|
+
NodeNativeCapEntry,
|
|
34
|
+
HubLocalChildDispatcher,
|
|
35
|
+
CapCallInput,
|
|
36
|
+
} from '@camstack/kernel'
|
|
37
|
+
import type { ServiceBroker } from 'moleculer'
|
|
38
|
+
|
|
39
|
+
// A node authority that knows nothing — the only routable thing is the
|
|
40
|
+
// hub-in-process provider injected via inProcessProviders.
|
|
41
|
+
const emptyNodeAuthority: NodeCapAuthority = {
|
|
42
|
+
nodeKnowsCap: () => false,
|
|
43
|
+
nodeIsAgent: () => false,
|
|
44
|
+
nodeOnline: () => false,
|
|
45
|
+
listNodeIds: () => [],
|
|
46
|
+
getAddonId: () => null,
|
|
47
|
+
getAgentChildId: () => null,
|
|
48
|
+
isNativeCap: () => false,
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Broker fake exposing the hub's `$core-caps` core service (the bridge for
|
|
53
|
+
* `system`/`addons`/`capabilities`/`nodes` routers) so the broker-fallback
|
|
54
|
+
* branch resolves it. `$core-caps` is NOT a CapabilityRegistry provider, so
|
|
55
|
+
* the resolver returns `no-provider` for it and the fallback runs.
|
|
56
|
+
*/
|
|
57
|
+
function hubBrokerFake(): ServiceBroker {
|
|
58
|
+
const services = [{ name: '$core-caps', nodeID: 'hub', actions: { 'system.info': {} } }]
|
|
59
|
+
const broker = {
|
|
60
|
+
nodeID: 'hub',
|
|
61
|
+
call: vi.fn(async (action: string, params: unknown) => {
|
|
62
|
+
if (action === '$core-caps.system.info') return { uptimeSec: 99, params }
|
|
63
|
+
throw Object.assign(new Error(`Service not found: ${action}`), { type: 'SERVICE_NOT_FOUND' })
|
|
64
|
+
}),
|
|
65
|
+
waitForServices: vi.fn(async () => undefined),
|
|
66
|
+
registry: { getServiceList: () => services },
|
|
67
|
+
}
|
|
68
|
+
return broker as unknown as ServiceBroker
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Minimal `HubNodeRegistry`-shaped fake exposing only the lookup the unowned
|
|
73
|
+
* handler uses: `listNativeCapEntriesForDevice`. The handler depends solely on
|
|
74
|
+
* this method, so we don't need the full registry to exercise its routing.
|
|
75
|
+
*/
|
|
76
|
+
function nodeRegistryFake(
|
|
77
|
+
byDevice: Record<number, readonly NodeNativeCapEntry[]> = {},
|
|
78
|
+
): HubNodeRegistry {
|
|
79
|
+
const fake = {
|
|
80
|
+
listNativeCapEntriesForDevice: (deviceId: number): readonly NodeNativeCapEntry[] =>
|
|
81
|
+
byDevice[deviceId] ?? [],
|
|
82
|
+
}
|
|
83
|
+
return fake as unknown as HubNodeRegistry
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Hub-local UDS dispatcher fake (LocalChildRegistry surface). `resolveChildId`
|
|
88
|
+
* returns the configured childId on a (capName, deviceId) match else null;
|
|
89
|
+
* `callCapOnChild` records the call and echoes a sentinel result.
|
|
90
|
+
*/
|
|
91
|
+
function localDispatcherFake(owner?: { capName: string; deviceId: number; childId: string }): {
|
|
92
|
+
dispatcher: HubLocalChildDispatcher
|
|
93
|
+
resolveChildId: ReturnType<typeof vi.fn>
|
|
94
|
+
callCapOnChild: ReturnType<typeof vi.fn>
|
|
95
|
+
} {
|
|
96
|
+
const resolveChildId = vi.fn((capName: string, deviceId?: number): string | null =>
|
|
97
|
+
owner !== undefined && capName === owner.capName && deviceId === owner.deviceId ? owner.childId : null,
|
|
98
|
+
)
|
|
99
|
+
const callCapOnChild = vi.fn(async (childId: string, input: CapCallInput) => ({
|
|
100
|
+
routedOverUds: childId,
|
|
101
|
+
capName: input.capName,
|
|
102
|
+
}))
|
|
103
|
+
const dispatcher: HubLocalChildDispatcher = { resolveChildId, callCapOnChild }
|
|
104
|
+
return { dispatcher, resolveChildId, callCapOnChild }
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
describe('hub onUnownedCall wiring (F0)', () => {
|
|
108
|
+
it('resolver-first: resolves a hub-in-process cap through the real CapRouteResolver', async () => {
|
|
109
|
+
const broker = hubBrokerFake()
|
|
110
|
+
|
|
111
|
+
// Hub-in-process provider for `settings-store`.
|
|
112
|
+
const settingsStore = { get: async (params: unknown) => ({ value: 'hub-resolved', params }) }
|
|
113
|
+
const inProcessProviders: InProcessProviderLookup = (capName) =>
|
|
114
|
+
capName === 'settings-store'
|
|
115
|
+
? { invoke: (method, args) => Promise.resolve((settingsStore as Record<string, (a: unknown) => unknown>)[method](args)) }
|
|
116
|
+
: null
|
|
117
|
+
|
|
118
|
+
const resolverDeps: CapRouteResolverDeps = {
|
|
119
|
+
hubNodeId: 'hub',
|
|
120
|
+
broker,
|
|
121
|
+
hubLocalRegistry: null,
|
|
122
|
+
nodeAuthority: emptyNodeAuthority,
|
|
123
|
+
inProcessProviders,
|
|
124
|
+
}
|
|
125
|
+
const resolver = new CapRouteResolver(resolverDeps)
|
|
126
|
+
|
|
127
|
+
const handler = createParentUnownedCallHandler({ getResolver: () => resolver, broker, nodeRegistry: nodeRegistryFake() })
|
|
128
|
+
|
|
129
|
+
const result = await handler({ capName: 'settings-store', method: 'get', args: { key: 'k' } })
|
|
130
|
+
expect(result).toEqual({ value: 'hub-resolved', params: { key: 'k' } })
|
|
131
|
+
// Resolver served it — broker untouched.
|
|
132
|
+
expect((broker.call as ReturnType<typeof vi.fn>)).not.toHaveBeenCalled()
|
|
133
|
+
})
|
|
134
|
+
|
|
135
|
+
it('broker-fallback: a `$`-infra core service (`$core-caps.system.info`) routes via the broker', async () => {
|
|
136
|
+
const broker = hubBrokerFake()
|
|
137
|
+
|
|
138
|
+
// No in-process providers and no known nodes → the resolver returns
|
|
139
|
+
// no-provider for `system`, exactly as it does live for the core routers.
|
|
140
|
+
const resolver = new CapRouteResolver({
|
|
141
|
+
hubNodeId: 'hub',
|
|
142
|
+
broker,
|
|
143
|
+
hubLocalRegistry: null,
|
|
144
|
+
nodeAuthority: emptyNodeAuthority,
|
|
145
|
+
inProcessProviders: () => null,
|
|
146
|
+
})
|
|
147
|
+
|
|
148
|
+
const handler = createParentUnownedCallHandler({ getResolver: () => resolver, broker, nodeRegistry: nodeRegistryFake() })
|
|
149
|
+
|
|
150
|
+
const result = await handler({ capName: 'system', method: 'info', args: undefined })
|
|
151
|
+
expect(result).toEqual({ uptimeSec: 99, params: undefined })
|
|
152
|
+
expect(broker.call).toHaveBeenCalledWith('$core-caps.system.info', undefined, undefined)
|
|
153
|
+
})
|
|
154
|
+
|
|
155
|
+
it('pre-init safety: getResolver returns null before onModuleInit → broker fallback still works', async () => {
|
|
156
|
+
const broker = hubBrokerFake()
|
|
157
|
+
// Mirrors the window before `this.resolver` is constructed: getResolver
|
|
158
|
+
// returns null, so the handler goes straight to the broker fallback.
|
|
159
|
+
const handler = createParentUnownedCallHandler({ getResolver: () => null, broker, nodeRegistry: nodeRegistryFake() })
|
|
160
|
+
|
|
161
|
+
const result = await handler({ capName: 'system', method: 'info', args: undefined })
|
|
162
|
+
expect(result).toEqual({ uptimeSec: 99, params: undefined })
|
|
163
|
+
})
|
|
164
|
+
|
|
165
|
+
it('deviceId-aware resolver: derives deviceId from args and routes via the resolver (no broker fallback)', async () => {
|
|
166
|
+
const broker = hubBrokerFake()
|
|
167
|
+
|
|
168
|
+
// A resolver-shaped fake that resolves a route ONLY when invoked with a
|
|
169
|
+
// deviceId — mirroring the real resolver routing a device-scoped native cap
|
|
170
|
+
// to its owning provider. Without a deviceId it returns no-provider.
|
|
171
|
+
let resolvedWithDeviceId: number | undefined
|
|
172
|
+
const resolver = {
|
|
173
|
+
resolveCapRoute: (capName: string, opts: { deviceId?: number }) => {
|
|
174
|
+
if (opts.deviceId === undefined) {
|
|
175
|
+
throw new CapRouteError(capName, undefined, { reason: 'no-provider', rejected: [] })
|
|
176
|
+
}
|
|
177
|
+
resolvedWithDeviceId = opts.deviceId
|
|
178
|
+
return { kind: 'hub-in-process', capName, deviceId: opts.deviceId }
|
|
179
|
+
},
|
|
180
|
+
dispatch: async () => ({ catalog: ['stream-a'] }),
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
const handler = createParentUnownedCallHandler({
|
|
184
|
+
getResolver: () => resolver as unknown as CapRouteResolver,
|
|
185
|
+
broker,
|
|
186
|
+
nodeRegistry: nodeRegistryFake(),
|
|
187
|
+
})
|
|
188
|
+
|
|
189
|
+
// deviceId lives ONLY in args, NOT top-level — the handler must derive it.
|
|
190
|
+
const result = await handler({ capName: 'stream-catalog', method: 'getCatalog', args: { deviceId: 7 } })
|
|
191
|
+
expect(result).toEqual({ catalog: ['stream-a'] })
|
|
192
|
+
expect(resolvedWithDeviceId).toBe(7)
|
|
193
|
+
// Resolver served it via the derived deviceId — broker untouched.
|
|
194
|
+
expect(broker.call).not.toHaveBeenCalled()
|
|
195
|
+
})
|
|
196
|
+
|
|
197
|
+
it('pinned broker fallback: resolver finds nothing → call is pinned to the owning node', async () => {
|
|
198
|
+
const broker = hubBrokerFake()
|
|
199
|
+
|
|
200
|
+
// Resolver always returns no-provider for this device-scoped cap.
|
|
201
|
+
const resolver = {
|
|
202
|
+
resolveCapRoute: (capName: string) => {
|
|
203
|
+
throw new CapRouteError(capName, undefined, { reason: 'no-provider', rejected: [] })
|
|
204
|
+
},
|
|
205
|
+
dispatch: async () => {
|
|
206
|
+
throw new Error('dispatch should not be reached')
|
|
207
|
+
},
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
const owner: NodeNativeCapEntry = { nodeId: 'agent', addonId: 'reolink', capName: 'stream-catalog', deviceId: 7 }
|
|
211
|
+
const handler = createParentUnownedCallHandler({
|
|
212
|
+
getResolver: () => resolver as unknown as CapRouteResolver,
|
|
213
|
+
broker,
|
|
214
|
+
nodeRegistry: nodeRegistryFake({ 7: [owner] }),
|
|
215
|
+
})
|
|
216
|
+
|
|
217
|
+
await handler({ capName: 'stream-catalog', method: 'getCatalog', args: { deviceId: 7 } }).catch(() => undefined)
|
|
218
|
+
|
|
219
|
+
// The broker call is pinned to the owning node via call-opts `{ nodeID }`.
|
|
220
|
+
const callArgs = (broker.call as ReturnType<typeof vi.fn>).mock.calls[0]
|
|
221
|
+
expect(callArgs[2]).toEqual({ nodeID: 'agent' })
|
|
222
|
+
})
|
|
223
|
+
|
|
224
|
+
it('back-compat: no resolvable device-owner → broker call stays UNPINNED (load-balanced)', async () => {
|
|
225
|
+
const broker = hubBrokerFake()
|
|
226
|
+
|
|
227
|
+
const resolver = {
|
|
228
|
+
resolveCapRoute: (capName: string) => {
|
|
229
|
+
throw new CapRouteError(capName, undefined, { reason: 'no-provider', rejected: [] })
|
|
230
|
+
},
|
|
231
|
+
dispatch: async () => {
|
|
232
|
+
throw new Error('dispatch should not be reached')
|
|
233
|
+
},
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
const handler = createParentUnownedCallHandler({
|
|
237
|
+
getResolver: () => resolver as unknown as CapRouteResolver,
|
|
238
|
+
broker,
|
|
239
|
+
nodeRegistry: nodeRegistryFake(), // no owners for any device
|
|
240
|
+
})
|
|
241
|
+
|
|
242
|
+
const result = await handler({ capName: 'system', method: 'info', args: undefined })
|
|
243
|
+
expect(result).toEqual({ uptimeSec: 99, params: undefined })
|
|
244
|
+
// Unpinned: third arg (call-opts) is undefined — today's behavior preserved.
|
|
245
|
+
const callArgs = (broker.call as ReturnType<typeof vi.fn>).mock.calls[0]
|
|
246
|
+
expect(callArgs[2]).toBeUndefined()
|
|
247
|
+
})
|
|
248
|
+
|
|
249
|
+
it('hub-local owner: device-native cap owned by a hub-local UDS child routes over UDS (broker untouched)', async () => {
|
|
250
|
+
const broker = hubBrokerFake()
|
|
251
|
+
// Resolver misses the device-scoped native cap (mirrors live behavior).
|
|
252
|
+
const resolver = {
|
|
253
|
+
resolveCapRoute: (capName: string) => {
|
|
254
|
+
throw new CapRouteError(capName, undefined, { reason: 'no-provider', rejected: [] })
|
|
255
|
+
},
|
|
256
|
+
dispatch: async () => { throw new Error('dispatch should not be reached') },
|
|
257
|
+
}
|
|
258
|
+
const { dispatcher, resolveChildId, callCapOnChild } = localDispatcherFake({
|
|
259
|
+
capName: 'stream-catalog',
|
|
260
|
+
deviceId: 7,
|
|
261
|
+
childId: 'child-reolink',
|
|
262
|
+
})
|
|
263
|
+
|
|
264
|
+
const handler = createParentUnownedCallHandler({
|
|
265
|
+
getResolver: () => resolver as unknown as CapRouteResolver,
|
|
266
|
+
broker,
|
|
267
|
+
nodeRegistry: nodeRegistryFake(), // empty — hub-local child is NOT in HubNodeRegistry
|
|
268
|
+
getLocalDispatcher: () => dispatcher,
|
|
269
|
+
})
|
|
270
|
+
|
|
271
|
+
const result = await handler({ capName: 'stream-catalog', method: 'getCatalog', args: { deviceId: 7 } })
|
|
272
|
+
expect(result).toEqual({ routedOverUds: 'child-reolink', capName: 'stream-catalog' })
|
|
273
|
+
expect(resolveChildId).toHaveBeenCalledWith('stream-catalog', 7)
|
|
274
|
+
expect(callCapOnChild).toHaveBeenCalledWith('child-reolink', {
|
|
275
|
+
capName: 'stream-catalog',
|
|
276
|
+
method: 'getCatalog',
|
|
277
|
+
args: { deviceId: 7 },
|
|
278
|
+
deviceId: 7,
|
|
279
|
+
})
|
|
280
|
+
expect(broker.call).not.toHaveBeenCalled()
|
|
281
|
+
})
|
|
282
|
+
|
|
283
|
+
it('remote owner: hub-local dispatcher misses → pinned broker call', async () => {
|
|
284
|
+
const broker = hubBrokerFake()
|
|
285
|
+
const resolver = {
|
|
286
|
+
resolveCapRoute: (capName: string) => {
|
|
287
|
+
throw new CapRouteError(capName, undefined, { reason: 'no-provider', rejected: [] })
|
|
288
|
+
},
|
|
289
|
+
dispatch: async () => { throw new Error('dispatch should not be reached') },
|
|
290
|
+
}
|
|
291
|
+
const { dispatcher, callCapOnChild } = localDispatcherFake() // no local owner
|
|
292
|
+
const owner: NodeNativeCapEntry = { nodeId: 'agent', addonId: 'reolink', capName: 'stream-catalog', deviceId: 7 }
|
|
293
|
+
|
|
294
|
+
const handler = createParentUnownedCallHandler({
|
|
295
|
+
getResolver: () => resolver as unknown as CapRouteResolver,
|
|
296
|
+
broker,
|
|
297
|
+
nodeRegistry: nodeRegistryFake({ 7: [owner] }),
|
|
298
|
+
getLocalDispatcher: () => dispatcher,
|
|
299
|
+
})
|
|
300
|
+
|
|
301
|
+
await handler({ capName: 'stream-catalog', method: 'getCatalog', args: { deviceId: 7 } }).catch(() => undefined)
|
|
302
|
+
|
|
303
|
+
expect(callCapOnChild).not.toHaveBeenCalled()
|
|
304
|
+
const callArgs = (broker.call as ReturnType<typeof vi.fn>).mock.calls[0]
|
|
305
|
+
expect(callArgs[2]).toEqual({ nodeID: 'agent' })
|
|
306
|
+
})
|
|
307
|
+
|
|
308
|
+
it('no local dispatcher (getter returns null): behaves as before — HubNodeRegistry → broker', async () => {
|
|
309
|
+
const broker = hubBrokerFake()
|
|
310
|
+
const resolver = {
|
|
311
|
+
resolveCapRoute: (capName: string) => {
|
|
312
|
+
throw new CapRouteError(capName, undefined, { reason: 'no-provider', rejected: [] })
|
|
313
|
+
},
|
|
314
|
+
dispatch: async () => { throw new Error('dispatch should not be reached') },
|
|
315
|
+
}
|
|
316
|
+
const owner: NodeNativeCapEntry = { nodeId: 'agent', addonId: 'reolink', capName: 'stream-catalog', deviceId: 7 }
|
|
317
|
+
|
|
318
|
+
const handler = createParentUnownedCallHandler({
|
|
319
|
+
getResolver: () => resolver as unknown as CapRouteResolver,
|
|
320
|
+
broker,
|
|
321
|
+
nodeRegistry: nodeRegistryFake({ 7: [owner] }),
|
|
322
|
+
getLocalDispatcher: () => null,
|
|
323
|
+
})
|
|
324
|
+
|
|
325
|
+
await handler({ capName: 'stream-catalog', method: 'getCatalog', args: { deviceId: 7 } }).catch(() => undefined)
|
|
326
|
+
const callArgs = (broker.call as ReturnType<typeof vi.fn>).mock.calls[0]
|
|
327
|
+
expect(callArgs[2]).toEqual({ nodeID: 'agent' })
|
|
328
|
+
})
|
|
329
|
+
})
|
|
@@ -49,12 +49,17 @@ interface RegisterNodeDriver {
|
|
|
49
49
|
createCapabilityProxy: (capabilityName: string, nodeId: string) => Record<string, (params: unknown) => Promise<unknown>> | null
|
|
50
50
|
}
|
|
51
51
|
|
|
52
|
+
/** Build a harness whose declared caps are SINGLETON, for active-provider preference tests. */
|
|
53
|
+
function createSingletonHarness(capNames: readonly string[]): Harness {
|
|
54
|
+
return createHarness(capNames, 'singleton')
|
|
55
|
+
}
|
|
56
|
+
|
|
52
57
|
/** A minimal real `CapabilityDefinition` so `getDefinition`/`expandCapMethods` resolve. */
|
|
53
|
-
function makeCapDef(name: string): CapabilityDefinition {
|
|
58
|
+
function makeCapDef(name: string, mode: 'collection' | 'singleton' = 'collection'): CapabilityDefinition {
|
|
54
59
|
return {
|
|
55
60
|
name,
|
|
56
61
|
scope: 'system',
|
|
57
|
-
mode
|
|
62
|
+
mode,
|
|
58
63
|
methods: {
|
|
59
64
|
ping: {
|
|
60
65
|
input: z.object({}),
|
|
@@ -93,10 +98,10 @@ interface Harness {
|
|
|
93
98
|
* The broker is never started, so there is nothing to stop in teardown —
|
|
94
99
|
* no `afterEach` teardown is needed.
|
|
95
100
|
*/
|
|
96
|
-
function createHarness(capNames: readonly string[]): Harness {
|
|
101
|
+
function createHarness(capNames: readonly string[], mode: 'collection' | 'singleton' = 'collection'): Harness {
|
|
97
102
|
const registry = new CapabilityRegistry(makeLogger())
|
|
98
103
|
for (const name of capNames) {
|
|
99
|
-
registry.declareCapability(makeCapDef(name))
|
|
104
|
+
registry.declareCapability(makeCapDef(name, mode))
|
|
100
105
|
}
|
|
101
106
|
// Boot-complete state — `getAllProviders` returns [] until `ready()`.
|
|
102
107
|
registry.ready()
|
|
@@ -227,3 +232,33 @@ describe('MoleculerService.applyNodeManifest — re-handshake idempotency', () =
|
|
|
227
232
|
expect(harness.driver.createCapabilityProxy('cap-alpha', 'hub/reolink')).not.toBeNull()
|
|
228
233
|
})
|
|
229
234
|
})
|
|
235
|
+
|
|
236
|
+
describe('MoleculerService.applyNodeManifest — singleton local-first preference (UDS regression)', () => {
|
|
237
|
+
// A `placement: 'any-node'` singleton (e.g. `pipeline-executor`) can register
|
|
238
|
+
// on BOTH the hub-local forked child AND a remote agent. The hub must resolve
|
|
239
|
+
// its OWN local provider (reachable over UDS) — never the agent's proxy, whose
|
|
240
|
+
// callFn routes over Moleculer to a UDS-only agent runner that no longer hosts
|
|
241
|
+
// the service ("not found on <agent>"). First-registered-wins picked the agent.
|
|
242
|
+
const LOCAL = 'hub/detection-pipeline'
|
|
243
|
+
const REMOTE = 'dev-agent-0/detection-pipeline'
|
|
244
|
+
const singleton: (nodeId: string) => RegisterNodeParams = (nodeId) => ({
|
|
245
|
+
nodeId,
|
|
246
|
+
addons: [{ addonId: 'detection-pipeline', capabilities: ['pipeline-executor'] }],
|
|
247
|
+
})
|
|
248
|
+
|
|
249
|
+
it('prefers the hub-local provider when the REMOTE one registered first', () => {
|
|
250
|
+
const { driver, registry } = createSingletonHarness(['pipeline-executor'])
|
|
251
|
+
driver.onRegisterNode(singleton(REMOTE)) // agent first → would win under first-wins
|
|
252
|
+
driver.onRegisterNode(singleton(LOCAL)) // hub-local second
|
|
253
|
+
// Active provider must be the hub-local key (bare addonId, no '@').
|
|
254
|
+
expect(registry.getSingletonAddonId('pipeline-executor')).toBe('detection-pipeline')
|
|
255
|
+
expect(registry.getSingletonAddonId('pipeline-executor')).not.toContain('@')
|
|
256
|
+
})
|
|
257
|
+
|
|
258
|
+
it('keeps the hub-local provider active when a REMOTE one registers afterwards', () => {
|
|
259
|
+
const { driver, registry } = createSingletonHarness(['pipeline-executor'])
|
|
260
|
+
driver.onRegisterNode(singleton(LOCAL)) // hub-local first
|
|
261
|
+
driver.onRegisterNode(singleton(REMOTE)) // agent second must NOT steal active
|
|
262
|
+
expect(registry.getSingletonAddonId('pipeline-executor')).toBe('detection-pipeline')
|
|
263
|
+
})
|
|
264
|
+
})
|