File: /home/xedaptot/ai.naniguide.com/tests/Unit/FunnelFlowTest.php
<?php
/**
* FunnelFlowTest — unit test for funnel lifecycle:
* - Create funnel from template (clone steps, clone templates, verify content)
* - Add step to funnel
* - Delete step from funnel (cleanup template)
* - Duplicate funnel (deep copy isolation)
*
* Uses real DB with manual cleanup (same pattern as CampaignFlowTest).
*/
use App\Model\Contact;
use App\Model\Customer;
use App\Model\Funnel;
use App\Model\FunnelStep;
use App\Model\Template;
use Illuminate\Support\Facades\DB;
use Tests\TestCase;
uses(TestCase::class);
// ─── Cleanup ─────────────────────────────────────────────────────────────────
afterEach(function () {
// Clean up test funnels, steps, templates created during tests
$customerIds = DB::table('customers')->where('uid', 'like', 'funnel_test_%')->pluck('id');
if ($customerIds->isEmpty()) {
return;
}
// Delete funnel steps + their templates
$funnelIds = DB::table('funnels')->whereIn('customer_id', $customerIds)->pluck('id');
$stepTemplateIds = DB::table('funnel_steps')->whereIn('funnel_id', $funnelIds)->pluck('template_id')->filter();
DB::table('funnel_steps')->whereIn('funnel_id', $funnelIds)->delete();
if ($stepTemplateIds->isNotEmpty()) {
DB::table('templates')->whereIn('id', $stepTemplateIds)->delete();
}
// Delete funnel product links
DB::table('funnel_product_links')->whereIn('funnel_id', $funnelIds)->delete();
// Delete funnel domains
DB::table('funnel_domains')->whereIn('funnel_id', $funnelIds)->delete();
// Delete funnels (including soft-deleted)
DB::table('funnels')->whereIn('customer_id', $customerIds)->delete();
// Delete customer + contact
$contactIds = DB::table('customers')->whereIn('id', $customerIds)->pluck('contact_id');
DB::table('customers')->whereIn('id', $customerIds)->delete();
DB::table('contacts')->whereIn('id', $contactIds)->delete();
});
// ─── Helpers ─────────────────────────────────────────────────────────────────
function funnelTestCustomer(): Customer
{
$contact = Contact::forceCreate([
'uid' => uniqid('funnel_test_'),
'first_name' => 'Funnel',
'last_name' => 'Tester',
'email' => uniqid() . '@funnel-test.invalid',
]);
return Customer::forceCreate([
'uid' => uniqid('funnel_test_'),
'contact_id' => $contact->id,
'status' => 'active',
'timezone' => 'UTC',
]);
}
/**
* Create a template funnel with N steps, each having its own Template with content.
*/
function createTemplateFunnel(array $steps = []): Funnel
{
$funnel = new Funnel();
$funnel->name = 'Test Template Funnel';
$funnel->slug = 'test-template-funnel-' . uniqid();
$funnel->is_template = true;
$funnel->status = Funnel::STATUS_ACTIVE;
$funnel->save();
foreach ($steps as $i => $stepData) {
// Create template with content
$template = new Template();
$template->name = $stepData['name'] . ' Template';
$template->content = $stepData['content'];
$template->json = $stepData['json'] ?? Template::DEFAULT_BUILDER_JSON;
$template->save();
// Create step
$step = new FunnelStep();
$step->funnel_id = $funnel->id;
$step->name = $stepData['name'];
$step->type = $stepData['type'];
$step->slug = $stepData['slug'] ?? \Illuminate\Support\Str::slug($stepData['name']);
$step->sort_order = $i;
$step->is_published = true;
$step->template_id = $template->id;
$step->save();
}
return $funnel->fresh();
}
// ─── Tests ───────────────────────────────────────────────────────────────────
test('clone from template copies all steps with independent templates', function () {
$customer = funnelTestCustomer();
$templateFunnel = createTemplateFunnel([
[
'name' => 'Opt-in Page',
'type' => FunnelStep::TYPE_OPTIN,
'content' => '<h1>Subscribe Now</h1><p>Get our free guide.</p>',
'json' => '{"blocks":[]}',
],
[
'name' => 'Thank You',
'type' => FunnelStep::TYPE_THANK_YOU,
'content' => '<h1>Thanks!</h1><p>Check your inbox.</p>',
],
[
'name' => 'Sales Page',
'type' => FunnelStep::TYPE_SALES,
'content' => '<h1>Buy Our Product</h1><p>Only $49.</p>',
],
]);
// Verify template funnel has 3 steps
expect($templateFunnel->steps)->toHaveCount(3);
// Clone
$funnel = Funnel::cloneFrom($templateFunnel, $customer);
// ── Funnel properties ──
expect($funnel->id)->not->toBe($templateFunnel->id);
expect($funnel->uid)->not->toBe($templateFunnel->uid);
expect($funnel->customer_id)->toBe($customer->id);
expect($funnel->is_template)->toBeFalse();
expect($funnel->published_at)->toBeNull();
// ── Steps cloned ──
$clonedSteps = $funnel->steps()->orderBy('sort_order')->get();
expect($clonedSteps)->toHaveCount(3);
$originalSteps = $templateFunnel->steps()->orderBy('sort_order')->get();
foreach ($clonedSteps as $i => $clonedStep) {
$originalStep = $originalSteps[$i];
// Same name and type
expect($clonedStep->name)->toBe($originalStep->name);
expect($clonedStep->type)->toBe($originalStep->type);
expect($clonedStep->sort_order)->toBe($originalStep->sort_order);
// Different IDs — not the same record
expect($clonedStep->id)->not->toBe($originalStep->id);
expect($clonedStep->uid)->not->toBe($originalStep->uid);
// Belongs to the new funnel
expect($clonedStep->funnel_id)->toBe($funnel->id);
// ── Template is cloned, not shared ──
expect($clonedStep->template_id)->not->toBeNull();
expect($clonedStep->template_id)->not->toBe($originalStep->template_id);
// Content is identical
expect($clonedStep->template->content)->toBe($originalStep->template->content);
expect($clonedStep->template->json)->toBe($originalStep->template->json);
}
});
test('clone from template with no steps produces empty funnel', function () {
$customer = funnelTestCustomer();
$templateFunnel = createTemplateFunnel([]);
$funnel = Funnel::cloneFrom($templateFunnel, $customer);
expect($funnel->steps)->toHaveCount(0);
expect($funnel->customer_id)->toBe($customer->id);
expect($funnel->is_template)->toBeFalse();
});
test('cloned funnel name and slug can be overridden', function () {
$customer = funnelTestCustomer();
$templateFunnel = createTemplateFunnel([
['name' => 'Step 1', 'type' => FunnelStep::TYPE_OPTIN, 'content' => '<p>Hi</p>'],
]);
$funnel = Funnel::cloneFrom($templateFunnel, $customer);
$funnel->name = 'My Custom Funnel';
$funnel->slug = $funnel->generateUniqueSlug('My Custom Funnel');
$funnel->save();
expect($funnel->name)->toBe('My Custom Funnel');
expect($funnel->slug)->toBe('my-custom-funnel');
});
test('add step to funnel creates step with template', function () {
$customer = funnelTestCustomer();
$funnel = Funnel::newDefault($customer);
$funnel->name = 'Add Step Test';
$funnel->save();
// Create a template for the step
$template = new Template();
$template->name = 'Checkout Template';
$template->content = '<h1>Checkout</h1>';
$template->json = Template::DEFAULT_BUILDER_JSON;
$template->save();
// Create step
$step = new FunnelStep();
$step->funnel_id = $funnel->id;
$step->template_id = $template->id;
$validator = $step->saveFromParams([
'name' => 'Checkout Step',
'type' => FunnelStep::TYPE_CHECKOUT,
]);
expect($validator->fails())->toBeFalse();
expect($step->exists)->toBeTrue();
expect($step->funnel_id)->toBe($funnel->id);
expect($step->type)->toBe(FunnelStep::TYPE_CHECKOUT);
expect($step->template_id)->toBe($template->id);
expect($step->hasTemplate())->toBeTrue();
expect($step->getTemplateContent())->toBe('<h1>Checkout</h1>');
// Funnel now has 1 step
expect($funnel->steps()->count())->toBe(1);
});
test('add step without template and set content later', function () {
$customer = funnelTestCustomer();
$funnel = Funnel::newDefault($customer);
$funnel->name = 'No Template Test';
$funnel->save();
// Create step without template
$step = new FunnelStep();
$step->funnel_id = $funnel->id;
$step->saveFromParams([
'name' => 'Custom Page',
'type' => FunnelStep::TYPE_CUSTOM,
]);
expect($step->hasTemplate())->toBeFalse();
// Later: create template and set content (mimics FunnelStepController::updateContent)
$template = new Template();
$template->name = $step->name;
$template->content = '';
$template->json = Template::DEFAULT_BUILDER_JSON;
$template->save();
$step->template_id = $template->id;
$step->save();
$step->refresh();
expect($step->hasTemplate())->toBeTrue();
// Set content
$json = Template::DEFAULT_BUILDER_JSON;
$content = '<div class="landing">Hello World</div>';
$step->setTemplateContent($content, $json);
$step->refresh();
expect($step->getTemplateContent())->toBe($content);
});
test('delete step cleans up its template', function () {
$customer = funnelTestCustomer();
$funnel = Funnel::newDefault($customer);
$funnel->name = 'Delete Step Test';
$funnel->save();
// Create template
$template = new Template();
$template->name = 'To Be Deleted';
$template->content = '<p>Temporary</p>';
$template->json = Template::DEFAULT_BUILDER_JSON;
$template->save();
$templateId = $template->id;
// Create step with template
$step = new FunnelStep();
$step->funnel_id = $funnel->id;
$step->template_id = $templateId;
$step->saveFromParams([
'name' => 'Temp Step',
'type' => FunnelStep::TYPE_CUSTOM,
]);
$stepId = $step->id;
// Verify exists
expect(FunnelStep::find($stepId))->not->toBeNull();
expect(Template::find($templateId))->not->toBeNull();
// Delete
$step->deleteAndCleanup();
// Step is gone
expect(FunnelStep::find($stepId))->toBeNull();
// Template is also gone
expect(Template::find($templateId))->toBeNull();
});
test('delete step without template does not error', function () {
$customer = funnelTestCustomer();
$funnel = Funnel::newDefault($customer);
$funnel->name = 'Delete No Template Test';
$funnel->save();
$step = new FunnelStep();
$step->funnel_id = $funnel->id;
$step->saveFromParams([
'name' => 'No Template Step',
'type' => FunnelStep::TYPE_OPTIN,
]);
$stepId = $step->id;
// Should not throw
$step->deleteAndCleanup();
expect(FunnelStep::find($stepId))->toBeNull();
});
test('duplicate funnel creates independent deep copy', function () {
$customer = funnelTestCustomer();
// Create funnel with 2 steps
$funnel = Funnel::newDefault($customer);
$funnel->name = 'Original Funnel';
$funnel->save();
$htmlContents = [
'<h1>Opt-in</h1><form>...</form>',
'<h1>Thanks!</h1><p>Done.</p>',
];
foreach ($htmlContents as $i => $html) {
$tpl = new Template();
$tpl->name = "Step $i Template";
$tpl->content = $html;
$tpl->json = Template::DEFAULT_BUILDER_JSON;
$tpl->save();
$step = new FunnelStep();
$step->funnel_id = $funnel->id;
$step->template_id = $tpl->id;
$step->saveFromParams([
'name' => $i === 0 ? 'Opt-in' : 'Thank You',
'type' => $i === 0 ? FunnelStep::TYPE_OPTIN : FunnelStep::TYPE_THANK_YOU,
]);
}
$funnel->refresh();
expect($funnel->steps)->toHaveCount(2);
// Duplicate
$copy = $funnel->duplicate();
expect($copy->id)->not->toBe($funnel->id);
expect($copy->uid)->not->toBe($funnel->uid);
expect($copy->name)->toBe('Original Funnel (Copy)');
expect($copy->is_template)->toBeFalse();
expect($copy->published_at)->toBeNull();
$copySteps = $copy->steps()->orderBy('sort_order')->get();
$origSteps = $funnel->steps()->orderBy('sort_order')->get();
expect($copySteps)->toHaveCount(2);
foreach ($copySteps as $i => $cs) {
// Independent records
expect($cs->id)->not->toBe($origSteps[$i]->id);
expect($cs->funnel_id)->toBe($copy->id);
// Independent template with same content
expect($cs->template_id)->not->toBe($origSteps[$i]->template_id);
expect($cs->template->content)->toBe($origSteps[$i]->template->content);
}
// ── Mutation isolation: changing copy's content doesn't affect original ──
$copySteps[0]->setTemplateContent('<h1>Modified</h1>', Template::DEFAULT_BUILDER_JSON);
$origSteps[0]->refresh();
expect($origSteps[0]->template->content)->toBe('<h1>Opt-in</h1><form>...</form>');
expect($copySteps[0]->fresh()->template->content)->toBe('<h1>Modified</h1>');
});
test('deleteAndCleanup removes funnel with all steps and templates', function () {
$customer = funnelTestCustomer();
$funnel = Funnel::newDefault($customer);
$funnel->name = 'Full Cleanup Test';
$funnel->save();
$funnelId = $funnel->id;
$templateIds = [];
$stepIds = [];
for ($i = 0; $i < 3; $i++) {
$tpl = new Template();
$tpl->name = "Cleanup tpl $i";
$tpl->content = "<p>Step $i</p>";
$tpl->json = Template::DEFAULT_BUILDER_JSON;
$tpl->save();
$templateIds[] = $tpl->id;
$step = new FunnelStep();
$step->funnel_id = $funnel->id;
$step->template_id = $tpl->id;
$step->saveFromParams([
'name' => "Step $i",
'type' => FunnelStep::TYPE_CUSTOM,
]);
$stepIds[] = $step->id;
}
// Verify all exist
expect(FunnelStep::whereIn('id', $stepIds)->count())->toBe(3);
expect(Template::whereIn('id', $templateIds)->count())->toBe(3);
// Delete funnel
$funnel->deleteAndCleanup();
// Funnel soft-deleted
expect(Funnel::find($funnelId))->toBeNull();
// All steps gone
expect(FunnelStep::whereIn('id', $stepIds)->count())->toBe(0);
// All templates gone
expect(Template::whereIn('id', $templateIds)->count())->toBe(0);
});