diff --git a/src/identify-redirects/handler.js b/src/identify-redirects/handler.js index 85771af1c..1c7ddc8a2 100644 --- a/src/identify-redirects/handler.js +++ b/src/identify-redirects/handler.js @@ -18,23 +18,6 @@ import { postMessageSafe } from '../utils/slack-utils.js'; const DEFAULT_MINUTES = 3000; // 50 hours -const DEFAULT_SPLUNK_FIELDS = { - envField: 'aem_envId', - programField: 'aem_program_id', - pathField: 'url', -}; - -const CONFIDENCE = { - acsredirectmanager: 0.95, - acsredirectmapmanager: 0.95, - redirectmapTxt: 0.90, - damredirectmgr: 0.85, -}; - -const MATCH_FIELD = 'matched_path'; -const DEFAULT_HEAD_LIMIT = 200; -const RX_ANY_TAIL = '[^\\\\s\\\\\\"?]*'; - async function oneshotSearchCompat(client, searchString) { if (typeof client?.oneshotSearch === 'function') { return client.oneshotSearch(searchString); @@ -79,115 +62,47 @@ async function oneshotSearchCompat(client, searchString) { return response.json(); } -function buildBaseSearch({ - minutes, - envField, - programField, - environmentId, - programId, -}) { - // Keep the top-level search stable and narrow. - // Note: field names must be verified in Splunk for your index schema. - return [ - 'search', - 'index=dx_aem_engineering', - `earliest=-${minutes}m@m`, - 'latest=@m', - `${envField}="${environmentId}"`, - `${programField}="${programId}"`, - ].join(' '); -} - -function withMatchExtraction(search, { - matchRegex, - headLimit = DEFAULT_HEAD_LIMIT, - pathField = 'url', - includeRaw = true, -} = {}) { - const fields = [MATCH_FIELD]; - if (typeof pathField === 'string' && pathField.length > 0) { - fields.push(pathField); - } - if (includeRaw) fields.push('_raw'); - const tableFields = Array.from(new Set(fields)).join(' '); - - return `${search} | rex field=_raw "(?<${MATCH_FIELD}>${matchRegex})" | where isnotnull(${MATCH_FIELD}) | table ${tableFields} | head ${headLimit}`; -} - -// build out the splunk queries to look for logs related to redirects -function buildQueries(params) { - const { pathField } = params; - return [ - { - id: 'acsredirectmanager', - confidence: CONFIDENCE.acsredirectmanager, - // IMPORTANT: naive /conf/ matching causes false positives. We require redirect context. - search: withMatchExtraction( - `${buildBaseSearch(params)} "/conf/" (redirect OR "acs-commons") NOT "/settings/wcm/templates/" 200`, - { matchRegex: `/conf/${RX_ANY_TAIL}`, pathField }, - ), - }, - { - id: 'acsredirectmapmanager', - confidence: CONFIDENCE.acsredirectmapmanager, - search: withMatchExtraction( - `${buildBaseSearch(params)} "/etc/acs-commons/redirect-maps" 200`, - { matchRegex: `/etc/acs-commons/redirect-maps${RX_ANY_TAIL}`, pathField }, - ), - }, - { - id: 'redirectmapTxt', - confidence: CONFIDENCE.redirectmapTxt, - search: withMatchExtraction( - `${buildBaseSearch(params)} "redirectmap.txt"`, - { matchRegex: `redirectmap\\\\.txt${RX_ANY_TAIL}`, pathField }, - ), - }, - { - id: 'damredirectmgr', - confidence: CONFIDENCE.damredirectmgr, - // IMPORTANT: DAM is noisy unless constrained to redirect context. - search: withMatchExtraction( - `${buildBaseSearch(params)} "/content/dam/" redirect`, - { matchRegex: `/content/dam/${RX_ANY_TAIL}`, pathField }, - ), - }, - ]; -} - -function scorePattern({ totalCount, confidence }) { - // Keep the displayed score as a float, but use an integer key for stable sorting/ties. - const confidenceWeight = Math.round(confidence * 100); +function buildDispatcherQuery(serviceId, minutes) { return { - score: totalCount * confidence, - scoreKey: totalCount * confidenceWeight, + id: 'dispatcher-logs', + search: `search ( index=dx_aem_engineering OR index=dx_aem_engineering_prod OR index=dx_aem_engineering_restricted ) + sourcetype=httpderror + namespace!=ns-team-buds* + level=managed_rewrite_maps* + earliest=-${minutes}m@m + latest=@m + aem_service="${serviceId}" + message="*mapping '*' from path '*'" + | rex field=message "^mapping '(?[^']*)' from path '(?[^']*)' with params.*$" + | eval redirectMethodUsed = case( + match(fileName,"^/conf/"), "acsredirectmanager", + match(fileName,"^(/etc/acs-commons|/content/.*\\.redirectmap\\.txt$)"), "acsredirectmapmanager", + match(fileName,"^/content/dam/"), "damredirectmgr", + 1=1, "Custom" + ) + | stats count AS totalLogHits, + latest(_time) AS mostRecentEpoch, + first(fileName) AS "fileName", + values(aem_service) AS Services + BY redirectMethodUsed + | sort redirectMethodUsed + `, }; } -function computeTopMatches(values, limit = 10) { - const counts = new Map(); - for (const v of values) { - counts.set(v, (counts.get(v) || 0) + 1); - } - return Array.from(counts.entries()) - .sort((a, b) => (b[1] - a[1]) || String(a[0]).localeCompare(String(b[0]))) - .slice(0, limit) - .map(([value, count]) => ({ value, count })); -} - -function pickWinner(patternResults) { - const successful = patternResults.filter((r) => !r.error); - if (successful.length === 0) return null; +export function pickWinner(patternResults) { + if (patternResults.error) return { redirectMethodUsed: 'none', fileName: 'none' }; + if (patternResults.length === 0) return { redirectMethodUsed: 'vanityurlmgr', fileName: 'none' }; + if (patternResults.every((r) => r.error)) return null; - // Sort by score desc, then by totalCount desc, then by confidence desc. - // Use scoreKey (integer) for stable comparisons. - successful.sort((a, b) => { - if (b.scoreKey !== a.scoreKey) return b.scoreKey - a.scoreKey; - if (b.totalCount !== a.totalCount) return b.totalCount - a.totalCount; - return b.confidence - a.confidence; + // Sort by most recent, then by totalLogHits; ties leave order unchanged + patternResults.sort((a, b) => { + if (b.mostRecentEpoch !== a.mostRecentEpoch) return b.mostRecentEpoch - a.mostRecentEpoch; + if (b.totalLogHits !== a.totalLogHits) return b.totalLogHits - a.totalLogHits; + return 0; }); - return successful[0]; + return patternResults[0]; } function formatQueriesForSlack(queries) { @@ -203,10 +118,10 @@ function formatResponsesPreviewForSlack(results, { let acc = ''; for (const r of results) { - const header = `# ${r.id}\n`; + const header = `# ${r.redirectMethodUsed}\n`; const body = hasText(r.error) ? `error: ${String(r.error)}\n` - : `${r.rows.slice(0, maxRowsPerPattern).map((row) => JSON.stringify(row)).join('\n') || '(no rows)'}\n`; + : `${r.fileName}\n`; const block = `${header}${body}\n`; if (acc.length + block.length > totalLimit) { @@ -233,22 +148,23 @@ function formatSlackMessage({ + `AEM CS: programId=\`${programId}\`, environmentId=\`${environmentId}\`, window=\`last ${minutes}m\`\n`; const lines = results.map((r) => { + const methodUsed = r.redirectMethodUsed; const status = r.error ? `failed: ${r.error}` - : `rows=${r.rowsCount}, count=${r.totalCount}, score=${r.score.toFixed(2)}`; - return `- \`${r.id}\` (conf=${r.confidence}): ${status}`; + : `rows=${r.rowsCount ?? (r.rows?.length ?? 1)}, count=${r.totalCount ?? r.totalLogHits ?? 0}`; + return `- \`${methodUsed}\`: ${status}`; }).join('\n'); if (!winner) { return `${header}\n*Results*\n${lines}\n\n*Winner*: none${formatQueriesForSlack(queries)}${formatResponsesPreviewForSlack(results)}`; } - const examples = (winner.examples || []).slice(0, 8).join('\n'); - const examplesBlock = hasText(examples) - ? `\n\n*Top matched strings for winner (\`${winner.id}\`)*\n\`\`\`\n${examples}\n\`\`\`` + const examplesBlock = hasText(winner.fileName) + ? `\n\n*Top matched strings for winner (\`${winner.redirectMethodUsed}\`)*\n\`\`\`\n${winner.fileName}\n\`\`\`` : ''; - return `${header}\n*Winner*: \`${winner.id}\`\n\n*Results*\n${lines}${examplesBlock}${formatQueriesForSlack(queries)}${formatResponsesPreviewForSlack(results)}`; + const methodUsed = winner.redirectMethodUsed; + return `${header}\n*Winner*: \`${methodUsed}\`\n\n*Results*\n${lines}${examplesBlock}${formatQueriesForSlack(queries)}${formatResponsesPreviewForSlack(results)}`; } export default async function identifyRedirects(message, context) { @@ -262,7 +178,6 @@ export default async function identifyRedirects(message, context) { environmentId, minutes = DEFAULT_MINUTES, slackContext, - splunkFields = {}, updateRedirects = false, } = message || {}; @@ -287,8 +202,6 @@ export default async function identifyRedirects(message, context) { return ok({ status: 'error', reason: 'missing-inputs' }); } - const { envField, programField, pathField } = { ...DEFAULT_SPLUNK_FIELDS, ...splunkFields }; - const client = SplunkAPIClient.createFrom(context); await postMessageSafe( @@ -301,84 +214,45 @@ export default async function identifyRedirects(message, context) { }, ); - const queryParams = { - minutes, - envField, - programField, - pathField, - environmentId, - programId, - }; - - // [ acsredirectmanager, acsredirectmapmanager, redirectmapTxt, damredirectmgr ] - const queries = buildQueries(queryParams); + const serviceId = `cm-p${programId}-e${environmentId}`; + const query = buildDispatcherQuery(serviceId, minutes); + const queries = [query]; - let settled; + let response; try { - // Ensure a single login, then run queries in parallel. + // Ensure a single login, then run the query. await client.login(); - settled = await Promise.allSettled(queries.map((q) => oneshotSearchCompat(client, q.search))); + response = await oneshotSearchCompat(client, query.search); } catch (e) { - const text = `:x: Failed to query Splunk for redirect patterns for *${baseURL}*: ${e.message}`; - await postMessageSafe(context, channelId, text, { - threadTs, - ...(slackTarget && { target: slackTarget }), - }); - return ok({ status: 'error', reason: 'splunk-query-failed' }); + response = { rejected: true, error: e }; } - const results = queries.map((q, idx) => { - const item = settled[idx]; - if (item.status === 'rejected') { - return { - id: q.id, - confidence: q.confidence, - rowsCount: 0, - rows: [], - totalCount: 0, - score: 0, - scoreKey: 0, - topMatches: [], - examples: [], - error: item.reason?.message || String(item.reason), - }; - } - - const response = item.value || {}; - const splunkResults = Array.isArray(response.results) ? response.results : []; - const totalCount = splunkResults.length; - const rows = splunkResults.slice(0, 3); - const matches = splunkResults - .map((r) => r[MATCH_FIELD]) - .filter((v) => typeof v === 'string' && v.length > 0); - const examplesList = (matches.length > 0 ? matches : splunkResults.map((r) => r[pathField])) - .filter((v) => typeof v === 'string' && v.length > 0) - .slice(0, 8); - const examples = examplesList.length > 0 ? examplesList : null; - - const topMatches = computeTopMatches(matches, 10); - const { score, scoreKey } = scorePattern({ totalCount, confidence: q.confidence }); - return { - id: q.id, - confidence: q.confidence, - rowsCount: splunkResults.length, - rows, - totalCount, - score, - scoreKey, - topMatches, - examples, - error: null, - }; - }); + let results; + if (response.rejected) { + const err = response.error; + results = [{ + redirectMethodUsed: query.id, + error: err?.message ?? String(err), + }]; + } else if (response.error) { + results = [{ + redirectMethodUsed: query.id, + rowsCount: 0, + rows: [], + totalCount: 0, + error: response.reason?.message || String(response.reason), + }]; + } else { + results = response.results || []; + } const winner = pickWinner(results); - const allZero = results.every((r) => !r.error && r.totalCount === 0); + const allZero = results.every((r) => !r.error && r.totalLogHits === 0); const finalText = allZero ? `*Redirect pattern detection* for *${baseURL}*\n` + `AEM CS: programId=\`${programId}\`, environmentId=\`${environmentId}\`, window=\`last ${minutes}m\`\n\n` - + `No redirect patterns detected in the last ${minutes} minutes.` + + `No redirect patterns detected in the last ${minutes} minutes.\n\n*Winner*: \`${winner?.redirectMethodUsed ?? 'none'}\` (no patterns)` + `${formatQueriesForSlack(queries)}${formatResponsesPreviewForSlack(results)}` : formatSlackMessage({ baseURL, @@ -406,12 +280,8 @@ export default async function identifyRedirects(message, context) { { threadTs, ...(slackTarget && { target: slackTarget }) }, ); } else { - let redirectsSource = 'none'; - const redirectsMode = winner.id; - - if (redirectsMode !== 'vanityurlmgr') { - [redirectsSource] = winner.examples || ['none']; - } + const redirectsMode = winner.redirectMethodUsed; + const redirectsSource = redirectsMode === 'vanityurlmgr' ? 'none' : (winner.fileName || 'none'); site.setDeliveryConfig({ ...site.getDeliveryConfig(), diff --git a/test/audits/identify-redirects.test.js b/test/audits/identify-redirects.test.js index 4b47b11b8..d187008ea 100644 --- a/test/audits/identify-redirects.test.js +++ b/test/audits/identify-redirects.test.js @@ -45,13 +45,14 @@ async function loadHandler({ loginImpl, oneshotImpl } = {}) { const postMessageSafe = sinon.stub().resolves({ success: true }); - const identifyRedirects = (await esmock('../../src/identify-redirects/handler.js', { + const handlerModule = await esmock('../../src/identify-redirects/handler.js', { '@adobe/spacecat-shared-splunk-client': { default: SplunkAPIClient }, '../../src/utils/slack-utils.js': { postMessageSafe }, - })).default; + }); return { - identifyRedirects, + identifyRedirects: handlerModule.default, + pickWinner: handlerModule.pickWinner, SplunkAPIClient, splunkClient, login, @@ -97,6 +98,12 @@ describe('identify-redirects handler', () => { sandbox.restore(); }); + it('pickWinner returns none when patternResults.error is set', async () => { + const { pickWinner } = await loadHandler(); + const result = pickWinner({ error: true }); + expect(result).to.deep.equal({ redirectMethodUsed: 'none', fileName: 'none' }); + }); + it('ignores messages missing slackContext.channelId/threadTs', async () => { const { identifyRedirects, postMessageSafe } = await loadHandler(); const resp = await identifyRedirects(null, context); @@ -152,8 +159,10 @@ describe('identify-redirects handler', () => { expect(postMessageSafe).to.have.been.calledTwice; expect(postMessageSafe.firstCall.args[2]).to.include(':hourglass: Started Splunk searches'); - expect(postMessageSafe.secondCall.args[2]).to.include('Failed to query Splunk'); - expect(postMessageSafe.secondCall.args[2]).to.include('splunk down'); + const text = postMessageSafe.secondCall.args[2]; + expect(text).to.include('*Winner*: none'); + expect(text).to.include('*Results*'); + expect(text).to.include('splunk down'); }); it('includes slack target in splunk-failure messages when provided', async () => { @@ -195,7 +204,7 @@ describe('identify-redirects handler', () => { slackContext: { channelId: 'C1', threadTs: '123.456', target: 'WORKSPACE_INTERNAL' }, }, context); - expect(oneshotSearch.callCount).to.equal(4); + expect(oneshotSearch.callCount).to.equal(1); expect(postMessageSafe).to.have.been.calledTwice; expect(postMessageSafe.firstCall.args[2]).to.include(':hourglass: Started Splunk searches'); const text = postMessageSafe.secondCall.args[2]; @@ -212,13 +221,90 @@ describe('identify-redirects handler', () => { }); }); + it('formats results with no winner when Splunk returns response.error', async () => { + const loaded = await loadHandler(); + loaded.oneshotSearch.onCall(0).resolves({ + error: true, + reason: { message: 'Search job failed' }, + }); + + await loaded.identifyRedirects({ + baseURL: 'https://example.com', + programId: 'p1', + environmentId: 'e1', + slackContext: { channelId: 'C1', threadTs: '123.456' }, + }, context); + + expect(loaded.postMessageSafe).to.have.been.calledTwice; + const text = loaded.postMessageSafe.secondCall.args[2]; + expect(text).to.include('*Winner*: none'); + expect(text).to.include('*Results*'); + expect(text).to.include('Search job failed'); + }); + + it('formats results with no winner when Splunk returns response.error with reason as string', async () => { + const loaded = await loadHandler(); + loaded.oneshotSearch.onCall(0).resolves({ + error: true, + reason: 'Splunk service unavailable', + }); + + await loaded.identifyRedirects({ + baseURL: 'https://example.com', + programId: 'p1', + environmentId: 'e1', + slackContext: { channelId: 'C1', threadTs: '123.456' }, + }, context); + + expect(loaded.postMessageSafe).to.have.been.calledTwice; + const text = loaded.postMessageSafe.secondCall.args[2]; + expect(text).to.include('*Winner*: none'); + expect(text).to.include('*Results*'); + expect(text).to.include('Splunk service unavailable'); + }); + + it('treats response with no results key as zero results (uses [] fallback)', async () => { + const loaded = await loadHandler(); + loaded.oneshotSearch.onCall(0).resolves({}); + + await loaded.identifyRedirects({ + baseURL: 'https://example.com', + programId: 'p1', + environmentId: 'e1', + minutes: 5, + slackContext: { channelId: 'C1', threadTs: '123.456' }, + }, context); + + expect(loaded.postMessageSafe).to.have.been.calledTwice; + const text = loaded.postMessageSafe.secondCall.args[2]; + expect(text).to.include('No redirect patterns detected'); + expect(text).to.include('*Winner*: `vanityurlmgr` (no patterns)'); + }); + + it('allZero message shows none when winner has no redirectMethodUsed (raw shape)', async () => { + const loaded = await loadHandler(); + loaded.oneshotSearch.onCall(0).resolves({ + results: [{ totalLogHits: 0 }], + }); + + await loaded.identifyRedirects({ + baseURL: 'https://example.com', + programId: 'p1', + environmentId: 'e1', + minutes: 5, + slackContext: { channelId: 'C1', threadTs: '123.456' }, + }, context); + + const text = loaded.postMessageSafe.secondCall.args[2]; + expect(text).to.include('No redirect patterns detected'); + expect(text).to.include('*Winner*: `none` (no patterns)'); + }); + it('posts a no-patterns message when all queries return zero results', async () => { const loaded = await loadHandler(); - loaded.oneshotSearch - .onCall(0).resolves(undefined) - .onCall(1).resolves({ results: null }) - .onCall(2).resolves({ results: [] }) - .onCall(3).resolves({ results: [] }); + loaded.oneshotSearch.onCall(0).resolves({ + results: [], + }); await loaded.identifyRedirects({ baseURL: 'https://example.com', @@ -232,20 +318,22 @@ describe('identify-redirects handler', () => { expect(loaded.postMessageSafe.firstCall.args[2]).to.include(':hourglass: Started Splunk searches'); expect(loaded.postMessageSafe.secondCall.args[2]).to.include('No redirect patterns detected'); expect(loaded.postMessageSafe.secondCall.args[2]).to.include('last 5m'); + expect(loaded.postMessageSafe.secondCall.args[2]).to.include('*Winner*: `vanityurlmgr` (no patterns)'); expect(loaded.postMessageSafe.secondCall.args[2]).to.include('*Queries run*'); - expect(loaded.postMessageSafe.secondCall.args[2]).to.include('*Response preview'); - expect(loaded.postMessageSafe.secondCall.args[2]).to.include('acsredirectmapmanager:'); }); it('truncates response preview when it would exceed the slack limit', async () => { const loaded = await loadHandler(); const longUrl = `/${'x'.repeat(2000)}`; - loaded.oneshotSearch - .onCall(0).resolves({ results: [{ url: longUrl, count: '1' }] }) - .onCall(1).resolves({ results: [{ url: longUrl, count: '1' }] }) - .onCall(2).resolves({ results: [] }) - .onCall(3).resolves({ results: [] }); + loaded.oneshotSearch.onCall(0).resolves({ + results: [{ + redirectMethodUsed: 'acsredirectmapmanager', + fileName: longUrl, + totalLogHits: 1, + mostRecentEpoch: 1, + }], + }); await loaded.identifyRedirects({ baseURL: 'https://example.com', @@ -257,19 +345,22 @@ describe('identify-redirects handler', () => { expect(loaded.postMessageSafe).to.have.been.calledTwice; const text = loaded.postMessageSafe.secondCall.args[2]; expect(text).to.include('*Response preview'); - expect(text).to.include('# acsredirectmanager'); - expect(text).to.not.include('# acsredirectmapmanager'); + expect(text).to.include('# acsredirectmapmanager'); + expect(text).to.not.include('# vanityurlmgr'); }); it('omits response preview when the first pattern block exceeds the limit', async () => { const loaded = await loadHandler(); const longUrl = `/${'x'.repeat(3000)}`; - loaded.oneshotSearch - .onCall(0).resolves({ results: [{ url: longUrl, count: '1' }] }) - .onCall(1).resolves({ results: [] }) - .onCall(2).resolves({ results: [] }) - .onCall(3).resolves({ results: [] }); + loaded.oneshotSearch.onCall(0).resolves({ + results: [{ + redirectMethodUsed: 'acsredirectmapmanager', + fileName: longUrl, + totalLogHits: 1, + mostRecentEpoch: 1, + }], + }); await loaded.identifyRedirects({ baseURL: 'https://example.com', @@ -283,18 +374,24 @@ describe('identify-redirects handler', () => { expect(text).to.not.include('*Response preview'); }); - it('includes top paths for the winner when examples are available', async () => { + it('includes file name for redirect method used when the file name is available', async () => { const loaded = await loadHandler(); - loaded.oneshotSearch - .onCall(0).resolves({ - results: [ - { matched_path: '/etc/acs-commons/redirect-maps/map', url: '/etc/acs-commons/redirect-maps/map' }, - { matched_path: '/etc/acs-commons/redirect-maps/other', url: '/etc/acs-commons/redirect-maps/other' }, - ], - }) - .onCall(1).resolves({ results: [] }) - .onCall(2).resolves({ results: [] }) - .onCall(3).resolves({ results: [] }); + loaded.oneshotSearch.onCall(0).resolves({ + results: [ + { + redirectMethodUsed: 'acsredirectmapmanager', + fileName: '/etc/acs-commons/redirect-maps/map', + totalLogHits: 10, + mostRecentEpoch: 1715328000, + }, + { + redirectMethodUsed: 'acsredirectmapmanager', + fileName: '/etc/acs-commons/redirect-maps/other', + totalLogHits: 5, + mostRecentEpoch: 1715327999, + }, + ], + }); await loaded.identifyRedirects({ baseURL: 'https://example.com', @@ -310,7 +407,30 @@ describe('identify-redirects handler', () => { expect(text).to.include('*Top matched strings for winner'); expect(text).to.include('/etc/acs-commons/redirect-maps/map'); expect(text).to.include('*Response preview'); - expect(text).to.include('"matched_path":"/etc/acs-commons/redirect-maps/map"'); + }); + + it('formats result status with rows length when rowsCount is missing (raw Splunk shape)', async () => { + const loaded = await loadHandler(); + loaded.oneshotSearch.onCall(0).resolves({ + results: [{ + redirectMethodUsed: 'dispatcher-logs', + rows: [{ url: '/foo' }, { url: '/bar' }], + totalLogHits: 2, + mostRecentEpoch: 1, + }], + }); + + await loaded.identifyRedirects({ + baseURL: 'https://example.com', + programId: 'p1', + environmentId: 'e1', + slackContext: { channelId: 'C1', threadTs: '123.456' }, + }, context); + + const text = loaded.postMessageSafe.secondCall.args[2]; + expect(text).to.include('*Results*'); + expect(text).to.include('rows=2'); + expect(text).to.include('count=2'); }); it('omits the examples block when winner has no string paths and supports splunkFields overrides', async () => { @@ -340,9 +460,8 @@ describe('identify-redirects handler', () => { expect(loaded.oneshotSearch).to.have.been.called; const firstQuery = loaded.oneshotSearch.firstCall.args[0]; - expect(firstQuery).to.include('env="e1"'); - expect(firstQuery).to.include('prog="p1"'); - expect(firstQuery).to.include('"/conf/"'); + expect(firstQuery).to.include('aem_service="cm-pp1-ee1"'); + expect(firstQuery).to.include('httpderror'); expect(loaded.postMessageSafe).to.have.been.calledTwice; expect(loaded.postMessageSafe.firstCall.args[2]).to.include(':hourglass: Started Splunk searches'); @@ -351,19 +470,91 @@ describe('identify-redirects handler', () => { expect(text).to.not.include('*Top matched strings for winner'); }); - it('breaks ties by totalCount when scores are equal', async () => { + it('determine winner by most recent', async () => { const loaded = await loadHandler(); loaded.oneshotSearch - .onCall(0).resolves({ results: [] }) - .onCall(1).resolves({ - // 18 * 0.95 = 17.1 - results: Array.from({ length: 18 }, () => ({ url: '/etc/acs-commons/redirect-maps/a' })), - }) - .onCall(2).resolves({ - // 19 * 0.90 = 17.1 (exact tie in JS float math) - results: Array.from({ length: 19 }, () => ({ url: '/content/dam/something.redirectmap.txt' })), - }) - .onCall(3).resolves({ results: [] }); + .onCall(0).resolves({ + results: [ + { + redirectMethodUsed: 'acsredirectmanager', + fileName: '/etc/acs-commons/redirect-maps/a', + mostRecentEpoch: 1715328000, + totalLogHits: 10, + }, + { + redirectMethodUsed: 'damredirectmgr', + fileName: '/content/dam/something.redirectmap.txt', + mostRecentEpoch: 1715328001, + totalLogHits: 11, + }, + ], + }); + + await loaded.identifyRedirects({ + baseURL: 'https://example.com', + programId: 'p1', + environmentId: 'e1', + slackContext: { channelId: 'C1', threadTs: '123.456' }, + }, context); + + expect(loaded.postMessageSafe).to.have.been.calledTwice; + expect(loaded.postMessageSafe.firstCall.args[2]).to.include(':hourglass: Started Splunk searches'); + const text = loaded.postMessageSafe.secondCall.args[2]; + expect(text).to.include('*Winner*: `damredirectmgr`'); + }); + + it('determine winner by most loghits when most recent is tied', async () => { + const loaded = await loadHandler(); + loaded.oneshotSearch + .onCall(0).resolves({ + results: [ + { + redirectMethodUsed: 'acsredirectmanager', + fileName: '/etc/acs-commons/redirect-maps/a', + mostRecentEpoch: 1715328000, + totalLogHits: 10, + }, + { + redirectMethodUsed: 'damredirectmgr', + fileName: '/content/dam/something.redirectmap.txt', + mostRecentEpoch: 1715328000, + totalLogHits: 11, + }, + ], + }); + + await loaded.identifyRedirects({ + baseURL: 'https://example.com', + programId: 'p1', + environmentId: 'e1', + slackContext: { channelId: 'C1', threadTs: '123.456' }, + }, context); + + expect(loaded.postMessageSafe).to.have.been.calledTwice; + expect(loaded.postMessageSafe.firstCall.args[2]).to.include(':hourglass: Started Splunk searches'); + const text = loaded.postMessageSafe.secondCall.args[2]; + expect(text).to.include('*Winner*: `damredirectmgr`'); + }); + + it('determine winner by array index when most recent and most loghits are tied', async () => { + const loaded = await loadHandler(); + loaded.oneshotSearch + .onCall(0).resolves({ + results: [ + { + redirectMethodUsed: 'acsredirectmanager', + fileName: '/etc/acs-commons/redirect-maps/a', + mostRecentEpoch: 1715328000, + totalLogHits: 10, + }, + { + redirectMethodUsed: 'damredirectmgr', + fileName: '/content/dam/something.redirectmap.txt', + mostRecentEpoch: 1715328000, + totalLogHits: 10, + }, + ], + }); await loaded.identifyRedirects({ baseURL: 'https://example.com', @@ -375,7 +566,7 @@ describe('identify-redirects handler', () => { expect(loaded.postMessageSafe).to.have.been.calledTwice; expect(loaded.postMessageSafe.firstCall.args[2]).to.include(':hourglass: Started Splunk searches'); const text = loaded.postMessageSafe.secondCall.args[2]; - expect(text).to.include('*Winner*: `redirectmapTxt`'); + expect(text).to.include('*Winner*: `acsredirectmanager`'); }); it('falls back to compat oneshot search when oneshotSearch is missing', async () => { @@ -588,17 +779,38 @@ describe('identify-redirects handler', () => { const text = loaded.postMessageSafe.secondCall.args[2]; expect(text).to.include('*Queries run*'); - expect(text).to.include('acsredirectmapmanager:'); + expect(text).to.include('sourcetype=httpderror'); + }); + + it('does not update site config when updateRedirects is true but there is no winner', async () => { + const loaded = await loadHandler({ + oneshotImpl: async () => { + throw new Error('search failed'); + }, + }); + + await loaded.identifyRedirects({ + siteId: 'site-123', + baseURL: 'https://example.com', + programId: 'p1', + environmentId: 'e1', + updateRedirects: true, + slackContext: { channelId: 'C1', threadTs: '123.456' }, + }, context); + + expect(loaded.postMessageSafe).to.have.been.calledTwice; + expect(context._siteStub.setDeliveryConfig).to.not.have.been.called; + expect(context._siteStub.save).to.not.have.been.called; }); it('updates site delivery config and saves when updateRedirects is true and there is a winner', async () => { const loaded = await loadHandler(); const examplePath = '/etc/acs-commons/redirect-maps/my-map'; + const ExampleMode = 'acsredirectmanager'; loaded.oneshotSearch .onCall(0).resolves({ results: [ - { matched_path: examplePath, url: examplePath }, - { matched_path: '/etc/acs-commons/redirect-maps/other', url: '/etc/acs-commons/redirect-maps/other' }, + { redirectMethodUsed: ExampleMode, fileName: examplePath }, ], }) .onCall(1).resolves({ results: [] }) @@ -619,19 +831,17 @@ describe('identify-redirects handler', () => { expect(context._siteStub.setDeliveryConfig).to.have.been.calledOnce; expect(context._siteStub.setDeliveryConfig.firstCall.args[0]).to.include({ redirectsSource: examplePath, - redirectsMode: 'acsredirectmanager', + redirectsMode: ExampleMode, }); expect(context._siteStub.save).to.have.been.calledOnce; }); - it('sets redirectsSource to "none" when updateRedirects is true and winner has no examples', async () => { + it('sets redirectsSource to "none" when updateRedirects is true and winner has no fileName', async () => { const loaded = await loadHandler(); - // First query returns one row but no matched_path/url → winner.examples is null → [redirectsSource] = ['none'] - loaded.oneshotSearch - .onCall(0).resolves({ results: [ {} ] }) - .onCall(1).resolves({ results: [] }) - .onCall(2).resolves({ results: [] }) - .onCall(3).resolves({ results: [] }); + // One row with redirectMethodUsed but no fileName → redirectsSource = 'none', redirectsMode = method + loaded.oneshotSearch.resolves({ + results: [{ redirectMethodUsed: 'acsredirectmanager' }], + }); await loaded.identifyRedirects({ siteId: 'site-123', @@ -650,6 +860,28 @@ describe('identify-redirects handler', () => { expect(context._siteStub.save).to.have.been.calledOnce; }); + it('sets redirectsSource to "none" when updateRedirects is true and winner is vanityurlmgr', async () => { + const loaded = await loadHandler(); + // Empty query result → pickWinner([]) returns { redirectMethodUsed: 'vanityurlmgr', fileName: 'none' } + loaded.oneshotSearch.resolves({ results: [] }); + + await loaded.identifyRedirects({ + siteId: 'site-123', + baseURL: 'https://example.com', + programId: 'p1', + environmentId: 'e1', + updateRedirects: true, + slackContext: { channelId: 'C1', threadTs: '123.456' }, + }, context); + + expect(context._siteStub.setDeliveryConfig).to.have.been.calledOnce; + expect(context._siteStub.setDeliveryConfig.firstCall.args[0]).to.include({ + redirectsSource: 'none', + redirectsMode: 'vanityurlmgr', + }); + expect(context._siteStub.save).to.have.been.calledOnce; + }); + it('posts warning and skips config update when updateRedirects is true, winner exists, but siteId is missing', async () => { const loaded = await loadHandler(); loaded.oneshotSearch