@camstack/server 0.1.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.env.example +17 -0
- package/package.json +55 -0
- package/src/__tests__/addon-install-e2e.test.ts +75 -0
- package/src/__tests__/addon-pages-e2e.test.ts +178 -0
- package/src/__tests__/addon-route-session.test.ts +17 -0
- package/src/__tests__/addon-settings-router.spec.ts +62 -0
- package/src/__tests__/addon-upload.spec.ts +355 -0
- package/src/__tests__/agent-registry.spec.ts +162 -0
- package/src/__tests__/agent-status-page.spec.ts +84 -0
- package/src/__tests__/auth-session-cookie.test.ts +21 -0
- package/src/__tests__/cap-providers/cap-usage-graph.spec.ts +23 -0
- package/src/__tests__/cap-providers/compute-topology-categories.spec.ts +64 -0
- package/src/__tests__/cap-routers/_meta.spec.ts +200 -0
- package/src/__tests__/cap-routers/addon-settings.router.spec.ts +106 -0
- package/src/__tests__/cap-routers/device-manager-aggregate.router.spec.ts +142 -0
- package/src/__tests__/cap-routers/harness.ts +159 -0
- package/src/__tests__/cap-routers/metrics-provider.router.spec.ts +119 -0
- package/src/__tests__/cap-routers/null-provider-guard.spec.ts +66 -0
- package/src/__tests__/cap-routers/pipeline-executor.router.spec.ts +135 -0
- package/src/__tests__/cap-routers/settings-store.router.spec.ts +247 -0
- package/src/__tests__/capability-e2e.test.ts +386 -0
- package/src/__tests__/cli-e2e.test.ts +129 -0
- package/src/__tests__/core-cap-bridge.spec.ts +89 -0
- package/src/__tests__/embedded-deps-e2e.test.ts +109 -0
- package/src/__tests__/event-bus-proxy-router.spec.ts +72 -0
- package/src/__tests__/fixtures/mock-analysis-addon-a.ts +37 -0
- package/src/__tests__/fixtures/mock-analysis-addon-b.ts +37 -0
- package/src/__tests__/fixtures/mock-log-addon.ts +37 -0
- package/src/__tests__/fixtures/mock-storage-addon.ts +40 -0
- package/src/__tests__/framework-allowlist.spec.ts +95 -0
- package/src/__tests__/https-e2e.test.ts +118 -0
- package/src/__tests__/lifecycle-e2e.test.ts +140 -0
- package/src/__tests__/live-events-subscription.spec.ts +150 -0
- package/src/__tests__/moleculer-register-node-idempotency.spec.ts +229 -0
- package/src/__tests__/oauth2-account-linking.spec.ts +736 -0
- package/src/__tests__/post-boot-restart.spec.ts +161 -0
- package/src/__tests__/singleton-contention.test.ts +487 -0
- package/src/__tests__/streaming-diagnostic.test.ts +512 -0
- package/src/__tests__/streaming-scale.test.ts +280 -0
- package/src/agent-status-page.ts +121 -0
- package/src/api/__tests__/addons-custom.spec.ts +134 -0
- package/src/api/__tests__/capabilities.router.test.ts +47 -0
- package/src/api/addon-upload.ts +472 -0
- package/src/api/addons-custom.router.ts +100 -0
- package/src/api/auth-whoami.ts +99 -0
- package/src/api/bridge-addons.router.ts +120 -0
- package/src/api/capabilities.router.ts +226 -0
- package/src/api/core/__tests__/auth-router-totp.spec.ts +256 -0
- package/src/api/core/addon-settings.router.ts +124 -0
- package/src/api/core/agents.router.ts +87 -0
- package/src/api/core/auth.router.ts +303 -0
- package/src/api/core/cap-providers.ts +993 -0
- package/src/api/core/capabilities.router.ts +119 -0
- package/src/api/core/collection-preference.ts +40 -0
- package/src/api/core/event-bus-proxy.router.ts +45 -0
- package/src/api/core/hwaccel.router.ts +81 -0
- package/src/api/core/live-events.router.ts +60 -0
- package/src/api/core/logs.router.ts +162 -0
- package/src/api/core/notifications.router.ts +65 -0
- package/src/api/core/repl.router.ts +41 -0
- package/src/api/core/settings-backend.router.ts +142 -0
- package/src/api/core/stream-probe.router.ts +57 -0
- package/src/api/core/system-events.router.ts +116 -0
- package/src/api/health/health.routes.ts +123 -0
- package/src/api/oauth2/__tests__/oauth2-routes.spec.ts +52 -0
- package/src/api/oauth2/consent-page.ts +42 -0
- package/src/api/oauth2/oauth2-routes.ts +248 -0
- package/src/api/trpc/__tests__/scope-access-device.spec.ts +223 -0
- package/src/api/trpc/__tests__/scope-access.spec.ts +107 -0
- package/src/api/trpc/cap-mount-helpers.ts +225 -0
- package/src/api/trpc/core-cap-bridge.ts +152 -0
- package/src/api/trpc/generated-cap-mounts.ts +707 -0
- package/src/api/trpc/generated-cap-routers.ts +6340 -0
- package/src/api/trpc/scope-access.ts +110 -0
- package/src/api/trpc/trpc.context.ts +255 -0
- package/src/api/trpc/trpc.middleware.ts +140 -0
- package/src/api/trpc/trpc.router.ts +275 -0
- package/src/auth/session-cookie.ts +44 -0
- package/src/boot/boot-config.ts +278 -0
- package/src/boot/post-boot.service.ts +103 -0
- package/src/core/addon/__tests__/addon-registry-capability.test.ts +53 -0
- package/src/core/addon/addon-package.service.ts +1684 -0
- package/src/core/addon/addon-registry.service.ts +2926 -0
- package/src/core/addon/addon-search.service.ts +90 -0
- package/src/core/addon/addon-settings-provider.ts +276 -0
- package/src/core/addon/addon.tokens.ts +2 -0
- package/src/core/addon-bridge/addon-bridge.service.ts +125 -0
- package/src/core/addon-pages/addon-pages.service.spec.ts +117 -0
- package/src/core/addon-pages/addon-pages.service.ts +80 -0
- package/src/core/addon-widgets/addon-widgets.service.ts +92 -0
- package/src/core/agent/agent-registry.service.ts +507 -0
- package/src/core/auth/auth.service.spec.ts +88 -0
- package/src/core/auth/auth.service.ts +8 -0
- package/src/core/capability/capability.service.ts +57 -0
- package/src/core/config/config.schema.ts +3 -0
- package/src/core/config/config.service.spec.ts +175 -0
- package/src/core/config/config.service.ts +7 -0
- package/src/core/events/event-bus.service.spec.ts +212 -0
- package/src/core/events/event-bus.service.ts +85 -0
- package/src/core/feature/feature.service.spec.ts +96 -0
- package/src/core/feature/feature.service.ts +8 -0
- package/src/core/lifecycle/lifecycle-state-machine.spec.ts +168 -0
- package/src/core/lifecycle/lifecycle-state-machine.ts +3 -0
- package/src/core/logging/log-ring-buffer.ts +3 -0
- package/src/core/logging/logging.service.spec.ts +247 -0
- package/src/core/logging/logging.service.ts +129 -0
- package/src/core/logging/scoped-logger.ts +3 -0
- package/src/core/moleculer/moleculer.service.ts +612 -0
- package/src/core/network/network-quality.service.spec.ts +47 -0
- package/src/core/network/network-quality.service.ts +5 -0
- package/src/core/notification/notification-wrapper.service.ts +36 -0
- package/src/core/notification/toast-wrapper.service.ts +31 -0
- package/src/core/provider/provider.tokens.ts +1 -0
- package/src/core/repl/repl-engine.service.spec.ts +417 -0
- package/src/core/repl/repl-engine.service.ts +156 -0
- package/src/core/storage/fs-storage-backend.spec.ts +70 -0
- package/src/core/storage/fs-storage-backend.ts +3 -0
- package/src/core/storage/settings-store.spec.ts +213 -0
- package/src/core/storage/settings-store.ts +2 -0
- package/src/core/storage/sql-schema.spec.ts +140 -0
- package/src/core/storage/sql-schema.ts +3 -0
- package/src/core/storage/storage-location-manager.spec.ts +121 -0
- package/src/core/storage/storage-location-manager.ts +3 -0
- package/src/core/storage/storage.service.spec.ts +73 -0
- package/src/core/storage/storage.service.ts +3 -0
- package/src/core/streaming/stream-probe.service.ts +212 -0
- package/src/core/topology/topology-emitter.service.ts +101 -0
- package/src/launcher.ts +309 -0
- package/src/main.ts +1049 -0
- package/src/manual-boot.ts +322 -0
- package/tsconfig.build.json +8 -0
- package/tsconfig.json +21 -0
- package/vitest.config.ts +26 -0
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
// server/backend/src/core/storage/fs-storage-backend.spec.ts
|
|
2
|
+
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
|
|
3
|
+
import { mkdirSync, rmSync } from 'node:fs'
|
|
4
|
+
import { join } from 'node:path'
|
|
5
|
+
import { tmpdir } from 'node:os'
|
|
6
|
+
import { FsStorageBackend } from './fs-storage-backend'
|
|
7
|
+
|
|
8
|
+
describe('FsStorageBackend', () => {
|
|
9
|
+
let tempDir: string
|
|
10
|
+
let backend: FsStorageBackend
|
|
11
|
+
|
|
12
|
+
beforeEach(() => {
|
|
13
|
+
tempDir = join(tmpdir(), `fs-backend-test-${Date.now()}`)
|
|
14
|
+
mkdirSync(tempDir, { recursive: true })
|
|
15
|
+
backend = new FsStorageBackend(tempDir)
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
afterEach(() => {
|
|
19
|
+
rmSync(tempDir, { recursive: true, force: true })
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
it('type is local', () => {
|
|
23
|
+
expect(backend.type).toBe('local')
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
it('basePath is resolved to absolute', () => {
|
|
27
|
+
const relative = new FsStorageBackend('./relative/path')
|
|
28
|
+
expect(relative.basePath).toContain('/')
|
|
29
|
+
expect(relative.basePath).not.toContain('./')
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
it('resolve joins subpath', () => {
|
|
33
|
+
expect(backend.resolve('models/yolov8n.onnx')).toBe(join(tempDir, 'models/yolov8n.onnx'))
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
it('resolve returns absolute paths as-is', () => {
|
|
37
|
+
expect(backend.resolve('/absolute/path')).toBe('/absolute/path')
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
it('isAvailable returns true for existing writable dir', () => {
|
|
41
|
+
expect(backend.isAvailable()).toBe(true)
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
it('isAvailable returns false when no existing ancestor is writable', () => {
|
|
45
|
+
// Nothing under /nonexistent exists, so no writable ancestor →
|
|
46
|
+
// the backend cannot be created when needed.
|
|
47
|
+
const missing = new FsStorageBackend('/nonexistent/path/xyz')
|
|
48
|
+
expect(missing.isAvailable()).toBe(false)
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
it('isAvailable returns true for a not-yet-existing dir whose parent is writable', () => {
|
|
52
|
+
// Lazy creation: the backend reports itself as available when the
|
|
53
|
+
// nearest existing ancestor (tempDir) is writable, because we can
|
|
54
|
+
// `mkdir -p` the missing segments on first write.
|
|
55
|
+
const notYet = new FsStorageBackend(join(tempDir, 'sub', 'dir'))
|
|
56
|
+
expect(notYet.isAvailable()).toBe(true)
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
it('initialize is a no-op — directory is created lazily on first write', async () => {
|
|
60
|
+
const { existsSync } = await import('node:fs')
|
|
61
|
+
const newPath = join(tempDir, 'sub', 'dir')
|
|
62
|
+
const newBackend = new FsStorageBackend(newPath)
|
|
63
|
+
// The base dir doesn't exist yet, but the ancestor chain is
|
|
64
|
+
// writable — so the backend is available and initialize is a
|
|
65
|
+
// no-op (doesn't create the dir eagerly).
|
|
66
|
+
expect(newBackend.isAvailable()).toBe(true)
|
|
67
|
+
await newBackend.initialize()
|
|
68
|
+
expect(existsSync(newPath)).toBe(false)
|
|
69
|
+
})
|
|
70
|
+
})
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
|
|
2
|
+
// server/backend/src/core/storage/settings-store.spec.ts
|
|
3
|
+
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
|
|
4
|
+
import { SettingsStore } from './settings-store'
|
|
5
|
+
import { RUNTIME_DEFAULTS } from '../config/config.schema'
|
|
6
|
+
|
|
7
|
+
// Use an in-memory SQLite DB for tests so no temp files are created.
|
|
8
|
+
function makeStore(): SettingsStore {
|
|
9
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-call --
|
|
10
|
+
return new (SettingsStore as any)(':memory:') as SettingsStore
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
// Re-open the private constructor with ':memory:' by temporarily patching the
|
|
14
|
+
// constructor argument. SettingsStore accepts a dbPath string; ':memory:' is
|
|
15
|
+
// the special better-sqlite3 in-memory sentinel.
|
|
16
|
+
function createStore(): SettingsStore {
|
|
17
|
+
return new SettingsStore(':memory:')
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
describe('SettingsStore — system settings', () => {
|
|
21
|
+
let store: SettingsStore
|
|
22
|
+
|
|
23
|
+
beforeEach(() => { store = createStore() })
|
|
24
|
+
afterEach(() => { store.close() })
|
|
25
|
+
|
|
26
|
+
it('returns undefined for a key that does not exist', () => {
|
|
27
|
+
expect(store.getSystem('nonexistent')).toBeUndefined()
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
it('stores and retrieves a string value', () => {
|
|
31
|
+
store.setSystem('logging.level', 'debug')
|
|
32
|
+
expect(store.getSystem('logging.level')).toBe('debug')
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
it('stores and retrieves a number value', () => {
|
|
36
|
+
store.setSystem('retention.days', 30)
|
|
37
|
+
expect(store.getSystem('retention.days')).toBe(30)
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
it('stores and retrieves a boolean value', () => {
|
|
41
|
+
store.setSystem('features.streaming', true)
|
|
42
|
+
expect(store.getSystem('features.streaming')).toBe(true)
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
it('stores and retrieves a nested object (JSON serialization)', () => {
|
|
46
|
+
const obj = { a: 1, b: [1, 2, 3], c: { nested: true } }
|
|
47
|
+
store.setSystem('complex.key', obj)
|
|
48
|
+
expect(store.getSystem('complex.key')).toEqual(obj)
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
it('overwrites an existing key', () => {
|
|
52
|
+
store.setSystem('logging.level', 'info')
|
|
53
|
+
store.setSystem('logging.level', 'warn')
|
|
54
|
+
expect(store.getSystem('logging.level')).toBe('warn')
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
it('getAllSystem returns all stored keys', () => {
|
|
58
|
+
store.setSystem('key.a', 1)
|
|
59
|
+
store.setSystem('key.b', 2)
|
|
60
|
+
const all = store.getAllSystem()
|
|
61
|
+
expect(all['key.a']).toBe(1)
|
|
62
|
+
expect(all['key.b']).toBe(2)
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
it('getAllSystem returns empty object when no keys are set', () => {
|
|
66
|
+
expect(store.getAllSystem()).toEqual({})
|
|
67
|
+
})
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
describe('SettingsStore — addon settings', () => {
|
|
71
|
+
let store: SettingsStore
|
|
72
|
+
|
|
73
|
+
beforeEach(() => { store = createStore() })
|
|
74
|
+
afterEach(() => { store.close() })
|
|
75
|
+
|
|
76
|
+
it('returns undefined for a missing addon key', () => {
|
|
77
|
+
expect(store.getAddon('sqlite-storage', 'dbPath')).toBeUndefined()
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
it('stores and retrieves a value scoped by addonId', () => {
|
|
81
|
+
store.setAddon('sqlite-storage', 'dbPath', '/data/camstack.db')
|
|
82
|
+
expect(store.getAddon('sqlite-storage', 'dbPath')).toBe('/data/camstack.db')
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
it('scopes values: different addons do not share keys', () => {
|
|
86
|
+
store.setAddon('addon-a', 'sharedKey', 'value-a')
|
|
87
|
+
store.setAddon('addon-b', 'sharedKey', 'value-b')
|
|
88
|
+
expect(store.getAddon('addon-a', 'sharedKey')).toBe('value-a')
|
|
89
|
+
expect(store.getAddon('addon-b', 'sharedKey')).toBe('value-b')
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
it('getAllAddon returns only keys for the requested addon', () => {
|
|
93
|
+
store.setAddon('addon-a', 'key1', 10)
|
|
94
|
+
store.setAddon('addon-a', 'key2', 20)
|
|
95
|
+
store.setAddon('addon-b', 'key1', 99)
|
|
96
|
+
const all = store.getAllAddon('addon-a')
|
|
97
|
+
expect(all).toEqual({ key1: 10, key2: 20 })
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
it('setAllAddon replaces all keys atomically', () => {
|
|
101
|
+
store.setAddon('my-addon', 'old', 'gone')
|
|
102
|
+
store.setAllAddon('my-addon', { newKey: 'newVal', another: 42 })
|
|
103
|
+
const all = store.getAllAddon('my-addon')
|
|
104
|
+
expect(all).toEqual({ newKey: 'newVal', another: 42 })
|
|
105
|
+
expect(store.getAddon('my-addon', 'old')).toBeUndefined()
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
it('setAllAddon does not affect other addons', () => {
|
|
109
|
+
store.setAddon('other-addon', 'keep', 'me')
|
|
110
|
+
store.setAllAddon('my-addon', { x: 1 })
|
|
111
|
+
expect(store.getAddon('other-addon', 'keep')).toBe('me')
|
|
112
|
+
})
|
|
113
|
+
})
|
|
114
|
+
|
|
115
|
+
describe('SettingsStore — provider settings', () => {
|
|
116
|
+
let store: SettingsStore
|
|
117
|
+
|
|
118
|
+
beforeEach(() => { store = createStore() })
|
|
119
|
+
afterEach(() => { store.close() })
|
|
120
|
+
|
|
121
|
+
it('returns undefined for a missing provider key', () => {
|
|
122
|
+
expect(store.getProvider('prov-1', 'url')).toBeUndefined()
|
|
123
|
+
})
|
|
124
|
+
|
|
125
|
+
it('stores and retrieves a provider value', () => {
|
|
126
|
+
store.setProvider('prov-1', 'url', 'http://localhost:8554')
|
|
127
|
+
expect(store.getProvider('prov-1', 'url')).toBe('http://localhost:8554')
|
|
128
|
+
})
|
|
129
|
+
|
|
130
|
+
it('scopes values between providers', () => {
|
|
131
|
+
store.setProvider('prov-1', 'url', 'http://host-a')
|
|
132
|
+
store.setProvider('prov-2', 'url', 'http://host-b')
|
|
133
|
+
expect(store.getProvider('prov-1', 'url')).toBe('http://host-a')
|
|
134
|
+
expect(store.getProvider('prov-2', 'url')).toBe('http://host-b')
|
|
135
|
+
})
|
|
136
|
+
|
|
137
|
+
it('getAllProvider returns all keys for a provider', () => {
|
|
138
|
+
store.setProvider('prov-1', 'url', 'http://x')
|
|
139
|
+
store.setProvider('prov-1', 'port', 8554)
|
|
140
|
+
expect(store.getAllProvider('prov-1')).toEqual({ url: 'http://x', port: 8554 })
|
|
141
|
+
})
|
|
142
|
+
})
|
|
143
|
+
|
|
144
|
+
describe('SettingsStore — device settings', () => {
|
|
145
|
+
let store: SettingsStore
|
|
146
|
+
|
|
147
|
+
beforeEach(() => { store = createStore() })
|
|
148
|
+
afterEach(() => { store.close() })
|
|
149
|
+
|
|
150
|
+
it('returns undefined for a missing device key', () => {
|
|
151
|
+
expect(store.getDevice('cam-1', 'label')).toBeUndefined()
|
|
152
|
+
})
|
|
153
|
+
|
|
154
|
+
it('stores and retrieves a device value', () => {
|
|
155
|
+
store.setDevice('cam-1', 'label', 'Front Door')
|
|
156
|
+
expect(store.getDevice('cam-1', 'label')).toBe('Front Door')
|
|
157
|
+
})
|
|
158
|
+
|
|
159
|
+
it('scopes values between devices', () => {
|
|
160
|
+
store.setDevice('cam-1', 'label', 'A')
|
|
161
|
+
store.setDevice('cam-2', 'label', 'B')
|
|
162
|
+
expect(store.getDevice('cam-1', 'label')).toBe('A')
|
|
163
|
+
expect(store.getDevice('cam-2', 'label')).toBe('B')
|
|
164
|
+
})
|
|
165
|
+
|
|
166
|
+
it('getAllDevice returns all keys for a device', () => {
|
|
167
|
+
store.setDevice('cam-1', 'label', 'Cam 1')
|
|
168
|
+
store.setDevice('cam-1', 'enabled', true)
|
|
169
|
+
expect(store.getAllDevice('cam-1')).toEqual({ label: 'Cam 1', enabled: true })
|
|
170
|
+
})
|
|
171
|
+
})
|
|
172
|
+
|
|
173
|
+
describe('SettingsStore — first boot seeding', () => {
|
|
174
|
+
let store: SettingsStore
|
|
175
|
+
|
|
176
|
+
beforeEach(() => { store = createStore() })
|
|
177
|
+
afterEach(() => { store.close() })
|
|
178
|
+
|
|
179
|
+
it('isSystemSettingsEmpty returns true on a fresh DB', () => {
|
|
180
|
+
expect(store.isSystemSettingsEmpty()).toBe(true)
|
|
181
|
+
})
|
|
182
|
+
|
|
183
|
+
it('seedDefaults populates system_settings with RUNTIME_DEFAULTS', () => {
|
|
184
|
+
store.seedDefaults()
|
|
185
|
+
expect(store.isSystemSettingsEmpty()).toBe(false)
|
|
186
|
+
for (const [key, expected] of Object.entries(RUNTIME_DEFAULTS)) {
|
|
187
|
+
expect(store.getSystem(key)).toEqual(expected)
|
|
188
|
+
}
|
|
189
|
+
})
|
|
190
|
+
|
|
191
|
+
it('seedDefaults does not overwrite existing values (INSERT OR IGNORE)', () => {
|
|
192
|
+
store.setSystem('logging.level', 'debug')
|
|
193
|
+
store.seedDefaults()
|
|
194
|
+
// The manually set value should survive — seedDefaults uses INSERT OR IGNORE
|
|
195
|
+
expect(store.getSystem('logging.level')).toBe('debug')
|
|
196
|
+
})
|
|
197
|
+
|
|
198
|
+
it('second boot: seedDefaults is skipped when table is not empty', () => {
|
|
199
|
+
store.seedDefaults()
|
|
200
|
+
// Simulate a manual override then "re-boot"
|
|
201
|
+
store.setSystem('logging.level', 'warn')
|
|
202
|
+
|
|
203
|
+
// On second boot we would call seedDefaults only when isEmpty — simulate that guard
|
|
204
|
+
if (!store.isSystemSettingsEmpty()) {
|
|
205
|
+
// No-op: skip seeding
|
|
206
|
+
} else {
|
|
207
|
+
store.seedDefaults()
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// Value from first seed should remain (not reverted to RUNTIME_DEFAULTS)
|
|
211
|
+
expect(store.getSystem('logging.level')).toBe('warn')
|
|
212
|
+
})
|
|
213
|
+
})
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
// server/backend/src/core/storage/sql-schema.spec.ts
|
|
2
|
+
import { describe, it, expect } from 'vitest'
|
|
3
|
+
import { addonTableToDdl, CORE_TABLE_DDL, type AddonTableSchema } from './sql-schema'
|
|
4
|
+
|
|
5
|
+
describe('CORE_TABLE_DDL', () => {
|
|
6
|
+
it('is a non-empty readonly array of strings', () => {
|
|
7
|
+
expect(CORE_TABLE_DDL.length).toBeGreaterThan(0)
|
|
8
|
+
for (const stmt of CORE_TABLE_DDL) {
|
|
9
|
+
expect(typeof stmt).toBe('string')
|
|
10
|
+
expect(stmt.length).toBeGreaterThan(0)
|
|
11
|
+
}
|
|
12
|
+
})
|
|
13
|
+
|
|
14
|
+
it('contains CREATE TABLE IF NOT EXISTS for core tables', () => {
|
|
15
|
+
// `detection_events`, `audio_levels`, `track_trails` were removed
|
|
16
|
+
// in the P12b sweep — they're now owned by `addon-pipeline-analytics`
|
|
17
|
+
// via `declareCollection` (pipeline-analytics:motion-events,
|
|
18
|
+
// :object-events, :audio-events, :tracks, :media). The hub-core
|
|
19
|
+
// DDL only carries the foundational settings + device registry
|
|
20
|
+
// tables.
|
|
21
|
+
const tables = ['system_settings', 'addon_settings', 'provider_settings', 'device_settings', 'devices']
|
|
22
|
+
for (const table of tables) {
|
|
23
|
+
const found = CORE_TABLE_DDL.some(stmt => stmt.includes(`CREATE TABLE IF NOT EXISTS ${table}`))
|
|
24
|
+
expect(found, `Expected DDL for table "${table}"`).toBe(true)
|
|
25
|
+
}
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
it('contains indexes for the devices table', () => {
|
|
29
|
+
const found = CORE_TABLE_DDL.some(stmt => stmt.includes('idx_devices_addon'))
|
|
30
|
+
expect(found).toBe(true)
|
|
31
|
+
})
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
describe('addonTableToDdl', () => {
|
|
35
|
+
it('generates a CREATE TABLE statement with single primary key', () => {
|
|
36
|
+
const schema: AddonTableSchema = {
|
|
37
|
+
name: 'my_addon_events',
|
|
38
|
+
columns: [
|
|
39
|
+
{ name: 'id', type: 'TEXT', primaryKey: true },
|
|
40
|
+
{ name: 'timestamp', type: 'INTEGER', notNull: true },
|
|
41
|
+
{ name: 'payload', type: 'JSON' },
|
|
42
|
+
],
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const stmts = addonTableToDdl(schema)
|
|
46
|
+
expect(stmts).toHaveLength(1)
|
|
47
|
+
const ddl = stmts[0]
|
|
48
|
+
expect(ddl).toContain('CREATE TABLE IF NOT EXISTS my_addon_events')
|
|
49
|
+
expect(ddl).toContain('id TEXT')
|
|
50
|
+
expect(ddl).toContain('timestamp INTEGER NOT NULL')
|
|
51
|
+
expect(ddl).toContain('payload JSON')
|
|
52
|
+
expect(ddl).toContain('PRIMARY KEY (id)')
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
it('generates a CREATE TABLE with composite primary key', () => {
|
|
56
|
+
const schema: AddonTableSchema = {
|
|
57
|
+
name: 'composite_pk_table',
|
|
58
|
+
columns: [
|
|
59
|
+
{ name: 'addon_id', type: 'TEXT', primaryKey: true, notNull: true },
|
|
60
|
+
{ name: 'key', type: 'TEXT', primaryKey: true, notNull: true },
|
|
61
|
+
{ name: 'value', type: 'JSON', notNull: true },
|
|
62
|
+
],
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const stmts = addonTableToDdl(schema)
|
|
66
|
+
expect(stmts[0]).toContain('PRIMARY KEY (addon_id, key)')
|
|
67
|
+
expect(stmts[0]).toContain('addon_id TEXT NOT NULL')
|
|
68
|
+
expect(stmts[0]).toContain('key TEXT NOT NULL')
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
it('generates indexes when provided', () => {
|
|
72
|
+
const schema: AddonTableSchema = {
|
|
73
|
+
name: 'indexed_table',
|
|
74
|
+
columns: [
|
|
75
|
+
{ name: 'id', type: 'TEXT', primaryKey: true },
|
|
76
|
+
{ name: 'device_id', type: 'TEXT', notNull: true },
|
|
77
|
+
{ name: 'ts', type: 'INTEGER', notNull: true },
|
|
78
|
+
],
|
|
79
|
+
indexes: [
|
|
80
|
+
{ name: 'idx_indexed_device_ts', columns: ['device_id', 'ts'] },
|
|
81
|
+
{ name: 'idx_unique_id', columns: ['id'], unique: true },
|
|
82
|
+
],
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const stmts = addonTableToDdl(schema)
|
|
86
|
+
expect(stmts).toHaveLength(3)
|
|
87
|
+
expect(stmts[1]).toContain('CREATE INDEX IF NOT EXISTS idx_indexed_device_ts ON indexed_table(device_id, ts)')
|
|
88
|
+
expect(stmts[2]).toContain('CREATE UNIQUE INDEX IF NOT EXISTS idx_unique_id ON indexed_table(id)')
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
it('generates no PRIMARY KEY clause when no column is marked primaryKey', () => {
|
|
92
|
+
const schema: AddonTableSchema = {
|
|
93
|
+
name: 'no_pk_table',
|
|
94
|
+
columns: [
|
|
95
|
+
{ name: 'col1', type: 'TEXT' },
|
|
96
|
+
{ name: 'col2', type: 'INTEGER' },
|
|
97
|
+
],
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const stmts = addonTableToDdl(schema)
|
|
101
|
+
expect(stmts[0]).not.toContain('PRIMARY KEY')
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
it('produces valid SQL string (no syntax placeholders)', () => {
|
|
105
|
+
const schema: AddonTableSchema = {
|
|
106
|
+
name: 'valid_sql_table',
|
|
107
|
+
columns: [
|
|
108
|
+
{ name: 'id', type: 'TEXT', primaryKey: true },
|
|
109
|
+
{ name: 'data', type: 'JSON' },
|
|
110
|
+
],
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const stmts = addonTableToDdl(schema)
|
|
114
|
+
for (const stmt of stmts) {
|
|
115
|
+
// Should not contain template placeholders or undefined
|
|
116
|
+
expect(stmt).not.toContain('undefined')
|
|
117
|
+
expect(stmt).not.toContain('${')
|
|
118
|
+
expect(stmt.trim().length).toBeGreaterThan(0)
|
|
119
|
+
}
|
|
120
|
+
})
|
|
121
|
+
|
|
122
|
+
it('handles all column types correctly', () => {
|
|
123
|
+
const schema: AddonTableSchema = {
|
|
124
|
+
name: 'all_types',
|
|
125
|
+
columns: [
|
|
126
|
+
{ name: 'text_col', type: 'TEXT' },
|
|
127
|
+
{ name: 'int_col', type: 'INTEGER' },
|
|
128
|
+
{ name: 'real_col', type: 'REAL' },
|
|
129
|
+
{ name: 'json_col', type: 'JSON' },
|
|
130
|
+
],
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const stmts = addonTableToDdl(schema)
|
|
134
|
+
const ddl = stmts[0]
|
|
135
|
+
expect(ddl).toContain('text_col TEXT')
|
|
136
|
+
expect(ddl).toContain('int_col INTEGER')
|
|
137
|
+
expect(ddl).toContain('real_col REAL')
|
|
138
|
+
expect(ddl).toContain('json_col JSON')
|
|
139
|
+
})
|
|
140
|
+
})
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
// server/backend/src/core/storage/storage-location-manager.spec.ts
|
|
2
|
+
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
|
|
3
|
+
import { existsSync, mkdirSync, rmSync } from 'node:fs'
|
|
4
|
+
import { join } from 'node:path'
|
|
5
|
+
import { tmpdir } from 'node:os'
|
|
6
|
+
import { StorageLocationManager, type StorageLocationName } from './storage-location-manager'
|
|
7
|
+
|
|
8
|
+
describe('StorageLocationManager', () => {
|
|
9
|
+
let tempDir: string
|
|
10
|
+
let manager: StorageLocationManager
|
|
11
|
+
|
|
12
|
+
beforeEach(() => {
|
|
13
|
+
tempDir = join(tmpdir(), `slm-test-${Date.now()}`)
|
|
14
|
+
mkdirSync(tempDir, { recursive: true })
|
|
15
|
+
manager = new StorageLocationManager(tempDir)
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
afterEach(() => {
|
|
19
|
+
rmSync(tempDir, { recursive: true, force: true })
|
|
20
|
+
rmSync('/tmp/camstack-cache', { recursive: true, force: true })
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
it('initializeDefaults registers all 6 location backends', async () => {
|
|
24
|
+
await manager.initializeDefaults()
|
|
25
|
+
const names = manager.getLocationNames()
|
|
26
|
+
expect(names).toHaveLength(6)
|
|
27
|
+
expect(names).toContain('data')
|
|
28
|
+
expect(names).toContain('media')
|
|
29
|
+
expect(names).toContain('recordings')
|
|
30
|
+
expect(names).toContain('models')
|
|
31
|
+
expect(names).toContain('cache')
|
|
32
|
+
expect(names).toContain('logs')
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
it('initializeDefaults does NOT create the location directories on disk (lazy)', async () => {
|
|
36
|
+
await manager.initializeDefaults()
|
|
37
|
+
// None of the 6 location paths should exist — directories are
|
|
38
|
+
// created on first write by the filesystem storage provider, not
|
|
39
|
+
// eagerly at boot. This is the anti-bloat invariant: a fresh
|
|
40
|
+
// installation should not materialise empty `recordings/`,
|
|
41
|
+
// `media/`, `logs/` folders just because StorageLocationManager
|
|
42
|
+
// was initialized.
|
|
43
|
+
expect(existsSync(join(tempDir, 'db'))).toBe(false)
|
|
44
|
+
expect(existsSync(join(tempDir, 'media'))).toBe(false)
|
|
45
|
+
expect(existsSync(join(tempDir, 'recordings'))).toBe(false)
|
|
46
|
+
expect(existsSync(join(tempDir, 'models'))).toBe(false)
|
|
47
|
+
expect(existsSync(join(tempDir, 'logs'))).toBe(false)
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
it('initializeDefaults reports all locations as available (parent dir writable)', async () => {
|
|
51
|
+
await manager.initializeDefaults()
|
|
52
|
+
const status = manager.getStatus()
|
|
53
|
+
// A backend is "available" as long as it can be created when
|
|
54
|
+
// needed (nearest existing ancestor is writable). Since `tempDir`
|
|
55
|
+
// exists and is writable, every location underneath it is
|
|
56
|
+
// available even though the location dir itself does not yet
|
|
57
|
+
// exist.
|
|
58
|
+
for (const entry of status) {
|
|
59
|
+
expect(entry.available).toBe(true)
|
|
60
|
+
}
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
it('getLocationNames returns all 6 location names', async () => {
|
|
64
|
+
await manager.initializeDefaults()
|
|
65
|
+
const names = manager.getLocationNames()
|
|
66
|
+
const expected: StorageLocationName[] = ['data', 'media', 'recordings', 'models', 'cache', 'logs']
|
|
67
|
+
expect(names.sort()).toEqual(expected.sort())
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
it('resolve joins subpath correctly within a location', async () => {
|
|
71
|
+
await manager.initializeDefaults()
|
|
72
|
+
const resolved = manager.resolve('data', 'camstack.db')
|
|
73
|
+
expect(resolved).toBe(join(tempDir, 'db', 'camstack.db'))
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
it('resolve for cache uses /tmp/camstack-cache base', async () => {
|
|
77
|
+
await manager.initializeDefaults()
|
|
78
|
+
const resolved = manager.resolve('cache', 'thumbnails')
|
|
79
|
+
expect(resolved).toBe('/tmp/camstack-cache/thumbnails')
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
it('setLocationPath overrides a location with absolute path', async () => {
|
|
83
|
+
await manager.initializeDefaults()
|
|
84
|
+
const newPath = join(tempDir, 'custom-media')
|
|
85
|
+
await manager.setLocationPath('media', newPath)
|
|
86
|
+
const backend = manager.getBackend('media')
|
|
87
|
+
expect(backend.basePath).toBe(newPath)
|
|
88
|
+
// Still lazy — overriding the path doesn't eagerly create it.
|
|
89
|
+
expect(backend.isAvailable()).toBe(true)
|
|
90
|
+
expect(existsSync(newPath)).toBe(false)
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
it('setLocationPath overrides a location with relative path (resolved against dataPath)', async () => {
|
|
94
|
+
await manager.initializeDefaults()
|
|
95
|
+
await manager.setLocationPath('logs', 'custom-logs')
|
|
96
|
+
const backend = manager.getBackend('logs')
|
|
97
|
+
expect(backend.basePath).toBe(join(tempDir, 'custom-logs'))
|
|
98
|
+
expect(backend.isAvailable()).toBe(true)
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
it('getStatus reports all locations with name, available, path', async () => {
|
|
102
|
+
await manager.initializeDefaults()
|
|
103
|
+
const status = manager.getStatus()
|
|
104
|
+
expect(status).toHaveLength(6)
|
|
105
|
+
for (const entry of status) {
|
|
106
|
+
expect(entry).toHaveProperty('name')
|
|
107
|
+
expect(entry).toHaveProperty('available')
|
|
108
|
+
expect(entry).toHaveProperty('path')
|
|
109
|
+
expect(typeof entry.available).toBe('boolean')
|
|
110
|
+
}
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
it('missing location throws error', () => {
|
|
114
|
+
// not initialized
|
|
115
|
+
expect(() => manager.getBackend('data')).toThrow('Storage location "data" not initialized')
|
|
116
|
+
})
|
|
117
|
+
|
|
118
|
+
it('resolve on uninitialized location throws error', () => {
|
|
119
|
+
expect(() => manager.resolve('media', 'file.mp4')).toThrow('Storage location "media" not initialized')
|
|
120
|
+
})
|
|
121
|
+
})
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { describe, it, expect, vi } from 'vitest'
|
|
2
|
+
import { StorageService } from './storage.service'
|
|
3
|
+
import type { ICoreStorageProvider as IStorageProvider, IStorageLocation } from '@camstack/core'
|
|
4
|
+
|
|
5
|
+
const createMockProvider = (): IStorageProvider => {
|
|
6
|
+
const mockLocation: IStorageLocation = { structured: undefined, files: undefined }
|
|
7
|
+
return {
|
|
8
|
+
initialize: vi.fn(),
|
|
9
|
+
shutdown: vi.fn(),
|
|
10
|
+
getLocation: vi.fn().mockReturnValue(mockLocation),
|
|
11
|
+
export: vi.fn(),
|
|
12
|
+
import: vi.fn(),
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
describe('StorageService', () => {
|
|
17
|
+
const createService = (): StorageService => {
|
|
18
|
+
return new StorageService()
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
it('delegates getLocation to active provider', () => {
|
|
22
|
+
const service = createService()
|
|
23
|
+
const provider = createMockProvider()
|
|
24
|
+
// eslint-disable-next-line @typescript-eslint/no-deprecated
|
|
25
|
+
service.setProvider(provider)
|
|
26
|
+
|
|
27
|
+
const result = service.getLocation('data')
|
|
28
|
+
|
|
29
|
+
expect(provider.getLocation).toHaveBeenCalledWith('data')
|
|
30
|
+
expect(result).toBeDefined()
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
it('maps legacy location names to new names', () => {
|
|
34
|
+
const service = createService()
|
|
35
|
+
const provider = createMockProvider()
|
|
36
|
+
// eslint-disable-next-line @typescript-eslint/no-deprecated
|
|
37
|
+
service.setProvider(provider)
|
|
38
|
+
|
|
39
|
+
// Legacy 'config' and 'events' both map to 'data'.
|
|
40
|
+
// StorageManager.getLocation accepts `StorageLocationName | string`,
|
|
41
|
+
// so string literals flow through without casts.
|
|
42
|
+
service.getLocation('config')
|
|
43
|
+
expect(provider.getLocation).toHaveBeenCalledWith('data')
|
|
44
|
+
|
|
45
|
+
service.getLocation('events')
|
|
46
|
+
expect(provider.getLocation).toHaveBeenNthCalledWith(2, 'data')
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
it('throws if no provider set', () => {
|
|
50
|
+
const service = createService()
|
|
51
|
+
|
|
52
|
+
expect(() => service.getLocation('data')).toThrow('No storage provider configured')
|
|
53
|
+
expect(() => service.getProvider()).toThrow('No storage provider configured')
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
it('setProvider changes the active provider', () => {
|
|
57
|
+
const service = createService()
|
|
58
|
+
const provider1 = createMockProvider()
|
|
59
|
+
const provider2 = createMockProvider()
|
|
60
|
+
|
|
61
|
+
// eslint-disable-next-line @typescript-eslint/no-deprecated
|
|
62
|
+
service.setProvider(provider1)
|
|
63
|
+
expect(service.getProvider()).toBe(provider1)
|
|
64
|
+
|
|
65
|
+
// eslint-disable-next-line @typescript-eslint/no-deprecated
|
|
66
|
+
service.setProvider(provider2)
|
|
67
|
+
expect(service.getProvider()).toBe(provider2)
|
|
68
|
+
|
|
69
|
+
service.getLocation('data')
|
|
70
|
+
expect(provider2.getLocation).toHaveBeenCalledWith('data')
|
|
71
|
+
expect(provider1.getLocation).not.toHaveBeenCalled()
|
|
72
|
+
})
|
|
73
|
+
})
|