Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 11 additions & 6 deletions src/content/da-api.js
Original file line number Diff line number Diff line change
Expand Up @@ -140,26 +140,31 @@ 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
* @param {Buffer} buffer
* @param {string} contentType
* @returns {Promise<object>} 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();
}
Expand Down
2 changes: 1 addition & 1 deletion src/content/push.cmd.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
15 changes: 8 additions & 7 deletions test/content/content-test-utils.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {}) {
Expand Down Expand Up @@ -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 {};
};
Expand Down
56 changes: 47 additions & 9 deletions test/content/da-api.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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('<html>'), 'text/html');
const result = await client.postSource('org', 'repo', '/file.html', Buffer.from('<html>'), '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 = '<html><body>hello</body></html>';
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 () => {
Expand All @@ -294,23 +332,23 @@ 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/,
);
});

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/,
);
});
});
Expand Down
84 changes: 78 additions & 6 deletions test/content/push.cmd.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -116,7 +116,7 @@ describe('PushCommand', () => {

let putCalled = false;
const DaClientClass = createDaClientClass({
onPut: () => {
onUpload: () => {
putCalled = true;
},
});
Expand Down Expand Up @@ -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', {
Expand All @@ -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' },
Expand All @@ -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'));
Expand Down Expand Up @@ -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' },
Expand All @@ -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' },
Expand All @@ -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' },
Expand Down Expand Up @@ -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'));
Expand Down
Loading