Files
dc/sas/mocks/test/startupservice.test.js
T
dc ca602082ff
Build / Build-and-ng-test (pull_request) Successful in 5m43s
Lighthouse Checks / lighthouse (pull_request) Successful in 8m58s
Build / Build-and-test-development (pull_request) Successful in 29m42s
fix(mocks): abort like mp_abort, lock like mp_lockanytable, derive saslibs
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.
2026-11-04 15:37:31 +00:00

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