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');