/ tests / GitModelTest.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\library\UnitTest;
use seekquarry\yioop\models\GitModel;
use seekquarry\yioop\models\datasources\Sqlite3Manager;

/**
 * Unit tests for GitModel, which serves the files of a git page's bare
 * repository, builds the small listings a cloning client asks for, and
 * keeps each person's Git application code. The tests build a small
 * repository in PHP (no git program needed) and check the model keeps file
 * lookups inside the repository folder and generates the ref and packfile
 * listings in the form a client expects. Further tests save and read back
 * application codes against a throwaway private database to check a code
 * round-trips, a missing code returns nothing, a fresh code replaces an
 * older one, codes stay separate between people, and a never-expiring code
 * keeps its marker.
 *
 * @author Chris Pollett
 */
class GitModelTest extends UnitTest
{
    /**
     * Folder, relative to this test file, holding the repository built for
     * a test run
     */
    const TEST_DIR = '/test_files/git_model_test';
    /**
     * Filesystem path for the throwaway private database holding the
     * application-code table
     * @var string
     */
    public $private_db_path;
    /**
     * Absolute path of the test repository for the current test
     * @var string
     */
    public $repo_path;
    /**
     * The model under test, created without a database connection since the
     * methods tested only read files
     * @var GitModel
     */
    public $git_model;
    /**
     * Starts each test from an empty, freshly created bare repository and a
     * model that does no database work.
     */
    public function setUp()
    {
        $this->repo_path = __DIR__ . self::TEST_DIR;
        $this->deleteTree($this->repo_path);
        $repository = new L\GitRepository($this->repo_path);
        $repository->initBare("main");
        $this->git_model = new GitModel(C\DB_NAME, false);
        $tag = getmypid() . "_" . random_int(1000, 9999);
        $this->private_db_path = C\WORK_DIRECTORY . "/temp/" .
            "git_app_code_test_$tag.db";
        $private_db = new Sqlite3Manager();
        $private_db->connect("", "", "", $this->private_db_path);
        $private_db->execute("CREATE TABLE GIT_APP_CODE (
            USER_ID INTEGER PRIMARY KEY,
            APP_CODE VARCHAR(" . C\LONG_NAME_LEN . "),
            EXPIRES INTEGER)");
        $this->git_model->private_db = $private_db;
    }
    /**
     * Removes the test repository created during a test.
     */
    public function tearDown()
    {
        $this->deleteTree($this->repo_path);
        if ($this->git_model && $this->git_model->private_db) {
            $this->git_model->private_db->disconnect();
            unset(Sqlite3Manager::$active_connections[
                $this->git_model->private_db->connect_string]);
        }
        if ($this->private_db_path &&
                file_exists($this->private_db_path)) {
            unlink($this->private_db_path);
        }
    }
    /**
     * Deletes a folder and everything under it.
     *
     * @param string $path folder to remove
     */
    public function deleteTree($path)
    {
        if (!is_dir($path)) {
            return;
        }
        foreach (scandir($path) as $name) {
            if ($name == "." || $name == "..") {
                continue;
            }
            $full = $path . "/" . $name;
            if (is_dir($full)) {
                $this->deleteTree($full);
            } else {
                unlink($full);
            }
        }
        rmdir($path);
    }
    /**
     * Writes one loose Git object and returns its object name.
     *
     * @param string $type object kind
     * @param string $body raw object contents
     * @return string the object name (hex SHA-1)
     */
    public function writeLooseObject($type, $body)
    {
        $store = $type . " " . strlen($body) . "\0" . $body;
        $sha = sha1($store);
        $dir = $this->repo_path . "/objects/" . substr($sha, 0, 2);
        if (!is_dir($dir)) {
            mkdir($dir, 0777, true);
        }
        file_put_contents($dir . "/" . substr($sha, 2), gzcompress($store));
        return $sha;
    }
    /**
     * Writes a ref file pointing at an object name.
     *
     * @param string $ref_name ref path, for example refs/heads/main
     * @param string $sha object name the ref points at
     */
    public function writeRef($ref_name, $sha)
    {
        $full = $this->repo_path . "/" . $ref_name;
        $dir = dirname($full);
        if (!is_dir($dir)) {
            mkdir($dir, 0777, true);
        }
        file_put_contents($full, $sha . "\n");
    }
    /**
     * A file inside the repository folder resolves, while any path that
     * tries to climb out of the folder with ".." is refused.
     */
    public function filePathStaysInsideTestCase()
    {
        $inside = $this->git_model->repositoryFilePath(
            $this->repo_path, "HEAD");
        $this->assertTrue($inside !== false, "a real file inside resolves");
        $this->assertFalse($this->git_model->repositoryFilePath(
            $this->repo_path, "../../../../etc/passwd"),
            "a climbing path is refused");
        $this->assertFalse($this->git_model->repositoryFilePath(
            $this->repo_path, "objects/../../../../etc/hosts"),
            "a climbing path through a real sub folder is refused");
        $this->assertFalse($this->git_model->repositoryFilePath(
            $this->repo_path, "no/such/file"),
            "a missing file resolves to nothing");
    }
    /**
     * The info/refs listing names every branch and tag, one per line as an
     * object name and ref, and adds the peeled line for an annotated tag.
     */
    public function infoRefsListingTestCase()
    {
        $commit_sha = $this->writeLooseObject("commit",
            "tree " . str_repeat("a", 40) . "\n\nm\n");
        $this->writeRef("refs/heads/main", $commit_sha);
        $tag_body = "object $commit_sha\ntype commit\ntag v1\n" .
            "tagger A U Thor <a@example.com> 100 +0000\n\nrelease\n";
        $tag_sha = $this->writeLooseObject("tag", $tag_body);
        $this->writeRef("refs/tags/v1", $tag_sha);
        $expected = "$commit_sha\trefs/heads/main\n" .
            "$tag_sha\trefs/tags/v1\n" .
            "$commit_sha\trefs/tags/v1^{}\n";
        $this->assertEqual($expected,
            $this->git_model->infoRefs($this->repo_path),
            "info/refs lists branch, tag, and peeled annotated tag");
    }
    /**
     * The objects/info/packs listing names each packfile present, one per
     * line, ending with a blank line.
     */
    public function packsListingTestCase()
    {
        $pack_dir = $this->repo_path . "/objects/pack";
        $name_one = "pack-" . str_repeat("1", 40);
        $name_two = "pack-" . str_repeat("2", 40);
        file_put_contents("$pack_dir/$name_one.pack", "");
        file_put_contents("$pack_dir/$name_one.idx", "");
        file_put_contents("$pack_dir/$name_two.pack", "");
        $expected = "P $name_one.pack\nP $name_two.pack\n\n";
        $this->assertEqual($expected,
            $this->git_model->objectsInfoPacks($this->repo_path),
            "packs listing names each packfile and ends with a blank line");
    }
    /**
     * A saved application code, together with its expiry, is read back
     * exactly as it was stored.
     */
    public function setAndGetAppCodeTestCase()
    {
        $this->git_model->setAppCode(7, "codealpha", 2000000000);
        $record = $this->git_model->getAppCode(7);
        $this->assertTrue($record !== false,
            "a stored code is found for the person who owns it");
        $this->assertEqual("codealpha", $record['APP_CODE'],
            "the code is stored and read back unchanged");
        $this->assertEqual(2000000000, (int)$record['EXPIRES'],
            "the expiry time is stored and read back unchanged");
    }
    /**
     * A person who has never made a code gets nothing back rather than a
     * stray row.
     */
    public function missingAppCodeTestCase()
    {
        $record = $this->git_model->getAppCode(42);
        $this->assertTrue($record === false,
            "a person with no code gets false");
    }
    /**
     * Making a fresh code replaces the person's earlier one, leaving a
     * single code rather than piling them up.
     */
    public function refreshReplacesAppCodeTestCase()
    {
        $this->git_model->setAppCode(7, "firstcode", 100);
        $this->git_model->setAppCode(7, "secondcode", 200);
        $record = $this->git_model->getAppCode(7);
        $this->assertEqual("secondcode", $record['APP_CODE'],
            "the newest code is the one that is kept");
        $this->assertEqual(200, (int)$record['EXPIRES'],
            "the newest expiry is the one that is kept");
        $result = $this->git_model->private_db->execute(
            "SELECT COUNT(*) AS N FROM GIT_APP_CODE WHERE USER_ID = ?", [7]);
        $count = $this->git_model->private_db->fetchArray($result);
        $this->assertEqual(1, (int)$count['N'],
            "a person keeps only one code at a time");
    }
    /**
     * One person's code does not stand in for another's; each is stored
     * and read back on its own.
     */
    public function perPersonAppCodeTestCase()
    {
        $this->git_model->setAppCode(7, "alphacode", 100);
        $this->git_model->setAppCode(8, "betacode", 100);
        $this->assertEqual("alphacode",
            $this->git_model->getAppCode(7)['APP_CODE'],
            "the first person keeps their own code");
        $this->assertEqual("betacode",
            $this->git_model->getAppCode(8)['APP_CODE'],
            "the second person keeps their own code");
    }
    /**
     * A code set never to expire keeps the forever marker so the push
     * check can recognize it as always valid.
     */
    public function neverExpiryAppCodeTestCase()
    {
        $this->git_model->setAppCode(7, "forevercode", C\FOREVER);
        $record = $this->git_model->getAppCode(7);
        $this->assertEqual(C\FOREVER, (int)$record['EXPIRES'],
            "a never-expiring code keeps the forever marker");
    }
    /**
     * Saved statistics are read back for the same newest commit, are not
     * reused once that commit changes, and give nothing back when the file
     * is absent.
     */
    public function statisticsCacheTestCase()
    {
        $path = C\WORK_DIRECTORY . "/temp/git_stats_cache_" .
            getmypid() . "_" . random_int(1000, 9999) . ".json";
        $stats = ["commits" => 3, "files" => 5, "authors" => ["Ann" => 3]];
        $this->git_model->writeStatisticsCache($path, "headsha", $stats);
        $read = $this->git_model->readStatisticsCache($path, "headsha");
        $this->assertEqual(3, $read["commits"],
            "saved statistics are read back for the same commit");
        $stale = $this->git_model->readStatisticsCache($path, "othersha");
        $this->assertTrue($stale === false,
            "statistics saved for one commit are not used for another");
        unlink($path);
        $missing = $this->git_model->readStatisticsCache($path, "headsha");
        $this->assertTrue($missing === false,
            "a missing cache file gives nothing back");
    }
}
X