forked from wangziqi/gongxue-base
186 lines
6.8 KiB
JavaScript
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');
|