@camstack/server 0.1.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (133) hide show
  1. package/.env.example +17 -0
  2. package/package.json +55 -0
  3. package/src/__tests__/addon-install-e2e.test.ts +75 -0
  4. package/src/__tests__/addon-pages-e2e.test.ts +178 -0
  5. package/src/__tests__/addon-route-session.test.ts +17 -0
  6. package/src/__tests__/addon-settings-router.spec.ts +62 -0
  7. package/src/__tests__/addon-upload.spec.ts +355 -0
  8. package/src/__tests__/agent-registry.spec.ts +162 -0
  9. package/src/__tests__/agent-status-page.spec.ts +84 -0
  10. package/src/__tests__/auth-session-cookie.test.ts +21 -0
  11. package/src/__tests__/cap-providers/cap-usage-graph.spec.ts +23 -0
  12. package/src/__tests__/cap-providers/compute-topology-categories.spec.ts +64 -0
  13. package/src/__tests__/cap-routers/_meta.spec.ts +200 -0
  14. package/src/__tests__/cap-routers/addon-settings.router.spec.ts +106 -0
  15. package/src/__tests__/cap-routers/device-manager-aggregate.router.spec.ts +142 -0
  16. package/src/__tests__/cap-routers/harness.ts +159 -0
  17. package/src/__tests__/cap-routers/metrics-provider.router.spec.ts +119 -0
  18. package/src/__tests__/cap-routers/null-provider-guard.spec.ts +66 -0
  19. package/src/__tests__/cap-routers/pipeline-executor.router.spec.ts +135 -0
  20. package/src/__tests__/cap-routers/settings-store.router.spec.ts +247 -0
  21. package/src/__tests__/capability-e2e.test.ts +386 -0
  22. package/src/__tests__/cli-e2e.test.ts +129 -0
  23. package/src/__tests__/core-cap-bridge.spec.ts +89 -0
  24. package/src/__tests__/embedded-deps-e2e.test.ts +109 -0
  25. package/src/__tests__/event-bus-proxy-router.spec.ts +72 -0
  26. package/src/__tests__/fixtures/mock-analysis-addon-a.ts +37 -0
  27. package/src/__tests__/fixtures/mock-analysis-addon-b.ts +37 -0
  28. package/src/__tests__/fixtures/mock-log-addon.ts +37 -0
  29. package/src/__tests__/fixtures/mock-storage-addon.ts +40 -0
  30. package/src/__tests__/framework-allowlist.spec.ts +95 -0
  31. package/src/__tests__/https-e2e.test.ts +118 -0
  32. package/src/__tests__/lifecycle-e2e.test.ts +140 -0
  33. package/src/__tests__/live-events-subscription.spec.ts +150 -0
  34. package/src/__tests__/moleculer-register-node-idempotency.spec.ts +229 -0
  35. package/src/__tests__/oauth2-account-linking.spec.ts +736 -0
  36. package/src/__tests__/post-boot-restart.spec.ts +161 -0
  37. package/src/__tests__/singleton-contention.test.ts +487 -0
  38. package/src/__tests__/streaming-diagnostic.test.ts +512 -0
  39. package/src/__tests__/streaming-scale.test.ts +280 -0
  40. package/src/agent-status-page.ts +121 -0
  41. package/src/api/__tests__/addons-custom.spec.ts +134 -0
  42. package/src/api/__tests__/capabilities.router.test.ts +47 -0
  43. package/src/api/addon-upload.ts +472 -0
  44. package/src/api/addons-custom.router.ts +100 -0
  45. package/src/api/auth-whoami.ts +99 -0
  46. package/src/api/bridge-addons.router.ts +120 -0
  47. package/src/api/capabilities.router.ts +226 -0
  48. package/src/api/core/__tests__/auth-router-totp.spec.ts +256 -0
  49. package/src/api/core/addon-settings.router.ts +124 -0
  50. package/src/api/core/agents.router.ts +87 -0
  51. package/src/api/core/auth.router.ts +303 -0
  52. package/src/api/core/cap-providers.ts +993 -0
  53. package/src/api/core/capabilities.router.ts +119 -0
  54. package/src/api/core/collection-preference.ts +40 -0
  55. package/src/api/core/event-bus-proxy.router.ts +45 -0
  56. package/src/api/core/hwaccel.router.ts +81 -0
  57. package/src/api/core/live-events.router.ts +60 -0
  58. package/src/api/core/logs.router.ts +162 -0
  59. package/src/api/core/notifications.router.ts +65 -0
  60. package/src/api/core/repl.router.ts +41 -0
  61. package/src/api/core/settings-backend.router.ts +142 -0
  62. package/src/api/core/stream-probe.router.ts +57 -0
  63. package/src/api/core/system-events.router.ts +116 -0
  64. package/src/api/health/health.routes.ts +123 -0
  65. package/src/api/oauth2/__tests__/oauth2-routes.spec.ts +52 -0
  66. package/src/api/oauth2/consent-page.ts +42 -0
  67. package/src/api/oauth2/oauth2-routes.ts +248 -0
  68. package/src/api/trpc/__tests__/scope-access-device.spec.ts +223 -0
  69. package/src/api/trpc/__tests__/scope-access.spec.ts +107 -0
  70. package/src/api/trpc/cap-mount-helpers.ts +225 -0
  71. package/src/api/trpc/core-cap-bridge.ts +152 -0
  72. package/src/api/trpc/generated-cap-mounts.ts +707 -0
  73. package/src/api/trpc/generated-cap-routers.ts +6340 -0
  74. package/src/api/trpc/scope-access.ts +110 -0
  75. package/src/api/trpc/trpc.context.ts +255 -0
  76. package/src/api/trpc/trpc.middleware.ts +140 -0
  77. package/src/api/trpc/trpc.router.ts +275 -0
  78. package/src/auth/session-cookie.ts +44 -0
  79. package/src/boot/boot-config.ts +278 -0
  80. package/src/boot/post-boot.service.ts +103 -0
  81. package/src/core/addon/__tests__/addon-registry-capability.test.ts +53 -0
  82. package/src/core/addon/addon-package.service.ts +1684 -0
  83. package/src/core/addon/addon-registry.service.ts +2926 -0
  84. package/src/core/addon/addon-search.service.ts +90 -0
  85. package/src/core/addon/addon-settings-provider.ts +276 -0
  86. package/src/core/addon/addon.tokens.ts +2 -0
  87. package/src/core/addon-bridge/addon-bridge.service.ts +125 -0
  88. package/src/core/addon-pages/addon-pages.service.spec.ts +117 -0
  89. package/src/core/addon-pages/addon-pages.service.ts +80 -0
  90. package/src/core/addon-widgets/addon-widgets.service.ts +92 -0
  91. package/src/core/agent/agent-registry.service.ts +507 -0
  92. package/src/core/auth/auth.service.spec.ts +88 -0
  93. package/src/core/auth/auth.service.ts +8 -0
  94. package/src/core/capability/capability.service.ts +57 -0
  95. package/src/core/config/config.schema.ts +3 -0
  96. package/src/core/config/config.service.spec.ts +175 -0
  97. package/src/core/config/config.service.ts +7 -0
  98. package/src/core/events/event-bus.service.spec.ts +212 -0
  99. package/src/core/events/event-bus.service.ts +85 -0
  100. package/src/core/feature/feature.service.spec.ts +96 -0
  101. package/src/core/feature/feature.service.ts +8 -0
  102. package/src/core/lifecycle/lifecycle-state-machine.spec.ts +168 -0
  103. package/src/core/lifecycle/lifecycle-state-machine.ts +3 -0
  104. package/src/core/logging/log-ring-buffer.ts +3 -0
  105. package/src/core/logging/logging.service.spec.ts +247 -0
  106. package/src/core/logging/logging.service.ts +129 -0
  107. package/src/core/logging/scoped-logger.ts +3 -0
  108. package/src/core/moleculer/moleculer.service.ts +612 -0
  109. package/src/core/network/network-quality.service.spec.ts +47 -0
  110. package/src/core/network/network-quality.service.ts +5 -0
  111. package/src/core/notification/notification-wrapper.service.ts +36 -0
  112. package/src/core/notification/toast-wrapper.service.ts +31 -0
  113. package/src/core/provider/provider.tokens.ts +1 -0
  114. package/src/core/repl/repl-engine.service.spec.ts +417 -0
  115. package/src/core/repl/repl-engine.service.ts +156 -0
  116. package/src/core/storage/fs-storage-backend.spec.ts +70 -0
  117. package/src/core/storage/fs-storage-backend.ts +3 -0
  118. package/src/core/storage/settings-store.spec.ts +213 -0
  119. package/src/core/storage/settings-store.ts +2 -0
  120. package/src/core/storage/sql-schema.spec.ts +140 -0
  121. package/src/core/storage/sql-schema.ts +3 -0
  122. package/src/core/storage/storage-location-manager.spec.ts +121 -0
  123. package/src/core/storage/storage-location-manager.ts +3 -0
  124. package/src/core/storage/storage.service.spec.ts +73 -0
  125. package/src/core/storage/storage.service.ts +3 -0
  126. package/src/core/streaming/stream-probe.service.ts +212 -0
  127. package/src/core/topology/topology-emitter.service.ts +101 -0
  128. package/src/launcher.ts +309 -0
  129. package/src/main.ts +1049 -0
  130. package/src/manual-boot.ts +322 -0
  131. package/tsconfig.build.json +8 -0
  132. package/tsconfig.json +21 -0
  133. package/vitest.config.ts +26 -0
package/.env.example ADDED
@@ -0,0 +1,17 @@
1
+ # CamStack Server Environment
2
+ # Copy to .env and customize. Loaded via Node.js --env-file flag.
3
+
4
+ # Fixed JWT secret (prevents token invalidation on server restart)
5
+ # Generate with: node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"
6
+ CAMSTACK_JWT_SECRET=
7
+
8
+ # Admin credentials
9
+ CAMSTACK_ADMIN_USER=admin
10
+ CAMSTACK_ADMIN_PASS=changeme
11
+
12
+ # Data directory (default: camstack-data)
13
+ # CAMSTACK_DATA=camstack-data
14
+
15
+ # Server port and host
16
+ # CAMSTACK_PORT=4443
17
+ # CAMSTACK_HOST=0.0.0.0
package/package.json ADDED
@@ -0,0 +1,55 @@
1
+ {
2
+ "name": "@camstack/server",
3
+ "version": "0.1.3",
4
+ "private": false,
5
+ "exports": {
6
+ "./package.json": "./package.json",
7
+ "./main.js": "./dist/main.js",
8
+ "./launcher.js": "./dist/launcher.js"
9
+ },
10
+ "scripts": {
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",
14
+ "serve:once": "tsx --env-file=.env src/launcher.ts",
15
+ "start": "node --env-file=.env dist/launcher.js",
16
+ "typecheck": "tsc --noEmit",
17
+ "test": "vitest run --reporter verbose",
18
+ "test:watch": "vitest"
19
+ },
20
+ "dependencies": {
21
+ "@camstack/addon-admin-ui": "*",
22
+ "@camstack/addon-advanced-notifier": "*",
23
+ "@camstack/addon-benchmark": "*",
24
+ "@camstack/addon-pipeline": "*",
25
+ "@camstack/addon-pipeline-orchestrator": "*",
26
+ "@camstack/addon-post-analysis": "*",
27
+ "@camstack/core": "*",
28
+ "@camstack/kernel": "*",
29
+ "@camstack/sdk": "*",
30
+ "@camstack/shm-ring": "*",
31
+ "@camstack/types": "*",
32
+ "@camstack/ui-library": "*",
33
+ "@fastify/cookie": "^11.0.2",
34
+ "@fastify/multipart": "^9.0.0",
35
+ "@fastify/static": "^8.0.0",
36
+ "@trpc/server": "^11.16.0",
37
+ "fastify": "^5",
38
+ "js-yaml": "^4",
39
+ "moleculer": "^0.15.0",
40
+ "tar": "^6.2.1",
41
+ "ws": "^8.20.0",
42
+ "zod": "^4.3.6"
43
+ },
44
+ "devDependencies": {
45
+ "@swc/core": "^1.15.18",
46
+ "@types/js-yaml": "^4",
47
+ "@types/node": "^22",
48
+ "@types/tar": "^6.1.13",
49
+ "@types/ws": "^8.18.1",
50
+ "tsx": "^4",
51
+ "typescript": "^5.7",
52
+ "unplugin-swc": "^1.5.9",
53
+ "vitest": "*"
54
+ }
55
+ }
@@ -0,0 +1,75 @@
1
+ // server/backend/src/__tests__/addon-install-e2e.test.ts
2
+ //
3
+ // E2E test: simulates addon directory scanning and verifies
4
+ // addon packages with package.json + dist/ can be loaded.
5
+ //
6
+ import { describe, it, expect, beforeAll, afterAll } from 'vitest'
7
+ import * as fs from 'node:fs'
8
+ import * as path from 'node:path'
9
+
10
+ const TEST_ADDONS_DIR = path.resolve('test-output/fresh-addons')
11
+
12
+ describe('Addon Directory Loading', () => {
13
+
14
+ beforeAll(() => {
15
+ // Clean slate — delete the test addons directory
16
+ fs.rmSync(TEST_ADDONS_DIR, { recursive: true, force: true })
17
+ fs.mkdirSync(TEST_ADDONS_DIR, { recursive: true })
18
+ })
19
+
20
+ afterAll(() => {
21
+ // Cleanup
22
+ fs.rmSync(TEST_ADDONS_DIR, { recursive: true, force: true })
23
+ })
24
+
25
+ it('AddonInstaller initializes the addons directory', async () => {
26
+ const { AddonInstaller } = await import('@camstack/kernel')
27
+
28
+ // Constructor creates the addons directory if it doesn't exist
29
+ const installer = new AddonInstaller({
30
+ addonsDir: TEST_ADDONS_DIR,
31
+ })
32
+
33
+ // Verify directory exists and installer was created
34
+ expect(installer.addonsDir).toBe(TEST_ADDONS_DIR)
35
+ expect(fs.existsSync(TEST_ADDONS_DIR)).toBe(true)
36
+ })
37
+
38
+ it('AddonInstaller lists installed addon packages', async () => {
39
+ const { AddonInstaller } = await import('@camstack/kernel')
40
+
41
+ // Create a mock addon directory
42
+ const addonDir = path.join(TEST_ADDONS_DIR, 'addon-test')
43
+ fs.mkdirSync(addonDir, { recursive: true })
44
+ fs.writeFileSync(
45
+ path.join(addonDir, 'package.json'),
46
+ JSON.stringify({
47
+ name: '@camstack/addon-test',
48
+ version: '1.0.0',
49
+ camstack: { addons: [{ id: 'test', entry: './dist/index.js', slot: 'detector' }] },
50
+ }),
51
+ )
52
+
53
+ const installer = new AddonInstaller({ addonsDir: TEST_ADDONS_DIR })
54
+ const installed = installer.listInstalled()
55
+
56
+ expect(installed).toHaveLength(1)
57
+ expect(installed[0]).toMatchObject({
58
+ name: '@camstack/addon-test',
59
+ version: '1.0.0',
60
+ })
61
+
62
+ // Cleanup
63
+ fs.rmSync(addonDir, { recursive: true, force: true })
64
+ })
65
+
66
+ it('AddonLoader scans addons directory and loads packages', async () => {
67
+ const { AddonLoader } = await import('@camstack/kernel')
68
+
69
+ const loader = new AddonLoader()
70
+
71
+ // With an empty directory, no addons should be loaded
72
+ await loader.loadFromDirectory(TEST_ADDONS_DIR)
73
+ expect(loader.listAddons()).toHaveLength(0)
74
+ })
75
+ })
@@ -0,0 +1,178 @@
1
+ /* eslint-disable @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-argument, @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access -- test file: mock AddonContext for `addon.initialize` is deliberately loose */
2
+ // server/backend/src/__tests__/addon-pages-e2e.test.ts
3
+ //
4
+ // Integration tests: verify addon-pages capability wiring end-to-end,
5
+ // BenchmarkAddon page registration, and AdminUIAddon capability provider.
6
+ //
7
+ import { describe, it, expect, vi } from 'vitest'
8
+ import { CapabilityRegistry } from '@camstack/kernel'
9
+ import type { AddonContext, IScopedLogger, ProviderRegistration } from '@camstack/types'
10
+
11
+ // ---------------------------------------------------------------------------
12
+ // Optional-module loader — returns the AdminUIAddon class if the admin-ui
13
+ // addon is built and resolvable, otherwise null. Typed against a narrow
14
+ // structural signature so the test never leaks `any` through the dynamic
15
+ // import (the module exports its own dist path which may not exist when
16
+ // the backend workspace is built in isolation).
17
+ // ---------------------------------------------------------------------------
18
+
19
+ interface AdminUIAddonInstance {
20
+ readonly id: string
21
+ initialize(ctx: AddonContext): Promise<ProviderRegistration[] | void | undefined | {
22
+ readonly providers?: readonly ProviderRegistration[]
23
+ }>
24
+ }
25
+
26
+ type AdminUIAddonCtor = new () => AdminUIAddonInstance
27
+
28
+ async function loadAdminUIAddon(): Promise<AdminUIAddonCtor | null> {
29
+ try {
30
+ const mod = await import('@camstack/addon-admin-ui/server/addon') as { AdminUIAddon?: AdminUIAddonCtor }
31
+ return mod.AdminUIAddon ?? null
32
+ } catch {
33
+ return null
34
+ }
35
+ }
36
+
37
+ function createMockLogger(): IScopedLogger {
38
+ const logger: IScopedLogger = {
39
+ error: vi.fn(),
40
+ warn: vi.fn(),
41
+ info: vi.fn(),
42
+ debug: vi.fn(),
43
+ child: vi.fn(() => logger),
44
+ }
45
+ return logger
46
+ }
47
+
48
+ describe('Addon Pages Integration', () => {
49
+ it('BenchmarkAddon registers addon-pages capability and provides page list', async () => {
50
+ const { BenchmarkAddon } = await import('@camstack/addon-benchmark')
51
+ const addon = new BenchmarkAddon()
52
+
53
+ // Addon has id directly — manifest is attached by AddonLoader at boot time
54
+ expect(addon.id).toBe('benchmark')
55
+
56
+ // Initialize with a mock context to capture registered providers.
57
+ // Addons may register providers either via context.registerProvider() or
58
+ // by returning AddonInitResult / ProviderRegistration[] from initialize().
59
+ const providers = new Map<string, unknown>()
60
+ const mockContext = {
61
+ registerProvider: (capName: string, provider: unknown) => {
62
+ providers.set(capName, provider)
63
+ },
64
+ }
65
+ const result = await addon.initialize(mockContext as any)
66
+ // Process return-value registrations (AddonInitResult.providers or ProviderRegistration[])
67
+ if (result) {
68
+ const regs = Array.isArray(result) ? result : ((result as any).providers ?? [])
69
+ for (const reg of regs as ProviderRegistration[]) {
70
+ const capName = typeof reg.capability === 'string'
71
+ ? reg.capability
72
+ : (reg.capability as any)?.name ?? String(reg.capability)
73
+ providers.set(capName, reg.provider)
74
+ }
75
+ }
76
+
77
+ const provider = providers.get('addon-pages') as { getPages(): unknown[] } | undefined
78
+ expect(provider).toBeDefined()
79
+
80
+
81
+ const pages = provider!.getPages()
82
+ expect(pages).toHaveLength(1)
83
+
84
+ expect(pages[0]).toMatchObject({
85
+ id: 'benchmark',
86
+ label: 'Benchmark',
87
+ icon: 'gauge',
88
+ path: '/addon/benchmark',
89
+ })
90
+ })
91
+
92
+ it('AdminUIAddon provides admin-ui capability with static dir and version', async () => {
93
+ // Load `@camstack/addon-admin-ui/server/addon` lazily — the package exports
94
+ // a dist/server path that may not exist when the backend workspace is
95
+ // built in isolation (admin-ui has its own Vite-based build). Wrapping the
96
+ // dynamic import in a helper means the test can be typed cleanly without
97
+ // `@ts-expect-error`: when the module is absent we skip instead of
98
+ // leaking `any` into the rest of the spec.
99
+ const AdminUIAddonCtor = await loadAdminUIAddon()
100
+ if (!AdminUIAddonCtor) {
101
+ console.warn('[addon-pages-e2e] Skipping AdminUIAddon test — module not built')
102
+ return
103
+ }
104
+
105
+ const addon = new AdminUIAddonCtor()
106
+ expect(addon.id).toBe('admin-ui')
107
+
108
+ const providers = new Map<string, unknown>()
109
+ const mockContext = {
110
+ registerProvider: (capName: string, provider: unknown) => {
111
+ providers.set(capName, provider)
112
+ },
113
+ }
114
+ const result = await addon.initialize(mockContext as any)
115
+ // Process return-value registrations (AddonInitResult.providers or ProviderRegistration[])
116
+ if (result) {
117
+ const regs = Array.isArray(result) ? result : ((result as any).providers ?? [])
118
+ for (const reg of regs as ProviderRegistration[]) {
119
+ const capName = typeof reg.capability === 'string'
120
+ ? reg.capability
121
+ : (reg.capability as any)?.name ?? String(reg.capability)
122
+ providers.set(capName, reg.provider)
123
+ }
124
+ }
125
+
126
+ const ui = providers.get('admin-ui') as {
127
+ getStaticDir(): Promise<{ readonly staticDir: string }>
128
+ getVersion(): Promise<{ readonly version: string }>
129
+ } | undefined
130
+ expect(ui).toBeDefined()
131
+ expect(typeof ui!.getStaticDir).toBe('function')
132
+ expect(typeof ui!.getVersion).toBe('function')
133
+ const version = await ui!.getVersion()
134
+ expect(version.version).toBe('0.1.0')
135
+ })
136
+
137
+ it('CapabilityRegistry wires addon-pages collection providers correctly', () => {
138
+ const registry = new CapabilityRegistry(createMockLogger())
139
+ registry.ready()
140
+
141
+ registry.declareCapability({ name: 'addon-pages', scope: 'system', mode: 'collection', methods: {} })
142
+
143
+ registry.registerProvider('addon-pages', 'benchmark', {
144
+ id: 'benchmark',
145
+ getPages: () => [
146
+ {
147
+ id: 'benchmark',
148
+ label: 'Benchmark',
149
+ icon: 'gauge',
150
+ path: '/addon/benchmark',
151
+ bundle: 'dist/pages/benchmark.js',
152
+ element: 'camstack-benchmark',
153
+ },
154
+ ],
155
+ })
156
+
157
+ const providers = registry.getCollection<{ getPages(): unknown[] }>('addon-pages')
158
+ expect(providers).toHaveLength(1)
159
+ expect(providers[0].getPages()).toHaveLength(1)
160
+ })
161
+
162
+ it('CapabilityRegistry wires admin-ui singleton correctly', async () => {
163
+ const registry = new CapabilityRegistry(createMockLogger())
164
+ registry.ready()
165
+
166
+ registry.declareCapability({ name: 'admin-ui', scope: 'system', mode: 'singleton', methods: {} })
167
+
168
+ registry.registerProvider('admin-ui', 'admin-ui', {
169
+ getStaticDir: async () => ({ staticDir: '/some/path/dist' }),
170
+ getVersion: async () => ({ version: '0.1.0' }),
171
+ })
172
+
173
+ const ui = registry.getSingleton<{ getVersion(): Promise<{ readonly version: string }> }>('admin-ui')
174
+ expect(ui).toBeDefined()
175
+ const version = await ui!.getVersion()
176
+ expect(version.version).toBe('0.1.0')
177
+ })
178
+ })
@@ -0,0 +1,17 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { shouldRedirectToLogin } from '../auth/session-cookie.js'
3
+
4
+ describe('shouldRedirectToLogin', () => {
5
+ it('true for an HTML GET with no credentials', () => {
6
+ expect(shouldRedirectToLogin('GET', 'text/html,application/xhtml+xml')).toBe(true)
7
+ })
8
+ it('false for a non-GET', () => {
9
+ expect(shouldRedirectToLogin('POST', 'text/html')).toBe(false)
10
+ })
11
+ it('false for an API GET (JSON accept)', () => {
12
+ expect(shouldRedirectToLogin('GET', 'application/json')).toBe(false)
13
+ })
14
+ it('false when Accept is absent', () => {
15
+ expect(shouldRedirectToLogin('GET', undefined)).toBe(false)
16
+ })
17
+ })
@@ -0,0 +1,62 @@
1
+ /* eslint-disable @typescript-eslint/consistent-type-assertions -- test mock typing */
2
+ import { describe, it, expect, beforeEach } from 'vitest'
3
+ import { createAddonSettingsRouter } from '../api/core/addon-settings.router.js'
4
+ import { makeCtx } from './cap-routers/harness.js'
5
+
6
+ function createMockConfigService() {
7
+ const addonStore = new Map<string, Record<string, unknown>>()
8
+ const deviceStore = new Map<string, Record<string, unknown>>()
9
+
10
+ return {
11
+ getAddonConfig: (id: string) => addonStore.get(id) ?? {},
12
+ setAddonConfig: (id: string, v: Record<string, unknown>) => {
13
+ addonStore.set(id, v)
14
+ },
15
+ getAddonDevice: (id: string, devId: string) => deviceStore.get(`${id}:${devId}`) ?? {},
16
+ setAddonDevice: (id: string, devId: string, v: Record<string, unknown>) => {
17
+ deviceStore.set(`${id}:${devId}`, v)
18
+ },
19
+ // Cast to satisfy the ConfigService type expected by the router factory.
20
+ // Only the four methods above are exercised by the router.
21
+ } as Parameters<typeof createAddonSettingsRouter>[0]
22
+ }
23
+
24
+ describe('addon-settings router', () => {
25
+ let caller: ReturnType<ReturnType<typeof createAddonSettingsRouter>['createCaller']>
26
+
27
+ beforeEach(() => {
28
+ const cfg = createMockConfigService()
29
+ const router = createAddonSettingsRouter(cfg)
30
+ const ctx = makeCtx('admin')
31
+ caller = router.createCaller(ctx)
32
+ })
33
+
34
+ it('getGlobal returns empty object when no stored config', async () => {
35
+ const result = await caller.getGlobal({ addonId: 'my-addon' })
36
+ expect(result).toEqual({})
37
+ })
38
+
39
+ it('updateGlobal stores a field and getGlobal reflects it', async () => {
40
+ await caller.updateGlobal({ addonId: 'my-addon', field: 'threshold', value: 42 })
41
+ const result = await caller.getGlobal({ addonId: 'my-addon' })
42
+ expect(result).toEqual({ threshold: 42 })
43
+ })
44
+
45
+ it('getDeviceOverrides returns empty object when no stored overrides', async () => {
46
+ const result = await caller.getDeviceOverrides({ addonId: 'my-addon', deviceId: 'cam-1' })
47
+ expect(result).toEqual({})
48
+ })
49
+
50
+ it('updateDevice stores a field and getDeviceOverrides reflects it', async () => {
51
+ await caller.updateDevice({ addonId: 'my-addon', deviceId: 'cam-1', field: 'resolution', value: '1080p' })
52
+ const result = await caller.getDeviceOverrides({ addonId: 'my-addon', deviceId: 'cam-1' })
53
+ expect(result).toEqual({ resolution: '1080p' })
54
+ })
55
+
56
+ it('updateGlobal merges with existing config without overwriting other fields', async () => {
57
+ await caller.updateGlobal({ addonId: 'my-addon', field: 'alpha', value: 1 })
58
+ await caller.updateGlobal({ addonId: 'my-addon', field: 'beta', value: 2 })
59
+ const result = await caller.getGlobal({ addonId: 'my-addon' })
60
+ expect(result).toEqual({ alpha: 1, beta: 2 })
61
+ })
62
+ })