Files
dc/sas/mocks/test/makedata-parity.test.js
T
dc b0bb57ecad
Build / Build-and-ng-test (pull_request) Canceled after 2m36s
Build / Build-and-test-development (pull_request) Canceled after 0s
Build / lighthouse (pull_request) Canceled after 0s
test(mocks): tie each table's key to the columns it names
The index map and the schema are two lists of the same column names, and nothing
else joins them: a later rename in one would leave getddl emitting CREATE UNIQUE
INDEX on a column the table does not have, while the test that exists to check
every table has a key kept passing.

Each uniqueIndex column must now resolve to a column of its own table,
case-insensitively, so a rename on either side fails here instead of in a
download.

Merges release/7.23, which carries the 4.14.0 pinned estate and the lazy-route
revert.
2026-11-07 20:37:51 +00:00

283 lines
10 KiB
JavaScript

/**
* The mock makedata must build the estate the SAS service builds.
*
* makedata.sas (the `server` target) runs `%mpe_makedatamodel` for the empty
* tables and `%mpe_makedata` for the rows. The JS mock in
* sas/mocks/sasjs/services/admin/makedata.js is a second implementation of
* those two macros, and the two used to disagree: two model tables
* (MPE_MAXKEYVALUES, MPE_SIGNOFFS) were never created, and a handful of seed
* rows were missing. Nothing failed, because the client only asks for the
* tables it opens.
*
* So the macros are read here, and the estate the mock seeds is compared with
* what they declare. A change to either side that is not made on the other
* fails this test.
*
* 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, readTable } = require('./harness')
const MACROS = path.join(__dirname, '..', '..', 'sasjs', 'macros')
const MODEL = fs.readFileSync(
path.join(MACROS, 'mpe_makedatamodel.sas'),
'utf8'
)
const SEED = fs.readFileSync(path.join(MACROS, 'mpe_makedata.sas'), 'utf8')
/** `create table &lib..name(` blocks, with their column definitions. */
function modelTables(source) {
const tables = {}
const re = /create table &lib\.\.\s*([A-Za-z0-9_-]+)\s*\(([\s\S]*?)\n\s*\);/gi
let match
while ((match = re.exec(source)) !== null) {
const name = match[1].toUpperCase()
const columns = []
for (const line of match[2].split('\n')) {
const col = /^\s*,?\s*([A-Za-z0-9_]+)\s+(num|char|varchar|float|int)\b/i.exec(
line
)
if (col) columns.push(col[1].toUpperCase())
}
tables[name] = columns
}
return tables
}
/** `insert into &lib..name set` statements, grouped by table. */
function seedInserts(source) {
const body = source.replace(/\/\*[\s\S]*?\*\//g, '')
const rows = {}
const re = /insert into &lib\.\.\s*([A-Za-z0-9_-]+)\s+set\s+([\s\S]*?);/gi
let match
while ((match = re.exec(body)) !== null) {
const name = match[1].toUpperCase()
const columns = []
for (const part of match[2].split(/[\n,]/)) {
const assignment = /^\s*([A-Za-z0-9_]+)\s*=/.exec(part)
if (assignment) columns.push(assignment[1].toUpperCase())
}
rows[name] = rows[name] || []
rows[name].push(columns.sort())
}
return rows
}
const model = modelTables(MODEL)
const seeds = seedInserts(SEED)
/*
These four carry rows the mock cannot copy verbatim:
- MPE_GROUPS, MPE_ROW_LEVEL_SECURITY and MPE_SECURITY name SAS metadata
groups, which a JS-only estate does not have (the mock uses its own).
- MPE_X_TEST is seeded with ~490 rows from a ranuni() loop, and the mock
keeps the seven deterministic rows only.
*/
const ADAPTED = new Set([
'MPE_GROUPS',
'MPE_ROW_LEVEL_SECURITY',
'MPE_SECURITY',
'MPE_X_TEST'
])
test('mpe_makedatamodel declares tables, and the mock creates all of them', () => {
const estate = seededEstate({ demo: false })
const created = {}
const dir = path.join(estate.appDir, 'data', 'DC_JSLIB')
for (const file of fs.readdirSync(dir)) {
const table = JSON.parse(fs.readFileSync(path.join(dir, file), 'utf8'))
created[table.name] = table
}
assert.ok(Object.keys(model).length > 30, 'the model was parsed')
const missing = Object.keys(model).filter((name) => !created[name])
assert.deepEqual(
missing,
[],
'every table the model creates must exist in the mock estate'
)
const known = new Set(Object.keys(model))
// the mock writes a marker for the format catalog, which is a SAS catalog
// rather than a dataset, so the model does not declare it
known.add('MPE_X_CATALOG')
const extra = Object.keys(created).filter((name) => !known.has(name))
assert.deepEqual(
extra,
[],
'a new table in the mock estate is a table the SAS service does not build'
)
})
test('each table has the model columns, in the model order', () => {
const estate = seededEstate({ demo: false })
for (const [name, expected] of Object.entries(model)) {
const actual = readTable(estate, name)
// readTable returns rows; the schema is read from the file instead
const file = path.join(
estate.appDir,
'data',
'DC_JSLIB',
name.toLowerCase() + '.json'
)
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.toUpperCase()),
expected,
name + ' columns differ from mpe_makedatamodel.sas'
)
assert.ok(Array.isArray(actual), name + ' rows')
}
})
test('each seeded table carries the rows mpe_makedata inserts', () => {
const estate = seededEstate({ demo: false })
for (const [name, inserts] of Object.entries(seeds)) {
if (ADAPTED.has(name)) continue
const rows = readTable(estate, name)
assert.equal(
rows.length,
inserts.length,
name + ' row count differs from mpe_makedata.sas'
)
// a table whose inserts name optional columns (MPE_XLMAP_RULES sets
// XLMAP_FINISH on some ranges only) has a union; every row must sit
// inside it, and the union must be the one the macro writes
const union = new Set()
for (const insert of inserts) for (const field of insert) union.add(field)
const expected = Array.from(union).sort()
for (const row of rows) {
const fields = Object.keys(row)
.map((field) => field.toUpperCase())
.sort()
for (const field of fields) {
assert.ok(
union.has(field),
name + ' row carries ' + field + ', which mpe_makedata never sets'
)
}
assert.deepEqual(
fields,
expected.filter((field) => fields.includes(field)),
name + ' row fields differ from mpe_makedata.sas'
)
}
}
})
test('the adapted tables are seeded, and document why they differ', () => {
const estate = seededEstate({ demo: false })
for (const name of ADAPTED) {
const rows = readTable(estate, name)
assert.ok(rows.length > 0, name + ' is empty')
}
// the mock's own groups are the ones its services report
const groups = readTable(estate, 'MPE_GROUPS')
assert.ok(
groups.some((row) => row.group_name === 'dc-admin'),
'the mock grants its own group, not a SAS metadata one'
)
})
test('every seeded value fits its column type', () => {
// The rows are transcribed from SAS source, where a value can be a macro
// expression. The mock has no macro engine, so a transcription that leaves
// one behind (a %mf_increment(rk) surrogate key, a quoted '0A'x newline)
// writes a string where the client expects a number or a line break.
const estate = seededEstate({ demo: false })
for (const name of Object.keys(model)) {
const file = path.join(
estate.appDir,
'data',
'DC_JSLIB',
name.toLowerCase() + '.json'
)
const table = JSON.parse(fs.readFileSync(file, 'utf8'))
const types = {}
for (const col of table.columns) types[col.name.toUpperCase()] = col.type
for (const row of table.rows) {
for (const [field, value] of Object.entries(row)) {
const type = types[field.toUpperCase()]
if (value === null || value === undefined) continue
if (type === 'N') {
assert.equal(
typeof value,
'number',
name + '.' + field + ' is numeric but carries ' + JSON.stringify(value)
)
} else {
assert.equal(
typeof value,
'string',
name + '.' + field + ' is character but carries ' + JSON.stringify(value)
)
assert.ok(
value.indexOf('\\u0001') < 0,
name + '.' + field + ' still holds an escaped newline'
)
}
}
}
}
})
test('makedata leaves no Cypress fixture behind', () => {
const estate = seededEstate({ demo: false })
const dir = path.join(estate.appDir, 'data')
assert.deepEqual(
fs.readdirSync(dir).sort(),
['DC_JSLIB'],
'the demo libraries belong to createdemodata, not makedata'
)
const tables = readTable(estate, 'MPE_TABLES')
const fixture = tables.filter(
(row) => String(row.libref).toUpperCase() !== 'DC_JSLIB'
)
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'
)
}
}
})