import fs from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; const FN_URL = process.env.SMOKE_FN_URL || 'https://server.fmode.cn/api/functions'; const APP_ID = process.env.SMOKE_PARSE_APP_ID || 'ncloudmaster'; const SESSION_TOKEN = process.env.SMOKE_SESSION_TOKEN || ''; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const rootDir = path.resolve(__dirname, '..', '..'); const cloudFunctionsPath = path.join(rootDir, 'src', 'app', 'services', 'cloud-functions.ts'); const fn = readCloudFunctionIds(cloudFunctionsPath); const probeType = 'governanceProbe'; const probeId = `parse-storage-${Date.now()}`; const errors = []; let probeAssetId = ''; if (!SESSION_TOKEN) { fail('Missing SMOKE_SESSION_TOKEN. Provide one test user Parse session token.'); } for (const key of ['systemStorage', 'upload', 'fileAsset', 'authCredit']) { if (!fn[key]) fail(`CLOUD_FN.${key} is empty. Deploy and fill src/app/services/cloud-functions.ts before running validation.`); } if (errors.length) exitWithErrors(); try { await run('authCredit.balance keeps APIG reachable', async () => { const result = await call(fn.authCredit, { action: 'balance' }); assertOk(result, 'authCredit.balance should succeed'); assert(result?.data?.apigId === '6pFf6EAdKT', `APIG id must stay 6pFf6EAdKT: ${summarize(result)}`); assert(Number.isFinite(Number(result?.data?.balance)), 'authCredit.balance must return numeric balance'); }); await run('systemStorage writes VideoWorkflowEntity', async () => { const upsert = await call(fn.systemStorage, { action: 'upsert', entityType: probeType, entityId: probeId, status: 'active', data: { marker: probeId, source: 'parse-storage-postdeploy', }, }); assertOk(upsert, 'systemStorage.upsert should succeed'); assert(upsert?.data?.data?.marker === probeId, `systemStorage.upsert should return probe payload: ${summarize(upsert)}`); }); await run('systemStorage reads VideoWorkflowEntity', async () => { const get = await call(fn.systemStorage, { action: 'get', entityType: probeType, entityId: probeId }); assertOk(get, 'systemStorage.get should succeed'); assert(get?.data?.data?.marker === probeId, `systemStorage.get should return the probe payload: ${summarize(get)}`); }); await run('systemStorage writes VideoWorkflowAudit detail string', async () => { const audit = await call(fn.systemStorage, { action: 'audit', auditAction: 'parse-storage-postdeploy', entityType: probeType, entityId: probeId, summary: 'Parse storage postdeploy probe', detail: { marker: probeId }, }); assertOk(audit, 'systemStorage.audit should succeed'); assert(audit?.data?.detail?.marker === probeId, `systemStorage.audit should return parsed detail: ${summarize(audit)}`); }); await run('upload issues user-partition qiniu key', async () => { const upload = await call(fn.upload, { action: 'createUploadToken', filename: 'parse-storage-postdeploy-probe.txt', mimeType: 'text/plain', kind: 'probe', bizId: probeId, }); assertOk(upload, 'upload.createUploadToken should succeed'); const qiniuKey = String(upload?.data?.key || ''); assert(qiniuKey.startsWith('users/'), `upload key must start with users/: ${summarize(upload)}`); const registered = await call(fn.fileAsset, { action: 'register', qiniuKey, url: upload?.data?.url || '', bucket: upload?.data?.bucket || 'nova-repos', mimeType: 'text/plain', kind: 'export', sizeBytes: 0, sourceModule: 'parse-storage-postdeploy', bizType: probeType, bizId: probeId, metadata: { marker: probeId }, }); assertOk(registered, 'fileAsset.register should succeed'); probeAssetId = String(registered?.data?.assetId || registered?.data?.id || ''); assert(probeAssetId, `fileAsset.register must return assetId: ${summarize(registered)}`); assert(registered?.data?.qiniuKey === qiniuKey, `fileAsset.register must preserve qiniuKey: ${summarize(registered)}`); }); await run('fileAsset reads VideoWorkflowFileAsset', async () => { const get = await call(fn.fileAsset, { action: 'get', assetId: probeAssetId }); assertOk(get, 'fileAsset.get should succeed'); assert(get?.data?.metadata?.marker === probeId, `fileAsset.get should return probe metadata: ${summarize(get)}`); }); } finally { if (probeAssetId) { await run('cleanup VideoWorkflowFileAsset', async () => { const deleted = await call(fn.fileAsset, { action: 'delete', assetId: probeAssetId }); assertOk(deleted, 'fileAsset.delete cleanup should succeed'); }); } await run('cleanup VideoWorkflowEntity', async () => { const deleted = await call(fn.systemStorage, { action: 'delete', entityType: probeType, entityId: probeId }); assertOk(deleted, 'systemStorage.delete cleanup should succeed'); }); } if (errors.length) exitWithErrors(); console.log('Parse storage postdeploy validation passed'); async function run(name, task) { try { await task(); console.log(`OK ${name}`); } catch (error) { errors.push(`${name}: ${error?.message || error}`); console.error(`FAIL ${name}: ${error?.message || error}`); } } async function call(id, body) { let lastResult = null; let lastError = null; for (let attempt = 1; attempt <= 4; attempt += 1) { try { const response = await fetch(FN_URL, { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-Parse-Application-Id': APP_ID, }, body: JSON.stringify({ id, _ApplicationId: APP_ID, sessionToken: SESSION_TOKEN, ...body }), }); const text = await response.text(); let result; try { result = JSON.parse(text); } catch { result = { code: response.status, success: false, error: text }; } lastResult = result; if (attempt < 4 && isRetryableResult(result)) { await sleep(600 * attempt); continue; } return result; } catch (error) { lastError = error; if (attempt < 4 && isRetryableMessage(error?.message || '')) { await sleep(600 * attempt); continue; } throw error; } } if (lastResult) return lastResult; throw lastError || new Error('fetch failed'); } function assertOk(result, message) { assert(Number(result?.code || 0) === 200 && result?.success !== false, `${message}: ${summarize(result)}`); } function assert(condition, message) { if (!condition) throw new Error(message); } function fail(message) { errors.push(message); } function exitWithErrors() { for (const error of errors) console.error(`FAIL ${error}`); process.exit(1); } function summarize(result) { return JSON.stringify(result, (key, value) => key === 'token' ? '' : value).slice(0, 700); } function isRetryableResult(result) { const message = `${result?.error || ''} ${result?.message || ''}`; return Number(result?.code || 0) >= 500 || isRetryableMessage(message); } function isRetryableMessage(message) { return /fetch failed|Failed to fetch|NetworkError|Load failed|ECONNRESET|ETIMEDOUT|EAI_AGAIN/i.test(message || ''); } function sleep(ms) { return new Promise((resolve) => setTimeout(resolve, ms)); } function readCloudFunctionIds(filePath) { const text = fs.readFileSync(filePath, 'utf8'); const ids = {}; const pattern = /^\s*([a-zA-Z][a-zA-Z0-9_]*)\s*:\s*'([^']*)'/gm; let match; while ((match = pattern.exec(text))) { ids[match[1]] = match[2]; } return ids; }