Created
January 28, 2026 13:47
-
-
Save jstoone/19210d3015d34335b700d6e0a08d3ece to your computer and use it in GitHub Desktop.
A new way of testing, using Journeys
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| <?php | |
| use App\Domain\PIM\Events\ProductCreated; | |
| use App\Domain\PIM\Events\ProductSplitCreated; | |
| use App\Domain\PIM\Events\VariantChangedOptionSelections; | |
| use App\Domain\PIM\Events\VariantCreated; | |
| use App\Domain\PIM\Events\VariantDeleted; | |
| use App\Domain\PIM\Events\VariantOptionAssigned; | |
| use App\Domain\PIM\Events\VariantOptionCreated; | |
| use App\Domain\PIM\Events\VariantOptionValueAdded; | |
| use App\Domain\PIM\Events\VariantOptionWasSplit; | |
| use App\Domain\PIM\Events\VariantOptionWasUnsplit; | |
| use App\Domain\PIM\States\ProductSplitState; | |
| use App\Domain\PIM\States\ProductState; | |
| use App\Domain\PIM\States\VariantOptionState; | |
| use App\Domain\PIM\States\VariantState; | |
| use App\Domain\Platform\Factories\OrganizationFactory; | |
| use App\Models\PIM\ProductSplit; | |
| use App\Models\PIM\Variant; | |
| use App\Models\VerbEvent; | |
| describe('The journey of That One Drink and how it splits', function () { | |
| it('can complete a full story arc', function () { | |
| // We're launching "That One Drink" - a simple canned beverage. | |
| // Starting with just one product and one variant (the default). | |
| $organization = OrganizationFactory::new() | |
| ->withLocale('en') | |
| ->create(); | |
| $product = verb(new ProductCreated( | |
| organization_id: $organization->id, | |
| handle: 'that-one-drink', | |
| name: 'That One Drink', | |
| ))->state(ProductState::class); | |
| $productId = $product->id; | |
| $variant = verb(new VariantCreated( | |
| organization_id: $organization->id, | |
| product_id: $product->id, | |
| option_selections: [], | |
| sku: 'DRINK-001', | |
| name: 'Default', | |
| ))->state(VariantState::class); | |
| // A default split should be automatically created for the product | |
| $splitCreatedEvent = VerbEvent::type(ProductSplitCreated::class)->sole(); | |
| expect($splitCreatedEvent)->not->toBeNull(); | |
| $event = $splitCreatedEvent->event(); | |
| expect($event->product_id)->toBe($product->id) | |
| ->and($event->is_default)->toBe(true) | |
| ->and($event->option_selections)->toBe([]); | |
| // Success! The drink is selling well. Customer feedback is pouring in. | |
| // The #1 request? A Kiwi flavor! Time to expand the product line. | |
| $flavorOption = verb(new VariantOptionCreated( | |
| organization_id: $organization->id, | |
| option_name: 'Flavor', | |
| option_key: 'flavor', | |
| ))->state(VariantOptionState::class); | |
| verb(new VariantOptionValueAdded( | |
| organization_id: $organization->id, | |
| option_id: $flavorOption->id, | |
| value_key: 'original', | |
| value_name: 'Original', | |
| )); | |
| verb(new VariantOptionValueAdded( | |
| organization_id: $organization->id, | |
| option_id: $flavorOption->id, | |
| value_key: 'kiwi', | |
| value_name: 'Kiwi', | |
| )); | |
| // Assign the Flavor option to our product | |
| verb(new VariantOptionAssigned( | |
| product_id: $product->id, | |
| option_id: $flavorOption->id, | |
| )); | |
| // Now we need to update our existing variant to specify it's the Original flavor. | |
| // This keeps our catalog organized as we add the new Kiwi variant. | |
| verb(new VariantChangedOptionSelections( | |
| organization_id: $organization->id, | |
| variant_id: $variant->id, | |
| option_selections: [ | |
| $flavorOption->id => 'original', | |
| ], | |
| )); | |
| // The variant should now have the flavor selection | |
| $variant->fresh(); | |
| expect($variant->option_selections)->toBe([ | |
| $flavorOption->id => 'original', | |
| ]); | |
| // Launch day for Kiwi! Let's create the new variant. | |
| $kiwiVariant = verb(new VariantCreated( | |
| organization_id: $organization->id, | |
| product_id: $product->id, | |
| option_selections: [ | |
| $flavorOption->id => 'kiwi', | |
| ], | |
| sku: 'DRINK-002', | |
| name: 'Kiwi', | |
| ))->state(VariantState::class); | |
| $product->fresh(); | |
| // We should still only have one split (the default split) | |
| expect($product->splits)->toHaveCount(1); | |
| // Load the default split and verify it contains both variants | |
| $defaultSplitId = array_values($product->splits)[0]; | |
| $defaultSplit = ProductSplitState::load($defaultSplitId); | |
| expect($defaultSplit->is_default)->toBe(true); | |
| expect(ProductSplit::where('product_id', $productId)->count())->toBe(1); | |
| $defaultSplitProjection = ProductSplit::where('product_id', $productId)->first(); | |
| expect($defaultSplitProjection)->not->toBeNull(); | |
| expect($defaultSplitProjection->is_default)->toBeTrue(); | |
| // Verify variant's split_id is set correctly in projection | |
| $kiwiVariantProjection = Variant::find($kiwiVariant->id); | |
| expect($kiwiVariantProjection->split_id)->toBe($defaultSplitId); | |
| // Marketing has been tracking the data - Kiwi is a hit! | |
| // But it's hard to deep-link or make specific ads because it's one click away. | |
| // They need Original and Kiwi to be separate products for better targeting. | |
| // Time to use the split function! We'll enable splitting on the Flavor option itself. | |
| // Capture the default split ID before subdividing | |
| $defaultSplitId = $product->splits['default']; | |
| verb(new VariantOptionWasSplit( | |
| organization_id: $organization->id, | |
| option_id: $flavorOption->id, | |
| )); | |
| $organization->fresh(); | |
| // Now we should have two splits: Original and Kiwi | |
| $product->fresh(); | |
| expect($product->splits)->toHaveCount(2); | |
| // The default split key should no longer exist | |
| expect($product->splits)->not->toHaveKey('default'); | |
| // Find the Original and Kiwi splits | |
| $flavorOptionId = $flavorOption->id; | |
| $originalSplitKey = $flavorOptionId . ':original'; | |
| $kiwiSplitKey = $flavorOptionId . ':kiwi'; | |
| expect($product->splits)->toHaveKey($originalSplitKey) | |
| ->and($product->splits)->toHaveKey($kiwiSplitKey); | |
| $originalSplit = ProductSplitState::load($product->splits[$originalSplitKey]); | |
| $kiwiSplit = ProductSplitState::load($product->splits[$kiwiSplitKey]); | |
| // The Original split should have reused the default split ID (first value in option->values) | |
| expect($originalSplit->id)->toBe($defaultSplitId) | |
| ->and($originalSplit->name)->toBe('Original') | |
| ->and($originalSplit->is_default)->toBe(false) | |
| ->and($originalSplit->option_selections)->toBe([$flavorOption->id => 'original']); | |
| // Kiwi split should be a new split | |
| expect($kiwiSplit->id)->not->toBe($defaultSplitId) | |
| ->and($kiwiSplit->name)->toBe('Kiwi') | |
| ->and($kiwiSplit->is_default)->toBe(false) | |
| ->and($kiwiSplit->option_selections)->toBe([$flavorOption->id => 'kiwi']); | |
| // Verify variant projections have correct split_id | |
| expect(Variant::find($variant->id)->split_id)->toBe($originalSplit->id); | |
| expect(Variant::find($kiwiVariant->id)->split_id)->toBe($kiwiSplit->id); | |
| $flavorSplits = ProductSplit::where('product_id', $productId) | |
| ->orderBy('path_key') | |
| ->get(); | |
| expect($flavorSplits)->toHaveCount(2) | |
| ->and($flavorSplits->pluck('path_key')->all())->toBe([ | |
| $kiwiSplitKey, | |
| $originalSplitKey, | |
| ]); | |
| // Next chapter: we are now allowing to sell cans and glass bottles | |
| // - Create “Container” variant option | |
| // - Add to product | |
| // - Assign current Original + Kiwi variants to “can” | |
| // - Create Original / Glass | |
| // - Create Kiwi / Glass | |
| // - Assert that the two existing splits now have 2 variants | |
| $containerOption = verb(new VariantOptionCreated( | |
| organization_id: $organization->id, | |
| option_name: 'Container', | |
| option_key: 'container', | |
| ))->state(VariantOptionState::class); | |
| verb(new VariantOptionValueAdded( | |
| organization_id: $organization->id, | |
| option_id: $containerOption->id, | |
| value_key: 'can', | |
| value_name: 'Can', | |
| )); | |
| verb(new VariantOptionValueAdded( | |
| organization_id: $organization->id, | |
| option_id: $containerOption->id, | |
| value_key: 'glass', | |
| value_name: 'Glass', | |
| )); | |
| verb(new VariantOptionAssigned( | |
| product_id: $product->id, | |
| option_id: $containerOption->id, | |
| )); | |
| // Existing variants become canned drinks | |
| verb(new VariantChangedOptionSelections( | |
| organization_id: $organization->id, | |
| variant_id: $variant->id, | |
| option_selections: [ | |
| $flavorOption->id => 'original', | |
| $containerOption->id => 'can', | |
| ], | |
| )); | |
| verb(new VariantChangedOptionSelections( | |
| organization_id: $organization->id, | |
| variant_id: $kiwiVariant->id, | |
| option_selections: [ | |
| $flavorOption->id => 'kiwi', | |
| $containerOption->id => 'can', | |
| ], | |
| )); | |
| // Launch glass variants for both flavors | |
| $originalGlassVariant = verb(new VariantCreated( | |
| organization_id: $organization->id, | |
| product_id: $product->id, | |
| option_selections: [ | |
| $flavorOption->id => 'original', | |
| $containerOption->id => 'glass', | |
| ], | |
| sku: 'DRINK-003', | |
| name: 'Original Glass', | |
| ))->state(VariantState::class); | |
| $kiwiGlassVariant = verb(new VariantCreated( | |
| organization_id: $organization->id, | |
| product_id: $product->id, | |
| option_selections: [ | |
| $flavorOption->id => 'kiwi', | |
| $containerOption->id => 'glass', | |
| ], | |
| sku: 'DRINK-004', | |
| name: 'Kiwi Glass', | |
| ))->state(VariantState::class); | |
| $product->fresh(); | |
| $originalSplit->fresh(); | |
| $kiwiSplit->fresh(); | |
| // Verify variant projections have correct split_id | |
| expect(Variant::find($variant->id)->split_id)->toBe($originalSplit->id); | |
| expect(Variant::find($originalGlassVariant->id)->split_id)->toBe($originalSplit->id); | |
| expect(Variant::find($kiwiVariant->id)->split_id)->toBe($kiwiSplit->id); | |
| expect(Variant::find($kiwiGlassVariant->id)->split_id)->toBe($kiwiSplit->id); | |
| // Next chapter: we are ready to create branding pages for cans and glass products | |
| // - Split “Container” variant option | |
| // - Assert it cascaded into four splits with one variant each | |
| verb(new VariantOptionWasSplit( | |
| organization_id: $organization->id, | |
| option_id: $containerOption->id, | |
| )); | |
| $product->fresh(); | |
| $containerOptionId = $containerOption->id; | |
| $originalCanKey = $flavorOptionId . ':original|' . $containerOptionId . ':can'; | |
| $originalGlassKey = $flavorOptionId . ':original|' . $containerOptionId . ':glass'; | |
| $kiwiCanKey = $flavorOptionId . ':kiwi|' . $containerOptionId . ':can'; | |
| $kiwiGlassKey = $flavorOptionId . ':kiwi|' . $containerOptionId . ':glass'; | |
| expect($product->splits)->toHaveCount(4); | |
| expect($product->splits)->toHaveKey($originalCanKey) | |
| ->and($product->splits)->toHaveKey($originalGlassKey) | |
| ->and($product->splits)->toHaveKey($kiwiCanKey) | |
| ->and($product->splits)->toHaveKey($kiwiGlassKey); | |
| $originalSplit->fresh(); | |
| $kiwiSplit->fresh(); | |
| // Existing split IDs should now represent the canned variants | |
| expect($product->splits[$originalCanKey])->toBe($originalSplit->id); | |
| expect($product->splits[$kiwiCanKey])->toBe($kiwiSplit->id); | |
| expect($originalSplit->path_key)->toBe($originalCanKey) | |
| ->and($originalSplit->name)->toBe('Original / Can'); | |
| expect($kiwiSplit->path_key)->toBe($kiwiCanKey) | |
| ->and($kiwiSplit->name)->toBe('Kiwi / Can'); | |
| $originalGlassSplit = ProductSplitState::load($product->splits[$originalGlassKey]); | |
| $kiwiGlassSplit = ProductSplitState::load($product->splits[$kiwiGlassKey]); | |
| expect($originalGlassSplit->name)->toBe('Original / Glass'); | |
| expect($kiwiGlassSplit->name)->toBe('Kiwi / Glass'); | |
| // Verify variant projections have correct split_id | |
| expect(Variant::find($variant->id)->split_id)->toBe($originalSplit->id); | |
| expect(Variant::find($originalGlassVariant->id)->split_id)->toBe($originalGlassSplit->id); | |
| expect(Variant::find($kiwiVariant->id)->split_id)->toBe($kiwiSplit->id); | |
| expect(Variant::find($kiwiGlassVariant->id)->split_id)->toBe($kiwiGlassSplit->id); | |
| $containerKeys = [ | |
| $originalCanKey, | |
| $originalGlassKey, | |
| $kiwiCanKey, | |
| $kiwiGlassKey, | |
| ]; | |
| $containerSplits = ProductSplit::where('product_id', $productId) | |
| ->whereIn('path_key', $containerKeys) | |
| ->with('variants') | |
| ->get() | |
| ->keyBy('path_key'); | |
| expect($containerSplits)->toHaveCount(4); | |
| expect($containerSplits[$originalGlassKey]->variants->pluck('id')->all()) | |
| ->toBe([$originalGlassVariant->id]); | |
| expect($containerSplits[$kiwiGlassKey]->variants->pluck('id')->all()) | |
| ->toBe([$kiwiGlassVariant->id]); | |
| // Campaign time: "Vote For That One New Drink" pushes an Orange flavour | |
| // - Add new flavor value | |
| // - Launch Orange Can + Orange Glass variants | |
| // - Expect new splits (flavor:orange with both containers) to be created lazily and contain the new variants | |
| verb(new VariantOptionValueAdded( | |
| organization_id: $organization->id, | |
| option_id: $flavorOption->id, | |
| value_key: 'orange', | |
| value_name: 'Orange', | |
| )); | |
| $orangeCanVariant = verb(new VariantCreated( | |
| organization_id: $organization->id, | |
| product_id: $product->id, | |
| option_selections: [ | |
| $flavorOption->id => 'orange', | |
| $containerOption->id => 'can', | |
| ], | |
| sku: 'DRINK-005', | |
| name: 'Orange Can', | |
| ))->state(VariantState::class); | |
| $orangeGlassVariant = verb(new VariantCreated( | |
| organization_id: $organization->id, | |
| product_id: $product->id, | |
| option_selections: [ | |
| $flavorOption->id => 'orange', | |
| $containerOption->id => 'glass', | |
| ], | |
| sku: 'DRINK-006', | |
| name: 'Orange Glass', | |
| ))->state(VariantState::class); | |
| $product->fresh(); | |
| $orangeCanKey = $flavorOption->id . ':orange|' . $containerOption->id . ':can'; | |
| $orangeGlassKey = $flavorOption->id . ':orange|' . $containerOption->id . ':glass'; | |
| expect($product->splits)->toHaveKey($orangeCanKey) | |
| ->and($product->splits)->toHaveKey($orangeGlassKey); | |
| $orangeCanSplit = ProductSplitState::load($product->splits[$orangeCanKey]); | |
| $orangeGlassSplit = ProductSplitState::load($product->splits[$orangeGlassKey]); | |
| expect($orangeCanSplit->name)->toBe('Orange / Can'); | |
| expect($orangeGlassSplit->name)->toBe('Orange / Glass'); | |
| // Verify variant projections have correct split_id | |
| expect(Variant::find($orangeCanVariant->id)->split_id)->toBe($orangeCanSplit->id); | |
| expect(Variant::find($orangeGlassVariant->id)->split_id)->toBe($orangeGlassSplit->id); | |
| // Plot twist: Orange is a massive success, but Kiwi sales have dropped to zero. | |
| // The data is clear - customers prefer Orange. Time to discontinue Kiwi. | |
| // We need to delete the Kiwi variants. When all variants in a split are deleted, | |
| // the split should be automatically removed from the product. | |
| // Delete Kiwi Can variant | |
| verb(new VariantDeleted( | |
| organization_id: $organization->id, | |
| variant_id: $kiwiVariant->id, | |
| )); | |
| // Delete Kiwi Glass variant | |
| verb(new VariantDeleted( | |
| organization_id: $organization->id, | |
| variant_id: $kiwiGlassVariant->id, | |
| )); | |
| // Verify variant states are marked as deleted | |
| $kiwiVariant->fresh(); | |
| $kiwiGlassVariant->fresh(); | |
| expect($kiwiVariant->deleted_at)->not->toBeNull(); | |
| expect($kiwiGlassVariant->deleted_at)->not->toBeNull(); | |
| // Verify variants are deleted from DB | |
| expect(Variant::find($kiwiVariant->id))->toBeNull(); | |
| expect(Variant::find($kiwiGlassVariant->id))->toBeNull(); | |
| // Empty splits persist (users can manually delete, won't pass Sync spec) | |
| $product->fresh(); | |
| expect($product->splits)->toHaveKey($kiwiCanKey); | |
| expect($product->splits)->toHaveKey($kiwiGlassKey); | |
| // Product has 6 splits - 4 with variants, 2 empty (Kiwi) | |
| expect($product->splits)->toHaveCount(6); | |
| expect($product->splits)->toHaveKey($originalCanKey); | |
| expect($product->splits)->toHaveKey($originalGlassKey); | |
| expect($product->splits)->toHaveKey($orangeCanKey); | |
| expect($product->splits)->toHaveKey($orangeGlassKey); | |
| // Verify variants are removed from product.variants | |
| expect($product->variants)->not->toContain($kiwiVariant->id); | |
| expect($product->variants)->not->toContain($kiwiGlassVariant->id); | |
| // CHAPTER 7: The New Webshop Era | |
| // After months of hard work, That One Drink launches their new webshop! | |
| // The new platform has advanced deep-linking capabilities - customers can | |
| // share direct links to specific variants (e.g., "Original in Glass"). | |
| // This means we no longer need separate product pages for Cans vs Glass! | |
| // | |
| // Time to simplify: We'll unsplit the Container option and merge back | |
| // to having just one page per flavor. Each flavor page will show both | |
| // the Can and Glass options within it. | |
| verb(new VariantOptionWasUnsplit( | |
| organization_id: $organization->id, | |
| option_id: $containerOption->id, | |
| )); | |
| $product->fresh(); | |
| $organization->fresh(); | |
| // Container should be removed from split layers | |
| expect($product->split_layers)->toBe([$flavorOption->id]); | |
| // We have 3 splits: Original, Orange, and Kiwi (empty, persisted) | |
| expect($product->splits)->toHaveCount(3); | |
| $simplifiedOriginalKey = $flavorOption->id . ':original'; | |
| $simplifiedOrangeKey = $flavorOption->id . ':orange'; | |
| $simplifiedKiwiKey = $flavorOption->id . ':kiwi'; | |
| expect($product->splits)->toHaveKey($simplifiedOriginalKey); | |
| expect($product->splits)->toHaveKey($simplifiedOrangeKey); | |
| expect($product->splits)->toHaveKey($simplifiedKiwiKey); // Empty but persisted | |
| // Load the merged splits | |
| $simplifiedOriginalSplit = ProductSplitState::load($product->splits[$simplifiedOriginalKey]); | |
| $simplifiedOrangeSplit = ProductSplitState::load($product->splits[$simplifiedOrangeKey]); | |
| // Original split should now contain both Can and Glass variants | |
| expect($simplifiedOriginalSplit->name)->toBe('Original') | |
| ->and($simplifiedOriginalSplit->option_selections)->toBe([$flavorOption->id => 'original']) | |
| ->and($simplifiedOriginalSplit->path_key)->toBe($simplifiedOriginalKey); | |
| // Orange split should now contain both Can and Glass variants | |
| expect($simplifiedOrangeSplit->name)->toBe('Orange') | |
| ->and($simplifiedOrangeSplit->option_selections)->toBe([$flavorOption->id => 'orange']) | |
| ->and($simplifiedOrangeSplit->path_key)->toBe($simplifiedOrangeKey); | |
| // Verify variant projections have correct split_id | |
| expect(Variant::find($variant->id)->split_id)->toBe($simplifiedOriginalSplit->id); | |
| expect(Variant::find($originalGlassVariant->id)->split_id)->toBe($simplifiedOriginalSplit->id); | |
| expect(Variant::find($orangeCanVariant->id)->split_id)->toBe($simplifiedOrangeSplit->id); | |
| expect(Variant::find($orangeGlassVariant->id)->split_id)->toBe($simplifiedOrangeSplit->id); | |
| // The old container-level splits should be gone from the product | |
| expect($product->splits)->not->toHaveKey($originalCanKey); | |
| expect($product->splits)->not->toHaveKey($originalGlassKey); | |
| expect($product->splits)->not->toHaveKey($orangeCanKey); | |
| expect($product->splits)->not->toHaveKey($orangeGlassKey); | |
| // Verify the container option is no longer marked for splitting | |
| $containerOption->fresh(); | |
| expect($containerOption->is_split)->toBe(false); | |
| // Success! That One Drink now has a simpler, more modern setup: | |
| // - Two product pages: Original and Orange | |
| // - Each page lets customers choose Can or Glass | |
| // - Deep links work beautifully: /products/original?variant=glass | |
| // - The business can pivot their strategy as their platform evolves | |
| // EPILOGUE: Back to Basics | |
| // After a year of analytics, the team has discovered something profound: | |
| // customers prefer browsing ALL drinks together, using filters for flavor | |
| // and container. The split pages were actually hurting discovery! | |
| // | |
| // The CEO makes the call: "Let's go back to one unified product page. | |
| // We'll use variant selectors like we did in the beginning, but now | |
| // we have the data to make it work perfectly." | |
| // | |
| // Time to unsplit Flavor as well and return to the default split. | |
| verb(new VariantOptionWasUnsplit( | |
| organization_id: $organization->id, | |
| option_id: $flavorOption->id, | |
| )); | |
| $product->fresh(); | |
| $organization->fresh(); | |
| // Both options should be removed from split layers | |
| expect($product->split_layers)->toBe([]); | |
| // We should now have exactly 1 split: the default split | |
| expect($product->splits)->toHaveCount(1); | |
| expect($product->splits)->toHaveKey('default'); | |
| // Load the default split - it should contain all 4 active variants | |
| $finalDefaultSplit = ProductSplitState::load($product->splits['default']); | |
| expect($finalDefaultSplit->name)->toBe('Default') | |
| ->and($finalDefaultSplit->is_default)->toBe(true) // Reused from first split transformation | |
| ->and($finalDefaultSplit->option_selections)->toBe([]) | |
| ->and($finalDefaultSplit->path_key)->toBe('default'); | |
| // Verify all 4 variants point to the default split | |
| expect(Variant::find($variant->id)->split_id)->toBe($finalDefaultSplit->id); | |
| expect(Variant::find($originalGlassVariant->id)->split_id)->toBe($finalDefaultSplit->id); | |
| expect(Variant::find($orangeCanVariant->id)->split_id)->toBe($finalDefaultSplit->id); | |
| expect(Variant::find($orangeGlassVariant->id)->split_id)->toBe($finalDefaultSplit->id); | |
| // Verify the split projections in the database | |
| expect(ProductSplit::where('product_id', $productId)->count())->toBe(1); | |
| $defaultSplitProjection = ProductSplit::where('product_id', $productId)->first(); | |
| expect($defaultSplitProjection)->not->toBeNull(); | |
| expect($defaultSplitProjection->path_key)->toBe('default'); | |
| expect($defaultSplitProjection->variants()->count())->toBe(4); | |
| expect($defaultSplitProjection->name)->toBe('Default'); | |
| // Verify flavor option is no longer marked for splitting | |
| $flavorOption->fresh(); | |
| expect($flavorOption->is_split)->toBe(false); | |
| // The old flavor-level splits should be completely gone | |
| expect($product->splits)->not->toHaveKey($simplifiedOriginalKey); | |
| expect($product->splits)->not->toHaveKey($simplifiedOrangeKey); | |
| // BONUS CHAPTER: The A/B Testing Pivot | |
| // Plot twist! Before celebrating the unified approach, the marketing team | |
| // has one more request: "We need to A/B test different landing pages for | |
| // Cans vs Glass bottles. Let's split by Container instead of Flavor!" | |
| // | |
| // First, let's split by Flavor again (we just unsplit it) | |
| verb(new VariantOptionWasSplit( | |
| organization_id: $organization->id, | |
| option_id: $flavorOption->id, | |
| )); | |
| $product->fresh(); | |
| // We should have 2 splits: Original and Orange (by flavor) | |
| expect($product->splits)->toHaveCount(2); | |
| expect($product->splits)->toHaveKey($simplifiedOriginalKey); | |
| expect($product->splits)->toHaveKey($simplifiedOrangeKey); | |
| // Now split by Container as well | |
| verb(new VariantOptionWasSplit( | |
| organization_id: $organization->id, | |
| option_id: $containerOption->id, | |
| )); | |
| $product->fresh(); | |
| $organization->fresh(); | |
| // We should now have 4 splits (Original Can/Glass, Orange Can/Glass) | |
| expect($product->splits)->toHaveCount(4); | |
| // Verify all 4 splits exist with correct names | |
| $originalCanKey = $flavorOption->id . ':original|' . $containerOption->id . ':can'; | |
| $originalGlassKey = $flavorOption->id . ':original|' . $containerOption->id . ':glass'; | |
| $orangeCanKey = $flavorOption->id . ':orange|' . $containerOption->id . ':can'; | |
| $orangeGlassKey = $flavorOption->id . ':orange|' . $containerOption->id . ':glass'; | |
| expect($product->splits)->toHaveKeys([$originalCanKey, $originalGlassKey, $orangeCanKey, $orangeGlassKey]); | |
| $originalCanSplit = ProductSplitState::load($product->splits[$originalCanKey]); | |
| $originalGlassSplit = ProductSplitState::load($product->splits[$originalGlassKey]); | |
| $orangeCanSplit = ProductSplitState::load($product->splits[$orangeCanKey]); | |
| $orangeGlassSplit = ProductSplitState::load($product->splits[$orangeGlassKey]); | |
| expect($originalCanSplit->name)->toBe('Original / Can'); | |
| expect($originalGlassSplit->name)->toBe('Original / Glass'); | |
| expect($orangeCanSplit->name)->toBe('Orange / Can'); | |
| expect($orangeGlassSplit->name)->toBe('Orange / Glass'); | |
| // Now here's the pivot: unsplit by Flavor (the first option) | |
| // This should give us 2 splits: Can and Glass | |
| verb(new VariantOptionWasUnsplit( | |
| organization_id: $organization->id, | |
| option_id: $flavorOption->id, | |
| )); | |
| $product->fresh(); | |
| $organization->fresh(); | |
| // Split layers should now only have Container | |
| expect($product->split_layers)->toBe([$containerOption->id]); | |
| // We should have 2 splits: Can and Glass | |
| expect($product->splits)->toHaveCount(2); | |
| $canKey = $containerOption->id . ':can'; | |
| $glassKey = $containerOption->id . ':glass'; | |
| expect($product->splits)->toHaveKey($canKey); | |
| expect($product->splits)->toHaveKey($glassKey); | |
| // Load the merged splits and verify their names | |
| $canSplit = ProductSplitState::load($product->splits[$canKey]); | |
| $glassSplit = ProductSplitState::load($product->splits[$glassKey]); | |
| // THIS IS THE KEY TEST: Names should be just "Can" and "Glass", not "Original / Can" | |
| expect($canSplit->name)->toBe('Can') | |
| ->and($canSplit->option_selections)->toBe([$containerOption->id => 'can']) | |
| ->and($canSplit->path_key)->toBe($canKey) | |
| ->and($canSplit->is_default)->toBe(false); | |
| expect($glassSplit->name)->toBe('Glass') | |
| ->and($glassSplit->option_selections)->toBe([$containerOption->id => 'glass']) | |
| ->and($glassSplit->path_key)->toBe($glassKey) | |
| ->and($glassSplit->is_default)->toBe(false); | |
| // Verify variant projections have correct split_id | |
| expect(Variant::find($variant->id)->split_id)->toBe($canSplit->id); | |
| expect(Variant::find($orangeCanVariant->id)->split_id)->toBe($canSplit->id); | |
| expect(Variant::find($originalGlassVariant->id)->split_id)->toBe($glassSplit->id); | |
| expect(Variant::find($orangeGlassVariant->id)->split_id)->toBe($glassSplit->id); | |
| // The old 4 splits should be gone | |
| expect($product->splits)->not->toHaveKey($originalCanKey); | |
| expect($product->splits)->not->toHaveKey($originalGlassKey); | |
| expect($product->splits)->not->toHaveKey($orangeCanKey); | |
| expect($product->splits)->not->toHaveKey($orangeGlassKey); | |
| // Perfect! Now we can A/B test landing pages for Can vs Glass products. | |
| // Let's clean up and go back to default for the finale. | |
| verb(new VariantOptionWasUnsplit( | |
| organization_id: $organization->id, | |
| option_id: $containerOption->id, | |
| )); | |
| $product->fresh(); | |
| expect($product->splits)->toHaveCount(1); | |
| expect($product->splits)->toHaveKey('default'); | |
| // INTERMISSION: Full Circle on the Drinks | |
| // That One Drink is now back where they started, but with so much more: | |
| // - 4 successful product variants (Original Can/Glass, Orange Can/Glass) | |
| // - Deep understanding of their customers from the split experiment | |
| // - A platform that can adapt as their business needs change | |
| // - Confidence that they can try new strategies without technical debt | |
| // | |
| // The journey from one split → flavor splits → flavor+container splits | |
| // → flavor splits → back to one split → flavor+container again | |
| // → container splits → back to one split proves the system's flexibility. | |
| // That One Drink can now focus on what matters: making great beverages! 🥤 | |
| // ═══════════════════════════════════════════════════════════════════════ | |
| // CHAPTER 8: The Merch Drop | |
| // ═══════════════════════════════════════════════════════════════════════ | |
| // | |
| // That One Drink has become a cultural phenomenon! Social media is flooded | |
| // with fans showing off their drink of choice. The marketing team sees an | |
| // opportunity: merchandise! They'll start simple - a black t-shirt with | |
| // the iconic TOD logo. | |
| // | |
| // Unlike the drinks, this product starts with just one variant. No sizes | |
| // yet (they're testing demand), no colors (black is classic), just | |
| // "The TOD Tee" in all its glory. | |
| $tshirt = verb(new ProductCreated( | |
| organization_id: $organization->id, | |
| handle: 'tod-tee', | |
| name: 'The TOD Tee', | |
| ))->state(ProductState::class); | |
| $tshirtDefaultVariant = verb(new VariantCreated( | |
| organization_id: $organization->id, | |
| product_id: $tshirt->id, | |
| option_selections: [], | |
| sku: 'TOD-TEE-001', | |
| name: 'Default', | |
| ))->state(VariantState::class); | |
| // Verify t-shirt has a default split with one variant | |
| $tshirt->fresh(); | |
| expect($tshirt->splits)->toHaveCount(1) | |
| ->and($tshirt->splits)->toHaveKey('default'); | |
| $tshirtDefaultSplit = ProductSplitState::load($tshirt->splits['default']); | |
| expect(Variant::find($tshirtDefaultVariant->id)->split_id)->toBe($tshirtDefaultSplit->id); | |
| // ═══════════════════════════════════════════════════════════════════════ | |
| // CHAPTER 9: "I'm a Kiwi Person!" | |
| // ═══════════════════════════════════════════════════════════════════════ | |
| // | |
| // The TOD Tee sells out in 48 hours. The feedback is overwhelming, but | |
| // one request keeps coming up: "Can I get a shirt that shows my flavor?" | |
| // | |
| // Fans have strong opinions. Original loyalists, Orange converts, and | |
| // yes - even the Kiwi fans who are still mourning their discontinued | |
| // favorite. The team realizes flavor identity is part of the brand now. | |
| // | |
| // Time to add flavor variants to the t-shirt! They'll reuse the existing | |
| // Flavor option (why create a new one?) and split by it so each flavor | |
| // gets its own product page with matching branding. | |
| // First, assign the existing Flavor option to the t-shirt | |
| verb(new VariantOptionAssigned( | |
| product_id: $tshirt->id, | |
| option_id: $flavorOption->id, | |
| )); | |
| $tshirt->fresh(); | |
| expect($tshirt->available_options)->toContain($flavorOption->id); | |
| // Now mark Flavor as a split option for the t-shirt | |
| // This will subdivide the default split into flavor-specific splits | |
| verb(new VariantOptionWasSplit( | |
| organization_id: $organization->id, | |
| option_id: $flavorOption->id, | |
| )); | |
| $tshirt->fresh(); | |
| $flavorOption->fresh(); | |
| // The t-shirt should now have splits for Original and Orange | |
| // (Kiwi was discontinued, so no variants exist for it yet) | |
| // But wait - our default variant doesn't have a flavor selection yet! | |
| // It should still be in a "limbo" state until we assign a flavor. | |
| // Actually, since no variants have flavor selections yet, we need to | |
| // update our default variant to specify which flavor it represents. | |
| // Let's make it the Original flavor t-shirt. | |
| verb(new VariantChangedOptionSelections( | |
| organization_id: $organization->id, | |
| variant_id: $tshirtDefaultVariant->id, | |
| option_selections: [ | |
| $flavorOption->id => 'original', | |
| ], | |
| )); | |
| // Refresh states | |
| $tshirt->fresh(); | |
| $tshirtDefaultVariant->fresh(); | |
| // The variant should now have the flavor selection | |
| expect($tshirtDefaultVariant->option_selections)->toBe([ | |
| $flavorOption->id => 'original', | |
| ]); | |
| // The default split should have been TRANSFORMED into Original (ID preserved!) | |
| $originalTshirtSplitKey = $flavorOption->id . ':original'; | |
| expect($tshirt->splits)->toHaveKey($originalTshirtSplitKey); | |
| $originalTshirtSplit = ProductSplitState::load($tshirt->splits[$originalTshirtSplitKey]); | |
| expect($originalTshirtSplit->name)->toBe('Original'); | |
| // CRITICAL: The split ID must be preserved when transforming from default to flavor-specific | |
| // This is essential for external systems (Sync context) that reference splits by stable IDs | |
| expect($originalTshirtSplit->id)->toBe($tshirtDefaultSplit->id); | |
| // Verify the variant projection has the correct split_id | |
| $tshirtVariantProjection = Variant::find($tshirtDefaultVariant->id); | |
| expect($tshirtVariantProjection->split_id)->toBe($originalTshirtSplit->id); | |
| // Now let's create the Orange flavor t-shirt! | |
| $orangeTshirtVariant = verb(new VariantCreated( | |
| organization_id: $organization->id, | |
| product_id: $tshirt->id, | |
| option_selections: [ | |
| $flavorOption->id => 'orange', | |
| ], | |
| sku: 'TOD-TEE-002', | |
| name: 'Orange Tee', | |
| ))->state(VariantState::class); | |
| $tshirt->fresh(); | |
| // Orange split should now exist | |
| $orangeTshirtSplitKey = $flavorOption->id . ':orange'; | |
| expect($tshirt->splits)->toHaveKey($orangeTshirtSplitKey); | |
| $orangeTshirtSplit = ProductSplitState::load($tshirt->splits[$orangeTshirtSplitKey]); | |
| expect($orangeTshirtSplit->name)->toBe('Orange'); | |
| expect(Variant::find($orangeTshirtVariant->id)->split_id)->toBe($orangeTshirtSplit->id); | |
| // Verify the database projections | |
| expect(ProductSplit::where('product_id', $tshirt->id)->count())->toBe(2); | |
| expect(Variant::find($orangeTshirtVariant->id)->split_id)->toBe($orangeTshirtSplit->id); | |
| // ═══════════════════════════════════════════════════════════════════════ | |
| // EPILOGUE: The Kiwi Comeback | |
| // ═══════════════════════════════════════════════════════════════════════ | |
| // | |
| // Plot twist! The Kiwi fans' persistence pays off. After months of | |
| // "Bring Back Kiwi" campaigns, the team announces a limited edition | |
| // Kiwi t-shirt - even though the drink is discontinued! | |
| // | |
| // "Kiwi Forever" becomes the rallying cry. The t-shirt sells out | |
| // faster than any other product in TOD history. | |
| $kiwiTshirtVariant = verb(new VariantCreated( | |
| organization_id: $organization->id, | |
| product_id: $tshirt->id, | |
| option_selections: [ | |
| $flavorOption->id => 'kiwi', | |
| ], | |
| sku: 'TOD-TEE-003', | |
| name: 'Kiwi Forever Tee', | |
| ))->state(VariantState::class); | |
| $tshirt->fresh(); | |
| // Kiwi split should now exist for the t-shirt | |
| $kiwiTshirtSplitKey = $flavorOption->id . ':kiwi'; | |
| expect($tshirt->splits)->toHaveKey($kiwiTshirtSplitKey); | |
| $kiwiTshirtSplit = ProductSplitState::load($tshirt->splits[$kiwiTshirtSplitKey]); | |
| expect($kiwiTshirtSplit->name)->toBe('Kiwi'); | |
| expect(Variant::find($kiwiTshirtVariant->id)->split_id)->toBe($kiwiTshirtSplit->id); | |
| // Final verification: T-shirt has 3 splits (Original, Orange, Kiwi) | |
| expect($tshirt->splits)->toHaveCount(3); | |
| expect(ProductSplit::where('product_id', $tshirt->id)->count())->toBe(3); | |
| // THE END: From Beverage to Lifestyle Brand | |
| // | |
| // That One Drink started as a simple canned beverage. Now it's a | |
| // lifestyle brand with: | |
| // - 4 drink variants across 2 flavors and 2 containers | |
| // - 3 t-shirt variants representing flavor identity | |
| // - A flexible platform that lets them experiment with product structure | |
| // - Proof that even discontinued products can live on in new forms | |
| // | |
| // The Kiwi fans finally got their win. 🥝👕 | |
| }); | |
| }); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment