Four review findings. - isCurrentOn counted TX_TO 0 as current. 0 is 01JAN1960 in SAS, so a real row with that window has been superseded; only a missing, null or blank TX_TO is open besides 31DEC9999. The fixtures are seeded with 31DEC9999 (253717919999) through an OPEN constant instead, and the 0 clause is gone. - The MPE_DATADICTIONARY filter compared DD_SOURCE against the libref without a boundary, so DC_JSLIB2.TABLE was excluded along with DC_JSLIB.TABLE. It now tests for the libref plus the dot, as the libds rewrite does. - The schema-less export took its column list from Object.keys(rows[0]), so a column that first appears in a later row was silently missing from the backup. The list is the union of the keys across the exported rows, in first-seen order. - control-tables.test.js imported readTable and never used it.
327 lines
9.8 KiB
JavaScript
327 lines
9.8 KiB
JavaScript
/**
|
|
* The control-table services: getxlmaps, refreshcatalog and exportconfig.
|
|
*
|
|
* These three had no mock at all, so the screens that call them either failed
|
|
* or showed nothing on the mock estate. Each is exercised against a seeded
|
|
* estate the way the runtime runs it, and the assertions are the behaviour the
|
|
* SAS service has: which rows are current, which are filtered out, and what
|
|
* the response members are called.
|
|
*
|
|
* Run with: node --test sas/mocks/test/
|
|
*/
|
|
const test = require('node:test')
|
|
const assert = require('node:assert')
|
|
const fs = require('node:fs')
|
|
|
|
const path = require('node:path')
|
|
|
|
const { seededEstate, runService, writeTable } = require('./harness')
|
|
|
|
/** SAS datetimes are seconds since 1960-01-01 - the value TX_TO holds. */
|
|
const sasNow = () => Math.floor((Date.now() - Date.UTC(1960, 0, 1)) / 1000)
|
|
|
|
const parse = (body) => JSON.parse(body)
|
|
|
|
/** 31DEC9999 23:59:59 in SAS seconds: the TX_TO of a row that is still open. */
|
|
const OPEN = 253717919999
|
|
|
|
// ─── getxlmaps ───────────────────────────────────────────────────────────────
|
|
|
|
test('getxlmaps returns the rules and info of the requested map only', () => {
|
|
const estate = seededEstate()
|
|
|
|
writeTable(estate, 'MPE_XLMAP_RULES', [
|
|
{
|
|
XLMAP_ID: 'Sample',
|
|
XLMAP_RANGE_ID: 'Range2',
|
|
XLMAP_SHEET: 'Sheet1',
|
|
XLMAP_START: 'RELATIVE R[2]C[2]',
|
|
XLMAP_FINISH: 'ABSOLUTE H11',
|
|
TX_TO: OPEN
|
|
},
|
|
{
|
|
XLMAP_ID: 'Sample',
|
|
XLMAP_RANGE_ID: 'Range1',
|
|
XLMAP_SHEET: 'Sheet1',
|
|
XLMAP_START: 'ABSOLUTE A1',
|
|
XLMAP_FINISH: '',
|
|
TX_TO: OPEN
|
|
},
|
|
{
|
|
XLMAP_ID: 'Other',
|
|
XLMAP_RANGE_ID: 'Range9',
|
|
XLMAP_SHEET: 'Sheet1',
|
|
XLMAP_START: 'ABSOLUTE A1',
|
|
XLMAP_FINISH: '',
|
|
TX_TO: OPEN
|
|
}
|
|
])
|
|
writeTable(estate, 'MPE_XLMAP_INFO', [
|
|
{ XLMAP_ID: 'Sample', XLMAP_TARGETLIBDS: 'TESTDATA.MPE_XLMAP_DATA', TX_TO: OPEN }
|
|
])
|
|
|
|
const body = parse(
|
|
runService(estate, 'editors/getxlmaps', {
|
|
rows: [{ XLMAP_ID: 'Sample' }],
|
|
tableName: 'getxlmaps_in'
|
|
})
|
|
)
|
|
|
|
assert.deepEqual(
|
|
body.xlmaprules.map((row) => row.XLMAP_RANGE_ID),
|
|
['Range1', 'Range2'],
|
|
'only that map, ordered by range id'
|
|
)
|
|
assert.equal(
|
|
body.xlmaprules.some((row) => row.XLMAP_ID === 'Other'),
|
|
false
|
|
)
|
|
assert.deepEqual(body.xlmapinfo, [{ TARGET_DS: 'TESTDATA.MPE_XLMAP_DATA' }])
|
|
})
|
|
|
|
test('getxlmaps drops a superseded rule and falls back to the map table', () => {
|
|
const estate = seededEstate()
|
|
|
|
writeTable(estate, 'MPE_XLMAP_RULES', [
|
|
{
|
|
XLMAP_ID: 'Sample',
|
|
XLMAP_RANGE_ID: 'Range1',
|
|
XLMAP_SHEET: 'Sheet1',
|
|
XLMAP_START: 'ABSOLUTE A1',
|
|
XLMAP_FINISH: '',
|
|
TX_TO: OPEN
|
|
},
|
|
{
|
|
XLMAP_ID: 'Sample',
|
|
XLMAP_RANGE_ID: 'Range2',
|
|
XLMAP_SHEET: 'Sheet1',
|
|
XLMAP_START: 'ABSOLUTE A2',
|
|
XLMAP_FINISH: '',
|
|
TX_TO: sasNow() - 3600
|
|
}
|
|
])
|
|
writeTable(estate, 'MPE_XLMAP_INFO', [])
|
|
|
|
const body = parse(
|
|
runService(estate, 'editors/getxlmaps', {
|
|
rows: [{ XLMAP_ID: 'Sample' }],
|
|
tableName: 'getxlmaps_in'
|
|
})
|
|
)
|
|
|
|
assert.deepEqual(
|
|
body.xlmaprules.map((row) => row.XLMAP_RANGE_ID),
|
|
['Range1'],
|
|
'a TX_TO in the past is a superseded row'
|
|
)
|
|
assert.deepEqual(
|
|
body.xlmapinfo,
|
|
[{ TARGET_DS: 'DC_JSLIB.MPE_XLMAP_DATA' }],
|
|
'the coalesce to the DC-managed map table'
|
|
)
|
|
})
|
|
|
|
test('getxlmaps returns empty members for an unknown map', () => {
|
|
const estate = seededEstate()
|
|
writeTable(estate, 'MPE_XLMAP_RULES', [])
|
|
writeTable(estate, 'MPE_XLMAP_INFO', [])
|
|
|
|
const body = parse(
|
|
runService(estate, 'editors/getxlmaps', {
|
|
rows: [{ XLMAP_ID: 'Nope' }],
|
|
tableName: 'getxlmaps_in'
|
|
})
|
|
)
|
|
|
|
assert.deepEqual(body.xlmaprules, [])
|
|
assert.deepEqual(body.xlmapinfo, [{ TARGET_DS: 'DC_JSLIB.MPE_XLMAP_DATA' }])
|
|
})
|
|
|
|
// ─── refreshcatalog ──────────────────────────────────────────────────────────
|
|
|
|
test('refreshcatalog reports completion for a full refresh', () => {
|
|
const estate = seededEstate()
|
|
|
|
const body = parse(runService(estate, 'admin/refreshcatalog', { params: {} }))
|
|
|
|
assert.deepEqual(body.sasparams, [{ msg: 'Catalog Refresh Complete' }])
|
|
})
|
|
|
|
test('refreshcatalog accepts a libref and refuses a malformed one', () => {
|
|
const estate = seededEstate()
|
|
|
|
const ok = parse(
|
|
runService(estate, 'admin/refreshcatalog', { params: { libref: 'sales' } })
|
|
)
|
|
assert.deepEqual(ok.sasparams, [{ msg: 'Catalog Refresh Complete' }])
|
|
|
|
const refused = parse(
|
|
runService(estate, 'admin/refreshcatalog', {
|
|
params: { libref: 'not a libref' }
|
|
})
|
|
)
|
|
assert.equal(refused.sasjsAbort.length, 1)
|
|
assert.equal(refused.sasjsAbort[0].MSG, 'Invalid libref')
|
|
})
|
|
|
|
// ─── exportconfig ────────────────────────────────────────────────────────────
|
|
//
|
|
// The service streams a zip where the runtime has Buffer (a server, and this
|
|
// harness), and describes the pack where it does not (the browser host). Both
|
|
// answers are the contract, so the helper below reads whichever arrived.
|
|
|
|
function exportConfig(estate, params) {
|
|
const body = runService(estate, 'admin/exportconfig', { params })
|
|
const headers = fs.readFileSync(estate.headersPath, 'utf8')
|
|
|
|
if (/content-type:\s*application\/zip/i.test(headers)) {
|
|
const bytes = fs.readFileSync(estate.weboutPath)
|
|
return { streamed: true, bytes, text: bytes.toString('latin1'), headers }
|
|
}
|
|
|
|
const parsed = JSON.parse(body)
|
|
return {
|
|
streamed: false,
|
|
names: parsed.contents.map((entry) => entry.name),
|
|
text: body
|
|
}
|
|
}
|
|
|
|
const memberList = (result) =>
|
|
result.streamed
|
|
? // entry names sit verbatim in the zip's local and central headers
|
|
Array.from(
|
|
new Set(
|
|
(result.text.match(/[A-Z0-9_]+\.[A-Z0-9_]+\.csv/g) || []).map((n) => n)
|
|
)
|
|
)
|
|
: result.names
|
|
|
|
test('exportconfig writes a member per non-empty table, and rewrites the DC libref', () => {
|
|
const estate = seededEstate()
|
|
|
|
writeTable(estate, 'MPE_TABLES', [
|
|
{
|
|
LIBREF: 'DC_JSLIB',
|
|
TABLE: 'MPE_CONFIG',
|
|
TX_TO: OPEN,
|
|
TX_FROM: 0,
|
|
NUM_OF_APPROVALS_REQUIRED: 1
|
|
},
|
|
{
|
|
LIBREF: 'TESTDATA',
|
|
TABLE: 'SOME_DATA',
|
|
TX_TO: OPEN,
|
|
TX_FROM: 0,
|
|
NUM_OF_APPROVALS_REQUIRED: 1
|
|
}
|
|
])
|
|
writeTable(estate, 'MPE_XLMAP_INFO', [
|
|
{ XLMAP_ID: 'Sample', XLMAP_TARGETLIBDS: 'DC_JSLIB.MPE_XLMAP_DATA', TX_TO: OPEN }
|
|
])
|
|
for (const name of ['MPE_ALERTS', 'MPE_EMAILS', 'MPE_XLMAP_RULES', 'MPE_CONFIG']) {
|
|
writeTable(estate, name, [])
|
|
}
|
|
|
|
const result = exportConfig(estate, {})
|
|
const names = memberList(result)
|
|
|
|
assert.ok(names.includes('DC_JSLIB.MPE_TABLES.csv'))
|
|
assert.ok(names.includes('DC_JSLIB.MPE_XLMAP_INFO.csv'))
|
|
assert.ok(
|
|
!names.includes('DC_JSLIB.MPE_ALERTS.csv'),
|
|
'an empty table is not exported'
|
|
)
|
|
assert.ok(
|
|
!result.text.includes('SOME_DATA'),
|
|
'the control library keeps its own rows out of MPE_TABLES'
|
|
)
|
|
// with the default target the rewrite is a no-op, so the DC name stays; it
|
|
// is the NEWLIB case below that proves the rewriting happens
|
|
if (result.streamed) {
|
|
assert.match(result.headers, /DCBACKUP\.zip/)
|
|
}
|
|
})
|
|
|
|
test('exportconfig names every member for the target libref', () => {
|
|
const estate = seededEstate()
|
|
writeTable(estate, 'MPE_XLMAP_INFO', [
|
|
{ XLMAP_ID: 'Sample', XLMAP_TARGETLIBDS: 'DC_JSLIB.MPE_XLMAP_DATA', TX_TO: OPEN }
|
|
])
|
|
|
|
const result = exportConfig(estate, { dclib: 'newlib' })
|
|
const names = memberList(result)
|
|
|
|
assert.ok(names.length > 0, 'at least one table was exported')
|
|
assert.ok(
|
|
result.text.includes('NEWLIB.MPE_XLMAP_DATA'),
|
|
'the map target is rewritten to the target libref'
|
|
)
|
|
assert.ok(
|
|
!result.text.includes('DC_JSLIB.MPE_XLMAP_DATA'),
|
|
'and the control library name is gone from the value'
|
|
)
|
|
assert.ok(
|
|
names.every((name) => name.indexOf('NEWLIB.') === 0),
|
|
'every member is named for the target libref'
|
|
)
|
|
})
|
|
|
|
test('exportconfig refuses a target that is not a libref', () => {
|
|
const estate = seededEstate()
|
|
|
|
const body = parse(
|
|
runService(estate, 'admin/exportconfig', { params: { dclib: 'bad libref' } })
|
|
)
|
|
|
|
assert.equal(body.sasjsAbort.length, 1)
|
|
assert.equal(body.sasjsAbort[0].MSG, 'BAD LIBREF is not a valid libref')
|
|
})
|
|
|
|
test('exportconfig keeps the dropped columns out when a table has no schema', () => {
|
|
const estate = seededEstate()
|
|
|
|
for (const name of ['MPE_ALERTS', 'MPE_EMAILS', 'MPE_XLMAP_RULES', 'MPE_CONFIG']) {
|
|
writeTable(estate, name, [])
|
|
}
|
|
writeTable(estate, 'MPE_XLMAP_INFO', [])
|
|
|
|
// A table file with rows but no column metadata: the estate was seeded by
|
|
// something other than makedata, so loadTableSchema finds nothing.
|
|
const file = path.join(estate.appDir, 'data', 'DC_JSLIB', 'mpe_tables.json')
|
|
fs.writeFileSync(
|
|
file,
|
|
JSON.stringify({
|
|
rows: [
|
|
{
|
|
LIBREF: 'TESTDATA',
|
|
TABLE: 'SOME_DATA',
|
|
TX_TO: OPEN,
|
|
TX_FROM: 0,
|
|
NUM_OF_APPROVALS_REQUIRED: 1
|
|
}
|
|
]
|
|
})
|
|
)
|
|
|
|
const result = exportConfig(estate, {})
|
|
|
|
const start = result.text.indexOf('DC_JSLIB.MPE_TABLES.csv')
|
|
assert.ok(start >= 0, 'the table with no schema is still exported')
|
|
const member = result.text.slice(start, result.text.indexOf('PK', start))
|
|
|
|
assert.match(
|
|
member,
|
|
/"LIBREF","TABLE","NUM_OF_APPROVALS_REQUIRED"/,
|
|
'the TX_ columns are dropped and the surviving ones keep their order'
|
|
)
|
|
assert.ok(
|
|
!member.includes('TX_'),
|
|
'a dropped column is not exported just because the schema is missing'
|
|
)
|
|
assert.ok(
|
|
member.includes('"TESTDATA","SOME_DATA",1'),
|
|
'a numeric value is left bare, as mp_ds2csv writes it'
|
|
)
|
|
})
|