diff --git a/lib/hosters.js b/lib/hosters.js index 97dbf0f..78baa10 100644 --- a/lib/hosters.js +++ b/lib/hosters.js @@ -468,6 +468,8 @@ async function _fetchDoodstreamFileList(apiKey, signal) { } } +const DOODSTREAM_POLL = { attempts: 12, delayMs: 2500 }; // test-tunable via __test + async function _resolveDoodstreamUploadByName(apiKey, fileName, baselineCodes, signal) { // Same recovery byse uses: the upload POST returned no filecode, but the file // may register in the account a little later. Poll the list for a NEW file @@ -475,8 +477,8 @@ async function _resolveDoodstreamUploadByName(apiKey, fileName, baselineCodes, s // (never "take the only new one") so parallel doodstream uploads can't claim // each other's files. const expected = _normalizeFileTitle(fileName); - const POLL_ATTEMPTS = 12; - const POLL_DELAY_MS = 2500; + const POLL_ATTEMPTS = DOODSTREAM_POLL.attempts; + const POLL_DELAY_MS = DOODSTREAM_POLL.delayMs; for (let i = 0; i < POLL_ATTEMPTS; i++) { if (signal && signal.aborted) return null; const list = await _fetchDoodstreamFileList(apiKey, signal); @@ -638,6 +640,7 @@ module.exports = { extractUploadServerUrl, parseVoeResult, parseDoodstreamResult, - parseByseResult + parseByseResult, + DOODSTREAM_POLL } }; diff --git a/tests/doodstream-api-upload.test.js b/tests/doodstream-api-upload.test.js new file mode 100644 index 0000000..1913a53 --- /dev/null +++ b/tests/doodstream-api-upload.test.js @@ -0,0 +1,105 @@ +const { test, before, after } = require('node:test'); +const assert = require('node:assert'); +const fs = require('fs'); +const os = require('os'); +const path = require('path'); + +// Mock the undici transport BEFORE requiring hosters so the destructured +// `request` picks up our stub. apiGet (getUploadServer) uses global fetch, which +// we override per-test. This exercises the FULL doodstream API upload + recovery +// orchestration against the doc-verified response shapes — the gap between the +// already-tested parseDoodstreamResult helper and the real uploadFile path. +// (mock.module needs an experimental flag npm test doesn't pass, so we reassign +// undici.request on the module object and refresh the hosters cache instead.) +let requestRouter = async () => ({ statusCode: 200, headers: {}, body: { text: async () => '{}' } }); +const undici = require('undici'); +const _origUndiciRequest = undici.request; +undici.request = (...a) => requestRouter(...a); +delete require.cache[require.resolve('../lib/hosters')]; +const hostersMod = require('../lib/hosters'); +const { uploadFile } = hostersMod; + +let tmpFile; +let origFetch; +before(() => { + tmpFile = path.join(os.tmpdir(), `dood-itest-${process.pid}.mkv`); + fs.writeFileSync(tmpFile, Buffer.alloc(2048, 7)); + origFetch = global.fetch; + // Keep the "never appears" recovery test fast (real default is 12 × 2.5 s). + hostersMod.__test.DOODSTREAM_POLL.attempts = 3; + hostersMod.__test.DOODSTREAM_POLL.delayMs = 5; +}); +after(() => { + global.fetch = origFetch; + undici.request = _origUndiciRequest; // restore real transport for other test files + delete require.cache[require.resolve('../lib/hosters')]; + try { fs.unlinkSync(tmpFile); } catch {} +}); + +// getUploadServer hits /api/upload/server via global fetch. +function stubUploadServer() { + global.fetch = async (url) => { + if (/upload\/server/.test(String(url))) { + return { status: 200, text: async () => JSON.stringify({ status: 200, result: 'https://node1.cloudatacdn.com/upload/01' }) }; + } + return { status: 200, text: async () => '{"status":200}' }; + }; +} + +// Build an undici-style router. uploadBody is the POST result; listBodies is a +// queue consumed by successive /api/file/list calls (baseline, then polls). +function routeWith(uploadBody, listBodies = []) { + return async (url, opts) => { + const u = String(url); + if (/\/api\/file\/list/.test(u)) { + const body = listBodies.length ? listBodies.shift() : '{"status":200,"result":{"files":[]}}'; + return { statusCode: 200, headers: {}, body: { text: async () => body } }; + } + // Upload POST: drain the streamed body so the file handle closes. + if (opts && opts.body && typeof opts.body[Symbol.asyncIterator] === 'function') { + for await (const chunk of opts.body) { if (chunk && chunk.length === -1) break; } + } + return { statusCode: uploadBody.status, headers: { 'content-type': 'application/json' }, body: { text: async () => uploadBody.body } }; + }; +} + +test('doodstream API upload: filecode returned directly is used', async () => { + stubUploadServer(); + requestRouter = routeWith({ + status: 200, + body: JSON.stringify({ status: 200, result: [{ filecode: 'DOODCODE1234', download_url: 'https://doodstream.com/d/DOODCODE1234', protected_embed: 'https://doodstream.com/e/DOODCODE1234' }] }) + }); + const res = await uploadFile('doodstream.com', tmpFile, 'VALIDKEY', null, null, null); + assert.equal(res.file_code, 'DOODCODE1234'); + assert.equal(res.download_url, 'https://doodstream.com/d/DOODCODE1234'); +}); + +test('doodstream API upload: codeless result recovered via file-list name match', async () => { + stubUploadServer(); + const fileName = path.basename(tmpFile).replace(/\.[^.]+$/, ''); // title doodstream stores + requestRouter = routeWith( + { status: 200, body: JSON.stringify({ status: 200, msg: 'OK' }) }, // codeless upload + [ + '{"status":200,"result":{"files":[]}}', // baseline (pre-upload) + `{"status":200,"result":{"files":[{"file_code":"RECOVER9999","title":"${fileName}"}]}}` // poll finds it + ] + ); + const res = await uploadFile('doodstream.com', tmpFile, 'VALIDKEY', null, null, null); + assert.equal(res.file_code, 'RECOVER9999'); + assert.equal(res.download_url, 'https://doodstream.com/d/RECOVER9999'); +}); + +test('doodstream API upload: codeless + file never appears → throws hosterTransient (no account poison)', async () => { + stubUploadServer(); + requestRouter = routeWith( + { status: 200, body: JSON.stringify({ status: 200, msg: 'OK' }) }, + [] // every file/list returns empty + ); + await assert.rejects( + () => uploadFile('doodstream.com', tmpFile, 'VALIDKEY', null, null, null), + (err) => { + assert.equal(err.hosterTransient, true, 'codeless result must be tagged hosterTransient'); + return true; + } + ); +});