From 8c01a3f35ccaa8ee5a81ebfd77b4a4eee95919c8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=80=9CWilfred007=E2=80=9D?= <“adzerwilfred007@gmail.com”> Date: Mon, 29 Jun 2026 12:21:26 +0100 Subject: [PATCH 1/2] implemented Backend: Add content moderation integration for campaign text --- backend/src/index.js | 45 +++- backend/src/middleware/contentModeration.js | 42 ++++ backend/src/moderation/blocklist.json | 14 ++ backend/src/moderation/moderation.test.js | 223 ++++++++++++++++++++ backend/src/moderation/moderationService.js | 138 ++++++++++++ backend/trivela.db | Bin 286720 -> 667648 bytes package-lock.json | 26 +++ trivela.db | Bin 0 -> 675840 bytes 8 files changed, 485 insertions(+), 3 deletions(-) create mode 100644 backend/src/middleware/contentModeration.js create mode 100644 backend/src/moderation/blocklist.json create mode 100644 backend/src/moderation/moderation.test.js create mode 100644 backend/src/moderation/moderationService.js create mode 100644 trivela.db diff --git a/backend/src/index.js b/backend/src/index.js index bba3ae7a..ed1bfbef 100644 --- a/backend/src/index.js +++ b/backend/src/index.js @@ -85,6 +85,8 @@ import { createIndexReadRoutes } from './routes/indexRead.js'; import { createSep10Routes, createRequireWalletAuth } from './routes/sep10.js'; import { createZkInputsRoutes } from './routes/zkInputs.js'; import { createOperatorBalanceJob } from './jobs/operatorBalanceJob.js'; +import { createModerationService } from './moderation/moderationService.js'; +import { createContentModerationMiddleware } from './middleware/contentModeration.js'; const DEFAULT_PORT = 3001; const DEFAULT_RATE_LIMIT_WINDOW_MS = 60_000; @@ -491,6 +493,20 @@ export async function createApp(options = {}) { const usageMeteringMiddleware = createUsageMeteringMiddleware({ usageMeteringService }); + const moderationService = + /** @type {any} */ (options.moderationService) ?? + createModerationService({ + provider: + /** @type {string} */ (options.moderationProvider) ?? process.env.MODERATION_PROVIDER, + openaiApiKey: + /** @type {string} */ (options.moderationApiKey) ?? process.env.OPENAI_API_KEY, + fetchImpl, + }); + const contentModerationMiddleware = createContentModerationMiddleware({ + moderationService, + log, + }); + const rateLimiter = createRateLimiter({ windowMs: rateLimitWindowMs, maxRequests: rateLimitMaxRequests, @@ -929,7 +945,7 @@ export async function createApp(options = {}) { } /** @param {import('express').Request} req @param {import('express').Response} res */ - function listCampaigns(req, res) { + async function listCampaigns(req, res) { const cacheKey = `campaigns:${req.originalUrl}`; const cached = shortCache.get(cacheKey); if (cached && cached.expiresAt > Date.now()) { @@ -1785,7 +1801,7 @@ export async function createApp(options = {}) { app.get(`${prefix}/admin/audit/verify`, rateLimiter, requireMasterKey, verifyAuditChain); app.get(`${prefix}/indexer/cursor`, rateLimiter, getIndexerCursorState); app.post(`${prefix}/indexer/cursor`, rateLimiter, ...guard, setIndexerCursorState); - app.post(`${prefix}/campaigns`, rateLimiter, idempotencyMiddleware, ...guard, requireScope('campaigns:write'), createCampaign); + app.post(`${prefix}/campaigns`, rateLimiter, idempotencyMiddleware, ...guard, requireScope('campaigns:write'), contentModerationMiddleware, createCampaign); app.post(`${prefix}/campaigns/:id/clone`, rateLimiter, idempotencyMiddleware, ...guard, requireScope('campaigns:write'), cloneCampaign); app.post(`${prefix}/campaigns/:id/image`, rateLimiter, ...guard, requireScope('campaigns:write'), (req, res, next) => { imageUpload.single('image')(req, res, (err) => { @@ -1799,7 +1815,7 @@ export async function createApp(options = {}) { return uploadCampaignImageHandler(req, res); }); }); - app.put(`${prefix}/campaigns/:id`, rateLimiter, idempotencyMiddleware, ...guard, requireScope('campaigns:write'), updateCampaign); + app.put(`${prefix}/campaigns/:id`, rateLimiter, idempotencyMiddleware, ...guard, requireScope('campaigns:write'), contentModerationMiddleware, updateCampaign); app.delete(`${prefix}/campaigns/:id`, rateLimiter, ...guard, requireScope('campaigns:write'), deleteCampaign); app.put(`${prefix}/campaigns/:id`, rateLimiter, idempotencyMiddleware, requireApiKey, updateCampaign); app.put(`${prefix}/campaigns/:id/publish`, rateLimiter, idempotencyMiddleware, requireApiKey, publishCampaign); @@ -1825,6 +1841,29 @@ export async function createApp(options = {}) { app.get(`${prefix}/admin/dashboard`, rateLimiter, requireMasterKey, getAdminDashboard); app.get(`${prefix}/admin/campaigns`, rateLimiter, requireMasterKey, listAdminCampaigns); + // Content moderation blocklist management + app.get(`${prefix}/admin/moderation/blocklist`, rateLimiter, requireMasterKey, (_req, res) => { + return res.json({ terms: moderationService.getTerms() }); + }); + app.post(`${prefix}/admin/moderation/blocklist`, rateLimiter, requireMasterKey, (req, res) => { + const { action, term } = req.body ?? {}; + if (!term || typeof term !== 'string' || !term.trim()) { + return res.status(400).json({ error: 'term is required', code: 'VALIDATION_ERROR' }); + } + if (action !== 'add' && action !== 'remove') { + return res.status(400).json({ + error: 'action must be "add" or "remove"', + code: 'VALIDATION_ERROR', + }); + } + if (action === 'add') { + moderationService.addTerm(term); + } else { + moderationService.removeTerm(term); + } + return res.json({ ok: true, terms: moderationService.getTerms() }); + }); + // Tenant usage metering (Issue #574) app.get(`${prefix}/usage`, rateLimiter, ...guard, (req, res) => { const orgId = req.auth?.orgId; diff --git a/backend/src/middleware/contentModeration.js b/backend/src/middleware/contentModeration.js new file mode 100644 index 00000000..d155505c --- /dev/null +++ b/backend/src/middleware/contentModeration.js @@ -0,0 +1,42 @@ +/** + * Content moderation middleware for campaign create/update. + * + * Checks name, description, and tags against the configured moderation + * provider. Admin (master-key) requests may pass `override_moderation: true` + * in the body to skip the check. + */ + +/** + * @param {{ + * moderationService: import('../moderation/moderationService.js').ReturnType, + * log: import('../middleware/logger.js').Logger, + * }} options + */ +export function createContentModerationMiddleware({ moderationService, log }) { + return async function contentModerationMiddleware(req, res, next) { + // Env-sourced API keys are operator-level (equivalent to admin); allow override. + if (req.body?.override_moderation === true && req.auth?.source === 'env') { + return next(); + } + + const { name, description, tags } = req.body ?? {}; + + try { + const result = await moderationService.check({ name, description, tags }); + + if (result.flagged) { + const campaignId = req.params?.id ?? 'new'; + log.info({ campaignId, categories: result.categories }, 'Content moderation flag'); + return res.status(422).json({ + error: 'Content violates community guidelines', + categories: result.categories, + }); + } + + return next(); + } catch (err) { + log.warn({ err }, 'Content moderation check failed, allowing request through'); + return next(); + } + }; +} diff --git a/backend/src/moderation/blocklist.json b/backend/src/moderation/blocklist.json new file mode 100644 index 00000000..065f21d1 --- /dev/null +++ b/backend/src/moderation/blocklist.json @@ -0,0 +1,14 @@ +{ + "terms": [ + "buy now", + "click here", + "free money", + "win now", + "you're a winner", + "urgent action required", + "verify your account", + "limited time offer", + "act now", + "make money fast" + ] +} diff --git a/backend/src/moderation/moderation.test.js b/backend/src/moderation/moderation.test.js new file mode 100644 index 00000000..a93d4796 --- /dev/null +++ b/backend/src/moderation/moderation.test.js @@ -0,0 +1,223 @@ +import assert from 'node:assert/strict'; +import { once } from 'node:events'; +import test from 'node:test'; +import { createApp } from '../index.js'; +import { createModerationService } from './moderationService.js'; + +// ── Unit tests: moderationService ──────────────────────────────────────────── + +test('moderationService (local): flags content containing a blocked term', async () => { + const svc = createModerationService({ provider: 'local', terms: ['spam', 'buy now'] }); + const result = await svc.check({ name: 'Buy Now Exclusive', description: 'great deal' }); + assert.equal(result.flagged, true); + assert.deepEqual(result.categories, ['spam']); +}); + +test('moderationService (local): passes clean content', async () => { + const svc = createModerationService({ provider: 'local', terms: ['spam', 'buy now'] }); + const result = await svc.check({ name: 'Summer Referral', description: 'Earn points' }); + assert.equal(result.flagged, false); + assert.deepEqual(result.categories, []); +}); + +test('moderationService (local): checks description and tags too', async () => { + const svc = createModerationService({ provider: 'local', terms: ['free money'] }); + const result = await svc.check({ name: 'Good Name', description: 'Earn free money now', tags: [] }); + assert.equal(result.flagged, true); +}); + +test('moderationService (local): tags are checked', async () => { + const svc = createModerationService({ provider: 'local', terms: ['click here'] }); + const result = await svc.check({ name: 'Normal', description: 'Normal', tags: ['click here'] }); + assert.equal(result.flagged, true); +}); + +test('moderationService (none): always passes', async () => { + const svc = createModerationService({ provider: 'none', terms: ['spam'] }); + const result = await svc.check({ name: 'spam spam spam', description: 'buy now' }); + assert.equal(result.flagged, false); + assert.deepEqual(result.categories, []); +}); + +test('moderationService: addTerm and removeTerm update in-memory list', async () => { + const svc = createModerationService({ provider: 'local', terms: ['spam'] }); + + assert.equal((await svc.check({ name: 'new evil word' })).flagged, false); + svc.addTerm('evil word'); + assert.equal((await svc.check({ name: 'new evil word' })).flagged, true); + + svc.removeTerm('evil word'); + assert.equal((await svc.check({ name: 'new evil word' })).flagged, false); +}); + +test('moderationService: getTerms returns current list', () => { + const svc = createModerationService({ provider: 'local', terms: ['alpha', 'beta'] }); + assert.deepEqual(svc.getTerms().sort(), ['alpha', 'beta']); + svc.addTerm('gamma'); + assert.deepEqual(svc.getTerms().sort(), ['alpha', 'beta', 'gamma']); +}); + +// ── Integration tests: HTTP endpoints ──────────────────────────────────────── + +async function startTestServer(options = {}) { + const app = await createApp({ disableJobs: true, disableWebSocket: true, dbPath: ':memory:', ...options }); + const server = app.listen(0); + await once(server, 'listening'); + const { port } = server.address(); + return { server, baseUrl: `http://127.0.0.1:${port}` }; +} + +async function stopTestServer(server) { + await new Promise((resolve, reject) => server.close((err) => (err ? reject(err) : resolve()))); +} + +test('POST /api/v1/campaigns: blocked term returns 422 with categories', async () => { + const blockedSvc = createModerationService({ provider: 'local', terms: ['buy now'] }); + const { server, baseUrl } = await startTestServer({ + apiKey: 'test-key', + moderationService: blockedSvc, + }); + + try { + const res = await fetch(`${baseUrl}/api/v1/campaigns`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'X-API-Key': 'test-key' }, + body: JSON.stringify({ + name: 'Buy Now and Win', + description: 'Great campaign', + rewardPerAction: 5, + }), + }); + assert.equal(res.status, 422); + const body = await res.json(); + assert.equal(body.error, 'Content violates community guidelines'); + assert.ok(Array.isArray(body.categories)); + assert.ok(body.categories.length > 0); + } finally { + await stopTestServer(server); + } +}); + +test('POST /api/v1/campaigns: clean content passes moderation and creates campaign', async () => { + const cleanSvc = createModerationService({ provider: 'local', terms: ['buy now'] }); + const { server, baseUrl } = await startTestServer({ + apiKey: 'test-key', + moderationService: cleanSvc, + }); + + try { + const res = await fetch(`${baseUrl}/api/v1/campaigns`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'X-API-Key': 'test-key' }, + body: JSON.stringify({ + name: 'Summer Referral Programme', + description: 'Earn points for every friend you refer', + rewardPerAction: 10, + }), + }); + assert.equal(res.status, 201); + const body = await res.json(); + assert.equal(typeof body.id, 'string'); + } finally { + await stopTestServer(server); + } +}); + +test('POST /api/v1/campaigns: override_moderation bypasses check for env-sourced (admin) API key', async () => { + const strictSvc = createModerationService({ provider: 'local', terms: ['buy now'] }); + // 'admin-key' is env-sourced (passed via apiKey option → source: 'env') + const { server, baseUrl } = await startTestServer({ + apiKey: 'admin-key', + moderationService: strictSvc, + }); + + try { + const res = await fetch(`${baseUrl}/api/v1/campaigns`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'X-API-Key': 'admin-key' }, + body: JSON.stringify({ + name: 'Buy Now Admin Test', + description: 'Admin override test', + rewardPerAction: 1, + override_moderation: true, + }), + }); + assert.equal(res.status, 201); + const body = await res.json(); + assert.equal(typeof body.id, 'string'); + } finally { + await stopTestServer(server); + } +}); + +test('POST /api/v1/admin/moderation/blocklist: add and remove terms', async () => { + const svc = createModerationService({ provider: 'local', terms: ['spam'] }); + const { server, baseUrl } = await startTestServer({ + masterKey: 'admin-key', + moderationService: svc, + }); + + try { + // Add a term + const addRes = await fetch(`${baseUrl}/api/v1/admin/moderation/blocklist`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'X-API-Key': 'admin-key' }, + body: JSON.stringify({ action: 'add', term: 'new-blocked-term' }), + }); + assert.equal(addRes.status, 200); + const addBody = await addRes.json(); + assert.ok(addBody.terms.includes('new-blocked-term')); + + // Remove the term + const removeRes = await fetch(`${baseUrl}/api/v1/admin/moderation/blocklist`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'X-API-Key': 'admin-key' }, + body: JSON.stringify({ action: 'remove', term: 'new-blocked-term' }), + }); + assert.equal(removeRes.status, 200); + const removeBody = await removeRes.json(); + assert.ok(!removeBody.terms.includes('new-blocked-term')); + } finally { + await stopTestServer(server); + } +}); + +test('GET /api/v1/admin/moderation/blocklist: returns current terms', async () => { + const svc = createModerationService({ provider: 'local', terms: ['alpha', 'beta'] }); + const { server, baseUrl } = await startTestServer({ + masterKey: 'admin-key', + moderationService: svc, + }); + + try { + const res = await fetch(`${baseUrl}/api/v1/admin/moderation/blocklist`, { + headers: { 'X-API-Key': 'admin-key' }, + }); + assert.equal(res.status, 200); + const body = await res.json(); + assert.ok(Array.isArray(body.terms)); + assert.deepEqual(body.terms.sort(), ['alpha', 'beta']); + } finally { + await stopTestServer(server); + } +}); + +test('POST /api/v1/admin/moderation/blocklist: requires master key', async () => { + const svc = createModerationService({ provider: 'local', terms: [] }); + const { server, baseUrl } = await startTestServer({ + apiKey: 'regular-key', + masterKey: 'admin-key', + moderationService: svc, + }); + + try { + const res = await fetch(`${baseUrl}/api/v1/admin/moderation/blocklist`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'X-API-Key': 'regular-key' }, + body: JSON.stringify({ action: 'add', term: 'test' }), + }); + assert.equal(res.status, 401); + } finally { + await stopTestServer(server); + } +}); diff --git a/backend/src/moderation/moderationService.js b/backend/src/moderation/moderationService.js new file mode 100644 index 00000000..3069ae89 --- /dev/null +++ b/backend/src/moderation/moderationService.js @@ -0,0 +1,138 @@ +import { readFileSync, writeFileSync } from 'node:fs'; +import { join, dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const DEFAULT_BLOCKLIST_PATH = join( + dirname(fileURLToPath(import.meta.url)), + 'blocklist.json', +); + +function loadBlocklistFile(path) { + try { + const raw = readFileSync(path, 'utf8'); + const data = JSON.parse(raw); + return Array.isArray(data.terms) ? data.terms.map((t) => String(t).toLowerCase().trim()) : []; + } catch { + return []; + } +} + +function saveBlocklistFile(path, terms) { + writeFileSync(path, JSON.stringify({ terms }, null, 2) + '\n', 'utf8'); +} + +async function checkOpenAI(input, fetchImpl, apiKey) { + try { + const response = await fetchImpl('https://api.openai.com/v1/moderations', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${apiKey}`, + }, + body: JSON.stringify({ input }), + }); + + if (!response.ok) { + return { flagged: false, categories: [] }; + } + + const data = await response.json(); + const result = data.results?.[0]; + if (!result) { + return { flagged: false, categories: [] }; + } + + const categories = Object.entries(result.categories ?? {}) + .filter(([, hit]) => hit) + .map(([cat]) => cat); + + return { flagged: result.flagged === true, categories }; + } catch { + return { flagged: false, categories: [] }; + } +} + +/** + * @param {{ + * provider?: 'local' | 'openai' | 'none', + * terms?: string[] | null, + * blocklistPath?: string, + * fetchImpl?: typeof globalThis.fetch, + * openaiApiKey?: string, + * }} [options] + */ +export function createModerationService({ + provider = process.env.MODERATION_PROVIDER ?? 'local', + terms = null, + blocklistPath = DEFAULT_BLOCKLIST_PATH, + fetchImpl = globalThis.fetch, + openaiApiKey = process.env.OPENAI_API_KEY ?? '', +} = {}) { + // When terms are injected directly (tests / in-process overrides), skip file I/O. + const fileBacked = terms === null; + let currentTerms = fileBacked + ? loadBlocklistFile(blocklistPath) + : terms.map((t) => String(t).toLowerCase().trim()); + + /** + * @param {{ name?: string, description?: string, tags?: string[] }} fields + * @returns {Promise<{ flagged: boolean, categories: string[] }>} + */ + async function check({ name = '', description = '', tags = [] } = {}) { + if (provider === 'none') { + return { flagged: false, categories: [] }; + } + + const combined = [name, description, ...(Array.isArray(tags) ? tags : [])] + .filter(Boolean) + .join(' '); + + if (!combined.trim()) { + return { flagged: false, categories: [] }; + } + + if (provider === 'local') { + const lower = combined.toLowerCase(); + const hit = currentTerms.some((term) => lower.includes(term)); + return { flagged: hit, categories: hit ? ['spam'] : [] }; + } + + if (provider === 'openai') { + if (!openaiApiKey) { + return { flagged: false, categories: [] }; + } + return checkOpenAI(combined, fetchImpl, openaiApiKey); + } + + return { flagged: false, categories: [] }; + } + + /** @param {string} term */ + function addTerm(term) { + const normalized = String(term).toLowerCase().trim(); + if (!normalized || currentTerms.includes(normalized)) { + return; + } + currentTerms.push(normalized); + if (fileBacked) { + saveBlocklistFile(blocklistPath, currentTerms); + } + } + + /** @param {string} term */ + function removeTerm(term) { + const normalized = String(term).toLowerCase().trim(); + const before = currentTerms.length; + currentTerms = currentTerms.filter((t) => t !== normalized); + if (fileBacked && currentTerms.length !== before) { + saveBlocklistFile(blocklistPath, currentTerms); + } + } + + /** @returns {string[]} */ + function getTerms() { + return [...currentTerms]; + } + + return { check, addTerm, removeTerm, getTerms }; +} diff --git a/backend/trivela.db b/backend/trivela.db index 405756f0c83fe655a5d6aad2e79a0cd7da860847..7c4786a9c61006ed198bad7a40e08e167070490c 100644 GIT binary patch literal 667648 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 diff --git a/package-lock.json b/package-lock.json index 5bcc39f6..fcb7836c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -48,6 +48,7 @@ "zod": "^3.23.0" }, "devDependencies": { + "@eslint/js": "^9.0.0", "@readme/openapi-parser": "^2.6.0", "@redocly/cli": "^1.34.5", "@types/cors": "^2.8.0", @@ -56,12 +57,27 @@ "@types/supertest": "^6.0.0", "ajv": "^8.17.0", "ajv-formats": "^3.0.1", + "eslint": "^9.0.0", + "globals": "^15.0.0", "js-yaml": "^4.1.0", "nodemon": "^3.1.0", "redoc-cli": "^0.13.21", "typescript": "^5.0.0" } }, + "backend/node_modules/globals": { + "version": "15.15.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-15.15.0.tgz", + "integrity": "sha512-7ACyT3wmyp3I61S4fG682L0VA2RGD9otkqGJIwNUMF1SWUombIIk+af1unuDYgMm082aHYwD+mzJvv9Iu8dsgg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "frontend": { "version": "0.1.0", "license": "Apache-2.0", @@ -70,6 +86,7 @@ "@tanstack/react-virtual": "^3.14.3", "dompurify": "^3.1.0", "driver.js": "^1.3.1", + "qrcode.react": "^3.1.0", "react": "^18.3.1", "react-dom": "^18.3.1", "react-helmet-async": "^2.0.5", @@ -13742,6 +13759,15 @@ "node": ">=6" } }, + "node_modules/qrcode.react": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/qrcode.react/-/qrcode.react-3.2.0.tgz", + "integrity": "sha512-YietHHltOHA4+l5na1srdaMx4sVSOjV9tamHs+mwiLWAMr6QVACRUw1Neax5CptFILcNoITctJY0Ipyn5enQ8g==", + "license": "ISC", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0" + } + }, "node_modules/qs": { "version": "6.15.2", "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.2.tgz", diff --git a/trivela.db b/trivela.db new file mode 100644 index 0000000000000000000000000000000000000000..0807695199d410f9b7967ba960df0c7f076c2e26 GIT binary patch literal 675840 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 literal 0 HcmV?d00001 From b66d57334dcd98f991ab6cf6764e0a5bb6e1243a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=80=9CWilfred007=E2=80=9D?= <“adzerwilfred007@gmail.com”> Date: Mon, 29 Jun 2026 12:40:32 +0100 Subject: [PATCH 2/2] trivela backend implementations --- backend/src/dal/sqliteCampaignRepository.js | 56 ++- .../migrations/027_campaign_translations.js | 16 + backend/src/index.js | 193 +++++++++- backend/src/routes/translations.test.js | 361 ++++++++++++++++++ 4 files changed, 606 insertions(+), 20 deletions(-) create mode 100644 backend/src/db/migrations/027_campaign_translations.js create mode 100644 backend/src/routes/translations.test.js diff --git a/backend/src/dal/sqliteCampaignRepository.js b/backend/src/dal/sqliteCampaignRepository.js index 3148b4b8..71792f44 100644 --- a/backend/src/dal/sqliteCampaignRepository.js +++ b/backend/src/dal/sqliteCampaignRepository.js @@ -86,7 +86,17 @@ function parseTagsFromRow(row) { } } +function parseTranslationsFromRow(row) { + try { + const parsed = JSON.parse(row.translations ?? '{}'); + return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : {}; + } catch { + return {}; + } +} + function rowToCampaign(row) { + const rawTranslations = parseTranslationsFromRow(row); const campaign = { id: String(row.id), name: row.name, @@ -107,8 +117,9 @@ function rowToCampaign(row) { status: row.status ?? 'draft', createdAt: row.created_at, updatedAt: row.updated_at ?? row.created_at, + available_locales: Object.keys(rawTranslations), + _rawTranslations: rawTranslations, }; - // Keep the computed status for backward compatibility with time-based status campaign.computedStatus = computeCampaignStatus(campaign); return campaign; } @@ -503,6 +514,46 @@ export function createSqliteCampaignRepository({ return getById(id); } + /** @param {string | number} id @returns {Record} */ + function getTranslations(id) { + const row = db.prepare('SELECT translations FROM campaigns WHERE id = ?').get(Number(id)); + if (!row) return {}; + return parseTranslationsFromRow(row); + } + + /** + * @param {string | number} id + * @param {string} locale + * @param {{ name?: string, description?: string }} data + */ + function upsertTranslation(id, locale, data) { + const current = getTranslations(id); + const updated = { ...current, [locale]: { ...data } }; + const updatedAt = new Date().toISOString(); + db.prepare('UPDATE campaigns SET translations = ?, updated_at = ? WHERE id = ?').run( + JSON.stringify(updated), + updatedAt, + Number(id), + ); + } + + /** + * @param {string | number} id + * @param {string} locale + */ + function deleteTranslation(id, locale) { + const current = getTranslations(id); + if (!(locale in current)) return false; + const { [locale]: _removed, ...rest } = current; + const updatedAt = new Date().toISOString(); + db.prepare('UPDATE campaigns SET translations = ?, updated_at = ? WHERE id = ?').run( + JSON.stringify(rest), + updatedAt, + Number(id), + ); + return true; + } + return { list, listCategories, @@ -515,6 +566,9 @@ export function createSqliteCampaignRepository({ clone, publish, archive, + getTranslations, + upsertTranslation, + deleteTranslation, ftsAvailable, }; } diff --git a/backend/src/db/migrations/027_campaign_translations.js b/backend/src/db/migrations/027_campaign_translations.js new file mode 100644 index 00000000..70b56d1a --- /dev/null +++ b/backend/src/db/migrations/027_campaign_translations.js @@ -0,0 +1,16 @@ +export const version = 27; +export const description = 'Add translations JSON column to campaigns'; + +export function up(db) { + const columns = db.prepare('PRAGMA table_info(campaigns)').all(); + const columnNames = new Set(columns.map((col) => col.name)); + + if (!columnNames.has('translations')) { + db.exec("ALTER TABLE campaigns ADD COLUMN translations TEXT NOT NULL DEFAULT '{}'"); + } +} + +export function down(db) { + // SQLite does not support DROP COLUMN in older versions; left as no-op. + void db; +} diff --git a/backend/src/index.js b/backend/src/index.js index ed1bfbef..11d88077 100644 --- a/backend/src/index.js +++ b/backend/src/index.js @@ -89,6 +89,61 @@ import { createModerationService } from './moderation/moderationService.js'; import { createContentModerationMiddleware } from './middleware/contentModeration.js'; const DEFAULT_PORT = 3001; + +// BCP-47: language (2-3 chars) + optional script (4 chars) + optional region (2 chars) +const BCP47_RE = /^[a-z]{2,3}(-[A-Za-z]{2,4}(-[A-Z]{2})?)?$/; + +/** @param {string} locale */ +export function isValidLocale(locale) { + return BCP47_RE.test(locale); +} + +/** @param {string | undefined} header */ +function parseAcceptLanguage(header) { + if (!header) return []; + return header + .split(',') + .map((part) => { + const [tag, qPart] = part.trim().split(';'); + const q = qPart ? parseFloat(qPart.replace(/.*=/, '')) : 1.0; + return { locale: tag.trim(), q: Number.isFinite(q) ? q : 1.0 }; + }) + .sort((a, b) => b.q - a.q) + .map(({ locale }) => locale) + .filter(Boolean); +} + +/** @param {import('express').Request} req @returns {string[]} */ +function getRequestLocales(req) { + const queryLocale = req.query?.locale; + if (typeof queryLocale === 'string' && queryLocale.trim()) { + return [queryLocale.trim()]; + } + return parseAcceptLanguage(req.headers['accept-language']); +} + +/** + * Strips `_rawTranslations` and applies locale negotiation to name/description. + * @param {Record} campaign + * @param {string[]} [locales] + * @returns {Record} + */ +function serializeCampaign(campaign, locales = []) { + const { _rawTranslations, ...pub } = campaign; + if (!_rawTranslations || !locales.length) return pub; + for (const locale of locales) { + if (locale === 'en' || locale.startsWith('en-')) break; + const trans = + _rawTranslations[locale] ?? _rawTranslations[locale.split('-')[0]] ?? null; + if (trans) { + if (trans.name) pub.name = trans.name; + if (trans.description) pub.description = trans.description; + break; + } + } + return pub; +} + const DEFAULT_RATE_LIMIT_WINDOW_MS = 60_000; const DEFAULT_RATE_LIMIT_MAX_REQUESTS = 60; const DEFAULT_AUTH_LOCKOUT_SOFT_THRESHOLD = 5; @@ -946,10 +1001,16 @@ export async function createApp(options = {}) { /** @param {import('express').Request} req @param {import('express').Response} res */ async function listCampaigns(req, res) { - const cacheKey = `campaigns:${req.originalUrl}`; - const cached = shortCache.get(cacheKey); - if (cached && cached.expiresAt > Date.now()) { - return res.set('x-cache', 'HIT').json(cached.payload); + // Exclude ?locale from cache key so locale variants share the same raw-data cache entry. + const cacheKey = `campaigns:${req.originalUrl.replace(/([?&])locale=[^&]*/g, '$1').replace(/[?&]$/, '')}`; + const locales = getRequestLocales(req); + const rawCached = shortCache.get(cacheKey); + if (rawCached && rawCached.expiresAt > Date.now()) { + const payload = { + ...rawCached.payload, + data: rawCached.payload.data.map((c) => serializeCampaign(c, locales)), + }; + return res.set('x-cache', 'HIT').json(payload); } const activeRaw = @@ -1010,12 +1071,16 @@ export async function createApp(options = {}) { sortedItems = sortByUrgency(items); } - const payload = paginateItems(sortedItems, req.query); + const rawPayload = paginateItems(sortedItems, req.query); shortCache.set(cacheKey, { expiresAt: Date.now() + shortCacheTtlMs, - payload, + payload: rawPayload, }); res.set('Cache-Control', 'public, max-age=60, stale-while-revalidate=120'); + const payload = { + ...rawPayload, + data: rawPayload.data.map((c) => serializeCampaign(c, locales)), + }; return res.set('x-cache', 'MISS').json(payload); } @@ -1023,12 +1088,14 @@ export async function createApp(options = {}) { function getTrendingCampaigns(req, res) { const limitRaw = Number.parseInt(String(req.query.limit ?? '6'), 10); const limit = Number.isFinite(limitRaw) && limitRaw > 0 && limitRaw <= 50 ? limitRaw : 6; + const locales = getRequestLocales(req); const cacheKey = `trending:${limit}`; - const cached = shortCache.get(cacheKey); - if (cached && cached.expiresAt > Date.now()) { + const rawCached = shortCache.get(cacheKey); + if (rawCached && rawCached.expiresAt > Date.now()) { res.set('Cache-Control', 'public, max-age=60, stale-while-revalidate=120'); - return res.set('x-cache', 'HIT').json(cached.payload); + const payload = { ...rawCached.payload, data: rawCached.payload.data.map((c) => serializeCampaign(c, locales)) }; + return res.set('x-cache', 'HIT').json(payload); } const all = campaignRepository.list({ @@ -1036,11 +1103,12 @@ export async function createApp(options = {}) { sort: 'reward_per_action', order: 'desc', }); - const data = all.slice(0, limit); - const payload = { data, total: data.length }; + const rawData = all.slice(0, limit); + const rawPayload = { data: rawData, total: rawData.length }; - shortCache.set(cacheKey, { expiresAt: Date.now() + shortCacheTtlMs, payload }); + shortCache.set(cacheKey, { expiresAt: Date.now() + shortCacheTtlMs, payload: rawPayload }); res.set('Cache-Control', 'public, max-age=60, stale-while-revalidate=120'); + const payload = { ...rawPayload, data: rawPayload.data.map((c) => serializeCampaign(c, locales)) }; return res.set('x-cache', 'MISS').json(payload); } @@ -1050,7 +1118,7 @@ export async function createApp(options = {}) { if (!campaign) { return res.status(404).json({ error: 'Campaign not found', code: 'CAMPAIGN_NOT_FOUND' }); } - return res.json(campaign); + return res.json(serializeCampaign(campaign, getRequestLocales(req))); } /** @param {import('express').Request} req @param {import('express').Response} res */ @@ -1077,7 +1145,7 @@ export async function createApp(options = {}) { if (!campaign) { return res.status(404).json({ error: 'Campaign not found', code: 'CAMPAIGN_NOT_FOUND' }); } - return res.json(campaign); + return res.json(serializeCampaign(campaign, getRequestLocales(req))); } /** @param {import('express').Request} req @param {import('express').Response} res */ @@ -1158,7 +1226,7 @@ export async function createApp(options = {}) { } shortCache.clear(); - return res.status(201).json(campaign); + return res.status(201).json(serializeCampaign(campaign)); } catch (error) { if ( /** @type {any} */ (error).message?.includes('Tag') || @@ -1305,7 +1373,7 @@ export async function createApp(options = {}) { } shortCache.clear(); - return res.json(campaign); + return res.json(serializeCampaign(campaign)); } /** @param {import('express').Request} req @param {import('express').Response} res */ @@ -1369,7 +1437,7 @@ export async function createApp(options = {}) { }); shortCache.clear(); - return res.status(201).json(clonedCampaign); + return res.status(201).json(serializeCampaign(clonedCampaign)); } catch (error) { if (/** @type {any} */ (error).message?.includes('UNIQUE constraint failed')) { return res.status(409).json({ @@ -1414,7 +1482,7 @@ export async function createApp(options = {}) { }); shortCache.clear(); - return res.json(campaign); + return res.json(serializeCampaign(campaign)); } catch (error) { return res.status(400).json({ error: /** @type {Error} */ (error).message, @@ -1455,7 +1523,7 @@ export async function createApp(options = {}) { }); shortCache.clear(); - return res.json(campaign); + return res.json(serializeCampaign(campaign)); } catch (error) { return res.status(400).json({ error: /** @type {Error} */ (error).message, @@ -1826,6 +1894,93 @@ export async function createApp(options = {}) { app.put(`${prefix}/campaigns/:id/archive`, rateLimiter, idempotencyMiddleware, ...guard, archiveCampaign); app.delete(`${prefix}/campaigns/:id`, rateLimiter, ...guard, deleteCampaign); + // Campaign translations (i18n) + app.get(`${prefix}/campaigns/:id/translations`, rateLimiter, ...guard, (req, res) => { + const campaign = campaignRepository.getById(req.params.id); + if (!campaign) { + return res.status(404).json({ error: 'Campaign not found', code: 'CAMPAIGN_NOT_FOUND' }); + } + const translations = campaignRepository.getTranslations(req.params.id); + return res.json({ campaignId: campaign.id, translations }); + }); + + app.put( + `${prefix}/campaigns/:id/translations/:locale`, + rateLimiter, + idempotencyMiddleware, + ...guard, + requireScope('campaigns:write'), + (req, res) => { + const { locale } = req.params; + + if (!isValidLocale(locale)) { + return res.status(400).json({ + error: 'Invalid locale — must be a valid BCP-47 tag (e.g. es, fr, zh-CN)', + code: 'INVALID_LOCALE', + }); + } + + const campaign = campaignRepository.getById(req.params.id); + if (!campaign) { + return res.status(404).json({ error: 'Campaign not found', code: 'CAMPAIGN_NOT_FOUND' }); + } + + const { name, description } = req.body ?? {}; + if (!name && !description) { + return res.status(400).json({ + error: 'At least one of name or description is required', + code: 'VALIDATION_ERROR', + }); + } + + const translationPayload = {}; + if (name !== undefined) translationPayload.name = String(name); + if (description !== undefined) translationPayload.description = String(description); + + if (Buffer.byteLength(JSON.stringify(translationPayload), 'utf8') > 2048) { + return res.status(413).json({ + error: 'Translation exceeds the 2KB limit', + code: 'TRANSLATION_TOO_LARGE', + }); + } + + const current = campaignRepository.getTranslations(req.params.id); + const currentLocales = Object.keys(current); + if (!currentLocales.includes(locale) && currentLocales.length >= 10) { + return res.status(422).json({ + error: 'Maximum 10 locales per campaign', + code: 'LOCALE_LIMIT_EXCEEDED', + }); + } + + campaignRepository.upsertTranslation(req.params.id, locale, translationPayload); + shortCache.clear(); + + const updated = campaignRepository.getTranslations(req.params.id); + return res.json({ campaignId: campaign.id, locale, translation: updated[locale] }); + }, + ); + + app.delete( + `${prefix}/campaigns/:id/translations/:locale`, + rateLimiter, + ...guard, + requireScope('campaigns:write'), + (req, res) => { + const { locale } = req.params; + const campaign = campaignRepository.getById(req.params.id); + if (!campaign) { + return res.status(404).json({ error: 'Campaign not found', code: 'CAMPAIGN_NOT_FOUND' }); + } + const removed = campaignRepository.deleteTranslation(req.params.id, locale); + if (!removed) { + return res.status(404).json({ error: 'Locale not found', code: 'LOCALE_NOT_FOUND' }); + } + shortCache.clear(); + return res.status(204).end(); + }, + ); + app.post(`${prefix}/admin/api-keys`, rateLimiter, idempotencyMiddleware, requireMasterKey, createApiKeyHandler); app.get(`${prefix}/admin/api-keys`, rateLimiter, requireMasterKey, listApiKeysHandler); app.delete(`${prefix}/admin/api-keys/:id`, rateLimiter, requireMasterKey, revokeApiKeyHandler); diff --git a/backend/src/routes/translations.test.js b/backend/src/routes/translations.test.js new file mode 100644 index 00000000..f931860d --- /dev/null +++ b/backend/src/routes/translations.test.js @@ -0,0 +1,361 @@ +import assert from 'node:assert/strict'; +import { once } from 'node:events'; +import test from 'node:test'; +import Database from 'better-sqlite3'; +import { runMigrations } from '../db/migrate.js'; +import { createSqliteCampaignRepository } from '../dal/sqliteCampaignRepository.js'; +import { createApp, isValidLocale } from '../index.js'; + +// ── BCP-47 validation ───────────────────────────────────────────────────────── + +test('isValidLocale accepts common BCP-47 tags', () => { + assert.ok(isValidLocale('es')); + assert.ok(isValidLocale('fr')); + assert.ok(isValidLocale('zh')); + assert.ok(isValidLocale('zh-CN')); + assert.ok(isValidLocale('pt-BR')); + assert.ok(isValidLocale('en-US')); + assert.ok(isValidLocale('zh-Hant')); +}); + +test('isValidLocale rejects invalid locales', () => { + assert.ok(!isValidLocale('')); + assert.ok(!isValidLocale('e')); + assert.ok(!isValidLocale('english')); + assert.ok(!isValidLocale('en_US')); + assert.ok(!isValidLocale('EN')); + assert.ok(!isValidLocale('123')); +}); + +// ── Repository: translations CRUD ──────────────────────────────────────────── + +async function setupRepo() { + const db = new Database(':memory:'); + await runMigrations(db); + return createSqliteCampaignRepository({ db, seed: [] }); +} + +test('repository: getTranslations returns empty object for new campaign', async () => { + const repo = await setupRepo(); + const campaign = repo.create({ name: 'Test', rewardPerAction: 0 }); + assert.deepEqual(repo.getTranslations(campaign.id), {}); +}); + +test('repository: upsertTranslation stores and retrieves a translation', async () => { + const repo = await setupRepo(); + const campaign = repo.create({ name: 'Test', rewardPerAction: 0 }); + + repo.upsertTranslation(campaign.id, 'es', { name: 'Prueba', description: 'Descripción' }); + const translations = repo.getTranslations(campaign.id); + + assert.deepEqual(translations.es, { name: 'Prueba', description: 'Descripción' }); +}); + +test('repository: upsertTranslation overwrites existing locale', async () => { + const repo = await setupRepo(); + const campaign = repo.create({ name: 'Test', rewardPerAction: 0 }); + + repo.upsertTranslation(campaign.id, 'es', { name: 'Primera' }); + repo.upsertTranslation(campaign.id, 'es', { name: 'Segunda' }); + + const translations = repo.getTranslations(campaign.id); + assert.equal(translations.es.name, 'Segunda'); +}); + +test('repository: available_locales reflects stored translations', async () => { + const repo = await setupRepo(); + const campaign = repo.create({ name: 'Test', rewardPerAction: 0 }); + assert.deepEqual(campaign.available_locales, []); + + repo.upsertTranslation(campaign.id, 'fr', { name: 'Essai' }); + repo.upsertTranslation(campaign.id, 'es', { name: 'Prueba' }); + + const refreshed = repo.getById(campaign.id); + assert.ok(refreshed.available_locales.includes('fr')); + assert.ok(refreshed.available_locales.includes('es')); + assert.equal(refreshed.available_locales.length, 2); +}); + +test('repository: deleteTranslation removes a locale', async () => { + const repo = await setupRepo(); + const campaign = repo.create({ name: 'Test', rewardPerAction: 0 }); + + repo.upsertTranslation(campaign.id, 'es', { name: 'Prueba' }); + const removed = repo.deleteTranslation(campaign.id, 'es'); + assert.ok(removed); + assert.deepEqual(repo.getTranslations(campaign.id), {}); +}); + +test('repository: deleteTranslation returns false for missing locale', async () => { + const repo = await setupRepo(); + const campaign = repo.create({ name: 'Test', rewardPerAction: 0 }); + assert.equal(repo.deleteTranslation(campaign.id, 'es'), false); +}); + +// ── HTTP integration tests ──────────────────────────────────────────────────── + +async function startTestServer(options = {}) { + const app = await createApp({ + disableJobs: true, + disableWebSocket: true, + dbPath: ':memory:', + apiKey: 'test-key', + ...options, + }); + const server = app.listen(0); + await once(server, 'listening'); + const { port } = server.address(); + return { server, baseUrl: `http://127.0.0.1:${port}` }; +} + +async function stop(server) { + await new Promise((resolve, reject) => server.close((err) => (err ? reject(err) : resolve()))); +} + +async function createTestCampaign(baseUrl) { + const res = await fetch(`${baseUrl}/api/v1/campaigns`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'X-API-Key': 'test-key' }, + body: JSON.stringify({ name: 'My Campaign', description: 'English desc', rewardPerAction: 5 }), + }); + return res.json(); +} + +test('PUT /campaigns/:id/translations/:locale stores a translation', async () => { + const { server, baseUrl } = await startTestServer(); + try { + const campaign = await createTestCampaign(baseUrl); + const res = await fetch(`${baseUrl}/api/v1/campaigns/${campaign.id}/translations/es`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json', 'X-API-Key': 'test-key' }, + body: JSON.stringify({ name: 'Mi Campaña', description: 'Descripción en español' }), + }); + assert.equal(res.status, 200); + const body = await res.json(); + assert.equal(body.locale, 'es'); + assert.equal(body.translation.name, 'Mi Campaña'); + } finally { + await stop(server); + } +}); + +test('GET /campaigns/:id/translations returns all translations', async () => { + const { server, baseUrl } = await startTestServer(); + try { + const campaign = await createTestCampaign(baseUrl); + + await fetch(`${baseUrl}/api/v1/campaigns/${campaign.id}/translations/es`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json', 'X-API-Key': 'test-key' }, + body: JSON.stringify({ name: 'Mi Campaña' }), + }); + await fetch(`${baseUrl}/api/v1/campaigns/${campaign.id}/translations/fr`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json', 'X-API-Key': 'test-key' }, + body: JSON.stringify({ name: 'Ma Campagne' }), + }); + + const res = await fetch(`${baseUrl}/api/v1/campaigns/${campaign.id}/translations`, { + headers: { 'X-API-Key': 'test-key' }, + }); + assert.equal(res.status, 200); + const body = await res.json(); + assert.ok(body.translations.es); + assert.ok(body.translations.fr); + } finally { + await stop(server); + } +}); + +test('GET /campaigns/:id returns available_locales', async () => { + const { server, baseUrl } = await startTestServer(); + try { + const campaign = await createTestCampaign(baseUrl); + + await fetch(`${baseUrl}/api/v1/campaigns/${campaign.id}/translations/es`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json', 'X-API-Key': 'test-key' }, + body: JSON.stringify({ name: 'Mi Campaña' }), + }); + + const res = await fetch(`${baseUrl}/api/v1/campaigns/${campaign.id}`); + const body = await res.json(); + assert.ok(Array.isArray(body.available_locales)); + assert.ok(body.available_locales.includes('es')); + assert.ok(!('_rawTranslations' in body)); + } finally { + await stop(server); + } +}); + +test('GET /campaigns/:id?locale=es applies locale negotiation', async () => { + const { server, baseUrl } = await startTestServer(); + try { + const campaign = await createTestCampaign(baseUrl); + + await fetch(`${baseUrl}/api/v1/campaigns/${campaign.id}/translations/es`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json', 'X-API-Key': 'test-key' }, + body: JSON.stringify({ name: 'Mi Campaña', description: 'En español' }), + }); + + const res = await fetch(`${baseUrl}/api/v1/campaigns/${campaign.id}?locale=es`); + const body = await res.json(); + assert.equal(body.name, 'Mi Campaña'); + assert.equal(body.description, 'En español'); + } finally { + await stop(server); + } +}); + +test('GET /campaigns/:id falls back to English for missing locale', async () => { + const { server, baseUrl } = await startTestServer(); + try { + const campaign = await createTestCampaign(baseUrl); + + const res = await fetch(`${baseUrl}/api/v1/campaigns/${campaign.id}?locale=de`); + const body = await res.json(); + assert.equal(body.name, 'My Campaign'); + assert.equal(body.description, 'English desc'); + } finally { + await stop(server); + } +}); + +test('GET /campaigns/:id uses Accept-Language header for locale negotiation', async () => { + const { server, baseUrl } = await startTestServer(); + try { + const campaign = await createTestCampaign(baseUrl); + + await fetch(`${baseUrl}/api/v1/campaigns/${campaign.id}/translations/fr`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json', 'X-API-Key': 'test-key' }, + body: JSON.stringify({ name: 'Ma Campagne', description: 'En français' }), + }); + + const res = await fetch(`${baseUrl}/api/v1/campaigns/${campaign.id}`, { + headers: { 'Accept-Language': 'fr,en;q=0.9' }, + }); + const body = await res.json(); + assert.equal(body.name, 'Ma Campagne'); + } finally { + await stop(server); + } +}); + +test('GET /campaigns list includes locale-negotiated names', async () => { + const { server, baseUrl } = await startTestServer(); + try { + const campaign = await createTestCampaign(baseUrl); + + await fetch(`${baseUrl}/api/v1/campaigns/${campaign.id}/translations/es`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json', 'X-API-Key': 'test-key' }, + body: JSON.stringify({ name: 'Mi Campaña' }), + }); + + // publish so it appears in public list + await fetch(`${baseUrl}/api/v1/campaigns/${campaign.id}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json', 'X-API-Key': 'test-key' }, + body: JSON.stringify({ status: 'published', contractId: 'CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA', active: true }), + }); + + const res = await fetch(`${baseUrl}/api/v1/campaigns?locale=es`); + const body = await res.json(); + const found = body.data.find((c) => c.id === campaign.id); + assert.ok(found); + assert.equal(found.name, 'Mi Campaña'); + assert.ok(!('_rawTranslations' in found)); + } finally { + await stop(server); + } +}); + +test('PUT /campaigns/:id/translations/:locale rejects invalid locale', async () => { + const { server, baseUrl } = await startTestServer(); + try { + const campaign = await createTestCampaign(baseUrl); + const res = await fetch(`${baseUrl}/api/v1/campaigns/${campaign.id}/translations/not_valid`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json', 'X-API-Key': 'test-key' }, + body: JSON.stringify({ name: 'test' }), + }); + assert.equal(res.status, 400); + const body = await res.json(); + assert.equal(body.code, 'INVALID_LOCALE'); + } finally { + await stop(server); + } +}); + +test('PUT /campaigns/:id/translations/:locale enforces 10-locale limit', async () => { + const { server, baseUrl } = await startTestServer(); + try { + const campaign = await createTestCampaign(baseUrl); + const locales = ['es', 'fr', 'de', 'it', 'pt', 'ja', 'ko', 'zh', 'ar', 'ru']; + + for (const locale of locales) { + await fetch(`${baseUrl}/api/v1/campaigns/${campaign.id}/translations/${locale}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json', 'X-API-Key': 'test-key' }, + body: JSON.stringify({ name: `name in ${locale}` }), + }); + } + + // 11th locale should fail + const res = await fetch(`${baseUrl}/api/v1/campaigns/${campaign.id}/translations/nl`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json', 'X-API-Key': 'test-key' }, + body: JSON.stringify({ name: 'Nederlands' }), + }); + assert.equal(res.status, 422); + const body = await res.json(); + assert.equal(body.code, 'LOCALE_LIMIT_EXCEEDED'); + } finally { + await stop(server); + } +}); + +test('PUT /campaigns/:id/translations/:locale enforces 2KB size limit', async () => { + const { server, baseUrl } = await startTestServer(); + try { + const campaign = await createTestCampaign(baseUrl); + const res = await fetch(`${baseUrl}/api/v1/campaigns/${campaign.id}/translations/es`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json', 'X-API-Key': 'test-key' }, + body: JSON.stringify({ name: 'test', description: 'x'.repeat(2100) }), + }); + assert.equal(res.status, 413); + const body = await res.json(); + assert.equal(body.code, 'TRANSLATION_TOO_LARGE'); + } finally { + await stop(server); + } +}); + +test('?locale=es query param takes precedence over Accept-Language header', async () => { + const { server, baseUrl } = await startTestServer(); + try { + const campaign = await createTestCampaign(baseUrl); + + await fetch(`${baseUrl}/api/v1/campaigns/${campaign.id}/translations/es`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json', 'X-API-Key': 'test-key' }, + body: JSON.stringify({ name: 'Mi Campaña' }), + }); + await fetch(`${baseUrl}/api/v1/campaigns/${campaign.id}/translations/fr`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json', 'X-API-Key': 'test-key' }, + body: JSON.stringify({ name: 'Ma Campagne' }), + }); + + const res = await fetch(`${baseUrl}/api/v1/campaigns/${campaign.id}?locale=es`, { + headers: { 'Accept-Language': 'fr,en;q=0.9' }, + }); + const body = await res.json(); + assert.equal(body.name, 'Mi Campaña'); + } finally { + await stop(server); + } +});