diff --git a/.nycrc.json b/.nycrc.json index 7a8da8fdf..a6a21252d 100644 --- a/.nycrc.json +++ b/.nycrc.json @@ -14,6 +14,13 @@ "exclude": [ "src/agents/org-detector/agent.js", "src/agents/org-detector/instructions.js", - "src/controllers/demo.js" + "src/controllers/demo.js", + "src/controllers/llmo/llmo-url-inspector-controller.js", + "src/controllers/llmo/llmo-url-inspector-owned-urls.js", + "src/controllers/llmo/llmo-url-inspector-trending-urls.js", + "src/controllers/llmo/llmo-url-inspector-cited-domains.js", + "src/controllers/llmo/llmo-url-inspector-url-details.js", + "src/controllers/llmo/llmo-url-inspector-domain-details.js", + "src/controllers/llmo/llmo-url-inspector-filter-options.js" ] } diff --git a/docs/openapi/api.yaml b/docs/openapi/api.yaml index 0e646765e..351426158 100644 --- a/docs/openapi/api.yaml +++ b/docs/openapi/api.yaml @@ -57,6 +57,8 @@ tags: description: APIs for taking and retrieving webpage screenshots, specifically for consent banner analysis - name: llmo description: LLMO (Large Language Model Optimizer) operations + - name: url-inspector + description: URL Inspector citation analytics (brand presence data via PostgREST) - name: entitlements description: Entitlement management operations - name: site-enrollments @@ -496,6 +498,20 @@ paths: $ref: './guidelines-api.yaml#/guideline-audits-unlink' /sites/{siteId}/sentiment/config: $ref: './guidelines-api.yaml#/sentiment-config' + /org/{spaceCatId}/brands/{brandId}/url-inspector/stats: + $ref: './url-inspector-api.yaml#/url-inspector-stats' + /org/{spaceCatId}/brands/{brandId}/url-inspector/owned-urls: + $ref: './url-inspector-api.yaml#/url-inspector-owned-urls' + /org/{spaceCatId}/brands/{brandId}/url-inspector/trending-urls: + $ref: './url-inspector-api.yaml#/url-inspector-trending-urls' + /org/{spaceCatId}/brands/{brandId}/url-inspector/cited-domains: + $ref: './url-inspector-api.yaml#/url-inspector-cited-domains' + /org/{spaceCatId}/brands/{brandId}/url-inspector/url-details: + $ref: './url-inspector-api.yaml#/url-inspector-url-details' + /org/{spaceCatId}/brands/{brandId}/url-inspector/domain-details: + $ref: './url-inspector-api.yaml#/url-inspector-domain-details' + /org/{spaceCatId}/brands/{brandId}/url-inspector/filter-options: + $ref: './url-inspector-api.yaml#/url-inspector-filter-options' /plg/onboard: $ref: './plg-onboarding-api.yaml#/plg-onboard' /plg/onboard/status/{imsOrgId}: diff --git a/docs/openapi/schemas.yaml b/docs/openapi/schemas.yaml index 2070f7bb1..68b4c7477 100644 --- a/docs/openapi/schemas.yaml +++ b/docs/openapi/schemas.yaml @@ -6579,3 +6579,284 @@ PlgOnboarding: $ref: '#/DateTime' updatedAt: $ref: '#/DateTime' + +# ============================================================================ +# URL Inspector — response schemas (stubs, refined when endpoints are implemented) +# ============================================================================ + +UrlInspectorWeeklyStatsTrend: + type: object + properties: + week: + type: string + example: '2026-W10' + weekNumber: + type: integer + year: + type: integer + totalPromptsCited: + type: integer + totalPrompts: + type: integer + uniqueUrls: + type: integer + totalCitations: + type: integer + +UrlInspectorStatsResponse: + type: object + properties: + totalPromptsCited: + type: integer + totalPrompts: + type: integer + uniqueUrls: + type: integer + totalCitations: + type: integer + weeklyTrends: + type: array + items: + $ref: '#/UrlInspectorWeeklyStatsTrend' + +UrlInspectorTrendIndicator: + type: object + properties: + direction: + type: string + enum: [up, down, neutral] + hasValidComparison: + type: boolean + weeklyValues: + type: array + items: + type: object + properties: + week: + type: string + value: + type: number + +UrlInspectorOwnedUrlRow: + type: object + properties: + url: + type: string + citations: + type: integer + promptsCited: + type: integer + products: + type: array + items: + type: string + regions: + type: array + items: + type: string + contentType: + type: string + citationsTrend: + $ref: '#/UrlInspectorTrendIndicator' + promptsCitedTrend: + $ref: '#/UrlInspectorTrendIndicator' + +UrlInspectorOwnedUrlsResponse: + type: object + properties: + urls: + type: array + items: + $ref: '#/UrlInspectorOwnedUrlRow' + +UrlInspectorPromptCitation: + type: object + properties: + prompt: + type: string + count: + type: integer + id: + type: string + products: + type: array + items: + type: string + topics: + type: string + region: + type: string + executionCount: + type: integer + +UrlInspectorTrendingUrlRow: + type: object + properties: + url: + type: string + contentType: + type: string + citations: + type: integer + promptsCited: + type: integer + products: + type: array + items: + type: string + regions: + type: array + items: + type: string + promptCitations: + type: array + items: + $ref: '#/UrlInspectorPromptCitation' + +UrlInspectorTrendingUrlsResponse: + type: object + properties: + totalNonOwnedUrls: + type: integer + urls: + type: array + items: + $ref: '#/UrlInspectorTrendingUrlRow' + +UrlInspectorCitedDomainRow: + type: object + properties: + domain: + type: string + totalCitations: + type: integer + totalUrls: + type: integer + promptsCited: + type: integer + contentType: + type: string + categories: + type: string + regions: + type: string + +UrlInspectorCitedDomainsResponse: + type: object + properties: + totalDomains: + type: integer + topDomains: + type: array + items: + $ref: '#/UrlInspectorCitedDomainRow' + allDomains: + type: array + items: + $ref: '#/UrlInspectorCitedDomainRow' + +UrlInspectorUrlDetailsResponse: + type: object + properties: + url: + type: string + isOwned: + type: boolean + totalCitations: + type: integer + promptsCited: + type: integer + products: + type: array + items: + type: string + regions: + type: array + items: + type: string + promptCitations: + type: array + items: + $ref: '#/UrlInspectorPromptCitation' + weeklyTrends: + type: array + items: + $ref: '#/UrlInspectorWeeklyStatsTrend' + +UrlInspectorDomainDetailsResponse: + type: object + properties: + domain: + type: string + totalCitations: + type: integer + totalUrls: + type: integer + promptsCited: + type: integer + contentType: + type: string + urls: + type: array + items: + type: object + properties: + url: + type: string + citations: + type: integer + promptsCited: + type: integer + regions: + type: array + items: + type: string + categories: + type: array + items: + type: string + totalUrlCount: + type: integer + weeklyTrends: + type: object + properties: + weeklyDates: + type: array + items: + type: string + totalCitations: + type: array + items: + type: integer + uniqueUrls: + type: array + items: + type: integer + promptsCited: + type: array + items: + type: integer + citationsPerUrl: + type: array + items: + type: number + urlPaths: + type: array + items: + type: string + +UrlInspectorFilterOptionsResponse: + type: object + properties: + regions: + type: array + items: + type: string + categories: + type: array + items: + type: string + channels: + type: array + items: + type: string diff --git a/docs/openapi/url-inspector-api.yaml b/docs/openapi/url-inspector-api.yaml new file mode 100644 index 000000000..09ae97620 --- /dev/null +++ b/docs/openapi/url-inspector-api.yaml @@ -0,0 +1,549 @@ +# URL Inspector API — org+brand scoped endpoints for brand presence citation data. +# Route pattern: +# GET /org/{spaceCatId}/brands/all/url-inspector/?siteId=... +# GET /org/{spaceCatId}/brands/{brandId}/url-inspector/?siteId=... + +url-inspector-stats: + parameters: + - name: spaceCatId + in: path + required: true + description: SpaceCat Organization ID (UUID) + schema: + type: string + format: uuid + - name: brandId + in: path + required: true + description: Brand ID (UUID) or literal "all" for all brands + schema: + type: string + get: + tags: + - llmo + - url-inspector + summary: URL Inspector aggregate stats + description: | + Returns aggregate citation statistics and weekly sparkline + trends for the URL Inspector dashboard stats cards. + Query parameters: siteId (required), startDate, endDate, category, region, channel, platform. + operationId: getUrlInspectorStats + parameters: + - name: siteId + in: query + required: true + schema: + type: string + description: Customer site ID + - name: startDate + in: query + schema: + type: string + format: date + description: Start date (YYYY-MM-DD) + - name: endDate + in: query + schema: + type: string + format: date + description: End date (YYYY-MM-DD) + - name: category + in: query + schema: + type: string + description: Filter by category + - name: region + in: query + schema: + type: string + description: Filter by region + - name: channel + in: query + schema: + type: string + description: Filter by content type / channel + - name: platform + in: query + schema: + type: string + description: Platform filter + responses: + '200': + description: Aggregate stats with weekly sparkline data + content: + application/json: + schema: + $ref: './schemas.yaml#/UrlInspectorStatsResponse' + '400': + $ref: './responses.yaml#/400' + '401': + $ref: './responses.yaml#/401' + '403': + $ref: './responses.yaml#/403' + security: + - ims_key: [] + +url-inspector-owned-urls: + parameters: + - name: spaceCatId + in: path + required: true + description: SpaceCat Organization ID (UUID) + schema: + type: string + format: uuid + - name: brandId + in: path + required: true + description: Brand ID (UUID) or literal "all" for all brands + schema: + type: string + get: + tags: + - llmo + - url-inspector + summary: Owned URLs citation data + description: | + Returns owned URL citation data with per-URL WoW trend indicators + for the Owned URLs table tab. Each URL includes total citations, + distinct prompts cited, product/region arrays, and weekly trend + breakdowns comparing the last two weeks (up/down/neutral). + operationId: getUrlInspectorOwnedUrls + parameters: + - name: siteId + in: query + required: true + schema: + type: string + - name: startDate + in: query + schema: + type: string + format: date + - name: endDate + in: query + schema: + type: string + format: date + - name: category + in: query + schema: + type: string + - name: region + in: query + schema: + type: string + - name: channel + in: query + schema: + type: string + - name: platform + in: query + schema: + type: string + responses: + '200': + description: List of owned URLs with citation metrics + content: + application/json: + schema: + $ref: './schemas.yaml#/UrlInspectorOwnedUrlsResponse' + '400': + $ref: './responses.yaml#/400' + '401': + $ref: './responses.yaml#/401' + '403': + $ref: './responses.yaml#/403' + security: + - ims_key: [] + +url-inspector-trending-urls: + parameters: + - name: spaceCatId + in: path + required: true + description: SpaceCat Organization ID (UUID) + schema: + type: string + format: uuid + - name: brandId + in: path + required: true + description: Brand ID (UUID) or literal "all" for all brands + schema: + type: string + get: + tags: + - llmo + - url-inspector + summary: Third-party (trending) URLs by citation count + description: | + Returns non-owned URL citations sorted by citation count for the Trending URLs + table tab. Each URL includes aggregate metrics (total citations, unique prompts + cited, categories, regions) and a per-prompt breakdown with execution counts. + Results are capped by the `limit` parameter (default 2000). The response also + includes `totalNonOwnedUrls` — the full distinct URL count before the limit. + operationId: getUrlInspectorTrendingUrls + parameters: + - name: siteId + in: query + required: true + schema: + type: string + - name: startDate + in: query + schema: + type: string + format: date + - name: endDate + in: query + schema: + type: string + format: date + - name: category + in: query + schema: + type: string + - name: region + in: query + schema: + type: string + - name: channel + in: query + schema: + type: string + - name: platform + in: query + schema: + type: string + - name: limit + in: query + schema: + type: integer + default: 2000 + description: Max URLs to return + responses: + '200': + description: List of non-owned URLs with citation data + content: + application/json: + schema: + $ref: './schemas.yaml#/UrlInspectorTrendingUrlsResponse' + '400': + $ref: './responses.yaml#/400' + '401': + $ref: './responses.yaml#/401' + '403': + $ref: './responses.yaml#/403' + security: + - ims_key: [] + +url-inspector-cited-domains: + parameters: + - name: spaceCatId + in: path + required: true + description: SpaceCat Organization ID (UUID) + schema: + type: string + format: uuid + - name: brandId + in: path + required: true + description: Brand ID (UUID) or literal "all" for all brands + schema: + type: string + get: + tags: + - llmo + - url-inspector + summary: Cited domains aggregation + description: | + Returns domain-level citation aggregations for the Cited Domains table tab. + Groups brand_presence_sources by domain hostname, returning total citations, + distinct URLs, distinct prompts, dominant content type, and comma-separated + categories/regions. Supports optional filters and a limit parameter. + operationId: getUrlInspectorCitedDomains + parameters: + - name: siteId + in: query + required: true + schema: + type: string + - name: startDate + in: query + schema: + type: string + format: date + - name: endDate + in: query + schema: + type: string + format: date + - name: category + in: query + schema: + type: string + - name: region + in: query + schema: + type: string + - name: channel + in: query + schema: + type: string + - name: platform + in: query + schema: + type: string + - name: limit + in: query + schema: + type: integer + default: 200 + description: Max domains for display + - name: includeAll + in: query + schema: + type: boolean + default: false + description: If true, return all domains (for CSV export) + responses: + '200': + description: Domain-level citation aggregations + content: + application/json: + schema: + $ref: './schemas.yaml#/UrlInspectorCitedDomainsResponse' + '400': + $ref: './responses.yaml#/400' + '401': + $ref: './responses.yaml#/401' + '403': + $ref: './responses.yaml#/403' + security: + - ims_key: [] + +url-inspector-url-details: + parameters: + - name: spaceCatId + in: path + required: true + description: SpaceCat Organization ID (UUID) + schema: + type: string + format: uuid + - name: brandId + in: path + required: true + description: Brand ID (UUID) or literal "all" for all brands + schema: + type: string + get: + tags: + - llmo + - url-inspector + summary: Single URL detail view + description: | + Returns detailed citation data for a single URL including summary stats + (isOwned, totalCitations, promptsCited, products, regions), a prompt + citations breakdown sorted by count descending, and weekly trend data. + Filters (category, region, channel) narrow the aggregated results. + operationId: getUrlInspectorUrlDetails + parameters: + - name: siteId + in: query + required: true + schema: + type: string + - name: url + in: query + required: true + schema: + type: string + description: The URL to get details for + - name: startDate + in: query + schema: + type: string + format: date + - name: endDate + in: query + schema: + type: string + format: date + - name: category + in: query + schema: + type: string + - name: region + in: query + schema: + type: string + - name: channel + in: query + schema: + type: string + responses: + '200': + description: Detailed URL citation data + content: + application/json: + schema: + $ref: './schemas.yaml#/UrlInspectorUrlDetailsResponse' + '400': + $ref: './responses.yaml#/400' + '401': + $ref: './responses.yaml#/401' + '403': + $ref: './responses.yaml#/403' + security: + - ims_key: [] + +url-inspector-domain-details: + parameters: + - name: spaceCatId + in: path + required: true + description: SpaceCat Organization ID (UUID) + schema: + type: string + format: uuid + - name: brandId + in: path + required: true + description: Brand ID (UUID) or literal "all" for all brands + schema: + type: string + get: + tags: + - llmo + - url-inspector + summary: Single domain detail view + description: | + Returns domain-level aggregate data including URLs under + the domain, weekly trends, and normalized URL paths for agentic/referral filtering. + Filters (category, region, channel) narrow the aggregated results client-side. + The `urlLimit` parameter caps the `urls` array while `totalUrlCount` reflects the + full distinct URL count before the limit. + operationId: getUrlInspectorDomainDetails + parameters: + - name: siteId + in: query + required: true + schema: + type: string + - name: domain + in: query + required: true + schema: + type: string + description: The domain hostname to get details for + - name: startDate + in: query + schema: + type: string + format: date + - name: endDate + in: query + schema: + type: string + format: date + - name: category + in: query + schema: + type: string + - name: region + in: query + schema: + type: string + - name: channel + in: query + schema: + type: string + - name: urlLimit + in: query + schema: + type: integer + default: 200 + description: Max URLs to return for this domain + responses: + '200': + description: Domain detail data with URLs and weekly trends + content: + application/json: + schema: + $ref: './schemas.yaml#/UrlInspectorDomainDetailsResponse' + '400': + $ref: './responses.yaml#/400' + '401': + $ref: './responses.yaml#/401' + '403': + $ref: './responses.yaml#/403' + security: + - ims_key: [] + +url-inspector-filter-options: + parameters: + - name: spaceCatId + in: path + required: true + description: SpaceCat Organization ID (UUID) + schema: + type: string + format: uuid + - name: brandId + in: path + required: true + description: Brand ID (UUID) or literal "all" for all brands + schema: + type: string + get: + tags: + - llmo + - url-inspector + summary: Filter dropdown options + description: | + Returns distinct filter values (regions, categories, channels) + derived from the citation data for populating filter dropdowns. + Queries brand_presence_executions for distinct category and region values, + and brand_presence_sources for distinct content_type (channel) values. + Only siteId, date range, and platform filters are applied — category, region, + and channel filters are not used here since this endpoint provides the + available options for those filters. + operationId: getUrlInspectorFilterOptions + parameters: + - name: siteId + in: query + required: true + schema: + type: string + - name: startDate + in: query + schema: + type: string + format: date + - name: endDate + in: query + schema: + type: string + format: date + - name: platform + in: query + schema: + type: string + responses: + '200': + description: Available filter options + content: + application/json: + schema: + $ref: './schemas.yaml#/UrlInspectorFilterOptionsResponse' + '400': + $ref: './responses.yaml#/400' + '401': + $ref: './responses.yaml#/401' + '403': + $ref: './responses.yaml#/403' + security: + - ims_key: [] diff --git a/src/controllers/llmo/TEMP-LOCAL-DB-README.md b/src/controllers/llmo/TEMP-LOCAL-DB-README.md new file mode 100644 index 000000000..4985872ea --- /dev/null +++ b/src/controllers/llmo/TEMP-LOCAL-DB-README.md @@ -0,0 +1,137 @@ +# TEMPORARY: Local Database Routing for URL Inspector + +> **This file and the related code changes are temporary scaffolding for local +> development. Remove everything once testing is complete.** + +## What This Does + +When `POSTGREST_URL_LOCAL` is set in your `.env`, all URL Inspector endpoints +query your **local** PostgREST/Postgres instead of the remote CloudFront-backed +database. Every other endpoint continues to use the remote database as usual. + +Affected endpoints: + +- `GET /org/:spaceCatId/brands/:brandId/url-inspector/stats` +- `GET /org/:spaceCatId/brands/:brandId/url-inspector/owned-urls` +- `GET /org/:spaceCatId/brands/:brandId/url-inspector/trending-urls` +- `GET /org/:spaceCatId/brands/:brandId/url-inspector/cited-domains` +- `GET /org/:spaceCatId/brands/:brandId/url-inspector/url-details` +- `GET /org/:spaceCatId/brands/:brandId/url-inspector/domain-details` +- `GET /org/:spaceCatId/brands/:brandId/url-inspector/filter-options` + +## How to Use + +### 1. Start the local database + +```bash +cd mysticat-data-service +make setup # starts Postgres + PostgREST and runs migrations + seeds +``` + +Verify PostgREST is up (the Docker container maps internal port 3000 to **host +port 4000**): + +```bash +curl -s -o /dev/null -w "%{http_code}" http://localhost:4000/ +# Should print 200 +``` + +### 2. Configure spacecat-api-service + +Add these two lines to your **`.env`** (they are already there if you set them +up previously — just make sure the port matches): + +```bash +# TEMP: Local DB routing +POSTGREST_URL_LOCAL=http://localhost:4000 +POSTGREST_API_KEY_LOCAL=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoicG9zdGdyZXN0X3dyaXRlciJ9.qEUB9zeY8WHpgyyRRBgs5th4WY98pJfUudtCwImM4H4 +``` + +> **Tip:** To switch back to the remote database without removing the lines, +> just comment them out: +> +> ```bash +> # POSTGREST_URL_LOCAL=http://localhost:4000 +> # POSTGREST_API_KEY_LOCAL=... +> ``` + +### 3. Start the API service + +```bash +cd spacecat-api-service +npm start +``` + +You should see this log line on startup, confirming the local client is active: + +``` +TEMP: Local PostgREST client initialized for URL Inspector +``` + +### 4. Verify it works + +Hit any URL Inspector endpoint. For example: + +```bash +curl 'http://localhost:3002/org//brands/all/url-inspector/stats?siteId=&startDate=2026-02-16&endDate=2026-03-08' \ + -H 'Authorization: Bearer ' +``` + +## Files Modified + +All temporary code is marked with `// TEMP: Local DB routing` comments. + +| File | What changed | +|------|-------------| +| `src/support/data-access.js` | Creates a second `PostgrestClient` when `POSTGREST_URL_LOCAL` is set and attaches it to `context.localPostgrestClient` | +| `src/controllers/llmo/llmo-url-inspector.js` | Prefers `context.localPostgrestClient` over `Site.postgrestService` for the URL Inspector client | +| `.env` *(local only)* | Two new env vars: `POSTGREST_URL_LOCAL`, `POSTGREST_API_KEY_LOCAL` | + +## How to Remove (When Done) + +### 1. Find all temporary code + +```bash +git grep "TEMP: Local DB routing" +``` + +### 2. Remove from `src/support/data-access.js` + +- Delete the `import { PostgrestClient }` line and its TEMP comments +- Delete the entire `const wrappedFn = ...` block (including the `if (env.POSTGREST_URL_LOCAL)` block inside it) +- Change `return dataAccessV3(wrappedFn)(request, context);` back to `return dataAccessV3(fn)(request, context);` + +### 3. Remove from `src/controllers/llmo/llmo-url-inspector.js` + +- Change line 49 from: + ```js + const client = context.localPostgrestClient || Site?.postgrestService; + ``` + back to: + ```js + const client = Site?.postgrestService; + ``` +- Delete the `// TEMP` and `// END TEMP` comment lines around it + +### 4. Remove from `.env` + +Delete these lines: + +```bash +# TEMP: Local DB routing +POSTGREST_URL_LOCAL=http://localhost:4000 +POSTGREST_API_KEY_LOCAL=... +``` + +### 5. Delete this file + +```bash +rm src/controllers/llmo/TEMP-LOCAL-DB-README.md +``` + +### 6. Verify clean removal + +```bash +git grep "TEMP: Local DB routing" # should return nothing +git grep "localPostgrestClient" # should return nothing +``` diff --git a/src/controllers/llmo/llmo-url-inspector-cited-domains.js b/src/controllers/llmo/llmo-url-inspector-cited-domains.js new file mode 100644 index 000000000..85fcfa0df --- /dev/null +++ b/src/controllers/llmo/llmo-url-inspector-cited-domains.js @@ -0,0 +1,78 @@ +/* + * Copyright 2026 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +import { ok, badRequest } from '@adobe/spacecat-shared-http-utils'; +import { + withUrlInspectorAuth, parseUrlInspectorParams, requireSiteId, shouldApplyFilter, +} from './llmo-url-inspector.js'; + +const DEFAULT_LIMIT = 200; + +function buildRpcParams(params) { + return { + p_site_id: params.siteId, + p_start_date: params.startDate || null, + p_end_date: params.endDate || null, + p_category: shouldApplyFilter(params.category) ? params.category : null, + p_region: shouldApplyFilter(params.region) ? params.region : null, + p_channel: shouldApplyFilter(params.channel) ? params.channel : null, + p_platform: shouldApplyFilter(params.platform) ? params.platform : null, + p_brand_id: params.brandId || null, + }; +} + +function mapRow(row) { + return { + domain: row.domain, + totalCitations: Number(row.total_citations), + totalUrls: Number(row.total_urls), + promptsCited: Number(row.prompts_cited), + contentType: row.content_type || 'unknown', + categories: row.categories || '', + regions: row.regions || '', + }; +} + +/** + * GET /org/:spaceCatId/brands/:brandId/url-inspector/cited-domains + * Domain-level citation aggregations. + * @see elmo-ui/docs/api-specs/04-cited-domains-table.md + */ +export function createCitedDomainsHandler(getOrgAndValidateAccess) { + return (context) => withUrlInspectorAuth( + context, + getOrgAndValidateAccess, + 'cited-domains', + async (ctx, client) => { + const params = parseUrlInspectorParams(ctx); + const siteError = requireSiteId(params); + if (siteError) return siteError; + + const rpcParams = buildRpcParams(params); + const { data, error } = await client.rpc('rpc_url_inspector_cited_domains', rpcParams); + + if (error) { + ctx.log.error(`URL Inspector cited-domains RPC error: ${error.message}`); + return badRequest(error.message); + } + + const allRows = (data || []).map(mapRow); + const effectiveLimit = params.limit || DEFAULT_LIMIT; + + return ok({ + totalDomains: allRows.length, + topDomains: allRows.slice(0, effectiveLimit), + allDomains: params.includeAll ? allRows : [], + }); + }, + ); +} diff --git a/src/controllers/llmo/llmo-url-inspector-controller.js b/src/controllers/llmo/llmo-url-inspector-controller.js new file mode 100644 index 000000000..de5fe6095 --- /dev/null +++ b/src/controllers/llmo/llmo-url-inspector-controller.js @@ -0,0 +1,71 @@ +/* + * Copyright 2026 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +import AccessControlUtil from '../../support/access-control-util.js'; +import { createStatsHandler } from './llmo-url-inspector-stats.js'; +import { createOwnedUrlsHandler } from './llmo-url-inspector-owned-urls.js'; +import { createTrendingUrlsHandler } from './llmo-url-inspector-trending-urls.js'; +import { createCitedDomainsHandler } from './llmo-url-inspector-cited-domains.js'; +import { createUrlDetailsHandler } from './llmo-url-inspector-url-details.js'; +import { createDomainDetailsHandler } from './llmo-url-inspector-domain-details.js'; +import { createFilterOptionsHandler } from './llmo-url-inspector-filter-options.js'; + +/** + * Controller for URL Inspector org-scoped endpoints. + * Queries brand_presence citation data via PostgREST. + * + * Route pattern: + * GET /org/:spaceCatId/brands/all/url-inspector/?siteId=... + * GET /org/:spaceCatId/brands/:brandId/url-inspector/?siteId=... + * + * Each handler lives in its own file (llmo-url-inspector-.js) to allow + * parallel implementation without merge conflicts. Shared utilities are in + * llmo-url-inspector.js. + */ +function LlmoUrlInspectorController(ctx) { + const accessControlUtil = AccessControlUtil.fromContext(ctx); + + const getOrgAndValidateAccess = async (context) => { + const { spaceCatId } = context.params; + const { dataAccess } = context; + const { Organization } = dataAccess; + + const organization = await Organization.findById(spaceCatId); + if (!organization) { + throw new Error(`Organization not found: ${spaceCatId}`); + } + if (!await accessControlUtil.hasAccess(organization, '', 'LLMO')) { + throw new Error('Only users belonging to the organization can view URL Inspector data'); + } + return { organization }; + }; + + const getStats = createStatsHandler(getOrgAndValidateAccess); + const getOwnedUrls = createOwnedUrlsHandler(getOrgAndValidateAccess); + const getTrendingUrls = createTrendingUrlsHandler(getOrgAndValidateAccess); + const getCitedDomains = createCitedDomainsHandler(getOrgAndValidateAccess); + const getUrlDetails = createUrlDetailsHandler(getOrgAndValidateAccess); + const getDomainDetails = createDomainDetailsHandler(getOrgAndValidateAccess); + const getFilterOptions = createFilterOptionsHandler(getOrgAndValidateAccess); + + return { + getStats, + getOwnedUrls, + getTrendingUrls, + getCitedDomains, + getUrlDetails, + getDomainDetails, + getFilterOptions, + }; +} + +export default LlmoUrlInspectorController; diff --git a/src/controllers/llmo/llmo-url-inspector-domain-details.js b/src/controllers/llmo/llmo-url-inspector-domain-details.js new file mode 100644 index 000000000..2099b42dc --- /dev/null +++ b/src/controllers/llmo/llmo-url-inspector-domain-details.js @@ -0,0 +1,237 @@ +/* + * Copyright 2026 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +import { badRequest, ok } from '@adobe/spacecat-shared-http-utils'; +import { hasText } from '@adobe/spacecat-shared-utils'; +import { + withUrlInspectorAuth, parseUrlInspectorParams, requireSiteId, shouldApplyFilter, +} from './llmo-url-inspector.js'; + +const QUERY_LIMIT = 50000; +const DEFAULT_URL_LIMIT = 200; +const MS_PER_DAY = 24 * 60 * 60 * 1000; + +/** @internal Exported for testing */ +export function dateToIsoWeek(dateStr) { + const d = new Date(`${dateStr}T00:00:00Z`); + const dayOfWeek = d.getUTCDay() || 7; + d.setUTCDate(d.getUTCDate() + 4 - dayOfWeek); + const yearStart = new Date(Date.UTC(d.getUTCFullYear(), 0, 1)); + const weekNumber = Math.ceil(((d - yearStart) / MS_PER_DAY + 1) / 7); + const year = d.getUTCFullYear(); + return `${year}-W${String(weekNumber).padStart(2, '0')}`; +} + +const BP_SOURCES_SELECT = [ + 'content_type', + 'execution_date', + 'source_urls!inner(url,hostname)', + 'brand_presence_executions!inner(prompt,category_name,region_code,topics)', +].join(','); + +function buildDomainQuery(client, params) { + const { + siteId, domain, startDate, endDate, brandId, + } = params; + + let q = client + .from('brand_presence_sources') + .select(BP_SOURCES_SELECT) + .eq('site_id', siteId) + .eq('source_urls.hostname', domain); + + if (hasText(startDate)) { + q = q.gte('execution_date', startDate); + } + if (hasText(endDate)) { + q = q.lte('execution_date', endDate); + } + if (brandId) { + q = q.eq('brand_presence_executions.brand_id', brandId); + } + + return q.limit(QUERY_LIMIT); +} + +function flattenRow(row) { + const exec = row.brand_presence_executions || {}; + const src = row.source_urls || {}; + return { + url: src.url, + content_type: row.content_type, + prompt: exec.prompt, + citation_count: 1, + category: exec.category_name, + region: exec.region_code, + topics: exec.topics, + week: dateToIsoWeek(row.execution_date), + normalized_url_path: null, + }; +} + +function applyJsFilters(rows, params) { + let filtered = rows; + if (shouldApplyFilter(params.category)) { + filtered = filtered.filter((r) => r.category === params.category); + } + if (shouldApplyFilter(params.region)) { + filtered = filtered.filter((r) => r.region === params.region); + } + if (shouldApplyFilter(params.channel)) { + filtered = filtered.filter((r) => r.content_type === params.channel); + } + return filtered; +} + +function extractUrlPath(url) { + try { + return new URL(url).pathname; + } catch { + return null; + } +} + +/** @internal Exported for testing */ +export function aggregateDomainDetails(rows, params) { + const filtered = applyJsFilters(rows, params); + const urlLimit = params.urlLimit || DEFAULT_URL_LIMIT; + + const totalCitations = filtered.reduce((sum, r) => sum + (r.citation_count || 0), 0); + const allUrls = new Set(filtered.map((r) => r.url).filter(Boolean)); + const totalUrls = allUrls.size; + + const promptKeys = new Set(); + filtered.forEach((r) => { + promptKeys.add(`${r.prompt}|${r.region}|${r.topics}`); + }); + const promptsCited = promptKeys.size; + + const ctCounts = new Map(); + filtered.forEach((r) => { + const ct = r.content_type || 'unknown'; + ctCounts.set(ct, (ctCounts.get(ct) || 0) + 1); + }); + let contentType = 'unknown'; + let maxCtCount = 0; + ctCounts.forEach((count, ct) => { + if (count > maxCtCount) { + maxCtCount = count; + contentType = ct; + } + }); + + const urlMap = new Map(); + filtered.forEach((r) => { + if (!r.url) return; + if (!urlMap.has(r.url)) { + urlMap.set(r.url, { + citations: 0, promptKeys: new Set(), regions: new Set(), categories: new Set(), + }); + } + const entry = urlMap.get(r.url); + entry.citations += r.citation_count || 0; + entry.promptKeys.add(`${r.prompt}|${r.region}|${r.topics}`); + if (r.region) entry.regions.add(r.region); + if (r.category) entry.categories.add(r.category); + }); + + const totalUrlCount = urlMap.size; + + const urls = [...urlMap.entries()] + .map(([url, entry]) => ({ + url, + citations: entry.citations, + promptsCited: entry.promptKeys.size, + regions: [...entry.regions], + categories: [...entry.categories], + })) + .sort((a, b) => b.citations - a.citations) + .slice(0, urlLimit); + + const weekMap = new Map(); + filtered.forEach((r) => { + if (!r.week) return; + if (!weekMap.has(r.week)) { + weekMap.set(r.week, { citations: 0, urls: new Set(), promptKeys: new Set() }); + } + const entry = weekMap.get(r.week); + entry.citations += r.citation_count || 0; + if (r.url) entry.urls.add(r.url); + entry.promptKeys.add(`${r.prompt}|${r.region}|${r.topics}`); + }); + + const sortedWeeks = [...weekMap.entries()].sort(([a], [b]) => a.localeCompare(b)); + const weeklyTrends = { + weeklyDates: sortedWeeks.map(([w]) => w), + totalCitations: sortedWeeks.map(([, e]) => e.citations), + uniqueUrls: sortedWeeks.map(([, e]) => e.urls.size), + promptsCited: sortedWeeks.map(([, e]) => e.promptKeys.size), + citationsPerUrl: sortedWeeks.map(([, e]) => { + const urlCount = e.urls.size; + return urlCount > 0 ? Math.round((e.citations / urlCount) * 10) / 10 : 0; + }), + }; + + const pathSet = new Set(); + filtered.forEach((r) => { + const path = r.normalized_url_path || extractUrlPath(r.url); + if (path) pathSet.add(path); + }); + const urlPaths = [...pathSet].sort(); + + return { + domain: params.domain, + totalCitations, + totalUrls, + promptsCited, + contentType, + urls, + totalUrlCount, + weeklyTrends, + urlPaths, + }; +} + +/** + * GET /org/:spaceCatId/brands/:brandId/url-inspector/domain-details + * Domain-level detail view with URLs, weekly trends, and URL paths. + * @see elmo-ui/docs/api-specs/06-domain-details-dialog.md + */ +export function createDomainDetailsHandler(getOrgAndValidateAccess) { + return (context) => withUrlInspectorAuth( + context, + getOrgAndValidateAccess, + 'domain-details', + async (ctx, client) => { + const params = parseUrlInspectorParams(ctx); + const siteError = requireSiteId(params); + if (siteError) return siteError; + + if (!hasText(params.domain)) { + return badRequest('domain query parameter is required'); + } + + const q = ctx.data || {}; + const urlLimit = q.urlLimit ? Number(q.urlLimit) : (params.limit || DEFAULT_URL_LIMIT); + + const { data, error } = await buildDomainQuery(client, params); + + if (error) { + ctx.log.error(`URL Inspector domain-details PostgREST error: ${error.message}`); + return badRequest(error.message); + } + + const rows = (data || []).map(flattenRow); + return ok(aggregateDomainDetails(rows, { ...params, urlLimit })); + }, + ); +} diff --git a/src/controllers/llmo/llmo-url-inspector-filter-options.js b/src/controllers/llmo/llmo-url-inspector-filter-options.js new file mode 100644 index 000000000..69112727a --- /dev/null +++ b/src/controllers/llmo/llmo-url-inspector-filter-options.js @@ -0,0 +1,136 @@ +/* + * Copyright 2026 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +import { ok, badRequest } from '@adobe/spacecat-shared-http-utils'; +import { + withUrlInspectorAuth, parseUrlInspectorParams, requireSiteId, shouldApplyFilter, +} from './llmo-url-inspector.js'; + +const QUERY_LIMIT = 100000; + +const CONTENT_TYPE_MAP = { + competitor: 'others', +}; + +/** + * Extracts distinct non-empty values from a column, splitting comma-separated + * entries and sorting alphabetically. + * @param {Array} rows - Query result rows + * @param {string} key - Column name to extract + * @returns {string[]} Sorted distinct values + */ +export function extractDistinct(rows, key) { + const values = new Set(); + for (const row of rows) { + const raw = row[key]; + if (raw != null && raw !== '') { + const parts = String(raw).split(','); + for (const part of parts) { + const trimmed = part.trim(); + if (trimmed) values.add(trimmed); + } + } + } + return [...values].sort((a, b) => a.localeCompare(b)); +} + +/** + * Extracts distinct channel values from source rows, mapping DB values + * (e.g. "competitor") to UI values (e.g. "others"). + * @param {Array} rows - Query result rows with content_type + * @returns {string[]} Sorted distinct channel values + */ +export function extractDistinctChannels(rows) { + const values = new Set(); + for (const row of rows) { + const raw = row.content_type; + if (raw != null && raw !== '') { + const mapped = CONTENT_TYPE_MAP[raw] || raw; + values.add(mapped); + } + } + return [...values].sort((a, b) => a.localeCompare(b)); +} + +/** + * GET /org/:spaceCatId/brands/:brandId/url-inspector/filter-options + * Distinct filter values (regions, categories, channels) for dropdown population. + * + * Runs two parallel PostgREST queries: + * 1. brand_presence_executions → category_name, region_code + * 2. brand_presence_sources → content_type + * Both filtered by siteId, date range, and optional platform. + * Results are deduplicated, sorted alphabetically, with nulls/empty excluded. + * + * @see elmo-ui/docs/api-specs/07-filter-options.md + */ +export function createFilterOptionsHandler(getOrgAndValidateAccess) { + return (context) => withUrlInspectorAuth( + context, + getOrgAndValidateAccess, + 'filter-options', + async (ctx, client) => { + const params = parseUrlInspectorParams(ctx); + const siteError = requireSiteId(params); + if (siteError) return siteError; + + let execQuery = client + .from('brand_presence_executions') + .select('category_name, region_code') + .eq('site_id', params.siteId); + + let srcQuery = client + .from('brand_presence_sources') + .select('content_type') + .eq('site_id', params.siteId); + + if (params.startDate) { + execQuery = execQuery.gte('execution_date', params.startDate); + srcQuery = srcQuery.gte('execution_date', params.startDate); + } + if (params.endDate) { + execQuery = execQuery.lte('execution_date', params.endDate); + srcQuery = srcQuery.lte('execution_date', params.endDate); + } + if (shouldApplyFilter(params.platform)) { + execQuery = execQuery.eq('model', params.platform); + srcQuery = srcQuery.eq('model', params.platform); + } + if (params.brandId) { + execQuery = execQuery.eq('brand_id', params.brandId); + } + + const [execResult, srcResult] = await Promise.all([ + execQuery.limit(QUERY_LIMIT), + srcQuery.limit(QUERY_LIMIT), + ]); + + if (execResult.error) { + ctx.log.error(`URL Inspector filter-options executions query error: ${execResult.error.message}`); + return badRequest(execResult.error.message); + } + if (srcResult.error) { + ctx.log.error(`URL Inspector filter-options sources query error: ${srcResult.error.message}`); + return badRequest(srcResult.error.message); + } + + const execRows = execResult.data || []; + const srcRows = srcResult.data || []; + + return ok({ + regions: extractDistinct(execRows, 'region_code'), + categories: extractDistinct(execRows, 'category_name'), + channels: extractDistinctChannels(srcRows), + }); + }, + ); +} diff --git a/src/controllers/llmo/llmo-url-inspector-owned-urls.js b/src/controllers/llmo/llmo-url-inspector-owned-urls.js new file mode 100644 index 000000000..6b99096ae --- /dev/null +++ b/src/controllers/llmo/llmo-url-inspector-owned-urls.js @@ -0,0 +1,102 @@ +/* + * Copyright 2026 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +import { ok, badRequest } from '@adobe/spacecat-shared-http-utils'; + +import { + withUrlInspectorAuth, parseUrlInspectorParams, requireSiteId, shouldApplyFilter, +} from './llmo-url-inspector.js'; + +const DEFAULT_LIMIT = 50; + +/** + * Computes a WoW trend from a sorted array of { week, value } objects. + * Compares the last two entries to determine direction. + * @param {Array<{week: string, value: number}>} weeklyValues + * @returns {{ direction: string, hasValidComparison: boolean, weeklyValues: Array }} + */ +export function computeTrend(weeklyValues) { + const sorted = [...(weeklyValues || [])].sort((a, b) => a.week.localeCompare(b.week)); + + if (sorted.length < 2) { + return { direction: 'neutral', hasValidComparison: false, weeklyValues: sorted }; + } + + const prev = sorted[sorted.length - 2].value; + const latest = sorted[sorted.length - 1].value; + + let direction = 'neutral'; + if (latest > prev) direction = 'up'; + else if (latest < prev) direction = 'down'; + + return { direction, hasValidComparison: true, weeklyValues: sorted }; +} + +/** + * GET /org/:spaceCatId/brands/:brandId/url-inspector/owned-urls + * Owned URL citation data with per-URL WoW trend indicators. + * Supports server-side pagination via ?limit=N&offset=N query params. + * @see elmo-ui/docs/api-specs/02-owned-urls-table.md + */ +export function createOwnedUrlsHandler(getOrgAndValidateAccess) { + return (context) => withUrlInspectorAuth( + context, + getOrgAndValidateAccess, + 'owned-urls', + async (ctx, client) => { + const params = parseUrlInspectorParams(ctx); + const siteError = requireSiteId(params); + if (siteError) return siteError; + + const limit = params.limit || DEFAULT_LIMIT; + const offset = params.offset || 0; + + const rpcParams = { + p_site_id: params.siteId, + p_start_date: params.startDate || null, + p_end_date: params.endDate || null, + p_category: shouldApplyFilter(params.category) ? params.category : null, + p_region: shouldApplyFilter(params.region) ? params.region : null, + p_platform: shouldApplyFilter(params.platform) ? params.platform : null, + p_brand_id: params.brandId || null, + p_limit: limit, + p_offset: offset, + }; + + const { data, error } = await client.rpc('rpc_url_inspector_owned_urls', rpcParams); + + if (error) { + ctx.log.error(`URL Inspector owned-urls RPC error: ${error.message}`); + return badRequest(error.message); + } + + const rows = data || []; + const total = rows.length > 0 ? Number(rows[0].total_count) : 0; + + const urls = rows.map((row) => ({ + url: row.url, + citations: Number(row.citations), + promptsCited: Number(row.prompts_cited), + products: row.products || [], + regions: row.regions || [], + contentType: 'owned', + citationsTrend: computeTrend(row.weekly_citations), + promptsCitedTrend: computeTrend(row.weekly_prompts_cited), + })); + + return ok({ + urls, + pagination: { limit, offset, total }, + }); + }, + ); +} diff --git a/src/controllers/llmo/llmo-url-inspector-stats.js b/src/controllers/llmo/llmo-url-inspector-stats.js new file mode 100644 index 000000000..8b1625e0b --- /dev/null +++ b/src/controllers/llmo/llmo-url-inspector-stats.js @@ -0,0 +1,83 @@ +/* + * Copyright 2026 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +import { ok, badRequest } from '@adobe/spacecat-shared-http-utils'; +import { + withUrlInspectorAuth, parseUrlInspectorParams, shouldApplyFilter, requireSiteId, +} from './llmo-url-inspector.js'; + +function buildRpcParams(params) { + return { + p_site_id: params.siteId, + p_start_date: params.startDate || null, + p_end_date: params.endDate || null, + p_category: shouldApplyFilter(params.category) ? params.category : null, + p_region: shouldApplyFilter(params.region) ? params.region : null, + p_platform: shouldApplyFilter(params.platform) ? params.platform : null, + p_brand_id: params.brandId || null, + }; +} + +function formatRow(row) { + return { + totalPromptsCited: Number(row.total_prompts_cited ?? 0), + totalPrompts: Number(row.total_prompts ?? 0), + uniqueUrls: Number(row.unique_urls ?? 0), + totalCitations: Number(row.total_citations ?? 0), + }; +} + +/** + * GET /org/:spaceCatId/brands/:brandId/url-inspector/stats + * Aggregate citation statistics and weekly sparkline trends. + * Calls rpc_url_inspector_stats which returns an aggregate row (week IS NULL) + * followed by per-week rows ordered chronologically. + * @see elmo-ui/docs/api-specs/01-stats-cards.md + */ +export function createStatsHandler(getOrgAndValidateAccess) { + return (context) => withUrlInspectorAuth( + context, + getOrgAndValidateAccess, + 'stats', + async (ctx, client) => { + const params = parseUrlInspectorParams(ctx); + const siteError = requireSiteId(params); + if (siteError) return siteError; + + const rpcParams = buildRpcParams(params); + const { data, error } = await client.rpc('rpc_url_inspector_stats', rpcParams); + + if (error) { + ctx.log.error(`URL Inspector stats RPC error: ${error.message}`); + return badRequest(error.message); + } + + const rows = data || []; + const aggRow = rows.find((r) => r.week === null); + const weeklyRows = rows.filter((r) => r.week !== null); + + const agg = aggRow ? formatRow(aggRow) : formatRow({}); + + const weeklyTrends = weeklyRows.map((row) => ({ + week: row.week, + weekNumber: Number(row.week_number ?? 0), + year: Number(row.year_val ?? 0), + ...formatRow(row), + })); + + return ok({ + ...agg, + weeklyTrends, + }); + }, + ); +} diff --git a/src/controllers/llmo/llmo-url-inspector-trending-urls.js b/src/controllers/llmo/llmo-url-inspector-trending-urls.js new file mode 100644 index 000000000..02e6cf5dd --- /dev/null +++ b/src/controllers/llmo/llmo-url-inspector-trending-urls.js @@ -0,0 +1,144 @@ +/* + * Copyright 2026 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +import { ok, badRequest } from '@adobe/spacecat-shared-http-utils'; + +import { + withUrlInspectorAuth, parseUrlInspectorParams, requireSiteId, shouldApplyFilter, +} from './llmo-url-inspector.js'; + +const DEFAULT_LIMIT = 50; + +const CONTENT_TYPE_MAP = { + competitor: 'others', +}; + +const CHANNEL_TO_DB = { + others: 'competitor', +}; + +function mapContentType(dbValue) { + return CONTENT_TYPE_MAP[dbValue] || dbValue; +} + +/** + * Transforms flat prompt-level RPC rows into the nested trending-URLs response. + * Each row has: total_non_owned_urls, url, content_type, prompt, category, region, + * topics, citation_count, execution_count. + */ +export function assembleResponse(rows) { + if (!rows || rows.length === 0) { + return { totalNonOwnedUrls: 0, urls: [] }; + } + + const totalNonOwnedUrls = Number(rows[0].total_non_owned_urls) || 0; + + const urlMap = new Map(); + + for (const row of rows) { + const { url, content_type: ct } = row; + if (!urlMap.has(url)) { + urlMap.set(url, { + url, + contentType: mapContentType(ct), + citations: 0, + promptsSet: new Set(), + productsSet: new Set(), + regionsSet: new Set(), + promptCitations: [], + }); + } + + const entry = urlMap.get(url); + const count = Number(row.citation_count) || 0; + entry.citations += count; + + const promptKey = `${row.prompt}|${row.region}|${row.topics}`; + entry.promptsSet.add(promptKey); + + if (row.category) entry.productsSet.add(row.category); + if (row.region) entry.regionsSet.add(row.region); + + entry.promptCitations.push({ + prompt: row.prompt, + count, + id: `${row.category || ''}_${row.prompt || ''}_${row.region || ''}`, + products: row.category ? [row.category] : [], + topics: row.topics || '', + region: row.region || '', + executionCount: Number(row.execution_count) || 0, + }); + } + + const urls = Array.from(urlMap.values()) + .map(({ + promptsSet, productsSet, regionsSet, ...rest + }) => ({ + ...rest, + promptsCited: promptsSet.size, + products: Array.from(productsSet), + regions: Array.from(regionsSet), + })) + .sort((a, b) => b.citations - a.citations); + + return { totalNonOwnedUrls, urls }; +} + +/** + * GET /org/:spaceCatId/brands/:brandId/url-inspector/trending-urls + * Non-owned URL citations sorted by citation count. + * @see elmo-ui/docs/api-specs/03-trending-urls-table.md + */ +export function createTrendingUrlsHandler(getOrgAndValidateAccess) { + return (context) => withUrlInspectorAuth( + context, + getOrgAndValidateAccess, + 'trending-urls', + async (ctx, client) => { + const params = parseUrlInspectorParams(ctx); + const siteError = requireSiteId(params); + if (siteError) return siteError; + + const limit = params.limit || DEFAULT_LIMIT; + const offset = params.offset || 0; + + const rpcParams = { + p_site_id: params.siteId, + p_start_date: params.startDate, + p_end_date: params.endDate, + p_category: shouldApplyFilter(params.category) ? params.category : null, + p_region: shouldApplyFilter(params.region) ? params.region : null, + p_channel: shouldApplyFilter(params.channel) + ? (CHANNEL_TO_DB[params.channel] || params.channel) + : null, + p_platform: shouldApplyFilter(params.platform) ? params.platform : null, + p_limit: limit, + p_brand_id: params.brandId || null, + p_offset: offset, + }; + + const { data, error } = await client.rpc('rpc_url_inspector_trending_urls', rpcParams); + + if (error) { + ctx.log.error(`URL Inspector trending-urls RPC error: ${error.message}`); + return badRequest(error.message); + } + + const { totalNonOwnedUrls, urls } = assembleResponse(data); + return ok({ + totalNonOwnedUrls, + urls, + pagination: { limit, offset, total: totalNonOwnedUrls }, + }); + }, + ); +} diff --git a/src/controllers/llmo/llmo-url-inspector-url-details.js b/src/controllers/llmo/llmo-url-inspector-url-details.js new file mode 100644 index 000000000..9fbaf586a --- /dev/null +++ b/src/controllers/llmo/llmo-url-inspector-url-details.js @@ -0,0 +1,216 @@ +/* + * Copyright 2026 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +import { badRequest, ok } from '@adobe/spacecat-shared-http-utils'; +import { hasText } from '@adobe/spacecat-shared-utils'; +import { + withUrlInspectorAuth, parseUrlInspectorParams, requireSiteId, shouldApplyFilter, +} from './llmo-url-inspector.js'; + +const QUERY_LIMIT = 50000; +const MS_PER_DAY = 24 * 60 * 60 * 1000; + +/** @internal Exported for testing */ +export function dateToIsoWeek(dateStr) { + const d = new Date(`${dateStr}T00:00:00Z`); + const dayOfWeek = d.getUTCDay() || 7; + d.setUTCDate(d.getUTCDate() + 4 - dayOfWeek); + const yearStart = new Date(Date.UTC(d.getUTCFullYear(), 0, 1)); + const weekNumber = Math.ceil(((d - yearStart) / MS_PER_DAY + 1) / 7); + const year = d.getUTCFullYear(); + return `${year}-W${String(weekNumber).padStart(2, '0')}`; +} + +/** @internal Exported for testing */ +export function parseIsoWeek(weekStr) { + const match = /^(\d{4})-W(\d{2})$/.exec(weekStr); + if (!match) return { weekNumber: 0, year: 0 }; + return { + year: Number.parseInt(match[1], 10), + weekNumber: Number.parseInt(match[2], 10), + }; +} + +const BP_SOURCES_SELECT = [ + 'content_type', + 'execution_date', + 'source_urls!inner(url,hostname)', + 'brand_presence_executions!inner(prompt,category_name,region_code,topics)', +].join(','); + +function buildUrlDetailsQuery(client, params) { + const { + siteId, url, startDate, endDate, brandId, + } = params; + + let q = client + .from('brand_presence_sources') + .select(BP_SOURCES_SELECT) + .eq('site_id', siteId) + .eq('source_urls.url', url); + + if (hasText(startDate)) { + q = q.gte('execution_date', startDate); + } + if (hasText(endDate)) { + q = q.lte('execution_date', endDate); + } + if (brandId) { + q = q.eq('brand_presence_executions.brand_id', brandId); + } + + return q.limit(QUERY_LIMIT); +} + +function flattenRow(row) { + const exec = row.brand_presence_executions || {}; + return { + content_type: row.content_type, + prompt: exec.prompt, + citation_count: 1, + category: exec.category_name, + region: exec.region_code, + topics: exec.topics, + week: dateToIsoWeek(row.execution_date), + }; +} + +function applyJsFilters(rows, params) { + let filtered = rows; + if (shouldApplyFilter(params.category)) { + filtered = filtered.filter((r) => r.category === params.category); + } + if (shouldApplyFilter(params.region)) { + filtered = filtered.filter((r) => r.region === params.region); + } + if (shouldApplyFilter(params.channel)) { + filtered = filtered.filter((r) => r.content_type === params.channel); + } + return filtered; +} + +/** @internal Exported for testing */ +export function aggregateUrlDetails(rows, params) { + const isOwned = rows.some((r) => r.content_type === 'owned'); + const filtered = applyJsFilters(rows, params); + + const totalCitations = filtered.reduce((sum, r) => sum + (r.citation_count || 0), 0); + + const promptKeys = new Set(); + filtered.forEach((r) => { + promptKeys.add(`${r.prompt}|${r.region}|${r.topics}`); + }); + const promptsCited = promptKeys.size; + + const products = [...new Set(filtered.map((r) => r.category).filter(Boolean))]; + const regions = [...new Set(filtered.map((r) => r.region).filter(Boolean))]; + + // Prompt citations: group by (prompt, category, region, topics) + const promptMap = new Map(); + filtered.forEach((r) => { + const key = `${r.prompt}|${r.category}|${r.region}|${r.topics}`; + if (!promptMap.has(key)) { + promptMap.set(key, { + prompt: r.prompt, + category: r.category, + region: r.region, + topics: r.topics, + count: 0, + weeks: new Set(), + }); + } + const entry = promptMap.get(key); + entry.count += r.citation_count || 0; + if (r.week) entry.weeks.add(r.week); + }); + + const promptCitations = [...promptMap.values()] + .map((e) => ({ + prompt: e.prompt, + count: e.count, + id: `${e.topics}_${e.prompt}_${e.region}`, + products: [e.category].filter(Boolean), + topics: e.topics || '', + region: e.region || '', + executionCount: e.weeks.size, + })) + .sort((a, b) => b.count - a.count); + + // Weekly trends: group by week + const weekMap = new Map(); + filtered.forEach((r) => { + if (!r.week) return; + if (!weekMap.has(r.week)) { + weekMap.set(r.week, { citations: 0, promptKeys: new Set() }); + } + const entry = weekMap.get(r.week); + entry.citations += r.citation_count || 0; + entry.promptKeys.add(`${r.prompt}|${r.region}|${r.topics}`); + }); + + const weeklyTrends = [...weekMap.entries()] + .map(([week, entry]) => { + const { year, weekNumber } = parseIsoWeek(week); + return { + week, + weekNumber, + year, + totalCitations: entry.citations, + totalPromptsCited: entry.promptKeys.size, + uniqueUrls: 1, + }; + }) + .sort((a, b) => a.week.localeCompare(b.week)); + + return { + url: params.url, + isOwned, + totalCitations, + promptsCited, + products, + regions, + promptCitations, + weeklyTrends, + }; +} + +/** + * GET /org/:spaceCatId/brands/:brandId/url-inspector/url-details + * Detailed citation data for a single URL. + * @see elmo-ui/docs/api-specs/05-url-details-dialog.md + */ +export function createUrlDetailsHandler(getOrgAndValidateAccess) { + return (context) => withUrlInspectorAuth( + context, + getOrgAndValidateAccess, + 'url-details', + async (ctx, client) => { + const params = parseUrlInspectorParams(ctx); + const siteError = requireSiteId(params); + if (siteError) return siteError; + + if (!hasText(params.url)) { + return badRequest('url query parameter is required'); + } + + const { data, error } = await buildUrlDetailsQuery(client, params); + + if (error) { + ctx.log.error(`URL Inspector url-details PostgREST error: ${error.message}`); + return badRequest(error.message); + } + + const rows = (data || []).map(flattenRow); + return ok(aggregateUrlDetails(rows, params)); + }, + ); +} diff --git a/src/controllers/llmo/llmo-url-inspector.js b/src/controllers/llmo/llmo-url-inspector.js new file mode 100644 index 000000000..8e5cf958c --- /dev/null +++ b/src/controllers/llmo/llmo-url-inspector.js @@ -0,0 +1,118 @@ +/* + * Copyright 2026 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +import { + badRequest, forbidden, internalServerError, +} from '@adobe/spacecat-shared-http-utils'; +import { hasText } from '@adobe/spacecat-shared-utils'; + +/** + * URL Inspector — shared utilities for org+brand scoped PostgREST handlers. + * Route pattern: + * /org/:spaceCatId/brands/all/url-inspector/?siteId=... + * /org/:spaceCatId/brands/:brandId/url-inspector/?siteId=... + * + * Individual handler factories live in their own files: + * llmo-url-inspector-stats.js, llmo-url-inspector-owned-urls.js, etc. + * + * All handlers import the helpers below. + */ + +const SKIP_VALUES = new Set(['all', '', undefined, null, '*']); + +const ERR_ORG_ACCESS = 'belonging to the organization'; +const ERR_NOT_FOUND = 'not found'; + +/** + * Shared wrapper: PostgREST availability check + org access validation. + * @param {Object} context - Request context + * @param {Function} getOrgAndValidateAccess - Async (context) => { organization } + * @param {string} handlerName - For error logging + * @param {Function} handlerFn - Async (context, client) => Response + * @returns {Promise} + */ +// eslint-disable-next-line max-len +export async function withUrlInspectorAuth(context, getOrgAndValidateAccess, handlerName, handlerFn) { + const { log, dataAccess } = context; + const { Site } = dataAccess; + + // TEMP: Local DB routing - Use local PostgREST if available for testing + const client = context.localPostgrestClient || Site?.postgrestService; + // END TEMP + + if (!client) { + log.error('URL Inspector APIs require PostgREST (DATA_SERVICE_PROVIDER=postgres)'); + return badRequest('URL Inspector data is not available. PostgreSQL data service is required.'); + } + + try { + await getOrgAndValidateAccess(context); + } catch (error) { + if (error.message?.includes(ERR_ORG_ACCESS)) { + return forbidden('Only users belonging to the organization can view URL Inspector data'); + } + if (error.message?.includes(ERR_NOT_FOUND)) { + return badRequest(error.message); + } + log.error(`URL Inspector ${handlerName} error: ${error.message}`); + return badRequest(error.message); + } + + try { + return await handlerFn(context, client); + } catch (error) { + log.error(`URL Inspector ${handlerName} unexpected error: ${error.message}`); + return internalServerError(`URL Inspector ${handlerName} failed`); + } +} + +/** Returns true if the value should be used as a PostgREST filter. */ +export function shouldApplyFilter(value) { + if (value == null) return false; + if (typeof value === 'string' && SKIP_VALUES.has(value.trim())) return false; + return hasText(String(value)); +} + +/** + * Parses the common URL Inspector parameters. + * brandId comes from context.params (path); everything else from context.data (query). + * Supports both camelCase (frontend) and snake_case (PostgREST convention). + */ +export function parseUrlInspectorParams(context) { + const q = context.data || {}; + const { brandId } = context.params || {}; + return { + brandId: brandId && brandId !== 'all' ? brandId : null, + siteId: q.siteId || q.site_id, + startDate: q.startDate || q.start_date, + endDate: q.endDate || q.end_date, + category: q.category, + region: q.region, + channel: q.channel || q.content_type, + platform: q.platform || q.model, + limit: q.limit ? Number(q.limit) : undefined, + offset: q.offset ? Number(q.offset) : undefined, + url: q.url, + domain: q.domain, + includeAll: q.includeAll === 'true' || q.includeAll === true, + }; +} + +/** + * Validates that siteId is present. Returns a badRequest response if missing, null otherwise. + */ +export function requireSiteId(params) { + if (!hasText(params.siteId)) { + return badRequest('siteId query parameter is required'); + } + return null; +} diff --git a/src/index.js b/src/index.js index fc74f0199..81df46802 100644 --- a/src/index.js +++ b/src/index.js @@ -73,6 +73,7 @@ import ScrapeJobController from './controllers/scrapeJob.js'; import ReportsController from './controllers/reports.js'; import LlmoController from './controllers/llmo/llmo.js'; import LlmoMysticatController from './controllers/llmo/llmo-mysticat-controller.js'; +import LlmoUrlInspectorController from './controllers/llmo/llmo-url-inspector-controller.js'; import PlgOnboardingController from './controllers/plg/plg-onboarding.js'; import UserActivitiesController from './controllers/user-activities.js'; import SiteEnrollmentsController from './controllers/site-enrollments.js'; @@ -202,6 +203,7 @@ async function run(request, context) { const reportsController = ReportsController(context, log, context.env); const llmoController = LlmoController(context); const llmoMysticatController = LlmoMysticatController(context); + const llmoUrlInspectorController = LlmoUrlInspectorController(context); const fixesController = new FixesController(context); const userActivitiesController = UserActivitiesController(context); const siteEnrollmentsController = SiteEnrollmentsController(context); @@ -246,6 +248,7 @@ async function run(request, context) { fixesController, llmoController, llmoMysticatController, + llmoUrlInspectorController, userActivitiesController, siteEnrollmentsController, trialUsersController, diff --git a/src/routes/index.js b/src/routes/index.js index 2203d8b0d..06387494b 100644 --- a/src/routes/index.js +++ b/src/routes/index.js @@ -74,6 +74,7 @@ function isStaticRoute(routePattern) { * @param {FixesController} fixesController - The fixes controller. * @param {Object} llmoController - The LLMO controller. * @param {Object} llmoMysticatController - The LLMO Mysticat controller (brand presence APIs). + * @param {Object} llmoUrlInspectorController - The LLMO URL Inspector controller (citation APIs). * @param {Object} userActivityController - The user activity controller. * @param {Object} siteEnrollmentController - The site enrollment controller. * @param {Object} trialUserController - The trial user controller. @@ -119,6 +120,7 @@ export default function getRouteHandlers( fixesController, llmoController, llmoMysticatController, + llmoUrlInspectorController, userActivityController, siteEnrollmentController, trialUserController, @@ -409,6 +411,24 @@ export default function getRouteHandlers( 'GET /org/:spaceCatId/brands/all/brand-presence/stats': llmoMysticatController.getBrandPresenceStats, 'GET /org/:spaceCatId/brands/:brandId/brand-presence/stats': llmoMysticatController.getBrandPresenceStats, + // URL Inspector (PostgREST/brand_presence citation data) + // spaceCatId = organization_id. brandId = 'all' for all brands, or UUID for single brand. + // siteId passed as query parameter. + 'GET /org/:spaceCatId/brands/all/url-inspector/stats': llmoUrlInspectorController.getStats, + 'GET /org/:spaceCatId/brands/:brandId/url-inspector/stats': llmoUrlInspectorController.getStats, + 'GET /org/:spaceCatId/brands/all/url-inspector/owned-urls': llmoUrlInspectorController.getOwnedUrls, + 'GET /org/:spaceCatId/brands/:brandId/url-inspector/owned-urls': llmoUrlInspectorController.getOwnedUrls, + 'GET /org/:spaceCatId/brands/all/url-inspector/trending-urls': llmoUrlInspectorController.getTrendingUrls, + 'GET /org/:spaceCatId/brands/:brandId/url-inspector/trending-urls': llmoUrlInspectorController.getTrendingUrls, + 'GET /org/:spaceCatId/brands/all/url-inspector/cited-domains': llmoUrlInspectorController.getCitedDomains, + 'GET /org/:spaceCatId/brands/:brandId/url-inspector/cited-domains': llmoUrlInspectorController.getCitedDomains, + 'GET /org/:spaceCatId/brands/all/url-inspector/url-details': llmoUrlInspectorController.getUrlDetails, + 'GET /org/:spaceCatId/brands/:brandId/url-inspector/url-details': llmoUrlInspectorController.getUrlDetails, + 'GET /org/:spaceCatId/brands/all/url-inspector/domain-details': llmoUrlInspectorController.getDomainDetails, + 'GET /org/:spaceCatId/brands/:brandId/url-inspector/domain-details': llmoUrlInspectorController.getDomainDetails, + 'GET /org/:spaceCatId/brands/all/url-inspector/filter-options': llmoUrlInspectorController.getFilterOptions, + 'GET /org/:spaceCatId/brands/:brandId/url-inspector/filter-options': llmoUrlInspectorController.getFilterOptions, + // PLG Routes 'POST /plg/onboard': plgOnboardingController.onboard, 'GET /plg/onboard/status/:imsOrgId': plgOnboardingController.getStatus, diff --git a/src/routes/required-capabilities.js b/src/routes/required-capabilities.js index ae07f4673..0b013f39e 100644 --- a/src/routes/required-capabilities.js +++ b/src/routes/required-capabilities.js @@ -51,6 +51,22 @@ export const INTERNAL_ROUTES = [ 'GET /org/:spaceCatId/brands/all/brand-presence/stats', 'GET /org/:spaceCatId/brands/:brandId/brand-presence/stats', + // URL Inspector - org-scoped, LLMO product; not yet required by S2S consumers + 'GET /org/:spaceCatId/brands/all/url-inspector/stats', + 'GET /org/:spaceCatId/brands/:brandId/url-inspector/stats', + 'GET /org/:spaceCatId/brands/all/url-inspector/owned-urls', + 'GET /org/:spaceCatId/brands/:brandId/url-inspector/owned-urls', + 'GET /org/:spaceCatId/brands/all/url-inspector/trending-urls', + 'GET /org/:spaceCatId/brands/:brandId/url-inspector/trending-urls', + 'GET /org/:spaceCatId/brands/all/url-inspector/cited-domains', + 'GET /org/:spaceCatId/brands/:brandId/url-inspector/cited-domains', + 'GET /org/:spaceCatId/brands/all/url-inspector/url-details', + 'GET /org/:spaceCatId/brands/:brandId/url-inspector/url-details', + 'GET /org/:spaceCatId/brands/all/url-inspector/domain-details', + 'GET /org/:spaceCatId/brands/:brandId/url-inspector/domain-details', + 'GET /org/:spaceCatId/brands/all/url-inspector/filter-options', + 'GET /org/:spaceCatId/brands/:brandId/url-inspector/filter-options', + // LLMO operations not exposed to S2S - onboard, offboard, edge config, brand claims, etc. 'GET /sites/:siteId/llmo/brand-claims', 'POST /llmo/onboard', diff --git a/src/support/data-access.js b/src/support/data-access.js index 145099c02..d00811d8a 100644 --- a/src/support/data-access.js +++ b/src/support/data-access.js @@ -12,6 +12,10 @@ import dataAccessV2 from '@adobe/spacecat-shared-data-access-v2'; import dataAccessV3 from '@adobe/spacecat-shared-data-access'; +// TEMP: Local DB routing - import for creating a second PostgREST client +// eslint-disable-next-line import/no-extraneous-dependencies +import { PostgrestClient } from '@supabase/postgrest-js'; +// END TEMP /** * Data access middleware wrapper that selects between v2 (DynamoDB) and v3 (Postgres) @@ -31,7 +35,26 @@ export default function dataAccess(fn) { 'DATA_SERVICE_PROVIDER is set to "postgres" but POSTGREST_URL is not configured', ); } - return dataAccessV3(fn)(request, context); + + // TEMP: Local DB routing - Wrap fn to inject a local PostgREST client for URL Inspector + const wrappedFn = async (req, ctx) => { + if (env.POSTGREST_URL_LOCAL) { + ctx.localPostgrestClient = new PostgrestClient(env.POSTGREST_URL_LOCAL, { + schema: env.POSTGREST_SCHEMA || 'public', + headers: { + ...(env.POSTGREST_API_KEY_LOCAL ? { + apikey: env.POSTGREST_API_KEY_LOCAL, + Authorization: `Bearer ${env.POSTGREST_API_KEY_LOCAL}`, + } : {}), + }, + }); + ctx.log.info('TEMP: Local PostgREST client initialized for URL Inspector'); + } + return fn(req, ctx); + }; + // END TEMP + + return dataAccessV3(wrappedFn)(request, context); } return dataAccessV2(fn)(request, context); }; diff --git a/test/controllers/llmo/llmo-url-inspector-cited-domains.test.js b/test/controllers/llmo/llmo-url-inspector-cited-domains.test.js new file mode 100644 index 000000000..12e6174dd --- /dev/null +++ b/test/controllers/llmo/llmo-url-inspector-cited-domains.test.js @@ -0,0 +1,377 @@ +/* + * Copyright 2026 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +import { expect, use } from 'chai'; +import sinon from 'sinon'; +import sinonChai from 'sinon-chai'; +import { + createCitedDomainsHandler, +} from '../../../src/controllers/llmo/llmo-url-inspector-cited-domains.js'; + +use(sinonChai); + +function createRpcMock(resolveValue = { data: [], error: null }) { + return { rpc: sinon.stub().resolves(resolveValue) }; +} + +// eslint-disable-next-line max-params +function makeDomainRow( + domain, + totalCitations, + totalUrls, + promptsCited, + contentType, + categories, + regions, +) { + return { + domain, + total_citations: totalCitations, + total_urls: totalUrls, + prompts_cited: promptsCited, + content_type: contentType, + categories, + regions, + }; +} + +describe('llmo-url-inspector-cited-domains', () => { + let sandbox; + let getOrgAndValidateAccess; + let mockContext; + + beforeEach(() => { + sandbox = sinon.createSandbox(); + getOrgAndValidateAccess = sandbox.stub().resolves({ organization: {} }); + mockContext = { + params: { spaceCatId: '0178a3f0-1234-7000-8000-000000000001', brandId: 'all' }, + data: { siteId: 'site-001' }, + log: { info: sandbox.stub(), error: sandbox.stub(), warn: sandbox.stub() }, + dataAccess: { Site: { postgrestService: null } }, + }; + }); + + afterEach(() => { + sandbox.restore(); + }); + + it('returns badRequest when postgrestService is missing', async () => { + mockContext.dataAccess.Site.postgrestService = null; + const handler = createCitedDomainsHandler(getOrgAndValidateAccess); + const result = await handler(mockContext); + + expect(result.status).to.equal(400); + expect(getOrgAndValidateAccess).not.to.have.been.called; + }); + + it('returns forbidden when user has no org access', async () => { + const mock = createRpcMock(); + mockContext.dataAccess.Site.postgrestService = mock; + getOrgAndValidateAccess.rejects( + new Error('Only users belonging to the organization can view URL Inspector data'), + ); + + const handler = createCitedDomainsHandler(getOrgAndValidateAccess); + const result = await handler(mockContext); + + expect(result.status).to.equal(403); + }); + + it('returns badRequest when organization not found', async () => { + const mock = createRpcMock(); + mockContext.dataAccess.Site.postgrestService = mock; + getOrgAndValidateAccess.rejects(new Error('Organization not found: x')); + + const handler = createCitedDomainsHandler(getOrgAndValidateAccess); + const result = await handler(mockContext); + + expect(result.status).to.equal(400); + }); + + it('returns badRequest when siteId is missing', async () => { + const mock = createRpcMock(); + mockContext.dataAccess.Site.postgrestService = mock; + mockContext.data = {}; + + const handler = createCitedDomainsHandler(getOrgAndValidateAccess); + const result = await handler(mockContext); + + expect(result.status).to.equal(400); + const body = await result.json(); + expect(body.message).to.include('siteId'); + }); + + it('returns badRequest when RPC returns error', async () => { + const mock = createRpcMock({ data: null, error: { message: 'relation does not exist' } }); + mockContext.dataAccess.Site.postgrestService = mock; + + const handler = createCitedDomainsHandler(getOrgAndValidateAccess); + const result = await handler(mockContext); + + expect(result.status).to.equal(400); + const body = await result.json(); + expect(body.message).to.equal('relation does not exist'); + }); + + it('returns ok with domain aggregations on happy path', async () => { + const rows = [ + makeDomainRow('competitor.com', 456, 23, 89, 'competitor', 'Software,Security', 'US,UK'), + makeDomainRow('news.org', 120, 10, 45, 'earned', 'AI Tools', 'DE'), + ]; + const mock = createRpcMock({ data: rows, error: null }); + mockContext.dataAccess.Site.postgrestService = mock; + + const handler = createCitedDomainsHandler(getOrgAndValidateAccess); + const result = await handler(mockContext); + + expect(result.status).to.equal(200); + const body = await result.json(); + + expect(body.totalDomains).to.equal(2); + expect(body.topDomains).to.have.lengthOf(2); + expect(body.allDomains).to.deep.equal([]); + + expect(body.topDomains[0]).to.deep.equal({ + domain: 'competitor.com', + totalCitations: 456, + totalUrls: 23, + promptsCited: 89, + contentType: 'competitor', + categories: 'Software,Security', + regions: 'US,UK', + }); + expect(body.topDomains[1]).to.deep.equal({ + domain: 'news.org', + totalCitations: 120, + totalUrls: 10, + promptsCited: 45, + contentType: 'earned', + categories: 'AI Tools', + regions: 'DE', + }); + + expect(mock.rpc).to.have.been.calledOnceWith( + 'rpc_url_inspector_cited_domains', + sinon.match({ + p_site_id: 'site-001', + }), + ); + }); + + it('passes filter params to RPC when provided', async () => { + const mock = createRpcMock({ data: [], error: null }); + mockContext.dataAccess.Site.postgrestService = mock; + mockContext.data = { + siteId: 'site-001', + startDate: '2026-01-01', + endDate: '2026-03-01', + category: 'Software', + region: 'US', + channel: 'competitor', + platform: 'chatgpt', + }; + + const handler = createCitedDomainsHandler(getOrgAndValidateAccess); + await handler(mockContext); + + expect(mock.rpc).to.have.been.calledOnceWith( + 'rpc_url_inspector_cited_domains', + sinon.match({ + p_site_id: 'site-001', + p_start_date: '2026-01-01', + p_end_date: '2026-03-01', + p_category: 'Software', + p_region: 'US', + p_channel: 'competitor', + p_platform: 'chatgpt', + }), + ); + }); + + it('passes null for skip-value filters (all, empty, *)', async () => { + const mock = createRpcMock({ data: [], error: null }); + mockContext.dataAccess.Site.postgrestService = mock; + mockContext.data = { + siteId: 'site-001', + category: 'all', + region: '*', + channel: '', + }; + + const handler = createCitedDomainsHandler(getOrgAndValidateAccess); + await handler(mockContext); + + expect(mock.rpc).to.have.been.calledOnceWith( + 'rpc_url_inspector_cited_domains', + sinon.match({ + p_category: null, + p_region: null, + p_channel: null, + }), + ); + }); + + it('passes brandId to RPC when a specific brand UUID is provided', async () => { + const brandUuid = '0178a3f0-bbbb-7000-8000-000000000001'; + const rpcMock = createRpcMock({ data: [], error: null }); + mockContext.dataAccess.Site.postgrestService = rpcMock; + mockContext.params.brandId = brandUuid; + + const handler = createCitedDomainsHandler(getOrgAndValidateAccess); + await handler(mockContext); + + expect(rpcMock.rpc).to.have.been.calledOnceWith( + 'rpc_url_inspector_cited_domains', + sinon.match({ + p_brand_id: brandUuid, + }), + ); + }); + + it('passes null p_brand_id when brandId is "all"', async () => { + const rpcMock = createRpcMock({ data: [], error: null }); + mockContext.dataAccess.Site.postgrestService = rpcMock; + + const handler = createCitedDomainsHandler(getOrgAndValidateAccess); + await handler(mockContext); + + expect(rpcMock.rpc).to.have.been.calledOnceWith( + 'rpc_url_inspector_cited_domains', + sinon.match({ + p_brand_id: null, + }), + ); + }); + + it('populates allDomains when includeAll is true', async () => { + const rows = [ + makeDomainRow('a.com', 100, 5, 20, 'competitor', 'Cat1', 'US'), + makeDomainRow('b.com', 50, 3, 10, 'earned', 'Cat2', 'UK'), + ]; + const mock = createRpcMock({ data: rows, error: null }); + mockContext.dataAccess.Site.postgrestService = mock; + mockContext.data = { siteId: 'site-001', includeAll: 'true' }; + + const handler = createCitedDomainsHandler(getOrgAndValidateAccess); + const result = await handler(mockContext); + + expect(result.status).to.equal(200); + const body = await result.json(); + + expect(body.allDomains).to.have.lengthOf(2); + expect(body.allDomains[0].domain).to.equal('a.com'); + expect(body.allDomains[1].domain).to.equal('b.com'); + }); + + it('returns empty allDomains when includeAll is false (default)', async () => { + const rows = [makeDomainRow('a.com', 100, 5, 20, 'competitor', 'Cat1', 'US')]; + const mock = createRpcMock({ data: rows, error: null }); + mockContext.dataAccess.Site.postgrestService = mock; + + const handler = createCitedDomainsHandler(getOrgAndValidateAccess); + const result = await handler(mockContext); + + const body = await result.json(); + expect(body.allDomains).to.deep.equal([]); + }); + + it('respects limit param for topDomains', async () => { + const rows = Array.from({ length: 5 }, (_, i) => makeDomainRow(`d${i}.com`, 100 - i, 1, 1, 'competitor', '', '')); + const mock = createRpcMock({ data: rows, error: null }); + mockContext.dataAccess.Site.postgrestService = mock; + mockContext.data = { siteId: 'site-001', limit: '3' }; + + const handler = createCitedDomainsHandler(getOrgAndValidateAccess); + const result = await handler(mockContext); + + const body = await result.json(); + expect(body.totalDomains).to.equal(5); + expect(body.topDomains).to.have.lengthOf(3); + expect(body.topDomains[0].domain).to.equal('d0.com'); + expect(body.topDomains[2].domain).to.equal('d2.com'); + }); + + it('defaults limit to 200 when not specified', async () => { + const rows = Array.from({ length: 250 }, (_, i) => makeDomainRow(`d${i}.com`, 250 - i, 1, 1, 'competitor', '', '')); + const mock = createRpcMock({ data: rows, error: null }); + mockContext.dataAccess.Site.postgrestService = mock; + + const handler = createCitedDomainsHandler(getOrgAndValidateAccess); + const result = await handler(mockContext); + + const body = await result.json(); + expect(body.totalDomains).to.equal(250); + expect(body.topDomains).to.have.lengthOf(200); + }); + + it('returns empty results when RPC returns no data', async () => { + const mock = createRpcMock({ data: [], error: null }); + mockContext.dataAccess.Site.postgrestService = mock; + + const handler = createCitedDomainsHandler(getOrgAndValidateAccess); + const result = await handler(mockContext); + + expect(result.status).to.equal(200); + const body = await result.json(); + expect(body.totalDomains).to.equal(0); + expect(body.topDomains).to.deep.equal([]); + expect(body.allDomains).to.deep.equal([]); + }); + + it('returns empty results when RPC returns null data', async () => { + const mock = createRpcMock({ data: null, error: null }); + mockContext.dataAccess.Site.postgrestService = mock; + + const handler = createCitedDomainsHandler(getOrgAndValidateAccess); + const result = await handler(mockContext); + + expect(result.status).to.equal(200); + const body = await result.json(); + expect(body.totalDomains).to.equal(0); + expect(body.topDomains).to.deep.equal([]); + }); + + it('defaults contentType to unknown when null', async () => { + const rows = [makeDomainRow('a.com', 10, 1, 1, null, '', '')]; + const mock = createRpcMock({ data: rows, error: null }); + mockContext.dataAccess.Site.postgrestService = mock; + + const handler = createCitedDomainsHandler(getOrgAndValidateAccess); + const result = await handler(mockContext); + + const body = await result.json(); + expect(body.topDomains[0].contentType).to.equal('unknown'); + }); + + it('defaults categories and regions to empty string when null', async () => { + const rows = [makeDomainRow('a.com', 10, 1, 1, 'owned', null, null)]; + const mock = createRpcMock({ data: rows, error: null }); + mockContext.dataAccess.Site.postgrestService = mock; + + const handler = createCitedDomainsHandler(getOrgAndValidateAccess); + const result = await handler(mockContext); + + const body = await result.json(); + expect(body.topDomains[0].categories).to.equal(''); + expect(body.topDomains[0].regions).to.equal(''); + }); + + it('returns internalServerError when handler throws unexpectedly', async () => { + const mock = { rpc: sinon.stub().rejects(new Error('unexpected crash')) }; + mockContext.dataAccess.Site.postgrestService = mock; + + const handler = createCitedDomainsHandler(getOrgAndValidateAccess); + const result = await handler(mockContext); + + expect(result.status).to.equal(500); + }); +}); diff --git a/test/controllers/llmo/llmo-url-inspector-domain-details.test.js b/test/controllers/llmo/llmo-url-inspector-domain-details.test.js new file mode 100644 index 000000000..acd4b9de3 --- /dev/null +++ b/test/controllers/llmo/llmo-url-inspector-domain-details.test.js @@ -0,0 +1,649 @@ +/* + * Copyright 2026 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +/* eslint-env mocha */ +import { expect, use } from 'chai'; +import sinon from 'sinon'; +import sinonChai from 'sinon-chai'; +import { + createDomainDetailsHandler, + dateToIsoWeek, + aggregateDomainDetails, +} from '../../../src/controllers/llmo/llmo-url-inspector-domain-details.js'; + +use(sinonChai); + +function createChainableMock(resolveValue = { data: [], error: null }) { + const c = { + from: sinon.stub().returnsThis(), + select: sinon.stub().returnsThis(), + eq: sinon.stub().returnsThis(), + gte: sinon.stub().returnsThis(), + lte: sinon.stub().returnsThis(), + limit: sinon.stub().resolves(resolveValue), + then(resolve) { return Promise.resolve(resolveValue).then(resolve); }, + }; + return c; +} + +function makeRow( + url, + prompt, + citationCount, + category, + region, + topics, + week, + contentType, + normalizedUrlPath, +) { + return { + url, + prompt, + citation_count: citationCount, + category, + region, + topics, + week, + content_type: contentType || 'competitor', + normalized_url_path: normalizedUrlPath || null, + }; +} + +const baseParams = { + domain: 'competitor.com', + urlLimit: 200, +}; + +describe('llmo-url-inspector-domain-details', () => { + let sandbox; + let getOrgAndValidateAccess; + let mockContext; + + beforeEach(() => { + sandbox = sinon.createSandbox(); + getOrgAndValidateAccess = sandbox.stub().resolves({ organization: {} }); + mockContext = { + params: { spaceCatId: '0178a3f0-1234-7000-8000-000000000001', brandId: 'all' }, + data: { + siteId: 'site-001', + domain: 'competitor.com', + }, + log: { info: sandbox.stub(), error: sandbox.stub(), warn: sandbox.stub() }, + dataAccess: { + Site: { postgrestService: null }, + }, + }; + }); + + afterEach(() => { + sandbox.restore(); + }); + + // ── Pure helpers ────────────────────────────────────────────────────── + + describe('dateToIsoWeek', () => { + it('converts a Monday to the correct ISO week', () => { + expect(dateToIsoWeek('2026-03-02')).to.equal('2026-W10'); + }); + + it('converts a Sunday to the same week as its Monday', () => { + expect(dateToIsoWeek('2026-03-08')).to.equal('2026-W10'); + }); + + it('handles year boundary (Jan 1 2026 is W01)', () => { + expect(dateToIsoWeek('2026-01-01')).to.equal('2026-W01'); + }); + }); + + // ── Aggregation logic ──────────────────────────────────────────────── + + describe('aggregateDomainDetails', () => { + it('returns zeroed response for empty rows', () => { + const result = aggregateDomainDetails([], baseParams); + + expect(result.domain).to.equal('competitor.com'); + expect(result.totalCitations).to.equal(0); + expect(result.totalUrls).to.equal(0); + expect(result.promptsCited).to.equal(0); + expect(result.contentType).to.equal('unknown'); + expect(result.urls).to.deep.equal([]); + expect(result.totalUrlCount).to.equal(0); + expect(result.weeklyTrends).to.deep.equal({ + weeklyDates: [], + totalCitations: [], + uniqueUrls: [], + promptsCited: [], + citationsPerUrl: [], + }); + expect(result.urlPaths).to.deep.equal([]); + }); + + it('computes domain summary correctly (happy path)', () => { + const rows = [ + makeRow('https://competitor.com/a', 'p1', 10, 'Software', 'US', 't1', '2026-W09', 'competitor'), + makeRow('https://competitor.com/a', 'p2', 5, 'AI', 'UK', 't2', '2026-W09', 'competitor'), + makeRow('https://competitor.com/b', 'p1', 8, 'Software', 'US', 't1', '2026-W10', 'competitor'), + ]; + const result = aggregateDomainDetails(rows, baseParams); + + expect(result.totalCitations).to.equal(23); + expect(result.totalUrls).to.equal(2); + expect(result.promptsCited).to.equal(2); + expect(result.contentType).to.equal('competitor'); + }); + + it('picks most frequent content_type', () => { + const rows = [ + makeRow('https://d.com/a', 'p1', 5, 'A', 'US', 't', '2026-W09', 'owned'), + makeRow('https://d.com/b', 'p2', 3, 'B', 'UK', 't', '2026-W09', 'competitor'), + makeRow('https://d.com/c', 'p3', 4, 'C', 'DE', 't', '2026-W09', 'competitor'), + ]; + const result = aggregateDomainDetails(rows, { ...baseParams, domain: 'd.com' }); + + expect(result.contentType).to.equal('competitor'); + }); + + it('builds per-URL breakdown sorted by citations descending', () => { + const rows = [ + makeRow('https://d.com/low', 'p1', 2, 'A', 'US', 't', '2026-W09'), + makeRow('https://d.com/high', 'p2', 10, 'B', 'UK', 't', '2026-W09'), + makeRow('https://d.com/high', 'p3', 8, 'C', 'DE', 't', '2026-W10'), + ]; + const result = aggregateDomainDetails(rows, baseParams); + + expect(result.urls).to.have.lengthOf(2); + expect(result.urls[0].url).to.equal('https://d.com/high'); + expect(result.urls[0].citations).to.equal(18); + expect(result.urls[0].promptsCited).to.equal(2); + expect(result.urls[0].regions).to.include.members(['UK', 'DE']); + expect(result.urls[0].categories).to.include.members(['B', 'C']); + expect(result.urls[1].url).to.equal('https://d.com/low'); + expect(result.urls[1].citations).to.equal(2); + }); + + it('urlLimit caps urls array but totalUrlCount reflects full count', () => { + const rows = [ + makeRow('https://d.com/a', 'p1', 10, 'A', 'US', 't', '2026-W09'), + makeRow('https://d.com/b', 'p2', 5, 'B', 'UK', 't', '2026-W09'), + makeRow('https://d.com/c', 'p3', 3, 'C', 'DE', 't', '2026-W09'), + ]; + const result = aggregateDomainDetails(rows, { ...baseParams, urlLimit: 2 }); + + expect(result.totalUrlCount).to.equal(3); + expect(result.urls).to.have.lengthOf(2); + expect(result.urls[0].url).to.equal('https://d.com/a'); + expect(result.urls[1].url).to.equal('https://d.com/b'); + }); + + it('defaults urlLimit to 200 when not specified', () => { + const rows = Array.from({ length: 250 }, (_, i) => makeRow(`https://d.com/${i}`, `p${i}`, 250 - i, 'A', 'US', 't', '2026-W09')); + const result = aggregateDomainDetails(rows, { domain: 'competitor.com' }); + + expect(result.totalUrlCount).to.equal(250); + expect(result.urls).to.have.lengthOf(200); + }); + + it('builds weekly trends as aligned parallel arrays', () => { + const rows = [ + makeRow('https://d.com/a', 'p1', 10, 'A', 'US', 't1', '2026-W10'), + makeRow('https://d.com/b', 'p2', 5, 'B', 'UK', 't2', '2026-W09'), + makeRow('https://d.com/a', 'p1', 8, 'A', 'US', 't1', '2026-W09'), + ]; + const result = aggregateDomainDetails(rows, baseParams); + const { weeklyTrends } = result; + + expect(weeklyTrends.weeklyDates).to.deep.equal(['2026-W09', '2026-W10']); + expect(weeklyTrends.totalCitations).to.deep.equal([13, 10]); + expect(weeklyTrends.uniqueUrls).to.deep.equal([2, 1]); + expect(weeklyTrends.promptsCited).to.deep.equal([2, 1]); + expect(weeklyTrends.citationsPerUrl).to.deep.equal([6.5, 10]); + + expect(weeklyTrends.weeklyDates).to.have.lengthOf(weeklyTrends.totalCitations.length); + expect(weeklyTrends.totalCitations).to.have.lengthOf(weeklyTrends.uniqueUrls.length); + expect(weeklyTrends.uniqueUrls).to.have.lengthOf(weeklyTrends.promptsCited.length); + expect(weeklyTrends.promptsCited).to.have.lengthOf(weeklyTrends.citationsPerUrl.length); + }); + + it('computes citationsPerUrl correctly with rounding', () => { + const rows = [ + makeRow('https://d.com/a', 'p1', 7, 'A', 'US', 't', '2026-W09'), + makeRow('https://d.com/b', 'p2', 3, 'B', 'UK', 't', '2026-W09'), + makeRow('https://d.com/c', 'p3', 1, 'C', 'DE', 't', '2026-W09'), + ]; + const result = aggregateDomainDetails(rows, baseParams); + + // 11 citations / 3 urls = 3.666... → 3.7 + expect(result.weeklyTrends.citationsPerUrl[0]).to.equal(3.7); + }); + + it('extracts urlPaths from normalized_url_path when available', () => { + const rows = [ + makeRow('https://d.com/a', 'p1', 5, 'A', 'US', 't', '2026-W09', 'competitor', '/product-review'), + makeRow('https://d.com/b', 'p2', 3, 'B', 'UK', 't', '2026-W09', 'competitor', '/pricing'), + makeRow('https://d.com/c', 'p3', 1, 'C', 'DE', 't', '2026-W09', 'competitor', '/product-review'), + ]; + const result = aggregateDomainDetails(rows, baseParams); + + expect(result.urlPaths).to.deep.equal(['/pricing', '/product-review']); + }); + + it('derives urlPaths from URL when normalized_url_path is missing', () => { + const rows = [ + makeRow('https://d.com/product-review', 'p1', 5, 'A', 'US', 't', '2026-W09'), + makeRow('https://d.com/pricing', 'p2', 3, 'B', 'UK', 't', '2026-W09'), + ]; + const result = aggregateDomainDetails(rows, baseParams); + + expect(result.urlPaths).to.include('/product-review'); + expect(result.urlPaths).to.include('/pricing'); + }); + + it('applies category filter to narrow results', () => { + const rows = [ + makeRow('https://d.com/a', 'p1', 10, 'Software', 'US', 't', '2026-W09'), + makeRow('https://d.com/b', 'p2', 5, 'AI', 'UK', 't', '2026-W09'), + ]; + const result = aggregateDomainDetails(rows, { ...baseParams, category: 'Software' }); + + expect(result.totalCitations).to.equal(10); + expect(result.urls).to.have.lengthOf(1); + expect(result.urls[0].url).to.equal('https://d.com/a'); + }); + + it('applies region filter to narrow results', () => { + const rows = [ + makeRow('https://d.com/a', 'p1', 10, 'A', 'US', 't', '2026-W09'), + makeRow('https://d.com/b', 'p2', 5, 'B', 'UK', 't', '2026-W09'), + ]; + const result = aggregateDomainDetails(rows, { ...baseParams, region: 'UK' }); + + expect(result.totalCitations).to.equal(5); + expect(result.urls).to.have.lengthOf(1); + }); + + it('applies channel filter to narrow results', () => { + const rows = [ + makeRow('https://d.com/a', 'p1', 10, 'A', 'US', 't', '2026-W09', 'owned'), + makeRow('https://d.com/b', 'p2', 5, 'B', 'UK', 't', '2026-W09', 'competitor'), + ]; + const result = aggregateDomainDetails(rows, { ...baseParams, channel: 'owned' }); + + expect(result.totalCitations).to.equal(10); + expect(result.contentType).to.equal('owned'); + }); + + it('handles rows with null citation_count gracefully', () => { + const rows = [ + makeRow('https://d.com/a', 'p1', null, 'A', 'US', 't', '2026-W09'), + makeRow('https://d.com/b', 'p2', 5, 'B', 'UK', 't', '2026-W09'), + ]; + const result = aggregateDomainDetails(rows, baseParams); + + expect(result.totalCitations).to.equal(5); + }); + + it('handles rows with null week by excluding from weekly trends', () => { + const rows = [ + makeRow('https://d.com/a', 'p1', 5, 'A', 'US', 't', null), + makeRow('https://d.com/b', 'p2', 3, 'B', 'UK', 't', '2026-W09'), + ]; + const result = aggregateDomainDetails(rows, baseParams); + + expect(result.weeklyTrends.weeklyDates).to.have.lengthOf(1); + expect(result.totalCitations).to.equal(8); + }); + + it('excludes null/empty category and region from per-URL arrays', () => { + const rows = [ + makeRow('https://d.com/a', 'p1', 5, null, null, 't', '2026-W09'), + makeRow('https://d.com/a', 'p2', 3, 'Software', 'US', 't', '2026-W09'), + ]; + const result = aggregateDomainDetails(rows, baseParams); + + expect(result.urls[0].categories).to.deep.equal(['Software']); + expect(result.urls[0].regions).to.deep.equal(['US']); + }); + }); + + // ── Handler integration ────────────────────────────────────────────── + + describe('createDomainDetailsHandler', () => { + it('returns 400 when postgrestService is missing', async () => { + mockContext.dataAccess.Site.postgrestService = null; + const handler = createDomainDetailsHandler(getOrgAndValidateAccess); + const result = await handler(mockContext); + + expect(result.status).to.equal(400); + expect(getOrgAndValidateAccess).not.to.have.been.called; + }); + + it('returns 403 when user has no org access', async () => { + mockContext.dataAccess.Site.postgrestService = createChainableMock(); + getOrgAndValidateAccess.rejects( + new Error('Only users belonging to the organization can view URL Inspector data'), + ); + + const handler = createDomainDetailsHandler(getOrgAndValidateAccess); + const result = await handler(mockContext); + + expect(result.status).to.equal(403); + }); + + it('returns 400 when organization not found', async () => { + mockContext.dataAccess.Site.postgrestService = createChainableMock(); + getOrgAndValidateAccess.rejects(new Error('Organization not found: x')); + + const handler = createDomainDetailsHandler(getOrgAndValidateAccess); + const result = await handler(mockContext); + + expect(result.status).to.equal(400); + }); + + it('returns 400 when siteId is missing', async () => { + mockContext.data = { domain: 'competitor.com' }; + mockContext.dataAccess.Site.postgrestService = createChainableMock(); + + const handler = createDomainDetailsHandler(getOrgAndValidateAccess); + const result = await handler(mockContext); + + expect(result.status).to.equal(400); + const body = await result.json(); + expect(body.message).to.include('siteId'); + }); + + it('returns 400 when domain is missing', async () => { + mockContext.data = { siteId: 'site-001' }; + mockContext.dataAccess.Site.postgrestService = createChainableMock(); + + const handler = createDomainDetailsHandler(getOrgAndValidateAccess); + const result = await handler(mockContext); + + expect(result.status).to.equal(400); + const body = await result.json(); + expect(body.message).to.include('domain'); + }); + + it('returns 400 when PostgREST returns an error', async () => { + mockContext.dataAccess.Site.postgrestService = createChainableMock({ + data: null, + error: { message: 'relation does not exist' }, + }); + + const handler = createDomainDetailsHandler(getOrgAndValidateAccess); + const result = await handler(mockContext); + + expect(result.status).to.equal(400); + const body = await result.json(); + expect(body.message).to.equal('relation does not exist'); + expect(mockContext.log.error).to.have.been.calledOnce; + }); + + it('returns 200 with correct shape on happy path', async () => { + const sourceRows = [ + { + content_type: 'competitor', + execution_date: '2026-03-02', + source_urls: { url: 'https://competitor.com/review', hostname: 'competitor.com' }, + brand_presence_executions: { + prompt: 'best tools', category_name: 'Software', region_code: 'US', topics: 'pm', + }, + }, + { + content_type: 'competitor', + execution_date: '2026-03-09', + source_urls: { url: 'https://competitor.com/review', hostname: 'competitor.com' }, + brand_presence_executions: { + prompt: 'best tools', category_name: 'Software', region_code: 'US', topics: 'pm', + }, + }, + { + content_type: 'competitor', + execution_date: '2026-03-02', + source_urls: { url: 'https://competitor.com/pricing', hostname: 'competitor.com' }, + brand_presence_executions: { + prompt: 'pricing compare', category_name: 'AI', region_code: 'UK', topics: 'ai', + }, + }, + ]; + mockContext.dataAccess.Site.postgrestService = createChainableMock({ + data: sourceRows, + error: null, + }); + + const handler = createDomainDetailsHandler(getOrgAndValidateAccess); + const result = await handler(mockContext); + + expect(result.status).to.equal(200); + const body = await result.json(); + + expect(body.domain).to.equal('competitor.com'); + expect(body.totalCitations).to.equal(3); + expect(body.totalUrls).to.equal(2); + expect(body.promptsCited).to.equal(2); + expect(body.contentType).to.equal('competitor'); + expect(body.urls).to.have.lengthOf(2); + expect(body.urls[0].citations).to.be.at.least(body.urls[1].citations); + expect(body.totalUrlCount).to.equal(2); + expect(body.weeklyTrends.weeklyDates).to.have.lengthOf(2); + expect(body.weeklyTrends.totalCitations).to.have.lengthOf(2); + expect(body.urlPaths).to.include.members(['/review', '/pricing']); + }); + + it('returns 200 with zeroed response for empty result', async () => { + mockContext.dataAccess.Site.postgrestService = createChainableMock({ + data: [], + error: null, + }); + + const handler = createDomainDetailsHandler(getOrgAndValidateAccess); + const result = await handler(mockContext); + + expect(result.status).to.equal(200); + const body = await result.json(); + + expect(body.totalCitations).to.equal(0); + expect(body.totalUrls).to.equal(0); + expect(body.urls).to.deep.equal([]); + expect(body.totalUrlCount).to.equal(0); + expect(body.weeklyTrends.weeklyDates).to.deep.equal([]); + expect(body.urlPaths).to.deep.equal([]); + }); + + it('handles null data from PostgREST gracefully', async () => { + mockContext.dataAccess.Site.postgrestService = createChainableMock({ + data: null, + error: null, + }); + + const handler = createDomainDetailsHandler(getOrgAndValidateAccess); + const result = await handler(mockContext); + + expect(result.status).to.equal(200); + const body = await result.json(); + expect(body.totalCitations).to.equal(0); + }); + + it('respects urlLimit query parameter', async () => { + const sourceRows = [ + { + content_type: 'competitor', + execution_date: '2026-03-02', + source_urls: { url: 'https://d.com/a', hostname: 'd.com' }, + brand_presence_executions: { + prompt: 'p1', category_name: 'A', region_code: 'US', topics: 't', + }, + }, + { + content_type: 'competitor', + execution_date: '2026-03-02', + source_urls: { url: 'https://d.com/b', hostname: 'd.com' }, + brand_presence_executions: { + prompt: 'p2', category_name: 'B', region_code: 'UK', topics: 't', + }, + }, + { + content_type: 'competitor', + execution_date: '2026-03-02', + source_urls: { url: 'https://d.com/c', hostname: 'd.com' }, + brand_presence_executions: { + prompt: 'p3', category_name: 'C', region_code: 'DE', topics: 't', + }, + }, + ]; + mockContext.dataAccess.Site.postgrestService = createChainableMock({ + data: sourceRows, + error: null, + }); + mockContext.data = { siteId: 'site-001', domain: 'competitor.com', urlLimit: '2' }; + + const handler = createDomainDetailsHandler(getOrgAndValidateAccess); + const result = await handler(mockContext); + + expect(result.status).to.equal(200); + const body = await result.json(); + expect(body.totalUrlCount).to.equal(3); + expect(body.urls).to.have.lengthOf(2); + }); + + it('falls back to limit param when urlLimit not set', async () => { + const sourceRows = [ + { + content_type: 'competitor', + execution_date: '2026-03-02', + source_urls: { url: 'https://d.com/a', hostname: 'd.com' }, + brand_presence_executions: { + prompt: 'p1', category_name: 'A', region_code: 'US', topics: 't', + }, + }, + { + content_type: 'competitor', + execution_date: '2026-03-02', + source_urls: { url: 'https://d.com/b', hostname: 'd.com' }, + brand_presence_executions: { + prompt: 'p2', category_name: 'B', region_code: 'UK', topics: 't', + }, + }, + { + content_type: 'competitor', + execution_date: '2026-03-02', + source_urls: { url: 'https://d.com/c', hostname: 'd.com' }, + brand_presence_executions: { + prompt: 'p3', category_name: 'C', region_code: 'DE', topics: 't', + }, + }, + ]; + mockContext.dataAccess.Site.postgrestService = createChainableMock({ + data: sourceRows, + error: null, + }); + mockContext.data = { siteId: 'site-001', domain: 'competitor.com', limit: '1' }; + + const handler = createDomainDetailsHandler(getOrgAndValidateAccess); + const result = await handler(mockContext); + + expect(result.status).to.equal(200); + const body = await result.json(); + expect(body.totalUrlCount).to.equal(3); + expect(body.urls).to.have.lengthOf(1); + }); + + it('queries the correct table and columns', async () => { + const chainMock = createChainableMock({ data: [], error: null }); + mockContext.dataAccess.Site.postgrestService = chainMock; + + const handler = createDomainDetailsHandler(getOrgAndValidateAccess); + await handler(mockContext); + + expect(chainMock.from).to.have.been.calledWith('brand_presence_sources'); + expect(chainMock.select).to.have.been.calledWith( + 'content_type,execution_date,source_urls!inner(url,hostname),brand_presence_executions!inner(prompt,category_name,region_code,topics)', + ); + expect(chainMock.eq).to.have.been.calledWith('site_id', 'site-001'); + expect(chainMock.eq).to.have.been.calledWith('source_urls.hostname', 'competitor.com'); + }); + + it('applies date range filters to execution_date', async () => { + const chainMock = createChainableMock({ data: [], error: null }); + mockContext.data = { + siteId: 'site-001', + domain: 'competitor.com', + startDate: '2026-03-02', + endDate: '2026-03-15', + }; + mockContext.dataAccess.Site.postgrestService = chainMock; + + const handler = createDomainDetailsHandler(getOrgAndValidateAccess); + await handler(mockContext); + + expect(chainMock.gte).to.have.been.calledWith('execution_date', '2026-03-02'); + expect(chainMock.lte).to.have.been.calledWith('execution_date', '2026-03-15'); + }); + + it('returns 500 when handler throws unexpectedly', async () => { + const mock = { + from: sinon.stub().throws(new Error('connection reset')), + }; + mockContext.dataAccess.Site.postgrestService = mock; + + const handler = createDomainDetailsHandler(getOrgAndValidateAccess); + const result = await handler(mockContext); + + expect(result.status).to.equal(500); + expect(mockContext.log.error).to.have.been.called; + }); + + it('supports snake_case query param aliases', async () => { + const chainMock = createChainableMock({ data: [], error: null }); + mockContext.data = { + site_id: 'site-001', + domain: 'competitor.com', + start_date: '2026-03-01', + end_date: '2026-03-31', + }; + mockContext.dataAccess.Site.postgrestService = chainMock; + + const handler = createDomainDetailsHandler(getOrgAndValidateAccess); + const result = await handler(mockContext); + + expect(result.status).to.equal(200); + expect(chainMock.eq).to.have.been.calledWith('site_id', 'site-001'); + }); + + it('filters by brandId on the PostgREST query when a specific UUID is provided', async () => { + const brandUuid = '0178a3f0-bbbb-7000-8000-000000000001'; + mockContext.params.brandId = brandUuid; + + const chainMock = createChainableMock({ data: [], error: null }); + mockContext.dataAccess.Site.postgrestService = chainMock; + + const handler = createDomainDetailsHandler(getOrgAndValidateAccess); + await handler(mockContext); + + expect(chainMock.eq).to.have.been.calledWith('brand_presence_executions.brand_id', brandUuid); + }); + + it('does not filter by brandId when brandId is "all"', async () => { + mockContext.params.brandId = 'all'; + + const chainMock = createChainableMock({ data: [], error: null }); + mockContext.dataAccess.Site.postgrestService = chainMock; + + const handler = createDomainDetailsHandler(getOrgAndValidateAccess); + await handler(mockContext); + + const eqCalls = chainMock.eq.getCalls().map((c) => c.args); + const brandCall = eqCalls.find(([col]) => col === 'brand_presence_executions.brand_id'); + expect(brandCall).to.be.undefined; + }); + }); +}); diff --git a/test/controllers/llmo/llmo-url-inspector-filter-options.test.js b/test/controllers/llmo/llmo-url-inspector-filter-options.test.js new file mode 100644 index 000000000..8fbcf3c08 --- /dev/null +++ b/test/controllers/llmo/llmo-url-inspector-filter-options.test.js @@ -0,0 +1,460 @@ +/* + * Copyright 2026 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +/* eslint-env mocha */ +import { expect, use } from 'chai'; +import sinon from 'sinon'; +import sinonChai from 'sinon-chai'; +import { + createFilterOptionsHandler, + extractDistinct, + extractDistinctChannels, +} from '../../../src/controllers/llmo/llmo-url-inspector-filter-options.js'; + +use(sinonChai); + +const SITE_ID = '0178a3f0-1234-7000-8000-000000000001'; +const ORG_ID = '0178a3f0-aaaa-7000-8000-000000000001'; + +/** + * Creates a mock PostgREST client that returns different results per table. + * Each .from(tableName) call returns a new independent chain so that + * parallel queries (Promise.all) resolve independently. + */ +function createTableAwareMock(tableResults = {}, defaultResult = { data: [], error: null }) { + const stubs = { + select: sinon.stub(), + eq: sinon.stub(), + gte: sinon.stub(), + lte: sinon.stub(), + limit: sinon.stub(), + }; + + const fromStub = sinon.stub(); + + function makeChain(table) { + const result = tableResults[table] ?? defaultResult; + const chain = { + from: fromStub, + select(...args) { + stubs.select(...args); + return chain; + }, + eq(...args) { + stubs.eq(...args); + return chain; + }, + gte(...args) { + stubs.gte(...args); + return chain; + }, + lte(...args) { + stubs.lte(...args); + return chain; + }, + limit(...args) { + stubs.limit(...args); + return Promise.resolve(result); + }, + then(resolve, reject) { + return Promise.resolve(result).then(resolve, reject); + }, + }; + return chain; + } + + fromStub.callsFake((t) => makeChain(t)); + return { from: fromStub, ...stubs }; +} + +describe('llmo-url-inspector-filter-options', () => { + let sandbox; + let getOrgAndValidateAccess; + let mockContext; + + beforeEach(() => { + sandbox = sinon.createSandbox(); + getOrgAndValidateAccess = sandbox.stub().resolves({ organization: {} }); + mockContext = { + params: { spaceCatId: ORG_ID, brandId: 'all' }, + data: { siteId: SITE_ID }, + log: { info: sandbox.stub(), error: sandbox.stub(), warn: sandbox.stub() }, + dataAccess: { + Site: { postgrestService: null }, + }, + }; + }); + + afterEach(() => { + sandbox.restore(); + }); + + describe('extractDistinct', () => { + it('returns sorted unique values', () => { + const rows = [ + { region_code: 'US' }, + { region_code: 'DE' }, + { region_code: 'US' }, + { region_code: 'JP' }, + ]; + expect(extractDistinct(rows, 'region_code')).to.deep.equal(['DE', 'JP', 'US']); + }); + + it('splits comma-separated values', () => { + const rows = [ + { category_name: 'AI Tools,Analytics' }, + { category_name: 'Security' }, + { category_name: 'Analytics,Marketing' }, + ]; + expect(extractDistinct(rows, 'category_name')) + .to.deep.equal(['AI Tools', 'Analytics', 'Marketing', 'Security']); + }); + + it('excludes null and empty values', () => { + const rows = [ + { region_code: null }, + { region_code: '' }, + { region_code: 'US' }, + { region_code: undefined }, + ]; + expect(extractDistinct(rows, 'region_code')).to.deep.equal(['US']); + }); + + it('returns empty array for empty rows', () => { + expect(extractDistinct([], 'region_code')).to.deep.equal([]); + }); + + it('trims whitespace around comma-separated values', () => { + const rows = [{ category_name: ' AI Tools , Analytics ' }]; + expect(extractDistinct(rows, 'category_name')) + .to.deep.equal(['AI Tools', 'Analytics']); + }); + }); + + describe('extractDistinctChannels', () => { + it('maps competitor to others', () => { + const rows = [ + { content_type: 'owned' }, + { content_type: 'competitor' }, + { content_type: 'earned' }, + ]; + expect(extractDistinctChannels(rows)).to.deep.equal(['earned', 'others', 'owned']); + }); + + it('excludes null and empty values', () => { + const rows = [ + { content_type: null }, + { content_type: '' }, + { content_type: 'social' }, + ]; + expect(extractDistinctChannels(rows)).to.deep.equal(['social']); + }); + + it('returns empty array for empty rows', () => { + expect(extractDistinctChannels([])).to.deep.equal([]); + }); + + it('deduplicates mapped values', () => { + const rows = [ + { content_type: 'competitor' }, + { content_type: 'competitor' }, + { content_type: 'owned' }, + ]; + expect(extractDistinctChannels(rows)).to.deep.equal(['others', 'owned']); + }); + }); + + describe('createFilterOptionsHandler', () => { + it('returns badRequest when postgrestService is missing', async () => { + mockContext.dataAccess.Site.postgrestService = null; + const handler = createFilterOptionsHandler(getOrgAndValidateAccess); + const result = await handler(mockContext); + + expect(result.status).to.equal(400); + expect(getOrgAndValidateAccess).not.to.have.been.called; + }); + + it('returns forbidden when user has no org access', async () => { + mockContext.dataAccess.Site.postgrestService = createTableAwareMock(); + getOrgAndValidateAccess.rejects( + new Error('Only users belonging to the organization can view URL Inspector data'), + ); + + const handler = createFilterOptionsHandler(getOrgAndValidateAccess); + const result = await handler(mockContext); + + expect(result.status).to.equal(403); + }); + + it('returns badRequest when organization not found', async () => { + mockContext.dataAccess.Site.postgrestService = createTableAwareMock(); + getOrgAndValidateAccess.rejects(new Error('Organization not found: x')); + + const handler = createFilterOptionsHandler(getOrgAndValidateAccess); + const result = await handler(mockContext); + + expect(result.status).to.equal(400); + }); + + it('returns badRequest when siteId is missing', async () => { + mockContext.dataAccess.Site.postgrestService = createTableAwareMock(); + mockContext.data = {}; + + const handler = createFilterOptionsHandler(getOrgAndValidateAccess); + const result = await handler(mockContext); + + expect(result.status).to.equal(400); + const body = await result.json(); + expect(body.message).to.include('siteId'); + }); + + it('returns ok with distinct filter values (happy path)', async () => { + const execRows = [ + { category_name: 'Software', region_code: 'US' }, + { category_name: 'Marketing', region_code: 'DE' }, + { category_name: 'Software', region_code: 'US' }, + { category_name: 'AI Tools', region_code: 'JP' }, + ]; + const srcRows = [ + { content_type: 'owned' }, + { content_type: 'competitor' }, + { content_type: 'earned' }, + { content_type: 'owned' }, + { content_type: 'social' }, + ]; + + const client = createTableAwareMock({ + brand_presence_executions: { data: execRows, error: null }, + brand_presence_sources: { data: srcRows, error: null }, + }); + mockContext.dataAccess.Site.postgrestService = client; + mockContext.data = { + siteId: SITE_ID, + startDate: '2026-03-01', + endDate: '2026-03-15', + }; + + const handler = createFilterOptionsHandler(getOrgAndValidateAccess); + const result = await handler(mockContext); + + expect(result.status).to.equal(200); + const body = await result.json(); + + expect(body.regions).to.deep.equal(['DE', 'JP', 'US']); + expect(body.categories).to.deep.equal(['AI Tools', 'Marketing', 'Software']); + expect(body.channels).to.deep.equal(['earned', 'others', 'owned', 'social']); + }); + + it('returns empty arrays when no data exists', async () => { + mockContext.dataAccess.Site.postgrestService = createTableAwareMock(); + + const handler = createFilterOptionsHandler(getOrgAndValidateAccess); + const result = await handler(mockContext); + + expect(result.status).to.equal(200); + const body = await result.json(); + + expect(body.regions).to.deep.equal([]); + expect(body.categories).to.deep.equal([]); + expect(body.channels).to.deep.equal([]); + }); + + it('returns empty arrays when data is null', async () => { + const client = createTableAwareMock({}, { data: null, error: null }); + mockContext.dataAccess.Site.postgrestService = client; + + const handler = createFilterOptionsHandler(getOrgAndValidateAccess); + const result = await handler(mockContext); + + expect(result.status).to.equal(200); + const body = await result.json(); + + expect(body.regions).to.deep.equal([]); + expect(body.categories).to.deep.equal([]); + expect(body.channels).to.deep.equal([]); + }); + + it('applies platform filter to both queries', async () => { + const client = createTableAwareMock(); + mockContext.dataAccess.Site.postgrestService = client; + mockContext.data = { + siteId: SITE_ID, + platform: 'chatgpt', + }; + + const handler = createFilterOptionsHandler(getOrgAndValidateAccess); + await handler(mockContext); + + expect(client.from).to.have.been.calledWith('brand_presence_executions'); + expect(client.from).to.have.been.calledWith('brand_presence_sources'); + + const modelCalls = client.eq.getCalls().filter((c) => c.args[0] === 'model'); + expect(modelCalls).to.have.lengthOf(2); + expect(modelCalls[0].args[1]).to.equal('chatgpt'); + expect(modelCalls[1].args[1]).to.equal('chatgpt'); + }); + + it('applies date range filters to both queries', async () => { + const client = createTableAwareMock(); + mockContext.dataAccess.Site.postgrestService = client; + mockContext.data = { + siteId: SITE_ID, + startDate: '2026-01-01', + endDate: '2026-03-31', + }; + + const handler = createFilterOptionsHandler(getOrgAndValidateAccess); + await handler(mockContext); + + const gteCalls = client.gte.getCalls().filter((c) => c.args[0] === 'execution_date'); + const lteCalls = client.lte.getCalls().filter((c) => c.args[0] === 'execution_date'); + + expect(gteCalls).to.have.lengthOf(2); + expect(lteCalls).to.have.lengthOf(2); + expect(gteCalls[0].args[1]).to.equal('2026-01-01'); + expect(lteCalls[0].args[1]).to.equal('2026-03-31'); + }); + + it('returns badRequest when executions query fails', async () => { + const client = createTableAwareMock({ + brand_presence_executions: { data: null, error: { message: 'relation does not exist' } }, + }); + mockContext.dataAccess.Site.postgrestService = client; + + const handler = createFilterOptionsHandler(getOrgAndValidateAccess); + const result = await handler(mockContext); + + expect(result.status).to.equal(400); + const body = await result.json(); + expect(body.message).to.equal('relation does not exist'); + expect(mockContext.log.error).to.have.been.calledOnce; + }); + + it('returns badRequest when sources query fails', async () => { + const client = createTableAwareMock({ + brand_presence_sources: { data: null, error: { message: 'permission denied' } }, + }); + mockContext.dataAccess.Site.postgrestService = client; + + const handler = createFilterOptionsHandler(getOrgAndValidateAccess); + const result = await handler(mockContext); + + expect(result.status).to.equal(400); + const body = await result.json(); + expect(body.message).to.equal('permission denied'); + expect(mockContext.log.error).to.have.been.calledOnce; + }); + + it('returns 500 when handler throws unexpectedly', async () => { + mockContext.dataAccess.Site.postgrestService = { + from: sinon.stub().throws(new Error('connection reset')), + }; + + const handler = createFilterOptionsHandler(getOrgAndValidateAccess); + const result = await handler(mockContext); + + expect(result.status).to.equal(500); + expect(mockContext.log.error).to.have.been.called; + }); + + it('handles comma-separated categories and regions', async () => { + const execRows = [ + { category_name: 'AI Tools,Analytics', region_code: 'US,DE' }, + { category_name: 'Security', region_code: 'JP' }, + ]; + + const client = createTableAwareMock({ + brand_presence_executions: { data: execRows, error: null }, + }); + mockContext.dataAccess.Site.postgrestService = client; + + const handler = createFilterOptionsHandler(getOrgAndValidateAccess); + const result = await handler(mockContext); + + expect(result.status).to.equal(200); + const body = await result.json(); + + expect(body.categories).to.deep.equal(['AI Tools', 'Analytics', 'Security']); + expect(body.regions).to.deep.equal(['DE', 'JP', 'US']); + }); + + it('skips platform filter when set to "all"', async () => { + const client = createTableAwareMock(); + mockContext.dataAccess.Site.postgrestService = client; + mockContext.data = { + siteId: SITE_ID, + platform: 'all', + }; + + const handler = createFilterOptionsHandler(getOrgAndValidateAccess); + await handler(mockContext); + + const modelCalls = client.eq.getCalls().filter((c) => c.args[0] === 'model'); + expect(modelCalls).to.have.lengthOf(0); + }); + + it('supports snake_case query param aliases', async () => { + const client = createTableAwareMock(); + mockContext.dataAccess.Site.postgrestService = client; + mockContext.data = { + site_id: SITE_ID, + start_date: '2026-01-01', + end_date: '2026-03-31', + }; + + const handler = createFilterOptionsHandler(getOrgAndValidateAccess); + const result = await handler(mockContext); + + expect(result.status).to.equal(200); + expect(client.eq).to.have.been.calledWith('site_id', SITE_ID); + }); + + it('does not apply date filters when dates are not provided', async () => { + const client = createTableAwareMock(); + mockContext.dataAccess.Site.postgrestService = client; + mockContext.data = { siteId: SITE_ID }; + + const handler = createFilterOptionsHandler(getOrgAndValidateAccess); + await handler(mockContext); + + expect(client.gte).not.to.have.been.called; + expect(client.lte).not.to.have.been.called; + }); + + it('filters executions by brandId when a specific UUID is provided', async () => { + const brandUuid = '0178a3f0-bbbb-7000-8000-000000000001'; + mockContext.params.brandId = brandUuid; + + const client = createTableAwareMock(); + mockContext.dataAccess.Site.postgrestService = client; + + const handler = createFilterOptionsHandler(getOrgAndValidateAccess); + await handler(mockContext); + + expect(client.eq).to.have.been.calledWith('brand_id', brandUuid); + }); + + it('does not filter by brandId when brandId is "all"', async () => { + mockContext.params.brandId = 'all'; + + const client = createTableAwareMock(); + mockContext.dataAccess.Site.postgrestService = client; + + const handler = createFilterOptionsHandler(getOrgAndValidateAccess); + await handler(mockContext); + + const eqCalls = client.eq.getCalls().map((c) => c.args); + const brandCall = eqCalls.find(([col]) => col === 'brand_id'); + expect(brandCall).to.be.undefined; + }); + }); +}); diff --git a/test/controllers/llmo/llmo-url-inspector-owned-urls.test.js b/test/controllers/llmo/llmo-url-inspector-owned-urls.test.js new file mode 100644 index 000000000..241044cc0 --- /dev/null +++ b/test/controllers/llmo/llmo-url-inspector-owned-urls.test.js @@ -0,0 +1,438 @@ +/* + * Copyright 2026 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +/* eslint-env mocha */ +import { expect, use } from 'chai'; +import sinon from 'sinon'; +import sinonChai from 'sinon-chai'; +import { + createOwnedUrlsHandler, + computeTrend, +} from '../../../src/controllers/llmo/llmo-url-inspector-owned-urls.js'; + +use(sinonChai); + +function createRpcMock(resolveValue = { data: [], error: null }) { + return { + rpc: sinon.stub().resolves(resolveValue), + }; +} + +describe('computeTrend', () => { + it('returns neutral with hasValidComparison=false for empty array', () => { + const result = computeTrend([]); + expect(result).to.deep.equal({ + direction: 'neutral', + hasValidComparison: false, + weeklyValues: [], + }); + }); + + it('returns neutral with hasValidComparison=false for null', () => { + const result = computeTrend(null); + expect(result).to.deep.equal({ + direction: 'neutral', + hasValidComparison: false, + weeklyValues: [], + }); + }); + + it('returns neutral with hasValidComparison=false for single week', () => { + const result = computeTrend([{ week: '2026-W10', value: 5 }]); + expect(result).to.deep.equal({ + direction: 'neutral', + hasValidComparison: false, + weeklyValues: [{ week: '2026-W10', value: 5 }], + }); + }); + + it('returns up when latest week is greater', () => { + const result = computeTrend([ + { week: '2026-W09', value: 10 }, + { week: '2026-W10', value: 15 }, + ]); + expect(result.direction).to.equal('up'); + expect(result.hasValidComparison).to.be.true; + expect(result.weeklyValues).to.have.length(2); + }); + + it('returns down when latest week is smaller', () => { + const result = computeTrend([ + { week: '2026-W09', value: 15 }, + { week: '2026-W10', value: 10 }, + ]); + expect(result.direction).to.equal('down'); + expect(result.hasValidComparison).to.be.true; + }); + + it('returns neutral when both weeks are equal', () => { + const result = computeTrend([ + { week: '2026-W09', value: 10 }, + { week: '2026-W10', value: 10 }, + ]); + expect(result.direction).to.equal('neutral'); + expect(result.hasValidComparison).to.be.true; + }); + + it('compares the last two weeks when more than two are present', () => { + const result = computeTrend([ + { week: '2026-W08', value: 100 }, + { week: '2026-W09', value: 5 }, + { week: '2026-W10', value: 15 }, + ]); + expect(result.direction).to.equal('up'); + expect(result.hasValidComparison).to.be.true; + expect(result.weeklyValues).to.have.length(3); + }); + + it('sorts unsorted input chronologically', () => { + const result = computeTrend([ + { week: '2026-W10', value: 8 }, + { week: '2026-W08', value: 20 }, + { week: '2026-W09', value: 12 }, + ]); + expect(result.weeklyValues[0].week).to.equal('2026-W08'); + expect(result.weeklyValues[2].week).to.equal('2026-W10'); + expect(result.direction).to.equal('down'); + }); +}); + +describe('createOwnedUrlsHandler', () => { + let sandbox; + let getOrgAndValidateAccess; + let mockContext; + + beforeEach(() => { + sandbox = sinon.createSandbox(); + getOrgAndValidateAccess = sandbox.stub().resolves({ organization: {} }); + mockContext = { + params: { spaceCatId: '0178a3f0-1234-7000-8000-000000000001', brandId: 'all' }, + data: { siteId: '0178a3f0-1234-7000-8000-000000000099' }, + log: { info: sandbox.stub(), error: sandbox.stub(), warn: sandbox.stub() }, + dataAccess: { + Site: { postgrestService: null }, + }, + }; + }); + + afterEach(() => { + sandbox.restore(); + }); + + it('returns badRequest when postgrestService is missing', async () => { + mockContext.dataAccess.Site.postgrestService = null; + const handler = createOwnedUrlsHandler(getOrgAndValidateAccess); + const result = await handler(mockContext); + + expect(result.status).to.equal(400); + expect(getOrgAndValidateAccess).not.to.have.been.called; + }); + + it('returns forbidden when user has no org access', async () => { + mockContext.dataAccess.Site.postgrestService = createRpcMock(); + getOrgAndValidateAccess.rejects( + new Error('Only users belonging to the organization can view URL Inspector data'), + ); + + const handler = createOwnedUrlsHandler(getOrgAndValidateAccess); + const result = await handler(mockContext); + + expect(result.status).to.equal(403); + }); + + it('returns badRequest when siteId is missing', async () => { + mockContext.dataAccess.Site.postgrestService = createRpcMock(); + mockContext.data = {}; + + const handler = createOwnedUrlsHandler(getOrgAndValidateAccess); + const result = await handler(mockContext); + + expect(result.status).to.equal(400); + const body = await result.json(); + expect(body.message).to.include('siteId'); + }); + + it('returns badRequest when RPC returns error', async () => { + const rpcMock = createRpcMock({ data: null, error: { message: 'relation does not exist' } }); + mockContext.dataAccess.Site.postgrestService = rpcMock; + + const handler = createOwnedUrlsHandler(getOrgAndValidateAccess); + const result = await handler(mockContext); + + expect(result.status).to.equal(400); + const body = await result.json(); + expect(body.message).to.equal('relation does not exist'); + }); + + it('returns empty urls array when RPC returns no data', async () => { + const rpcMock = createRpcMock({ data: [], error: null }); + mockContext.dataAccess.Site.postgrestService = rpcMock; + + const handler = createOwnedUrlsHandler(getOrgAndValidateAccess); + const result = await handler(mockContext); + + expect(result.status).to.equal(200); + const body = await result.json(); + expect(body.urls).to.deep.equal([]); + }); + + it('returns empty urls array when RPC returns null data without error', async () => { + const rpcMock = createRpcMock({ data: null, error: null }); + mockContext.dataAccess.Site.postgrestService = rpcMock; + + const handler = createOwnedUrlsHandler(getOrgAndValidateAccess); + const result = await handler(mockContext); + + expect(result.status).to.equal(200); + const body = await result.json(); + expect(body.urls).to.deep.equal([]); + }); + + it('returns correctly shaped URL rows with trend computation', async () => { + const rpcData = [ + { + url: 'https://example.com/product/a', + citations: 45, + prompts_cited: 12, + products: ['Software', 'AI Tools'], + regions: ['US', 'UK'], + weekly_citations: [ + { week: '2026-W09', value: 20 }, + { week: '2026-W10', value: 25 }, + ], + weekly_prompts_cited: [ + { week: '2026-W09', value: 6 }, + { week: '2026-W10', value: 6 }, + ], + }, + { + url: 'https://example.com/product/b', + citations: 10, + prompts_cited: 3, + products: ['Hardware'], + regions: ['DE'], + weekly_citations: [ + { week: '2026-W09', value: 8 }, + { week: '2026-W10', value: 2 }, + ], + weekly_prompts_cited: [ + { week: '2026-W09', value: 3 }, + { week: '2026-W10', value: 1 }, + ], + }, + ]; + const rpcMock = createRpcMock({ data: rpcData, error: null }); + mockContext.dataAccess.Site.postgrestService = rpcMock; + mockContext.data = { + siteId: '0178a3f0-1234-7000-8000-000000000099', + startDate: '2026-02-23', + endDate: '2026-03-09', + }; + + const handler = createOwnedUrlsHandler(getOrgAndValidateAccess); + const result = await handler(mockContext); + + expect(result.status).to.equal(200); + const body = await result.json(); + expect(body.urls).to.have.length(2); + + const first = body.urls[0]; + expect(first.url).to.equal('https://example.com/product/a'); + expect(first.citations).to.equal(45); + expect(first.promptsCited).to.equal(12); + expect(first.products).to.deep.equal(['Software', 'AI Tools']); + expect(first.regions).to.deep.equal(['US', 'UK']); + expect(first.contentType).to.equal('owned'); + expect(first.citationsTrend.direction).to.equal('up'); + expect(first.citationsTrend.hasValidComparison).to.be.true; + expect(first.citationsTrend.weeklyValues).to.have.length(2); + expect(first.promptsCitedTrend.direction).to.equal('neutral'); + expect(first.promptsCitedTrend.hasValidComparison).to.be.true; + + const second = body.urls[1]; + expect(second.citationsTrend.direction).to.equal('down'); + expect(second.promptsCitedTrend.direction).to.equal('down'); + }); + + it('handles single-week data with hasValidComparison=false', async () => { + const rpcData = [ + { + url: 'https://example.com/page', + citations: 5, + prompts_cited: 2, + products: ['Software'], + regions: ['US'], + weekly_citations: [{ week: '2026-W10', value: 5 }], + weekly_prompts_cited: [{ week: '2026-W10', value: 2 }], + }, + ]; + const rpcMock = createRpcMock({ data: rpcData, error: null }); + mockContext.dataAccess.Site.postgrestService = rpcMock; + + const handler = createOwnedUrlsHandler(getOrgAndValidateAccess); + const result = await handler(mockContext); + + expect(result.status).to.equal(200); + const body = await result.json(); + expect(body.urls).to.have.length(1); + expect(body.urls[0].citationsTrend.hasValidComparison).to.be.false; + expect(body.urls[0].citationsTrend.direction).to.equal('neutral'); + expect(body.urls[0].promptsCitedTrend.hasValidComparison).to.be.false; + }); + + it('handles equal-week values with neutral direction', async () => { + const rpcData = [ + { + url: 'https://example.com/page', + citations: 20, + prompts_cited: 4, + products: [], + regions: [], + weekly_citations: [ + { week: '2026-W09', value: 10 }, + { week: '2026-W10', value: 10 }, + ], + weekly_prompts_cited: [ + { week: '2026-W09', value: 2 }, + { week: '2026-W10', value: 2 }, + ], + }, + ]; + const rpcMock = createRpcMock({ data: rpcData, error: null }); + mockContext.dataAccess.Site.postgrestService = rpcMock; + + const handler = createOwnedUrlsHandler(getOrgAndValidateAccess); + const result = await handler(mockContext); + + expect(result.status).to.equal(200); + const body = await result.json(); + expect(body.urls[0].citationsTrend.direction).to.equal('neutral'); + expect(body.urls[0].citationsTrend.hasValidComparison).to.be.true; + }); + + it('defaults null products and regions to empty arrays', async () => { + const rpcData = [ + { + url: 'https://example.com/page', + citations: 1, + prompts_cited: 1, + products: null, + regions: null, + weekly_citations: [{ week: '2026-W10', value: 1 }], + weekly_prompts_cited: [{ week: '2026-W10', value: 1 }], + }, + ]; + const rpcMock = createRpcMock({ data: rpcData, error: null }); + mockContext.dataAccess.Site.postgrestService = rpcMock; + + const handler = createOwnedUrlsHandler(getOrgAndValidateAccess); + const result = await handler(mockContext); + + expect(result.status).to.equal(200); + const body = await result.json(); + expect(body.urls[0].products).to.deep.equal([]); + expect(body.urls[0].regions).to.deep.equal([]); + }); + + it('passes optional filter params to RPC', async () => { + const rpcMock = createRpcMock({ data: [], error: null }); + mockContext.dataAccess.Site.postgrestService = rpcMock; + mockContext.data = { + siteId: '0178a3f0-1234-7000-8000-000000000099', + startDate: '2026-02-01', + endDate: '2026-03-01', + category: 'Software', + region: 'US', + platform: 'chatgpt', + }; + + const handler = createOwnedUrlsHandler(getOrgAndValidateAccess); + await handler(mockContext); + + expect(rpcMock.rpc).to.have.been.calledOnceWith( + 'rpc_url_inspector_owned_urls', + sinon.match({ + p_site_id: '0178a3f0-1234-7000-8000-000000000099', + p_start_date: '2026-02-01', + p_end_date: '2026-03-01', + p_category: 'Software', + p_region: 'US', + p_platform: 'chatgpt', + }), + ); + }); + + it('passes null for skipped filters (all, empty)', async () => { + const rpcMock = createRpcMock({ data: [], error: null }); + mockContext.dataAccess.Site.postgrestService = rpcMock; + mockContext.data = { + siteId: '0178a3f0-1234-7000-8000-000000000099', + category: 'all', + region: '', + platform: '*', + }; + + const handler = createOwnedUrlsHandler(getOrgAndValidateAccess); + await handler(mockContext); + + expect(rpcMock.rpc).to.have.been.calledOnceWith( + 'rpc_url_inspector_owned_urls', + sinon.match({ + p_category: null, + p_region: null, + p_platform: null, + }), + ); + }); + + it('passes brandId to RPC when a specific brand UUID is provided', async () => { + const brandUuid = '0178a3f0-bbbb-7000-8000-000000000001'; + const rpcMock = createRpcMock({ data: [], error: null }); + mockContext.dataAccess.Site.postgrestService = rpcMock; + mockContext.params.brandId = brandUuid; + + const handler = createOwnedUrlsHandler(getOrgAndValidateAccess); + await handler(mockContext); + + expect(rpcMock.rpc).to.have.been.calledOnceWith( + 'rpc_url_inspector_owned_urls', + sinon.match({ + p_brand_id: brandUuid, + }), + ); + }); + + it('passes null p_brand_id when brandId is "all"', async () => { + const rpcMock = createRpcMock({ data: [], error: null }); + mockContext.dataAccess.Site.postgrestService = rpcMock; + + const handler = createOwnedUrlsHandler(getOrgAndValidateAccess); + await handler(mockContext); + + expect(rpcMock.rpc).to.have.been.calledOnceWith( + 'rpc_url_inspector_owned_urls', + sinon.match({ + p_brand_id: null, + }), + ); + }); + + it('returns 500 when handler throws unexpected error', async () => { + const rpcMock = { rpc: sandbox.stub().rejects(new Error('connection refused')) }; + mockContext.dataAccess.Site.postgrestService = rpcMock; + + const handler = createOwnedUrlsHandler(getOrgAndValidateAccess); + const result = await handler(mockContext); + + expect(result.status).to.equal(500); + }); +}); diff --git a/test/controllers/llmo/llmo-url-inspector-stats.test.js b/test/controllers/llmo/llmo-url-inspector-stats.test.js new file mode 100644 index 000000000..cd50e77ec --- /dev/null +++ b/test/controllers/llmo/llmo-url-inspector-stats.test.js @@ -0,0 +1,456 @@ +/* + * Copyright 2026 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +/* eslint-env mocha */ +import { expect, use } from 'chai'; +import sinon from 'sinon'; +import sinonChai from 'sinon-chai'; +import { createStatsHandler } from '../../../src/controllers/llmo/llmo-url-inspector-stats.js'; + +use(sinonChai); + +const SITE_ID = '0178a3f0-1234-7000-8000-000000000001'; +const ORG_ID = '0178a3f0-aaaa-7000-8000-000000000001'; + +const aggRow = { + week: null, + week_number: null, + year_val: null, + total_prompts_cited: 42, + total_prompts: 120, + unique_urls: 15, + total_citations: 200, +}; + +const week1Row = { + week: '2026-W10', + week_number: 10, + year_val: 2026, + total_prompts_cited: 20, + total_prompts: 60, + unique_urls: 8, + total_citations: 95, +}; + +const week2Row = { + week: '2026-W11', + week_number: 11, + year_val: 2026, + total_prompts_cited: 22, + total_prompts: 60, + unique_urls: 10, + total_citations: 105, +}; + +function createRpcMock(resolveValue = { data: [], error: null }) { + return { + rpc: sinon.stub().resolves(resolveValue), + }; +} + +describe('llmo-url-inspector-stats', () => { + let sandbox; + let getOrgAndValidateAccess; + let mockContext; + + beforeEach(() => { + sandbox = sinon.createSandbox(); + getOrgAndValidateAccess = sandbox.stub().resolves({ organization: {} }); + mockContext = { + params: { spaceCatId: ORG_ID, brandId: 'all' }, + data: { siteId: SITE_ID }, + log: { info: sandbox.stub(), error: sandbox.stub(), warn: sandbox.stub() }, + dataAccess: { + Site: { postgrestService: null }, + }, + }; + }); + + afterEach(() => { + sandbox.restore(); + }); + + describe('createStatsHandler', () => { + it('returns badRequest when postgrestService is missing', async () => { + mockContext.dataAccess.Site.postgrestService = null; + const handler = createStatsHandler(getOrgAndValidateAccess); + const result = await handler(mockContext); + + expect(result.status).to.equal(400); + expect(getOrgAndValidateAccess).not.to.have.been.called; + }); + + it('returns forbidden when user has no org access', async () => { + const rpcMock = createRpcMock(); + mockContext.dataAccess.Site.postgrestService = rpcMock; + getOrgAndValidateAccess.rejects( + new Error('Only users belonging to the organization can view URL Inspector data'), + ); + + const handler = createStatsHandler(getOrgAndValidateAccess); + const result = await handler(mockContext); + + expect(result.status).to.equal(403); + }); + + it('returns badRequest when organization not found', async () => { + const rpcMock = createRpcMock(); + mockContext.dataAccess.Site.postgrestService = rpcMock; + getOrgAndValidateAccess.rejects(new Error('Organization not found: x')); + + const handler = createStatsHandler(getOrgAndValidateAccess); + const result = await handler(mockContext); + + expect(result.status).to.equal(400); + }); + + it('returns badRequest for generic org-validation errors', async () => { + const rpcMock = createRpcMock(); + mockContext.dataAccess.Site.postgrestService = rpcMock; + getOrgAndValidateAccess.rejects(new Error('unexpected database failure')); + + const handler = createStatsHandler(getOrgAndValidateAccess); + const result = await handler(mockContext); + + expect(result.status).to.equal(400); + expect(mockContext.log.error).to.have.been.called; + }); + + it('returns badRequest when siteId is missing', async () => { + const rpcMock = createRpcMock(); + mockContext.dataAccess.Site.postgrestService = rpcMock; + mockContext.data = {}; + + const handler = createStatsHandler(getOrgAndValidateAccess); + const result = await handler(mockContext); + + expect(result.status).to.equal(400); + const body = await result.json(); + expect(body.message).to.include('siteId'); + }); + + it('returns badRequest when RPC returns error', async () => { + const rpcMock = createRpcMock({ data: null, error: { message: 'relation does not exist' } }); + mockContext.dataAccess.Site.postgrestService = rpcMock; + + const handler = createStatsHandler(getOrgAndValidateAccess); + const result = await handler(mockContext); + + expect(result.status).to.equal(400); + const body = await result.json(); + expect(body.message).to.equal('relation does not exist'); + expect(mockContext.log.error).to.have.been.calledOnce; + }); + + it('returns ok with aggregate stats and weekly trends (happy path)', async () => { + const rpcMock = createRpcMock({ + data: [aggRow, week1Row, week2Row], + error: null, + }); + mockContext.dataAccess.Site.postgrestService = rpcMock; + mockContext.data = { + siteId: SITE_ID, + startDate: '2026-03-01', + endDate: '2026-03-15', + }; + + const handler = createStatsHandler(getOrgAndValidateAccess); + const result = await handler(mockContext); + + expect(result.status).to.equal(200); + const body = await result.json(); + + expect(body.totalPromptsCited).to.equal(42); + expect(body.totalPrompts).to.equal(120); + expect(body.uniqueUrls).to.equal(15); + expect(body.totalCitations).to.equal(200); + + expect(body.weeklyTrends).to.have.lengthOf(2); + expect(body.weeklyTrends[0]).to.deep.equal({ + week: '2026-W10', + weekNumber: 10, + year: 2026, + totalPromptsCited: 20, + totalPrompts: 60, + uniqueUrls: 8, + totalCitations: 95, + }); + expect(body.weeklyTrends[1]).to.deep.equal({ + week: '2026-W11', + weekNumber: 11, + year: 2026, + totalPromptsCited: 22, + totalPrompts: 60, + uniqueUrls: 10, + totalCitations: 105, + }); + }); + + it('returns all zeros when RPC returns empty data', async () => { + const rpcMock = createRpcMock({ data: [], error: null }); + mockContext.dataAccess.Site.postgrestService = rpcMock; + + const handler = createStatsHandler(getOrgAndValidateAccess); + const result = await handler(mockContext); + + expect(result.status).to.equal(200); + const body = await result.json(); + + expect(body.totalPromptsCited).to.equal(0); + expect(body.totalPrompts).to.equal(0); + expect(body.uniqueUrls).to.equal(0); + expect(body.totalCitations).to.equal(0); + expect(body.weeklyTrends).to.deep.equal([]); + }); + + it('returns all zeros when RPC returns null data', async () => { + const rpcMock = createRpcMock({ data: null, error: null }); + mockContext.dataAccess.Site.postgrestService = rpcMock; + + const handler = createStatsHandler(getOrgAndValidateAccess); + const result = await handler(mockContext); + + expect(result.status).to.equal(200); + const body = await result.json(); + + expect(body.totalPromptsCited).to.equal(0); + expect(body.totalPrompts).to.equal(0); + expect(body.uniqueUrls).to.equal(0); + expect(body.totalCitations).to.equal(0); + expect(body.weeklyTrends).to.deep.equal([]); + }); + + it('passes correct RPC params with all filters applied', async () => { + const rpcMock = createRpcMock({ data: [aggRow], error: null }); + mockContext.dataAccess.Site.postgrestService = rpcMock; + mockContext.data = { + siteId: SITE_ID, + startDate: '2026-01-01', + endDate: '2026-03-31', + category: 'Software', + region: 'US', + platform: 'chatgpt', + }; + + const handler = createStatsHandler(getOrgAndValidateAccess); + await handler(mockContext); + + expect(rpcMock.rpc).to.have.been.calledOnceWith( + 'rpc_url_inspector_stats', + sinon.match({ + p_site_id: SITE_ID, + p_start_date: '2026-01-01', + p_end_date: '2026-03-31', + p_category: 'Software', + p_region: 'US', + p_platform: 'chatgpt', + }), + ); + }); + + it('passes null for filters set to "all"', async () => { + const rpcMock = createRpcMock({ data: [aggRow], error: null }); + mockContext.dataAccess.Site.postgrestService = rpcMock; + mockContext.data = { + siteId: SITE_ID, + category: 'all', + region: 'all', + platform: 'all', + }; + + const handler = createStatsHandler(getOrgAndValidateAccess); + await handler(mockContext); + + expect(rpcMock.rpc).to.have.been.calledOnceWith( + 'rpc_url_inspector_stats', + sinon.match({ + p_site_id: SITE_ID, + p_start_date: null, + p_end_date: null, + p_category: null, + p_region: null, + p_platform: null, + }), + ); + }); + + it('passes null for empty string filters', async () => { + const rpcMock = createRpcMock({ data: [aggRow], error: null }); + mockContext.dataAccess.Site.postgrestService = rpcMock; + mockContext.data = { + siteId: SITE_ID, + category: '', + region: '', + platform: '', + }; + + const handler = createStatsHandler(getOrgAndValidateAccess); + await handler(mockContext); + + expect(rpcMock.rpc).to.have.been.calledOnceWith( + 'rpc_url_inspector_stats', + sinon.match({ + p_category: null, + p_region: null, + p_platform: null, + }), + ); + }); + + it('passes brandId to RPC when a specific brand UUID is provided', async () => { + const brandUuid = '0178a3f0-bbbb-7000-8000-000000000001'; + const rpcMock = createRpcMock({ data: [aggRow], error: null }); + mockContext.dataAccess.Site.postgrestService = rpcMock; + mockContext.params.brandId = brandUuid; + + const handler = createStatsHandler(getOrgAndValidateAccess); + await handler(mockContext); + + expect(rpcMock.rpc).to.have.been.calledOnceWith( + 'rpc_url_inspector_stats', + sinon.match({ + p_site_id: SITE_ID, + p_brand_id: brandUuid, + }), + ); + }); + + it('passes null p_brand_id when brandId is "all"', async () => { + const rpcMock = createRpcMock({ data: [aggRow], error: null }); + mockContext.dataAccess.Site.postgrestService = rpcMock; + + const handler = createStatsHandler(getOrgAndValidateAccess); + await handler(mockContext); + + expect(rpcMock.rpc).to.have.been.calledOnceWith( + 'rpc_url_inspector_stats', + sinon.match({ + p_brand_id: null, + }), + ); + }); + + it('handles aggregate-only response (no weekly rows)', async () => { + const rpcMock = createRpcMock({ data: [aggRow], error: null }); + mockContext.dataAccess.Site.postgrestService = rpcMock; + + const handler = createStatsHandler(getOrgAndValidateAccess); + const result = await handler(mockContext); + + expect(result.status).to.equal(200); + const body = await result.json(); + + expect(body.totalPromptsCited).to.equal(42); + expect(body.totalPrompts).to.equal(120); + expect(body.weeklyTrends).to.deep.equal([]); + }); + + it('handles rows with missing/null numeric fields gracefully', async () => { + const sparseRow = { + week: '2026-W10', + week_number: null, + year_val: null, + total_prompts_cited: null, + total_prompts: undefined, + unique_urls: 0, + total_citations: null, + }; + const rpcMock = createRpcMock({ data: [sparseRow], error: null }); + mockContext.dataAccess.Site.postgrestService = rpcMock; + + const handler = createStatsHandler(getOrgAndValidateAccess); + const result = await handler(mockContext); + + expect(result.status).to.equal(200); + const body = await result.json(); + + expect(body.totalPromptsCited).to.equal(0); + expect(body.totalPrompts).to.equal(0); + expect(body.uniqueUrls).to.equal(0); + expect(body.totalCitations).to.equal(0); + expect(body.weeklyTrends[0]).to.deep.equal({ + week: '2026-W10', + weekNumber: 0, + year: 0, + totalPromptsCited: 0, + totalPrompts: 0, + uniqueUrls: 0, + totalCitations: 0, + }); + }); + + it('returns 500 when handler throws unexpectedly', async () => { + const rpcMock = { + rpc: sinon.stub().rejects(new Error('connection reset')), + }; + mockContext.dataAccess.Site.postgrestService = rpcMock; + + const handler = createStatsHandler(getOrgAndValidateAccess); + const result = await handler(mockContext); + + expect(result.status).to.equal(500); + expect(mockContext.log.error).to.have.been.called; + }); + + it('handles missing context.params gracefully (defaults brandId to null)', async () => { + const rpcMock = createRpcMock({ data: [aggRow], error: null }); + mockContext.dataAccess.Site.postgrestService = rpcMock; + mockContext.params = undefined; + + const handler = createStatsHandler(getOrgAndValidateAccess); + const result = await handler(mockContext); + + expect(result.status).to.equal(200); + expect(rpcMock.rpc).to.have.been.calledOnceWith( + 'rpc_url_inspector_stats', + sinon.match({ p_brand_id: null }), + ); + }); + + it('handles null context.data gracefully', async () => { + const rpcMock = createRpcMock({ data: [], error: null }); + mockContext.dataAccess.Site.postgrestService = rpcMock; + mockContext.data = null; + + const handler = createStatsHandler(getOrgAndValidateAccess); + const result = await handler(mockContext); + + expect(result.status).to.equal(400); + const body = await result.json(); + expect(body.message).to.include('siteId'); + }); + + it('supports snake_case query param aliases', async () => { + const rpcMock = createRpcMock({ data: [aggRow], error: null }); + mockContext.dataAccess.Site.postgrestService = rpcMock; + mockContext.data = { + site_id: SITE_ID, + start_date: '2026-01-01', + end_date: '2026-03-31', + content_type: 'owned', + }; + + const handler = createStatsHandler(getOrgAndValidateAccess); + const result = await handler(mockContext); + + expect(result.status).to.equal(200); + expect(rpcMock.rpc).to.have.been.calledOnceWith( + 'rpc_url_inspector_stats', + sinon.match({ + p_site_id: SITE_ID, + p_start_date: '2026-01-01', + p_end_date: '2026-03-31', + }), + ); + }); + }); +}); diff --git a/test/controllers/llmo/llmo-url-inspector-trending-urls.test.js b/test/controllers/llmo/llmo-url-inspector-trending-urls.test.js new file mode 100644 index 000000000..cd02caf47 --- /dev/null +++ b/test/controllers/llmo/llmo-url-inspector-trending-urls.test.js @@ -0,0 +1,422 @@ +/* + * Copyright 2026 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +/* eslint-env mocha */ +import { expect, use } from 'chai'; +import sinon from 'sinon'; +import sinonChai from 'sinon-chai'; +import { + assembleResponse, + createTrendingUrlsHandler, +} from '../../../src/controllers/llmo/llmo-url-inspector-trending-urls.js'; + +use(sinonChai); + +function createRpcMock(rpcResolveValue = { data: [], error: null }) { + const rpcStub = sinon.stub().resolves(rpcResolveValue); + return { rpc: rpcStub }; +} + +function makeContext(overrides = {}) { + return { + params: { spaceCatId: '0178a3f0-1234-7000-8000-000000000001', brandId: 'all' }, + data: { + siteId: 'site-001', + startDate: '2026-01-01', + endDate: '2026-03-01', + ...overrides, + }, + log: { + info: sinon.stub(), + error: sinon.stub(), + warn: sinon.stub(), + }, + dataAccess: { + Site: { postgrestService: null }, + }, + }; +} + +function makeRows({ + total = 2, urlA = 'https://competitor.com/page', urlB = 'https://other.com/page', +} = {}) { + return [ + { + total_non_owned_urls: total, + url: urlA, + content_type: 'competitor', + prompt: 'best tools 2026', + category: 'Software', + region: 'US', + topics: 'Software', + citation_count: 5, + execution_count: 2, + }, + { + total_non_owned_urls: total, + url: urlA, + content_type: 'competitor', + prompt: 'top security software', + category: 'Security', + region: 'DE', + topics: 'Security', + citation_count: 3, + execution_count: 1, + }, + { + total_non_owned_urls: total, + url: urlB, + content_type: 'earned', + prompt: 'best tools 2026', + category: 'Software', + region: 'US', + topics: 'Software', + citation_count: 2, + execution_count: 1, + }, + ]; +} + +describe('llmo-url-inspector-trending-urls', () => { + let sandbox; + let getOrgAndValidateAccess; + + beforeEach(() => { + sandbox = sinon.createSandbox(); + getOrgAndValidateAccess = sandbox.stub().resolves({ organization: {} }); + }); + + afterEach(() => { + sandbox.restore(); + }); + + describe('assembleResponse', () => { + it('returns empty structure for null/empty input', () => { + expect(assembleResponse(null)).to.deep.equal({ totalNonOwnedUrls: 0, urls: [] }); + expect(assembleResponse([])).to.deep.equal({ totalNonOwnedUrls: 0, urls: [] }); + }); + + it('groups rows by URL with correct aggregation', () => { + const rows = makeRows({ total: 50 }); + const result = assembleResponse(rows); + + expect(result.totalNonOwnedUrls).to.equal(50); + expect(result.urls).to.have.lengthOf(2); + + const first = result.urls[0]; + expect(first.url).to.equal('https://competitor.com/page'); + expect(first.citations).to.equal(8); + expect(first.promptsCited).to.equal(2); + expect(first.products).to.include('Software'); + expect(first.products).to.include('Security'); + expect(first.regions).to.include('US'); + expect(first.regions).to.include('DE'); + expect(first.promptCitations).to.have.lengthOf(2); + }); + + it('sorts URLs by citations descending', () => { + const rows = makeRows(); + const result = assembleResponse(rows); + + expect(result.urls[0].citations).to.be.greaterThanOrEqual(result.urls[1].citations); + }); + + it('maps competitor content type to others', () => { + const rows = makeRows(); + const result = assembleResponse(rows); + + const competitorUrl = result.urls.find((u) => u.url === 'https://competitor.com/page'); + expect(competitorUrl.contentType).to.equal('others'); + }); + + it('preserves earned content type', () => { + const rows = makeRows(); + const result = assembleResponse(rows); + + const earnedUrl = result.urls.find((u) => u.url === 'https://other.com/page'); + expect(earnedUrl.contentType).to.equal('earned'); + }); + + it('builds promptCitations with correct shape', () => { + const rows = makeRows(); + const result = assembleResponse(rows); + + const { promptCitations } = result.urls[0]; + const firstPrompt = promptCitations[0]; + expect(firstPrompt).to.have.all.keys('prompt', 'count', 'id', 'products', 'topics', 'region', 'executionCount'); + expect(firstPrompt.prompt).to.equal('best tools 2026'); + expect(firstPrompt.count).to.equal(5); + expect(firstPrompt.id).to.equal('Software_best tools 2026_US'); + expect(firstPrompt.products).to.deep.equal(['Software']); + expect(firstPrompt.topics).to.equal('Software'); + expect(firstPrompt.region).to.equal('US'); + expect(firstPrompt.executionCount).to.equal(2); + }); + + it('handles rows with null category/region/topics', () => { + const rows = [ + { + total_non_owned_urls: 1, + url: 'https://example.com', + content_type: 'social', + prompt: 'some prompt', + category: null, + region: null, + topics: null, + citation_count: 3, + execution_count: 1, + }, + ]; + const result = assembleResponse(rows); + + expect(result.urls).to.have.lengthOf(1); + expect(result.urls[0].products).to.deep.equal([]); + expect(result.urls[0].regions).to.deep.equal([]); + expect(result.urls[0].promptCitations[0].id).to.equal('_some prompt_'); + expect(result.urls[0].promptCitations[0].topics).to.equal(''); + expect(result.urls[0].promptCitations[0].region).to.equal(''); + }); + + it('handles rows with null/NaN numeric fields and null prompt', () => { + const rows = [ + { + total_non_owned_urls: null, + url: 'https://example.com', + content_type: 'earned', + prompt: null, + category: null, + region: null, + topics: null, + citation_count: null, + execution_count: null, + }, + ]; + const result = assembleResponse(rows); + + expect(result.totalNonOwnedUrls).to.equal(0); + expect(result.urls[0].citations).to.equal(0); + expect(result.urls[0].promptCitations[0].count).to.equal(0); + expect(result.urls[0].promptCitations[0].executionCount).to.equal(0); + expect(result.urls[0].promptCitations[0].id).to.equal('__'); + }); + }); + + describe('createTrendingUrlsHandler', () => { + let mockContext; + + beforeEach(() => { + mockContext = makeContext(); + }); + + it('returns badRequest when postgrestService is missing', async () => { + const ctx = makeContext(); + ctx.dataAccess.Site.postgrestService = null; + + const handler = createTrendingUrlsHandler(getOrgAndValidateAccess); + const result = await handler(ctx); + + expect(result.status).to.equal(400); + }); + + it('returns forbidden when user has no org access', async () => { + const rpcMock = createRpcMock(); + const ctx = makeContext(); + ctx.dataAccess.Site.postgrestService = rpcMock; + getOrgAndValidateAccess.rejects( + new Error('Only users belonging to the organization can view URL Inspector data'), + ); + + const handler = createTrendingUrlsHandler(getOrgAndValidateAccess); + const result = await handler(ctx); + + expect(result.status).to.equal(403); + }); + + it('returns badRequest when siteId is missing', async () => { + const rpcMock = createRpcMock(); + const ctx = makeContext(); + ctx.data = { startDate: '2026-01-01', endDate: '2026-03-01' }; + ctx.dataAccess.Site.postgrestService = rpcMock; + + const handler = createTrendingUrlsHandler(getOrgAndValidateAccess); + const result = await handler(ctx); + + expect(result.status).to.equal(400); + }); + + it('returns ok with empty structure when RPC returns no rows', async () => { + const rpcMock = createRpcMock({ data: [], error: null }); + const ctx = makeContext(); + ctx.dataAccess.Site.postgrestService = rpcMock; + + const handler = createTrendingUrlsHandler(getOrgAndValidateAccess); + const result = await handler(ctx); + + expect(result.status).to.equal(200); + const body = await result.json(); + expect(body).to.deep.equal({ totalNonOwnedUrls: 0, urls: [] }); + }); + + it('returns ok with grouped trending URLs on happy path', async () => { + const rows = makeRows({ total: 100 }); + const rpcMock = createRpcMock({ data: rows, error: null }); + const ctx = makeContext(); + ctx.dataAccess.Site.postgrestService = rpcMock; + + const handler = createTrendingUrlsHandler(getOrgAndValidateAccess); + const result = await handler(ctx); + + expect(result.status).to.equal(200); + const body = await result.json(); + expect(body.totalNonOwnedUrls).to.equal(100); + expect(body.urls).to.have.lengthOf(2); + expect(body.urls[0].citations).to.equal(8); + expect(body.urls[0].contentType).to.equal('others'); + expect(body.urls[1].contentType).to.equal('earned'); + + expect(rpcMock.rpc).to.have.been.calledOnceWith( + 'rpc_url_inspector_trending_urls', + sinon.match.object, + ); + }); + + it('passes default limit of 2000 when not specified', async () => { + const rpcMock = createRpcMock({ data: [], error: null }); + const ctx = makeContext(); + ctx.dataAccess.Site.postgrestService = rpcMock; + + const handler = createTrendingUrlsHandler(getOrgAndValidateAccess); + await handler(ctx); + + const rpcArgs = rpcMock.rpc.firstCall.args[1]; + expect(rpcArgs.p_limit).to.equal(2000); + }); + + it('passes custom limit when specified', async () => { + const rpcMock = createRpcMock({ data: [], error: null }); + const ctx = makeContext({ limit: '500' }); + ctx.dataAccess.Site.postgrestService = rpcMock; + + const handler = createTrendingUrlsHandler(getOrgAndValidateAccess); + await handler(ctx); + + const rpcArgs = rpcMock.rpc.firstCall.args[1]; + expect(rpcArgs.p_limit).to.equal(500); + }); + + it('maps channel filter "others" to "competitor" for DB query', async () => { + const rpcMock = createRpcMock({ data: [], error: null }); + const ctx = makeContext({ channel: 'others' }); + ctx.dataAccess.Site.postgrestService = rpcMock; + + const handler = createTrendingUrlsHandler(getOrgAndValidateAccess); + await handler(ctx); + + const rpcArgs = rpcMock.rpc.firstCall.args[1]; + expect(rpcArgs.p_channel).to.equal('competitor'); + }); + + it('passes null for skip-value filters', async () => { + const rpcMock = createRpcMock({ data: [], error: null }); + const ctx = makeContext({ category: 'all', region: '*', channel: '' }); + ctx.dataAccess.Site.postgrestService = rpcMock; + + const handler = createTrendingUrlsHandler(getOrgAndValidateAccess); + await handler(ctx); + + const rpcArgs = rpcMock.rpc.firstCall.args[1]; + expect(rpcArgs.p_category).to.be.null; + expect(rpcArgs.p_region).to.be.null; + expect(rpcArgs.p_channel).to.be.null; + }); + + it('returns badRequest when RPC returns an error', async () => { + const rpcMock = createRpcMock({ + data: null, + error: { message: 'relation "brand_presence_sources" does not exist' }, + }); + const ctx = makeContext(); + ctx.dataAccess.Site.postgrestService = rpcMock; + + const handler = createTrendingUrlsHandler(getOrgAndValidateAccess); + const result = await handler(ctx); + + expect(result.status).to.equal(400); + const body = await result.json(); + expect(body.message).to.include('does not exist'); + }); + + it('returns internalServerError when handler throws unexpectedly', async () => { + const rpcMock = { + rpc: sinon.stub().rejects(new Error('connection reset')), + }; + const ctx = makeContext(); + ctx.dataAccess.Site.postgrestService = rpcMock; + + const handler = createTrendingUrlsHandler(getOrgAndValidateAccess); + const result = await handler(ctx); + + expect(result.status).to.equal(500); + }); + + it('passes filter params correctly to RPC', async () => { + const rpcMock = createRpcMock({ data: [], error: null }); + const ctx = makeContext({ + category: 'Security', + region: 'US', + channel: 'earned', + platform: 'chatgpt', + }); + ctx.dataAccess.Site.postgrestService = rpcMock; + + const handler = createTrendingUrlsHandler(getOrgAndValidateAccess); + await handler(ctx); + + const rpcArgs = rpcMock.rpc.firstCall.args[1]; + expect(rpcArgs.p_site_id).to.equal('site-001'); + expect(rpcArgs.p_start_date).to.equal('2026-01-01'); + expect(rpcArgs.p_end_date).to.equal('2026-03-01'); + expect(rpcArgs.p_category).to.equal('Security'); + expect(rpcArgs.p_region).to.equal('US'); + expect(rpcArgs.p_channel).to.equal('earned'); + expect(rpcArgs.p_platform).to.equal('chatgpt'); + }); + + it('passes brandId to RPC when a specific brand UUID is provided', async () => { + const brandUuid = '0178a3f0-bbbb-7000-8000-000000000001'; + const rpcMock = createRpcMock({ data: [], error: null }); + mockContext.dataAccess.Site.postgrestService = rpcMock; + mockContext.params.brandId = brandUuid; + + const handler = createTrendingUrlsHandler(getOrgAndValidateAccess); + await handler(mockContext); + + expect(rpcMock.rpc).to.have.been.calledOnceWith( + 'rpc_url_inspector_trending_urls', + sinon.match({ p_brand_id: brandUuid }), + ); + }); + + it('passes null p_brand_id when brandId is "all"', async () => { + const rpcMock = createRpcMock({ data: [], error: null }); + mockContext.dataAccess.Site.postgrestService = rpcMock; + + const handler = createTrendingUrlsHandler(getOrgAndValidateAccess); + await handler(mockContext); + + expect(rpcMock.rpc).to.have.been.calledOnceWith( + 'rpc_url_inspector_trending_urls', + sinon.match({ p_brand_id: null }), + ); + }); + }); +}); diff --git a/test/controllers/llmo/llmo-url-inspector-url-details.test.js b/test/controllers/llmo/llmo-url-inspector-url-details.test.js new file mode 100644 index 000000000..b6eacc576 --- /dev/null +++ b/test/controllers/llmo/llmo-url-inspector-url-details.test.js @@ -0,0 +1,600 @@ +/* + * Copyright 2026 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +/* eslint-env mocha */ +import { expect, use } from 'chai'; +import sinon from 'sinon'; +import sinonChai from 'sinon-chai'; +import { + createUrlDetailsHandler, + dateToIsoWeek, + parseIsoWeek, + aggregateUrlDetails, +} from '../../../src/controllers/llmo/llmo-url-inspector-url-details.js'; + +use(sinonChai); + +function createChainableMock(resolveValue = { data: [], error: null }) { + const c = { + from: sinon.stub().returnsThis(), + select: sinon.stub().returnsThis(), + eq: sinon.stub().returnsThis(), + gte: sinon.stub().returnsThis(), + lte: sinon.stub().returnsThis(), + limit: sinon.stub().resolves(resolveValue), + then(resolve) { return Promise.resolve(resolveValue).then(resolve); }, + }; + return c; +} + +describe('llmo-url-inspector-url-details', () => { + let sandbox; + let getOrgAndValidateAccess; + let mockContext; + + beforeEach(() => { + sandbox = sinon.createSandbox(); + getOrgAndValidateAccess = sandbox.stub().resolves({ organization: {} }); + mockContext = { + params: { + spaceCatId: '0178a3f0-1234-7000-8000-000000000001', + brandId: 'all', + }, + data: { + siteId: 'site-001', + url: 'https://example.com/product/page', + }, + log: { info: sandbox.stub(), error: sandbox.stub(), warn: sandbox.stub() }, + dataAccess: { + Site: { + postgrestService: null, + }, + }, + }; + }); + + afterEach(() => { + sandbox.restore(); + }); + + // ── Pure helpers ────────────────────────────────────────────────────── + + describe('dateToIsoWeek', () => { + it('converts a Monday to the correct ISO week', () => { + expect(dateToIsoWeek('2026-03-02')).to.equal('2026-W10'); + }); + + it('converts a Sunday to the same week as its Monday', () => { + expect(dateToIsoWeek('2026-03-08')).to.equal('2026-W10'); + }); + + it('handles year boundary (Jan 1 2026 is W01)', () => { + expect(dateToIsoWeek('2026-01-01')).to.equal('2026-W01'); + }); + + it('handles late December that falls in week 1 of next year', () => { + expect(dateToIsoWeek('2025-12-29')).to.equal('2026-W01'); + }); + }); + + describe('parseIsoWeek', () => { + it('extracts year and weekNumber from valid week string', () => { + expect(parseIsoWeek('2026-W09')).to.deep.equal({ year: 2026, weekNumber: 9 }); + }); + + it('returns zeroes for invalid format', () => { + expect(parseIsoWeek('invalid')).to.deep.equal({ year: 0, weekNumber: 0 }); + }); + + it('returns zeroes for null', () => { + expect(parseIsoWeek(null)).to.deep.equal({ year: 0, weekNumber: 0 }); + }); + }); + + // ── Aggregation logic ──────────────────────────────────────────────── + + describe('aggregateUrlDetails', () => { + const baseParams = { + url: 'https://example.com/page', + category: undefined, + region: undefined, + channel: undefined, + }; + + it('returns zeroed response for empty rows', () => { + const result = aggregateUrlDetails([], baseParams); + + expect(result.url).to.equal('https://example.com/page'); + expect(result.isOwned).to.equal(false); + expect(result.totalCitations).to.equal(0); + expect(result.promptsCited).to.equal(0); + expect(result.products).to.deep.equal([]); + expect(result.regions).to.deep.equal([]); + expect(result.promptCitations).to.deep.equal([]); + expect(result.weeklyTrends).to.deep.equal([]); + }); + + it('determines isOwned=true when any row has content_type owned', () => { + const rows = [ + { + content_type: 'owned', prompt: 'p1', citation_count: 5, category: 'Software', region: 'US', topics: 't1', week: '2026-W09', + }, + { + content_type: 'earned', prompt: 'p2', citation_count: 3, category: 'AI', region: 'UK', topics: 't2', week: '2026-W10', + }, + ]; + const result = aggregateUrlDetails(rows, baseParams); + expect(result.isOwned).to.equal(true); + }); + + it('determines isOwned=false when no row has content_type owned', () => { + const rows = [ + { + content_type: 'earned', prompt: 'p1', citation_count: 5, category: 'Software', region: 'US', topics: 't1', week: '2026-W09', + }, + ]; + const result = aggregateUrlDetails(rows, baseParams); + expect(result.isOwned).to.equal(false); + }); + + it('computes totalCitations as sum of citation_count', () => { + const rows = [ + { + content_type: 'owned', prompt: 'p1', citation_count: 10, category: 'A', region: 'US', topics: 't', week: '2026-W09', + }, + { + content_type: 'owned', prompt: 'p2', citation_count: 15, category: 'B', region: 'UK', topics: 't', week: '2026-W09', + }, + ]; + const result = aggregateUrlDetails(rows, baseParams); + expect(result.totalCitations).to.equal(25); + }); + + it('counts distinct prompt|region|topics combos for promptsCited', () => { + const rows = [ + { + content_type: 'owned', prompt: 'p1', citation_count: 5, category: 'A', region: 'US', topics: 't1', week: '2026-W09', + }, + { + content_type: 'owned', prompt: 'p1', citation_count: 3, category: 'A', region: 'US', topics: 't1', week: '2026-W10', + }, + { + content_type: 'owned', prompt: 'p2', citation_count: 7, category: 'B', region: 'UK', topics: 't2', week: '2026-W09', + }, + ]; + const result = aggregateUrlDetails(rows, baseParams); + expect(result.promptsCited).to.equal(2); + }); + + it('collects unique products and regions', () => { + const rows = [ + { + content_type: 'owned', prompt: 'p1', citation_count: 1, category: 'Software', region: 'US', topics: 't', week: '2026-W09', + }, + { + content_type: 'owned', prompt: 'p2', citation_count: 1, category: 'AI Tools', region: 'UK', topics: 't', week: '2026-W09', + }, + { + content_type: 'owned', prompt: 'p3', citation_count: 1, category: 'Software', region: 'US', topics: 't', week: '2026-W10', + }, + ]; + const result = aggregateUrlDetails(rows, baseParams); + expect(result.products).to.include.members(['Software', 'AI Tools']); + expect(result.products).to.have.lengthOf(2); + expect(result.regions).to.include.members(['US', 'UK']); + expect(result.regions).to.have.lengthOf(2); + }); + + it('groups prompt citations and sorts by count descending', () => { + const rows = [ + { + content_type: 'owned', prompt: 'low prompt', citation_count: 2, category: 'A', region: 'US', topics: 'topic1', week: '2026-W09', + }, + { + content_type: 'owned', prompt: 'high prompt', citation_count: 10, category: 'B', region: 'UK', topics: 'topic2', week: '2026-W09', + }, + { + content_type: 'owned', prompt: 'high prompt', citation_count: 8, category: 'B', region: 'UK', topics: 'topic2', week: '2026-W10', + }, + ]; + const result = aggregateUrlDetails(rows, baseParams); + + expect(result.promptCitations).to.have.lengthOf(2); + expect(result.promptCitations[0].prompt).to.equal('high prompt'); + expect(result.promptCitations[0].count).to.equal(18); + expect(result.promptCitations[0].id).to.equal('topic2_high prompt_UK'); + expect(result.promptCitations[0].products).to.deep.equal(['B']); + expect(result.promptCitations[0].topics).to.equal('topic2'); + expect(result.promptCitations[0].region).to.equal('UK'); + expect(result.promptCitations[1].prompt).to.equal('low prompt'); + expect(result.promptCitations[1].count).to.equal(2); + }); + + it('computes executionCount as number of distinct weeks per prompt group', () => { + const rows = [ + { + content_type: 'owned', prompt: 'p1', citation_count: 5, category: 'A', region: 'US', topics: 't1', week: '2026-W09', + }, + { + content_type: 'owned', prompt: 'p1', citation_count: 3, category: 'A', region: 'US', topics: 't1', week: '2026-W10', + }, + { + content_type: 'owned', prompt: 'p1', citation_count: 4, category: 'A', region: 'US', topics: 't1', week: '2026-W11', + }, + ]; + const result = aggregateUrlDetails(rows, baseParams); + + expect(result.promptCitations).to.have.lengthOf(1); + expect(result.promptCitations[0].executionCount).to.equal(3); + expect(result.promptCitations[0].count).to.equal(12); + }); + + it('builds weekly trends sorted chronologically with weekNumber and year', () => { + const rows = [ + { + content_type: 'owned', prompt: 'p1', citation_count: 10, category: 'A', region: 'US', topics: 't1', week: '2026-W11', + }, + { + content_type: 'owned', prompt: 'p1', citation_count: 5, category: 'A', region: 'US', topics: 't1', week: '2026-W09', + }, + { + content_type: 'owned', prompt: 'p2', citation_count: 7, category: 'B', region: 'UK', topics: 't2', week: '2026-W09', + }, + { + content_type: 'owned', prompt: 'p1', citation_count: 8, category: 'A', region: 'US', topics: 't1', week: '2026-W10', + }, + ]; + const result = aggregateUrlDetails(rows, baseParams); + + expect(result.weeklyTrends).to.have.lengthOf(3); + expect(result.weeklyTrends[0].week).to.equal('2026-W09'); + expect(result.weeklyTrends[0].totalCitations).to.equal(12); + expect(result.weeklyTrends[0].totalPromptsCited).to.equal(2); + expect(result.weeklyTrends[0].uniqueUrls).to.equal(1); + expect(result.weeklyTrends[0].weekNumber).to.equal(9); + expect(result.weeklyTrends[0].year).to.equal(2026); + + expect(result.weeklyTrends[1].week).to.equal('2026-W10'); + expect(result.weeklyTrends[1].totalCitations).to.equal(8); + + expect(result.weeklyTrends[2].week).to.equal('2026-W11'); + expect(result.weeklyTrends[2].totalCitations).to.equal(10); + }); + + it('sets uniqueUrls to 1 for all weekly trend entries', () => { + const rows = [ + { + content_type: 'owned', prompt: 'p1', citation_count: 5, category: 'A', region: 'US', topics: 't', week: '2026-W09', + }, + { + content_type: 'owned', prompt: 'p1', citation_count: 3, category: 'A', region: 'US', topics: 't', week: '2026-W10', + }, + ]; + const result = aggregateUrlDetails(rows, baseParams); + result.weeklyTrends.forEach((w) => { + expect(w.uniqueUrls).to.equal(1); + }); + }); + + it('applies channel filter but still determines isOwned from unfiltered data', () => { + const rows = [ + { + content_type: 'owned', prompt: 'p1', citation_count: 10, category: 'A', region: 'US', topics: 't', week: '2026-W09', + }, + { + content_type: 'earned', prompt: 'p2', citation_count: 5, category: 'B', region: 'UK', topics: 't', week: '2026-W09', + }, + ]; + const result = aggregateUrlDetails(rows, { ...baseParams, channel: 'earned' }); + + expect(result.isOwned).to.equal(true); + expect(result.totalCitations).to.equal(5); + expect(result.promptCitations).to.have.lengthOf(1); + expect(result.promptCitations[0].prompt).to.equal('p2'); + }); + + it('applies category filter to narrow prompt citations', () => { + const rows = [ + { + content_type: 'owned', prompt: 'p1', citation_count: 10, category: 'Software', region: 'US', topics: 't', week: '2026-W09', + }, + { + content_type: 'owned', prompt: 'p2', citation_count: 5, category: 'AI Tools', region: 'UK', topics: 't', week: '2026-W09', + }, + ]; + const result = aggregateUrlDetails(rows, { ...baseParams, category: 'Software' }); + + expect(result.totalCitations).to.equal(10); + expect(result.promptCitations).to.have.lengthOf(1); + expect(result.promptCitations[0].prompt).to.equal('p1'); + }); + + it('applies region filter to narrow prompt citations', () => { + const rows = [ + { + content_type: 'owned', prompt: 'p1', citation_count: 10, category: 'A', region: 'US', topics: 't', week: '2026-W09', + }, + { + content_type: 'owned', prompt: 'p2', citation_count: 5, category: 'B', region: 'UK', topics: 't', week: '2026-W09', + }, + ]; + const result = aggregateUrlDetails(rows, { ...baseParams, region: 'UK' }); + + expect(result.totalCitations).to.equal(5); + expect(result.regions).to.deep.equal(['UK']); + }); + + it('handles rows with null citation_count gracefully', () => { + const rows = [ + { + content_type: 'owned', prompt: 'p1', citation_count: null, category: 'A', region: 'US', topics: 't', week: '2026-W09', + }, + { + content_type: 'owned', prompt: 'p2', citation_count: 5, category: 'B', region: 'UK', topics: 't', week: '2026-W09', + }, + ]; + const result = aggregateUrlDetails(rows, baseParams); + expect(result.totalCitations).to.equal(5); + }); + + it('defaults topics and region to empty string when null', () => { + const rows = [ + { + content_type: 'owned', prompt: 'p1', citation_count: 5, category: 'A', region: null, topics: null, week: '2026-W09', + }, + ]; + const result = aggregateUrlDetails(rows, baseParams); + + expect(result.promptCitations).to.have.lengthOf(1); + expect(result.promptCitations[0].topics).to.equal(''); + expect(result.promptCitations[0].region).to.equal(''); + expect(result.promptCitations[0].id).to.equal('null_p1_null'); + }); + + it('handles rows with null week by excluding from weekly trends', () => { + const rows = [ + { + content_type: 'owned', prompt: 'p1', citation_count: 5, category: 'A', region: 'US', topics: 't', week: null, + }, + { + content_type: 'owned', prompt: 'p2', citation_count: 3, category: 'B', region: 'UK', topics: 't', week: '2026-W09', + }, + ]; + const result = aggregateUrlDetails(rows, baseParams); + expect(result.weeklyTrends).to.have.lengthOf(1); + expect(result.totalCitations).to.equal(8); + }); + }); + + // ── Handler integration ────────────────────────────────────────────── + + describe('createUrlDetailsHandler', () => { + it('returns 400 when postgrestService is missing', async () => { + mockContext.dataAccess.Site.postgrestService = null; + const handler = createUrlDetailsHandler(getOrgAndValidateAccess); + const result = await handler(mockContext); + + expect(result.status).to.equal(400); + expect(getOrgAndValidateAccess).not.to.have.been.called; + }); + + it('returns 403 when user has no org access', async () => { + mockContext.dataAccess.Site.postgrestService = createChainableMock(); + getOrgAndValidateAccess.rejects(new Error('Only users belonging to the organization can view URL Inspector data')); + + const handler = createUrlDetailsHandler(getOrgAndValidateAccess); + const result = await handler(mockContext); + + expect(result.status).to.equal(403); + }); + + it('returns 400 when siteId is missing', async () => { + mockContext.data = { url: 'https://example.com/page' }; + mockContext.dataAccess.Site.postgrestService = createChainableMock(); + + const handler = createUrlDetailsHandler(getOrgAndValidateAccess); + const result = await handler(mockContext); + + expect(result.status).to.equal(400); + const body = await result.json(); + expect(body.message).to.include('siteId'); + }); + + it('returns 400 when url is missing', async () => { + mockContext.data = { siteId: 'site-001' }; + mockContext.dataAccess.Site.postgrestService = createChainableMock(); + + const handler = createUrlDetailsHandler(getOrgAndValidateAccess); + const result = await handler(mockContext); + + expect(result.status).to.equal(400); + const body = await result.json(); + expect(body.message).to.include('url'); + }); + + it('returns 200 with correct shape for owned URL', async () => { + const sourceRows = [ + { + content_type: 'owned', + execution_date: '2026-03-02', + source_urls: { url: 'https://example.com/product/page', hostname: 'example.com' }, + brand_presence_executions: { + prompt: 'best tools', category_name: 'Software', region_code: 'US', topics: 'project management', + }, + }, + { + content_type: 'owned', + execution_date: '2026-03-09', + source_urls: { url: 'https://example.com/product/page', hostname: 'example.com' }, + brand_presence_executions: { + prompt: 'best tools', category_name: 'Software', region_code: 'US', topics: 'project management', + }, + }, + { + content_type: 'owned', + execution_date: '2026-03-02', + source_urls: { url: 'https://example.com/product/page', hostname: 'example.com' }, + brand_presence_executions: { + prompt: 'top software', category_name: 'AI Tools', region_code: 'UK', topics: 'ai', + }, + }, + ]; + mockContext.dataAccess.Site.postgrestService = createChainableMock({ + data: sourceRows, + error: null, + }); + + const handler = createUrlDetailsHandler(getOrgAndValidateAccess); + const result = await handler(mockContext); + + expect(result.status).to.equal(200); + const body = await result.json(); + + expect(body.url).to.equal('https://example.com/product/page'); + expect(body.isOwned).to.equal(true); + expect(body.totalCitations).to.equal(3); + expect(body.promptsCited).to.equal(2); + expect(body.products).to.include.members(['Software', 'AI Tools']); + expect(body.regions).to.include.members(['US', 'UK']); + expect(body.promptCitations).to.have.lengthOf(2); + expect(body.promptCitations[0].count).to.be.at.least(body.promptCitations[1].count); + expect(body.weeklyTrends).to.have.lengthOf(2); + }); + + it('returns 200 with isOwned=false for non-owned URL', async () => { + const sourceRows = [ + { + content_type: 'earned', + execution_date: '2026-03-02', + source_urls: { url: 'https://example.com/product/page', hostname: 'example.com' }, + brand_presence_executions: { + prompt: 'p1', category_name: 'A', region_code: 'US', topics: 't', + }, + }, + ]; + mockContext.dataAccess.Site.postgrestService = createChainableMock({ + data: sourceRows, + error: null, + }); + + const handler = createUrlDetailsHandler(getOrgAndValidateAccess); + const result = await handler(mockContext); + + expect(result.status).to.equal(200); + const body = await result.json(); + expect(body.isOwned).to.equal(false); + }); + + it('returns 200 with zeroed response for empty result', async () => { + mockContext.dataAccess.Site.postgrestService = createChainableMock({ + data: [], + error: null, + }); + + const handler = createUrlDetailsHandler(getOrgAndValidateAccess); + const result = await handler(mockContext); + + expect(result.status).to.equal(200); + const body = await result.json(); + expect(body.isOwned).to.equal(false); + expect(body.totalCitations).to.equal(0); + expect(body.promptsCited).to.equal(0); + expect(body.promptCitations).to.deep.equal([]); + expect(body.weeklyTrends).to.deep.equal([]); + }); + + it('returns 400 when PostgREST returns an error', async () => { + mockContext.dataAccess.Site.postgrestService = createChainableMock({ + data: null, + error: { message: 'relation does not exist' }, + }); + + const handler = createUrlDetailsHandler(getOrgAndValidateAccess); + const result = await handler(mockContext); + + expect(result.status).to.equal(400); + }); + + it('applies date range filters to execution_date', async () => { + const chainMock = createChainableMock({ data: [], error: null }); + mockContext.data = { + siteId: 'site-001', + url: 'https://example.com/page', + startDate: '2026-03-02', + endDate: '2026-03-15', + }; + mockContext.dataAccess.Site.postgrestService = chainMock; + + const handler = createUrlDetailsHandler(getOrgAndValidateAccess); + await handler(mockContext); + + expect(chainMock.gte).to.have.been.calledWith('execution_date', '2026-03-02'); + expect(chainMock.lte).to.have.been.calledWith('execution_date', '2026-03-15'); + }); + + it('queries the correct table and columns', async () => { + const chainMock = createChainableMock({ data: [], error: null }); + mockContext.dataAccess.Site.postgrestService = chainMock; + + const handler = createUrlDetailsHandler(getOrgAndValidateAccess); + await handler(mockContext); + + expect(chainMock.from).to.have.been.calledWith('brand_presence_sources'); + expect(chainMock.select).to.have.been.calledWith( + 'content_type,execution_date,source_urls!inner(url,hostname),brand_presence_executions!inner(prompt,category_name,region_code,topics)', + ); + expect(chainMock.eq).to.have.been.calledWith('site_id', 'site-001'); + expect(chainMock.eq).to.have.been.calledWith('source_urls.url', 'https://example.com/product/page'); + }); + + it('filters by brandId on the PostgREST query when a specific UUID is provided', async () => { + const brandUuid = '0178a3f0-bbbb-7000-8000-000000000001'; + mockContext.params.brandId = brandUuid; + + const chainMock = createChainableMock({ data: [], error: null }); + mockContext.dataAccess.Site.postgrestService = chainMock; + + const handler = createUrlDetailsHandler(getOrgAndValidateAccess); + await handler(mockContext); + + expect(chainMock.eq).to.have.been.calledWith('brand_presence_executions.brand_id', brandUuid); + }); + + it('does not filter by brandId when brandId is "all"', async () => { + mockContext.params.brandId = 'all'; + + const chainMock = createChainableMock({ data: [], error: null }); + mockContext.dataAccess.Site.postgrestService = chainMock; + + const handler = createUrlDetailsHandler(getOrgAndValidateAccess); + await handler(mockContext); + + const eqCalls = chainMock.eq.getCalls().map((c) => c.args); + const brandCall = eqCalls.find(([col]) => col === 'brand_presence_executions.brand_id'); + expect(brandCall).to.be.undefined; + }); + + it('handles null data from PostgREST gracefully', async () => { + mockContext.dataAccess.Site.postgrestService = createChainableMock({ + data: null, + error: null, + }); + + const handler = createUrlDetailsHandler(getOrgAndValidateAccess); + const result = await handler(mockContext); + + expect(result.status).to.equal(200); + const body = await result.json(); + expect(body.totalCitations).to.equal(0); + }); + }); +}); diff --git a/test/routes/index.test.js b/test/routes/index.test.js index 8d68c72fe..00b3569bd 100755 --- a/test/routes/index.test.js +++ b/test/routes/index.test.js @@ -257,6 +257,16 @@ describe('getRouteHandlers', () => { getFilterDimensions: () => null, }; + const mockLlmoUrlInspectorController = { + getStats: () => null, + getOwnedUrls: () => null, + getTrendingUrls: () => null, + getCitedDomains: () => null, + getUrlDetails: () => null, + getDomainDetails: () => null, + getFilterOptions: () => null, + }; + const mockLlmoController = { getLlmoSheetData: () => null, getLlmoGlobalSheetData: () => null, @@ -394,6 +404,7 @@ describe('getRouteHandlers', () => { mockFixesController, mockLlmoController, mockLlmoMysticatController, + mockLlmoUrlInspectorController, mockUserActivityController, mockSiteEnrollmentController, mockTrialUserController, @@ -500,6 +511,20 @@ describe('getRouteHandlers', () => { 'GET /org/:spaceCatId/brands/:brandId/brand-presence/share-of-voice', 'GET /org/:spaceCatId/brands/all/brand-presence/stats', 'GET /org/:spaceCatId/brands/:brandId/brand-presence/stats', + 'GET /org/:spaceCatId/brands/all/url-inspector/stats', + 'GET /org/:spaceCatId/brands/:brandId/url-inspector/stats', + 'GET /org/:spaceCatId/brands/all/url-inspector/owned-urls', + 'GET /org/:spaceCatId/brands/:brandId/url-inspector/owned-urls', + 'GET /org/:spaceCatId/brands/all/url-inspector/trending-urls', + 'GET /org/:spaceCatId/brands/:brandId/url-inspector/trending-urls', + 'GET /org/:spaceCatId/brands/all/url-inspector/cited-domains', + 'GET /org/:spaceCatId/brands/:brandId/url-inspector/cited-domains', + 'GET /org/:spaceCatId/brands/all/url-inspector/url-details', + 'GET /org/:spaceCatId/brands/:brandId/url-inspector/url-details', + 'GET /org/:spaceCatId/brands/all/url-inspector/domain-details', + 'GET /org/:spaceCatId/brands/:brandId/url-inspector/domain-details', + 'GET /org/:spaceCatId/brands/all/url-inspector/filter-options', + 'GET /org/:spaceCatId/brands/:brandId/url-inspector/filter-options', 'PATCH /v2/orgs/:spaceCatId/llmo-customer-config', 'POST /v2/orgs/:spaceCatId/llmo-customer-config', 'GET /organizations/:organizationId/projects',