Three more places where the mock answered differently from the SAS service. mp_abort (dcMockUtils): every mock refusal is now a sasjsAbort table (MSG + MAC) written where the macro writes it, so the client shows the abort modal and rejects the promise. A thrown error had become an HTTP 500 "Job execution failed" - the browser host discards the webout when a program throws - which is unreadable to the user and hides the message in a stack. mpAbort also stops the program, so nothing is written after a refusal; each service that can abort catches that stop at its top level. stagedata, loadfile, getauditfile and makedata now go through it, and the hand-rolled sasjsAbort blocks are gone. mp_lockanytable (dcMockUtils): a lock is a row in MPE_LOCKANYTABLE marked LOCKED carrying the holder's job id, user and reference, exactly as the macro records it, and a lock held by another session refuses the write with the macro's message. postdata and rejection now lock MPE_REVIEW and MPE_SUBMIT around each write, which is where postdata.sas and rejection.sas take them. The macro's retry loop is collapsed to a single attempt: the runtime is single threaded, so nothing can release a lock while the request waits. startupservice: saslibs is the distinct LIBREF of the permitted datasets, which is what "create table saslibs as select distinct libref from &syslast" produces - it was a four-column row describing the control library, with fields the client does not read. Tests: abort.test.js and locks.test.js join the harness suites; 25 pass, and 9 of the 13 relevant cases fail against the base services.
204 lines
6.3 KiB
JavaScript
204 lines
6.3 KiB
JavaScript
/**
|
|
* The mock startupservice, exercised the way the estates run it.
|
|
*
|
|
* Every screen reads this service's payload, so a divergence here is a
|
|
* divergence in the whole app. The SAS service derives the Excel maps from
|
|
* the control tables, removes the map target datasets from the editable list,
|
|
* and reports the session's timezone; the mock used to hardcode all three.
|
|
*
|
|
* 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, readTable, writeTable } = require('./harness')
|
|
|
|
const DC_LIB = 'DC_JSLIB'
|
|
|
|
function startupservice(estate) {
|
|
return JSON.parse(runService(estate, 'public/startupservice'))
|
|
}
|
|
|
|
/** Rewrites one control table on the estate's drive. */
|
|
const setTable = (estate, name, rows) => writeTable(estate, name, rows)
|
|
const getTable = (estate, name) => readTable(estate, name)
|
|
|
|
/** The offset as SAS formats it, computed through a different Intl API. */
|
|
function expectedOffset() {
|
|
const parts = new Intl.DateTimeFormat('en-GB', {
|
|
timeZoneName: 'longOffset'
|
|
}).formatToParts(new Date())
|
|
const name = (parts.find((p) => p.type === 'timeZoneName') || {}).value || ''
|
|
const m = /GMT([+-])(\d{2}):(\d{2})/.exec(name)
|
|
return m ? m[1] + m[2] + m[3] : '+0000'
|
|
}
|
|
|
|
test('the Excel maps are derived from the rules, not a hardcoded list', () => {
|
|
const estate = seededEstate()
|
|
const payload = startupservice(estate)
|
|
|
|
// the seed carries rules for three maps, but MPE_XLMAP_INFO describes one
|
|
assert.deepEqual(
|
|
payload.xlmaps.map((entry) => entry[0]),
|
|
['BASEL-CR2', 'BASEL-KM1', 'SAMPLE']
|
|
)
|
|
assert.deepEqual(
|
|
payload.xlmaps.find((entry) => entry[0] === 'BASEL-KM1'),
|
|
['BASEL-KM1', 'Basel 3 Key Metrics report', DC_LIB + '.MPE_XLMAP_DATA'],
|
|
'the description comes from MPE_XLMAP_INFO'
|
|
)
|
|
assert.deepEqual(
|
|
payload.xlmaps.find((entry) => entry[0] === 'SAMPLE'),
|
|
['SAMPLE', '', DC_LIB + '.MPE_XLMAP_DATA'],
|
|
'a map with no INFO row still appears, with the default target'
|
|
)
|
|
})
|
|
|
|
test('removing a rule removes the map', () => {
|
|
const estate = seededEstate()
|
|
setTable(
|
|
estate,
|
|
'MPE_XLMAP_RULES',
|
|
getTable(estate, 'MPE_XLMAP_RULES').filter(
|
|
(rule) => rule.xlmap_id !== 'SAMPLE'
|
|
)
|
|
)
|
|
|
|
const payload = startupservice(estate)
|
|
|
|
assert.deepEqual(
|
|
payload.xlmaps.map((entry) => entry[0]),
|
|
['BASEL-CR2', 'BASEL-KM1'],
|
|
'a hardcoded list would still offer SAMPLE'
|
|
)
|
|
})
|
|
|
|
test('a rule with no MPE_XLMAP_INFO row still appears, with the default target', () => {
|
|
const estate = seededEstate()
|
|
const rules = getTable(estate, 'MPE_XLMAP_RULES')
|
|
rules.push({
|
|
tx_from: 0,
|
|
tx_to: 253717919999,
|
|
xlmap_id: 'NO-INFO',
|
|
xlmap_range_id: 'X:a',
|
|
xlmap_sheet: 'X',
|
|
xlmap_start: 'MATCH 1 R[1]C[1]:a'
|
|
})
|
|
setTable(estate, 'MPE_XLMAP_RULES', rules)
|
|
|
|
const payload = startupservice(estate)
|
|
const map = payload.xlmaps.find((entry) => entry[0] === 'NO-INFO')
|
|
|
|
assert.deepEqual(map, ['NO-INFO', '', DC_LIB + '.MPE_XLMAP_DATA'])
|
|
})
|
|
|
|
test('an INFO row naming another target excludes that dataset instead', () => {
|
|
const estate = seededEstate()
|
|
const infos = getTable(estate, 'MPE_XLMAP_INFO')
|
|
infos.push({
|
|
tx_from: 0,
|
|
tx_to: 253717919999,
|
|
XLMAP_ID: 'OTHER-TARGET',
|
|
XLMAP_DESCRIPTION: 'Writes to a table of its own',
|
|
XLMAP_TARGETLIBDS: 'TESTDATA.DEMO_ORDERS'
|
|
})
|
|
setTable(estate, 'MPE_XLMAP_INFO', infos)
|
|
|
|
const rules = getTable(estate, 'MPE_XLMAP_RULES')
|
|
rules.push({
|
|
tx_from: 0,
|
|
tx_to: 253717919999,
|
|
xlmap_id: 'OTHER-TARGET',
|
|
xlmap_range_id: 'Y:a',
|
|
xlmap_sheet: 'Y',
|
|
xlmap_start: 'MATCH 1 R[1]C[1]:a'
|
|
})
|
|
setTable(estate, 'MPE_XLMAP_RULES', rules)
|
|
|
|
const payload = startupservice(estate)
|
|
|
|
assert.deepEqual(
|
|
payload.xlmaps.find((entry) => entry[0] === 'OTHER-TARGET'),
|
|
['OTHER-TARGET', 'Writes to a table of its own', 'TESTDATA.DEMO_ORDERS']
|
|
)
|
|
assert.ok(
|
|
!payload.sasdatasets.some(
|
|
(table) => table.LIBREF === 'TESTDATA' && table.DSN === 'DEMO_ORDERS'
|
|
),
|
|
'the named target is not offered as an editable table'
|
|
)
|
|
})
|
|
|
|
test('the map target dataset is not offered as an editable table', () => {
|
|
const estate = seededEstate()
|
|
const payload = startupservice(estate)
|
|
|
|
const keys = payload.sasdatasets.map((t) => t.LIBREF + '.' + t.DSN)
|
|
assert.ok(!keys.includes(DC_LIB + '.MPE_XLMAP_DATA'), 'MPE_XLMAP_DATA hidden')
|
|
assert.ok(
|
|
keys.includes(DC_LIB + '.MPE_XLMAP_INFO'),
|
|
'the map config tables stay editable'
|
|
)
|
|
assert.ok(keys.includes(DC_LIB + '.MPE_TABLES'))
|
|
})
|
|
|
|
test('a closed rule is neither published nor excluded', () => {
|
|
const estate = seededEstate()
|
|
// only a closed rule: TX_TO before now, so the row is history
|
|
setTable(estate, 'MPE_XLMAP_RULES', [
|
|
{
|
|
tx_from: 0,
|
|
tx_to: 1000,
|
|
xlmap_id: 'CLOSED-MAP',
|
|
xlmap_range_id: 'Z:a',
|
|
xlmap_sheet: 'Z',
|
|
xlmap_start: 'MATCH 1 R[1]C[1]:a'
|
|
}
|
|
])
|
|
|
|
const payload = startupservice(estate)
|
|
|
|
assert.deepEqual(payload.xlmaps, [])
|
|
assert.ok(
|
|
payload.sasdatasets.some((t) => t.DSN === 'MPE_XLMAP_DATA'),
|
|
'nothing is excluded on behalf of a closed map'
|
|
)
|
|
})
|
|
|
|
test('the session timezone and offset are read from the runtime', () => {
|
|
const estate = seededEstate()
|
|
const globvars = startupservice(estate).globvars[0]
|
|
|
|
assert.equal(
|
|
globvars.SYSTIMEZONEIDENT,
|
|
new Intl.DateTimeFormat().resolvedOptions().timeZone,
|
|
'the zone is the one the session runs in'
|
|
)
|
|
assert.equal(
|
|
globvars.SYSTIMEZONEOFFSET,
|
|
expectedOffset(),
|
|
'the offset is the current one, DST included'
|
|
)
|
|
assert.match(globvars.SYSTIMEZONEOFFSET, /^[+-]\d{4}$/)
|
|
assert.equal(
|
|
globvars.TIMEZONE,
|
|
'',
|
|
'the TIMEZONE option is unset in this runtime, as it is on a Linux SAS'
|
|
)
|
|
})
|
|
|
|
test('saslibs is the distinct libraries of the permitted datasets', () => {
|
|
const estate = seededEstate()
|
|
const payload = startupservice(estate)
|
|
|
|
// "create table saslibs as select distinct libref from &syslast" - one row
|
|
// per library the datasets live in, and no other columns
|
|
const expected = [...new Set(payload.sasdatasets.map((t) => t.LIBREF))].sort(
|
|
(a, b) => a.localeCompare(b)
|
|
)
|
|
assert.deepEqual(payload.saslibs, expected.map((libref) => ({ LIBREF: libref })))
|
|
assert.ok(payload.saslibs.length > 1, 'the seed spans the control and test libs')
|
|
})
|