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>
152 lines
6.4 KiB
TypeScript
152 lines
6.4 KiB
TypeScript
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);
|
|
});
|
|
});
|
|
});
|