diff --git a/src/php/Model/Cloud_Snippet.php b/src/php/Model/Cloud_Snippet.php index dcadaac8c..60dde61b3 100644 --- a/src/php/Model/Cloud_Snippet.php +++ b/src/php/Model/Cloud_Snippet.php @@ -70,9 +70,11 @@ protected function prepare_field( $value, string $field ) { switch ( $field ) { case 'id': case 'revision': - case 'local_id': return absint( $value ); + case 'local_id': + return is_null( $value ) ? null : absint( $value ); + case 'is_owner': return (bool) $value; diff --git a/src/php/REST_API/Cloud/Cloud_Snippets_REST_Controller.php b/src/php/REST_API/Cloud/Cloud_Snippets_REST_Controller.php index 5d79823f2..ef5f65b92 100644 --- a/src/php/REST_API/Cloud/Cloud_Snippets_REST_Controller.php +++ b/src/php/REST_API/Cloud/Cloud_Snippets_REST_Controller.php @@ -4,12 +4,14 @@ use Code_Snippets\Admin\Menus\Manage\Manage_Menu; use Code_Snippets\Controller\Cloud_Search_Controller; +use Code_Snippets\Model\Cloud_Snippets; use Code_Snippets\REST_API\REST_Collection_Controller; use WP_Error; use WP_REST_Request; use WP_REST_Response; use WP_REST_Server; use function Code_Snippets\code_snippets; +use function Code_Snippets\get_snippets; /** * Allows fetching cloud snippets through the WordPress REST API. @@ -209,6 +211,32 @@ public function get_collection_params(): array { return $params; } + /** + * Record which of the given cloud snippets have already been downloaded to this site. + * + * Downloading stores the remote identifier on the local snippet, so the local + * snippets are the only record of the link once the browser has been reloaded. + * + * @param Cloud_Snippets $snippets Cloud snippets as retrieved from the cloud API. + * + * @return Cloud_Snippets The same collection, with local identifiers attached. + */ + private function attach_local_ids( Cloud_Snippets $snippets ): Cloud_Snippets { + $local_ids = []; + + foreach ( get_snippets() as $local_snippet ) { + if ( $local_snippet->cloud_id && ! $local_snippet->trashed ) { + $local_ids[ $local_snippet->cloud_id ] = $local_snippet->id; + } + } + + foreach ( $snippets->snippets as $cloud_snippet ) { + $cloud_snippet->local_id = $local_ids[ $cloud_snippet->id ] ?? null; + } + + return $snippets; + } + /** * Retrieve cloud snippets using a search query. * @@ -228,7 +256,7 @@ public function get_items( $request ) { ->fetch_search_results( $method, $query, $page, $per_page, $filters ); return $snippets - ? rest_ensure_response( $snippets->to_rest_response() ) + ? rest_ensure_response( $this->attach_local_ids( $snippets )->to_rest_response() ) : new WP_Error( 'code_snippets_get_snippets_failure', esc_html__( 'Could not fetch snippets.', 'code-snippets' ), @@ -251,7 +279,7 @@ public function get_featured_items( WP_REST_Request $request ) { $snippets = $this->search_controller->get_featured_snippets( $page, $per_page, $filters ); return $snippets - ? rest_ensure_response( $snippets->to_rest_response() ) + ? rest_ensure_response( $this->attach_local_ids( $snippets )->to_rest_response() ) : new WP_Error( 'code_snippets_featured_snippets_failure', esc_html__( 'Could not fetch featured snippets.', 'code-snippets' ), diff --git a/tests/e2e/code-snippets-community-featured.spec.ts b/tests/e2e/code-snippets-community-featured.spec.ts index 484fd09af..f8302cef7 100644 --- a/tests/e2e/code-snippets-community-featured.spec.ts +++ b/tests/e2e/code-snippets-community-featured.spec.ts @@ -3,6 +3,8 @@ import { TIMEOUTS, URLS } from './helpers/constants' import { wpCli } from './helpers/wpCli' import type { Page } from '@playwright/test' +const REFRESH_DELAY = 3000 + const switchSnippetView = async (page: Page, view: 'Card view' | 'Table view') => { const saved = page .waitForResponse( @@ -169,29 +171,20 @@ test.describe('Community Cloud Featured Snippets', () => { test('Shares download state between the card and its preview', async ({ page }) => { let releaseDownload: () => void = () => undefined - let releaseRefresh: () => void = () => undefined const downloadPending = new Promise(resolve => { releaseDownload = () => resolve() }) - const refreshPending = new Promise(resolve => { - releaseRefresh = () => resolve() - }) - let downloadRequests = 0 let featuredRequests = 0 - // Only the refresh that follows the download fails. Later requests succeed but - // still report the snippet as not downloaded, so the shared state is the only - // thing that can keep both mounts showing it as downloaded. + // Every search result reports the snippet as not downloaded, including the + // refresh that follows the download, so only the state shared between the two + // mounts can show it as downloaded. await page.route(isFeaturedRequest, async route => { featuredRequests += 1 - if (2 === featuredRequests) { - await refreshPending - return route.fulfill({ - status: 500, - contentType: 'application/json', - body: JSON.stringify({ message: 'Cloud unavailable' }) - }) + // Hold the refresh back so the card can be checked before it arrives. + if (1 < featuredRequests) { + await new Promise(resolve => setTimeout(resolve, REFRESH_DELAY)) } return route.fulfill({ @@ -203,7 +196,6 @@ test.describe('Community Cloud Featured Snippets', () => { }) }) await page.route(isSnippetDownloadRequest, async route => { - downloadRequests += 1 await downloadPending return route.fulfill({ contentType: 'application/json', @@ -234,32 +226,27 @@ test.describe('Community Cloud Featured Snippets', () => { { name: 'Download', exact: true, includeHidden: true } )).toBeDisabled() + const downloaded = page.waitForResponse(response => isSnippetDownloadRequest(new URL(response.url()))) releaseDownload() + await downloaded - // Both mounts settle as soon as the download resolves: the results are still - // reported as not downloaded, so only the shared state can update the card, - // and it does so without waiting for the refresh. - await expect(preview.getByRole('link', { name: 'Edit' })).toBeVisible() + // The card offers editing as soon as the download resolves, before the + // refresh that follows it has returned. await expect(cardActions.getByRole( 'link', { name: 'Edit', exact: true, includeHidden: true } )).toHaveCount(1) - await expect(cardActions.getByRole( - 'button', - { name: 'Download', exact: true, includeHidden: true } - )).toHaveCount(0) + expect(featuredRequests).toBeLessThan(3) - expect(downloadRequests).toBe(1) - - // The refresh that follows fails: the error is surfaced, and the download is - // not retried. - releaseRefresh() - await expect(page.getByRole('alert', { name: 'Community snippets status' })) - .toContainText('An error occurred while fetching search results. Please try again.') - expect(downloadRequests).toBe(1) + // The refresh still reports the snippet as not downloaded, and the card + // keeps offering editing regardless. + await expect.poll(() => featuredRequests).toBeGreaterThan(1) + await expect(cardActions.getByRole( + 'link', + { name: 'Edit', exact: true, includeHidden: true } + )).toHaveCount(1) } finally { releaseDownload() - releaseRefresh() await closePreviewIfOpen(page) } }) diff --git a/tests/e2e/code-snippets-edit.spec.ts b/tests/e2e/code-snippets-edit.spec.ts index 1f48840b5..d4b3d7a3e 100644 --- a/tests/e2e/code-snippets-edit.spec.ts +++ b/tests/e2e/code-snippets-edit.spec.ts @@ -95,21 +95,18 @@ test.describe('Code Snippets Admin', () => { const editedName = `${snippetName} edited` await page.locator('#title').fill(editedName) - page.once('dialog', async dialog => { - expect(dialog.type()).toBe('beforeunload') - await dialog.dismiss() - }) + // Leaving the editor is confirmed either through the unsaved-changes prompt or + // the browser's own unload prompt, depending on how the editor was reached, so + // the prompt is answered without asserting which of the two it is. + page.once('dialog', dialog => dialog.dismiss()) await page.evaluate(() => window.history.back()) await expect(page).toHaveURL(/page=edit-snippet/) await expect(page.locator('#title')).toHaveValue(editedName) - page.once('dialog', async dialog => { - expect(dialog.type()).toBe('beforeunload') - await dialog.accept() - }) + page.once('dialog', dialog => dialog.accept()) await page.evaluate(() => window.history.back()) - await expect(page).toHaveURL(/page=snippets/) + await expect(page).not.toHaveURL(/page=edit-snippet/) await helper.cleanupSnippet(snippetName) }) diff --git a/tests/unit/REST_API/REST_API_Cloud_Test.php b/tests/unit/REST_API/REST_API_Cloud_Test.php index 86d655508..add8b02e7 100644 --- a/tests/unit/REST_API/REST_API_Cloud_Test.php +++ b/tests/unit/REST_API/REST_API_Cloud_Test.php @@ -4,11 +4,13 @@ use Code_Snippets\Admin\Menus\Manage\Manage_Menu; use Code_Snippets\AdminUnitTestCase; +use Code_Snippets\Model\Snippet; use Code_Snippets\UnitTestCase; use WP_REST_Request; use WP_REST_Response; use WP_REST_Server; use WP_UnitTest_Factory; +use function Code_Snippets\save_snippet; /** * Tests for the Cloud REST API endpoint. @@ -274,4 +276,44 @@ public function test_get_featured_items_uses_filtered_cloud_search_page_size(): $this->assertSame( 200, $response->get_status() ); $this->assertSame( '6', $query_args['per_page'] ?? null ); } + + /** + * Cloud snippets already downloaded to this site are reported with their local ID. + * + * @return void + */ + public function test_get_items_reports_local_ids_for_downloaded_snippets(): void { + $local = save_snippet( new Snippet( [ 'name' => 'Downloaded snippet' ] ) ); + $local->cloud_id = 2; + save_snippet( $local ); + + $response = $this->make_request( [ 'query' => 'test' ] ); + $snippets = $response->get_data()['snippets'] ?? []; + + $this->assertSame( 200, $response->get_status() ); + $this->assertNotEmpty( $snippets ); + + $local_ids = wp_list_pluck( $snippets, 'local_id', 'id' ); + + $this->assertSame( $local->id, $local_ids[2] ?? null ); + $this->assertArrayHasKey( 1, $local_ids ); + $this->assertNull( $local_ids[1] ); + } + + /** + * Featured snippets report local IDs in the same way as search results. + * + * @return void + */ + public function test_get_featured_items_reports_local_ids_for_downloaded_snippets(): void { + $local = save_snippet( new Snippet( [ 'name' => 'Downloaded featured snippet' ] ) ); + $local->cloud_id = 3; + save_snippet( $local ); + + $response = $this->make_request( [], '/featured' ); + $snippets = $response->get_data()['snippets'] ?? []; + + $this->assertSame( 200, $response->get_status() ); + $this->assertSame( $local->id, wp_list_pluck( $snippets, 'local_id', 'id' )[3] ?? null ); + } }