import express from 'express'; import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import type { AddressInfo } from 'node:net'; import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; const mocks = vi.hoisted(() => ({ authenticated: true, resultTaskId: 7, })); vi.mock('./_core/auth.js', () => ({ authenticateRequest: vi.fn(async () => mocks.authenticated ? { id: 1, role: 'user', mustChangePassword: 0 } : null), })); vi.mock('./db', () => ({ getTaskAnalysisResultById: vi.fn(async (id: number) => id === 11 ? { id, taskId: mocks.resultTaskId } : undefined), listTaskPointcloudFiles: vi.fn(async () => []), })); const dataDir = mkdtempSync(join(tmpdir(), 'analysis-ply-route-')); process.env.DATA_DIR = dataDir; function writePly(relativePath: string, withColor = false): void { const resultDir = join(dataDir, 'tasks', '7', 'analysis', '11'); const filePath = join(resultDir, relativePath); mkdirSync(join(filePath, '..'), { recursive: true }); const properties = withColor ? ['x', 'y', 'z', 'red', 'green', 'blue'] : ['x', 'y', 'z']; const header = Buffer.from([ 'ply', 'format binary_little_endian 1.0', 'element vertex 2', ...properties.map((name) => `property float ${name}`), 'end_header', '', ].join('\n'), 'ascii'); const values = withColor ? [[1, 2, 3, 255, 0, 128], [4, 5, 6, 0, 255, 64]] : [[7, 8, 9], [10, 11, 12]]; const body = Buffer.alloc(values.length * properties.length * 4); values.flat().forEach((value, index) => body.writeFloatLE(value, index * 4)); writeFileSync(filePath, Buffer.concat([header, body])); } describe('POST /api/files/sample/analysis', () => { let origin: string; let closeServer: () => Promise; beforeAll(async () => { writePly('plain.ply'); writePly('nested/color.ply', true); const { fileRouter } = await import('./fileRoutes'); const app = express(); app.use(express.json()); app.use('/api/files', fileRouter); const server = app.listen(0); await new Promise((resolve) => server.once('listening', resolve)); origin = `http://127.0.0.1:${(server.address() as AddressInfo).port}`; closeServer = () => new Promise((resolve, reject) => server.close((error) => error ? reject(error) : resolve())); }); afterAll(async () => { await closeServer(); rmSync(dataDir, { recursive: true, force: true }); }); beforeEach(() => { mocks.authenticated = true; mocks.resultTaskId = 7; }); async function post(body: unknown) { return fetch(`${origin}/api/files/sample/analysis`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body), }); } it('采样单个无色 PLY 并返回 16 字节步长', async () => { const response = await post({ taskId: 7, resultId: 11, files: ['plain.ply'], totalPoints: 2 }); expect(response.status).toBe(200); expect(response.headers.get('X-Point-Count')).toBe('2'); expect(response.headers.get('X-Has-Color')).toBe('0'); expect((await response.arrayBuffer()).byteLength).toBe(2 * 16); }); it('采样多个 PLY,彩色响应使用 28 字节步长且不超预算', async () => { const response = await post({ taskId: 7, resultId: 11, files: ['plain.ply', 'nested/color.ply'], totalPoints: 3, }); expect(response.status).toBe(200); expect(response.headers.get('X-Point-Count')).toBe('3'); expect(response.headers.get('X-Has-Color')).toBe('1'); expect((await response.arrayBuffer()).byteLength).toBe(3 * 28); }); it('拒绝未登录访问', async () => { mocks.authenticated = false; expect((await post({ taskId: 7, resultId: 11, files: ['plain.ply'] })).status).toBe(401); }); it('拒绝错误的任务与结果组合', async () => { mocks.resultTaskId = 8; expect((await post({ taskId: 7, resultId: 11, files: ['plain.ply'] })).status).toBe(404); }); it.each([ ['路径穿越', '../plain.ply', 403], ['绝对路径', '/tmp/plain.ply', 400], ['非 PLY 文件', 'result.json', 400], ['缺失文件', 'missing.ply', 404], ])('拒绝%s', async (_label, file, status) => { expect((await post({ taskId: 7, resultId: 11, files: [file] })).status).toBe(status); }); });