|
|
@@ -0,0 +1,339 @@
|
|
|
+// 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/<YYYYMMDD>/<caseSlug>-<order>.<ext>。
|
|
|
+ *
|
|
|
+ * 全部离线:不真连对象存储。网络相关的用例起本地 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/<YYYYMMDD>/<caseSlug>-<order>.<ext>(相对 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;
|
|
|
+ }
|
|
|
+});
|