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