arckode-framework 1.0.0 → 1.0.2
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/adapters/redis-cache.ts +8 -2
- package/cli/stubs/module-stub.ts +21 -15
- package/package.json +15 -4
- package/skills/connectors/SKILL.md +5 -4
- package/skills/testing/SKILL.md +46 -23
- package/adapters/__tests__/mysql.test.ts +0 -283
- package/adapters/vendor.d.ts +0 -48
- package/kernel/__tests__/adapters.test.ts +0 -101
- package/kernel/__tests__/analyzer.test.ts +0 -282
- package/kernel/__tests__/framework.test.ts +0 -617
- package/kernel/__tests__/middlewares.test.ts +0 -174
- package/kernel/__tests__/static.test.ts +0 -94
- package/modules/ws/__tests__/ws.test.ts +0 -114
package/adapters/redis-cache.ts
CHANGED
|
@@ -5,7 +5,10 @@ import { createClient, type RedisClientType } from 'redis'
|
|
|
5
5
|
import type { CacheAdapter } from '../kernel/framework'
|
|
6
6
|
|
|
7
7
|
export interface RedisCacheConfig {
|
|
8
|
-
|
|
8
|
+
/** URL completa: redis://localhost:6379 o rediss://user:pass@host:6380 */
|
|
9
|
+
url?: string
|
|
10
|
+
/** Config por host/port como alternativa a url */
|
|
11
|
+
socket?: { host: string; port: number; password?: string }
|
|
9
12
|
keyPrefix?: string
|
|
10
13
|
defaultTTL?: number
|
|
11
14
|
}
|
|
@@ -17,7 +20,10 @@ export class RedisCacheAdapter implements CacheAdapter {
|
|
|
17
20
|
constructor(private config: RedisCacheConfig) {}
|
|
18
21
|
|
|
19
22
|
async connect(): Promise<void> {
|
|
20
|
-
|
|
23
|
+
const clientConfig = this.config.url
|
|
24
|
+
? { url: this.config.url }
|
|
25
|
+
: { socket: this.config.socket, password: this.config.socket?.password }
|
|
26
|
+
this.client = createClient(clientConfig)
|
|
21
27
|
await this.client.connect()
|
|
22
28
|
this.connected = true
|
|
23
29
|
}
|
package/cli/stubs/module-stub.ts
CHANGED
|
@@ -27,7 +27,7 @@ export type { ${p.name}DTO, Create${p.name}DTO, Update${p.name}DTO, ${p.name}Que
|
|
|
27
27
|
export type { ${p.name}Sockets } from './sockets'
|
|
28
28
|
export { ${p.name}Validator, Create${p.name}Schema, Update${p.name}Schema } from './validators/schema'
|
|
29
29
|
|
|
30
|
-
export function ${p.name}Module(
|
|
30
|
+
export function ${p.name}Module() {
|
|
31
31
|
return createModule({
|
|
32
32
|
name: '${p.module}',
|
|
33
33
|
version: '1.0.0',
|
|
@@ -44,10 +44,10 @@ export function ${p.name}Module(sockets?: import('./sockets').${p.name}Sockets)
|
|
|
44
44
|
rules: ['No importar de otros módulos'],
|
|
45
45
|
},
|
|
46
46
|
|
|
47
|
-
create({ logger, orm, cache, router }) {
|
|
47
|
+
create({ logger, orm, cache, router, auth }) {
|
|
48
48
|
const repo = new OrmRepository<${p.name}DTO>(orm, '${p.name}')
|
|
49
49
|
const log = logger.child('${p.module}')
|
|
50
|
-
const service = new ${p.name}Service(repo, log, cache
|
|
50
|
+
const service = new ${p.name}Service(repo, log, cache)
|
|
51
51
|
const controller = new ${p.name}Controller(service, log)
|
|
52
52
|
|
|
53
53
|
// Rutas públicas por defecto — agregar [auth.authenticate()] para proteger
|
|
@@ -168,13 +168,18 @@ import type { ${p.name}DTO, Create${p.name}DTO, Update${p.name}DTO, ${p.name}Que
|
|
|
168
168
|
import type { ${p.name}Sockets } from '../sockets'
|
|
169
169
|
|
|
170
170
|
export class ${p.name}Service {
|
|
171
|
+
private sockets: ${p.name}Sockets = {}
|
|
172
|
+
|
|
171
173
|
constructor(
|
|
172
174
|
private readonly repo: RepositoryAdapter<${p.name}DTO>,
|
|
173
175
|
private readonly logger: Logger,
|
|
174
176
|
private readonly cache: CacheAdapter,
|
|
175
|
-
private readonly sockets?: ${p.name}Sockets,
|
|
176
177
|
) {}
|
|
177
178
|
|
|
179
|
+
setSockets(s: Partial<${p.name}Sockets>): void {
|
|
180
|
+
this.sockets = { ...this.sockets, ...s }
|
|
181
|
+
}
|
|
182
|
+
|
|
178
183
|
async list(query?: ${p.name}Query): Promise<${p.name}Paginated> {
|
|
179
184
|
this.logger.info('Listando ${p.module}', { query })
|
|
180
185
|
|
|
@@ -210,7 +215,7 @@ export class ${p.name}Service {
|
|
|
210
215
|
async create(dto: Create${p.name}DTO): Promise<${p.name}DTO> {
|
|
211
216
|
this.logger.info('Creando ${p.module}')
|
|
212
217
|
const item = await this.repo.create(dto as Omit<${p.name}DTO, 'id'>)
|
|
213
|
-
await this.sockets
|
|
218
|
+
await this.sockets.on${p.name}Created?.(item)
|
|
214
219
|
await this.cache.delete('${p.module}:list')
|
|
215
220
|
return item
|
|
216
221
|
}
|
|
@@ -219,7 +224,7 @@ export class ${p.name}Service {
|
|
|
219
224
|
this.logger.info('Actualizando ${p.module}', { id })
|
|
220
225
|
const item = await this.repo.update(id, dto as Partial<Omit<${p.name}DTO, 'id'>>)
|
|
221
226
|
if (!item) throw new NotFoundError('${p.name} no encontrado')
|
|
222
|
-
await this.sockets
|
|
227
|
+
await this.sockets.on${p.name}Updated?.(item)
|
|
223
228
|
await this.cache.delete('${p.module}:list')
|
|
224
229
|
return item
|
|
225
230
|
}
|
|
@@ -228,7 +233,7 @@ export class ${p.name}Service {
|
|
|
228
233
|
this.logger.info('Eliminando ${p.module}', { id })
|
|
229
234
|
const deleted = await this.repo.delete(id)
|
|
230
235
|
if (!deleted) throw new NotFoundError('${p.name} no encontrado')
|
|
231
|
-
await this.sockets
|
|
236
|
+
await this.sockets.on${p.name}Deleted?.(id)
|
|
232
237
|
await this.cache.delete('${p.module}:list')
|
|
233
238
|
}
|
|
234
239
|
}
|
|
@@ -333,13 +338,16 @@ export function testStub(p: ModuleStubParams): string {
|
|
|
333
338
|
return `// ${p.module}/tests/service.test.ts — Tests del servicio
|
|
334
339
|
// Usa RepositoryAdapter mock — sin dependencia de SQLite ni Postgres.
|
|
335
340
|
|
|
336
|
-
import { describe, it, expect
|
|
341
|
+
import { describe, it, expect } from 'bun:test'
|
|
337
342
|
import type { RepositoryAdapter, CacheAdapter } from 'arckode-framework'
|
|
338
343
|
import { silentLogger } from 'arckode-framework/testing'
|
|
339
344
|
import { ${p.name}Service } from '../actions/service'
|
|
340
345
|
import type { ${p.name}DTO } from '../types'
|
|
341
346
|
|
|
342
|
-
//
|
|
347
|
+
// silentLogger es una factory function — SIEMPRE llamarla con ()
|
|
348
|
+
const log = silentLogger()
|
|
349
|
+
const silentCache: CacheAdapter = { get: async () => null, set: async () => {}, delete: async () => {}, clear: async () => {}, flush: async () => {} }
|
|
350
|
+
|
|
343
351
|
function makeRepo(overrides: Partial<RepositoryAdapter<${p.name}DTO>> = {}): RepositoryAdapter<${p.name}DTO> {
|
|
344
352
|
return {
|
|
345
353
|
findMany: async () => [],
|
|
@@ -354,25 +362,23 @@ function makeRepo(overrides: Partial<RepositoryAdapter<${p.name}DTO>> = {}): Rep
|
|
|
354
362
|
}
|
|
355
363
|
}
|
|
356
364
|
|
|
357
|
-
const silentCache: CacheAdapter = { get: async () => null, set: async () => {}, delete: async () => {}, clear: async () => {} }
|
|
358
|
-
|
|
359
365
|
describe('${p.name}Service', () => {
|
|
360
366
|
describe('getById', () => {
|
|
361
367
|
it('lanza NotFound si el item no existe', async () => {
|
|
362
|
-
const service = new ${p.name}Service(makeRepo(),
|
|
368
|
+
const service = new ${p.name}Service(makeRepo(), log, silentCache)
|
|
363
369
|
await expect(service.getById('no-existe')).rejects.toThrow('${p.name} no encontrado')
|
|
364
370
|
})
|
|
365
371
|
|
|
366
372
|
it('retorna el item si existe', async () => {
|
|
367
373
|
const item = { id: '1' } as ${p.name}DTO
|
|
368
|
-
const service = new ${p.name}Service(makeRepo({ findById: async () => item }),
|
|
374
|
+
const service = new ${p.name}Service(makeRepo({ findById: async () => item }), log, silentCache)
|
|
369
375
|
expect(await service.getById('1')).toEqual(item)
|
|
370
376
|
})
|
|
371
377
|
})
|
|
372
378
|
|
|
373
379
|
describe('create', () => {
|
|
374
380
|
it('crea y retorna el item', async () => {
|
|
375
|
-
const service = new ${p.name}Service(makeRepo(),
|
|
381
|
+
const service = new ${p.name}Service(makeRepo(), log, silentCache)
|
|
376
382
|
const result = await service.create({} as any)
|
|
377
383
|
expect(result.id).toBe('test-id')
|
|
378
384
|
})
|
|
@@ -380,7 +386,7 @@ describe('${p.name}Service', () => {
|
|
|
380
386
|
|
|
381
387
|
describe('delete', () => {
|
|
382
388
|
it('lanza NotFound si el item no existe', async () => {
|
|
383
|
-
const service = new ${p.name}Service(makeRepo({ delete: async () => false }),
|
|
389
|
+
const service = new ${p.name}Service(makeRepo({ delete: async () => false }), log, silentCache)
|
|
384
390
|
await expect(service.delete('no-existe')).rejects.toThrow('${p.name} no encontrado')
|
|
385
391
|
})
|
|
386
392
|
})
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "arckode-framework",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.2",
|
|
4
4
|
"description": "AI-first TypeScript/Bun framework. Modular, SOLID, zero magic. The AI reads the composition root and knows everything.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./kernel/framework.ts",
|
|
@@ -25,9 +25,20 @@
|
|
|
25
25
|
},
|
|
26
26
|
"files": [
|
|
27
27
|
"bin/",
|
|
28
|
-
"kernel/",
|
|
29
|
-
"
|
|
30
|
-
"
|
|
28
|
+
"kernel/framework.ts",
|
|
29
|
+
"kernel/middlewares.ts",
|
|
30
|
+
"kernel/static.ts",
|
|
31
|
+
"kernel/testing.ts",
|
|
32
|
+
"adapters/jwt.ts",
|
|
33
|
+
"adapters/mysql.ts",
|
|
34
|
+
"adapters/postgres.ts",
|
|
35
|
+
"adapters/redis-cache.ts",
|
|
36
|
+
"adapters/sqlite.ts",
|
|
37
|
+
"modules/events/index.ts",
|
|
38
|
+
"modules/mail/",
|
|
39
|
+
"modules/queue/index.ts",
|
|
40
|
+
"modules/storage/",
|
|
41
|
+
"modules/ws/index.ts",
|
|
31
42
|
"cli/",
|
|
32
43
|
"skills/",
|
|
33
44
|
"README.md"
|
|
@@ -66,9 +66,9 @@ import type { PedidosSockets } from '../sockets'
|
|
|
66
66
|
export class PedidosService {
|
|
67
67
|
private sockets: PedidosSockets = {}
|
|
68
68
|
|
|
69
|
-
//
|
|
70
|
-
setSockets(
|
|
71
|
-
this.sockets = sockets
|
|
69
|
+
// MERGE — no replace. Si dos conectores inyectan sockets, el segundo no pisa al primero.
|
|
70
|
+
setSockets(s: Partial<PedidosSockets>): void {
|
|
71
|
+
this.sockets = { ...this.sockets, ...s }
|
|
72
72
|
}
|
|
73
73
|
|
|
74
74
|
async confirmar(id: string): Promise<PedidoDTO> {
|
|
@@ -253,7 +253,8 @@ test('confirmar pedido funciona sin sockets registrados', async () => {
|
|
|
253
253
|
|
|
254
254
|
- [ ] Solo importa tipos (no implementaciones) desde `index.ts` de módulos
|
|
255
255
|
- [ ] Sin `if`, `for`, ni lógica de negocio — solo llamadas de delegación
|
|
256
|
-
- [ ] El service emisor implementa `setSockets()`
|
|
256
|
+
- [ ] El service emisor implementa `setSockets()` con MERGE (`{...this.sockets, ...s}`) — no replace
|
|
257
|
+
- [ ] `setSockets()` usa `?.` al llamar los hooks (son opcionales)
|
|
257
258
|
- [ ] Registrado en composition-root DESPUÉS de los módulos
|
|
258
259
|
- [ ] El módulo funciona correctamente sin el conector (sockets son opcionales)
|
|
259
260
|
- [ ] `arckode analyze` detecta: `CONNECTOR_BUSINESS_LOGIC` si hay lógica
|
package/skills/testing/SKILL.md
CHANGED
|
@@ -8,18 +8,36 @@
|
|
|
8
8
|
|
|
9
9
|
```ts
|
|
10
10
|
import { describe, test, expect, beforeEach, mock } from 'bun:test'
|
|
11
|
-
|
|
12
|
-
|
|
11
|
+
// NOMBRES REALES: silentLogger (factory), createRecordingDb, createTestClient
|
|
12
|
+
// createRecordingOrm / createSilentLogger / createNullCache NO existen — nunca usar esos nombres
|
|
13
|
+
import { silentLogger, createRecordingDb } from 'arckode-framework/testing'
|
|
14
|
+
import type { RepositoryAdapter, CacheAdapter } from 'arckode-framework'
|
|
13
15
|
import { ProductosService } from '../actions/service'
|
|
14
16
|
import type { ProductoDTO } from '../types'
|
|
15
17
|
|
|
18
|
+
// silentLogger es factory function — SIEMPRE llamar con ()
|
|
19
|
+
const log = silentLogger()
|
|
20
|
+
const silentCache: CacheAdapter = { get: async () => null, set: async () => {}, delete: async () => {}, flush: async () => {} }
|
|
21
|
+
|
|
22
|
+
function makeRepo(overrides: Partial<RepositoryAdapter<ProductoDTO>> = {}): RepositoryAdapter<ProductoDTO> {
|
|
23
|
+
return {
|
|
24
|
+
findMany: async () => [],
|
|
25
|
+
findById: async () => null,
|
|
26
|
+
findOne: async () => null,
|
|
27
|
+
create: async (data) => ({ id: 'test-id', ...data } as ProductoDTO),
|
|
28
|
+
update: async (id, data) => ({ id, ...data } as ProductoDTO),
|
|
29
|
+
delete: async () => true,
|
|
30
|
+
count: async () => 0,
|
|
31
|
+
paginate: async () => ({ data: [], total: 0, limit: 20, offset: 0, pages: 0 }),
|
|
32
|
+
...overrides,
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
16
36
|
describe('ProductosService', () => {
|
|
17
37
|
let service: ProductosService
|
|
18
38
|
|
|
19
39
|
beforeEach(() => {
|
|
20
|
-
|
|
21
|
-
const repo = new OrmRepository<ProductoDTO>(orm, 'Producto')
|
|
22
|
-
service = new ProductosService(repo, createSilentLogger(), createNullCache())
|
|
40
|
+
service = new ProductosService(makeRepo(), log, silentCache)
|
|
23
41
|
})
|
|
24
42
|
})
|
|
25
43
|
```
|
|
@@ -31,24 +49,31 @@ describe('ProductosService', () => {
|
|
|
31
49
|
## 2. UTILIDADES DE TESTING
|
|
32
50
|
|
|
33
51
|
```ts
|
|
52
|
+
// Exports REALES del módulo arckode-framework/testing:
|
|
34
53
|
import {
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
54
|
+
silentLogger, // factory: silentLogger() → Logger (NO usarla sin ())
|
|
55
|
+
createRecordingDb, // captura SQL sin ejecutar BD real → db.calls, db.lastSql()
|
|
56
|
+
createTestClient, // cliente HTTP sin levantar servidor
|
|
57
|
+
createIntegrationClient, // cliente HTTP con servidor real (puerto aleatorio)
|
|
58
|
+
mockAuth, // middleware mock para simular autenticación
|
|
39
59
|
} from 'arckode-framework/testing'
|
|
40
60
|
|
|
41
|
-
//
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
61
|
+
// ❌ ESTOS NO EXISTEN — nunca usar:
|
|
62
|
+
// createRecordingOrm, createSilentLogger, createNullCache
|
|
63
|
+
|
|
64
|
+
// silentLogger — factory, siempre con ()
|
|
65
|
+
const log = silentLogger()
|
|
66
|
+
|
|
67
|
+
// createRecordingDb — captura SQL sin ejecutar nada
|
|
68
|
+
const db = createRecordingDb({ queryData: [{ id: '1', nombre: 'Laptop' }] })
|
|
69
|
+
// db.calls → array de { sql, params, type }
|
|
70
|
+
// db.lastSql() → último SQL ejecutado
|
|
71
|
+
// db.reset() → limpia el historial
|
|
46
72
|
|
|
47
|
-
// TestClient —
|
|
73
|
+
// TestClient — router real, sin HTTP
|
|
48
74
|
const client = createTestClient(router)
|
|
49
75
|
const res = await client.get('/productos')
|
|
50
76
|
expect(res.status).toBe(200)
|
|
51
|
-
expect(res.body).toBeArray()
|
|
52
77
|
```
|
|
53
78
|
|
|
54
79
|
---
|
|
@@ -119,9 +144,7 @@ describe('Productos API', () => {
|
|
|
119
144
|
beforeEach(() => {
|
|
120
145
|
const router = new Router()
|
|
121
146
|
// montar el módulo de prueba
|
|
122
|
-
const
|
|
123
|
-
const repo = new OrmRepository<ProductoDTO>(orm, 'Producto')
|
|
124
|
-
const service = new ProductosService(repo, createSilentLogger(), createNullCache())
|
|
147
|
+
const service = new ProductosService(makeRepo(), log, silentCache)
|
|
125
148
|
const controller = new ProductosController(service)
|
|
126
149
|
|
|
127
150
|
router.get('/productos', req => controller.index(req))
|
|
@@ -161,7 +184,7 @@ describe('Productos API', () => {
|
|
|
161
184
|
import { Auth } from 'arckode-framework'
|
|
162
185
|
import { jwtTokenAdapter } from 'arckode-framework/adapters/jwt'
|
|
163
186
|
|
|
164
|
-
const auth = new Auth(jwtTokenAdapter, 'test-secret-12345678901234567890',
|
|
187
|
+
const auth = new Auth(jwtTokenAdapter, 'test-secret-12345678901234567890', silentLogger())
|
|
165
188
|
const token = auth.createToken({ id: 'user-1', role: 'user' })
|
|
166
189
|
const adminToken = auth.createToken({ id: 'admin-1', role: 'admin' })
|
|
167
190
|
|
|
@@ -249,9 +272,9 @@ test('ORM recibe query INSERT', async () => {
|
|
|
249
272
|
## 10. CHECKLIST TESTING
|
|
250
273
|
|
|
251
274
|
- [ ] Mínimo 2 test cases: happy path + error esperado
|
|
252
|
-
- [ ]
|
|
253
|
-
- [ ]
|
|
254
|
-
- [ ]
|
|
275
|
+
- [ ] `silentLogger()` — con paréntesis, es factory function (no objeto)
|
|
276
|
+
- [ ] Cache mock inline (`get: async () => null`, etc.) — no hay createNullCache
|
|
277
|
+
- [ ] `RepositoryAdapter<T>` mock inline con `makeRepo()` — no hay createRecordingOrm
|
|
255
278
|
- [ ] Todo `expect(...).rejects` tiene `await`
|
|
256
279
|
- [ ] Los nombres de test describen comportamiento, no implementación
|
|
257
280
|
- [ ] `bun test` pasa sin errores antes del commit
|
|
@@ -1,283 +0,0 @@
|
|
|
1
|
-
// adapters/__tests__/mysql.test.ts
|
|
2
|
-
// Testea MySQLAdapter con mocks de mysql2/promise — sin servidor real.
|
|
3
|
-
// Para tests de integración real ver el bloque comentado al final.
|
|
4
|
-
|
|
5
|
-
import { describe, it, expect, beforeAll, beforeEach, mock } from 'bun:test'
|
|
6
|
-
import type { MySQLAdapter } from '../mysql'
|
|
7
|
-
|
|
8
|
-
// ── Mocks de conexión (transacciones) ────────────────────────────────────────
|
|
9
|
-
const connMock = {
|
|
10
|
-
beginTransaction: mock(async () => {}),
|
|
11
|
-
execute: mock(async (): Promise<unknown[]> => [[], []]),
|
|
12
|
-
commit: mock(async () => {}),
|
|
13
|
-
rollback: mock(async () => {}),
|
|
14
|
-
release: mock(() => {}),
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
// ── Mock del pool ─────────────────────────────────────────────────────────────
|
|
18
|
-
const poolMock = {
|
|
19
|
-
getConnection: mock(async () => connMock),
|
|
20
|
-
execute: mock(async (): Promise<unknown[]> => [[], []]),
|
|
21
|
-
end: mock(async () => {}),
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
// Registrar el mock ANTES de que el adapter importe mysql2/promise.
|
|
25
|
-
// El dynamic import en beforeAll garantiza que mysql2/promise ya está mockeado.
|
|
26
|
-
mock.module('mysql2/promise', () => ({
|
|
27
|
-
default: { createPool: mock(() => poolMock) },
|
|
28
|
-
}))
|
|
29
|
-
|
|
30
|
-
// ── Carga diferida del adapter ────────────────────────────────────────────────
|
|
31
|
-
let Adapter: typeof MySQLAdapter
|
|
32
|
-
|
|
33
|
-
beforeAll(async () => {
|
|
34
|
-
const mod = await import('../mysql')
|
|
35
|
-
Adapter = mod.MySQLAdapter
|
|
36
|
-
})
|
|
37
|
-
|
|
38
|
-
// ── Helpers ───────────────────────────────────────────────────────────────────
|
|
39
|
-
function resetMocks() {
|
|
40
|
-
connMock.beginTransaction.mockClear()
|
|
41
|
-
connMock.execute.mockClear()
|
|
42
|
-
connMock.commit.mockClear()
|
|
43
|
-
connMock.rollback.mockClear()
|
|
44
|
-
connMock.release.mockClear()
|
|
45
|
-
poolMock.getConnection.mockClear()
|
|
46
|
-
poolMock.execute.mockClear()
|
|
47
|
-
poolMock.end.mockClear()
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
function makeAdapter() {
|
|
51
|
-
return new Adapter({ host: 'localhost', user: 'test', password: 'test', database: 'testdb' })
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
// ─────────────────────────────────────────────────────────────────────────────
|
|
55
|
-
|
|
56
|
-
describe('MySQLAdapter', () => {
|
|
57
|
-
beforeEach(resetMocks)
|
|
58
|
-
|
|
59
|
-
// ── connect() ───────────────────────────────────────────────────────────────
|
|
60
|
-
describe('connect()', () => {
|
|
61
|
-
it('crea el pool y verifica la conexión con getConnection+release', async () => {
|
|
62
|
-
const adapter = makeAdapter()
|
|
63
|
-
await adapter.connect()
|
|
64
|
-
|
|
65
|
-
expect(poolMock.getConnection).toHaveBeenCalledTimes(1)
|
|
66
|
-
expect(connMock.release).toHaveBeenCalledTimes(1)
|
|
67
|
-
})
|
|
68
|
-
})
|
|
69
|
-
|
|
70
|
-
// ── query() ─────────────────────────────────────────────────────────────────
|
|
71
|
-
describe('query()', () => {
|
|
72
|
-
let adapter: MySQLAdapter
|
|
73
|
-
|
|
74
|
-
beforeEach(async () => {
|
|
75
|
-
adapter = makeAdapter()
|
|
76
|
-
await adapter.connect()
|
|
77
|
-
resetMocks() // limpia las calls de connect()
|
|
78
|
-
})
|
|
79
|
-
|
|
80
|
-
it('retorna el array de filas', async () => {
|
|
81
|
-
const filas = [{ id: '1', nombre: 'Laptop' }, { id: '2', nombre: 'Mouse' }]
|
|
82
|
-
poolMock.execute.mockImplementation(async () => [filas, []])
|
|
83
|
-
|
|
84
|
-
const result = await adapter.query('SELECT * FROM productos')
|
|
85
|
-
expect(result).toEqual(filas)
|
|
86
|
-
})
|
|
87
|
-
|
|
88
|
-
it('pasa los params al execute del pool', async () => {
|
|
89
|
-
poolMock.execute.mockImplementation(async () => [[{ id: '1' }], []])
|
|
90
|
-
|
|
91
|
-
await adapter.query('SELECT * FROM productos WHERE id = ?', ['1'])
|
|
92
|
-
expect(poolMock.execute).toHaveBeenCalledWith(
|
|
93
|
-
'SELECT * FROM productos WHERE id = ?',
|
|
94
|
-
['1'],
|
|
95
|
-
)
|
|
96
|
-
})
|
|
97
|
-
|
|
98
|
-
it('retorna array vacío cuando no hay resultados', async () => {
|
|
99
|
-
poolMock.execute.mockImplementation(async () => [[], []])
|
|
100
|
-
|
|
101
|
-
const result = await adapter.query('SELECT * FROM productos WHERE id = ?', ['999'])
|
|
102
|
-
expect(result).toEqual([])
|
|
103
|
-
})
|
|
104
|
-
})
|
|
105
|
-
|
|
106
|
-
// ── run() ────────────────────────────────────────────────────────────────────
|
|
107
|
-
describe('run()', () => {
|
|
108
|
-
let adapter: MySQLAdapter
|
|
109
|
-
|
|
110
|
-
beforeEach(async () => {
|
|
111
|
-
adapter = makeAdapter()
|
|
112
|
-
await adapter.connect()
|
|
113
|
-
resetMocks()
|
|
114
|
-
})
|
|
115
|
-
|
|
116
|
-
it('INSERT retorna changes y lastId como string', async () => {
|
|
117
|
-
poolMock.execute.mockImplementation(async () => [{ affectedRows: 1, insertId: 42 }, []])
|
|
118
|
-
|
|
119
|
-
const result = await adapter.run(
|
|
120
|
-
'INSERT INTO productos (nombre, precio) VALUES (?, ?)',
|
|
121
|
-
['Laptop', 999],
|
|
122
|
-
)
|
|
123
|
-
|
|
124
|
-
expect(result.changes).toBe(1)
|
|
125
|
-
expect(result.lastId).toBe('42')
|
|
126
|
-
})
|
|
127
|
-
|
|
128
|
-
it('UPDATE retorna changes y lastId undefined (insertId === 0)', async () => {
|
|
129
|
-
poolMock.execute.mockImplementation(async () => [{ affectedRows: 3, insertId: 0 }, []])
|
|
130
|
-
|
|
131
|
-
const result = await adapter.run(
|
|
132
|
-
'UPDATE productos SET activo = ? WHERE categoria = ?',
|
|
133
|
-
[false, 'perifericos'],
|
|
134
|
-
)
|
|
135
|
-
|
|
136
|
-
expect(result.changes).toBe(3)
|
|
137
|
-
expect(result.lastId).toBeUndefined()
|
|
138
|
-
})
|
|
139
|
-
|
|
140
|
-
it('DELETE retorna changes y sin lastId', async () => {
|
|
141
|
-
poolMock.execute.mockImplementation(async () => [{ affectedRows: 1, insertId: 0 }, []])
|
|
142
|
-
|
|
143
|
-
const result = await adapter.run('DELETE FROM productos WHERE id = ?', ['1'])
|
|
144
|
-
expect(result.changes).toBe(1)
|
|
145
|
-
expect(result.lastId).toBeUndefined()
|
|
146
|
-
})
|
|
147
|
-
})
|
|
148
|
-
|
|
149
|
-
// ── transaction() ────────────────────────────────────────────────────────────
|
|
150
|
-
describe('transaction()', () => {
|
|
151
|
-
let adapter: MySQLAdapter
|
|
152
|
-
|
|
153
|
-
beforeEach(async () => {
|
|
154
|
-
adapter = makeAdapter()
|
|
155
|
-
await adapter.connect()
|
|
156
|
-
resetMocks()
|
|
157
|
-
})
|
|
158
|
-
|
|
159
|
-
it('adquiere conexión, abre transacción, ejecuta fn y hace commit', async () => {
|
|
160
|
-
const filas = [{ id: '1' }]
|
|
161
|
-
connMock.execute.mockImplementation(async () => [filas, []])
|
|
162
|
-
|
|
163
|
-
const result = await adapter.transaction(async (tx) => {
|
|
164
|
-
return tx.query('SELECT * FROM productos')
|
|
165
|
-
})
|
|
166
|
-
|
|
167
|
-
expect(poolMock.getConnection).toHaveBeenCalledTimes(1)
|
|
168
|
-
expect(connMock.beginTransaction).toHaveBeenCalledTimes(1)
|
|
169
|
-
expect(connMock.commit).toHaveBeenCalledTimes(1)
|
|
170
|
-
expect(connMock.rollback).not.toHaveBeenCalled()
|
|
171
|
-
expect(connMock.release).toHaveBeenCalledTimes(1)
|
|
172
|
-
expect(result).toEqual(filas)
|
|
173
|
-
})
|
|
174
|
-
|
|
175
|
-
it('el adapter de transacción usa la conexión, no el pool', async () => {
|
|
176
|
-
connMock.execute.mockImplementation(async () => [[{ total: 5 }], []])
|
|
177
|
-
|
|
178
|
-
await adapter.transaction(async (tx) => {
|
|
179
|
-
await tx.query('SELECT COUNT(*) as total FROM productos')
|
|
180
|
-
await tx.run('INSERT INTO auditoria (evento) VALUES (?)', ['sync'])
|
|
181
|
-
})
|
|
182
|
-
|
|
183
|
-
// Ambas llamadas van a connMock.execute, NO a poolMock.execute
|
|
184
|
-
expect(connMock.execute).toHaveBeenCalledTimes(2)
|
|
185
|
-
expect(poolMock.execute).not.toHaveBeenCalled()
|
|
186
|
-
})
|
|
187
|
-
|
|
188
|
-
it('hace rollback y lanza el error si fn falla', async () => {
|
|
189
|
-
const error = new Error('error de negocio')
|
|
190
|
-
|
|
191
|
-
await expect(
|
|
192
|
-
adapter.transaction(async () => {
|
|
193
|
-
throw error
|
|
194
|
-
}),
|
|
195
|
-
).rejects.toThrow('error de negocio')
|
|
196
|
-
|
|
197
|
-
expect(connMock.beginTransaction).toHaveBeenCalledTimes(1)
|
|
198
|
-
expect(connMock.rollback).toHaveBeenCalledTimes(1)
|
|
199
|
-
expect(connMock.commit).not.toHaveBeenCalled()
|
|
200
|
-
expect(connMock.release).toHaveBeenCalledTimes(1)
|
|
201
|
-
})
|
|
202
|
-
|
|
203
|
-
it('libera la conexión aunque el rollback falle (finally garantizado)', async () => {
|
|
204
|
-
connMock.rollback.mockImplementation(async () => {
|
|
205
|
-
throw new Error('rollback falló')
|
|
206
|
-
})
|
|
207
|
-
|
|
208
|
-
await expect(
|
|
209
|
-
adapter.transaction(async () => {
|
|
210
|
-
throw new Error('error original')
|
|
211
|
-
}),
|
|
212
|
-
).rejects.toBeDefined()
|
|
213
|
-
|
|
214
|
-
// release() debe ejecutarse siempre — está en finally
|
|
215
|
-
expect(connMock.release).toHaveBeenCalledTimes(1)
|
|
216
|
-
|
|
217
|
-
// Restaurar implementación para tests siguientes
|
|
218
|
-
connMock.rollback.mockImplementation(async () => {})
|
|
219
|
-
})
|
|
220
|
-
})
|
|
221
|
-
|
|
222
|
-
// ── close() ──────────────────────────────────────────────────────────────────
|
|
223
|
-
describe('close()', () => {
|
|
224
|
-
it('termina el pool', async () => {
|
|
225
|
-
const adapter = makeAdapter()
|
|
226
|
-
await adapter.connect()
|
|
227
|
-
resetMocks()
|
|
228
|
-
|
|
229
|
-
await adapter.close()
|
|
230
|
-
expect(poolMock.end).toHaveBeenCalledTimes(1)
|
|
231
|
-
})
|
|
232
|
-
})
|
|
233
|
-
})
|
|
234
|
-
|
|
235
|
-
// ─────────────────────────────────────────────────────────────────────────────
|
|
236
|
-
// Test de integración real (requiere MySQL corriendo):
|
|
237
|
-
//
|
|
238
|
-
// describe('MySQLAdapter — integración', () => {
|
|
239
|
-
// let adapter: MySQLAdapter
|
|
240
|
-
//
|
|
241
|
-
// beforeAll(async () => {
|
|
242
|
-
// const { MySQLAdapter } = await import('../mysql')
|
|
243
|
-
// adapter = new MySQLAdapter({
|
|
244
|
-
// host: 'localhost',
|
|
245
|
-
// user: 'test',
|
|
246
|
-
// password: 'test',
|
|
247
|
-
// database: 'arckode_test',
|
|
248
|
-
// })
|
|
249
|
-
// await adapter.connect()
|
|
250
|
-
// await adapter.run('CREATE TABLE IF NOT EXISTS test_items (id INT AUTO_INCREMENT PRIMARY KEY, nombre VARCHAR(255))')
|
|
251
|
-
// })
|
|
252
|
-
//
|
|
253
|
-
// afterAll(async () => {
|
|
254
|
-
// await adapter.run('DROP TABLE IF EXISTS test_items')
|
|
255
|
-
// await adapter.close()
|
|
256
|
-
// })
|
|
257
|
-
//
|
|
258
|
-
// it('INSERT + SELECT funcionan con placeholders ?', async () => {
|
|
259
|
-
// await adapter.run('INSERT INTO test_items (nombre) VALUES (?)', ['Laptop'])
|
|
260
|
-
// const rows = await adapter.query('SELECT * FROM test_items WHERE nombre = ?', ['Laptop'])
|
|
261
|
-
// expect(rows).toHaveLength(1)
|
|
262
|
-
// expect((rows[0] as any).nombre).toBe('Laptop')
|
|
263
|
-
// })
|
|
264
|
-
//
|
|
265
|
-
// it('transacción commit persiste los datos', async () => {
|
|
266
|
-
// await adapter.transaction(async (tx) => {
|
|
267
|
-
// await tx.run('INSERT INTO test_items (nombre) VALUES (?)', ['Mouse'])
|
|
268
|
-
// })
|
|
269
|
-
// const rows = await adapter.query('SELECT * FROM test_items WHERE nombre = ?', ['Mouse'])
|
|
270
|
-
// expect(rows).toHaveLength(1)
|
|
271
|
-
// })
|
|
272
|
-
//
|
|
273
|
-
// it('transacción rollback no persiste los datos', async () => {
|
|
274
|
-
// await expect(
|
|
275
|
-
// adapter.transaction(async (tx) => {
|
|
276
|
-
// await tx.run('INSERT INTO test_items (nombre) VALUES (?)', ['Keyboard'])
|
|
277
|
-
// throw new Error('forzar rollback')
|
|
278
|
-
// })
|
|
279
|
-
// ).rejects.toThrow()
|
|
280
|
-
// const rows = await adapter.query('SELECT * FROM test_items WHERE nombre = ?', ['Keyboard'])
|
|
281
|
-
// expect(rows).toHaveLength(0)
|
|
282
|
-
// })
|
|
283
|
-
// })
|
package/adapters/vendor.d.ts
DELETED
|
@@ -1,48 +0,0 @@
|
|
|
1
|
-
// adapters/vendor.d.ts — Minimal type stubs for optional peer dependencies.
|
|
2
|
-
// pg and redis are optional — users install them only when using those adapters.
|
|
3
|
-
// These stubs let TypeScript compile without the packages installed.
|
|
4
|
-
|
|
5
|
-
declare module 'pg' {
|
|
6
|
-
export interface PoolConfig {
|
|
7
|
-
connectionString?: string
|
|
8
|
-
min?: number
|
|
9
|
-
max?: number
|
|
10
|
-
[key: string]: unknown
|
|
11
|
-
}
|
|
12
|
-
|
|
13
|
-
export interface QueryResult<R = Record<string, unknown>> {
|
|
14
|
-
rows: R[]
|
|
15
|
-
rowCount: number | null
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
export interface PoolClient {
|
|
19
|
-
release(): void
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
export class Pool {
|
|
23
|
-
constructor(config?: PoolConfig)
|
|
24
|
-
connect(): Promise<PoolClient>
|
|
25
|
-
query(sql: string, params?: unknown[]): Promise<QueryResult>
|
|
26
|
-
end(): Promise<void>
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
const pg: {
|
|
30
|
-
Pool: typeof Pool
|
|
31
|
-
} & typeof import('pg')
|
|
32
|
-
export default pg
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
declare module 'redis' {
|
|
36
|
-
export type RedisClientType = {
|
|
37
|
-
connect(): Promise<void>
|
|
38
|
-
get(key: string): Promise<string | null>
|
|
39
|
-
set(key: string, value: string): Promise<unknown>
|
|
40
|
-
setEx(key: string, seconds: number, value: string): Promise<unknown>
|
|
41
|
-
del(keys: string | string[]): Promise<unknown>
|
|
42
|
-
keys(pattern: string): Promise<string[]>
|
|
43
|
-
flushAll(): Promise<unknown>
|
|
44
|
-
quit(): Promise<unknown>
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
export function createClient(options?: { url?: string; [key: string]: unknown }): RedisClientType
|
|
48
|
-
}
|