rootDir = sys_get_temp_dir() . '/ktrix-mail-store-test-' . uniqid('', true); mkdir($this->rootDir, 0755, true); $this->store = new CompositionStore($this->rootDir); } protected function tearDown(): void { $this->deleteDir($this->rootDir); } public function testFailedReplacementPreservesPreviousSnapshot(): void { $initial = ['identifier' => self::COMPOSITION_ID, 'revision' => 1]; $this->saveSnapshot($initial); $resource = fopen('php://memory', 'r'); try { $saveInvalidSnapshot = static fn(): array => [ 'identifier' => self::COMPOSITION_ID, 'invalid' => $resource, ]; $this->store->compositionSave( self::TENANT_ID, self::USER_ID, self::COMPOSITION_ID, $saveInvalidSnapshot, ); $this->fail('Saving a value that cannot be encoded as JSON should fail'); } catch (JsonException) { $this->assertSame( $initial, $this->store->compositionFetch(self::TENANT_ID, self::USER_ID, self::COMPOSITION_ID), ); } finally { fclose($resource); } } public function testUpdateCanLeaveSnapshotUnchanged(): void { $initial = ['identifier' => self::COMPOSITION_ID, 'revision' => 1]; $this->saveSnapshot($initial); $leaveUnchanged = static fn(?array $snapshot): ?array => null; $updated = $this->store->compositionSave( self::TENANT_ID, self::USER_ID, self::COMPOSITION_ID, $leaveUnchanged, ); $this->assertNull($updated); $this->assertSame( $initial, $this->store->compositionFetch(self::TENANT_ID, self::USER_ID, self::COMPOSITION_ID), ); } public function testConcurrentUpdatesDoNotLoseChanges(): void { if (!function_exists('pcntl_fork')) { $this->markTestSkipped('The pcntl extension is required for the concurrency test'); } $this->saveSnapshot([ 'identifier' => self::COMPOSITION_ID, 'updates' => 0, ]); $childPid = pcntl_fork(); if ($childPid === -1) { $this->fail('Unable to fork the concurrency test process'); } if ($childPid === 0) { $this->incrementAfterDelay(); exit(0); } $this->incrementAfterDelay(); pcntl_waitpid($childPid, $status); $this->assertTrue(pcntl_wifexited($status)); $this->assertSame(0, pcntl_wexitstatus($status)); $this->assertSame( 2, $this->store->compositionFetch(self::TENANT_ID, self::USER_ID, self::COMPOSITION_ID)['updates'], ); } private function incrementAfterDelay(): void { $incrementSnapshot = static function (?array $snapshot): array { usleep(200_000); $snapshot['updates']++; return $snapshot; }; $this->store->compositionSave( self::TENANT_ID, self::USER_ID, self::COMPOSITION_ID, $incrementSnapshot, ); } private function saveSnapshot(array $snapshot): void { $saveSnapshot = static fn(): array => $snapshot; $this->store->compositionSave( self::TENANT_ID, self::USER_ID, self::COMPOSITION_ID, $saveSnapshot, ); } private function deleteDir(string $path): void { if (!is_dir($path)) { return; } foreach (scandir($path) ?: [] as $entry) { if ($entry === '.' || $entry === '..') { continue; } $child = $path . '/' . $entry; if (is_dir($child)) { $this->deleteDir($child); continue; } unlink($child); } rmdir($path); } }