Files
gongxue-base/scripts/pb-sqlite-export-test.js
2026-06-30 09:30:00 +08:00

186 lines
6.8 KiB
JavaScript

import assert from 'node:assert/strict';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { spawnSync } from 'node:child_process';
const repoRoot = process.cwd();
const exportScript = path.join(repoRoot, 'scripts', 'import-pocketbase', 'src', 'export-sqlite.ts');
function findPython() {
for (const candidate of [process.env.PYTHON, process.env.PYTHON_BIN, 'python', 'py', 'python3'].filter(Boolean)) {
const result = spawnSync(candidate, ['--version'], { encoding: 'utf8' });
if (result.status === 0) return candidate;
}
throw new Error('Python 3 is required for pb-sqlite-export-test');
}
function writeFixture(sqliteDir, storageDir) {
const python = findPython();
const script = `
import json
import pathlib
import sqlite3
sqlite_dir = pathlib.Path(r'''${sqliteDir}''')
storage_dir = pathlib.Path(r'''${storageDir}''')
data_db = sqlite_dir / 'data.db'
aux_db = sqlite_dir / 'auxiliary.db'
db = sqlite3.connect(data_db)
db.executescript("""
create table "_collections" (
id text primary key,
system boolean,
type text,
name text,
fields text,
indexes text,
listRule text,
viewRule text,
createRule text,
updateRule text,
deleteRule text,
options text,
created text,
updated text
);
create table "users" (
id text primary key,
created text,
updated text,
phone text,
password text,
tokenKey text,
wechatOpenId text,
wechatUnionId text,
stats text
);
create table "questions" (
id text primary key,
created text,
updated text,
content text,
options text,
media text
);
create table "_superusers" (
id text primary key,
email text,
password text
);
""")
insert_collection = """
insert into "_collections" (
id, system, type, name, fields, indexes, listRule, viewRule, createRule, updateRule, deleteRule, options, created, updated
) values (?, ?, ?, ?, ?, '[]', '', '', '', '', '', '{}', '2026-01-01 00:00:00Z', '2026-01-01 00:00:00Z')
"""
db.execute(insert_collection, ('_pb_users_auth_', 0, 'auth', 'users', json.dumps([
{'name': 'id', 'type': 'text'},
{'name': 'password', 'type': 'password'},
{'name': 'tokenKey', 'type': 'text'},
{'name': 'phone', 'type': 'text'},
{'name': 'wechatOpenId', 'type': 'text'},
{'name': 'wechatUnionId', 'type': 'text'},
{'name': 'stats', 'type': 'json'},
])))
db.execute(insert_collection, ('pbc_questions', 0, 'base', 'questions', json.dumps([
{'name': 'id', 'type': 'text'},
{'name': 'content', 'type': 'text'},
{'name': 'options', 'type': 'json'},
{'name': 'media', 'type': 'file', 'maxSelect': 1},
])))
db.execute(insert_collection, ('pbc_superusers', 1, 'auth', '_superusers', json.dumps([
{'name': 'password', 'type': 'password'},
])))
db.execute(
'insert into users (id, created, updated, phone, password, tokenKey, wechatOpenId, wechatUnionId, stats) values (?, ?, ?, ?, ?, ?, ?, ?, ?)',
('u1', '2026-01-01 00:00:00Z', '2026-01-01 00:00:00Z', '13800000000', 'hash-secret', 'token-secret', 'wx-openid', 'wx-unionid', json.dumps({'answered': 3})),
)
db.execute(
'insert into questions (id, created, updated, content, options, media) values (?, ?, ?, ?, ?, ?)',
('q1', '2026-01-01 00:00:00Z', '2026-01-01 00:00:00Z', '题干', json.dumps(['A', 'B']), 'question.png'),
)
db.execute('insert into _superusers (id, email, password) values (?, ?, ?)', ('su1', 'root@example.com', 'secret'))
db.commit()
db.close()
aux = sqlite3.connect(aux_db)
aux.executescript("""
create table "_logs" (
id text primary key,
level integer not null,
message text not null,
data text not null,
created text not null
);
""")
aux.execute(
'insert into "_logs" (id, level, message, data, created) values (?, ?, ?, ?, ?)',
('l1', 1, 'GET /api/collections/users/records/u1', json.dumps({'token': 'must-not-export'}), '2026-01-01 00:00:00Z'),
)
aux.commit()
aux.close()
media_dir = storage_dir / 'pbc_questions' / 'q1'
media_dir.mkdir(parents=True, exist_ok=True)
(media_dir / 'question.png').write_bytes(b'fixture-image')
(media_dir / 'question.png.attrs').write_text('{}', encoding='utf-8')
`;
const result = spawnSync(python, ['-c', script], { encoding: 'utf8' });
if (result.error) throw result.error;
assert.equal(result.status, 0, `fixture setup should pass\nstdout=${result.stdout}\nstderr=${result.stderr}`);
}
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'tiku-pb-sqlite-export-'));
const sqliteDir = path.join(root, 'pb');
const exportDir = path.join(root, 'export');
const storageDir = path.join(sqliteDir, 'storage');
fs.mkdirSync(storageDir, { recursive: true });
writeFixture(sqliteDir, storageDir);
const result = spawnSync(process.execPath, ['--import', 'tsx', exportScript], {
cwd: repoRoot,
encoding: 'utf8',
env: {
...process.env,
PB_SQLITE_DIR: sqliteDir,
PB_EXPORT_DIR: exportDir,
},
});
assert.equal(result.status, 0, `export should pass\nstdout=${result.stdout}\nstderr=${result.stderr}`);
const users = JSON.parse(fs.readFileSync(path.join(exportDir, 'users.json'), 'utf8'));
assert.equal(users.length, 1, 'users collection should export records');
assert.equal(users[0].phone, '13800000000', 'phone should be kept for migration matching');
assert.equal('password' in users[0], false, 'password should be removed from normal JSON export');
assert.equal('tokenKey' in users[0], false, 'token should be removed from normal JSON export');
assert.equal('wechatOpenId' in users[0], false, 'openid should be removed by default');
assert.deepEqual(users[0].stats, { answered: 3 }, 'JSON fields should be parsed');
const questions = JSON.parse(fs.readFileSync(path.join(exportDir, 'questions.json'), 'utf8'));
assert.deepEqual(questions[0].options, ['A', 'B'], 'question JSON fields should be parsed');
assert.ok(!fs.existsSync(path.join(exportDir, '_superusers.json')), 'system collections should not export by default');
const manifest = JSON.parse(fs.readFileSync(path.join(exportDir, 'sqlite-export-manifest.json'), 'utf8'));
assert.equal(manifest.collectionCount, 2, 'manifest should include exported collection count');
assert.equal(manifest.recordCount, 2, 'manifest should include exported record count');
assert.ok(
manifest.collections.find(item => item.name === 'users')?.redactedFields.some(item => item.fieldPath === 'password'),
'manifest should report redacted user password field',
);
assert.equal(manifest.storage.fileCount, 1, 'manifest should summarize storage files');
assert.equal(manifest.auxiliary.tables[0].name, '_logs', 'manifest should summarize auxiliary logs');
const storageManifest = JSON.parse(fs.readFileSync(path.join(exportDir, 'storage-manifest.json'), 'utf8'));
assert.equal(storageManifest.files[0].collectionName, 'questions', 'storage manifest should map collection id to name');
assert.ok(storageManifest.files[0].sha256, 'storage manifest should include file hash');
fs.rmSync(root, { recursive: true, force: true });
console.log('[PASS] PocketBase SQLite export');