@hexabot-ai/api 3.1.1-alpha.0 → 3.1.3-alpha.0

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@hexabot-ai/api",
3
3
  "private": false,
4
- "version": "3.1.1-alpha.0",
4
+ "version": "3.1.3-alpha.0",
5
5
  "description": "Hexabot is a solution for creating and managing chatbots across multiple channels, leveraging AI for advanced conversational capabilities. It provides a user-friendly interface for building, training, and deploying chatbots with integrated support for various messaging platforms.",
6
6
  "author": "Hexastack",
7
7
  "license": "FCL-1.0-ALv2",
@@ -20,36 +20,6 @@
20
20
  "types",
21
21
  "!src/**/*.spec.ts"
22
22
  ],
23
- "scripts": {
24
- "dev": "pnpm run start:dev",
25
- "build": "pnpm run frontend:build && nest build && pnpm run copy-types && pnpm run frontend:copy",
26
- "copy-types": "node ./scripts/copy-types.js",
27
- "frontend:build": "node ./scripts/frontend-build.js",
28
- "frontend:copy": "node ./scripts/frontend-copy.js",
29
- "clean": "rimraf dist coverage",
30
- "format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\" \"libs/**/*.ts\"",
31
- "start": "pnpm exec nest start",
32
- "doc": "pnpm exec compodoc --hideGenerator -p tsconfig.doc.json -s -r 9003 -w",
33
- "start:dev": "pnpm exec nest start --watch",
34
- "start:debug": "pnpm exec nest start --debug 0.0.0.0:9229 --watch",
35
- "start:prod": "node dist/main",
36
- "start:repl": "pnpm run start -- --entryFile repl",
37
- "lint": "eslint \"{src,apps,libs,test}/**/*.ts\"",
38
- "lint:fix": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
39
- "test": "jest --logHeapUsage --maxWorkers=50% --workerIdleMemoryLimit=1GB --testTimeout=10000",
40
- "test:runInBand": "jest --runInBand --logHeapUsage --detectOpenHandles --forceExit",
41
- "test:half": "jest --logHeapUsage --maxWorkers=50% --testTimeout=10000",
42
- "test:full": "jest --logHeapUsage --maxWorkers=100% --testTimeout=10000",
43
- "test:watch": "jest --watch --detectOpenHandles",
44
- "test:cov": "jest --coverage --runInBand --detectOpenHandles --forceExit",
45
- "test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand",
46
- "test:e2e": "jest --config ./test/jest-e2e.json",
47
- "test:clear": "jest --clearCache",
48
- "typecheck": "tsc --noEmit -p tsconfig.typecheck.json",
49
- "reset": "pnpm install && pnpm run containers:restart",
50
- "reset:hard": "pnpm install --force && pnpm run containers:rebuild",
51
- "cli": "HEXABOT_CLI=1 ts-node --files --log-error --compiler-options '{\"diagnostics\": true}' src/cli.ts"
52
- },
53
23
  "lint-staged": {
54
24
  "*.{js,ts}": "eslint --fix --config eslint.config-staged.cjs"
55
25
  },
@@ -58,7 +28,6 @@
58
28
  "@ai-sdk/mcp": "^1.0.25",
59
29
  "@ai-sdk/openai": "^3.0.7",
60
30
  "@ai-sdk/provider": "^3.0.2",
61
- "@hexabot-ai/agentic": "workspace:*",
62
31
  "@keyv/redis": "^5.1.3",
63
32
  "@llamaindex/openai": "0.4.22",
64
33
  "@llamaindex/postgres": "0.0.65",
@@ -118,11 +87,11 @@
118
87
  "typeorm": "^0.3.27",
119
88
  "uuid": "^9.0.1",
120
89
  "yaml": "^2.5.0",
121
- "zod": "^4.3.6"
90
+ "zod": "^4.3.6",
91
+ "@hexabot-ai/agentic": "3.0.1-alpha.0"
122
92
  },
123
93
  "devDependencies": {
124
94
  "@compodoc/compodoc": "^1.1.31",
125
- "@hexabot-ai/frontend": "workspace:*",
126
95
  "@nestjs/cli": "^11.0.10",
127
96
  "@nestjs/schematics": "^11.0.9",
128
97
  "@nestjs/testing": "^11.1.6",
@@ -166,7 +135,8 @@
166
135
  "ts-node": "^10.9.2",
167
136
  "tsconfig-paths": "^4.2.0",
168
137
  "tsconfig-paths-jest": "^0.0.1",
169
- "typescript": "^5.1.3"
138
+ "typescript": "^5.1.3",
139
+ "@hexabot-ai/frontend": "3.1.3-alpha.0"
170
140
  },
171
141
  "optionalDependencies": {
172
142
  "@css-inline/css-inline-linux-arm64-musl": "^0.14.1",
@@ -205,5 +175,35 @@
205
175
  },
206
176
  "engines": {
207
177
  "node": "^20.18.1"
178
+ },
179
+ "scripts": {
180
+ "dev": "pnpm run start:dev",
181
+ "build": "pnpm run frontend:build && nest build && pnpm run copy-types && pnpm run frontend:copy",
182
+ "copy-types": "node ./scripts/copy-types.js",
183
+ "frontend:build": "node ./scripts/frontend-build.js",
184
+ "frontend:copy": "node ./scripts/frontend-copy.js",
185
+ "clean": "rimraf dist coverage",
186
+ "format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\" \"libs/**/*.ts\"",
187
+ "start": "pnpm exec nest start",
188
+ "doc": "pnpm exec compodoc --hideGenerator -p tsconfig.doc.json -s -r 9003 -w",
189
+ "start:dev": "pnpm exec nest start --watch",
190
+ "start:debug": "pnpm exec nest start --debug 0.0.0.0:9229 --watch",
191
+ "start:prod": "node dist/main",
192
+ "start:repl": "pnpm run start -- --entryFile repl",
193
+ "lint": "eslint \"{src,apps,libs,test}/**/*.ts\"",
194
+ "lint:fix": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
195
+ "test": "jest --logHeapUsage --maxWorkers=50% --workerIdleMemoryLimit=1GB --testTimeout=10000",
196
+ "test:runInBand": "jest --runInBand --logHeapUsage --detectOpenHandles --forceExit",
197
+ "test:half": "jest --logHeapUsage --maxWorkers=50% --testTimeout=10000",
198
+ "test:full": "jest --logHeapUsage --maxWorkers=100% --testTimeout=10000",
199
+ "test:watch": "jest --watch --detectOpenHandles",
200
+ "test:cov": "jest --coverage --runInBand --detectOpenHandles --forceExit",
201
+ "test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand",
202
+ "test:e2e": "jest --config ./test/jest-e2e.json",
203
+ "test:clear": "jest --clearCache",
204
+ "typecheck": "tsc --noEmit -p tsconfig.typecheck.json",
205
+ "reset": "pnpm install && pnpm run containers:restart",
206
+ "reset:hard": "pnpm install --force && pnpm run containers:rebuild",
207
+ "cli": "HEXABOT_CLI=1 ts-node --files --log-error --compiler-options '{\"diagnostics\": true}' src/cli.ts"
208
208
  }
209
- }
209
+ }
@@ -0,0 +1,987 @@
1
+ /*
2
+ * Hexabot — Fair Core License (FCL-1.0-ALv2)
3
+ * Copyright (c) 2026 Hexastack.
4
+ * Full terms: see LICENSE.md.
5
+ */
6
+
7
+ import { BadRequestException } from '@nestjs/common';
8
+ import { DataSource } from 'typeorm';
9
+
10
+ import { SettingOrmEntity } from '@/setting/entities/setting.entity';
11
+ import { UserOrmEntity } from '@/user/entities/user.entity';
12
+ import { UpdateEntityEvent } from '@/utils/types/entity-event.types';
13
+ import { WorkflowOrmEntity } from '@/workflow/entities/workflow.entity';
14
+
15
+ import {
16
+ LemonSqueezyActivationResponse,
17
+ LemonSqueezyValidationResponse,
18
+ } from '../types/lemon-squeezy.types';
19
+ import { LicenseFeature } from '../types/license-feature.enum';
20
+ import { LicenseQuotaTier } from '../types/license-quota';
21
+
22
+ import { LicenseService } from './license.service';
23
+
24
+ type MockSettingService = {
25
+ getSettings: jest.Mock;
26
+ clearCache: jest.Mock;
27
+ };
28
+
29
+ type MockMetadataService = {
30
+ findOne: jest.Mock;
31
+ deleteOne: jest.Mock;
32
+ updateOne: jest.Mock;
33
+ };
34
+
35
+ type MockLemonSqueezyService = {
36
+ validate: jest.Mock;
37
+ activate: jest.Mock;
38
+ deactivate: jest.Mock;
39
+ extractAxiosError: jest.Mock;
40
+ };
41
+
42
+ type MockLoggerService = {
43
+ debug: jest.Mock;
44
+ error: jest.Mock;
45
+ warn: jest.Mock;
46
+ verbose: jest.Mock;
47
+ };
48
+
49
+ type ServiceEnv = {
50
+ service: LicenseService;
51
+ apiService: MockLemonSqueezyService;
52
+ settingService: MockSettingService;
53
+ metadataService: MockMetadataService;
54
+ logger: MockLoggerService;
55
+ dataSource: DataSource;
56
+ };
57
+
58
+ type CreateEnvOptions = {
59
+ apiService?: Partial<MockLemonSqueezyService>;
60
+ settingService?: Partial<MockSettingService>;
61
+ metadataService?: Partial<MockMetadataService>;
62
+ logger?: Partial<MockLoggerService>;
63
+ userCount?: number;
64
+ workflowCount?: number;
65
+ };
66
+
67
+ const buildExpectedQuotas = (
68
+ tier: LicenseQuotaTier,
69
+ counts: { users?: number; workflows?: number } = {},
70
+ ) => {
71
+ const usersUsed = counts.users ?? 0;
72
+ const workflowsUsed = counts.workflows ?? 0;
73
+ const usersLimitByTier: Record<LicenseQuotaTier, number | null> = {
74
+ community: 1,
75
+ starter: 1,
76
+ pro: 10,
77
+ unlimited: 25,
78
+ };
79
+ const workflowsLimitByTier: Record<LicenseQuotaTier, number | null> = {
80
+ community: 3,
81
+ starter: 25,
82
+ pro: 150,
83
+ unlimited: null,
84
+ };
85
+ const usersLimit = usersLimitByTier[tier];
86
+ const workflowsLimit = workflowsLimitByTier[tier];
87
+
88
+ return {
89
+ tier,
90
+ resources: {
91
+ users: {
92
+ limit: usersLimit,
93
+ used: usersUsed,
94
+ remaining:
95
+ typeof usersLimit === 'number'
96
+ ? Math.max(usersLimit - usersUsed, 0)
97
+ : null,
98
+ reached:
99
+ typeof usersLimit === 'number' ? usersUsed >= usersLimit : false,
100
+ },
101
+ workflows: {
102
+ limit: workflowsLimit,
103
+ used: workflowsUsed,
104
+ remaining:
105
+ typeof workflowsLimit === 'number'
106
+ ? Math.max(workflowsLimit - workflowsUsed, 0)
107
+ : null,
108
+ reached:
109
+ typeof workflowsLimit === 'number'
110
+ ? workflowsUsed >= workflowsLimit
111
+ : false,
112
+ },
113
+ },
114
+ };
115
+ };
116
+ const defaultLicenseKey = {
117
+ id: 123,
118
+ status: 'active' as const,
119
+ key: 'key_123',
120
+ activation_limit: 10,
121
+ activation_usage: 1,
122
+ created_at: '2024-01-01',
123
+ expires_at: null,
124
+ };
125
+ const defaultInstance = {
126
+ id: 'instance-1',
127
+ name: 'Hexabot',
128
+ created_at: '2024-01-01',
129
+ };
130
+ const defaultMeta = {
131
+ store_id: 1,
132
+ order_id: 2,
133
+ order_item_id: 3,
134
+ product_id: 4,
135
+ product_name: 'Pro Plan',
136
+ variant_id: 5,
137
+ variant_name: 'Pro',
138
+ customer_id: 6,
139
+ customer_name: 'Hexa',
140
+ customer_email: 'hexabot@example.com',
141
+ };
142
+ const createValidationResponse = (
143
+ overrides: Partial<LemonSqueezyValidationResponse> = {},
144
+ ): LemonSqueezyValidationResponse => ({
145
+ valid: true,
146
+ error: null,
147
+ license_key: {
148
+ ...defaultLicenseKey,
149
+ ...(overrides.license_key ?? {}),
150
+ },
151
+ instance: overrides.instance ?? defaultInstance,
152
+ meta: overrides.meta ?? defaultMeta,
153
+ ...overrides,
154
+ });
155
+ const createActivationResponse = (
156
+ overrides: Partial<LemonSqueezyActivationResponse> = {},
157
+ ): LemonSqueezyActivationResponse => ({
158
+ activated: true,
159
+ error: null,
160
+ license_key: {
161
+ ...defaultLicenseKey,
162
+ ...(overrides.license_key ?? {}),
163
+ },
164
+ instance: overrides.instance ?? defaultInstance,
165
+ meta: overrides.meta ?? defaultMeta,
166
+ ...overrides,
167
+ });
168
+ const createSettingUpdateEvent = ({
169
+ oldValue,
170
+ newValue,
171
+ group = 'chatbot_settings',
172
+ label = 'license_key',
173
+ }: {
174
+ oldValue?: unknown;
175
+ newValue?: unknown;
176
+ group?: string;
177
+ label?: string;
178
+ }) => {
179
+ const databaseEntity = Object.assign(new SettingOrmEntity(), {
180
+ group,
181
+ label,
182
+ value: oldValue,
183
+ });
184
+ const entity = Object.assign(new SettingOrmEntity(), {
185
+ group,
186
+ label,
187
+ value: newValue,
188
+ });
189
+
190
+ return {
191
+ databaseEntity,
192
+ entity,
193
+ } as unknown as UpdateEntityEvent<SettingOrmEntity>;
194
+ };
195
+ const createEnv = (overrides: CreateEnvOptions = {}): ServiceEnv => {
196
+ const apiService: MockLemonSqueezyService = {
197
+ validate: jest.fn(),
198
+ activate: jest.fn(),
199
+ deactivate: jest.fn(),
200
+ extractAxiosError: jest
201
+ .fn()
202
+ .mockImplementation((err) => (err instanceof Error ? err.message : err)),
203
+ ...overrides.apiService,
204
+ };
205
+ const settingService: MockSettingService = {
206
+ getSettings: jest.fn().mockResolvedValue({ chatbot_settings: {} }),
207
+ clearCache: jest.fn().mockResolvedValue(undefined),
208
+ ...overrides.settingService,
209
+ };
210
+ const metadataService: MockMetadataService = {
211
+ findOne: jest.fn().mockResolvedValue(undefined),
212
+ deleteOne: jest
213
+ .fn()
214
+ .mockResolvedValue({ acknowledged: true, deletedCount: 1 }),
215
+ updateOne: jest.fn().mockResolvedValue({}),
216
+ ...overrides.metadataService,
217
+ };
218
+ const logger: MockLoggerService = {
219
+ debug: jest.fn(),
220
+ error: jest.fn(),
221
+ warn: jest.fn(),
222
+ verbose: jest.fn(),
223
+ ...overrides.logger,
224
+ };
225
+ const userCount = overrides.userCount ?? 0;
226
+ const workflowCount = overrides.workflowCount ?? 0;
227
+ const userRepository = {
228
+ count: jest.fn().mockResolvedValue(userCount),
229
+ };
230
+ const workflowRepository = {
231
+ count: jest.fn().mockResolvedValue(workflowCount),
232
+ };
233
+ const dataSource = {
234
+ getRepository: jest.fn((target: unknown) => {
235
+ if (target === UserOrmEntity) {
236
+ return userRepository;
237
+ }
238
+
239
+ if (target === WorkflowOrmEntity) {
240
+ return workflowRepository;
241
+ }
242
+
243
+ return {
244
+ count: jest.fn().mockResolvedValue(0),
245
+ };
246
+ }),
247
+ } as unknown as DataSource;
248
+ const service = new LicenseService(
249
+ apiService as unknown as any,
250
+ settingService as unknown as any,
251
+ metadataService as unknown as any,
252
+ logger as unknown as any,
253
+ dataSource,
254
+ );
255
+
256
+ return {
257
+ service,
258
+ apiService,
259
+ settingService,
260
+ metadataService,
261
+ logger,
262
+ dataSource,
263
+ };
264
+ };
265
+
266
+ describe('LicenseService', () => {
267
+ afterEach(() => {
268
+ jest.restoreAllMocks();
269
+ jest.clearAllMocks();
270
+ });
271
+
272
+ describe('refresh', () => {
273
+ it('starts with all features disabled', () => {
274
+ const { service } = createEnv();
275
+
276
+ expect(service.hasFeature(LicenseFeature.UserManagement)).toBe(false);
277
+ });
278
+
279
+ it('marks state as undefined when no license key is stored', async () => {
280
+ const { service } = createEnv();
281
+
282
+ await service.refresh('bootstrap');
283
+
284
+ await expect(service.getSnapshot()).resolves.toEqual({
285
+ status: 'undefined',
286
+ plan: 'unknown',
287
+ activationLimit: null,
288
+ activationUsage: null,
289
+ lastError: 'No license key set (bootstrap).',
290
+ quotas: buildExpectedQuotas('community'),
291
+ });
292
+ expect(service.hasFeature(LicenseFeature.UserManagement)).toBe(false);
293
+ });
294
+
295
+ it('marks state invalid when validation fails', async () => {
296
+ const { service, settingService, apiService } = createEnv();
297
+ settingService.getSettings.mockResolvedValueOnce({
298
+ chatbot_settings: { license_key: 'invalid-key' },
299
+ });
300
+ apiService.validate.mockResolvedValue(
301
+ createValidationResponse({ valid: false, license_key: null }),
302
+ );
303
+
304
+ await service.refresh('bootstrap');
305
+
306
+ await expect(service.getSnapshot()).resolves.toEqual({
307
+ status: 'invalid',
308
+ plan: 'unknown',
309
+ activationLimit: null,
310
+ activationUsage: null,
311
+ lastError: 'Invalid license key (bootstrap).',
312
+ quotas: buildExpectedQuotas('community'),
313
+ });
314
+ });
315
+
316
+ it('marks state expired when the license has expired', async () => {
317
+ const { service, settingService, apiService } = createEnv();
318
+ settingService.getSettings.mockResolvedValueOnce({
319
+ chatbot_settings: { license_key: 'expired-key' },
320
+ });
321
+ apiService.validate.mockResolvedValue(
322
+ createValidationResponse({
323
+ license_key: {
324
+ status: 'expired',
325
+ id: 0,
326
+ key: 'expired-key',
327
+ activation_limit: null,
328
+ activation_usage: null,
329
+ created_at: '',
330
+ expires_at: null,
331
+ },
332
+ meta: {
333
+ ...defaultMeta,
334
+ product_name: 'Unlimited Plan',
335
+ },
336
+ }),
337
+ );
338
+
339
+ await service.refresh('bootstrap');
340
+
341
+ await expect(service.getSnapshot()).resolves.toEqual({
342
+ status: 'expired',
343
+ plan: 'unlimited',
344
+ activationLimit: null,
345
+ activationUsage: null,
346
+ lastError: 'License key expired (bootstrap).',
347
+ quotas: buildExpectedQuotas('community'),
348
+ });
349
+ expect(service.hasFeature(LicenseFeature.UserManagement)).toBe(false);
350
+ });
351
+
352
+ it('marks state disabled when the license has been disabled', async () => {
353
+ const { service, settingService, apiService } = createEnv();
354
+ settingService.getSettings.mockResolvedValueOnce({
355
+ chatbot_settings: { license_key: 'disabled-key' },
356
+ });
357
+ apiService.validate.mockResolvedValue(
358
+ createValidationResponse({
359
+ license_key: {
360
+ status: 'disabled',
361
+ id: 0,
362
+ key: 'disabled-key',
363
+ activation_limit: null,
364
+ activation_usage: null,
365
+ created_at: '',
366
+ expires_at: null,
367
+ },
368
+ }),
369
+ );
370
+
371
+ await service.refresh('bootstrap');
372
+
373
+ await expect(service.getSnapshot()).resolves.toEqual({
374
+ status: 'disabled',
375
+ plan: 'pro',
376
+ activationLimit: null,
377
+ activationUsage: null,
378
+ lastError: 'License key disabled (bootstrap).',
379
+ quotas: buildExpectedQuotas('community'),
380
+ });
381
+ expect(service.hasFeature(LicenseFeature.UserManagement)).toBe(false);
382
+ });
383
+
384
+ it('activates a pro license and enables user management', async () => {
385
+ const { service, settingService, apiService, metadataService } =
386
+ createEnv();
387
+ settingService.getSettings.mockResolvedValueOnce({
388
+ chatbot_settings: { license_key: 'active-key' },
389
+ });
390
+ apiService.validate.mockResolvedValue(
391
+ createValidationResponse({
392
+ meta: {
393
+ ...defaultMeta,
394
+ product_name: 'Pro Annual',
395
+ },
396
+ }),
397
+ );
398
+
399
+ await service.refresh('bootstrap');
400
+
401
+ expect(metadataService.updateOne).toHaveBeenCalledWith(
402
+ { where: { name: 'instance_id' } },
403
+ { name: 'instance_id', value: defaultInstance.id },
404
+ );
405
+ expect(service.getStatus()).toBe('active');
406
+ expect(service.getPlan()).toBe('pro');
407
+ expect(service.hasFeature(LicenseFeature.UserManagement)).toBe(true);
408
+ });
409
+
410
+ it('keeps user management disabled for starter plan', async () => {
411
+ const { service, settingService, apiService } = createEnv();
412
+ settingService.getSettings.mockResolvedValueOnce({
413
+ chatbot_settings: { license_key: 'starter-key' },
414
+ });
415
+ apiService.validate.mockResolvedValue(
416
+ createValidationResponse({
417
+ meta: {
418
+ ...defaultMeta,
419
+ product_name: 'Starter Monthly',
420
+ },
421
+ }),
422
+ );
423
+
424
+ await service.refresh('bootstrap');
425
+
426
+ expect(service.getStatus()).toBe('active');
427
+ expect(service.getPlan()).toBe('starter');
428
+ expect(service.hasFeature(LicenseFeature.UserManagement)).toBe(false);
429
+ });
430
+
431
+ it('includes pro quotas in snapshot when license is active', async () => {
432
+ const { service, settingService, apiService } = createEnv({
433
+ userCount: 9,
434
+ workflowCount: 150,
435
+ });
436
+ settingService.getSettings.mockResolvedValueOnce({
437
+ chatbot_settings: { license_key: 'active-pro' },
438
+ });
439
+ apiService.validate.mockResolvedValue(
440
+ createValidationResponse({
441
+ meta: {
442
+ ...defaultMeta,
443
+ product_name: 'Pro Monthly',
444
+ },
445
+ }),
446
+ );
447
+
448
+ await service.refresh('bootstrap');
449
+
450
+ await expect(service.getSnapshot()).resolves.toEqual({
451
+ status: 'active',
452
+ plan: 'pro',
453
+ activationLimit: 10,
454
+ activationUsage: 1,
455
+ lastError: null,
456
+ quotas: buildExpectedQuotas('pro', { users: 9, workflows: 150 }),
457
+ });
458
+ });
459
+
460
+ it('resolves active unknown plan to community quota tier', async () => {
461
+ const { service, settingService, apiService } = createEnv({
462
+ userCount: 1,
463
+ workflowCount: 2,
464
+ });
465
+ settingService.getSettings.mockResolvedValueOnce({
466
+ chatbot_settings: { license_key: 'active-unknown' },
467
+ });
468
+ apiService.validate.mockResolvedValue(
469
+ createValidationResponse({
470
+ meta: {
471
+ ...defaultMeta,
472
+ product_name: 'Enterprise Plan',
473
+ },
474
+ }),
475
+ );
476
+
477
+ await service.refresh('bootstrap');
478
+
479
+ await expect(service.getSnapshot()).resolves.toEqual({
480
+ status: 'active',
481
+ plan: 'unknown',
482
+ activationLimit: 10,
483
+ activationUsage: 1,
484
+ lastError: null,
485
+ quotas: buildExpectedQuotas('community', {
486
+ users: 1,
487
+ workflows: 2,
488
+ }),
489
+ });
490
+ });
491
+
492
+ it('keeps unlimited workflow quota uncapped in snapshot', async () => {
493
+ const { service, settingService, apiService } = createEnv({
494
+ userCount: 24,
495
+ workflowCount: 600,
496
+ });
497
+ settingService.getSettings.mockResolvedValueOnce({
498
+ chatbot_settings: { license_key: 'active-unlimited' },
499
+ });
500
+ apiService.validate.mockResolvedValue(
501
+ createValidationResponse({
502
+ meta: {
503
+ ...defaultMeta,
504
+ product_name: 'Unlimited Plan',
505
+ },
506
+ }),
507
+ );
508
+
509
+ await service.refresh('bootstrap');
510
+
511
+ await expect(service.getSnapshot()).resolves.toEqual({
512
+ status: 'active',
513
+ plan: 'unlimited',
514
+ activationLimit: 10,
515
+ activationUsage: 1,
516
+ lastError: null,
517
+ quotas: buildExpectedQuotas('unlimited', {
518
+ users: 24,
519
+ workflows: 600,
520
+ }),
521
+ });
522
+ });
523
+
524
+ it('activates through API when status is inactive and slots are available', async () => {
525
+ const { service, settingService, apiService, metadataService } =
526
+ createEnv();
527
+ settingService.getSettings.mockResolvedValueOnce({
528
+ chatbot_settings: { license_key: 'to-activate' },
529
+ });
530
+ apiService.validate.mockResolvedValue(
531
+ createValidationResponse({
532
+ license_key: {
533
+ status: 'inactive',
534
+ activation_limit: 3,
535
+ activation_usage: 1,
536
+ id: 0,
537
+ key: 'to-activate',
538
+ created_at: '',
539
+ expires_at: null,
540
+ },
541
+ instance: null,
542
+ meta: {
543
+ ...defaultMeta,
544
+ product_name: 'Unlimited Plan',
545
+ },
546
+ }),
547
+ );
548
+ apiService.activate.mockResolvedValue(
549
+ createActivationResponse({
550
+ license_key: {
551
+ status: 'active',
552
+ activation_limit: 3,
553
+ activation_usage: 2,
554
+ id: 0,
555
+ key: 'to-activate',
556
+ created_at: '',
557
+ expires_at: null,
558
+ },
559
+ meta: {
560
+ ...defaultMeta,
561
+ product_name: 'Unlimited Plan',
562
+ },
563
+ }),
564
+ );
565
+
566
+ await service.refresh('bootstrap');
567
+
568
+ expect(apiService.activate).toHaveBeenCalledWith('to-activate');
569
+ expect(metadataService.updateOne).toHaveBeenCalledWith(
570
+ { where: { name: 'instance_id' } },
571
+ { name: 'instance_id', value: defaultInstance.id },
572
+ );
573
+ expect(service.getStatus()).toBe('active');
574
+ expect(service.getPlan()).toBe('unlimited');
575
+ expect(service.hasFeature(LicenseFeature.UserManagement)).toBe(true);
576
+ });
577
+
578
+ it('stores an error when activation slots are exhausted', async () => {
579
+ const { service, settingService, apiService } = createEnv();
580
+ settingService.getSettings.mockResolvedValueOnce({
581
+ chatbot_settings: { license_key: 'full' },
582
+ });
583
+ apiService.validate.mockResolvedValue(
584
+ createValidationResponse({
585
+ license_key: {
586
+ status: 'inactive',
587
+ activation_limit: 1,
588
+ activation_usage: 1,
589
+ id: 0,
590
+ key: 'full',
591
+ created_at: '',
592
+ expires_at: null,
593
+ },
594
+ instance: null,
595
+ }),
596
+ );
597
+
598
+ await service.refresh('bootstrap');
599
+
600
+ await expect(service.getSnapshot()).resolves.toEqual({
601
+ status: 'inactive',
602
+ plan: 'pro',
603
+ activationLimit: null,
604
+ activationUsage: null,
605
+ lastError: 'Activation limit reached during bootstrap (usage=1/1).',
606
+ quotas: buildExpectedQuotas('community'),
607
+ });
608
+ expect(service.hasFeature(LicenseFeature.UserManagement)).toBe(false);
609
+ });
610
+
611
+ it('uses Axios extractor when refresh throws', async () => {
612
+ const { service, settingService, apiService } = createEnv();
613
+ settingService.getSettings.mockResolvedValueOnce({
614
+ chatbot_settings: { license_key: 'network' },
615
+ });
616
+ apiService.extractAxiosError.mockReturnValue('Detailed');
617
+ apiService.validate.mockRejectedValue(new Error('fail'));
618
+
619
+ await service.refresh('bootstrap');
620
+
621
+ await expect(service.getSnapshot()).resolves.toEqual({
622
+ status: 'error',
623
+ plan: 'unknown',
624
+ activationLimit: null,
625
+ activationUsage: null,
626
+ lastError: 'Bootstrap failed: Detailed',
627
+ quotas: buildExpectedQuotas('community'),
628
+ });
629
+ });
630
+ });
631
+
632
+ describe('handleLicenseKeyUpdate', () => {
633
+ it('ignores non-license settings', async () => {
634
+ const { service, apiService } = createEnv();
635
+
636
+ await service.handleLicenseKeyUpdate(
637
+ createSettingUpdateEvent({
638
+ oldValue: 'old',
639
+ newValue: 'new',
640
+ group: 'other',
641
+ label: 'other',
642
+ }),
643
+ );
644
+
645
+ expect(apiService.validate).not.toHaveBeenCalled();
646
+ });
647
+
648
+ it('deactivates old key when removing a license key', async () => {
649
+ const { service } = createEnv();
650
+ const deactivateSpy = jest
651
+ .spyOn(service, 'deactivate')
652
+ .mockResolvedValue(true);
653
+
654
+ await service.handleLicenseKeyUpdate(
655
+ createSettingUpdateEvent({ oldValue: 'old-key', newValue: '' }),
656
+ );
657
+
658
+ expect(deactivateSpy).toHaveBeenCalledWith(
659
+ 'old-key',
660
+ 'License key removed.',
661
+ );
662
+ expect(service.getStatus()).toBe('undefined');
663
+ expect(service.getPlan()).toBe('unknown');
664
+ expect(service.getLastError()).toBe('No license key set.');
665
+ });
666
+
667
+ it('rejects removal when deactivation fails', async () => {
668
+ const { service } = createEnv();
669
+ jest.spyOn(service, 'deactivate').mockResolvedValue(false);
670
+
671
+ await expect(
672
+ service.handleLicenseKeyUpdate(
673
+ createSettingUpdateEvent({ oldValue: 'old-key', newValue: '' }),
674
+ ),
675
+ ).rejects.toThrow(BadRequestException);
676
+ });
677
+
678
+ it('refreshes state when key does not change', async () => {
679
+ const { service } = createEnv();
680
+ const refreshSpy = jest
681
+ .spyOn(service, 'refresh')
682
+ .mockResolvedValue(undefined);
683
+
684
+ await service.handleLicenseKeyUpdate(
685
+ createSettingUpdateEvent({
686
+ oldValue: 'same-key',
687
+ newValue: 'same-key',
688
+ }),
689
+ );
690
+
691
+ expect(refreshSpy).toHaveBeenCalledWith('setting');
692
+ });
693
+
694
+ it('rejects invalid license keys', async () => {
695
+ const { service, apiService } = createEnv();
696
+ apiService.validate.mockResolvedValue(
697
+ createValidationResponse({ valid: false, license_key: null }),
698
+ );
699
+
700
+ await expect(
701
+ service.handleLicenseKeyUpdate(
702
+ createSettingUpdateEvent({
703
+ oldValue: 'old-key',
704
+ newValue: 'new-key',
705
+ }),
706
+ ),
707
+ ).rejects.toThrow('Invalid license key.');
708
+ });
709
+
710
+ it('updates runtime state with clear error when first key is invalid', async () => {
711
+ const { service, apiService } = createEnv();
712
+ apiService.validate.mockResolvedValue(
713
+ createValidationResponse({ valid: false, license_key: null }),
714
+ );
715
+
716
+ await expect(
717
+ service.handleLicenseKeyUpdate(
718
+ createSettingUpdateEvent({
719
+ oldValue: undefined,
720
+ newValue: 'invalid-key',
721
+ }),
722
+ ),
723
+ ).rejects.toThrow('Invalid license key.');
724
+
725
+ expect(service.getStatus()).toBe('invalid');
726
+ expect(service.getLastError()).toBe('Invalid license key.');
727
+ });
728
+
729
+ it('rejects rotation when old license deactivation fails', async () => {
730
+ const { service, apiService } = createEnv();
731
+ jest.spyOn(service, 'deactivate').mockResolvedValue(false);
732
+ apiService.validate.mockResolvedValue(
733
+ createValidationResponse({
734
+ license_key: {
735
+ status: 'inactive',
736
+ activation_limit: 5,
737
+ activation_usage: 0,
738
+ id: 0,
739
+ key: 'new-key',
740
+ created_at: '',
741
+ expires_at: null,
742
+ },
743
+ instance: null,
744
+ }),
745
+ );
746
+
747
+ await expect(
748
+ service.handleLicenseKeyUpdate(
749
+ createSettingUpdateEvent({
750
+ oldValue: 'old-key',
751
+ newValue: 'new-key',
752
+ }),
753
+ ),
754
+ ).rejects.toThrow(
755
+ 'Failed to deactivate existing license key before rotation.',
756
+ );
757
+ expect(apiService.activate).not.toHaveBeenCalled();
758
+ });
759
+
760
+ it('sets a new license key when there is no previous key', async () => {
761
+ const { service, apiService, metadataService } = createEnv();
762
+ const deactivateSpy = jest.spyOn(service, 'deactivate');
763
+ apiService.validate.mockResolvedValue(
764
+ createValidationResponse({
765
+ license_key: {
766
+ status: 'inactive',
767
+ activation_limit: 5,
768
+ activation_usage: 0,
769
+ id: 0,
770
+ key: 'new-key',
771
+ created_at: '',
772
+ expires_at: null,
773
+ },
774
+ instance: null,
775
+ }),
776
+ );
777
+ apiService.activate.mockResolvedValue(
778
+ createActivationResponse({
779
+ license_key: {
780
+ status: 'active',
781
+ activation_limit: 5,
782
+ activation_usage: 1,
783
+ id: 0,
784
+ key: 'new-key',
785
+ created_at: '',
786
+ expires_at: null,
787
+ },
788
+ }),
789
+ );
790
+
791
+ await service.handleLicenseKeyUpdate(
792
+ createSettingUpdateEvent({
793
+ oldValue: undefined,
794
+ newValue: 'new-key',
795
+ }),
796
+ );
797
+
798
+ expect(deactivateSpy).not.toHaveBeenCalled();
799
+ expect(metadataService.updateOne).toHaveBeenCalledWith(
800
+ { where: { name: 'instance_id' } },
801
+ { name: 'instance_id', value: defaultInstance.id },
802
+ );
803
+ expect(service.getStatus()).toBe('active');
804
+ expect(service.getPlan()).toBe('pro');
805
+ expect(service.hasFeature(LicenseFeature.UserManagement)).toBe(true);
806
+ });
807
+
808
+ it('rotates to a valid key and applies activated state', async () => {
809
+ const { service, apiService, metadataService } = createEnv();
810
+ jest.spyOn(service, 'deactivate').mockResolvedValue(true);
811
+ apiService.validate.mockResolvedValue(
812
+ createValidationResponse({
813
+ license_key: {
814
+ status: 'inactive',
815
+ activation_limit: 5,
816
+ activation_usage: 2,
817
+ id: 0,
818
+ key: 'new-key',
819
+ created_at: '',
820
+ expires_at: null,
821
+ },
822
+ instance: null,
823
+ }),
824
+ );
825
+ apiService.activate.mockResolvedValue(
826
+ createActivationResponse({
827
+ license_key: {
828
+ status: 'active',
829
+ activation_limit: 5,
830
+ activation_usage: 3,
831
+ id: 0,
832
+ key: 'new-key',
833
+ created_at: '',
834
+ expires_at: null,
835
+ },
836
+ meta: {
837
+ ...defaultMeta,
838
+ product_name: 'Pro Plan',
839
+ },
840
+ }),
841
+ );
842
+
843
+ await service.handleLicenseKeyUpdate(
844
+ createSettingUpdateEvent({ oldValue: 'old-key', newValue: 'new-key' }),
845
+ );
846
+
847
+ expect(apiService.activate).toHaveBeenCalledWith('new-key');
848
+ expect(metadataService.updateOne).toHaveBeenCalledWith(
849
+ { where: { name: 'instance_id' } },
850
+ { name: 'instance_id', value: defaultInstance.id },
851
+ );
852
+ expect(service.getStatus()).toBe('active');
853
+ expect(service.hasFeature(LicenseFeature.UserManagement)).toBe(true);
854
+ });
855
+
856
+ it('tries to restore old key when activation of new key fails', async () => {
857
+ const { service, apiService } = createEnv();
858
+ jest.spyOn(service, 'deactivate').mockResolvedValue(true);
859
+ apiService.validate.mockResolvedValue(
860
+ createValidationResponse({
861
+ license_key: {
862
+ status: 'inactive',
863
+ activation_limit: 5,
864
+ activation_usage: 0,
865
+ id: 0,
866
+ key: 'new-key',
867
+ created_at: '',
868
+ expires_at: null,
869
+ },
870
+ instance: null,
871
+ }),
872
+ );
873
+ apiService.activate
874
+ .mockResolvedValueOnce(
875
+ createActivationResponse({ activated: false, error: null }),
876
+ )
877
+ .mockResolvedValueOnce(
878
+ createActivationResponse({
879
+ license_key: {
880
+ status: 'active',
881
+ activation_limit: 5,
882
+ activation_usage: 1,
883
+ id: 0,
884
+ key: 'old-key',
885
+ created_at: '',
886
+ expires_at: null,
887
+ },
888
+ }),
889
+ );
890
+
891
+ await expect(
892
+ service.handleLicenseKeyUpdate(
893
+ createSettingUpdateEvent({
894
+ oldValue: 'old-key',
895
+ newValue: 'new-key',
896
+ }),
897
+ ),
898
+ ).rejects.toThrow(BadRequestException);
899
+
900
+ expect(apiService.activate).toHaveBeenNthCalledWith(1, 'new-key');
901
+ expect(apiService.activate).toHaveBeenNthCalledWith(2, 'old-key');
902
+ });
903
+ });
904
+
905
+ describe('deactivate', () => {
906
+ it('deactivates active licenses and clears stored instance id', async () => {
907
+ const { service, apiService, metadataService } = createEnv();
908
+ apiService.validate.mockResolvedValue(
909
+ createValidationResponse({
910
+ license_key: {
911
+ status: 'active',
912
+ id: 0,
913
+ key: 'key_123',
914
+ activation_limit: 5,
915
+ activation_usage: 2,
916
+ created_at: '',
917
+ expires_at: null,
918
+ },
919
+ }),
920
+ );
921
+ metadataService.findOne.mockResolvedValueOnce({ value: 'instance-2' });
922
+ apiService.deactivate.mockResolvedValue({
923
+ deactivated: true,
924
+ error: null,
925
+ license_key: {
926
+ ...defaultLicenseKey,
927
+ status: 'inactive',
928
+ },
929
+ });
930
+
931
+ const result = await service.deactivate('key_123', 'cleanup');
932
+
933
+ expect(result).toBe(true);
934
+ expect(metadataService.deleteOne).toHaveBeenCalledWith({
935
+ where: { name: 'instance_id' },
936
+ });
937
+ expect(service.getStatus()).toBe('inactive');
938
+ expect(service.getPlan()).toBe('pro');
939
+ expect(service.getLastError()).toBe('cleanup');
940
+ });
941
+
942
+ it('sets error status when license is invalid', async () => {
943
+ const { service, apiService } = createEnv();
944
+ apiService.validate.mockResolvedValue(
945
+ createValidationResponse({ valid: false, license_key: null }),
946
+ );
947
+
948
+ await service.deactivate('key_123');
949
+
950
+ expect(service.getStatus()).toBe('error');
951
+ });
952
+
953
+ it('returns false when deactivation throws', async () => {
954
+ const { service, apiService, logger } = createEnv();
955
+ apiService.validate.mockRejectedValue(new Error('network'));
956
+
957
+ const result = await service.deactivate('key_123');
958
+
959
+ expect(result).toBe(false);
960
+ expect(logger.error).toHaveBeenCalled();
961
+ });
962
+
963
+ it('marks local state inactive when instance id is missing', async () => {
964
+ const { service, apiService, metadataService } = createEnv();
965
+ metadataService.findOne.mockResolvedValueOnce(undefined);
966
+ apiService.validate.mockResolvedValue(
967
+ createValidationResponse({
968
+ license_key: {
969
+ status: 'active',
970
+ id: 0,
971
+ key: 'key_123',
972
+ activation_limit: 5,
973
+ activation_usage: 2,
974
+ created_at: '',
975
+ expires_at: null,
976
+ },
977
+ }),
978
+ );
979
+
980
+ const result = await service.deactivate('key_123', 'cleanup');
981
+
982
+ expect(result).toBe(true);
983
+ expect(apiService.deactivate).not.toHaveBeenCalled();
984
+ expect(service.getStatus()).toBe('inactive');
985
+ });
986
+ });
987
+ });