diff --git a/ProcessMaker/Http/Controllers/Api/DevLinkController.php b/ProcessMaker/Http/Controllers/Api/DevLinkController.php index 8994184d1a..86956296da 100644 --- a/ProcessMaker/Http/Controllers/Api/DevLinkController.php +++ b/ProcessMaker/Http/Controllers/Api/DevLinkController.php @@ -4,6 +4,7 @@ use Illuminate\Database\Eloquent\ModelNotFoundException; use Illuminate\Http\Request; +use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\File; use Illuminate\Support\Facades\Notification; use Illuminate\Validation\Rule; @@ -23,6 +24,7 @@ use ProcessMaker\Models\SettingsMenus; use ProcessMaker\Models\User; use ProcessMaker\Notifications\BundleUpdatedNotification; +use ProcessMaker\Services\DevLink\BundleFingerprint; class DevLinkController extends Controller { @@ -146,14 +148,21 @@ public function remoteBundles(Request $request, DevLink $devLink) return $devLink->remoteBundles($request->input('filter')); } - public function createBundle(Request $request) + public function createBundle(Request $request, BundleFingerprint $fingerprint) { - $bundle = new Bundle(); - $bundle->name = $request->input('name'); - $bundle->description = $request->input('description'); - $bundle->published = (bool) $request->input('published', false); - $bundle->version = 1; - $bundle->saveOrFail(); + $bundle = DB::transaction(function () use ($request, $fingerprint) { + $bundle = new Bundle(); + $bundle->name = $request->input('name'); + $bundle->description = $request->input('description'); + $bundle->published = (bool) $request->input('published', false); + $bundle->version = 1; + $bundle->saveOrFail(); + + $bundle->published_fingerprint = $fingerprint->calculate($bundle); + $bundle->saveOrFail(); + + return $bundle; + }); return $bundle; } @@ -170,12 +179,34 @@ public function updateBundle(Request $request, Bundle $bundle) return $bundle; } - public function increaseBundleVersion(Bundle $bundle) + public function increaseBundleVersion(Bundle $bundle, BundleFingerprint $fingerprint) { - $bundle->notifyBundleUpdated(); + $bundle->validateEditable(); - $bundle->version = $bundle->version + 1; - $bundle->saveOrFail(); + $bundle = DB::transaction(function () use ($bundle, $fingerprint) { + $lockedBundle = Bundle::whereKey($bundle->id)->lockForUpdate()->firstOrFail(); + $lockedBundle->validateEditable(); + $currentFingerprint = $fingerprint->calculate($lockedBundle); + + // Legacy source bundles have no trustworthy published snapshot to backfill from. + // Their first publication establishes the baseline; later identical attempts are blocked. + if ( + $lockedBundle->published_fingerprint !== null + && hash_equals($lockedBundle->published_fingerprint, $currentFingerprint) + ) { + throw ValidationException::withMessages([ + '*' => 'There are no changes to publish for this bundle.', + ]); + } + + $lockedBundle->version = $lockedBundle->version + 1; + $lockedBundle->published_fingerprint = $currentFingerprint; + $lockedBundle->saveOrFail(); + + return $lockedBundle; + }); + + $bundle->notifyBundleUpdated(); return $bundle; } @@ -202,10 +233,13 @@ public function bundleUpdated($bundleId, $token) public function deleteBundle(Bundle $bundle) { - $bundle->assets()->delete(); - $bundle->settings()->delete(); - $bundle->instances()->delete(); - $bundle->delete(); + DB::transaction(function () use ($bundle) { + $lockedBundle = Bundle::whereKey($bundle->id)->lockForUpdate()->firstOrFail(); + $lockedBundle->assets()->delete(); + $lockedBundle->settings()->delete(); + $lockedBundle->instances()->delete(); + $lockedBundle->delete(); + }); } public function installRemoteBundle(Request $request, DevLink $devLink, $remoteBundleId) @@ -378,14 +412,20 @@ public function remoteBundleVersion(DevLink $devLink, $remoteBundleId) public function deleteBundleAsset(BundleAsset $bundleAsset) { - $bundleAsset->delete(); + DB::transaction(function () use ($bundleAsset) { + Bundle::whereKey($bundleAsset->bundle_id)->lockForUpdate()->firstOrFail(); + $bundleAsset->delete(); + }); return response()->json(['message' => 'Bundle asset association deleted.'], 200); } public function deleteBundleSetting(BundleSetting $bundleSetting) { - $bundleSetting->delete(); + DB::transaction(function () use ($bundleSetting) { + Bundle::whereKey($bundleSetting->bundle_id)->lockForUpdate()->firstOrFail(); + $bundleSetting->delete(); + }); return response()->json(['message' => 'Bundle setting deleted.'], 200); } diff --git a/ProcessMaker/Models/Bundle.php b/ProcessMaker/Models/Bundle.php index a62545e499..70cf77cebe 100644 --- a/ProcessMaker/Models/Bundle.php +++ b/ProcessMaker/Models/Bundle.php @@ -3,6 +3,7 @@ namespace ProcessMaker\Models; use Illuminate\Database\Eloquent\Factories\HasFactory; +use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Http; use ProcessMaker\Exception\ExporterNotSupported; use ProcessMaker\Exception\ValidationException; @@ -105,6 +106,13 @@ public function exportSettingPayloads() } public function syncAssets($assets) + { + return $this->mutateWithLock(function (Bundle $bundle) use ($assets) { + return $bundle->syncAssetsWithoutLock($assets); + }); + } + + private function syncAssetsWithoutLock($assets) { $assetKeys = []; foreach ($assets as $asset) { @@ -139,6 +147,13 @@ public function syncAssets($assets) } public function addAsset(ProcessMakerModel $asset) + { + return $this->mutateWithLock(function (Bundle $bundle) use ($asset) { + return $bundle->addAssetWithoutLock($asset); + }); + } + + private function addAssetWithoutLock(ProcessMakerModel $asset) { if (!BundleAsset::canExport($asset)) { throw new ExporterNotSupported(); @@ -159,6 +174,13 @@ public function addAsset(ProcessMakerModel $asset) } public function addSettings($setting, $newId, $type = null, $replaceIds = false) + { + return $this->mutateWithLock(function (Bundle $bundle) use ($setting, $newId, $type, $replaceIds) { + return $bundle->addSettingsWithoutLock($setting, $newId, $type, $replaceIds); + }); + } + + private function addSettingsWithoutLock($setting, $newId, $type = null, $replaceIds = false) { $existingSetting = $this->settings()->where('setting', $setting)->first(); @@ -218,7 +240,7 @@ private function updateExistingSetting($existingSetting, $decodedNewId, $replace return; } - $config = json_decode($existingSetting->config, true) ?: ['id' => []]; + $config = $existingSetting->configAsArray() ?: ['id' => []]; if (!isset($config['id']) || !is_array($config['id'])) { $config['id'] = []; @@ -374,6 +396,13 @@ public function savePayloadsToFile(array $payloads, array $payloadsSettings, $lo } public function installSettings($settings) + { + return $this->mutateWithLock(function (Bundle $bundle) use ($settings) { + return $bundle->installSettingsWithoutLock($settings); + }); + } + + private function installSettingsWithoutLock($settings) { $newSettingsKeys = collect($settings)->pluck('setting')->toArray(); @@ -382,10 +411,23 @@ public function installSettings($settings) ->delete(); foreach ($settings as $setting) { - $this->addSettings($setting['setting'], $setting['config']); + $this->addSettingsWithoutLock($setting['setting'], $setting['config']); } } + private function mutateWithLock(callable $callback) + { + $result = DB::transaction(function () use ($callback) { + $bundle = static::whereKey($this->getKey())->lockForUpdate()->firstOrFail(); + + return $callback($bundle); + }); + + $this->refresh(); + + return $result; + } + public function installSettingsPayloads(array $payloads, $mode, $logger = null) { $options = new Options([ diff --git a/ProcessMaker/Models/BundleSetting.php b/ProcessMaker/Models/BundleSetting.php index 316ee37bb4..3cd1f6d1d5 100644 --- a/ProcessMaker/Models/BundleSetting.php +++ b/ProcessMaker/Models/BundleSetting.php @@ -36,9 +36,24 @@ public function bundle() return $this->belongsTo(Bundle::class); } + public function configAsArray(): array + { + if (is_array($this->config)) { + return $this->config; + } + + if (!is_string($this->config)) { + return []; + } + + $config = json_decode($this->config, true); + + return is_array($config) ? $config : []; + } + public function export() { - $configData = json_decode($this->config, true); + $configData = $this->configAsArray(); $ids = $configData['id'] ?? []; switch ($this->setting) { diff --git a/ProcessMaker/Services/DevLink/BundleFingerprint.php b/ProcessMaker/Services/DevLink/BundleFingerprint.php new file mode 100644 index 0000000000..662eeb0096 --- /dev/null +++ b/ProcessMaker/Services/DevLink/BundleFingerprint.php @@ -0,0 +1,159 @@ + $this->assetPayloads($bundle), + 'settings' => $this->settingPayloads($bundle), + ]; + + return self::PREFIX . hash('sha256', json_encode( + $this->normalize($payload), + JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE, + )); + } + + private function assetPayloads(Bundle $bundle): array + { + $payloads = array_map( + fn ($payload) => $this->normalize($payload), + $bundle->export(), + ); + + usort( + $payloads, + fn ($left, $right) => $this->payloadSortKey($left) <=> $this->payloadSortKey($right), + ); + + return $payloads; + } + + private function settingPayloads(Bundle $bundle): array + { + return $bundle->settings() + ->orderBy('setting') + ->orderBy('id') + ->get() + ->map(function (BundleSetting $setting) { + return [ + 'setting' => $setting->setting, + 'config' => $this->normalizeSettingConfig($setting->config), + 'payloads' => $this->normalizeSettingPayloads($setting->export()), + ]; + }) + ->all(); + } + + private function normalizeSettingConfig(mixed $config): mixed + { + if (is_string($config)) { + $decoded = json_decode($config, true); + if (json_last_error() === JSON_ERROR_NONE) { + $config = $decoded; + } + } + + if (is_array($config) && isset($config['id']) && is_array($config['id'])) { + sort($config['id']); + } + + return $this->normalize($config); + } + + private function normalizeSettingPayloads(mixed $payloads): mixed + { + $payloads = $this->normalize($payloads); + + if (!is_array($payloads) || !array_is_list($payloads)) { + return $payloads; + } + + usort( + $payloads, + fn ($left, $right) => $this->payloadSortKey($left) <=> $this->payloadSortKey($right), + ); + + return $payloads; + } + + private function normalize(mixed $value): mixed + { + if ($value instanceof Arrayable) { + $value = $value->toArray(); + } elseif ($value instanceof JsonSerializable) { + $value = $value->jsonSerialize(); + } + + if (!is_array($value)) { + return $value; + } + + $isExporterNode = isset($value['exporter'], $value['attributes']); + $isModelRecord = array_key_exists('created_at', $value) && array_key_exists('updated_at', $value); + $normalized = []; + + foreach ($value as $key => $item) { + if ($isExporterNode && in_array($key, self::AUDIT_KEYS, true)) { + continue; + } + + if ($isModelRecord && in_array($key, self::TIMESTAMP_KEYS, true)) { + continue; + } + + if ($key === 'attributes' && is_array($item)) { + $item = array_diff_key($item, array_flip(self::TIMESTAMP_KEYS)); + } + + $normalized[$key] = $this->normalize($item); + } + + if (!array_is_list($normalized)) { + ksort($normalized, SORT_STRING); + } + + return $normalized; + } + + private function payloadSortKey(mixed $payload): string + { + if (!is_array($payload)) { + return json_encode($payload, JSON_THROW_ON_ERROR); + } + + if (isset($payload['type'], $payload['root'])) { + return $payload['type'] . '|' . $payload['root']; + } + + if (isset($payload['key'])) { + return (string) $payload['key']; + } + + return json_encode( + $payload, + JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE, + ); + } +} diff --git a/database/migrations/2026_07_22_000000_add_published_fingerprint_to_bundles_table.php b/database/migrations/2026_07_22_000000_add_published_fingerprint_to_bundles_table.php new file mode 100644 index 0000000000..d246d019a4 --- /dev/null +++ b/database/migrations/2026_07_22_000000_add_published_fingerprint_to_bundles_table.php @@ -0,0 +1,27 @@ +string('published_fingerprint', 80)->nullable()->after('version'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('bundles', function (Blueprint $table) { + $table->dropColumn('published_fingerprint'); + }); + } +}; diff --git a/resources/js/admin/devlink/components/BundleDetail.vue b/resources/js/admin/devlink/components/BundleDetail.vue index b92b82b46b..a54e5918d0 100644 --- a/resources/js/admin/devlink/components/BundleDetail.vue +++ b/resources/js/admin/devlink/components/BundleDetail.vue @@ -129,7 +129,8 @@ centered content-class="modal-style" title="Publish New Version" - @ok="executeIncrease" + :ok-disabled="publishing" + @ok.prevent="executeIncrease" >
@@ -160,6 +161,7 @@ const bundleForEdit = ref({}); const updateAvailable = ref(false); const selected = ref(null); const confirmPublishNewVersion = ref(null); +const publishing = ref(false); const loadAssets = async () => { loading.value = true; @@ -200,10 +202,23 @@ const publishBundle = () => { }; const executeIncrease = () => { + if (publishing.value) { + return; + } + + publishing.value = true; ProcessMaker.apiClient .post(`devlink/local-bundles/${selected.value.id}/increase-version`) - .then((result) => { + .then(() => { confirmPublishNewVersion.value.hide(); + loadAssets(); + }) + .catch((error) => { + const message = error.response?.data?.error?.message || error.message; + window.ProcessMaker.alert(vue.$t(message), "warning"); + }) + .finally(() => { + publishing.value = false; }); }; diff --git a/resources/js/admin/devlink/components/LocalBundles.vue b/resources/js/admin/devlink/components/LocalBundles.vue index 6a57f68285..68b3a1a77d 100644 --- a/resources/js/admin/devlink/components/LocalBundles.vue +++ b/resources/js/admin/devlink/components/LocalBundles.vue @@ -16,6 +16,7 @@ const bundles = ref([]); const editModal = ref(null); const confirmDeleteModal = ref(null); const confirmPublishNewVersion = ref(null); +const publishing = ref(false); const confirmUpdateVersion = ref(null); const filter = ref(""); const bundleModal = ref(null); @@ -175,11 +176,23 @@ const increaseVersionBundle = (bundle) => { }; const executeIncrease = () => { + if (publishing.value) { + return; + } + + publishing.value = true; ProcessMaker.apiClient .post(`devlink/local-bundles/${selected.value.id}/increase-version`) - .then((result) => { + .then(() => { confirmPublishNewVersion.value.hide(); load(); + }) + .catch((error) => { + const message = error.response?.data?.error?.message || error.message; + window.ProcessMaker.alert(vue.$t(message), "warning"); + }) + .finally(() => { + publishing.value = false; }); }; @@ -250,7 +263,8 @@ const handleInstallationComplete = () => { centered content-class="modal-style" title="Publish New Version" - @ok="executeIncrease" + :ok-disabled="publishing" + @ok.prevent="executeIncrease" > diff --git a/resources/lang/en.json b/resources/lang/en.json index f5747fc4d6..a10e0215d3 100644 --- a/resources/lang/en.json +++ b/resources/lang/en.json @@ -2348,6 +2348,7 @@ "The variant determines the appearance of the button": "The variant determines the appearance of the button", "The version was saved.": "The version was saved.", "The weight of the text": "The weight of the text", + "There are no changes to publish for this bundle.": "There are no changes to publish for this bundle.", "There are {{items}} validation errors in your form.": "There are {{items}} validation errors in your form.", "There is a validation error in your form.": "There is a validation error in your form.", "There is no field with the name ':fieldName'.": "There is no field with the name ':fieldName'.", diff --git a/tests/Feature/Api/DevLinkConcurrencyTest.php b/tests/Feature/Api/DevLinkConcurrencyTest.php new file mode 100644 index 0000000000..857fc59343 --- /dev/null +++ b/tests/Feature/Api/DevLinkConcurrencyTest.php @@ -0,0 +1,59 @@ +create(['version' => 1]); + $screen = Screen::factory()->create(['title' => 'Concurrent Publication Screen']); + $bundle->published_fingerprint = app(BundleFingerprint::class)->calculate($bundle); + $bundle->saveOrFail(); + $bundle->addAsset($screen); + $bundleId = $bundle->id; + $startAt = microtime(true) + 1; + + $publish = static function () use ($bundleId, $startAt) { + while (microtime(true) < $startAt) { + usleep(1000); + } + + try { + app(DevLinkController::class)->increaseBundleVersion( + Bundle::findOrFail($bundleId), + app(BundleFingerprint::class), + ); + + return 'published'; + } catch (ValidationException $exception) { + return $exception->errors()['*'][0] ?? 'unexpected validation error'; + } + }; + + try { + $results = Concurrency::driver('process')->run([$publish, $publish], timeout: 30); + sort($results); + + $this->assertSame([ + 'There are no changes to publish for this bundle.', + 'published', + ], $results); + $this->assertSame(2, (int) $bundle->refresh()->version); + } finally { + $bundle->assets()->delete(); + $bundle->delete(); + $screen->delete(); + } + } +} diff --git a/tests/Feature/Api/DevLinkTest.php b/tests/Feature/Api/DevLinkTest.php index 9bed587b88..1c517e2026 100644 --- a/tests/Feature/Api/DevLinkTest.php +++ b/tests/Feature/Api/DevLinkTest.php @@ -5,8 +5,11 @@ use Illuminate\Support\Facades\Http; use ProcessMaker\Http\Controllers\Api\DevLinkController; use ProcessMaker\Models\Bundle; +use ProcessMaker\Models\BundleInstance; +use ProcessMaker\Models\BundleSetting; use ProcessMaker\Models\DevLink; use ProcessMaker\Models\Screen; +use ProcessMaker\Models\User; use Tests\Feature\Shared\RequestHelper; use Tests\TestCase; @@ -23,6 +26,181 @@ public function testShowBundle() $this->assertEquals($bundle->id, $response->json()['id']); } + public function testCreateBundleInitializesPublishedFingerprint() + { + $response = $this->apiCall('POST', route('api.devlink.create-bundle'), [ + 'name' => 'Versioned Bundle', + 'published' => true, + ]); + + $response->assertCreated(); + $this->assertStringStartsWith('v1:', $response->json('published_fingerprint')); + $this->assertSame(1, $response->json('version')); + } + + public function testPublishNewVersionRejectsUnchangedBundleWithoutNotification() + { + $createResponse = $this->apiCall('POST', route('api.devlink.create-bundle'), [ + 'name' => 'Unchanged Bundle', + 'published' => true, + ]); + $bundle = Bundle::findOrFail($createResponse->json('id')); + $originalFingerprint = $bundle->published_fingerprint; + BundleInstance::create([ + 'bundle_id' => $bundle->id, + 'instance_url' => 'https://target.test/bundle-updated', + ]); + Http::fake(); + + $response = $this->apiCall( + 'POST', + route('api.devlink.increase-bundle-version', ['bundle' => $bundle->id]), + ); + + $response->assertStatus(422); + $response->assertJsonPath('error.message', 'There are no changes to publish for this bundle.'); + $bundle->refresh(); + $this->assertSame(1, (int) $bundle->version); + $this->assertSame($originalFingerprint, $bundle->published_fingerprint); + Http::assertNothingSent(); + } + + public function testPublishNewVersionIncrementsOnceAfterAssetChange() + { + $createResponse = $this->apiCall('POST', route('api.devlink.create-bundle'), [ + 'name' => 'Changed Bundle', + 'published' => true, + ]); + $bundle = Bundle::findOrFail($createResponse->json('id')); + $originalFingerprint = $bundle->published_fingerprint; + $bundle->addAsset(Screen::factory()->create(['title' => 'New Screen'])); + BundleInstance::create([ + 'bundle_id' => $bundle->id, + 'instance_url' => 'https://target.test/bundle-updated', + ]); + Http::fake([ + 'https://target.test/bundle-updated' => Http::response([], 200), + ]); + + $response = $this->apiCall( + 'POST', + route('api.devlink.increase-bundle-version', ['bundle' => $bundle->id]), + ); + + $response->assertStatus(200); + $bundle->refresh(); + $this->assertSame(2, (int) $bundle->version); + $this->assertNotSame($originalFingerprint, $bundle->published_fingerprint); + Http::assertSentCount(1); + + $response = $this->apiCall( + 'POST', + route('api.devlink.increase-bundle-version', ['bundle' => $bundle->id]), + ); + + $response->assertStatus(422); + $this->assertSame(2, (int) $bundle->refresh()->version); + Http::assertSentCount(1); + + $bundle->assets()->firstOrFail()->delete(); + $response = $this->apiCall( + 'POST', + route('api.devlink.increase-bundle-version', ['bundle' => $bundle->id]), + ); + + $response->assertStatus(200); + $this->assertSame(3, (int) $bundle->refresh()->version); + Http::assertSentCount(2); + } + + public function testPublishNewVersionEstablishesLegacyFingerprint() + { + $bundle = Bundle::factory()->create([ + 'published_fingerprint' => null, + 'version' => 4, + ]); + + $response = $this->apiCall( + 'POST', + route('api.devlink.increase-bundle-version', ['bundle' => $bundle->id]), + ); + + $response->assertStatus(200); + $bundle->refresh(); + $this->assertSame(5, (int) $bundle->version); + $this->assertStringStartsWith('v1:', $bundle->published_fingerprint); + + $response = $this->apiCall( + 'POST', + route('api.devlink.increase-bundle-version', ['bundle' => $bundle->id]), + ); + + $response->assertStatus(422); + $response->assertJsonPath('error.message', 'There are no changes to publish for this bundle.'); + $this->assertSame(5, (int) $bundle->refresh()->version); + } + + public function testPublishNewVersionSupportsObjectJsonSettingConfiguration() + { + $createResponse = $this->apiCall('POST', route('api.devlink.create-bundle'), [ + 'name' => 'Bundle With Settings', + 'published' => true, + ]); + $bundle = Bundle::findOrFail($createResponse->json('id')); + $user = User::factory()->create(); + $setting = BundleSetting::create([ + 'bundle_id' => $bundle->id, + 'setting' => 'users', + 'config' => ['id' => [$user->id]], + ]); + $this->assertIsArray($setting->fresh()->config); + + BundleInstance::create([ + 'bundle_id' => $bundle->id, + 'instance_url' => 'https://target.test/bundle-updated', + ]); + Http::fake([ + 'https://target.test/bundle-updated' => Http::response([], 200), + ]); + + $response = $this->apiCall( + 'POST', + route('api.devlink.increase-bundle-version', ['bundle' => $bundle->id]), + ); + + $response->assertStatus(200); + $this->assertSame(2, (int) $bundle->refresh()->version); + Http::assertSentCount(1); + + $response = $this->apiCall( + 'POST', + route('api.devlink.increase-bundle-version', ['bundle' => $bundle->id]), + ); + + $response->assertStatus(422); + $response->assertJsonPath('error.message', 'There are no changes to publish for this bundle.'); + $this->assertSame(2, (int) $bundle->refresh()->version); + Http::assertSentCount(1); + } + + public function testPublishNewVersionRejectsRemoteBundle() + { + $devLink = DevLink::factory()->create(); + $bundle = Bundle::factory()->create([ + 'dev_link_id' => $devLink->id, + ]); + Http::fake(); + + $response = $this->apiCall( + 'POST', + route('api.devlink.increase-bundle-version', ['bundle' => $bundle->id]), + ); + + $response->assertStatus(422); + $response->assertJsonPath('error.message', 'Bundle is not editable'); + Http::assertNothingSent(); + } + public function testAddAssets() { $screen1 = Screen::factory()->create(); diff --git a/tests/Model/BundleSettingTest.php b/tests/Model/BundleSettingTest.php new file mode 100644 index 0000000000..97b160f0bc --- /dev/null +++ b/tests/Model/BundleSettingTest.php @@ -0,0 +1,40 @@ +create(); + $selectedUser = ['id' => [$user->id]]; + $configurations = [ + 'array' => $selectedUser, + 'JSON string' => json_encode($selectedUser), + 'null' => null, + ]; + + foreach ($configurations as $description => $config) { + $setting = new BundleSetting([ + 'setting' => 'users', + 'config' => $config, + ]); + + $this->assertSame( + $config === null ? [] : $selectedUser, + $setting->configAsArray(), + "Failed to normalize the {$description} configuration.", + ); + $this->assertTrue( + $setting->export()->contains( + fn ($payload) => ($payload['root'] ?? null) === $user->uuid, + ), + "Failed to export the {$description} configuration.", + ); + } + } +} diff --git a/tests/Model/BundleTest.php b/tests/Model/BundleTest.php index cd0b31ff3f..dc8ef6a950 100644 --- a/tests/Model/BundleTest.php +++ b/tests/Model/BundleTest.php @@ -6,6 +6,8 @@ use ProcessMaker\Models\BundleAsset; use ProcessMaker\Models\Process; use ProcessMaker\Models\Screen; +use ProcessMaker\Models\User; +use ProcessMaker\Services\DevLink\BundleFingerprint; use Tests\Feature\ImportExport\HelperTrait; use Tests\TestCase; @@ -40,6 +42,83 @@ public function testExport() $this->assertEquals($screen->title, $payload[1]['name']); } + public function testPublicationFingerprintIsStableAcrossAssetOrder() + { + $screen1 = Screen::factory()->create(['title' => 'Screen 1']); + $screen2 = Screen::factory()->create(['title' => 'Screen 2']); + $bundle1 = Bundle::factory()->create(); + $bundle2 = Bundle::factory()->create(); + + $bundle1->syncAssets([$screen1, $screen2]); + $bundle2->syncAssets([$screen2, $screen1]); + + $fingerprint = app(BundleFingerprint::class); + + $this->assertSame( + $fingerprint->calculate($bundle1->fresh()), + $fingerprint->calculate($bundle2->fresh()), + ); + } + + public function testPublicationFingerprintIgnoresTimestampsButDetectsAssetChanges() + { + $screen = Screen::factory()->create(['title' => 'Original Screen']); + $bundle = Bundle::factory()->create(); + $bundle->syncAssets([$screen]); + $fingerprint = app(BundleFingerprint::class); + $originalFingerprint = $fingerprint->calculate($bundle->fresh()); + + $screen->timestamps = false; + $screen->updated_at = now()->addHour(); + $screen->saveQuietly(); + $screen->timestamps = true; + + $this->assertSame($originalFingerprint, $fingerprint->calculate($bundle->fresh())); + + $screen->title = 'Updated Screen'; + $screen->save(); + + $this->assertNotSame($originalFingerprint, $fingerprint->calculate($bundle->fresh())); + } + + public function testPublicationFingerprintIgnoresBundleMetadata() + { + $bundle = Bundle::factory()->create([ + 'name' => 'Original Bundle', + 'description' => 'Original Description', + 'published' => true, + ]); + $fingerprint = app(BundleFingerprint::class); + $originalFingerprint = $fingerprint->calculate($bundle); + + $bundle->name = 'Renamed Bundle'; + $bundle->description = 'Updated Description'; + $bundle->published = false; + $bundle->save(); + + $this->assertSame($originalFingerprint, $fingerprint->calculate($bundle->fresh())); + } + + public function testPublicationFingerprintNormalizesSettingSelectionOrder() + { + $user1 = User::factory()->create(); + $user2 = User::factory()->create(); + $bundle1 = Bundle::factory()->create(); + $bundle2 = Bundle::factory()->create(); + + $bundle1->addSettings('users', json_encode(['id' => [$user1->id, $user2->id]])); + $bundle2->addSettings('users', json_encode(['id' => [$user2->id, $user1->id]])); + + $fingerprint = app(BundleFingerprint::class); + $originalFingerprint = $fingerprint->calculate($bundle1->fresh()); + + $this->assertSame($originalFingerprint, $fingerprint->calculate($bundle2->fresh())); + + $bundle1->addSettings('users', json_encode(['id' => [$user1->id]]), replaceIds: true); + + $this->assertNotSame($originalFingerprint, $fingerprint->calculate($bundle1->fresh())); + } + public function testSyncAssets() { $screen1 = Screen::factory()->create(['title' => 'Screen 1']);