diff --git a/docs/openapi/parameters.yaml b/docs/openapi/parameters.yaml index cb72766072..de91c9a753 100644 --- a/docs/openapi/parameters.yaml +++ b/docs/openapi/parameters.yaml @@ -401,6 +401,21 @@ xPromiseToken: type: string example: eyJhbGciOiJSUzI1NiIs... +locale: + name: locale + description: | + Locale code in underscore form (e.g. `fr_fr`, `ja_jp`, `de_de`). + When provided and a translation exists, localizable fields (`title`, `description` on + opportunities; AI-generated fields inside `data` on suggestions) are replaced by the + stored translation. Falls back to the original English value when the locale is absent + or no translation has been stored yet for that locale. + in: query + required: false + schema: + type: string + pattern: '^[a-z]{2}_[a-z]{2}$' + example: fr_fr + suggestionView: name: view description: | diff --git a/docs/openapi/site-opportunities.yaml b/docs/openapi/site-opportunities.yaml index 6a5368e0c1..1cacde83e7 100644 --- a/docs/openapi/site-opportunities.yaml +++ b/docs/openapi/site-opportunities.yaml @@ -1,6 +1,7 @@ site-opportunities: parameters: - $ref: './parameters.yaml#/siteId' + - $ref: './parameters.yaml#/locale' get: operationId: getSiteOpportunities summary: | @@ -71,6 +72,7 @@ site-opportunities-by-status: required: true schema: type: string + - $ref: './parameters.yaml#/locale' get: operationId: getSiteOpportunitiesByStatus summary: | @@ -103,6 +105,7 @@ site-opportunity: parameters: - $ref: './parameters.yaml#/siteId' - $ref: './parameters.yaml#/opportunityId' + - $ref: './parameters.yaml#/locale' get: operationId: getSiteOpportunity summary: | @@ -197,6 +200,7 @@ site-opportunity-suggestions: - $ref: './parameters.yaml#/opportunityId' - $ref: './parameters.yaml#/suggestionView' - $ref: './parameters.yaml#/suggestionStatus' + - $ref: './parameters.yaml#/locale' get: operationId: getSiteOpportunitySuggestions summary: | @@ -278,6 +282,7 @@ site-opportunity-suggestions-by-status: schema: type: string - $ref: './parameters.yaml#/suggestionView' + - $ref: './parameters.yaml#/locale' get: operationId: getSiteOpportunitySuggestionsByStatus summary: | @@ -314,6 +319,7 @@ site-opportunity-suggestions-paged: - $ref: './parameters.yaml#/opportunityId' - $ref: './parameters.yaml#/limit' - $ref: './parameters.yaml#/suggestionView' + - $ref: './parameters.yaml#/locale' get: operationId: getSiteOpportunitySuggestionsPaged summary: | @@ -353,6 +359,7 @@ site-opportunity-suggestions-paged-with-cursor: - $ref: './parameters.yaml#/limit' - $ref: './parameters.yaml#/cursor' - $ref: './parameters.yaml#/suggestionView' + - $ref: './parameters.yaml#/locale' get: operationId: getSiteOpportunitySuggestionsPagedWithCursor summary: | @@ -396,6 +403,7 @@ site-opportunity-suggestions-by-status-paged: type: string - $ref: './parameters.yaml#/limit' - $ref: './parameters.yaml#/suggestionView' + - $ref: './parameters.yaml#/locale' get: operationId: getSiteOpportunitySuggestionsByStatusPaged summary: | @@ -439,6 +447,7 @@ site-opportunity-suggestions-by-status-paged-with-cursor: - $ref: './parameters.yaml#/limit' - $ref: './parameters.yaml#/cursor' - $ref: './parameters.yaml#/suggestionView' + - $ref: './parameters.yaml#/locale' get: operationId: getSiteOpportunitySuggestionsByStatusPagedWithCursor summary: | @@ -1082,6 +1091,7 @@ site-opportunity-suggestion: - $ref: './parameters.yaml#/opportunityId' - $ref: './parameters.yaml#/suggestionId' - $ref: './parameters.yaml#/suggestionView' + - $ref: './parameters.yaml#/locale' get: operationId: getSiteOpportunitySuggestion summary: | diff --git a/src/controllers/opportunities.js b/src/controllers/opportunities.js index 27b0c87632..ad90a558b6 100644 --- a/src/controllers/opportunities.js +++ b/src/controllers/opportunities.js @@ -26,6 +26,7 @@ import { isValidUUID, } from '@adobe/spacecat-shared-utils'; import { OpportunityDto } from '../dto/opportunity.js'; +import { isValidLocale } from '../utils/validations.js'; import AccessControlUtil from '../support/access-control-util.js'; import { grantSuggestionsForOpportunity } from '../support/grant-suggestions-handler.js'; import { getIsSummitPlgEnabled } from '../support/utils.js'; @@ -96,6 +97,11 @@ function OpportunitiesController(ctx) { */ const getAllForSite = async (context) => { const siteId = context.params?.siteId; + const locale = context.data?.locale ?? null; + + if (!isValidLocale(locale)) { + return badRequest('Invalid locale format'); + } if (!isValidUUID(siteId)) { return badRequest('Site ID required'); @@ -111,7 +117,7 @@ function OpportunitiesController(ctx) { const allOpptys = await Opportunity.allBySiteId(siteId); const opptys = (await filterForSummitPlg(site, allOpptys, context)) - .map((oppty) => OpportunityDto.toJSON(oppty)); + .map((oppty) => OpportunityDto.toJSON(oppty, locale)); return ok(opptys); }; @@ -124,6 +130,11 @@ function OpportunitiesController(ctx) { const getByStatus = async (context) => { const siteId = context.params?.siteId; const status = context.params?.status; + const locale = context.data?.locale ?? null; + + if (!isValidLocale(locale)) { + return badRequest('Invalid locale format'); + } if (!isValidUUID(siteId)) { return badRequest('Site ID required'); @@ -142,7 +153,7 @@ function OpportunitiesController(ctx) { const allOpptys = await Opportunity.allBySiteIdAndStatus(siteId, status); const opptys = (await filterForSummitPlg(site, allOpptys, context)) - .map((oppty) => OpportunityDto.toJSON(oppty)); + .map((oppty) => OpportunityDto.toJSON(oppty, locale)); return ok(opptys); }; @@ -155,6 +166,11 @@ function OpportunitiesController(ctx) { const getByID = async (context) => { const siteId = context.params?.siteId; const opptyId = context.params?.opportunityId; + const locale = context.data?.locale ?? null; + + if (!isValidLocale(locale)) { + return badRequest('Invalid locale format'); + } if (!isValidUUID(siteId)) { return badRequest('Site ID required'); @@ -184,7 +200,7 @@ function OpportunitiesController(ctx) { ctx.log?.warn?.('Grant suggestions handler failed', err?.message ?? err); } } - return ok(OpportunityDto.toJSON(oppty)); + return ok(OpportunityDto.toJSON(oppty, locale)); }; /** diff --git a/src/controllers/suggestions.js b/src/controllers/suggestions.js index 322fa4e917..4d3aab2405 100644 --- a/src/controllers/suggestions.js +++ b/src/controllers/suggestions.js @@ -32,6 +32,7 @@ import { Suggestion as SuggestionModel, GeoExperiment as GeoExperimentModel } fr import TokowakaClient from '@adobe/spacecat-shared-tokowaka-client'; import DrsClient, { EXPERIMENT_PHASES } from '@adobe/spacecat-shared-drs-client'; import { SuggestionDto, SUGGESTION_VIEWS, SUGGESTION_SKIP_REASONS } from '../dto/suggestion.js'; +import { isValidLocale } from '../utils/validations.js'; import { getScheduleParams, buildExperimentMetadata, @@ -301,6 +302,11 @@ function SuggestionsController(ctx, sqs, env) { const opptyId = context.params?.opportunityId; const viewParam = context.data?.view; const statusParam = context.data?.status; + const locale = context.data?.locale ?? null; + + if (!isValidLocale(locale)) { + return badRequest('Invalid locale format'); + } if (!isValidUUID(siteId)) { return badRequest('Site ID required'); @@ -357,7 +363,7 @@ function SuggestionsController(ctx, sqs, env) { } const grantedEntities = await filterByGrantStatus(site, suggestionEntities, context); const suggestions = grantedEntities.map( - (sugg) => SuggestionDto.toJSON(sugg, view, opportunity), + (sugg) => SuggestionDto.toJSON(sugg, view, opportunity, locale), ); return ok(suggestions); }; @@ -376,6 +382,11 @@ function SuggestionsController(ctx, sqs, env) { const limit = parseInt(context.params?.limit, 10) || DEFAULT_PAGE_SIZE; const cursor = context.params?.cursor || null; const viewParam = context.data?.view; + const locale = context.data?.locale ?? null; + + if (!isValidLocale(locale)) { + return badRequest('Invalid locale format'); + } if (!isValidUUID(siteId)) { return badRequest('Site ID required'); @@ -418,7 +429,7 @@ function SuggestionsController(ctx, sqs, env) { } const grantedEntities = await filterByGrantStatus(site, suggestionEntities, context); const suggestions = grantedEntities.map( - (sugg) => SuggestionDto.toJSON(sugg, view, opportunity), + (sugg) => SuggestionDto.toJSON(sugg, view, opportunity, locale), ); return ok({ @@ -441,6 +452,11 @@ function SuggestionsController(ctx, sqs, env) { const opptyId = context.params?.opportunityId; const status = context.params?.status || undefined; const viewParam = context.data?.view; + const locale = context.data?.locale ?? null; + + if (!isValidLocale(locale)) { + return badRequest('Invalid locale format'); + } if (!isValidUUID(siteId)) { return badRequest('Site ID required'); @@ -476,7 +492,7 @@ function SuggestionsController(ctx, sqs, env) { } const grantedEntities = await filterByGrantStatus(site, suggestionEntities, context); const suggestions = grantedEntities.map( - (sugg) => SuggestionDto.toJSON(sugg, view, opportunity), + (sugg) => SuggestionDto.toJSON(sugg, view, opportunity, locale), ); return ok(suggestions); }; @@ -493,6 +509,11 @@ function SuggestionsController(ctx, sqs, env) { const limit = parseInt(context.params?.limit, 10) || DEFAULT_PAGE_SIZE; const cursor = context.params?.cursor || null; const viewParam = context.data?.view; + const locale = context.data?.locale ?? null; + + if (!isValidLocale(locale)) { + return badRequest('Invalid locale format'); + } if (!isValidUUID(siteId)) { return badRequest('Site ID required'); @@ -537,7 +558,7 @@ function SuggestionsController(ctx, sqs, env) { } const grantedEntities = await filterByGrantStatus(site, suggestionEntities, context); const suggestions = grantedEntities.map( - (sugg) => SuggestionDto.toJSON(sugg, view, opportunity), + (sugg) => SuggestionDto.toJSON(sugg, view, opportunity, locale), ); return ok({ suggestions, @@ -559,6 +580,11 @@ function SuggestionsController(ctx, sqs, env) { const opptyId = context.params?.opportunityId || undefined; const suggestionId = context.params?.suggestionId || undefined; const viewParam = context.data?.view; + const locale = context.data?.locale ?? null; + + if (!isValidLocale(locale)) { + return badRequest('Invalid locale format'); + } if (!isValidUUID(siteId)) { return badRequest('Site ID required'); @@ -598,7 +624,7 @@ function SuggestionsController(ctx, sqs, env) { && !(await SuggestionGrant.isSuggestionGranted(suggestion.getId()))) { return notFound('Suggestion not found'); } - return ok(SuggestionDto.toJSON(suggestion, view, opportunity)); + return ok(SuggestionDto.toJSON(suggestion, view, opportunity, locale)); }; /** diff --git a/src/dto/opportunity.js b/src/dto/opportunity.js index 64e16df519..ba6c0836c1 100644 --- a/src/dto/opportunity.js +++ b/src/dto/opportunity.js @@ -21,7 +21,15 @@ export const OpportunityDto = { * that must only appear in the brand-scoped endpoint response, added by the brand * controller directly. Including them here would expose brand UUIDs on shared sites * to users who have site access but not brand access. + * + * Translations for `title` and `description` are stored by audit workers in + * `opportunity.data.i18n` as a map of locale → { title, description }. + * When `locale` is supplied the matching translation is promoted to the top-level + * fields and `data.i18n` is stripped from the response so the shape stays stable. + * Falls back to the original English values when the locale is absent or not found. + * * @param {Readonly} oppty - Opportunity object. + * @param {string|null} [locale] - Optional locale code (e.g. 'fr_fr', 'ja_jp'). * @returns {{ * id: string, * siteId: string, @@ -40,22 +48,44 @@ export const OpportunityDto = { * lastAuditedAt: date * }} JSON object. */ - toJSON: (oppty) => ({ - id: oppty.getId(), - siteId: oppty.getSiteId(), - auditId: oppty.getAuditId(), - runbook: oppty.getRunbook(), - type: oppty.getType(), - data: oppty.getData(), - origin: oppty.getOrigin(), - title: oppty.getTitle(), - description: oppty.getDescription(), - guidance: oppty.getGuidance(), - tags: oppty.getTags(), - status: oppty.getStatus(), - createdAt: oppty.getCreatedAt(), - updatedAt: oppty.getUpdatedAt(), - updatedBy: oppty.getUpdatedBy(), - lastAuditedAt: oppty.getLastAuditedAt(), - }), + toJSON: (oppty, locale = null) => { + const rawData = oppty.getData(); + const data = rawData ? (() => { + // eslint-disable-next-line no-unused-vars + const { i18n, ...rest } = rawData; + return rest; + })() : null; + + let title = oppty.getTitle(); + let description = oppty.getDescription(); + + if (locale && rawData?.i18n?.[locale]) { + const localized = rawData.i18n[locale]; + if (localized.title != null) { + title = localized.title; + } + if (localized.description != null) { + description = localized.description; + } + } + + return { + id: oppty.getId(), + siteId: oppty.getSiteId(), + auditId: oppty.getAuditId(), + runbook: oppty.getRunbook(), + type: oppty.getType(), + data, + origin: oppty.getOrigin(), + title, + description, + guidance: oppty.getGuidance(), + tags: oppty.getTags(), + status: oppty.getStatus(), + createdAt: oppty.getCreatedAt(), + updatedAt: oppty.getUpdatedAt(), + updatedBy: oppty.getUpdatedBy(), + lastAuditedAt: oppty.getLastAuditedAt(), + }; + }, }; diff --git a/src/dto/suggestion.js b/src/dto/suggestion.js index 3d67955768..e4858f9d7c 100644 --- a/src/dto/suggestion.js +++ b/src/dto/suggestion.js @@ -19,6 +19,20 @@ import { Suggestion } from '@adobe/spacecat-shared-data-access'; */ export const SUGGESTION_VIEWS = ['minimal', 'summary', 'full']; +/** + * Supported localizable fields that can be translated for suggestions. + * @type {string[]} + */ +export const ALLOWED_I18N_FIELDS = [ + 'title', + 'description', + 'rationale', + 'aiRationale', + 'aiSuggestion', + 'actionItems', + 'persona', +]; + /** * Valid skip reasons when a suggestion is marked as SKIPPED. * @type {string[]} @@ -64,15 +78,39 @@ const extractMinimalData = (data, opportunityType) => { export const SuggestionDto = { /** * Converts a Suggestion object into a JSON object with optional projection. + * + * Translations for AI-generated fields (title, rationale, description, etc.) are stored by + * audit workers in `suggestion.data.i18n` as a map of locale → { field: value, ... }. + * When `locale` is supplied the matching translation is merged on top of `data` and + * `data.i18n` is stripped so the response shape stays stable. + * Falls back to the original English values when the locale is absent or not found. + * * @param {Readonly} suggestion - Suggestion object. * @param {string} [view='full'] - Projection view: 'minimal', 'summary', or 'full'. * @param {object} [opportunity] - Optional opportunity entity for type-specific filtering. + * @param {string|null} [locale] - Optional locale code (e.g. 'fr_fr', 'ja_jp'). * @returns {object} JSON object with fields based on the selected view. */ - toJSON: (suggestion, view = 'full', opportunity = null) => { - const data = suggestion.getData(); + toJSON: (suggestion, view = 'full', opportunity = null, locale = null) => { + const rawData = suggestion.getData(); const opportunityType = opportunity?.getType() || null; + // Apply locale projection and strip the internal i18n key from the response + // eslint-disable-next-line no-unused-vars + const { i18n, ...baseData } = rawData ?? {}; + let data = baseData; + + if (locale && i18n?.[locale]) { + const localized = i18n[locale]; + const filteredLocalized = {}; + for (const field of ALLOWED_I18N_FIELDS) { + if (localized[field] != null) { + filteredLocalized[field] = localized[field]; + } + } + data = { ...baseData, ...filteredLocalized }; + } + const skipReason = suggestion.getSkipReason?.(); const skipDetail = suggestion.getSkipDetail?.(); const skipFields = {}; diff --git a/src/utils/validations.js b/src/utils/validations.js index effc1d5231..dd040f6141 100644 --- a/src/utils/validations.js +++ b/src/utils/validations.js @@ -47,8 +47,22 @@ function checkBodySize(data, maxSize) { return true; } +/** + * Validates if the locale is a valid locale code (e.g. 'fr_fr'). + * + * @param {string} locale - The locale code. + * @returns {boolean} true if the locale is valid, false otherwise. + */ +function isValidLocale(locale) { + if (locale === undefined || locale === null) { + return true; + } + return typeof locale === 'string' && /^[a-z]{2}_[a-z]{2}$/.test(locale); +} + export { MAX_BODY_SIZE, validateRepoUrl, checkBodySize, + isValidLocale, }; diff --git a/test/controllers/opportunities.test.js b/test/controllers/opportunities.test.js index 2f927185b9..6c31a8ae36 100644 --- a/test/controllers/opportunities.test.js +++ b/test/controllers/opportunities.test.js @@ -318,6 +318,43 @@ describe('Opportunities Controller', () => { expect(opportunity).to.have.property('id', OPPORTUNITY_ID); }); + describe('locale validation', () => { + const invalidLocaleContext = { data: { locale: 'fr-FR' } }; + + it('getAllForSite returns bad request for invalid locale', async () => { + const response = await opportunitiesController.getAllForSite({ + params: { siteId: SITE_ID }, + ...invalidLocaleContext, + }); + expect(mockOpportunityDataAccess.Opportunity.allBySiteId.called).to.be.false; + expect(response.status).to.equal(400); + const error = await response.json(); + expect(error).to.have.property('message', 'Invalid locale format'); + }); + + it('getByStatus returns bad request for invalid locale', async () => { + const response = await opportunitiesController.getByStatus({ + params: { siteId: SITE_ID, status: 'NEW' }, + ...invalidLocaleContext, + }); + expect(mockOpportunityDataAccess.Opportunity.allBySiteIdAndStatus.called).to.be.false; + expect(response.status).to.equal(400); + const error = await response.json(); + expect(error).to.have.property('message', 'Invalid locale format'); + }); + + it('getByID returns bad request for invalid locale', async () => { + const response = await opportunitiesController.getByID({ + params: { siteId: SITE_ID, opportunityId: OPPORTUNITY_ID }, + ...invalidLocaleContext, + }); + expect(mockOpportunityDataAccess.Opportunity.findById.called).to.be.false; + expect(response.status).to.equal(400); + const error = await response.json(); + expect(error).to.have.property('message', 'Invalid locale format'); + }); + }); + it('gets all opportunities for a site by status returns bad request if no site ID is passed', async () => { const response = await opportunitiesController.getByStatus({ params: {} }); expect(mockOpportunityDataAccess.Opportunity.allBySiteIdAndStatus.calledOnce).to.be.false; diff --git a/test/controllers/suggestions.test.js b/test/controllers/suggestions.test.js index 611f7e96d5..3ce36f1952 100644 --- a/test/controllers/suggestions.test.js +++ b/test/controllers/suggestions.test.js @@ -562,6 +562,72 @@ describe('Suggestions Controller', () => { expect(suggestions[0]).to.have.property('opportunityId', OPPORTUNITY_ID); }); + describe('locale validation', () => { + const invalidLocaleContext = { data: { locale: 'fr-FR' } }; + const baseParams = { + params: { + siteId: SITE_ID, + opportunityId: OPPORTUNITY_ID, + }, + ...context, + }; + + it('getAllForOpportunity returns bad request for invalid locale', async () => { + const response = await suggestionsController.getAllForOpportunity({ + ...baseParams, + ...invalidLocaleContext, + }); + expect(mockSuggestionDataAccess.Suggestion.allByOpportunityId.called).to.be.false; + expect(response.status).to.equal(400); + const error = await response.json(); + expect(error).to.have.property('message', 'Invalid locale format'); + }); + + it('getAllForOpportunityPaged returns bad request for invalid locale', async () => { + const response = await suggestionsController.getAllForOpportunityPaged({ + ...baseParams, + params: { ...baseParams.params, limit: '10' }, + ...invalidLocaleContext, + }); + expect(response.status).to.equal(400); + const error = await response.json(); + expect(error).to.have.property('message', 'Invalid locale format'); + }); + + it('getByStatus returns bad request for invalid locale', async () => { + const response = await suggestionsController.getByStatus({ + ...baseParams, + params: { ...baseParams.params, status: 'NEW' }, + ...invalidLocaleContext, + }); + expect(response.status).to.equal(400); + const error = await response.json(); + expect(error).to.have.property('message', 'Invalid locale format'); + }); + + it('getByStatusPaged returns bad request for invalid locale', async () => { + const response = await suggestionsController.getByStatusPaged({ + ...baseParams, + params: { ...baseParams.params, status: 'NEW', limit: '10' }, + ...invalidLocaleContext, + }); + expect(response.status).to.equal(400); + const error = await response.json(); + expect(error).to.have.property('message', 'Invalid locale format'); + }); + + it('getByID returns bad request for invalid locale', async () => { + const response = await suggestionsController.getByID({ + ...baseParams, + params: { ...baseParams.params, suggestionId: SUGGESTION_IDS[0] }, + ...invalidLocaleContext, + }); + expect(response.status).to.equal(400); + const error = await response.json(); + expect(error).to.have.property('message', 'Invalid locale format'); + }); + }); + it('returns all suggestions when grant filtering throws an error', async () => { mockSuggestionGrant.splitSuggestionsByGrantStatus.rejects(new Error('db failure')); const ControllerWithSummitPlg = await esmock('../../src/controllers/suggestions.js', { diff --git a/test/dto/opportunity.test.js b/test/dto/opportunity.test.js new file mode 100644 index 0000000000..c93bfaba70 --- /dev/null +++ b/test/dto/opportunity.test.js @@ -0,0 +1,159 @@ +/* + * 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 } from 'chai'; +import { OpportunityDto } from '../../src/dto/opportunity.js'; + +describe('OpportunityDto', () => { + const createMockOppty = (dataOverrides = {}, opptyOverrides = {}) => ({ + getId: () => 'oppty-id-123', + getSiteId: () => 'site-id-456', + getAuditId: () => 'audit-id-789', + getRunbook: () => 'http://runbook.url', + getType: () => 'youtube-analysis', + getData: () => ({ someField: 'value', ...dataOverrides }), + getOrigin: () => 'ESS_OPS', + getTitle: () => opptyOverrides.title ?? 'English title', + getDescription: () => opptyOverrides.description ?? 'English description', + getGuidance: () => ({ steps: [] }), + getTags: () => ['video content'], + getStatus: () => 'NEW', + getCreatedAt: () => '2025-01-01T00:00:00.000Z', + getUpdatedAt: () => '2025-01-02T00:00:00.000Z', + getUpdatedBy: () => 'system', + getLastAuditedAt: () => '2025-01-01T00:00:00.000Z', + }); + + describe('toJSON', () => { + it('returns all standard fields without locale', () => { + const oppty = createMockOppty(); + + const json = OpportunityDto.toJSON(oppty); + + expect(json).to.have.property('id', 'oppty-id-123'); + expect(json).to.have.property('siteId', 'site-id-456'); + expect(json).to.have.property('title', 'English title'); + expect(json).to.have.property('description', 'English description'); + expect(json).to.have.property('data'); + expect(json.data).to.have.property('someField', 'value'); + }); + + describe('locale projection', () => { + it('returns original English when no locale is provided', () => { + const oppty = createMockOppty({ + i18n: { fr_fr: { title: 'Titre français', description: 'Description française' } }, + }); + + const json = OpportunityDto.toJSON(oppty); + + expect(json.title).to.equal('English title'); + expect(json.description).to.equal('English description'); + expect(json.data).to.not.have.property('i18n'); + }); + + it('promotes locale-specific title and description when locale matches', () => { + const oppty = createMockOppty({ + i18n: { + fr_fr: { title: 'Titre français', description: 'Description française' }, + }, + }); + + const json = OpportunityDto.toJSON(oppty, 'fr_fr'); + + expect(json.title).to.equal('Titre français'); + expect(json.description).to.equal('Description française'); + expect(json.data).to.not.have.property('i18n'); + expect(json.data).to.have.property('someField', 'value'); + }); + + it('falls back to English when locale has no stored translation', () => { + const oppty = createMockOppty({ + i18n: { fr_fr: { title: 'Titre français', description: 'Description française' } }, + }); + + const json = OpportunityDto.toJSON(oppty, 'ja_jp'); + + expect(json.title).to.equal('English title'); + expect(json.description).to.equal('English description'); + }); + + it('promotes only the fields present in the locale translation', () => { + const oppty = createMockOppty({ + i18n: { fr_fr: { title: 'Titre français' } }, + }); + + const json = OpportunityDto.toJSON(oppty, 'fr_fr'); + + expect(json.title).to.equal('Titre français'); + expect(json.description).to.equal('English description'); + }); + + it('promotes empty-string translations (intentional blank override)', () => { + const oppty = createMockOppty({ + i18n: { fr_fr: { title: '', description: '' } }, + }); + + const json = OpportunityDto.toJSON(oppty, 'fr_fr'); + + expect(json.title).to.equal(''); + expect(json.description).to.equal(''); + }); + + it('falls back to English when locale field is null', () => { + const oppty = createMockOppty({ + i18n: { fr_fr: { title: 'Titre français', description: null } }, + }); + + const json = OpportunityDto.toJSON(oppty, 'fr_fr'); + + expect(json.title).to.equal('Titre français'); + expect(json.description).to.equal('English description'); + }); + + it('strips i18n key from data even without locale param', () => { + const oppty = createMockOppty({ + i18n: { fr_fr: { title: 'Titre français' } }, + }); + + const json = OpportunityDto.toJSON(oppty); + + expect(json.data).to.not.have.property('i18n'); + expect(json.data).to.have.property('someField', 'value'); + }); + + it('handles getData returning null gracefully', () => { + const oppty = { + ...createMockOppty(), + getData: () => null, + }; + + const json = OpportunityDto.toJSON(oppty, 'fr_fr'); + + expect(json.title).to.equal('English title'); + expect(json.description).to.equal('English description'); + expect(json.data).to.be.null; + }); + + it('handles getData returning empty object gracefully', () => { + const oppty = { + ...createMockOppty(), + getData: () => ({}), + }; + + const json = OpportunityDto.toJSON(oppty, 'fr_fr'); + + expect(json.title).to.equal('English title'); + expect(json.data).to.deep.equal({}); + }); + }); + }); +}); diff --git a/test/dto/suggestion.test.js b/test/dto/suggestion.test.js index b3670f06a6..e5c7a4f3d7 100644 --- a/test/dto/suggestion.test.js +++ b/test/dto/suggestion.test.js @@ -12,7 +12,12 @@ import { expect } from 'chai'; -import { SuggestionDto, SUGGESTION_VIEWS, SUGGESTION_SKIP_REASONS } from '../../src/dto/suggestion.js'; +import { + SuggestionDto, + SUGGESTION_VIEWS, + SUGGESTION_SKIP_REASONS, + ALLOWED_I18N_FIELDS, +} from '../../src/dto/suggestion.js'; describe('Suggestion DTO', () => { const createMockSuggestion = (dataOverrides = {}, suggestionOverrides = {}) => ({ @@ -36,6 +41,20 @@ describe('Suggestion DTO', () => { }); }); + describe('ALLOWED_I18N_FIELDS', () => { + it('exports the supported localizable suggestion fields', () => { + expect(ALLOWED_I18N_FIELDS).to.deep.equal([ + 'title', + 'description', + 'rationale', + 'aiRationale', + 'aiSuggestion', + 'actionItems', + 'persona', + ]); + }); + }); + describe('SUGGESTION_SKIP_REASONS', () => { it('exports valid skip reason enum values from shared-data-access', () => { expect(SUGGESTION_SKIP_REASONS).to.be.an('array').that.is.not.empty; @@ -619,5 +638,117 @@ describe('Suggestion DTO', () => { }); }); }); + + describe('locale projection', () => { + it('returns original English data when no locale is provided', () => { + const suggestion = createMockSuggestion({ + title: 'English title', + i18n: { fr_fr: { title: 'Titre français' } }, + }); + + const json = SuggestionDto.toJSON(suggestion, 'full', null, null); + + expect(json.data).to.have.property('title', 'English title'); + expect(json.data).to.not.have.property('i18n'); + }); + + it('promotes locale-specific fields when locale matches', () => { + const suggestion = createMockSuggestion({ + title: 'English title', + rationale: 'English rationale', + i18n: { + fr_fr: { title: 'Titre français', rationale: 'Justification française' }, + }, + }); + + const json = SuggestionDto.toJSON(suggestion, 'full', null, 'fr_fr'); + + expect(json.data).to.have.property('title', 'Titre français'); + expect(json.data).to.have.property('rationale', 'Justification française'); + expect(json.data).to.not.have.property('i18n'); + }); + + it('falls back to English when locale has no stored translation', () => { + const suggestion = createMockSuggestion({ + title: 'English title', + i18n: { fr_fr: { title: 'Titre français' } }, + }); + + const json = SuggestionDto.toJSON(suggestion, 'full', null, 'ja_jp'); + + expect(json.data).to.have.property('title', 'English title'); + expect(json.data).to.not.have.property('i18n'); + }); + + it('strips i18n key even when locale is not provided', () => { + const suggestion = createMockSuggestion({ + title: 'English title', + i18n: { fr_fr: { title: 'Titre français' } }, + }); + + const json = SuggestionDto.toJSON(suggestion, 'full'); + + expect(json.data).to.not.have.property('i18n'); + }); + + it('preserves non-translated fields alongside translated ones', () => { + const suggestion = createMockSuggestion({ + url: 'https://example.com/page', + title: 'English title', + i18n: { fr_fr: { title: 'Titre français' } }, + }); + + const json = SuggestionDto.toJSON(suggestion, 'full', null, 'fr_fr'); + + expect(json.data).to.have.property('url', 'https://example.com/page'); + expect(json.data).to.have.property('title', 'Titre français'); + }); + + it('promotes empty-string translations for allowed fields', () => { + const suggestion = createMockSuggestion({ + title: 'English title', + rationale: 'English rationale', + i18n: { fr_fr: { title: '', rationale: '' } }, + }); + + const json = SuggestionDto.toJSON(suggestion, 'full', null, 'fr_fr'); + + expect(json.data).to.have.property('title', ''); + expect(json.data).to.have.property('rationale', ''); + }); + + it('falls back to English when locale field is null', () => { + const suggestion = createMockSuggestion({ + title: 'English title', + rationale: 'English rationale', + i18n: { fr_fr: { title: 'Titre français', rationale: null } }, + }); + + const json = SuggestionDto.toJSON(suggestion, 'full', null, 'fr_fr'); + + expect(json.data).to.have.property('title', 'Titre français'); + expect(json.data).to.have.property('rationale', 'English rationale'); + }); + + it('ignores disallowed fields in locale translations', () => { + const suggestion = createMockSuggestion({ + url: 'https://example.com/page', + title: 'English title', + i18n: { + fr_fr: { + title: 'Titre français', + url: 'https://example.com/fr-page', + customField: 'should not appear', + }, + }, + }); + + const json = SuggestionDto.toJSON(suggestion, 'full', null, 'fr_fr'); + + expect(json.data).to.have.property('title', 'Titre français'); + expect(json.data).to.have.property('url', 'https://example.com/page'); + expect(json.data).to.not.have.property('customField'); + }); + }); }); }); diff --git a/test/it/postgres/seed-data/opportunities.js b/test/it/postgres/seed-data/opportunities.js index d8d5b50ad1..c3d1b1c1be 100644 --- a/test/it/postgres/seed-data/opportunities.js +++ b/test/it/postgres/seed-data/opportunities.js @@ -28,7 +28,17 @@ export const opportunities = [ title: 'Fix CWV issues', description: 'Improve Core Web Vitals scores', status: 'NEW', - data: { cwvMetric: 'lcp', currentScore: 3200, targetScore: 2500 }, + data: { + cwvMetric: 'lcp', + currentScore: 3200, + targetScore: 2500, + i18n: { + fr_fr: { + title: 'Corriger les problèmes CWV', + description: 'Améliorer les scores Core Web Vitals', + }, + }, + }, runbook: 'https://wiki.example.com/runbooks/cwv-optimization', guidance: { steps: ['Review affected pages', 'Optimize LCP resources', 'Re-audit'] }, tags: ['performance', 'cwv'], diff --git a/test/it/postgres/seed-data/suggestions.js b/test/it/postgres/seed-data/suggestions.js index 5e719d16ef..71e7d9e04d 100644 --- a/test/it/postgres/seed-data/suggestions.js +++ b/test/it/postgres/seed-data/suggestions.js @@ -27,7 +27,16 @@ export const suggestions = [ type: 'CODE_CHANGE', rank: 1, status: 'NEW', - data: { title: 'Update hero image', from: '/old-hero.png', to: '/new-hero.webp' }, + data: { + title: 'Update hero image', + from: '/old-hero.png', + to: '/new-hero.webp', + i18n: { + fr_fr: { + title: "Mettre à jour l'image principale", + }, + }, + }, kpi_deltas: { estimatedKPILift: 0.15 }, }, { diff --git a/test/it/shared/tests/opportunities.js b/test/it/shared/tests/opportunities.js index 9ce91f1a99..094d5efac6 100644 --- a/test/it/shared/tests/opportunities.js +++ b/test/it/shared/tests/opportunities.js @@ -79,6 +79,36 @@ export default function opportunityTests(getHttpClient, resetData) { const res = await http.admin.get('/sites/not-a-uuid/opportunities'); expect(res.status).to.equal(400); }); + + it('user: returns French title and description when locale=fr_fr', async () => { + const http = getHttpClient(); + const res = await http.user.get(`/sites/${SITE_1_ID}/opportunities?locale=fr_fr`); + expect(res.status).to.equal(200); + expect(res.body).to.be.an('array').with.lengthOf(2); + + const oppty1 = res.body.find((o) => o.id === OPPTY_1_ID); + expect(oppty1).to.exist; + expect(oppty1.title).to.equal('Corriger les problèmes CWV'); + expect(oppty1.description).to.equal('Améliorer les scores Core Web Vitals'); + expect(oppty1.data).to.not.have.property('i18n'); + }); + + it('user: returns English when locale is omitted', async () => { + const http = getHttpClient(); + const res = await http.user.get(`/sites/${SITE_1_ID}/opportunities`); + expect(res.status).to.equal(200); + + const oppty1 = res.body.find((o) => o.id === OPPTY_1_ID); + expect(oppty1.title).to.equal('Fix CWV issues'); + expect(oppty1.description).to.equal('Improve Core Web Vitals scores'); + }); + + it('user: returns 400 for invalid locale format', async () => { + const http = getHttpClient(); + const res = await http.user.get(`/sites/${SITE_1_ID}/opportunities?locale=fr-FR`); + expect(res.status).to.equal(400); + expect(res.body).to.have.property('message', 'Invalid locale format'); + }); }); describe('GET /sites/:siteId/opportunities/by-status/:status', () => { @@ -124,6 +154,26 @@ export default function opportunityTests(getHttpClient, resetData) { expect(res.body[0].id).to.equal(OPPTY_1_ID); expect(res.body[0].status).to.equal('NEW'); }); + + it('user: returns French title when locale=fr_fr', async () => { + const http = getHttpClient(); + const res = await http.user.get( + `/sites/${SITE_1_ID}/opportunities/by-status/NEW?locale=fr_fr`, + ); + expect(res.status).to.equal(200); + expect(res.body).to.be.an('array').with.lengthOf(1); + expect(res.body[0].title).to.equal('Corriger les problèmes CWV'); + expect(res.body[0].description).to.equal('Améliorer les scores Core Web Vitals'); + }); + + it('user: returns 400 for invalid locale format', async () => { + const http = getHttpClient(); + const res = await http.user.get( + `/sites/${SITE_1_ID}/opportunities/by-status/NEW?locale=invalid`, + ); + expect(res.status).to.equal(400); + expect(res.body).to.have.property('message', 'Invalid locale format'); + }); }); describe('GET /sites/:siteId/opportunities/:opportunityId', () => { @@ -168,6 +218,27 @@ export default function opportunityTests(getHttpClient, resetData) { const res = await http.admin.get(`/sites/${SITE_1_ID}/opportunities/not-a-uuid`); expect(res.status).to.equal(400); }); + + it('user: returns French title and description when locale=fr_fr', async () => { + const http = getHttpClient(); + const res = await http.user.get( + `/sites/${SITE_1_ID}/opportunities/${OPPTY_1_ID}?locale=fr_fr`, + ); + expect(res.status).to.equal(200); + expectOpportunityDto(res.body); + expect(res.body.title).to.equal('Corriger les problèmes CWV'); + expect(res.body.description).to.equal('Améliorer les scores Core Web Vitals'); + expect(res.body.data).to.not.have.property('i18n'); + }); + + it('user: returns 400 for invalid locale format', async () => { + const http = getHttpClient(); + const res = await http.user.get( + `/sites/${SITE_1_ID}/opportunities/${OPPTY_1_ID}?locale=FR_fr`, + ); + expect(res.status).to.equal(400); + expect(res.body).to.have.property('message', 'Invalid locale format'); + }); }); // ── Write endpoints ── diff --git a/test/it/shared/tests/suggestions.js b/test/it/shared/tests/suggestions.js index 108b20fc9a..0724fd438c 100644 --- a/test/it/shared/tests/suggestions.js +++ b/test/it/shared/tests/suggestions.js @@ -79,6 +79,34 @@ export default function suggestionTests(getHttpClient, resetData) { expect(res.status).to.equal(200); expect(res.body).to.be.an('array').with.lengthOf(0); }); + + it('user: returns French title in data when locale=fr_fr', async () => { + const http = getHttpClient(); + const res = await http.user.get(`${BASE}?locale=fr_fr`); + expect(res.status).to.equal(200); + expect(res.body).to.be.an('array').with.lengthOf(3); + + const sugg1 = res.body.find((s) => s.id === SUGG_1_ID); + expect(sugg1).to.exist; + expect(sugg1.data.title).to.equal("Mettre à jour l'image principale"); + expect(sugg1.data).to.not.have.property('i18n'); + }); + + it('user: returns English title when locale is omitted', async () => { + const http = getHttpClient(); + const res = await http.user.get(BASE); + expect(res.status).to.equal(200); + + const sugg1 = res.body.find((s) => s.id === SUGG_1_ID); + expect(sugg1.data.title).to.equal('Update hero image'); + }); + + it('user: returns 400 for invalid locale format', async () => { + const http = getHttpClient(); + const res = await http.user.get(`${BASE}?locale=fr`); + expect(res.status).to.equal(400); + expect(res.body).to.have.property('message', 'Invalid locale format'); + }); }); describe('GET .../suggestions/paged/:limit', () => { @@ -214,6 +242,22 @@ export default function suggestionTests(getHttpClient, resetData) { const res = await http.admin.get(`${BASE}/not-a-uuid`); expect(res.status).to.equal(400); }); + + it('user: returns French title in data when locale=fr_fr', async () => { + const http = getHttpClient(); + const res = await http.user.get(`${BASE}/${SUGG_1_ID}?locale=fr_fr`); + expect(res.status).to.equal(200); + expectSuggestionDto(res.body); + expect(res.body.data.title).to.equal("Mettre à jour l'image principale"); + expect(res.body.data).to.not.have.property('i18n'); + }); + + it('user: returns 400 for invalid locale format', async () => { + const http = getHttpClient(); + const res = await http.user.get(`${BASE}/${SUGG_1_ID}?locale=fr-fr`); + expect(res.status).to.equal(400); + expect(res.body).to.have.property('message', 'Invalid locale format'); + }); }); describe('GET .../suggestions/:suggestionId/fixes', () => { diff --git a/test/utils/validations.test.js b/test/utils/validations.test.js index ba3a0fe7cb..26b1d5dff8 100644 --- a/test/utils/validations.test.js +++ b/test/utils/validations.test.js @@ -11,7 +11,7 @@ */ import { expect } from 'chai'; -import { validateRepoUrl, checkBodySize } from '../../src/utils/validations.js'; +import { validateRepoUrl, checkBodySize, isValidLocale } from '../../src/utils/validations.js'; // Helper to build Uint8Array of given length function makeBytes(len) { @@ -58,4 +58,28 @@ describe('utils/validations', () => { expect(checkBodySize(undefined, 1)).to.be.true; }); }); + + describe('isValidLocale', () => { + it('returns true for undefined or null', () => { + expect(isValidLocale(undefined)).to.be.true; + expect(isValidLocale(null)).to.be.true; + }); + + it('returns true for valid locale format', () => { + expect(isValidLocale('fr_fr')).to.be.true; + expect(isValidLocale('ja_jp')).to.be.true; + expect(isValidLocale('en_us')).to.be.true; + }); + + it('returns false for invalid locale format', () => { + expect(isValidLocale('fr')).to.be.false; + expect(isValidLocale('FR_FR')).to.be.false; + expect(isValidLocale('fr-fr')).to.be.false; + expect(isValidLocale('fr_')).to.be.false; + expect(isValidLocale('_fr')).to.be.false; + expect(isValidLocale('')).to.be.false; + expect(isValidLocale(123)).to.be.false; + expect(isValidLocale({})).to.be.false; + }); + }); });