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.
- package/.dockerignore +10 -0
- package/.env.example +46 -0
- package/.github/workflows/browser-test.yml +34 -0
- package/.github/workflows/docker-publish.yml +54 -0
- package/.github/workflows/docs.yml +31 -0
- package/.github/workflows/npm-chadstart.yml +27 -0
- package/.github/workflows/npm-sdk.yml +38 -0
- package/.github/workflows/test.yml +85 -0
- package/.weblate +9 -0
- package/Dockerfile +23 -0
- package/README.md +348 -0
- package/admin/index.html +2802 -0
- package/admin/login.html +207 -0
- package/chadstart.example.yml +416 -0
- package/chadstart.schema.json +367 -0
- package/chadstart.yaml +53 -0
- package/cli/cli.js +295 -0
- package/core/api-generator.js +606 -0
- package/core/auth.js +298 -0
- package/core/db.js +384 -0
- package/core/entity-engine.js +166 -0
- package/core/error-reporter.js +132 -0
- package/core/file-storage.js +97 -0
- package/core/functions-engine.js +353 -0
- package/core/openapi.js +171 -0
- package/core/plugin-loader.js +92 -0
- package/core/realtime.js +93 -0
- package/core/schema-validator.js +50 -0
- package/core/seeder.js +231 -0
- package/core/telemetry.js +119 -0
- package/core/upload.js +372 -0
- package/core/workers/php_worker.php +19 -0
- package/core/workers/python_worker.py +33 -0
- package/core/workers/ruby_worker.rb +21 -0
- package/core/yaml-loader.js +64 -0
- package/demo/chadstart.yaml +178 -0
- package/demo/docker-compose.yml +31 -0
- package/demo/functions/greet.go +39 -0
- package/demo/functions/hello.cpp +18 -0
- package/demo/functions/hello.py +13 -0
- package/demo/functions/hello.rb +10 -0
- package/demo/functions/onTodoCreated.js +13 -0
- package/demo/functions/ping.sh +13 -0
- package/demo/functions/stats.js +22 -0
- package/demo/public/index.html +522 -0
- package/docker-compose.yml +17 -0
- package/docs/access-policies.md +155 -0
- package/docs/admin-ui.md +29 -0
- package/docs/angular.md +69 -0
- package/docs/astro.md +71 -0
- package/docs/auth.md +160 -0
- package/docs/cli.md +56 -0
- package/docs/config.md +127 -0
- package/docs/crud.md +627 -0
- package/docs/deploy.md +113 -0
- package/docs/docker.md +59 -0
- package/docs/entities.md +385 -0
- package/docs/functions.md +196 -0
- package/docs/getting-started.md +79 -0
- package/docs/groups.md +85 -0
- package/docs/index.md +5 -0
- package/docs/llm-rules.md +81 -0
- package/docs/middlewares.md +78 -0
- package/docs/overrides/home.html +350 -0
- package/docs/plugins.md +59 -0
- package/docs/react.md +75 -0
- package/docs/realtime.md +43 -0
- package/docs/s3-storage.md +40 -0
- package/docs/security.md +23 -0
- package/docs/stylesheets/extra.css +375 -0
- package/docs/svelte.md +71 -0
- package/docs/telemetry.md +97 -0
- package/docs/upload.md +168 -0
- package/docs/validation.md +115 -0
- package/docs/vue.md +86 -0
- package/docs/webhooks.md +87 -0
- package/index.js +11 -0
- package/locales/en/admin.json +169 -0
- package/mkdocs.yml +82 -0
- package/package.json +65 -0
- package/playwright.config.js +24 -0
- package/public/.gitkeep +0 -0
- package/sdk/README.md +284 -0
- package/sdk/package.json +39 -0
- package/sdk/scripts/build.js +58 -0
- package/sdk/src/index.js +368 -0
- package/sdk/test/sdk.test.cjs +340 -0
- package/sdk/types/index.d.ts +217 -0
- package/server/express-server.js +734 -0
- package/test/access-policies.test.js +96 -0
- package/test/ai.test.js +81 -0
- package/test/api-keys.test.js +361 -0
- package/test/auth.test.js +122 -0
- package/test/browser/admin-ui.spec.js +127 -0
- package/test/browser/global-setup.js +71 -0
- package/test/browser/global-teardown.js +11 -0
- package/test/db.test.js +227 -0
- package/test/entity-engine.test.js +193 -0
- package/test/error-reporter.test.js +140 -0
- package/test/functions-engine.test.js +240 -0
- package/test/groups.test.js +212 -0
- package/test/hot-reload.test.js +153 -0
- package/test/i18n.test.js +173 -0
- package/test/middleware.test.js +76 -0
- package/test/openapi.test.js +67 -0
- package/test/schema-validator.test.js +83 -0
- package/test/sdk.test.js +90 -0
- package/test/seeder.test.js +279 -0
- package/test/settings.test.js +109 -0
- package/test/telemetry.test.js +254 -0
- package/test/test.js +17 -0
- package/test/upload.test.js +265 -0
- package/test/validation.test.js +96 -0
- package/test/yaml-loader.test.js +93 -0
- package/utils/logger.js +24 -0
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Browser tests for the Admin UI navigation.
|
|
6
|
+
* Validates that switching between Dashboard, API Keys, Config Editor and an
|
|
7
|
+
* entity shows exactly the right panel and hides all others.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
const { test, expect } = require('@playwright/test');
|
|
11
|
+
const fs = require('fs');
|
|
12
|
+
|
|
13
|
+
// ── Helpers ──────────────────────────────────────────────────────────────────
|
|
14
|
+
|
|
15
|
+
/** IDs of every top-level content area in the admin UI. */
|
|
16
|
+
const AREAS = ['dashboard-area', 'table-view', 'api-keys-area', 'config-area'];
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Assert that only `visibleId` is shown; all other areas must be hidden.
|
|
20
|
+
* @param {import('@playwright/test').Page} page
|
|
21
|
+
* @param {string} visibleId
|
|
22
|
+
*/
|
|
23
|
+
async function assertOnlyAreaVisible(page, visibleId) {
|
|
24
|
+
for (const id of AREAS) {
|
|
25
|
+
const el = page.locator(`#${id}`);
|
|
26
|
+
if (id === visibleId) {
|
|
27
|
+
await expect(el, `#${id} should be visible`).not.toHaveClass(/hidden/);
|
|
28
|
+
} else {
|
|
29
|
+
await expect(el, `#${id} should be hidden`).toHaveClass(/hidden/);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// ── Login fixture ─────────────────────────────────────────────────────────────
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Log in to the admin UI and return once the dashboard is visible.
|
|
38
|
+
* @param {import('@playwright/test').Page} page
|
|
39
|
+
* @param {{ email: string, password: string, collectionName: string }} creds
|
|
40
|
+
*/
|
|
41
|
+
async function loginToAdmin(page, creds) {
|
|
42
|
+
// The collection input is in a hidden section by default; reveal it first
|
|
43
|
+
const collectionSection = page.locator('#login-collection-section');
|
|
44
|
+
if (await collectionSection.isHidden()) {
|
|
45
|
+
await page.click('#toggle-collection-btn');
|
|
46
|
+
}
|
|
47
|
+
await page.fill('#login-collection', creds.collectionName);
|
|
48
|
+
await page.fill('#login-email', creds.email);
|
|
49
|
+
await page.fill('#login-password', creds.password);
|
|
50
|
+
await page.click('#login-btn');
|
|
51
|
+
// Wait for the login overlay to disappear
|
|
52
|
+
await expect(page.locator('#login-overlay')).toBeHidden({ timeout: 10000 });
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// ── Tests ─────────────────────────────────────────────────────────────────────
|
|
56
|
+
|
|
57
|
+
test.describe('Admin UI – navigation area switching', () => {
|
|
58
|
+
let creds;
|
|
59
|
+
|
|
60
|
+
test.beforeAll(() => {
|
|
61
|
+
const stateFile = process.env.TEST_STATE_FILE;
|
|
62
|
+
if (!stateFile || !fs.existsSync(stateFile)) {
|
|
63
|
+
throw new Error('TEST_STATE_FILE not set or missing – was global setup run?');
|
|
64
|
+
}
|
|
65
|
+
creds = JSON.parse(fs.readFileSync(stateFile, 'utf8'));
|
|
66
|
+
// Override BASE_URL from state so we target the correct port
|
|
67
|
+
process.env.TEST_BASE_URL = `http://localhost:${creds.port}`;
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test.beforeEach(async ({ page }) => {
|
|
71
|
+
const baseUrl = `http://localhost:${creds.port}`;
|
|
72
|
+
await page.goto(`${baseUrl}/admin`);
|
|
73
|
+
await loginToAdmin(page, creds);
|
|
74
|
+
// The dashboard is selected by default after login
|
|
75
|
+
await expect(page.locator('#dashboard-area')).not.toHaveClass(/hidden/, { timeout: 10000 });
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
test('Dashboard shows only dashboard-area', async ({ page }) => {
|
|
79
|
+
// Already on dashboard after login; verify it explicitly
|
|
80
|
+
await page.click('#nav-dashboard-item');
|
|
81
|
+
await assertOnlyAreaVisible(page, 'dashboard-area');
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
test('API Keys shows only api-keys-area', async ({ page }) => {
|
|
85
|
+
await page.click('#nav-apikeys-item');
|
|
86
|
+
await expect(page.locator('#api-keys-area')).not.toHaveClass(/hidden/);
|
|
87
|
+
await assertOnlyAreaVisible(page, 'api-keys-area');
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
test('Config Editor shows only config-area', async ({ page }) => {
|
|
91
|
+
await page.click('#nav-config-item');
|
|
92
|
+
await expect(page.locator('#config-area')).not.toHaveClass(/hidden/);
|
|
93
|
+
await assertOnlyAreaVisible(page, 'config-area');
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
test('Clicking an entity shows only table-view', async ({ page }) => {
|
|
97
|
+
// Wait for the sidebar to be populated with entities
|
|
98
|
+
await expect(page.locator('#nav-entities')).not.toBeEmpty({ timeout: 5000 });
|
|
99
|
+
const firstEntity = page.locator('#nav-entities .nav-item').first();
|
|
100
|
+
await expect(firstEntity).toBeVisible();
|
|
101
|
+
await firstEntity.click();
|
|
102
|
+
await expect(page.locator('#table-view')).not.toHaveClass(/hidden/);
|
|
103
|
+
await assertOnlyAreaVisible(page, 'table-view');
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
test('Switching from entity to API Keys hides table-view', async ({ page }) => {
|
|
107
|
+
// Navigate to an entity first
|
|
108
|
+
await expect(page.locator('#nav-entities')).not.toBeEmpty({ timeout: 5000 });
|
|
109
|
+
await page.locator('#nav-entities .nav-item').first().click();
|
|
110
|
+
await expect(page.locator('#table-view')).not.toHaveClass(/hidden/);
|
|
111
|
+
|
|
112
|
+
// Now switch to API Keys
|
|
113
|
+
await page.click('#nav-apikeys-item');
|
|
114
|
+
await assertOnlyAreaVisible(page, 'api-keys-area');
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
test('Switching from API Keys back to an entity shows table-view', async ({ page }) => {
|
|
118
|
+
// Go to API Keys
|
|
119
|
+
await page.click('#nav-apikeys-item');
|
|
120
|
+
await assertOnlyAreaVisible(page, 'api-keys-area');
|
|
121
|
+
|
|
122
|
+
// Go back to an entity
|
|
123
|
+
await expect(page.locator('#nav-entities')).not.toBeEmpty({ timeout: 5000 });
|
|
124
|
+
await page.locator('#nav-entities .nav-item').first().click();
|
|
125
|
+
await assertOnlyAreaVisible(page, 'table-view');
|
|
126
|
+
});
|
|
127
|
+
});
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Playwright global setup: starts a chadstart server on a random port,
|
|
5
|
+
* signs up a test admin user, and writes the server URL + credentials
|
|
6
|
+
* to environment so tests can use them.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
const path = require('path');
|
|
10
|
+
const fs = require('fs');
|
|
11
|
+
const os = require('os');
|
|
12
|
+
const http = require('http');
|
|
13
|
+
|
|
14
|
+
const TMP_DIR = fs.mkdtempSync(path.join(os.tmpdir(), 'cs-browser-test-'));
|
|
15
|
+
const DB_PATH = path.join(TMP_DIR, 'test.db');
|
|
16
|
+
const STATE_FILE = path.join(TMP_DIR, 'server-state.json');
|
|
17
|
+
|
|
18
|
+
async function httpPost(port, urlPath, body) {
|
|
19
|
+
const data = JSON.stringify(body);
|
|
20
|
+
return new Promise((resolve, reject) => {
|
|
21
|
+
const req = http.request({
|
|
22
|
+
hostname: 'localhost', port, path: urlPath, method: 'POST',
|
|
23
|
+
headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(data) },
|
|
24
|
+
}, (res) => {
|
|
25
|
+
let buf = '';
|
|
26
|
+
res.on('data', (c) => { buf += c; });
|
|
27
|
+
res.on('end', () => { try { resolve(JSON.parse(buf)); } catch { resolve(buf); } });
|
|
28
|
+
});
|
|
29
|
+
req.on('error', reject);
|
|
30
|
+
req.write(data);
|
|
31
|
+
req.end();
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
module.exports = async function globalSetup() {
|
|
36
|
+
process.env.DB_PATH = DB_PATH;
|
|
37
|
+
|
|
38
|
+
const { buildApp } = require('../../server/express-server');
|
|
39
|
+
const YAML_PATH = path.resolve(__dirname, '../../chadstart.yaml');
|
|
40
|
+
|
|
41
|
+
const { app } = await buildApp(YAML_PATH, null);
|
|
42
|
+
const server = http.createServer(app);
|
|
43
|
+
await new Promise((resolve) => server.listen(0, resolve));
|
|
44
|
+
const port = server.address().port;
|
|
45
|
+
|
|
46
|
+
// Sign up a test admin user
|
|
47
|
+
const result = await httpPost(port, '/api/auth/admin/signup', {
|
|
48
|
+
email: 'admin@test.com',
|
|
49
|
+
password: 'testpass123',
|
|
50
|
+
name: 'Test Admin',
|
|
51
|
+
});
|
|
52
|
+
if (!result.token) {
|
|
53
|
+
throw new Error('Failed to create test admin user: ' + JSON.stringify(result));
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Persist state for teardown and tests
|
|
57
|
+
fs.writeFileSync(STATE_FILE, JSON.stringify({
|
|
58
|
+
port,
|
|
59
|
+
dbPath: DB_PATH,
|
|
60
|
+
tmpDir: TMP_DIR,
|
|
61
|
+
email: 'admin@test.com',
|
|
62
|
+
password: 'testpass123',
|
|
63
|
+
collectionName: 'Admin',
|
|
64
|
+
}));
|
|
65
|
+
|
|
66
|
+
process.env.TEST_BASE_URL = `http://localhost:${port}`;
|
|
67
|
+
process.env.TEST_STATE_FILE = STATE_FILE;
|
|
68
|
+
|
|
69
|
+
// Attach server to global so teardown can close it
|
|
70
|
+
global.__TEST_SERVER__ = server;
|
|
71
|
+
};
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Playwright global teardown: closes the test server started in global-setup.js.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
module.exports = async function globalTeardown() {
|
|
8
|
+
if (global.__TEST_SERVER__) {
|
|
9
|
+
await new Promise((resolve) => global.__TEST_SERVER__.close(resolve));
|
|
10
|
+
}
|
|
11
|
+
};
|
package/test/db.test.js
ADDED
|
@@ -0,0 +1,227 @@
|
|
|
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
|
+
|
|
10
|
+
describe('db', () => {
|
|
11
|
+
let tmpDb;
|
|
12
|
+
const testCore = buildCore({ name: 'T', entities: { Widget: { properties: ['name', 'color'] } } });
|
|
13
|
+
|
|
14
|
+
before(() => {
|
|
15
|
+
tmpDb = path.join(os.tmpdir(), `chadstart-test-${Date.now()}.db`);
|
|
16
|
+
dbModule.initDb(testCore, tmpDb);
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
after(() => { fs.unlinkSync(tmpDb); });
|
|
20
|
+
|
|
21
|
+
it('initDb creates database file', () => { assert.ok(fs.existsSync(tmpDb)); });
|
|
22
|
+
it('create inserts a row', () => { const r = dbModule.create('widget', { name: 'Foo', color: 'red' }); assert.strictEqual(r.name, 'Foo'); assert.ok(typeof r.id === 'string' && r.id.length > 0); assert.ok(r.createdAt); assert.ok(r.updatedAt); });
|
|
23
|
+
it('findAll returns paginated result', () => { const result = dbModule.findAll('widget'); assert.ok(result.data.length >= 1); assert.ok(typeof result.total === 'number'); assert.ok(typeof result.currentPage === 'number'); });
|
|
24
|
+
it('findById works', () => { const c = dbModule.create('widget', { name: 'Bar', color: 'blue' }); assert.strictEqual(dbModule.findById('widget', c.id).name, 'Bar'); });
|
|
25
|
+
it('findById returns null for missing', () => assert.strictEqual(dbModule.findById('widget', 'nonexistent-id'), null));
|
|
26
|
+
it('update modifies row', () => { const c = dbModule.create('widget', { name: 'Baz', color: 'green' }); assert.strictEqual(dbModule.update('widget', c.id, { color: 'yellow' }).color, 'yellow'); });
|
|
27
|
+
it('remove deletes row', () => { const c = dbModule.create('widget', { name: 'Del', color: 'gray' }); dbModule.remove('widget', c.id); assert.strictEqual(dbModule.findById('widget', c.id), null); });
|
|
28
|
+
it('remove returns null for missing', () => assert.strictEqual(dbModule.remove('widget', 'nonexistent-id'), null));
|
|
29
|
+
it('findAll with filters', () => { dbModule.create('widget', { name: 'R1', color: 'red' }); const result = dbModule.findAll('widget', { color: 'red' }); assert.ok(result.data.every((r) => r.color === 'red')); });
|
|
30
|
+
it('findAll with filter suffixes', () => { dbModule.create('widget', { name: 'FilterTest', color: 'green' }); const result = dbModule.findAll('widget', { color_neq: 'red' }); assert.ok(result.data.some((r) => r.color !== 'red')); });
|
|
31
|
+
it('findAll with ordering', () => { const result = dbModule.findAll('widget', {}, { orderBy: 'name', order: 'ASC' }); assert.ok(result.data.length >= 1); });
|
|
32
|
+
it('findAll with pagination', () => { const result = dbModule.findAll('widget', {}, { page: 1, perPage: 2 }); assert.ok(result.perPage === 2); assert.ok(result.currentPage === 1); });
|
|
33
|
+
it('findAllSimple returns raw array', () => { const rows = dbModule.findAllSimple('widget'); assert.ok(Array.isArray(rows)); });
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
describe('db – authenticable entities', () => {
|
|
37
|
+
let tmp;
|
|
38
|
+
|
|
39
|
+
before(() => {
|
|
40
|
+
tmp = path.join(os.tmpdir(), `chadstart-auth-${Date.now()}.db`);
|
|
41
|
+
const core = buildCore({ name: 'T', entities: { Admin: { authenticable: true, properties: ['name'] } } });
|
|
42
|
+
dbModule.initDb(core, tmp);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
after(() => { fs.unlinkSync(tmp); });
|
|
46
|
+
|
|
47
|
+
it('authenticable entity has email + password columns', () => {
|
|
48
|
+
const cols = dbModule.getDb().pragma('table_info("admin")').map((r) => r.name);
|
|
49
|
+
assert.ok(cols.includes('email') && cols.includes('password') && cols.includes('name'));
|
|
50
|
+
});
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
describe('db – belongsToMany junction tables', () => {
|
|
54
|
+
let tmp;
|
|
55
|
+
|
|
56
|
+
before(() => {
|
|
57
|
+
tmp = path.join(os.tmpdir(), `chadstart-btm-${Date.now()}.db`);
|
|
58
|
+
const core = buildCore({ name: 'T', entities: { Player: { properties: ['n'], belongsToMany: ['Skill'] }, Skill: { properties: ['n'] } } });
|
|
59
|
+
dbModule.initDb(core, tmp);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
after(() => { fs.unlinkSync(tmp); });
|
|
63
|
+
|
|
64
|
+
it('creates junction table', () => {
|
|
65
|
+
const tables = dbModule.getDb().prepare("SELECT name FROM sqlite_master WHERE type='table'").all().map((t) => t.name);
|
|
66
|
+
assert.ok(tables.some((t) => t.includes('player') && t.includes('skill')));
|
|
67
|
+
});
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
describe('db – advanced filters', () => {
|
|
71
|
+
let tmp;
|
|
72
|
+
|
|
73
|
+
before(() => {
|
|
74
|
+
tmp = path.join(os.tmpdir(), `chadstart-advfilter-${Date.now()}.db`);
|
|
75
|
+
const core = buildCore({
|
|
76
|
+
name: 'T',
|
|
77
|
+
entities: { Score: { properties: [{ name: 'value', type: 'integer' }, { name: 'tag', type: 'string' }] } },
|
|
78
|
+
});
|
|
79
|
+
dbModule.initDb(core, tmp);
|
|
80
|
+
dbModule.create('score', { value: 10, tag: 'alpha' });
|
|
81
|
+
dbModule.create('score', { value: 20, tag: 'bravo' });
|
|
82
|
+
dbModule.create('score', { value: 30, tag: 'charlie' });
|
|
83
|
+
dbModule.create('score', { value: 40, tag: 'delta' });
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
after(() => { fs.unlinkSync(tmp); });
|
|
87
|
+
|
|
88
|
+
it('_eq filter returns exact match', () => {
|
|
89
|
+
const result = dbModule.findAll('score', { tag_eq: 'alpha' });
|
|
90
|
+
assert.ok(result.data.every((r) => r.tag === 'alpha'));
|
|
91
|
+
assert.strictEqual(result.data.length, 1);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it('_gt filter returns rows greater than value', () => {
|
|
95
|
+
const result = dbModule.findAll('score', { value_gt: '15' });
|
|
96
|
+
assert.ok(result.data.every((r) => r.value > 15));
|
|
97
|
+
assert.strictEqual(result.data.length, 3);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it('_gte filter returns rows >= value', () => {
|
|
101
|
+
const result = dbModule.findAll('score', { value_gte: '20' });
|
|
102
|
+
assert.ok(result.data.every((r) => r.value >= 20));
|
|
103
|
+
assert.strictEqual(result.data.length, 3);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it('_lt filter returns rows below value', () => {
|
|
107
|
+
const result = dbModule.findAll('score', { value_lt: '25' });
|
|
108
|
+
assert.ok(result.data.every((r) => r.value < 25));
|
|
109
|
+
assert.strictEqual(result.data.length, 2);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it('_lte filter returns rows <= value', () => {
|
|
113
|
+
const result = dbModule.findAll('score', { value_lte: '20' });
|
|
114
|
+
assert.ok(result.data.every((r) => r.value <= 20));
|
|
115
|
+
assert.strictEqual(result.data.length, 2);
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it('_like filter matches pattern', () => {
|
|
119
|
+
const result = dbModule.findAll('score', { tag_like: '%lph%' });
|
|
120
|
+
assert.ok(result.data.every((r) => r.tag.includes('lph')));
|
|
121
|
+
assert.strictEqual(result.data.length, 1);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it('_in filter returns rows matching any listed value', () => {
|
|
125
|
+
const result = dbModule.findAll('score', { tag_in: 'alpha,bravo' });
|
|
126
|
+
assert.ok(result.data.every((r) => r.tag === 'alpha' || r.tag === 'bravo'));
|
|
127
|
+
assert.strictEqual(result.data.length, 2);
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it('findAllSimple with filter returns matching rows', () => {
|
|
131
|
+
const rows = dbModule.findAllSimple('score', { tag: 'alpha' });
|
|
132
|
+
assert.ok(rows.every((r) => r.tag === 'alpha'));
|
|
133
|
+
assert.strictEqual(rows.length, 1);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it('findAllSimple with unknown filter key returns all rows', () => {
|
|
137
|
+
const rows = dbModule.findAllSimple('score', { nonexistent_col: 'xyz' });
|
|
138
|
+
assert.ok(Array.isArray(rows));
|
|
139
|
+
assert.ok(rows.length >= 4);
|
|
140
|
+
});
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
describe('db – relations', () => {
|
|
144
|
+
let tmp, post, commentNoPost, comment1, player, skill1, skill2;
|
|
145
|
+
let core;
|
|
146
|
+
|
|
147
|
+
before(() => {
|
|
148
|
+
tmp = path.join(os.tmpdir(), `chadstart-rel-${Date.now()}.db`);
|
|
149
|
+
core = buildCore({
|
|
150
|
+
name: 'T',
|
|
151
|
+
entities: {
|
|
152
|
+
Post: { properties: ['title'] },
|
|
153
|
+
Comment: { properties: ['body'], belongsTo: ['Post'] },
|
|
154
|
+
Player: { properties: ['name'], belongsToMany: ['Skill'] },
|
|
155
|
+
Skill: { properties: ['label'] },
|
|
156
|
+
},
|
|
157
|
+
});
|
|
158
|
+
dbModule.initDb(core, tmp);
|
|
159
|
+
|
|
160
|
+
post = dbModule.create('post', { title: 'Hello World' });
|
|
161
|
+
dbModule.create('comment', { body: 'Great!', post_id: post.id });
|
|
162
|
+
dbModule.create('comment', { body: 'Thanks', post_id: post.id });
|
|
163
|
+
commentNoPost = dbModule.create('comment', { body: 'Orphan', post_id: null });
|
|
164
|
+
comment1 = dbModule.create('comment', { body: 'Reply', post_id: post.id });
|
|
165
|
+
player = dbModule.create('player', { name: 'Alice' });
|
|
166
|
+
skill1 = dbModule.create('skill', { label: 'Jump' });
|
|
167
|
+
skill2 = dbModule.create('skill', { label: 'Swim' });
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
after(() => { fs.unlinkSync(tmp); });
|
|
171
|
+
|
|
172
|
+
it('loadRelations: noop when row is null', () => {
|
|
173
|
+
const result = dbModule.loadRelations(null, core.entities.Comment, 'Post');
|
|
174
|
+
assert.strictEqual(result, null);
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
it('loadRelations: belongsTo resolves related row', () => {
|
|
178
|
+
const row = { ...comment1 };
|
|
179
|
+
dbModule.loadRelations(row, core.entities.Comment, 'Post');
|
|
180
|
+
assert.ok(row.Post, 'related row should be attached');
|
|
181
|
+
assert.strictEqual(row.Post.id, post.id);
|
|
182
|
+
assert.strictEqual(row.Post.title, 'Hello World');
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
it('loadRelations: belongsTo with null FK sets null', () => {
|
|
186
|
+
const row = { ...commentNoPost };
|
|
187
|
+
dbModule.loadRelations(row, core.entities.Comment, 'Post');
|
|
188
|
+
assert.strictEqual(row.Post, null);
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
it('loadRelations: hasMany (reverse) resolves children', () => {
|
|
192
|
+
const row = { ...post };
|
|
193
|
+
dbModule.loadRelations(row, core.entities.Post, 'comment');
|
|
194
|
+
assert.ok(Array.isArray(row.comment));
|
|
195
|
+
assert.ok(row.comment.length >= 3);
|
|
196
|
+
assert.ok(row.comment.every((c) => c.post_id === post.id));
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
it('loadRelations: comma-separated names loads multiple relations', () => {
|
|
200
|
+
const row = { ...comment1 };
|
|
201
|
+
dbModule.loadRelations(row, core.entities.Comment, 'Post,nonexistent');
|
|
202
|
+
assert.ok(row.Post, 'Post relation should be loaded');
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
it('saveBelongsToMany: saves junction rows and loadRelations retrieves them', () => {
|
|
206
|
+
dbModule.saveBelongsToMany(core.entities.Player, player.id, { skillIds: [skill1.id, skill2.id] });
|
|
207
|
+
const row = { ...player };
|
|
208
|
+
dbModule.loadRelations(row, core.entities.Player, 'Skill');
|
|
209
|
+
assert.ok(Array.isArray(row.Skill));
|
|
210
|
+
assert.strictEqual(row.Skill.length, 2);
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
it('saveBelongsToMany: clears and replaces existing junction rows', () => {
|
|
214
|
+
dbModule.saveBelongsToMany(core.entities.Player, player.id, { skillIds: [skill1.id] });
|
|
215
|
+
const row = { ...player };
|
|
216
|
+
dbModule.loadRelations(row, core.entities.Player, 'Skill');
|
|
217
|
+
assert.strictEqual(row.Skill.length, 1);
|
|
218
|
+
assert.strictEqual(row.Skill[0].id, skill1.id);
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
it('saveBelongsToMany: skips when no ids key in body', () => {
|
|
222
|
+
dbModule.saveBelongsToMany(core.entities.Player, player.id, {});
|
|
223
|
+
const row = { ...player };
|
|
224
|
+
dbModule.loadRelations(row, core.entities.Player, 'Skill');
|
|
225
|
+
assert.strictEqual(row.Skill.length, 1);
|
|
226
|
+
});
|
|
227
|
+
});
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const assert = require('assert');
|
|
4
|
+
const { buildCore, toSnakeCase, toKebabCase } = require('../core/entity-engine');
|
|
5
|
+
|
|
6
|
+
describe('entity-engine', () => {
|
|
7
|
+
it('toSnakeCase converts PascalCase', () => assert.strictEqual(toSnakeCase('BlogPost'), 'blog_post'));
|
|
8
|
+
it('toSnakeCase leaves lowercase', () => assert.strictEqual(toSnakeCase('post'), 'post'));
|
|
9
|
+
it('toKebabCase converts PascalCase', () => assert.strictEqual(toKebabCase('BlogPost'), 'blog-post'));
|
|
10
|
+
|
|
11
|
+
it('buildCore populates entities', () => {
|
|
12
|
+
const core = buildCore({ name: 'Blog', entities: { Post: { properties: ['title', 'content'] } } });
|
|
13
|
+
assert.ok(core.entities.Post);
|
|
14
|
+
assert.strictEqual(core.entities.Post.tableName, 'post');
|
|
15
|
+
assert.deepStrictEqual(core.entities.Post.properties.map((p) => p.name), ['title', 'content']);
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it('buildCore normalizes object properties', () => {
|
|
19
|
+
const core = buildCore({ name: 'App', entities: { Item: { properties: [{ name: 'price', type: 'number' }] } } });
|
|
20
|
+
assert.strictEqual(core.entities.Item.properties[0].type, 'number');
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it('buildCore sets default port', () => assert.ok(typeof buildCore({ name: 'App' }).port === 'number'));
|
|
24
|
+
|
|
25
|
+
it('buildCore handles authenticable entities', () => {
|
|
26
|
+
const core = buildCore({ name: 'App', entities: { Admin: { authenticable: true, properties: ['name'] }, Post: { properties: ['t'] } } });
|
|
27
|
+
assert.ok(core.entities.Admin.authenticable);
|
|
28
|
+
assert.ok(core.authenticableEntities.Admin);
|
|
29
|
+
assert.ok(!core.authenticableEntities.Post);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it('buildCore handles policies with emoji', () => {
|
|
33
|
+
const core = buildCore({ name: 'App', entities: { Post: { properties: ['t'], policies: { read: [{ access: '🌐' }], delete: [{ access: '🚫' }] } } } });
|
|
34
|
+
assert.strictEqual(core.entities.Post.policies.read[0].access, 'public');
|
|
35
|
+
assert.strictEqual(core.entities.Post.policies.delete[0].access, 'forbidden');
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it('buildCore normalizes belongsTo', () => {
|
|
39
|
+
const core = buildCore({ name: 'App', entities: { Comment: { properties: ['text'], belongsTo: ['Post'] }, Post: { properties: ['t'] } } });
|
|
40
|
+
assert.strictEqual(core.entities.Comment.belongsTo[0].entity, 'Post');
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it('buildCore handles belongsToMany', () => {
|
|
44
|
+
const core = buildCore({ name: 'App', entities: { Player: { properties: ['n'], belongsToMany: ['Skill'] }, Skill: { properties: ['n'] } } });
|
|
45
|
+
assert.strictEqual(core.entities.Player.belongsToMany[0].entity, 'Skill');
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it('buildCore handles singles, validation, hooks, functions, groups', () => {
|
|
49
|
+
const core = buildCore({
|
|
50
|
+
name: 'App',
|
|
51
|
+
entities: { Home: { single: true, properties: ['t'], validation: { t: { minLength: 3 } }, hooks: { beforeCreate: [{ url: 'https://x.com' }] } } },
|
|
52
|
+
functions: { hi: { path: '/hi', method: 'GET', function: 'hi.js' } },
|
|
53
|
+
groups: { G: { properties: [{ name: 'a', type: 'string' }] } },
|
|
54
|
+
});
|
|
55
|
+
assert.ok(core.entities.Home.single);
|
|
56
|
+
assert.strictEqual(core.entities.Home.validation.t.minLength, 3);
|
|
57
|
+
assert.strictEqual(core.entities.Home.hooks.beforeCreate[0].url, 'https://x.com');
|
|
58
|
+
assert.ok(core.functions.hi);
|
|
59
|
+
assert.ok(core.groups.G);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it('buildCore passes nameSingular and namePlural', () => {
|
|
63
|
+
const core = buildCore({
|
|
64
|
+
name: 'App',
|
|
65
|
+
entities: { Person: { nameSingular: 'person', namePlural: 'people', properties: ['name'] } },
|
|
66
|
+
});
|
|
67
|
+
assert.strictEqual(core.entities.Person.nameSingular, 'person');
|
|
68
|
+
assert.strictEqual(core.entities.Person.namePlural, 'people');
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it('inline validation merges and inline prevails on conflict', () => {
|
|
72
|
+
const base = buildCore({
|
|
73
|
+
name: 'App',
|
|
74
|
+
entities: {
|
|
75
|
+
Dog: {
|
|
76
|
+
properties: [{ name: 'name', type: 'string', validation: { minLength: 3 } }, { name: 'age', type: 'number' }],
|
|
77
|
+
validation: { age: { min: 1 } },
|
|
78
|
+
},
|
|
79
|
+
},
|
|
80
|
+
});
|
|
81
|
+
assert.strictEqual(base.entities.Dog.validation.name.minLength, 3);
|
|
82
|
+
assert.strictEqual(base.entities.Dog.validation.age.min, 1);
|
|
83
|
+
|
|
84
|
+
const conflict = buildCore({
|
|
85
|
+
name: 'App',
|
|
86
|
+
entities: {
|
|
87
|
+
Dog: {
|
|
88
|
+
properties: [{ name: 'name', type: 'string', validation: { minLength: 5 } }],
|
|
89
|
+
validation: { name: { minLength: 3, maxLength: 100 } },
|
|
90
|
+
},
|
|
91
|
+
},
|
|
92
|
+
});
|
|
93
|
+
assert.strictEqual(conflict.entities.Dog.validation.name.minLength, 5, 'inline prevails');
|
|
94
|
+
assert.strictEqual(conflict.entities.Dog.validation.name.maxLength, 100, 'block-only key preserved');
|
|
95
|
+
});
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
describe('entity-engine – branches', () => {
|
|
99
|
+
it('normalizeRelation: object with only entity key', () => {
|
|
100
|
+
const core = buildCore({
|
|
101
|
+
name: 'App',
|
|
102
|
+
entities: {
|
|
103
|
+
Post: { properties: ['t'] },
|
|
104
|
+
Comment: { properties: ['b'], belongsTo: [{ entity: 'Post' }] },
|
|
105
|
+
},
|
|
106
|
+
});
|
|
107
|
+
assert.strictEqual(core.entities.Comment.belongsTo[0].entity, 'Post');
|
|
108
|
+
assert.strictEqual(core.entities.Comment.belongsTo[0].name, 'Post');
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it('normalizeRelation: object with only name key', () => {
|
|
112
|
+
const core = buildCore({
|
|
113
|
+
name: 'App',
|
|
114
|
+
entities: {
|
|
115
|
+
Post: { properties: ['t'] },
|
|
116
|
+
Comment: { properties: ['b'], belongsTo: [{ name: 'Post' }] },
|
|
117
|
+
},
|
|
118
|
+
});
|
|
119
|
+
assert.strictEqual(core.entities.Comment.belongsTo[0].entity, 'Post');
|
|
120
|
+
assert.strictEqual(core.entities.Comment.belongsTo[0].name, 'Post');
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it('normalizeProperty: carries hidden, default, options, helpText, validation', () => {
|
|
124
|
+
const core = buildCore({
|
|
125
|
+
name: 'App',
|
|
126
|
+
entities: {
|
|
127
|
+
Item: {
|
|
128
|
+
properties: [{
|
|
129
|
+
name: 'status', type: 'string',
|
|
130
|
+
hidden: true,
|
|
131
|
+
default: 'draft',
|
|
132
|
+
options: ['draft', 'published'],
|
|
133
|
+
helpText: 'Choose a status',
|
|
134
|
+
validation: { isIn: ['draft', 'published'] },
|
|
135
|
+
}],
|
|
136
|
+
},
|
|
137
|
+
},
|
|
138
|
+
});
|
|
139
|
+
const prop = core.entities.Item.properties[0];
|
|
140
|
+
assert.strictEqual(prop.hidden, true);
|
|
141
|
+
assert.strictEqual(prop.default, 'draft');
|
|
142
|
+
assert.deepStrictEqual(prop.options, ['draft', 'published']);
|
|
143
|
+
assert.strictEqual(prop.helpText, 'Choose a status');
|
|
144
|
+
assert.deepStrictEqual(prop.validation, { isIn: ['draft', 'published'] });
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it('buildCore entity with no properties defaults to empty array', () => {
|
|
148
|
+
const core = buildCore({ name: 'App', entities: { Tag: {} } });
|
|
149
|
+
assert.deepStrictEqual(core.entities.Tag.properties, []);
|
|
150
|
+
});
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
describe('entity-engine – default Admin entity', () => {
|
|
154
|
+
it('buildCore includes a default Admin entity when no entities defined', () => {
|
|
155
|
+
const core = buildCore({ name: 'App' });
|
|
156
|
+
assert.ok(core.entities.Admin, 'Default Admin entity should be created');
|
|
157
|
+
assert.ok(core.entities.Admin.authenticable, 'Admin entity should be authenticable');
|
|
158
|
+
assert.strictEqual(core.entities.Admin.slug, 'admin');
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
it('buildCore merges YAML Admin entity with default (authenticable always true)', () => {
|
|
162
|
+
const core = buildCore({ name: 'App', entities: { Admin: { properties: ['role'] } } });
|
|
163
|
+
assert.ok(core.entities.Admin.authenticable, 'Admin should always be authenticable after merge');
|
|
164
|
+
assert.ok(core.entities.Admin.properties.some(p => (typeof p === 'string' ? p : p.name) === 'role'));
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
it('buildCore does not add Admin entity when admin.enable_entity is false', () => {
|
|
168
|
+
const core = buildCore({ name: 'App', admin: { enable_entity: false } });
|
|
169
|
+
assert.strictEqual(core.entities.Admin, undefined);
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
it('buildCore exposes admin config with defaults', () => {
|
|
173
|
+
const core = buildCore({ name: 'App' });
|
|
174
|
+
assert.ok(core.admin);
|
|
175
|
+
assert.strictEqual(core.admin.enable_app, true);
|
|
176
|
+
assert.strictEqual(core.admin.enable_entity, true);
|
|
177
|
+
assert.deepStrictEqual(core.admin.policies, [{ access: 'admin' }]);
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
it('buildCore admin config merges with YAML admin settings', () => {
|
|
181
|
+
const core = buildCore({ name: 'App', admin: { enable_app: false, policies: [{ access: 'public' }] } });
|
|
182
|
+
assert.strictEqual(core.admin.enable_app, false);
|
|
183
|
+
assert.deepStrictEqual(core.admin.policies, [{ access: 'public' }]);
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
it('schema accepts admin property', () => {
|
|
187
|
+
const { validateSchema } = require('../core/schema-validator');
|
|
188
|
+
assert.strictEqual(validateSchema({
|
|
189
|
+
name: 'App',
|
|
190
|
+
admin: { enable_app: true, enable_entity: true, policies: [{ access: 'admin' }] },
|
|
191
|
+
}), true);
|
|
192
|
+
});
|
|
193
|
+
});
|