chadstart 1.0.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.
Files changed (115) hide show
  1. package/.dockerignore +10 -0
  2. package/.env.example +46 -0
  3. package/.github/workflows/browser-test.yml +34 -0
  4. package/.github/workflows/docker-publish.yml +54 -0
  5. package/.github/workflows/docs.yml +31 -0
  6. package/.github/workflows/npm-chadstart.yml +27 -0
  7. package/.github/workflows/npm-sdk.yml +38 -0
  8. package/.github/workflows/test.yml +85 -0
  9. package/.weblate +9 -0
  10. package/Dockerfile +23 -0
  11. package/README.md +348 -0
  12. package/admin/index.html +2802 -0
  13. package/admin/login.html +207 -0
  14. package/chadstart.example.yml +416 -0
  15. package/chadstart.schema.json +367 -0
  16. package/chadstart.yaml +53 -0
  17. package/cli/cli.js +295 -0
  18. package/core/api-generator.js +606 -0
  19. package/core/auth.js +298 -0
  20. package/core/db.js +384 -0
  21. package/core/entity-engine.js +166 -0
  22. package/core/error-reporter.js +132 -0
  23. package/core/file-storage.js +97 -0
  24. package/core/functions-engine.js +353 -0
  25. package/core/openapi.js +171 -0
  26. package/core/plugin-loader.js +92 -0
  27. package/core/realtime.js +93 -0
  28. package/core/schema-validator.js +50 -0
  29. package/core/seeder.js +231 -0
  30. package/core/telemetry.js +119 -0
  31. package/core/upload.js +372 -0
  32. package/core/workers/php_worker.php +19 -0
  33. package/core/workers/python_worker.py +33 -0
  34. package/core/workers/ruby_worker.rb +21 -0
  35. package/core/yaml-loader.js +64 -0
  36. package/demo/chadstart.yaml +178 -0
  37. package/demo/docker-compose.yml +31 -0
  38. package/demo/functions/greet.go +39 -0
  39. package/demo/functions/hello.cpp +18 -0
  40. package/demo/functions/hello.py +13 -0
  41. package/demo/functions/hello.rb +10 -0
  42. package/demo/functions/onTodoCreated.js +13 -0
  43. package/demo/functions/ping.sh +13 -0
  44. package/demo/functions/stats.js +22 -0
  45. package/demo/public/index.html +522 -0
  46. package/docker-compose.yml +17 -0
  47. package/docs/access-policies.md +155 -0
  48. package/docs/admin-ui.md +29 -0
  49. package/docs/angular.md +69 -0
  50. package/docs/astro.md +71 -0
  51. package/docs/auth.md +160 -0
  52. package/docs/cli.md +56 -0
  53. package/docs/config.md +127 -0
  54. package/docs/crud.md +627 -0
  55. package/docs/deploy.md +113 -0
  56. package/docs/docker.md +59 -0
  57. package/docs/entities.md +385 -0
  58. package/docs/functions.md +196 -0
  59. package/docs/getting-started.md +79 -0
  60. package/docs/groups.md +85 -0
  61. package/docs/index.md +5 -0
  62. package/docs/llm-rules.md +81 -0
  63. package/docs/middlewares.md +78 -0
  64. package/docs/overrides/home.html +350 -0
  65. package/docs/plugins.md +59 -0
  66. package/docs/react.md +75 -0
  67. package/docs/realtime.md +43 -0
  68. package/docs/s3-storage.md +40 -0
  69. package/docs/security.md +23 -0
  70. package/docs/stylesheets/extra.css +375 -0
  71. package/docs/svelte.md +71 -0
  72. package/docs/telemetry.md +97 -0
  73. package/docs/upload.md +168 -0
  74. package/docs/validation.md +115 -0
  75. package/docs/vue.md +86 -0
  76. package/docs/webhooks.md +87 -0
  77. package/index.js +11 -0
  78. package/locales/en/admin.json +169 -0
  79. package/mkdocs.yml +82 -0
  80. package/package.json +65 -0
  81. package/playwright.config.js +24 -0
  82. package/public/.gitkeep +0 -0
  83. package/sdk/README.md +284 -0
  84. package/sdk/package.json +39 -0
  85. package/sdk/scripts/build.js +58 -0
  86. package/sdk/src/index.js +368 -0
  87. package/sdk/test/sdk.test.cjs +340 -0
  88. package/sdk/types/index.d.ts +217 -0
  89. package/server/express-server.js +734 -0
  90. package/test/access-policies.test.js +96 -0
  91. package/test/ai.test.js +81 -0
  92. package/test/api-keys.test.js +361 -0
  93. package/test/auth.test.js +122 -0
  94. package/test/browser/admin-ui.spec.js +127 -0
  95. package/test/browser/global-setup.js +71 -0
  96. package/test/browser/global-teardown.js +11 -0
  97. package/test/db.test.js +227 -0
  98. package/test/entity-engine.test.js +193 -0
  99. package/test/error-reporter.test.js +140 -0
  100. package/test/functions-engine.test.js +240 -0
  101. package/test/groups.test.js +212 -0
  102. package/test/hot-reload.test.js +153 -0
  103. package/test/i18n.test.js +173 -0
  104. package/test/middleware.test.js +76 -0
  105. package/test/openapi.test.js +67 -0
  106. package/test/schema-validator.test.js +83 -0
  107. package/test/sdk.test.js +90 -0
  108. package/test/seeder.test.js +279 -0
  109. package/test/settings.test.js +109 -0
  110. package/test/telemetry.test.js +254 -0
  111. package/test/test.js +17 -0
  112. package/test/upload.test.js +265 -0
  113. package/test/validation.test.js +96 -0
  114. package/test/yaml-loader.test.js +93 -0
  115. package/utils/logger.js +24 -0
@@ -0,0 +1,140 @@
1
+ 'use strict';
2
+
3
+ const assert = require('assert');
4
+
5
+ describe('error-reporter', () => {
6
+ const reporter = require('../core/error-reporter');
7
+
8
+ afterEach(() => {
9
+ // Reset module state and env between tests
10
+ reporter._reset();
11
+ delete process.env.SENTRY_DSN;
12
+ });
13
+
14
+ it('does nothing when SENTRY_DSN is not set', () => {
15
+ reporter.initErrorReporter({});
16
+ assert.strictEqual(reporter.getSentry(), null);
17
+ });
18
+
19
+ it('getRequestHandler returns null when not initialised', () => {
20
+ assert.strictEqual(reporter.getRequestHandler(), null);
21
+ });
22
+
23
+ it('getErrorHandler returns null when not initialised', () => {
24
+ assert.strictEqual(reporter.getErrorHandler(), null);
25
+ });
26
+
27
+ it('attachErrorHandler is a no-op when not initialised', () => {
28
+ let called = false;
29
+ reporter.attachErrorHandler({ use: () => { called = true; } });
30
+ assert.strictEqual(called, false, 'app.use should not have been called');
31
+ });
32
+
33
+ it('getSentry returns null when not initialised', () => {
34
+ assert.strictEqual(reporter.getSentry(), null);
35
+ });
36
+
37
+ it('initialises Sentry when SENTRY_DSN is set', () => {
38
+ process.env.SENTRY_DSN = 'https://test@sentry.io/1';
39
+
40
+ const calls = [];
41
+ // Stub @sentry/node by temporarily overriding require cache
42
+ const sentryStub = {
43
+ init: (opts) => calls.push(opts),
44
+ setupExpressErrorHandler: () => {},
45
+ };
46
+ const Module = require('module');
47
+ const origLoad = Module._load;
48
+ Module._load = function (request, parent, isMain) {
49
+ if (request === '@sentry/node') return sentryStub;
50
+ return origLoad.call(this, request, parent, isMain);
51
+ };
52
+
53
+ try {
54
+ reporter.initErrorReporter({ sentry: { environment: 'test', tracesSampleRate: 0.5 } });
55
+ assert.strictEqual(calls.length, 1);
56
+ assert.strictEqual(calls[0].dsn, 'https://test@sentry.io/1');
57
+ assert.strictEqual(calls[0].environment, 'test');
58
+ assert.strictEqual(calls[0].tracesSampleRate, 0.5);
59
+ assert.strictEqual(reporter.getSentry(), sentryStub);
60
+ } finally {
61
+ Module._load = origLoad;
62
+ reporter._reset();
63
+ }
64
+ });
65
+
66
+ it('uses NODE_ENV as environment when sentry.environment is not set', () => {
67
+ process.env.SENTRY_DSN = 'https://test@sentry.io/2';
68
+ const prevNodeEnv = process.env.NODE_ENV;
69
+ process.env.NODE_ENV = 'staging';
70
+
71
+ const calls = [];
72
+ const sentryStub = {
73
+ init: (opts) => calls.push(opts),
74
+ setupExpressErrorHandler: () => {},
75
+ };
76
+ const Module = require('module');
77
+ const origLoad = Module._load;
78
+ Module._load = function (request, parent, isMain) {
79
+ if (request === '@sentry/node') return sentryStub;
80
+ return origLoad.call(this, request, parent, isMain);
81
+ };
82
+
83
+ try {
84
+ reporter.initErrorReporter({});
85
+ assert.strictEqual(calls[0].environment, 'staging');
86
+ } finally {
87
+ Module._load = origLoad;
88
+ process.env.NODE_ENV = prevNodeEnv;
89
+ reporter._reset();
90
+ }
91
+ });
92
+
93
+ it('defaults tracesSampleRate to 1.0 when not configured', () => {
94
+ process.env.SENTRY_DSN = 'https://test@sentry.io/3';
95
+
96
+ const calls = [];
97
+ const sentryStub = {
98
+ init: (opts) => calls.push(opts),
99
+ setupExpressErrorHandler: () => {},
100
+ };
101
+ const Module = require('module');
102
+ const origLoad = Module._load;
103
+ Module._load = function (request, parent, isMain) {
104
+ if (request === '@sentry/node') return sentryStub;
105
+ return origLoad.call(this, request, parent, isMain);
106
+ };
107
+
108
+ try {
109
+ reporter.initErrorReporter({});
110
+ assert.strictEqual(calls[0].tracesSampleRate, 1.0);
111
+ } finally {
112
+ Module._load = origLoad;
113
+ reporter._reset();
114
+ }
115
+ });
116
+
117
+ it('attachErrorHandler calls setupExpressErrorHandler on the app', () => {
118
+ process.env.SENTRY_DSN = 'https://test@sentry.io/4';
119
+ let attached = false;
120
+ const sentryStub = {
121
+ init: () => {},
122
+ setupExpressErrorHandler: () => { attached = true; },
123
+ };
124
+ const Module = require('module');
125
+ const origLoad = Module._load;
126
+ Module._load = function (request, parent, isMain) {
127
+ if (request === '@sentry/node') return sentryStub;
128
+ return origLoad.call(this, request, parent, isMain);
129
+ };
130
+
131
+ try {
132
+ reporter.initErrorReporter({});
133
+ reporter.attachErrorHandler({});
134
+ assert.ok(attached, 'setupExpressErrorHandler should have been called');
135
+ } finally {
136
+ Module._load = origLoad;
137
+ reporter._reset();
138
+ }
139
+ });
140
+ });
@@ -0,0 +1,240 @@
1
+ 'use strict';
2
+
3
+ const assert = require('assert');
4
+ const path = require('path');
5
+ const fs = require('fs');
6
+ const os = require('os');
7
+ const http = require('http');
8
+ const express = require('express');
9
+
10
+ const { resolveSchedule, setupFunctions, cleanup, eventBus } = require('../core/functions-engine');
11
+
12
+ // ── resolveSchedule ────────────────────────────────────────────────────────────
13
+
14
+ describe('functions-engine – resolveSchedule', () => {
15
+ it('maps @yearly → 0 0 1 1 *', () => assert.strictEqual(resolveSchedule('@yearly'), '0 0 1 1 *'));
16
+ it('maps @annually → 0 0 1 1 *', () => assert.strictEqual(resolveSchedule('@annually'),'0 0 1 1 *'));
17
+ it('maps @monthly → 0 0 1 * *', () => assert.strictEqual(resolveSchedule('@monthly'), '0 0 1 * *'));
18
+ it('maps @weekly → 0 0 * * 0', () => assert.strictEqual(resolveSchedule('@weekly'), '0 0 * * 0'));
19
+ it('maps @daily → 0 0 * * *', () => assert.strictEqual(resolveSchedule('@daily'), '0 0 * * *'));
20
+ it('maps @midnight → 0 0 * * *', () => assert.strictEqual(resolveSchedule('@midnight'),'0 0 * * *'));
21
+ it('maps @hourly → 0 * * * *', () => assert.strictEqual(resolveSchedule('@hourly'), '0 * * * *'));
22
+ it('passes through standard cron expressions unchanged', () => {
23
+ assert.strictEqual(resolveSchedule('*/10 * * * *'), '*/10 * * * *');
24
+ });
25
+ });
26
+
27
+ // ── HTTP trigger ───────────────────────────────────────────────────────────────
28
+
29
+ describe('functions-engine – HTTP trigger', () => {
30
+ let server, port, fnDir;
31
+
32
+ before(async () => {
33
+ cleanup();
34
+ fnDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cs-fn-'));
35
+ fs.writeFileSync(path.join(fnDir, 'greet.js'), `module.exports = async function(event, ctx) { return { msg: 'hi', trigger: ctx.trigger }; };`);
36
+
37
+ process.env.CHADSTART_FUNCTIONS_FOLDER = fnDir;
38
+
39
+ const app = express();
40
+ app.use(express.json());
41
+ setupFunctions(app, {
42
+ greet: {
43
+ runtime: 'js',
44
+ function: 'greet.js',
45
+ triggers: [{ type: 'http', method: 'GET', path: '/greet' }],
46
+ },
47
+ });
48
+
49
+ server = http.createServer(app);
50
+ await new Promise((r) => server.listen(0, '127.0.0.1', r));
51
+ port = server.address().port;
52
+ });
53
+
54
+ after(async () => {
55
+ cleanup();
56
+ await new Promise((r) => server.close(r));
57
+ delete process.env.CHADSTART_FUNCTIONS_FOLDER;
58
+ fs.rmSync(fnDir, { recursive: true, force: true });
59
+ });
60
+
61
+ it('GET /greet returns function result', async () => {
62
+ const res = await fetch(`http://127.0.0.1:${port}/greet`);
63
+ const body = await res.json();
64
+ assert.strictEqual(res.status, 200);
65
+ assert.strictEqual(body.msg, 'hi');
66
+ assert.strictEqual(body.trigger, 'http');
67
+ });
68
+ });
69
+
70
+ // ── Event trigger ──────────────────────────────────────────────────────────────
71
+
72
+ describe('functions-engine – event trigger', () => {
73
+ let fnDir;
74
+
75
+ before(() => {
76
+ cleanup();
77
+ fnDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cs-fn-'));
78
+ fs.writeFileSync(path.join(fnDir, 'onMyEvent.js'), `
79
+ module.exports = async function(event, ctx) { global.__csTestPayload = event; };
80
+ `);
81
+ process.env.CHADSTART_FUNCTIONS_FOLDER = fnDir;
82
+
83
+ setupFunctions(null, {
84
+ myEventFn: {
85
+ runtime: 'js',
86
+ function: 'onMyEvent.js',
87
+ triggers: [{ type: 'event', name: 'test-event' }],
88
+ },
89
+ });
90
+ });
91
+
92
+ after(() => {
93
+ cleanup();
94
+ delete process.env.CHADSTART_FUNCTIONS_FOLDER;
95
+ delete global.__csTestPayload;
96
+ fs.rmSync(fnDir, { recursive: true, force: true });
97
+ });
98
+
99
+ it('emitting an event invokes the registered function', async () => {
100
+ global.__csTestPayload = null;
101
+ eventBus.emit('test-event', { value: 42 });
102
+ await new Promise((r) => setTimeout(r, 100));
103
+ assert.deepStrictEqual(global.__csTestPayload, { value: 42 });
104
+ });
105
+ });
106
+
107
+ // ── HTTP trigger with policies ─────────────────────────────────────────────────
108
+
109
+ describe('functions-engine – HTTP trigger policies', () => {
110
+ let server, port, fnDir;
111
+ const jwt = require('jsonwebtoken');
112
+ const JWT_SECRET = process.env.JWT_SECRET || process.env.TOKEN_SECRET_KEY || 'chadstart-dev-secret-change-in-production';
113
+
114
+ before(async () => {
115
+ cleanup();
116
+ fnDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cs-fn-'));
117
+ fs.writeFileSync(path.join(fnDir, 'ok.js'), `module.exports = async function() { return { ok: true }; };`);
118
+
119
+ process.env.CHADSTART_FUNCTIONS_FOLDER = fnDir;
120
+
121
+ const app = express();
122
+ app.use(express.json());
123
+ setupFunctions(app, {
124
+ publicFn: { function: 'ok.js', triggers: [{ type: 'http', method: 'GET', path: '/pub', policies: [{ access: 'public' }] }] },
125
+ defaultFn: { function: 'ok.js', triggers: [{ type: 'http', method: 'GET', path: '/default' }] },
126
+ adminFn: { function: 'ok.js', triggers: [{ type: 'http', method: 'GET', path: '/admin', policies: [{ access: 'admin' }] }] },
127
+ forbiddenFn: { function: 'ok.js', triggers: [{ type: 'http', method: 'GET', path: '/forbidden', policies: [{ access: 'forbidden' }] }] },
128
+ restrictedFn: { function: 'ok.js', triggers: [{ type: 'http', method: 'GET', path: '/restricted', policies: [{ access: 'restricted', allow: 'Customer' }] }] },
129
+ });
130
+
131
+ server = http.createServer(app);
132
+ await new Promise((r) => server.listen(0, '127.0.0.1', r));
133
+ port = server.address().port;
134
+ });
135
+
136
+ after(async () => {
137
+ cleanup();
138
+ await new Promise((r) => server.close(r));
139
+ delete process.env.CHADSTART_FUNCTIONS_FOLDER;
140
+ fs.rmSync(fnDir, { recursive: true, force: true });
141
+ });
142
+
143
+ function makeToken(entity) {
144
+ return jwt.sign({ id: 'test-id', entity }, JWT_SECRET, { expiresIn: '1h' });
145
+ }
146
+
147
+ it('public policy: allows unauthenticated requests', async () => {
148
+ const res = await fetch(`http://127.0.0.1:${port}/pub`);
149
+ assert.strictEqual(res.status, 200);
150
+ const body = await res.json();
151
+ assert.strictEqual(body.ok, true);
152
+ });
153
+
154
+ it('no policy (default): allows unauthenticated requests', async () => {
155
+ const res = await fetch(`http://127.0.0.1:${port}/default`);
156
+ assert.strictEqual(res.status, 200);
157
+ });
158
+
159
+ it('admin policy: rejects unauthenticated request with 401', async () => {
160
+ const res = await fetch(`http://127.0.0.1:${port}/admin`);
161
+ assert.strictEqual(res.status, 401);
162
+ });
163
+
164
+ it('admin policy: allows any valid JWT', async () => {
165
+ const token = makeToken('Admin');
166
+ const res = await fetch(`http://127.0.0.1:${port}/admin`, { headers: { Authorization: `Bearer ${token}` } });
167
+ assert.strictEqual(res.status, 200);
168
+ });
169
+
170
+ it('forbidden policy: always returns 403', async () => {
171
+ const res = await fetch(`http://127.0.0.1:${port}/forbidden`);
172
+ assert.strictEqual(res.status, 403);
173
+ });
174
+
175
+ it('restricted policy: rejects unauthenticated request with 401', async () => {
176
+ const res = await fetch(`http://127.0.0.1:${port}/restricted`);
177
+ assert.strictEqual(res.status, 401);
178
+ });
179
+
180
+ it('restricted policy: rejects token from wrong entity with 403', async () => {
181
+ const token = makeToken('Admin');
182
+ const res = await fetch(`http://127.0.0.1:${port}/restricted`, { headers: { Authorization: `Bearer ${token}` } });
183
+ assert.strictEqual(res.status, 403);
184
+ });
185
+
186
+ it('restricted policy: allows token from correct entity', async () => {
187
+ const token = makeToken('Customer');
188
+ const res = await fetch(`http://127.0.0.1:${port}/restricted`, { headers: { Authorization: `Bearer ${token}` } });
189
+ assert.strictEqual(res.status, 200);
190
+ const body = await res.json();
191
+ assert.strictEqual(body.ok, true);
192
+ });
193
+ });
194
+
195
+
196
+ describe('functions-engine – JS format auto-detection', () => {
197
+ let server, port, fnDir;
198
+
199
+ before(async () => {
200
+ cleanup();
201
+ fnDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cs-fn-'));
202
+ fs.writeFileSync(path.join(fnDir, 'universal.js'), `
203
+ module.exports = async function(event, ctx) {
204
+ if (ctx.trigger === 'http') return { format: 'universal' };
205
+ };
206
+ `);
207
+ fs.writeFileSync(path.join(fnDir, 'lambda.js'), `
208
+ exports.handler = async (event, context) => ({ statusCode: 200, body: JSON.stringify({ format: 'lambda' }) });
209
+ `);
210
+
211
+ process.env.CHADSTART_FUNCTIONS_FOLDER = fnDir;
212
+ const app = express();
213
+ app.use(express.json());
214
+ setupFunctions(app, {
215
+ universal: { function: 'universal.js', triggers: [{ type: 'http', method: 'GET', path: '/universal' }] },
216
+ lambda: { function: 'lambda.js', triggers: [{ type: 'http', method: 'GET', path: '/lambda' }] },
217
+ });
218
+ server = http.createServer(app);
219
+ await new Promise((r) => server.listen(0, '127.0.0.1', r));
220
+ port = server.address().port;
221
+ });
222
+
223
+ after(async () => {
224
+ cleanup();
225
+ await new Promise((r) => server.close(r));
226
+ delete process.env.CHADSTART_FUNCTIONS_FOLDER;
227
+ fs.rmSync(fnDir, { recursive: true, force: true });
228
+ });
229
+
230
+ it('universal format function responds correctly', async () => {
231
+ const body = await fetch(`http://127.0.0.1:${port}/universal`).then((r) => r.json());
232
+ assert.strictEqual(body.format, 'universal');
233
+ });
234
+
235
+ it('AWS Lambda format function is auto-detected and responds correctly', async () => {
236
+ const body = await fetch(`http://127.0.0.1:${port}/lambda`).then((r) => r.json());
237
+ assert.strictEqual(body.format, 'lambda');
238
+ });
239
+ });
240
+
@@ -0,0 +1,212 @@
1
+ 'use strict';
2
+
3
+ const assert = require('assert');
4
+ const path = require('path');
5
+ const fs = require('fs');
6
+ const os = require('os');
7
+ const { buildCore } = require('../core/entity-engine');
8
+ const dbModule = require('../core/db');
9
+ const { validateBody, deserializeGroupProps } = require('../core/api-generator');
10
+
11
+ describe('groups – serialization / deserialization', () => {
12
+ const groupEntity = {
13
+ properties: [
14
+ { name: 'testimonials', type: 'group', options: { group: 'Testimonial' } },
15
+ { name: 'callToAction', type: 'group', options: { group: 'CallToAction', multiple: false } },
16
+ { name: 'title', type: 'string' },
17
+ ],
18
+ belongsTo: [],
19
+ };
20
+
21
+ it('deserializeGroupProps: parses JSON string to array for multiple group', () => {
22
+ const items = [{ author: 'Alice', rating: 5 }, { author: 'Bob', rating: 4 }];
23
+ const row = { id: '1', testimonials: JSON.stringify(items), title: 'hi' };
24
+ const result = deserializeGroupProps(row, groupEntity);
25
+ assert.deepStrictEqual(result.testimonials, items);
26
+ assert.strictEqual(result.title, 'hi');
27
+ });
28
+
29
+ it('deserializeGroupProps: parses JSON string to object for single group', () => {
30
+ const cta = { title: 'Buy now', buttonText: 'Go' };
31
+ const row = { id: '1', callToAction: JSON.stringify(cta) };
32
+ const result = deserializeGroupProps(row, groupEntity);
33
+ assert.deepStrictEqual(result.callToAction, cta);
34
+ });
35
+
36
+ it('deserializeGroupProps: leaves non-string group values unchanged', () => {
37
+ const items = [{ author: 'Alice' }];
38
+ const row = { id: '1', testimonials: items };
39
+ const result = deserializeGroupProps(row, groupEntity);
40
+ assert.deepStrictEqual(result.testimonials, items);
41
+ });
42
+
43
+ it('deserializeGroupProps: handles invalid JSON gracefully (leaves as string)', () => {
44
+ const row = { id: '1', testimonials: 'not-json' };
45
+ const result = deserializeGroupProps(row, groupEntity);
46
+ assert.strictEqual(result.testimonials, 'not-json');
47
+ });
48
+
49
+ it('deserializeGroupProps: is a no-op when no group properties exist', () => {
50
+ const simpleEntity = { properties: [{ name: 'title', type: 'string' }] };
51
+ const row = { id: '1', title: 'hello' };
52
+ const result = deserializeGroupProps(row, simpleEntity);
53
+ assert.deepStrictEqual(result, row);
54
+ });
55
+
56
+ it('deserializeGroupProps: returns row unchanged when row is null', () => {
57
+ assert.strictEqual(deserializeGroupProps(null, groupEntity), null);
58
+ });
59
+ });
60
+
61
+ describe('groups – validateBody with group validation', () => {
62
+ const groups = {
63
+ Testimonial: {
64
+ properties: [
65
+ { name: 'author', type: 'text' },
66
+ { name: 'rating', type: 'number' },
67
+ ],
68
+ validation: { rating: { isNotEmpty: true } },
69
+ },
70
+ CallToAction: {
71
+ properties: [
72
+ { name: 'title', type: 'string' },
73
+ { name: 'description', type: 'text' },
74
+ ],
75
+ validation: {
76
+ title: { isNotEmpty: true },
77
+ description: { isNotEmpty: true },
78
+ },
79
+ },
80
+ };
81
+
82
+ const entityWithGroup = {
83
+ properties: [
84
+ { name: 'name', type: 'string' },
85
+ { name: 'testimonials', type: 'group', options: { group: 'Testimonial' } },
86
+ ],
87
+ validation: {},
88
+ };
89
+
90
+ it('validateBody: passes when group items satisfy validation', () => {
91
+ const body = { name: 'Service A', testimonials: [{ author: 'Alice', rating: 5 }, { author: 'Bob', rating: 4 }] };
92
+ assert.strictEqual(validateBody(body, entityWithGroup, groups).errors, null);
93
+ });
94
+
95
+ it('validateBody: fails when a group item violates validation', () => {
96
+ const body = { name: 'Service A', testimonials: [{ author: 'Alice', rating: 5 }, { author: 'Bob', rating: '' }] };
97
+ const result = validateBody(body, entityWithGroup, groups);
98
+ assert.ok(result.errors, 'should have errors');
99
+ assert.ok(result.errors.some((e) => e.property.startsWith('testimonials[1]')));
100
+ });
101
+
102
+ it('validateBody: skips group validation when group value is absent', () => {
103
+ const body = { name: 'Service A' };
104
+ assert.strictEqual(validateBody(body, entityWithGroup, groups).errors, null);
105
+ });
106
+
107
+ it('validateBody: skips group validation when no groups map provided', () => {
108
+ const body = { name: 'Service A', testimonials: [{ author: 'Alice', rating: '' }] };
109
+ assert.strictEqual(validateBody(body, entityWithGroup).errors, null);
110
+ });
111
+
112
+ it('validateBody: single (non-multiple) group validates the object directly', () => {
113
+ const ctaEntity = {
114
+ properties: [
115
+ { name: 'callToAction', type: 'group', options: { group: 'CallToAction', multiple: false } },
116
+ ],
117
+ validation: {},
118
+ };
119
+ const badBody = { callToAction: { title: '', description: 'desc' } };
120
+ const result = validateBody(badBody, ctaEntity, groups);
121
+ assert.ok(result.errors, 'should have errors for empty title');
122
+ assert.ok(result.errors.some((e) => e.property === 'callToAction.title'),
123
+ `expected callToAction.title, got: ${JSON.stringify(result.errors.map((e) => e.property))}`);
124
+
125
+ const goodBody = { callToAction: { title: 'Act now', description: 'Do it' } };
126
+ assert.strictEqual(validateBody(goodBody, ctaEntity, groups).errors, null);
127
+ });
128
+ });
129
+
130
+ describe('groups – seeder generates group values', () => {
131
+ let tmp;
132
+ const groupSeedCore = buildCore({
133
+ name: 'GroupSeedTest',
134
+ entities: {
135
+ Service: {
136
+ properties: [
137
+ { name: 'name', type: 'string' },
138
+ { name: 'testimonials', type: 'group', options: { group: 'Testimonial' } },
139
+ { name: 'callToAction', type: 'group', options: { group: 'CallToAction', multiple: false } },
140
+ ],
141
+ seedCount: 3,
142
+ },
143
+ },
144
+ groups: {
145
+ Testimonial: {
146
+ properties: [
147
+ { name: 'author', type: 'text' },
148
+ { name: 'content', type: 'text' },
149
+ { name: 'rating', type: 'number' },
150
+ ],
151
+ },
152
+ CallToAction: {
153
+ properties: [
154
+ { name: 'title', type: 'string' },
155
+ { name: 'buttonText', type: 'string' },
156
+ ],
157
+ },
158
+ },
159
+ });
160
+
161
+ before(() => {
162
+ tmp = path.join(os.tmpdir(), `chadstart-group-seed-${Date.now()}.db`);
163
+ dbModule.initDb(groupSeedCore, tmp);
164
+ });
165
+
166
+ after(() => { fs.unlinkSync(tmp); });
167
+
168
+ it('seeder: group property stores valid JSON array for multiple group', async () => {
169
+ const { seedAll } = require('../core/seeder');
170
+ const result = await seedAll(groupSeedCore);
171
+ assert.strictEqual(result.summary.Service, 3);
172
+
173
+ const rows = dbModule.findAll('service', {}, { perPage: 100 });
174
+ assert.strictEqual(rows.total, 3);
175
+
176
+ for (const row of rows.data) {
177
+ assert.ok(typeof row.testimonials === 'string', 'testimonials should be stored as string');
178
+ const items = JSON.parse(row.testimonials);
179
+ assert.ok(Array.isArray(items), 'testimonials should parse to array');
180
+ assert.ok(items.length >= 1, 'testimonials should have at least one item');
181
+ assert.ok('author' in items[0], 'each item should have author');
182
+ assert.ok('rating' in items[0], 'each item should have rating');
183
+
184
+ assert.ok(typeof row.callToAction === 'string', 'callToAction should be stored as string');
185
+ const cta = JSON.parse(row.callToAction);
186
+ assert.ok(cta && typeof cta === 'object' && !Array.isArray(cta), 'callToAction should parse to object');
187
+ assert.ok('title' in cta, 'callToAction should have title');
188
+ }
189
+ });
190
+
191
+ it('seeder: group with no matching group definition stores empty JSON array', async () => {
192
+ const { seedAll } = require('../core/seeder');
193
+ const coreNoGroupDef = buildCore({
194
+ name: 'NoGroupDef',
195
+ entities: {
196
+ Item: {
197
+ properties: [{ name: 'stuff', type: 'group', options: { group: 'Missing' } }],
198
+ seedCount: 1,
199
+ },
200
+ },
201
+ groups: {},
202
+ });
203
+ const tmpNoGroup = path.join(os.tmpdir(), `chadstart-nogrp-${Date.now()}.db`);
204
+ dbModule.initDb(coreNoGroupDef, tmpNoGroup);
205
+ const result = await seedAll(coreNoGroupDef);
206
+ assert.strictEqual(result.summary.Item, 1);
207
+ const rows = dbModule.findAll('item', {}, { perPage: 10 });
208
+ assert.ok(typeof rows.data[0].stuff === 'string');
209
+ assert.deepStrictEqual(JSON.parse(rows.data[0].stuff), []);
210
+ fs.unlinkSync(tmpNoGroup);
211
+ });
212
+ });