diff --git a/src/php/snippet-ops.php b/src/php/snippet-ops.php index 8f6dc441b..d43f5f056 100644 --- a/src/php/snippet-ops.php +++ b/src/php/snippet-ops.php @@ -383,7 +383,7 @@ function update_shared_network_snippets( array $snippets ): bool { } $existing_shared_ids = get_site_option( 'shared_network_snippets', [] ); - $updated_shared_ids = array_values( array_diff( array_merge( $existing_shared_ids, $shared_ids ), $unshared_ids ) ); + $updated_shared_ids = array_values( array_unique( array_diff( array_merge( $existing_shared_ids, $shared_ids ), $unshared_ids ) ) ); if ( $existing_shared_ids === $updated_shared_ids ) { return false; @@ -393,21 +393,30 @@ function update_shared_network_snippets( array $snippets ): bool { // Deactivate the snippet on all sites if necessary. if ( $unshared_ids ) { - $sites = get_sites( [ 'fields' => 'ids' ] ); + // Every site, not the first hundred that get_sites() returns by default: + // one left out would keep running a snippet that is no longer shared. + $sites = get_sites( + [ + 'fields' => 'ids', + 'number' => 0, + ] + ); foreach ( $sites as $site ) { switch_to_blog( $site ); $active_shared_ids = get_option( 'active_shared_network_snippets' ); if ( is_array( $active_shared_ids ) ) { - $active_shared_ids = array_diff( $active_shared_ids, $unshared_ids ); + $active_shared_ids = array_values( array_diff( $active_shared_ids, $unshared_ids ) ); update_option( 'active_shared_network_snippets', $active_shared_ids ); } clean_active_snippets_cache( code_snippets()->db->ms_table ); - } - restore_current_blog(); + // Each switch is undone before the next, as one restore only steps + // back a single switch and would leave the request on another site. + restore_current_blog(); + } } return true; @@ -807,6 +816,7 @@ function save_snippet( $snippet ): ?Snippet { $snippet->id = $wpdb->insert_id; $updated = get_snippet( $snippet->id, $snippet->network ); + $updated->shared_network = $snippet->shared_network; $updated->code_error = $snippet->code_error; $updated->code_error_trace = $snippet->code_error_trace; do_action( 'code_snippets/create_snippet', $updated, $table ); @@ -828,6 +838,7 @@ function save_snippet( $snippet ): ?Snippet { clean_snippets_cache( $table ); $updated = get_snippet( $snippet->id, $snippet->network ); + $updated->shared_network = $snippet->shared_network; $updated->code_error = $snippet->code_error; $updated->code_error_trace = $snippet->code_error_trace; diff --git a/tests/unit/Snippets/Shared_Network_Snippets_Test.php b/tests/unit/Snippets/Shared_Network_Snippets_Test.php new file mode 100644 index 000000000..e35f80c7b --- /dev/null +++ b/tests/unit/Snippets/Shared_Network_Snippets_Test.php @@ -0,0 +1,244 @@ +markTestSkipped( 'Sharing with subsites only exists on multisite installs.' ); + } + + delete_site_option( 'shared_network_snippets' ); + } + + /** + * Build an unsaved network snippet. + * + * @param bool $shared Whether it is shared with subsites. + * + * @return Snippet + */ + private function network_snippet( bool $shared ): Snippet { + return new Snippet( + [ + 'name' => 'Shared network fixture', + 'code' => 'body { color: red; }', + 'scope' => 'site-css', + 'network' => true, + 'shared_network' => $shared, + ] + ); + } + + /** + * Save an existing network snippet with a different share setting, as the editor does. + * + * @param int $id Snippet identifier. + * @param bool $shared Requested share setting. + * + * @return Snippet|null + */ + private function save_with_sharing( int $id, bool $shared ): ?Snippet { + $edit = clone get_snippet( $id, true ); + $edit->shared_network = $shared; + + return save_snippet( $edit ); + } + + /** + * IDs currently shared with subsites. + * + * @return array + */ + private function shared_ids(): array { + return (array) get_site_option( 'shared_network_snippets', [] ); + } + + /** + * A new snippet saved as shared is shared. + * + * @return void + */ + public function test_new_snippet_saved_as_shared_is_shared(): void { + $saved = save_snippet( $this->network_snippet( true ) ); + + $this->assertSame( [ $saved->id ], $this->shared_ids() ); + $this->assertTrue( $saved->shared_network, 'the saved snippet should report the setting it was saved with' ); + } + + /** + * A new snippet saved without sharing is not shared. + * + * @return void + */ + public function test_new_snippet_saved_without_sharing_is_not_shared(): void { + $saved = save_snippet( $this->network_snippet( false ) ); + + $this->assertSame( [], $this->shared_ids() ); + $this->assertFalse( $saved->shared_network ); + } + + /** + * Sharing can be switched on for a snippet that already exists. + * + * @return void + */ + public function test_sharing_can_be_switched_on_for_an_existing_snippet(): void { + $id = save_snippet( $this->network_snippet( false ) )->id; + + $saved = $this->save_with_sharing( $id, true ); + + $this->assertSame( [ $id ], $this->shared_ids() ); + $this->assertTrue( $saved->shared_network ); + $this->assertTrue( get_snippet( $id, true )->shared_network, 'the setting should still be on when the snippet is read again' ); + } + + /** + * Sharing can be switched off again. + * + * @return void + */ + public function test_sharing_can_be_switched_off(): void { + $id = save_snippet( $this->network_snippet( true ) )->id; + + $saved = $this->save_with_sharing( $id, false ); + + $this->assertSame( [], $this->shared_ids() ); + $this->assertFalse( $saved->shared_network ); + } + + /** + * A save that does not mention sharing leaves it as it was. + * + * @return void + */ + public function test_saving_without_a_share_setting_keeps_the_current_one(): void { + $id = save_snippet( $this->network_snippet( true ) )->id; + + $edit = new Snippet( + [ + 'id' => $id, + 'name' => 'Shared network fixture', + 'code' => 'body { color: blue; }', + 'scope' => 'site-css', + 'network' => true, + ] + ); + save_snippet( $edit ); + + $this->assertSame( [ $id ], $this->shared_ids() ); + } + + /** + * Hooks on a save are handed the share setting that was saved. + * + * @return void + */ + public function test_save_hooks_receive_the_requested_share_setting(): void { + $seen = []; + $record = function ( Snippet $snippet ) use ( &$seen ) { + $seen[] = $snippet->shared_network; + }; + + add_action( 'code_snippets/create_snippet', $record ); + add_action( 'code_snippets/update_snippet', $record ); + + $id = save_snippet( $this->network_snippet( true ) )->id; + $this->save_with_sharing( $id, false ); + + remove_action( 'code_snippets/create_snippet', $record ); + remove_action( 'code_snippets/update_snippet', $record ); + + $this->assertSame( [ true, false ], $seen ); + } + + /** + * Saving a shared snippet repeatedly lists its ID once. + * + * @return void + */ + public function test_repeated_saves_list_a_shared_snippet_once(): void { + $id = save_snippet( $this->network_snippet( true ) )->id; + + $this->save_with_sharing( $id, true ); + $this->save_with_sharing( $id, true ); + + $this->assertSame( [ $id ], $this->shared_ids() ); + } + + /** + * A list that already holds repeats is tidied by the next save. + * + * @return void + */ + public function test_existing_repeats_are_removed_on_the_next_save(): void { + $id = save_snippet( $this->network_snippet( true ) )->id; + update_site_option( 'shared_network_snippets', [ $id, $id, $id ] ); + + $this->save_with_sharing( $id, true ); + + $this->assertSame( [ $id ], $this->shared_ids() ); + } + + /** + * Switching sharing off switches the snippet off on every subsite using it. + * + * @return void + */ + public function test_switching_sharing_off_deactivates_the_snippet_on_every_subsite(): void { + $id = save_snippet( $this->network_snippet( true ) )->id; + $other_id = $id + 1000; + $subsites = [ $this->factory()->blog->create(), $this->factory()->blog->create() ]; + + foreach ( $subsites as $subsite ) { + update_blog_option( $subsite, 'active_shared_network_snippets', [ $id, $other_id ] ); + } + + $this->save_with_sharing( $id, false ); + + foreach ( $subsites as $subsite ) { + $this->assertSame( + [ $other_id ], + array_values( (array) get_blog_option( $subsite, 'active_shared_network_snippets' ) ), + 'the snippet should no longer be active on a subsite, and others it had on should be untouched' + ); + } + } + + /** + * Switching sharing off leaves the request on the site it started on. + * + * @return void + */ + public function test_switching_sharing_off_returns_to_the_original_site(): void { + $id = save_snippet( $this->network_snippet( true ) )->id; + $this->factory()->blog->create(); + $this->factory()->blog->create(); + $original = get_current_blog_id(); + + $this->save_with_sharing( $id, false ); + + $this->assertSame( $original, get_current_blog_id(), 'the save should finish on the site it started on' ); + $this->assertFalse( ms_is_switched(), 'no switch to another site should be left open' ); + } +}