/ tests / MailCloneModelTest.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\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");
    }
}
X