| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339 |
- // 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;
- }
- });
|