19declare(strict_types=1);
28use PHPUnit\Framework\Attributes\DataProvider;
29use PHPUnit\Framework\TestCase;
40 MigratedLanguageFileSync::sync($this->manager, $this->ilias_root,
'de',
'other_module', [
'x' =>
'y'], $this->client_data_dir);
41 MigratedLanguageFileSync::sync($this->manager, $this->ilias_root,
'de',
'', [
'x' =>
'y'], $this->client_data_dir,
true);
43 $this->assertDirectoryDoesNotExist($this->client_data_dir .
'/lang');
50 $this->
sync([
'greeting' =>
'Hallo'],
true);
52 $this->assertDirectoryDoesNotExist($this->client_data_dir .
'/lang');
63 $po_before = file_get_contents($this->
overlayBase() .
'.po');
67 $this->
sync([
'greeting' =>
'Hallo']);
69 $this->assertSame($po_before, file_get_contents($this->
overlayBase() .
'.po'));
84 $this->assertNull(MigratedLanguageFileSync::loadModuleTranslations(
88 $this->client_data_dir,
93 MigratedLanguageFileSync::getMigratedModules($this->manager,
'de', $this->client_data_dir, $this->ilias_root)
106 $this->
seedOverlay([
'greeting' => [
'value' =>
'Hallo',
'original' =>
'Hello',
'local_change' =>
'2020-01-01T00:00:00Z']]);
111 $this->
sync([
'greeting' =>
'Hallo']);
113 $preserved_during_the_gap = $this->
overlayPo()->find(self::MODULE,
'greeting');
114 $this->assertSame(
'Hello', LocalChangeComments::getOriginal($preserved_during_the_gap),
'original survives the gap');
115 $this->assertSame(
'2020-01-01T00:00:00Z', LocalChangeComments::getLocalChange($preserved_during_the_gap));
122 $this->
sync([
'greeting' =>
'Servus'],
true);
125 $this->assertFileDoesNotExist($this->
overlayBase() .
'.po');
127 [
'value' =>
'Servus',
'local_change' =>
false,
'local_change_date' =>
null,
'original' =>
'Servus'],
128 MigratedLanguageFileSync::loadModuleTranslations($this->manager,
'de', self::MODULE, $this->client_data_dir)[
'greeting']
135 $shipped_before = file_get_contents($this->
shippedPo());
137 $this->
sync([
'greeting' =>
'Servus'],
true,
null);
139 $this->assertSame($shipped_before, file_get_contents($this->
shippedPo()));
140 $this->assertSame([
'stest_de.po'], array_values(array_diff(scandir($this->fixture_directory), [
'.',
'..'])));
147 #[DataProvider('refreshFlags')]
150 $this->
seedShipped([
'greeting' =>
'Hallo',
'farewell' =>
'Tschüss']);
151 $this->
seedOverlay([
'greeting' => [
'value' =>
'Hallo',
'original' =>
'Hallo']]);
152 file_put_contents($this->
overlayBase() .
'.po',
"msgid \"broken\nmsgstr \"\"\n");
154 $this->
sync([
'greeting' =>
'Hallo',
'farewell' =>
'Servus'], $refresh);
159 $farewell = $overlay->find(
null,
'farewell');
160 $this->assertSame(
'Tschüss', LocalChangeComments::getOriginal($farewell));
161 $this->assertNotNull(LocalChangeComments::getLocalChange($farewell));
169 #[DataProvider('overlaysOnlyAStrictParserRejects')]
173 $this->
seedOverlay([
'greeting' => [
'value' =>
'Hallo',
'original' =>
'Hallo']]);
174 file_put_contents($this->
overlayBase() .
'.po', $content);
176 $this->
sync([
'greeting' =>
'Servus']);
178 $greeting = $this->
overlayPo()->find(
null,
'greeting');
179 $this->assertSame(
'Servus', $greeting?->getTranslation());
180 $this->assertSame(
'Hallo', LocalChangeComments::getOriginal($greeting));
181 $this->assertNotNull(LocalChangeComments::getLocalChange($greeting));
190 $message =
"msgctxt \"" . self::MODULE .
"\"\nmsgid \"greeting\"\nmsgstr \"Hallo\"\n";
194 'message without msgstr' => [
"msgctxt \"" . self::MODULE .
"\"\nmsgid \"greeting\"\n"],
195 'truncated inside a string' => [substr(
$message, 0, -4)],
196 'gap in the plural forms' => [
"msgid \"a\"\nmsgid_plural \"as\"\nmsgstr[0] \"x\"\nmsgstr[2] \"z\"\n"],
197 'binary data' => [
"\x00\x01\x02\xde\x12\x04\x95"],
198 'header value with a line break' => [
"msgid \"\"\nmsgstr \"\"\n\"X-A: a\\rb\\n\"\n\n" .
$message],
204 file_put_contents($this->
shippedPo(),
"msgid \"broken\n");
206 $this->expectException(RuntimeException::class);
207 $this->
sync([
'greeting' =>
'Hallo']);
218 "msgid \"\"\nmsgstr \"\"\n\"X-A: a\\rb\\n\"\n\nmsgctxt \"" . self::MODULE .
"\"\nmsgid \"greeting\"\nmsgstr \"Hallo\"\n"
222 $this->
sync([
'greeting' =>
'Hallo']);
223 $this->fail(
'Expected a RuntimeException');
224 }
catch (RuntimeException
$e) {
225 $this->assertStringContainsString(
'contains a line break',
$e->getMessage());
228 $this->assertFileDoesNotExist($this->
overlayBase() .
'.po');
239 file_put_contents($this->client_data_dir .
'/lang',
'not a directory');
241 set_error_handler(
static fn():
bool =>
true, E_WARNING);
243 $this->expectException(RuntimeException::class);
244 $this->expectExceptionMessageMatches(
'/Could not create directory/');
245 $this->
sync([
'greeting' =>
'Servus']);
247 restore_error_handler();
257 if (function_exists(
'posix_geteuid') && posix_geteuid() === 0) {
258 $this->markTestSkipped(
'An unopenable file cannot be forced for root.');
262 $this->
sync([
'greeting' =>
'Servus']);
264 $this->assertFileExists($lock_file);
265 chmod($lock_file, 0000);
267 $this->expectErrorLog();
268 $this->
sync([
'greeting' =>
'Nochmal geändert']);
270 $this->assertStringContainsString(
'Could not open the lock file', $this->
errorLog());
280 $file = (string) ini_get(
'error_log');
281 return $file !==
'' && is_file($file) ? (string) file_get_contents($file) :
'';
293 set_error_handler(
static fn():
bool =>
true, E_WARNING);
295 $this->
sync([
'greeting' =>
'Servus']);
296 $this->fail(
'Expected a RuntimeException');
297 }
catch (RuntimeException
$e) {
299 $this->assertStringContainsString(
'no regular file',
$e->getMessage());
301 restore_error_handler();
306 [
'lock',
'stest_de.po'],
307 array_values(array_diff(scandir(dirname($this->
overlayBase())), [
'.',
'..'])),
308 'neither a temporary file nor a .mo may be left behind'
319 $link = $this->client_data_dir .
'-link';
320 symlink($this->client_data_dir, $link);
323 MigratedLanguageFileSync::sync($this->manager, $this->ilias_root,
'de', self::MODULE, [
'greeting' =>
'Servus'], $link);
340 $this->
sync([
'greeting' =>
'Servus']);
341 $this->assertFileExists($this->
overlayBase() .
'.po',
'precondition: the admin edit created an overlay');
345 MigratedLanguageFileSync::sync($this->manager, $this->ilias_root,
'de', self::MODULE, [
'greeting' =>
'Hallo'], $this->client_data_dir,
true,
false);
346 $this->fail(
'Expected a RuntimeException');
347 }
catch (RuntimeException
$e) {
348 $this->assertStringContainsString(
'was created after',
$e->getMessage());
353 MigratedLanguageFileSync::loadModuleTranslations($this->manager,
'de', self::MODULE, $this->client_data_dir)[
'greeting'][
'local_change']
An in-memory gettext catalog (header block plus messages) and the only place of the component that re...
Reads the shipped .po of a migrated module and maintains its per-installation overlay: the ....
sync() guards: no-ops, missing shipped .po, corrupt files, write failures, concurrent installation.
testAnOverlayPoTheStrictParserRejectsIsRebuilt(string $content)
StrictPoLoader rejects more than the old parser (e.g.
testAcquireLockLogsAWarningWhenTheLockFileCannotBeOpenedButTheWriteStillSucceeds()
acquireLock() logs a warning when the lock file cannot be opened (e.g.
static overlaysOnlyAStrictParserRejects()
testThrowsWhenTheOverlayPoCannotBeReplacedAndLeavesNoTemporaryFile()
Root-proof write failure of the file itself: a directory at the overlay .po path, so the final rename...
testAShippedPoWithALineBreakInAHeaderValueThrowsARuntimeExceptionAndWritesNothing()
A shipped .po whose header keeps a line break is broken (checkLanguage() refuses the language): sync(...
testAnExistingOverlayIsLeftUntouchedWhenTheShippedPoNoLongerExists()
Un-migration: the shipped .po of a migrated module was removed after its overlay was compiled - sync(...
testARefreshAfterTheShippedPoComesBackStillMovesTheOriginalForwardCorrectly()
sync() writes exactly the entries it is given; the shipped-vs-local decision once the ....
testIsANoOpWhenNoShippedPoExistsForTheLanguage()
testACorruptOverlayPoIsRebuiltFromEntriesAndTheShippedPo(bool $refresh)
A corrupt overlay .po is rebuilt like a missing one (original/local_change recomputed against the shi...
testIsANoOpWhenTheModuleHasNoContributedDirectory()
testThrowsWhenTheOverlayDirectoryCannotBeCreated()
Root-proof write failure: a regular file where the overlay's "lang" directory must be created - mkdir...
testWritesTheOverlayWhenTheClientDataDirIsASymlink()
A symlinked client data directory (or parent), e.g.
testReadersIgnoreAnExistingOverlayWhileTheShippedPoIsMissing()
Readers must not serve a frozen overlay while its shipped .po is missing: getMigratedModules()/loadMo...
testAnInstallationDoesNotDiscardAnOverlayCreatedConcurrentlyAfterItsDecision()
Race: an installation decides on $entries before a concurrent admin edit adds a local change and comp...
testIsANoOpWhenTheClientDataDirIsNull()
testACorruptShippedPoThrows()
Fixtures of the MigratedLanguageFileSync tests.
sync(array $entries, bool $refresh=false, ?string $client_data_dir='default')
seedOverlay(array $entries, string $lang_key='de')
seedShipped(array $entries, string $lang_key='de', string $module=self::MODULE)
overlayBase(string $lang_key='de')
shippedPo(string $lang_key='de', string $module=self::MODULE)
static readMo(string $file)
static overlayLock(string $base_path)
static overlayMo(string $base_path)
The compiled catalog of the revision current names; a non-existing path if there is none.
A directory of language files with a path and a prefix for them.