// Copyright (c) 未来飞马 // // This Source Code Form is subject to the terms of the Mozilla Public // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at https://mozilla.org/MPL/2.0/. /** * 素材上传(storage-bridge + case-intake 优先级)单测。 * * 覆盖任务书要求的 4 类: * ① 有 item.url(文档自带远程 URL)→ 不触发上传; * ② --no-upload → 不上传、url 为空且产生 warning; * ③ 兄弟技能缺失 → StorageUnavailableError,且 ingest 不崩(降级留空 + warning); * ④ 上传失败 → 单张降级、其余照常,最后汇总「N 张上传失败」; * ⑤ key 规范:case-library//-.。 * * 全部离线:不真连对象存储。网络相关的用例起本地 HTTP 服务替代云函道。 * 注意:storage-bridge 的进程内 upload 缓存在用例间共享,用 resetUploadCache() 隔离。 */ import test from 'node:test'; import assert from 'node:assert/strict'; import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; import { pathToFileURL } from 'node:url'; import { StorageUnavailableError, buildObjectKey, dateStamp, normalizeExt, slugify, uploadMaterial, resetUploadCache, } from '../storage-bridge.mjs'; import { runIngest, resolveAssetUrl, makeTinyPng } from '../case-intake.mjs'; const SAMPLE_PNG = makeTinyPng(8, 8); /** 建一个临时素材文件(内容用合成 PNG,不含任何真实素材)。 */ function makeTempImage(name = 'sample.png', { mtime } = {}) { const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'case-upload-src-')); const file = path.join(dir, name); fs.writeFileSync(file, SAMPLE_PNG); if (mtime) fs.utimesSync(file, mtime, mtime); return file; } /** 每个用例一个独立产物目录,互不污染。 */ function tempOutDir(label) { return path.join(fs.mkdtempSync(path.join(os.tmpdir(), `case-upload-${label}-`)), 'out'); } /** 跑一次 ingest,默认跳过图片理解(离线),只关心 materialAssets[].url。 */ async function ingestOnce(file, options = {}) { return runIngest({ inputs: [file], outDir: options.outDir || tempOutDir('ingest'), authorizationStatus: 'authorized', skipVision: true, noLearn: true, ...options, }); } // --------------------------------------------------------------------------- // ① key 规范 // --------------------------------------------------------------------------- test('buildObjectKey:case-library//-.(相对 key)', () => { const key = buildObjectKey({ caseSlug: 'imperial-aero50006', order: 2, ext: 'png', date: new Date(2026, 9, 10), }); assert.equal(key, 'case-library/20261010/imperial-aero50006-2.png'); // 绝不能带 user/ 前缀(云函数会自动补,带了会变双层) assert.ok(!key.startsWith('user/'), `key 不应带 user/ 前缀:${key}`); }); test('buildObjectKey:slug 化中文/空格、order 兜底 1、ext 归一', () => { assert.equal( buildObjectKey({ caseSlug: '帝国理工 AERO/50006', order: 0, ext: '.PNG', date: new Date(2026, 0, 5) }), 'case-library/20260105/AERO-50006-1.png', ); assert.equal( buildObjectKey({ caseSlug: '', order: 'x', ext: '', date: new Date(2026, 11, 31) }), 'case-library/20261231/case-1.bin', ); }); test('slugify / normalizeExt / dateStamp 边界', () => { assert.equal(slugify('帝国理工 AERO50006'), 'AERO50006'); assert.equal(slugify(''), ''); assert.equal(normalizeExt('.JPEG'), 'jpeg'); assert.equal(normalizeExt(''), 'bin'); assert.equal(dateStamp(new Date(2026, 10, 3)), '20261103'); }); // --------------------------------------------------------------------------- // ② 有 item.url → 不触发上传 // --------------------------------------------------------------------------- test('resolveAssetUrl:item.url 已存在 → 原样返回,不调用上传', async () => { process.env.CASE_STORAGE_SCRIPT = '/definitely/not/here/uploader.mjs'; try { const resolved = await resolveAssetUrl( { url: 'https://cdn.example.com/a.png', localPath: '/tmp/nope-a.png' }, { upload: true, assetBaseUrl: 'https://fallback.example.com' }, ); assert.equal(resolved.url, 'https://cdn.example.com/a.png'); assert.equal(resolved.reason, 'remote-url'); assert.equal(resolved.upload, null); assert.equal(resolved.error, null); } finally { delete process.env.CASE_STORAGE_SCRIPT; } }); test('ingest:兄弟技能缺失时,本地素材降级留空 + 告警(不崩、不伪造 URL)', async () => { const outDir = tempOutDir('remote'); const file = makeTempImage('remote.png'); // 用一个「不存在」的兄弟技能路径:若 ingest 试图上传,就会报 StorageUnavailableError。 process.env.CASE_STORAGE_SCRIPT = '/definitely/not/here/uploader.mjs'; try { const report = await ingestOnce(file, { outDir, assetBaseUrl: '' }); const asset = report.casePackage.materialAssets[0]; // item.url 不存在(本地文件),所以走的是上传失败降级——确认上传确实被尝试过且没有伪造 URL assert.equal(asset.url, ''); assert.ok(report.warnings.some((w) => /上传失败/.test(w)), '应报上传失败'); } finally { delete process.env.CASE_STORAGE_SCRIPT; } }); // --------------------------------------------------------------------------- // ③ --no-upload → 不上传、告警 // --------------------------------------------------------------------------- test('resolveAssetUrl:--no-upload 且无 --asset-base-url → 空 url', async () => { const resolved = await resolveAssetUrl( { localPath: '/tmp/whatever.png' }, { upload: false, assetBaseUrl: '' }, ); assert.equal(resolved.url, ''); assert.equal(resolved.reason, 'no-url'); assert.equal(resolved.upload, null); }); test('ingest:--no-upload → url 为空 + 「只有本地路径、没有 URL」warning(不触发上传)', async () => { const file = makeTempImage('no-upload.png'); const report = await ingestOnce(file, { upload: false, outDir: tempOutDir('noupload') }); const asset = report.casePackage.materialAssets[0]; assert.equal(asset.url, ''); assert.equal(asset.uploadKey, undefined); assert.equal(report.casePackage.hasImage, false); assert.deepEqual(report.casePackage.imageUrls, []); assert.ok( report.warnings.some((w) => /只有本地路径、没有 URL/.test(w)), `应有缺 URL 告警,实际:${JSON.stringify(report.warnings)}`, ); assert.ok(!report.warnings.some((w) => /上传失败/.test(w)), '--no-upload 不应出现上传失败告警'); }); test('ingest:--no-upload + --asset-base-url → 保留旧派生行为并标记 isFallbackUrl', async () => { const file = makeTempImage('derived.png'); const report = await ingestOnce(file, { upload: false, assetBaseUrl: 'https://cdn.example.com/cases', outDir: tempOutDir('derived'), }); const asset = report.casePackage.materialAssets[0]; assert.equal(asset.url, 'https://cdn.example.com/cases/derived.png'); assert.equal(asset.isFallbackUrl, true); assert.equal(report.casePackage.hasImage, true); }); // --------------------------------------------------------------------------- // ④ 兄弟技能缺失 → StorageUnavailableError,且 ingest 不崩 // --------------------------------------------------------------------------- test('uploadMaterial:兄弟技能缺失 → StorageUnavailableError(明确错误,不静默)', async () => { resetUploadCache(); const file = makeTempImage('real-file.png'); process.env.CASE_STORAGE_SCRIPT = '/definitely/not/here/uploader.mjs'; try { await assert.rejects( () => uploadMaterial(file, { caseSlug: 'x', order: 1 }), (error) => { assert.ok(error instanceof StorageUnavailableError, `应为 StorageUnavailableError,实际 ${error.name}`); assert.equal(error.code, 'STORAGE_UNAVAILABLE'); assert.match(error.message, /fmode-storage/); return true; }, ); } finally { delete process.env.CASE_STORAGE_SCRIPT; } }); test('ingest:兄弟技能缺失 → 不崩、url 留空、汇总「N 张上传失败」', async () => { resetUploadCache(); const file = makeTempImage('missing-sibling.png'); process.env.CASE_STORAGE_SCRIPT = '/definitely/not/here/uploader.mjs'; try { const report = await ingestOnce(file, { outDir: tempOutDir('missing') }); assert.equal(report.casePackage.materialAssets.length, 1); assert.equal(report.casePackage.materialAssets[0].url, ''); assert.equal(report.casePackage.materialAssets[0].order, 1); assert.ok( report.warnings.some((w) => /上传失败(missing-sibling\.png)/.test(w) && /fmode-storage/.test(w)), `应有单张上传失败告警,实际:${JSON.stringify(report.warnings, null, 2)}`, ); assert.ok( report.warnings.some((w) => /1 张上传失败/.test(w) && /fmode-storage 不可用/.test(w)), `应有汇总告警,实际:${JSON.stringify(report.warnings, null, 2)}`, ); // 案例包仍然产出,红线字段不变 assert.equal(report.casePackage.expected.reviewStatus, 'pending'); assert.equal(report.casePackage.expected.readyForUse, false); } finally { delete process.env.CASE_STORAGE_SCRIPT; } }); // --------------------------------------------------------------------------- // ⑤ 真上传链路(桩 uploader,零网络:不起 socket、不连对象存储) // --------------------------------------------------------------------------- /** * 生成一个「契约与 fmode-storage 一致」的桩 uploader,可精确模拟上传成功 / 失败。 * 只验证 storage-bridge 与 case-intake 的编排,不去重测 fmode-storage 自己的 HTTP 客户端 * (那是兄弟技能的职责)。 */ function makeStubUploader({ failKeys = [] } = {}) { const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'fake-storage-')); const stub = path.join(dir, 'uploader.mjs'); fs.writeFileSync(stub, ` import fs from 'node:fs'; import path from 'node:path'; const FAIL_KEYS = ${JSON.stringify(failKeys)}; export const putCalls = []; export function guessMime(file) { return path.extname(file).toLowerCase() === '.png' ? 'image/png' : 'application/octet-stream'; } export function normalizeKey(key) { const m = String(key || '').match(/^user\\/[^/]+\\/(.+)$/); return m ? m[1] : key; } export function publicUrl(cfg, key) { return 'https://s3.fmode.cn/' + key; } export async function putViaCloudFunction(file, objectKey, namespace, name, mimeType) { const buf = fs.readFileSync(file); const rel = objectKey || 'anon'; putCalls.push(rel); if (FAIL_KEYS.some((f) => rel.includes(f))) return null; // 模拟该通道不可用 const key = 'user/u-test/' + rel; return { ok: true, key, url: 'https://s3.fmode.cn/' + key, bytes: buf.length, via: 'level0:stub' }; } `, 'utf8'); return stub; } test('uploadMaterial:走第 0 级通道 → 返回公网 URL / key / bytes', async () => { resetUploadCache(); const stub = makeStubUploader(); process.env.CASE_STORAGE_SCRIPT = stub; try { const file = makeTempImage('upload-me.png'); const result = await uploadMaterial(file, { tenantId: 'lumi', caseSlug: 'upload-me', order: 3 }); assert.match(result.url, /^https:\/\/s3\.fmode\.cn\/user\/u-test\/case-library\/\d{8}\/upload-me-3\.png$/); assert.equal(result.key, `user/u-test/case-library/${dateStamp()}/upload-me-3.png`); assert.equal(result.bytes, SAMPLE_PNG.length); } finally { delete process.env.CASE_STORAGE_SCRIPT; } }); test('uploadMaterial:同一 (localPath, key) 只传一次(进程内幂等)', async () => { resetUploadCache(); const stub = makeStubUploader(); process.env.CASE_STORAGE_SCRIPT = stub; try { const file = makeTempImage('same.png'); const first = await uploadMaterial(file, { caseSlug: 'same', order: 1 }); const second = await uploadMaterial(file, { caseSlug: 'same', order: 1 }); assert.deepEqual(second, first); const calls = (await import(pathToFileURL(stub).href)).putCalls; assert.equal(calls.length, 1, '重复上传应命中缓存,只调一次'); } finally { delete process.env.CASE_STORAGE_SCRIPT; } }); test('ingest:上传成功 → materialAssets[].url 填真 URL,imageUrls / hasImage 随之派生', async () => { resetUploadCache(); const stub = makeStubUploader(); process.env.CASE_STORAGE_SCRIPT = stub; try { const file = makeTempImage('real-upload.png'); const report = await ingestOnce(file, { outDir: tempOutDir('real'), sourceRef: 'wx-e2e', title: '帝国理工 AERO50006 案例', }); const asset = report.casePackage.materialAssets[0]; assert.match(asset.url, /^https:\/\/s3\.fmode\.cn\/user\/u-test\/case-library\/\d{8}\//); assert.equal(asset.uploadVia, 'cloud-function'); assert.ok(asset.uploadBytes > 0); assert.equal(asset.isFallbackUrl, undefined); assert.deepEqual(report.casePackage.imageUrls, [asset.url]); assert.equal(report.casePackage.hasImage, true); assert.ok(!report.warnings.some((w) => /上传失败/.test(w))); } finally { delete process.env.CASE_STORAGE_SCRIPT; } }); test('ingest:单张上传失败 → 该张留空并告警,其余素材照常上传(不拖垮整批)', async () => { resetUploadCache(); // 同一案例内所有素材共享 caseSlug,靠 order 区分:只让第 2 张(-2.png)失败 const stub = makeStubUploader({ failKeys: ['-2.png'] }); process.env.CASE_STORAGE_SCRIPT = stub; try { // 明确时间差,确保 a、b 不被分组逻辑并成一组(否则 order/key 会变) const fileA = makeTempImage('a.png', { mtime: new Date('2026-10-10T00:00:00Z') }); const fileB = makeTempImage('b.png', { mtime: new Date('2026-10-10T00:10:00Z') }); const report = await runIngest({ inputs: [fileA, fileB], outDir: tempOutDir('failbatch'), authorizationStatus: 'authorized', skipVision: true, noLearn: true, }); const [assetA, assetB] = report.casePackage.materialAssets; assert.equal(report.casePackage.materialAssets.length, 2, '两张素材都要产出'); assert.match(assetA.url, /^https:\/\/s3\.fmode\.cn\/user\/u-test\/case-library\//, 'a 仍应上传成功'); assert.equal(assetB.url, '', 'b 上传失败 → url 留空'); assert.ok(report.warnings.some((w) => /上传失败(b\.png)/.test(w)), '应定位到具体失败文件'); assert.ok(report.warnings.some((w) => /1 张上传失败/.test(w)), '应有汇总告警'); // 红线字段不变:仍只进待审清单 assert.equal(report.casePackage.expected.reviewStatus, 'pending'); assert.equal(report.casePackage.expected.readyForUse, false); } finally { delete process.env.CASE_STORAGE_SCRIPT; } });