<?php
/**
* SeekQuarry/Yioop --
* Open Source Pure PHP Search Engine, Crawler, and Indexer
*
* Copyright (C) 2009 - 2026 Chris Pollett chris@pollett.org
*
* LICENSE:
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
* END LICENSE
*
* @author Chris Pollett chris@pollett.org
* @license https://www.gnu.org/licenses/ GPL3
* @link https://www.seekquarry.com/
* @copyright 2009 - 2026
* @filesource
*/
namespace seekquarry\yioop\tests;
use seekquarry\yioop\configs as C;
use seekquarry\yioop\models\ProfileModel as RealProfileModel;
use seekquarry\yioop\library\UnitTest;
/**
* Unit tests for ProfileModel — specifically the field-resolution
* policy used when writing a new Profile.php. Covers the
* shouldKeepZero helper that protects explicit 0 settings (such
* as RECOVERY_MODE = NO_RECOVERY) from being silently overwritten
* by the not-null fallback.
*
* @author Chris Pollett
*/
class ProfileModelTest extends UnitTest
{
/**
* No setUp needed; shouldKeepZero is a pure static helper
*/
public function setUp()
{
}
/**
* No tearDown
*/
public function tearDown()
{
}
/**
* shouldKeepZero should return true ONLY when (a) the field
* is in the zero-valid list, (b) the field appears in new or
* old profile data, and (c) the resolved value coerces to
* the literal string "0". Covers the case-by-case logic that
* keeps an admin's deliberate RECOVERY_MODE = NO_RECOVERY
* setting alive across saves while still defaulting new
* installs to EMAIL_RECOVERY.
*/
public function shouldKeepZeroTestCase()
{
$zero_valid = ['RECOVERY_MODE'];
/* New install: no new, no old, falls through to "". Don't keep. */
$this->assertFalse(
RealProfileModel::shouldKeepZero('RECOVERY_MODE', "",
[], [], $zero_valid),
"New install (no new, no old) does not keep the empty string");
/*
Admin explicitly sets RECOVERY_MODE = 0 via the form
(new_profile_data carries the choice). Keep the zero.
*/
$this->assertTrue(
RealProfileModel::shouldKeepZero('RECOVERY_MODE', 0,
['RECOVERY_MODE' => 0], [], $zero_valid),
"Explicit 0 in new_profile_data is preserved");
/*
Same case but the resolved value is the string "0"
(which is how it comes back out of a saved
Profile.php). Still keep.
*/
$this->assertTrue(
RealProfileModel::shouldKeepZero('RECOVERY_MODE', "0",
['RECOVERY_MODE' => "0"], [], $zero_valid),
'String "0" from saved profile is preserved');
/*
Existing profile had RECOVERY_MODE = 0; admin saves
something else (so new_profile_data has no
RECOVERY_MODE, old does). Keep the zero so the
admin's preference survives.
*/
$this->assertTrue(
RealProfileModel::shouldKeepZero('RECOVERY_MODE', "0",
[], ['RECOVERY_MODE' => "0"], $zero_valid),
"Existing zero in old_profile_data is preserved");
/*
Legacy install with empty string left over from a bug:
resolved value is "" not "0". Don't keep — let the
not-null fallback fix it to EMAIL_RECOVERY.
*/
$this->assertFalse(
RealProfileModel::shouldKeepZero('RECOVERY_MODE', "",
[], ['RECOVERY_MODE' => ""], $zero_valid),
"Empty string left by legacy install is not kept (gets fixed)");
/*
Field not in the zero-valid list (DIFFERENTIAL_PRIVACY
uses false as default and is not zero-sensitive):
never keep.
*/
$this->assertFalse(
RealProfileModel::shouldKeepZero('DIFFERENTIAL_PRIVACY', 0,
['DIFFERENTIAL_PRIVACY' => 0], [], $zero_valid),
"Fields outside zero-valid list always get the not-null default");
/*
Truthy values would never reach this helper in real
code (the falsy guard is at the call site), but make
sure the helper itself doesn't misclassify them as
keep-worthy.
*/
$this->assertFalse(
RealProfileModel::shouldKeepZero('RECOVERY_MODE', 1,
['RECOVERY_MODE' => 1], [], $zero_valid),
"Resolved value of 1 is not classified as a keep-worthy zero");
/* Negative integer: also not a "0". Don't keep. */
$this->assertFalse(
RealProfileModel::shouldKeepZero('RECOVERY_MODE', -1,
['RECOVERY_MODE' => -1], [], $zero_valid),
"Resolved value of -1 is not a literal-zero match");
}
/**
* A theme or domain name is reduced to a plain name that cannot
* change folder: surrounding quotes come off, and directory
* separators and parent-folder steps are taken out.
*/
public function themeFileNameTestCase()
{
$model = (new \ReflectionClass(RealProfileModel::class))
->newInstanceWithoutConstructor();
$this->assertEqual($model->themeFileName('"quoted"'), "quoted",
"quotes around a name from a profile come off");
$this->assertEqual($model->themeFileName("../../etc/passwd"),
"etcpasswd", "separators and parent steps are taken out");
$this->assertEqual($model->themeFileName(""), "",
"an empty name stays empty");
}
/**
* Every theme the site has sits in the one folder, whichever domain
* wears it.
*/
public function themeFolderTestCase()
{
$model = (new \ReflectionClass(RealProfileModel::class))
->newInstanceWithoutConstructor();
$this->assertEqual($model->themeFolder(),
C\APP_DIR . "/css/" . C\DEFAULT_THEME_FOLDER,
"the site keeps its themes in one folder");
}
/**
* A theme's rules are read back as written, and a theme that was
* never written reads as no rules rather than an error.
*/
public function themeStylesheetRoundTripTestCase()
{
$model = (new \ReflectionClass(RealProfileModel::class))
->newInstanceWithoutConstructor();
$this->assertTrue(
$model->saveThemeStylesheet("roundtrip", "body { color: red; }"),
"a theme with rules and a name is written");
$this->assertEqual($model->getThemeStylesheet("roundtrip"),
"body { color: red; }", "a theme reads back as written");
$this->assertEqual($model->getThemeStylesheet("never-written"),
"", "a theme that is not there reads as no rules");
$this->assertFalse($model->saveThemeStylesheet("", "body {}"),
"a theme with no name is not written");
$this->assertTrue(
in_array("roundtrip", $model->getThemeNames()),
"the site's themes list the theme just written");
$this->cleanTheme("roundtrip");
}
/**
* A theme is written once and read by every domain, so writing the
* same name again changes the one theme rather than making a second
* one, and deleting it takes it away from every domain at once.
*/
public function deleteThemeTestCase()
{
$model = (new \ReflectionClass(RealProfileModel::class))
->newInstanceWithoutConstructor();
$model->saveThemeStylesheet("shared", "body { color: red; }");
$model->saveThemeStylesheet("shared", "body { color: blue; }");
$this->assertEqual($model->getThemeStylesheet("shared"),
"body { color: blue; }",
"writing a theme's name again changes that one theme");
$this->assertEqual(
count(array_keys($model->getThemeNames(), "shared")), 1,
"and does not leave a second theme of the same name");
$model->deleteTheme("shared");
$this->assertEqual($model->getThemeStylesheet("shared"), "",
"a deleted theme is gone");
$this->assertFalse(in_array("shared", $model->getThemeNames()),
"and is no longer offered to any domain");
}
/**
* Removes a theme's file left by a test, so one test's themes are
* not seen by the next.
*
* @param string $name name of the theme to remove
*/
public function cleanTheme($name)
{
$model = (new \ReflectionClass(RealProfileModel::class))
->newInstanceWithoutConstructor();
$css_file = $model->themeFolder() . "/$name.css";
if (file_exists($css_file)) {
unlink($css_file);
}
}
/**
* A value stored through the authorized RealProfileModel::updateProfile
* caller comes back on the next read.
*/
public function setThenReadTestCase()
{
(new ProfileModel())->updateProfile('P_TEST_ALPHA', 'hello');
$this->assertEqual(C\p('P_TEST_ALPHA'), 'hello',
"a value stored through updateProfile is returned on read");
}
/**
* A stored zero must be kept. An earlier version keyed on empty() would
* treat the zero as absent and try to reseed from a constant.
*/
public function falsyValueSticksTestCase()
{
(new ProfileModel())->updateProfile('P_TEST_BETA', 0);
$this->assertTrue(C\p('P_TEST_BETA') === 0,
"a stored zero is kept, not treated as an unset label");
}
/**
* A label never stored falls back to the boot constant of that name.
*/
public function seedsFromConstantTestCase()
{
$this->assertEqual(C\p('NAME_LEN'), C\NAME_LEN,
"an unset label seeds from the boot constant of the same name");
}
/**
* Reading a label with no cache entry and no matching constant throws.
*/
public function undefinedThrowsTestCase()
{
$threw = false;
try {
C\p('P_TEST_UNDEFINED_LABEL');
} catch (\Exception $e) {
$threw = true;
}
$this->assertTrue($threw,
"reading a label with neither a cache entry nor a constant throws");
}
/**
* A set attempted from anywhere other than RealProfileModel::updateProfile
* is rejected, so the cache changes only when the profile does.
*/
public function unauthorizedSetThrowsTestCase()
{
$threw = false;
try {
C\p('P_TEST_GAMMA', 'nope', true);
} catch (\Exception $e) {
$threw = true;
}
$this->assertTrue($threw,
"a direct set not from updateProfile is rejected");
}
/**
* settingTurnedOffStaysOffTestCase checks that a setting somebody
* turned off is read back as off. A setting turned off is written
* into the settings file with an empty value, and the reading used
* to put a starting value in place of anything empty, so a password
* requirement whose starting value is on could be unchecked and
* saved and came back checked. The reading now gives what the file
* holds.
*/
public function settingTurnedOffStaysOffTestCase()
{
$model = new RealProfileModel();
$file = "nsdefine('PASSWORD_REQUIRE_SYMBOL', \"\");\n" .
"nsdefine('PASSWORD_MIN_LEN', \"8\");\n";
$this->assertEqual("",
$model->matchDefine('PASSWORD_REQUIRE_SYMBOL', $file),
"a requirement turned off reads back as off");
$this->assertEqual("8",
$model->matchDefine('PASSWORD_MIN_LEN', $file),
"and a setting holding a value reads back as that value");
$this->assertEqual("",
$model->matchDefine('PASSWORD_REQUIRE_DIGIT', $file),
"a setting the file does not name reads as empty, which the "
. "upgrade to version 128 fills in from its constant");
}
/**
* Stores a profile value through the p() accessor, matching the caller
* shape (RealProfileModel::updateProfile) the guard allows.
*
* @param string $label profile setting name
* @param mixed $value value to store under $label
*/
public function updateProfile($label, $value)
{
C\p($label, $value, true);
}
}
/**
* Minimal stand-in whose updateProfile method is the authorized caller the
* p() set guard checks for, letting the set path be tested without writing
* Profile.php or touching the database.
*/
class ProfileModel
{
/**
* Stores a profile value through the p() accessor, matching the caller
* shape (ProfileModel::updateProfile) the guard allows.
*
* @param string $label profile setting name
* @param mixed $value value to store under $label
*/
public function updateProfile($label, $value)
{
C\p($label, $value, true);
}
}