diff --git a/app/Export/CSV/CreditExport.php b/app/Export/CSV/CreditExport.php index 234a9f9c10..2cf25cb568 100644 --- a/app/Export/CSV/CreditExport.php +++ b/app/Export/CSV/CreditExport.php @@ -86,15 +86,20 @@ class CreditExport extends BaseExport { $query = $this->init(); - $header = $this->buildHeader(); + $headerdisplay = $this->buildHeader(); + $header = []; + + foreach ($this->input['report_keys'] as $key => $value) { + $header[] = ['identifier' => $value, 'display_value' => $headerdisplay[$key]]; + } $report = $query->cursor() ->map(function ($credit) { $row = $this->buildRow($credit); return $this->processMetaData($row, $credit); })->toArray(); - - return array_merge([$header], $report); + + return array_merge(['columns' => $header], $report); } private function processMetaData(array $row, Credit $credit): array @@ -112,6 +117,7 @@ class CreditExport extends BaseExport $clean_row[$key]['id'] = $report_keys[1] ?? $report_keys[0]; $clean_row[$key]['hashed_id'] = $report_keys[0] == 'credit' ? null : $credit->{$report_keys[0]}->hashed_id ?? null; $clean_row[$key]['value'] = $row[$column_key]; + $clean_row[$key]['identifier'] = $value; if(in_array($clean_row[$key]['id'], ['amount', 'balance', 'partial', 'refunded', 'applied','unit_cost','cost','price'])) $clean_row[$key]['display_value'] = Number::formatMoney($row[$column_key], $credit->client); @@ -134,14 +140,6 @@ class CreditExport extends BaseExport if (count($this->input['report_keys']) == 0) { $this->input['report_keys'] = array_values($this->entity_keys); - // $this->input['report_keys'] = collect(array_values($this->entity_keys))->map(function ($value){ - - // // if(in_array($value,['client_id','country_id'])) - // // return $value; - // // else - // return 'credit.'.$value; - // })->toArray(); - } $query = Credit::query() diff --git a/app/Http/Controllers/EmailHistoryController.php b/app/Http/Controllers/EmailHistoryController.php new file mode 100644 index 0000000000..e8f2d4894f --- /dev/null +++ b/app/Http/Controllers/EmailHistoryController.php @@ -0,0 +1,70 @@ +id) + ->where('category_id', SystemLog::CATEGORY_MAIL) + ->orderBy('id', 'DESC') + ->cursor() + ->map(function ($system_log) { + if($system_log->log['history'] ?? false) { + return $system_log->log['history']; + // return json_decode($system_log->log['history'], true); + } + }); + + return response()->json($data, 200); + + } + + /** + * May need to expand on this using + * just the message-id and search for the + * entity in the invitations + */ + public function entityHistory(EntityEmailHistoryRequest $request) + { + /** @var \App\Models\User $user */ + $user = auth()->user(); + + $data = SystemLog::where('company_id', $user->company()->id) + ->where('category_id', SystemLog::CATEGORY_MAIL) + ->whereJsonContains('log->history->entity_id', $this->encodePrimaryKey($request->entity_id)) + ->orderBy('id', 'DESC') + ->cursor() + ->map(function ($system_log) { + if($system_log->log['history'] ?? false) { + return $system_log->log['history']; + // return json_decode($system_log->log['history'], true); + } + }); + + return response()->json($data, 200); + + } +} diff --git a/app/Http/Requests/Email/ClientEmailHistoryRequest.php b/app/Http/Requests/Email/ClientEmailHistoryRequest.php new file mode 100644 index 0000000000..45d60b458b --- /dev/null +++ b/app/Http/Requests/Email/ClientEmailHistoryRequest.php @@ -0,0 +1,55 @@ +user(); + + return $user->can('view', $this->client); + + } + + /** + * Get the validation rules that apply to the request. + * + * @return array + */ + public function rules() + { + return [ + ]; + } + + public function prepareForValidation() + { + $input = $this->all(); + + $this->replace($input); + } + +} diff --git a/app/Http/Requests/Email/EntityEmailHistoryRequest.php b/app/Http/Requests/Email/EntityEmailHistoryRequest.php new file mode 100644 index 0000000000..2c506e653d --- /dev/null +++ b/app/Http/Requests/Email/EntityEmailHistoryRequest.php @@ -0,0 +1,62 @@ +user(); + + return [ + 'entity' => 'bail|required|string|in:invoice,quote,credit,recurring_invoice,purchase_order', + 'entity_id' => ['bail','required',Rule::exists($this->entity_plural, 'id')->where('company_id', $user->company()->id)], + ]; + } + + public function prepareForValidation() + { + $input = $this->all(); + + $this->entity_plural = Str::plural($input['entity']) ?? ''; + $input['entity_id'] = $this->decodePrimaryKey($input['entity_id']); + + $this->replace($input); + } + +} diff --git a/app/Jobs/PostMark/ProcessPostmarkWebhook.php b/app/Jobs/PostMark/ProcessPostmarkWebhook.php index d99455ab32..18dde45e43 100644 --- a/app/Jobs/PostMark/ProcessPostmarkWebhook.php +++ b/app/Jobs/PostMark/ProcessPostmarkWebhook.php @@ -11,27 +11,24 @@ namespace App\Jobs\PostMark; -use App\DataMapper\Analytics\Mail\EmailBounce; -use App\DataMapper\Analytics\Mail\EmailSpam; -use App\Jobs\Util\SystemLogger; +use App\Models\SystemLog; use App\Libraries\MultiDB; -use App\Models\Company; +use Postmark\PostmarkClient; +use Illuminate\Bus\Queueable; +use App\Jobs\Util\SystemLogger; +use App\Models\QuoteInvitation; use App\Models\CreditInvitation; use App\Models\InvoiceInvitation; -use App\Models\Payment; -use App\Models\PurchaseOrderInvitation; -use App\Models\QuoteInvitation; -use App\Models\RecurringInvoiceInvitation; -use App\Models\SystemLog; -use App\Notifications\Ninja\EmailBounceNotification; -use App\Notifications\Ninja\EmailSpamNotification; -use App\Utils\Ninja; -use Illuminate\Bus\Queueable; -use Illuminate\Contracts\Queue\ShouldQueue; -use Illuminate\Foundation\Bus\Dispatchable; -use Illuminate\Queue\InteractsWithQueue; use Illuminate\Queue\SerializesModels; use Turbo124\Beacon\Facades\LightLogs; +use App\Models\PurchaseOrderInvitation; +use Illuminate\Queue\InteractsWithQueue; +use App\Models\RecurringInvoiceInvitation; +use Illuminate\Contracts\Queue\ShouldQueue; +use Illuminate\Foundation\Bus\Dispatchable; +use App\DataMapper\Analytics\Mail\EmailSpam; +use App\DataMapper\Analytics\Mail\EmailBounce; +use App\Notifications\Ninja\EmailSpamNotification; class ProcessPostmarkWebhook implements ShouldQueue { @@ -41,6 +38,16 @@ class ProcessPostmarkWebhook implements ShouldQueue public $invitation; + private $entity; + + private array $default_response = [ + 'recipients' => '', + 'subject' => 'Message not found.', + 'entity' => '', + 'entity_id' => '', + 'events' => [], + ]; + /** * Create a new job instance. * @@ -126,8 +133,10 @@ class ProcessPostmarkWebhook implements ShouldQueue $this->invitation->opened_date = now(); $this->invitation->save(); + $data = array_merge($this->request, ['history' => $this->fetchMessage()]); + (new SystemLogger( - $this->request, + $data, SystemLog::CATEGORY_MAIL, SystemLog::EVENT_MAIL_OPENED, SystemLog::TYPE_WEBHOOK_RESPONSE, @@ -155,8 +164,10 @@ class ProcessPostmarkWebhook implements ShouldQueue $this->invitation->email_status = 'delivered'; $this->invitation->save(); + $data = array_merge($this->request, ['history' => $this->fetchMessage()]); + (new SystemLogger( - $this->request, + $data, SystemLog::CATEGORY_MAIL, SystemLog::EVENT_MAIL_DELIVERY, SystemLog::TYPE_WEBHOOK_RESPONSE, @@ -204,7 +215,9 @@ class ProcessPostmarkWebhook implements ShouldQueue LightLogs::create($bounce)->send(); - (new SystemLogger($this->request, SystemLog::CATEGORY_MAIL, SystemLog::EVENT_MAIL_BOUNCED, SystemLog::TYPE_WEBHOOK_RESPONSE, $this->invitation->contact->client, $this->invitation->company))->handle(); + $data = array_merge($this->request, ['history' => $this->fetchMessage()]); + + (new SystemLogger($data, SystemLog::CATEGORY_MAIL, SystemLog::EVENT_MAIL_BOUNCED, SystemLog::TYPE_WEBHOOK_RESPONSE, $this->invitation->contact->client, $this->invitation->company))->handle(); // if(config('ninja.notification.slack')) // $this->invitation->company->notification(new EmailBounceNotification($this->invitation->company->account))->ninja(); @@ -248,7 +261,9 @@ class ProcessPostmarkWebhook implements ShouldQueue LightLogs::create($spam)->send(); - (new SystemLogger($this->request, SystemLog::CATEGORY_MAIL, SystemLog::EVENT_MAIL_SPAM_COMPLAINT, SystemLog::TYPE_WEBHOOK_RESPONSE, $this->invitation->contact->client, $this->invitation->company))->handle(); + $data = array_merge($this->request, ['history' => $this->fetchMessage()]); + + (new SystemLogger($data, SystemLog::CATEGORY_MAIL, SystemLog::EVENT_MAIL_SPAM_COMPLAINT, SystemLog::TYPE_WEBHOOK_RESPONSE, $this->invitation->contact->client, $this->invitation->company))->handle(); if (config('ninja.notification.slack')) { $this->invitation->company->notification(new EmailSpamNotification($this->invitation->company->account))->ninja(); @@ -260,17 +275,65 @@ class ProcessPostmarkWebhook implements ShouldQueue $invitation = false; if ($invitation = InvoiceInvitation::where('message_id', $message_id)->first()) { + $this->entity = 'invoice'; return $invitation; } elseif ($invitation = QuoteInvitation::where('message_id', $message_id)->first()) { + $this->entity = 'quote'; return $invitation; } elseif ($invitation = RecurringInvoiceInvitation::where('message_id', $message_id)->first()) { + $this->entity = 'recurring_invoice'; return $invitation; } elseif ($invitation = CreditInvitation::where('message_id', $message_id)->first()) { + $this->entity = 'credit'; return $invitation; } elseif ($invitation = PurchaseOrderInvitation::where('message_id', $message_id)->first()) { + $this->entity = 'purchase_order'; return $invitation; } else { return $invitation; } } + + private function fetchMessage(): array + { + if(strlen($this->request['MessageID']) < 1){ + return $this->default_response; + } + + try { + + $postmark = new PostmarkClient(config('services.postmark.token')); + $messageDetail = $postmark->getOutboundMessageDetails($this->request['MessageID']); + + $recipients = collect($messageDetail['recipients'])->flatten()->implode(','); + $subject = $messageDetail->subject ?? ''; + + $events = collect($messageDetail->messageevents)->map(function ($event) { + + return [ + 'recipient' => $event->Recipient ?? '', + 'status' => $event->Type ?? '', + 'delivery_message' => $event->Details->DeliveryMessage ?? $event->Details->Summary ?? '', + 'server' => $event->Details->DestinationServer ?? '', + 'server_ip' => $event->Details->DestinationIP ?? '', + 'date' => \Carbon\Carbon::parse($event->ReceivedAt)->format('Y-m-d H:m:s') ?? '', + ]; + + })->toArray(); + + return [ + 'recipients' => $recipients, + 'subject' => $subject, + 'entity' => $this->entity ?? '', + 'entity_id' => $this->invitation->{$this->entity}->hashed_id ?? '', + 'events' => $events, + ]; + + } + catch (\Exception $e) { + + return $this->default_response; + + } + } } diff --git a/app/PaymentDrivers/SquarePaymentDriver.php b/app/PaymentDrivers/SquarePaymentDriver.php index 9a14358be6..5e6e3ecbb7 100644 --- a/app/PaymentDrivers/SquarePaymentDriver.php +++ b/app/PaymentDrivers/SquarePaymentDriver.php @@ -394,8 +394,10 @@ class SquarePaymentDriver extends BaseDriver //getsubscriptionid here $subscription_id = $this->checkWebhooks(); - if(!$subscription_id) - return nlog('No Subscription Found'); + if(!$subscription_id){ + nlog('No Subscription Found'); + return; + } $api_response = $this->square->getWebhookSubscriptionsApi()->testWebhookSubscription($subscription_id, $body); diff --git a/app/Services/Client/EmailHistory.php b/app/Services/Client/EmailHistory.php new file mode 100644 index 0000000000..f0715c49b3 --- /dev/null +++ b/app/Services/Client/EmailHistory.php @@ -0,0 +1,96 @@ + 'Message not found.', + 'status' => '', + 'recipient' => '', + 'type' => '', + 'delivery_message' => '', + 'server' => '', + 'server_ip' => '', + ]; + + public function __construct(public Client $client) + { + } + + public function run(): array + { + // $settings = $this->client->getMergedSettings(); + + // if($settings->email_sending_method == 'default'){ + // $this->postmark_token = config('services.postmark.token'); + // } + // elseif($settings->email_sending_method == 'client_postmark'){ + // $this->postmark_token = $settings->postmark_secret; + // } + // else{ + // return []; + // } + + // $this->postmark = new PostmarkClient($this->postmark_token); + + return SystemLog::query() + ->where('client_id', $this->client->id) + ->where('category_id', SystemLog::CATEGORY_MAIL) + ->orderBy('id','DESC') + ->cursor() + ->map(function ($system_log) { + + if($system_log->log['history'] ?? false){ + return json_decode($system_log->log['history'],true); + } + })->toArray(); + } + + private function fetchMessage(string $message_id): array + { + if(strlen($message_id) < 1){ + return $this->default_response; + } + + try { + + $messageDetail = $this->postmark->getOutboundMessageDetails($message_id); + + return [ + 'subject' => $messageDetail->subject ?? '', + 'status' => $messageDetail->status ?? '', + 'recipient' => $messageDetail->messageevents[0]['Recipient'] ?? '', + 'type' => $messageDetail->messageevents[0]->Type ?? '', + 'delivery_message' => $messageDetail->messageevents[0]->Details->DeliveryMessage ?? '', + 'server' => $messageDetail->messageevents[0]->Details->DestinationServer ?? '', + 'server_ip' => $messageDetail->messageevents[0]->Details->DestinationIP ?? '', + ]; + + } + catch (\Exception $e) { + + return $this->default_response; + + } + } +} \ No newline at end of file diff --git a/app/Services/Email/Email.php b/app/Services/Email/Email.php index 4c114d586d..cebb8b7b6f 100644 --- a/app/Services/Email/Email.php +++ b/app/Services/Email/Email.php @@ -240,7 +240,6 @@ class Email implements ShouldQueue } if ($this->client_mailgun_secret) { - $mailer->mailgun_config($this->client_mailgun_secret, $this->client_mailgun_domain, $this->client_mailgun_endpoint); } @@ -254,6 +253,7 @@ class Email implements ShouldQueue LightLogs::create(new EmailSuccess($this->company->company_key)) ->send(); + } catch(\Symfony\Component\Mime\Exception\RfcComplianceException $e) { nlog("Mailer failed with a Logic Exception {$e->getMessage()}"); $this->fail(); diff --git a/composer.json b/composer.json index d9bb6348f7..3204247f83 100644 --- a/composer.json +++ b/composer.json @@ -76,6 +76,7 @@ "omnipay/paypal": "^3.0", "payfast/payfast-php-sdk": "^1.1", "pragmarx/google2fa": "^8.0", + "psr/http-message": "^1.0", "pusher/pusher-php-server": "^7.2", "razorpay/razorpay": "2.*", "sentry/sentry-laravel": "^3", @@ -96,7 +97,7 @@ "twilio/sdk": "^6.40", "webpatser/laravel-countries": "dev-master#75992ad", "wepay/php-sdk": "^0.3", - "psr/http-message": "^1.0" + "wildbit/postmark-php": "^4.0" }, "require-dev": { "php": "^8.1", diff --git a/composer.lock b/composer.lock index 31584f8b75..accbb50706 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "673ca66ddfdb05c3ea29012594a196d3", + "content-hash": "70ade0ea4925946765213166010b0f32", "packages": [ { "name": "adrienrn/php-mimetyper", @@ -14230,6 +14230,44 @@ "source": "https://github.com/wepay/PHP-SDK/tree/master" }, "time": "2017-01-21T07:03:26+00:00" + }, + { + "name": "wildbit/postmark-php", + "version": "v4.0.5", + "source": { + "type": "git", + "url": "https://github.com/ActiveCampaign/postmark-php.git", + "reference": "b71efba061de7cf7e1f853d211b1c5edce4e3c5b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/ActiveCampaign/postmark-php/zipball/b71efba061de7cf7e1f853d211b1c5edce4e3c5b", + "reference": "b71efba061de7cf7e1f853d211b1c5edce4e3c5b", + "shasum": "" + }, + "require": { + "guzzlehttp/guzzle": "^6.0|^7.0", + "php": ">=7.0.0" + }, + "require-dev": { + "phpunit/phpunit": "^6.0.0" + }, + "type": "library", + "autoload": { + "psr-0": { + "Postmark\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "The officially supported client for Postmark (http://postmarkapp.com)", + "support": { + "issues": "https://github.com/ActiveCampaign/postmark-php/issues", + "source": "https://github.com/ActiveCampaign/postmark-php/tree/v4.0.5" + }, + "time": "2023-02-03T15:00:17+00:00" } ], "packages-dev": [ diff --git a/lang/en/texts.php b/lang/en/texts.php index a96969812c..183cbf5c8c 100644 --- a/lang/en/texts.php +++ b/lang/en/texts.php @@ -1150,7 +1150,7 @@ $LANG = array( 'plan_status' => 'Plan Status', 'plan_upgrade' => 'Upgrade', - 'plan_change' => 'Change Plan', + 'plan_change' => 'Manage Plan', 'pending_change_to' => 'Changes To', 'plan_changes_to' => ':plan on :date', 'plan_term_changes_to' => ':plan (:term) on :date', @@ -4330,7 +4330,7 @@ $LANG = array( 'include_drafts' => 'Include Drafts', 'include_drafts_help' => 'Include draft records in reports', 'is_invoiced' => 'Is Invoiced', - 'change_plan' => 'Change Plan', + 'change_plan' => 'Manage Plan', 'persist_data' => 'Persist Data', 'customer_count' => 'Customer Count', 'verify_customers' => 'Verify Customers', @@ -5158,8 +5158,7 @@ $LANG = array( 'unlinked_transaction' => 'Successfully unlinked transaction', 'view_dashboard_permission' => 'Allow user to access the dashboard, data is limited to available permissions', 'marked_sent_credits' => 'Successfully marked credits sent', - -); +); return $LANG; diff --git a/routes/api.php b/routes/api.php index 34e6c57c30..aa4e108a52 100644 --- a/routes/api.php +++ b/routes/api.php @@ -58,6 +58,7 @@ use App\Http\Controllers\TaskStatusController; use App\Http\Controllers\Bank\YodleeController; use App\Http\Controllers\CompanyUserController; use App\Http\Controllers\PaymentTermController; +use App\Http\Controllers\EmailHistoryController; use App\Http\Controllers\GroupSettingController; use App\Http\Controllers\OneTimeTokenController; use App\Http\Controllers\SubscriptionController; @@ -202,6 +203,8 @@ Route::group(['middleware' => ['throttle:api', 'api_db', 'token_auth', 'locale'] Route::post('documents/bulk', [DocumentController::class, 'bulk'])->name('documents.bulk'); Route::post('emails', [EmailController::class, 'send'])->name('email.send')->middleware('user_verified'); + Route::post('emails/clientHistory/{client}', [EmailHistoryController::class, 'clientHistory'])->name('email.clientHistory'); + Route::post('emails/entityHistory', [EmailHistoryController::class, 'entityHistory'])->name('email.entityHistory'); Route::resource('expenses', ExpenseController::class); // name = (expenses. index / create / show / update / destroy / edit Route::put('expenses/{expense}/upload', [ExpenseController::class, 'upload']); diff --git a/tests/Feature/InvoiceEmailTest.php b/tests/Feature/InvoiceEmailTest.php index 9609840e8a..0105f25105 100644 --- a/tests/Feature/InvoiceEmailTest.php +++ b/tests/Feature/InvoiceEmailTest.php @@ -11,14 +11,15 @@ namespace Tests\Feature; +use Tests\TestCase; +use App\Models\SystemLog; +use Tests\MockAccountData; use App\Jobs\Entity\EmailEntity; use App\Utils\Traits\GeneratesCounter; use Illuminate\Database\Eloquent\Model; -use Illuminate\Foundation\Testing\DatabaseTransactions; use Illuminate\Support\Facades\Session; -use Tests\MockAccountData; -use Tests\TestCase; use Illuminate\Validation\ValidationException; +use Illuminate\Foundation\Testing\DatabaseTransactions; /** * @test @@ -46,6 +47,109 @@ class InvoiceEmailTest extends TestCase } + public function testClientEmailHistory() + { + $system_log = new SystemLog(); + $system_log->company_id = $this->company->id; + $system_log->client_id = $this->client->id; + $system_log->category_id = SystemLog::CATEGORY_MAIL; + $system_log->event_id = SystemLog::EVENT_MAIL_SEND; + $system_log->type_id = SystemLog::TYPE_WEBHOOK_RESPONSE; + $system_log->log = [ + 'history' => [ + 'entity_id' => $this->invoice->hashed_id, + 'entity_type' => 'invoice', + 'subject' => 'Invoice #1', + 'events' => [ + [ + 'recipient' => 'bob@gmail.com', + 'status' => 'Delivered', + 'delivery_message' => 'A message that was deliveryed', + 'server' => 'email.mx.com', + 'server_ip' => '127.0.0.1', + 'date' => \Carbon\Carbon::parse('2023-10-10')->format('Y-m-d H:m:s') ?? '', + ], + ], + ] + ]; + + $system_log->save(); + + + $response = $this->withHeaders([ + 'X-API-SECRET' => config('ninja.api_secret'), + 'X-API-TOKEN' => $this->token, + ])->postJson('/api/v1/emails/clientHistory/'.$this->client->hashed_id); + + $response->assertStatus(200); + + $arr = $response->json(); + + $this->assertEquals('invoice', $arr[0]['entity_type']); + + $count = SystemLog::where('client_id', $this->client->id) + ->where('category_id', SystemLog::CATEGORY_MAIL) + ->orderBy('id', 'DESC') + ->count(); + + $this->assertEquals(1, $count); + } + + public function testEntityEmailHistory() + { + $system_log = new SystemLog(); + $system_log->company_id = $this->company->id; + $system_log->client_id = $this->client->id; + $system_log->category_id = SystemLog::CATEGORY_MAIL; + $system_log->event_id = SystemLog::EVENT_MAIL_SEND; + $system_log->type_id = SystemLog::TYPE_WEBHOOK_RESPONSE; + $system_log->log = [ + 'history' => [ + 'entity_id' => $this->invoice->hashed_id, + 'entity_type' => 'invoice', + 'subject' => 'Invoice #1', + 'events' => [ + [ + 'recipient' => 'bob@gmail.com', + 'status' => 'Delivered', + 'delivery_message' => 'A message that was deliveryed', + 'server' => 'email.mx.com', + 'server_ip' => '127.0.0.1', + 'date' => \Carbon\Carbon::parse('2023-10-10')->format('Y-m-d H:m:s') ?? '', + ], + ], + ] + ]; + + $system_log->save(); + + $data = [ + 'entity' => 'invoice', + 'entity_id' => $this->invoice->hashed_id, + ]; + + $response = $this->withHeaders([ + 'X-API-SECRET' => config('ninja.api_secret'), + 'X-API-TOKEN' => $this->token, + ])->postJson('/api/v1/emails/entityHistory/', $data); + + $response->assertStatus(200); + + $arr = $response->json(); + + $this->assertEquals('invoice', $arr[0]['entity_type']); + $this->assertEquals($this->invoice->hashed_id, $arr[0]['entity_id']); + + $count = SystemLog::where('company_id', $this->company->id) + ->where('category_id', SystemLog::CATEGORY_MAIL) + ->whereJsonContains('log->history->entity_id', $this->invoice->hashed_id) + ->count(); + + $this->assertEquals(1, $count); + + } + + public function testTemplateValidation() { $data = [