Ask for a fresh MakerWorld token when MakerWorld refuses it, then retry
When a download fails because MakerWorld refused (or is missing) the auth token, the error carries reason makerworld_token. The import and "Download files from source" then open an "Update your MakerWorld token" prompt with steps to copy the token cookie; saving it retries the download straight away and says if MakerWorld still refuses it. A missing FlareSolverr URL is reported separately, since a token alone won't help. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
parent
28a95ee330
commit
4a88d66e90
7 changed files with 146 additions and 19 deletions
|
|
@ -8,7 +8,7 @@ A self-hosted web app for organizing your 3D-printing files (STL, 3MF, OBJ, FBX,
|
||||||
- **Import from URL**:
|
- **Import from URL**:
|
||||||
- **Thingiverse** — downloads files automatically via the official API (needs a free app token).
|
- **Thingiverse** — downloads files automatically via the official API (needs a free app token).
|
||||||
- **Printables** — downloads files automatically via its public GraphQL API (no auth needed for free models).
|
- **Printables** — downloads files automatically via its public GraphQL API (no auth needed for free models).
|
||||||
- **MakerWorld** — downloads files via FlareSolverr (solves Cloudflare) + your account token.
|
- **MakerWorld** — downloads files via FlareSolverr (solves Cloudflare) + your account token. If files can't be downloaded the import says why, and when MakerWorld refuses the token (they expire) Printhive asks for a fresh one and retries.
|
||||||
- **cubee3d (Hive)** and any other URL — imports title/description/preview image; you add the files manually.
|
- **cubee3d (Hive)** and any other URL — imports title/description/preview image; you add the files manually.
|
||||||
- **In-browser 3D viewer** (Three.js) for STL, OBJ (with its MTL colours/textures), FBX (with textures), STEP/STP (OpenCascade WASM) and Rhino 3DM, and for 3MF via a server-side **trimesh GLB conversion** that streams even huge Bambu/MakerWorld projects.
|
- **In-browser 3D viewer** (Three.js) for STL, OBJ (with its MTL colours/textures), FBX (with textures), STEP/STP (OpenCascade WASM) and Rhino 3DM, and for 3MF via a server-side **trimesh GLB conversion** that streams even huge Bambu/MakerWorld projects.
|
||||||
- **Slicer projects plate by plate** — Bambu Studio / OrcaSlicer 3MFs open on plate 1 with the slicer's plate images as a switcher, parts shown in their filament colours, and a Filaments list; negative/modifier parts are hidden.
|
- **Slicer projects plate by plate** — Bambu Studio / OrcaSlicer 3MFs open on plate 1 with the slicer's plate images as a switcher, parts shown in their filament colours, and a Filaments list; negative/modifier parts are hidden.
|
||||||
|
|
|
||||||
|
|
@ -35,15 +35,15 @@ async function resolveDownload(flaresolverrUrl, instanceId, token) {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Why a download-link answer came back without a link, in words you can act on;
|
// Why a download-link answer came back without a link: { message, tokenRejected },
|
||||||
// null when it has one. Not logged in: {"code":1,"error":"Please log in to download models."}
|
// or null when it has one. Not logged in: {"code":1,"error":"Please log in to download models."}
|
||||||
function downloadProblem(answer) {
|
function downloadProblem(answer) {
|
||||||
if (answer && answer.url) return null;
|
if (answer && answer.url) return null;
|
||||||
const detail = String((answer && (answer.error || answer.message || answer.msg)) || '').trim();
|
const detail = String((answer && (answer.error || answer.message || answer.msg)) || '').trim();
|
||||||
if (/log ?in|sign ?in|unauthori[sz]ed|token|expired/i.test(detail)) {
|
if (/log ?in|sign ?in|unauthori[sz]ed|token|expired/i.test(detail)) {
|
||||||
return 'MakerWorld didn’t accept your auth token (it has probably expired). Paste a fresh one in Settings → MakerWorld auth token, then use “Download files from source”.';
|
return { message: 'MakerWorld didn’t accept your auth token (it has probably expired).', tokenRejected: true };
|
||||||
}
|
}
|
||||||
return `MakerWorld didn’t give a download link${detail ? `: ${detail}` : ''}`;
|
return { message: `MakerWorld didn’t give a download link${detail ? `: ${detail}` : ''}`, tokenRejected: false };
|
||||||
}
|
}
|
||||||
|
|
||||||
// Pull the license string out of the page data (e.g. "MakerWorld Exclusive License").
|
// Pull the license string out of the page data (e.g. "MakerWorld Exclusive License").
|
||||||
|
|
|
||||||
|
|
@ -30,6 +30,7 @@ const setupModal = document.getElementById('setupModal');
|
||||||
const editModal = document.getElementById('editModal');
|
const editModal = document.getElementById('editModal');
|
||||||
const fileDetailModal = document.getElementById('fileDetailModal');
|
const fileDetailModal = document.getElementById('fileDetailModal');
|
||||||
const slicerModal = document.getElementById('slicerModal');
|
const slicerModal = document.getElementById('slicerModal');
|
||||||
|
const tokenModal = document.getElementById('tokenModal');
|
||||||
|
|
||||||
document.addEventListener('DOMContentLoaded', () => {
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
loadPrefs();
|
loadPrefs();
|
||||||
|
|
@ -767,6 +768,8 @@ function setupEventListeners() {
|
||||||
});
|
});
|
||||||
syncSearchMode();
|
syncSearchMode();
|
||||||
document.getElementById('aiTagBtn').addEventListener('click', handleAiTagModel);
|
document.getElementById('aiTagBtn').addEventListener('click', handleAiTagModel);
|
||||||
|
document.getElementById('tokenForm').addEventListener('submit', handleTokenSubmit);
|
||||||
|
document.getElementById('cancelToken').addEventListener('click', () => closeModal(tokenModal));
|
||||||
document.getElementById('detailRenamed').addEventListener('click', (e) => {
|
document.getElementById('detailRenamed').addEventListener('click', (e) => {
|
||||||
if (e.target.closest('#undoRenameBtn')) handleUndoRename();
|
if (e.target.closest('#undoRenameBtn')) handleUndoRename();
|
||||||
});
|
});
|
||||||
|
|
@ -2055,13 +2058,83 @@ async function handleDownloadFiles() {
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`/api/models/${id}/download-files`, { method: 'POST' });
|
const res = await fetch(`/api/models/${id}/download-files`, { method: 'POST' });
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
|
if (!res.ok && data.reason === 'makerworld_token') {
|
||||||
|
resetDownloadFilesBtn(id);
|
||||||
|
promptMakerworldToken(id, data.error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (!res.ok) throw new Error(data.error);
|
if (!res.ok) throw new Error(data.error);
|
||||||
await loadData();
|
await loadData();
|
||||||
openModelDetail(id);
|
openModelDetail(id);
|
||||||
if (data.added === 0) alert('No new files were found (the source returned nothing new).');
|
if (data.added === 0) alert('No new files were found (the source returned nothing new).');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
alert('Download failed: ' + err.message);
|
alert('Download failed: ' + err.message);
|
||||||
btn.disabled = false;
|
resetDownloadFilesBtn(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetDownloadFilesBtn(id) {
|
||||||
|
const btn = document.getElementById('downloadFilesBtn');
|
||||||
|
const model = findModel(id);
|
||||||
|
btn.disabled = false;
|
||||||
|
btn.textContent = model && model.files.length ? 'Re-check source for files' : 'Download files from source';
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- MakerWorld token prompt
|
||||||
|
// MakerWorld tokens expire. When one is refused, ask for a fresh one right away and
|
||||||
|
// retry the download with it.
|
||||||
|
let tokenPromptModelId = null;
|
||||||
|
|
||||||
|
function promptMakerworldToken(modelId, reason) {
|
||||||
|
tokenPromptModelId = modelId;
|
||||||
|
document.getElementById('tokenReason').textContent = reason;
|
||||||
|
document.getElementById('tokenInput').value = '';
|
||||||
|
const status = document.getElementById('tokenStatus');
|
||||||
|
status.textContent = '';
|
||||||
|
status.classList.remove('is-error');
|
||||||
|
document.getElementById('tokenSubmit').disabled = false;
|
||||||
|
tokenModal.classList.add('active');
|
||||||
|
document.getElementById('tokenInput').focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleTokenSubmit(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
const token = document.getElementById('tokenInput').value.trim();
|
||||||
|
if (!token) return;
|
||||||
|
const id = tokenPromptModelId;
|
||||||
|
const status = document.getElementById('tokenStatus');
|
||||||
|
const submit = document.getElementById('tokenSubmit');
|
||||||
|
const fail = (text) => {
|
||||||
|
status.classList.add('is-error');
|
||||||
|
status.textContent = text;
|
||||||
|
submit.disabled = false;
|
||||||
|
};
|
||||||
|
submit.disabled = true;
|
||||||
|
status.classList.remove('is-error');
|
||||||
|
status.textContent = 'Saving your token and downloading the files… this can take a minute.';
|
||||||
|
try {
|
||||||
|
await postJson('/api/settings', { makerworld_token: token }, 'PUT');
|
||||||
|
} catch (err) {
|
||||||
|
fail(`Couldn’t save the token: ${err.message}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/models/${id}/download-files`, { method: 'POST' });
|
||||||
|
const data = await res.json().catch(() => ({}));
|
||||||
|
if (!res.ok) {
|
||||||
|
fail(data.reason === 'makerworld_token'
|
||||||
|
? 'MakerWorld still didn’t accept that token. Check you copied the whole value of the token cookie while signed in, then try again.'
|
||||||
|
: `Your token is saved, but the download failed: ${data.error}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
status.textContent = data.added ? `Downloaded ${plural(data.added, 'file')}.` : 'Token saved. No new files were found.';
|
||||||
|
await loadData();
|
||||||
|
setTimeout(() => {
|
||||||
|
closeModal(tokenModal);
|
||||||
|
if (fileDetailModal.classList.contains('active') && fileDetailModal.dataset.currentModelId === String(id)) openModelDetail(id);
|
||||||
|
}, 900);
|
||||||
|
} catch (err) {
|
||||||
|
fail(`Your token is saved, but the download failed: ${err.message}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -2251,7 +2324,12 @@ async function handleImport(e) {
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
await loadData();
|
await loadData();
|
||||||
if (data.metadataOnly) {
|
if (data.metadataOnly && data.filesErrorReason === 'makerworld_token') {
|
||||||
|
// Details imported, files refused: offer to fix the token and fetch them now.
|
||||||
|
closeModal(importModal);
|
||||||
|
openModelDetail(data.id);
|
||||||
|
promptMakerworldToken(data.id, `Imported “${data.name}”, but its files couldn’t be downloaded: ${data.filesError}`);
|
||||||
|
} else if (data.metadataOnly) {
|
||||||
status.textContent = data.filesError
|
status.textContent = data.filesError
|
||||||
? `Imported "${data.name}", but its files couldn’t be downloaded: ${data.filesError}`
|
? `Imported "${data.name}", but its files couldn’t be downloaded: ${data.filesError}`
|
||||||
: data.metadataFetched
|
: data.metadataFetched
|
||||||
|
|
|
||||||
|
|
@ -549,6 +549,36 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- MakerWorld token prompt (shown when MakerWorld refuses the saved token) -->
|
||||||
|
<div id="tokenModal" class="modal">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h2>Update your MakerWorld token</h2>
|
||||||
|
<button class="close-btn">×</button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<form id="tokenForm">
|
||||||
|
<p id="tokenReason" class="token-reason"></p>
|
||||||
|
<ol class="token-steps">
|
||||||
|
<li>In this browser, sign in to <a href="https://makerworld.com" target="_blank" rel="noopener">makerworld.com</a>.</li>
|
||||||
|
<li>Open the developer tools (<kbd>⌥⌘I</kbd> on a Mac, <kbd>F12</kbd> elsewhere) and go to
|
||||||
|
Application (Storage in Firefox) → Cookies → https://makerworld.com.</li>
|
||||||
|
<li>Copy the value of the <code>token</code> cookie and paste it below.</li>
|
||||||
|
</ol>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="tokenInput">MakerWorld auth token</label>
|
||||||
|
<input type="password" id="tokenInput" autocomplete="off" spellcheck="false" required>
|
||||||
|
</div>
|
||||||
|
<div id="tokenStatus" class="form-hint token-status" role="status"></div>
|
||||||
|
<div class="form-actions">
|
||||||
|
<button type="button" class="btn btn-secondary" id="cancelToken">Not now</button>
|
||||||
|
<button type="submit" class="btn btn-primary" id="tokenSubmit">Save & download files</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Slicer choice (per browser) -->
|
<!-- Slicer choice (per browser) -->
|
||||||
<div id="slicerModal" class="modal">
|
<div id="slicerModal" class="modal">
|
||||||
<div class="modal-content">
|
<div class="modal-content">
|
||||||
|
|
|
||||||
|
|
@ -447,3 +447,8 @@ label { display: block; font-size: 13px; font-weight: 500; color: var(--text-mut
|
||||||
.suggestion-actions .btn-sm { align-self: auto; }
|
.suggestion-actions .btn-sm { align-self: auto; }
|
||||||
.suggestion-models { font-size: 13px; color: var(--text-muted); line-height: 1.7; }
|
.suggestion-models { font-size: 13px; color: var(--text-muted); line-height: 1.7; }
|
||||||
.suggested-collections { font-size: 12px; color: var(--accent); }
|
.suggested-collections { font-size: 12px; color: var(--accent); }
|
||||||
|
.token-reason { font-size: 14px; color: var(--text); line-height: 1.5; margin-bottom: 12px; }
|
||||||
|
.token-steps { margin: 0 0 16px 18px; font-size: 13px; color: var(--text-muted); line-height: 1.7; }
|
||||||
|
.token-steps code, .token-steps kbd { font-family: var(--mono); font-size: 12px; }
|
||||||
|
.token-status { min-height: 1em; }
|
||||||
|
.token-status.is-error { color: var(--danger); }
|
||||||
|
|
|
||||||
27
server.js
27
server.js
|
|
@ -830,12 +830,14 @@ app.post('/api/import', async (req, res, next) => {
|
||||||
// Printables via its public GraphQL API), and capture the license.
|
// Printables via its public GraphQL API), and capture the license.
|
||||||
let files = [];
|
let files = [];
|
||||||
let filesError = null; // why no files could be downloaded, shown after the import
|
let filesError = null; // why no files could be downloaded, shown after the import
|
||||||
|
let filesErrorReason = null; // makerworld_token: the app offers to update the token
|
||||||
let license = meta.license || null;
|
let license = meta.license || null;
|
||||||
let commercial = null;
|
let commercial = null;
|
||||||
if (detected.site === 'makerworld' && html) {
|
if (detected.site === 'makerworld' && html) {
|
||||||
files = await downloadMakerworldFiles(html).catch((err) => {
|
files = await downloadMakerworldFiles(html).catch((err) => {
|
||||||
console.warn('MakerWorld file download failed:', err.message);
|
console.warn('MakerWorld file download failed:', err.message);
|
||||||
filesError = err.message;
|
filesError = err.message;
|
||||||
|
filesErrorReason = err.reason || null;
|
||||||
return [];
|
return [];
|
||||||
});
|
});
|
||||||
license = makerworld.parseLicense(html) || license;
|
license = makerworld.parseLicense(html) || license;
|
||||||
|
|
@ -873,7 +875,7 @@ app.post('/api/import', async (req, res, next) => {
|
||||||
files,
|
files,
|
||||||
});
|
});
|
||||||
fingerprint.kick();
|
fingerprint.kick();
|
||||||
res.status(201).json({ ...publicModel(model), metadataOnly: files.length === 0, metadataFetched, filesError });
|
res.status(201).json({ ...publicModel(model), metadataOnly: files.length === 0, metadataFetched, filesError, filesErrorReason });
|
||||||
} catch (e) { next(e); }
|
} catch (e) { next(e); }
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -923,11 +925,16 @@ async function resolveCommercial(licenseName, fallback) {
|
||||||
// Download every printable instance of a MakerWorld model (needs token + FlareSolverr).
|
// Download every printable instance of a MakerWorld model (needs token + FlareSolverr).
|
||||||
// skipNames: original filenames already present (so a re-download only adds new ones).
|
// skipNames: original filenames already present (so a re-download only adds new ones).
|
||||||
// MakerWorld downloads need both a FlareSolverr address and your auth token.
|
// MakerWorld downloads need both a FlareSolverr address and your auth token.
|
||||||
|
// Errors carry a reason the app can act on: makerworld_token opens the token prompt.
|
||||||
async function requireMakerworldSettings() {
|
async function requireMakerworldSettings() {
|
||||||
const { value: token } = await resolveSetting('makerworld_token');
|
const { value: token } = await resolveSetting('makerworld_token');
|
||||||
const { value: flareUrl } = await resolveSetting('flaresolverr_url');
|
const { value: flareUrl } = await resolveSetting('flaresolverr_url');
|
||||||
if (!token || !flareUrl) {
|
if (!flareUrl) {
|
||||||
throw Object.assign(new Error('Add your MakerWorld auth token and FlareSolverr URL in Settings to download MakerWorld files.'), { status: 400 });
|
throw Object.assign(new Error('Add your FlareSolverr URL in Settings to download MakerWorld files (MakerWorld is behind Cloudflare).'),
|
||||||
|
{ status: 400, reason: 'makerworld_settings' });
|
||||||
|
}
|
||||||
|
if (!token) {
|
||||||
|
throw Object.assign(new Error('Add your MakerWorld auth token to download MakerWorld files.'), { status: 400, reason: 'makerworld_token' });
|
||||||
}
|
}
|
||||||
return { token, flareUrl };
|
return { token, flareUrl };
|
||||||
}
|
}
|
||||||
|
|
@ -952,12 +959,12 @@ async function downloadMakerworldFiles(html, skipNames = new Set()) {
|
||||||
dl = await makerworld.resolveDownload(flareUrl, inst.id, token);
|
dl = await makerworld.resolveDownload(flareUrl, inst.id, token);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.warn(`MakerWorld instance ${inst.id} resolve failed:`, err.message);
|
console.warn(`MakerWorld instance ${inst.id} resolve failed:`, err.message);
|
||||||
problems.push(`Couldn’t ask MakerWorld for a download link: ${err.message}`);
|
problems.push({ message: `Couldn’t ask MakerWorld for a download link: ${err.message}` });
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
const problem = makerworld.downloadProblem(dl);
|
const problem = makerworld.downloadProblem(dl);
|
||||||
if (problem) {
|
if (problem) {
|
||||||
console.warn(`MakerWorld instance ${inst.id}: ${problem}`);
|
console.warn(`MakerWorld instance ${inst.id}: ${problem.message}`);
|
||||||
problems.push(problem);
|
problems.push(problem);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
@ -966,7 +973,7 @@ async function downloadMakerworldFiles(html, skipNames = new Set()) {
|
||||||
const resp = await fetch(dl.url);
|
const resp = await fetch(dl.url);
|
||||||
if (!resp.ok) {
|
if (!resp.ok) {
|
||||||
console.warn(`MakerWorld CDN download ${resp.status} for ${original}`);
|
console.warn(`MakerWorld CDN download ${resp.status} for ${original}`);
|
||||||
problems.push(`Downloading ${original} from MakerWorld failed (HTTP ${resp.status})`);
|
problems.push({ message: `Downloading ${original} from MakerWorld failed (HTTP ${resp.status})` });
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
const fileName = uniqueName(original);
|
const fileName = uniqueName(original);
|
||||||
|
|
@ -974,7 +981,11 @@ async function downloadMakerworldFiles(html, skipNames = new Set()) {
|
||||||
fs.writeFileSync(filePath, Buffer.from(await resp.arrayBuffer()));
|
fs.writeFileSync(filePath, Buffer.from(await resp.arrayBuffer()));
|
||||||
files.push(describeFile(filePath, fileName, original));
|
files.push(describeFile(filePath, fileName, original));
|
||||||
}
|
}
|
||||||
if (!files.length && problems.length) throw Object.assign(new Error(problems[0]), { status: 502 });
|
if (!files.length && problems.length) {
|
||||||
|
const refused = problems.find((p) => p.tokenRejected);
|
||||||
|
throw Object.assign(new Error((refused || problems[0]).message),
|
||||||
|
{ status: 502, reason: refused ? 'makerworld_token' : undefined });
|
||||||
|
}
|
||||||
return files;
|
return files;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1358,7 +1369,7 @@ app.post('/api/autotag/reapply', async (req, res, next) => {
|
||||||
// Central error handler.
|
// Central error handler.
|
||||||
app.use((err, req, res, next) => {
|
app.use((err, req, res, next) => {
|
||||||
if (!err.status) console.error('Request error:', err.message);
|
if (!err.status) console.error('Request error:', err.message);
|
||||||
res.status(err.status || 500).json({ error: err.message });
|
res.status(err.status || 500).json({ error: err.message, ...(err.reason ? { reason: err.reason } : {}) });
|
||||||
});
|
});
|
||||||
|
|
||||||
db.init()
|
db.init()
|
||||||
|
|
|
||||||
|
|
@ -19,9 +19,12 @@ test('printable instances and the license are read from the page data', () => {
|
||||||
assert.deepEqual(makerworld.parseInstances('<html><title>Just a moment...</title></html>'), []);
|
assert.deepEqual(makerworld.parseInstances('<html><title>Just a moment...</title></html>'), []);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('a download answer without a link says why', () => {
|
test('a download answer without a link says why, and flags a refused token', () => {
|
||||||
assert.equal(makerworld.downloadProblem({ name: 'bookmarks.3mf', url: 'https://cdn.example/bookmarks.3mf' }), null);
|
assert.equal(makerworld.downloadProblem({ name: 'bookmarks.3mf', url: 'https://cdn.example/bookmarks.3mf' }), null);
|
||||||
assert.match(makerworld.downloadProblem({ code: 1, error: 'Please log in to download models.' }), /token \(it has probably expired\)/);
|
const refused = makerworld.downloadProblem({ code: 1, error: 'Please log in to download models.' });
|
||||||
assert.equal(makerworld.downloadProblem({ code: 7, error: 'Model not found' }), 'MakerWorld didn’t give a download link: Model not found');
|
assert.equal(refused.tokenRejected, true);
|
||||||
assert.equal(makerworld.downloadProblem(null), 'MakerWorld didn’t give a download link');
|
assert.match(refused.message, /token \(it has probably expired\)/);
|
||||||
|
assert.deepEqual(makerworld.downloadProblem({ code: 7, error: 'Model not found' }),
|
||||||
|
{ message: 'MakerWorld didn’t give a download link: Model not found', tokenRejected: false });
|
||||||
|
assert.deepEqual(makerworld.downloadProblem(null), { message: 'MakerWorld didn’t give a download link', tokenRejected: false });
|
||||||
});
|
});
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue