import assert from 'node:assert/strict'; import test from 'node:test'; import { ModelApiError, ModelContentProjectSession, } from '../../unreal_tran/src/model-content-api.js'; const clone = value => structuredClone(value); const jpeg = () => new Blob(['test-jpeg'], { type: 'image/jpeg' }); const deferred = () => { let resolve; let reject; const promise = new Promise((yes, no) => { resolve = yes; reject = no; }); return { promise, resolve, reject }; }; const tick = () => new Promise(resolve => setImmediate(resolve)); const oldCoverUri = `content://sha256/${'b'.repeat(64)}`; const initialDocument = () => ({ schema: 'digital-twin-editor-project', version: 4, name: 'Original model', modelId: 'test-model', modelResource: { kind: 'imported', assetCode: 'MODEL_MAIN', code: 'MODEL_MAIN', storageUri: `content://sha256/${'a'.repeat(64)}`, sourceName: 'model.glb', }, model: { objects: {} }, camera: { position: [1, 2, 3], target: [0, 0, 0] }, }); async function fixture() { const state = { id: '876', type: 'MODEL', code: 'MODEL_TEST', name: 'Original model', description: 'Cover transaction test', categoryCode: 'EQUIPMENT', status: 'DRAFT', version: 7, coverUri: oldCoverUri, content: initialDocument(), assets: [ { id: '101', code: 'MODEL_MAIN', name: 'model.glb', type: 'MODEL_FILE', storageUri: `content://sha256/${'a'.repeat(64)}`, mimeType: 'model/gltf-binary', sizeBytes: 100, sha256: 'a'.repeat(64), metadata: {}, status: 'READY', sortOrder: 0, }, { id: '102', code: 'MODEL_COVER', name: 'Old cover', type: 'IMAGE', storageUri: oldCoverUri, mimeType: 'image/jpeg', sizeBytes: 40, sha256: 'b'.repeat(64), metadata: {}, status: 'READY', sortOrder: 1, }, ], dependencies: [], }; const controls = { postError: null, putError: null, postBarrier: null, putBarrier: null }; const requests = []; const tickets = new Map(); let uploadCount = 0; const response = () => ({ project: { ...clone(state), content: undefined, assets: undefined, currentVersionId: String(700 + state.version) }, currentVersion: { id: String(700 + state.version), versionNo: state.version, content: clone(state.content), assets: clone(state.assets), dependencies: clone(state.dependencies), }, }); const client = { async request(path, options = {}) { const method = options.method ?? 'GET'; assert.ok(path === '/content/projects/876' || path === '/content/projects/876/assets/upload'); if (method === 'GET') return response(); if (method === 'POST') { assert.equal(path, '/content/projects/876/assets/upload'); const form = options.body; const request = { method, version: Number(form.get('projectVersion')), code: form.get('code'), type: form.get('type'), file: form.get('file'), sortOrder: Number(form.get('sortOrder')), metadata: JSON.parse(form.get('metadata')), }; requests.push(request); if (controls.postBarrier) await controls.postBarrier.promise; if (controls.postError) throw controls.postError; assert.equal(request.version, state.version, 'upload must use the current project version'); uploadCount += 1; const sha256 = String(uploadCount + 2).repeat(64); const asset = { code: request.code, name: form.get('name'), type: request.type, storageUri: `content://sha256/${sha256}`, mimeType: request.file.type, sizeBytes: request.file.size, sha256, metadata: request.metadata, status: 'READY', sortOrder: request.sortOrder, uploadTicket: `cover-ticket-${uploadCount}`, }; tickets.set(asset.uploadTicket, clone(asset)); return clone(asset); } assert.equal(method, 'PUT'); const payload = clone(options.json); requests.push({ method, payload }); if (controls.putBarrier) await controls.putBarrier.promise; if (controls.putError) throw controls.putError; assert.equal(payload.version, state.version, 'save must use the current project version'); assert.equal(new Set(payload.assets.map(asset => asset.code)).size, payload.assets.length); for (const asset of payload.assets) { if (!asset.uploadTicket) continue; const uploaded = tickets.get(asset.uploadTicket); assert.ok(uploaded, 'each upload ticket must exist and be consumed once'); assert.equal(asset.storageUri, uploaded.storageUri); tickets.delete(asset.uploadTicket); } state.version += 1; state.name = payload.name; state.content = clone(payload.content); state.coverUri = payload.coverUri; state.assets = payload.assets.map((asset, index) => { const persisted = { ...asset, id: String(200 + index), versionId: String(700 + state.version) }; delete persisted.uploadTicket; return persisted; }); return response(); }, }; const api = new ModelContentProjectSession({ projectId: state.id, client }); await api.loadProject(); api.markDocumentLoaded(state.content); return { api, state, controls, requests, tickets }; } test('persists edited content and the uploaded cover in one PUT, replacing fixed MODEL_COVER', async () => { const f = await fixture(); const document = { ...initialDocument(), name: 'Edited model' }; await f.api.queueSave(document, { coverImage: jpeg() }); assert.deepEqual(f.requests.map(request => request.method), ['POST', 'PUT']); const post = f.requests[0]; assert.equal(post.code, 'MODEL_COVER'); assert.equal(post.type, 'IMAGE'); assert.equal(post.file.name, 'model-cover.jpg'); assert.equal(post.file.type, 'image/jpeg'); assert.equal(post.sortOrder, 1); assert.equal(post.metadata.usage, 'model-cover'); const payload = f.requests[1].payload; assert.equal(payload.content.name, 'Edited model'); assert.equal(payload.assets.length, 2); const cover = payload.assets.find(asset => asset.code === 'MODEL_COVER'); assert.equal(payload.coverUri, cover.storageUri); assert.equal(cover.uploadTicket, 'cover-ticket-1'); assert.equal(f.api.project.coverUri, payload.coverUri); assert.equal(f.api.project.version, 8); assert.equal(f.api.pendingUploads.size, 0); assert.equal(f.tickets.size, 0); await f.api.queueSave(document, { coverImage: jpeg() }); assert.equal(f.state.assets.filter(asset => asset.code === 'MODEL_COVER').length, 1); assert.notEqual(f.state.coverUri, payload.coverUri); assert.equal(f.state.assets.find(asset => asset.code === 'MODEL_MAIN').storageUri, initialDocument().modelResource.storageUri); }); test('cover POST failure still saves edited content with the previous cover', async () => { const f = await fixture(); const error = new ModelApiError('upload unavailable', 'SERVER_ERROR', '', 503); f.controls.postError = error; const warnings = []; await f.api.queueSave({ ...initialDocument(), name: 'Content survives' }, { coverImage: jpeg(), onCoverError: value => warnings.push(value), }); assert.deepEqual(warnings, [error]); assert.deepEqual(f.requests.map(request => request.method), ['POST', 'PUT']); assert.equal(f.state.content.name, 'Content survives'); assert.equal(f.state.coverUri, oldCoverUri); assert.equal(f.requests[1].payload.assets.some(asset => asset.uploadTicket), false); }); test('cover POST 409 stops the transaction before any PUT', async () => { const f = await fixture(); const error = new ModelApiError('version conflict', 'CONFLICT', '', 409); f.controls.postError = error; const previous = clone(f.api.project); let warnings = 0; await assert.rejects(f.api.queueSave({ ...initialDocument(), name: 'Stale content' }, { coverImage: jpeg(), onCoverError: () => { warnings += 1; }, }), actual => actual === error); assert.deepEqual(f.requests.map(request => request.method), ['POST']); assert.deepEqual(f.api.project, previous); assert.equal(warnings, 0); }); for (const status of [409, 503]) { test(`final PUT ${status} preserves the old cover and cannot leak its ticket into a later autosave`, async () => { const f = await fixture(); const error = new ModelApiError('save failed', 'SAVE_FAILED', '', status); f.controls.putError = error; const previous = clone(f.api.project); const fingerprint = f.api.lastSavedFingerprint; const document = { ...initialDocument(), name: 'Still unsaved' }; await assert.rejects(f.api.queueSave(document, { coverImage: jpeg() }), actual => actual === error); assert.deepEqual(f.api.project, previous); assert.equal(f.api.lastSavedFingerprint, fingerprint); assert.equal(f.api.pendingUploads.size, 0); f.controls.putError = null; await f.api.queueSave(document); const later = f.requests.at(-1).payload; assert.equal(later.coverUri, oldCoverUri); assert.equal(later.assets.some(asset => asset.uploadTicket), false); assert.equal(f.state.coverUri, oldCoverUri); assert.equal(f.requests.filter(request => request.method === 'POST').length, 1); }); } test('automatic saves never upload a cover, and unchanged content skips PUT before and after cover replacement', async () => { const f = await fixture(); const document = initialDocument(); await f.api.queueSave(document); assert.equal(f.requests.length, 0); document.name = 'Automatic content edit'; await f.api.queueSave(document); await f.api.queueSave(document); assert.deepEqual(f.requests.map(request => request.method), ['PUT']); await f.api.queueSave(document, { coverImage: jpeg() }); const count = f.requests.length; await f.api.queueSave(document); assert.equal(f.requests.length, count, 'consumed cover ticket must not cause another save'); }); test('asynchronous JPEG encoding owns the queue until upload and final PUT finish', async () => { const f = await fixture(); const image = deferred(); const postBarrier = deferred(); f.controls.postBarrier = postBarrier; const first = f.api.queueSave({ ...initialDocument(), name: 'Manual snapshot' }, { coverImage: image.promise }); const laterDocument = { ...initialDocument(), name: 'Later automatic snapshot' }; const later = f.api.queueSave(laterDocument); laterDocument.name = 'Mutation after enqueue must not leak'; await tick(); assert.equal(f.requests.length, 0, 'later autosave cannot pass pending JPEG encoding'); image.resolve(jpeg()); await tick(); assert.deepEqual(f.requests.map(request => request.method), ['POST']); postBarrier.resolve(); await Promise.all([first, later]); assert.deepEqual(f.requests.map(request => request.method), ['POST', 'PUT', 'PUT']); assert.equal(f.requests[0].version, 7); assert.equal(f.requests[1].payload.version, 7); assert.equal(f.requests[2].payload.version, 8); assert.equal(f.requests[1].payload.content.name, 'Manual snapshot'); assert.equal(f.state.content.name, 'Later automatic snapshot'); assert.equal(f.requests[2].payload.coverUri, f.requests[1].payload.coverUri); }); test('JPEG rejection while another save owns the queue is observed immediately and falls back safely', async () => { const f = await fixture(); const barrier = deferred(); f.controls.putBarrier = barrier; const first = f.api.queueSave({ ...initialDocument(), name: 'First save' }); await tick(); const captureError = new Error('JPEG encoding failed'); const image = deferred(); const warnings = []; const later = f.api.queueSave({ ...initialDocument(), name: 'Fallback content' }, { coverImage: image.promise, onCoverError: error => warnings.push(error), }); image.reject(captureError); await tick(); barrier.resolve(); await Promise.all([first, later]); assert.deepEqual(warnings, [captureError]); assert.deepEqual(f.requests.map(request => request.method), ['PUT', 'PUT']); assert.equal(f.state.content.name, 'Fallback content'); assert.equal(f.state.coverUri, oldCoverUri); });