Files
wordpress-theme-aksrt/tests/widget-system/front-submit-pending-harness.php
T
kate522andClaude Opus 5 35bb96e82f refactor: rename the theme brand and every identifier from AKSRT to Jio
Full-project rename. `git grep -i aksrt` is now empty apart from the
DBMigrator 3.3.0 changelog note that documents the rename itself.

Code identifiers
- PHP namespace AKSRT\ -> Jio\ (composer PSR-4 + 378 declarations)
- global constants AKSRT_* -> JIO_* (VERSION/DIR/URI, PAY_TYPE_*, CAPTCHA_*)
- procedural functions, hooks, options, meta keys, table names,
  cookies, query vars, ajax actions and nonces: aksrt_ -> jio_
- text domain aksrt -> jio; Theme Name -> Jio
- JS globals: aksrtData/aksrtAdminData/... -> jioData/jioAdminData/...,
  window.AKSRTCaptcha & friends -> window.JioCaptcha, Aksrt* -> Jio*
- Vue components Aks*.vue -> Jio*.vue (15 files renamed + imports)
- CSS classes/data-attrs/custom properties: aksrt- -> jio-
- REST namespace aksrt/v1 -> jio/v1 (incl. payment gateway callbacks)

Storage layer
- DB tables {prefix}aksrt_* -> {prefix}jio_*; SCHEMA_VERSION 3.2.0 -> 3.3.0
  so dbDelta creates the new names on install
- widget id_base aksrt_widget_* -> jio_widget_*
- uninstall.php drop list rebuilt from the actual migrator tables
  (dropped 3 stale entries, added the 17 missing ones)

Removed
- hardcoded https://api.aksrt.com/version.json default update endpoint;
  get_endpoint() now reads the 「更新端点」 setting and do_check() no-ops
  when it is empty

Files renamed: inc/helpers/aksrt-*.php -> jio-*.php (54),
assets/admin/src/components/form/Aks*.vue -> Jio*.vue (15).
Build artifacts regenerated: assets/css/build, assets/js/runtime,
assets/admin/build.

Verified: audit-psr4-paths errors=0, audit-admin-contracts errors=0
(warnings=56, unchanged baseline), 813/813 widget-system tests,
44/44 phpunit, php -l clean across all PHP.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-11-01 20:05:04 +08:00

610 lines
29 KiB
PHP

<?php
/**
* WordPress-free behavior harness for front-submit pending-upload ownership.
* Exercises the production FrontSubmitSave, FrontSubmitMedia, FrontSubmit,
* and PendingUploads implementations against a small in-memory WP surface.
*/
declare(strict_types=1);
namespace {
if (!defined('ABSPATH')) define('ABSPATH', __DIR__ . '/');
if (!defined('DAY_IN_SECONDS')) define('DAY_IN_SECONDS', 86400);
if (!defined('HOUR_IN_SECONDS')) define('HOUR_IN_SECONDS', 3600);
class WP_Post {
public int $ID = 0;
public string $post_type = 'post';
public int $post_author = 0;
public string $post_status = 'draft';
public int $post_parent = 0;
public string $post_content = '';
public string $post_title = '';
public string $post_mime_type = '';
public function __construct(array $data = []) {
foreach ($data as $key => $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, '_jio_front_submit_media', $kind);
$GLOBALS['front_pending_state']['url_map']['https://example.test/uploads/media-' . $id . '.' . fp_ext($mime)] = $id;
if ($pending) \Jio\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, \Jio\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 jio_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 Jio\Core {
final class DomainModel {
public const PAY_META_KEY = '_jio_pay';
public const POST_META_KEY = 'jio_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 Jio\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 Jio\Attachment\PendingUploads;
use Jio\Core\FrontSubmit;
use Jio\Core\FrontSubmitPendingProbe;
use Jio\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]['_jio_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. '
. '<img data-edit-file-id="100" src="/image.jpg">'
. '<video data-edit-file-id="101" data-video-url="https://video.example.test/movie.mp4"></video>'
. '<a data-jio-file-id="102" href="/resource.pdf">resource</a>'
. 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('<p>Pending body has sufficient words.</p><img data-edit-file-id="410" src="/p.jpg">'));
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('<p>Edited body has enough content.</p><video data-video-url="https://video.example.test/edit.mp4" data-edit-file-id="421"></video>', ['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('<p>Reused attachment body text.</p><img data-edit-file-id="431" src="/reuse.jpg">', ['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('<p>Reference removed on edit.</p>', ['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, '_jio_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('<p>Body remains persisted after pay error.</p><img data-edit-file-id="450" src="/saved.jpg">', [
'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('<p>Insert error body is never saved.</p><img data-edit-file-id="460">'));
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('<p>Update error body is never saved.</p><img data-edit-file-id="471">', ['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 = '<p>Invalid media references must not be claimed.</p>'
. '<img data-edit-file-id="480"><img data-edit-file-id="481">'
. '<img data-edit-file-id="482"><img data-edit-file-id="483">'
. '<img data-edit-file-id="484"><img data-edit-file-id="485"><img data-edit-file-id="99999">';
$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('<p>Saved content survives relation failure.</p><img data-edit-file-id="490">'));
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' => '<img data-edit-file-id="500">']);
$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' => '<img data-edit-file-id="500">']));
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";
}