<?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");
}
}