| Code Coverage
| Lines | Functions and Methods | Classes and Traits
Total | 28.14% covered (danger) | 28.14% | 65 / 231 | 15.38% covered (danger) | 15.38% | 2 / 13 | CRAP | 0.00% covered (danger) | 0.00% | 0 / 1
SourceCheckPage | 28.14% covered (danger) | 28.14% | 65 / 231 | 15.38% covered (danger) | 15.38% | 2 / 13 | 796.47 | 0.00% covered (danger) | 0.00% | 0 / 1
register | 100.00% covered (success) | 100.00% | 2 / 2 | 100.00% covered (success) | 100.00% | 1 / 1 | 1 |
addMenu | 0.00% covered (danger) | 0.00% | 0 / 8 | 0.00% covered (danger) | 0.00% | 0 / 1 | 2 |
render | 45.65% covered (danger) | 45.65% | 21 / 46 | 0.00% covered (danger) | 0.00% | 0 / 1 | 14.87 |
fetchSourceCheckStatus | 36.84% covered (danger) | 36.84% | 7 / 19 | 0.00% covered (danger) | 0.00% | 0 / 1 | 11.30 |
renderSummaryCard | 0.00% covered (danger) | 0.00% | 0 / 25 | 0.00% covered (danger) | 0.00% | 0 / 1 | 42 |
renderTable | 0.00% covered (danger) | 0.00% | 0 / 34 | 0.00% covered (danger) | 0.00% | 0 / 1 | 30 |
renderRecheckForm | 0.00% covered (danger) | 0.00% | 0 / 9 | 0.00% covered (danger) | 0.00% | 0 / 1 | 2 |
renderRecheckAllForm | 0.00% covered (danger) | 0.00% | 0 / 8 | 0.00% covered (danger) | 0.00% | 0 / 1 | 2 |
handleRecheck | 69.77% covered (warning) | 69.77% | 30 / 43 | 0.00% covered (danger) | 0.00% | 0 / 1 | 15.98 |
redirectWithError | 100.00% covered (success) | 100.00% | 5 / 5 | 100.00% covered (success) | 100.00% | 1 / 1 | 2 |
renderStatusBadge | 0.00% covered (danger) | 0.00% | 0 / 12 | 0.00% covered (danger) | 0.00% | 0 / 1 | 2 |
renderResultBadge | 0.00% covered (danger) | 0.00% | 0 / 14 | 0.00% covered (danger) | 0.00% | 0 / 1 | 6 |
renderMetricTile | 0.00% covered (danger) | 0.00% | 0 / 6 | 0.00% covered (danger) | 0.00% | 0 / 1 | 2 |

1 | <?php
2 | /**
3 | * Source Check Admin Page (F117).
4 | *
5 | * Operator-facing UI for the daily source-URL check.
6 | *
7 | * Per BACKLINK-SYSTEM-ARCHITECTURE §6.4: server daily GET request to
8 | * each offer's source URL. On 404 / no_content / error → mark offer
9 | * paused + refund all approvers (handled by SourceCheckService).
10 | *
11 | * This page:
12 | * - Lists all of the operator's backlink offers with their
13 | * source-check status (last_checked_at, last_source_check_status).
14 | * - Highlights offers that are overdue (>20h since last check).
15 | * - Highlights offers whose status is not "ok" (paused, 404, etc).
16 | * - Lets the operator manually trigger a recheck on individual offers
17 | * or all active offers in one click.
18 | *
19 | * Errors from the API are surfaced inline via ClientErrorRenderer
20 | * (F198 pattern).
21 | *
22 | * @package SwapAds\Client\Admin
23 | * @since 1.5.0
24 | */
25 |
26 | declare(strict_types=1);
27 |
28 | namespace SwapAds\Client\Admin;
29 |
30 | use SwapAds\Client\Admin\OperatorDashboardHubPage;
31 | use SwapAds\Client\Api\RestClient;
32 | use SwapAds\Client\License\LicenseManager;
33 |
34 | /**
35 | * Class SourceCheckPage.
36 | *
37 | * @since 1.5.0
38 | */
39 | final class SourceCheckPage
40 | {
41 | public const MENU_SLUG = 'swapads-client-source-check';
42 | public const NONCE_ACTION = 'swapads_client_source_check';
43 | public const ERROR_CONTEXT = 'swapads_client_source_check';
44 | public const RECHECK_BATCH_LIMIT = 50;
45 |
46 | /**
47 | * Register WP hooks.
48 | */
49 | public static function register(): void
50 | {
51 | add_action('admin_menu', [self::class, 'addMenu']);
52 | add_action('admin_post_swapads_client_source_check_recheck', [self::class, 'handleRecheck']);
53 | }
54 |
55 | /**
56 | * Register the admin page (sub-menu of SwapAds).
57 | */
58 | public static function addMenu(): void
59 | {
60 | add_submenu_page(
61 | OperatorDashboardHubPage::MENU_SLUG,
62 | 'Source Check Status',
63 | 'Source Check',
64 | 'manage_options',
65 | self::MENU_SLUG,
66 | [self::class, 'render']
67 | );
68 | }
69 |
70 | /**
71 | * Render the source-check admin page.
72 | */
73 | public static function render(): void
74 | {
75 | if (!current_user_can('manage_options')) {
76 | wp_die('Insufficient permissions', 'Forbidden', ['response' => 403]);
77 | }
78 |
79 | echo Renderer::pageHeader(
80 | 'Source Check Status',
81 | 'Each of your backlink offers is checked daily. Offfers that return 404 or no content are auto-paused and approvers are refunded.'
82 | );
83 |
84 | // F198: surface any persisted error from previous recheck attempt.
85 | // F2XX (2026-08-01): also surface the success-summary transient that
86 | // handleRecheck() writes on a successful run (was getting rendered as
87 | // a red "failed" banner before this fix).
88 | $persisted = get_transient(self::ERROR_CONTEXT);
89 | if (is_array($persisted)) {
90 | echo ClientErrorRenderer::render(self::ERROR_CONTEXT, $persisted, 'recheck source URLs');
91 | delete_transient(self::ERROR_CONTEXT);
92 | }
93 | $summary = get_transient('swapads_source_check_summary');
94 | if (is_array($summary)) {
95 | echo ClientErrorRenderer::render('swapads_source_check_summary', $summary, 'recheck source URLs');
96 | delete_transient('swapads_source_check_summary');
97 | }
98 |
99 | if (!LicenseManager::isLicensed()) {
100 | echo Renderer::card(
101 | 'License required',
102 | '<p>Activate your license before viewing source-check status.</p>',
103 | 'swapads-card-warning'
104 | );
105 | echo Renderer::pageFooter();
106 | return;
107 | }
108 |
109 | $data = self::fetchSourceCheckStatus();
110 | if (!is_array($data)) {
111 | echo Renderer::card(
112 | 'Source check unavailable',
113 | '<p>Could not fetch source-check data from the server. See the notice above for the exact response.</p>',
114 | 'swapads-card-error'
115 | );
116 | echo Renderer::pageFooter();
117 | return;
118 | }
119 |
120 | $items = $data['items'] ?? [];
121 | $totalCount = (int) ($data['count'] ?? count($items));
122 | echo self::renderSummaryCard($items, $totalCount);
123 |
124 | if (count($items) === 0) {
125 | echo Renderer::card(
126 | 'No backlink offers yet',
127 | '<p>Once you create your first backlink offer, it will appear here with daily source-check status.</p>',
128 | 'swapads-card-info'
129 | );
130 | echo self::renderRecheckAllForm();
131 | echo Renderer::pageFooter();
132 | return;
133 | }
134 |
135 | echo self::renderTable($items);
136 | echo self::renderRecheckAllForm();
137 | echo Renderer::pageFooter();
138 | }
139 |
140 | /**
141 | * Fetch source-check status from server.
142 | *
143 | * F198: RestClient::request() never throws for REST/transport
144 | * failures — lastError() captures the envelope and we persist it
145 | * for render() to surface inline.
146 | *
147 | * @return array<string, mixed>|null Null on error (error persisted to transient).
148 | */
149 | public static function fetchSourceCheckStatus(): ?array
150 | {
151 | $client = RestClient::fromOption();
152 | try {
153 | $response = $client->get('/v1/backlinks/source-check');
154 | } catch (\Throwable $e) {
155 | set_transient(self::ERROR_CONTEXT, [
156 | 'error_code' => 'FATAL',
157 | 'message' => 'Unexpected fatal error: ' . $e->getMessage(),
158 | 'http_status' => 0,
159 | 'when' => time(),
160 | ], MINUTE_IN_SECONDS);
161 | return null;
162 | }
163 | if (!is_array($response)) {
164 | if (($err = $client->lastError()) !== null) {
165 | set_transient(self::ERROR_CONTEXT, $err, MINUTE_IN_SECONDS);
166 | }
167 | return null;
168 | }
169 | if (empty($response['success'])) {
170 | $err = $client->lastError() ?? $response;
171 | set_transient(self::ERROR_CONTEXT, $err, MINUTE_IN_SECONDS);
172 | return null;
173 | }
174 | return $response;
175 | }
176 |
177 | /**
178 | * Render the summary card (counts by status).
179 | *
180 | * @param array<int, array<string, mixed>> $items
181 | */
182 | private static function renderSummaryCard(array $items, int $totalCount): string
183 | {
184 | $ok = 0;
185 | $paused = 0;
186 | $overdue = 0;
187 | $other = 0;
188 | $overdueThresholdHours = 20;
189 | foreach ($items as $it) {
190 | $status = (string) ($it['status'] ?? '');
191 | $age = $it['last_check_age_hours'];
192 | if ($status === 'active') {
193 | $ok++;
194 | } elseif ($status === 'paused') {
195 | $paused++;
196 | } else {
197 | $other++;
198 | }
199 | if ($age !== null && (int) $age > $overdueThresholdHours) {
200 | $overdue++;
201 | }
202 | }
203 | $html = '<div class="swapads-card">';
204 | $html .= '<h2>Summary</h2>';
205 | $html .= '<div class="swapads-metric-grid">';
206 | $html .= self::renderMetricTile('Total offers', $totalCount, 'all your backlink offers');
207 | $html .= self::renderMetricTile('Active', $ok, 'currently in rotation');
208 | $html .= self::renderMetricTile('Auto-paused', $paused, 'paused by source check');
209 | $html .= self::renderMetricTile('Other status', $other, 'depleted, removed, etc.');
210 | $html .= self::renderMetricTile('Overdue check', $overdue, 'not checked in >20h');
211 | $html .= '</div></div>';
212 | return $html;
213 | }
214 |
215 | /**
216 | * Render the per-offer source-check table.
217 | *
218 | * @param array<int, array<string, mixed>> $items
219 | */
220 | private static function renderTable(array $items): string
221 | {
222 | $html = '<div class="swapads-card">';
223 | $html .= '<h2>Per-Offer Source-Check Status</h2>';
224 | $html .= '<table class="widefat striped" style="margin-top:8px;">';
225 | $html .= '<thead><tr>';
226 | $html .= '<th style="width:60px;">ID</th>';
227 | $html .= '<th>Source URL</th>';
228 | $html .= '<th style="width:100px;">Status</th>';
229 | $html .= '<th style="width:160px;">Last Checked</th>';
230 | $html .= '<th style="width:160px;">Check Result</th>';
231 | $html .= '<th style="width:120px;">Action</th>';
232 | $html .= '</tr></thead><tbody>';
233 |
234 | foreach ($items as $it) {
235 | $id = (int) ($it['id'] ?? 0);
236 | $sourceUrl = (string) ($it['source_url'] ?? '');
237 | $status = (string) ($it['status'] ?? '');
238 | $lastCheckedAt = $it['last_checked_at'] ?? null;
239 | $lastStatus = (string) ($it['last_source_check_status'] ?? '');
240 | $ageHours = $it['last_check_age_hours'];
241 | $ageLabel = $ageHours === null ? 'never' : ((int) $ageHours) . 'h ago';
242 |
243 | $statusBadge = self::renderStatusBadge($status);
244 | $resultBadge = self::renderResultBadge($lastStatus);
245 |
246 | $html .= '<tr>';
247 | $html .= '<td>' . esc_html((string) $id) . '</td>';
248 | $html .= '<td><code style="font-size:11px;">' . esc_html($sourceUrl) . '</code></td>';
249 | $html .= '<td>' . $statusBadge . '</td>';
250 | $html .= '<td>' . esc_html($ageLabel) . ($lastCheckedAt !== null ? '<br><small>' . esc_html((string) $lastCheckedAt) . '</small>' : '') . '</td>';
251 | $html .= '<td>' . $resultBadge . '</td>';
252 | $html .= '<td>';
253 | if ($id > 0) {
254 | $html .= self::renderRecheckForm($id);
255 | }
256 | $html .= '</td>';
257 | $html .= '</tr>';
258 | }
259 |
260 | $html .= '</tbody></table></div>';
261 | return $html;
262 | }
263 |
264 | /**
265 | * Render the recheck form for a single backlink.
266 | */
267 | private static function renderRecheckForm(int $backlinkId): string
268 | {
269 | $action = esc_url(admin_url('admin-post.php'));
270 | $nonce = wp_create_nonce(self::NONCE_ACTION);
271 | return sprintf(
272 | '<form method="post" action="%s" style="display:inline-block;">
273 | <input type="hidden" name="action" value="swapads_client_source_check_recheck">
274 | <input type="hidden" name="_wpnonce" value="%s">
275 | <input type="hidden" name="backlink_id" value="%d">
276 | <button type="submit" class="button button-small">Recheck</button>
277 | </form>',
278 | $action,
279 | esc_attr($nonce),
280 | $backlinkId
281 | );
282 | }
283 |
284 | /**
285 | * Render the "recheck all active" form.
286 | */
287 | private static function renderRecheckAllForm(): string
288 | {
289 | $action = esc_url(admin_url('admin-post.php'));
290 | $nonce = wp_create_nonce(self::NONCE_ACTION);
291 | return sprintf(
292 | '<div class="swapads-card" style="margin-top:16px;">
293 | <h2>Manual Recheck</h2>
294 | <p>Force a recheck of all your active offers right now (in addition to the daily 03:00 UTC cron run).</p>
295 | <form method="post" action="%s">
296 | <input type="hidden" name="action" value="swapads_client_source_check_recheck">
297 | <input type="hidden" name="_wpnonce" value="%s">
298 | <input type="hidden" name="all" value="1">
299 | <button type="submit" class="button button-primary">Recheck all active offers</button>
300 | </form>
301 | </div>',
302 | $action,
303 | esc_attr($nonce)
304 | );
305 | }
306 |
307 | /**
308 | * Handle the recheck form POST.
309 | */
310 | public static function handleRecheck(): void
311 | {
312 | if (!current_user_can('manage_options')) {
313 | wp_die('Insufficient permissions', 'Forbidden', ['response' => 403]);
314 | }
315 | check_admin_referer(self::NONCE_ACTION);
316 |
317 | $all = (string) ($_POST['all'] ?? '') === '1';
318 | $backlinkId = (int) ($_POST['backlink_id'] ?? 0);
319 |
320 | $client = RestClient::fromOption();
321 | try {
322 | if ($all) {
323 | $response = $client->post('/v1/backlinks/source-check/recheck', ['all' => true]);
324 | } elseif ($backlinkId > 0) {
325 | $response = $client->post('/v1/backlinks/source-check/recheck', ['backlink_ids' => [$backlinkId]]);
326 | } else {
327 | self::redirectWithError('Either all=1 or backlink_id required');
328 | return;
329 | }
330 | } catch (\Throwable $e) {
331 | set_transient(self::ERROR_CONTEXT, [
332 | 'error_code' => 'FATAL',
333 | 'message' => 'Unexpected fatal error: ' . $e->getMessage(),
334 | 'http_status' => 0,
335 | 'when' => time(),
336 | ], MINUTE_IN_SECONDS);
337 | self::redirectWithError($e->getMessage());
338 | return;
339 | }
340 |
341 | if (is_array($response) && empty($response['success'])) {
342 | $err = $client->lastError() ?? $response;
343 | set_transient(self::ERROR_CONTEXT, $err, MINUTE_IN_SECONDS);
344 | self::redirectWithError((string) ($response['error_code'] ?? 'unknown'));
345 | return;
346 | }
347 |
348 | if (is_array($response) && !empty($response['success'])) {
349 | $results = $response['results'] ?? [];
350 | $ok = 0;
351 | $bad = 0;
352 | $refunds = 0;
353 | foreach ($results as $r) {
354 | $res = (string) ($r['result'] ?? '');
355 | if ($res === 'ok') { $ok++; } else { $bad++; }
356 | $refunds += (int) ($r['refunds'] ?? 0);
357 | }
358 | // F2XX (2026-08-01): use a SEPARATE transient for success + non-error
359 | // messaging. ClientErrorRenderer renders RECHECK_SUMMARY as a green
360 | // success notice (was red "failed" banner before this fix).
361 | set_transient('swapads_source_check_summary', [
362 | 'error_code' => 'RECHECK_SUMMARY',
363 | 'message' => sprintf('Recheck complete: %d ok, %d issues, %d refunds.', $ok, $bad, $refunds),
364 | 'http_status' => 200,
365 | 'when' => time(),
366 | ], MINUTE_IN_SECONDS);
367 | }
368 |
369 | wp_safe_redirect(add_query_arg(['page' => self::MENU_SLUG, 'rechecked' => '1'], admin_url('admin.php')));
370 | if (!defined('SWAPADS_TESTING_REDIRECT_EXIT')) { exit; }
371 | }
372 |
373 | /**
374 | * Redirect back with an error code.
375 | */
376 | private static function redirectWithError(string $errorCode): void
377 | {
378 | wp_safe_redirect(add_query_arg(
379 | ['page' => self::MENU_SLUG, 'error' => rawurlencode($errorCode)],
380 | admin_url('admin.php')
381 | ));
382 | if (!defined('SWAPADS_TESTING_REDIRECT_EXIT')) { exit; }
383 | }
384 |
385 | /**
386 | * Render a status badge (active/paused/depleted).
387 | */
388 | private static function renderStatusBadge(string $status): string
389 | {
390 | $colors = [
391 | 'active' => '#46b450',
392 | 'paused' => '#d63638',
393 | 'depleted' => '#996633',
394 | 'removed' => '#666',
395 | ];
396 | $color = $colors[$status] ?? '#666';
397 | return sprintf(
398 | '<span style="display:inline-block;padding:2px 8px;border-radius:3px;background:%s;color:#fff;font-size:11px;font-weight:600;">%s</span>',
399 | esc_attr($color),
400 | esc_html($status)
401 | );
402 | }
403 |
404 | /**
405 | * Render a check-result badge (ok/404/no_content/error).
406 | */
407 | private static function renderResultBadge(string $result): string
408 | {
409 | if ($result === '') {
410 | return '<span style="color:#666;">—</span>';
411 | }
412 | $colors = [
413 | 'ok' => '#46b450',
414 | '404' => '#d63638',
415 | 'no_content' => '#dba617',
416 | 'error' => '#d63638',
417 | ];
418 | $color = $colors[$result] ?? '#996633';
419 | return sprintf(
420 | '<span style="display:inline-block;padding:2px 8px;border-radius:3px;background:%s;color:#fff;font-size:11px;font-weight:600;">%s</span>',
421 | esc_attr($color),
422 | esc_html($result)
423 | );
424 | }
425 |
426 | /**
427 | * Render a metric tile (used in summary).
428 | */
429 | private static function renderMetricTile(string $label, int $value, string $hint): string
430 | {
431 | return sprintf(
432 | '<div class="swapads-metric-tile"><div class="swapads-metric-value">%s</div><div class="swapads-metric-label">%s</div><div class="swapads-metric-hint">%s</div></div>',
433 | esc_html((string) $value),
434 | esc_html($label),
435 | esc_html($hint)
436 | );
437 | }
438 | }