diff --git a/packages/spacecat-shared-content-client/src/clients/content-client.js b/packages/spacecat-shared-content-client/src/clients/content-client.js index cf1d233af..249c5bdee 100644 --- a/packages/spacecat-shared-content-client/src/clients/content-client.js +++ b/packages/spacecat-shared-content-client/src/clients/content-client.js @@ -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}`, @@ -349,6 +351,15 @@ export default class ContentClient { }; } + /** + * @param {string} path + * @returns {Promise} + */ + async getEditURL(path) { + const helixResourceStatus = await this.#getHelixResourceStatus(path, true); + return helixResourceStatus?.edit?.url; + } + async getPageMetadata(path) { const startTime = process.hrtime.bigint(); diff --git a/packages/spacecat-shared-content-client/src/clients/index.d.ts b/packages/spacecat-shared-content-client/src/clients/index.d.ts index a0eb5ff8c..89edda2e0 100644 --- a/packages/spacecat-shared-content-client/src/clients/index.d.ts +++ b/packages/spacecat-shared-content-client/src/clients/index.d.ts @@ -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} 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; + /** * Retrieves all links from a document at the specified path. * This method extracts links from the document content, including both internal diff --git a/packages/spacecat-shared-content-client/test/clients/content-client.test.js b/packages/spacecat-shared-content-client/test/clients/content-client.test.js index d51cc0e4e..c564d4dcd 100644 --- a/packages/spacecat-shared-content-client/test/clients/content-client.test.js +++ b/packages/spacecat-shared-content-client/test/clients/content-client.test.js @@ -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;