'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 */ 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' => '

Hi {{ name|default(Name|default("there")) }}

', 'PDFAttachment' => '

PDF {{ id|default("none") }}

', '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' => '

Hi {{ name|default(Name|default("there")) }}

', 'PDFAttachment' => '

PDF {{ id|default("none") }}

', '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) {} } }