@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,289 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unit tests for the two adapter factories and the resolver wired with them.
|
|
3
|
+
*
|
|
4
|
+
* Exercises:
|
|
5
|
+
* - createNodeCapAuthority: delegates to HubNodeRegistry correctly
|
|
6
|
+
* - createInProcessProviderLookup: invokes provider methods cast-free
|
|
7
|
+
* - Resolver wired with both adapters:
|
|
8
|
+
* hub-local UDS child cap resolves via callCapOnChild
|
|
9
|
+
* hub-resident singleton resolves in-process (invoke called)
|
|
10
|
+
* absent cap throws CapRouteError (NOT the old opaque string)
|
|
11
|
+
*
|
|
12
|
+
* We test the adapter factories directly + a resolver wired with them —
|
|
13
|
+
* standing up a full MoleculerService is too heavy for a unit test, and
|
|
14
|
+
* testing the adapters + resolver in isolation exercises the meaningful unit.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { describe, it, expect, vi } from 'vitest'
|
|
18
|
+
import { CapRouteError, CapRouteResolver } from '@camstack/kernel'
|
|
19
|
+
import type {
|
|
20
|
+
NodeCapAuthority,
|
|
21
|
+
InProcessProviderLookup,
|
|
22
|
+
HubLocalChildDispatcher,
|
|
23
|
+
CapRouteResolverDeps,
|
|
24
|
+
} from '@camstack/kernel'
|
|
25
|
+
import type { InProcessProviderRef } from '@camstack/kernel'
|
|
26
|
+
import {
|
|
27
|
+
createNodeCapAuthority,
|
|
28
|
+
createInProcessProviderLookup,
|
|
29
|
+
} from '../core/moleculer/cap-route-authority.js'
|
|
30
|
+
|
|
31
|
+
// ---------------------------------------------------------------------------
|
|
32
|
+
// Helpers for stub HubNodeRegistry
|
|
33
|
+
// ---------------------------------------------------------------------------
|
|
34
|
+
|
|
35
|
+
interface StubNodeEntry {
|
|
36
|
+
readonly addonId: string
|
|
37
|
+
readonly capabilities: readonly string[]
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function makeNodeRegistry(nodes: ReadonlyMap<string, readonly StubNodeEntry[]>) {
|
|
41
|
+
return {
|
|
42
|
+
getNodeManifest(nodeId: string) {
|
|
43
|
+
return nodes.get(nodeId)
|
|
44
|
+
},
|
|
45
|
+
listNodeIds(): readonly string[] {
|
|
46
|
+
return [...nodes.keys()]
|
|
47
|
+
},
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// ---------------------------------------------------------------------------
|
|
52
|
+
// Helpers for stub CapabilityService
|
|
53
|
+
// ---------------------------------------------------------------------------
|
|
54
|
+
|
|
55
|
+
function makeCapabilityService(
|
|
56
|
+
providers: ReadonlyMap<string, Record<string, unknown>>,
|
|
57
|
+
) {
|
|
58
|
+
return {
|
|
59
|
+
getSingleton<T>(capability: string): T | null {
|
|
60
|
+
return (providers.get(capability) as T) ?? null
|
|
61
|
+
},
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// ---------------------------------------------------------------------------
|
|
66
|
+
// createNodeCapAuthority
|
|
67
|
+
// ---------------------------------------------------------------------------
|
|
68
|
+
|
|
69
|
+
describe('createNodeCapAuthority', () => {
|
|
70
|
+
const nodes = new Map([
|
|
71
|
+
['hub/stream-broker', [{ addonId: 'addon-stream-broker', capabilities: ['stream-broker', 'stream-params'] }]],
|
|
72
|
+
['dev-agent-0', [{ addonId: 'addon-detection-pipeline', capabilities: ['pipeline-executor'] }]],
|
|
73
|
+
])
|
|
74
|
+
const registry = makeNodeRegistry(nodes)
|
|
75
|
+
const authority = createNodeCapAuthority(registry)
|
|
76
|
+
|
|
77
|
+
it('nodeKnowsCap returns true when the node manifest includes the cap', () => {
|
|
78
|
+
expect(authority.nodeKnowsCap('hub/stream-broker', 'stream-broker')).toBe(true)
|
|
79
|
+
expect(authority.nodeKnowsCap('hub/stream-broker', 'stream-params')).toBe(true)
|
|
80
|
+
expect(authority.nodeKnowsCap('hub/stream-broker', 'ghost-cap')).toBe(false)
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
it('nodeKnowsCap returns false for unknown nodes', () => {
|
|
84
|
+
expect(authority.nodeKnowsCap('not-a-node', 'stream-broker')).toBe(false)
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
it('getAddonId returns the addonId for a known cap', () => {
|
|
88
|
+
expect(authority.getAddonId('hub/stream-broker', 'stream-broker')).toBe('addon-stream-broker')
|
|
89
|
+
expect(authority.getAddonId('dev-agent-0', 'pipeline-executor')).toBe('addon-detection-pipeline')
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
it('getAddonId returns null for missing nodes or caps', () => {
|
|
93
|
+
expect(authority.getAddonId('not-a-node', 'stream-broker')).toBeNull()
|
|
94
|
+
expect(authority.getAddonId('hub/stream-broker', 'ghost-cap')).toBeNull()
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
it('nodeIsAgent: hub and hub-children are NOT agents; bare-id non-hub nodes are agents', () => {
|
|
98
|
+
expect(authority.nodeIsAgent('hub')).toBe(false)
|
|
99
|
+
expect(authority.nodeIsAgent('hub/stream-broker')).toBe(false)
|
|
100
|
+
expect(authority.nodeIsAgent('dev-agent-0')).toBe(true)
|
|
101
|
+
expect(authority.nodeIsAgent('some-remote-agent')).toBe(true)
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
it('nodeOnline: nodes in the registry are online; absent ones are not', () => {
|
|
105
|
+
expect(authority.nodeOnline('hub/stream-broker')).toBe(true)
|
|
106
|
+
expect(authority.nodeOnline('dev-agent-0')).toBe(true)
|
|
107
|
+
expect(authority.nodeOnline('not-registered')).toBe(false)
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
it('listNodeIds returns all registered node ids', () => {
|
|
111
|
+
const ids = authority.listNodeIds()
|
|
112
|
+
expect(ids).toContain('hub/stream-broker')
|
|
113
|
+
expect(ids).toContain('dev-agent-0')
|
|
114
|
+
expect(ids).toHaveLength(2)
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
it('getAgentChildId always returns null (hub cannot resolve; Task 6 handles it)', () => {
|
|
118
|
+
expect(authority.getAgentChildId('dev-agent-0', 'pipeline-executor')).toBeNull()
|
|
119
|
+
})
|
|
120
|
+
})
|
|
121
|
+
|
|
122
|
+
// ---------------------------------------------------------------------------
|
|
123
|
+
// createNodeCapAuthority — per-node singleton override
|
|
124
|
+
// ---------------------------------------------------------------------------
|
|
125
|
+
|
|
126
|
+
describe('createNodeCapAuthority — per-node singleton override', () => {
|
|
127
|
+
it('getAddonId honors the per-node singleton override when available', () => {
|
|
128
|
+
const nodeRegistry = {
|
|
129
|
+
getNodeManifest: (id: string) => id === 'dev-agent-0'
|
|
130
|
+
? [{ addonId: 'webrtc-native', capabilities: ['webrtc-session'] },
|
|
131
|
+
{ addonId: 'stream-broker', capabilities: ['webrtc-session'] }]
|
|
132
|
+
: undefined,
|
|
133
|
+
listNodeIds: () => ['hub', 'dev-agent-0'],
|
|
134
|
+
}
|
|
135
|
+
const authority = createNodeCapAuthority(nodeRegistry, {
|
|
136
|
+
resolveSingleton: (cap, nodeId) =>
|
|
137
|
+
cap === 'webrtc-session' && nodeId === 'dev-agent-0' ? 'stream-broker' : null,
|
|
138
|
+
})
|
|
139
|
+
expect(authority.getAddonId('dev-agent-0', 'webrtc-session')).toBe('stream-broker')
|
|
140
|
+
})
|
|
141
|
+
|
|
142
|
+
it('getAddonId falls back to first manifest match without an override', () => {
|
|
143
|
+
const nodeRegistry = {
|
|
144
|
+
getNodeManifest: (id: string) => id === 'dev-agent-0'
|
|
145
|
+
? [{ addonId: 'webrtc-native', capabilities: ['webrtc-session'] }]
|
|
146
|
+
: undefined,
|
|
147
|
+
listNodeIds: () => ['hub', 'dev-agent-0'],
|
|
148
|
+
}
|
|
149
|
+
const authority = createNodeCapAuthority(nodeRegistry, { resolveSingleton: () => null })
|
|
150
|
+
expect(authority.getAddonId('dev-agent-0', 'webrtc-session')).toBe('webrtc-native')
|
|
151
|
+
})
|
|
152
|
+
})
|
|
153
|
+
|
|
154
|
+
// ---------------------------------------------------------------------------
|
|
155
|
+
// createInProcessProviderLookup
|
|
156
|
+
// ---------------------------------------------------------------------------
|
|
157
|
+
|
|
158
|
+
describe('createInProcessProviderLookup', () => {
|
|
159
|
+
it('returns null for a cap not hosted in-process', () => {
|
|
160
|
+
const lookup = createInProcessProviderLookup(makeCapabilityService(new Map()))
|
|
161
|
+
expect(lookup('device-manager')).toBeNull()
|
|
162
|
+
})
|
|
163
|
+
|
|
164
|
+
it('returns an InProcessProviderRef whose invoke delegates to the provider method', async () => {
|
|
165
|
+
const getStatusImpl = vi.fn().mockReturnValue({ ok: true })
|
|
166
|
+
const providers = new Map<string, Record<string, unknown>>([
|
|
167
|
+
['device-manager', { getStatus: getStatusImpl }],
|
|
168
|
+
])
|
|
169
|
+
const lookup = createInProcessProviderLookup(makeCapabilityService(providers))
|
|
170
|
+
|
|
171
|
+
const ref = lookup('device-manager')
|
|
172
|
+
expect(ref).not.toBeNull()
|
|
173
|
+
|
|
174
|
+
const result = await ref!.invoke('getStatus', { deviceId: 1 })
|
|
175
|
+
expect(result).toEqual({ ok: true })
|
|
176
|
+
expect(getStatusImpl).toHaveBeenCalledOnce()
|
|
177
|
+
expect(getStatusImpl).toHaveBeenCalledWith({ deviceId: 1 })
|
|
178
|
+
})
|
|
179
|
+
|
|
180
|
+
it('invoke throws a typed Error when the method is not a function (never casts)', async () => {
|
|
181
|
+
const providers = new Map<string, Record<string, unknown>>([
|
|
182
|
+
['device-manager', { notAFn: 'some-string' }],
|
|
183
|
+
])
|
|
184
|
+
const lookup = createInProcessProviderLookup(makeCapabilityService(providers))
|
|
185
|
+
const ref = lookup('device-manager')
|
|
186
|
+
expect(ref).not.toBeNull()
|
|
187
|
+
|
|
188
|
+
await expect(ref!.invoke('notAFn', {})).rejects.toThrow(/method "notAFn" not found/)
|
|
189
|
+
await expect(ref!.invoke('missingMethod', {})).rejects.toThrow(/method "missingMethod" not found/)
|
|
190
|
+
})
|
|
191
|
+
})
|
|
192
|
+
|
|
193
|
+
// ---------------------------------------------------------------------------
|
|
194
|
+
// Resolver wired with both adapters
|
|
195
|
+
// ---------------------------------------------------------------------------
|
|
196
|
+
|
|
197
|
+
describe('Resolver + adapters — end-to-end dispatch', () => {
|
|
198
|
+
const HUB_NODE_ID = 'hub'
|
|
199
|
+
|
|
200
|
+
function makeCallCapOnChildSpy() {
|
|
201
|
+
return vi.fn(async (_childId: string, _input: unknown) => ({ ok: true, from: 'uds' }))
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function makeHubLocalRegistry(caps: ReadonlyMap<string, string>): HubLocalChildDispatcher & { callSpy: ReturnType<typeof vi.fn> } {
|
|
205
|
+
const callSpy = makeCallCapOnChildSpy()
|
|
206
|
+
return {
|
|
207
|
+
resolveChildId: (capName: string) => caps.get(capName) ?? null,
|
|
208
|
+
callCapOnChild: callSpy,
|
|
209
|
+
callSpy,
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
it('hub-local UDS child cap resolves via callCapOnChild', async () => {
|
|
214
|
+
// Registry: stream-broker is served by a hub-local child.
|
|
215
|
+
const nodes = new Map([
|
|
216
|
+
['hub/stream-broker', [{ addonId: 'addon-stream-broker', capabilities: ['stream-broker'] }]],
|
|
217
|
+
])
|
|
218
|
+
const nodeRegistry = makeNodeRegistry(nodes)
|
|
219
|
+
const authority = createNodeCapAuthority(nodeRegistry)
|
|
220
|
+
|
|
221
|
+
const hubLocalCaps = new Map([['stream-broker', 'addon-stream-broker']])
|
|
222
|
+
const hubLocalRegistry = makeHubLocalRegistry(hubLocalCaps)
|
|
223
|
+
|
|
224
|
+
const lookup = createInProcessProviderLookup(makeCapabilityService(new Map()))
|
|
225
|
+
|
|
226
|
+
const deps: CapRouteResolverDeps = {
|
|
227
|
+
hubNodeId: HUB_NODE_ID,
|
|
228
|
+
broker: { call: vi.fn(), waitForServices: vi.fn() },
|
|
229
|
+
hubLocalRegistry,
|
|
230
|
+
nodeAuthority: authority,
|
|
231
|
+
inProcessProviders: lookup,
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
const resolver = new CapRouteResolver(deps)
|
|
235
|
+
const route = resolver.resolveCapRoute('stream-broker', { nodeId: HUB_NODE_ID })
|
|
236
|
+
expect(route.kind).toBe('hub-local-uds')
|
|
237
|
+
|
|
238
|
+
const result = await resolver.dispatch(route, 'attachCamera', { deviceId: 5 })
|
|
239
|
+
expect(result).toEqual({ ok: true, from: 'uds' })
|
|
240
|
+
expect(hubLocalRegistry.callSpy).toHaveBeenCalledOnce()
|
|
241
|
+
})
|
|
242
|
+
|
|
243
|
+
it('hub-resident singleton resolves in-process via invoke', async () => {
|
|
244
|
+
const invokeSpy = vi.fn().mockResolvedValue({ devices: [] })
|
|
245
|
+
const providers = new Map<string, Record<string, unknown>>([
|
|
246
|
+
['device-manager', { listAll: invokeSpy }],
|
|
247
|
+
])
|
|
248
|
+
const lookup = createInProcessProviderLookup(makeCapabilityService(providers))
|
|
249
|
+
|
|
250
|
+
const deps: CapRouteResolverDeps = {
|
|
251
|
+
hubNodeId: HUB_NODE_ID,
|
|
252
|
+
broker: { call: vi.fn(), waitForServices: vi.fn() },
|
|
253
|
+
hubLocalRegistry: null,
|
|
254
|
+
nodeAuthority: createNodeCapAuthority(makeNodeRegistry(new Map())),
|
|
255
|
+
inProcessProviders: lookup,
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
const resolver = new CapRouteResolver(deps)
|
|
259
|
+
const route = resolver.resolveCapRoute('device-manager', { nodeId: HUB_NODE_ID })
|
|
260
|
+
expect(route.kind).toBe('hub-in-process')
|
|
261
|
+
|
|
262
|
+
const result = await resolver.dispatch(route, 'listAll', {})
|
|
263
|
+
expect(result).toEqual({ devices: [] })
|
|
264
|
+
expect(invokeSpy).toHaveBeenCalledOnce()
|
|
265
|
+
})
|
|
266
|
+
|
|
267
|
+
it('genuinely-absent cap throws CapRouteError, NOT the old opaque string', () => {
|
|
268
|
+
const deps: CapRouteResolverDeps = {
|
|
269
|
+
hubNodeId: HUB_NODE_ID,
|
|
270
|
+
broker: { call: vi.fn(), waitForServices: vi.fn() },
|
|
271
|
+
hubLocalRegistry: null,
|
|
272
|
+
nodeAuthority: createNodeCapAuthority(makeNodeRegistry(new Map())),
|
|
273
|
+
inProcessProviders: createInProcessProviderLookup(makeCapabilityService(new Map())),
|
|
274
|
+
}
|
|
275
|
+
const resolver = new CapRouteResolver(deps)
|
|
276
|
+
|
|
277
|
+
let thrown: unknown
|
|
278
|
+
try {
|
|
279
|
+
resolver.resolveCapRoute('ghost-cap', {})
|
|
280
|
+
} catch (e) {
|
|
281
|
+
thrown = e
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
expect(thrown).toBeInstanceOf(CapRouteError)
|
|
285
|
+
expect((thrown as CapRouteError).reason).toBe('no-provider')
|
|
286
|
+
// Must NOT be the old opaque string
|
|
287
|
+
expect((thrown as CapRouteError).message).not.toContain('Capability "ghost-cap" not available on node')
|
|
288
|
+
})
|
|
289
|
+
})
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
|
|
2
|
+
/**
|
|
3
|
+
* Routing regression spec for the `broker` system-collection cap.
|
|
4
|
+
*
|
|
5
|
+
* The bug: `broker` is `scope:'system', mode:'collection'`. Two addons
|
|
6
|
+
* register a `broker` provider, each owning a DISJOINT id set
|
|
7
|
+
* (mqtt-broker owns `mqtt_*`, provider-homeassistant owns `ha_*`). The
|
|
8
|
+
* generated collection mount only fans out the array-output methods
|
|
9
|
+
* (`list` / `listProviders`); every id-keyed method (`get` / `getSettings`
|
|
10
|
+
* / `remove` / `testConnection` / …) falls through to `providers[0]`
|
|
11
|
+
* (the FIRST-registered provider = mqtt-broker). So operating on an HA
|
|
12
|
+
* broker `ha_1` hit mqtt-broker → "broker 'ha_1' not found".
|
|
13
|
+
*
|
|
14
|
+
* The fix: each broker carries its owning `addonId`; the admin UI threads
|
|
15
|
+
* it back as the `{ addonId }` system-collection selector so the call
|
|
16
|
+
* routes to the OWNING provider via `getProviderByAddonId`. Providers
|
|
17
|
+
* also return `null` (not throw) for ids they don't own, so the
|
|
18
|
+
* no-addonId fallback degrades gracefully.
|
|
19
|
+
*
|
|
20
|
+
* This spec exercises `createCapRouter_broker` with the SAME selector
|
|
21
|
+
* the generated mount builds (registry-backed: addonId → provider, else
|
|
22
|
+
* first-provider aggregate with array methods fanned out). It registers
|
|
23
|
+
* two mock providers and asserts:
|
|
24
|
+
* - `list()` returns both, each tagged with its `addonId`.
|
|
25
|
+
* - `get({id:'ha_1'}, addonId:'ha')` routes to the HA provider.
|
|
26
|
+
* - `get({id:'ha_1'})` WITHOUT addonId returns null (graceful) — the
|
|
27
|
+
* HA-owned id isn't in providers[0]'s registry, so it doesn't throw.
|
|
28
|
+
* - `listProviders()` aggregates both providers' entries.
|
|
29
|
+
*/
|
|
30
|
+
import { describe, it, expect } from 'vitest'
|
|
31
|
+
import { createCapRouter_broker } from '../../api/trpc/generated-cap-routers.js'
|
|
32
|
+
import { type IBrokerProvider, type BrokerInfo, type BrokerProviderInfo } from '@camstack/types'
|
|
33
|
+
import { concatCollection } from '../../api/trpc/cap-mount-helpers.js'
|
|
34
|
+
import { makeCtx, invokeProcedure } from './harness.js'
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* A self-contained mock `broker` provider that owns a fixed set of
|
|
38
|
+
* broker ids and self-tags every returned record with its `addonId`.
|
|
39
|
+
* Returns `null` / no-op for ids it does NOT own (never throws) — the
|
|
40
|
+
* exact graceful-degradation contract the real providers must honour.
|
|
41
|
+
*/
|
|
42
|
+
function makeProvider(addonId: string, ownedIds: readonly string[], kind: string): IBrokerProvider {
|
|
43
|
+
const owns = (id: string): boolean => ownedIds.includes(id)
|
|
44
|
+
const infoFor = (id: string): BrokerInfo => ({
|
|
45
|
+
id,
|
|
46
|
+
addonId,
|
|
47
|
+
name: `${kind}-${id}`,
|
|
48
|
+
kind,
|
|
49
|
+
status: 'connected',
|
|
50
|
+
info: {},
|
|
51
|
+
lastCheckedAt: null,
|
|
52
|
+
error: null,
|
|
53
|
+
})
|
|
54
|
+
return {
|
|
55
|
+
list: async () => ownedIds.map(infoFor),
|
|
56
|
+
get: async ({ id }) => (owns(id) ? infoFor(id) : null),
|
|
57
|
+
listProviders: async (): Promise<BrokerProviderInfo[]> => [
|
|
58
|
+
{ addonId, kinds: [{ kind, label: kind }] },
|
|
59
|
+
],
|
|
60
|
+
add: async () => ({ id: 'new' }),
|
|
61
|
+
remove: async () => {
|
|
62
|
+
// no-op for foreign ids (and owned ids in this mock)
|
|
63
|
+
},
|
|
64
|
+
testConnection: async ({ id }) =>
|
|
65
|
+
owns(id) ? { ok: true, latencyMs: 1 } : { ok: false, error: 'unknown broker' },
|
|
66
|
+
getSettings: async ({ id }) => (owns(id) ? { secret: id } : null),
|
|
67
|
+
setSettings: async () => {
|
|
68
|
+
// no-op
|
|
69
|
+
},
|
|
70
|
+
getBrokerConfig: async ({ id }) => (owns(id) ? { url: id } : null),
|
|
71
|
+
getSettingsSchema: async ({ kind: k }) => (k === kind ? { form: kind } : null),
|
|
72
|
+
testSettings: async () => ({ ok: true }),
|
|
73
|
+
publish: async () => null,
|
|
74
|
+
subscribe: async () => ({ subscriptionId: 's' }),
|
|
75
|
+
unsubscribe: async () => {
|
|
76
|
+
// no-op
|
|
77
|
+
},
|
|
78
|
+
getState: async () => null,
|
|
79
|
+
getStatus: async () => ({ brokerCount: ownedIds.length, connectedCount: ownedIds.length }),
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Mirror of the generated collection mount selector for `broker`
|
|
85
|
+
* (`generated-cap-mounts.ts`): addonId → that provider directly; else a
|
|
86
|
+
* first-provider aggregate whose array-output methods are
|
|
87
|
+
* `concatCollection`-fanned. Two providers registered in order:
|
|
88
|
+
* mqtt (owns `mqtt_1`) first, ha (owns `ha_1`) second.
|
|
89
|
+
*/
|
|
90
|
+
function makeSelector(): (addonId?: string) => IBrokerProvider | null {
|
|
91
|
+
const mqtt = makeProvider('mqtt', ['mqtt_1'], 'mqtt')
|
|
92
|
+
const ha = makeProvider('ha', ['ha_1'], 'home-assistant')
|
|
93
|
+
const byAddonId: Record<string, IBrokerProvider> = { mqtt, ha }
|
|
94
|
+
const providers: readonly IBrokerProvider[] = [mqtt, ha]
|
|
95
|
+
return (addonId?: string): IBrokerProvider | null => {
|
|
96
|
+
if (addonId !== undefined) return byAddonId[addonId] ?? null
|
|
97
|
+
const first = providers[0]!
|
|
98
|
+
return {
|
|
99
|
+
...first,
|
|
100
|
+
list: concatCollection(providers, 'list') as IBrokerProvider['list'],
|
|
101
|
+
listProviders: concatCollection(providers, 'listProviders') as IBrokerProvider['listProviders'],
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
describe('broker cap — addonId ownership routing', () => {
|
|
107
|
+
it('list() aggregates both providers, each broker tagged with its addonId', async () => {
|
|
108
|
+
const selector = makeSelector()
|
|
109
|
+
const router = createCapRouter_broker((_ctx, addonId) => selector(addonId))
|
|
110
|
+
|
|
111
|
+
const outcome = await invokeProcedure(router, 'list', makeCtx('admin'), {})
|
|
112
|
+
|
|
113
|
+
expect(outcome.ok).toBe(true)
|
|
114
|
+
if (!outcome.ok) return
|
|
115
|
+
const rows = outcome.value as BrokerInfo[]
|
|
116
|
+
const byId = new Map(rows.map(r => [r.id, r]))
|
|
117
|
+
expect(byId.get('mqtt_1')?.addonId).toBe('mqtt')
|
|
118
|
+
expect(byId.get('ha_1')?.addonId).toBe('ha')
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
it('get({id:ha_1}, addonId:ha) routes to the HA provider (not mqtt-broker)', async () => {
|
|
122
|
+
const selector = makeSelector()
|
|
123
|
+
const router = createCapRouter_broker((_ctx, addonId) => selector(addonId))
|
|
124
|
+
|
|
125
|
+
const outcome = await invokeProcedure(router, 'get', makeCtx('admin'), { id: 'ha_1', addonId: 'ha' })
|
|
126
|
+
|
|
127
|
+
expect(outcome.ok).toBe(true)
|
|
128
|
+
if (!outcome.ok) return
|
|
129
|
+
const row = outcome.value as BrokerInfo | null
|
|
130
|
+
expect(row).not.toBeNull()
|
|
131
|
+
expect(row?.id).toBe('ha_1')
|
|
132
|
+
expect(row?.addonId).toBe('ha')
|
|
133
|
+
})
|
|
134
|
+
|
|
135
|
+
it('get({id:ha_1}) WITHOUT addonId returns null gracefully (first provider does not own it)', async () => {
|
|
136
|
+
const selector = makeSelector()
|
|
137
|
+
const router = createCapRouter_broker((_ctx, addonId) => selector(addonId))
|
|
138
|
+
|
|
139
|
+
const outcome = await invokeProcedure(router, 'get', makeCtx('admin'), { id: 'ha_1' })
|
|
140
|
+
|
|
141
|
+
expect(outcome.ok).toBe(true)
|
|
142
|
+
if (!outcome.ok) return
|
|
143
|
+
expect(outcome.value).toBeNull()
|
|
144
|
+
})
|
|
145
|
+
|
|
146
|
+
it('testConnection({id:ha_1}, addonId:ha) routes to HA provider and succeeds', async () => {
|
|
147
|
+
const selector = makeSelector()
|
|
148
|
+
const router = createCapRouter_broker((_ctx, addonId) => selector(addonId))
|
|
149
|
+
|
|
150
|
+
const outcome = await invokeProcedure(router, 'testConnection', makeCtx('admin'), { id: 'ha_1', addonId: 'ha' })
|
|
151
|
+
|
|
152
|
+
expect(outcome.ok).toBe(true)
|
|
153
|
+
if (!outcome.ok) return
|
|
154
|
+
expect(outcome.value).toEqual({ ok: true, latencyMs: 1 })
|
|
155
|
+
})
|
|
156
|
+
|
|
157
|
+
it('listProviders() aggregates both providers entries', async () => {
|
|
158
|
+
const selector = makeSelector()
|
|
159
|
+
const router = createCapRouter_broker((_ctx, addonId) => selector(addonId))
|
|
160
|
+
|
|
161
|
+
const outcome = await invokeProcedure(router, 'listProviders', makeCtx('admin'))
|
|
162
|
+
|
|
163
|
+
expect(outcome.ok).toBe(true)
|
|
164
|
+
if (!outcome.ok) return
|
|
165
|
+
const entries = outcome.value as BrokerProviderInfo[]
|
|
166
|
+
const addonIds = entries.map(e => e.addonId).sort()
|
|
167
|
+
expect(addonIds).toEqual(['ha', 'mqtt'])
|
|
168
|
+
})
|
|
169
|
+
})
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unit tests for the cap-route-error-formatter.
|
|
3
|
+
*
|
|
4
|
+
* `formatTrpcError` is a pure function — no tRPC plumbing required.
|
|
5
|
+
* We hand-craft minimal TRPCError-like / DefaultErrorShape-like objects
|
|
6
|
+
* to verify the augmentation logic.
|
|
7
|
+
*/
|
|
8
|
+
import { describe, it, expect } from 'vitest'
|
|
9
|
+
import { TRPCError } from '@trpc/server'
|
|
10
|
+
import { CapRouteError } from '@camstack/kernel'
|
|
11
|
+
import { formatTrpcError } from '../../api/trpc/cap-route-error-formatter.js'
|
|
12
|
+
|
|
13
|
+
// ── Helpers ──────────────────────────────────────────────────────────────────
|
|
14
|
+
|
|
15
|
+
/** Minimal DefaultErrorShape stub. */
|
|
16
|
+
function makeShape(overrides: Partial<{ message: string }> = {}): Parameters<typeof formatTrpcError>[0]['shape'] {
|
|
17
|
+
return {
|
|
18
|
+
message: overrides.message ?? 'Something went wrong',
|
|
19
|
+
code: -32603, // INTERNAL_SERVER_ERROR code number
|
|
20
|
+
data: {
|
|
21
|
+
code: 'INTERNAL_SERVER_ERROR',
|
|
22
|
+
httpStatus: 500,
|
|
23
|
+
},
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Build a TRPCError-like object that formatTrpcError accepts. */
|
|
28
|
+
function makeTrpcError(
|
|
29
|
+
message: string,
|
|
30
|
+
cause?: Error,
|
|
31
|
+
): Parameters<typeof formatTrpcError>[0]['error'] {
|
|
32
|
+
return new TRPCError({
|
|
33
|
+
code: 'PRECONDITION_FAILED',
|
|
34
|
+
message,
|
|
35
|
+
cause,
|
|
36
|
+
})
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// ── Tests ─────────────────────────────────────────────────────────────────────
|
|
40
|
+
|
|
41
|
+
describe('formatTrpcError', () => {
|
|
42
|
+
it('returns shape unchanged when error is not a CapRouteError', () => {
|
|
43
|
+
const shape = makeShape({ message: 'Boom' })
|
|
44
|
+
const error = makeTrpcError('Some generic error')
|
|
45
|
+
const result = formatTrpcError({ error, shape })
|
|
46
|
+
expect(result).toStrictEqual(shape)
|
|
47
|
+
expect(result.data).not.toHaveProperty('capRouteReason')
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
it('augments shape with capRouteReason when error.cause is a CapRouteError', () => {
|
|
51
|
+
const capRouteErr = new CapRouteError('my-cap', undefined, {
|
|
52
|
+
reason: 'no-provider',
|
|
53
|
+
rejected: [{ kind: 'hub-in-process', why: 'no provider bound' }],
|
|
54
|
+
})
|
|
55
|
+
const shape = makeShape()
|
|
56
|
+
const error = makeTrpcError('Capability "my-cap" provider not available', capRouteErr)
|
|
57
|
+
const result = formatTrpcError({ error, shape })
|
|
58
|
+
|
|
59
|
+
expect(result.data.capRouteReason).toBe('no-provider')
|
|
60
|
+
expect(result.data.capRouteRejected).toStrictEqual([
|
|
61
|
+
{ kind: 'hub-in-process', why: 'no provider bound' },
|
|
62
|
+
])
|
|
63
|
+
expect(result.data.capRouteNodeId).toBeUndefined()
|
|
64
|
+
// Original shape fields preserved
|
|
65
|
+
expect(result.message).toBe(shape.message)
|
|
66
|
+
expect(result.code).toBe(shape.code)
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
it('augments shape with capRouteReason when error itself is a CapRouteError', () => {
|
|
70
|
+
const capRouteErr = new CapRouteError('some-cap', undefined, {
|
|
71
|
+
reason: 'node-offline',
|
|
72
|
+
nodeId: 'node-abc',
|
|
73
|
+
rejected: [{ kind: 'remote-moleculer', why: 'node node-abc is offline' }],
|
|
74
|
+
})
|
|
75
|
+
const shape = makeShape()
|
|
76
|
+
// Wrap as TRPCError with the CapRouteError AS the cause
|
|
77
|
+
const error = makeTrpcError('Transport failed', capRouteErr)
|
|
78
|
+
const result = formatTrpcError({ error, shape })
|
|
79
|
+
|
|
80
|
+
expect(result.data.capRouteReason).toBe('node-offline')
|
|
81
|
+
expect(result.data.capRouteNodeId).toBe('node-abc')
|
|
82
|
+
expect(result.data.capRouteRejected).toHaveLength(1)
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
it('surfaces transport-failed reason correctly (not absent provider)', () => {
|
|
86
|
+
const capRouteErr = new CapRouteError('stream-params', 'getStatus', {
|
|
87
|
+
reason: 'transport-failed',
|
|
88
|
+
rejected: [{ kind: 'hub-local-uds', why: 'socket closed' }],
|
|
89
|
+
})
|
|
90
|
+
const shape = makeShape()
|
|
91
|
+
const error = makeTrpcError('Transport failed', capRouteErr)
|
|
92
|
+
const result = formatTrpcError({ error, shape })
|
|
93
|
+
|
|
94
|
+
expect(result.data.capRouteReason).toBe('transport-failed')
|
|
95
|
+
expect(result.data.capRouteRejected).toStrictEqual([
|
|
96
|
+
{ kind: 'hub-local-uds', why: 'socket closed' },
|
|
97
|
+
])
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
it('walks a nested cause chain to find a CapRouteError', () => {
|
|
101
|
+
const capRouteErr = new CapRouteError('ptz', undefined, {
|
|
102
|
+
reason: 'cap-unknown',
|
|
103
|
+
rejected: [],
|
|
104
|
+
})
|
|
105
|
+
// Wrap two levels deep
|
|
106
|
+
const innerError = new Error('dispatch failed', { cause: capRouteErr })
|
|
107
|
+
const shape = makeShape()
|
|
108
|
+
const error = makeTrpcError('Outer error', innerError)
|
|
109
|
+
const result = formatTrpcError({ error, shape })
|
|
110
|
+
|
|
111
|
+
expect(result.data.capRouteReason).toBe('cap-unknown')
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
it('returns shape unchanged for a plain Error (non-CapRouteError cause)', () => {
|
|
115
|
+
const plainErr = new Error('plain cause')
|
|
116
|
+
const shape = makeShape()
|
|
117
|
+
const error = makeTrpcError('Wrapper', plainErr)
|
|
118
|
+
const result = formatTrpcError({ error, shape })
|
|
119
|
+
|
|
120
|
+
expect(result.data).not.toHaveProperty('capRouteReason')
|
|
121
|
+
expect(result).toStrictEqual(shape)
|
|
122
|
+
})
|
|
123
|
+
})
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Spec for the per-node singleton extension in capabilities.router.
|
|
3
|
+
*
|
|
4
|
+
* Exercises:
|
|
5
|
+
* - setActiveSingleton with nodeId → calls registry with (cap, addonId, nodeId)
|
|
6
|
+
* + persists `capabilities.singletonNode.<cap>.<nodeId>`
|
|
7
|
+
* - setActiveSingleton without nodeId → persists `capabilities.singleton.<cap>` (global key)
|
|
8
|
+
* - clearSingletonNodeOverride → calls registry.clearSingletonNodeOverride(cap, nodeId)
|
|
9
|
+
* + persists the per-node key as null
|
|
10
|
+
*/
|
|
11
|
+
import { describe, it, expect, vi } from 'vitest'
|
|
12
|
+
import type { CapabilityRegistry } from '@camstack/kernel'
|
|
13
|
+
import type { ConfigService } from '../../core/config/config.service.js'
|
|
14
|
+
import { createCapabilitiesRouter } from '../../api/core/capabilities.router.js'
|
|
15
|
+
import { makeCtx } from './harness.js'
|
|
16
|
+
|
|
17
|
+
function harness() {
|
|
18
|
+
const calls: { setActiveSingleton: unknown[][]; clear: unknown[][] } = { setActiveSingleton: [], clear: [] }
|
|
19
|
+
const sets: Record<string, unknown> = {}
|
|
20
|
+
const registry = {
|
|
21
|
+
setActiveSingleton: vi.fn(async (...a: unknown[]) => { calls.setActiveSingleton.push(a) }),
|
|
22
|
+
clearSingletonNodeOverride: vi.fn((...a: unknown[]) => { calls.clear.push(a) }),
|
|
23
|
+
listCapabilities: () => [],
|
|
24
|
+
} as unknown as CapabilityRegistry
|
|
25
|
+
const config = { set: (k: string, v: unknown) => { sets[k] = v } } as unknown as ConfigService
|
|
26
|
+
const router = createCapabilitiesRouter(registry, config)
|
|
27
|
+
return { router, calls, sets, registry }
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
describe('capabilities.router — per-node singleton', () => {
|
|
31
|
+
it('setActiveSingleton with nodeId persists the per-node key', async () => {
|
|
32
|
+
const { router, calls, sets } = harness()
|
|
33
|
+
const caller = router.createCaller(makeCtx('admin'))
|
|
34
|
+
await caller.setActiveSingleton({
|
|
35
|
+
capability: 'webrtc-session', addonId: 'webrtc-native', nodeId: 'dev-agent-0',
|
|
36
|
+
})
|
|
37
|
+
expect(calls.setActiveSingleton[0]).toEqual(['webrtc-session', 'webrtc-native', 'dev-agent-0'])
|
|
38
|
+
expect(sets['capabilities.singletonNode.webrtc-session.dev-agent-0']).toBe('webrtc-native')
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
it('setActiveSingleton without nodeId persists the global key', async () => {
|
|
42
|
+
const { router, sets } = harness()
|
|
43
|
+
const caller = router.createCaller(makeCtx('admin'))
|
|
44
|
+
await caller.setActiveSingleton({ capability: 'webrtc-session', addonId: 'stream-broker' })
|
|
45
|
+
expect(sets['capabilities.singleton.webrtc-session']).toBe('stream-broker')
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
it('clearSingletonNodeOverride clears the per-node key', async () => {
|
|
49
|
+
const { router, calls, sets } = harness()
|
|
50
|
+
const caller = router.createCaller(makeCtx('admin'))
|
|
51
|
+
await caller.clearSingletonNodeOverride({ capability: 'webrtc-session', nodeId: 'dev-agent-0' })
|
|
52
|
+
expect(calls.clear[0]).toEqual(['webrtc-session', 'dev-agent-0'])
|
|
53
|
+
expect(sets['capabilities.singletonNode.webrtc-session.dev-agent-0']).toBe(null)
|
|
54
|
+
})
|
|
55
|
+
})
|