Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 17 additions & 4 deletions src/backend/services/cms_service.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import { errors, HttpContext } from '@adonisjs/core/http';
import config from '@adonisjs/core/services/config';
import type {
CmsConfig,
Version,
Expand All @@ -9,6 +8,7 @@ import type {
AppUserInterface,
UiConfig,
} from '../../types';
import { isValidLanguageTag } from '../../shared/language_helpers.js';
import { defineConfig } from '../define_config.js';
import { PreferenceService } from './preference_service.js';
import Config from '../models/config.js';
Expand Down Expand Up @@ -49,7 +49,8 @@ export class CmsService {
active.data = newConfig;
await active.save();

const trackedConfig = config.get<CmsConfig>('cms') || {};
const { default: adonisConfig } = await import('@adonisjs/core/services/config');
const trackedConfig = adonisConfig.get<CmsConfig>('cms') || {};

// make sure we do not clobber the tracked config
this.#config = { ...newConfig, ...trackedConfig };
Expand All @@ -76,7 +77,7 @@ export class CmsService {
}

public localeFromQuery(ctx: HttpContext): string {
return ctx.request.qs()['locale'] || this.sourceLocale;
return this.localeFromQueryParam(ctx.request.qs()['locale']);
}

public versionFromPath(ctx: HttpContext): Version {
Expand All @@ -93,10 +94,22 @@ export class CmsService {
public versionFromQuery(ctx: HttpContext): Version {
return {
apiVersion: 1,
locale: ctx.request.qs()['locale'] || this.sourceLocale,
locale: this.localeFromQueryParam(ctx.request.qs()['locale']),
};
}

private localeFromQueryParam(queryLocale: string | undefined): string {
if (!queryLocale) return this.sourceLocale;

// Query-param locales are not auth-gated like path locales (localeFromPath), but
// must still be valid BCP 47 tags. Underscore variants (e.g. zh_Hans) pass i18n
// string lookup yet crash IntlMessageFormat during formatting → 500 on RSS/API.
Comment thread
timosville marked this conversation as resolved.
// Reject malformed tags here so downstream i18n/ICU never sees them.
if (!isValidLanguageTag(queryLocale)) throw errors.E_ROUTE_NOT_FOUND;

return queryLocale;
}

protected getLanguage(locale: string | null): LanguageSpecification {
const found =
this.#config.languages.find(
Expand Down
10 changes: 10 additions & 0 deletions src/shared/language_helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,16 @@ import type { LanguageSpecification, LocaleItem } from '../types.js';

export const LANGUAGE_LABEL_SEPARATOR = /\s*-\s*|\s*\|\s*/;

/** Whether tag is a well-formed IETF BCP 47 language tag (hyphen-separated). */
export function isValidLanguageTag(tag: string): boolean {
try {
new Intl.Locale(tag);
Comment thread
timosville marked this conversation as resolved.
return true;
} catch {
return false;
}
}

/** Name, native name, and locale from a language specification. */
export function parseLanguageSpecification(spec: LanguageSpecification): LocaleItem {
const { language, locale, languageDirection } = spec;
Expand Down
4 changes: 4 additions & 0 deletions tests/unit/audioRule.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,10 @@ import audioRule from '../../src/backend/validators/audio_rule.js';
import { Audio } from '../../src/types';

test.describe('Audio Rule Validator', () => {
test.beforeEach(() => {
vine.convertEmptyStringsToNull = false;
});

test('should validate valid audio objects with .mp3 extension', async () => {
const validAudios: Audio[] = [
{ url: 'https://example.com/audio.mp3', length: 120 },
Expand Down
12 changes: 12 additions & 0 deletions tests/unit/bundleService.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,10 @@ import { complexFields, nestedFields, simpleFields } from '../mocks.js';
import vine from '@vinejs/vine';

test.describe('Bundle builder', () => {
test.beforeEach(() => {
vine.convertEmptyStringsToNull = false;
});

test('Identify junk', () => {
// arrange
const service = new BundleService(simpleFields);
Expand Down Expand Up @@ -66,6 +70,10 @@ test.describe('Bundle builder', () => {
});

test.describe('Bundle updater', () => {
test.beforeEach(() => {
vine.convertEmptyStringsToNull = false;
});

test('updates bundle with new changes', async () => {
// arrange
const service = new BundleService(simpleFields);
Expand Down Expand Up @@ -228,6 +236,10 @@ test.describe('Bundle updater', () => {
});

test.describe('Bundle validator', () => {
test.beforeEach(() => {
vine.convertEmptyStringsToNull = false;
});

test('it can validate a complex object', async () => {
// arrange
const objectSpec = [
Expand Down
54 changes: 54 additions & 0 deletions tests/unit/cms_service.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import { test, expect } from '@playwright/test';
import type { HttpContext } from '@adonisjs/core/http';

import { CmsService } from '../../src/backend/services/cms_service';

function mockContext(query: Record<string, string | undefined>): HttpContext {
return {
request: {
qs: () => query,
},
} as HttpContext;
}

test.describe('CmsService query locale', () => {
const cms = CmsService.default();

test('localeFromQuery returns sourceLocale when param is absent', () => {
expect(cms.localeFromQuery(mockContext({}))).toBe('en');
expect(cms.localeFromQuery(mockContext({ locale: undefined }))).toBe('en');
});

test('localeFromQuery returns valid tags as-is', () => {
expect(cms.localeFromQuery(mockContext({ locale: 'en' }))).toBe('en');
expect(cms.localeFromQuery(mockContext({ locale: 'zh' }))).toBe('zh');
expect(cms.localeFromQuery(mockContext({ locale: 'zh-Hans' }))).toBe('zh-Hans');
expect(cms.localeFromQuery(mockContext({ locale: 'en-US' }))).toBe('en-US');
});

test('localeFromQuery throws E_ROUTE_NOT_FOUND for invalid tags', () => {
for (const locale of ['zh_Hans', 'zh_CN']) {
expect(() => cms.localeFromQuery(mockContext({ locale }))).toThrow();
try {
cms.localeFromQuery(mockContext({ locale }));
} catch (error) {
expect((error as { code: string; status: number }).code).toBe('E_ROUTE_NOT_FOUND');
expect((error as { code: string; status: number }).status).toBe(404);
}
}
});

test('versionFromQuery mirrors localeFromQuery behavior', () => {
expect(cms.versionFromQuery(mockContext({}))).toEqual({
apiVersion: 1,
locale: 'en',
});

expect(cms.versionFromQuery(mockContext({ locale: 'zh' }))).toEqual({
apiVersion: 1,
locale: 'zh',
});

expect(() => cms.versionFromQuery(mockContext({ locale: 'zh_Hans' }))).toThrow();
});
});
5 changes: 5 additions & 0 deletions tests/unit/course.spec.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { test, expect } from '@playwright/test';
import vine from '@vinejs/vine';
import { CourseValidator } from '../../src/backend/validators/course.js';

const validBundle = () => ({
Expand All @@ -13,6 +14,10 @@ const validBundle = () => ({
});

test.describe('CourseValidator', () => {
test.beforeEach(() => {
vine.convertEmptyStringsToNull = false;
});

test('accepts bundle without imageUrl', async () => {
const validator = new CourseValidator();
const result = await validator.validate(validBundle());
Expand Down
4 changes: 4 additions & 0 deletions tests/unit/dateRangeRule.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,10 @@ import vine from '@vinejs/vine';
import dateRangeRule from '../../src/backend/validators/date_range_rule.js';

test.describe('Date Range Rule Validator', () => {
test.beforeEach(() => {
vine.convertEmptyStringsToNull = false;
});

test('should validate valid date ranges', async () => {
const validDateRanges = [
'2027-01-08T07:30:00.000Z|2027-01-15T07:30:00.000Z',
Expand Down
5 changes: 5 additions & 0 deletions tests/unit/drop.spec.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,13 @@
import { test, expect } from '@playwright/test';
import vine from '@vinejs/vine';
import DropValidator from '../../src/backend/validators/drop.js';
import { dropBundleFields } from '../mocks.js';

test.describe('Drop Validator', () => {
test.beforeEach(() => {
vine.convertEmptyStringsToNull = false;
});

test.describe('Draft mode (isPublished = false)', () => {
test('should validate valid draft data with all fields', async () => {
const validator = new DropValidator(false, dropBundleFields);
Expand Down
4 changes: 4 additions & 0 deletions tests/unit/invitation.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,10 @@ function createMockHttpContext(inputs: Record<string, any>): HttpContext {
}

test.describe('Invitation validator', () => {
test.beforeEach(() => {
vine.convertEmptyStringsToNull = false;
});

test.describe('Draft Schema', () => {
test('allows all fields to be optional', async () => {
const data = {
Expand Down
31 changes: 31 additions & 0 deletions tests/unit/language_helpers.spec.ts
Comment thread
timosville marked this conversation as resolved.
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import { test, expect } from '@playwright/test';

import { languages } from '../../src/frontend/settings/languages/languages';
import { isValidLanguageTag } from '../../src/shared/language_helpers';

test.describe('isValidLanguageTag', () => {
test('accepts short and extended BCP 47 tags', () => {
expect(isValidLanguageTag('en')).toBe(true);
expect(isValidLanguageTag('zh')).toBe(true);
expect(isValidLanguageTag('zh-Hans')).toBe(true);
expect(isValidLanguageTag('en-US')).toBe(true);
});

test('accepts exotic, well-formed tags', () => {
expect(isValidLanguageTag('qqq')).toBe(true);
expect(isValidLanguageTag('xx-Sw')).toBe(true);
});

test('rejects underscore-style tags and empty string', () => {
Comment thread
timosville marked this conversation as resolved.
expect(isValidLanguageTag('zh_Hans')).toBe(false);
expect(isValidLanguageTag('zh_CN')).toBe(false);
expect(isValidLanguageTag('')).toBe(false);
});

// Kit language picker defaults — syntax only, not per-client config.languages.
test('kit language catalog locales are valid BCP 47 tags', () => {
for (const { locale } of languages) {
expect(isValidLanguageTag(locale), `invalid locale: ${locale}`).toBe(true);
}
});
});
5 changes: 5 additions & 0 deletions tests/unit/page.spec.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { test, expect } from '@playwright/test';
import vine from '@vinejs/vine';
import PageValidator from '../../src/backend/validators/page.js';
import type { HttpContext } from '@adonisjs/core/http';

Expand All @@ -12,6 +13,10 @@ function createMockHttpContext(inputs: Record<string, any>): HttpContext {
}

test.describe('Page Validator', () => {
test.beforeEach(() => {
vine.convertEmptyStringsToNull = false;
});

test.describe('Draft Schema', () => {
test('requires title', async () => {
const data = {
Expand Down
4 changes: 4 additions & 0 deletions tests/unit/videoRule.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,10 @@ import videoRule from '../../src/backend/validators/video_rule.js';
import { Video } from '../../src/types';

test.describe('Video Rule Validator', () => {
test.beforeEach(() => {
vine.convertEmptyStringsToNull = false;
});

test('should validate valid video objects with .mp4 extension', async () => {
const validVideos: Video[] = [
{ url: 'https://example.com/video.mp4' },
Expand Down
Loading