arckode-framework 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/README.md +546 -0
- package/adapters/__tests__/mysql.test.ts +283 -0
- package/adapters/jwt.ts +18 -0
- package/adapters/mysql.ts +98 -0
- package/adapters/postgres.ts +52 -0
- package/adapters/redis-cache.ts +64 -0
- package/adapters/sqlite.ts +73 -0
- package/adapters/vendor.d.ts +48 -0
- package/bin/arckode.js +7 -0
- package/cli/analyze.ts +506 -0
- package/cli/commands/db-migrate.ts +121 -0
- package/cli/commands/db-seed.ts +54 -0
- package/cli/commands/generate-api-client.ts +106 -0
- package/cli/commands/make-adapter.ts +132 -0
- package/cli/commands/make-auth.ts +297 -0
- package/cli/commands/make-frontend-module.ts +271 -0
- package/cli/commands/make-helper.ts +65 -0
- package/cli/commands/make-migration.ts +30 -0
- package/cli/commands/make-seed.ts +29 -0
- package/cli/generate.ts +132 -0
- package/cli/index.ts +604 -0
- package/cli/stubs/frontend-stub.ts +294 -0
- package/cli/stubs/fullstack-stub.ts +46 -0
- package/cli/stubs/module-stub.ts +469 -0
- package/kernel/__tests__/adapters.test.ts +101 -0
- package/kernel/__tests__/analyzer.test.ts +282 -0
- package/kernel/__tests__/framework.test.ts +617 -0
- package/kernel/__tests__/middlewares.test.ts +174 -0
- package/kernel/__tests__/static.test.ts +94 -0
- package/kernel/framework.ts +1851 -0
- package/kernel/middlewares.ts +179 -0
- package/kernel/static.ts +76 -0
- package/kernel/testing.ts +237 -0
- package/modules/events/index.ts +99 -0
- package/modules/mail/index.ts +51 -0
- package/modules/mail/smtp-adapter.ts +42 -0
- package/modules/queue/index.ts +78 -0
- package/modules/storage/index.ts +40 -0
- package/modules/storage/local-adapter.ts +41 -0
- package/modules/ws/__tests__/ws.test.ts +114 -0
- package/modules/ws/index.ts +136 -0
- package/package.json +99 -0
- package/skills/auth/SKILL.md +243 -0
- package/skills/cli/SKILL.md +258 -0
- package/skills/config/SKILL.md +253 -0
- package/skills/connectors/SKILL.md +259 -0
- package/skills/helpers/SKILL.md +206 -0
- package/skills/middlewares/SKILL.md +282 -0
- package/skills/orm/SKILL.md +260 -0
- package/skills/realtime/SKILL.md +307 -0
- package/skills/services/SKILL.md +206 -0
- package/skills/testing/SKILL.md +257 -0
|
@@ -0,0 +1,469 @@
|
|
|
1
|
+
// cli/stubs/module-stub.ts — Plantillas para generar módulos completos
|
|
2
|
+
// Usadas por el generador. Cada stub produce código 100% correcto.
|
|
3
|
+
|
|
4
|
+
export interface ModuleStubParams {
|
|
5
|
+
name: string // PascalCase: Producto
|
|
6
|
+
module: string // kebab-case: productos
|
|
7
|
+
fields: { name: string; type: 'string' | 'number' | 'boolean' | 'date'; required: boolean; default?: unknown }[]
|
|
8
|
+
relations?: { type: 'hasMany' | 'belongsTo'; model: string; foreignKey: string }[]
|
|
9
|
+
softDelete?: boolean
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function indexStub(p: ModuleStubParams): string {
|
|
13
|
+
const actions = ['list', 'getById', 'create', 'update', 'delete']
|
|
14
|
+
const events = [`on${p.name}Created`, `on${p.name}Updated`, `on${p.name}Deleted`]
|
|
15
|
+
|
|
16
|
+
return `// ${p.module}/index.ts — PUERTA PÚBLICA
|
|
17
|
+
// Solo esto es visible para otros módulos y conectores.
|
|
18
|
+
// ⚠ REGLA: Append-only. No sacar ni modificar exports existentes.
|
|
19
|
+
|
|
20
|
+
import { createModule, OrmRepository } from 'arckode-framework'
|
|
21
|
+
import { ${p.name}Service } from './actions/service'
|
|
22
|
+
import { ${p.name}Controller } from './actions/controller'
|
|
23
|
+
import type { ${p.name}DTO } from './types'
|
|
24
|
+
|
|
25
|
+
export { ${p.name}Service }
|
|
26
|
+
export type { ${p.name}DTO, Create${p.name}DTO, Update${p.name}DTO, ${p.name}Query, ${p.name}Paginated } from './types'
|
|
27
|
+
export type { ${p.name}Sockets } from './sockets'
|
|
28
|
+
export { ${p.name}Validator, Create${p.name}Schema, Update${p.name}Schema } from './validators/schema'
|
|
29
|
+
|
|
30
|
+
export function ${p.name}Module(sockets?: import('./sockets').${p.name}Sockets) {
|
|
31
|
+
return createModule({
|
|
32
|
+
name: '${p.module}',
|
|
33
|
+
version: '1.0.0',
|
|
34
|
+
description: 'Módulo de ${p.module}',
|
|
35
|
+
|
|
36
|
+
contract: {
|
|
37
|
+
name: '${p.module}',
|
|
38
|
+
version: '1.0.0',
|
|
39
|
+
description: 'Módulo de ${p.module}',
|
|
40
|
+
actions: ${JSON.stringify(actions)},
|
|
41
|
+
events: ${JSON.stringify(events)},
|
|
42
|
+
tables: ['${p.module}'],
|
|
43
|
+
dependencies: [],
|
|
44
|
+
rules: ['No importar de otros módulos'],
|
|
45
|
+
},
|
|
46
|
+
|
|
47
|
+
create({ logger, orm, cache, router }) {
|
|
48
|
+
const repo = new OrmRepository<${p.name}DTO>(orm, '${p.name}')
|
|
49
|
+
const log = logger.child('${p.module}')
|
|
50
|
+
const service = new ${p.name}Service(repo, log, cache, sockets)
|
|
51
|
+
const controller = new ${p.name}Controller(service, log)
|
|
52
|
+
|
|
53
|
+
// Rutas públicas por defecto — agregar [auth.authenticate()] para proteger
|
|
54
|
+
router.get('/${p.module}', (req) => controller.index(req))
|
|
55
|
+
router.get('/${p.module}/:id', (req) => controller.show(req))
|
|
56
|
+
router.post('/${p.module}', (req) => controller.store(req))
|
|
57
|
+
router.put('/${p.module}/:id', (req) => controller.update(req))
|
|
58
|
+
router.delete('/${p.module}/:id', (req) => controller.destroy(req))
|
|
59
|
+
|
|
60
|
+
log.info('Módulo ${p.module} listo')
|
|
61
|
+
return service
|
|
62
|
+
},
|
|
63
|
+
})
|
|
64
|
+
}
|
|
65
|
+
`
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function typesStub(p: ModuleStubParams): string {
|
|
69
|
+
const fields = p.fields.map(f => ` ${f.name}${f.required ? '' : '?'}: ${mapTS(f.type)}`).join('\n')
|
|
70
|
+
const filterFields = p.fields.map(f => ` ${f.name}?: ${mapTS(f.type)}`).join('\n')
|
|
71
|
+
|
|
72
|
+
const modelFields = p.fields
|
|
73
|
+
.filter(f => !['id', 'createdAt', 'updatedAt', 'deletedAt'].includes(f.name))
|
|
74
|
+
.map(f => {
|
|
75
|
+
const parts = [` ${f.name}: { type: '${f.type}'`]
|
|
76
|
+
if (f.required) parts.push(`, required: true`)
|
|
77
|
+
if (f.default !== undefined) parts.push(`, default: ${JSON.stringify(f.default)}`)
|
|
78
|
+
parts.push(` }`)
|
|
79
|
+
return parts.join('')
|
|
80
|
+
})
|
|
81
|
+
.join(',\n')
|
|
82
|
+
|
|
83
|
+
return `// ${p.module}/types.ts — DTOs y definición del modelo
|
|
84
|
+
// Responsabilidad ÚNICA: definir la forma de los datos.
|
|
85
|
+
// El módulo es dueño de su ModelDefinition — composition-root la importa desde aquí.
|
|
86
|
+
|
|
87
|
+
import type { ModelDefinition } from 'arckode-framework'
|
|
88
|
+
|
|
89
|
+
// Definición del modelo para ORM.define() — importar en composition-root.ts
|
|
90
|
+
export const ${p.name}Model: ModelDefinition = {
|
|
91
|
+
table: '${p.module}',
|
|
92
|
+
fields: {
|
|
93
|
+
${modelFields}
|
|
94
|
+
},
|
|
95
|
+
timestamps: true,
|
|
96
|
+
${p.softDelete ? ' softDelete: true,' : ''}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export interface ${p.name}DTO {
|
|
100
|
+
id: string
|
|
101
|
+
${fields}
|
|
102
|
+
createdAt: string
|
|
103
|
+
updatedAt: string
|
|
104
|
+
${p.softDelete ? ' deletedAt: string | null' : ''}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export interface Create${p.name}DTO {
|
|
108
|
+
${p.fields.filter(f => !f.name.match(/^(id|createdAt|updatedAt|deletedAt)$/)).map(f => ` ${f.name}${f.required ? '' : '?'}: ${mapTS(f.type)}`).join('\n')}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export interface Update${p.name}DTO {
|
|
112
|
+
${p.fields.filter(f => !f.name.match(/^(id|createdAt|updatedAt|deletedAt)$/)).map(f => ` ${f.name}?: ${mapTS(f.type)}`).join('\n')}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// ─── Consultas y paginación ────────────────────────────
|
|
116
|
+
export interface ${p.name}Query {
|
|
117
|
+
${filterFields}
|
|
118
|
+
page?: number
|
|
119
|
+
limit?: number
|
|
120
|
+
sortBy?: string
|
|
121
|
+
sortOrder?: 'asc' | 'desc'
|
|
122
|
+
search?: string
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export interface ${p.name}Paginated {
|
|
126
|
+
data: ${p.name}DTO[]
|
|
127
|
+
pagination: {
|
|
128
|
+
page: number
|
|
129
|
+
limit: number
|
|
130
|
+
total: number
|
|
131
|
+
totalPages: number
|
|
132
|
+
hasNext: boolean
|
|
133
|
+
hasPrev: boolean
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
`
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export function socketsStub(p: ModuleStubParams): string {
|
|
140
|
+
return `// ${p.module}/sockets.ts — Hooks OPCIONALES hacia otros módulos
|
|
141
|
+
// Los sockets son opcionales. El módulo funciona sin ellos.
|
|
142
|
+
// Un conector puede pasar sockets para reaccionar a eventos del módulo.
|
|
143
|
+
|
|
144
|
+
import type { ${p.name}DTO } from './types'
|
|
145
|
+
|
|
146
|
+
export interface ${p.name}Sockets {
|
|
147
|
+
on${p.name}Created?: (data: ${p.name}DTO) => Promise<void>
|
|
148
|
+
on${p.name}Updated?: (data: ${p.name}DTO) => Promise<void>
|
|
149
|
+
on${p.name}Deleted?: (id: string) => Promise<void>
|
|
150
|
+
}
|
|
151
|
+
`
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
export function serviceStub(p: ModuleStubParams): string {
|
|
155
|
+
const hasSearch = p.fields.some(f => f.type === 'string')
|
|
156
|
+
|
|
157
|
+
return `// ${p.module}/actions/service.ts — Lógica de negocio
|
|
158
|
+
// Responsabilidad ÚNICA: casos de uso del módulo.
|
|
159
|
+
// NO sabe de HTTP. NO importa de otros módulos.
|
|
160
|
+
// Recibe dependencias por constructor (Dependency Inversion).
|
|
161
|
+
//
|
|
162
|
+
// IMPORTANTE: depende de RepositoryAdapter<${p.name}DTO>, no del ORM directamente.
|
|
163
|
+
// Esto permite swapear SQL → MongoDB → Prisma en composition-root.ts sin tocar este archivo.
|
|
164
|
+
|
|
165
|
+
import type { RepositoryAdapter, Logger, CacheAdapter } from 'arckode-framework'
|
|
166
|
+
import { NotFoundError } from 'arckode-framework'
|
|
167
|
+
import type { ${p.name}DTO, Create${p.name}DTO, Update${p.name}DTO, ${p.name}Query, ${p.name}Paginated } from '../types'
|
|
168
|
+
import type { ${p.name}Sockets } from '../sockets'
|
|
169
|
+
|
|
170
|
+
export class ${p.name}Service {
|
|
171
|
+
constructor(
|
|
172
|
+
private readonly repo: RepositoryAdapter<${p.name}DTO>,
|
|
173
|
+
private readonly logger: Logger,
|
|
174
|
+
private readonly cache: CacheAdapter,
|
|
175
|
+
private readonly sockets?: ${p.name}Sockets,
|
|
176
|
+
) {}
|
|
177
|
+
|
|
178
|
+
async list(query?: ${p.name}Query): Promise<${p.name}Paginated> {
|
|
179
|
+
this.logger.info('Listando ${p.module}', { query })
|
|
180
|
+
|
|
181
|
+
const page = query?.page ?? 1
|
|
182
|
+
const limit = query?.limit ?? 20
|
|
183
|
+
const offset = (page - 1) * limit
|
|
184
|
+
const filters: Record<string, unknown> = {}
|
|
185
|
+
|
|
186
|
+
${p.fields.filter(f => f.name !== 'id').slice(0, 3).map(f => `if (query?.${f.name} !== undefined) filters.${f.name} = query.${f.name}`).join('\n ')}
|
|
187
|
+
|
|
188
|
+
const result = await this.repo.paginate(filters, { limit, offset })
|
|
189
|
+
|
|
190
|
+
return {
|
|
191
|
+
data: result.data,
|
|
192
|
+
pagination: {
|
|
193
|
+
page,
|
|
194
|
+
limit,
|
|
195
|
+
total: result.total,
|
|
196
|
+
totalPages: result.pages,
|
|
197
|
+
hasNext: offset + limit < result.total,
|
|
198
|
+
hasPrev: page > 1,
|
|
199
|
+
},
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
async getById(id: string): Promise<${p.name}DTO> {
|
|
204
|
+
this.logger.info('Obteniendo ${p.module}', { id })
|
|
205
|
+
const item = await this.repo.findById(id)
|
|
206
|
+
if (!item) throw new NotFoundError('${p.name} no encontrado')
|
|
207
|
+
return item
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
async create(dto: Create${p.name}DTO): Promise<${p.name}DTO> {
|
|
211
|
+
this.logger.info('Creando ${p.module}')
|
|
212
|
+
const item = await this.repo.create(dto as Omit<${p.name}DTO, 'id'>)
|
|
213
|
+
await this.sockets?.on${p.name}Created?.(item)
|
|
214
|
+
await this.cache.delete('${p.module}:list')
|
|
215
|
+
return item
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
async update(id: string, dto: Update${p.name}DTO): Promise<${p.name}DTO> {
|
|
219
|
+
this.logger.info('Actualizando ${p.module}', { id })
|
|
220
|
+
const item = await this.repo.update(id, dto as Partial<Omit<${p.name}DTO, 'id'>>)
|
|
221
|
+
if (!item) throw new NotFoundError('${p.name} no encontrado')
|
|
222
|
+
await this.sockets?.on${p.name}Updated?.(item)
|
|
223
|
+
await this.cache.delete('${p.module}:list')
|
|
224
|
+
return item
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
async delete(id: string): Promise<void> {
|
|
228
|
+
this.logger.info('Eliminando ${p.module}', { id })
|
|
229
|
+
const deleted = await this.repo.delete(id)
|
|
230
|
+
if (!deleted) throw new NotFoundError('${p.name} no encontrado')
|
|
231
|
+
await this.sockets?.on${p.name}Deleted?.(id)
|
|
232
|
+
await this.cache.delete('${p.module}:list')
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
`
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
export function controllerStub(p: ModuleStubParams): string {
|
|
239
|
+
return `// ${p.module}/actions/controller.ts — Capa HTTP
|
|
240
|
+
// Responsabilidad ÚNICA: traducir request/response.
|
|
241
|
+
// SIN lógica de negocio. SIN validación directa.
|
|
242
|
+
|
|
243
|
+
import type { HttpRequest, Logger } from 'arckode-framework'
|
|
244
|
+
import { validateSchema } from 'arckode-framework'
|
|
245
|
+
import type { ${p.name}Service } from './service'
|
|
246
|
+
import { Create${p.name}Schema, Update${p.name}Schema } from '../validators/schema'
|
|
247
|
+
|
|
248
|
+
export class ${p.name}Controller {
|
|
249
|
+
constructor(
|
|
250
|
+
private readonly service: ${p.name}Service,
|
|
251
|
+
private readonly logger: Logger,
|
|
252
|
+
) {}
|
|
253
|
+
|
|
254
|
+
async index(req: HttpRequest) {
|
|
255
|
+
this.logger.info('GET /${p.module}')
|
|
256
|
+
const result = await this.service.list(req.query as any)
|
|
257
|
+
return { status: 200, body: result }
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
async show(req: HttpRequest) {
|
|
261
|
+
this.logger.info('GET /${p.module}/:id', { id: req.params.id })
|
|
262
|
+
const item = await this.service.getById(req.params.id)
|
|
263
|
+
return { status: 200, body: item }
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
async store(req: HttpRequest) {
|
|
267
|
+
this.logger.info('POST /${p.module}')
|
|
268
|
+
const data = validateSchema(Create${p.name}Schema, req.body)
|
|
269
|
+
const item = await this.service.create(data as any)
|
|
270
|
+
return { status: 201, body: item }
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
async update(req: HttpRequest) {
|
|
274
|
+
this.logger.info('PUT /${p.module}/:id', { id: req.params.id })
|
|
275
|
+
const data = validateSchema(Update${p.name}Schema, req.body)
|
|
276
|
+
const item = await this.service.update(req.params.id, data as any)
|
|
277
|
+
return { status: 200, body: item }
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
async destroy(req: HttpRequest) {
|
|
281
|
+
this.logger.info('DELETE /${p.module}/:id', { id: req.params.id })
|
|
282
|
+
await this.service.delete(req.params.id)
|
|
283
|
+
return { status: 204, body: null }
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
`
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
export function validatorStub(p: ModuleStubParams): string {
|
|
290
|
+
const createRules = p.fields
|
|
291
|
+
.filter(f => f.name !== 'id')
|
|
292
|
+
.map(f => {
|
|
293
|
+
const parts = [` ${f.name}: { type: '${f.type}' as const`]
|
|
294
|
+
if (f.required && f.name !== 'createdAt' && f.name !== 'updatedAt') parts.push(', required: true')
|
|
295
|
+
if (f.type === 'string' && f.required) parts.push(', min: 2, max: 200')
|
|
296
|
+
parts.push(' }')
|
|
297
|
+
return parts.join('')
|
|
298
|
+
})
|
|
299
|
+
.join('\n')
|
|
300
|
+
|
|
301
|
+
const updateRules = p.fields
|
|
302
|
+
.filter(f => f.name !== 'id' && f.name !== 'createdAt')
|
|
303
|
+
.map(f => {
|
|
304
|
+
const parts = [` ${f.name}: { type: '${f.type}' as const`]
|
|
305
|
+
if (f.type === 'string') parts.push(', min: 2, max: 200')
|
|
306
|
+
parts.push(' }')
|
|
307
|
+
return parts.join('')
|
|
308
|
+
})
|
|
309
|
+
.join('\n')
|
|
310
|
+
|
|
311
|
+
return `// ${p.module}/validators/schema.ts — Validación de entrada
|
|
312
|
+
// Schemas planos, sin dependencias externas.
|
|
313
|
+
|
|
314
|
+
import type { ValidationRule } from 'arckode-framework'
|
|
315
|
+
|
|
316
|
+
export const Create${p.name}Schema: Record<string, ValidationRule> = {
|
|
317
|
+
${createRules}
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
export const Update${p.name}Schema: Record<string, ValidationRule> = {
|
|
321
|
+
${updateRules}
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
// Schema compuesto para usar en validación directa
|
|
325
|
+
export const ${p.name}Validator = {
|
|
326
|
+
create: Create${p.name}Schema,
|
|
327
|
+
update: Update${p.name}Schema,
|
|
328
|
+
}
|
|
329
|
+
`
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
export function testStub(p: ModuleStubParams): string {
|
|
333
|
+
return `// ${p.module}/tests/service.test.ts — Tests del servicio
|
|
334
|
+
// Usa RepositoryAdapter mock — sin dependencia de SQLite ni Postgres.
|
|
335
|
+
|
|
336
|
+
import { describe, it, expect, beforeEach } from 'bun:test'
|
|
337
|
+
import type { RepositoryAdapter, CacheAdapter } from 'arckode-framework'
|
|
338
|
+
import { silentLogger } from 'arckode-framework/testing'
|
|
339
|
+
import { ${p.name}Service } from '../actions/service'
|
|
340
|
+
import type { ${p.name}DTO } from '../types'
|
|
341
|
+
|
|
342
|
+
// Mock mínimo de RepositoryAdapter — devuelve datos predefinidos
|
|
343
|
+
function makeRepo(overrides: Partial<RepositoryAdapter<${p.name}DTO>> = {}): RepositoryAdapter<${p.name}DTO> {
|
|
344
|
+
return {
|
|
345
|
+
findMany: async () => [],
|
|
346
|
+
findById: async () => null,
|
|
347
|
+
findOne: async () => null,
|
|
348
|
+
create: async (data) => ({ id: 'test-id', ...data } as ${p.name}DTO),
|
|
349
|
+
update: async (id, data) => ({ id, ...data } as ${p.name}DTO),
|
|
350
|
+
delete: async () => true,
|
|
351
|
+
count: async () => 0,
|
|
352
|
+
paginate: async () => ({ data: [], total: 0, limit: 20, offset: 0, pages: 0 }),
|
|
353
|
+
...overrides,
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
const silentCache: CacheAdapter = { get: async () => null, set: async () => {}, delete: async () => {}, clear: async () => {} }
|
|
358
|
+
|
|
359
|
+
describe('${p.name}Service', () => {
|
|
360
|
+
describe('getById', () => {
|
|
361
|
+
it('lanza NotFound si el item no existe', async () => {
|
|
362
|
+
const service = new ${p.name}Service(makeRepo(), silentLogger, silentCache)
|
|
363
|
+
await expect(service.getById('no-existe')).rejects.toThrow('${p.name} no encontrado')
|
|
364
|
+
})
|
|
365
|
+
|
|
366
|
+
it('retorna el item si existe', async () => {
|
|
367
|
+
const item = { id: '1' } as ${p.name}DTO
|
|
368
|
+
const service = new ${p.name}Service(makeRepo({ findById: async () => item }), silentLogger, silentCache)
|
|
369
|
+
expect(await service.getById('1')).toEqual(item)
|
|
370
|
+
})
|
|
371
|
+
})
|
|
372
|
+
|
|
373
|
+
describe('create', () => {
|
|
374
|
+
it('crea y retorna el item', async () => {
|
|
375
|
+
const service = new ${p.name}Service(makeRepo(), silentLogger, silentCache)
|
|
376
|
+
const result = await service.create({} as any)
|
|
377
|
+
expect(result.id).toBe('test-id')
|
|
378
|
+
})
|
|
379
|
+
})
|
|
380
|
+
|
|
381
|
+
describe('delete', () => {
|
|
382
|
+
it('lanza NotFound si el item no existe', async () => {
|
|
383
|
+
const service = new ${p.name}Service(makeRepo({ delete: async () => false }), silentLogger, silentCache)
|
|
384
|
+
await expect(service.delete('no-existe')).rejects.toThrow('${p.name} no encontrado')
|
|
385
|
+
})
|
|
386
|
+
})
|
|
387
|
+
})
|
|
388
|
+
`
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
export function migrationStub(p: ModuleStubParams): string {
|
|
392
|
+
// Tipos ANSI SQL — compatibles con SQLite, MySQL y Postgres sin modificaciones
|
|
393
|
+
const sqlType = (f: ModuleStubParams['fields'][0]): string => {
|
|
394
|
+
if (f.name === 'id') return 'VARCHAR(36)'
|
|
395
|
+
const map: Record<string, string> = {
|
|
396
|
+
string: 'VARCHAR(255)',
|
|
397
|
+
number: 'DECIMAL(15,4)',
|
|
398
|
+
boolean: 'BOOLEAN',
|
|
399
|
+
date: 'TIMESTAMP',
|
|
400
|
+
json: 'TEXT',
|
|
401
|
+
}
|
|
402
|
+
return map[f.type] ?? 'TEXT'
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
const cols = p.fields.map(f => {
|
|
406
|
+
const pk = f.name === 'id' ? ' PRIMARY KEY' : ''
|
|
407
|
+
const req = f.required && f.name !== 'id' ? ' NOT NULL' : ''
|
|
408
|
+
const def = f.default !== undefined ? ` DEFAULT ${typeof f.default === 'string' ? `'${f.default}'` : f.default}` : ''
|
|
409
|
+
return ` ${f.name} ${sqlType(f)}${pk}${req}${def}`
|
|
410
|
+
}).join('\n')
|
|
411
|
+
|
|
412
|
+
return `// migrations/${Date.now()}_create_${p.module}.ts
|
|
413
|
+
// Migración generada por arckode make:module
|
|
414
|
+
// SQL ANSI — compatible con SQLite, MySQL y Postgres sin modificaciones.
|
|
415
|
+
|
|
416
|
+
export async function up(db: any): Promise<void> {
|
|
417
|
+
await db.run(\`
|
|
418
|
+
CREATE TABLE IF NOT EXISTS ${p.module} (
|
|
419
|
+
${cols},
|
|
420
|
+
createdAt TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
|
421
|
+
updatedAt TIMESTAMP DEFAULT CURRENT_TIMESTAMP${p.softDelete ? ',\n deletedAt TIMESTAMP NULL' : ''}
|
|
422
|
+
)
|
|
423
|
+
\`)
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
export async function down(db: any): Promise<void> {
|
|
427
|
+
await db.run(\`DROP TABLE IF EXISTS ${p.module}\`)
|
|
428
|
+
}
|
|
429
|
+
`
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
export function seedStub(p: ModuleStubParams): string {
|
|
433
|
+
const sampleData = p.fields
|
|
434
|
+
.filter(f => f.name !== 'id' && f.name !== 'createdAt' && f.name !== 'updatedAt')
|
|
435
|
+
.map(f => {
|
|
436
|
+
if (f.name === 'nombre' || f.name === 'name') return ` ${f.name}: '${p.name} de ejemplo'`
|
|
437
|
+
if (f.name === 'email') return ` ${f.name}: 'ejemplo@correo.com'`
|
|
438
|
+
if (f.name === 'activo' || f.name === 'active') return ` ${f.name}: true`
|
|
439
|
+
if (f.type === 'number') return ` ${f.name}: 100`
|
|
440
|
+
if (f.type === 'boolean') return ` ${f.name}: false`
|
|
441
|
+
return ` ${f.name}: 'valor'`
|
|
442
|
+
})
|
|
443
|
+
.join('\n')
|
|
444
|
+
|
|
445
|
+
return `// seeds/${p.module}.ts — Datos de prueba
|
|
446
|
+
|
|
447
|
+
export async function seed${p.name}(orm: any): Promise<void> {
|
|
448
|
+
const items = [
|
|
449
|
+
{
|
|
450
|
+
${sampleData}
|
|
451
|
+
},
|
|
452
|
+
{
|
|
453
|
+
${sampleData.replace(`'${p.name} de ejemplo'`, `'${p.name} de ejemplo 2'`).replace(`'ejemplo@correo.com'`, `'ejemplo2@correo.com'`)}
|
|
454
|
+
},
|
|
455
|
+
]
|
|
456
|
+
|
|
457
|
+
for (const item of items) {
|
|
458
|
+
await orm.create('${p.name}', item)
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
console.log(' ✓ ${p.name} seeded: ' + items.length + ' items')
|
|
462
|
+
}
|
|
463
|
+
`
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
function mapTS(type: string): string {
|
|
467
|
+
const map: Record<string, string> = { string: 'string', number: 'number', boolean: 'boolean', date: 'string' }
|
|
468
|
+
return map[type] ?? 'unknown'
|
|
469
|
+
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
// kernel/__tests__/adapters.test.ts — Tests de adapters reales
|
|
2
|
+
// Bun:test. Requiere: better-sqlite3 (SQLite), pg (Postgres), redis (Redis)
|
|
3
|
+
|
|
4
|
+
import { describe, it, expect, beforeAll, afterAll } from 'bun:test'
|
|
5
|
+
|
|
6
|
+
// ─── SQLite Adapter ────────────────────────────────────
|
|
7
|
+
describe('SQLiteAdapter', () => {
|
|
8
|
+
let adapter: any
|
|
9
|
+
|
|
10
|
+
beforeAll(async () => {
|
|
11
|
+
const { SqliteAdapter } = await import('../../adapters/sqlite')
|
|
12
|
+
adapter = new SqliteAdapter({ path: ':memory:' })
|
|
13
|
+
await adapter.connect()
|
|
14
|
+
})
|
|
15
|
+
|
|
16
|
+
afterAll(async () => {
|
|
17
|
+
await adapter.close()
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
it('crea tabla e inserta datos', async () => {
|
|
21
|
+
await adapter.run('CREATE TABLE IF NOT EXISTS test (id TEXT PRIMARY KEY, nombre TEXT)')
|
|
22
|
+
await adapter.run('INSERT INTO test (id, nombre) VALUES (?, ?)', ['1', 'Test'])
|
|
23
|
+
const rows = await adapter.query('SELECT * FROM test WHERE id = ?', ['1'])
|
|
24
|
+
expect(rows).toHaveLength(1)
|
|
25
|
+
expect((rows[0] as any).nombre).toBe('Test')
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
it('query con múltiples resultados', async () => {
|
|
29
|
+
await adapter.run('INSERT INTO test (id, nombre) VALUES (?, ?)', ['2', 'Otro'])
|
|
30
|
+
const rows = await adapter.query('SELECT * FROM test ORDER BY id')
|
|
31
|
+
expect(rows).toHaveLength(2)
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
it('run devuelve changes', async () => {
|
|
35
|
+
const result = await adapter.run('DELETE FROM test WHERE id = ?', ['1'])
|
|
36
|
+
expect(result.changes).toBeGreaterThan(0)
|
|
37
|
+
expect(result.lastId).toBeDefined()
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
it('query sin resultados devuelve array vacío', async () => {
|
|
41
|
+
const rows = await adapter.query('SELECT * FROM test WHERE id = ?', ['999'])
|
|
42
|
+
expect(rows).toEqual([])
|
|
43
|
+
})
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
// ─── JWT Adapter ───────────────────────────────────────
|
|
47
|
+
describe('JwtAdapter', () => {
|
|
48
|
+
it('sign y verify funcionan', async () => {
|
|
49
|
+
const { jwtTokenAdapter } = await import('../../adapters/jwt')
|
|
50
|
+
const token = jwtTokenAdapter.sign({ id: '1', role: 'admin' }, 'secret', '1h')
|
|
51
|
+
expect(token).toBeTruthy()
|
|
52
|
+
expect(typeof token).toBe('string')
|
|
53
|
+
|
|
54
|
+
const payload = jwtTokenAdapter.verify(token, 'secret')
|
|
55
|
+
expect(payload.id).toBe('1')
|
|
56
|
+
expect(payload.role).toBe('admin')
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
it('verify rechaza token con secret incorrecto', async () => {
|
|
60
|
+
const { jwtTokenAdapter } = await import('../../adapters/jwt')
|
|
61
|
+
const token = jwtTokenAdapter.sign({ id: '1' }, 'correct', '1h')
|
|
62
|
+
expect(() => jwtTokenAdapter.verify(token, 'wrong')).toThrow()
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
it('verify rechaza token expirado', async () => {
|
|
66
|
+
const { jwtTokenAdapter } = await import('../../adapters/jwt')
|
|
67
|
+
const token = jwtTokenAdapter.sign({ id: '1' }, 'secret', '0s')
|
|
68
|
+
expect(() => jwtTokenAdapter.verify(token, 'secret')).toThrow()
|
|
69
|
+
})
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
// ═════════════════════════════════════════════════════════
|
|
73
|
+
// Nota: PostgresAdapter y RedisCacheAdapter requieren
|
|
74
|
+
// servidores externos. Se testean con mock o con docker.
|
|
75
|
+
//
|
|
76
|
+
// Test Postgres (requiere pg instalado y servidor postgres):
|
|
77
|
+
// describe('PostgresAdapter', () => {
|
|
78
|
+
// it('conecta y ejecuta query', async () => {
|
|
79
|
+
// const { PostgresAdapter } = await import('../../adapters/postgres')
|
|
80
|
+
// const adapter = new PostgresAdapter({
|
|
81
|
+
// connectionString: 'postgres://test:test@localhost:5432/test'
|
|
82
|
+
// })
|
|
83
|
+
// await adapter.connect()
|
|
84
|
+
// const rows = await adapter.query('SELECT 1 as num')
|
|
85
|
+
// expect((rows[0] as any).num).toBe(1)
|
|
86
|
+
// await adapter.close()
|
|
87
|
+
// })
|
|
88
|
+
// })
|
|
89
|
+
//
|
|
90
|
+
// Test Redis (requiere redis instalado y servidor redis):
|
|
91
|
+
// describe('RedisCacheAdapter', () => {
|
|
92
|
+
// it('set y get', async () => {
|
|
93
|
+
// const { RedisCacheAdapter } = await import('../../adapters/redis-cache')
|
|
94
|
+
// const cache = new RedisCacheAdapter({ url: 'redis://localhost:6379' })
|
|
95
|
+
// await cache.connect()
|
|
96
|
+
// await cache.set('test', { ok: true })
|
|
97
|
+
// const val = await cache.get('test')
|
|
98
|
+
// expect(val).toEqual({ ok: true })
|
|
99
|
+
// await cache.delete('test')
|
|
100
|
+
// })
|
|
101
|
+
// })
|