@camstack/server 0.1.3

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 (133) hide show
  1. package/.env.example +17 -0
  2. package/package.json +55 -0
  3. package/src/__tests__/addon-install-e2e.test.ts +75 -0
  4. package/src/__tests__/addon-pages-e2e.test.ts +178 -0
  5. package/src/__tests__/addon-route-session.test.ts +17 -0
  6. package/src/__tests__/addon-settings-router.spec.ts +62 -0
  7. package/src/__tests__/addon-upload.spec.ts +355 -0
  8. package/src/__tests__/agent-registry.spec.ts +162 -0
  9. package/src/__tests__/agent-status-page.spec.ts +84 -0
  10. package/src/__tests__/auth-session-cookie.test.ts +21 -0
  11. package/src/__tests__/cap-providers/cap-usage-graph.spec.ts +23 -0
  12. package/src/__tests__/cap-providers/compute-topology-categories.spec.ts +64 -0
  13. package/src/__tests__/cap-routers/_meta.spec.ts +200 -0
  14. package/src/__tests__/cap-routers/addon-settings.router.spec.ts +106 -0
  15. package/src/__tests__/cap-routers/device-manager-aggregate.router.spec.ts +142 -0
  16. package/src/__tests__/cap-routers/harness.ts +159 -0
  17. package/src/__tests__/cap-routers/metrics-provider.router.spec.ts +119 -0
  18. package/src/__tests__/cap-routers/null-provider-guard.spec.ts +66 -0
  19. package/src/__tests__/cap-routers/pipeline-executor.router.spec.ts +135 -0
  20. package/src/__tests__/cap-routers/settings-store.router.spec.ts +247 -0
  21. package/src/__tests__/capability-e2e.test.ts +386 -0
  22. package/src/__tests__/cli-e2e.test.ts +129 -0
  23. package/src/__tests__/core-cap-bridge.spec.ts +89 -0
  24. package/src/__tests__/embedded-deps-e2e.test.ts +109 -0
  25. package/src/__tests__/event-bus-proxy-router.spec.ts +72 -0
  26. package/src/__tests__/fixtures/mock-analysis-addon-a.ts +37 -0
  27. package/src/__tests__/fixtures/mock-analysis-addon-b.ts +37 -0
  28. package/src/__tests__/fixtures/mock-log-addon.ts +37 -0
  29. package/src/__tests__/fixtures/mock-storage-addon.ts +40 -0
  30. package/src/__tests__/framework-allowlist.spec.ts +95 -0
  31. package/src/__tests__/https-e2e.test.ts +118 -0
  32. package/src/__tests__/lifecycle-e2e.test.ts +140 -0
  33. package/src/__tests__/live-events-subscription.spec.ts +150 -0
  34. package/src/__tests__/moleculer-register-node-idempotency.spec.ts +229 -0
  35. package/src/__tests__/oauth2-account-linking.spec.ts +736 -0
  36. package/src/__tests__/post-boot-restart.spec.ts +161 -0
  37. package/src/__tests__/singleton-contention.test.ts +487 -0
  38. package/src/__tests__/streaming-diagnostic.test.ts +512 -0
  39. package/src/__tests__/streaming-scale.test.ts +280 -0
  40. package/src/agent-status-page.ts +121 -0
  41. package/src/api/__tests__/addons-custom.spec.ts +134 -0
  42. package/src/api/__tests__/capabilities.router.test.ts +47 -0
  43. package/src/api/addon-upload.ts +472 -0
  44. package/src/api/addons-custom.router.ts +100 -0
  45. package/src/api/auth-whoami.ts +99 -0
  46. package/src/api/bridge-addons.router.ts +120 -0
  47. package/src/api/capabilities.router.ts +226 -0
  48. package/src/api/core/__tests__/auth-router-totp.spec.ts +256 -0
  49. package/src/api/core/addon-settings.router.ts +124 -0
  50. package/src/api/core/agents.router.ts +87 -0
  51. package/src/api/core/auth.router.ts +303 -0
  52. package/src/api/core/cap-providers.ts +993 -0
  53. package/src/api/core/capabilities.router.ts +119 -0
  54. package/src/api/core/collection-preference.ts +40 -0
  55. package/src/api/core/event-bus-proxy.router.ts +45 -0
  56. package/src/api/core/hwaccel.router.ts +81 -0
  57. package/src/api/core/live-events.router.ts +60 -0
  58. package/src/api/core/logs.router.ts +162 -0
  59. package/src/api/core/notifications.router.ts +65 -0
  60. package/src/api/core/repl.router.ts +41 -0
  61. package/src/api/core/settings-backend.router.ts +142 -0
  62. package/src/api/core/stream-probe.router.ts +57 -0
  63. package/src/api/core/system-events.router.ts +116 -0
  64. package/src/api/health/health.routes.ts +123 -0
  65. package/src/api/oauth2/__tests__/oauth2-routes.spec.ts +52 -0
  66. package/src/api/oauth2/consent-page.ts +42 -0
  67. package/src/api/oauth2/oauth2-routes.ts +248 -0
  68. package/src/api/trpc/__tests__/scope-access-device.spec.ts +223 -0
  69. package/src/api/trpc/__tests__/scope-access.spec.ts +107 -0
  70. package/src/api/trpc/cap-mount-helpers.ts +225 -0
  71. package/src/api/trpc/core-cap-bridge.ts +152 -0
  72. package/src/api/trpc/generated-cap-mounts.ts +707 -0
  73. package/src/api/trpc/generated-cap-routers.ts +6340 -0
  74. package/src/api/trpc/scope-access.ts +110 -0
  75. package/src/api/trpc/trpc.context.ts +255 -0
  76. package/src/api/trpc/trpc.middleware.ts +140 -0
  77. package/src/api/trpc/trpc.router.ts +275 -0
  78. package/src/auth/session-cookie.ts +44 -0
  79. package/src/boot/boot-config.ts +278 -0
  80. package/src/boot/post-boot.service.ts +103 -0
  81. package/src/core/addon/__tests__/addon-registry-capability.test.ts +53 -0
  82. package/src/core/addon/addon-package.service.ts +1684 -0
  83. package/src/core/addon/addon-registry.service.ts +2926 -0
  84. package/src/core/addon/addon-search.service.ts +90 -0
  85. package/src/core/addon/addon-settings-provider.ts +276 -0
  86. package/src/core/addon/addon.tokens.ts +2 -0
  87. package/src/core/addon-bridge/addon-bridge.service.ts +125 -0
  88. package/src/core/addon-pages/addon-pages.service.spec.ts +117 -0
  89. package/src/core/addon-pages/addon-pages.service.ts +80 -0
  90. package/src/core/addon-widgets/addon-widgets.service.ts +92 -0
  91. package/src/core/agent/agent-registry.service.ts +507 -0
  92. package/src/core/auth/auth.service.spec.ts +88 -0
  93. package/src/core/auth/auth.service.ts +8 -0
  94. package/src/core/capability/capability.service.ts +57 -0
  95. package/src/core/config/config.schema.ts +3 -0
  96. package/src/core/config/config.service.spec.ts +175 -0
  97. package/src/core/config/config.service.ts +7 -0
  98. package/src/core/events/event-bus.service.spec.ts +212 -0
  99. package/src/core/events/event-bus.service.ts +85 -0
  100. package/src/core/feature/feature.service.spec.ts +96 -0
  101. package/src/core/feature/feature.service.ts +8 -0
  102. package/src/core/lifecycle/lifecycle-state-machine.spec.ts +168 -0
  103. package/src/core/lifecycle/lifecycle-state-machine.ts +3 -0
  104. package/src/core/logging/log-ring-buffer.ts +3 -0
  105. package/src/core/logging/logging.service.spec.ts +247 -0
  106. package/src/core/logging/logging.service.ts +129 -0
  107. package/src/core/logging/scoped-logger.ts +3 -0
  108. package/src/core/moleculer/moleculer.service.ts +612 -0
  109. package/src/core/network/network-quality.service.spec.ts +47 -0
  110. package/src/core/network/network-quality.service.ts +5 -0
  111. package/src/core/notification/notification-wrapper.service.ts +36 -0
  112. package/src/core/notification/toast-wrapper.service.ts +31 -0
  113. package/src/core/provider/provider.tokens.ts +1 -0
  114. package/src/core/repl/repl-engine.service.spec.ts +417 -0
  115. package/src/core/repl/repl-engine.service.ts +156 -0
  116. package/src/core/storage/fs-storage-backend.spec.ts +70 -0
  117. package/src/core/storage/fs-storage-backend.ts +3 -0
  118. package/src/core/storage/settings-store.spec.ts +213 -0
  119. package/src/core/storage/settings-store.ts +2 -0
  120. package/src/core/storage/sql-schema.spec.ts +140 -0
  121. package/src/core/storage/sql-schema.ts +3 -0
  122. package/src/core/storage/storage-location-manager.spec.ts +121 -0
  123. package/src/core/storage/storage-location-manager.ts +3 -0
  124. package/src/core/storage/storage.service.spec.ts +73 -0
  125. package/src/core/storage/storage.service.ts +3 -0
  126. package/src/core/streaming/stream-probe.service.ts +212 -0
  127. package/src/core/topology/topology-emitter.service.ts +101 -0
  128. package/src/launcher.ts +309 -0
  129. package/src/main.ts +1049 -0
  130. package/src/manual-boot.ts +322 -0
  131. package/tsconfig.build.json +8 -0
  132. package/tsconfig.json +21 -0
  133. package/vitest.config.ts +26 -0
@@ -0,0 +1,140 @@
1
+
2
+ import { describe, it, expect, vi } from 'vitest'
3
+ import { CapabilityRegistry, isInfraCapability } from '@camstack/kernel'
4
+ import type { IScopedLogger } from '@camstack/types'
5
+
6
+ // ─── Mock helpers ────────────────────────────────────────────────────
7
+ //
8
+ // PipelineOrchestrator phase-transition + result-flow tests used to live
9
+ // in this file (importing from the now-deleted server/backend/src/core/
10
+ // orchestrator/ directory). They have been migrated into the runner package
11
+ // (`packages/addon-pipeline-runner/src/__tests__/runner.spec.ts`) which
12
+ // is the canonical home of the scheduler core.
13
+
14
+ function createMockLogger(): IScopedLogger {
15
+ return {
16
+ info: vi.fn(),
17
+ warn: vi.fn(),
18
+ error: vi.fn(),
19
+ debug: vi.fn(),
20
+ } as unknown as IScopedLogger
21
+ }
22
+
23
+ function createRegistry(configReader?: (cap: string) => string | undefined): CapabilityRegistry {
24
+ const registry = new CapabilityRegistry(createMockLogger())
25
+ if (configReader) {
26
+ registry.setConfigReader(configReader)
27
+ }
28
+ registry.ready()
29
+ return registry
30
+ }
31
+
32
+ // ─── Tests ────────────────────────────────────────────────────────────
33
+
34
+ describe('Full Lifecycle E2E (mock, no external services)', () => {
35
+ describe('CapabilityRegistry boot sequence', () => {
36
+ it('registers singleton and collection capabilities correctly', () => {
37
+ const registry = createRegistry()
38
+
39
+ registry.declareCapability({ name: 'storage', scope: 'system', mode: 'singleton', methods: {} })
40
+ registry.declareCapability({ name: 'log-destination', scope: 'system', mode: 'collection', methods: {} })
41
+ registry.declareCapability({ name: 'streaming-engine', scope: 'system', mode: 'singleton', methods: {} })
42
+ registry.declareCapability({ name: 'analysis-pipeline', scope: 'system', mode: 'singleton', methods: {} })
43
+ registry.declareCapability({ name: 'device-provider', scope: 'system', mode: 'collection', methods: {} })
44
+ registry.declareCapability({ name: 'admin-ui', scope: 'system', mode: 'singleton', methods: {} })
45
+
46
+ const mockStorage = { id: 'sqlite' }
47
+ const mockLogger = { id: 'winston' }
48
+ const mockStreaming = { id: 'go2rtc' }
49
+ const mockAnalysis = { id: 'pipeline-analysis' }
50
+ const mockProvider = { id: 'frigate' }
51
+
52
+ registry.registerProvider('storage', 'sqlite-storage', mockStorage)
53
+ registry.registerProvider('log-destination', 'winston-logging', mockLogger)
54
+ registry.registerProvider('streaming-engine', 'go2rtc', mockStreaming)
55
+ registry.registerProvider('analysis-pipeline', 'pipeline-analysis', mockAnalysis)
56
+ registry.registerProvider('device-provider', 'provider-frigate', mockProvider)
57
+
58
+ expect(registry.getSingleton('storage')).toBe(mockStorage)
59
+ expect(registry.getCollection('log-destination')).toEqual([mockLogger])
60
+ expect(registry.getSingleton('streaming-engine')).toBe(mockStreaming)
61
+ expect(registry.getSingleton('analysis-pipeline')).toBe(mockAnalysis)
62
+ expect(registry.getCollection('device-provider')).toEqual([mockProvider])
63
+
64
+ const caps = registry.listCapabilities()
65
+ expect(caps.length).toBeGreaterThanOrEqual(5)
66
+ })
67
+
68
+ it('infra capabilities are identified correctly', () => {
69
+ expect(isInfraCapability('storage')).toBe(true)
70
+ expect(isInfraCapability('log-destination')).toBe(true)
71
+ expect(isInfraCapability('streaming-engine')).toBe(false)
72
+ })
73
+ })
74
+
75
+ // Note: PipelineOrchestrator phase transition + detection result flow tests
76
+ // moved to packages/addon-pipeline-runner/src/__tests__/runner.spec.ts
77
+ // when the scheduler was extracted into the addon-pipeline-runner package.
78
+
79
+ describe('Singleton swap', () => {
80
+ it('swapping analysis provider changes getSingleton result', async () => {
81
+ const registry = createRegistry()
82
+ registry.declareCapability({ name: 'analysis-pipeline', scope: 'system', mode: 'singleton', methods: {} })
83
+
84
+ const analysisA = { id: 'analysis-a', processFrame: vi.fn() }
85
+ const analysisB = { id: 'analysis-b', processFrame: vi.fn() }
86
+
87
+ registry.registerProvider('analysis-pipeline', 'addon-a', analysisA)
88
+ expect(registry.getSingleton('analysis-pipeline')).toBe(analysisA)
89
+
90
+ registry.registerProvider('analysis-pipeline', 'addon-b', analysisB)
91
+
92
+ await registry.setActiveSingleton('analysis-pipeline', 'addon-b', true)
93
+ expect(registry.getSingleton('analysis-pipeline')).toBe(analysisB)
94
+ })
95
+ })
96
+
97
+ describe('Collection capability add/remove', () => {
98
+ it('adding and removing device providers', () => {
99
+ const registry = createRegistry()
100
+ registry.declareCapability({ name: 'device-provider', scope: 'system', mode: 'collection', methods: {} })
101
+
102
+ const frigate = { id: 'frigate', type: 'frigate' }
103
+ const onvif = { id: 'onvif', type: 'onvif' }
104
+
105
+ registry.registerProvider('device-provider', 'addon-frigate', frigate)
106
+ expect(registry.getCollection('device-provider')).toHaveLength(1)
107
+
108
+ registry.registerProvider('device-provider', 'addon-onvif', onvif)
109
+ expect(registry.getCollection('device-provider')).toHaveLength(2)
110
+
111
+ registry.unregisterProvider('device-provider', 'addon-frigate')
112
+ expect(registry.getCollection('device-provider')).toHaveLength(1)
113
+ })
114
+ })
115
+
116
+ describe('Capability introspection', () => {
117
+ it('listCapabilities returns full info', () => {
118
+ const registry = createRegistry()
119
+ registry.declareCapability({ name: 'storage', scope: 'system', mode: 'singleton', methods: {} })
120
+ registry.declareCapability({ name: 'device-provider', scope: 'system', mode: 'collection', methods: {} })
121
+
122
+ registry.registerProvider('storage', 'sqlite', { id: 'sqlite' })
123
+ registry.registerProvider('device-provider', 'frigate', { id: 'frigate' })
124
+ registry.registerProvider('device-provider', 'onvif', { id: 'onvif' })
125
+
126
+ const caps = registry.listCapabilities()
127
+
128
+ const storage = caps.find((c) => c.name === 'storage')
129
+ expect(storage).toBeDefined()
130
+ expect(storage!.mode).toBe('singleton')
131
+ expect(storage!.providers).toContain('sqlite')
132
+ expect(storage!.activeProvider).toBe('sqlite')
133
+
134
+ const providers = caps.find((c) => c.name === 'device-provider')
135
+ expect(providers).toBeDefined()
136
+ expect(providers!.mode).toBe('collection')
137
+ expect(providers!.providers).toHaveLength(2)
138
+ })
139
+ })
140
+ })
@@ -0,0 +1,150 @@
1
+ /**
2
+ * Live events subscription — unit + in-process e2e test.
3
+ *
4
+ * Proves that an event emitted on `EventBusService` is delivered to a
5
+ * consumer that went through `live.onEvent` → `iterableSubscription`.
6
+ * This is the same path UI components use via the WS client (AlertBell,
7
+ * EngineTab). If this test passes but the UI still doesn't receive
8
+ * events, the bug is downstream in the WS adapter, not in the routing.
9
+ */
10
+ import { describe, it, expect, vi } from 'vitest'
11
+ import { SystemEventBus } from '@camstack/core'
12
+ import { createLiveEventsRouter } from '../api/core/live-events.router.js'
13
+ import { makeCtx } from './cap-routers/harness.js'
14
+ import type { EventBusService } from '../core/events/event-bus.service.js'
15
+
16
+ function sleep(ms: number): Promise<void> {
17
+ return new Promise((resolve) => setTimeout(resolve, ms))
18
+ }
19
+
20
+ function makeEvent(category: string, data: Record<string, unknown> = {}) {
21
+ return {
22
+ id: `evt-${Math.random().toString(36).slice(2, 8)}`,
23
+ timestamp: new Date(),
24
+ source: { type: 'pipeline' as const, id: 'benchmark' },
25
+ category,
26
+ data: { type: `pipeline.${category}`, ...data },
27
+ }
28
+ }
29
+
30
+ describe('live events subscription', () => {
31
+ it('delivers events emitted AFTER subscription', async () => {
32
+ const bus = new SystemEventBus() as unknown as EventBusService
33
+ const router = createLiveEventsRouter(
34
+ bus,
35
+ { getDeviceRegistry: () => ({ getAll: () => [], getAllForAddon: () => [] }) } as never,
36
+ )
37
+ const caller = router.createCaller(makeCtx('admin'))
38
+
39
+ const iter = await caller.onEvent({ category: 'benchmark.progress' })
40
+
41
+ // Collect received events concurrently while emitting.
42
+ const received: Array<{ category: string; data: Record<string, unknown> }> = []
43
+ const collector = (async () => {
44
+ for await (const ev of iter) {
45
+ received.push({ category: ev.category, data: ev.data })
46
+ if (received.length >= 3) return
47
+ }
48
+ })()
49
+
50
+ // Give the async generator a tick to wire up its internal promise.
51
+ await sleep(10)
52
+
53
+ bus.emit(makeEvent('benchmark.progress', { iteration: 1, totalMs: 50 }))
54
+ bus.emit(makeEvent('benchmark.progress', { iteration: 2, totalMs: 48 }))
55
+ bus.emit(makeEvent('other.category', { ignored: true }))
56
+ bus.emit(makeEvent('benchmark.progress', { iteration: 3, totalMs: 52 }))
57
+
58
+ await collector
59
+
60
+ expect(received).toHaveLength(3)
61
+ expect(received.map((r) => r.data.iteration)).toEqual([1, 2, 3])
62
+ })
63
+
64
+ it('filters events that do not match the requested category', async () => {
65
+ const bus = new SystemEventBus() as unknown as EventBusService
66
+ const router = createLiveEventsRouter(
67
+ bus,
68
+ { getDeviceRegistry: () => ({ getAll: () => [], getAllForAddon: () => [] }) } as never,
69
+ )
70
+ const caller = router.createCaller(makeCtx('admin'))
71
+
72
+ const iter = await caller.onEvent({ category: 'benchmark.progress' })
73
+
74
+ const received: string[] = []
75
+ const stop = vi.fn()
76
+ const collector = (async () => {
77
+ for await (const ev of iter) {
78
+ received.push(ev.category)
79
+ if (received.length >= 2) { stop(); return }
80
+ }
81
+ })()
82
+
83
+ await sleep(10)
84
+
85
+ // Emit a mix — only 'benchmark.progress' should pass.
86
+ bus.emit(makeEvent('alert.created', {}))
87
+ bus.emit(makeEvent('benchmark.progress', { iteration: 1 }))
88
+ bus.emit(makeEvent('benchmark.heartbeat', {}))
89
+ bus.emit(makeEvent('alert.updated', {}))
90
+ bus.emit(makeEvent('benchmark.progress', { iteration: 2 }))
91
+
92
+ await collector
93
+
94
+ expect(received).toEqual(['benchmark.progress', 'benchmark.progress'])
95
+ expect(stop).toHaveBeenCalledOnce()
96
+ })
97
+
98
+ it('receives recent events via recentSystemEvents query', async () => {
99
+ const bus = new SystemEventBus() as unknown as EventBusService
100
+ const router = createLiveEventsRouter(
101
+ bus,
102
+ { getDeviceRegistry: () => ({ getAll: () => [], getAllForAddon: () => [] }) } as never,
103
+ )
104
+ const caller = router.createCaller(makeCtx('admin'))
105
+
106
+ bus.emit(makeEvent('benchmark.progress', { iteration: 1 }))
107
+ bus.emit(makeEvent('alert.created', {}))
108
+ bus.emit(makeEvent('benchmark.progress', { iteration: 2 }))
109
+
110
+ const recent = await caller.recentSystemEvents({ category: 'benchmark.progress', limit: 10 })
111
+ expect(recent).toHaveLength(2)
112
+ // recentSystemEvents returns newest-first
113
+ const iterations = recent.map((e) => e.data.iteration as number).sort((a, b) => a - b)
114
+ expect(iterations).toEqual([1, 2])
115
+ })
116
+
117
+ it('delivers bursts — multiple events queued before the consumer drains', async () => {
118
+ // Reproduces the benchmark scenario where runBenchmark emits 3 events
119
+ // back-to-back; the subscription should buffer them and deliver all 3.
120
+ const bus = new SystemEventBus() as unknown as EventBusService
121
+ const router = createLiveEventsRouter(
122
+ bus,
123
+ { getDeviceRegistry: () => ({ getAll: () => [], getAllForAddon: () => [] }) } as never,
124
+ )
125
+ const caller = router.createCaller(makeCtx('admin'))
126
+
127
+ const iter = await caller.onEvent({ category: 'benchmark.progress' })
128
+
129
+ const received: number[] = []
130
+ const collector = (async () => {
131
+ for await (const ev of iter) {
132
+ received.push(ev.data.iteration as number)
133
+ if (received.length >= 5) return
134
+ }
135
+ })()
136
+
137
+ // Let the generator body run through its first `await`, registering the
138
+ // bus subscription. Without this tick subscribe() hasn't attached yet.
139
+ await sleep(20)
140
+
141
+ // Emit 5 events synchronously — no awaits between.
142
+ for (let i = 1; i <= 5; i++) {
143
+ bus.emit(makeEvent('benchmark.progress', { iteration: i }))
144
+ }
145
+
146
+ await collector
147
+
148
+ expect(received).toEqual([1, 2, 3, 4, 5])
149
+ })
150
+ })
@@ -0,0 +1,229 @@
1
+ /**
2
+ * MoleculerService.applyNodeManifest re-handshake idempotency (D3).
3
+ *
4
+ * Pre-existing bug: `applyNodeManifest` ran on EVERY `$hub.registerNode`
5
+ * and called `registry.registerProvider(cap, key, proxy)` unconditionally
6
+ * for every cap in the manifest. The D3 protocol legitimately re-handshakes
7
+ * (a node sends its COMPLETE manifest again — e.g. the post-device-restore
8
+ * `nativeCaps` re-handshake). `CapabilityRegistry.registerProvider` throws
9
+ * on a duplicate `(cap, addonId)` pair (the guard is CORRECT — it catches an
10
+ * addon double-`initialize()`), so the second handshake threw, the
11
+ * registering node's retry loop retried forever, and the cluster entered a
12
+ * registration storm.
13
+ *
14
+ * The fix makes `applyNodeManifest` diff-based: it honours the CLAUDE.md
15
+ * invariant "`registerNode` replaces the node's entire cap set atomically".
16
+ * A re-handshake with the SAME manifest is a no-op; a re-handshake that
17
+ * drops a cap unregisters exactly that cap; a re-handshake that adds a cap
18
+ * registers only the new one. No throw, no churn.
19
+ *
20
+ * These specs drive the genuine `MoleculerService` registration path twice
21
+ * for the same `nodeId` against a REAL `CapabilityRegistry` (with its real
22
+ * duplicate guard) and assert idempotency.
23
+ */
24
+ import { describe, it, expect, beforeEach } from 'vitest'
25
+ import { z } from 'zod'
26
+ import { CapabilityRegistry } from '@camstack/kernel'
27
+ import type { RegisterNodeParams } from '@camstack/kernel'
28
+ import type { CapabilityDefinition, IScopedLogger, SystemEvent } from '@camstack/types'
29
+ import { MoleculerService } from '../core/moleculer/moleculer.service.js'
30
+ import type { EventBusService } from '../core/events/event-bus.service.js'
31
+ import type { ConfigService } from '../core/config/config.service.js'
32
+ import type { LoggingService } from '../core/logging/logging.service.js'
33
+ import type { CapabilityService } from '../core/capability/capability.service.js'
34
+ import type { StreamProbeService } from '../core/streaming/stream-probe.service.js'
35
+
36
+ /**
37
+ * Test-only view of `MoleculerService` exposing:
38
+ * - the genuine private registration entrypoint `onRegisterNode` — the
39
+ * same closure the `$hub.registerNode` Moleculer action invokes in
40
+ * production. Driving it directly exercises the real
41
+ * `nodeRegistry.registerNode` + `applyNodeManifest` path without
42
+ * standing up a TCP broker.
43
+ * - the public `createCapabilityProxy` method used to assert whether a
44
+ * dropped capability's call-routing entry was correctly removed from
45
+ * `nodeCallFns` after a reduced re-handshake.
46
+ */
47
+ interface RegisterNodeDriver {
48
+ onRegisterNode: (params: RegisterNodeParams) => void
49
+ createCapabilityProxy: (capabilityName: string, nodeId: string) => Record<string, (params: unknown) => Promise<unknown>> | null
50
+ }
51
+
52
+ /** A minimal real `CapabilityDefinition` so `getDefinition`/`expandCapMethods` resolve. */
53
+ function makeCapDef(name: string): CapabilityDefinition {
54
+ return {
55
+ name,
56
+ scope: 'system',
57
+ mode: 'collection',
58
+ methods: {
59
+ ping: {
60
+ input: z.object({}),
61
+ output: z.object({}),
62
+ kind: 'query',
63
+ auth: 'protected',
64
+ },
65
+ },
66
+ }
67
+ }
68
+
69
+ function makeLogger(): IScopedLogger {
70
+ const logger = {
71
+ info: () => undefined,
72
+ warn: () => undefined,
73
+ error: () => undefined,
74
+ debug: () => undefined,
75
+ trace: () => undefined,
76
+ fatal: () => undefined,
77
+ child: (() => logger) as IScopedLogger['child'],
78
+ }
79
+ return logger as unknown as IScopedLogger
80
+ }
81
+
82
+ interface Harness {
83
+ readonly driver: RegisterNodeDriver
84
+ readonly registry: CapabilityRegistry
85
+ readonly capNames: readonly string[]
86
+ }
87
+
88
+ /**
89
+ * Build a real `MoleculerService` (constructor only — no broker start) wired
90
+ * to a real `CapabilityRegistry` pre-declaring `capNames`. Returns the
91
+ * service viewed through `RegisterNodeDriver` plus the registry to assert on.
92
+ *
93
+ * The broker is never started, so there is nothing to stop in teardown —
94
+ * no `afterEach` teardown is needed.
95
+ */
96
+ function createHarness(capNames: readonly string[]): Harness {
97
+ const registry = new CapabilityRegistry(makeLogger())
98
+ for (const name of capNames) {
99
+ registry.declareCapability(makeCapDef(name))
100
+ }
101
+ // Boot-complete state — `getAllProviders` returns [] until `ready()`.
102
+ registry.ready()
103
+
104
+ const fakeEventBus = {
105
+ emit: (_event: SystemEvent) => undefined,
106
+ // `ReadinessRegistry` (built in the MoleculerService constructor)
107
+ // subscribes to `system.ready-state` + `agent.offline`. A no-op
108
+ // subscription returning an unsubscribe fn keeps the constructor happy.
109
+ subscribe: () => () => undefined,
110
+ getRecent: () => [],
111
+ } as unknown as EventBusService
112
+
113
+ const fakeConfig = {
114
+ get: () => undefined,
115
+ getAddonConfig: () => ({}),
116
+ } as unknown as ConfigService
117
+
118
+ const fakeLogging = {
119
+ createLogger: () => makeLogger(),
120
+ writeFromWorker: () => undefined,
121
+ } as unknown as LoggingService
122
+
123
+ const fakeCapability = {
124
+ getRegistry: () => registry,
125
+ } as unknown as CapabilityService
126
+
127
+ const fakeStreamProbe = {} as unknown as StreamProbeService
128
+
129
+ const service = new MoleculerService(
130
+ fakeEventBus,
131
+ fakeConfig,
132
+ fakeLogging,
133
+ fakeCapability,
134
+ fakeStreamProbe,
135
+ )
136
+
137
+ return {
138
+ driver: service as unknown as RegisterNodeDriver,
139
+ registry,
140
+ capNames,
141
+ }
142
+ }
143
+
144
+ /** Provider count for a cap on the registry — counts every registered key. */
145
+ function providerCount(registry: CapabilityRegistry, capName: string): number {
146
+ return registry.getAllProviders(capName).length
147
+ }
148
+
149
+ describe('MoleculerService.applyNodeManifest — re-handshake idempotency', () => {
150
+ let harness: Harness
151
+
152
+ beforeEach(() => {
153
+ harness = createHarness(['cap-alpha', 'cap-beta'])
154
+ })
155
+
156
+ it('a re-handshake with the IDENTICAL manifest does not throw and leaves each provider registered exactly once', () => {
157
+ const manifest: RegisterNodeParams = {
158
+ nodeId: 'hub/reolink',
159
+ addons: [{ addonId: 'reolink', capabilities: ['cap-alpha', 'cap-beta'] }],
160
+ }
161
+
162
+ // First handshake.
163
+ harness.driver.onRegisterNode(manifest)
164
+ expect(providerCount(harness.registry, 'cap-alpha')).toBe(1)
165
+ expect(providerCount(harness.registry, 'cap-beta')).toBe(1)
166
+
167
+ // Re-handshake — the documented post-device-restore re-handshake.
168
+ // BEFORE the fix this threw `provider already registered for capability`.
169
+ expect(() => harness.driver.onRegisterNode(manifest)).not.toThrow()
170
+
171
+ // No duplicate, no loss — exactly one provider per cap.
172
+ expect(providerCount(harness.registry, 'cap-alpha')).toBe(1)
173
+ expect(providerCount(harness.registry, 'cap-beta')).toBe(1)
174
+ expect(harness.registry.getProviderByAddon('cap-alpha', 'reolink')).not.toBeNull()
175
+ expect(harness.registry.getProviderByAddon('cap-beta', 'reolink')).not.toBeNull()
176
+ })
177
+
178
+ it('three handshakes survive without throwing (registration storm regression guard)', () => {
179
+ const manifest: RegisterNodeParams = {
180
+ nodeId: 'hub/reolink',
181
+ addons: [{ addonId: 'reolink', capabilities: ['cap-alpha', 'cap-beta'] }],
182
+ }
183
+
184
+ expect(() => {
185
+ harness.driver.onRegisterNode(manifest)
186
+ harness.driver.onRegisterNode(manifest)
187
+ harness.driver.onRegisterNode(manifest)
188
+ }).not.toThrow()
189
+
190
+ expect(providerCount(harness.registry, 'cap-alpha')).toBe(1)
191
+ expect(providerCount(harness.registry, 'cap-beta')).toBe(1)
192
+ })
193
+
194
+ it('a re-handshake that DROPS one capability unregisters that provider while the others remain', () => {
195
+ const fullManifest: RegisterNodeParams = {
196
+ nodeId: 'hub/reolink',
197
+ addons: [{ addonId: 'reolink', capabilities: ['cap-alpha', 'cap-beta'] }],
198
+ }
199
+ const reducedManifest: RegisterNodeParams = {
200
+ nodeId: 'hub/reolink',
201
+ addons: [{ addonId: 'reolink', capabilities: ['cap-alpha'] }],
202
+ }
203
+
204
+ // First: full manifest — both caps registered.
205
+ harness.driver.onRegisterNode(fullManifest)
206
+ expect(providerCount(harness.registry, 'cap-alpha')).toBe(1)
207
+ expect(providerCount(harness.registry, 'cap-beta')).toBe(1)
208
+
209
+ // Second: identical full manifest — still idempotent, no throw.
210
+ expect(() => harness.driver.onRegisterNode(fullManifest)).not.toThrow()
211
+ expect(providerCount(harness.registry, 'cap-alpha')).toBe(1)
212
+ expect(providerCount(harness.registry, 'cap-beta')).toBe(1)
213
+
214
+ // Third: reduced manifest — cap-beta dropped, cap-alpha kept.
215
+ expect(() => harness.driver.onRegisterNode(reducedManifest)).not.toThrow()
216
+ expect(providerCount(harness.registry, 'cap-alpha')).toBe(1)
217
+ expect(providerCount(harness.registry, 'cap-beta')).toBe(0)
218
+ expect(harness.registry.getProviderByAddon('cap-alpha', 'reolink')).not.toBeNull()
219
+ expect(harness.registry.getProviderByAddon('cap-beta', 'reolink')).toBeNull()
220
+
221
+ // Verify that the call-routing entry for the dropped cap was removed
222
+ // from `nodeCallFns`. `createCapabilityProxy` is the public seam that
223
+ // delegates to `findCallFn` internally — a null return means no entry
224
+ // exists for that (nodeId, cap) pair, confirming the delete ran.
225
+ expect(harness.driver.createCapabilityProxy('cap-beta', 'hub/reolink')).toBeNull()
226
+ // The still-present cap must remain routable.
227
+ expect(harness.driver.createCapabilityProxy('cap-alpha', 'hub/reolink')).not.toBeNull()
228
+ })
229
+ })