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
+247
-90
No files matched your search
@@ -27,6 +27,17 @@ function loadTableData(tableName) {
|
|||||||
} catch(err) { return null }
|
} 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
|
// SAS epoch for date/datetime/time formatting
|
||||||
const SAS_EPOCH_MS = Date.UTC(1960, 0, 1)
|
const SAS_EPOCH_MS = Date.UTC(1960, 0, 1)
|
||||||
const MONTHS = ['JAN','FEB','MAR','APR','MAY','JUN','JUL','AUG','SEP','OCT','NOV','DEC']
|
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
|
helpers collide with it, so the envelope is written here the way mpAbort
|
||||||
writes it: the abort member, no CLOSE.
|
writes it: the abort member, no CLOSE.
|
||||||
*/
|
*/
|
||||||
function abortInput(message) {
|
function abortInput(message, mac) {
|
||||||
webout('OPEN')
|
webout('OPEN')
|
||||||
webout('OBJ', 'sasjsAbort', [{ MSG: message, MAC: _program + '.sas' }])
|
webout('OBJ', 'sasjsAbort', [{ MSG: message, MAC: mac || _program + '.sas' }])
|
||||||
const stop = new Error(message)
|
const stop = new Error(message)
|
||||||
stop.mockAbort = true
|
stop.mockAbort = true
|
||||||
throw stop
|
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
|
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 =
|
const withoutCatalog =
|
||||||
requestTable.length > 3 && requestTable.slice(-3).toUpperCase() === '-FC'
|
rawTable.length > 3 && rawTable.slice(-3).toUpperCase() === '-FC'
|
||||||
? requestTable.slice(0, -3)
|
? rawTable.slice(0, -3)
|
||||||
: requestTable
|
: rawTable
|
||||||
if (!requestTable || !TABLE_SHAPE.test(withoutCatalog)) abortInput('Invalid table')
|
if (!rawTable || !TABLE_SHAPE.test(withoutCatalog)) abortInput('Invalid table')
|
||||||
|
const requestTable = withoutCatalog.toUpperCase()
|
||||||
|
|
||||||
const requestFilter = String(
|
const requestFilter = String(
|
||||||
typeof filter === 'undefined' || filter === null ? '' : filter
|
typeof filter === 'undefined' || filter === null ? '' : filter
|
||||||
).trim() || '0'
|
).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)
|
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')
|
abortInput('Invalid filter')
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -130,90 +159,104 @@ const writesWeboutDirectly = requestedType === 'WEBCSV' || requestedType === 'WE
|
|||||||
|
|
||||||
if (!download && !writesWeboutDirectly) abortInput('Invalid type')
|
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)
|
// Generate CSV from the table data (data-driven, not hardcoded)
|
||||||
let csvLines = []
|
let csvLines = []
|
||||||
|
|
||||||
// Determine the table name from the `table` param (format: LIBREF.TABLE)
|
|
||||||
const tableName = requestTable.split('.')[1]
|
const tableName = requestTable.split('.')[1]
|
||||||
|
const exportLibref = requestTable.split('.')[0]
|
||||||
|
|
||||||
const tableData = loadTableData(tableName)
|
/*
|
||||||
if (tableData && tableData.columns && tableData.rows) {
|
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
|
||||||
getrawdata.sas drops the registration's validity columns before it writes
|
registration row. A table that cannot be resolved aborts before anything is
|
||||||
the download (`data staged(drop=&txfrom &txto)`), so a control table never
|
written, and the estate gives one message for both cases, a library it does
|
||||||
carries its history columns into a user's file.
|
not have and a member missing from one it does:
|
||||||
*/
|
|
||||||
const exportLibref = requestTable.split('.')[0]
|
NOLIB.MPE_TABLES -> Target table not found: NOLIB.MPE_TABLES
|
||||||
const registry = loadTableData('MPE_TABLES')
|
DC634301.NO_SUCH_TABLE -> Target table not found: DC634301.NO_SUCH_TABLE
|
||||||
const registration =
|
|
||||||
registry && registry.rows
|
The abort is mp_derivedvars', so the MAC is that macro's own default and not
|
||||||
? registry.rows.find(
|
the service, and the message carries the upcased LIBDS getrawdata.sas builds.
|
||||||
(row) => String(row.libref).toUpperCase() === exportLibref.toUpperCase() &&
|
*/
|
||||||
String(row.dsn).toUpperCase() === tableName.toUpperCase()
|
const tableData = loadTableFrom(exportLibref, tableName)
|
||||||
)
|
if (!tableData || !tableData.columns || !tableData.rows) {
|
||||||
: null
|
abortInput('Target table not found: ' + requestTable, 'MP_ABORT')
|
||||||
const dropped = ['var_txfrom', 'var_txto']
|
}
|
||||||
.map((key) => (registration ? registration[key] : ''))
|
|
||||||
.filter((name) => name && String(name).trim() !== '')
|
/*
|
||||||
.map((name) => String(name).trim().toUpperCase())
|
getrawdata.sas drops the registration's validity columns before it writes
|
||||||
const exportColumns = tableData.columns.filter(
|
the download (`data staged(drop=&txfrom &txto)`), so a control table never
|
||||||
(col) => dropped.indexOf(col.name.toUpperCase()) < 0
|
carries its history columns into a user's file.
|
||||||
|
*/
|
||||||
|
const registry = loadTableData('MPE_TABLES')
|
||||||
|
const registration =
|
||||||
|
registry && registry.rows
|
||||||
|
? registry.rows.find(
|
||||||
|
(row) => String(row.libref).toUpperCase() === exportLibref.toUpperCase() &&
|
||||||
|
String(row.dsn).toUpperCase() === tableName.toUpperCase()
|
||||||
|
)
|
||||||
|
: null
|
||||||
|
const dropped = ['var_txfrom', 'var_txto']
|
||||||
|
.map((key) => (registration ? registration[key] : ''))
|
||||||
|
.filter((name) => name && String(name).trim() !== '')
|
||||||
|
.map((name) => String(name).trim().toUpperCase())
|
||||||
|
const exportColumns = tableData.columns.filter(
|
||||||
|
(col) => dropped.indexOf(col.name.toUpperCase()) < 0
|
||||||
|
)
|
||||||
|
|
||||||
|
// a CSV download opens with a header row, as the SAS export writes one. The
|
||||||
|
// other types stream the same lines under their own content type, and none
|
||||||
|
// of them is a CSV, so they keep starting at the first row
|
||||||
|
if (requestedType === 'CSV') {
|
||||||
|
/* the header the SAS export writes is upper case, whatever case the
|
||||||
|
table was created with. This service carries its own helpers rather
|
||||||
|
than eval'ing dcMockUtils, so it upcases here rather than calling
|
||||||
|
payloadKey. */
|
||||||
|
csvLines.push(
|
||||||
|
exportColumns.map((col) => String(col.name).toUpperCase()).join(',')
|
||||||
)
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// a CSV download opens with a header row, as the SAS export writes one. The
|
// Build column ddtype lookup
|
||||||
// other types stream the same lines under their own content type, and none
|
const colDdtypes = {}
|
||||||
// of them is a CSV, so they keep starting at the first row
|
for (const col of exportColumns) {
|
||||||
if (requestedType === 'CSV') {
|
const fmt = (col.format || '').toLowerCase()
|
||||||
/* the header the SAS export writes is upper case, whatever case the
|
let ddtype = col.type === 'N' ? 'N' : 'C'
|
||||||
table was created with. This service carries its own helpers rather
|
if (fmt.startsWith('datetime') || fmt.startsWith('e8601dt')) ddtype = 'DATETIME'
|
||||||
than eval'ing dcMockUtils, so it upcases here rather than calling
|
else if (fmt.startsWith('date')) ddtype = 'DATE'
|
||||||
payloadKey. */
|
else if (fmt.startsWith('time')) ddtype = 'TIME'
|
||||||
csvLines.push(
|
colDdtypes[col.name.toUpperCase()] = ddtype
|
||||||
exportColumns.map((col) => String(col.name).toUpperCase()).join(',')
|
}
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Build column ddtype lookup
|
for (const row of tableData.rows) {
|
||||||
const colDdtypes = {}
|
const values = []
|
||||||
for (const col of exportColumns) {
|
for (const col of exportColumns) {
|
||||||
const fmt = (col.format || '').toLowerCase()
|
let val = row[col.name]
|
||||||
let ddtype = col.type === 'N' ? 'N' : 'C'
|
if (val === undefined) val = row[col.name.toLowerCase()]
|
||||||
if (fmt.startsWith('datetime') || fmt.startsWith('e8601dt')) ddtype = 'DATETIME'
|
if (val === undefined) {
|
||||||
else if (fmt.startsWith('date')) ddtype = 'DATE'
|
const key = Object.keys(row).find(k => k.toLowerCase() === col.name.toLowerCase())
|
||||||
else if (fmt.startsWith('time')) ddtype = 'TIME'
|
if (key) val = row[key]
|
||||||
colDdtypes[col.name.toUpperCase()] = ddtype
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const row of tableData.rows) {
|
|
||||||
const values = []
|
|
||||||
for (const col of exportColumns) {
|
|
||||||
let val = row[col.name]
|
|
||||||
if (val === undefined) val = row[col.name.toLowerCase()]
|
|
||||||
if (val === undefined) {
|
|
||||||
const key = Object.keys(row).find(k => k.toLowerCase() === col.name.toLowerCase())
|
|
||||||
if (key) val = row[key]
|
|
||||||
}
|
|
||||||
if (val === null || val === undefined) {
|
|
||||||
val = ''
|
|
||||||
} else {
|
|
||||||
const ddtype = colDdtypes[col.name.toUpperCase()]
|
|
||||||
if (ddtype === 'DATE' || ddtype === 'DATETIME' || ddtype === 'TIME') {
|
|
||||||
val = formatSasValue(val, ddtype)
|
|
||||||
} else {
|
|
||||||
val = String(val)
|
|
||||||
}
|
|
||||||
// Quote if it contains a delimiter, a quote or a line break -
|
|
||||||
// a description with a newline is still one CSV record
|
|
||||||
if (val.includes(',') || val.includes('"') || /[\r\n]/.test(val)) {
|
|
||||||
val = '"' + val.replace(/"/g, '""') + '"'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
values.push(val)
|
|
||||||
}
|
}
|
||||||
csvLines.push(values.join(','))
|
if (val === null || val === undefined) {
|
||||||
|
val = ''
|
||||||
|
} else {
|
||||||
|
const ddtype = colDdtypes[col.name.toUpperCase()]
|
||||||
|
if (ddtype === 'DATE' || ddtype === 'DATETIME' || ddtype === 'TIME') {
|
||||||
|
val = formatSasValue(val, ddtype)
|
||||||
|
} else {
|
||||||
|
val = String(val)
|
||||||
|
}
|
||||||
|
// Quote if it contains a delimiter, a quote or a line break -
|
||||||
|
// a description with a newline is still one CSV record
|
||||||
|
if (val.includes(',') || val.includes('"') || /[\r\n]/.test(val)) {
|
||||||
|
val = '"' + val.replace(/"/g, '""') + '"'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
values.push(val)
|
||||||
}
|
}
|
||||||
|
csvLines.push(values.join(','))
|
||||||
}
|
}
|
||||||
|
|
||||||
_webout = csvLines.join('\n')
|
_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 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', {
|
const body = runService(estate, 'public/getrawdata', {
|
||||||
params: { type: 'CSV', table: 'DC_JSLIB.MPE_SELECTBOX', filter }
|
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)
|
assert.equal(headersOf(estate), '', filter)
|
||||||
}
|
}
|
||||||
|
|
||||||
/* %mp_validatecol(...,ISINT,...) is input(&filter,??best32.) with mod 1 = 0,
|
/* And these pass validation. The estate's own answer for them is a downstream
|
||||||
so a decimal written as one and an exponent are both integers to it. The
|
abort (the filter RK does not exist), not "Invalid filter"; the mock does
|
||||||
estate's own answer for these three is a downstream abort (the filter RK
|
not resolve filter RKs, so it streams. */
|
||||||
does not exist), not "Invalid filter", which is what shows validation
|
for (const filter of ['7.0', '1e3', '+5', '007', ' 7 ', '-3']) {
|
||||||
passed; the mock does not resolve filter RKs, so it streams. */
|
|
||||||
for (const filter of ['7.0', '1e3', ' 0 ']) {
|
|
||||||
const body = runService(estate, 'public/getrawdata', {
|
const body = runService(estate, 'public/getrawdata', {
|
||||||
params: { type: 'CSV', table: 'DC_JSLIB.MPE_SELECTBOX', filter }
|
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)
|
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 ──────────────────────────────
|
// ─── admin/exportdb and the shared mpStreamfile ──────────────────────────────
|
||||||
|
|
||||||
test('exportdb streams the DDL as application/text, named <libref>.ddl', async () => {
|
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",
|
"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": []
|
"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",
|
"id": "control_library",
|
||||||
"service": "public/viewtables",
|
"service": "public/viewtables",
|
||||||
|
|||||||
Reference in new issue
Block a user