optins->find($lead->optinId); if ($optin === null) { return; } foreach (OptinBinding::ids($optin->publishedConfig) as $destinationId) { $destination = $this->destinations->find($destinationId); if ($destination === null) { // An id naming nothing. Not a skip to record: there is no // Destination to record it against, and nothing a merchant // could do about a binding to something that no longer exists // except unbind it, which the builder already shows. continue; } if (!$this->registry->isDispatchable($destination->type)) { $this->health->skipped($destinationId, current_time('mysql')); continue; } try { $this->queue->dispatch(PushJob::HOOK, (new PushJob($lead->id, $destinationId))->toArgs()); } catch (QueueFailure $failure) { // The Lead is already stored, so this is Destination health, // not a failed capture. Keep walking: one rejected action must // not suppress every Destination bound after it. $this->health->failed($destinationId, $failure->getMessage(), current_time('mysql')); } } } /** * What a merchant is told about a type this install cannot run. * * **One sentence, one home.** The *Test connection* route answers the same * situation without ever reaching {@see self::test()}, and two literals * would drift the moment either was reworded — which is how a screen ends * up giving two different explanations of one missing plugin and teaching * the merchant that it is guessing. `wp i18n make-pot` sees it once, too. */ public static function unavailableHere(): string { return __('This Destination’s type is not available on this site.', 'wconvert'); } /** * Send one test — **the merchant's own address, with no [[Lead]] behind * it.** * * ======================================================================== * A TEST SEND NEVER WRITES A LEAD, AND IT IS NOT A POLICY, IT IS A SHAPE. * ======================================================================== * A [[Lead]] has exactly one origin — a visitor submitting a form on a page * WConvert served — and that is total because a [[Consent Record]] is the * wording shown to a person at an instant (ADR 0031). A test Lead would * carry wording nobody was shown, which is manufactured evidence. So this * takes VALUES and builds its own {@see PushSubject}: there is no argument * a caller can pass that makes a real capture take this path, and none that * makes a test take the queue. * * **It is synchronous, and that is the same posture * {@see DestinationType::testConnection()} already has.** A merchant who * pressed a button is owed an answer while they are still looking at the * screen; a queued test would report success the moment it was queued and * say nothing about whether it landed, which is the one thing the button is * for. It also means no payload sits in `actionscheduler_actions`, so * {@see PushJob}'s no-Lead-data-in-arguments rule needs no exception. * * **It records NOTHING.** Not health, not a {@see DeliveryFailures} entry, * not a delivery count. A test that failed is not an outage — the merchant * asked a question and got an answer — and a merchant pressing it four * times while fixing an API key must not walk away with a Destination * marked unhealthy and a failure ring full of a Lead id that does not * exist. Delivery state is about Leads that were captured (ADR 0008), and * nothing was. * * **A campaign editor may name its campaign and pass its questions**, so a * receiver that learns its fields from a sample (Zapier, Make) can map that * campaign's answers before its first real Lead. The questions are sample * values typed by the merchant, not a visitor's, so nothing here becomes * evidence of anything. * * @param array $values Canonical keys; anything else is dropped. * @param array $mapped * @param array}>|null $answers */ public function test(string $destinationId, array $values, array $mapped = [], ?array $answers = null, ?string $optinId = null): PushResult { $destination = $this->destinations->find($destinationId); if ($destination === null) { return PushResult::terminal(__('There is no Destination with that id.', 'wconvert')); } $type = $this->registry->find($destination->type); if ($type === null || !$this->registry->isDispatchable($destination->type)) { // The same answer `dispatch()` gives, minus the recording: a type // that cannot run is a skip and not a failure. Here it is simply // handed back, because the merchant is the one who can act on it. return PushResult::skipped(self::unavailableHere()); } // Only a type that forwards the submission whole is told the campaign. $optin = $optinId === null || !$type instanceof ForwardsWholeSubmission ? null : $this->optins->find($optinId); return $type->push(PushSubject::test($values, $mapped, 'email_marketing', $answers), new PushContext( // Null unless a campaign editor named its campaign to a type that // forwards it, and deliberately so. A test send from the // Destinations screen has no [[Optin]] — naming one would assert // provenance that does not exist, which is the case // {@see PushContext} already documents null for (ADR 0148). $optin?->name, $destination->settings, $this->connections->credentialsFor($destination), $optin?->id, $optin?->goal )); } }