printhive/test/archives.test.js
dlawler489 d4b7e29348 Browse archives, preview companion files, and view FBX/STEP/3DM/OBJ+MTL
Archives
- ZIP, 7Z, RAR and TAR/.tar.gz uploads are indexed in the background into
  archive_entries (7-Zip compiled to WASM in a worker thread; node-tar for
  tar). macOS/Windows clutter and paths that could escape the extraction
  folder are skipped.
- Models inside archives are extracted once per archive to hash and
  geometry-fingerprint them, so duplicates are found inside packs too.
  Pictures, PDFs and readmes are listed but never matched.
- New routes list an archive's entries and serve/convert a single entry,
  extracted on first use and cached under extracted/.

Viewer
- Model detail lists what's inside each archive with view/download links;
  opening an entry adds a viewer tab.
- New 3D formats: FBX (with sibling textures), OBJ with its MTL and
  textures, STEP/STP via occt-import-js, and Rhino 3DM via rhino3dm — the
  WASM readers are served from node_modules, loaded on first use.
- Pictures, PDFs and text files (jpg/png/gif/webp/bmp/svg, pdf, txt/md)
  can be uploaded and preview in the viewer; a model without an embedded
  3MF preview uses its first picture as the card thumbnail.
- Inline file responses send nosniff, and SVGs are sandboxed.
- GLB conversions write to a temp file and rename, so a half-written
  preview is never served; a later tab can't be overdrawn by a slow load.
- Better 3MF card image: the project thumbnail or plate 1 render, not a
  slicer pick map.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-15 07:36:47 +10:00

47 lines
2.2 KiB
JavaScript

const test = require('node:test');
const assert = require('node:assert/strict');
const crypto = require('crypto');
const fs = require('fs');
const os = require('os');
const path = require('path');
process.env.EXTRACT_DIR = fs.mkdtempSync(path.join(os.tmpdir(), 'printhive-extract-'));
const archives = require('../lib/archives');
const fixture = (name) => path.join(__dirname, 'fixtures', name);
const sha = (p) => crypto.createHash('sha256').update(fs.readFileSync(p)).digest('hex');
const clean = (p) => p.replace(/^\.\//, '');
// Each pack holds models/cube.stl, models/inner.3mf and readme.txt, plus macOS
// junk (._ files, __MACOSX, .DS_Store); the zip also has a ../ path.
const EXPECTED = ['models/cube.stl', 'models/inner.3mf', 'readme.txt'];
test.after(() => fs.rmSync(process.env.EXTRACT_DIR, { recursive: true, force: true }));
for (const [name, format] of [['pack.zip', 'zip'], ['pack.7z', '7z'], ['pack.tar.gz', 'tgz']]) {
const file = { id: `test-${format}`, file_path: fixture(name), format };
test(`${name}: lists real files, skipping folders, junk and unsafe paths`, async () => {
const entries = await archives.listEntries(file);
assert.deepEqual(entries.map((e) => clean(e.path)).sort(), EXPECTED);
const cube = entries.find((e) => clean(e.path) === 'models/cube.stl');
assert.equal(cube.size, fs.statSync(fixture('cube.stl')).size);
});
test(`${name}: extracts one entry byte-for-byte, and caches it`, async () => {
const entries = await archives.listEntries(file);
const cube = { ...entries.find((e) => clean(e.path) === 'models/cube.stl'), id: 1 };
const abs = await archives.extractEntry(file, cube);
assert.equal(sha(abs), sha(fixture('cube.stl')));
assert.equal(await archives.extractEntry(file, cube), abs);
});
}
test('a missing entry fails clearly instead of returning a path', async () => {
const file = { id: 'test-missing', file_path: fixture('pack.zip'), format: 'zip' };
await assert.rejects(archives.extractEntry(file, { id: 2, path: 'models/nope.stl' }), /Could not extract/);
});
test('an unreadable archive rejects', async () => {
await assert.rejects(archives.listEntries({ file_path: fixture('cube.stl'), format: 'zip' }));
});