<?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\UnitTest;
use seekquarry\yioop\models\MailCloneModel;
use seekquarry\yioop\models\ProfileModel;
use seekquarry\yioop\models\datasources\Sqlite3Manager;
/**
* Tests for MailCloneModel. Covers the insert / fetch / cancel /
* progress-update / dedup-mark-and-check / GC paths against a
* throwaway sqlite DB created per test method so the
* access-control predicates can be exercised with multiple
* distinct USER_IDs side by side. The schema-creation in setUp
* mirrors what upgradeDatabaseVersion94 produces, so a drift
* between the live migration and the test setup would fail
* here first.
*
* @author Chris Pollett
*/
class MailCloneModelTest extends UnitTest
{
/**
* Filesystem path for the throwaway public DB
* @var string
*/
public $public_db_path;
/**
* Model under test
* @var MailCloneModel
*/
public $model;
/**
* Sets up an empty public DB with the MAIL_CLONE_JOB and
* MAIL_CLONE_SEEN tables and hands it to a fresh model.
*/
public function setUp()
{
$tag = getmypid() . "_" . random_int(1000, 9999);
$this->public_db_path = C\WORK_DIRECTORY . "/temp/" .
"mail_clone_test_public_$tag.db";
$public_db = new Sqlite3Manager();
$public_db->connect("", "", "", $this->public_db_path);
/* Build the tables from ProfileModel's definitions, the same ones the
live database uses, so this test tracks any schema change rather
than hand-copied CREATE TABLEs that could drift. */
$dbinfo = ["DBMS" => "Sqlite3", "DB_HOST" => ""];
$profile = new ProfileModel(C\DB_NAME, false);
$profile->initializeSql($public_db, $dbinfo);
$public_db->execute($profile->create_statements['MAIL_CLONE_JOB']);
$public_db->execute("CREATE INDEX MAIL_CLONE_JOB_USER_IDX " .
"ON MAIL_CLONE_JOB (USER_ID, STATUS)");
$public_db->execute($profile->create_statements['MAIL_CLONE_SEEN']);
$this->model = new MailCloneModel(C\DB_NAME, false);
$this->model->db = $public_db;
}
/**
* Disconnects + removes the throwaway DB.
*/
public function tearDown()
{
if ($this->model && $this->model->db) {
$this->model->db->disconnect();
/* Evict this test's own handle from the data-source layer's
process-wide connection cache (keyed by file path). A
plain disconnect leaves it cached, so if a later test in
the same run draws the same scratch-file name it would be
handed this still-open handle, rows and all. Only this
test's key is removed; shared handles are left alone. */
unset(Sqlite3Manager::$active_connections[
$this->model->db->connect_string]);
}
if ($this->public_db_path &&
file_exists($this->public_db_path)) {
unlink($this->public_db_path);
}
}
/**
* insertJob writes a pending row, returns its generated id,
* and initializes the counters and JSON state correctly.
* activeJobForUser then surfaces the same row by user id.
*/
public function insertAndFetchTestCase()
{
$job_id = $this->model->insertJob(7, 42, 'bob',
'add', false);
$this->assertTrue($job_id > 0,
'insertJob returned a positive new ID');
$job = $this->model->activeJobForUser(7);
$this->assertTrue($job !== null,
'activeJobForUser surfaces the freshly-inserted row');
$this->assertEqual('pending', $job['STATUS'],
'New job is in pending state');
$this->assertEqual('add', $job['MODE'],
'Mode is preserved');
$this->assertEqual('bob', $job['DESTINATION_USER'],
'Destination user is preserved');
$this->assertEqual(0, (int) $job['DRY_RUN'],
'DRY_RUN defaults to 0 when false passed');
$this->assertEqual('[]', $job['FOLDERS_DONE'],
'FOLDERS_DONE initialized to empty JSON array');
$this->assertEqual(0, (int) $job['IMPORTED'],
'IMPORTED counter starts at zero');
}
/**
* activeJobForUser must scope by USER_ID -- one user's
* pending job is invisible to another user.
*/
public function userScopeTestCase()
{
$model = $this->model;
$model->insertJob(7, 42, 'bob', 'add', false);
$this->assertTrue($model->activeJobForUser(7) !== null,
"User 7 sees their own job");
$this->assertTrue($model->activeJobForUser(8) === null,
"User 8 does not see user 7's job");
}
/**
* getJob refuses to return a row belonging to a different
* user, even when the caller knows the exact job id.
*/
public function getJobAccessControlTestCase()
{
$model = $this->model;
$job_id = $model->insertJob(7, 42, 'bob', 'add', false);
$this->assertTrue($model->getJob($job_id, 7) !== null,
"Owner sees their job by id");
$this->assertTrue($model->getJob($job_id, 8) === null,
"Non-owner does not see the job by id");
}
/**
* setStatus drives the lifecycle states. Once a job is
* 'done', activeJobForUser stops surfacing it (because the
* SELECT filters to pending/running), which is what the
* single-job-per-user gate relies on.
*/
public function setStatusTestCase()
{
$model = $this->model;
$job_id = $model->insertJob(7, 42, 'bob', 'add', false);
$model->setStatus($job_id, 'running');
$job = $model->activeJobForUser(7);
$this->assertEqual('running', $job['STATUS'],
"Status advanced to running");
$model->setStatus($job_id, 'done');
$this->assertTrue($model->activeJobForUser(7) === null,
"Done jobs are no longer active");
$still = $model->getJob($job_id, 7);
$this->assertEqual('done', $still['STATUS'],
"Done job is still fetchable by id with its status");
}
/**
* cancelJob only succeeds when the row belongs to the
* caller and is still in pending or running state. A done
* job is not retroactively cancelled.
*/
public function cancelJobTestCase()
{
$model = $this->model;
$job_id = $model->insertJob(7, 42, 'bob', 'add', false);
$model->cancelJob($job_id, 7);
$job = $model->getJob($job_id, 7);
$this->assertEqual('cancelled', $job['STATUS'],
"Cancelled by owner");
$job_id_2 = $model->insertJob(7, 42, 'bob', 'add', false);
$model->cancelJob($job_id_2, 8);
$job_2 = $model->getJob($job_id_2, 7);
$this->assertEqual('pending', $job_2['STATUS'],
"Non-owner cancel is a no-op");
$job_id_3 = $model->insertJob(7, 42, 'bob', 'add', false);
$model->setStatus($job_id_3, 'done');
$model->cancelJob($job_id_3, 7);
$job_3 = $model->getJob($job_id_3, 7);
$this->assertEqual('done', $job_3['STATUS'],
"Already-done job is not retroactively cancelled");
}
/**
* updateProgress advances counters and cursor fields, but
* silently drops attempts to set STATUS or USER_ID through
* the allow-list filter.
*/
public function updateProgressTestCase()
{
$model = $this->model;
$job_id = $model->insertJob(7, 42, 'bob', 'add', false);
$model->updateProgress($job_id, [
'CURRENT_FOLDER' => 'INBOX',
'CURRENT_UID' => 1234,
'IMPORTED' => 42,
'STATUS' => 'done',
'USER_ID' => 999,
]);
$job = $model->getJob($job_id, 7);
$this->assertEqual('INBOX', $job['CURRENT_FOLDER'],
"Allowed field CURRENT_FOLDER written through");
$this->assertEqual(1234, (int) $job['CURRENT_UID'],
"Allowed field CURRENT_UID written through");
$this->assertEqual(42, (int) $job['IMPORTED'],
"Allowed field IMPORTED written through");
$this->assertEqual('pending', $job['STATUS'],
"Disallowed STATUS field dropped by allow-list");
$this->assertEqual(7, (int) $job['USER_ID'],
"Disallowed USER_ID field dropped by allow-list");
}
/**
* markSeen + isSeen build the per-message dedup set. Marking
* the same (job, UIDVALIDITY, UID) twice is idempotent.
*/
public function dedupTestCase()
{
$model = $this->model;
$job_id = $model->insertJob(7, 42, 'bob', 'add', false);
$this->assertFalse($model->isSeen($job_id, 100, 5),
"Unmarked UID reads as not-seen");
$model->markSeen($job_id, 100, 5);
$this->assertTrue($model->isSeen($job_id, 100, 5),
"Marked UID reads as seen");
$model->markSeen($job_id, 100, 5);
$this->assertTrue($model->isSeen($job_id, 100, 5),
"Double-marking is idempotent");
$this->assertFalse($model->isSeen($job_id, 100, 6),
"Sibling UID still reads as not-seen");
$this->assertFalse($model->isSeen($job_id, 101, 5),
"Same UID under different UIDVALIDITY is not-seen");
}
/**
* purgeOldSeen drops dedup rows whose parent job is in
* STATUS='done' past the retention window. Active or
* pending jobs' dedup rows are left alone regardless of
* age.
*/
public function purgeOldSeenTestCase()
{
$model = $this->model;
$done_job = $model->insertJob(7, 42, 'bob', 'add', false);
$model->markSeen($done_job, 100, 1);
$model->markSeen($done_job, 100, 2);
$model->setStatus($done_job, 'done');
/* Backdate UPDATED_AT to a month + a day ago so the GC
window applies. setStatus bumped UPDATED_AT to now;
we overwrite directly through the underlying db. */
$cutoff = time() - (C\ONE_MONTH + C\ONE_DAY);
$model->db->execute(
"UPDATE MAIL_CLONE_JOB SET UPDATED_AT = ? " .
"WHERE ID = ?", [$cutoff, $done_job]);
$active_job = $model->insertJob(7, 42, 'carol',
'add', false);
$model->markSeen($active_job, 200, 9);
$model->purgeOldSeen();
$this->assertFalse($model->isSeen($done_job, 100, 1),
"Done job's dedup row is purged");
$this->assertFalse($model->isSeen($done_job, 100, 2),
"Done job's other dedup row is purged");
$this->assertTrue($model->isSeen($active_job, 200, 9),
"Active job's dedup rows are left alone");
}
/**
* listClaimable returns pending + running jobs across all
* users -- the daemon-only entrypoint that crosses the
* user-scope boundary on purpose.
*/
public function listClaimableTestCase()
{
$model = $this->model;
$pending_job_id = $model->insertJob(7, 42, 'bob',
'add', false);
$running_job_id = $model->insertJob(8, 43, 'carol',
'wipe', false);
$done_job_id = $model->insertJob(9, 44, 'dave',
'add', false);
$model->setStatus($running_job_id, 'running');
$model->setStatus($done_job_id, 'done');
$rows = $model->listClaimable();
$this->assertEqual(2, count($rows),
"Two claimable jobs (pending + running)");
$ids = array_map(fn ($row) => (int) $row['ID'], $rows);
$this->assertTrue(in_array($pending_job_id, $ids, true),
"Pending job listed");
$this->assertTrue(in_array($running_job_id, $ids, true),
"Running job listed");
$this->assertFalse(in_array($done_job_id, $ids, true),
"Done job not listed");
}
}