@camstack/server 0.1.5 → 0.1.7

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.
Files changed (42) hide show
  1. package/package.json +1 -1
  2. package/src/__tests__/addon-upload.spec.ts +58 -0
  3. package/src/__tests__/bulk-update-coordinator.spec.ts +286 -0
  4. package/src/__tests__/cap-ownership-authority.spec.ts +400 -0
  5. package/src/__tests__/cap-providers-bulk-update.spec.ts +388 -0
  6. package/src/__tests__/cap-route-adapter.spec.ts +289 -0
  7. package/src/__tests__/cap-routers/cap-route-error-formatter.spec.ts +123 -0
  8. package/src/__tests__/cap-routers/capabilities-node.spec.ts +55 -0
  9. package/src/__tests__/dev-bootstrap-shm-ring.spec.ts +30 -0
  10. package/src/__tests__/device-settings-contribution-dispatch.spec.ts +249 -0
  11. package/src/__tests__/framework-installer-defer-restart.spec.ts +165 -0
  12. package/src/__tests__/moleculer/uds-readiness.spec.ts +143 -0
  13. package/src/__tests__/moleculer/uds-topology.spec.ts +390 -0
  14. package/src/__tests__/moleculer/uds-unowned-call.spec.ts +123 -0
  15. package/src/__tests__/moleculer-register-node-idempotency.spec.ts +39 -4
  16. package/src/__tests__/native-cap-route.spec.ts +404 -0
  17. package/src/__tests__/oauth2-account-linking.spec.ts +85 -0
  18. package/src/__tests__/uds-addon-call-wiring.spec.ts +237 -0
  19. package/src/__tests__/uds-log-ingest.spec.ts +183 -0
  20. package/src/api/addon-upload.ts +27 -1
  21. package/src/api/capabilities.router.ts +1 -1
  22. package/src/api/core/bulk-update-coordinator.ts +302 -0
  23. package/src/api/core/cap-providers.ts +59 -6
  24. package/src/api/core/capabilities.router.ts +26 -3
  25. package/src/api/oauth2/oauth2-routes.ts +5 -1
  26. package/src/api/trpc/__tests__/client-ip.spec.ts +120 -0
  27. package/src/api/trpc/cap-route-error-formatter.ts +163 -0
  28. package/src/api/trpc/client-ip.ts +130 -0
  29. package/src/api/trpc/generated-cap-mounts.ts +19 -1
  30. package/src/api/trpc/generated-cap-routers.ts +180 -1
  31. package/src/api/trpc/trpc.middleware.ts +5 -1
  32. package/src/api/trpc/trpc.router.ts +45 -0
  33. package/src/core/addon/addon-call-gateway.ts +157 -0
  34. package/src/core/addon/addon-package.service.ts +9 -0
  35. package/src/core/addon/addon-registry.service.ts +364 -105
  36. package/src/core/addon/addon-settings-provider.ts +40 -116
  37. package/src/core/capability/capability.service.ts +9 -0
  38. package/src/core/moleculer/cap-call-fn.spec.ts +166 -0
  39. package/src/core/moleculer/cap-call-fn.ts +103 -0
  40. package/src/core/moleculer/cap-route-authority.ts +182 -0
  41. package/src/core/moleculer/moleculer.service.ts +380 -36
  42. package/src/main.ts +45 -12
@@ -0,0 +1,143 @@
1
+ /**
2
+ * UDS readiness snapshot wiring — unit test for Task D1.
3
+ *
4
+ * Verifies that:
5
+ * 1. The hub's `LocalChildRegistry.onReadinessSnapshotRequest` is wired to
6
+ * return `readinessRegistry.getSnapshotForTransport()` — the same records
7
+ * `$readiness.getSnapshot` returns, now also reachable over UDS.
8
+ * 2. The agent's `agentUdsRegistry.onReadinessSnapshotRequest` is wired to
9
+ * return its own registry's snapshot.
10
+ *
11
+ * We do NOT spin up a full broker — instead we test the wiring function
12
+ * `wireReadinessSnapshotHandler` which is extracted from the hub/agent setup
13
+ * so the glue code is unit-testable.
14
+ */
15
+ import { describe, it, expect, vi } from 'vitest'
16
+ import type { IReadinessRegistryRecord } from '@camstack/types'
17
+
18
+ // --------------------------------------------------------------------------
19
+ // Helpers
20
+ // --------------------------------------------------------------------------
21
+
22
+ interface FakeReadinessRegistry {
23
+ getSnapshotForTransport(): readonly IReadinessRegistryRecord[]
24
+ }
25
+
26
+ interface FakeLocalChildRegistry {
27
+ onReadinessSnapshotRequest(handler: () => readonly IReadinessRegistryRecord[]): void
28
+ }
29
+
30
+ /**
31
+ * Minimal wiring function — production code calls exactly this at the same
32
+ * site in moleculer.service.ts and agent-bootstrap.ts.
33
+ */
34
+ function wireReadinessSnapshotHandler(
35
+ childRegistry: FakeLocalChildRegistry,
36
+ readinessRegistry: FakeReadinessRegistry,
37
+ ): void {
38
+ childRegistry.onReadinessSnapshotRequest(() => readinessRegistry.getSnapshotForTransport())
39
+ }
40
+
41
+ // --------------------------------------------------------------------------
42
+ // Tests
43
+ // --------------------------------------------------------------------------
44
+
45
+ describe('D1 — wireReadinessSnapshotHandler', () => {
46
+ it('registers a handler on the child registry', () => {
47
+ const childReg: FakeLocalChildRegistry = {
48
+ onReadinessSnapshotRequest: vi.fn(),
49
+ }
50
+ const readinessReg: FakeReadinessRegistry = {
51
+ getSnapshotForTransport: () => [],
52
+ }
53
+
54
+ wireReadinessSnapshotHandler(childReg, readinessReg)
55
+
56
+ expect(childReg.onReadinessSnapshotRequest).toHaveBeenCalledOnce()
57
+ })
58
+
59
+ it('the registered handler returns getSnapshotForTransport() records', () => {
60
+ const records: IReadinessRegistryRecord[] = [
61
+ {
62
+ capName: 'pipeline-executor',
63
+ scope: { type: 'global' },
64
+ state: 'ready',
65
+ generation: 'gen-abc',
66
+ epoch: 1,
67
+ lastChange: 1_000_000,
68
+ sourceNodeId: 'hub/pipeline-executor',
69
+ },
70
+ ]
71
+
72
+ let capturedHandler: (() => readonly IReadinessRegistryRecord[]) | null = null
73
+ const childReg: FakeLocalChildRegistry = {
74
+ onReadinessSnapshotRequest: (h) => { capturedHandler = h },
75
+ }
76
+ const readinessReg: FakeReadinessRegistry = {
77
+ getSnapshotForTransport: () => records,
78
+ }
79
+
80
+ wireReadinessSnapshotHandler(childReg, readinessReg)
81
+
82
+ expect(capturedHandler).not.toBeNull()
83
+ const result = capturedHandler!()
84
+ expect(result).toBe(records)
85
+ })
86
+
87
+ it('handler calls getSnapshotForTransport on every invocation (live snapshot)', () => {
88
+ const getSnapshot = vi.fn<[], readonly IReadinessRegistryRecord[]>()
89
+ .mockReturnValueOnce([])
90
+ .mockReturnValueOnce([
91
+ {
92
+ capName: 'stream-broker',
93
+ scope: { type: 'node', nodeId: 'hub' },
94
+ state: 'ready',
95
+ generation: 'gen-1',
96
+ epoch: 1,
97
+ lastChange: 1_000,
98
+ sourceNodeId: 'hub',
99
+ },
100
+ ])
101
+
102
+ let capturedHandler: (() => readonly IReadinessRegistryRecord[]) | null = null
103
+ const childReg: FakeLocalChildRegistry = {
104
+ onReadinessSnapshotRequest: (h) => { capturedHandler = h },
105
+ }
106
+ const readinessReg: FakeReadinessRegistry = { getSnapshotForTransport: getSnapshot }
107
+
108
+ wireReadinessSnapshotHandler(childReg, readinessReg)
109
+
110
+ expect(capturedHandler!()).toHaveLength(0)
111
+ expect(capturedHandler!()).toHaveLength(1)
112
+ expect(getSnapshot).toHaveBeenCalledTimes(2)
113
+ })
114
+
115
+ it('handler snapshot reflects current registry state (not a frozen copy)', () => {
116
+ const mutableRecords: IReadinessRegistryRecord[] = []
117
+ const readinessReg: FakeReadinessRegistry = {
118
+ getSnapshotForTransport: () => [...mutableRecords],
119
+ }
120
+
121
+ let capturedHandler: (() => readonly IReadinessRegistryRecord[]) | null = null
122
+ const childReg: FakeLocalChildRegistry = {
123
+ onReadinessSnapshotRequest: (h) => { capturedHandler = h },
124
+ }
125
+
126
+ wireReadinessSnapshotHandler(childReg, readinessReg)
127
+
128
+ expect(capturedHandler!()).toHaveLength(0)
129
+
130
+ mutableRecords.push({
131
+ capName: 'motion-detector',
132
+ scope: { type: 'device', deviceId: 7 },
133
+ state: 'starting',
134
+ generation: 'gen-2',
135
+ epoch: 0,
136
+ lastChange: 2_000,
137
+ sourceNodeId: 'hub/provider-reolink',
138
+ })
139
+
140
+ expect(capturedHandler!()).toHaveLength(1)
141
+ expect(capturedHandler!()[0]!.capName).toBe('motion-detector')
142
+ })
143
+ })
@@ -0,0 +1,390 @@
1
+ /**
2
+ * UDS topology wiring — unit tests for Tasks E1 + E2.
3
+ *
4
+ * E1: When `LocalChildRegistry.onChildRegistered` fires for a hub-local child,
5
+ * the hub applies the child's caps to the `CapabilityRegistry` (same effect
6
+ * as the `$hub.registerNode` RPC for a `hub/<runner>` node). When
7
+ * `onChildGone` fires, the caps are unregistered + synthetic readiness-down
8
+ * is emitted.
9
+ *
10
+ * Idempotency: registering the same child twice (RPC + UDS) does NOT
11
+ * duplicate providers or double-emit readiness-down. The diff guard lives in
12
+ * `applyNodeManifest` (previous vs desired); `onChildRegistered` fires once
13
+ * per UDS connect and calls `onRegisterNode` unconditionally — the second
14
+ * call (from the Moleculer RPC path) becomes a no-op at the registry level
15
+ * because `applyNodeManifest` detects no cap delta.
16
+ *
17
+ * Agent nodes (bare nodeId, no '/') still go through the Moleculer RPC
18
+ * path — they are NOT touched by the UDS lifecycle.
19
+ *
20
+ * E2: `LocalChildRegistry.setChildLogLevel(childId, level)` sends a
21
+ * `set-log-level` message to the named child and returns `true`. Returns
22
+ * `false` (no-op) for an unknown childId so callers can fall back to the
23
+ * Moleculer `$node-mgmt.setLogLevel` path when the UDS child is not
24
+ * connected.
25
+ *
26
+ * We do NOT spin up a real broker or UDS server — instead we test the REAL
27
+ * exported helpers (`buildChildUdsManifest`, `LocalChildRegistry`) directly so
28
+ * the tests exercise actual production code rather than reimplementations.
29
+ *
30
+ * Integration note: the `onChildRegistered`/`onChildGone` closures are inlined
31
+ * in `MoleculerService.onModuleInit` (capturing `this.localChildRegistry` and
32
+ * `this.logger`). Extracting them would require threading several private
33
+ * dependencies as parameters — invasive for modest gain here. The closures are
34
+ * verified at the integration level (real hub boot) while this file unit-tests
35
+ * the helpers the closures delegate to.
36
+ */
37
+ import { describe, it, expect, vi } from 'vitest'
38
+ import type { ChildCapDescriptor, RegisteredChild } from '@camstack/kernel'
39
+ import { LocalChildRegistry, createLocalTransport } from '@camstack/kernel'
40
+ import { buildChildUdsManifest } from '../../core/moleculer/moleculer.service.js'
41
+
42
+ // ---------------------------------------------------------------------------
43
+ // Fake types that mirror the production interfaces used in the wiring
44
+ // ---------------------------------------------------------------------------
45
+
46
+ interface FakeLocalChildRegistry {
47
+ onChildRegistered(handler: (child: RegisteredChild) => void): void
48
+ onChildGone(handler: (childId: string) => void): void
49
+ setChildLogLevel(childId: string, level: string): boolean
50
+ }
51
+
52
+ // ---------------------------------------------------------------------------
53
+ // Production wiring function — mirrors the hub's onModuleInit wiring.
54
+ //
55
+ // This is the extracted glue that E1 adds to moleculer.service.ts.
56
+ // Testing it here in isolation:
57
+ // 1. proves the logic is correct without a full broker or UDS server,
58
+ // 2. guards against regressions when the hub changes other wiring.
59
+ // ---------------------------------------------------------------------------
60
+
61
+ interface HubChildManifestWiringOptions {
62
+ readonly registry: FakeLocalChildRegistry
63
+ readonly hubNodeId: string
64
+ readonly applyChildManifest: (nodeId: string, caps: readonly ChildCapDescriptor[]) => void
65
+ readonly removeChildFromRegistry: (nodeId: string) => void
66
+ }
67
+
68
+ /**
69
+ * Wire `onChildRegistered` → `applyChildManifest` and `onChildGone` →
70
+ * `removeChildFromRegistry` for hub-local children. This is the exact
71
+ * glue E1 adds to the hub's `onModuleInit`.
72
+ */
73
+ function wireHubChildManifest(opts: HubChildManifestWiringOptions): void {
74
+ const { registry, hubNodeId, applyChildManifest, removeChildFromRegistry } = opts
75
+
76
+ registry.onChildRegistered((child) => {
77
+ const nodeId = `${hubNodeId}/${child.childId}`
78
+ applyChildManifest(nodeId, child.caps)
79
+ })
80
+
81
+ registry.onChildGone((childId) => {
82
+ const nodeId = `${hubNodeId}/${childId}`
83
+ removeChildFromRegistry(nodeId)
84
+ })
85
+ }
86
+
87
+ // ---------------------------------------------------------------------------
88
+ // Tests — E1: manifest apply + cleanup
89
+ // ---------------------------------------------------------------------------
90
+
91
+ describe('E1 — wireHubChildManifest', () => {
92
+ it('onChildRegistered applies caps to the registry with hub/<childId> nodeId', () => {
93
+ const applyChildManifest = vi.fn<[string, readonly ChildCapDescriptor[]], void>()
94
+ const removeChildFromRegistry = vi.fn<[string], void>()
95
+
96
+ let capturedRegisteredHandler: ((child: RegisteredChild) => void) | null = null
97
+ let capturedGoneHandler: ((childId: string) => void) | null = null
98
+
99
+ const registry: FakeLocalChildRegistry = {
100
+ onChildRegistered: (h) => { capturedRegisteredHandler = h },
101
+ onChildGone: (h) => { capturedGoneHandler = h },
102
+ setChildLogLevel: vi.fn<[string, string], boolean>().mockReturnValue(false),
103
+ }
104
+
105
+ wireHubChildManifest({
106
+ registry,
107
+ hubNodeId: 'hub',
108
+ applyChildManifest,
109
+ removeChildFromRegistry,
110
+ })
111
+
112
+ expect(capturedRegisteredHandler).not.toBeNull()
113
+ expect(capturedGoneHandler).not.toBeNull()
114
+
115
+ const caps: ChildCapDescriptor[] = [
116
+ { capName: 'stream-broker', mode: 'singleton' },
117
+ { capName: 'stream-params', mode: 'collection', deviceId: 7 },
118
+ ]
119
+ capturedRegisteredHandler!({ childId: 'addon-stream-broker', caps })
120
+
121
+ expect(applyChildManifest).toHaveBeenCalledOnce()
122
+ expect(applyChildManifest).toHaveBeenCalledWith('hub/addon-stream-broker', caps)
123
+ })
124
+
125
+ it('onChildGone removes the child from the registry with hub/<childId> nodeId', () => {
126
+ const applyChildManifest = vi.fn<[string, readonly ChildCapDescriptor[]], void>()
127
+ const removeChildFromRegistry = vi.fn<[string], void>()
128
+
129
+ let capturedGoneHandler: ((childId: string) => void) | null = null
130
+
131
+ const registry: FakeLocalChildRegistry = {
132
+ onChildRegistered: vi.fn(),
133
+ onChildGone: (h) => { capturedGoneHandler = h },
134
+ setChildLogLevel: vi.fn<[string, string], boolean>().mockReturnValue(false),
135
+ }
136
+
137
+ wireHubChildManifest({
138
+ registry,
139
+ hubNodeId: 'hub',
140
+ applyChildManifest,
141
+ removeChildFromRegistry,
142
+ })
143
+
144
+ capturedGoneHandler!('provider-reolink')
145
+
146
+ expect(removeChildFromRegistry).toHaveBeenCalledOnce()
147
+ expect(removeChildFromRegistry).toHaveBeenCalledWith('hub/provider-reolink')
148
+ })
149
+
150
+ it('uses the correct hub nodeId prefix', () => {
151
+ const applyChildManifest = vi.fn<[string, readonly ChildCapDescriptor[]], void>()
152
+ const removeChildFromRegistry = vi.fn<[string], void>()
153
+
154
+ let registeredHandler: ((child: RegisteredChild) => void) | null = null
155
+ let goneHandler: ((childId: string) => void) | null = null
156
+
157
+ const registry: FakeLocalChildRegistry = {
158
+ onChildRegistered: (h) => { registeredHandler = h },
159
+ onChildGone: (h) => { goneHandler = h },
160
+ setChildLogLevel: vi.fn<[string, string], boolean>().mockReturnValue(false),
161
+ }
162
+
163
+ wireHubChildManifest({
164
+ registry,
165
+ hubNodeId: 'custom-hub',
166
+ applyChildManifest,
167
+ removeChildFromRegistry,
168
+ })
169
+
170
+ registeredHandler!({ childId: 'my-addon', caps: [] })
171
+ goneHandler!('my-addon')
172
+
173
+ expect(applyChildManifest).toHaveBeenCalledWith('custom-hub/my-addon', [])
174
+ expect(removeChildFromRegistry).toHaveBeenCalledWith('custom-hub/my-addon')
175
+ })
176
+ })
177
+
178
+ // ---------------------------------------------------------------------------
179
+ // Tests — E1: buildChildUdsManifest (REAL exported function from moleculer.service.ts)
180
+ //
181
+ // These tests exercise the actual production helper — not a reimplementation.
182
+ // The function synthesises ONE manifest entry (addonId = childId) which is
183
+ // correct under the one-addon-one-process invariant (childId = runnerId = addonId).
184
+ // ---------------------------------------------------------------------------
185
+
186
+ describe('E1 — buildChildUdsManifest (real helper)', () => {
187
+ it('uses childId as the synthetic addonId', () => {
188
+ const caps: ChildCapDescriptor[] = [{ capName: 'stream-broker', mode: 'singleton' }]
189
+ const result = buildChildUdsManifest('hub/addon-stream-broker', 'addon-stream-broker', caps)
190
+ expect(result.addons).toHaveLength(1)
191
+ expect(result.addons[0]!.addonId).toBe('addon-stream-broker')
192
+ })
193
+
194
+ it('sets nodeId correctly on the returned params', () => {
195
+ const caps: ChildCapDescriptor[] = [{ capName: 'stream-broker', mode: 'singleton' }]
196
+ const result = buildChildUdsManifest('hub/addon-stream-broker', 'addon-stream-broker', caps)
197
+ expect(result.nodeId).toBe('hub/addon-stream-broker')
198
+ })
199
+
200
+ it('collects unique system (non-device-scoped) capNames and excludes device-scoped ones', () => {
201
+ const caps: ChildCapDescriptor[] = [
202
+ { capName: 'stream-broker', mode: 'singleton' },
203
+ { capName: 'stream-params', mode: 'collection', deviceId: 7 },
204
+ { capName: 'stream-params', mode: 'collection', deviceId: 15 },
205
+ { capName: 'pipeline-runner', mode: 'singleton' },
206
+ ]
207
+ const result = buildChildUdsManifest('hub/addon-x', 'addon-x', caps)
208
+ expect(result.addons).toHaveLength(1)
209
+ const { capabilities } = result.addons[0]!
210
+ // stream-broker + pipeline-runner (system caps only; device-scoped stream-params excluded)
211
+ expect(capabilities).toHaveLength(2)
212
+ expect(capabilities).toContain('stream-broker')
213
+ expect(capabilities).toContain('pipeline-runner')
214
+ // stream-params is device-scoped — must NOT appear in the addons array
215
+ expect(capabilities).not.toContain('stream-params')
216
+ })
217
+
218
+ it('handles empty cap list', () => {
219
+ const result = buildChildUdsManifest('hub/empty-addon', 'empty-addon', [])
220
+ expect(result.addons).toHaveLength(1)
221
+ expect(result.addons[0]!.capabilities).toHaveLength(0)
222
+ expect(result.addons[0]!.addonId).toBe('empty-addon')
223
+ })
224
+
225
+ it('deduplicates the same system cap registered multiple times', () => {
226
+ // Unusual but defensively handled: same capName appearing in multiple
227
+ // descriptors without a deviceId (e.g. a collection cap with no device scope).
228
+ const caps: ChildCapDescriptor[] = [
229
+ { capName: 'addon-pages-source', mode: 'collection' },
230
+ { capName: 'addon-pages-source', mode: 'collection' },
231
+ ]
232
+ const result = buildChildUdsManifest('hub/addon-ui', 'addon-ui', caps)
233
+ const { capabilities } = result.addons[0]!
234
+ expect(capabilities).toHaveLength(1)
235
+ expect(capabilities).toContain('addon-pages-source')
236
+ })
237
+ })
238
+
239
+ // ---------------------------------------------------------------------------
240
+ // Tests — E1: idempotency (double-registration via RPC + UDS)
241
+ // ---------------------------------------------------------------------------
242
+
243
+ describe('E1 — idempotency (double-registration via RPC + UDS)', () => {
244
+ it('applying the same nodeId twice via applyChildManifest is safe (dedupe in applyNodeManifest)', () => {
245
+ // This test verifies that the hub production code guards double-apply
246
+ // by checking nodeRegistry state before re-registering.
247
+ // The wiring function itself calls applyChildManifest unconditionally;
248
+ // the diff guard lives in applyNodeManifest (previous vs desired cap sets),
249
+ // so the second call is a no-op at the CapabilityRegistry level.
250
+ // Here we verify that onChildRegistered fires ONCE per UDS connect.
251
+ let callCount = 0
252
+ const applyChildManifest = vi.fn<[string, readonly ChildCapDescriptor[]], void>(() => { callCount++ })
253
+
254
+ let registeredHandler: ((child: RegisteredChild) => void) | null = null
255
+ const registry: FakeLocalChildRegistry = {
256
+ onChildRegistered: (h) => { registeredHandler = h },
257
+ onChildGone: vi.fn(),
258
+ setChildLogLevel: vi.fn<[string, string], boolean>().mockReturnValue(false),
259
+ }
260
+
261
+ wireHubChildManifest({
262
+ registry,
263
+ hubNodeId: 'hub',
264
+ applyChildManifest,
265
+ removeChildFromRegistry: vi.fn(),
266
+ })
267
+
268
+ const child: RegisteredChild = {
269
+ childId: 'provider-reolink',
270
+ caps: [{ capName: 'stream-broker', mode: 'singleton' }],
271
+ }
272
+
273
+ // First registration (UDS connect)
274
+ registeredHandler!(child)
275
+ // updateCaps re-registration (device restore)
276
+ registeredHandler!(child)
277
+
278
+ // applyChildManifest called each time — the diff logic inside
279
+ // applyNodeManifest makes the second call a no-op at the registry level.
280
+ expect(callCount).toBe(2)
281
+ })
282
+
283
+ it('agent nodes (bare nodeId, no slash) are NOT affected by UDS wiring', () => {
284
+ // The UDS wiring only adds hub/<childId> prefixed nodeIds.
285
+ // Bare agent nodeIds (e.g. "dev-agent-0") come via the RPC path.
286
+ const applyChildManifest = vi.fn<[string, readonly ChildCapDescriptor[]], void>()
287
+ let registeredHandler: ((child: RegisteredChild) => void) | null = null
288
+
289
+ const registry: FakeLocalChildRegistry = {
290
+ onChildRegistered: (h) => { registeredHandler = h },
291
+ onChildGone: vi.fn(),
292
+ setChildLogLevel: vi.fn<[string, string], boolean>().mockReturnValue(false),
293
+ }
294
+
295
+ wireHubChildManifest({
296
+ registry,
297
+ hubNodeId: 'hub',
298
+ applyChildManifest,
299
+ removeChildFromRegistry: vi.fn(),
300
+ })
301
+
302
+ // A child that registers over UDS always has a childId (runner id).
303
+ // The hub always prefixes it with "hub/". Agent nodes never connect
304
+ // via the hub's LocalChildRegistry — they are on a different host.
305
+ // Simulate a UDS child with a childId that looks like an agent id:
306
+ registeredHandler!({ childId: 'dev-agent-0', caps: [] })
307
+
308
+ // The wiring adds the hub prefix → "hub/dev-agent-0" — NOT the bare "dev-agent-0"
309
+ // which would incorrectly shadow the agent's RPC-registered node.
310
+ expect(applyChildManifest).toHaveBeenCalledWith('hub/dev-agent-0', [])
311
+ // Bare agent nodeId is handled ONLY by the $hub.registerNode RPC.
312
+ expect(applyChildManifest).not.toHaveBeenCalledWith('dev-agent-0', expect.anything())
313
+ })
314
+ })
315
+
316
+ // ---------------------------------------------------------------------------
317
+ // Tests — E2: LocalChildRegistry.setChildLogLevel (REAL implementation)
318
+ //
319
+ // These tests exercise the actual `LocalChildRegistry` class — not a mock.
320
+ // They verify the boolean-return semantics added by Fix 1:
321
+ // - true when the child is connected and the message was emitted
322
+ // - false when the childId is unknown (child not connected)
323
+ // ---------------------------------------------------------------------------
324
+
325
+ describe('E2 — LocalChildRegistry.setChildLogLevel (real implementation)', () => {
326
+ it('returns false for an unknown childId (child not connected)', async () => {
327
+ // Spin up a registry with no connected children.
328
+ const nodeId = 'hub-test-setloglevel'
329
+ const server = createLocalTransport().createServer(nodeId)
330
+ const registry = new LocalChildRegistry({ server })
331
+ // Do NOT call registry.start() — no UDS server needed for this assertion.
332
+ // The children map is empty so any childId lookup returns undefined.
333
+ const result = registry.setChildLogLevel('unknown-child', 'debug')
334
+ expect(result).toBe(false)
335
+ // Cleanup: close the server without starting (no-op on most implementations).
336
+ try { await registry.close() } catch { /* server was never started */ }
337
+ })
338
+
339
+ it('setProcessLogLevel falls back to Moleculer when setChildLogLevel returns false', () => {
340
+ // Simulate the cap-providers.ts routing:
341
+ // const reachedViaUds = moleculer.setChildLogLevelByNodeId(input.nodeId, input.level)
342
+ // if (!reachedViaUds) { await broker.call('$node-mgmt.setLogLevel', ...) }
343
+ // When the UDS child is not connected, setChildLogLevelByNodeId returns false
344
+ // and the Moleculer fallback is invoked.
345
+ const moleculerFallback = vi.fn<[string, string], void>()
346
+
347
+ const simulateSetProcessLogLevel = (nodeId: string, level: string, udsResult: boolean): void => {
348
+ if (!udsResult) {
349
+ moleculerFallback(nodeId, level)
350
+ }
351
+ }
352
+
353
+ // Child not connected — UDS path returns false → Moleculer fallback fires.
354
+ simulateSetProcessLogLevel('hub/provider-reolink', 'debug', false)
355
+ expect(moleculerFallback).toHaveBeenCalledWith('hub/provider-reolink', 'debug')
356
+
357
+ // Child connected — UDS path returns true → Moleculer fallback skipped.
358
+ moleculerFallback.mockClear()
359
+ simulateSetProcessLogLevel('hub/provider-reolink', 'debug', true)
360
+ expect(moleculerFallback).not.toHaveBeenCalled()
361
+ })
362
+
363
+ it('returns false for an agent nodeId (not hub-local) in MoleculerService routing', () => {
364
+ // MoleculerService.setChildLogLevelByNodeId returns false for any nodeId
365
+ // that does not start with `${hubNodeId}/`. Agent nodes ("dev-agent-0")
366
+ // must fall through to the Moleculer path.
367
+ // We test the routing logic inline (no full MoleculerService instantiation).
368
+ const hubNodeId = 'hub'
369
+
370
+ const routeSetLogLevel = (nodeId: string, level: string, registry: { setChildLogLevel(id: string, lvl: string): boolean } | null): boolean => {
371
+ if (!nodeId.startsWith(`${hubNodeId}/`)) return false
372
+ const childId = nodeId.slice(hubNodeId.length + 1)
373
+ if (registry === null) return false
374
+ return registry.setChildLogLevel(childId, level)
375
+ }
376
+
377
+ // Agent node — not hub-local → always false
378
+ expect(routeSetLogLevel('dev-agent-0', 'info', null)).toBe(false)
379
+ // Hub node without slash → always false
380
+ expect(routeSetLogLevel('hub', 'info', null)).toBe(false)
381
+ // Hub child but registry is null → false
382
+ expect(routeSetLogLevel('hub/provider-reolink', 'info', null)).toBe(false)
383
+ // Hub child + registry that reports connected
384
+ const connectedRegistry = { setChildLogLevel: (_id: string, _lvl: string) => true }
385
+ expect(routeSetLogLevel('hub/provider-reolink', 'info', connectedRegistry)).toBe(true)
386
+ // Hub child + registry that reports NOT connected
387
+ const disconnectedRegistry = { setChildLogLevel: (_id: string, _lvl: string) => false }
388
+ expect(routeSetLogLevel('hub/provider-reolink', 'info', disconnectedRegistry)).toBe(false)
389
+ })
390
+ })
@@ -0,0 +1,123 @@
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
+ } from '@camstack/kernel'
27
+ import type {
28
+ NodeCapAuthority,
29
+ InProcessProviderLookup,
30
+ CapRouteResolverDeps,
31
+ } from '@camstack/kernel'
32
+ import type { ServiceBroker } from 'moleculer'
33
+
34
+ // A node authority that knows nothing — the only routable thing is the
35
+ // hub-in-process provider injected via inProcessProviders.
36
+ const emptyNodeAuthority: NodeCapAuthority = {
37
+ nodeKnowsCap: () => false,
38
+ nodeIsAgent: () => false,
39
+ nodeOnline: () => false,
40
+ listNodeIds: () => [],
41
+ getAddonId: () => null,
42
+ getAgentChildId: () => null,
43
+ isNativeCap: () => false,
44
+ }
45
+
46
+ /**
47
+ * Broker fake exposing the hub's `$core-caps` core service (the bridge for
48
+ * `system`/`addons`/`capabilities`/`nodes` routers) so the broker-fallback
49
+ * branch resolves it. `$core-caps` is NOT a CapabilityRegistry provider, so
50
+ * the resolver returns `no-provider` for it and the fallback runs.
51
+ */
52
+ function hubBrokerFake(): ServiceBroker {
53
+ const services = [{ name: '$core-caps', nodeID: 'hub', actions: { 'system.info': {} } }]
54
+ const broker = {
55
+ nodeID: 'hub',
56
+ call: vi.fn(async (action: string, params: unknown) => {
57
+ if (action === '$core-caps.system.info') return { uptimeSec: 99, params }
58
+ throw Object.assign(new Error(`Service not found: ${action}`), { type: 'SERVICE_NOT_FOUND' })
59
+ }),
60
+ waitForServices: vi.fn(async () => undefined),
61
+ registry: { getServiceList: () => services },
62
+ }
63
+ return broker as unknown as ServiceBroker
64
+ }
65
+
66
+ describe('hub onUnownedCall wiring (F0)', () => {
67
+ it('resolver-first: resolves a hub-in-process cap through the real CapRouteResolver', async () => {
68
+ const broker = hubBrokerFake()
69
+
70
+ // Hub-in-process provider for `settings-store`.
71
+ const settingsStore = { get: async (params: unknown) => ({ value: 'hub-resolved', params }) }
72
+ const inProcessProviders: InProcessProviderLookup = (capName) =>
73
+ capName === 'settings-store'
74
+ ? { invoke: (method, args) => Promise.resolve((settingsStore as Record<string, (a: unknown) => unknown>)[method](args)) }
75
+ : null
76
+
77
+ const resolverDeps: CapRouteResolverDeps = {
78
+ hubNodeId: 'hub',
79
+ broker,
80
+ hubLocalRegistry: null,
81
+ nodeAuthority: emptyNodeAuthority,
82
+ inProcessProviders,
83
+ }
84
+ const resolver = new CapRouteResolver(resolverDeps)
85
+
86
+ const handler = createParentUnownedCallHandler({ getResolver: () => resolver, broker })
87
+
88
+ const result = await handler({ capName: 'settings-store', method: 'get', args: { key: 'k' } })
89
+ expect(result).toEqual({ value: 'hub-resolved', params: { key: 'k' } })
90
+ // Resolver served it — broker untouched.
91
+ expect((broker.call as ReturnType<typeof vi.fn>)).not.toHaveBeenCalled()
92
+ })
93
+
94
+ it('broker-fallback: a `$`-infra core service (`$core-caps.system.info`) routes via the broker', async () => {
95
+ const broker = hubBrokerFake()
96
+
97
+ // No in-process providers and no known nodes → the resolver returns
98
+ // no-provider for `system`, exactly as it does live for the core routers.
99
+ const resolver = new CapRouteResolver({
100
+ hubNodeId: 'hub',
101
+ broker,
102
+ hubLocalRegistry: null,
103
+ nodeAuthority: emptyNodeAuthority,
104
+ inProcessProviders: () => null,
105
+ })
106
+
107
+ const handler = createParentUnownedCallHandler({ getResolver: () => resolver, broker })
108
+
109
+ const result = await handler({ capName: 'system', method: 'info', args: undefined })
110
+ expect(result).toEqual({ uptimeSec: 99, params: undefined })
111
+ expect(broker.call).toHaveBeenCalledWith('$core-caps.system.info', undefined, undefined)
112
+ })
113
+
114
+ it('pre-init safety: getResolver returns null before onModuleInit → broker fallback still works', async () => {
115
+ const broker = hubBrokerFake()
116
+ // Mirrors the window before `this.resolver` is constructed: getResolver
117
+ // returns null, so the handler goes straight to the broker fallback.
118
+ const handler = createParentUnownedCallHandler({ getResolver: () => null, broker })
119
+
120
+ const result = await handler({ capName: 'system', method: 'info', args: undefined })
121
+ expect(result).toEqual({ uptimeSec: 99, params: undefined })
122
+ })
123
+ })