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