Files
dc/sas/mocks/test/control-tables.test.js
T
dc 67d9cfd1bd
Build / Build-and-ng-test (pull_request) Successful in 5m40s
Lighthouse Checks / lighthouse (pull_request) Successful in 7m54s
Build / Build-and-test-development (pull_request) Successful in 32m44s
fix(mocks): 0 is a closed TX_TO, a libref boundary, and the union of the row keys
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.
2026-11-05 10:44:28 +00:00

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'
)
})