/ tests / MailCloneJobTest.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\library\media_jobs\MailCloneJob;

/**
 * Tests for MailCloneJob's small parser helpers. The end-to-end
 * clone flow (open IMAP, walk folders, fetch, append) is tested
 * indirectly through MailCloneModel's coverage of the persistence
 * surface plus these unit tests of the IMAP-response parsers;
 * the network-tier integration is exercised at runtime by the
 * MailCloneJob smoke test through MediaUpdater.
 *
 * The parser helpers under test are protected on MailCloneJob, so
 * this test class extends MailCloneJob to surface them. The
 * subclass also stubs init() to skip the DB and model wiring,
 * keeping the tests isolated from the database.
 *
 * @author Chris Pollett
 */
class MailCloneJobTest extends UnitTest
{
    /**
     * Job-under-test instance, with the test subclass init stub
     * that skips DB connection. Methods are exposed for direct
     * call through the public helpers in TestableMailCloneJob.
     * @var TestableMailCloneJob
     */
    public $job;
    /**
     * Builds a TestableMailCloneJob whose init() stub leaves the
     * model fields unset so the parser-helper tests do not need
     * a real DB connection.
     */
    public function setUp()
    {
        $this->job = new TestableMailCloneJob();
    }
    /**
     * No-op teardown: the parser tests hold no resources that
     * need releasing. UnitTest::tearDown is abstract so a
     * concrete implementation is required even when empty.
     */
    public function tearDown()
    {
    }
    /**
     * parseUidValidity pulls the UIDVALIDITY token out of a
     * SELECT response's untagged OK line. Multiple bracket
     * tokens may appear; the parser picks the right one.
     */
    public function parseUidValidityTestCase()
    {
        $job = $this->job;
        $result = $job->parseUidValidityPublic([
            '* 5 EXISTS',
            '* 0 RECENT',
            '* OK [UIDVALIDITY 1701234567] UIDs valid',
            '* OK [UIDNEXT 42] Predicted next UID',
            '* FLAGS (\\Answered \\Flagged \\Deleted)',
        ]);
        $this->assertEqual(1701234567, $result,
            "UIDVALIDITY extracted from bracket token");
    }
    /**
     * parseUidValidity returns 0 when no UIDVALIDITY token is
     * present (defensive default; callers treat 0 as
     * server-quirk fallback).
     */
    public function parseUidValidityMissingTestCase()
    {
        $job = $this->job;
        $result = $job->parseUidValidityPublic([
            '* 5 EXISTS',
            '* OK [UIDNEXT 42] Predicted next UID',
        ]);
        $this->assertEqual(0, $result,
            "Missing UIDVALIDITY yields 0");
    }
    /**
     * parseSearchUids handles single-line SEARCH responses
     * with multiple UIDs.
     */
    public function parseSearchUidsTestCase()
    {
        $job = $this->job;
        $result = $job->parseSearchUidsPublic([
            '* SEARCH 1 2 3 5 8 13',
        ]);
        $this->assertEqual([1, 2, 3, 5, 8, 13], $result,
            "UIDs extracted from SEARCH line");
    }
    /**
     * parseSearchUids returns empty list when SEARCH found
     * nothing (untagged line is "* SEARCH" with no operands).
     */
    public function parseSearchUidsEmptyTestCase()
    {
        $job = $this->job;
        $result = $job->parseSearchUidsPublic([
            '* SEARCH',
        ]);
        $this->assertEqual([], $result,
            "Empty SEARCH yields empty list");
    }
    /**
     * parseFetchMeta extracts RFC822.SIZE, FLAGS, and
     * INTERNALDATE from a single FETCH untagged line.
     */
    public function parseFetchMetaTestCase()
    {
        $job = $this->job;
        $result = $job->parseFetchMetaPublic([
            '* 1 FETCH (UID 42 RFC822.SIZE 12345 ' .
                'FLAGS (\\Seen \\Answered) ' .
                'INTERNALDATE "26-May-2026 17:00:00 -0700")',
        ], 42);
        $this->assertEqual(12345, $result['size'],
            "RFC822.SIZE parsed");
        $this->assertEqual(['\\Seen', '\\Answered'],
            $result['flags'], "FLAGS list parsed");
        $expected_ts = strtotime("26-May-2026 17:00:00 -0700");
        $this->assertEqual($expected_ts,
            $result['internal_date'],
            "INTERNALDATE parsed to unix ts");
    }
    /**
     * parseFetchMeta defaults each missing field separately so
     * a server quirk dropping one item does not poison the
     * others.
     */
    public function parseFetchMetaPartialTestCase()
    {
        $job = $this->job;
        $result = $job->parseFetchMetaPublic([
            '* 1 FETCH (UID 42 RFC822.SIZE 99)',
        ], 42);
        $this->assertEqual(99, $result['size'],
            "Size parsed even without flags/date");
        $this->assertEqual([], $result['flags'],
            "Missing flags default to empty list");
        $this->assertEqual(0, $result['internal_date'],
            "Missing internal_date defaults to 0");
    }
    /**
     * parseFetchMeta on a FLAGS () (empty flag list) returns
     * an empty array, not [''].
     */
    public function parseFetchMetaEmptyFlagsTestCase()
    {
        $job = $this->job;
        $result = $job->parseFetchMetaPublic([
            '* 1 FETCH (UID 42 RFC822.SIZE 99 FLAGS ())',
        ], 42);
        $this->assertEqual([], $result['flags'],
            "Empty FLAGS list yields empty array");
    }
    /**
     * parseFetchBody returns the literal payload corresponding
     * to the FETCH line. Literals are keyed by the index of
     * the line that announced them; the parser walks untagged
     * lines and returns the first literal it finds attached to
     * a FETCH line.
     */
    public function parseFetchBodyTestCase()
    {
        $job = $this->job;
        $untagged = ['* 1 FETCH (UID 42 BODY[] {16}'];
        $literals = [0 => "raw rfc822 bytes"];
        $result = $job->parseFetchBodyPublic($untagged,
            $literals, 42);
        $this->assertEqual("raw rfc822 bytes", $result,
            "Body literal extracted from parallel array");
    }
    /**
     * parseFetchBody returns null when no FETCH line has an
     * attached literal (defensive; caller treats null as a
     * transient failure to retry on the next tick).
     */
    public function parseFetchBodyMissingTestCase()
    {
        $job = $this->job;
        $result = $job->parseFetchBodyPublic([
            '* OK Done',
        ], [], 42);
        $this->assertEqual(null, $result,
            "No FETCH+literal yields null");
    }
}

/**
 * Test-only subclass of MailCloneJob that:
 *   (a) stubs init() so the DB connection and model wiring is
 *       skipped, since the parser tests do not need them; and
 *   (b) re-exports the protected parser helpers as public methods
 *       so the test class can call them directly without
 *       reflection.
 *
 * Defining the subclass in the same file as MailCloneJobTest
 * matches Yioop's existing test convention of co-locating
 * narrow test-only helpers next to the test that needs them.
 */
class TestableMailCloneJob extends MailCloneJob
{
    /**
     * Empty stub so the parent constructor does not attempt to
     * open a DB connection. The parser tests do not need any
     * model state.
     */
    public function init()
    {
    }
    /**
     * Public passthrough for parseUidValidity.
     *
     * @param array $untagged untagged lines from SELECT
     * @return int UIDVALIDITY or 0
     */
    public function parseUidValidityPublic($untagged)
    {
        return $this->parseUidValidity($untagged);
    }
    /**
     * Public passthrough for parseSearchUids.
     *
     * @param array $untagged untagged lines from UID SEARCH
     * @return array list of UIDs
     */
    public function parseSearchUidsPublic($untagged)
    {
        return $this->parseSearchUids($untagged);
    }
    /**
     * Public passthrough for parseFetchMeta.
     *
     * @param array $untagged untagged lines from meta FETCH
     * @param int $uid UID this fetch was for
     * @return array meta values
     */
    public function parseFetchMetaPublic($untagged, $uid)
    {
        return $this->parseFetchMeta($untagged, $uid);
    }
    /**
     * Public passthrough for parseFetchBody.
     *
     * @param array $untagged untagged lines
     * @param array $literals parallel literals array
     * @param int $uid UID for diagnostic logging
     * @return string|null body bytes or null
     */
    public function parseFetchBodyPublic($untagged, $literals,
        $uid)
    {
        return $this->parseFetchBody($untagged, $literals,
            $uid);
    }
}
X