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
Original file line number Diff line number Diff line change
Expand Up @@ -311,10 +311,12 @@ export default class ContentClient {
return docPath;
}

async #getHelixResourceStatus(path) {
async #getHelixResourceStatus(path, includeEditUrl = false) {
const { rso } = this.site.getHlxConfig();
// https://www.aem.live/docs/admin.html#tag/status
const adminEndpointUrl = `https://admin.hlx.page/status/${rso.owner}/${rso.site}/${rso.ref}/${path.replace(/^\/+/, '')}`;
// https://www.aem.live/docs/admin.html#tag/status,
let adminEndpointUrl = `https://admin.hlx.page/status/${rso.owner}/${rso.site}/${rso.ref}/${path.replace(/^\/+/, '')}`;
// ?editUrl=auto for URL of the edit (authoring) document
adminEndpointUrl = includeEditUrl ? `${adminEndpointUrl}?editUrl=auto` : adminEndpointUrl;
const response = await fetch(adminEndpointUrl, {
headers: {
Authorization: `token ${this.config.helixAdminToken}`,
Expand Down Expand Up @@ -349,6 +351,15 @@ export default class ContentClient {
};
}

/**
* @param {string} path
* @returns {Promise<string>}
*/
async getEditURL(path) {
const helixResourceStatus = await this.#getHelixResourceStatus(path, true);
return helixResourceStatus?.edit?.url;
}

async getPageMetadata(path) {
const startTime = process.hrtime.bigint();

Expand Down
22 changes: 22 additions & 0 deletions packages/spacecat-shared-content-client/src/clients/index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -168,6 +168,28 @@ export class ContentClient {
getLivePreviewURLs(path: string):
Promise<{ liveURL: string | undefined, previewURL: string | undefined }>;

/**
* Retrieves the edit URL for a given content path from the AEM admin API.
* The edit URL represents the URL of the document source for the given content path
*
* The path should stem from a page's URL and is relative to the site's root.
* Example: "/path/to/page" (from the full URL: "https://www.example.com/path/to/page").
*
* @param {string} path The content path to get the edit URL for.
*
* @returns {Promise<string | undefined>} A promise that resolves to the edit URL
* string if found, or undefined if not available.
* @throws {Error} If the Helix admin API request fails or returns an error response.
*
* @example
* ```typescript
* const client = await ContentClient.createFrom(context, site);
* const editURL = await client.getEditURL('/content/page');
* console.log(editURL); // e.g., 'https://adobe.sharepoint.com/sites/Projects/_layouts/15/Doc.aspx?sourcedoc=%7xxxxxx-xxxx-xxxx-xxxx-xxxxxx%7D&file=page.docx&action=default'
*
*/
getEditURL(path: string): Promise<string | undefined>;

/**
* Retrieves all links from a document at the specified path.
* This method extracts links from the document content, including both internal
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -967,6 +967,196 @@ describe('ContentClient', () => {
});
});

describe('getEditURL', () => {
let client;

function getHlxConfig() {
return {
...hlxConfigGoogle,
rso: {
owner: 'owner',
site: 'repo',
ref: 'main',
},
};
}

const helixAdminToken = 'test-token';
/** @type {SecretsManagerClient} */
let secretsManagerClient;
let sendStub;

beforeEach(async () => {
secretsManagerClient = new SecretsManagerClient();
sendStub = sinon.stub(secretsManagerClient, 'send');
sendStub.resolves({ SecretString: JSON.stringify({ helix_admin_token: helixAdminToken }) });

client = await ContentClient.createFrom(
context,
{ ...siteConfigGoogleDrive, getHlxConfig },
secretsManagerClient,
);
});

it('should return the edit URL on success', async () => {
const path = '/example/path';
const mockResponse = {
edit: { url: 'https://drive.google.com/document/d/abc123/edit' },
};

nock('https://admin.hlx.page', {
reqheaders: {
authorization: `token ${helixAdminToken}`,
},
})
.get('/status/owner/repo/main/example/path?editUrl=auto')
.reply(200, mockResponse);

expect(sendStub).to.have.been.calledWithMatch(
{
input: {
SecretId: resolveCustomerSecretsName(baseUrl, context),
},
},
);
const result = await client.getEditURL(path);

expect(result).to.equal('https://drive.google.com/document/d/abc123/edit');
});

it('should handle undefined edit URL in response', async () => {
const path = '/example-path';
const mockResponse = {
edit: null,
};

nock('https://admin.hlx.page')
.get('/status/owner/repo/main/example-path?editUrl=auto')
.reply(200, mockResponse);

const result = await client.getEditURL(path);

expect(result).to.be.undefined;
});

it('should handle empty response object', async () => {
const path = '/example-path';

nock('https://admin.hlx.page')
.get('/status/owner/repo/main/example-path?editUrl=auto')
.reply(200, {});

const result = await client.getEditURL(path);

expect(result).to.be.undefined;
});

it('should remove leading slashes from path in API call', async () => {
const path = '///multiple/leading/slashes';
const mockResponse = {
edit: { url: 'https://sharepoint.com/document/edit' },
};

const scope = nock('https://admin.hlx.page')
.get('/status/owner/repo/main/multiple/leading/slashes?editUrl=auto')
.reply(200, mockResponse);

await client.getEditURL(path);

expect(scope.isDone()).to.be.true;
});

it('should include editUrl=auto query parameter', async () => {
const path = '/test-path';
const mockResponse = {
edit: { url: 'https://onedrive.com/document/edit' },
};

const scope = nock('https://admin.hlx.page')
.get((uri) => uri.includes('editUrl=auto'))
.reply(200, mockResponse);

await client.getEditURL(path);

expect(scope.isDone()).to.be.true;
});

it('should throw an error on HTTP failure', async () => {
const path = '/example-path';

nock('https://admin.hlx.page')
.get('/status/owner/repo/main/example-path?editUrl=auto')
.reply(404, { message: 'Not Found' });

try {
await client.getEditURL(path);
expect.fail('Should have thrown an error');
} catch (err) {
expect(err.message).to.equal('Failed to fetch document path for /example-path: {"message":"Not Found"}');
}
});

it('should throw an error on network failure', async () => {
const path = '/example-path';

nock('https://admin.hlx.page')
.get('/status/owner/repo/main/example-path?editUrl=auto')
.replyWithError('Network error');

try {
await client.getEditURL(path);
expect.fail('Should have thrown an error');
} catch (err) {
expect(err.message).to.include('Network error');
}
});

it('should handle OneDrive edit URLs', async () => {
const path = '/onedrive-document';
const mockResponse = {
edit: { url: 'https://adobe.sharepoint.com/:w:/r/sites/test/_layouts/15/Doc.aspx?sourcedoc=123' },
};

nock('https://admin.hlx.page')
.get('/status/owner/repo/main/onedrive-document?editUrl=auto')
.reply(200, mockResponse);

const result = await client.getEditURL(path);

expect(result).to.equal('https://adobe.sharepoint.com/:w:/r/sites/test/_layouts/15/Doc.aspx?sourcedoc=123');
});

it('should handle Google Drive edit URLs', async () => {
const path = '/gdrive-document';
const mockResponse = {
edit: { url: 'https://docs.google.com/document/d/1abc_DEF-xyz/edit' },
};

nock('https://admin.hlx.page')
.get('/status/owner/repo/main/gdrive-document?editUrl=auto')
.reply(200, mockResponse);

const result = await client.getEditURL(path);

expect(result).to.equal('https://docs.google.com/document/d/1abc_DEF-xyz/edit');
});

it('should handle paths ending with /', async () => {
const path = '/example-path/';
const mockResponse = {
edit: { url: 'https://drive.google.com/document/d/test/edit' },
};

nock('https://admin.hlx.page')
.get('/status/owner/repo/main/example-path/?editUrl=auto')
.reply(200, mockResponse);

const result = await client.getEditURL(path);

expect(result).to.equal('https://drive.google.com/document/d/test/edit');
});
});

describe('updateImageAltText', () => {
let client;
let mockDocument;
Expand Down