prints a JSON result object. */ namespace { define('ARRAY_A', 'ARRAY_A'); final class WP_Error { public function __construct(private string $code, private string $message, private array $data = []) {} public function get_error_code(): string { return $this->code; } public function get_error_message(): string { return $this->message; } public function get_error_data(): array { return $this->data; } } define('ABSPATH', __DIR__ . '/'); // ---------- WP function stubs ---------- function __($s, $d = null) { return (string) $s; } function esc_html($s) { return htmlspecialchars((string) $s, ENT_QUOTES); } function esc_url_raw($s) { return (string) $s; } function sanitize_key($s) { return preg_replace('/[^a-z0-9_\-]/', '', strtolower((string) $s)) ?? ''; } function sanitize_text_field($s) { return trim(strip_tags((string) $s)); } function sanitize_textarea_field($s) { return trim(strip_tags((string) $s)); } function wp_json_encode($v, $f = 0) { return (string) json_encode($v, $f); } function current_user_can($c) { return true; } // Monotonic clock: every append must land on a strictly later timestamp so the // chronological ordering assertion is meaningful. $GLOBALS['aksrt_log_now'] = strtotime('2026-10-01 10:00:00'); function current_time($type = 'mysql', $gmt = false) { $GLOBALS['aksrt_log_now'] += 1; return gmdate('Y-m-d H:i:s', $GLOBALS['aksrt_log_now']); } // ---------- In-memory $wpdb ---------- final class FakeWpdb { public string $prefix = 'wp_'; /** @var array> keyed by after-sale id */ public array $rows = []; public int $insert_id = 0; private int $next_id = 100; public function prepare(string $sql, ...$args) { if (count($args) === 1 && is_array($args[0])) $args = $args[0]; $i = 0; return preg_replace_callback('/%[sd]/', static function ($m) use (&$i, $args): string { if ($i >= count($args)) return $m[0]; $value = $args[$i++]; return $m[0][1] === 'd' ? (string) (int) $value : "'" . str_replace("'", "''", (string) $value) . "'"; }, $sql) ?? $sql; } public function get_var(string $sql) { if (preg_match("/GET_LOCK\('/", $sql)) return 1; if (preg_match("/RELEASE_LOCK\('/", $sql)) return 1; if (str_starts_with($sql, 'SELECT COUNT(*)')) return count($this->rows); throw new RuntimeException('unsupported get_var: ' . $sql); } public function get_row(string $sql, $output = null) { if (preg_match("/WHERE id = (\d+)$/", $sql, $m)) return $this->rows[(int) $m[1]] ?? null; if (preg_match("/WHERE order_number = '([^']*)'/", $sql, $m)) { $statuses = preg_match("/status IN \(([^)]*)\)/", $sql, $s) ? explode(',', str_replace("'", '', $s[1])) : null; $found = null; foreach ($this->rows as $row) { if ((string) ($row['order_number'] ?? '') !== $m[1]) continue; if ($statuses !== null && !in_array((string) ($row['status'] ?? ''), array_map('trim', $statuses), true)) continue; if ($found === null || (int) $row['id'] > (int) $found['id']) $found = $row; } return $found; } throw new RuntimeException('unsupported get_row: ' . $sql); } public function get_results(string $sql, $output = null) { if (preg_match("/WHERE order_number = '([^']*)'/", $sql, $m)) { $rows = array_values(array_filter($this->rows, static fn($row): bool => (string) ($row['order_number'] ?? '') === $m[1])); usort($rows, static fn($a, $b): int => (int) $b['id'] <=> (int) $a['id']); return $rows; } throw new RuntimeException('unsupported get_results: ' . $sql); } public function insert(string $table, array $data, array $formats = []) { $id = $this->next_id++; $this->rows[$id] = $data + ['id' => $id]; $this->insert_id = $id; return 1; } public function update(string $table, array $data, array $where, array $formats = [], array $where_formats = []) { $id = (int) ($where['id'] ?? 0); if (!isset($this->rows[$id])) return false; $this->rows[$id] = array_merge($this->rows[$id], $data); return 1; } } // ---------- Domain stubs the service collaborates with ---------- require __DIR__ . '/../../src/Ledger/AssetType.php'; require __DIR__ . '/../../src/Ledger/AssetAmount.php'; } namespace AKSRT\Core { final class DBMigrator { public static function table(string $name): string { return 'wp_aksrt_' . $name; } } } namespace AKSRT\Payment { final class Order { public const STATUS_PAID = 'paid'; } final class OrderRepository { private static $i; /** @var array> keyed by order number */ public static array $orders = []; public static function get_instance(): self { return self::$i ?: self::$i = new self(); } public function get_user_order(int $user_id, string $order_number): ?array { return self::$orders[$order_number] ?? null; } } require __DIR__ . '/../../src/Payment/AfterSaleService.php'; } namespace { $GLOBALS['aksrt_wpdb'] = new FakeWpdb(); // The service reaches for the real global $wpdb, so the stand-in has to live there. $GLOBALS['wpdb'] = $GLOBALS['aksrt_wpdb']; /** @return list */ function run(): array { $wpdb = $GLOBALS['aksrt_wpdb']; $service = \AKSRT\Payment\AfterSaleService::get_instance(); $order = ['order_number' => '20261001100000000001', 'user_id' => 7, 'status' => 'paid', 'currency' => 'CNY', 'amount' => '12.50']; \AKSRT\Payment\OrderRepository::$orders = [$order['order_number'] => $order]; // 1) create() seeds the log with the user's own submission. $wpdb->rows = []; $created = $service->create(7, $order['order_number'], '商品与描述不符'); if ($created instanceof \WP_Error) return ['error' => 'create failed: ' . $created->get_error_message()]; $log = $created['handling_log']; $results = []; $results[] = ['name' => 'create_seeds_single_user_entry', 'ok' => count($log) === 1 && $log[0]['status'] === 'pending' && $log[0]['source'] === 'user' && $log[0]['note'] === '' && $log[0]['created_at'] === $created['created_at'], 'detail' => $log]; // 2) Every admin submission appends, even with an empty note; order is chronological. $service->set_status((int) $created['id'], 'processing', '已联系仓库核实'); $service->set_status((int) $created['id'], 'completed', '已原路退款'); $service->set_status((int) $created['id'], 'rejected', ''); $log = $service->get((int) $created['id'])['handling_log']; $results[] = ['name' => 'status_changes_append_even_without_note', 'ok' => count($log) === 4 && $log[1] === ['status' => 'processing', 'note' => '已联系仓库核实', 'source' => 'admin', 'created_at' => $log[1]['created_at']] && $log[2]['status'] === 'completed' && $log[2]['note'] === '已原路退款' && $log[3]['status'] === 'rejected' && $log[3]['note'] === '' && $log[0]['created_at'] < $log[1]['created_at'] && $log[1]['created_at'] < $log[2]['created_at'] && $log[2]['created_at'] < $log[3]['created_at'], 'detail' => $log]; // 3) A user-side cancel is logged as a user entry. $wpdb->rows = []; $second = $service->create(7, $order['order_number'], '拍错了'); $service->cancel_for_user(7, $order['order_number']); $log = $service->get((int) $second['id'])['handling_log']; $last = end($log); $results[] = ['name' => 'cancel_appends_user_entry', 'ok' => count($log) === 2 && $last['status'] === 'cancelled' && $last['source'] === 'user' && $last['note'] === '', 'detail' => $log]; // 4) The log is capped, keeping the most recent entries. $wpdb->rows = []; $seeded = []; for ($i = 1; $i <= 50; $i++) { $seeded[] = ['status' => 'processing', 'note' => 'seed-' . $i, 'source' => 'admin', 'created_at' => sprintf('2026-10-01 10:%02d:00', $i)]; } $wpdb->rows[900] = ['id' => 900, 'order_number' => $order['order_number'], 'user_id' => 7, 'status' => 'processing', 'handling_log' => wp_json_encode($seeded), 'created_at' => '2026-10-01 10:01:00']; $service->set_status(900, 'completed', '第五十条'); $log = $service->get(900)['handling_log']; $results[] = ['name' => 'log_is_capped_at_50', 'ok' => count($log) === 50 && $log[0]['note'] === 'seed-2' && end($log)['note'] === '第五十条', 'detail' => ['count' => count($log), 'first' => $log[0]['note'], 'last' => end($log)['note']]]; // 5) Malformed rows normalize instead of fataling. $wpdb->rows[901] = ['id' => 901, 'order_number' => $order['order_number'], 'user_id' => 7, 'status' => 'processing', 'handling_log' => wp_json_encode([ ['status' => 'pending', 'note' => 'ok', 'source' => 'user', 'created_at' => '2026-10-01 10:00:00'], ['status' => '', 'note' => 'no status', 'source' => 'admin', 'created_at' => '2026-10-01 10:05:00'], ['note' => 'not an array value', 'source' => 'admin', 'created_at' => '2026-10-01 10:06:00'], 'garbage', ['status' => 'completed', 'source' => 'mystery', 'created_at' => '2026-10-01 10:07:00'], ])]; $wpdb->rows[902] = ['id' => 902, 'order_number' => $order['order_number'], 'user_id' => 7, 'status' => 'pending', 'handling_log' => 'not-json']; $normalized = $service->get(901)['handling_log']; $broken = $service->get(902)['handling_log']; $results[] = ['name' => 'normalize_drops_malformed_entries', 'ok' => count($normalized) === 2 && $normalized[0]['status'] === 'pending' && $normalized[1] === ['status' => 'completed', 'note' => '', 'source' => 'admin', 'created_at' => '2026-10-01 10:07:00'] && $broken === [], 'detail' => ['normalized' => $normalized, 'broken_json' => $broken]]; // 6) 再次申请的闸门只看「进行中」的工单:completed / rejected / cancelled 都放行, // 订单有没有另标已退款不影响这条(订单已退款时 create() 的 STATUS_PAID 校验先挡)。 $gate = static function (string $status) use ($service, $wpdb, $order) { $wpdb->rows = []; $wpdb->rows[950] = ['id' => 950, 'order_number' => $order['order_number'], 'user_id' => 7, 'status' => $status, 'reason' => '旧申请', 'handling_log' => wp_json_encode([['status' => $status, 'note' => '', 'source' => 'admin', 'created_at' => '2026-10-01 10:00:00']]), 'created_at' => '2026-10-01 10:00:00']; return $service->create(7, $order['order_number'], '新的申请原因'); }; $completed = $gate('completed'); $rejected = $gate('rejected'); $cancelled = $gate('cancelled'); $results[] = ['name' => 'settled_ticket_still_allows_reapply', 'ok' => !$completed instanceof \WP_Error && !$rejected instanceof \WP_Error && !$cancelled instanceof \WP_Error, 'detail' => [ 'completed' => $completed instanceof \WP_Error ? $completed->get_error_code() : 'created', 'rejected' => $rejected instanceof \WP_Error ? $rejected->get_error_code() : 'created', 'cancelled' => $cancelled instanceof \WP_Error ? $cancelled->get_error_code() : 'created', ]]; // 待审核:同人同因幂等返回原工单,换原因则挡住 $wpdb->rows = []; $wpdb->rows[951] = ['id' => 951, 'order_number' => $order['order_number'], 'user_id' => 7, 'status' => 'pending', 'reason' => '同一个原因', 'handling_log' => '[]', 'created_at' => '2026-10-01 10:00:00']; $same = $service->create(7, $order['order_number'], '同一个原因'); $other = $service->create(7, $order['order_number'], '换了个原因'); $results[] = ['name' => 'open_ticket_blocks_duplicate_apply', 'ok' => is_array($same) && (int) $same['id'] === 951 && $other instanceof \WP_Error && $other->get_error_code() === 'aksrt_after_sale_exists', 'detail' => ['same' => is_array($same) ? (int) $same['id'] : 'error', 'other' => $other instanceof \WP_Error ? $other->get_error_code() : 'created']]; return $results; } $results = run(); $failed = array_values(array_filter($results, static fn($r): bool => empty($r['ok']))); echo json_encode(['passed' => count($results) - count($failed), 'failed' => count($failed), 'results' => $results], JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT) . "\n"; exit($failed ? 1 : 0); }