Files
wordpress-theme-aksrt/tests/widget-system/pending-upload-contract.test.cjs
T
kate522andClaude Fable 5 32ac2112b0 fix(uploads): reclaim abandoned front-submit attachments
Mark new regular and chunked uploads as pending and claim persisted article references, including partial saves and large reference sets. Protect associated media from cleanup and add upload lifecycle regression coverage.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-10-30 21:57:46 +08:00

111 lines
5.3 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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');
assert.match(controller, /PendingUploads::mark\(\(int\) \$attachment_id\)/);
assert.match(controller, /'id'\s*=> \(int\) \$attachment_id/);
});
test('pending lifecycle: discard route, owner check, claim, cron cleanup', () => {
const svc = read('src/Attachment/PendingUploads.php');
assert.match(svc, /'\/upload\/pending\/\(\?P<id>\\d\+\)'/);
assert.match(svc, /'methods'\s*=> 'DELETE'/);
assert.match(svc, /'permission_callback'/);
assert.match(svc, /post_author === \$user_id/);
assert.match(svc, /wp_schedule_event\(time\(\) \+ HOUR_IN_SECONDS, 'hourly', self::CRON_HOOK\)/);
assert.match(svc, /\$wpdb->prepare\(/);
assert.match(svc, /add_action\('comment_post', \[\$this, 'claim_comment'\]/);
assert.match(read('functions.php'), /PendingUploads::get_instance\(\)->init\(\)/);
});
test('submit paths claim referenced uploads', () => {
const rest = read('src/Ticket/TicketRest.php');
const admin = read('src/Ticket/TicketRestAdmin.php');
const report = read('src/Admin/UserControllerReportOperations.php');
assert.equal((rest.match(/PendingUploads::claim_urls\(\$attachment/g) || []).length, 2);
assert.equal((admin.match(/PendingUploads::claim_urls\(\$attachment/g) || []).length, 2);
assert.match(report, /PendingUploads::claim_text\(\$reason, \$reporter_id\)/);
});
test('front-end remove buttons discard the pending upload', () => {
const core = read('assets/js/ticket-center-core.js');
const report = read('assets/js/report.js');
assert.match(core, /'\/upload\/pending\/' \+ id/);
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 });
});