diff --git a/src/Attachment/PendingUploads.php b/src/Attachment/PendingUploads.php index 105e1f61..681a20a1 100644 --- a/src/Attachment/PendingUploads.php +++ b/src/Attachment/PendingUploads.php @@ -80,34 +80,48 @@ final class PendingUploads { /** 删除用户自己仍待认领的附件;已被引用的附件绝不删除。 */ public static function discard(int $attachment_id, int $user_id): bool { - if (!self::is_owned_pending($attachment_id, $user_id)) { + if (!self::is_owned_pending($attachment_id, $user_id, true)) { return false; } return (bool) wp_delete_attachment($attachment_id, true); } - /** - * 按 URL 列表认领(工单附件)。 - * - * @param list $urls - */ - public static function claim_urls(array $urls, int $user_id): void { + /** @param list $ids */ + public static function claim_ids(array $ids, int $user_id): void { if ($user_id <= 0) { return; } - foreach (array_slice(array_unique($urls), 0, self::CLAIM_MAX) as $url) { - if (!is_string($url) || $url === '') { - continue; - } - $id = (int) attachment_url_to_postid($url); + foreach (array_unique(array_map('absint', $ids)) as $id) { if (self::is_owned_pending($id, $user_id)) { delete_post_meta($id, self::META); } } } - /** 从正文中提取本站上传目录下的 URL 并认领(评论 [img=…]、举报举证)。 */ - public static function claim_text(string $text, int $user_id): void { + /** + * 按 URL 列表认领;已保存文章可传 null 认领全部引用。 + * + * @param list $urls + */ + public static function claim_urls(array $urls, int $user_id, ?int $limit = self::CLAIM_MAX): void { + if ($user_id <= 0) { + return; + } + $urls = array_values(array_unique($urls)); + if ($limit !== null) { + $urls = array_slice($urls, 0, max(0, $limit)); + } + $ids = []; + foreach ($urls as $url) { + if (is_string($url) && $url !== '') { + $ids[] = (int) attachment_url_to_postid($url); + } + } + self::claim_ids($ids, $user_id); + } + + /** 从正文中提取本站上传目录下的 URL 并认领(评论 [img=…]、举报举证、已保存文章)。 */ + public static function claim_text(string $text, int $user_id, ?int $limit = self::CLAIM_MAX): void { if ($user_id <= 0 || $text === '') { return; } @@ -121,7 +135,7 @@ final class PendingUploads { $urls = array_filter($matches[0], static function (string $url) use ($base): bool { return strpos(set_url_scheme($url), set_url_scheme($base)) === 0; }); - self::claim_urls(array_values($urls), $user_id); + self::claim_urls(array_values($urls), $user_id, $limit); } /** @param int|string $approved */ @@ -143,18 +157,25 @@ final class PendingUploads { $ids = $wpdb->get_col($wpdb->prepare( "SELECT p.ID FROM {$wpdb->posts} p INNER JOIN {$wpdb->postmeta} m ON m.post_id = p.ID AND m.meta_key = %s - WHERE p.post_type = 'attachment' AND CAST(m.meta_value AS UNSIGNED) < %d + WHERE p.post_type = 'attachment' AND p.post_parent = 0 AND CAST(m.meta_value AS UNSIGNED) < %d ORDER BY p.ID ASC LIMIT %d", self::META, time() - self::TTL, self::BATCH_SIZE )); foreach ((array) $ids as $id) { - wp_delete_attachment((int) $id, true); + $id = (int) $id; + $post = get_post($id); + $pending_at = (int) get_post_meta($id, self::META, true); + if ($post && $post->post_type === 'attachment' && (int) $post->post_parent === 0 + && $pending_at > 0 && $pending_at < time() - self::TTL + ) { + wp_delete_attachment($id, true); + } } } - private static function is_owned_pending(int $attachment_id, int $user_id): bool { + private static function is_owned_pending(int $attachment_id, int $user_id, bool $unattached = false): bool { if ($attachment_id <= 0 || $user_id <= 0) { return false; } @@ -162,6 +183,7 @@ final class PendingUploads { return $post && $post->post_type === 'attachment' && (int) $post->post_author === $user_id + && (!$unattached || (int) $post->post_parent === 0) && get_post_meta($attachment_id, self::META, true) !== ''; } } diff --git a/src/Core/FrontSubmit.php b/src/Core/FrontSubmit.php index 3530d1c3..a8c1824f 100644 --- a/src/Core/FrontSubmit.php +++ b/src/Core/FrontSubmit.php @@ -38,8 +38,6 @@ class FrontSubmit { add_action('wp_ajax_nopriv_aksrt_submit_post', [$this, 'ajax_submit_post']); add_action('rest_api_init', [$this, 'register_rest_routes']); - add_action('aksrt_new_add_posts', [$this, 'sync_post_attachments']); - add_action('aksrt_new_edit_posts', [$this, 'sync_post_attachments']); add_filter('body_class', [$this, 'body_class']); add_filter('wp_robots', [$this, 'robots_noindex']); @@ -231,6 +229,7 @@ class FrontSubmit { return new \WP_Error('aksrt_upload_failed', __('上传后无法读取文件。', 'aksrt'), ['status' => 500]); } + \AKSRT\Attachment\PendingUploads::mark($attachment_id); $attachment['success'] = true; return new \WP_REST_Response($attachment, 201); } diff --git a/src/Core/FrontSubmitMedia.php b/src/Core/FrontSubmitMedia.php index fcb0ce75..1b951216 100644 --- a/src/Core/FrontSubmitMedia.php +++ b/src/Core/FrontSubmitMedia.php @@ -5,6 +5,8 @@ namespace AKSRT\Core; +use AKSRT\Attachment\PendingUploads; + defined('ABSPATH') || exit; /** @@ -197,7 +199,10 @@ trait FrontSubmitMedia { ? aksrt_get_pay_meta($post_id) : get_post_meta($post_id, DomainModel::PAY_META_KEY, true); $pay_meta = is_array($pay_meta) ? $pay_meta : []; + $download_urls = []; foreach (is_array($pay_meta['downloads'] ?? null) ? $pay_meta['downloads'] : [] as $download) { + $url = (string) ($download['url'] ?? ''); + if ($url !== '') $download_urls[] = $url; $id = absint($download['attachment_id'] ?? 0); if ($id > 0 && $this->attachment_can_attach($id, $user_id, $post_id, 'file')) { $desired[$id] = 'file'; @@ -249,6 +254,9 @@ trait FrontSubmitMedia { $wpdb->update($wpdb->posts, ['post_parent' => $post_id], ['ID' => $attachment_id], ['%d'], ['%d']); clean_post_cache($attachment_id); } + PendingUploads::claim_ids(array_keys($desired), $user_id); + PendingUploads::claim_urls($download_urls, $user_id, null); + PendingUploads::claim_text((string) $post->post_content, $user_id, null); } /** diff --git a/src/Core/FrontSubmitSave.php b/src/Core/FrontSubmitSave.php index e1999e9a..2e1e8072 100644 --- a/src/Core/FrontSubmitSave.php +++ b/src/Core/FrontSubmitSave.php @@ -192,15 +192,19 @@ trait FrontSubmitSave { $this->apply_custom_filters((int) $in_id, $data, $settings); $pay_result = $this->save_pay_meta((int) $in_id, $data, $user_id, $settings); + if ($pay_result['success']) { + $this->save_featured_meta((int) $in_id, $data, $user_id); + } + + // The post is already persisted even when pay validation fails. + $post = get_post((int) $in_id); + $this->sync_post_attachments($post); if (!$pay_result['success']) { $pay_result['post_id'] = (int) $in_id; $pay_result['posts_id'] = (int) $in_id; return $pay_result; } - $this->save_featured_meta((int) $in_id, $data, $user_id); - - $post = get_post((int) $in_id); if ($post instanceof \WP_Post) { do_action($is_edit ? 'aksrt_new_edit_posts' : 'aksrt_new_add_posts', $post); if ($status === 'pending') { diff --git a/tests/widget-system/front-submit-attachment-contract.test.cjs b/tests/widget-system/front-submit-attachment-contract.test.cjs index 8e2eabce..cfd14e9d 100644 --- a/tests/widget-system/front-submit-attachment-contract.test.cjs +++ b/tests/widget-system/front-submit-attachment-contract.test.cjs @@ -10,6 +10,7 @@ const read = (file) => fs.readFileSync(path.join(root, file), 'utf8'); const frontSubmit = read('src/Core/FrontSubmit.php'); const mediaTrait = read('src/Core/FrontSubmitMedia.php'); +const saveTrait = read('src/Core/FrontSubmitSave.php'); const page = read('templates/pages/page-submit.php'); const editor = read('assets/js/front-submit-editor.js'); const submit = read('assets/js/front-submit.js'); @@ -26,8 +27,8 @@ const runtimeFiles = [ test('front submit owns the typed media REST routes and media trait wiring', () => { assert.match(frontSubmit, /use FrontSubmitMedia\s*;/); assert.match(frontSubmit, /add_action\('rest_api_init', \[\$this, 'register_rest_routes'\]\)/); - assert.match(frontSubmit, /add_action\('aksrt_new_add_posts', \[\$this, 'sync_post_attachments'\]\)/); - assert.match(frontSubmit, /add_action\('aksrt_new_edit_posts', \[\$this, 'sync_post_attachments'\]\)/); + assert.doesNotMatch(frontSubmit, /add_action\('aksrt_new_(?:add|edit)_posts', \[\$this, 'sync_post_attachments'\]\)/); + assert.match(saveTrait, /\$this->sync_post_attachments\(\$post\);\s*if \(!\$pay_result\['success'\]\)/); assert.match(frontSubmit, /register_rest_route\('aksrt\/v1', '\/front-submit\/media\/\(\?Pimage\|video\|file\)'/); assert.match(frontSubmit, /'methods'\s*=>\s*\\WP_REST_Server::READABLE/); diff --git a/tests/widget-system/front-submit-pending-contract.test.cjs b/tests/widget-system/front-submit-pending-contract.test.cjs new file mode 100644 index 00000000..c2c674fc --- /dev/null +++ b/tests/widget-system/front-submit-pending-contract.test.cjs @@ -0,0 +1,12 @@ +'use strict'; + +const assert = require('node:assert/strict'); +const { execFileSync } = require('node:child_process'); +const path = require('node:path'); +const test = require('node:test'); + +test('front-submit pending upload lifecycle runs against production PHP traits', () => { + const harness = path.join(__dirname, 'front-submit-pending-harness.php'); + const output = execFileSync('php', [harness], { encoding: 'utf8' }); + assert.match(output, /Front-submit pending behavior harness passed/); +}); diff --git a/tests/widget-system/front-submit-pending-harness.php b/tests/widget-system/front-submit-pending-harness.php new file mode 100644 index 00000000..818cc07f --- /dev/null +++ b/tests/widget-system/front-submit-pending-harness.php @@ -0,0 +1,609 @@ + $value) { + if (property_exists($this, (string) $key)) { + $this->{$key} = match ($key) { + 'ID', 'post_author', 'post_parent' => (int) $value, + default => (string) $value, + }; + } + } + } + } + + class WP_Error { + private string $code; + private string $message; + + public function __construct($code = '', $message = '', $data = null) { + $this->code = (string) $code; + $this->message = (string) $message; + } + + public function get_error_code(): string { return $this->code; } + public function get_error_message(): string { return $this->message; } + } + + class WP_REST_Request { + private array $params; + private array $files; + + public function __construct(array $params = [], array $files = []) { + $this->params = $params; + $this->files = $files; + } + + public function get_param(string $key) { return $this->params[$key] ?? null; } + public function get_file_params(): array { return $this->files; } + } + + class WP_REST_Response { + private $data; + private int $status; + + public function __construct($data = [], int $status = 200) { + $this->data = $data; + $this->status = $status; + } + + public function get_data() { return $this->data; } + public function get_status(): int { return $this->status; } + } + + class WP_REST_Server { public const READABLE = 'GET'; } + + final class FrontSubmitPendingWpdb { + public string $posts = 'wp_posts'; + public string $postmeta = 'wp_postmeta'; + + public function update($table, array $data, array $where, array $format = [], array $where_format = []) { + $state = &$GLOBALS['front_pending_state']; + if (!empty($state['fail_relation_updates'])) return false; + $id = (int) ($where['ID'] ?? 0); + $post = $state['posts'][$id] ?? null; + if (!$post instanceof WP_Post) return 0; + foreach ($data as $key => $value) { + if ($key === 'post_parent') $post->post_parent = (int) $value; + } + $state['relation_updates'][] = ['id' => $id, 'data' => $data]; + return 1; + } + + public function prepare(string $query, ...$args): string { return $query; } + public function get_col(string $query): array { return []; } + } + + function fp_assert(bool $condition, string $message): void { + if (!$condition) throw new \RuntimeException($message); + } + + function fp_reset(): void { + $GLOBALS['front_pending_state'] = [ + 'user_id' => 7, + 'posts' => [], + 'post_meta' => [], + 'post_thumbnails' => [], + 'pay_meta' => [], + 'url_map' => [], + 'deleted' => [], + 'relation_updates' => [], + 'meta_writes' => [], + 'upload_calls' => [], + 'featured_save_calls' => 0, + 'fail_relation_updates' => false, + 'fail_insert' => false, + 'fail_update' => false, + 'insert_zero' => false, + 'update_zero' => false, + 'next_post_id' => 1000, + 'next_attachment_id' => 2000, + 'failed_js_ids' => [], + ]; + $GLOBALS['wpdb'] = new FrontSubmitPendingWpdb(); + $_FILES = []; + } + + function fp_post(int $id, string $type = 'post', int $author = 7, string $status = 'draft', string $content = '', int $parent = 0, string $mime = ''): WP_Post { + $post = new WP_Post([ + 'ID' => $id, + 'post_type' => $type, + 'post_author' => $author, + 'post_status' => $status, + 'post_content' => $content, + 'post_parent' => $parent, + 'post_mime_type' => $mime, + 'post_title' => 'Harness post ' . $id, + ]); + $GLOBALS['front_pending_state']['posts'][$id] = $post; + return $post; + } + + function fp_attachment(int $id, int $author = 7, string $mime = 'image/jpeg', int $parent = 0, string $status = 'inherit', bool $pending = true, ?string $kind = 'image'): WP_Post { + $post = fp_post($id, 'attachment', $author, $status, '', $parent, $mime); + if ($kind !== null) update_post_meta($id, '_aksrt_front_submit_media', $kind); + $GLOBALS['front_pending_state']['url_map']['https://example.test/uploads/media-' . $id . '.' . fp_ext($mime)] = $id; + if ($pending) \AKSRT\Attachment\PendingUploads::mark($id); + return $post; + } + + function fp_ext(string $mime): string { + return match ($mime) { + 'image/jpeg' => 'jpg', 'image/png' => 'png', 'video/mp4' => 'mp4', 'application/pdf' => 'pdf', default => 'bin', + }; + } + + function fp_pending(int $id): bool { + return get_post_meta($id, \AKSRT\Attachment\PendingUploads::META, true) !== ''; + } + + function fp_article_data(string $content, array $extra = []): array { + return array_merge([ + 'post_title' => 'Pending lifecycle behavior test', + 'post_content' => $content, + 'category' => [1], + ], $extra); + } + + function __($text, $domain = ''): string { return (string) $text; } + function absint($value): int { return abs((int) $value); } + function sanitize_key($value): string { return preg_replace('/[^a-z0-9_\-]/', '', strtolower((string) $value)) ?? ''; } + function sanitize_text_field($value): string { return trim(strip_tags((string) $value)); } + function sanitize_textarea_field($value): string { return trim(strip_tags((string) $value)); } + function sanitize_file_name($value): string { return preg_replace('/[^A-Za-z0-9._\-]/', '', (string) $value) ?? ''; } + function wp_unslash($value) { return $value; } + function wp_strip_all_tags($value): string { return trim(strip_tags((string) $value)); } + function wp_kses_post($value): string { return (string) $value; } + function is_wp_error($value): bool { return $value instanceof WP_Error; } + function get_current_user_id(): int { return (int) $GLOBALS['front_pending_state']['user_id']; } + function current_user_can($capability, ...$args): bool { return false; } + function user_can($user_id, $capability, ...$args): bool { return false; } + function add_filter($tag, $callback, $priority = 10, $accepted_args = 1): bool { return true; } + function remove_filter($tag, $callback, $priority = 10): bool { return true; } + function do_action($tag, ...$args): void { $GLOBALS['front_pending_state']['actions'][] = $tag; } + function current_time($type): string { return '2026-10-30 12:00:00'; } + function wp_set_post_categories($post_id, $categories): void {} + function wp_set_post_tags($post_id, $tags): void {} + function get_permalink($post_id): string { return 'https://example.test/?p=' . (int) $post_id; } + function clean_post_cache($post_id): void {} + function wp_max_upload_size(): int { return 50 * 1024 * 1024; } + function size_format($bytes): string { return (string) $bytes . 'B'; } + function get_allowed_mime_types(): array { return ['pdf' => 'application/pdf']; } + function wp_check_filetype_and_ext($file, $filename, $mimes = []): array { + $ext = strtolower(pathinfo((string) $filename, PATHINFO_EXTENSION)); + return ['ext' => $ext, 'type' => $mimes[$ext] ?? '']; + } + function get_post($post_id) { + if ($post_id instanceof WP_Post) return $post_id; + return $GLOBALS['front_pending_state']['posts'][(int) $post_id] ?? null; + } + function get_post_meta($post_id, $key = '', $single = false) { + $value = $GLOBALS['front_pending_state']['post_meta'][(int) $post_id][(string) $key] ?? null; + if ($single) return $value ?? ''; + return $value === null ? [] : [$value]; + } + function update_post_meta($post_id, $key, $value): bool { + $state = &$GLOBALS['front_pending_state']; + $id = (int) $post_id; + $meta_key = (string) $key; + $state['post_meta'][$id][$meta_key] = $value; + $state['meta_writes'][] = ['id' => $id, 'key' => $meta_key, 'value' => $value]; + return true; + } + function delete_post_meta($post_id, $key): bool { + unset($GLOBALS['front_pending_state']['post_meta'][(int) $post_id][(string) $key]); + return true; + } + function get_post_thumbnail_id($post_id): int { return (int) ($GLOBALS['front_pending_state']['post_thumbnails'][(int) $post_id] ?? 0); } + function set_post_thumbnail($post_id, $thumbnail_id): bool { + $GLOBALS['front_pending_state']['post_thumbnails'][(int) $post_id] = (int) $thumbnail_id; + return true; + } + function delete_post_thumbnail($post_id): bool { + unset($GLOBALS['front_pending_state']['post_thumbnails'][(int) $post_id]); + return true; + } + function wp_attachment_is_image($attachment_id): bool { + $post = get_post((int) $attachment_id); + return $post instanceof WP_Post && strpos($post->post_mime_type, 'image/') === 0; + } + function get_posts(array $args = []): array { + $results = []; + foreach ($GLOBALS['front_pending_state']['posts'] as $id => $post) { + if (!$post instanceof WP_Post) continue; + if (($args['post_type'] ?? '') !== $post->post_type) continue; + if (isset($args['post_parent']) && $post->post_parent !== (int) $args['post_parent']) continue; + if (isset($args['author']) && $post->post_author !== (int) $args['author']) continue; + if (isset($args['post_status']) && !in_array($post->post_status, (array) $args['post_status'], true)) continue; + if (!empty($args['meta_key']) && get_post_meta((int) $id, (string) $args['meta_key'], true) === '') continue; + $results[] = !empty($args['fields']) && $args['fields'] === 'ids' ? (int) $id : $post; + } + return $results; + } + function wp_insert_post(array $postarr, $wp_error = false) { + $state = &$GLOBALS['front_pending_state']; + if ($state['fail_insert']) return new WP_Error('insert_failed', 'insert failed'); + if ($state['insert_zero']) return 0; + $id = (int) $state['next_post_id']++; + $postarr['ID'] = $id; + $state['posts'][$id] = new WP_Post($postarr + ['post_type' => 'post', 'post_status' => 'draft']); + return $id; + } + function wp_update_post(array $postarr, $wp_error = false) { + $state = &$GLOBALS['front_pending_state']; + $id = (int) ($postarr['ID'] ?? 0); + if ($id <= 0 || !isset($state['posts'][$id])) return new WP_Error('update_missing', 'post missing'); + if ($state['fail_update']) return new WP_Error('update_failed', 'update failed'); + if ($state['update_zero']) return 0; + $post = $state['posts'][$id]; + foreach ($postarr as $key => $value) { + if (property_exists($post, (string) $key)) { + $post->{$key} = in_array($key, ['ID', 'post_author', 'post_parent'], true) ? (int) $value : (string) $value; + } + } + return $id; + } + function aksrt_get_pay_meta($post_id): array { + return $GLOBALS['front_pending_state']['pay_meta'][(int) $post_id] ?? []; + } + function attachment_url_to_postid($url): int { + return (int) ($GLOBALS['front_pending_state']['url_map'][(string) $url] ?? 0); + } + function wp_get_upload_dir(): array { return ['baseurl' => 'https://example.test/uploads']; } + function set_url_scheme($url, $scheme = null): string { + $url = (string) $url; + return preg_match('#^https?://#i', $url) ? $url : 'https://' . ltrim($url, '/'); + } + function wp_parse_url($url, $component = -1) { return parse_url((string) $url, $component); } + function wp_get_attachment_url($attachment_id): string { + foreach ($GLOBALS['front_pending_state']['url_map'] as $url => $id) { + if ((int) $id === (int) $attachment_id) return (string) $url; + } + return 'https://example.test/uploads/media-' . (int) $attachment_id . '.bin'; + } + function wp_get_attachment_image_url($attachment_id, $size = 'thumbnail') { return wp_get_attachment_url($attachment_id); } + function wp_prepare_attachment_for_js($attachment_id) { + if (in_array((int) $attachment_id, $GLOBALS['front_pending_state']['failed_js_ids'], true)) return false; + $post = get_post((int) $attachment_id); + if (!$post instanceof WP_Post) return false; + $mime = $post->post_mime_type; + [$type, $subtype] = array_pad(explode('/', $mime, 2), 2, ''); + $url = wp_get_attachment_url((int) $attachment_id); + return [ + 'id' => (int) $attachment_id, + 'title' => $post->post_title, + 'name' => basename($url), + 'filename' => basename($url), + 'url' => $url, + 'type' => $type, + 'subtype' => $subtype, + 'mime' => $mime, + 'sizes' => [], + 'filesizeInBytes' => 1, + ]; + } + function get_the_title($post_id): string { return (string) (get_post((int) $post_id)->post_title ?? ''); } + function get_attached_file($attachment_id): string { return sys_get_temp_dir() . '/front-submit-' . (int) $attachment_id; } + function wp_basename($path): string { return basename((string) $path); } + function media_handle_upload($file_id, $post_id, $post_data = [], $overrides = []) { + $state = &$GLOBALS['front_pending_state']; + $id = (int) $state['next_attachment_id']++; + $file = $_FILES[$file_id] ?? []; + $state['posts'][$id] = new WP_Post([ + 'ID' => $id, + 'post_type' => 'attachment', + 'post_author' => 0, + 'post_status' => 'inherit', + 'post_parent' => 0, + 'post_title' => (string) ($file['name'] ?? 'upload'), + 'post_mime_type' => 'application/pdf', + ]); + $url = 'https://example.test/uploads/upload-' . $id . '.pdf'; + $state['url_map'][$url] = $id; + $state['upload_calls'][] = ['id' => $id, 'overrides' => $overrides]; + return $id; + } + function wp_delete_attachment($attachment_id, $force_delete = false) { + $state = &$GLOBALS['front_pending_state']; + $id = (int) $attachment_id; + $state['deleted'][] = $id; + unset($state['posts'][$id], $state['post_meta'][$id]); + return (object) ['ID' => $id]; + } + + fp_reset(); + require_once dirname(__DIR__, 2) . '/src/Attachment/PendingUploads.php'; + require_once dirname(__DIR__, 2) . '/src/Core/FrontSubmitSave.php'; + require_once dirname(__DIR__, 2) . '/src/Core/FrontSubmitPay.php'; + require_once dirname(__DIR__, 2) . '/src/Core/FrontSubmitFeatured.php'; + require_once dirname(__DIR__, 2) . '/src/Core/FrontSubmitMedia.php'; + require_once dirname(__DIR__, 2) . '/src/Core/FrontSubmitChunk.php'; + require_once dirname(__DIR__, 2) . '/src/Core/FrontSubmit.php'; +} + +namespace AKSRT\Core { + final class DomainModel { + public const PAY_META_KEY = '_aksrt_pay'; + public const POST_META_KEY = 'aksrt_post_meta'; + } + + final class FrontSubmitPendingProbe { + use FrontSubmitSave; + use FrontSubmitMedia; + + public function settings(): array { + return [ + 'enabled' => true, + 'default_user_id' => 1, + 'title_min' => 3, + 'title_max' => 100, + 'allowed_cat_ids' => [], + 'allow_tags' => false, + 'custom_filter_s' => false, + ]; + } + + public function can_submit(?int $user_id = null): bool { return true; } + + public function can_edit_post(int $post_id, ?int $user_id = null): bool { + $post = \get_post($post_id); + return $post instanceof \WP_Post && (int) $post->post_author === (int) ($user_id ?? \get_current_user_id()); + } + + public function user_can_cap(string $cap, int $user_id = 0, ?int $context = null): bool { + return $cap !== 'new_post_audit_no'; + } + + private function save_pay_meta(int $post_id, array $data, int $user_id, array $settings): array { + if (isset($data['fake_saved_pay_meta']) && is_array($data['fake_saved_pay_meta'])) { + $GLOBALS['front_pending_state']['pay_meta'][$post_id] = $data['fake_saved_pay_meta']; + \update_post_meta($post_id, DomainModel::PAY_META_KEY, $data['fake_saved_pay_meta']); + } + if (!empty($data['fake_pay_failure'])) { + return ['success' => false, 'message' => 'pay validation failed']; + } + return ['success' => true]; + } + + private function save_featured_meta(int $post_id, array $data, int $user_id): void { + $GLOBALS['front_pending_state']['featured_save_calls']++; + $meta = $data['fake_featured_meta'] ?? null; + if (is_array($meta)) \update_post_meta($post_id, DomainModel::POST_META_KEY, $meta); + $thumbnail = (int) ($data['fake_thumbnail_id'] ?? 0); + if ($thumbnail > 0) \set_post_thumbnail($post_id, $thumbnail); + } + } +} + +namespace AKSRT\User { + final class Restriction { + private static ?self $instance = null; + public static function get_instance(): self { return self::$instance ??= new self(); } + public function is_suspended(int $user_id): bool { return false; } + public function get_active_suspend(int $user_id): array { return []; } + } +} + +namespace { + use AKSRT\Attachment\PendingUploads; + use AKSRT\Core\FrontSubmit; + use AKSRT\Core\FrontSubmitPendingProbe; + use AKSRT\Core\DomainModel; + + $submit = new FrontSubmitPendingProbe(); + + // Successful single-file and sideload persistence both pass through the real + // FrontSubmit normalizer and mark the resulting attachment as pending. + fp_reset(); + $tmp = tempnam(sys_get_temp_dir(), 'front-submit-pending-'); + file_put_contents($tmp, 'pdf test'); + $real_submit = new FrontSubmit(); + foreach ([false, true] as $sideload) { + $response = $real_submit->persist_uploaded_file('resource', [ + 'name' => 'document.pdf', 'tmp_name' => $tmp, 'size' => filesize($tmp), 'error' => UPLOAD_ERR_OK, + ], $sideload); + fp_assert($response instanceof \WP_REST_Response && $response->get_status() === 201, 'real upload persistence should return a created response'); + $id = (int) ($response->get_data()['attachment_id'] ?? 0); + fp_assert($id > 0 && fp_pending($id), 'each normalized upload must be marked pending'); + fp_assert(($GLOBALS['front_pending_state']['post_meta'][$id]['_aksrt_front_submit_media'] ?? '') === 'resource', 'upload should retain the normalized media type'); + } + fp_assert(count($GLOBALS['front_pending_state']['upload_calls']) === 2, 'single and sideload calls should each persist once'); + fp_assert(empty($GLOBALS['front_pending_state']['upload_calls'][0]['overrides']['action']), 'ordinary upload should use the upload action'); + fp_assert(($GLOBALS['front_pending_state']['upload_calls'][1]['overrides']['action'] ?? '') === 'wp_handle_sideload', 'chunk merge sideload should use the sideload action'); + $GLOBALS['front_pending_state']['failed_js_ids'][] = 2002; + $un_normalized = $real_submit->persist_uploaded_file('resource', [ + 'name' => 'unreadable.pdf', 'tmp_name' => $tmp, 'size' => filesize($tmp), 'error' => UPLOAD_ERR_OK, + ]); + fp_assert(is_wp_error($un_normalized), 'upload normalization failure should return an error'); + $premature_marks = array_filter($GLOBALS['front_pending_state']['meta_writes'], static fn (array $row): bool => $row['id'] === 2002 && $row['key'] === PendingUploads::META); + fp_assert($premature_marks === [], 'upload must not be marked pending before response normalization succeeds'); + @unlink($tmp); + + // Draft save covers all persisted reference sources, including more than the + // default twenty URLs in both saved pay metadata and post_content. + fp_reset(); + $id_refs = [100, 101, 102, 103, 104, 105, 106, 107, 108]; + foreach ($id_refs as $id) { + $mime = in_array($id, [101, 104], true) ? 'video/mp4' : ($id === 102 ? 'application/pdf' : 'image/jpeg'); + fp_attachment($id, 7, $mime); + } + $pay_url_ids = []; + $pay_downloads = [ + ['attachment_id' => 107, 'url' => ''], + ['attachment_id' => 108, 'url' => ''], + ]; + for ($i = 0; $i < 23; $i++) { + $id = 200 + $i; + $url = 'https://example.test/uploads/pay-url-' . $i . '.pdf'; + fp_attachment($id, 7, 'application/pdf', 0, 'inherit', true, null); + $GLOBALS['front_pending_state']['url_map'][$url] = $id; + $pay_downloads[] = ['attachment_id' => 0, 'url' => $url]; + $pay_url_ids[] = $id; + } + $body_url_ids = []; + $body_urls = []; + for ($i = 0; $i < 23; $i++) { + $id = 300 + $i; + $url = 'https://example.test/uploads/body-url-' . $i . '.jpg'; + fp_attachment($id, 7, 'image/jpeg', 0, 'inherit', true, null); + $GLOBALS['front_pending_state']['url_map'][$url] = $id; + $body_urls[] = $url; + $body_url_ids[] = $id; + } + $unreferenced = 399; + fp_attachment($unreferenced, 7, 'image/jpeg'); + $content = 'Draft body with enough text for a save. ' + . '' + . '' + . 'resource' + . implode(' ', $body_urls); + $result = $submit->persist(fp_article_data($content, [ + 'fake_saved_pay_meta' => ['downloads' => $pay_downloads], + 'fake_featured_meta' => [ + 'featured_video_attachment_id' => 104, + 'featured_slide' => '105,106', + ], + 'fake_thumbnail_id' => 103, + ]), 'posts_draft'); + fp_assert(!empty($result['success']), 'draft with persisted references should save'); + $draft_id = (int) ($result['post_id'] ?? 0); + fp_assert(($GLOBALS['front_pending_state']['posts'][$draft_id]->post_status ?? '') === 'draft', 'draft status should be preserved'); + $claimed_ids = array_merge($id_refs, $pay_url_ids, $body_url_ids); + foreach ($claimed_ids as $id) fp_assert(!fp_pending($id), 'saved draft reference should be claimed: ' . $id); + foreach ([100, 101, 102, 103, 104, 105, 106, 107, 108] as $id) { + fp_assert(($GLOBALS['front_pending_state']['posts'][$id]->post_parent ?? -1) === $draft_id, 'ID-backed reference should bind to the saved draft: ' . $id); + } + fp_assert(fp_pending($unreferenced), 'newly uploaded but unreferenced media should remain pending'); + + // Pending submissions also synchronize after a successful post write. + fp_reset(); + fp_attachment(410, 7, 'image/jpeg'); + $pending = $submit->persist(fp_article_data('

Pending body has sufficient words.

')); + fp_assert(!empty($pending['success']), 'pending post should save'); + $pending_id = (int) $pending['post_id']; + fp_assert(($GLOBALS['front_pending_state']['posts'][$pending_id]->post_status ?? '') === 'pending', 'normal submit should retain pending review status'); + fp_assert(!fp_pending(410), 'pending-review post body image should be claimed'); + + // Edit path reuses the production persist and synchronization implementation. + fp_reset(); + $edit_post = fp_post(420, 'post', 7, 'publish', 'Old content'); + fp_attachment(421, 7, 'video/mp4'); + $edited = $submit->persist(fp_article_data('

Edited body has enough content.

', ['posts_id' => 420])); + fp_assert(!empty($edited['success']) && (int) $edited['post_id'] === 420, 'owned post should edit successfully'); + fp_assert($edit_post->post_content !== 'Old content', 'edit should write the new body before synchronization'); + fp_assert(!fp_pending(421) && $GLOBALS['front_pending_state']['posts'][421]->post_parent === 420, 'edit body media should be claimed and attached'); + + // Existing media is reused without creating a new pending marker. + fp_reset(); + $reuse_post = fp_post(430, 'post', 7, 'draft', ''); + fp_attachment(431, 7, 'image/jpeg', 430, 'inherit', false, 'image'); + $reuse_writes_before = count(array_filter($GLOBALS['front_pending_state']['meta_writes'], static fn (array $row): bool => $row['id'] === 431 && $row['key'] === PendingUploads::META)); + $reuse = $submit->persist(fp_article_data('

Reused attachment body text.

', ['posts_id' => 430]), 'posts_draft'); + fp_assert(!empty($reuse['success']), 'existing media reuse should save'); + $reuse_writes_after = count(array_filter($GLOBALS['front_pending_state']['meta_writes'], static fn (array $row): bool => $row['id'] === 431 && $row['key'] === PendingUploads::META)); + fp_assert($reuse_writes_before === $reuse_writes_after && !fp_pending(431), 'reused media must not be marked pending again'); + fp_assert($GLOBALS['front_pending_state']['posts'][431]->post_parent === 430, 'reused media should remain attached to its post'); + + // Removing an edit reference unbinds only; it neither deletes nor re-pends. + fp_reset(); + fp_post(440, 'post', 7, 'draft', ''); + fp_attachment(441, 7, 'image/jpeg', 440, 'inherit', false, 'image'); + $removed = $submit->persist(fp_article_data('

Reference removed on edit.

', ['posts_id' => 440]), 'posts_draft'); + fp_assert(!empty($removed['success']), 'edit removing a media reference should save'); + fp_assert(isset($GLOBALS['front_pending_state']['posts'][441]), 'unreferenced attached media must not be deleted'); + fp_assert($GLOBALS['front_pending_state']['posts'][441]->post_parent === 0, 'removed media should be detached from the edited post'); + fp_assert(get_post_meta(441, '_aksrt_front_submit_media', true) === '', 'removed media should lose only its submit relation marker'); + fp_assert(!fp_pending(441) && $GLOBALS['front_pending_state']['deleted'] === [], 'removed media must not be deleted or marked pending again'); + + // A payment validation error happens after the body write. The saved body is + // protected, but the request's featured values were never persisted and must + // therefore not be considered references. + fp_reset(); + fp_attachment(450, 7, 'image/jpeg'); + fp_attachment(451, 7, 'image/jpeg'); + $pay_error = $submit->persist(fp_article_data('

Body remains persisted after pay error.

', [ + 'fake_pay_failure' => true, + 'fake_featured_meta' => ['featured_video_attachment_id' => 0, 'featured_slide' => '451'], + 'fake_thumbnail_id' => 451, + ])); + fp_assert(empty($pay_error['success']) && (int) ($pay_error['post_id'] ?? 0) > 0, 'pay validation should return its error after the post was written'); + fp_assert(!fp_pending(450), 'persisted body reference should be claimed even when payment validation fails'); + fp_assert(fp_pending(451), 'unsaved featured media from failed request must not be claimed'); + fp_assert($GLOBALS['front_pending_state']['featured_save_calls'] === 0, 'featured metadata must not be saved after payment failure'); + + // Failed insert/update writes cannot claim anything because no saved post is + // synchronized. Test both the create and edit branches. + fp_reset(); + fp_attachment(460, 7, 'image/jpeg'); + $GLOBALS['front_pending_state']['fail_insert'] = true; + $insert_error = $submit->persist(fp_article_data('

Insert error body is never saved.

')); + fp_assert(empty($insert_error['success']) && fp_pending(460), 'insert error must leave referenced media pending'); + + fp_reset(); + fp_post(470, 'post', 7, 'draft', 'Existing post'); + fp_attachment(471, 7, 'image/jpeg'); + $GLOBALS['front_pending_state']['fail_update'] = true; + $update_error = $submit->persist(fp_article_data('

Update error body is never saved.

', ['posts_id' => 470]), 'posts_draft'); + fp_assert(empty($update_error['success']) && fp_pending(471), 'update error must leave referenced media pending'); + + // Invalid references (foreign owner, invalid MIME/status/type, conflicting + // parent, missing ID) are rejected by the real media attachment checks. + fp_reset(); + fp_attachment(480, 8, 'image/jpeg'); + fp_attachment(481, 7, 'application/pdf'); + fp_attachment(482, 7, 'image/jpeg', 0, 'trash'); + fp_attachment(483, 7, 'image/jpeg', 999, 'inherit'); + fp_attachment(484, 7, 'video/mp4', 0, 'inherit', true, null); + fp_post(485, 'post', 7, 'inherit', '', 0, 'image/jpeg'); + PendingUploads::mark(485); + $invalid_content = '

Invalid media references must not be claimed.

' + . '' + . '' + . ''; + $invalid = $submit->persist(fp_article_data($invalid_content)); + fp_assert(!empty($invalid['success']), 'post containing invalid media markers should still save'); + foreach ([480, 481, 482, 483, 484, 485] as $id) fp_assert(fp_pending($id), 'invalid attachment must remain pending: ' . $id); + fp_assert($GLOBALS['front_pending_state']['posts'][483]->post_parent === 999, 'attachment already parented elsewhere must not be reassigned'); + + // A relation update failure cannot expose already-saved references to TTL + // cleanup: claiming still runs after association attempts. + fp_reset(); + fp_attachment(490, 7, 'image/jpeg'); + $GLOBALS['front_pending_state']['fail_relation_updates'] = true; + $association_error = $submit->persist(fp_article_data('

Saved content survives relation failure.

')); + fp_assert(!empty($association_error['success']), 'database relation failure should not change the saved post result'); + fp_assert(!fp_pending(490), 'saved body attachment must be claimed even when parent association fails'); + fp_assert($GLOBALS['front_pending_state']['posts'][490]->post_parent === 0, 'injected relation failure should leave parent unchanged'); + + // The existing owner and post-type guards remain in force for direct syncs. + fp_reset(); + fp_attachment(500, 7, 'image/jpeg'); + $foreign_post = new \WP_Post(['ID' => 501, 'post_type' => 'post', 'post_author' => 8, 'post_status' => 'draft', 'post_content' => '']); + $submit->sync_post_attachments($foreign_post); + $submit->sync_post_attachments(new \WP_Post(['ID' => 502, 'post_type' => 'page', 'post_author' => 7, 'post_status' => 'draft', 'post_content' => ''])); + fp_assert(fp_pending(500) && $GLOBALS['front_pending_state']['posts'][500]->post_parent === 0, 'foreign-author and non-post sync calls must not claim or attach media'); + + echo "Front-submit pending behavior harness passed\n"; +} diff --git a/tests/widget-system/pending-upload-contract.test.cjs b/tests/widget-system/pending-upload-contract.test.cjs index 8a7fd3e2..59ffef7b 100644 --- a/tests/widget-system/pending-upload-contract.test.cjs +++ b/tests/widget-system/pending-upload-contract.test.cjs @@ -2,9 +2,20 @@ const test = require('node:test'); const assert = require('node:assert/strict'); const fs = require('node:fs'); const path = require('node:path'); +const { spawnSync } = require('node:child_process'); const root = path.resolve(__dirname, '..', '..'); const read = (rel) => fs.readFileSync(path.join(root, rel), 'utf8'); +let behavior; +const harness = () => { + if (!behavior) { + const run = spawnSync('php', [path.join(__dirname, 'pending-upload-harness.php')], { encoding: 'utf8' }); + assert.equal(run.status, 0, `${run.stderr}${run.stdout}`); + assert.equal(run.stderr, ''); + behavior = JSON.parse(run.stdout); + } + return behavior; +}; test('upload marks pending and returns attachment id', () => { const controller = read('src/Admin/UserController.php'); @@ -40,3 +51,60 @@ test('front-end remove buttons discard the pending upload', () => { assert.match(core, /data-aksrt-ticket-image-id/); assert.match(report, /'\/upload\/pending\/'/); }); + +test('front submission uploads are marked and saved references are claimed without close-time deletion', () => { + const upload = read('src/Core/FrontSubmit.php'); + const media = read('src/Core/FrontSubmitMedia.php'); + const editor = read('assets/js/front-submit-editor.js'); + assert.match(upload, /PendingUploads::mark\(\$attachment_id\)/); + assert.match(media, /PendingUploads::claim_ids\(array_keys\(\$desired\), \$user_id\)/); + assert.match(media, /PendingUploads::claim_urls\(\$download_urls, \$user_id, null\)/); + assert.match(media, /PendingUploads::claim_text\(\(string\) \$post->post_content, \$user_id, null\)/); + assert.doesNotMatch(editor, /\/upload\/pending\//); +}); + +test('cleanup is scheduled once hourly and discard requires a logged-in user', () => { + const state = harness().registration; + assert.ok(state.hooks.includes('aksrt_pending_upload_cleanup')); + assert.ok(state.hooks.includes('init')); + assert.equal(state.schedule_count, 1); + assert.equal(state.recurrence, 'hourly'); + assert.equal(state.namespace, 'aksrt/v1'); + assert.equal(state.method, 'DELETE'); + assert.equal(state.member_allowed, true); + assert.equal(state.guest_allowed, false); +}); + +test('ID claims handle more than 20 attachments but only clear the owner’s pending marker', () => { + assert.deepEqual(harness().claim_ids, { remaining: [26], marked_recently: true, invalid_marked: false }); +}); + +test('URL and text claims retain their default bound while saved content may claim all references', () => { + assert.deepEqual(harness().claim_urls, { default_remaining: [21, 22, 23, 24, 25], unlimited_remaining: [] }); + assert.deepEqual(harness().claim_text, { default_remaining: [21, 22, 23, 24, 25, 26, 27], unlimited_remaining: [26, 27] }); +}); + +test('discard refuses foreign, claimed and parented media and permanently deletes only an owned pending upload', () => { + assert.deepEqual(harness().discard, { denied: [false, false, false, false], success: true, repeat_status: 404, deleted: [[1, true]] }); +}); + +test('cleanup deletes only expired unattached pending uploads and uses prepared bounded SQL', () => { + const state = harness().cleanup; + assert.deepEqual(state.deleted, [[1, true]]); + assert.deepEqual(state.remaining, [2, 3, 4]); + assert.match(state.query.sql, /p\.post_type = 'attachment' AND p\.post_parent = 0/); + assert.match(state.query.sql, /m\.meta_key = %s/); + assert.match(state.query.sql, /LIMIT %d/); + assert.equal(state.query.params[0], '_aksrt_pending_upload'); + assert.equal(state.query.params[2], 100); + assert.ok(Math.abs(state.query.params[1] - (Math.floor(Date.now() / 1000) - 86400)) < 10); +}); + +test('cleanup rechecks selected candidates after claim, attach, refresh or removal', () => { + assert.deepEqual(harness().stale_selection.deleted, [[5, true]]); +}); + +test('cleanup processes a bounded batch and retries unsuccessful deletions later', () => { + assert.deepEqual(harness().batch, { first: 100, total: 105, remaining: 0 }); + assert.deepEqual(harness().retry, { retained: true, deleted_after_retry: true }); +}); diff --git a/tests/widget-system/pending-upload-harness.php b/tests/widget-system/pending-upload-harness.php new file mode 100644 index 00000000..f7b017b3 --- /dev/null +++ b/tests/widget-system/pending-upload-harness.php @@ -0,0 +1,211 @@ +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 => '', 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); diff --git a/tests/widget-system/report-ban-contract.test.cjs b/tests/widget-system/report-ban-contract.test.cjs index 45b44d14..ddd4c234 100644 --- a/tests/widget-system/report-ban-contract.test.cjs +++ b/tests/widget-system/report-ban-contract.test.cjs @@ -331,7 +331,7 @@ test('native ban page mirrors the reference theme layout', () => { const tickets = read('src/Admin/Native/UsersMenusTickets.php') // 菜单与页面标题对齐参考主题(page title=处理举报及禁封申诉,menu title=举报&禁封申诉) - assert.ok(usersMenus.includes(`__('处理举报及禁封申诉', 'aksrt'),\n __('举报&禁封申诉', 'aksrt')`), 'submenu titles should match the reference theme') + assert.match(usersMenus, /__\('处理举报及禁封申诉', 'aksrt'\),\s*__\('举报&禁封申诉', 'aksrt'\)/, 'submenu titles should match the reference theme') assert.ok(usersMenus.includes('用户禁封申诉及用户举报管理'), 'page heading should match the reference theme') // 类型筛选按钮组 + 状态 subsubsub + 搜索