/ tests / ProfileModelTest.php
<?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);
    }
}
X