381 lines
15 KiB
PHP
381 lines
15 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
require_once dirname(__DIR__, 2) . '/feca_mailshots_plugin/src/autoload.php';
|
|
|
|
use FecaMailshots\Application\DataSourceService;
|
|
use FecaMailshots\Application\DslCompiler;
|
|
use FecaMailshots\Application\DslValidator;
|
|
use FecaMailshots\Application\ImapAppender;
|
|
use FecaMailshots\Application\MailCredentialsProvider;
|
|
use FecaMailshots\Application\MailshotRunService;
|
|
use FecaMailshots\Application\SmtpSender;
|
|
use FecaMailshots\Application\TemplateRenderer;
|
|
use FecaMailshots\Domain\DslParser;
|
|
use FecaMailshots\Infrastructure\DatabaseSourceMetadataProvider;
|
|
use FecaMailshots\Infrastructure\Env;
|
|
use FecaMailshots\Infrastructure\PdoDatabaseRouter;
|
|
use FecaMailshots\Repository\LastRunRepository;
|
|
use FecaMailshots\Repository\AttachmentRepository;
|
|
use FecaMailshots\Repository\MailshotQueryRepository;
|
|
use FecaMailshots\Repository\MailshotRepository;
|
|
|
|
final class FakeCreds implements MailCredentialsProvider {
|
|
public function credentials(): ?array {
|
|
return [
|
|
'smtp_host' => 'smtp.test.local',
|
|
'smtp_port' => 587,
|
|
'smtp_user' => 'user',
|
|
'smtp_password' => 'pass',
|
|
'smtp_from_email' => 'editor@example.org',
|
|
'smtp_from_name' => 'Editor',
|
|
'smtp_require_tls' => false,
|
|
'imap_host' => 'imap.test.local',
|
|
'imap_port' => 993,
|
|
'imap_user' => 'user',
|
|
'imap_password' => 'pass',
|
|
'imap_sent_folder' => 'Sent',
|
|
'imap_mailbox_flags' => '/imap/ssl',
|
|
];
|
|
}
|
|
}
|
|
|
|
final class FakeSmtp implements SmtpSender {
|
|
public int $count = 0;
|
|
/** @var list<array{filename:string,mime_type:string,content_bytes:string}> */
|
|
public array $lastAttachments = [];
|
|
public function send(array $credentials, array $to, array $cc, array $bcc, string $subject, string $htmlBody, ?string $replyTo = null, array $attachments = []): array {
|
|
$this->count++;
|
|
$this->lastAttachments = $attachments;
|
|
if ($to === []) {
|
|
throw new RuntimeException('No recipients');
|
|
}
|
|
return ['raw_mime' => "To: " . implode(',', $to) . "\r\nSubject: {$subject}\r\n\r\n{$htmlBody}"];
|
|
}
|
|
}
|
|
|
|
final class FakeImap implements ImapAppender {
|
|
public int $count = 0;
|
|
public function appendSent(array $credentials, string $rawMime, string $attemptId): void {
|
|
$this->count++;
|
|
}
|
|
}
|
|
|
|
Env::load(dirname(__DIR__, 2) . '/credentials/.env');
|
|
|
|
$dbConfig = [
|
|
'MYSQL_HOST' => '127.0.0.1',
|
|
'MYSQL_PORT' => (string) (getenv('MYSQL_TUNNEL_LOCAL_PORT') ?: '13306'),
|
|
'MYSQL_USER' => Env::require('REMOTE_MYSQL_USER'),
|
|
'MYSQL_PASSWORD' => Env::require('REMOTE_MYSQL_PASSWORD'),
|
|
'MAILSHOTS_REMOTE_MYSQL_DB' => Env::require('MAILSHOTS_REMOTE_MYSQL_DB'),
|
|
'MEMBERS_REMOTE_MYSQL_DB' => Env::require('MEMBERS_REMOTE_MYSQL_DB'),
|
|
'FEN_REMOTE_MYSQL_DB' => Env::require('FEN_REMOTE_MYSQL_DB'),
|
|
];
|
|
|
|
$router = new PdoDatabaseRouter($dbConfig);
|
|
$metadata = new DatabaseSourceMetadataProvider($router);
|
|
$parser = new DslParser();
|
|
$validator = new DslValidator($metadata);
|
|
$compiler = new DslCompiler($metadata);
|
|
$queryRepo = new MailshotQueryRepository($router);
|
|
$mailshotRepo = new MailshotRepository($router);
|
|
$attachmentRepo = new AttachmentRepository($router);
|
|
$dataSourceService = new DataSourceService($queryRepo, $router, $parser, $validator, $compiler, $metadata, $mailshotRepo);
|
|
$lastRunRepo = new LastRunRepository($router);
|
|
|
|
$smtp = new FakeSmtp();
|
|
$imap = new FakeImap();
|
|
$run = new MailshotRunService(
|
|
$mailshotRepo,
|
|
$queryRepo,
|
|
$attachmentRepo,
|
|
$dataSourceService,
|
|
new TemplateRenderer(),
|
|
$smtp,
|
|
$imap,
|
|
new FakeCreds(),
|
|
$lastRunRepo
|
|
);
|
|
|
|
$membersDb = $router->membersDbName();
|
|
$stmt = $router->membersPdo()->prepare('SELECT table_name FROM information_schema.tables WHERE table_schema = :schema ORDER BY table_name ASC LIMIT 1');
|
|
$stmt->execute(['schema' => $membersDb]);
|
|
$table = $stmt->fetchColumn();
|
|
if (!is_string($table) || $table === '') {
|
|
fwrite(STDERR, "No source table in members db\n");
|
|
exit(1);
|
|
}
|
|
|
|
$dsl = $membersDb . '.' . $table;
|
|
$uniq = gmdate('Ymd_His') . '_' . bin2hex(random_bytes(3));
|
|
$queryName = 'phase4_ds_' . $uniq;
|
|
$queryId = null;
|
|
$mailshotId = null;
|
|
$attachmentId = null;
|
|
$attachmentName = 'phase4_att_' . $uniq;
|
|
$generatedPdfPaths = [];
|
|
$generatedPdfDirs = [];
|
|
|
|
try {
|
|
$savedQ = $dataSourceService->save(null, $queryName, $dsl);
|
|
if (($savedQ['ok'] ?? false) !== true) {
|
|
fwrite(STDERR, 'Failed saving datasource: ' . json_encode($savedQ) . "\n");
|
|
exit(1);
|
|
}
|
|
$queryId = (int) $savedQ['id'];
|
|
|
|
$attachmentId = $attachmentRepo->create([
|
|
'name' => $attachmentName,
|
|
'file_name' => 'phase4-note.txt',
|
|
'mime_type' => 'text/plain',
|
|
'file_bytes' => 'phase4 attachment bytes',
|
|
]);
|
|
|
|
$savedM = $mailshotRepo->create([
|
|
'Purpose' => 'Phase4 ' . $uniq,
|
|
'DataSource' => $queryName,
|
|
'CC' => '',
|
|
'BCC' => '',
|
|
'Subject' => 'Hello {{ id|default(ID|default("recipient")) }}',
|
|
'Message' => '<p>Hi {{ name|default(Name|default("there")) }}</p>',
|
|
'PDFAttachment' => '<p>PDF {{ id|default("none") }}</p>',
|
|
'AttachmentNames' => json_encode([$attachmentName], JSON_UNESCAPED_SLASHES),
|
|
'PDFFilenameDerivedFrom' => '',
|
|
'ReplyTo' => '',
|
|
]);
|
|
$mailshotId = (int) $savedM;
|
|
|
|
$render = $run->renderTest($mailshotId, 0);
|
|
if (($render['ok'] ?? false) !== true) {
|
|
fwrite(STDERR, 'renderTest failed: ' . json_encode($render) . "\n");
|
|
exit(1);
|
|
}
|
|
|
|
$pdfBatch = $run->generatePdfBatch($mailshotId);
|
|
if (($pdfBatch['ok'] ?? false) !== true) {
|
|
fwrite(STDERR, 'generatePdfBatch failed: ' . json_encode($pdfBatch) . "\n");
|
|
exit(1);
|
|
}
|
|
if ((int) ($pdfBatch['generated_count'] ?? 0) <= 0) {
|
|
fwrite(STDERR, "generatePdfBatch should generate at least one PDF\n");
|
|
exit(1);
|
|
}
|
|
$mergedPdfPath = (string) ($pdfBatch['merged_pdf_path'] ?? '');
|
|
if ($mergedPdfPath === '' || !is_file($mergedPdfPath)) {
|
|
fwrite(STDERR, "generatePdfBatch should return a merged PDF temp file path\n");
|
|
exit(1);
|
|
}
|
|
$generatedPdfPaths[] = $mergedPdfPath;
|
|
if (file_get_contents($mergedPdfPath, false, null, 0, 4) !== '%PDF') {
|
|
fwrite(STDERR, "generatePdfBatch merged_pdf_path should point to a PDF payload\n");
|
|
exit(1);
|
|
}
|
|
if (array_key_exists('merged_pdf_bytes', $pdfBatch)) {
|
|
fwrite(STDERR, "generatePdfBatch should not return merged PDF bytes\n");
|
|
exit(1);
|
|
}
|
|
$pdfFiles = is_array($pdfBatch['files'] ?? null) ? $pdfBatch['files'] : [];
|
|
if ($pdfFiles === []) {
|
|
fwrite(STDERR, "generatePdfBatch should return individual PDF file rows\n");
|
|
exit(1);
|
|
}
|
|
foreach ($pdfFiles as $file) {
|
|
$path = (string) ($file['path'] ?? '');
|
|
if ($path === '' || !is_file($path)) {
|
|
fwrite(STDERR, "generatePdfBatch individual PDF path missing\n");
|
|
exit(1);
|
|
}
|
|
$generatedPdfPaths[] = $path;
|
|
if (array_key_exists('content_bytes', $file)) {
|
|
fwrite(STDERR, "generatePdfBatch file rows should not return PDF bytes\n");
|
|
exit(1);
|
|
}
|
|
if (file_get_contents($path, false, null, 0, 4) !== '%PDF') {
|
|
fwrite(STDERR, "generatePdfBatch individual file should be a PDF payload\n");
|
|
exit(1);
|
|
}
|
|
}
|
|
$filesDir = (string) ($pdfBatch['files_dir'] ?? '');
|
|
if ($filesDir !== '') {
|
|
$generatedPdfDirs[] = $filesDir;
|
|
}
|
|
|
|
$mergedOnly = $run->generatePdfBatch($mailshotId, true, false);
|
|
if (($mergedOnly['ok'] ?? false) !== true) {
|
|
fwrite(STDERR, 'generatePdfBatch merged-only failed: ' . json_encode($mergedOnly) . "\n");
|
|
exit(1);
|
|
}
|
|
$mergedOnlyPath = (string) ($mergedOnly['merged_pdf_path'] ?? '');
|
|
if ($mergedOnlyPath === '' || !is_file($mergedOnlyPath)) {
|
|
fwrite(STDERR, "generatePdfBatch merged-only should return a temp file path\n");
|
|
exit(1);
|
|
}
|
|
$generatedPdfPaths[] = $mergedOnlyPath;
|
|
if (($mergedOnly['files'] ?? []) !== []) {
|
|
fwrite(STDERR, "generatePdfBatch merged-only should not return individual files\n");
|
|
exit(1);
|
|
}
|
|
if (array_key_exists('merged_pdf_bytes', $mergedOnly)) {
|
|
fwrite(STDERR, "generatePdfBatch merged-only should not return merged PDF bytes\n");
|
|
exit(1);
|
|
}
|
|
|
|
$sendBlank = $run->sendTest($mailshotId, 0, '');
|
|
if (($sendBlank['ok'] ?? true) !== false) {
|
|
fwrite(STDERR, "sendTest blank email should fail\n");
|
|
exit(1);
|
|
}
|
|
|
|
$sendTest = $run->sendTest($mailshotId, 0, 'receiver@example.org');
|
|
if (($sendTest['ok'] ?? false) !== true) {
|
|
fwrite(STDERR, 'sendTest failed: ' . json_encode($sendTest) . "\n");
|
|
exit(1);
|
|
}
|
|
if (array_key_exists('rendered', $sendTest)) {
|
|
fwrite(STDERR, "sendTest should not return rendered payload\n");
|
|
exit(1);
|
|
}
|
|
if (count($smtp->lastAttachments) < 2) {
|
|
fwrite(STDERR, "sendTest should include static attachment and rendered PDF attachment\n");
|
|
exit(1);
|
|
}
|
|
$mimeTypes = array_map(static fn(array $a): string => (string) ($a['mime_type'] ?? ''), $smtp->lastAttachments);
|
|
if (!in_array('application/pdf', $mimeTypes, true)) {
|
|
fwrite(STDERR, "sendTest should include a rendered application/pdf attachment\n");
|
|
exit(1);
|
|
}
|
|
if (!in_array('text/plain', $mimeTypes, true)) {
|
|
fwrite(STDERR, "sendTest should include configured text/plain attachment\n");
|
|
exit(1);
|
|
}
|
|
$pdfAttachment = null;
|
|
foreach ($smtp->lastAttachments as $att) {
|
|
if ((string) ($att['mime_type'] ?? '') === 'application/pdf') {
|
|
$pdfAttachment = $att;
|
|
break;
|
|
}
|
|
}
|
|
if (!is_array($pdfAttachment) || strpos((string) ($pdfAttachment['content_bytes'] ?? ''), '%PDF') !== 0) {
|
|
fwrite(STDERR, "sendTest PDF attachment content is not a PDF payload\n");
|
|
exit(1);
|
|
}
|
|
if (!is_array($sendTest['message_size'] ?? null)) {
|
|
fwrite(STDERR, "sendTest should return message size diagnostics\n");
|
|
exit(1);
|
|
}
|
|
|
|
$oldSizeLimit = getenv('FECA_MAILSHOTS_MAX_ESTIMATED_MIME_BYTES');
|
|
try {
|
|
putenv('FECA_MAILSHOTS_MAX_ESTIMATED_MIME_BYTES=1024');
|
|
$sendTooLarge = $run->sendTest($mailshotId, 0, 'receiver@example.org');
|
|
if (($sendTooLarge['ok'] ?? true) !== false) {
|
|
fwrite(STDERR, "sendTest should fail when estimated MIME size exceeds configured limit\n");
|
|
exit(1);
|
|
}
|
|
$tooLargeText = implode('; ', (array) ($sendTooLarge['errors'] ?? []));
|
|
if (strpos($tooLargeText, 'Estimated message size') === false) {
|
|
fwrite(STDERR, "sendTest size-limit error text mismatch: {$tooLargeText}\n");
|
|
exit(1);
|
|
}
|
|
if (!is_array($sendTooLarge['message_size'] ?? null)) {
|
|
fwrite(STDERR, "sendTest size-limit failure should include diagnostics\n");
|
|
exit(1);
|
|
}
|
|
$sendAllTooLarge = $run->sendTestAll($mailshotId, 'receiver@example.org');
|
|
if (($sendAllTooLarge['ok'] ?? true) !== false) {
|
|
fwrite(STDERR, "sendTestAll should fail when estimated MIME size exceeds configured limit\n");
|
|
exit(1);
|
|
}
|
|
if ((int) ($sendAllTooLarge['failed'] ?? 0) <= 0) {
|
|
fwrite(STDERR, "sendTestAll size-limit failure should count failed recipients\n");
|
|
exit(1);
|
|
}
|
|
$sendAllTooLargeText = implode('; ', (array) ($sendAllTooLarge['errors'] ?? []));
|
|
if (strpos($sendAllTooLargeText, 'Estimated message size') === false) {
|
|
fwrite(STDERR, "sendTestAll size-limit error text mismatch: {$sendAllTooLargeText}\n");
|
|
exit(1);
|
|
}
|
|
} finally {
|
|
if ($oldSizeLimit === false) {
|
|
putenv('FECA_MAILSHOTS_MAX_ESTIMATED_MIME_BYTES');
|
|
} else {
|
|
putenv('FECA_MAILSHOTS_MAX_ESTIMATED_MIME_BYTES=' . $oldSizeLimit);
|
|
}
|
|
}
|
|
|
|
// Regression: configured-but-missing attachment must return a controlled error, not throw/fatal.
|
|
$mailshotRepo->update($mailshotId, [
|
|
'Purpose' => 'Phase4 ' . $uniq,
|
|
'DataSource' => $queryName,
|
|
'CC' => '',
|
|
'BCC' => '',
|
|
'Subject' => 'Hello {{ id|default(ID|default("recipient")) }}',
|
|
'Message' => '<p>Hi {{ name|default(Name|default("there")) }}</p>',
|
|
'PDFAttachment' => '<p>PDF {{ id|default("none") }}</p>',
|
|
'AttachmentNames' => json_encode(['missing_attachment_name'], JSON_UNESCAPED_SLASHES),
|
|
'PDFFilenameDerivedFrom' => '',
|
|
'ReplyTo' => '',
|
|
'RecipientEmailField' => '',
|
|
]);
|
|
$sendMissing = $run->sendTest($mailshotId, 0, 'receiver@example.org');
|
|
if (($sendMissing['ok'] ?? true) !== false) {
|
|
fwrite(STDERR, "sendTest should fail when configured attachment is missing\n");
|
|
exit(1);
|
|
}
|
|
$missingText = implode('; ', (array) ($sendMissing['errors'] ?? []));
|
|
if (strpos($missingText, 'Attachment "missing_attachment_name" not found.') === false) {
|
|
fwrite(STDERR, "sendTest missing-attachment error text mismatch: {$missingText}\n");
|
|
exit(1);
|
|
}
|
|
|
|
$runRes = $run->runMailshot($mailshotId);
|
|
if (($runRes['ok'] ?? false) !== true) {
|
|
fwrite(STDERR, 'runMailshot failed: ' . json_encode($runRes) . "\n");
|
|
exit(1);
|
|
}
|
|
if ((int) ($runRes['attempted'] ?? 0) <= 0) {
|
|
fwrite(STDERR, "runMailshot attempted should be > 0\n");
|
|
exit(1);
|
|
}
|
|
|
|
$rows = $lastRunRepo->listForMailshot($mailshotId);
|
|
if (count($rows) !== (int) $runRes['attempted']) {
|
|
fwrite(STDERR, 'lastRun row count mismatch\n');
|
|
exit(1);
|
|
}
|
|
|
|
$retry = $run->retryFailed($mailshotId);
|
|
if (($retry['ok'] ?? false) !== true) {
|
|
fwrite(STDERR, 'retryFailed failed: ' . json_encode($retry) . "\n");
|
|
exit(1);
|
|
}
|
|
|
|
echo "Phase 4 run-flow integration test passed\n";
|
|
echo "attempted=" . (int) $runRes['attempted'] . "\n";
|
|
echo "smtp_calls={$smtp->count}\n";
|
|
echo "imap_calls={$imap->count}\n";
|
|
} finally {
|
|
foreach ($generatedPdfPaths as $path) {
|
|
if (is_string($path) && $path !== '') {
|
|
@unlink($path);
|
|
}
|
|
}
|
|
foreach ($generatedPdfDirs as $dir) {
|
|
if (is_string($dir) && $dir !== '') {
|
|
@rmdir($dir);
|
|
}
|
|
}
|
|
if ($mailshotId !== null) {
|
|
try { $lastRunRepo->clearForMailshot($mailshotId); } catch (Throwable $e) {}
|
|
try { $mailshotRepo->delete($mailshotId); } catch (Throwable $e) {}
|
|
}
|
|
if ($attachmentId !== null) {
|
|
try { $attachmentRepo->delete($attachmentId); } catch (Throwable $e) {}
|
|
}
|
|
if ($queryId !== null) {
|
|
try { $queryRepo->delete($queryId); } catch (Throwable $e) {}
|
|
}
|
|
}
|