ILIAS  trunk Revision v13.0_alpha-62-g3b72d83f2e5
MigratedLanguageFileSyncGuardsTest.php
Go to the documentation of this file.
1<?php
2
19declare(strict_types=1);
20
28use PHPUnit\Framework\Attributes\DataProvider;
29use PHPUnit\Framework\TestCase;
30
35{
37 {
38 $this->seedShipped(['greeting' => 'Hallo']);
39
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);
42
43 $this->assertDirectoryDoesNotExist($this->client_data_dir . '/lang');
44 }
45
47 {
48 $this->seedShipped(['greeting' => 'Hello'], 'en');
49
50 $this->sync(['greeting' => 'Hallo'], true);
51
52 $this->assertDirectoryDoesNotExist($this->client_data_dir . '/lang');
53 }
54
60 {
61 $this->seedShipped(['greeting' => 'Hello']);
62 $this->seedOverlay(['greeting' => 'Hallo']);
63 $po_before = file_get_contents($this->overlayBase() . '.po');
64 $mo_before = file_get_contents(MigratedPoFixture::overlayMo($this->overlayBase()));
65 unlink($this->shippedPo());
66
67 $this->sync(['greeting' => 'Hallo']);
68
69 $this->assertSame($po_before, file_get_contents($this->overlayBase() . '.po'));
70 $this->assertSame($mo_before, file_get_contents(MigratedPoFixture::overlayMo($this->overlayBase())));
71 }
72
79 {
80 $this->seedShipped(['greeting' => 'Hello']);
81 $this->seedOverlay(['greeting' => 'Hallo']);
82 unlink($this->shippedPo());
83
84 $this->assertNull(MigratedLanguageFileSync::loadModuleTranslations(
85 $this->manager,
86 'de',
87 self::MODULE,
88 $this->client_data_dir,
89 $this->ilias_root
90 ));
91 $this->assertSame(
92 [],
93 MigratedLanguageFileSync::getMigratedModules($this->manager, 'de', $this->client_data_dir, $this->ilias_root)
94 );
95 }
96
103 {
104 $this->seedShipped(['greeting' => 'Hello']);
105 // the "original" of this local change must survive the whole gap below untouched
106 $this->seedOverlay(['greeting' => ['value' => 'Hallo', 'original' => 'Hello', 'local_change' => '2020-01-01T00:00:00Z']]);
107 unlink($this->shippedPo());
108
109 // asserted here so a regression touching the overlay's "original"/local_change bookkeeping while the `.po` is
110 // missing fails here, not only once it is back ($refresh_original_from_shipped would then mask the loss)
111 $this->sync(['greeting' => 'Hallo']);
112 // untouched, so still as seeded: with the module as msgctxt
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));
116
117 // the module is migrated again - the shipped `.po` is back, with a changed value
118 $this->seedShipped(['greeting' => 'Servus']);
119
120 // an unmodified local value ("Hallo" is still tracked against the preserved original
121 // "Hello") is refreshed to the new shipped value, exactly like an ordinary update
122 $this->sync(['greeting' => 'Servus'], true);
123
124 // the value is now the shipped one: served from the shipped .po, the overlay is removed
125 $this->assertFileDoesNotExist($this->overlayBase() . '.po');
126 $this->assertSame(
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']
129 );
130 }
131
133 {
134 $this->seedShipped(['greeting' => 'Hallo']);
135 $shipped_before = file_get_contents($this->shippedPo());
136
137 $this->sync(['greeting' => 'Servus'], true, null);
138
139 $this->assertSame($shipped_before, file_get_contents($this->shippedPo()));
140 $this->assertSame(['stest_de.po'], array_values(array_diff(scandir($this->fixture_directory), ['.', '..'])));
141 }
142
147 #[DataProvider('refreshFlags')]
149 {
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");
153
154 $this->sync(['greeting' => 'Hallo', 'farewell' => 'Servus'], $refresh);
155
156 // "greeting" carries the shipped value and is not written
157 $overlay = $this->overlayPo();
158 $this->assertSame(['farewell'], $this->overlayIdentifiers());
159 $farewell = $overlay->find(null, 'farewell');
160 $this->assertSame('Tschüss', LocalChangeComments::getOriginal($farewell));
161 $this->assertNotNull(LocalChangeComments::getLocalChange($farewell));
162 $this->assertSame(['farewell' => 'Servus'], MigratedPoFixture::readMo(MigratedPoFixture::overlayMo($this->overlayBase())));
163 }
164
169 #[DataProvider('overlaysOnlyAStrictParserRejects')]
170 public function testAnOverlayPoTheStrictParserRejectsIsRebuilt(string $content): void
171 {
172 $this->seedShipped(['greeting' => 'Hallo']);
173 $this->seedOverlay(['greeting' => ['value' => 'Hallo', 'original' => 'Hallo']]);
174 file_put_contents($this->overlayBase() . '.po', $content);
175
176 $this->sync(['greeting' => 'Servus']);
177
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));
182 $this->assertSame(['greeting' => 'Servus'], MigratedPoFixture::readMo(MigratedPoFixture::overlayMo($this->overlayBase())));
183 }
184
188 public static function overlaysOnlyAStrictParserRejects(): array
189 {
190 $message = "msgctxt \"" . self::MODULE . "\"\nmsgid \"greeting\"\nmsgstr \"Hallo\"\n";
191
192 return [
193 'duplicate message' => [$message . "\n" . $message],
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],
199 ];
200 }
201
202 public function testACorruptShippedPoThrows(): void
203 {
204 file_put_contents($this->shippedPo(), "msgid \"broken\n");
205
206 $this->expectException(RuntimeException::class);
207 $this->sync(['greeting' => 'Hallo']);
208 }
209
215 {
216 file_put_contents(
217 $this->shippedPo(),
218 "msgid \"\"\nmsgstr \"\"\n\"X-A: a\\rb\\n\"\n\nmsgctxt \"" . self::MODULE . "\"\nmsgid \"greeting\"\nmsgstr \"Hallo\"\n"
219 );
220
221 try {
222 $this->sync(['greeting' => 'Hallo']);
223 $this->fail('Expected a RuntimeException');
224 } catch (RuntimeException $e) {
225 $this->assertStringContainsString('contains a line break', $e->getMessage());
226 }
227
228 $this->assertFileDoesNotExist($this->overlayBase() . '.po');
229 $this->assertFileDoesNotExist(MigratedPoFixture::overlayMo($this->overlayBase()));
230 }
231
237 {
238 $this->seedShipped(['greeting' => 'Hallo']);
239 file_put_contents($this->client_data_dir . '/lang', 'not a directory');
240
241 set_error_handler(static fn(): bool => true, E_WARNING);
242 try {
243 $this->expectException(RuntimeException::class);
244 $this->expectExceptionMessageMatches('/Could not create directory/');
245 $this->sync(['greeting' => 'Servus']);
246 } finally {
247 restore_error_handler();
248 }
249 }
250
256 {
257 if (function_exists('posix_geteuid') && posix_geteuid() === 0) {
258 $this->markTestSkipped('An unopenable file cannot be forced for root.');
259 }
260 $this->seedShipped(['greeting' => 'Hallo']);
261 // a first sync creates the lock file (and the overlay) so its own permission can be revoked
262 $this->sync(['greeting' => 'Servus']);
263 $lock_file = MigratedPoFixture::overlayLock($this->overlayBase());
264 $this->assertFileExists($lock_file);
265 chmod($lock_file, 0000);
266
267 $this->expectErrorLog();
268 $this->sync(['greeting' => 'Nochmal geändert']);
269
270 $this->assertStringContainsString('Could not open the lock file', $this->errorLog());
271 $this->assertSame(['greeting' => 'Nochmal geändert'], MigratedPoFixture::readMo(MigratedPoFixture::overlayMo($this->overlayBase())));
272 }
273
278 private function errorLog(): string
279 {
280 $file = (string) ini_get('error_log');
281 return $file !== '' && is_file($file) ? (string) file_get_contents($file) : '';
282 }
283
289 {
290 $this->seedShipped(['greeting' => 'Hallo']);
291 mkdir($this->overlayBase() . '.po', 0775, true);
292
293 set_error_handler(static fn(): bool => true, E_WARNING);
294 try {
295 $this->sync(['greeting' => 'Servus']);
296 $this->fail('Expected a RuntimeException');
297 } catch (RuntimeException $e) {
298 // refused before any write is attempted: the .po path is no regular file
299 $this->assertStringContainsString('no regular file', $e->getMessage());
300 } finally {
301 restore_error_handler();
302 }
303
304 $this->assertSame(
305 // the lock file (see MigratedLanguageFileSync::withOverlayLock()) is never removed
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'
309 );
310 }
311
317 {
318 $this->seedShipped(['greeting' => 'Hallo']);
319 $link = $this->client_data_dir . '-link';
320 symlink($this->client_data_dir, $link);
321
322 try {
323 MigratedLanguageFileSync::sync($this->manager, $this->ilias_root, 'de', self::MODULE, ['greeting' => 'Servus'], $link);
324 } finally {
325 unlink($link);
326 }
327
328 $this->assertSame(['greeting' => 'Servus'], MigratedPoFixture::readMo(MigratedPoFixture::overlayMo($this->overlayBase())));
329 }
330
336 {
337 $this->seedShipped(['greeting' => 'Hallo']);
338
339 // The concurrent admin edit completes first and creates a genuine local change.
340 $this->sync(['greeting' => 'Servus']);
341 $this->assertFileExists($this->overlayBase() . '.po', 'precondition: the admin edit created an overlay');
342
343 // The installation's snapshot is from BEFORE that edit: no overlay, only the shipped value.
344 try {
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());
349 }
350
351 $this->assertSame(['greeting' => 'Servus'], MigratedPoFixture::readMo(MigratedPoFixture::overlayMo($this->overlayBase())));
352 $this->assertTrue(
353 MigratedLanguageFileSync::loadModuleTranslations($this->manager, 'de', self::MODULE, $this->client_data_dir)['greeting']['local_change']
354 );
355 }
356}
An in-memory gettext catalog (header block plus messages) and the only place of the component that re...
Tracks per overlay entry whether the current value still matches the value of the shipped ....
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.
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 ....
testACorruptOverlayPoIsRebuiltFromEntriesAndTheShippedPo(bool $refresh)
A corrupt overlay .po is rebuilt like a missing one (original/local_change recomputed against the shi...
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...
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)
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.