@quatrain/app 1.1.17 → 1.1.18

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,6 +1,6 @@
1
1
  {
2
2
  "name": "@quatrain/app",
3
- "version": "1.1.17",
3
+ "version": "1.1.18",
4
4
  "license": "AGPL-3.0-only",
5
5
  "description": "Quatrain App Bootloader and configuration helpers",
6
6
  "main": "dist/index.js",
@@ -21,19 +21,19 @@
21
21
  "author": "Quatrain Développement SAS <developers@quatrain.com>",
22
22
  "dependencies": {
23
23
  "@quatrain/api": "^1.1.5",
24
- "@quatrain/api-client": "^1.1.4",
24
+ "@quatrain/api-client": "^1.1.5",
25
25
  "@quatrain/api-server": "^1.1.9",
26
- "@quatrain/api-server-express": "^1.1.10",
27
- "@quatrain/auth": "^1.2.1",
28
- "@quatrain/auth-basic": "^1.0.1",
29
- "@quatrain/auth-oidc": "^1.1.4",
30
- "@quatrain/backend": "^1.2.6",
31
- "@quatrain/backend-migrations": "^1.1.4",
32
- "@quatrain/core": "^1.2.5",
33
- "@quatrain/log": "^1.2.1",
34
- "@quatrain/messaging": "^1.1.2",
35
- "@quatrain/queue": "^1.2.2",
36
- "@quatrain/storage": "^1.2.3",
26
+ "@quatrain/api-server-express": "^1.1.13",
27
+ "@quatrain/auth": "^1.2.3",
28
+ "@quatrain/auth-basic": "^1.0.2",
29
+ "@quatrain/auth-oidc": "^1.1.5",
30
+ "@quatrain/backend": "^1.2.12",
31
+ "@quatrain/backend-migrations": "^1.1.6",
32
+ "@quatrain/core": "^1.2.14",
33
+ "@quatrain/log": "^1.2.3",
34
+ "@quatrain/messaging": "^1.1.4",
35
+ "@quatrain/queue": "^1.2.3",
36
+ "@quatrain/storage": "^1.2.8",
37
37
  "@quatrain/ui-form-react": "^1.1.5",
38
38
  "yaml": "^2.4.0"
39
39
  },
@@ -0,0 +1,173 @@
1
+ import fs from 'node:fs'
2
+ import path from 'node:path'
3
+ import { AppBootloader } from './Bootloader'
4
+ import { Backend } from '@quatrain/backend'
5
+ import { Auth } from '@quatrain/auth'
6
+ import { Queue } from '@quatrain/queue'
7
+ import { Storage } from '@quatrain/storage'
8
+ import { Messaging } from '@quatrain/messaging'
9
+ import { Log } from '@quatrain/log'
10
+
11
+ // Mock dynamic require targets as virtual modules
12
+ jest.mock('mock-backend', () => ({
13
+ MockBackend: jest.fn().mockImplementation(() => ({ name: 'mock-backend' }))
14
+ }), { virtual: true })
15
+
16
+ jest.mock('mock-auth', () => ({
17
+ MockAuth: jest.fn().mockImplementation(() => ({ name: 'mock-auth' }))
18
+ }), { virtual: true })
19
+
20
+ jest.mock('mock-queue', () => ({
21
+ MockQueue: jest.fn().mockImplementation(() => ({ name: 'mock-queue' }))
22
+ }), { virtual: true })
23
+
24
+ jest.mock('mock-storage', () => ({
25
+ MockStorage: jest.fn().mockImplementation(() => ({ name: 'mock-storage' }))
26
+ }), { virtual: true })
27
+
28
+ jest.mock('mock-messaging', () => ({
29
+ MockMessaging: jest.fn().mockImplementation(() => ({ name: 'mock-messaging' }))
30
+ }), { virtual: true })
31
+
32
+ describe('AppBootloader', () => {
33
+ describe('resolveEnv', () => {
34
+ beforeEach(() => {
35
+ process.env.TEST_VAR = 'value1'
36
+ process.env.ANOTHER_VAR = 'value2'
37
+ })
38
+
39
+ afterEach(() => {
40
+ delete process.env.TEST_VAR
41
+ delete process.env.ANOTHER_VAR
42
+ })
43
+
44
+ it('should parse env(VAR) strings to environment variables values', () => {
45
+ const resolved = (AppBootloader as any).resolveEnv('env(TEST_VAR)')
46
+ expect(resolved).toBe('value1')
47
+ })
48
+
49
+ it('should parse raw strings as is', () => {
50
+ const resolved = (AppBootloader as any).resolveEnv('just-a-string')
51
+ expect(resolved).toBe('just-a-string')
52
+ })
53
+
54
+ it('should resolve env markers nested inside arrays', () => {
55
+ const input = ['just-a-string', 'env(TEST_VAR)', 'env(ANOTHER_VAR)']
56
+ const resolved = (AppBootloader as any).resolveEnv(input)
57
+ expect(resolved).toEqual(['just-a-string', 'value1', 'value2'])
58
+ })
59
+
60
+ it('should resolve env markers nested inside objects', () => {
61
+ const input = {
62
+ plain: 'string',
63
+ secret: 'env(TEST_VAR)',
64
+ nested: {
65
+ key: 'env(ANOTHER_VAR)'
66
+ }
67
+ }
68
+ const resolved = (AppBootloader as any).resolveEnv(input)
69
+ expect(resolved).toEqual({
70
+ plain: 'string',
71
+ secret: 'value1',
72
+ nested: {
73
+ key: 'value2'
74
+ }
75
+ })
76
+ })
77
+
78
+ it('should return default empty strings for undefined environment variables', () => {
79
+ const resolved = (AppBootloader as any).resolveEnv('env(UNDEFINED_VAR)')
80
+ expect(resolved).toBe('')
81
+ })
82
+ })
83
+
84
+ describe('bootstrap', () => {
85
+ let existsSpy: jest.SpyInstance
86
+ let readSpy: jest.SpyInstance
87
+
88
+ beforeEach(() => {
89
+ existsSpy = jest.spyOn(fs, 'existsSync').mockReturnValue(true)
90
+ readSpy = jest.spyOn(fs, 'readFileSync').mockReturnValue('{}')
91
+
92
+ // Spy on manager classes to prevent real registrations polluting other test runs
93
+ jest.spyOn(Backend, 'addBackend').mockImplementation(() => {})
94
+ jest.spyOn(Auth, 'addProvider').mockImplementation(() => {})
95
+ jest.spyOn(Queue, 'addQueue').mockImplementation(() => {})
96
+ jest.spyOn(Storage, 'addStorage').mockImplementation(() => {})
97
+ jest.spyOn(Messaging, 'addMessager').mockImplementation(() => {})
98
+ jest.spyOn(Log, 'info').mockImplementation(() => {})
99
+ jest.spyOn(Log, 'error').mockImplementation(() => {})
100
+ })
101
+
102
+ afterEach(() => {
103
+ jest.restoreAllMocks()
104
+ })
105
+
106
+ it('should throw an error if configuration file is not found', async () => {
107
+ existsSpy.mockReturnValue(false)
108
+ await expect(AppBootloader.bootstrap('missing.json')).rejects.toThrow(
109
+ '[Bootloader] Configuration file not found'
110
+ )
111
+ })
112
+
113
+ it('should bootstrap active configured adapters from configuration JSON', async () => {
114
+ const config = {
115
+ logLevel: 'debug',
116
+ backend: {
117
+ package: 'mock-backend',
118
+ adapter: 'MockBackend',
119
+ config: { db: 'test' }
120
+ },
121
+ auth: {
122
+ package: 'mock-auth',
123
+ adapter: 'MockAuth',
124
+ config: {}
125
+ },
126
+ queue: {
127
+ package: 'mock-queue',
128
+ adapter: 'MockQueue',
129
+ config: {}
130
+ },
131
+ storage: {
132
+ package: 'mock-storage',
133
+ adapter: 'MockStorage',
134
+ config: {}
135
+ },
136
+ messaging: {
137
+ package: 'mock-messaging',
138
+ adapter: 'MockMessaging',
139
+ config: {}
140
+ }
141
+ }
142
+
143
+ readSpy.mockReturnValue(JSON.stringify(config))
144
+
145
+ await AppBootloader.bootstrap('quatrain-test.json')
146
+
147
+ expect(Log.info).toHaveBeenCalledWith(expect.stringContaining('[Bootloader] Initializing application'))
148
+ expect(Backend.addBackend).toHaveBeenCalled()
149
+ expect(Auth.addProvider).toHaveBeenCalled()
150
+ expect(Queue.addQueue).toHaveBeenCalled()
151
+ expect(Storage.addStorage).toHaveBeenCalled()
152
+ expect(Messaging.addMessager).toHaveBeenCalled()
153
+ })
154
+
155
+ it('should catch and log loading errors gracefully on package load failures', async () => {
156
+ const config = {
157
+ backend: {
158
+ package: 'invalid-backend-package',
159
+ adapter: 'InvalidBackend',
160
+ config: {}
161
+ }
162
+ }
163
+
164
+ readSpy.mockReturnValue(JSON.stringify(config))
165
+
166
+ await AppBootloader.bootstrap('quatrain-test.json')
167
+
168
+ expect(Log.error).toHaveBeenCalledWith(
169
+ expect.stringContaining('[Bootloader] Failed to load backend')
170
+ )
171
+ })
172
+ })
173
+ })
@@ -0,0 +1,125 @@
1
+ import * as fs from 'node:fs'
2
+ import * as path from 'node:path'
3
+ import { CodeGenerator } from './CodeGenerator'
4
+ import { Log } from '@quatrain/log'
5
+
6
+ describe('CodeGenerator', () => {
7
+ const targetTestDir = path.resolve(__dirname, 'temp_codegen_test')
8
+
9
+ beforeEach(() => {
10
+ jest.spyOn(Log, 'info').mockImplementation(() => {})
11
+ if (fs.existsSync(targetTestDir)) {
12
+ fs.rmSync(targetTestDir, { recursive: true, force: true })
13
+ }
14
+ })
15
+
16
+ afterEach(() => {
17
+ if (fs.existsSync(targetTestDir)) {
18
+ fs.rmSync(targetTestDir, { recursive: true, force: true })
19
+ }
20
+ jest.restoreAllMocks()
21
+ })
22
+
23
+ it('should generate a complete, valid application with isolated directories', () => {
24
+ const mockConfig = {
25
+ name: 'TestApp',
26
+ authMode: 'basic',
27
+ backend: {
28
+ adapter: 'SQLiteAdapter'
29
+ },
30
+ models: [
31
+ {
32
+ name: 'Book',
33
+ collectionName: 'books',
34
+ properties: [
35
+ { name: 'title', type: 'StringProperty', mandatory: true, htmlType: 'text' },
36
+ { name: 'price', type: 'NumberProperty', mandatory: false }
37
+ ]
38
+ }
39
+ ],
40
+ widgets: [
41
+ {
42
+ modelName: 'Book',
43
+ widgetType: 'form',
44
+ layout: [
45
+ { type: 'field', name: 'title' },
46
+ { type: 'group', fields: ['price'] }
47
+ ]
48
+ }
49
+ ]
50
+ }
51
+
52
+ CodeGenerator.generate(mockConfig, targetTestDir)
53
+
54
+ // Assert basic configuration files exist
55
+ expect(fs.existsSync(path.join(targetTestDir, 'package.json'))).toBe(true)
56
+ expect(fs.existsSync(path.join(targetTestDir, 'tsconfig.json'))).toBe(true)
57
+
58
+ // Assert server-side model and API routes exist
59
+ expect(fs.existsSync(path.join(targetTestDir, 'src/models/Book.ts'))).toBe(true)
60
+ expect(fs.existsSync(path.join(targetTestDir, 'src/api/BookApi.ts'))).toBe(true)
61
+ expect(fs.existsSync(path.join(targetTestDir, 'src/index.ts'))).toBe(true)
62
+
63
+ // Assert database migrations generated
64
+ const migrationsDir = path.join(targetTestDir, 'data/migrations/default')
65
+ expect(fs.existsSync(migrationsDir)).toBe(true)
66
+ const migrationFiles = fs.readdirSync(migrationsDir)
67
+ expect(migrationFiles.length).toBe(1)
68
+ expect(migrationFiles[0]).toMatch(/_init\.ts$/)
69
+
70
+ // Assert frontend codebases scaffolded correctly
71
+ const webDir = path.join(targetTestDir, 'web')
72
+ expect(fs.existsSync(path.join(webDir, 'package.json'))).toBe(true)
73
+ expect(fs.existsSync(path.join(webDir, 'tsconfig.json'))).toBe(true)
74
+ expect(fs.existsSync(path.join(webDir, 'vite.config.ts'))).toBe(true)
75
+ expect(fs.existsSync(path.join(webDir, 'index.html'))).toBe(true)
76
+ expect(fs.existsSync(path.join(webDir, 'src/main.tsx'))).toBe(true)
77
+ expect(fs.existsSync(path.join(webDir, 'src/api.ts'))).toBe(true)
78
+ expect(fs.existsSync(path.join(webDir, 'src/App.tsx'))).toBe(true)
79
+
80
+ // Assert frontend React CRUD pages compiled
81
+ const pagesDir = path.join(webDir, 'src/pages')
82
+ expect(fs.existsSync(path.join(pagesDir, 'BookList.tsx'))).toBe(true)
83
+ expect(fs.existsSync(path.join(pagesDir, 'BookForm.tsx'))).toBe(true)
84
+
85
+ // Assert Docker integration exists
86
+ expect(fs.existsSync(path.join(webDir, 'Dockerfile'))).toBe(true)
87
+ expect(fs.existsSync(path.join(webDir, 'nginx.conf'))).toBe(true)
88
+
89
+ // Let's verify layout code inside BookForm.tsx
90
+ const formCode = fs.readFileSync(path.join(pagesDir, 'BookForm.tsx'), 'utf8')
91
+ expect(formCode).toContain('TextInput label="title"')
92
+ expect(formCode).toContain('TextInput label="price"')
93
+ expect(formCode).toContain('<Group grow align="flex-start">')
94
+ })
95
+
96
+ it('should generate fallback CoreForm components when layout is missing', () => {
97
+ const mockConfig = {
98
+ name: 'FallbackApp',
99
+ authMode: 'oauth',
100
+ backend: {
101
+ adapter: 'PostgresAdapter'
102
+ },
103
+ models: [
104
+ {
105
+ name: 'Customer',
106
+ collectionName: 'customers',
107
+ properties: [
108
+ { name: 'fullName', type: 'string', mandatory: true }
109
+ ]
110
+ }
111
+ ],
112
+ widgets: [] // Empty widgets list should trigger dynamic fallback
113
+ }
114
+
115
+ CodeGenerator.generate(mockConfig, targetTestDir)
116
+
117
+ const formCode = fs.readFileSync(path.join(targetTestDir, 'web/src/pages/CustomerForm.tsx'), 'utf8')
118
+ expect(formCode).toContain('CoreForm')
119
+ expect(formCode).toContain('CustomerForm')
120
+
121
+ const packageCode = fs.readFileSync(path.join(targetTestDir, 'package.json'), 'utf8')
122
+ expect(packageCode).toContain('@quatrain/auth-oidc')
123
+ expect(packageCode).toContain('@quatrain/backend-postgres')
124
+ })
125
+ })