@camstack/server 0.1.7 → 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__/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-routers/broker-routing.router.spec.ts +169 -0
- package/src/__tests__/cap-routers/device-link-overlay.spec.ts +132 -0
- package/src/__tests__/moleculer/uds-unowned-call.spec.ts +209 -3
- package/src/api/core/__tests__/integration-markers.spec.ts +10 -0
- package/src/api/core/cap-providers.ts +152 -3
- package/src/api/core/logs.router.ts +4 -0
- package/src/api/trpc/__tests__/client-ip.spec.ts +27 -1
- 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/client-ip.ts +17 -0
- package/src/api/trpc/generated-cap-mounts.ts +281 -8
- package/src/api/trpc/generated-cap-routers.ts +2087 -184
- package/src/api/trpc/trpc.router.ts +43 -7
- 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-registry.service.ts +89 -2
- package/src/core/addon/addon-row-manifest.ts +29 -0
- package/src/core/logging/logging.service.ts +7 -2
- package/src/core/moleculer/moleculer.service.ts +28 -0
- package/src/core/network/network-quality.service.spec.ts +2 -1
- package/src/main.ts +92 -0
- 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
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@camstack/server",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.8",
|
|
4
4
|
"private": false,
|
|
5
5
|
"exports": {
|
|
6
6
|
"./package.json": "./package.json",
|
|
@@ -9,8 +9,8 @@
|
|
|
9
9
|
},
|
|
10
10
|
"scripts": {
|
|
11
11
|
"build": "tsc -p tsconfig.build.json",
|
|
12
|
-
"dev": "npx concurrently -n server,ui -c blue,magenta \"tsx watch --env-file=.env --ignore=./camstack-data src/launcher.ts\" \"cd ../../packages/addon-admin-ui && npx vite --port 3001\"",
|
|
13
|
-
"serve": "tsx watch --env-file=.env --ignore=./camstack-data src/launcher.ts",
|
|
12
|
+
"dev": "npx concurrently -n server,ui -c blue,magenta \"CAMSTACK_RESTART_TOUCH_FILE=src/launcher.ts tsx watch --env-file=.env --ignore=./camstack-data --ignore=../../packages/core/dist --ignore=../../packages/sdk/dist --ignore=../../packages/ui-library/dist --ignore=../../packages/shm-ring/dist src/launcher.ts\" \"cd ../../packages/addon-admin-ui && npx vite --port 3001\"",
|
|
13
|
+
"serve": "CAMSTACK_RESTART_TOUCH_FILE=src/launcher.ts tsx watch --env-file=.env --ignore=./camstack-data --ignore=../../packages/core/dist --ignore=../../packages/sdk/dist --ignore=../../packages/ui-library/dist --ignore=../../packages/shm-ring/dist src/launcher.ts",
|
|
14
14
|
"serve:once": "tsx --env-file=.env src/launcher.ts",
|
|
15
15
|
"start": "node --env-file=.env dist/launcher.js",
|
|
16
16
|
"typecheck": "tsc --noEmit",
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* cap-providers-location-import.spec.ts
|
|
3
|
+
*
|
|
4
|
+
* Verifies that `buildIntegrationsProvider().getAvailableTypes()` correctly
|
|
5
|
+
* surfaces the `supportsLocationImport` flag:
|
|
6
|
+
* - A `device-adoption` addon whose manifest declares `supportsLocationImport: true`
|
|
7
|
+
* → returned entry has `supportsLocationImport === true`.
|
|
8
|
+
* - A `device-adoption` addon WITHOUT the flag
|
|
9
|
+
* → returned entry has `supportsLocationImport === false`.
|
|
10
|
+
* - A `device-provider` addon (non-adoption kind), regardless of the flag
|
|
11
|
+
* → returned entry has `supportsLocationImport === false`.
|
|
12
|
+
*
|
|
13
|
+
* Harness mirrors integrations-delete-cascade.spec.ts in the same directory.
|
|
14
|
+
*/
|
|
15
|
+
import { describe, it, expect, vi } from 'vitest'
|
|
16
|
+
import { buildIntegrationsProvider } from '../../api/core/cap-providers.js'
|
|
17
|
+
|
|
18
|
+
// ── Minimal stubs ────────────────────────────────────────────────────────────
|
|
19
|
+
|
|
20
|
+
function makeIntegrationRegistry() {
|
|
21
|
+
return {
|
|
22
|
+
listIntegrations: vi.fn(async () => []),
|
|
23
|
+
getIntegration: vi.fn(async (_id: string) => null),
|
|
24
|
+
deleteIntegration: vi.fn(async (_id: string) => undefined),
|
|
25
|
+
createIntegration: vi.fn(),
|
|
26
|
+
updateIntegration: vi.fn(),
|
|
27
|
+
getIntegrationByAddonId: vi.fn(),
|
|
28
|
+
getIntegrationSettings: vi.fn(),
|
|
29
|
+
setIntegrationSettings: vi.fn(),
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
type FakeAddonRow = {
|
|
34
|
+
manifest: {
|
|
35
|
+
id: string
|
|
36
|
+
name: string
|
|
37
|
+
description: string
|
|
38
|
+
capabilities: Array<{ name: string }>
|
|
39
|
+
supportsLocationImport?: boolean
|
|
40
|
+
brokerKind?: string
|
|
41
|
+
instanceMode?: string
|
|
42
|
+
icon?: string
|
|
43
|
+
color?: string
|
|
44
|
+
}
|
|
45
|
+
declaration?: {
|
|
46
|
+
supportsLocationImport?: boolean
|
|
47
|
+
brokerKind?: string
|
|
48
|
+
instanceMode?: string
|
|
49
|
+
icon?: string
|
|
50
|
+
color?: string
|
|
51
|
+
}
|
|
52
|
+
process?: { state: string }
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function makeAddonRegistry(addons: FakeAddonRow[] = []) {
|
|
56
|
+
const integrationReg = makeIntegrationRegistry()
|
|
57
|
+
return {
|
|
58
|
+
getIntegrationRegistry: vi.fn(() => integrationReg),
|
|
59
|
+
listAddons: vi.fn(() => addons),
|
|
60
|
+
restartAddon: vi.fn(),
|
|
61
|
+
getCapabilityRegistry: vi.fn(() => ({
|
|
62
|
+
getProviderByAddon: vi.fn(() => null),
|
|
63
|
+
})),
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function makeEventBus() {
|
|
68
|
+
return { emit: vi.fn() }
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function makeLogger() {
|
|
72
|
+
return {
|
|
73
|
+
info: vi.fn(),
|
|
74
|
+
warn: vi.fn(),
|
|
75
|
+
error: vi.fn(),
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function makeLoggingService() {
|
|
80
|
+
const log = makeLogger()
|
|
81
|
+
return { createLogger: vi.fn(() => log) }
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// ── Tests ────────────────────────────────────────────────────────────────────
|
|
85
|
+
|
|
86
|
+
describe('getAvailableTypes — supportsLocationImport flag', () => {
|
|
87
|
+
it('returns supportsLocationImport=true for a device-adoption addon that declares it in the manifest', async () => {
|
|
88
|
+
const addon: FakeAddonRow = {
|
|
89
|
+
manifest: {
|
|
90
|
+
id: 'provider-homeassistant',
|
|
91
|
+
name: 'Home Assistant Provider',
|
|
92
|
+
description: 'Adopt HA devices',
|
|
93
|
+
capabilities: [{ name: 'device-adoption' }],
|
|
94
|
+
supportsLocationImport: true,
|
|
95
|
+
brokerKind: 'home-assistant',
|
|
96
|
+
instanceMode: 'multiple',
|
|
97
|
+
},
|
|
98
|
+
}
|
|
99
|
+
const ar = makeAddonRegistry([addon])
|
|
100
|
+
const provider = buildIntegrationsProvider(ar as never, makeEventBus() as never, makeLoggingService() as never, null)
|
|
101
|
+
const types = await provider.getAvailableTypes()
|
|
102
|
+
|
|
103
|
+
expect(types).toHaveLength(1)
|
|
104
|
+
expect(types[0]).toMatchObject({
|
|
105
|
+
addonId: 'provider-homeassistant',
|
|
106
|
+
kind: 'device-adoption',
|
|
107
|
+
supportsLocationImport: true,
|
|
108
|
+
})
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
it('returns supportsLocationImport=false for a device-adoption addon that omits the flag', async () => {
|
|
112
|
+
const addon: FakeAddonRow = {
|
|
113
|
+
manifest: {
|
|
114
|
+
id: 'provider-test-adoption',
|
|
115
|
+
name: 'Test Adoption Provider',
|
|
116
|
+
description: 'Adoption without location import',
|
|
117
|
+
capabilities: [{ name: 'device-adoption' }],
|
|
118
|
+
brokerKind: 'test-broker',
|
|
119
|
+
instanceMode: 'multiple',
|
|
120
|
+
},
|
|
121
|
+
}
|
|
122
|
+
const ar = makeAddonRegistry([addon])
|
|
123
|
+
const provider = buildIntegrationsProvider(ar as never, makeEventBus() as never, makeLoggingService() as never, null)
|
|
124
|
+
const types = await provider.getAvailableTypes()
|
|
125
|
+
|
|
126
|
+
expect(types).toHaveLength(1)
|
|
127
|
+
expect(types[0]).toMatchObject({
|
|
128
|
+
addonId: 'provider-test-adoption',
|
|
129
|
+
kind: 'device-adoption',
|
|
130
|
+
supportsLocationImport: false,
|
|
131
|
+
})
|
|
132
|
+
})
|
|
133
|
+
|
|
134
|
+
it('returns supportsLocationImport=false for a device-provider addon even when the flag is set on manifest', async () => {
|
|
135
|
+
const addon: FakeAddonRow = {
|
|
136
|
+
manifest: {
|
|
137
|
+
id: 'provider-reolink',
|
|
138
|
+
name: 'Reolink Provider',
|
|
139
|
+
description: 'Classic device provider',
|
|
140
|
+
capabilities: [{ name: 'device-provider' }],
|
|
141
|
+
// Hypothetical: even if someone mistakenly sets this on a device-provider
|
|
142
|
+
supportsLocationImport: true,
|
|
143
|
+
instanceMode: 'single',
|
|
144
|
+
},
|
|
145
|
+
}
|
|
146
|
+
const ar = makeAddonRegistry([addon])
|
|
147
|
+
const provider = buildIntegrationsProvider(ar as never, makeEventBus() as never, makeLoggingService() as never, null)
|
|
148
|
+
const types = await provider.getAvailableTypes()
|
|
149
|
+
|
|
150
|
+
expect(types).toHaveLength(1)
|
|
151
|
+
expect(types[0]).toMatchObject({
|
|
152
|
+
addonId: 'provider-reolink',
|
|
153
|
+
kind: 'device-provider',
|
|
154
|
+
supportsLocationImport: false,
|
|
155
|
+
})
|
|
156
|
+
})
|
|
157
|
+
|
|
158
|
+
it('prefers declaration.supportsLocationImport over manifest when declaration is present', async () => {
|
|
159
|
+
const addon: FakeAddonRow = {
|
|
160
|
+
manifest: {
|
|
161
|
+
id: 'provider-homeassistant',
|
|
162
|
+
name: 'Home Assistant Provider',
|
|
163
|
+
description: 'Adopt HA devices',
|
|
164
|
+
capabilities: [{ name: 'device-adoption' }],
|
|
165
|
+
supportsLocationImport: false,
|
|
166
|
+
brokerKind: 'home-assistant',
|
|
167
|
+
instanceMode: 'multiple',
|
|
168
|
+
},
|
|
169
|
+
declaration: {
|
|
170
|
+
supportsLocationImport: true,
|
|
171
|
+
brokerKind: 'home-assistant',
|
|
172
|
+
instanceMode: 'multiple',
|
|
173
|
+
},
|
|
174
|
+
}
|
|
175
|
+
const ar = makeAddonRegistry([addon])
|
|
176
|
+
const provider = buildIntegrationsProvider(ar as never, makeEventBus() as never, makeLoggingService() as never, null)
|
|
177
|
+
const types = await provider.getAvailableTypes()
|
|
178
|
+
|
|
179
|
+
expect(types).toHaveLength(1)
|
|
180
|
+
expect(types[0]).toMatchObject({
|
|
181
|
+
addonId: 'provider-homeassistant',
|
|
182
|
+
kind: 'device-adoption',
|
|
183
|
+
supportsLocationImport: true,
|
|
184
|
+
})
|
|
185
|
+
})
|
|
186
|
+
})
|
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
|
2
|
+
import { buildIntegrationsProvider } from '../../api/core/cap-providers'
|
|
3
|
+
import type { CapabilityRegistry } from '@camstack/kernel'
|
|
4
|
+
import type { Integration } from '@camstack/types'
|
|
5
|
+
|
|
6
|
+
// ── Minimal stubs ────────────────────────────────────────────────────────────
|
|
7
|
+
|
|
8
|
+
const INTEGRATION_ID = 'integ-abc-123'
|
|
9
|
+
const ADDON_ID = 'provider-test'
|
|
10
|
+
|
|
11
|
+
function makeIntegration(id = INTEGRATION_ID): Integration {
|
|
12
|
+
return {
|
|
13
|
+
id,
|
|
14
|
+
addonId: ADDON_ID,
|
|
15
|
+
name: 'Test Integration',
|
|
16
|
+
enabled: true,
|
|
17
|
+
info: null,
|
|
18
|
+
settings: null,
|
|
19
|
+
createdAt: new Date().toISOString(),
|
|
20
|
+
updatedAt: new Date().toISOString(),
|
|
21
|
+
} as unknown as Integration
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function makeIntegrationRegistry(integration: Integration | null = makeIntegration()) {
|
|
25
|
+
return {
|
|
26
|
+
getIntegration: vi.fn(async (_id: string) => integration),
|
|
27
|
+
deleteIntegration: vi.fn(async (_id: string) => undefined),
|
|
28
|
+
listIntegrations: vi.fn(async () => (integration ? [integration] : [])),
|
|
29
|
+
createIntegration: vi.fn(),
|
|
30
|
+
updateIntegration: vi.fn(),
|
|
31
|
+
getIntegrationByAddonId: vi.fn(),
|
|
32
|
+
getIntegrationSettings: vi.fn(),
|
|
33
|
+
setIntegrationSettings: vi.fn(),
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function makeAddonRegistry(reg = makeIntegrationRegistry()) {
|
|
38
|
+
return {
|
|
39
|
+
getIntegrationRegistry: vi.fn(() => reg),
|
|
40
|
+
listAddons: vi.fn(() => []),
|
|
41
|
+
restartAddon: vi.fn(),
|
|
42
|
+
getCapabilityRegistry: vi.fn(() => ({
|
|
43
|
+
getProviderByAddon: vi.fn(() => null),
|
|
44
|
+
})),
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function makeEventBus() {
|
|
49
|
+
return { emit: vi.fn() }
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function makeLogger() {
|
|
53
|
+
return {
|
|
54
|
+
info: vi.fn(),
|
|
55
|
+
warn: vi.fn(),
|
|
56
|
+
error: vi.fn(),
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function makeLoggingService(log = makeLogger()) {
|
|
61
|
+
return { createLogger: vi.fn(() => log) }
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function makeCapabilityRegistry(
|
|
65
|
+
removeByIntegration: ((input: { integrationId: string }) => Promise<{ removed: number }>) | null = vi.fn(async () => ({ removed: 2 })),
|
|
66
|
+
): CapabilityRegistry {
|
|
67
|
+
return {
|
|
68
|
+
getSingleton: vi.fn((_cap: string) =>
|
|
69
|
+
removeByIntegration ? { removeByIntegration } : null,
|
|
70
|
+
),
|
|
71
|
+
} as unknown as CapabilityRegistry
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// ── Tests ────────────────────────────────────────────────────────────────────
|
|
75
|
+
|
|
76
|
+
describe('integrations.delete cascade-removes devices via removeByIntegration', () => {
|
|
77
|
+
let integrationReg: ReturnType<typeof makeIntegrationRegistry>
|
|
78
|
+
let addonReg: ReturnType<typeof makeAddonRegistry>
|
|
79
|
+
let eb: ReturnType<typeof makeEventBus>
|
|
80
|
+
let log: ReturnType<typeof makeLogger>
|
|
81
|
+
let loggingService: ReturnType<typeof makeLoggingService>
|
|
82
|
+
|
|
83
|
+
beforeEach(() => {
|
|
84
|
+
integrationReg = makeIntegrationRegistry()
|
|
85
|
+
addonReg = makeAddonRegistry(integrationReg)
|
|
86
|
+
eb = makeEventBus()
|
|
87
|
+
log = makeLogger()
|
|
88
|
+
loggingService = makeLoggingService(log)
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
it('calls removeByIntegration with the integration id before deleteIntegration', async () => {
|
|
92
|
+
const removeByIntegration = vi.fn(async () => ({ removed: 3 }))
|
|
93
|
+
const capReg = makeCapabilityRegistry(removeByIntegration)
|
|
94
|
+
|
|
95
|
+
const provider = buildIntegrationsProvider(addonReg as never, eb as never, loggingService as never, capReg)
|
|
96
|
+
await provider.delete({ id: INTEGRATION_ID })
|
|
97
|
+
|
|
98
|
+
expect(removeByIntegration).toHaveBeenCalledOnce()
|
|
99
|
+
expect(removeByIntegration).toHaveBeenCalledWith({ integrationId: INTEGRATION_ID })
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
it('still deletes the integration record and emits integration.deleted after the cascade', async () => {
|
|
103
|
+
const capReg = makeCapabilityRegistry()
|
|
104
|
+
|
|
105
|
+
const provider = buildIntegrationsProvider(addonReg as never, eb as never, loggingService as never, capReg)
|
|
106
|
+
const result = await provider.delete({ id: INTEGRATION_ID })
|
|
107
|
+
|
|
108
|
+
expect(integrationReg.deleteIntegration).toHaveBeenCalledOnce()
|
|
109
|
+
expect(integrationReg.deleteIntegration).toHaveBeenCalledWith(INTEGRATION_ID)
|
|
110
|
+
|
|
111
|
+
expect(eb.emit).toHaveBeenCalledOnce()
|
|
112
|
+
expect(eb.emit).toHaveBeenCalledWith(
|
|
113
|
+
expect.objectContaining({
|
|
114
|
+
category: 'integration.deleted',
|
|
115
|
+
data: expect.objectContaining({ integrationId: INTEGRATION_ID }),
|
|
116
|
+
}),
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
expect(result).toEqual({ success: true, deletedId: INTEGRATION_ID })
|
|
120
|
+
})
|
|
121
|
+
|
|
122
|
+
it('logs the removed count from removeByIntegration', async () => {
|
|
123
|
+
const removeByIntegration = vi.fn(async () => ({ removed: 5 }))
|
|
124
|
+
const capReg = makeCapabilityRegistry(removeByIntegration)
|
|
125
|
+
|
|
126
|
+
const provider = buildIntegrationsProvider(addonReg as never, eb as never, loggingService as never, capReg)
|
|
127
|
+
await provider.delete({ id: INTEGRATION_ID })
|
|
128
|
+
|
|
129
|
+
expect(log.info).toHaveBeenCalledWith(
|
|
130
|
+
'cascade-removed devices',
|
|
131
|
+
expect.objectContaining({ meta: expect.objectContaining({ removed: 5 }) }),
|
|
132
|
+
)
|
|
133
|
+
})
|
|
134
|
+
|
|
135
|
+
it('does NOT abort the integration delete when removeByIntegration throws (best-effort)', async () => {
|
|
136
|
+
const removeByIntegration = vi.fn(async () => {
|
|
137
|
+
throw new Error('device-manager transient error')
|
|
138
|
+
})
|
|
139
|
+
const capReg = makeCapabilityRegistry(removeByIntegration)
|
|
140
|
+
|
|
141
|
+
const provider = buildIntegrationsProvider(addonReg as never, eb as never, loggingService as never, capReg)
|
|
142
|
+
|
|
143
|
+
// Should NOT throw
|
|
144
|
+
await expect(provider.delete({ id: INTEGRATION_ID })).resolves.toEqual({
|
|
145
|
+
success: true,
|
|
146
|
+
deletedId: INTEGRATION_ID,
|
|
147
|
+
})
|
|
148
|
+
|
|
149
|
+
// Integration record deletion and event still fire
|
|
150
|
+
expect(integrationReg.deleteIntegration).toHaveBeenCalledOnce()
|
|
151
|
+
expect(eb.emit).toHaveBeenCalledOnce()
|
|
152
|
+
|
|
153
|
+
// A warning is logged
|
|
154
|
+
expect(log.warn).toHaveBeenCalledWith(
|
|
155
|
+
'device cascade-remove failed (best-effort — continuing)',
|
|
156
|
+
expect.anything(),
|
|
157
|
+
)
|
|
158
|
+
})
|
|
159
|
+
|
|
160
|
+
it('warns and skips cascade when capabilityRegistry is null', async () => {
|
|
161
|
+
const provider = buildIntegrationsProvider(addonReg as never, eb as never, loggingService as never, null)
|
|
162
|
+
await provider.delete({ id: INTEGRATION_ID })
|
|
163
|
+
|
|
164
|
+
expect(integrationReg.deleteIntegration).toHaveBeenCalledOnce()
|
|
165
|
+
expect(eb.emit).toHaveBeenCalledOnce()
|
|
166
|
+
expect(log.warn).toHaveBeenCalledWith(
|
|
167
|
+
'device-manager not available — skipping cascade device removal',
|
|
168
|
+
expect.anything(),
|
|
169
|
+
)
|
|
170
|
+
})
|
|
171
|
+
|
|
172
|
+
it('warns and skips cascade when device-manager singleton is not registered', async () => {
|
|
173
|
+
const capReg = makeCapabilityRegistry(null)
|
|
174
|
+
|
|
175
|
+
const provider = buildIntegrationsProvider(addonReg as never, eb as never, loggingService as never, capReg)
|
|
176
|
+
await provider.delete({ id: INTEGRATION_ID })
|
|
177
|
+
|
|
178
|
+
expect(integrationReg.deleteIntegration).toHaveBeenCalledOnce()
|
|
179
|
+
expect(log.warn).toHaveBeenCalledWith(
|
|
180
|
+
'device-manager not available — skipping cascade device removal',
|
|
181
|
+
expect.anything(),
|
|
182
|
+
)
|
|
183
|
+
})
|
|
184
|
+
|
|
185
|
+
it('stamps legacy un-tagged devices of the addon BEFORE cascade so they are removed too', async () => {
|
|
186
|
+
const removeByIntegration = vi.fn(async () => ({ removed: 1 }))
|
|
187
|
+
const setIntegrationId = vi.fn(async (_input: { deviceId: number; integrationId: string }) => undefined)
|
|
188
|
+
// One un-tagged top-level device of the integration's addon (claimable),
|
|
189
|
+
// one already-tagged (skip), one child (skip), one other-addon (skip).
|
|
190
|
+
const listAll = vi.fn(async () => [
|
|
191
|
+
{ id: 4, addonId: ADDON_ID, parentDeviceId: null },
|
|
192
|
+
{ id: 5, addonId: ADDON_ID, parentDeviceId: null, integrationId: INTEGRATION_ID },
|
|
193
|
+
{ id: 6, addonId: ADDON_ID, parentDeviceId: 4 },
|
|
194
|
+
{ id: 7, addonId: 'provider-other', parentDeviceId: null },
|
|
195
|
+
])
|
|
196
|
+
const capReg = {
|
|
197
|
+
getSingleton: vi.fn(() => ({ removeByIntegration, listAll, setIntegrationId })),
|
|
198
|
+
} as unknown as CapabilityRegistry
|
|
199
|
+
|
|
200
|
+
const provider = buildIntegrationsProvider(addonReg as never, eb as never, loggingService as never, capReg)
|
|
201
|
+
await provider.delete({ id: INTEGRATION_ID })
|
|
202
|
+
|
|
203
|
+
// Only device 4 is claimed (untagged, top-level, this addon's single integration).
|
|
204
|
+
expect(setIntegrationId).toHaveBeenCalledOnce()
|
|
205
|
+
expect(setIntegrationId).toHaveBeenCalledWith({ deviceId: 4, integrationId: INTEGRATION_ID })
|
|
206
|
+
// Cascade still runs after the claim.
|
|
207
|
+
expect(removeByIntegration).toHaveBeenCalledWith({ integrationId: INTEGRATION_ID })
|
|
208
|
+
})
|
|
209
|
+
|
|
210
|
+
it('claim failure does not abort the integration delete (best-effort)', async () => {
|
|
211
|
+
const removeByIntegration = vi.fn(async () => ({ removed: 0 }))
|
|
212
|
+
const setIntegrationId = vi.fn(async () => { throw new Error('stamp boom') })
|
|
213
|
+
const listAll = vi.fn(async () => [{ id: 4, addonId: ADDON_ID, parentDeviceId: null }])
|
|
214
|
+
const capReg = {
|
|
215
|
+
getSingleton: vi.fn(() => ({ removeByIntegration, listAll, setIntegrationId })),
|
|
216
|
+
} as unknown as CapabilityRegistry
|
|
217
|
+
|
|
218
|
+
const provider = buildIntegrationsProvider(addonReg as never, eb as never, loggingService as never, capReg)
|
|
219
|
+
await expect(provider.delete({ id: INTEGRATION_ID })).resolves.toEqual({
|
|
220
|
+
success: true,
|
|
221
|
+
deletedId: INTEGRATION_ID,
|
|
222
|
+
})
|
|
223
|
+
expect(integrationReg.deleteIntegration).toHaveBeenCalledOnce()
|
|
224
|
+
expect(log.warn).toHaveBeenCalledWith(
|
|
225
|
+
'legacy device claim failed (best-effort — continuing)',
|
|
226
|
+
expect.anything(),
|
|
227
|
+
)
|
|
228
|
+
})
|
|
229
|
+
|
|
230
|
+
it('throws when the integration is not found (guard still fires before cascade)', async () => {
|
|
231
|
+
integrationReg = makeIntegrationRegistry(null)
|
|
232
|
+
addonReg = makeAddonRegistry(integrationReg)
|
|
233
|
+
const capReg = makeCapabilityRegistry()
|
|
234
|
+
|
|
235
|
+
const provider = buildIntegrationsProvider(addonReg as never, eb as never, loggingService as never, capReg)
|
|
236
|
+
|
|
237
|
+
await expect(provider.delete({ id: 'missing-id' })).rejects.toThrow('not found')
|
|
238
|
+
|
|
239
|
+
// Nothing should have been cleaned up
|
|
240
|
+
expect(integrationReg.deleteIntegration).not.toHaveBeenCalled()
|
|
241
|
+
expect(eb.emit).not.toHaveBeenCalled()
|
|
242
|
+
})
|
|
243
|
+
})
|
|
@@ -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
|
+
})
|