Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
95.31% covered (success)
95.31%
61 / 64
71.43% covered (warning)
71.43%
5 / 7
CRAP
0.00% covered (danger)
0.00%
0 / 1
StatisticsQueue
95.24% covered (success)
95.24%
60 / 63
71.43% covered (warning)
71.43%
5 / 7
25
0.00% covered (danger)
0.00%
0 / 1
 enqueue
97.56% covered (success)
97.56%
40 / 41
0.00% covered (danger)
0.00%
0 / 1
15
 drain
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 drainOlderThan
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
3
 count
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 load
71.43% covered (warning)
71.43%
5 / 7
0.00% covered (danger)
0.00%
0 / 1
3.21
 save
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 mintVisitorCookie
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
1<?php
2/**
3 * SwapAds Client — StatisticsQueue (F238).
4 *
5 * Per F238 design (locked 2026-07-31):
6 * - Operator-B's plugin enqueues backlink view/click events locally
7 *   (no HTTP call to our server at render time)
8 * - Our server's AdaptivePollCron pulls events on an adaptive schedule
9 *   (default 15 min, backoff to 2 hr under load)
10 * - HMAC-signed flush endpoint validates server identity
11 *
12 * Storage: wp_options('swapads_pending_statistics') JSON array.
13 * Max 1000 entries per license (oldest pruned if exceeded).
14 *
15 * Naming-convention-locked (avoid tracker/ad/beacon trigger words).
16 *
17 * @since 1.5.3
18 */
19
20declare(strict_types=1);
21
22namespace SwapAds\Client\Statistics;
23
24if (!defined('ABSPATH')) { /* test mode: skip WordPress bootstrap guard */ }
25
26final class StatisticsQueue
27{
28    /** Max queue size. Oldest pruned when exceeded. */
29    public const MAX_QUEUE_SIZE = 1000;
30
31    /** Option key for the queue. */
32    public const OPTION_KEY = 'swapads_pending_statistics';
33
34    /**
35     * Enqueue a backlink view or click event.
36     *
37     * TODO-CLIENT-API-002 (2026-08-01): visitor_ip + visitor_ua are now
38     * HASHES (32-char SHA-256 prefix), never raw. Caller (StatisticsFlushEndpoint)
39     * is responsible for hashing the real values before calling enqueue().
40     * Backwards-compat: any legacy 'visitor_ip' / 'visitor_ua' string fields
41     * in the input are dropped silently.
42     *
43     * @param array<string, mixed> $event Must contain backlink_id, partner_id, event_type ('view' or 'click'), ts_ms.
44     *                                  Optional: page_url, visitor_ip_hash, visitor_ua_hash, swapads_sid.
45     */
46    public static function enqueue(array $event): bool
47    {
48        $backlinkId = (int) ($event['backlink_id'] ?? 0);
49        $partnerId = (int) ($event['partner_id'] ?? 0);
50        $eventType = (string) ($event['event_type'] ?? '');
51        $tsMs = (int) ($event['ts_ms'] ?? 0);
52        if ($backlinkId <= 0 || $partnerId <= 0 || $tsMs <= 0) {
53            return false;
54        }
55        if (!in_array($eventType, ['view', 'click'], true)) {
56            return false;
57        }
58
59        $queue = self::load();
60        // Prune oldest if at cap (LIFO drop).
61        if (count($queue) >= self::MAX_QUEUE_SIZE) {
62            $queue = array_slice($queue, -(self::MAX_QUEUE_SIZE - 1));
63        }
64        // TODO-CLIENT-API-002 (2026-08-01): strip any legacy raw IP/UA fields.
65        // Accept ONLY the hash variants (visitor_ip_hash, visitor_ua_hash).
66        $visitorIpHash = isset($event['visitor_ip_hash'])
67            ? (string) $event['visitor_ip_hash']
68            : '';
69        $visitorUaHash = isset($event['visitor_ua_hash'])
70            ? (string) $event['visitor_ua_hash']
71            : '';
72        // Defensive: if a caller still sends raw IP (e.g., legacy code
73        // path), hash it here too. New callers MUST pre-hash.
74        if ($visitorIpHash === '' && isset($event['visitor_ip']) && is_string($event['visitor_ip'])) {
75            $visitorIpHash = substr(
76                hash('sha256', $event['visitor_ip'] . wp_salt('auth')),
77                0,
78                32
79            );
80        }
81        if ($visitorUaHash === '' && isset($event['visitor_ua']) && is_string($event['visitor_ua'])) {
82            $visitorUaHash = substr(
83                hash('sha256', $event['visitor_ua'] . wp_salt('auth')),
84                0,
85                32
86            );
87        }
88
89        $queue[] = [
90            'backlink_id'      => $backlinkId,
91            'partner_id'       => $partnerId,
92            'event_type'       => $eventType,
93            'ts_ms'            => $tsMs,
94            'page_url'         => (string) ($event['page_url'] ?? ''),
95            'visitor_ip_hash'  => $visitorIpHash,
96            'visitor_ua_hash'  => $visitorUaHash,
97            'swapads_sid'      => isset($event['swapads_sid']) ? (string) $event['swapads_sid'] : null,
98            'enqueued_at'      => time(),
99        ];
100        return self::save($queue);
101    }
102
103    /**
104     * Drain all queued events.
105     *
106     * @return array<int, array<string, mixed>>
107     */
108    public static function drain(): array
109    {
110        $queue = self::load();
111        self::save([]);
112        return $queue;
113    }
114
115    /**
116     * Drain events older than a given timestamp (used by server ACK).
117     *
118     * @return array<int, array<string, mixed>>
119     */
120    public static function drainOlderThan(int $maxEnqueuedAt): array
121    {
122        $queue = self::load();
123        $kept = [];
124        $drained = [];
125        foreach ($queue as $item) {
126            if ((int) ($item['enqueued_at'] ?? 0) <= $maxEnqueuedAt) {
127                $drained[] = $item;
128            } else {
129                $kept[] = $item;
130            }
131        }
132        self::save($kept);
133        return $drained;
134    }
135
136    /**
137     * Count events in queue.
138     */
139    public static function count(): int
140    {
141        return count(self::load());
142    }
143
144    /**
145     * Load the queue from wp_options.
146     *
147     * @return array<int, array<string, mixed>>
148     */
149    private static function load(): array
150    {
151        $raw = get_option(self::OPTION_KEY, '[]');
152        if (!is_string($raw)) {
153            return [];
154        }
155        $decoded = json_decode($raw, true);
156        if (!is_array($decoded)) {
157            return [];
158        }
159        return $decoded;
160    }
161
162    /**
163     * Save the queue to wp_options.
164     *
165     * @param array<int, array<string, mixed>> $queue
166     */
167    private static function save(array $queue): bool
168    {
169        return (bool) update_option(self::OPTION_KEY, (string) wp_json_encode($queue), 'no');
170    }
171
172    /**
173     * Generate a stable, anonymous visitor cookie token.
174     *
175     * Called by JS to set the swapads_sid cookie on first page-load.
176     * Server uses this for cross-page dedup within operator-B's domain.
177     */
178    public static function mintVisitorCookie(): string
179    {
180        return bin2hex(random_bytes(16));
181    }
182}