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