SeekquarrySeekquarry - Yioop Repo - Seekquarry

/ 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\library as L;
use seekquarry\yioop\models\Model;
use seekquarry\yioop\models\ProfileModel as RealProfileModel;
use seekquarry\yioop\library\UnitTest;

/**
 * ProfileModelTest holds the unit tests for ProfileModel, and above all 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()
    {
    }
    /**
     * tearDown deletes the themes folder and the resources folder that the
     * theme cases write in, under tests/test_files, so each case starts
     * with neither.
     */
    public function tearDown()
    {
        $model = new Model();
        foreach ([TestThemesProfileModel::THEMES, self::TEST_RESOURCES] as
            $folder) {
            if (file_exists($folder)) {
                $model->db->unlinkRecursive($folder);
            }
        }
    }
    /**
     * TEST_RESOURCES is the folder the theme cases use in place of the
     * site's app/resources, so the domains' auxiliary.css files they make
     * are in the tests folder.
     */
    const TEST_RESOURCES = C\TEST_DIR . "/test_files/theme_resources";
    /**
     * themeModel gives a ProfileModel for the theme cases that keeps its
     * themes and the domains' auxiliary.css files under tests/test_files,
     * so a case writes nothing in the site's app folder.
     *
     * @return TestThemesProfileModel the model
     */
    public function themeModel()
    {
        $model = (new \ReflectionClass(TestThemesProfileModel::class))
            ->newInstanceWithoutConstructor();
        $model->resource_path = self::TEST_RESOURCES;
        L\makePath($model->themeFolder());
        return $model;
    }
    /**
     * profileRecordsNoSiteImageTestCase checks that Profile.php is not
     * written with the site's logos, favicon or background: each is found
     * by the name of its role in the site folder, so no profile field
     * names which uploaded file plays which role. Before, LOGO_SMALL,
     * LOGO_MEDIUM, LOGO_LARGE, FAVICON and BACKGROUND_IMAGE were profile
     * fields holding the uploaded files' addresses.
     */
    public function profileRecordsNoSiteImageTestCase()
    {
        $model = new RealProfileModel(C\p('DB_NAME'), false);
        $this->assertEqual(array_values(array_intersect(
            $model->profile_fields, ['LOGO_SMALL', 'LOGO_MEDIUM',
            'LOGO_LARGE', 'FAVICON', 'BACKGROUND_IMAGE'])), [],
            "no profile field names a site image");
    }
    /**
     * profileHoldsOnlyFieldsAPageSetsTestCase checks that Profile.php is not
     * written with fields no page lets an administrator change.
     * GIT_MAX_PUSH_SIZE is no longer a setting; GROUP_ITEM, PRIVATE_DBMS,
     * PRIVATE_DB_HOST, PRIVATE_DB_USER and PRIVATE_DB_PASSWORD were in the
     * list though no page set them, so they now come from Config.php.
     */
    public function profileHoldsOnlyFieldsAPageSetsTestCase()
    {
        $model = new RealProfileModel(C\p('DB_NAME'), false);
        $this->assertEqual(array_values(array_intersect(
            $model->profile_fields, ['GIT_MAX_PUSH_SIZE', 'GROUP_ITEM',
            'PRIVATE_DBMS', 'PRIVATE_DB_HOST', 'PRIVATE_DB_USER',
            'PRIVATE_DB_PASSWORD'])), [],
            "no profile field lacks a page to set it");
    }
    /**
     * shouldKeepZeroTestCase checks that shouldKeepZero gives 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)");
        $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");
    }
    /**
     * themeFileNameTestCase checks that 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");
    }
    /**
     * themeFolderTestCase checks that every theme the site has sits in the
     * one folder, whichever domain
     * uses it.
     */
    public function themeFolderTestCase()
    {
        $model = (new \ReflectionClass(RealProfileModel::class))
            ->newInstanceWithoutConstructor();
        $this->assertEqual($model->themeFolder(), C\APP_DIR . "/css",
            "the site keeps its themes in the app css folder");
    }
    /**
     * searchStylesheetIsNotAThemeTestCase checks that search.css, which
     * shares the themes folder, is treated as the site's own
     * stylesheet, so it is not offered, written, linked or deleted as a
     * theme. The case reads a themes folder of its own, which
     * TestThemesProfileModel names, so no other process using the site's
     * app/css folder can change the file it compares.
     */
    public function searchStylesheetIsNotAThemeTestCase()
    {
        $model = $this->themeModel();
        L\makePath($model->themeFolder());
        $search_file = $model->themeFolder() . "/search.css";
        $search_rules = "/* the site's search rules */";
        file_put_contents($search_file, $search_rules);
        $this->assertFalse(in_array("search", $model->getThemeNames()),
            "search.css is not listed among the themes");
        $this->assertFalse($model->saveThemeStylesheet("search",
            "body { color: red; }"), "a theme named search is not written");
        $model->deleteTheme("search");
        $this->assertEqual((string)@file_get_contents($search_file),
            $search_rules, "search.css is neither written nor deleted");
        $this->assertFalse($model->linkThemeToDomain("search",
            "search-link.example"), "no domain is linked to search.css");
        unlink($search_file);
        $this->cleanDomain("search-link.example");
    }
    /**
     * deleteThemeRemovesDomainLinksTestCase checks deleting a theme. A
     * domain uses the theme its auxiliary.css links to; deleting the
     * theme first takes away every domain's link to it, so no domain is
     * left with a link to a file that is gone, and leaves a domain using
     * another theme alone.
     */
    public function deleteThemeRemovesDomainLinksTestCase()
    {
        $model = $this->themeModel();
        $model->saveThemeStylesheet("linked", "body { color: red; }");
        $model->saveThemeStylesheet("other", "body { color: blue; }");
        $hosts = ["one-link.example", "two-link.example",
            "other-link.example"];
        $this->assertTrue($model->linkThemeToDomain("linked", $hosts[0]) &&
            $model->linkThemeToDomain("linked", $hosts[1]) &&
            $model->linkThemeToDomain("other", $hosts[2]),
            "each domain is linked to its theme");
        $this->assertEqual($model->domainTheme($hosts[0]), "linked",
            "a domain's theme is the one its auxiliary.css links to");
        $this->assertEqual(file_get_contents(
            L\ResourceFolders::domainStylesheetFile($hosts[1],
            self::TEST_RESOURCES)),
            "body { color: red; }", "the link reads as the theme's rules");
        $model->saveThemeStylesheet("linked", "body { color: green; }");
        $this->assertEqual(file_get_contents(
            L\ResourceFolders::domainStylesheetFile($hosts[0],
            self::TEST_RESOURCES)),
            "body { color: green; }", "an edit of the theme shows through");
        $model->deleteTheme("linked");
        foreach ([$hosts[0], $hosts[1]] as $host) {
            $link = L\ResourceFolders::domainStylesheetFile($host,
                self::TEST_RESOURCES);
            $this->assertFalse(file_exists($link) || is_link($link),
                "$host loses its link to the deleted theme");
        }
        $this->assertEqual($model->domainTheme($hosts[2]), "other",
            "a domain using another theme keeps it");
        $this->assertTrue($model->linkThemeToDomain("", $hosts[2]) &&
            $model->domainTheme($hosts[2]) === "",
            "linking no theme takes the domain's link away");
        $this->cleanTheme("other");
        foreach ($hosts as $host) {
            $this->cleanDomain($host);
        }
    }
    /**
     * themeSaveReachesACopyTestCase checks saving a theme where the system
     * allows neither kind of link. a domain's
     * auxiliary.css is a copy of its theme; saving the theme writes the
     * copy too, so the domain shows the edit as a linked one does.
     */
    public function themeSaveReachesACopyTestCase()
    {
        $model = $this->themeModel();
        $model->saveThemeStylesheet("copied", "body { color: red; }");
        $host = "copy-link.example";
        $copy = L\ResourceFolders::domainStylesheetFile($host,
            self::TEST_RESOURCES);
        L\makePath(dirname($copy));
        copy($model->themeFolder() . "/copied.css", $copy);
        $this->assertEqual($model->domainTheme($host), "copied",
            "a copy is matched to its theme by its rules");
        $model->saveThemeStylesheet("copied", "body { color: blue; }");
        $this->assertEqual(file_get_contents($copy), "body { color: blue; }",
            "saving the theme writes the domain's copy too");
        $this->cleanTheme("copied");
        $this->cleanDomain($host);
    }
    /**
     * themeSaveGoesThroughTheWebSiteTestCase checks how a theme is saved.
     * Where Yioop serves its own pages it keeps files it has served in
     * memory, so a theme saved past it went on being served with its old
     * rules; a save goes through the web site's filePutContents, which
     * keeps that copy current.
     */
    public function themeSaveGoesThroughTheWebSiteTestCase()
    {
        $model = $this->themeModel();
        $model->web_site = new class {
            /** $written lists the files this stand-in for the web site wrote */
            public $written = [];
            /**
             * filePutContents records the name of the file written and
             * writes it.
             *
             * @param string $filename file to write
             * @param string $data what to write
             * @return int bytes written
             */
            public function filePutContents($filename, $data)
            {
                $this->written[] = basename($filename);
                return file_put_contents($filename, $data);
            }
        };
        $model->saveThemeStylesheet("served", "body { color: red; }");
        $this->assertEqual($model->web_site->written, ["served.css"],
            "the theme is written through the web site");
        $model->web_site = null;
        $this->cleanTheme("served");
    }
    /**
     * modelsKeepTheWebSiteTestCase checks that a controller hands each
     * model it makes the WebSite serving pages,
     * so the model's file writes keep the site's cache of served files
     * current. ProfileModel and four other models with constructors of
     * their own dropped it, so a theme saved on the Appearance screen
     * went on being served with its old rules.
     */
    public function modelsKeepTheWebSiteTestCase()
    {
        $web_site = new \stdClass();
        foreach (["Crawl", "Cron", "Parallel", "Phrase", "Profile"] as
            $name) {
            $class = C\NS_MODELS . $name . "Model";
            $model = new $class(C\p('DB_NAME'), false, $web_site);
            $this->assertTrue($model->web_site === $web_site,
                "{$name}Model keeps the WebSite it is given");
        }
    }
    /**
     * cleanDomain is called at the end of a theme case to take away the
     * domain folder the case made and the auxiliary.css inside it, so the
     * next case starts with no such domain.
     *
     * @param string $host the domain the folder is for
     */
    public function cleanDomain($host)
    {
        $folder = L\ResourceFolders::domainPath($host,
            self::TEST_RESOURCES);
        $link = "$folder/" . L\ResourceFolders::DOMAIN_STYLESHEET;
        if (file_exists($link) || is_link($link)) {
            unlink($link);
        }
        if (is_dir($folder)) {
            @rmdir($folder);
        }
    }
    /**
     * themeStylesheetRoundTripTestCase checks that 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 = $this->themeModel();
        $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");
    }
    /**
     * deleteThemeTestCase checks that 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 = $this->themeModel();
        $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");
    }
    /**
     * cleanTheme is called at the end of a theme case to delete the theme
     * file the case wrote, so the next case does not find that theme.
     *
     * @param string $name name of the theme to remove
     */
    public function cleanTheme($name)
    {
        $model = $this->themeModel();
        $css_file = $model->themeFolder() . "/$name.css";
        if (file_exists($css_file)) {
            unlink($css_file);
        }
    }
    /**
     * setThenReadTestCase checks that a value stored through
     * RealProfileModel::updateProfile, the one place allowed to store it,
     * where RealProfileModel is the name this file gives ProfileModel,
     * 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");
    }
    /**
     * falsyValueSticksTestCase checks that a stored zero is 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");
    }
    /**
     * seedsFromConstantTestCase checks that a label never stored falls back
     * to the constant of that name set when Yioop starts.
     */
    public function seedsFromConstantTestCase()
    {
        $this->assertEqual(C\p('NAME_LEN'), C\NAME_LEN,
            "an unset label seeds from the boot constant of the same name");
    }
    /**
     * undefinedThrowsTestCase checks that reading a label with no stored
     * value and no constant of that name throws an exception.
     */
    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");
    }
    /**
     * unauthorizedSetThrowsTestCase checks that storing a value from anywhere
     * other than RealProfileModel::updateProfile is refused, where
     * RealProfileModel is the name this file gives ProfileModel, 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");
    }
    /**
     * profileWrittenElsewhereLeavesRunningValuesTestCase writes a profile
     * whose MIN_RESULTS_TO_GROUP is empty to a folder that is not this
     * process's work directory. The running process must keep its own
     * value. When it took the empty value, later code multiplying by it
     * stopped the whole unit test run with a type error.
     */
    public function profileWrittenElsewhereLeavesRunningValuesTestCase()
    {
        $before = C\p('MIN_RESULTS_TO_GROUP');
        $folder = C\TEST_DIR . "/test_files/profile_elsewhere_" . getmypid();
        if (!file_exists($folder)) {
            mkdir($folder, 0777, true);
        }
        $model = new RealProfileModel();
        $profile = $model->getProfile($folder);
        $profile['MIN_RESULTS_TO_GROUP'] = "";
        $model->updateProfile($folder, $profile, $profile);
        unlink($folder . C\PROFILE_FILE_NAME);
        rmdir($folder);
        $this->assertEqual(C\p('MIN_RESULTS_TO_GROUP'), $before,
            "a profile written to another folder leaves the running value");
    }
    /**
     * databaseNamesNeverWrittenEmptyTestCase checks what a save writes for
     * the database names. A public database name given as empty is written
     * as public_default, and the private database's name is not written at
     * all, since only Config.php or LocalConfig.php sets it. A settings file
     * holding an empty private name once made the site open a database
     * called .db.
     */
    public function databaseNamesNeverWrittenEmptyTestCase()
    {
        $folder = C\TEST_DIR . "/test_files/profile_test_" . getmypid();
        if (!file_exists($folder)) {
            mkdir($folder, 0777, true);
        }
        $model = new RealProfileModel();
        $profile = $model->getProfile($folder);
        $profile['DB_NAME'] = "";
        $profile['PRIVATE_DB_NAME'] = "";
        $model->updateProfile($folder, $profile, $profile);
        $written = (string)file_get_contents($folder . C\PROFILE_FILE_NAME);
        $this->assertEqual($model->matchDefine('DB_NAME', $written),
            "public_default", "an empty public database name is written " .
            "as public_default");
        $this->assertEqual(strpos($written, "PRIVATE_DB_NAME"), false,
            "the private database name is not written to the profile");
        unlink($folder . C\PROFILE_FILE_NAME);
        rmdir($folder);
    }
    /**
     * updateProfile stores a profile value through p(), the function that
     * reads and stores profile values. p() allows a store only from a
     * method of this name in a class named ProfileModel, as
     * RealProfileModel::updateProfile is, where RealProfileModel is the
     * name this file gives ProfileModel.
     *
     * @param string $label profile setting name
     * @param mixed $value value to store under $label
     */
    public function updateProfile($label, $value)
    {
        C\p($label, $value, true);
    }
}
/**
 * ProfileModel here is a small stand-in whose updateProfile method is the one
 * caller p() allows to store a value, letting the set path be tested without
 * writing Profile.php or touching the database.
 */
class ProfileModel
{
    /**
     * updateProfile stores a profile value through p(), the function that
     * reads and stores profile values, which allows a store only from
     * this method.
     *
     * @param string $label profile setting name
     * @param mixed $value value to store under $label
     */
    public function updateProfile($label, $value)
    {
        C\p($label, $value, true);
    }
}
/**
 * TestThemesProfileModel is a ProfileModel whose themes folder is one of
 * its own in the tests folder, so the theme cases of ProfileModelTest
 * write nothing in the site's app/css folder.
 */
class TestThemesProfileModel extends RealProfileModel
{
    /**
     * THEMES is the folder under tests/test_files that this model keeps
     * its themes in.
     */
    const THEMES = C\TEST_DIR . "/test_files/theme_css";
    /**
     * themeFolder gives THEMES, the folder this model keeps its themes in,
     * in place of the site's app/css.
     * @return string the folder's path
     */
    public function themeFolder()
    {
        return self::THEMES;
    }
}