diff --git a/src/content/da-api.js b/src/content/da-api.js index 22d843df5..ae640dc61 100644 --- a/src/content/da-api.js +++ b/src/content/da-api.js @@ -140,7 +140,10 @@ export class DaClient { } /** - * Uploads a file via PUT. + * Uploads a file via multipart POST — the documented DA API contract. + * Raw body uploads are only supported by DA for text/html; all other types + * (including application/json) are silently ignored and must use form-data. + * See: https://docs.da.live/developers/api/source * @param {string} org * @param {string} site * @param {string} daPath @@ -148,18 +151,20 @@ export class DaClient { * @param {string} contentType * @returns {Promise} API response body */ - async putSource(org, site, daPath, buffer, contentType) { + async postSource(org, site, daPath, buffer, contentType) { const url = `${DA_ADMIN}/source/${org}/${site}${daPath}`; + const formData = new FormData(); + formData.append('data', new Blob([buffer], { type: contentType })); const res = await this.fetch(url, { - method: 'PUT', - headers: { ...this.authHeader, 'Content-Type': contentType }, - body: buffer, + method: 'POST', + headers: { ...this.authHeader }, + body: formData, }); if (res.status === 401) { throw new Error('Unauthorized: invalid or missing token'); } if (!res.ok) { - throw new Error(`PUT failed for ${daPath}: ${res.status} ${res.statusText}`); + throw new Error(`POST failed for ${daPath}: ${res.status} ${res.statusText}`); } return res.json(); } diff --git a/src/content/push.cmd.js b/src/content/push.cmd.js index 9473e4b18..4222f311a 100644 --- a/src/content/push.cmd.js +++ b/src/content/push.cmd.js @@ -123,7 +123,7 @@ export default class PushCommand { const ext = daPath.split('.').pop(); try { const buffer = await fse.readFile(localPath); - await client.putSource(org, site, daPath, buffer, getContentType(ext)); + await client.postSource(org, site, daPath, buffer, getContentType(ext)); log.info(` ✓ ${daPath}`); return { ok: true }; } catch (err) { diff --git a/test/content/content-test-utils.js b/test/content/content-test-utils.js index 5e9dac119..b284a29e0 100644 --- a/test/content/content-test-utils.js +++ b/test/content/content-test-utils.js @@ -78,10 +78,10 @@ export async function stageAllAndCommit(contentDir, message) { * @param {Array} opts.files files returned by listAll * @param {string} opts.sourceContent content returned by getSource (null = 404) * @param {number} opts.remoteLastModified lastModified returned by getRemoteLastModified - * @param {boolean} opts.putFails if true, putSource throws + * @param {boolean} opts.uploadFails if true, postSource throws * @param {boolean} opts.deleteFails if true, deleteSource throws * @param {Function} opts.onGetSource called with (owner, repo, daPath) when getSource is called - * @param {Function} opts.onPut called with (daPath) when putSource is called + * @param {Function} opts.onUpload called with (daPath, buffer, contentType) * @param {Function} opts.onDelete called with (daPath) when deleteSource is called */ export function createDaClientClass(opts = {}) { @@ -125,12 +125,13 @@ export function createDaClientClass(opts = {}) { return opts.remoteLastModified !== undefined ? opts.remoteLastModified : null; }; - DaClientMock.prototype.putSource = async function putSource(org, site, daPath) { - if (opts.putFails) { - throw new Error(`PUT failed for ${daPath}`); + // eslint-disable-next-line max-len + DaClientMock.prototype.postSource = async function postSource(org, site, daPath, buffer, contentType) { + if (opts.uploadFails) { + throw new Error(`POST failed for ${daPath}`); } - if (opts.onPut) { - opts.onPut(daPath); + if (opts.onUpload) { + opts.onUpload(daPath, buffer, contentType); } return {}; }; diff --git a/test/content/da-api.test.js b/test/content/da-api.test.js index 33a902a14..ae85f36af 100644 --- a/test/content/da-api.test.js +++ b/test/content/da-api.test.js @@ -270,22 +270,60 @@ describe('DaClient', () => { }); }); - describe('putSource', () => { + describe('postSource', () => { it('returns parsed JSON on success', async () => { const responseBody = { status: 'ok' }; client.fetch = async () => mockResponse(200, responseBody); - const result = await client.putSource('org', 'repo', '/file.html', Buffer.from(''), 'text/html'); + const result = await client.postSource('org', 'repo', '/file.html', Buffer.from(''), 'text/html'); assert.deepStrictEqual(result, responseBody); }); - it('sends PUT request', async () => { + it('sends POST request', async () => { let method; client.fetch = async (url, opts) => { method = opts.method; return mockResponse(200, {}); }; - await client.putSource('org', 'repo', '/file.html', Buffer.from(''), 'text/html'); - assert.strictEqual(method, 'PUT'); + await client.postSource('org', 'repo', '/file.html', Buffer.from(''), 'text/html'); + assert.strictEqual(method, 'POST'); + }); + + it('sends multipart form-data body with file content in data field', async () => { + const content = 'hello'; + let body; + client.fetch = async (url, opts) => { + body = opts.body; + return mockResponse(200, {}); + }; + await client.postSource('org', 'repo', '/file.html', Buffer.from(content), 'text/html'); + assert.ok(body instanceof FormData, 'body should be FormData'); + const dataField = body.get('data'); + assert.ok(dataField != null, 'FormData should have a data field'); + assert.strictEqual(await dataField.text(), content); + }); + + it('sets correct content type on the data Blob for each file type', async () => { + for (const [ext, expectedType] of [['html', 'text/html'], ['json', 'application/json']]) { + let body; + client.fetch = async (url, opts) => { + body = opts.body; + return mockResponse(200, {}); + }; + // eslint-disable-next-line no-await-in-loop + await client.postSource('org', 'repo', `/file.${ext}`, Buffer.from('content'), expectedType); + const dataField = body.get('data'); + assert.strictEqual(dataField.type, expectedType, `Blob type should be ${expectedType} for .${ext}`); + } + }); + + it('does not set Content-Type header directly (lets fetch set multipart boundary)', async () => { + let reqHeaders; + client.fetch = async (url, opts) => { + reqHeaders = opts.headers; + return mockResponse(200, {}); + }; + await client.postSource('org', 'repo', '/file.json', Buffer.from('{}'), 'application/json'); + assert.ok(!('Content-Type' in reqHeaders), 'Content-Type must not be set manually on multipart requests'); }); it('calls correct source URL', async () => { @@ -294,14 +332,14 @@ describe('DaClient', () => { calledUrl = url; return mockResponse(200, {}); }; - await client.putSource('org', 'repo', '/page.html', Buffer.from(''), 'text/html'); + await client.postSource('org', 'repo', '/page.html', Buffer.from(''), 'text/html'); assert.strictEqual(calledUrl, 'https://admin.da.live/source/org/repo/page.html'); }); it('throws Unauthorized on 401', async () => { client.fetch = async () => mockResponse(401, 'Unauthorized', false); await assert.rejects( - () => client.putSource('org', 'repo', '/file.html', Buffer.from(''), 'text/html'), + () => client.postSource('org', 'repo', '/file.html', Buffer.from(''), 'text/html'), /Unauthorized/, ); }); @@ -309,8 +347,8 @@ describe('DaClient', () => { it('throws on non-ok status', async () => { client.fetch = async () => mockResponse(500, 'Error', false); await assert.rejects( - () => client.putSource('org', 'repo', '/file.html', Buffer.from(''), 'text/html'), - /PUT failed/, + () => client.postSource('org', 'repo', '/file.html', Buffer.from(''), 'text/html'), + /POST failed/, ); }); }); diff --git a/test/content/push.cmd.test.js b/test/content/push.cmd.test.js index 08fb6a6a2..af547114e 100644 --- a/test/content/push.cmd.test.js +++ b/test/content/push.cmd.test.js @@ -116,7 +116,7 @@ describe('PushCommand', () => { let putCalled = false; const DaClientClass = createDaClientClass({ - onPut: () => { + onUpload: () => { putCalled = true; }, }); @@ -167,7 +167,7 @@ describe('PushCommand', () => { let putCalled = false; const DaClientClass = createDaClientClass({ remoteLastModified: Date.now() + 60_000, - onPut: () => { putCalled = true; }, + onUpload: () => { putCalled = true; }, }); const log = makeLogger(); const mod = await esmock('../../src/content/push.cmd.js', { @@ -190,7 +190,7 @@ describe('PushCommand', () => { await stageAllAndCommit(contentDir, 'edit index'); const pushed = []; - const DaClientClass = createDaClientClass({ onPut: (p) => pushed.push(p) }); + const DaClientClass = createDaClientClass({ onUpload: (p) => pushed.push(p) }); const log = makeLogger(); const mod = await esmock('../../src/content/push.cmd.js', { '../../src/content/da-auth.js': { getValidToken: async () => 'token' }, @@ -206,6 +206,48 @@ describe('PushCommand', () => { assert.ok(pushed.includes('/index.html')); }); + it('pushes modified JSON file with correct content and content-type', async () => { + const contentDir = await setupContentDir(testRoot); + + // Add a JSON file in the baseline (the synced point) + await fse.writeFile(path.join(contentDir, 'metadata.json'), '{"title":"original"}'); + await stageAllAndCommit(contentDir, 'add json'); + + // Write synced ref to this new HEAD so the json file is the baseline + const baseOid = await git.resolveRef({ fs, dir: contentDir, ref: 'HEAD' }); + await git.writeRef({ + fs, dir: contentDir, ref: DA_SYNCED_REF, value: baseOid, force: true, + }); + + // Now modify the JSON — this is the scenario Claude edits then user commits/pushes + const updatedContent = '{"title":"updated"}'; + await fse.writeFile(path.join(contentDir, 'metadata.json'), updatedContent); + await stageAllAndCommit(contentDir, 'edit json'); + + const puts = {}; + const DaClientClass = createDaClientClass({ + onUpload: (daPath, buffer, contentType) => { + puts[daPath] = { body: buffer.toString('utf-8'), contentType }; + }, + }); + + // Use real getContentType so we can verify application/json is sent + const { getContentType: realGetContentType } = await import('../../src/content/da-api.js'); + const mod = await esmock('../../src/content/push.cmd.js', { + '../../src/content/da-auth.js': { getValidToken: async () => 'token' }, + '../../src/content/da-api.js': { + DaClient: DaClientClass, + getContentType: realGetContentType, + }, + }); + const Cmd = mod.default; + await new Cmd(makeLogger()).withDirectory(testRoot).run(); + + assert.ok('/metadata.json' in puts, 'JSON file should be pushed'); + assert.strictEqual(puts['/metadata.json'].body, updatedContent, 'pushed content should match modified file'); + assert.strictEqual(puts['/metadata.json'].contentType, 'application/json', 'content-type should be application/json'); + }); + it('deletes removed files from da.live', async () => { const contentDir = await setupContentDir(testRoot); await fse.remove(path.join(contentDir, 'index.html')); @@ -235,7 +277,7 @@ describe('PushCommand', () => { await stageAllAndCommit(contentDir, 'edit pages'); const pushed = []; - const DaClientClass = createDaClientClass({ onPut: (p) => pushed.push(p) }); + const DaClientClass = createDaClientClass({ onUpload: (p) => pushed.push(p) }); const log = makeLogger(); const mod = await esmock('../../src/content/push.cmd.js', { '../../src/content/da-auth.js': { getValidToken: async () => 'token' }, @@ -260,7 +302,7 @@ describe('PushCommand', () => { await stageAllAndCommit(contentDir, 'edit blog and add blog2'); const pushed = []; - const DaClientClass = createDaClientClass({ onPut: (p) => pushed.push(p) }); + const DaClientClass = createDaClientClass({ onUpload: (p) => pushed.push(p) }); const log = makeLogger(); const mod = await esmock('../../src/content/push.cmd.js', { '../../src/content/da-auth.js': { getValidToken: async () => 'token' }, @@ -284,7 +326,7 @@ describe('PushCommand', () => { await stageAllAndCommit(contentDir, 'edit pages'); const pushed = []; - const DaClientClass = createDaClientClass({ onPut: (p) => pushed.push(p) }); + const DaClientClass = createDaClientClass({ onUpload: (p) => pushed.push(p) }); const log = makeLogger(); const mod = await esmock('../../src/content/push.cmd.js', { '../../src/content/da-auth.js': { getValidToken: async () => 'token' }, @@ -334,6 +376,36 @@ describe('PushCommand', () => { assert.strictEqual(syncedOid, headOid); }); + it('does not advance synced ref when an upload fails on the server', async () => { + const contentDir = await setupContentDir(testRoot); + await fse.writeFile(path.join(contentDir, 'index.html'), 'changed'); + await stageAllAndCommit(contentDir, 'edit index'); + + const DaClientClass = createDaClientClass({ uploadFails: true }); + const log = makeLogger(); + const mod = await esmock('../../src/content/push.cmd.js', { + '../../src/content/da-auth.js': { getValidToken: async () => 'token' }, + '../../src/content/da-api.js': { + DaClient: DaClientClass, + getContentType: () => 'text/html', + }, + }); + const Cmd = mod.default; + const cmd = new Cmd(log).withDirectory(testRoot); + await cmd.run(); + + const headOid = await git.resolveRef({ fs, dir: contentDir, ref: 'HEAD' }); + let syncedOid; + try { + syncedOid = await git.resolveRef({ fs, dir: contentDir, ref: DA_SYNCED_REF }); + } catch { + syncedOid = null; + } + assert.notStrictEqual(syncedOid, headOid); + assert.ok(log.logs.some((l) => l.msg.includes('Sync ref not updated'))); + assert.ok(log.logs.some((l) => l.msg.includes('error') || l.msg.includes('✗'))); + }); + it('does not advance synced ref when a delete fails on the server', async () => { const contentDir = await setupContentDir(testRoot); await fse.remove(path.join(contentDir, 'index.html'));