seed-demo-data-template.test.mjs
8.56 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
// lib/seed-demo-data-template.test.mjs — 校验生成模板 scripts/seed-demo-data.mjs 的演示种子注入逻辑。
// 跑的是真实模板产物:复制到临时 scripts/ 下、写一个 ../config-vars.yaml、可选写 sql/seed/*.sql、再 node 执行。
// 所有会触达 DB 的用例 host/port 故意指向 127.0.0.1:1(必拒连),不会触碰真实库。
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { spawnSync } from 'node:child_process'
import { mkdtempSync, mkdirSync, copyFileSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { fileURLToPath } from 'node:url'
const TEMPLATE = fileURLToPath(new URL('../skills/plan/skeleton-gen/templates/scripts-seed-demo-data-template.mjs', import.meta.url))
// 搭一个临时目标项目:scripts/seed-demo-data.mjs + config-vars.yaml + 可选 sql/seed/*.sql,然后跑脚本。
// seedFiles 为 null 表示不创建 sql/seed 目录;为对象 { name: content } 表示创建目录并写入这些文件({} = 空目录)。
// env:传给子进程的增量环境变量;laneMarker:非 null 时写 .tmp/erp-lane-db 兜底标记。
// 基础环境强制剔除 ERP_TEST_DB_SCHEMA——外层 shell 若恰好设了该变量,"缺省行为"用例会被污染。
function run({
host = '127.0.0.1',
port = '1',
user = 'root',
password = 'x',
schemaLine = 'schema: erp_dev',
seedFiles = null,
env = {},
laneMarker = null,
} = {}) {
const dir = mkdtempSync(join(tmpdir(), 'erp-seed-'))
mkdirSync(join(dir, 'scripts'))
copyFileSync(TEMPLATE, join(dir, 'scripts', 'seed-demo-data.mjs'))
writeFileSync(
join(dir, 'config-vars.yaml'),
['database:', ` host: ${host}`, ` port: ${port}`, ` user: ${user}`, ` password: ${password}`, ' ' + schemaLine, ''].join('\n'),
)
if (seedFiles !== null) {
const seedDir = join(dir, 'sql', 'seed')
mkdirSync(seedDir, { recursive: true })
for (const [name, content] of Object.entries(seedFiles)) {
writeFileSync(join(seedDir, name), content)
}
}
if (laneMarker !== null) {
mkdirSync(join(dir, '.tmp'))
writeFileSync(join(dir, '.tmp', 'erp-lane-db'), laneMarker)
}
const childEnv = { ...process.env, ...env }
if (!('ERP_TEST_DB_SCHEMA' in env)) delete childEnv.ERP_TEST_DB_SCHEMA
return spawnSync('node', [join(dir, 'scripts', 'seed-demo-data.mjs')], { encoding: 'utf8', env: childEnv })
}
test('seed-demo-data: unfilled 【人工填写】 config placeholders fail before mysql is invoked', () => {
for (const cfg of [
{ host: '【人工填写:MySQL host】' },
{ port: '【人工填写:MySQL port】' },
{ user: '【人工填写:账号】' },
{ password: '【人工填写:密码】' },
{ schemaLine: 'schema: 【人工填写:schema 名】' },
]) {
const r = run(cfg)
assert.equal(r.status, 1, 'stdout: ' + r.stdout + ' stderr: ' + r.stderr)
assert.match(r.stderr, /仍是占位/, 'stderr: ' + r.stderr)
}
})
test('seed-demo-data: empty schema fails before mysql is invoked', () => {
const r = run({ schemaLine: 'schema:' })
assert.equal(r.status, 1)
assert.match(r.stderr, /database\.schema/, '应是 schema 缺失报错而非连库失败 — stderr: ' + r.stderr)
})
test('seed-demo-data: missing sql/seed directory exits 0 with no-seed message', () => {
const r = run({ seedFiles: null })
assert.equal(r.status, 0, 'stdout: ' + r.stdout + ' stderr: ' + r.stderr)
assert.match(r.stdout, /无种子文件/, 'stdout: ' + r.stdout)
})
test('seed-demo-data: empty sql/seed directory exits 0 with no-seed message', () => {
const r = run({ seedFiles: {} })
assert.equal(r.status, 0, 'stdout: ' + r.stdout + ' stderr: ' + r.stderr)
assert.match(r.stdout, /无种子文件/, 'stdout: ' + r.stdout)
})
test('seed-demo-data: illegal seed filename fails before mysql is invoked', () => {
const r = run({ seedFiles: { '01 bad.sql': '-- expect: t=0\n' } })
assert.equal(r.status, 1, 'stdout: ' + r.stdout + ' stderr: ' + r.stderr)
assert.match(r.stderr, /非法种子文件名/, 'stderr: ' + r.stderr)
assert.match(r.stderr, /01 bad\.sql/, '应列出违规文件名 — stderr: ' + r.stderr)
// 文件名校验在任何 mysql 调用之前 —— 不应出现已进入 DB 阶段的日志。
assert.doesNotMatch(r.stdout, /目标库/, 'stdout: ' + r.stdout)
})
// 缺 <NN>__ 结构的文件名(会被旧宽松正则放过,但违反 <NN>__<module_id>.sql 契约)必须被拒。
test('seed-demo-data: filenames missing the <NN>__ structure are rejected', () => {
for (const bad of [
'inventory.sql', // 无 NN__ 前缀
'1__x.sql', // 单位数 NN
'001__x.sql', // 三位数 NN
'01-x.sql', // 连字符而非双下划线
'01_x.sql', // 单下划线而非双下划线
'01__.sql', // module_id 为空
'01__bad-id.sql', // module_id 含连字符(非 [A-Za-z0-9_])
]) {
const r = run({ seedFiles: { [bad]: '-- expect: t=0\n' } })
assert.equal(r.status, 1, `${bad} 应被拒 — stdout: ${r.stdout} stderr: ${r.stderr}`)
assert.match(r.stderr, /非法种子文件名/, `${bad} — stderr: ${r.stderr}`)
// 文件名校验在任何 mysql 调用之前 —— 不应进入 DB 阶段。
assert.doesNotMatch(r.stdout, /目标库/, `${bad} — stdout: ${r.stdout}`)
}
})
// 合法 <NN>__<module_id>.sql 通过文件名校验后进入 DB 阶段(验证收紧后的正则不误伤合法名)。
test('seed-demo-data: well-formed <NN>__<module_id>.sql passes filename check', () => {
const r = run({ seedFiles: { '01__inventory.sql': '-- expect: inventory_item=3\nSELECT 1;\n' } })
assert.equal(r.status, 1, 'stdout: ' + r.stdout + ' stderr: ' + r.stderr)
assert.doesNotMatch(r.stderr, /非法种子文件名/, 'stderr: ' + r.stderr)
assert.match(r.stdout, /目标库 erp_dev__test/, '串行缺省目标应为副本 erp_dev__test(非源库)— stdout: ' + r.stdout)
})
test('seed-demo-data: valid seed + port 1 passes local checks then fails at DB connect', () => {
const r = run({ seedFiles: { '01__inventory.sql': '-- expect: inventory_item=3\nSELECT 1;\n' } })
assert.equal(r.status, 1, 'stdout: ' + r.stdout + ' stderr: ' + r.stderr)
// 已通过全部本地校验、进入 DB 阶段:应打印目标库摘要,且报错是连库失败(非占位/非法名)。
assert.match(r.stdout, /目标库 erp_dev__test/, '串行缺省目标应为副本 erp_dev__test(非源库)— stdout: ' + r.stdout)
assert.doesNotMatch(r.stderr, /仍是占位/, 'stderr: ' + r.stderr)
assert.doesNotMatch(r.stderr, /非法种子文件名/, 'stderr: ' + r.stderr)
})
// ── 注入目标副本 lane 化:取值顺序 env > .tmp/erp-lane-db marker > `<source>__test` ──
const LANE_SEED = { '01__inventory.sql': '-- expect: inventory_item=3\nSELECT 1;\n' }
test('seed-demo-data: ERP_TEST_DB_SCHEMA env overrides config schema', () => {
const r = run({ seedFiles: LANE_SEED, env: { ERP_TEST_DB_SCHEMA: 'erp_dev_lane2' } })
assert.match(r.stdout, /目标库 erp_dev_lane2/, 'stdout: ' + r.stdout)
})
test('seed-demo-data: .tmp/erp-lane-db marker overrides config when env is absent, with audit log', () => {
const r = run({ seedFiles: LANE_SEED, laneMarker: 'erp_dev_lane3\n' })
assert.match(r.stdout, /lane DB marker 生效: erp_dev_lane3/, 'stdout: ' + r.stdout)
assert.match(r.stdout, /目标库 erp_dev_lane3/, 'stdout: ' + r.stdout)
})
test('seed-demo-data: env wins over marker; blank marker falls through to config', () => {
const both = run({ seedFiles: LANE_SEED, env: { ERP_TEST_DB_SCHEMA: 'erp_dev_lane2' }, laneMarker: 'erp_dev_lane3\n' })
assert.match(both.stdout, /目标库 erp_dev_lane2/, 'stdout: ' + both.stdout)
assert.doesNotMatch(both.stdout, /lane DB marker 生效/, 'stdout: ' + both.stdout)
const blank = run({ seedFiles: LANE_SEED, laneMarker: '\n' })
assert.match(blank.stdout, /目标库 erp_dev__test/, '空 marker 应回退到副本 erp_dev__test — stdout: ' + blank.stdout)
assert.doesNotMatch(blank.stdout, /lane DB marker 生效/, 'stdout: ' + blank.stdout)
})
// **安全红线**:注入目标 = 源库 → 拒绝(演示种子绝不写源库)。守卫先于任何 mysql 调用。
test('seed-demo-data: injection target equal to source schema is rejected before mysql', () => {
const r = run({ seedFiles: LANE_SEED, env: { ERP_TEST_DB_SCHEMA: 'erp_dev' } }) // 与源库同名
assert.equal(r.status, 1, 'stdout: ' + r.stdout + ' stderr: ' + r.stderr)
assert.match(r.stderr, /注入目标副本名与源库名相同/, 'stderr: ' + r.stderr)
assert.doesNotMatch(r.stdout, /目标库/, '拒绝应发生在进入 DB 阶段之前 — stdout: ' + r.stdout)
})