import { NotFoundException } from '@nestjs/common'; import { vi } from 'vitest'; import type { AuditService } from '../audit/audit.service.js'; import type { AuthUser } from '../auth/auth.types.js'; import type { AppConfigService } from '../config/app-config.service.js'; import type { PermissionsService } from '../permissions/permissions.service.js'; import { SapDbService } from './sapdb.service.js'; import type { SapDbAdapter } from './sapdb.types.js'; import { readUserField } from './user-fields.js'; const user: AuthUser = { webUser: 'ana', empId: 7, firstName: 'Ana', lastName: 'P' }; const opts = { timeoutMs: 1000, maxRows: 2 }; /** Una base simulada: responde según el SQL. */ function fakeDb(over: { info?: unknown[]; values?: unknown[]; linked?: unknown[] | Error } = {}) { const calls: { sql: string; args: readonly unknown[] }[] = []; const adapter: SapDbAdapter = { async query(sql, args) { calls.push({ sql, args }); if (sql.includes('"CUFD"')) return (over.info ?? []) as never; if (sql.includes('"UFD1"')) return (over.values ?? []) as never; if (over.linked instanceof Error) throw over.linked; return (over.linked ?? []) as never; }, }; return { adapter, calls }; } describe('campo de usuario de SAP', () => { it('un campo con valores válidos entrega su tipo y sus valores', async () => { const db = fakeDb({ info: [{ FieldID: 12, Descr: 'Estado del lote', TypeID: 'A', EditSize: 3, RTable: null }], values: [{ FldValue: '101', Descr: 'Cuarentena' }, { FldValue: '102', Descr: 'Aprobado' }] }); expect(await readUserField(db.adapter, 'hana', 'OBTN', 'SIS_ESTLOT', opts)).toEqual({ table: 'OBTN', alias: 'SIS_ESTLOT', description: 'Estado del lote', type: 'A', size: 3, validValues: [{ value: '101', description: 'Cuarentena' }, { value: '102', description: 'Aprobado' }], linkedTable: null, linkedRows: [], linkedTruncated: false, }); // el campo y la tabla viajan como argumentos, nunca dentro del SQL expect(db.calls[0].args).toEqual(['OBTN', 'SIS_ESTLOT']); expect(db.calls[1].args).toEqual(['OBTN', 12]); }); it('un campo enlazado a una tabla de usuario entrega sus filas, recortadas al tope', async () => { const db = fakeDb({ info: [{ FieldID: 3, Descr: 'Registro sanitario', TypeID: 'A', EditSize: 50, RTable: 'SIS_REGSAN' }], linked: [{ Code: '1', Name: 'A' }, { Code: '2', Name: null }, { Code: '3', Name: 'C' }] }); const r = await readUserField(db.adapter, 'hana', 'OBTN', 'SIS_REGSAN', opts); expect(r?.linkedTable).toBe('SIS_REGSAN'); expect(r?.linkedRows).toEqual([{ code: '1', name: 'A' }, { code: '2', name: '' }]); expect(r?.linkedTruncated).toBe(true); expect(db.calls[2].sql).toBe('SELECT "Code", "Name" FROM "@SIS_REGSAN" ORDER BY "Code"'); }); it('la tabla enlazada sale del campo y se valida: un nombre raro no llega al SQL', async () => { const db = fakeDb({ info: [{ FieldID: 3, Descr: 'X', TypeID: 'A', EditSize: 5, RTable: 'X"; DROP TABLE "OITM' }] }); const r = await readUserField(db.adapter, 'hana', 'OBTN', 'X', opts); expect(r?.linkedRows).toEqual([]); expect(db.calls).toHaveLength(2); }); it('si el enlace no es a una tabla con Code y Name, el campo se trata como libre; un campo que no existe da null', async () => { const db = fakeDb({ info: [{ FieldID: 3, Descr: 'X', TypeID: 'A', EditSize: 5, RTable: 'OCRD' }], linked: new Error('invalid column name: Code') }); expect((await readUserField(db.adapter, 'hana', 'OBTN', 'X', opts))?.linkedRows).toEqual([]); expect(await readUserField(fakeDb().adapter, 'hana', 'OBTN', 'NADA', opts)).toBeNull(); }); }); describe('SapDbService.userField', () => { const make = (adapter: SapDbAdapter | null) => { const record = vi.fn(async () => undefined); const service = new SapDbService( adapter, { get: (k: string) => ({ SAPDB_ENGINE: 'hana', SAPDB_MAX_CONCURRENCY: 2, SAPDB_MAX_ROWS: 100, SAPDB_TIMEOUT: 5 })[k] } as unknown as AppConfigService, { getPaths: async () => ['*'] } as unknown as PermissionsService, { record } as unknown as AuditService, ); return { service, record }; }; it('rechaza una tabla o un campo con caracteres raros antes de tocar la base', async () => { const db = fakeDb(); const { service } = make(db.adapter); await expect(service.userField(user, 'OBTN"; --', 'X', '')).rejects.toThrow(/inválido/); await expect(service.userField(user, 'OBTN', 'a b', '')).rejects.toThrow(/inválido/); expect(db.calls).toHaveLength(0); }); it('un campo que no existe da 404 y se recuerda (no se vuelve a preguntar)', async () => { const db = fakeDb(); const { service } = make(db.adapter); await expect(service.userField(user, 'OBTN', 'NADA', '')).rejects.toThrow(NotFoundException); const calls = db.calls.length; await expect(service.userField(user, 'obtn', 'nada', '')).rejects.toThrow(NotFoundException); expect(db.calls.length).toBe(calls); }); it('guarda la respuesta en memoria y audita solo la tabla y el campo', async () => { const db = fakeDb({ info: [{ FieldID: 1, Descr: 'E', TypeID: 'A', EditSize: 3, RTable: null }] }); const { service, record } = make(db.adapter); await service.userField(user, 'OBTN', 'SIS_ESTLOT', '10.0.0.5'); const calls = db.calls.length; await service.userField(user, 'OBTN', 'SIS_ESTLOT', '10.0.0.5'); expect(db.calls.length).toBe(calls); expect(record).toHaveBeenCalledWith(expect.objectContaining({ action: 'db_query', resource: 'user-field', detail: { table: 'OBTN', alias: 'SIS_ESTLOT', found: true } })); }); it('sin base configurada responde 503', async () => { await expect(make(null).service.userField(user, 'OBTN', 'X', '')).rejects.toThrow(/no está configurada/); }); });