Mark new regular and chunked uploads as pending and claim persisted article references, including partial saves and large reference sets. Protect associated media from cleanup and add upload lifecycle regression coverage. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
212 lines
8.6 KiB
PHP
212 lines
8.6 KiB
PHP
<?php
|
|
/** WordPress-free pending upload lifecycle and cleanup harness. */
|
|
|
|
declare(strict_types=1);
|
|
|
|
define('ABSPATH', __DIR__ . '/');
|
|
define('DAY_IN_SECONDS', 86400);
|
|
define('HOUR_IN_SECONDS', 3600);
|
|
|
|
final class WP_REST_Request {
|
|
public function __construct(private array $params) {}
|
|
public function get_param(string $key) { return $this->params[$key] ?? null; }
|
|
}
|
|
final class WP_REST_Response {
|
|
public function __construct(public array $data, public int $status = 200) {}
|
|
}
|
|
|
|
function absint($value): int { return abs((int) $value); }
|
|
function __($text, $domain = '') { return $text; }
|
|
function get_current_user_id(): int { return $GLOBALS['current_user']; }
|
|
function is_user_logged_in(): bool { return get_current_user_id() > 0; }
|
|
function get_post(int $id) { return $GLOBALS['posts'][$id] ?? null; }
|
|
function get_post_meta(int $id, string $key, bool $single = false) { return $GLOBALS['meta'][$id][$key] ?? ''; }
|
|
function update_post_meta(int $id, string $key, $value): void { $GLOBALS['meta'][$id][$key] = $value; }
|
|
function delete_post_meta(int $id, string $key): void { unset($GLOBALS['meta'][$id][$key]); }
|
|
function wp_delete_attachment(int $id, bool $force) {
|
|
$GLOBALS['deletions'][] = [$id, $force];
|
|
if (!empty($GLOBALS['delete_failures'][$id])) return false;
|
|
$post = get_post($id);
|
|
unset($GLOBALS['posts'][$id], $GLOBALS['meta'][$id]);
|
|
return $post;
|
|
}
|
|
function wp_get_upload_dir(): array { return ['baseurl' => 'https://example.test/wp-content/uploads']; }
|
|
function wp_parse_url(string $url, int $component = -1) { return parse_url($url, $component); }
|
|
function set_url_scheme(string $url): string { return preg_replace('#^https?:#', 'https:', $url); }
|
|
function attachment_url_to_postid(string $url): int { return $GLOBALS['attachment_urls'][$url] ?? 0; }
|
|
function add_action(string $hook, callable $callback, int $priority = 10, int $accepted = 1): void {
|
|
$GLOBALS['hooks'][$hook] = [$callback, $accepted];
|
|
}
|
|
function register_rest_route(string $namespace, string $route, array $args): void {
|
|
$GLOBALS['routes'][] = [$namespace, $route, $args];
|
|
}
|
|
function wp_next_scheduled(string $hook) { return $GLOBALS['scheduled'][$hook] ?? false; }
|
|
function wp_schedule_event(int $at, string $recurrence, string $hook): void {
|
|
$GLOBALS['schedule_calls'][] = [$at, $recurrence, $hook];
|
|
$GLOBALS['scheduled'][$hook] = $at;
|
|
}
|
|
|
|
final class PendingWpdb {
|
|
public string $posts = 'wp_posts';
|
|
public string $postmeta = 'wp_postmeta';
|
|
public array $queries = [];
|
|
public ?array $forced_ids = null;
|
|
public $after_select = null;
|
|
private array $params = [];
|
|
|
|
public function prepare(string $sql, ...$params): string {
|
|
$this->params = $params;
|
|
$this->queries[] = ['sql' => $sql, 'params' => $params];
|
|
return $sql;
|
|
}
|
|
public function get_col(string $sql): array {
|
|
if ($this->forced_ids !== null) {
|
|
$ids = $this->forced_ids;
|
|
} else {
|
|
[$key, $cutoff, $limit] = $this->params;
|
|
$posts = $GLOBALS['posts'];
|
|
ksort($posts);
|
|
$ids = [];
|
|
foreach ($posts as $id => $post) {
|
|
$pending = get_post_meta($id, $key, true);
|
|
if ($post->post_type !== 'attachment' || $pending === '' || (int) $pending >= $cutoff) continue;
|
|
if (str_contains($sql, 'p.post_parent = 0') && $post->post_parent !== 0) continue;
|
|
$ids[] = $id;
|
|
if (count($ids) >= $limit) break;
|
|
}
|
|
}
|
|
if (is_callable($this->after_select)) ($this->after_select)();
|
|
return $ids;
|
|
}
|
|
}
|
|
|
|
require dirname(__DIR__, 2) . '/src/Attachment/PendingUploads.php';
|
|
use AKSRT\Attachment\PendingUploads;
|
|
|
|
function reset_fixture(): void {
|
|
$GLOBALS['posts'] = [];
|
|
$GLOBALS['meta'] = [];
|
|
$GLOBALS['attachment_urls'] = [];
|
|
$GLOBALS['deletions'] = [];
|
|
$GLOBALS['delete_failures'] = [];
|
|
$GLOBALS['current_user'] = 7;
|
|
$GLOBALS['wpdb'] = new PendingWpdb();
|
|
}
|
|
function attachment(int $id, int $owner = 7, int $parent = 0, ?int $pending = null): string {
|
|
$GLOBALS['posts'][$id] = (object) ['ID' => $id, 'post_type' => 'attachment', 'post_author' => $owner, 'post_parent' => $parent];
|
|
$url = 'https://example.test/wp-content/uploads/file-' . $id . '.jpg';
|
|
$GLOBALS['attachment_urls'][$url] = $id;
|
|
if ($pending !== null) update_post_meta($id, PendingUploads::META, $pending);
|
|
return $url;
|
|
}
|
|
function pending_ids(): array {
|
|
return array_values(array_filter(array_keys($GLOBALS['posts']), static fn(int $id): bool => get_post_meta($id, PendingUploads::META, true) !== ''));
|
|
}
|
|
|
|
$service = PendingUploads::get_instance();
|
|
$results = [];
|
|
reset_fixture();
|
|
$GLOBALS['hooks'] = [];
|
|
$GLOBALS['routes'] = [];
|
|
$GLOBALS['scheduled'] = [];
|
|
$GLOBALS['schedule_calls'] = [];
|
|
$service->init();
|
|
$service->maybe_schedule();
|
|
$service->maybe_schedule();
|
|
$service->register_routes();
|
|
$route = $GLOBALS['routes'][0];
|
|
$permission = $route[2]['permission_callback'];
|
|
$member_allowed = $permission();
|
|
$GLOBALS['current_user'] = 0;
|
|
$results['registration'] = [
|
|
'hooks' => array_keys($GLOBALS['hooks']),
|
|
'schedule_count' => count($GLOBALS['schedule_calls']),
|
|
'recurrence' => $GLOBALS['schedule_calls'][0][1],
|
|
'namespace' => $route[0], 'route' => $route[1], 'method' => $route[2]['methods'],
|
|
'member_allowed' => $member_allowed, 'guest_allowed' => $permission(),
|
|
];
|
|
|
|
reset_fixture();
|
|
for ($id = 1; $id <= 25; $id++) attachment($id, 7, 0, time() - 2 * DAY_IN_SECONDS);
|
|
attachment(26, 8, 0, time() - 2 * DAY_IN_SECONDS);
|
|
attachment(27);
|
|
PendingUploads::mark(27);
|
|
$marked_at = get_post_meta(27, PendingUploads::META, true);
|
|
PendingUploads::mark(0);
|
|
PendingUploads::claim_ids(array_merge(range(1, 27), [1, 0]), 7);
|
|
$results['claim_ids'] = ['remaining' => pending_ids(), 'marked_recently' => abs(time() - $marked_at) <= 2, 'invalid_marked' => isset($GLOBALS['meta'][0])];
|
|
|
|
reset_fixture();
|
|
$urls = [];
|
|
for ($id = 1; $id <= 25; $id++) $urls[] = attachment($id, 7, 0, time() - 2 * DAY_IN_SECONDS);
|
|
PendingUploads::claim_urls($urls, 7);
|
|
$default_remaining = pending_ids();
|
|
PendingUploads::claim_urls($urls, 7, null);
|
|
$results['claim_urls'] = ['default_remaining' => $default_remaining, 'unlimited_remaining' => pending_ids()];
|
|
|
|
reset_fixture();
|
|
$urls = [];
|
|
for ($id = 1; $id <= 25; $id++) $urls[] = attachment($id, 7, 0, time() - 2 * DAY_IN_SECONDS);
|
|
$other = attachment(26, 8, 0, time() - 2 * DAY_IN_SECONDS);
|
|
$outside = 'https://cdn.other.test/file.jpg';
|
|
attachment(27, 7, 0, time() - 2 * DAY_IN_SECONDS);
|
|
$GLOBALS['attachment_urls'][$outside] = 27;
|
|
$text = implode(' ', array_map(static fn(string $url): string => '<img src="' . $url . '">', array_merge($urls, [$other, $outside])));
|
|
PendingUploads::claim_text($text, 7);
|
|
$text_default = pending_ids();
|
|
PendingUploads::claim_text($text, 7, null);
|
|
$results['claim_text'] = ['default_remaining' => $text_default, 'unlimited_remaining' => pending_ids()];
|
|
|
|
reset_fixture();
|
|
attachment(1, 7, 0, time());
|
|
attachment(2, 8, 0, time());
|
|
attachment(3);
|
|
attachment(4, 7, 99, time());
|
|
$denied = [PendingUploads::discard(2, 7), PendingUploads::discard(3, 7), PendingUploads::discard(4, 7), PendingUploads::discard(1, 0)];
|
|
$allowed = $service->rest_discard(new WP_REST_Request(['id' => 1]));
|
|
$repeat = $service->rest_discard(new WP_REST_Request(['id' => 1]));
|
|
$results['discard'] = ['denied' => $denied, 'success' => $allowed->data['success'], 'repeat_status' => $repeat->status, 'deleted' => $GLOBALS['deletions']];
|
|
|
|
reset_fixture();
|
|
$expired = time() - 2 * DAY_IN_SECONDS;
|
|
attachment(1, 7, 0, $expired);
|
|
attachment(2, 7, 0, time() - HOUR_IN_SECONDS);
|
|
attachment(3);
|
|
attachment(4, 7, 99, $expired);
|
|
$service->cleanup();
|
|
$results['cleanup'] = [
|
|
'deleted' => $GLOBALS['deletions'], 'remaining' => array_keys($GLOBALS['posts']),
|
|
'query' => $GLOBALS['wpdb']->queries[0],
|
|
];
|
|
|
|
reset_fixture();
|
|
for ($id = 1; $id <= 6; $id++) attachment($id, 7, 0, $expired);
|
|
$GLOBALS['posts'][6]->post_type = 'post';
|
|
$GLOBALS['wpdb']->forced_ids = range(1, 6);
|
|
$GLOBALS['wpdb']->after_select = static function (): void {
|
|
PendingUploads::claim_ids([1], 7);
|
|
$GLOBALS['posts'][2]->post_parent = 99;
|
|
PendingUploads::mark(3);
|
|
unset($GLOBALS['posts'][4]);
|
|
};
|
|
$service->cleanup();
|
|
$results['stale_selection'] = ['deleted' => $GLOBALS['deletions']];
|
|
|
|
reset_fixture();
|
|
for ($id = 1; $id <= 105; $id++) attachment($id, 7, 0, $expired);
|
|
$service->cleanup();
|
|
$first_batch = count($GLOBALS['deletions']);
|
|
$service->cleanup();
|
|
$results['batch'] = ['first' => $first_batch, 'total' => count($GLOBALS['deletions']), 'remaining' => count($GLOBALS['posts'])];
|
|
|
|
reset_fixture();
|
|
attachment(1, 7, 0, $expired);
|
|
$GLOBALS['delete_failures'][1] = true;
|
|
$service->cleanup();
|
|
$retained_after_failure = isset($GLOBALS['posts'][1]) && get_post_meta(1, PendingUploads::META, true) !== '';
|
|
$GLOBALS['delete_failures'][1] = false;
|
|
$service->cleanup();
|
|
$results['retry'] = ['retained' => $retained_after_failure, 'deleted_after_retry' => !isset($GLOBALS['posts'][1])];
|
|
|
|
echo json_encode($results, JSON_THROW_ON_ERROR);
|