Compare commits
22
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bda5dea33e | ||
|
|
ef7fccd5ab | ||
|
|
39dfdf1230 | ||
|
|
ad732f42b6 | ||
|
|
bb771e9f6f | ||
|
|
719964f0af | ||
|
|
8ed51607a8 | ||
|
|
26a8818f26 | ||
|
|
d4335e83c6 | ||
|
|
4be34a760e | ||
|
|
b0bb57ecad | ||
|
|
08fe488ee2 | ||
|
|
d90ad6b944 | ||
|
|
a3f76b45b2 | ||
|
|
0cfcd7dcc8 | ||
|
|
1f1ebc6aa4 | ||
|
|
98c93127e2 | ||
|
|
dd3a8b2f83 | ||
|
|
96f70d53b6 | ||
|
|
d189fd4055 | ||
|
|
5aa8cd75fc | ||
|
|
8e2e407837 |
No files matched your search
@@ -15,16 +15,37 @@ concurrency:
|
||||
|
||||
env:
|
||||
# the server the mocked services and app tests run against - bump deliberately.
|
||||
#
|
||||
# 4.14.0 is the pin because it is the first release that compresses its
|
||||
# responses and sends cache headers for the app it streams (sasjs/server#575).
|
||||
# That is not cosmetic here: the audit runs against this server, and the app it
|
||||
# serves is a single 4.83MB build output. Measured on the previous pin, same
|
||||
# build and same machine: FCP 8.48s and 0.34 performance with the assets served
|
||||
# raw, 2.81s and 0.49 with the same bytes gzipped and hashed files immutable.
|
||||
#
|
||||
# 4.0.0's breaking change is the auth statuses - `POST /SASjsApi/auth/token` and
|
||||
# `/auth/refresh` answer 401 where they answered 403, the undocumented DELETE on
|
||||
# the logout route is gone, and `GET /SASLogon/logout` answers 500 where it
|
||||
# answered 403 when the session store fails. The estate does touch all three,
|
||||
# through @sasjs/adapter: auth/AuthManager.js logs out with a GET on the logout
|
||||
# route and auth/getAccessTokenForSasjs.js posts the token. What it keys on is
|
||||
# the call succeeding rather than the code being 403 in particular, so 403 to
|
||||
# 401 changes nothing here - and the suite on this pin checks the logout path
|
||||
# still completes.
|
||||
#
|
||||
# The floors this pin also carries, kept because a future bump has to keep
|
||||
# clearing them:
|
||||
#
|
||||
# 3.12.0 is the floor: /ddc-host carries `timing=early` from there, which
|
||||
# client/cypress/e2e/va-ddc-host.cy.ts needs. 3.12.2 also carries the
|
||||
# packaged-executable fix for the same page, so the version the tests run
|
||||
# against is the one that ships.
|
||||
#
|
||||
# 3.14.0 ships @sasjs/lint 5.0.0 (the studio reports OS commands) and is the
|
||||
# release the estate runs. It still declares @sasjs/utils ^4.7.0, so the
|
||||
# envelope *formats* (SYSCC as text, DATE7, TIME5, E8601DT26.6 from utils 4.8)
|
||||
# are only guaranteed once the next server release raises that floor - a fresh
|
||||
# install resolves 4.8.0 today, a cached tree need not. Bump again when it lands.
|
||||
# 3.14.0 ships @sasjs/lint 5.0.0 (the studio reports OS commands). It declared
|
||||
# @sasjs/utils ^4.7.0, which is why the envelope *formats* (SYSCC as text,
|
||||
# DATE7, TIME5, E8601DT26.6) used to depend on what a fresh install resolved:
|
||||
# 4.8 introduced them, a 4.7 tree did not have them. 4.14.0 declares ^4.8.0,
|
||||
# so the pin guarantees them now rather than being a floor that hoped for them.
|
||||
#
|
||||
# 3.13.0 is the one that matters for licensing: @sasjs/utils 4.7 reports
|
||||
# SYSSITE, which %webout takes from &syssite. Before it the prelude wrote an
|
||||
@@ -36,9 +57,9 @@ env:
|
||||
# options argument. getdata, viewdata and getcolvals pass { showmeta }, and a
|
||||
# 4.5 install drops it without a word - so the pin is what makes the metadata
|
||||
# members guaranteed here rather than whatever a fresh install happens to
|
||||
# resolve. requireWeboutOptions in sasjs/mocks/sasjs/services/dcMockUtils.js
|
||||
# refuses a runtime without it.
|
||||
SASJS_SERVER_VERSION: '3.14.0'
|
||||
# resolve. sas/mocks/test/harness-webout.test.js is what holds that: showmeta
|
||||
# is written beside the rows, before CLOSE.
|
||||
SASJS_SERVER_VERSION: '4.14.0'
|
||||
NODE_VERSION: '24.15.0'
|
||||
|
||||
jobs:
|
||||
|
||||
@@ -7,7 +7,9 @@ on:
|
||||
|
||||
env:
|
||||
# the server the mocked services and app tests run against - bump deliberately
|
||||
SASJS_SERVER_VERSION: '3.14.0'
|
||||
# (build.yaml carries the full rationale; this workflow runs the same mocked
|
||||
# estate, so it stays on the same pin).
|
||||
SASJS_SERVER_VERSION: '4.14.0'
|
||||
NODE_VERSION: '24.15.0'
|
||||
|
||||
jobs:
|
||||
|
||||
@@ -11,7 +11,9 @@ on:
|
||||
|
||||
env:
|
||||
# the server the mocked services and app tests run against - bump deliberately
|
||||
SASJS_SERVER_VERSION: '3.14.0'
|
||||
# (build.yaml carries the full rationale; this workflow runs the same mocked
|
||||
# estate, so it stays on the same pin).
|
||||
SASJS_SERVER_VERSION: '4.14.0'
|
||||
NODE_VERSION: '24.15.0'
|
||||
APP_LOC: /Public/app/dc
|
||||
SURFER_HOST: https://demo.datacontroller.io
|
||||
|
||||
@@ -148,13 +148,15 @@ for (const table of tables) {
|
||||
|
||||
// ─── Stream it back as a download, as mp_streamfile does ─────────────────────
|
||||
|
||||
/* exportdb.sas streams with %mp_streamfile(contenttype=TEXT
|
||||
,outname=&dc_libref..ddl): an application/text attachment named
|
||||
<libref>.ddl, unquoted. Confirmed against the estate's own response
|
||||
(`content-type: application/text`,
|
||||
`content-disposition: attachment; filename=DC634301.ddl`). */
|
||||
const filename = `${libref}.ddl`
|
||||
fs.writeFile(
|
||||
fs.writeFileSync(
|
||||
_SASJS_WEBOUT_HEADERS,
|
||||
`Content-type: text/plain \n Content-Disposition: attachment;filename="${filename}"`,
|
||||
function (err) {
|
||||
if (err) throw err
|
||||
}
|
||||
`Content-Type: application/text\nContent-disposition: attachment; filename=${filename}`
|
||||
)
|
||||
|
||||
_webout = lines.join('\n') + '\n'
|
||||
File diff suppressed because it is too large.
Load diff
@@ -207,6 +207,17 @@ function _parseCsvLine(line) {
|
||||
|
||||
// ─── SAS format <-> ISO conversions ──────────────────────────────────────────
|
||||
|
||||
/** The key a payload reports a column under.
|
||||
*
|
||||
* SAS upcases the names it hands the client - the getdata, getcols and viewdata
|
||||
* payloads all arrive upper case - while a table keeps the case it was created
|
||||
* with, which is what the DDL reports. So the metadata stores the creation case
|
||||
* and every payload goes through here.
|
||||
*/
|
||||
function payloadKey(col) {
|
||||
return String((col && col.name) || '').toUpperCase()
|
||||
}
|
||||
|
||||
/**
|
||||
* Column metadata, mirroring %mp_getcols.sas.
|
||||
*
|
||||
@@ -245,7 +256,9 @@ function mpGetcols(schemaCols) {
|
||||
NAME: name,
|
||||
TYPE: isChar ? 'C' : 'N',
|
||||
LENGTH: Number(col.length) || 0,
|
||||
LABEL: col.label || col.name,
|
||||
// mp_getcols falls back to the name as the payload reports it -
|
||||
// upper case, whatever case the table was created with
|
||||
LABEL: col.label || name,
|
||||
VARNUM: index + 1,
|
||||
FORMAT: String(col.format || ''),
|
||||
FMTNAME: fmtname,
|
||||
@@ -602,10 +615,10 @@ function notNullRules(schema) {
|
||||
function sasVarsEntry(col) {
|
||||
const ddtype = getDdType(col)
|
||||
if (ddtype === 'DATE' || ddtype === 'DATETIME' || ddtype === 'TIME') {
|
||||
return { format: '$200.', label: col.label || col.name, length: '200', type: 'char' }
|
||||
return { format: '$200.', label: col.label || payloadKey(col), length: '200', type: 'char' }
|
||||
}
|
||||
const fmt = col.type === 'N' ? (col.format || 'best.') : ('$' + col.length + '.')
|
||||
return { format: fmt, label: col.label || col.name, length: String(col.length), type: col.type === 'N' ? 'num' : 'char' }
|
||||
return { format: fmt, label: col.label || payloadKey(col), length: String(col.length), type: col.type === 'N' ? 'num' : 'char' }
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -889,9 +902,11 @@ function mpStreamfile(opts) {
|
||||
if (typeof Buffer === 'undefined') return false
|
||||
// A content-type header is what tells the SASjs Server to send webout.txt as
|
||||
// binary rather than a string (api/src/controllers/internal/Execution.ts).
|
||||
/* One header per line, as %mfs_httpheader writes them, and the filename
|
||||
unquoted: %mp_streamfile sends `attachment; filename=&outname`. */
|
||||
fs.writeFileSync(
|
||||
_SASJS_WEBOUT_HEADERS,
|
||||
`Content-type: ${opts.contentType} \n Content-Disposition: attachment;filename="${opts.filename}"`
|
||||
`Content-Type: ${opts.contentType}\nContent-disposition: attachment; filename=${opts.filename}`
|
||||
)
|
||||
fs.writeFileSync(weboutPath, Buffer.from(opts.bytes))
|
||||
return true
|
||||
|
||||
@@ -457,11 +457,11 @@ if (tableData && tableData.columns && tableData.rows) {
|
||||
const normalised = {}
|
||||
for (const key of Object.keys(r)) {
|
||||
const info = colLookup[key.toLowerCase()]
|
||||
const targetKey = info ? info.name : key
|
||||
const targetKey = info ? payloadKey(info) : key
|
||||
normalised[targetKey] = info ? formatCellValue(r[key], info.ddtype) : r[key]
|
||||
}
|
||||
for (const col of charColumns) {
|
||||
const value = normalised[col.name]
|
||||
const value = normalised[payloadKey(col)]
|
||||
/*
|
||||
Only a cell the row does not carry is padded: that is SAS's
|
||||
missing, which the writer sends as one space. A value the row does
|
||||
@@ -472,9 +472,9 @@ if (tableData && tableData.columns && tableData.rows) {
|
||||
as modified on load).
|
||||
*/
|
||||
if (value === undefined || value === null) {
|
||||
normalised[col.name] = ' '
|
||||
normalised[payloadKey(col)] = ' '
|
||||
} else if (String(value).trim() !== '') {
|
||||
normalised[col.name] = String(value).trim()
|
||||
normalised[payloadKey(col)] = String(value).trim()
|
||||
}
|
||||
}
|
||||
return { ...normalised }
|
||||
@@ -487,14 +487,15 @@ if (tableData && tableData.columns && tableData.rows) {
|
||||
the order it was read in.
|
||||
*/
|
||||
if (pkColumns.length) {
|
||||
// the row keys keep the DDL's casing, so the sort keys are resolved to
|
||||
// the column's own name rather than uppercased: a lowercase DDL would
|
||||
// otherwise compare undefined against undefined and lose the order
|
||||
// the rows are keyed the way the payload reports a column, so the sort
|
||||
// keys are resolved through payloadKey: a table created with lower-case
|
||||
// names would otherwise compare undefined against undefined and lose
|
||||
// the order the grid's duplicate detection depends on
|
||||
const keys = pkColumns.map(name => {
|
||||
const match = visibleColumns.find(
|
||||
col => col.name.toUpperCase() === name.toUpperCase()
|
||||
)
|
||||
return match ? match.name : name
|
||||
return match ? payloadKey(match) : name
|
||||
})
|
||||
augmentedRows.sort((a, b) => {
|
||||
for (const key of keys) {
|
||||
@@ -513,18 +514,20 @@ if (tableData && tableData.columns && tableData.rows) {
|
||||
// $sasdata vars: temporal columns are char ($200.), others keep their SAS type
|
||||
const vars = {}
|
||||
for (const col of visibleColumns) {
|
||||
vars[col.name] = sasVarsEntry(col)
|
||||
vars[payloadKey(col)] = sasVarsEntry(col)
|
||||
}
|
||||
|
||||
const colHeaders = visibleColumns.map(c => c.name)
|
||||
const colHeaders = visibleColumns.map(c => payloadKey(c))
|
||||
|
||||
// Determine DTVARS, DTTMVARS, TMVARS from column formats
|
||||
// Determine DTVARS, DTTMVARS, TMVARS from column formats. These are matched
|
||||
// against the grid's headers by the client, so they are payload keys like
|
||||
// every other name the response carries.
|
||||
let dtVars = [], dttmVars = [], tmVars = []
|
||||
for (const col of visibleColumns) {
|
||||
const ddtype = getDdType(col)
|
||||
if (ddtype === 'DATETIME') dttmVars.push(col.name)
|
||||
else if (ddtype === 'DATE') dtVars.push(col.name)
|
||||
else if (ddtype === 'TIME') tmVars.push(col.name)
|
||||
if (ddtype === 'DATETIME') dttmVars.push(payloadKey(col))
|
||||
else if (ddtype === 'DATE') dtVars.push(payloadKey(col))
|
||||
else if (ddtype === 'TIME') tmVars.push(payloadKey(col))
|
||||
}
|
||||
|
||||
// Build cols array. Every column of the table, as getdata.sas emits them:
|
||||
@@ -564,8 +567,8 @@ if (tableData && tableData.columns && tableData.rows) {
|
||||
// leaves COLTYPE blank for DATE/DATETIME/TIME and the client types those
|
||||
// from DDTYPE
|
||||
if (ddtype === 'DATE' || ddtype === 'DATETIME' || ddtype === 'TIME') coltype = ' '
|
||||
else if (ddtype === 'N') coltype = `{"data":"${raw.name}","type":"numeric","format":"0"}`
|
||||
else coltype = `{"data":"${raw.name}"}`
|
||||
else if (ddtype === 'N') coltype = `{"data":"${payloadKey(raw)}","type":"numeric","format":"0"}`
|
||||
else coltype = `{"data":"${payloadKey(raw)}"}`
|
||||
const entry = dictionary[String(c.NAME).toUpperCase()]
|
||||
return {
|
||||
NAME: c.NAME,
|
||||
@@ -586,7 +589,7 @@ if (tableData && tableData.columns && tableData.rows) {
|
||||
let maxLen = 0
|
||||
const lcName = col.name.toLowerCase()
|
||||
for (const row of augmentedRows) {
|
||||
let val = row[col.name]
|
||||
let val = row[payloadKey(col)]
|
||||
if (val === undefined) val = row[lcName]
|
||||
if (val !== undefined && val !== null) {
|
||||
// SAS measures the value as stored, so a blank character
|
||||
|
||||
@@ -30,25 +30,35 @@ if (libds) {
|
||||
|
||||
// ─── Load the table schema ────────────────────────────────────────────────────
|
||||
/*
|
||||
getcols.sas validates LIBDS against the libraries it can assign before it
|
||||
reads anything (%mpe_validatecol, then dc_assignlib), so a LIBDS naming a
|
||||
library the estate does not have aborts "Invalid libds" - it does not go
|
||||
looking for that member somewhere else. The mock used to search every libref,
|
||||
which answered for a request the estate refuses, and a missing or malformed
|
||||
LIBDS arrives at the same abort.
|
||||
Two checks, and the estate answers them differently.
|
||||
|
||||
A member missing from a library that does exist is a different path: the
|
||||
estate fails inside its query, and there is no message there worth copying.
|
||||
"Invalid libds" comes from getcols.sas:42, and it is guarded on
|
||||
%mp_validatecol(_libds,LIBDS,...) - the SHAPE of the LIBDS, a libref of eight
|
||||
characters or fewer and a member of 32 or fewer, not whether the library
|
||||
exists. So a malformed LIBDS is that abort, and only a malformed one.
|
||||
|
||||
Everything else that cannot be resolved - a well-formed libref the estate does
|
||||
not have, or a member missing from a library it does have - reaches the second
|
||||
abort at getcols.sas:66-68, msg=syscc=&syscc. What that says today is
|
||||
syscc=3000 with a SAS syntax error behind it ("Numeric value found where
|
||||
character value needed"), for all three of those inputs, which is a defect in
|
||||
the service rather than a designed message. The mock reproduces it as it is:
|
||||
the client renders the same abort either way, and inventing a cleaner one here
|
||||
would describe an estate nobody runs.
|
||||
*/
|
||||
const knownLib = libds
|
||||
? allLibrefs().find(lib => lib.toUpperCase() === libref.toUpperCase())
|
||||
: null
|
||||
if (!libds || !table || !knownLib) {
|
||||
const LIBDS_SHAPE = /^[_a-z]\w{0,7}\.[_a-z]\w{0,31}$/i
|
||||
if (!LIBDS_SHAPE.test(String(libds || ''))) {
|
||||
mpAbort('Invalid libds', _program + '.sas')
|
||||
}
|
||||
libref = knownLib
|
||||
|
||||
const tableData = makeTableLoader(libDataDir(libref))(table) || null
|
||||
const knownLib = allLibrefs().find(
|
||||
lib => lib.toUpperCase() === libref.toUpperCase()
|
||||
)
|
||||
const tableData = knownLib
|
||||
? makeTableLoader(libDataDir(knownLib))(table) || null
|
||||
: null
|
||||
if (!knownLib || !tableData) {
|
||||
mpAbort('syscc=3000', _program + '.sas')
|
||||
}
|
||||
|
||||
// ─── Build the column list ────────────────────────────────────────────────────
|
||||
// Every column, as getdata.sas emits them: the temporal columns are dropped from
|
||||
|
||||
@@ -80,19 +80,32 @@ const tableName =
|
||||
// have even when the table exists somewhere else, because the abort comes from
|
||||
// %mp_derivedvars after dc_assignlib has already refused the library.
|
||||
|
||||
/* getcolvals.sas validates the SHAPE of libds first, with
|
||||
%mpe_validatecol(...,LIBDS,...), and that is the only case it answers
|
||||
"Invalid libds" (mac=&_program..sas). It then assigns the library and leaves
|
||||
the table to %mp_derivedvars, which aborts "Target table not found: <LIBDS>"
|
||||
with the macro's own MAC. So a malformed value is not a missing table, and a
|
||||
well-formed libref the estate does not have is not a malformed one - the
|
||||
recorded answer for NOLIB.MPE_TABLES is the resolution abort, which is what
|
||||
the scenario public/getcolvals/wrong_libref now pins. */
|
||||
const LIBDS_SHAPE = /^[_a-z]\w{0,7}\.[_a-z]\w{0,31}$/i
|
||||
const libdsWithoutCatalog =
|
||||
libds.length > 3 && libds.slice(-3).toUpperCase() === '-FC'
|
||||
? libds.slice(0, -3)
|
||||
: libds
|
||||
if (!LIBDS_SHAPE.test(libdsWithoutCatalog.trim())) {
|
||||
mpAbort('Invalid libds', _program + '.sas')
|
||||
}
|
||||
|
||||
let _loaded = null
|
||||
if (namedLibref && tableName) {
|
||||
/* %mpe_validatecol refuses a libref the estate does not have before the
|
||||
table is looked for, which is why an unknown library is "Invalid libds"
|
||||
and a missing member in a known library is "Target table not found". */
|
||||
const knownLib = allLibrefs().find(
|
||||
lib => lib.toUpperCase() === namedLibref.toUpperCase()
|
||||
)
|
||||
if (!knownLib) {
|
||||
mpAbort('Invalid libds', _program + '.sas')
|
||||
if (knownLib) {
|
||||
const named = makeTableLoader(libDataDir(knownLib))(tableName)
|
||||
if (named) _loaded = { data: named, libref: knownLib }
|
||||
}
|
||||
const named = makeTableLoader(libDataDir(knownLib))(tableName)
|
||||
if (named) _loaded = { data: named, libref: knownLib }
|
||||
} else {
|
||||
_loaded = loadTableAnyLib(tableName)
|
||||
}
|
||||
|
||||
@@ -1,54 +1,225 @@
|
||||
let mimeType = `application/octet-stream`
|
||||
let filename = `dc-download`
|
||||
|
||||
const nodePath = require('path')
|
||||
let appLoc = nodePath.join(..._program.split('services')[0].split('/'))
|
||||
const sasjsRoot = nodePath.resolve(weboutPath, '..', '..', '..')
|
||||
const driveRoot = nodePath.resolve(sasjsRoot, 'drive')
|
||||
eval(
|
||||
fs.readFileSync(
|
||||
nodePath.resolve(driveRoot, 'files', appLoc, 'services', 'dcMockUtils.js'),
|
||||
'utf8'
|
||||
)
|
||||
)
|
||||
|
||||
const reqLibref =
|
||||
typeof libref === 'undefined' ? '' : String(libref || '').trim()
|
||||
const reqDs = typeof ds === 'undefined' ? '' : String(ds || '').trim()
|
||||
|
||||
/**
|
||||
* getddl.sas assigns the library, checks the member is there and aborts with
|
||||
* "Dataset <libref>.<ds> was not found" - the same abort answers an unknown
|
||||
* library and a missing member, which is why the message names both parts.
|
||||
* The mock used to answer its fixed sample body for any request at all,
|
||||
* including a table it has never heard of.
|
||||
*/
|
||||
const requested = reqDs ? makeTableLoader(libDataDir(reqLibref))(reqDs) : null
|
||||
if (!reqLibref || !reqDs || !requested) {
|
||||
mpAbort(`Dataset ${reqLibref}.${reqDs} was not found`, _program)
|
||||
}
|
||||
|
||||
filename = `${reqLibref}_${reqDs}.ddl`
|
||||
|
||||
fs.writeFile(_SASJS_WEBOUT_HEADERS, `Content-type: ${mimeType} \n Content-Disposition: attachment;filename=\"${filename}\"`, function (err) {
|
||||
if (err) throw err;
|
||||
})
|
||||
|
||||
_webout = `/* DDL generated by sassrv on 29SEP2022:11:59:44 */
|
||||
CREATE SCHEMA DC_JSLIB;
|
||||
/* Postgres Flavour DDL for DC_JSLIB.MPE_X_TEST */
|
||||
CREATE TABLE DC_JSLIB.MPE_X_TEST (
|
||||
"primary_key_field" DOUBLE PRECISION NOT NULL
|
||||
,"some_char" VARCHAR(32767)
|
||||
,"some_dropdown" VARCHAR(128)
|
||||
,"some_num" DOUBLE PRECISION
|
||||
,"some_date" DOUBLE PRECISION
|
||||
,"some_datetime" TIMESTAMP
|
||||
,"some_time" DOUBLE PRECISION
|
||||
,"some_shortnum" DOUBLE PRECISION
|
||||
,"some_bestnum" DOUBLE PRECISION
|
||||
);
|
||||
CREATE UNIQUE INDEX "primary_key_field" ON DC_JSLIB.MPE_X_TEST(
|
||||
"primary_key_field"
|
||||
);
|
||||
`
|
||||
/* mp_streamfile(contenttype=TEXT) sends an application/text attachment named
|
||||
after the member, unquoted - confirmed against the estate's own response for
|
||||
public/getddl, whose headers are `content-type: application/text` and
|
||||
`content-disposition: attachment; filename=<libref>_<ds>.ddl`. */
|
||||
let mimeType = `application/text`
|
||||
let filename = `dc-download`
|
||||
|
||||
const nodePath = require('path')
|
||||
let appLoc = nodePath.join(..._program.split('services')[0].split('/'))
|
||||
const sasjsRoot = nodePath.resolve(weboutPath, '..', '..', '..')
|
||||
const driveRoot = nodePath.resolve(sasjsRoot, 'drive')
|
||||
eval(
|
||||
fs.readFileSync(
|
||||
nodePath.resolve(driveRoot, 'files', appLoc, 'services', 'dcMockUtils.js'),
|
||||
'utf8'
|
||||
)
|
||||
)
|
||||
|
||||
const reqLibref =
|
||||
typeof libref === 'undefined' ? '' : String(libref || '').trim()
|
||||
const reqDs = typeof ds === 'undefined' ? '' : String(ds || '').trim()
|
||||
|
||||
/**
|
||||
* getddl.sas assigns the library, checks the member is there and aborts with
|
||||
* "Dataset <libref>.<ds> was not found" - the same abort answers an unknown
|
||||
* library and a missing member, which is why the message names both parts.
|
||||
* The mock used to answer its fixed sample body for any request at all,
|
||||
* including a table it has never heard of.
|
||||
*/
|
||||
const requested = reqDs ? makeTableLoader(libDataDir(reqLibref))(reqDs) : null
|
||||
if (!reqLibref || !reqDs || !requested) {
|
||||
mpAbort(`Dataset ${reqLibref}.${reqDs} was not found`, _program)
|
||||
}
|
||||
|
||||
filename = `${reqLibref}_${reqDs}.ddl`
|
||||
|
||||
/* ─── the DDL, generated from the table's own metadata ─────────────────────
|
||||
mp_ds2ddl reads dictionary.columns, dictionary.indexes and the table's
|
||||
constraints; the mock reads the table file makedata wrote, which carries the
|
||||
same things - the columns in order at the case the estate reports, and the
|
||||
unique key. Verified byte-for-byte against the synthetic estate's own DDL for
|
||||
MPE_TABLES in all three flavours, which is the table the parity gated
|
||||
scenario records (public/getddl/mpe_tables); no golden pins the other
|
||||
control tables yet.
|
||||
*/
|
||||
const DDL_MONTHS = ['JAN','FEB','MAR','APR','MAY','JUN','JUL','AUG','SEP','OCT','NOV','DEC']
|
||||
|
||||
/**
|
||||
* The estate's header comes from `%sysfunc(datetime(),datetime19.)`, a SAS
|
||||
* datetime, so it carries the session's own local time rather than UTC. The
|
||||
* mock reads the local components for the same reason: the synthetic estate
|
||||
* answers 07NOV2025:22:27 for a moment whose UTC getters give 21:27, and the
|
||||
* projection masks the whole comment, so only this choice keeps the two equal.
|
||||
*/
|
||||
function ddlDatetime(now) {
|
||||
const d = new Date(now)
|
||||
const p = (n) => String(n).padStart(2, '0')
|
||||
return (
|
||||
p(d.getDate()) + DDL_MONTHS[d.getMonth()] + d.getFullYear() + ':' +
|
||||
p(d.getHours()) + ':' + p(d.getMinutes()) + ':' + p(d.getSeconds())
|
||||
)
|
||||
}
|
||||
|
||||
/** The name as created: the mock's metadata carries the estate's own casing. */
|
||||
function ddlType(col) {
|
||||
const isChar = col.type === 'C' || col.type === 'char'
|
||||
if (isChar) return 'char(' + col.length + ')'
|
||||
if (Number(col.length) !== 8) return 'num length=' + col.length
|
||||
return 'num'
|
||||
}
|
||||
|
||||
/**
|
||||
* One column line, as `put name typ fmt notnul lab` writes it: list output puts
|
||||
* one space between parts, and a blank part costs a space of its own - which is
|
||||
* why the trailing blanks differ between a column that is not null and one that
|
||||
* carries a label.
|
||||
*/
|
||||
function ddlColumnLine(col, first) {
|
||||
const indent = first ? ' ' : ' ,'
|
||||
const fmt =
|
||||
col.format && String(col.format).length > 1
|
||||
? 'format=' + String(col.format).toUpperCase()
|
||||
: ' '
|
||||
const notnul = col.notnull ? 'not null' : ' '
|
||||
const lab =
|
||||
col.label && String(col.label).length > 1
|
||||
? "label='" + String(col.label).replace(/'/g, "''") + "'"
|
||||
: ' '
|
||||
return indent + [col.name, ddlType(col), fmt, notnul, lab].join(' ')
|
||||
}
|
||||
|
||||
/** Is this numeric column a datetime the way mp_ds2ddl reads the format? */
|
||||
function ddlIsDatetime(col) {
|
||||
return String(col.format || '').toUpperCase().indexOf('DATETIME') === 0
|
||||
}
|
||||
|
||||
function ddlPostgresType(col) {
|
||||
if (col.type === 'C' || col.type === 'char') return 'VARCHAR(' + col.length + ')'
|
||||
return ddlIsDatetime(col) ? 'TIMESTAMP' : 'DOUBLE PRECISION'
|
||||
}
|
||||
|
||||
function ddlTsqlType(col) {
|
||||
if (col.type === 'C' || col.type === 'char') {
|
||||
return '[varchar](' + (Number(col.length) <= 8000 ? col.length : 'max') + ')'
|
||||
}
|
||||
return ddlIsDatetime(col) ? '[datetime2](7)' : '[decimal](18,2)'
|
||||
}
|
||||
|
||||
/** The line each dialect writes for a column, blanks and all. */
|
||||
function ddlDialectColumn(col, first, flavour) {
|
||||
const indent = first ? ' ' : ' ,'
|
||||
const notnul = col.notnull ? 'NOT NULL' : ' '
|
||||
if (flavour === 'PGSQL') {
|
||||
return indent + ['"' + col.name + '"', ddlPostgresType(col), notnul].join(' ')
|
||||
}
|
||||
// the type follows the name with no separator, as mp_ds2ddl writes it
|
||||
return indent + '[' + col.name + ']' + ddlTsqlType(col) + ' ' + notnul
|
||||
}
|
||||
|
||||
/**
|
||||
* The DDL mp_ds2ddl writes, from the table file makedata wrote. SAS, TSQL and
|
||||
* PGSQL each have their own shape, and each was checked against the estate's
|
||||
* own output for MPE_TABLES - the table the parity gate records.
|
||||
*/
|
||||
function ddlText(table, libref, ds, flavour, user, now) {
|
||||
const lines = []
|
||||
lines.push('/* DDL generated by ' + user + ' on ' + ddlDatetime(now) + ' */')
|
||||
|
||||
/* mp_ds2ddl writes that comment before it branches, and has branches only
|
||||
for SAS, TSQL and PGSQL: any other flavour leaves the file holding the
|
||||
comment alone, which mp_streamfile then sends as a normal download. The
|
||||
estate answers exactly that for an unknown flavour - a 200 with a
|
||||
55-byte body - so the mock does too rather than inventing a refusal. */
|
||||
if (['SAS', 'TSQL', 'PGSQL'].indexOf(flavour) < 0) {
|
||||
return lines.join('\n') + '\n'
|
||||
}
|
||||
|
||||
if (flavour === 'PGSQL') {
|
||||
lines.push('CREATE SCHEMA ' + libref + ';')
|
||||
lines.push('/* Postgres Flavour DDL for ' + libref + '.' + ds + ' */')
|
||||
lines.push('CREATE TABLE ' + libref + '.' + ds + ' (')
|
||||
table.columns.forEach(function (col, i) {
|
||||
lines.push(ddlDialectColumn(col, i === 0, 'PGSQL'))
|
||||
})
|
||||
lines.push(');')
|
||||
if (table.uniqueIndex) {
|
||||
// the index is a second statement in Postgres, after the table
|
||||
lines.push('CREATE UNIQUE INDEX "' + table.uniqueIndex.name + '" ON ' + libref + '.' + ds + '(')
|
||||
table.uniqueIndex.columns.forEach(function (name, i) {
|
||||
lines.push((i === 0 ? ' ' : ' ,') + '"' + name + '"')
|
||||
})
|
||||
lines.push(');')
|
||||
}
|
||||
return lines.join('\n') + '\n'
|
||||
}
|
||||
|
||||
if (flavour === 'TSQL') {
|
||||
lines.push('/* TSQL Flavour DDL for ' + libref + '.' + ds + ' */')
|
||||
lines.push('create table [' + libref + '].[' + ds + '](')
|
||||
table.columns.forEach(function (col, i) {
|
||||
lines.push(ddlDialectColumn(col, i === 0, 'TSQL'))
|
||||
})
|
||||
if (table.uniqueIndex) {
|
||||
// TSQL declares the key inside the create table, not after it
|
||||
lines.push(' ,index [' + table.uniqueIndex.name + '] UNIQUE NONCLUSTERED (')
|
||||
table.uniqueIndex.columns.forEach(function (name, i) {
|
||||
lines.push((i === 0 ? ' ' : ' ,') + '[' + name + ']')
|
||||
})
|
||||
lines.push(' )')
|
||||
}
|
||||
lines.push(')')
|
||||
lines.push('GO')
|
||||
/* A label becomes an extended property, one statement per column, and
|
||||
the statement block is separated from the create by a line holding a
|
||||
single space - which is what put ' ' / writes. */
|
||||
table.columns.forEach(function (col) {
|
||||
if (!col.label || String(col.label).length < 2) return
|
||||
lines.push(' ')
|
||||
lines.push('EXEC sys.sp_addextendedproperty ')
|
||||
lines.push(" @name=N'MS_Description',@value=N'" + String(col.label).replace(/'/g, "''") + "'")
|
||||
lines.push(" ,@level0type=N'SCHEMA',@level0name=N'" + libref + "' ")
|
||||
lines.push(" ,@level1type=N'TABLE',@level1name=N'" + ds + "'")
|
||||
lines.push(" ,@level2type=N'COLUMN',@level2name=N'" + col.name + "'")
|
||||
})
|
||||
if (table.columns.some(function (col) { return col.label && String(col.label).length > 1 })) {
|
||||
lines.push('GO')
|
||||
}
|
||||
return lines.join('\n') + '\n'
|
||||
}
|
||||
|
||||
lines.push('/* SAS Flavour DDL for ' + libref + '.' + ds + ' */')
|
||||
lines.push('proc sql;')
|
||||
lines.push('create table ' + libref + '.' + ds + '(')
|
||||
table.columns.forEach(function (col, i) {
|
||||
lines.push(ddlColumnLine(col, i === 0))
|
||||
})
|
||||
lines.push(');')
|
||||
if (table.uniqueIndex) {
|
||||
lines.push('CREATE UNIQUE INDEX ' + table.uniqueIndex.name + ' ON ' + libref + '.' + ds + ' (')
|
||||
table.uniqueIndex.columns.forEach(function (name, i) {
|
||||
lines.push((i === 0 ? ' ' : ' ,') + name)
|
||||
})
|
||||
lines.push(');')
|
||||
}
|
||||
return lines.join('\n') + '\n'
|
||||
}
|
||||
|
||||
_webout = ddlText(
|
||||
requested,
|
||||
reqLibref,
|
||||
reqDs,
|
||||
String(typeof flavour === 'undefined' ? 'SAS' : flavour || 'SAS').toUpperCase(),
|
||||
typeof _SASJS_USERNAME === 'undefined' ? 'sasjs' : _SASJS_USERNAME,
|
||||
Date.now()
|
||||
)
|
||||
|
||||
/* The headers go on last because the estate writes none until mp_streamfile,
|
||||
which runs after mp_ds2ddl has produced the file - so a request that cannot
|
||||
produce one has already aborted as a JSON envelope by now. One header per
|
||||
line, as %mfs_httpheader writes them, and synchronously as that macro does,
|
||||
so they are on disk before the program returns. */
|
||||
fs.writeFileSync(
|
||||
_SASJS_WEBOUT_HEADERS,
|
||||
`Content-Type: ${mimeType}\nContent-disposition: attachment; filename=${filename}`
|
||||
)
|
||||
@@ -19,12 +19,31 @@ eval(
|
||||
const dcLibref = dcReadSettings(driveRoot, appLoc).dcLibref
|
||||
const dataDir = nodePath.resolve(driveRoot, 'files', appLoc, 'data', dcLibref)
|
||||
|
||||
// Helper: load a table JSON file
|
||||
function loadTableData(tableName) {
|
||||
/*
|
||||
Loads a table's JSON file. `libref` defaults to the control library, which is
|
||||
where the settings and the MPE_TABLES registration live; a download passes the
|
||||
library it was asked for, because makedata and createdemodata write each
|
||||
library's tables under data/<libref>.
|
||||
|
||||
Returns null when there is no such member. A file that is there but cannot be
|
||||
read or parsed throws instead: that is a broken fixture, and reporting it as
|
||||
"not found" would send the next reader looking for a table that exists.
|
||||
*/
|
||||
function loadTableFile(libref, tableName) {
|
||||
const dir = libref ? nodePath.resolve(dataDir, '..', libref) : dataDir
|
||||
const file = nodePath.resolve(dir, String(tableName).toLowerCase() + '.json')
|
||||
let raw
|
||||
try {
|
||||
const file = nodePath.resolve(dataDir, tableName.toLowerCase() + '.json')
|
||||
return JSON.parse(fs.readFileSync(file, {encoding:'utf8'}).toString())
|
||||
} catch(err) { return null }
|
||||
raw = fs.readFileSync(file, {encoding:'utf8'}).toString()
|
||||
} catch (err) {
|
||||
if (err.code === 'ENOENT') return null
|
||||
throw new Error('mock table file unreadable: ' + file + ' (' + err.code + ')')
|
||||
}
|
||||
try {
|
||||
return JSON.parse(raw)
|
||||
} catch (err) {
|
||||
throw new Error('mock table file is not valid JSON: ' + file + ' (' + err.message + ')')
|
||||
}
|
||||
}
|
||||
|
||||
// SAS epoch for date/datetime/time formatting
|
||||
@@ -51,123 +70,230 @@ function formatSasValue(value, ddtype) {
|
||||
return String(value)
|
||||
}
|
||||
|
||||
// Determine mime type and filename from request params
|
||||
let mimeType = 'application/octet-stream'
|
||||
let filename = table || 'dc-download'
|
||||
/*
|
||||
The download contract, read from getrawdata.sas and confirmed against the
|
||||
estate's own responses: `type` is validated against a fixed list before
|
||||
anything is written, each type streams under the content type %mp_streamfile
|
||||
maps it to, and the file is named LIBREF.DS.ext with both parts upcased
|
||||
(`%let table=%upcase(&table)` ... outname=&table..&ext).
|
||||
|
||||
switch((type || '').toUpperCase()) {
|
||||
case 'CSV':
|
||||
mimeType = 'text/csv'
|
||||
filename += '.csv'
|
||||
break
|
||||
case 'EXCEL':
|
||||
mimeType = 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
|
||||
filename += '.xlsx'
|
||||
break
|
||||
case 'MARKDOWN':
|
||||
mimeType = 'application/octet-stream'
|
||||
filename += '.md'
|
||||
break
|
||||
case 'PDF':
|
||||
/* the mock layer has no ODS, so it streams the table listing as the
|
||||
body: enough for the client to check the option, the content type
|
||||
and the filename, which is what this layer owns. The real report is
|
||||
laid out by mp_ds2pdf on the SAS side. */
|
||||
mimeType = 'application/pdf'
|
||||
filename += '.pdf'
|
||||
break
|
||||
default:
|
||||
mimeType = 'application/octet-stream'
|
||||
filename += '.sas'
|
||||
break
|
||||
SAS application/text LIBREF.DS.sas
|
||||
CSV application/csv LIBREF.DS.csv
|
||||
EXCEL openxml sheet LIBREF.DS.xlsx
|
||||
MARKDOWN text/markdown LIBREF.DS.md
|
||||
PDF application/pdf LIBREF.DS.pdf
|
||||
|
||||
WEBCSV and WEBTAB write their rows straight to _webout and set no headers at
|
||||
all - the estate answers them with no content-disposition, so an attachment
|
||||
from the mock would be a download the estate does not offer. EXCEL is the one
|
||||
type the synthetic estate cannot produce (PROC EXPORT xlsx fails there with
|
||||
syscc=1012), so its row comes from the service source and mp_streamfile. The
|
||||
PDF body is the table listing rather than a typeset report - the mock layer
|
||||
has no ODS - but the content type and filename are this layer's to get right.
|
||||
*/
|
||||
const DOWNLOAD_TYPES = {
|
||||
SAS: { ext: 'sas', contentType: 'application/text' },
|
||||
CSV: { ext: 'csv', contentType: 'application/csv' },
|
||||
EXCEL: {
|
||||
ext: 'xlsx',
|
||||
contentType: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
|
||||
},
|
||||
MARKDOWN: { ext: 'md', contentType: 'text/markdown' },
|
||||
PDF: { ext: 'pdf', contentType: 'application/pdf' }
|
||||
}
|
||||
|
||||
// Write headers
|
||||
fs.writeFile(_SASJS_WEBOUT_HEADERS,
|
||||
`Content-type: ${mimeType} \n Content-Disposition: attachment;filename="${filename}"`,
|
||||
function (err) { if (err) throw err }
|
||||
)
|
||||
/*
|
||||
getrawdata.sas validates all three inputs in one data step, then aborts in a
|
||||
fixed order before a byte is streamed: table shape, then the filter as an
|
||||
integer, then the type against its list - each with mac=&_program..sas. The
|
||||
rules come from the macros the service calls:
|
||||
|
||||
- %mpe_validatecol(...,LIBDS,...) strips a trailing -FC and validates the
|
||||
rest with %mp_validatecol's /^[_a-z]\w{0,7}\.[_a-z]\w{0,31}$/i, so the
|
||||
check is on the SHAPE of LIBREF.DATASET and not on whether it exists.
|
||||
- %mp_validatecol(...,ISINT,...) is input(&filter,??best32.) with mod 1 = 0,
|
||||
so 7.0 and 1e3 pass while abc and 1.5 do not, and a missing filter becomes
|
||||
the string 0 rather than an error.
|
||||
|
||||
mpAbort lives in dcMockUtils, which this service cannot eval because its own
|
||||
helpers collide with it, so the envelope is written here the way mpAbort
|
||||
writes it: the abort member, no CLOSE.
|
||||
*/
|
||||
function abortInput(message, mac) {
|
||||
webout('OPEN')
|
||||
webout('OBJ', 'sasjsAbort', [{ MSG: message, MAC: mac || _program + '.sas' }])
|
||||
const stop = new Error(message)
|
||||
stop.mockAbort = true
|
||||
throw stop
|
||||
}
|
||||
|
||||
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 =
|
||||
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 (
|
||||
!FILTER_NUMBER.test(requestFilter) ||
|
||||
!isFinite(filterValue) ||
|
||||
Math.floor(filterValue) !== filterValue
|
||||
) {
|
||||
abortInput('Invalid filter')
|
||||
}
|
||||
|
||||
const requestedType = String(typeof type === 'undefined' ? '' : type || '').toUpperCase()
|
||||
const download = DOWNLOAD_TYPES[requestedType]
|
||||
const writesWeboutDirectly = requestedType === 'WEBCSV' || requestedType === 'WEBTAB'
|
||||
|
||||
if (!download && !writesWeboutDirectly) abortInput('Invalid type')
|
||||
|
||||
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)
|
||||
let tableName = ''
|
||||
if (table) {
|
||||
const parts = table.split('.')
|
||||
tableName = parts[parts.length - 1]
|
||||
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.
|
||||
|
||||
A LIBREF.NAME-FC request is different: it names a format catalog, which the
|
||||
estate reads through %mp_cntlout into work.fmtextract and then streams as
|
||||
LIBREF.NAME.ext. The mock holds a catalog as a member of the same name whose
|
||||
MPE_TABLES registration carries the -FC suffix, and only that registration
|
||||
makes the request a catalog - so a suffix on an ordinary dataset is not
|
||||
answered with the dataset's rows, which would be a successful download of an
|
||||
object the caller did not name. The estate's own answer for that case is
|
||||
syscc=1012 from %mp_cntlout, recorded as the scenario
|
||||
public/getrawdata/catalog_suffix_on_dataset.
|
||||
*/
|
||||
const registry = loadTableFile(null, 'MPE_TABLES')
|
||||
const registryRows = (registry && registry.rows) || []
|
||||
const findRegistration = (dsn) =>
|
||||
registryRows.find(
|
||||
(row) =>
|
||||
String(row.libref).toUpperCase() === exportLibref &&
|
||||
String(row.dsn).toUpperCase() === dsn
|
||||
) || null
|
||||
|
||||
const isCatalogRequest =
|
||||
rawTable.length > 3 && rawTable.slice(-3).toUpperCase() === '-FC'
|
||||
let tableData = null
|
||||
let registration = null
|
||||
if (isCatalogRequest) {
|
||||
registration = findRegistration(tableName + '-FC')
|
||||
if (registration) tableData = loadTableFile(exportLibref, tableName)
|
||||
if (!tableData) abortInput('syscc=1012', 'MP_ABORT')
|
||||
} else {
|
||||
tableData = loadTableFile(exportLibref, tableName)
|
||||
if (!tableData) abortInput('Target table not found: ' + requestTable, 'MP_ABORT')
|
||||
registration = findRegistration(tableName)
|
||||
}
|
||||
|
||||
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 = String(table || '').split('.')[0]
|
||||
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
|
||||
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
|
||||
// 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 (String(type || '').toUpperCase() === 'CSV') {
|
||||
csvLines.push(exportColumns.map((col) => col.name).join(','))
|
||||
}
|
||||
// Build column ddtype lookup
|
||||
const colDdtypes = {}
|
||||
for (const col of exportColumns) {
|
||||
const fmt = (col.format || '').toLowerCase()
|
||||
let ddtype = col.type === 'N' ? 'N' : 'C'
|
||||
if (fmt.startsWith('datetime') || fmt.startsWith('e8601dt')) ddtype = 'DATETIME'
|
||||
else if (fmt.startsWith('date')) ddtype = 'DATE'
|
||||
else if (fmt.startsWith('time')) ddtype = 'TIME'
|
||||
colDdtypes[col.name.toUpperCase()] = ddtype
|
||||
}
|
||||
|
||||
// Build column ddtype lookup
|
||||
const colDdtypes = {}
|
||||
for (const row of tableData.rows) {
|
||||
const values = []
|
||||
for (const col of exportColumns) {
|
||||
const fmt = (col.format || '').toLowerCase()
|
||||
let ddtype = col.type === 'N' ? 'N' : 'C'
|
||||
if (fmt.startsWith('datetime') || fmt.startsWith('e8601dt')) ddtype = 'DATETIME'
|
||||
else if (fmt.startsWith('date')) ddtype = 'DATE'
|
||||
else if (fmt.startsWith('time')) ddtype = 'TIME'
|
||||
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)
|
||||
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]
|
||||
}
|
||||
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')
|
||||
|
||||
/* The headers go on last, as the estate's do: %mp_streamfile runs after the
|
||||
file has been written, so nothing describes a download that failed. One
|
||||
header per line, as %mfs_httpheader writes them, and the filename unquoted.
|
||||
WEBCSV and WEBTAB have no entry in DOWNLOAD_TYPES and so send none. */
|
||||
if (download) {
|
||||
/* Synchronously, as %mfs_httpheader writes them (fopen/fput/fclose on the
|
||||
same file), so the headers are on disk when the program returns: a
|
||||
caller - the runtime, or a test - never has to wait for a write. */
|
||||
fs.writeFileSync(
|
||||
_SASJS_WEBOUT_HEADERS,
|
||||
`Content-Type: ${download.contentType}\nContent-disposition: attachment; filename=${filename}`
|
||||
)
|
||||
}
|
||||
@@ -368,7 +368,7 @@ if (tableData && tableData.columns) {
|
||||
stringifies to.
|
||||
*/
|
||||
if (val === undefined || val === null || val === '') {
|
||||
row[col.name] = col.type === 'N' ? '.' : ' '
|
||||
row[payloadKey(col)] = col.type === 'N' ? '.' : ' '
|
||||
} else {
|
||||
/*
|
||||
Only trailing blanks are noise - the width SAS pads to - and
|
||||
@@ -381,7 +381,7 @@ if (tableData && tableData.columns) {
|
||||
viewCell or formatSasValue, both of which return text.
|
||||
*/
|
||||
const text = String(val).replace(/ +$/, '')
|
||||
row[col.name] = text === '' ? ' ' : text
|
||||
row[payloadKey(col)] = text === '' ? ' ' : text
|
||||
}
|
||||
}
|
||||
return row
|
||||
@@ -397,7 +397,7 @@ if (tableData && tableData.columns) {
|
||||
const emptyRow = {}
|
||||
// a blank character cell is a space everywhere else in this service,
|
||||
// so the empty row says the same thing
|
||||
for (const col of physicalColumns) emptyRow[col.name] = col.type === 'N' ? '.' : ' '
|
||||
for (const col of physicalColumns) emptyRow[payloadKey(col)] = col.type === 'N' ? '.' : ' '
|
||||
viewdata = [emptyRow]
|
||||
}
|
||||
|
||||
@@ -417,7 +417,7 @@ if (tableData && tableData.columns) {
|
||||
vars = {}
|
||||
for (const col of physicalColumns) {
|
||||
const isTemporal = ['DATE', 'DATETIME', 'TIME'].indexOf(getDdType(col)) >= 0
|
||||
vars[col.name] = {
|
||||
vars[payloadKey(col)] = {
|
||||
format: isTemporal
|
||||
? String(
|
||||
col.format ||
|
||||
@@ -426,7 +426,7 @@ if (tableData && tableData.columns) {
|
||||
: 'DATETIME19.3')
|
||||
).toUpperCase()
|
||||
: (col.type === 'N' ? (col.format || 'best.') : ('$' + col.length + '.')),
|
||||
label: col.label || col.name,
|
||||
label: col.label || payloadKey(col),
|
||||
length: String(col.length),
|
||||
type: col.type === 'N' ? 'num' : 'char'
|
||||
}
|
||||
|
||||
@@ -86,7 +86,8 @@ test('MPE_X_TEST carries the excel/viewer fixtures, not the SAS demo rules', ()
|
||||
)
|
||||
)
|
||||
const byName = {}
|
||||
for (const column of table.columns) byName[column.name] = column.label
|
||||
// the table keeps the case the SAS model creates it with, which is lower
|
||||
for (const column of table.columns) byName[column.name.toUpperCase()] = column.label
|
||||
assert.equal(byName.SOME_CHAR, 'Some Character Column')
|
||||
assert.equal(byName.SOME_DATE, 'Some Date')
|
||||
assert.equal(byName.SOME_NUM, '', 'a blank label falls back to the name')
|
||||
|
||||
@@ -0,0 +1,368 @@
|
||||
/**
|
||||
* What the download services send, as opposed to what they say. The parity gate
|
||||
* projects a DDL down to its `text` member after masking the "DDL generated by"
|
||||
* comment and carries no response headers at all, so neither a header timestamp
|
||||
* nor any content type or filename is visible to it: these tests are the only
|
||||
* thing that pins them, and each answers the estate's own response rather than
|
||||
* the mock's intent.
|
||||
*
|
||||
* The timezone is fixed to a zone three hours from UTC, with no daylight
|
||||
* saving, so "local" and "UTC" are always different and a regression to the
|
||||
* UTC getters cannot pass by coincidence. Each test file runs in its own
|
||||
* process, so setting TZ here does not reach the other suites.
|
||||
*/
|
||||
process.env.TZ = 'Etc/GMT-3'
|
||||
|
||||
const test = require('node:test')
|
||||
const assert = require('node:assert')
|
||||
const fs = require('node:fs')
|
||||
const path = require('node:path')
|
||||
|
||||
const { seededEstate, runService } = require('./harness')
|
||||
|
||||
const MONTHS = ['JAN', 'FEB', 'MAR', 'APR', 'MAY', 'JUN', 'JUL', 'AUG', 'SEP', 'OCT', 'NOV', 'DEC']
|
||||
|
||||
/**
|
||||
* The wall clock the header prints, read as if it were UTC - which is how it
|
||||
* can be compared with a Date.UTC() built from the same fields.
|
||||
*/
|
||||
function parsedWallClock(body) {
|
||||
const m = /\/\* DDL generated by \S+ on +(\d{2})([A-Z]{3})(\d{4}):(\d{2}):(\d{2}):(\d{2}) \*\//.exec(body)
|
||||
assert.ok(m, 'the DDL carries its generated-by comment: ' + body.slice(0, 80))
|
||||
return Date.UTC(
|
||||
Number(m[3]),
|
||||
MONTHS.indexOf(m[2]),
|
||||
Number(m[1]),
|
||||
Number(m[4]),
|
||||
Number(m[5]),
|
||||
Number(m[6])
|
||||
)
|
||||
}
|
||||
|
||||
test('getddl stamps the DDL in the session time, not UTC', () => {
|
||||
const estate = seededEstate({ demo: false })
|
||||
const before = new Date()
|
||||
const body = runService(estate, 'public/getddl', {
|
||||
params: { libref: 'DC_JSLIB', ds: 'MPE_TABLES', flavour: 'SAS' }
|
||||
})
|
||||
const after = new Date()
|
||||
|
||||
/* getddl.sas writes `%sysfunc(datetime(),datetime19.)`, a SAS datetime, so
|
||||
the clock it prints is the session's own: the synthetic estate answers
|
||||
07NOV2025:22:27 for a moment whose UTC getters give 21:27. Here the
|
||||
session is three hours ahead, so the printed clock is the UTC clock plus
|
||||
three hours. */
|
||||
const utcWall = Date.UTC(
|
||||
before.getUTCFullYear(),
|
||||
before.getUTCMonth(),
|
||||
before.getUTCDate(),
|
||||
before.getUTCHours(),
|
||||
before.getUTCMinutes(),
|
||||
before.getUTCSeconds()
|
||||
)
|
||||
const expected = utcWall + 3 * 60 * 60 * 1000
|
||||
const printed = parsedWallClock(body)
|
||||
|
||||
// a run has to finish inside the two-minute window the comparison allows
|
||||
assert.ok(after - before < 120000)
|
||||
assert.ok(
|
||||
Math.abs(printed - expected) <= 120000,
|
||||
'the DDL is stamped ' + new Date(printed).toISOString() + ' (read as UTC), expected the local clock ' +
|
||||
new Date(expected).toISOString() + ' - the UTC getters would print ' + new Date(utcWall).toISOString()
|
||||
)
|
||||
})
|
||||
|
||||
test('the download headers are the two %mfs_httpheader writes, one per line', async () => {
|
||||
const estate = seededEstate({ demo: false })
|
||||
runService(estate, 'public/getddl', {
|
||||
params: { libref: 'DC_JSLIB', ds: 'MPE_TABLES', flavour: 'SAS' }
|
||||
})
|
||||
|
||||
const headers = headersOf(estate)
|
||||
assert.deepEqual(headers.split('\n'), [
|
||||
'Content-Type: application/text',
|
||||
'Content-disposition: attachment; filename=DC_JSLIB_MPE_TABLES.ddl'
|
||||
])
|
||||
})
|
||||
|
||||
test('an aborted DDL request writes no download headers', async () => {
|
||||
const estate = seededEstate({ demo: false })
|
||||
const body = runService(estate, 'public/getddl', {
|
||||
params: { libref: 'DC_JSLIB', ds: 'NO_SUCH_DS', flavour: 'SAS' }
|
||||
})
|
||||
assert.match(body, /sasjsAbort/)
|
||||
assert.doesNotMatch(body, /DDL generated by/)
|
||||
|
||||
/* Every abort this service can produce happens before the headers are
|
||||
written, which is where the estate's are too: it writes none until
|
||||
%mp_streamfile, after %mp_ds2ddl has produced the file. So an aborted
|
||||
request describes no download, and the write being synchronous is what
|
||||
makes this assertion mean "never written" rather than "not written yet". */
|
||||
assert.equal(headersOf(estate), '', 'an aborted request must not describe a download')
|
||||
})
|
||||
|
||||
/*
|
||||
Every service in this file writes its headers synchronously, as %mfs_httpheader
|
||||
does (fopen/fput/fclose on the same file), so the file is complete when
|
||||
runService returns. That is why there is no polling here and why the absence
|
||||
assertions mean something: no write can still be in flight when they read.
|
||||
*/
|
||||
function headersOf(estate) {
|
||||
return fs.existsSync(estate.headersPath)
|
||||
? fs.readFileSync(estate.headersPath, 'utf8')
|
||||
: ''
|
||||
}
|
||||
|
||||
// ─── public/getrawdata ───────────────────────────────────────────────────────
|
||||
|
||||
test('getrawdata downloads a CSV the way the estate does', async () => {
|
||||
const estate = seededEstate({ demo: false })
|
||||
// the caller's case does not survive: getrawdata.sas upcases the LIBDS
|
||||
runService(estate, 'public/getrawdata', {
|
||||
params: { type: 'CSV', table: 'dc_jslib.mpe_selectbox', filter: '0' }
|
||||
})
|
||||
|
||||
/* The estate answers
|
||||
content-type: application/csv
|
||||
content-disposition: attachment; filename=DC634301.MPE_SELECTBOX.csv
|
||||
- the file is named from the upcased LIBREF.DS getrawdata.sas builds. */
|
||||
assert.deepEqual(headersOf(estate).split('\n'), [
|
||||
'Content-Type: application/csv',
|
||||
'Content-disposition: attachment; filename=DC_JSLIB.MPE_SELECTBOX.csv'
|
||||
])
|
||||
})
|
||||
|
||||
test('getrawdata names the SAS and Markdown downloads as the estate does', async () => {
|
||||
const estate = seededEstate({ demo: false })
|
||||
// the estate's own responses for these two types, less its libref
|
||||
const cases = [
|
||||
['SAS', 'application/text', '.sas'],
|
||||
['MARKDOWN', 'text/markdown', '.md']
|
||||
]
|
||||
|
||||
for (const [type, contentType, ext] of cases) {
|
||||
runService(estate, 'public/getrawdata', {
|
||||
params: { type, table: 'DC_JSLIB.MPE_SELECTBOX', filter: '0' }
|
||||
})
|
||||
const lines = headersOf(estate).split('\n')
|
||||
assert.deepEqual(
|
||||
lines,
|
||||
[
|
||||
'Content-Type: ' + contentType,
|
||||
'Content-disposition: attachment; filename=DC_JSLIB.MPE_SELECTBOX' + ext
|
||||
],
|
||||
type
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
test('getrawdata sends no attachment headers for the direct-to-webout types', async () => {
|
||||
const estate = seededEstate({ demo: false })
|
||||
const body = runService(estate, 'public/getrawdata', {
|
||||
params: { type: 'WEBCSV', table: 'DC_JSLIB.MPE_SELECTBOX', filter: '0' }
|
||||
})
|
||||
|
||||
// the estate writes its rows straight to _webout and sets no headers at all,
|
||||
// so a content-disposition here would advertise a download it does not offer
|
||||
assert.ok(body.length > 0)
|
||||
assert.doesNotMatch(body, /sasjsAbort/)
|
||||
assert.equal(headersOf(estate), '')
|
||||
})
|
||||
|
||||
test('getrawdata aborts an unknown type before describing any download', async () => {
|
||||
const estate = seededEstate({ demo: false })
|
||||
const body = runService(estate, 'public/getrawdata', {
|
||||
params: { type: 'FOOBAR', table: 'DC_JSLIB.MPE_SELECTBOX', filter: '0' }
|
||||
})
|
||||
|
||||
// getrawdata.sas validates type against its list and aborts with mac=
|
||||
// &_program..sas; the estate's own answer for FOOBAR is exactly this
|
||||
assert.deepEqual(JSON.parse(body).sasjsAbort, [
|
||||
{
|
||||
MSG: 'Invalid type',
|
||||
MAC: '/Public/app/dc/services/public/getrawdata.sas'
|
||||
}
|
||||
])
|
||||
assert.equal(headersOf(estate), '')
|
||||
})
|
||||
|
||||
test('getrawdata validates the table before it streams, as the estate does', () => {
|
||||
const estate = seededEstate({ demo: false })
|
||||
const cases = [
|
||||
// %mp_validatecol's LIBDS rule is the SHAPE libref.dataset, not existence
|
||||
[{ type: 'CSV', table: 'NOT_A_LIBDS', filter: '0' }, 'Invalid table'],
|
||||
[{ type: 'CSV', table: 'TOOLONGLIBREF.X', filter: '0' }, 'Invalid table'],
|
||||
// an absent table aborts like any other bad shape; it must not throw
|
||||
[{ type: 'CSV', filter: '0' }, 'Invalid table'],
|
||||
/* getrawdata.sas aborts in a fixed order - table, then filter, then type -
|
||||
so a request that fails all three answers the table message */
|
||||
[{ type: 'FOOBAR', table: 'NOT_A_LIBDS', filter: 'abc' }, 'Invalid table']
|
||||
]
|
||||
|
||||
for (const [params, message] of cases) {
|
||||
const body = runService(estate, 'public/getrawdata', { params })
|
||||
assert.deepEqual(
|
||||
JSON.parse(body).sasjsAbort,
|
||||
[{ MSG: message, MAC: '/Public/app/dc/services/public/getrawdata.sas' }],
|
||||
JSON.stringify(params)
|
||||
)
|
||||
assert.equal(headersOf(estate), '', JSON.stringify(params))
|
||||
}
|
||||
})
|
||||
|
||||
test('getrawdata reads the filter the way the estate does', () => {
|
||||
const estate = seededEstate({ demo: false })
|
||||
|
||||
/* 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 }
|
||||
})
|
||||
assert.deepEqual(
|
||||
JSON.parse(body).sasjsAbort,
|
||||
[{ MSG: 'Invalid filter', MAC: '/Public/app/dc/services/public/getrawdata.sas' }],
|
||||
filter
|
||||
)
|
||||
assert.equal(headersOf(estate), '', filter)
|
||||
}
|
||||
|
||||
/* 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, /Invalid filter/, filter)
|
||||
assert.equal(headersOf(estate).split('\n')[0], 'Content-Type: application/csv', filter)
|
||||
}
|
||||
})
|
||||
|
||||
test('getrawdata answers a catalog name with the catalog, never the dataset', () => {
|
||||
const estate = seededEstate({ demo: false })
|
||||
|
||||
/*
|
||||
A LIBREF.NAME-FC request names a format catalog: getrawdata.sas reads it
|
||||
through %mp_cntlout and streams LIBREF.NAME.ext. The mock's catalog is a member
|
||||
with no columns whose MPE_TABLES registration carries the -FC suffix, and
|
||||
DC_JSLIB.MPE_X_CATALOG is the one it holds. The fixture has no catalog
|
||||
entries, so the listing is empty - the divergence from the estate's cntlout
|
||||
columns is recorded in the parity README - but it must be the catalog's
|
||||
filename and body, not MPE_X_CATALOG's sibling dataset.
|
||||
*/
|
||||
const catalog = runService(estate, 'public/getrawdata', {
|
||||
params: { type: 'CSV', table: 'DC_JSLIB.MPE_X_CATALOG-FC', filter: '0' }
|
||||
})
|
||||
|
||||
assert.doesNotMatch(catalog, /sasjsAbort/)
|
||||
assert.deepEqual(headersOf(estate).split('\n'), [
|
||||
'Content-Type: application/csv',
|
||||
'Content-disposition: attachment; filename=DC_JSLIB.MPE_X_CATALOG.csv'
|
||||
])
|
||||
assert.ok(catalog.indexOf('SELECTBOX_RK') < 0, 'the catalog is not answered with a dataset\'s rows')
|
||||
|
||||
/*
|
||||
A suffix on an ordinary dataset is not a catalog, and the estate answers it
|
||||
with syscc=1012 from %mp_cntlout - recorded as the scenario
|
||||
public/getrawdata/catalog_suffix_on_dataset, which is why the mock does not
|
||||
fall back to the dataset either.
|
||||
*/
|
||||
const dataset = runService(estate, 'public/getrawdata', {
|
||||
params: { type: 'CSV', table: 'DC_JSLIB.MPE_SELECTBOX-FC', filter: '0' }
|
||||
})
|
||||
|
||||
assert.deepEqual(JSON.parse(dataset).sasjsAbort, [
|
||||
{ MSG: 'syscc=1012', MAC: 'MP_ABORT' }
|
||||
])
|
||||
assert.equal(headersOf(estate), '')
|
||||
})
|
||||
|
||||
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)
|
||||
}
|
||||
})
|
||||
|
||||
test('getrawdata names a broken table file instead of calling it missing', () => {
|
||||
const estate = seededEstate({ demo: false })
|
||||
const file = path.join(estate.appDir, 'data', 'DC_JSLIB', 'mpe_selectbox.json')
|
||||
fs.writeFileSync(file, '{ this is not json')
|
||||
|
||||
/* A member that cannot be parsed is a broken fixture, not an absent table:
|
||||
mapping it onto "Target table not found" sends the next reader looking for
|
||||
a table that is right there. */
|
||||
assert.throws(
|
||||
() =>
|
||||
runService(estate, 'public/getrawdata', {
|
||||
params: { type: 'CSV', table: 'DC_JSLIB.MPE_SELECTBOX', filter: '0' }
|
||||
}),
|
||||
/mock table file is not valid JSON: .*mpe_selectbox\.json/
|
||||
)
|
||||
})
|
||||
|
||||
// ─── admin/exportdb and the shared mpStreamfile ──────────────────────────────
|
||||
|
||||
test('exportdb streams the DDL as application/text, named <libref>.ddl', async () => {
|
||||
const estate = seededEstate({ demo: false })
|
||||
runService(estate, 'admin/exportdb', { params: { flavour: 'SAS' } })
|
||||
|
||||
/* exportdb.sas: %mp_streamfile(contenttype=TEXT, outname=&dc_libref..ddl);
|
||||
the estate answers application/text and filename=DC634301.ddl. */
|
||||
assert.deepEqual(headersOf(estate).split('\n'), [
|
||||
'Content-Type: application/text',
|
||||
'Content-disposition: attachment; filename=DC_JSLIB.ddl'
|
||||
])
|
||||
})
|
||||
|
||||
test('the shared mpStreamfile writes the same two header lines', async () => {
|
||||
const estate = seededEstate()
|
||||
runService(estate, 'admin/exportconfig', { params: {} })
|
||||
|
||||
/* exportconfig.sas streams a zip through %mp_streamfile(contenttype=ZIP,
|
||||
outname=DCBACKUP.zip), so the helper every binary stream goes through has
|
||||
to write the same shape the two services above test by hand. */
|
||||
assert.deepEqual(headersOf(estate).split('\n'), [
|
||||
'Content-Type: application/zip',
|
||||
'Content-disposition: attachment; filename=DCBACKUP.zip'
|
||||
])
|
||||
})
|
||||
@@ -54,9 +54,36 @@ test('getddl names the file after the member it is for', async () => {
|
||||
params: { libref: 'DC_JSLIB', ds: 'MPE_TABLES', flavour: 'SAS' }
|
||||
})
|
||||
|
||||
const pattern = /filename="DC_JSLIB_MPE_TABLES\.ddl"/
|
||||
// mp_streamfile(contenttype=TEXT) sends an application/text attachment, and
|
||||
// names it without quoting - the estate's own response headers are
|
||||
// `content-type: application/text` and
|
||||
// `content-disposition: attachment; filename=DC634301_MPE_TABLES.ddl`
|
||||
const pattern = /content-disposition: attachment; filename=DC_JSLIB_MPE_TABLES\.ddl/i
|
||||
const headers = await headersWhen(estate, pattern)
|
||||
assert.match(headers, pattern)
|
||||
assert.match(headers, /content-type: application\/text/i)
|
||||
assert.doesNotMatch(headers, /application\/octet-stream/i)
|
||||
})
|
||||
|
||||
test('getddl answers an unknown flavour the way the estate does', async () => {
|
||||
const estate = seededEstate({ demo: false })
|
||||
const params = { libref: 'DC_JSLIB', ds: 'MPE_TABLES', flavour: 'XXYY' }
|
||||
const body = runService(estate, 'public/getddl', { params })
|
||||
|
||||
/* getddl.sas passes the flavour straight to mp_ds2ddl, which has branches for
|
||||
SAS, TSQL and PGSQL only: anything else leaves the file holding its opening
|
||||
comment, and mp_streamfile sends it. So the answer is a download, not an
|
||||
abort - and the estate's own response for an unknown flavour is a 200 whose
|
||||
body is that one line. */
|
||||
assert.doesNotMatch(body, /sasjsAbort/)
|
||||
assert.match(body, /^\/\* DDL generated by \S+ on +\S+ \*\/\n$/)
|
||||
|
||||
// the headers are still those of a download, written after the body
|
||||
const headers = await headersWhen(
|
||||
estate,
|
||||
/content-disposition: attachment; filename=DC_JSLIB_MPE_TABLES\.ddl/i
|
||||
)
|
||||
assert.match(headers, /content-type: application\/text/i)
|
||||
})
|
||||
|
||||
test('getcolvals aborts when the table is not there', () => {
|
||||
@@ -83,13 +110,14 @@ test('getcolvals aborts when the table is not there', () => {
|
||||
assert.equal(body.vals, undefined)
|
||||
})
|
||||
|
||||
test('getcolvals aborts on a library it does not have, even for a real table', () => {
|
||||
test('getcolvals refuses a malformed libds before it looks anything up', () => {
|
||||
const estate = seededEstate({ demo: false })
|
||||
const body = JSON.parse(
|
||||
runService(estate, 'public/getcolvals', {
|
||||
tableName: 'iwant',
|
||||
rows: [
|
||||
{
|
||||
// an 11-character libref: %mpe_validatecol's LIBDS rule is shape only
|
||||
libds: 'NO_SUCH_LIB.MPE_TABLES',
|
||||
col: 'RULE_TYPE',
|
||||
STARTROW: 1,
|
||||
@@ -99,8 +127,7 @@ test('getcolvals aborts on a library it does not have, even for a real table', (
|
||||
})
|
||||
)
|
||||
|
||||
// the library is checked before the member is looked for: MPE_TABLES is in
|
||||
// DC_JSLIB, and searching every libref would answer where the estate refuses
|
||||
// the shape abort is the service's own, from getcolvals.sas:124-127
|
||||
assert.deepEqual(body.sasjsAbort, [
|
||||
{
|
||||
MSG: 'Invalid libds',
|
||||
@@ -109,3 +136,29 @@ test('getcolvals aborts on a library it does not have, even for a real table', (
|
||||
])
|
||||
assert.equal(body.vals, undefined)
|
||||
})
|
||||
|
||||
test('getcolvals aborts a well-formed library it does not have as a missing table', () => {
|
||||
const estate = seededEstate({ demo: false })
|
||||
const body = JSON.parse(
|
||||
runService(estate, 'public/getcolvals', {
|
||||
tableName: 'iwant',
|
||||
rows: [
|
||||
{
|
||||
// NOLIB passes the shape rule, so this is a resolution failure
|
||||
libds: 'NOLIB.MPE_TABLES',
|
||||
col: 'RULE_TYPE',
|
||||
STARTROW: 1,
|
||||
ROWS: 5
|
||||
}
|
||||
]
|
||||
})
|
||||
)
|
||||
|
||||
/* The estate assigns the library, then %mp_derivedvars aborts, so the MAC is
|
||||
the macro's default rather than the service - recorded as the golden for
|
||||
public/getcolvals/wrong_libref. */
|
||||
assert.deepEqual(body.sasjsAbort, [
|
||||
{ MSG: 'Target table not found: NOLIB.MPE_TABLES', MAC: 'MP_ABORT' }
|
||||
])
|
||||
assert.equal(body.vals, undefined)
|
||||
})
|
||||
@@ -114,12 +114,12 @@ function runService(
|
||||
*/
|
||||
estate.sessionCount = (estate.sessionCount || 1) + 1
|
||||
const sessionDir = path.join(estate.root, 'sessions', 's' + estate.sessionCount)
|
||||
/* Earlier sessions are left in place. Removing them pruned the estate, but a
|
||||
service that writes its headers with the async fs.writeFile lands after the
|
||||
program returns - so the prune could delete the directory under a pending
|
||||
write and the callback's error took the test process down with it (an
|
||||
intermittent ENOENT on stpsrv_header.txt). Each run still gets its own
|
||||
directory, which is what keeps a stale body from being served; the whole
|
||||
/* Earlier sessions are left in place. Pruning them kept the estate tidy, but
|
||||
it only ever bought tidiness: a service that wrote its headers with an
|
||||
async fs.writeFile used to land after the program returned, and the prune
|
||||
could delete the directory under that pending write. The header writers are
|
||||
synchronous now, so there is nothing to race, but each run keeps its own
|
||||
directory because that is what stops a stale body being served, and the
|
||||
estate is removed when the process exits. */
|
||||
fs.mkdirSync(sessionDir, { recursive: true })
|
||||
const runWeboutPath = path.join(sessionDir, 'webout.txt')
|
||||
|
||||
@@ -126,8 +126,10 @@ test('each table has the model columns, in the model order', () => {
|
||||
)
|
||||
assert.ok(fs.existsSync(file), name + ' is missing')
|
||||
const table = JSON.parse(fs.readFileSync(file, 'utf8'))
|
||||
// the parser upper-cases what it reads out of the macro, and the mock
|
||||
// keeps the case SAS creates the table with - the comparison is on names
|
||||
assert.deepEqual(
|
||||
(table.columns || []).map((col) => col.name),
|
||||
(table.columns || []).map((col) => col.name.toUpperCase()),
|
||||
expected,
|
||||
name + ' columns differ from mpe_makedatamodel.sas'
|
||||
)
|
||||
@@ -240,3 +242,41 @@ test('makedata leaves no Cypress fixture behind', () => {
|
||||
)
|
||||
assert.deepEqual(fixture, [], 'a fixture table is registered by makedata')
|
||||
})
|
||||
|
||||
test('every control table carries the unique key the estate creates it with', () => {
|
||||
// mp_ds2ddl builds CREATE UNIQUE INDEX from dictionary.indexes, so the key is
|
||||
// part of the table's definition here too - and the one table without one on
|
||||
// the estate is named rather than left to drift.
|
||||
const estate = seededEstate({ demo: false })
|
||||
// MPE_REQUESTS is the one control table the estate creates without a unique
|
||||
// index; MPE_X_CATALOG is the format catalog marker, which is a catalog
|
||||
// rather than a dataset and has no columns to key
|
||||
const withoutKey = ['MPE_REQUESTS', 'MPE_X_CATALOG']
|
||||
const files = fs
|
||||
.readdirSync(path.join(estate.appDir, 'data', 'DC_JSLIB'))
|
||||
.filter((f) => f.endsWith('.json'))
|
||||
|
||||
for (const file of files) {
|
||||
const table = JSON.parse(
|
||||
fs.readFileSync(path.join(estate.appDir, 'data', 'DC_JSLIB', file), 'utf8')
|
||||
)
|
||||
const expected = withoutKey.indexOf(table.name) < 0
|
||||
assert.equal(
|
||||
!!(table.uniqueIndex && table.uniqueIndex.name && table.uniqueIndex.columns.length),
|
||||
expected,
|
||||
table.name + (expected ? ' has no unique key' : ' should have no unique key')
|
||||
)
|
||||
/* The index map and the schema are two lists of the same column names and
|
||||
nothing else ties them together: a rename in one would otherwise leave
|
||||
getddl emitting CREATE UNIQUE INDEX on a column the table does not have,
|
||||
and the test above would still pass. */
|
||||
if (!expected) continue
|
||||
const names = (table.columns || []).map((col) => col.name.toUpperCase())
|
||||
for (const keyed of table.uniqueIndex.columns) {
|
||||
assert.ok(
|
||||
names.indexOf(keyed.toUpperCase()) >= 0,
|
||||
table.name + ' indexes ' + keyed + ', which is not one of its columns'
|
||||
)
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -36,11 +36,11 @@ test('viewlibs lists the library the settings file names', () => {
|
||||
'the seeded estate names its control library'
|
||||
)
|
||||
|
||||
rewriteSettings(estate, 'DC7QATEST', 'dc-admin')
|
||||
rewriteSettings(estate, 'DC7QATST', 'dc-admin')
|
||||
|
||||
const after = JSON.parse(runService(estate, 'public/viewlibs', {})).saslibs
|
||||
assert.ok(
|
||||
after.some((row) => row.LIBRARYREF === 'DC7QATEST'),
|
||||
after.some((row) => row.LIBRARYREF === 'DC7QATST'),
|
||||
'viewlibs follows the settings file rather than a constant'
|
||||
)
|
||||
})
|
||||
@@ -50,14 +50,14 @@ test('getdata reads its table from the library the settings file names', () => {
|
||||
// move the control library's data files to the new libref, as a deployment
|
||||
// with a different DCPATH would
|
||||
const from = path.join(estate.appDir, 'data', 'DC_JSLIB')
|
||||
const to = path.join(estate.appDir, 'data', 'DC7QATEST')
|
||||
const to = path.join(estate.appDir, 'data', 'DC7QATST')
|
||||
fs.renameSync(from, to)
|
||||
rewriteSettings(estate, 'DC7QATEST', 'dc-admin')
|
||||
rewriteSettings(estate, 'DC7QATST', 'dc-admin')
|
||||
|
||||
const body = JSON.parse(
|
||||
runService(estate, 'editors/getdata', {
|
||||
tableName: 'SASControlTable',
|
||||
rows: [{ LIBDS: 'DC7QATEST.MPE_TABLES', FILTER_RK: -1 }]
|
||||
rows: [{ LIBDS: 'DC7QATST.MPE_TABLES', FILTER_RK: -1 }]
|
||||
})
|
||||
)
|
||||
|
||||
@@ -71,7 +71,7 @@ test('a converted service reads the library the settings file names', () => {
|
||||
writeTable(estate, 'MPE_SUBMIT', [
|
||||
{
|
||||
TABLE_ID: 'dc7qatest_1',
|
||||
BASE_LIB: 'DC7QATEST',
|
||||
BASE_LIB: 'DC7QATST',
|
||||
BASE_DS: 'MPE_TABLES',
|
||||
SUBMIT_STATUS_CD: 'SUBMITTED',
|
||||
SUBMITTED_BY_NM: 'someone',
|
||||
@@ -83,9 +83,9 @@ test('a converted service reads the library the settings file names', () => {
|
||||
])
|
||||
|
||||
const from = path.join(estate.appDir, 'data', 'DC_JSLIB')
|
||||
const to = path.join(estate.appDir, 'data', 'DC7QATEST')
|
||||
const to = path.join(estate.appDir, 'data', 'DC7QATST')
|
||||
fs.renameSync(from, to)
|
||||
rewriteSettings(estate, 'DC7QATEST', 'dc-admin')
|
||||
rewriteSettings(estate, 'DC7QATST', 'dc-admin')
|
||||
|
||||
const body = JSON.parse(runService(estate, 'approvers/getapprovals', {}))
|
||||
|
||||
@@ -174,13 +174,16 @@ test('a getrawdata export comes from the library the settings file names', () =>
|
||||
// reach, and the one that would silently drift
|
||||
const estate = seededEstate({ demo: false })
|
||||
const from = path.join(estate.appDir, 'data', 'DC_JSLIB')
|
||||
const to = path.join(estate.appDir, 'data', 'DC7QATEST')
|
||||
/* Eight characters, because that is the limit a LIBREF has: the service
|
||||
validates the LIBDS shape before it reads anything, and the estate answers
|
||||
a nine-character one with Invalid table. */
|
||||
const to = path.join(estate.appDir, 'data', 'DC7QATST')
|
||||
fs.renameSync(from, to)
|
||||
rewriteSettings(estate, 'DC7QATEST', 'dc-admin')
|
||||
rewriteSettings(estate, 'DC7QATST', 'dc-admin')
|
||||
|
||||
const csv = String(
|
||||
runService(estate, 'public/getrawdata', {
|
||||
params: { type: 'CSV', table: 'DC7QATEST.MPE_SELECTBOX', filter: '0' }
|
||||
params: { type: 'CSV', table: 'DC7QATST.MPE_SELECTBOX', filter: '0' }
|
||||
})
|
||||
)
|
||||
|
||||
|
||||
@@ -74,12 +74,17 @@ Run from the repository root, with the estate reachable and `sas/.env.server` ho
|
||||
|
||||
Two refusals are worth recognising. `REFUSED to publish` names a literal the projection kept - the raw response is in `.captures/<service>/<id>.raw.json`, gitignored, so the refusal can be diagnosed without hitting the estate again; fix the projection or the service, never the golden. A missing `sas/.env.server` means no credentials on this machine: the refresh is an agent or maintainer job, never a CI one, and the target must stay inside `SYNTHETIC_TARGETS`.
|
||||
|
||||
## The case a column name is reported in
|
||||
|
||||
A table keeps the case SAS created it with - `proc sql create table` with `alert_event` leaves `alert_event` in `dictionary.columns`, and the DDL reports it that way - while every payload the client reads (`getdata`, `getcols`, `viewdata`, the variables in `$sasdata`/`$viewdata`) reports the name upper case. So the mock's table files store the creation case, which is what the DDL generator prints, and every service builds its payload keys through `payloadKey()` in `dcMockUtils.js`. A service that reads a row by `col.name` instead of `payloadKey(col)` gets `undefined`, which shows up as an empty grid rather than as an error.
|
||||
|
||||
## Fixture divergences that are deliberate
|
||||
|
||||
`createdemodata` owns two differences from a SAS estate, both driven by existing specs rather than by the product:
|
||||
|
||||
- MPE_X_TEST loses one of the rules `mpe_makedata.sas` seeds for it: the `HARDREGEX` on SOME_SHORTNUM, which rejects 1-5. `regular_excel.xlsx` - the file the specs upload to `dc_jslib` - holds five rows where SOME_SHORTNUM is 5, so with the rule in place that upload is refused before it reaches the approval screen. SAS's other rules for the table are kept, because they are the ones the mock has always had and `editor.cy.ts` exercises them (the dropdown on a cell comes from the `HARDSELECT_HOOK` on SOME_NUM).
|
||||
- MPE_X_TEST's SOME_CHAR/SOME_DATE/SOME_DATETIME/SOME_TIME carry LABELs that differ from their names, which `viewer-labels.cy.ts` asserts, while a SAS estate has no labels on that table.
|
||||
- `MPE_X_CATALOG` holds no format entries, and `viewtables` identifies a catalog by the absence of variables, so a `-FC` download of it is an empty listing where the estate's `%mp_cntlout` would produce the FMTNAME/START/END/LABEL/TYPE columns with no rows. The service path around it is pinned instead: `public/getrawdata/catalog_suffix_on_dataset` records what the estate answers when the suffix names something that is not a catalog, and the mock answers that case the same way.
|
||||
|
||||
Both are asserted in `test/createdemodata.test.js`, so a change to either side is a failing test rather than a broken Cypress run. A future scenario covering `editors/getdata` on MPE_X_TEST would show them as accepted differences; today the startup scenario does not touch that table.
|
||||
|
||||
@@ -98,8 +103,7 @@ Some answers are estate state rather than service behaviour, and a golden for th
|
||||
Next, and why the obvious recording does not work:
|
||||
|
||||
- **A table the caller may not see.** `%mpe_accesscheck` lets an admin through, and the synthetic estate's user is in `DC_ADMIN_GROUP`, so every table is editable there and the "not authorised to edit" abort cannot be produced without changing the estate's security rows - which would make the golden a recording of that change. It needs an estate with a non-admin login before it can be recorded.
|
||||
- **The `public/getddl` body.** The abort is recorded; the DDL text is not. `mp_ds2ddl` builds it from `dictionary.columns`, `dictionary.indexes` and the table's constraints, and the mock's table files carry columns but no index or constraint metadata, so the mock would have to invent the `CREATE UNIQUE INDEX` block. Give the mock seed the key metadata first, then record the download.
|
||||
- **`public/getcols` on a libref that is not assigned.** The estate aborts `Invalid libds`; the mock falls back to searching every libref and answers. Tightening it is a behaviour change for the specs that pass a libref the mock does not hold a folder for, so it needs a survey of those callers before the golden.
|
||||
- **The older dialects of `public/getddl` that the client does not offer.** All three flavours the download menu names - SAS, TSQL and PGSQL - are generated from the table's own metadata, and each is recorded for `MPE_TABLES`, which is the table that pins them. A fourth flavour, or a change to one of these, needs its own recording before it is generated.
|
||||
|
||||
See the parity section of the `data-controller` skill for the surrounding workflow.
|
||||
|
||||
|
||||
@@ -15,10 +15,10 @@
|
||||
"body": {
|
||||
"sasjsAbort": [
|
||||
{
|
||||
"MSG": "Invalid libds",
|
||||
"MSG": "syscc=3000",
|
||||
"MAC": "/Public/app/dc/services/public/getcols.sas"
|
||||
}
|
||||
],
|
||||
"SYSCC": "0"
|
||||
"SYSCC": "3000"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"_meta": {
|
||||
"scenario": "malformed_libds",
|
||||
"service": "public/getcolvals",
|
||||
"estate": "synthetic",
|
||||
"targetType": "SASJS",
|
||||
"sas": "9.4M9",
|
||||
"dc": "7.22.0",
|
||||
"captured": "2026-11-08",
|
||||
"tls": "verified",
|
||||
"members": [
|
||||
"sasjsAbort"
|
||||
]
|
||||
},
|
||||
"body": {
|
||||
"sasjsAbort": [
|
||||
{
|
||||
"MSG": "Invalid libds",
|
||||
"MAC": "/Public/app/dc/services/public/getcolvals.sas"
|
||||
}
|
||||
],
|
||||
"SYSCC": "0"
|
||||
}
|
||||
}
|
||||
@@ -6,7 +6,7 @@
|
||||
"targetType": "SASJS",
|
||||
"sas": "9.4M9",
|
||||
"dc": "7.22.0",
|
||||
"captured": "2026-11-07",
|
||||
"captured": "2026-11-08",
|
||||
"tls": "verified",
|
||||
"members": [
|
||||
"sasjsAbort"
|
||||
@@ -15,8 +15,8 @@
|
||||
"body": {
|
||||
"sasjsAbort": [
|
||||
{
|
||||
"MSG": "Invalid libds",
|
||||
"MAC": "/Public/app/dc/services/public/getcolvals.sas"
|
||||
"MSG": "Target table not found: NOLIB.MPE_TABLES",
|
||||
"MAC": "MP_ABORT"
|
||||
}
|
||||
],
|
||||
"SYSCC": "0"
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"_meta": {
|
||||
"scenario": "mpe_tables",
|
||||
"service": "public/getddl",
|
||||
"estate": "synthetic",
|
||||
"targetType": "SASJS",
|
||||
"sas": "unknown",
|
||||
"dc": "7.22.0",
|
||||
"captured": "2026-11-07",
|
||||
"tls": "verified",
|
||||
"members": [
|
||||
"text"
|
||||
]
|
||||
},
|
||||
"body": {
|
||||
"text": "/* DDL generated by <USER> on <DATETIME> */\n/* SAS Flavour DDL for <LIBREF>.MPE_TABLES */\nproc sql;\ncreate table <LIBREF>.MPE_TABLES(\n tx_from num format=DATETIME19.3 not null \n ,tx_to num format=DATETIME19.3 not null \n ,libref char(8) not null \n ,dsn char(32) not null \n ,num_of_approvals_required num \n ,loadtype char(12) \n ,buskey char(1000) \n ,var_txfrom char(32) \n ,var_txto char(32) \n ,var_busfrom char(32) \n ,var_busto char(32) \n ,var_processed char(32) \n ,close_vars char(500) \n ,pre_edit_hook char(200) \n ,post_edit_hook char(200) \n ,pre_approve_hook char(200) \n ,post_approve_hook char(200) \n ,signoff_cols char(500) \n ,signoff_hook char(200) \n ,notes char(1000) \n ,rk_underlying char(1000) \n ,audit_libds char(41) \n);\nCREATE UNIQUE INDEX pk_mpe_tables ON <LIBREF>.MPE_TABLES (\n tx_to\n ,libref\n ,dsn\n);\n"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"_meta": {
|
||||
"scenario": "mpe_tables_pgsql",
|
||||
"service": "public/getddl",
|
||||
"estate": "synthetic",
|
||||
"targetType": "SASJS",
|
||||
"sas": "unknown",
|
||||
"dc": "7.22.0",
|
||||
"captured": "2026-11-07",
|
||||
"tls": "verified",
|
||||
"members": [
|
||||
"text"
|
||||
]
|
||||
},
|
||||
"body": {
|
||||
"text": "/* DDL generated by <USER> on <DATETIME> */\nCREATE SCHEMA <LIBREF>;\n/* Postgres Flavour DDL for <LIBREF>.MPE_TABLES */\nCREATE TABLE <LIBREF>.MPE_TABLES (\n \"tx_from\" TIMESTAMP NOT NULL\n ,\"tx_to\" TIMESTAMP NOT NULL\n ,\"libref\" VARCHAR(8) NOT NULL\n ,\"dsn\" VARCHAR(32) NOT NULL\n ,\"num_of_approvals_required\" DOUBLE PRECISION \n ,\"loadtype\" VARCHAR(12) \n ,\"buskey\" VARCHAR(1000) \n ,\"var_txfrom\" VARCHAR(32) \n ,\"var_txto\" VARCHAR(32) \n ,\"var_busfrom\" VARCHAR(32) \n ,\"var_busto\" VARCHAR(32) \n ,\"var_processed\" VARCHAR(32) \n ,\"close_vars\" VARCHAR(500) \n ,\"pre_edit_hook\" VARCHAR(200) \n ,\"post_edit_hook\" VARCHAR(200) \n ,\"pre_approve_hook\" VARCHAR(200) \n ,\"post_approve_hook\" VARCHAR(200) \n ,\"signoff_cols\" VARCHAR(500) \n ,\"signoff_hook\" VARCHAR(200) \n ,\"notes\" VARCHAR(1000) \n ,\"rk_underlying\" VARCHAR(1000) \n ,\"audit_libds\" VARCHAR(41) \n);\nCREATE UNIQUE INDEX \"pk_mpe_tables\" ON <LIBREF>.MPE_TABLES(\n \"tx_to\"\n ,\"libref\"\n ,\"dsn\"\n);\n"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"_meta": {
|
||||
"scenario": "mpe_tables_tsql",
|
||||
"service": "public/getddl",
|
||||
"estate": "synthetic",
|
||||
"targetType": "SASJS",
|
||||
"sas": "unknown",
|
||||
"dc": "7.22.0",
|
||||
"captured": "2026-11-07",
|
||||
"tls": "verified",
|
||||
"members": [
|
||||
"text"
|
||||
]
|
||||
},
|
||||
"body": {
|
||||
"text": "/* DDL generated by <USER> on <DATETIME> */\n/* TSQL Flavour DDL for <LIBREF>.MPE_TABLES */\ncreate table [<LIBREF>].[MPE_TABLES](\n [tx_from][datetime2](7) NOT NULL\n ,[tx_to][datetime2](7) NOT NULL\n ,[libref][varchar](8) NOT NULL\n ,[dsn][varchar](32) NOT NULL\n ,[num_of_approvals_required][decimal](18,2) \n ,[loadtype][varchar](12) \n ,[buskey][varchar](1000) \n ,[var_txfrom][varchar](32) \n ,[var_txto][varchar](32) \n ,[var_busfrom][varchar](32) \n ,[var_busto][varchar](32) \n ,[var_processed][varchar](32) \n ,[close_vars][varchar](500) \n ,[pre_edit_hook][varchar](200) \n ,[post_edit_hook][varchar](200) \n ,[pre_approve_hook][varchar](200) \n ,[post_approve_hook][varchar](200) \n ,[signoff_cols][varchar](500) \n ,[signoff_hook][varchar](200) \n ,[notes][varchar](1000) \n ,[rk_underlying][varchar](1000) \n ,[audit_libds][varchar](41) \n ,index [pk_mpe_tables] UNIQUE NONCLUSTERED (\n [tx_to]\n ,[libref]\n ,[dsn]\n )\n)\nGO\n"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"_meta": {
|
||||
"scenario": "unknown_flavour",
|
||||
"service": "public/getddl",
|
||||
"estate": "synthetic",
|
||||
"targetType": "SASJS",
|
||||
"sas": "unknown",
|
||||
"dc": "7.22.0",
|
||||
"captured": "2026-11-07",
|
||||
"tls": "verified",
|
||||
"members": [
|
||||
"text"
|
||||
]
|
||||
},
|
||||
"body": {
|
||||
"text": "/* DDL generated by <USER> on <DATETIME> */\n"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"_meta": {
|
||||
"scenario": "catalog_suffix_on_dataset",
|
||||
"service": "public/getrawdata",
|
||||
"estate": "synthetic",
|
||||
"targetType": "SASJS",
|
||||
"sas": "9.4M9",
|
||||
"dc": "7.22.0",
|
||||
"captured": "2026-11-08",
|
||||
"tls": "verified",
|
||||
"members": [
|
||||
"sasjsAbort"
|
||||
]
|
||||
},
|
||||
"body": {
|
||||
"sasjsAbort": [
|
||||
{
|
||||
"MSG": "syscc=1012",
|
||||
"MAC": "MP_ABORT"
|
||||
}
|
||||
],
|
||||
"SYSCC": "1012"
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -69,7 +69,13 @@ const PLACEHOLDERS = [
|
||||
[/\/tmp\/[^\s"']+/g, '<TMP>'],
|
||||
[/https?:\/\/[^\s"']+/g, '<URL>'],
|
||||
[/[\w.+-]+@[\w-]+\.[\w.-]+/g, '<EMAIL>'],
|
||||
[/\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?/g, '<DATETIME>']
|
||||
[/\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?/g, '<DATETIME>'],
|
||||
/*
|
||||
A DDL download credits the user who asked for it and the moment it was
|
||||
generated: a user id and a timestamp, neither of which the comparison is
|
||||
about. Both are masked together because they sit in one comment.
|
||||
*/
|
||||
[/\/\* DDL generated by \S+ on\s+\S+ \*\//g, '/* DDL generated by <USER> on <DATETIME> */']
|
||||
]
|
||||
|
||||
/* Keys whose value is a secret by definition, whatever the estate. Blank on the
|
||||
|
||||
@@ -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",
|
||||
@@ -497,7 +521,7 @@
|
||||
"table": "iwant",
|
||||
"rows": [
|
||||
{
|
||||
"libds": "NO_SUCH_LIB.MPE_TABLES",
|
||||
"libds": "NOLIB.MPE_TABLES",
|
||||
"col": "RULE_TYPE",
|
||||
"STARTROW": 1,
|
||||
"ROWS": 5
|
||||
@@ -508,7 +532,7 @@
|
||||
"table": "iwant",
|
||||
"rows": [
|
||||
{
|
||||
"libds": "NO_SUCH_LIB.MPE_TABLES",
|
||||
"libds": "NOLIB.MPE_TABLES",
|
||||
"col": "RULE_TYPE",
|
||||
"STARTROW": 1,
|
||||
"ROWS": 5
|
||||
@@ -560,6 +584,86 @@
|
||||
"note": "a temporal column formatted DATETIME. - no width in the name, so the format's own default (16) is the width, not the character fallback",
|
||||
"accepted": []
|
||||
},
|
||||
{
|
||||
"id": "mpe_tables",
|
||||
"service": "public/getddl",
|
||||
"format": "text",
|
||||
"params": {
|
||||
"ds": "MPE_TABLES",
|
||||
"libref": "{{LIBREF}}",
|
||||
"flavour": "SAS"
|
||||
},
|
||||
"mockParams": {
|
||||
"ds": "MPE_TABLES",
|
||||
"libref": "DC_JSLIB",
|
||||
"flavour": "SAS"
|
||||
},
|
||||
"members": [
|
||||
"text"
|
||||
],
|
||||
"note": "the DDL download for a control table: every column at the estate's own casing with its format, not-null and label, and the unique key mp_ds2ddl builds from dictionary.indexes",
|
||||
"accepted": []
|
||||
},
|
||||
{
|
||||
"id": "mpe_tables_pgsql",
|
||||
"service": "public/getddl",
|
||||
"format": "text",
|
||||
"params": {
|
||||
"ds": "MPE_TABLES",
|
||||
"libref": "{{LIBREF}}",
|
||||
"flavour": "PGSQL"
|
||||
},
|
||||
"mockParams": {
|
||||
"ds": "MPE_TABLES",
|
||||
"libref": "DC_JSLIB",
|
||||
"flavour": "PGSQL"
|
||||
},
|
||||
"members": [
|
||||
"text"
|
||||
],
|
||||
"note": "the PGSQL-flavoured DDL download: its own type names, quoting and key placement, from the same table metadata as the SAS flavour",
|
||||
"accepted": []
|
||||
},
|
||||
{
|
||||
"id": "mpe_tables_tsql",
|
||||
"service": "public/getddl",
|
||||
"format": "text",
|
||||
"params": {
|
||||
"ds": "MPE_TABLES",
|
||||
"libref": "{{LIBREF}}",
|
||||
"flavour": "TSQL"
|
||||
},
|
||||
"mockParams": {
|
||||
"ds": "MPE_TABLES",
|
||||
"libref": "DC_JSLIB",
|
||||
"flavour": "TSQL"
|
||||
},
|
||||
"members": [
|
||||
"text"
|
||||
],
|
||||
"note": "the TSQL-flavoured DDL download: its own type names, quoting and key placement, from the same table metadata as the SAS flavour",
|
||||
"accepted": []
|
||||
},
|
||||
{
|
||||
"id": "unknown_flavour",
|
||||
"service": "public/getddl",
|
||||
"format": "text",
|
||||
"params": {
|
||||
"ds": "MPE_TABLES",
|
||||
"libref": "{{LIBREF}}",
|
||||
"flavour": "NOSUCH"
|
||||
},
|
||||
"mockParams": {
|
||||
"ds": "MPE_TABLES",
|
||||
"libref": "DC_JSLIB",
|
||||
"flavour": "NOSUCH"
|
||||
},
|
||||
"members": [
|
||||
"text"
|
||||
],
|
||||
"note": "a flavour mp_ds2ddl has no branch for: the estate still streams a download, holding the generated-by comment and nothing else. The refusal that used to sit in the mock was invented, and this is what pins the absence",
|
||||
"accepted": []
|
||||
},
|
||||
{
|
||||
"id": "wrong_libref",
|
||||
"service": "public/getcols",
|
||||
@@ -568,7 +672,7 @@
|
||||
"table": "iwant",
|
||||
"rows": [
|
||||
{
|
||||
"LIBDS": "NO_SUCH_LIB.MPE_TABLES"
|
||||
"LIBDS": "NOLIB.MPE_TABLES"
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -576,20 +680,81 @@
|
||||
"table": "iwant",
|
||||
"rows": [
|
||||
{
|
||||
"LIBDS": "NO_SUCH_LIB.MPE_TABLES"
|
||||
"LIBDS": "NOLIB.MPE_TABLES"
|
||||
}
|
||||
]
|
||||
},
|
||||
"members": [
|
||||
"sasjsAbort"
|
||||
],
|
||||
"note": "a column list for a library the estate does not have: the library is validated before the member is looked for, so this aborts instead of answering from another library's copy",
|
||||
"note": "a column list for a well-formed library the estate does not have: the LIBDS passes the shape check and fails resolution, which is the estate's second abort",
|
||||
"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 mp_abort stops before it, so the mock's abort carries none. The message and the macro name - what the client renders - do match"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "malformed_libds",
|
||||
"service": "public/getcolvals",
|
||||
"params": {},
|
||||
"input": {
|
||||
"table": "iwant",
|
||||
"rows": [
|
||||
{
|
||||
"libds": "NO_SUCH_LIB.MPE_TABLES",
|
||||
"col": "RULE_TYPE",
|
||||
"STARTROW": 1,
|
||||
"ROWS": 5
|
||||
}
|
||||
]
|
||||
},
|
||||
"mockInput": {
|
||||
"table": "iwant",
|
||||
"rows": [
|
||||
{
|
||||
"libds": "NO_SUCH_LIB.MPE_TABLES",
|
||||
"col": "RULE_TYPE",
|
||||
"STARTROW": 1,
|
||||
"ROWS": 5
|
||||
}
|
||||
]
|
||||
},
|
||||
"members": [
|
||||
"sasjsAbort"
|
||||
],
|
||||
"note": "an 11-character libref: the LIBDS shape rule rejects it before anything is assigned, so this is the service's own abort rather than the resolution one - the pair with public/getcolvals/wrong_libref, which uses a valid-length name",
|
||||
"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 mp_abort stops before it, so the mock's abort carries none. The message and the macro name - what the client renders - do match"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "catalog_suffix_on_dataset",
|
||||
"service": "public/getrawdata",
|
||||
"params": {
|
||||
"type": "CSV",
|
||||
"table": "{{LIBREF}}.MPE_TABLES-FC",
|
||||
"filter": "0"
|
||||
},
|
||||
"mockParams": {
|
||||
"type": "CSV",
|
||||
"table": "DC_JSLIB.MPE_TABLES-FC",
|
||||
"filter": "0"
|
||||
},
|
||||
"members": [
|
||||
"sasjsAbort"
|
||||
],
|
||||
"note": "a -FC suffix on a name that is not a format catalog: the estate reads the name through %mp_cntlout and blows up with syscc=1012, so the mock must not fall back to the dataset of the same name",
|
||||
"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"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -98,39 +98,47 @@ test('a download carries a header row, drops the validity columns and keeps a ne
|
||||
)
|
||||
})
|
||||
|
||||
test('getcols refuses a library the estate does not have, instead of searching', () => {
|
||||
test('getcols separates a malformed LIBDS from one it cannot resolve', () => {
|
||||
const estate = seededEstate({ demo: false })
|
||||
const MAC = '/Public/app/dc/services/public/getcols.sas'
|
||||
|
||||
// getcols.sas validates LIBDS before it reads: a library it cannot assign is
|
||||
// "Invalid libds", even when the member exists in another library - the mock
|
||||
// used to find MPE_TABLES in DC_JSLIB and answer for it
|
||||
const wrongLib = JSON.parse(
|
||||
// the shape check is getcols.sas:42, and it is the only place "Invalid libds"
|
||||
// comes from: no member part, or a libref over eight characters
|
||||
for (const libds of ['MPE_TABLES', 'NO_SUCH_LIB.MPE_TABLES']) {
|
||||
const malformed = JSON.parse(
|
||||
runService(estate, 'public/getcols', {
|
||||
tableName: 'iwant',
|
||||
rows: [{ LIBDS: libds }]
|
||||
})
|
||||
)
|
||||
assert.deepEqual(
|
||||
malformed.sasjsAbort,
|
||||
[{ MSG: 'Invalid libds', MAC: MAC }],
|
||||
libds + ' is malformed, not merely unknown'
|
||||
)
|
||||
}
|
||||
|
||||
/* A well-formed library the estate does not have passes the shape check and
|
||||
fails resolution, which is the second abort - and today that abort is
|
||||
syscc=3000 for every input it covers, a defect in getcols.sas rather than a
|
||||
designed message, so the mock reproduces it as it is. */
|
||||
const unknown = JSON.parse(
|
||||
runService(estate, 'public/getcols', {
|
||||
tableName: 'iwant',
|
||||
rows: [{ LIBDS: 'NO_SUCH_LIB.MPE_TABLES' }]
|
||||
rows: [{ LIBDS: 'NOLIB.MPE_TABLES' }]
|
||||
})
|
||||
)
|
||||
assert.deepEqual(wrongLib.sasjsAbort, [
|
||||
{
|
||||
MSG: 'Invalid libds',
|
||||
MAC: '/Public/app/dc/services/public/getcols.sas'
|
||||
}
|
||||
])
|
||||
assert.equal(wrongLib.cols, undefined)
|
||||
assert.deepEqual(unknown.sasjsAbort, [{ MSG: 'syscc=3000', MAC: MAC }])
|
||||
assert.equal(unknown.cols, undefined)
|
||||
|
||||
// a LIBDS with no library part arrives at the same abort
|
||||
const malformed = JSON.parse(
|
||||
// a member missing from a library that does exist takes the same path
|
||||
const missing = JSON.parse(
|
||||
runService(estate, 'public/getcols', {
|
||||
tableName: 'iwant',
|
||||
rows: [{ LIBDS: 'MPE_TABLES' }]
|
||||
rows: [{ LIBDS: 'DC_JSLIB.NO_SUCH_TABLE' }]
|
||||
})
|
||||
)
|
||||
assert.deepEqual(malformed.sasjsAbort, [
|
||||
{
|
||||
MSG: 'Invalid libds',
|
||||
MAC: '/Public/app/dc/services/public/getcols.sas'
|
||||
}
|
||||
])
|
||||
assert.deepEqual(missing.sasjsAbort, [{ MSG: 'syscc=3000', MAC: MAC }])
|
||||
|
||||
// and the library the estate does have still answers
|
||||
const found = JSON.parse(
|
||||
|
||||
Reference in new issue
Block a user