Ви не можете вибрати більше 25 тем Теми мають розпочинатися з літери або цифри, можуть містити дефіси (-) і не повинні перевищувати 35 символів.
 
 
 
 

273 рядки
12 KiB

  1. import assert from 'node:assert/strict';
  2. import test from 'node:test';
  3. import {
  4. ModelApiError,
  5. ModelContentProjectSession,
  6. } from '../../unreal_tran/src/model-content-api.js';
  7. const clone = value => structuredClone(value);
  8. const jpeg = () => new Blob(['test-jpeg'], { type: 'image/jpeg' });
  9. const deferred = () => {
  10. let resolve;
  11. let reject;
  12. const promise = new Promise((yes, no) => { resolve = yes; reject = no; });
  13. return { promise, resolve, reject };
  14. };
  15. const tick = () => new Promise(resolve => setImmediate(resolve));
  16. const oldCoverUri = `content://sha256/${'b'.repeat(64)}`;
  17. const initialDocument = () => ({
  18. schema: 'digital-twin-editor-project',
  19. version: 4,
  20. name: 'Original model',
  21. modelId: 'test-model',
  22. modelResource: {
  23. kind: 'imported',
  24. assetCode: 'MODEL_MAIN',
  25. code: 'MODEL_MAIN',
  26. storageUri: `content://sha256/${'a'.repeat(64)}`,
  27. sourceName: 'model.glb',
  28. },
  29. model: { objects: {} },
  30. camera: { position: [1, 2, 3], target: [0, 0, 0] },
  31. });
  32. async function fixture() {
  33. const state = {
  34. id: '876',
  35. type: 'MODEL',
  36. code: 'MODEL_TEST',
  37. name: 'Original model',
  38. description: 'Cover transaction test',
  39. categoryCode: 'EQUIPMENT',
  40. status: 'DRAFT',
  41. version: 7,
  42. coverUri: oldCoverUri,
  43. content: initialDocument(),
  44. assets: [
  45. {
  46. id: '101', code: 'MODEL_MAIN', name: 'model.glb', type: 'MODEL_FILE',
  47. storageUri: `content://sha256/${'a'.repeat(64)}`, mimeType: 'model/gltf-binary',
  48. sizeBytes: 100, sha256: 'a'.repeat(64), metadata: {}, status: 'READY', sortOrder: 0,
  49. },
  50. {
  51. id: '102', code: 'MODEL_COVER', name: 'Old cover', type: 'IMAGE',
  52. storageUri: oldCoverUri, mimeType: 'image/jpeg',
  53. sizeBytes: 40, sha256: 'b'.repeat(64), metadata: {}, status: 'READY', sortOrder: 1,
  54. },
  55. ],
  56. dependencies: [],
  57. };
  58. const controls = { postError: null, putError: null, postBarrier: null, putBarrier: null };
  59. const requests = [];
  60. const tickets = new Map();
  61. let uploadCount = 0;
  62. const response = () => ({
  63. project: { ...clone(state), content: undefined, assets: undefined, currentVersionId: String(700 + state.version) },
  64. currentVersion: {
  65. id: String(700 + state.version), versionNo: state.version,
  66. content: clone(state.content), assets: clone(state.assets), dependencies: clone(state.dependencies),
  67. },
  68. });
  69. const client = {
  70. async request(path, options = {}) {
  71. const method = options.method ?? 'GET';
  72. assert.ok(path === '/content/projects/876' || path === '/content/projects/876/assets/upload');
  73. if (method === 'GET') return response();
  74. if (method === 'POST') {
  75. assert.equal(path, '/content/projects/876/assets/upload');
  76. const form = options.body;
  77. const request = {
  78. method, version: Number(form.get('projectVersion')),
  79. code: form.get('code'), type: form.get('type'),
  80. file: form.get('file'), sortOrder: Number(form.get('sortOrder')),
  81. metadata: JSON.parse(form.get('metadata')),
  82. };
  83. requests.push(request);
  84. if (controls.postBarrier) await controls.postBarrier.promise;
  85. if (controls.postError) throw controls.postError;
  86. assert.equal(request.version, state.version, 'upload must use the current project version');
  87. uploadCount += 1;
  88. const sha256 = String(uploadCount + 2).repeat(64);
  89. const asset = {
  90. code: request.code, name: form.get('name'), type: request.type,
  91. storageUri: `content://sha256/${sha256}`, mimeType: request.file.type,
  92. sizeBytes: request.file.size, sha256, metadata: request.metadata,
  93. status: 'READY', sortOrder: request.sortOrder, uploadTicket: `cover-ticket-${uploadCount}`,
  94. };
  95. tickets.set(asset.uploadTicket, clone(asset));
  96. return clone(asset);
  97. }
  98. assert.equal(method, 'PUT');
  99. const payload = clone(options.json);
  100. requests.push({ method, payload });
  101. if (controls.putBarrier) await controls.putBarrier.promise;
  102. if (controls.putError) throw controls.putError;
  103. assert.equal(payload.version, state.version, 'save must use the current project version');
  104. assert.equal(new Set(payload.assets.map(asset => asset.code)).size, payload.assets.length);
  105. for (const asset of payload.assets) {
  106. if (!asset.uploadTicket) continue;
  107. const uploaded = tickets.get(asset.uploadTicket);
  108. assert.ok(uploaded, 'each upload ticket must exist and be consumed once');
  109. assert.equal(asset.storageUri, uploaded.storageUri);
  110. tickets.delete(asset.uploadTicket);
  111. }
  112. state.version += 1;
  113. state.name = payload.name;
  114. state.content = clone(payload.content);
  115. state.coverUri = payload.coverUri;
  116. state.assets = payload.assets.map((asset, index) => {
  117. const persisted = { ...asset, id: String(200 + index), versionId: String(700 + state.version) };
  118. delete persisted.uploadTicket;
  119. return persisted;
  120. });
  121. return response();
  122. },
  123. };
  124. const api = new ModelContentProjectSession({ projectId: state.id, client });
  125. await api.loadProject();
  126. api.markDocumentLoaded(state.content);
  127. return { api, state, controls, requests, tickets };
  128. }
  129. test('persists edited content and the uploaded cover in one PUT, replacing fixed MODEL_COVER', async () => {
  130. const f = await fixture();
  131. const document = { ...initialDocument(), name: 'Edited model' };
  132. await f.api.queueSave(document, { coverImage: jpeg() });
  133. assert.deepEqual(f.requests.map(request => request.method), ['POST', 'PUT']);
  134. const post = f.requests[0];
  135. assert.equal(post.code, 'MODEL_COVER');
  136. assert.equal(post.type, 'IMAGE');
  137. assert.equal(post.file.name, 'model-cover.jpg');
  138. assert.equal(post.file.type, 'image/jpeg');
  139. assert.equal(post.sortOrder, 1);
  140. assert.equal(post.metadata.usage, 'model-cover');
  141. const payload = f.requests[1].payload;
  142. assert.equal(payload.content.name, 'Edited model');
  143. assert.equal(payload.assets.length, 2);
  144. const cover = payload.assets.find(asset => asset.code === 'MODEL_COVER');
  145. assert.equal(payload.coverUri, cover.storageUri);
  146. assert.equal(cover.uploadTicket, 'cover-ticket-1');
  147. assert.equal(f.api.project.coverUri, payload.coverUri);
  148. assert.equal(f.api.project.version, 8);
  149. assert.equal(f.api.pendingUploads.size, 0);
  150. assert.equal(f.tickets.size, 0);
  151. await f.api.queueSave(document, { coverImage: jpeg() });
  152. assert.equal(f.state.assets.filter(asset => asset.code === 'MODEL_COVER').length, 1);
  153. assert.notEqual(f.state.coverUri, payload.coverUri);
  154. assert.equal(f.state.assets.find(asset => asset.code === 'MODEL_MAIN').storageUri, initialDocument().modelResource.storageUri);
  155. });
  156. test('cover POST failure still saves edited content with the previous cover', async () => {
  157. const f = await fixture();
  158. const error = new ModelApiError('upload unavailable', 'SERVER_ERROR', '', 503);
  159. f.controls.postError = error;
  160. const warnings = [];
  161. await f.api.queueSave({ ...initialDocument(), name: 'Content survives' }, {
  162. coverImage: jpeg(), onCoverError: value => warnings.push(value),
  163. });
  164. assert.deepEqual(warnings, [error]);
  165. assert.deepEqual(f.requests.map(request => request.method), ['POST', 'PUT']);
  166. assert.equal(f.state.content.name, 'Content survives');
  167. assert.equal(f.state.coverUri, oldCoverUri);
  168. assert.equal(f.requests[1].payload.assets.some(asset => asset.uploadTicket), false);
  169. });
  170. test('cover POST 409 stops the transaction before any PUT', async () => {
  171. const f = await fixture();
  172. const error = new ModelApiError('version conflict', 'CONFLICT', '', 409);
  173. f.controls.postError = error;
  174. const previous = clone(f.api.project);
  175. let warnings = 0;
  176. await assert.rejects(f.api.queueSave({ ...initialDocument(), name: 'Stale content' }, {
  177. coverImage: jpeg(), onCoverError: () => { warnings += 1; },
  178. }), actual => actual === error);
  179. assert.deepEqual(f.requests.map(request => request.method), ['POST']);
  180. assert.deepEqual(f.api.project, previous);
  181. assert.equal(warnings, 0);
  182. });
  183. for (const status of [409, 503]) {
  184. test(`final PUT ${status} preserves the old cover and cannot leak its ticket into a later autosave`, async () => {
  185. const f = await fixture();
  186. const error = new ModelApiError('save failed', 'SAVE_FAILED', '', status);
  187. f.controls.putError = error;
  188. const previous = clone(f.api.project);
  189. const fingerprint = f.api.lastSavedFingerprint;
  190. const document = { ...initialDocument(), name: 'Still unsaved' };
  191. await assert.rejects(f.api.queueSave(document, { coverImage: jpeg() }), actual => actual === error);
  192. assert.deepEqual(f.api.project, previous);
  193. assert.equal(f.api.lastSavedFingerprint, fingerprint);
  194. assert.equal(f.api.pendingUploads.size, 0);
  195. f.controls.putError = null;
  196. await f.api.queueSave(document);
  197. const later = f.requests.at(-1).payload;
  198. assert.equal(later.coverUri, oldCoverUri);
  199. assert.equal(later.assets.some(asset => asset.uploadTicket), false);
  200. assert.equal(f.state.coverUri, oldCoverUri);
  201. assert.equal(f.requests.filter(request => request.method === 'POST').length, 1);
  202. });
  203. }
  204. test('automatic saves never upload a cover, and unchanged content skips PUT before and after cover replacement', async () => {
  205. const f = await fixture();
  206. const document = initialDocument();
  207. await f.api.queueSave(document);
  208. assert.equal(f.requests.length, 0);
  209. document.name = 'Automatic content edit';
  210. await f.api.queueSave(document);
  211. await f.api.queueSave(document);
  212. assert.deepEqual(f.requests.map(request => request.method), ['PUT']);
  213. await f.api.queueSave(document, { coverImage: jpeg() });
  214. const count = f.requests.length;
  215. await f.api.queueSave(document);
  216. assert.equal(f.requests.length, count, 'consumed cover ticket must not cause another save');
  217. });
  218. test('asynchronous JPEG encoding owns the queue until upload and final PUT finish', async () => {
  219. const f = await fixture();
  220. const image = deferred();
  221. const postBarrier = deferred();
  222. f.controls.postBarrier = postBarrier;
  223. const first = f.api.queueSave({ ...initialDocument(), name: 'Manual snapshot' }, { coverImage: image.promise });
  224. const laterDocument = { ...initialDocument(), name: 'Later automatic snapshot' };
  225. const later = f.api.queueSave(laterDocument);
  226. laterDocument.name = 'Mutation after enqueue must not leak';
  227. await tick();
  228. assert.equal(f.requests.length, 0, 'later autosave cannot pass pending JPEG encoding');
  229. image.resolve(jpeg());
  230. await tick();
  231. assert.deepEqual(f.requests.map(request => request.method), ['POST']);
  232. postBarrier.resolve();
  233. await Promise.all([first, later]);
  234. assert.deepEqual(f.requests.map(request => request.method), ['POST', 'PUT', 'PUT']);
  235. assert.equal(f.requests[0].version, 7);
  236. assert.equal(f.requests[1].payload.version, 7);
  237. assert.equal(f.requests[2].payload.version, 8);
  238. assert.equal(f.requests[1].payload.content.name, 'Manual snapshot');
  239. assert.equal(f.state.content.name, 'Later automatic snapshot');
  240. assert.equal(f.requests[2].payload.coverUri, f.requests[1].payload.coverUri);
  241. });
  242. test('JPEG rejection while another save owns the queue is observed immediately and falls back safely', async () => {
  243. const f = await fixture();
  244. const barrier = deferred();
  245. f.controls.putBarrier = barrier;
  246. const first = f.api.queueSave({ ...initialDocument(), name: 'First save' });
  247. await tick();
  248. const captureError = new Error('JPEG encoding failed');
  249. const image = deferred();
  250. const warnings = [];
  251. const later = f.api.queueSave({ ...initialDocument(), name: 'Fallback content' }, {
  252. coverImage: image.promise, onCoverError: error => warnings.push(error),
  253. });
  254. image.reject(captureError);
  255. await tick();
  256. barrier.resolve();
  257. await Promise.all([first, later]);
  258. assert.deepEqual(warnings, [captureError]);
  259. assert.deepEqual(f.requests.map(request => request.method), ['PUT', 'PUT']);
  260. assert.equal(f.state.content.name, 'Fallback content');
  261. assert.equal(f.state.coverUri, oldCoverUri);
  262. });