You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
pixelfed/tests/Feature/LocalizationTest.php

428 lines
15 KiB
PHP

<?php
use App\Models\User;
use App\Util\Localization\Localization;
use Illuminate\Auth\Events\Login;
use Illuminate\Foundation\Testing\LazilyRefreshDatabase;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Config;
use Illuminate\Support\Facades\File;
uses(LazilyRefreshDatabase::class);
/*
|--------------------------------------------------------------------------
| Localization / Language Selection Tests
|--------------------------------------------------------------------------
|
| Covers:
| - New users resolve locale dynamically (fallback to APP_LOCALE), the
| stored language column stays null until explicitly chosen.
| - Guests resolve to the configured app locale.
| - Logged-in users with a stored language use it for the session.
| - Changing language (SPA endpoint + /i/lang) persists and updates session.
| - The locales manifest (Localization::languages()/locales()) shape.
| - Empty (untranslated) strings fall back to the fallback locale.
|
*/
describe('new user language default (dynamic fallback)', function () {
it('leaves a new user language null rather than persisting a default', function () {
Config::set('app.locale', 'de-DE');
$user = User::factory()->create();
// The language is intentionally not persisted; the effective locale
// is resolved dynamically at request/login time via config fallback.
expect($user->language)->toBeNull();
});
it('falls back to the configured app locale when the user has no language', function () {
Config::set('app.locale', 'de-DE');
$user = User::factory()->create();
expect($user->language)->toBeNull();
// Logging in triggers AuthLogin::userLanguage, which resolves the
// session locale via $user->language ?? config('app.locale').
event(new Login('web', $user, false));
expect(session('locale'))->toBe('de-DE');
});
it('uses the stored user language over the app default on login', function () {
Config::set('app.locale', 'de-DE');
$user = User::factory()->create(['language' => 'fr-FR']);
event(new Login('web', $user, false));
expect(session('locale'))->toBe('fr-FR');
});
});
describe('guest locale resolution', function () {
it('uses the configured app locale for guests with no session locale', function () {
Config::set('app.locale', 'de-DE');
// Simulate a request lifecycle without a session locale.
expect(app()->getLocale())->toBe('de-DE');
});
it('honors a session locale set on the request via the Localization middleware', function () {
Config::set('app.locale', 'en-US');
$locale = firstNonDefaultLocale();
// The Localization middleware calls app()->setLocale() from the
// session. Drive a request with the session locale pre-set.
$this->withSession(['locale' => $locale])
->get('/')
->assertSuccessful();
expect(app()->getLocale())->toBe($locale);
});
});
describe('locales manifest', function () {
it('returns a non-empty list of language codes including en-US', function () {
$langs = Localization::languages();
expect($langs)->toBeArray()
->and($langs)->toContain('en-US')
->and(count($langs))->toBeGreaterThan(1);
});
it('excludes the vendor directory and the manifest file from the list', function () {
$langs = Localization::languages();
expect($langs)->not->toContain('vendor')
->and($langs)->not->toContain('locales.json')
->and($langs)->not->toContain('locales');
});
it('exposes locale metadata with code, name and nativeName', function () {
$locales = Localization::locales();
expect($locales)->toBeArray()->not->toBeEmpty();
foreach ($locales as $locale) {
expect($locale)->toHaveKeys(['code', 'name', 'nativeName']);
}
});
it('sorts locale metadata alphabetically by display name', function () {
$names = array_map(fn ($l) => $l['name'], Localization::locales());
$sorted = $names;
usort($sorted, 'strcasecmp');
expect($names)->toBe($sorted);
});
it('keeps the languages() codes in sync with the locales() manifest', function () {
$codes = array_map(fn ($l) => $l['code'], Localization::locales());
expect(Localization::languages())->toEqualCanonicalizing($codes);
});
});
describe('logged-in language via change-language endpoint', function () {
beforeEach(function () {
Config::set('exp.spa', true);
});
it('persists a valid locale to the user and session', function () {
$locale = firstNonDefaultLocale();
$user = User::factory()->create();
$this->actingAs($user)
->postJson('/api/pixelfed/web/change-language.json', [
'v' => '0.1',
'l' => $locale,
])
->assertOk()
->assertJson(['language' => $locale]);
expect($user->fresh()->language)->toBe($locale)
->and(session('locale'))->toBe($locale);
});
it('rejects an unknown locale', function () {
$user = User::factory()->create();
$this->actingAs($user)
->postJson('/api/pixelfed/web/change-language.json', [
'v' => '0.1',
'l' => 'zz-ZZ',
])
->assertStatus(400);
// Unknown locale must not be stored.
expect($user->fresh()->language)->not->toBe('zz-ZZ');
});
it('rejects a missing locale parameter', function () {
$user = User::factory()->create();
$this->actingAs($user)
->postJson('/api/pixelfed/web/change-language.json', [
'v' => '0.1',
])
->assertStatus(422);
});
it('rejects an invalid api version', function () {
$user = User::factory()->create();
$this->actingAs($user)
->postJson('/api/pixelfed/web/change-language.json', [
'v' => '9.9',
'l' => firstNonDefaultLocale(),
])
->assertStatus(422);
});
it('rejects a locale code longer than the max length', function () {
$user = User::factory()->create();
// 13 chars, exceeds the max:12 rule (alpha_dash still valid chars).
$this->actingAs($user)
->postJson('/api/pixelfed/web/change-language.json', [
'v' => '0.1',
'l' => 'aa-bbbbbbbbbb',
])
->assertStatus(422);
});
it('accepts long custom locale codes such as en-x-pirate when available', function () {
$locales = Localization::languages();
if (! in_array('en-x-pirate', $locales, true)) {
test()->markTestSkipped('en-x-pirate locale not present in this environment');
}
$user = User::factory()->create();
$this->actingAs($user)
->postJson('/api/pixelfed/web/change-language.json', [
'v' => '0.1',
'l' => 'en-x-pirate',
])
->assertOk()
->assertJson(['language' => 'en-x-pirate']);
expect($user->fresh()->language)->toBe('en-x-pirate');
});
it('requires authentication', function () {
$this->postJson('/api/pixelfed/web/change-language.json', [
'v' => '0.1',
'l' => firstNonDefaultLocale(),
])->assertNotFound();
});
});
describe('changing language via /i/lang/{locale}', function () {
it('persists a valid locale for a logged-in user and updates the session', function () {
$locale = firstNonDefaultLocale();
$user = User::factory()->create();
$this->actingAs($user)
->get('/i/lang/'.$locale)
->assertRedirect(route('site.language'));
expect($user->fresh()->language)->toBe($locale)
->and(session('locale'))->toBe($locale);
});
it('ignores an invalid locale and does not change the stored language', function () {
$user = User::factory()->create(['language' => 'fr-FR']);
$this->actingAs($user)
->get('/i/lang/zz-ZZ')
->assertRedirect(route('site.language'));
expect($user->fresh()->language)->toBe('fr-FR')
->and(session()->has('locale'))->toBeFalse();
});
});
describe('language settings via /settings/home', function () {
it('updates the stored language when a valid locale is submitted', function () {
$locale = firstNonDefaultLocale();
$user = User::factory()->create();
$user->refresh();
$this->actingAs($user)
->post('/settings/home', [
'name' => $user->name,
'language' => $locale,
])->assertRedirect('/settings/home');
expect($user->fresh()->language)->toBe($locale)
->and(session('locale'))->toBe($locale);
});
it('rejects a locale code that exceeds the max length', function () {
$user = User::factory()->create();
$user->refresh();
$this->actingAs($user)
->post('/settings/home', [
'name' => $user->name,
'language' => 'aa-bbbbbbbbbb', // 13 chars > max:12
])->assertSessionHasErrors('language');
});
});
describe('locale-aware caching of rendered site pages', function () {
beforeEach(function () {
Cache::flush();
});
it('caches /site/about under a locale-scoped key', function () {
$this->withSession(['locale' => 'en-US'])
->get('/site/about')
->assertOk();
expect(Cache::has('site.about_v2:en-US'))->toBeTrue()
// The old, locale-unaware key must not be used.
->and(Cache::has('site.about_v2'))->toBeFalse();
});
it('does not let a non-default locale poison the cached render of another', function () {
$other = firstNonDefaultLocale();
// A visitor on a non-default locale warms the cache first (cold cache).
$this->withSession(['locale' => $other])
->get('/site/about')
->assertOk();
// Then an en-US visitor: must get its own cache entry, not the
// other locale's render.
$this->withSession(['locale' => 'en-US'])
->get('/site/about')
->assertOk();
// Each locale gets its own cache entry, so the first (non-default)
// render cannot overwrite / be served as the en-US render. We assert
// key isolation rather than content difference, since a partially
// translated locale may legitimately render identically to English.
expect(Cache::has('site.about_v2:'.$other))->toBeTrue()
->and(Cache::has('site.about_v2:en-US'))->toBeTrue()
// The old shared key must never be written.
->and(Cache::has('site.about_v2'))->toBeFalse();
})->skip(fn () => firstNonDefaultLocale() === 'en-US', 'needs a second locale');
});
describe('legacy locale normalization', function () {
/*
| Localization::normalizeLocale maps a legacy short code (e.g. "es") to the
| current locale folder (e.g. "es-ES"). config/app.php runs APP_LOCALE and
| APP_FALLBACK_LOCALE through it so instances upgrading with an old
| APP_LOCALE do not silently fall back to English.
*/
it('maps a legacy two-letter code to its current locale code', function () {
expect(Localization::normalizeLocale('es'))->toBe('es-ES')
->and(Localization::normalizeLocale('de'))->toBe('de-DE')
->and(Localization::normalizeLocale('fr'))->toBe('fr-FR');
});
it('maps a legacy region code to its current locale code', function () {
expect(Localization::normalizeLocale('zh-cn'))->toBe('zh-CN')
->and(Localization::normalizeLocale('zh-tw'))->toBe('zh-TW');
});
it('pins the ambiguous legacy codes deliberately', function () {
// "en" must resolve to en-US, not the en-x-pirate novelty locale.
expect(Localization::normalizeLocale('en'))->toBe('en-US')
// "sr" must resolve to the prior single Serbian translation.
->and(Localization::normalizeLocale('sr'))->toBe('sr-CS');
});
it('is case-insensitive on legacy short codes', function () {
expect(Localization::normalizeLocale('ES'))->toBe('es-ES')
->and(Localization::normalizeLocale('Zh-Cn'))->toBe('zh-CN');
});
it('leaves an already-current locale code unchanged', function () {
expect(Localization::normalizeLocale('es-ES'))->toBe('es-ES')
->and(Localization::normalizeLocale('en-US'))->toBe('en-US');
});
it('leaves unknown or custom locale codes unchanged', function () {
expect(Localization::normalizeLocale('xx-YY'))->toBe('xx-YY')
->and(Localization::normalizeLocale('en-x-pirate'))->toBe('en-x-pirate');
});
it('falls back to en-US for empty or whitespace input', function () {
expect(Localization::normalizeLocale(''))->toBe('en-US')
->and(Localization::normalizeLocale(' '))->toBe('en-US')
->and(Localization::normalizeLocale(null))->toBe('en-US');
});
it('resolves the configured app locale from a legacy APP_LOCALE', function () {
// Simulate an upgraded instance whose APP_LOCALE is still "es".
Config::set('app.locale', Localization::normalizeLocale('es'));
expect(config('app.locale'))->toBe('es-ES');
});
});
describe('empty string translation fallback', function () {
it('falls back to the fallback locale for empty (untranslated) strings', function () {
Config::set('app.fallback_locale', 'en-US');
// Build a self-contained throwaway locale with one empty and one
// translated key so the assertion does not depend on live Crowdin
// translation state.
$code = 'zz-Test';
$dir = lang_path($code);
File::ensureDirectoryExists($dir);
File::put($dir.'/testgroup.php', "<?php\n\nreturn [\n 'empty' => '',\n 'filled' => 'Localized',\n];\n");
// Also ensure the fallback locale has real values for both keys.
$fallbackDir = lang_path('en-US');
File::ensureDirectoryExists($fallbackDir);
$fallbackFile = $fallbackDir.'/testgroup.php';
$hadFallback = File::exists($fallbackFile);
File::put($fallbackFile, "<?php\n\nreturn [\n 'empty' => 'English Empty',\n 'filled' => 'English Filled',\n];\n");
try {
app()->setLocale($code);
// Empty in target locale -> stripped -> falls back to en-US.
expect(__('testgroup.empty'))->toBe('English Empty')
// Present in target locale -> uses the target translation.
->and(__('testgroup.filled'))->toBe('Localized');
} finally {
app()->setLocale('en-US');
File::deleteDirectory($dir);
if (! $hadFallback) {
File::delete($fallbackFile);
}
}
});
});
/**
* Pick a valid, available locale that isn't the current app default so
* assertions actually prove a change happened.
*/
function firstNonDefaultLocale(): string
{
$default = config('app.locale');
foreach (Localization::languages() as $code) {
if ($code !== $default) {
return $code;
}
}
return $default;
}