Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
87.50% covered (warning)
87.50%
56 / 64
50.00% covered (danger)
50.00%
6 / 12
CRAP
0.00% covered (danger)
0.00%
0 / 1
Preferences
87.10% covered (warning)
87.10%
54 / 62
50.00% covered (danger)
50.00%
6 / 12
37.63
0.00% covered (danger)
0.00%
0 / 1
 get
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
6
 set
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 mergeGuestPreferencesOnLogin
90.00% covered (success)
90.00%
9 / 10
0.00% covered (danger)
0.00%
0 / 1
7.05
 isDefaultValue
83.33% covered (warning)
83.33%
5 / 6
0.00% covered (danger)
0.00%
0 / 1
4.07
 clearTestStorage
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getFromDatabase
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
4
 setInDatabase
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
2
 getFromGuestStorage
75.00% covered (warning)
75.00%
3 / 4
0.00% covered (danger)
0.00%
0 / 1
2.06
 setInGuestStorage
60.00% covered (warning)
60.00%
3 / 5
0.00% covered (danger)
0.00%
0 / 1
2.26
 clearGuestStorageKey
60.00% covered (warning)
60.00%
3 / 5
0.00% covered (danger)
0.00%
0 / 1
2.26
 isInUnitTest
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 validateKey
50.00% covered (danger)
50.00%
1 / 2
0.00% covered (danger)
0.00%
0 / 1
2.50
1<?php
2
3App::uses('Auth', 'Utility');
4App::uses('Util', 'Utility');
5
6/**
7 * Unified Preferences storage class.
8 *
9 * - For logged-in users: Uses UserContribution table in database
10 * - For guests: Uses cookies (or in-memory storage during tests)
11 *
12 * Supported keys (matching TsumegoFilters/UserContribution columns):
13 * - query
14 * - collection_size
15 * - filtered_sets
16 * - filtered_ranks
17 * - filtered_tags
18 */
19class Preferences
20{
21    /**
22     * In-memory storage for unit tests (where setcookie() won't work)
23     * @var array<string, mixed>
24     */
25    private static array $testStorage = [];
26
27    /**
28     * Database default values - used to detect if a value was explicitly set
29     */
30    private static array $dbDefaults = [
31        'query' => 'topics',
32        'collection_size' => 200,
33        'filtered_sets' => null,
34        'filtered_ranks' => null,
35        'filtered_tags' => null,
36    ];
37
38    /**
39     * Valid preference keys (must match UserContribution columns)
40     */
41    private static array $validKeys = [
42        'query',
43        'collection_size',
44        'filtered_sets',
45        'filtered_ranks',
46        'filtered_tags',
47    ];
48
49    /**
50     * Get a preference value
51     *
52     * @param string $key The preference key
53     * @param mixed $default Default value if not set
54     * @return mixed The preference value
55     */
56    public static function get(string $key, $default = null)
57    {
58        self::validateKey($key);
59
60        // Check cookie override first (applies to both guests and logged-in users)
61        // For logged-in users: Check unprefixed cookie (set by layout.ctp JS)
62        // For guests: Check prefixed cookie ("loggedOff_" + key)
63        if (Auth::isLoggedIn())
64        {
65            // Logged-in users: unprefixed cookie override
66            if (isset($_COOKIE[$key]) && $_COOKIE[$key] !== '')
67                return $_COOKIE[$key];
68            // Fall through to database
69            return self::getFromDatabase($key, $default);
70        }
71        else
72        {
73            // Guests: check prefixed cookie first
74            $cookieKey = 'loggedOff_' . $key;
75            if (isset($_COOKIE[$cookieKey]) && $_COOKIE[$cookieKey] !== '')
76                return $_COOKIE[$cookieKey];
77            // Fall through to guest storage (which checks same cookie again, but that's OK)
78            return self::getFromGuestStorage($key, $default);
79        }
80    }
81
82    /**
83     * Set a preference value
84     *
85     * @param string $key The preference key
86     * @param mixed $value The value to set
87     */
88    public static function set(string $key, $value): void
89    {
90        self::validateKey($key);
91
92        if (Auth::isLoggedIn())
93            self::setInDatabase($key, $value);
94        else
95            self::setInGuestStorage($key, $value);
96    }
97
98    /**
99     * Merge guest preferences into database on login.
100     * Should be called after successful login.
101     */
102    public static function mergeGuestPreferencesOnLogin(): void
103    {
104        if (!Auth::isLoggedIn())
105            return;
106
107        foreach (self::$validKeys as $key)
108        {
109            $guestValue = self::getFromGuestStorage($key);
110            if ($guestValue !== null)
111            {
112                // Only set if not already explicitly set in database
113                // (i.e., current value is null, empty, or equals the database default)
114                $currentDbValue = self::getFromDatabase($key);
115                $isDefault = self::isDefaultValue($key, $currentDbValue);
116                if ($currentDbValue === null || $currentDbValue === '' || $isDefault)
117                    self::setInDatabase($key, $guestValue);
118
119                // Clear guest storage
120                self::clearGuestStorageKey($key);
121            }
122        }
123    }
124
125    /**
126     * Check if a value equals the database default for a key
127     */
128    private static function isDefaultValue(string $key, $value): bool
129    {
130        if (!isset(self::$dbDefaults[$key]))
131            return false;
132
133        $default = self::$dbDefaults[$key];
134        // Handle numeric comparison (DB might return string "200" vs int 200)
135        if (is_numeric($value) && is_numeric($default))
136            return (int) $value === (int) $default;
137
138        return $value === $default;
139    }
140
141    /**
142     * Clear all test storage (for use in test tearDown)
143     */
144    public static function clearTestStorage(): void
145    {
146        self::$testStorage = [];
147    }
148
149    /**
150     * Get preference from database (for logged-in users)
151     */
152    private static function getFromDatabase(string $key, $default = null)
153    {
154        $userContribution = ClassRegistry::init('UserContribution')->find('first', [
155            'conditions' => ['user_id' => Auth::getUserID()],
156        ]);
157
158        if (!$userContribution || !isset($userContribution['UserContribution'][$key]))
159            return $default;
160
161        $value = $userContribution['UserContribution'][$key];
162        return ($value === '') ? $default : $value;
163    }
164
165    /**
166     * Set preference in database (for logged-in users)
167     */
168    private static function setInDatabase(string $key, $value): void
169    {
170        $userId = Auth::getUserID();
171        $userContribution = ClassRegistry::init('UserContribution')->find('first', ['conditions' => ['user_id' => $userId]]);
172
173        if ($userContribution)
174        {
175            // Update existing record
176            $userContribution['UserContribution'][$key] = $value;
177            ClassRegistry::init('UserContribution')->save($userContribution);
178        }
179        else
180        {
181            // Create new record
182            $newContribution = ['user_id' => $userId, $key => $value];
183            ClassRegistry::init('UserContribution')->create();
184            ClassRegistry::init('UserContribution')->save($newContribution);
185        }
186    }
187
188    /**
189     * Get preference from guest storage (cookies or test memory)
190     */
191    private static function getFromGuestStorage(string $key, $default = null)
192    {
193        // In unit tests, use memory storage
194        if (self::isInUnitTest())
195            return self::$testStorage[$key] ?? $default;
196
197        // Cookie key uses 'loggedOff_' prefix for backward compatibility
198        $cookieKey = 'loggedOff_' . $key;
199        return $_COOKIE[$cookieKey] ?? $default;
200    }
201
202    /**
203     * Set preference in guest storage (cookies or test memory)
204     */
205    private static function setInGuestStorage(string $key, $value): void
206    {
207        // In unit tests, use memory storage
208        if (self::isInUnitTest())
209        {
210            self::$testStorage[$key] = $value;
211            return;
212        }
213
214        // Cookie key uses 'loggedOff_' prefix for backward compatibility
215        $cookieKey = 'loggedOff_' . $key;
216        Util::setCookie($cookieKey, $value);
217    }
218
219    /**
220     * Clear a single key from guest storage
221     */
222    private static function clearGuestStorageKey(string $key): void
223    {
224        if (self::isInUnitTest())
225        {
226            unset(self::$testStorage[$key]);
227            return;
228        }
229
230        $cookieKey = 'loggedOff_' . $key;
231        Util::clearCookie($cookieKey);
232    }
233
234    /**
235     * Check if we're running in a unit test environment
236     *
237     * NOTE: Only checks PHPUNIT_RUNNING, not php_sapi_name() === 'cli'
238     * because CLI check would also trigger in production CLI commands
239     * (cron jobs, migrations, shells) where we want real cookies, not test storage.
240     */
241    private static function isInUnitTest(): bool
242    {
243        return defined('PHPUNIT_RUNNING');
244    }
245
246    /**
247     * Validate that the key is allowed
248     */
249    private static function validateKey(string $key): void
250    {
251        if (!in_array($key, self::$validKeys))
252            throw new InvalidArgumentException("Invalid preference key: $key. Valid keys are: " . implode(', ', self::$validKeys));
253    }
254}