Merge pull request 'fix(mocks): getrawdata reads the library it was asked for, and aborts when it cannot' (#518) from fix/mock-getrawdata-resolution into release/7.23
Reviewed-on: #518
This commit was merged in pull request #518.
This commit is contained in:
commit
c407f85000
4 files changed
+181
-24
No files matched your search
@@ -27,6 +27,17 @@ function loadTableData(tableName) {
|
||||
} catch(err) { return null }
|
||||
}
|
||||
|
||||
/* The same, from the folder of any library on the Drive: makedata and
|
||||
createdemodata write each library's tables under data/<libref>, and this
|
||||
service downloads from the library it was asked for rather than from the
|
||||
control one. */
|
||||
function loadTableFrom(libref, tableName) {
|
||||
try {
|
||||
const file = nodePath.resolve(dataDir, '..', libref, tableName.toLowerCase() + '.json')
|
||||
return JSON.parse(fs.readFileSync(file, {encoding:'utf8'}).toString())
|
||||
} catch(err) { return null }
|
||||
}
|
||||
|
||||
// SAS epoch for date/datetime/time formatting
|
||||
const SAS_EPOCH_MS = Date.UTC(1960, 0, 1)
|
||||
const MONTHS = ['JAN','FEB','MAR','APR','MAY','JUN','JUL','AUG','SEP','OCT','NOV','DEC']
|
||||
@@ -100,27 +111,45 @@ const DOWNLOAD_TYPES = {
|
||||
helpers collide with it, so the envelope is written here the way mpAbort
|
||||
writes it: the abort member, no CLOSE.
|
||||
*/
|
||||
function abortInput(message) {
|
||||
function abortInput(message, mac) {
|
||||
webout('OPEN')
|
||||
webout('OBJ', 'sasjsAbort', [{ MSG: message, MAC: _program + '.sas' }])
|
||||
webout('OBJ', 'sasjsAbort', [{ MSG: message, MAC: mac || _program + '.sas' }])
|
||||
const stop = new Error(message)
|
||||
stop.mockAbort = true
|
||||
throw stop
|
||||
}
|
||||
|
||||
const requestTable = String(typeof table === 'undefined' || table === null ? '' : table).trim()
|
||||
const rawTable = String(typeof table === 'undefined' || table === null ? '' : table).trim()
|
||||
const TABLE_SHAPE = /^[_a-z]\w{0,7}\.[_a-z]\w{0,31}$/i
|
||||
/* %mpe_validatecol(...,LIBDS,...) strips a trailing -FC before validating, and
|
||||
getrawdata.sas reads LIBREF.CAT-FC as a format catalog and then works with
|
||||
the stripped pair (`table=cats("&lib..",ds)`), so the mock keeps ONE
|
||||
canonical name from here on: the guard, the filename, the library it reads
|
||||
and the name in an abort all use the stripped form rather than validating
|
||||
one name and using another. */
|
||||
const withoutCatalog =
|
||||
requestTable.length > 3 && requestTable.slice(-3).toUpperCase() === '-FC'
|
||||
? requestTable.slice(0, -3)
|
||||
: requestTable
|
||||
if (!requestTable || !TABLE_SHAPE.test(withoutCatalog)) abortInput('Invalid table')
|
||||
rawTable.length > 3 && rawTable.slice(-3).toUpperCase() === '-FC'
|
||||
? rawTable.slice(0, -3)
|
||||
: rawTable
|
||||
if (!rawTable || !TABLE_SHAPE.test(withoutCatalog)) abortInput('Invalid table')
|
||||
const requestTable = withoutCatalog.toUpperCase()
|
||||
|
||||
const requestFilter = String(
|
||||
typeof filter === 'undefined' || filter === null ? '' : filter
|
||||
).trim() || '0'
|
||||
/* ISINT is input(&filter,??best32.) with mod 1 = 0, and the estate's recorded
|
||||
answers show the whole value has to read as a number: it rejects 1,000,
|
||||
1abc and 1.5x, which a leading-numeric-prefix read would take, and accepts
|
||||
7.0, 1e3, +5, 007 and -3. Number() alone is the wrong shape test - it reads
|
||||
0x10 as 16, which the informat does not - so the number is matched first and
|
||||
then asked whether it is whole. */
|
||||
const FILTER_NUMBER = /^[+-]?(\d+(\.\d*)?|\.\d+)([eE][+-]?\d+)?$/
|
||||
const filterValue = Number(requestFilter)
|
||||
if (!isFinite(filterValue) || Math.floor(filterValue) !== filterValue) {
|
||||
if (
|
||||
!FILTER_NUMBER.test(requestFilter) ||
|
||||
!isFinite(filterValue) ||
|
||||
Math.floor(filterValue) !== filterValue
|
||||
) {
|
||||
abortInput('Invalid filter')
|
||||
}
|
||||
|
||||
@@ -130,22 +159,37 @@ const writesWeboutDirectly = requestedType === 'WEBCSV' || requestedType === 'WE
|
||||
|
||||
if (!download && !writesWeboutDirectly) abortInput('Invalid type')
|
||||
|
||||
const filename = download ? requestTable.toUpperCase() + '.' + download.ext : ''
|
||||
const filename = download ? requestTable + '.' + download.ext : ''
|
||||
|
||||
// Generate CSV from the table data (data-driven, not hardcoded)
|
||||
let csvLines = []
|
||||
|
||||
// Determine the table name from the `table` param (format: LIBREF.TABLE)
|
||||
const tableName = requestTable.split('.')[1]
|
||||
const exportLibref = requestTable.split('.')[0]
|
||||
|
||||
/*
|
||||
getrawdata.sas assigns the library it was asked for - %dc_assignlib(READ,&lib)
|
||||
- and reads the data from there; the control library supplies only the
|
||||
registration row. A table that cannot be resolved aborts before anything is
|
||||
written, and the estate gives one message for both cases, a library it does
|
||||
not have and a member missing from one it does:
|
||||
|
||||
NOLIB.MPE_TABLES -> Target table not found: NOLIB.MPE_TABLES
|
||||
DC634301.NO_SUCH_TABLE -> Target table not found: DC634301.NO_SUCH_TABLE
|
||||
|
||||
The abort is mp_derivedvars', so the MAC is that macro's own default and not
|
||||
the service, and the message carries the upcased LIBDS getrawdata.sas builds.
|
||||
*/
|
||||
const tableData = loadTableFrom(exportLibref, tableName)
|
||||
if (!tableData || !tableData.columns || !tableData.rows) {
|
||||
abortInput('Target table not found: ' + requestTable, 'MP_ABORT')
|
||||
}
|
||||
|
||||
const tableData = loadTableData(tableName)
|
||||
if (tableData && tableData.columns && tableData.rows) {
|
||||
/*
|
||||
getrawdata.sas drops the registration's validity columns before it writes
|
||||
the download (`data staged(drop=&txfrom &txto)`), so a control table never
|
||||
carries its history columns into a user's file.
|
||||
*/
|
||||
const exportLibref = requestTable.split('.')[0]
|
||||
const registry = loadTableData('MPE_TABLES')
|
||||
const registration =
|
||||
registry && registry.rows
|
||||
@@ -214,7 +258,6 @@ if (tableData && tableData.columns && tableData.rows) {
|
||||
}
|
||||
csvLines.push(values.join(','))
|
||||
}
|
||||
}
|
||||
|
||||
_webout = csvLines.join('\n')
|
||||
|
||||
|
||||
@@ -209,11 +209,15 @@ test('getrawdata validates the table before it streams, as the estate does', ()
|
||||
}
|
||||
})
|
||||
|
||||
test('getrawdata validates the filter as an integer, as ISINT reads it', () => {
|
||||
test('getrawdata reads the filter the way the estate does', () => {
|
||||
const estate = seededEstate({ demo: false })
|
||||
const rejected = ['abc', '1.5', '1,000']
|
||||
|
||||
for (const filter of rejected) {
|
||||
/* Recorded from the synthetic estate: each of these is answered "Invalid
|
||||
filter" and nothing is written. 1,000 and 1abc are the interesting ones -
|
||||
ISINT's input(&filter,??best32.) reading would take a leading numeric
|
||||
prefix, but the estate rejects them, so the whole value has to read as a
|
||||
number - and 0x10 is 16 to Number() and invalid there. */
|
||||
for (const filter of ['abc', '1.5', '1,000', '1abc', '1.5x', '0x10']) {
|
||||
const body = runService(estate, 'public/getrawdata', {
|
||||
params: { type: 'CSV', table: 'DC_JSLIB.MPE_SELECTBOX', filter }
|
||||
})
|
||||
@@ -225,20 +229,82 @@ test('getrawdata validates the filter as an integer, as ISINT reads it', () => {
|
||||
assert.equal(headersOf(estate), '', filter)
|
||||
}
|
||||
|
||||
/* %mp_validatecol(...,ISINT,...) is input(&filter,??best32.) with mod 1 = 0,
|
||||
so a decimal written as one and an exponent are both integers to it. The
|
||||
estate's own answer for these three is a downstream abort (the filter RK
|
||||
does not exist), not "Invalid filter", which is what shows validation
|
||||
passed; the mock does not resolve filter RKs, so it streams. */
|
||||
for (const filter of ['7.0', '1e3', ' 0 ']) {
|
||||
/* And these pass validation. The estate's own answer for them is a downstream
|
||||
abort (the filter RK does not exist), not "Invalid filter"; the mock does
|
||||
not resolve filter RKs, so it streams. */
|
||||
for (const filter of ['7.0', '1e3', '+5', '007', ' 7 ', '-3']) {
|
||||
const body = runService(estate, 'public/getrawdata', {
|
||||
params: { type: 'CSV', table: 'DC_JSLIB.MPE_SELECTBOX', filter }
|
||||
})
|
||||
assert.doesNotMatch(body, /sasjsAbort/, filter)
|
||||
assert.doesNotMatch(body, /Invalid filter/, filter)
|
||||
assert.equal(headersOf(estate).split('\n')[0], 'Content-Type: application/csv', filter)
|
||||
}
|
||||
})
|
||||
|
||||
test('getrawdata resolves the catalog form to one canonical name', () => {
|
||||
const estate = seededEstate({ demo: false })
|
||||
|
||||
/*
|
||||
%mpe_validatecol(...,LIBDS,...) strips the -FC suffix before validating, and
|
||||
getrawdata.sas then works with the stripped pair - so a request must not
|
||||
validate one name and name the file after another. The estate answers a real
|
||||
LIBREF.CAT-FC by running %mp_cntlout on the catalog; the mock has no catalog
|
||||
support, so the body is the member's rows and its filename is the stripped
|
||||
LIBREF.DS.
|
||||
*/
|
||||
const body = runService(estate, 'public/getrawdata', {
|
||||
params: { type: 'CSV', table: 'DC_JSLIB.MPE_SELECTBOX-FC', filter: '0' }
|
||||
})
|
||||
|
||||
assert.doesNotMatch(body, /sasjsAbort/)
|
||||
assert.deepEqual(headersOf(estate).split('\n'), [
|
||||
'Content-Type: application/csv',
|
||||
'Content-disposition: attachment; filename=DC_JSLIB.MPE_SELECTBOX.csv'
|
||||
])
|
||||
})
|
||||
|
||||
test('getrawdata downloads from the library it was asked for', () => {
|
||||
/* DC_JSLIB is the control library; the data comes from the library named in
|
||||
the LIBDS, which is what %dc_assignlib(READ,&lib) in getrawdata.sas does
|
||||
and what createdemodata writes TESTDATA for. */
|
||||
const estate = seededEstate()
|
||||
const body = runService(estate, 'public/getrawdata', {
|
||||
params: { type: 'CSV', table: 'TESTDATA.DEMO_ORDERS', filter: '0' }
|
||||
})
|
||||
|
||||
assert.doesNotMatch(body, /sasjsAbort/)
|
||||
assert.ok(body.split('\n').length > 2, 'the export holds rows of the requested library')
|
||||
assert.deepEqual(headersOf(estate).split('\n'), [
|
||||
'Content-Type: application/csv',
|
||||
'Content-disposition: attachment; filename=TESTDATA.DEMO_ORDERS.csv'
|
||||
])
|
||||
})
|
||||
|
||||
test('getrawdata aborts a table it cannot resolve, as the estate does', () => {
|
||||
const estate = seededEstate({ demo: false })
|
||||
const cases = [
|
||||
// a library the estate does not have: recorded as Target table not found
|
||||
['NOLIB.MPE_TABLES', 'Target table not found: NOLIB.MPE_TABLES'],
|
||||
// a member missing from a library it does have: the same message
|
||||
['DC_JSLIB.NO_SUCH_TABLE', 'Target table not found: DC_JSLIB.NO_SUCH_TABLE'],
|
||||
// the message carries the upcased LIBDS getrawdata.sas builds
|
||||
['nolib.mpe_tables', 'Target table not found: NOLIB.MPE_TABLES']
|
||||
]
|
||||
|
||||
for (const [table, message] of cases) {
|
||||
const body = runService(estate, 'public/getrawdata', {
|
||||
params: { type: 'CSV', table, filter: '0' }
|
||||
})
|
||||
// the abort is mp_derivedvars', so the MAC is that macro's default
|
||||
assert.deepEqual(
|
||||
JSON.parse(body).sasjsAbort,
|
||||
[{ MSG: message, MAC: 'MP_ABORT' }],
|
||||
table
|
||||
)
|
||||
assert.equal(headersOf(estate), '', table)
|
||||
}
|
||||
})
|
||||
|
||||
// ─── admin/exportdb and the shared mpStreamfile ──────────────────────────────
|
||||
|
||||
test('exportdb streams the DDL as application/text, named <libref>.ddl', async () => {
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"_meta": {
|
||||
"scenario": "missing_table",
|
||||
"service": "public/getrawdata",
|
||||
"estate": "synthetic",
|
||||
"targetType": "SASJS",
|
||||
"sas": "9.4M9",
|
||||
"dc": "7.22.0",
|
||||
"captured": "2026-11-07",
|
||||
"tls": "verified",
|
||||
"members": [
|
||||
"sasjsAbort"
|
||||
]
|
||||
},
|
||||
"body": {
|
||||
"sasjsAbort": [
|
||||
{
|
||||
"MSG": "Target table not found: <LIBREF>.NO_SUCH_TABLE",
|
||||
"MAC": "MP_ABORT"
|
||||
}
|
||||
],
|
||||
"SYSCC": "0"
|
||||
}
|
||||
}
|
||||
@@ -272,6 +272,30 @@
|
||||
"note": "the download path: a table as the client saves it, CSV with a header row and the registration's validity columns dropped. Parsed to rows on both sides, because SAS quotes and pads each cell to the variable's width",
|
||||
"accepted": []
|
||||
},
|
||||
{
|
||||
"id": "missing_table",
|
||||
"service": "public/getrawdata",
|
||||
"params": {
|
||||
"type": "CSV",
|
||||
"table": "{{LIBREF}}.NO_SUCH_TABLE",
|
||||
"filter": "0"
|
||||
},
|
||||
"mockParams": {
|
||||
"type": "CSV",
|
||||
"table": "DC_JSLIB.NO_SUCH_TABLE",
|
||||
"filter": "0"
|
||||
},
|
||||
"members": [
|
||||
"sasjsAbort"
|
||||
],
|
||||
"note": "a download for a table that cannot be resolved: the abort names the full LIBDS and comes from mp_derivedvars, so the MAC is the macro's default rather than the service. The same answer covers a library the estate does not have",
|
||||
"accepted": [
|
||||
{
|
||||
"path": ".SYSCC",
|
||||
"reason": "the SAS estate's wrapper adds SYSCC to an aborted response; a JS program's envelope is finalised at CLOSE, and the abort stops before it, so the mock's abort carries none. The message and the macro name - what the client renders - do match"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "control_library",
|
||||
"service": "public/viewtables",
|
||||
|
||||
Reference in new issue
Block a user