feat(api): proxy del Service Layer con registro de recursos
El frontend envía parámetros OData y cuerpo; el backend valida y ejecuta. - GET lista/por clave, POST y PATCH sobre /api/sl/:recurso, solo para recursos registrados (ventas, compras, inventario y maestros estándar); EmployeesInfo no se registra nunca (contiene U_WEB_PASS). - Permisos de menú por recurso (lectura/escritura); catálogos de consulta abiertos a cualquier sesión. Evaluador de patrones compartible (/inventario/* ya no habilita /inventario-x). - Parámetros OData validados con lista cerrada, $top máx. 500, $expand solo si el recurso lo declara, claves validadas y escapadas por tipo, cabeceras B1S-* con lista blanca. $inlinecount y B1S-PageSize automáticos. - configureApp() compartido entre main.ts y e2e (prefijo y límite de 5 mb). - Verificado contra SAP real: los 27 recursos existen; listado con total, filtro, paginación y claves. InventoryRequests no existe en el SL: se registra InventoryTransferRequests. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
73fffee2f7
commit
c0990ca83e
15 files changed
+683
-6
No files matched your search
@@ -2,6 +2,7 @@ import { Test } from '@nestjs/testing';
|
||||
import type { INestApplication } from '@nestjs/common';
|
||||
import { AppModule } from '../src/app.module.js';
|
||||
import { SapClientService } from '../src/sap/sap-client.service.js';
|
||||
import { SL_RESOURCES } from '../src/sl/sl-resources.js';
|
||||
|
||||
// Prueba contra un Service Layer real (usa apps/api/.env). Solo con SAP_LIVE=1:
|
||||
// SAP_LIVE=1 npm run test:e2e
|
||||
@@ -26,4 +27,11 @@ describe.skipIf(!process.env.SAP_LIVE)('SAP Service Layer (live)', () => {
|
||||
);
|
||||
expect(res.every((r) => r.status === 200 && Array.isArray(r.data.value))).toBe(true);
|
||||
}, 30000);
|
||||
|
||||
// Detecta nombres de entidad mal escritos o no disponibles en este Service Layer.
|
||||
it.each(SL_RESOURCES.map((r) => r.name))('el recurso registrado %s existe en el Service Layer', async (name) => {
|
||||
const res = await sap.get<{ value: unknown[] }>(name, { $top: 1 });
|
||||
expect(res.status).toBe(200);
|
||||
expect(Array.isArray(res.data.value)).toBe(true);
|
||||
}, 30000);
|
||||
});
|
||||
@@ -0,0 +1,151 @@
|
||||
import type { INestApplication } from '@nestjs/common';
|
||||
import request from 'supertest';
|
||||
import { createTestApp, employee, sapCalls } from './support/create-test-app.js';
|
||||
|
||||
describe('Proxy del Service Layer (e2e)', () => {
|
||||
let app: INestApplication;
|
||||
let admin: { Authorization: string };
|
||||
let luis: { Authorization: string };
|
||||
const http = () => request(app.getHttpServer());
|
||||
|
||||
const loginAs = async (username: string) => {
|
||||
const res = await http().post('/api/auth/login').send({ username, password: 'secreto' }).expect(200);
|
||||
return { Authorization: `Bearer ${res.body.token}` };
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
app = await createTestApp([employee('ana', 1), employee('luis', 2)], 'ana', (req) =>
|
||||
req.method === 'GET' ? { value: [{ ItemCode: 'A' }], 'odata.count': '1' } : { DocEntry: 77, DocNum: 1001 },
|
||||
);
|
||||
admin = await loginAs('ana');
|
||||
luis = await loginAs('luis');
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await app.close();
|
||||
});
|
||||
|
||||
describe('listar', () => {
|
||||
it('arma la consulta OData con total y tamaño de página', async () => {
|
||||
const res = await http()
|
||||
.get('/api/sl/Items')
|
||||
.query({ $select: 'ItemCode, ItemName', $filter: "contains(ItemName,'TUBO')", $orderby: 'ItemCode', $top: 20, $skip: 40 })
|
||||
.set(luis)
|
||||
.expect(200);
|
||||
expect(res.body).toEqual({ value: [{ ItemCode: 'A' }], 'odata.count': '1' });
|
||||
expect(sapCalls.at(-1)).toMatchObject({
|
||||
method: 'GET',
|
||||
path: 'Items',
|
||||
query: {
|
||||
$select: 'ItemCode,ItemName',
|
||||
$filter: "contains(ItemName,'TUBO')",
|
||||
$orderby: 'ItemCode',
|
||||
$top: 20,
|
||||
$skip: 40,
|
||||
$inlinecount: 'allpages',
|
||||
},
|
||||
headers: { 'B1S-PageSize': '20', 'B1S-CaseInsensitive': 'true' },
|
||||
});
|
||||
});
|
||||
|
||||
it('sin $top usa 50 y sin $filter no fuerza mayúsculas/minúsculas', async () => {
|
||||
await http().get('/api/sl/Items').set(luis).expect(200);
|
||||
const call = sapCalls.at(-1)!;
|
||||
expect(call.query).toMatchObject({ $top: 50 });
|
||||
expect(call.headers).toEqual({ 'B1S-PageSize': '50' });
|
||||
});
|
||||
|
||||
it('rechaza parámetros no permitidos y $expand no declarado', async () => {
|
||||
await http().get('/api/sl/Items').query({ $apply: 'x' }).set(luis).expect(400);
|
||||
await http().get('/api/sl/Items').query({ $top: 100000 }).set(luis).expect(400);
|
||||
await http().get('/api/sl/Items').query({ $expand: 'ItemPrices' }).set(luis).expect(400);
|
||||
expect(sapCalls).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('por clave', () => {
|
||||
it('clave numérica y de texto', async () => {
|
||||
await http().get('/api/sl/Orders/15').set(admin).expect(200);
|
||||
expect(sapCalls.at(-1)!.path).toBe('Orders(15)');
|
||||
await http().get("/api/sl/Items/O'NEIL").set(luis).expect(200);
|
||||
expect(sapCalls.at(-1)!.path).toBe("Items('O''NEIL')");
|
||||
});
|
||||
|
||||
it('no deja colar rutas ni acciones en la clave', async () => {
|
||||
await http().get('/api/sl/Orders/1)%2FCancel').set(admin).expect(400);
|
||||
await http().get('/api/sl/Orders/..%2FLogout').set(admin).expect(400);
|
||||
expect(sapCalls).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('recursos', () => {
|
||||
it('lo no registrado es 404 (EmployeesInfo incluido)', async () => {
|
||||
await http().get('/api/sl/EmployeesInfo').set(admin).expect(404);
|
||||
await http().get('/api/sl/Logout').set(admin).expect(404);
|
||||
await http().get('/api/sl/items').set(admin).expect(404);
|
||||
expect(sapCalls).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('sin sesión es 401', async () => {
|
||||
await http().get('/api/sl/Items').expect(401);
|
||||
});
|
||||
|
||||
it('método no permitido: los catálogos de solo lectura no aceptan POST', async () => {
|
||||
await http().post('/api/sl/Warehouses').set(admin).send({ WarehouseCode: 'X' }).expect(405);
|
||||
await http().delete('/api/sl/Orders/1').set(admin).expect(404); // no hay ruta DELETE
|
||||
});
|
||||
});
|
||||
|
||||
describe('permisos de menú', () => {
|
||||
it('un documento exige la ruta de menú; el catálogo de consulta no', async () => {
|
||||
await http().get('/api/sl/Orders').set(luis).expect(403);
|
||||
await http().get('/api/sl/Warehouses').set(luis).expect(200);
|
||||
|
||||
await http().put('/api/permissions/luis').set(admin).send({ paths: ['/ventas/*'] }).expect(200);
|
||||
await http().get('/api/sl/Orders').set(luis).expect(200);
|
||||
await http().get('/api/sl/PurchaseOrders').set(luis).expect(403);
|
||||
});
|
||||
|
||||
it('escribir en un maestro exige el módulo correspondiente', async () => {
|
||||
await http().put('/api/permissions/luis').set(admin).send({ paths: ['/socios/proveedores'] }).expect(200);
|
||||
await http().post('/api/sl/BusinessPartners').set(luis).send({ CardCode: 'P1' }).expect(201);
|
||||
await http().post('/api/sl/Items').set(luis).send({ ItemCode: 'X' }).expect(403);
|
||||
});
|
||||
});
|
||||
|
||||
describe('crear y modificar', () => {
|
||||
it('POST devuelve lo que responde SAP y reenvía el cuerpo', async () => {
|
||||
const body = { CardCode: 'C1', DocumentLines: [{ ItemCode: 'A', Quantity: 2 }] };
|
||||
const res = await http().post('/api/sl/Orders').set(admin).send(body).expect(201);
|
||||
expect(res.body).toEqual({ DocEntry: 77, DocNum: 1001 });
|
||||
expect(sapCalls.at(-1)).toMatchObject({ method: 'POST', path: 'Orders', body });
|
||||
});
|
||||
|
||||
it('PATCH responde 204 y reenvía B1S-ReplaceCollectionsOnPatch decidido por el frontend', async () => {
|
||||
await http()
|
||||
.patch('/api/sl/Orders/15')
|
||||
.set(admin)
|
||||
.set('B1S-ReplaceCollectionsOnPatch', 'true')
|
||||
.send({ Comments: 'x' })
|
||||
.expect(204);
|
||||
expect(sapCalls.at(-1)).toMatchObject({
|
||||
method: 'PATCH',
|
||||
path: 'Orders(15)',
|
||||
body: { Comments: 'x' },
|
||||
headers: { 'B1S-ReplaceCollectionsOnPatch': 'true' },
|
||||
});
|
||||
});
|
||||
|
||||
it('rechaza cuerpos que no son objetos y cabeceras B1S-* no permitidas', async () => {
|
||||
await http().post('/api/sl/Orders').set(admin).send([1, 2]).expect(400);
|
||||
await http().post('/api/sl/Orders').set(admin).set('B1S-Atomic', 'x').send({}).expect(400);
|
||||
await http().patch('/api/sl/Orders/1').set(admin).set('B1S-PageSize', '5').send({}).expect(400);
|
||||
expect(sapCalls).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('un cuerpo grande (documento con muchas líneas) no se corta por tamaño', async () => {
|
||||
const lines = Array.from({ length: 800 }, (_, i) => ({ ItemCode: `ITEM-${i}`, Quantity: 1, ItemDescription: 'x'.repeat(100) }));
|
||||
await http().post('/api/sl/Orders').set(admin).send({ DocumentLines: lines }).expect(201);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Test } from '@nestjs/testing';
|
||||
import type { INestApplication } from '@nestjs/common';
|
||||
import type { NestExpressApplication } from '@nestjs/platform-express';
|
||||
|
||||
// Valores ficticios: los e2e no se conectan a SAP ni a un archivo SQLite.
|
||||
process.env.SAPB1_SL_URL ??= 'https://sap.invalid:50000/b1s/v1';
|
||||
@@ -26,8 +27,24 @@ export const employee = (webUser: string, id = 1, pass = 'secreto'): FakeEmploye
|
||||
U_WEB_PASS: pass,
|
||||
});
|
||||
|
||||
export interface FakeSapRequest {
|
||||
method: string;
|
||||
path: string;
|
||||
query?: Record<string, unknown>;
|
||||
body?: unknown;
|
||||
headers?: Record<string, string>;
|
||||
}
|
||||
|
||||
/** Llamadas que llegaron al "Service Layer" simulado (se vacía en cada createTestApp). */
|
||||
export const sapCalls: FakeSapRequest[] = [];
|
||||
|
||||
/** App completa con EmployeesInfo y la sesión SAP simulados. */
|
||||
export async function createTestApp(employees: FakeEmployee[], adminWebUsers = ''): Promise<INestApplication> {
|
||||
export async function createTestApp(
|
||||
employees: FakeEmployee[],
|
||||
adminWebUsers = '',
|
||||
onSapRequest: (req: FakeSapRequest) => unknown = () => ({}),
|
||||
): Promise<INestApplication> {
|
||||
sapCalls.length = 0;
|
||||
process.env.ADMIN_WEB_USERS = adminWebUsers;
|
||||
const { AppModule } = await import('../../src/app.module.js');
|
||||
const { SapClientService } = await import('../../src/sap/sap-client.service.js');
|
||||
@@ -39,13 +56,18 @@ export async function createTestApp(employees: FakeEmployee[], adminWebUsers = '
|
||||
get: async (_path: string, q: { $filter: string }) => ({
|
||||
data: { value: employees.filter((e) => q.$filter === `U_WEB_USER eq '${e.U_WEB_USER}'`) },
|
||||
}),
|
||||
request: async (req: FakeSapRequest) => {
|
||||
sapCalls.push(req);
|
||||
return { status: 200, headers: {}, data: onSapRequest(req) };
|
||||
},
|
||||
})
|
||||
.overrideProvider(SapSessionService)
|
||||
.useValue({ getSession: async () => ({}), status: () => ({ connected: true }), onModuleDestroy: () => undefined })
|
||||
.compile();
|
||||
|
||||
const app = moduleRef.createNestApplication();
|
||||
app.setGlobalPrefix('api');
|
||||
const { configureApp } = await import('../../src/app.setup.js');
|
||||
const app = moduleRef.createNestApplication<NestExpressApplication>();
|
||||
configureApp(app);
|
||||
await app.init();
|
||||
return app;
|
||||
}
|
||||
Reference in new issue
Block a user