@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,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,388 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* cap-providers-bulk-update.spec.ts
|
|
3
|
+
*
|
|
4
|
+
* Verifies the wire-up in buildAddonsProvider for:
|
|
5
|
+
* 1. Delegation to BulkUpdateCoordinator (startBulkUpdate / cancelBulkUpdate /
|
|
6
|
+
* getBulkUpdateState / listActiveBulkUpdates)
|
|
7
|
+
* 2. isSystem field added to listUpdates output
|
|
8
|
+
* 3. deferRestart propagated through updateFrameworkPackage
|
|
9
|
+
*
|
|
10
|
+
* Spec: docs/superpowers/specs/2026-05-21-addons-bulk-update-progress-design.md
|
|
11
|
+
* Plan: docs/superpowers/plans/2026-05-21-addons-bulk-update-progress.md (Task 4)
|
|
12
|
+
*/
|
|
13
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
|
14
|
+
import type { IAddonsProvider } from '@camstack/types'
|
|
15
|
+
import { FRAMEWORK_PACKAGE_ALLOWLIST } from '../core/addon/addon-package.service.js'
|
|
16
|
+
|
|
17
|
+
// ── Module-level mock ─────────────────────────────────────────────────────────
|
|
18
|
+
// Must be hoisted before any import that resolves bulk-update-coordinator.
|
|
19
|
+
// Uses a real class so `new BulkUpdateCoordinator(...)` works; the instance
|
|
20
|
+
// methods are vi.fn stubs shared across all instances created in a test.
|
|
21
|
+
const _startFn = vi.fn()
|
|
22
|
+
const _getFn = vi.fn()
|
|
23
|
+
const _cancelFn = vi.fn()
|
|
24
|
+
const _listFn = vi.fn()
|
|
25
|
+
|
|
26
|
+
vi.mock('../api/core/bulk-update-coordinator.js', () => {
|
|
27
|
+
class MockBulkUpdateCoordinator {
|
|
28
|
+
start = _startFn
|
|
29
|
+
get = _getFn
|
|
30
|
+
cancel = _cancelFn
|
|
31
|
+
list = _listFn
|
|
32
|
+
}
|
|
33
|
+
return { BulkUpdateCoordinator: MockBulkUpdateCoordinator }
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
// ── Imports that depend on the mocked modules ─────────────────────────────────
|
|
37
|
+
import { buildAddonsProvider } from '../api/core/cap-providers.js'
|
|
38
|
+
import { makeCtx } from './cap-routers/harness.js'
|
|
39
|
+
|
|
40
|
+
// ── Helpers ───────────────────────────────────────────────────────────────────
|
|
41
|
+
|
|
42
|
+
function makeLogger() {
|
|
43
|
+
const logger = {
|
|
44
|
+
info: vi.fn(),
|
|
45
|
+
warn: vi.fn(),
|
|
46
|
+
error: vi.fn(),
|
|
47
|
+
debug: vi.fn(),
|
|
48
|
+
trace: vi.fn(),
|
|
49
|
+
fatal: vi.fn(),
|
|
50
|
+
scope: vi.fn(),
|
|
51
|
+
child: vi.fn(),
|
|
52
|
+
}
|
|
53
|
+
// scope returns a scoped logger with the same shape
|
|
54
|
+
logger.scope.mockReturnValue(logger)
|
|
55
|
+
logger.child.mockReturnValue(logger)
|
|
56
|
+
return logger
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
type StubStash = {
|
|
60
|
+
start: ReturnType<typeof vi.fn>
|
|
61
|
+
get: ReturnType<typeof vi.fn>
|
|
62
|
+
cancel: ReturnType<typeof vi.fn>
|
|
63
|
+
list: ReturnType<typeof vi.fn>
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function getCoordinatorStubs(): StubStash {
|
|
67
|
+
// The module-level fn references _startFn etc., shared across all mock instances.
|
|
68
|
+
return {
|
|
69
|
+
start: _startFn,
|
|
70
|
+
get: _getFn,
|
|
71
|
+
cancel: _cancelFn,
|
|
72
|
+
list: _listFn,
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
interface ProviderEnv {
|
|
77
|
+
readonly provider: IAddonsProvider
|
|
78
|
+
readonly psMock: Record<string, ReturnType<typeof vi.fn>>
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function createProviderEnv(): ProviderEnv {
|
|
82
|
+
const psMock: Record<string, ReturnType<typeof vi.fn>> = {
|
|
83
|
+
getRollbackablePackages: vi.fn().mockReturnValue(new Set()),
|
|
84
|
+
getAddonHealthSnapshot: vi.fn().mockReturnValue([]),
|
|
85
|
+
listAllAddons: vi.fn().mockReturnValue([]),
|
|
86
|
+
listInstalled: vi.fn().mockReturnValue([]),
|
|
87
|
+
installAndLoad: vi.fn().mockResolvedValue({ success: true }),
|
|
88
|
+
installFromWorkspaceAndLoad: vi.fn().mockResolvedValue({ success: true }),
|
|
89
|
+
isWorkspaceAvailable: vi.fn().mockResolvedValue(false),
|
|
90
|
+
listWorkspacePackages: vi.fn().mockResolvedValue([]),
|
|
91
|
+
uninstallAndReload: vi.fn().mockResolvedValue({ success: true }),
|
|
92
|
+
reloadPackages: vi.fn().mockResolvedValue({ success: true }),
|
|
93
|
+
searchNpm: vi.fn().mockResolvedValue([]),
|
|
94
|
+
checkUpdates: vi.fn().mockResolvedValue([]),
|
|
95
|
+
checkUpdatesForInstalled: vi.fn().mockResolvedValue([]),
|
|
96
|
+
updatePackage: vi.fn().mockResolvedValue({ success: true }),
|
|
97
|
+
rollbackPackage: vi.fn().mockResolvedValue({ success: true }),
|
|
98
|
+
restartServer: vi.fn().mockResolvedValue({ success: true }),
|
|
99
|
+
listFrameworkPackages: vi.fn().mockResolvedValue([]),
|
|
100
|
+
getPackageVersions: vi.fn().mockResolvedValue([]),
|
|
101
|
+
getAutoUpdateSettings: vi.fn().mockResolvedValue({ channel: 'stable', intervalSeconds: 3600 }),
|
|
102
|
+
setAutoUpdateSettings: vi.fn().mockResolvedValue({ success: true }),
|
|
103
|
+
getAddonAutoUpdate: vi.fn().mockResolvedValue({ channel: 'inherit' }),
|
|
104
|
+
setAddonAutoUpdate: vi.fn().mockResolvedValue({ success: true }),
|
|
105
|
+
updateFrameworkPackage: vi.fn().mockResolvedValue({
|
|
106
|
+
packageName: '@camstack/types',
|
|
107
|
+
fromVersion: '0.1.38',
|
|
108
|
+
toVersion: '0.1.40',
|
|
109
|
+
restartingAt: Date.now() + 500,
|
|
110
|
+
}),
|
|
111
|
+
packPackage: vi.fn().mockResolvedValue({ buffer: Buffer.alloc(0), version: '1.0.0' }),
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const arMock = {
|
|
115
|
+
listAddons: vi.fn().mockReturnValue([]),
|
|
116
|
+
listAllAddons: vi.fn().mockReturnValue([]),
|
|
117
|
+
getAddonHealthSnapshot: vi.fn().mockReturnValue([]),
|
|
118
|
+
getCapabilityRegistry: vi.fn().mockReturnValue({
|
|
119
|
+
listCapabilities: vi.fn().mockReturnValue([]),
|
|
120
|
+
}),
|
|
121
|
+
getCustomActionRegistry: vi.fn().mockReturnValue({
|
|
122
|
+
resolve: vi.fn().mockReturnValue(null),
|
|
123
|
+
}),
|
|
124
|
+
restartAddon: vi.fn().mockResolvedValue({ success: true }),
|
|
125
|
+
retryAddonLoad: vi.fn().mockResolvedValue({ success: true }),
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const lsMock = {
|
|
129
|
+
createLogger: vi.fn().mockReturnValue(makeLogger()),
|
|
130
|
+
query: vi.fn().mockResolvedValue([]),
|
|
131
|
+
subscribe: vi.fn().mockReturnValue(() => {}),
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const moleculerMock = {
|
|
135
|
+
broker: {
|
|
136
|
+
call: vi.fn().mockResolvedValue({}),
|
|
137
|
+
},
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const configServiceMock = {}
|
|
141
|
+
|
|
142
|
+
const ebMock = {
|
|
143
|
+
emit: vi.fn(),
|
|
144
|
+
subscribe: vi.fn().mockReturnValue(() => {}),
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const ctx = makeCtx('admin')
|
|
148
|
+
|
|
149
|
+
const provider = buildAddonsProvider(
|
|
150
|
+
arMock as never,
|
|
151
|
+
psMock as never,
|
|
152
|
+
lsMock as never,
|
|
153
|
+
moleculerMock as never,
|
|
154
|
+
configServiceMock as never,
|
|
155
|
+
ctx,
|
|
156
|
+
ebMock as never,
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
return { provider, psMock }
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// ── Tests ─────────────────────────────────────────────────────────────────────
|
|
163
|
+
|
|
164
|
+
describe('buildAddonsProvider — BulkUpdateCoordinator delegation', () => {
|
|
165
|
+
let env: ProviderEnv
|
|
166
|
+
let stubs: StubStash
|
|
167
|
+
|
|
168
|
+
beforeEach(() => {
|
|
169
|
+
vi.clearAllMocks()
|
|
170
|
+
env = createProviderEnv()
|
|
171
|
+
stubs = getCoordinatorStubs()
|
|
172
|
+
})
|
|
173
|
+
|
|
174
|
+
it('startBulkUpdate delegates to coordinator.start with same args and returns its value', async () => {
|
|
175
|
+
const returnValue = { id: 'bulk-abc-123' }
|
|
176
|
+
stubs.start.mockReturnValue(returnValue)
|
|
177
|
+
|
|
178
|
+
const input = {
|
|
179
|
+
nodeId: 'hub',
|
|
180
|
+
items: [
|
|
181
|
+
{ name: '@camstack/addon-stream-broker', version: '1.2.3', isSystem: false },
|
|
182
|
+
{ name: '@camstack/types', version: '0.1.40', isSystem: true },
|
|
183
|
+
] as const,
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
const result = await env.provider.startBulkUpdate(input)
|
|
187
|
+
|
|
188
|
+
expect(stubs.start).toHaveBeenCalledOnce()
|
|
189
|
+
expect(stubs.start).toHaveBeenCalledWith(input)
|
|
190
|
+
expect(result).toEqual(returnValue)
|
|
191
|
+
})
|
|
192
|
+
|
|
193
|
+
it('cancelBulkUpdate delegates to coordinator.cancel with the id and returns its value', async () => {
|
|
194
|
+
const returnValue = { cancelled: true }
|
|
195
|
+
stubs.cancel.mockReturnValue(returnValue)
|
|
196
|
+
|
|
197
|
+
const result = await env.provider.cancelBulkUpdate({ id: 'bulk-xyz' })
|
|
198
|
+
|
|
199
|
+
expect(stubs.cancel).toHaveBeenCalledOnce()
|
|
200
|
+
expect(stubs.cancel).toHaveBeenCalledWith('bulk-xyz')
|
|
201
|
+
expect(result).toEqual(returnValue)
|
|
202
|
+
})
|
|
203
|
+
|
|
204
|
+
it('getBulkUpdateState delegates to coordinator.get with the id and returns its value', async () => {
|
|
205
|
+
const mockState = {
|
|
206
|
+
id: 'bulk-abc',
|
|
207
|
+
nodeId: 'hub',
|
|
208
|
+
startedAtMs: 1000,
|
|
209
|
+
total: 2,
|
|
210
|
+
completed: 1,
|
|
211
|
+
failed: 0,
|
|
212
|
+
current: null,
|
|
213
|
+
phase: 'regular' as const,
|
|
214
|
+
cancelled: false,
|
|
215
|
+
items: [],
|
|
216
|
+
}
|
|
217
|
+
stubs.get.mockReturnValue(mockState)
|
|
218
|
+
|
|
219
|
+
const result = await env.provider.getBulkUpdateState({ id: 'bulk-abc' })
|
|
220
|
+
|
|
221
|
+
expect(stubs.get).toHaveBeenCalledOnce()
|
|
222
|
+
expect(stubs.get).toHaveBeenCalledWith('bulk-abc')
|
|
223
|
+
expect(result).toEqual(mockState)
|
|
224
|
+
})
|
|
225
|
+
|
|
226
|
+
it('getBulkUpdateState returns null when coordinator.get returns null', async () => {
|
|
227
|
+
stubs.get.mockReturnValue(null)
|
|
228
|
+
|
|
229
|
+
const result = await env.provider.getBulkUpdateState({ id: 'unknown-id' })
|
|
230
|
+
|
|
231
|
+
expect(result).toBeNull()
|
|
232
|
+
})
|
|
233
|
+
|
|
234
|
+
it('listActiveBulkUpdates delegates to coordinator.list with nodeId and returns its value', async () => {
|
|
235
|
+
const mockList = [
|
|
236
|
+
{ id: 'bulk-1', nodeId: 'hub', startedAtMs: 1000, total: 1, completed: 0, failed: 0, current: 'pkg-a', phase: 'regular' as const, cancelled: false, items: [] },
|
|
237
|
+
]
|
|
238
|
+
stubs.list.mockReturnValue(mockList)
|
|
239
|
+
|
|
240
|
+
const result = await env.provider.listActiveBulkUpdates({ nodeId: 'hub' })
|
|
241
|
+
|
|
242
|
+
expect(stubs.list).toHaveBeenCalledOnce()
|
|
243
|
+
expect(stubs.list).toHaveBeenCalledWith('hub')
|
|
244
|
+
expect(result).toEqual(mockList)
|
|
245
|
+
})
|
|
246
|
+
|
|
247
|
+
it('listActiveBulkUpdates passes undefined when nodeId is omitted', async () => {
|
|
248
|
+
stubs.list.mockReturnValue([])
|
|
249
|
+
|
|
250
|
+
await env.provider.listActiveBulkUpdates({})
|
|
251
|
+
|
|
252
|
+
expect(stubs.list).toHaveBeenCalledWith(undefined)
|
|
253
|
+
})
|
|
254
|
+
})
|
|
255
|
+
|
|
256
|
+
describe('buildAddonsProvider — listUpdates isSystem field', () => {
|
|
257
|
+
let env: ProviderEnv
|
|
258
|
+
|
|
259
|
+
beforeEach(() => {
|
|
260
|
+
vi.clearAllMocks()
|
|
261
|
+
env = createProviderEnv()
|
|
262
|
+
})
|
|
263
|
+
|
|
264
|
+
it('adds isSystem: true for packages in FRAMEWORK_PACKAGE_ALLOWLIST', async () => {
|
|
265
|
+
const systemPkg = FRAMEWORK_PACKAGE_ALLOWLIST[0]!
|
|
266
|
+
env.psMock['checkUpdates']!.mockResolvedValue([
|
|
267
|
+
{
|
|
268
|
+
name: systemPkg,
|
|
269
|
+
currentVersion: '0.1.38',
|
|
270
|
+
latestVersion: '0.1.40',
|
|
271
|
+
category: 'core',
|
|
272
|
+
requiresRestart: true,
|
|
273
|
+
},
|
|
274
|
+
])
|
|
275
|
+
|
|
276
|
+
const result = await env.provider.listUpdates({ nodeId: 'hub' })
|
|
277
|
+
|
|
278
|
+
expect(result).toHaveLength(1)
|
|
279
|
+
expect(result[0]!.name).toBe(systemPkg)
|
|
280
|
+
expect(result[0]!.isSystem).toBe(true)
|
|
281
|
+
})
|
|
282
|
+
|
|
283
|
+
it('adds isSystem: false for non-framework packages', async () => {
|
|
284
|
+
env.psMock['checkUpdates']!.mockResolvedValue([
|
|
285
|
+
{
|
|
286
|
+
name: '@camstack/addon-stream-broker',
|
|
287
|
+
currentVersion: '1.0.0',
|
|
288
|
+
latestVersion: '1.0.1',
|
|
289
|
+
category: 'addon',
|
|
290
|
+
requiresRestart: false,
|
|
291
|
+
},
|
|
292
|
+
])
|
|
293
|
+
|
|
294
|
+
const result = await env.provider.listUpdates({ nodeId: 'hub' })
|
|
295
|
+
|
|
296
|
+
expect(result).toHaveLength(1)
|
|
297
|
+
expect(result[0]!.isSystem).toBe(false)
|
|
298
|
+
})
|
|
299
|
+
|
|
300
|
+
it('handles mixed system and non-system packages correctly', async () => {
|
|
301
|
+
env.psMock['checkUpdates']!.mockResolvedValue([
|
|
302
|
+
{
|
|
303
|
+
name: '@camstack/types',
|
|
304
|
+
currentVersion: '0.1.38',
|
|
305
|
+
latestVersion: '0.1.40',
|
|
306
|
+
category: 'core',
|
|
307
|
+
requiresRestart: true,
|
|
308
|
+
},
|
|
309
|
+
{
|
|
310
|
+
name: '@camstack/addon-foo',
|
|
311
|
+
currentVersion: '1.0.0',
|
|
312
|
+
latestVersion: '1.0.1',
|
|
313
|
+
category: 'addon',
|
|
314
|
+
requiresRestart: false,
|
|
315
|
+
},
|
|
316
|
+
])
|
|
317
|
+
|
|
318
|
+
const result = await env.provider.listUpdates({ nodeId: 'hub' })
|
|
319
|
+
|
|
320
|
+
expect(result).toHaveLength(2)
|
|
321
|
+
const typesRow = result.find(r => r.name === '@camstack/types')
|
|
322
|
+
const fooRow = result.find(r => r.name === '@camstack/addon-foo')
|
|
323
|
+
expect(typesRow?.isSystem).toBe(true)
|
|
324
|
+
expect(fooRow?.isSystem).toBe(false)
|
|
325
|
+
})
|
|
326
|
+
|
|
327
|
+
it('all FRAMEWORK_PACKAGE_ALLOWLIST members get isSystem: true', async () => {
|
|
328
|
+
env.psMock['checkUpdates']!.mockResolvedValue(
|
|
329
|
+
FRAMEWORK_PACKAGE_ALLOWLIST.map(name => ({
|
|
330
|
+
name,
|
|
331
|
+
currentVersion: '0.1.0',
|
|
332
|
+
latestVersion: '0.1.1',
|
|
333
|
+
category: 'core',
|
|
334
|
+
requiresRestart: true,
|
|
335
|
+
})),
|
|
336
|
+
)
|
|
337
|
+
|
|
338
|
+
const result = await env.provider.listUpdates({ nodeId: 'hub' })
|
|
339
|
+
|
|
340
|
+
expect(result).toHaveLength(FRAMEWORK_PACKAGE_ALLOWLIST.length)
|
|
341
|
+
for (const row of result) {
|
|
342
|
+
expect(row.isSystem).toBe(true)
|
|
343
|
+
}
|
|
344
|
+
})
|
|
345
|
+
})
|
|
346
|
+
|
|
347
|
+
describe('buildAddonsProvider — updateFrameworkPackage deferRestart propagation', () => {
|
|
348
|
+
let env: ProviderEnv
|
|
349
|
+
|
|
350
|
+
beforeEach(() => {
|
|
351
|
+
vi.clearAllMocks()
|
|
352
|
+
env = createProviderEnv()
|
|
353
|
+
})
|
|
354
|
+
|
|
355
|
+
it('forwards deferRestart: true to the service', async () => {
|
|
356
|
+
await env.provider.updateFrameworkPackage({
|
|
357
|
+
packageName: '@camstack/types',
|
|
358
|
+
version: '0.1.40',
|
|
359
|
+
deferRestart: true,
|
|
360
|
+
})
|
|
361
|
+
|
|
362
|
+
expect(env.psMock['updateFrameworkPackage']).toHaveBeenCalledOnce()
|
|
363
|
+
const callArg = env.psMock['updateFrameworkPackage']!.mock.calls[0]![0] as Record<string, unknown>
|
|
364
|
+
expect(callArg['deferRestart']).toBe(true)
|
|
365
|
+
})
|
|
366
|
+
|
|
367
|
+
it('forwards deferRestart: false to the service', async () => {
|
|
368
|
+
await env.provider.updateFrameworkPackage({
|
|
369
|
+
packageName: '@camstack/types',
|
|
370
|
+
version: '0.1.40',
|
|
371
|
+
deferRestart: false,
|
|
372
|
+
})
|
|
373
|
+
|
|
374
|
+
const callArg = env.psMock['updateFrameworkPackage']!.mock.calls[0]![0] as Record<string, unknown>
|
|
375
|
+
expect(callArg['deferRestart']).toBe(false)
|
|
376
|
+
})
|
|
377
|
+
|
|
378
|
+
it('does not include deferRestart when it is omitted', async () => {
|
|
379
|
+
await env.provider.updateFrameworkPackage({
|
|
380
|
+
packageName: '@camstack/types',
|
|
381
|
+
version: '0.1.40',
|
|
382
|
+
})
|
|
383
|
+
|
|
384
|
+
const callArg = env.psMock['updateFrameworkPackage']!.mock.calls[0]![0] as Record<string, unknown>
|
|
385
|
+
// Either undefined or not present — both are acceptable
|
|
386
|
+
expect(callArg['deferRestart']).toBeUndefined()
|
|
387
|
+
})
|
|
388
|
+
})
|