forked from wangziqi/gongxue-base
199 lines
8.3 KiB
JavaScript
199 lines
8.3 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 dryRunScript = path.join(repoRoot, 'scripts', 'import-pocketbase', 'src', 'dry-run-report.ts');
|
|
|
|
function writeJson(filePath, value) {
|
|
fs.writeFileSync(filePath, JSON.stringify(value, null, 2), 'utf8');
|
|
}
|
|
|
|
function runDryRun(exportDir, options = {}) {
|
|
const result = spawnSync(process.execPath, ['--import', 'tsx', dryRunScript, '--json', ...(options.args || [])], {
|
|
cwd: repoRoot,
|
|
encoding: 'utf8',
|
|
env: {
|
|
...process.env,
|
|
PB_EXPORT_DIR: exportDir,
|
|
...(options.env || {}),
|
|
},
|
|
});
|
|
|
|
if (result.error) throw result.error;
|
|
const stdout = result.stdout || '';
|
|
const jsonStart = stdout.indexOf('{');
|
|
const payload = JSON.parse(jsonStart >= 0 ? stdout.slice(jsonStart) : '{}');
|
|
return { ...result, payload };
|
|
}
|
|
|
|
function createSafeExport() {
|
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'tiku-pb-dry-safe-'));
|
|
writeJson(path.join(dir, 'users.json'), [
|
|
{ id: 'u1', phone: '13800000000', role: 'student', nickname: '学生A' },
|
|
]);
|
|
writeJson(path.join(dir, 'subjects.json'), [
|
|
{ id: 's1', name: '英语' },
|
|
]);
|
|
writeJson(path.join(dir, 'categories.json'), [
|
|
{ id: 'c1', name: '阅读理解', subjectId: 's1' },
|
|
]);
|
|
writeJson(path.join(dir, 'questions.json'), [
|
|
{ id: 'q1', subjectId: 's1', categoryId: 'c1', type: 'choice', content: '题干', answer: 'A' },
|
|
]);
|
|
writeJson(path.join(dir, 'orders.json'), [
|
|
{ id: 'o1', userId: 'u1', planId: 'p1', amount: 99, status: 'paid' },
|
|
]);
|
|
writeJson(path.join(dir, 'svip_plans.json'), [
|
|
{ id: 'p1', name: '题库会员', price: 99 },
|
|
]);
|
|
writeJson(path.join(dir, 'codes.json'), [
|
|
{ id: 'code1', code: 'SAFE-001', status: 'unused' },
|
|
]);
|
|
writeJson(path.join(dir, 'vocabulary_units.json'), [
|
|
{ id: 'vu1', name: 'Unit 1' },
|
|
]);
|
|
writeJson(path.join(dir, 'vocabulary.json'), [
|
|
{ id: 'vw1', unitId: 'vu1', word: 'abandon', meaning: '放弃' },
|
|
]);
|
|
writeJson(path.join(dir, 'handbook_subjects.json'), [
|
|
{ id: 'hs1', name: '政治' },
|
|
]);
|
|
writeJson(path.join(dir, 'handbook_chapters.json'), [
|
|
{ id: 'hc1', subjectId: 'hs1', title: '第一章' },
|
|
]);
|
|
writeJson(path.join(dir, 'handbook_entries.json'), [
|
|
{ id: 'he1', chapterId: 'hc1', title: '知识点', content: '内容' },
|
|
]);
|
|
return dir;
|
|
}
|
|
|
|
function createUnsafeExport() {
|
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'tiku-pb-dry-unsafe-'));
|
|
writeJson(path.join(dir, 'users.json'), [
|
|
{ id: 'u1', phone: '13800000000', password: 'plaintext-should-not-exist' },
|
|
{ phone: '13900000000' },
|
|
]);
|
|
writeJson(path.join(dir, 'questions.json'), [
|
|
{ id: 'q1', subjectId: 'missing-subject', content: '题干' },
|
|
{ id: 'q1', subjectId: 'missing-subject', content: '重复题' },
|
|
]);
|
|
writeJson(path.join(dir, 'settings.json'), [
|
|
{ id: 'settings1', appSecret: 'secret-in-public-export' },
|
|
]);
|
|
writeJson(path.join(dir, 'unknown_business.json'), [
|
|
{ id: 'x1', name: '尚未映射集合' },
|
|
]);
|
|
return dir;
|
|
}
|
|
|
|
function createWarningOnlyExport() {
|
|
const dir = createSafeExport();
|
|
writeJson(path.join(dir, 'unknown_business.json'), [
|
|
{ id: 'x1', name: '尚未映射集合' },
|
|
]);
|
|
return dir;
|
|
}
|
|
|
|
function createBrokenRelationExport() {
|
|
const dir = createSafeExport();
|
|
writeJson(path.join(dir, 'questions.json'), [
|
|
{ id: 'q1', subjectId: 'missing-subject', categoryId: 'c1', type: 'choice', content: '题干', answer: 'A' },
|
|
]);
|
|
return dir;
|
|
}
|
|
|
|
function createProductionIncompleteExport() {
|
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'tiku-pb-dry-prod-incomplete-'));
|
|
writeJson(path.join(dir, 'users.json'), [
|
|
{ id: 'u1', role: 'student', nickname: '缺手机号学生' },
|
|
]);
|
|
writeJson(path.join(dir, 'questions.json'), [
|
|
{ id: 'q1', categoryId: 'c1', type: 'choice', content: '缺科目题' },
|
|
]);
|
|
return dir;
|
|
}
|
|
|
|
const safeDir = createSafeExport();
|
|
const safe = runDryRun(safeDir);
|
|
assert.equal(safe.status, 0, `safe dry-run should pass: ${safe.stdout} ${safe.stderr}`);
|
|
assert.equal(safe.payload.summary?.blockers, 0, 'safe dry-run should have no blockers');
|
|
assert.ok(safe.payload.summary?.records >= 12, 'safe dry-run should count fixture records');
|
|
assert.equal(safe.payload.businessCounts?.questions, 1, 'safe dry-run should report question count');
|
|
assert.equal(safe.payload.migrationProfile, 'development', 'default dry-run profile should be development');
|
|
assert.ok(Array.isArray(safe.payload.migrationReadiness?.requiredCollections), 'dry-run should report migration readiness');
|
|
|
|
const safeProduction = runDryRun(safeDir, { args: ['--profile=production'] });
|
|
assert.equal(safeProduction.status, 0, `complete production dry-run should pass: ${safeProduction.stdout} ${safeProduction.stderr}`);
|
|
assert.equal(safeProduction.payload.summary?.blockers, 0, 'complete production dry-run should have no blockers');
|
|
|
|
const invalidProfile = runDryRun(safeDir, { args: ['--profile=prodution'] });
|
|
assert.notEqual(invalidProfile.status, 0, 'invalid migration profile should fail closed');
|
|
assert.ok(
|
|
invalidProfile.payload.issues?.some(item => item.code === 'migration_profile_invalid'),
|
|
'invalid migration profile should be reported as a blocker',
|
|
);
|
|
|
|
const unsafeDir = createUnsafeExport();
|
|
const unsafe = runDryRun(unsafeDir);
|
|
assert.notEqual(unsafe.status, 0, 'unsafe dry-run should fail');
|
|
assert.ok(unsafe.payload.summary?.blockers > 0, 'unsafe dry-run should report blockers');
|
|
assert.ok(
|
|
unsafe.payload.issues?.some(item => item.code === 'record_id_missing' && item.collection === 'users'),
|
|
'unsafe dry-run should detect missing user id',
|
|
);
|
|
assert.ok(
|
|
unsafe.payload.issues?.some(item => item.code === 'record_id_duplicate' && item.collection === 'questions'),
|
|
'unsafe dry-run should detect duplicate question id',
|
|
);
|
|
assert.ok(
|
|
unsafe.payload.issues?.some(item => item.code === 'sensitive_field_in_export'),
|
|
'unsafe dry-run should detect sensitive fields',
|
|
);
|
|
assert.ok(
|
|
unsafe.payload.issues?.some(item => item.code === 'unsupported_collection'),
|
|
'unsafe dry-run should warn about unsupported collections',
|
|
);
|
|
assert.ok(
|
|
unsafe.payload.issues?.some(item => item.code === 'relation_target_export_missing' && item.collection === 'questions'),
|
|
'unsafe dry-run should block when question relation target export is missing',
|
|
);
|
|
|
|
const warningOnlyDir = createWarningOnlyExport();
|
|
const warningOnly = runDryRun(warningOnlyDir);
|
|
assert.equal(warningOnly.status, 0, 'warning-only dry-run should pass without fail-on-warnings');
|
|
assert.ok(warningOnly.payload.summary?.warnings > 0, 'warning-only dry-run should report warnings');
|
|
const warningAsFailure = runDryRun(warningOnlyDir, { args: ['--fail-on-warnings'] });
|
|
assert.notEqual(warningAsFailure.status, 0, 'warning-only dry-run should fail with --fail-on-warnings');
|
|
|
|
const brokenRelationDir = createBrokenRelationExport();
|
|
const brokenRelation = runDryRun(brokenRelationDir);
|
|
assert.notEqual(brokenRelation.status, 0, 'broken relation dry-run should fail');
|
|
assert.ok(
|
|
brokenRelation.payload.issues?.some(item => item.code === 'relation_unresolved' && item.collection === 'questions'),
|
|
'broken relation dry-run should detect unresolved question subject relation',
|
|
);
|
|
|
|
const productionIncompleteDir = createProductionIncompleteExport();
|
|
const productionIncomplete = runDryRun(productionIncompleteDir, { args: ['--profile=production'] });
|
|
assert.notEqual(productionIncomplete.status, 0, 'production profile should fail incomplete export');
|
|
assert.ok(
|
|
productionIncomplete.payload.issues?.some(item => item.code === 'production_required_collection_missing'),
|
|
'production profile should block missing required collections',
|
|
);
|
|
assert.ok(
|
|
productionIncomplete.payload.issues?.some(item => item.code === 'critical_field_coverage_low' && item.collection === 'questions'),
|
|
'production profile should block critical field coverage gaps',
|
|
);
|
|
assert.equal(productionIncomplete.payload.migrationProfile, 'production', 'production profile should be reflected in report');
|
|
|
|
fs.rmSync(safeDir, { recursive: true, force: true });
|
|
fs.rmSync(unsafeDir, { recursive: true, force: true });
|
|
fs.rmSync(warningOnlyDir, { recursive: true, force: true });
|
|
fs.rmSync(brokenRelationDir, { recursive: true, force: true });
|
|
fs.rmSync(productionIncompleteDir, { recursive: true, force: true });
|
|
|
|
console.log('[PASS] PocketBase dry-run report');
|