Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion src/php/Model/Cloud_Snippet.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down
32 changes: 30 additions & 2 deletions src/php/REST_API/Cloud/Cloud_Snippets_REST_Controller.php
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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.
*
Expand All @@ -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' ),
Expand All @@ -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' ),
Expand Down
53 changes: 20 additions & 33 deletions tests/e2e/code-snippets-community-featured.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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<void>(resolve => {
releaseDownload = () => resolve()
})
const refreshPending = new Promise<void>(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({
Expand All @@ -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',
Expand Down Expand Up @@ -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)
}
})
Expand Down
15 changes: 6 additions & 9 deletions tests/e2e/code-snippets-edit.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
})
Expand Down
42 changes: 42 additions & 0 deletions tests/unit/REST_API/REST_API_Cloud_Test.php
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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 );
}
}
Loading