├── .github
├── scripts
│ └── generate-release-notes.js
└── workflows
│ ├── merge.yml
│ ├── pr.yml
│ └── release.yml
├── .gitignore
├── .idea
├── .gitignore
├── bento-laravel-sdk.iml
├── blade.xml
├── codeception.xml
├── inspectionProfiles
│ └── Project_Default.xml
├── laravel-idea-personal.xml
├── laravel-idea.xml
├── modules.xml
├── php-test-framework.xml
├── php.xml
├── phpspec.xml
├── phpunit.xml
├── runConfigurations
│ └── phpunit_xml.xml
└── vcs.xml
├── CODE_OF_CONDUCT.md
├── LICENSE.md
├── README.md
├── art
└── bento-laravel-sdk.png
├── composer.json
├── composer.lock
├── config
└── bentonow.php
├── phpunit.xml
├── src
├── Actions
│ └── UserImportAction.php
├── BentoConnector.php
├── BentoLaravelServiceProvider.php
├── BentoTransport.php
├── Console
│ ├── InstallCommand.php
│ └── UserImportCommand.php
├── DataTransferObjects
│ ├── BlacklistStatusData.php
│ ├── CommandData.php
│ ├── ContactData.php
│ ├── ContentModerationData.php
│ ├── CreateBroadcastData.php
│ ├── CreateFieldData.php
│ ├── CreateSubscriberData.php
│ ├── CreateTagData.php
│ ├── EventData.php
│ ├── GenderData.php
│ ├── GeoLocateIpData.php
│ ├── ImportSubscribersData.php
│ ├── ReportStatsData.php
│ ├── SegmentStatsData.php
│ └── ValidateEmailData.php
├── Enums
│ ├── BroadcastType.php
│ └── Command.php
├── Facades
│ └── Bento.php
├── Http
│ └── Middleware
│ │ └── BentoSignatureExclusion.php
├── Requests
│ ├── CreateBroadcast.php
│ ├── CreateEvents.php
│ ├── CreateField.php
│ ├── CreateSubscriber.php
│ ├── CreateTag.php
│ ├── FindSubscriber.php
│ ├── GeoLocateIP.php
│ ├── GetBlacklistStatus.php
│ ├── GetBroadcasts.php
│ ├── GetContentModeration.php
│ ├── GetFields.php
│ ├── GetGender.php
│ ├── GetReportStats.php
│ ├── GetSegmentStats.php
│ ├── GetSiteStats.php
│ ├── GetTags.php
│ ├── ImportSubscribers.php
│ ├── SubscriberCommand.php
│ └── ValidateEmail.php
├── Responses
│ ├── BentoApiResponse.php
│ └── FindSubscriberResponse.php
└── SentMessagePayloadTransformer.php
└── tests
├── Architecture
└── NoDebuggingStatementsTest.php
├── Pest.php
├── TestCase.php
└── Unit
├── BentoSignatureExclusionTest.php
├── CreateEventTest.php
├── CreateFieldTest.php
├── CreateSubscriberTest.php
├── CreateTagTest.php
├── FindSubscriberTest.php
├── GeolocateIpTest.php
├── GetBlackListStatusTest.php
├── GetBroadcastsTest.php
├── GetContentModerationTest.php
├── GetFieldsTest.php
├── GetGenderTest.php
├── GetReportStatsTest.php
├── GetSegmentStatsTest.php
├── GetSiteStatsTest.php
├── GetTagsTest.php
├── ImportSubscribersTest.php
├── SubscriberCommandTest.php
├── TestMailable.php
├── TransportTest.php
├── UserImportCommandTest.php
└── ValidateEmailTest.php
/.github/scripts/generate-release-notes.js:
--------------------------------------------------------------------------------
1 | module.exports = async ({github, context}) => {
2 | // Get the latest tag
3 | const { data: tags } = await github.rest.repos.listTags({
4 | owner: context.repo.owner,
5 | repo: context.repo.repo,
6 | per_page: 1
7 | });
8 | const latestTag = tags[0]?.name || '';
9 | // Get commits since last tag
10 | const { data: commits } = await github.rest.repos.compareCommits({
11 | owner: context.repo.owner,
12 | repo: context.repo.repo,
13 | base: latestTag || 'main~1',
14 | head: 'main'
15 | });
16 | // Get PRs
17 | const { data: pulls } = await github.rest.pulls.list({
18 | owner: context.repo.owner,
19 | repo: context.repo.repo,
20 | state: 'closed',
21 | sort: 'updated',
22 | direction: 'desc',
23 | per_page: 100
24 | });
25 | // Filter merged PRs since last release
26 | const mergedPRs = pulls.filter(pr => {
27 | return pr.merged_at && (!latestTag || new Date(pr.merged_at) > new Date(tags[0]?.created_at));
28 | });
29 | // Enhanced change type detection
30 | const getChangeType = (subject, body = '') => {
31 | const text = `${subject}\n${body}`.toLowerCase();
32 | // PHP-specific breaking changes
33 | if (text.includes('breaking change') ||
34 | text.includes('breaking:') ||
35 | text.includes('bc break') ||
36 | text.includes('backwards compatibility')) return 'breaking';
37 | // PHP-specific features
38 | if (text.includes('feat:') ||
39 | text.includes('feature:') ||
40 | text.includes('enhancement:') ||
41 | text.includes('new class') ||
42 | text.includes('new interface')) return 'feature';
43 | // PHP-specific fixes
44 | if (text.includes('fix:') ||
45 | text.includes('bug:') ||
46 | text.includes('hotfix:') ||
47 | text.includes('patch:')) return 'bug';
48 | // Dependencies
49 | if (text.includes('go') ||
50 | text.includes('dependency') ||
51 | text.includes('upgrade') ||
52 | text.includes('bump')) return 'dependency';
53 | // Documentation
54 | if (text.includes('doc:') ||
55 | text.includes('docs:')) return 'docs';
56 | // Tests
57 | if (text.includes('test:') ||
58 | text.includes('test') ||
59 | text.includes('coverage')) return 'test';
60 | // Maintenance
61 | if (text.includes('chore:') ||
62 | text.includes('refactor:') ||
63 | text.includes('style:') ||
64 | text.includes('ci:') ||
65 | text.includes('lint')) return 'maintenance';
66 | return 'other';
67 | };
68 | // Enhanced categories
69 | const categories = {
70 | '🚀 New Features': {
71 | commits: commits.commits.filter(commit =>
72 | getChangeType(commit.commit.message) === 'feature'
73 | ),
74 | prs: mergedPRs.filter(pr =>
75 | getChangeType(pr.title, pr.body) === 'feature'
76 | )
77 | },
78 | '🐛 Bug Fixes': {
79 | commits: commits.commits.filter(commit =>
80 | getChangeType(commit.commit.message) === 'bug'
81 | ),
82 | prs: mergedPRs.filter(pr =>
83 | getChangeType(pr.title, pr.body) === 'bug'
84 | )
85 | },
86 | '📦 Dependencies': {
87 | commits: commits.commits.filter(commit =>
88 | getChangeType(commit.commit.message) === 'dependency'
89 | ),
90 | prs: mergedPRs.filter(pr =>
91 | getChangeType(pr.title, pr.body) === 'dependency'
92 | )
93 | },
94 | '📚 Documentation': {
95 | commits: commits.commits.filter(commit =>
96 | getChangeType(commit.commit.message) === 'docs'
97 | ),
98 | prs: mergedPRs.filter(pr =>
99 | getChangeType(pr.title, pr.body) === 'docs'
100 | )
101 | },
102 | '🧪 Tests': {
103 | commits: commits.commits.filter(commit =>
104 | getChangeType(commit.commit.message) === 'test'
105 | ),
106 | prs: mergedPRs.filter(pr =>
107 | getChangeType(pr.title, pr.body) === 'test'
108 | )
109 | },
110 | '🔧 Maintenance': {
111 | commits: commits.commits.filter(commit =>
112 | getChangeType(commit.commit.message) === 'maintenance'
113 | ),
114 | prs: mergedPRs.filter(pr =>
115 | getChangeType(pr.title, pr.body) === 'maintenance'
116 | )
117 | },
118 | '🔄 Other Changes': {
119 | commits: commits.commits.filter(commit =>
120 | getChangeType(commit.commit.message) === 'other'
121 | ),
122 | prs: mergedPRs.filter(pr =>
123 | getChangeType(pr.title, pr.body) === 'other'
124 | )
125 | }
126 | };
127 | // Generate markdown
128 | let markdown = `## Release v${process.env.VERSION}\n\n`;
129 | // Add PHP version and dependency information
130 | markdown += '### Requirements\n\n';
131 | markdown += '* The Bento Laravel SDK requires PHP 8.1 - 8.3\n';
132 | markdown += '* Composer\n';
133 | markdown += '* Laravel Framework\n';
134 | markdown += '* Bento API keys\n';
135 | // Add breaking changes first
136 | const breakingChanges = [
137 | ...commits.commits.filter(commit => getChangeType(commit.commit.message) === 'breaking'),
138 | ...mergedPRs.filter(pr => getChangeType(pr.title, pr.body) === 'breaking')
139 | ];
140 | if (breakingChanges.length > 0) {
141 | markdown += '⚠️ **Breaking Changes**\n\n';
142 | breakingChanges.forEach(change => {
143 | if ('number' in change) { // It's a PR
144 | markdown += `* ${change.title} (#${change.number})\n`;
145 | } else { // It's a commit
146 | const firstLine = change.commit.message.split('\n')[0];
147 | markdown += `* ${firstLine} (${change.sha.substring(0, 7)})\n`;
148 | }
149 | });
150 | markdown += '\n';
151 | }
152 | // Add categorized changes
153 | for (const [category, items] of Object.entries(categories)) {
154 | if (items.commits.length > 0 || items.prs.length > 0) {
155 | markdown += `### ${category}\n\n`;
156 | // Add PRs first
157 | items.prs.forEach(pr => {
158 | markdown += `* ${pr.title} (#${pr.number}) @${pr.user.login}\n`;
159 | });
160 | // Add commits that aren't associated with PRs
161 | items.commits
162 | .filter(commit => !items.prs.some(pr => pr.merge_commit_sha === commit.sha))
163 | .forEach(commit => {
164 | const firstLine = commit.commit.message.split('\n')[0];
165 | markdown += `* ${firstLine} (${commit.sha.substring(0, 7)}) @${commit.author?.login || commit.commit.author.name}\n`;
166 | });
167 | markdown += '\n';
168 | }
169 | }
170 | // Add installation instructions
171 | markdown += '### Installation\n\n';
172 | markdown += '```bash\n';
173 | markdown += 'composer require bentonow/bento-laravel-sdk\n';
174 | markdown += '```\n\n';
175 | // Add contributors section
176 | const contributors = new Set([
177 | ...mergedPRs.map(pr => pr.user.login),
178 | ...commits.commits.map(commit => commit.author?.login || commit.commit.author.name)
179 | ]);
180 | if (contributors.size > 0) {
181 | markdown += '## Contributors\n\n';
182 | [...contributors].forEach(contributor => {
183 | markdown += `* ${contributor.includes('@') ? contributor : '@' + contributor}\n`;
184 | });
185 | }
186 | return markdown;
187 | }
--------------------------------------------------------------------------------
/.github/workflows/merge.yml:
--------------------------------------------------------------------------------
1 | name: Merge Checks
2 |
3 | on: [push]
4 |
5 | jobs:
6 | build:
7 | strategy:
8 | matrix:
9 | php: ['8.2']
10 | runs-on: ubuntu-latest
11 | steps:
12 | - uses: actions/checkout@v2
13 | with:
14 | ref: ${{ github.head_ref }}
15 | token: ${{ secrets.GITHUB_TOKEN }}
16 |
17 | - uses: shivammathur/setup-php@v2
18 | with:
19 | php-version: ${{ matrix.php }}
20 | tools: composer
21 | coverage: xdebug
22 |
23 | - name: Install dependencies
24 | run: composer update -W --no-progress
25 |
26 | - name: Setup Node.js
27 | uses: actions/setup-node@v3
28 | with:
29 | node-version: '18'
30 |
31 | - name: Run Laravel Pint
32 | run: ./vendor/bin/pint
33 |
34 | - name: Commit Pint changes if any
35 | run: |
36 | if git diff --quiet; then
37 | echo "No changes to commit"
38 | else
39 | git config --local user.email "action@github.com"
40 | git config --local user.name "GitHub Action"
41 | git add .
42 | git commit -m "style: apply pint fixes"
43 | git push
44 | fi
45 |
46 | - name: Run Pest Tests
47 | run: ./vendor/bin/pest --ci
--------------------------------------------------------------------------------
/.github/workflows/pr.yml:
--------------------------------------------------------------------------------
1 | name: Pull Request Checks
2 |
3 | on:
4 | pull_request:
5 | branches: [ master, main ]
6 |
7 | jobs:
8 | build:
9 | strategy:
10 | matrix:
11 | php: ['8.2']
12 | runs-on: ubuntu-latest
13 | permissions:
14 | contents: write
15 | steps:
16 | - uses: actions/checkout@v2
17 | with:
18 | ref: ${{ github.head_ref }}
19 | token: ${{ secrets.GITHUB_TOKEN }}
20 |
21 | - uses: shivammathur/setup-php@v2
22 | with:
23 | php-version: ${{ matrix.php }}
24 | tools: composer
25 | coverage: xdebug
26 |
27 | - name: Install dependencies
28 | run: composer update -W --no-progress
29 |
30 | - name: Setup Node.js
31 | uses: actions/setup-node@v3
32 | with:
33 | node-version: '18'
34 |
35 | - name: Run Laravel Pint
36 | run: ./vendor/bin/pint
37 |
38 | - name: Commit Pint changes if any
39 | run: |
40 | if git diff --quiet; then
41 | echo "No changes to commit"
42 | else
43 | git config --local user.email "action@github.com"
44 | git config --local user.name "GitHub Action"
45 | git add .
46 | git commit -m "style: apply pint fixes"
47 | git push
48 | fi
49 |
50 | - name: Run Pest Tests
51 | run: ./vendor/bin/pest --ci
--------------------------------------------------------------------------------
/.github/workflows/release.yml:
--------------------------------------------------------------------------------
1 | name: Release
2 |
3 | on:
4 | workflow_dispatch:
5 | inputs:
6 | version:
7 | description: 'Version number (e.g., 1.0.0)'
8 | required: true
9 | type: string
10 | permissions:
11 | contents: write
12 | pull-requests: write
13 |
14 | jobs:
15 | release:
16 | runs-on: ubuntu-latest
17 | permissions:
18 | contents: write
19 | pull-requests: read
20 | steps:
21 | - uses: actions/checkout@v3
22 | with:
23 | fetch-depth: 0
24 | token: ${{ secrets.GITHUB_TOKEN }}
25 |
26 | - name: Setup PHP
27 | uses: shivammathur/setup-php@v2
28 | with:
29 | php-version: '8.2'
30 | coverage: xdebug
31 | tools: composer
32 |
33 | - name: Install dependencies
34 | run: composer install --prefer-dist --no-progress
35 |
36 | - name: Run Laravel Pint
37 | run: ./vendor/bin/pint
38 |
39 | - name: Run Pest Tests
40 | run: ./vendor/bin/pest --ci
41 |
42 | - name: Set version
43 | run: |
44 | echo "VERSION=${{ github.event.inputs.version }}" >> $GITHUB_ENV
45 | echo "PREVIOUS_TAG=$(git describe --tags --abbrev=0)" >> $GITHUB_ENV
46 |
47 | - name: Generate Release Notes
48 | id: release_notes
49 | uses: actions/github-script@v6
50 | with:
51 | script: |
52 | const script = require('./.github/scripts/generate-release-notes.js')
53 | const notes = await script({github, context})
54 | core.setOutput('notes', notes)
55 |
56 | - name: Commit version bump
57 | run: |
58 | git config --local user.email "action@github.com"
59 | git config --local user.name "GitHub Action"
60 | git tag v${{ env.VERSION }}
61 | git push && git push --tags
62 |
63 | - name: Create GitHub Release
64 | uses: softprops/action-gh-release@v1
65 | env:
66 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
67 | with:
68 | tag_name: v${{ env.VERSION }}
69 | name: Release v${{ env.VERSION }}
70 | body: ${{ steps.release_notes.outputs.notes }}
71 | draft: false
72 | prerelease: false
73 |
74 | - name: Update Packagist
75 | run: |
76 | curl -XPOST -H'content-type:application/json' \
77 | 'https://packagist.org/api/update-package?username=${{ secrets.PACKAGIST_USERNAME }}&apiToken=${{ secrets.PACKAGIST_TOKEN }}' \
78 | -d'{"repository":{"url":"https://github.com/bentonow/bento-laravel-sdk"}}'
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # OS generated files
2 | .DS_Store
3 | .DS_Store?
4 | ._*
5 | .Spotlight-V100
6 | .Trashes
7 | ehthumbs.db
8 | Thumbs.db
9 |
10 | # Composer
11 | /vendor/
12 | composer.lock
13 | composer.phar
14 |
15 | # IDE and editor files
16 | .idea/
17 | .vscode/
18 | *.swp
19 | *.swo
20 | *~
21 | *.sublime-workspace
22 | *.sublime-project
23 |
24 | # PHP files
25 | *.log
26 | .php_cs.cache
27 | .php_cs
28 | .phpunit.result.cache
29 | .phpunit.cache
30 |
31 | # Build and test artifacts
32 | /build/
33 | /coverage/
34 | /docs/
35 | phpunit.xml
36 | .phpunit.result.cache
37 |
38 | # Environment files
39 | .env
40 | .env.*
41 | !.env.example
42 |
43 | # Keep the dist file
44 | !phpunit.xml.dist
--------------------------------------------------------------------------------
/.idea/.gitignore:
--------------------------------------------------------------------------------
1 | # Default ignored files
2 | /shelf/
3 | /workspace.xml
4 | # Editor-based HTTP Client requests
5 | /httpRequests/
6 | # Datasource local storage ignored files
7 | /dataSources/
8 | /dataSources.local.xml
9 |
--------------------------------------------------------------------------------
/.idea/bento-laravel-sdk.iml:
--------------------------------------------------------------------------------
1 |
2 |
6 |
8 |
3 |
4 | > [!TIP]
5 | > Need help? Join our [Discord](https://discord.gg/ssXXFRmt5F) or email jesse@bentonow.com for personalized support.
6 |
7 | The Bento Laravel SDK makes it quick and easy to send emails and track events in your Laravel applications. We provide powerful and customizable APIs that can be used out-of-the-box to manage subscribers, track events, and send transactional emails. We also expose low-level APIs so that you can build fully custom experiences.
8 |
9 | Get started with our [📚 integration guides](https://docs.bentonow.com), or [📘 browse the SDK reference](https://docs.bentonow.com/subscribers).
10 |
11 | 🐶 Battle-tested by [High Performance SQLite](https://highperformancesqlite.com/) (a Bento customer)!
12 |
13 | ❤️ Thank you [@aarondfrancis](https://github.com/aarondfrancis) for your contribution.
14 |
15 | ❤️ Thank you [@ziptied](https://github.com/ziptied) for your contribution.
16 |
17 | [](https://github.com/bentonow/bento-laravel-sdk/actions/workflows/tests.yml)
18 |
19 | Table of contents
20 | =================
21 |
22 |
23 | * [Features](#features)
24 | * [Requirements](#requirements)
25 | * [Getting started](#getting-started)
26 | * [Installation](#installation)
27 | * [Configuration](#configuration)
28 | * [Modules](#modules)
29 | * [Things to Know](#things-to-know)
30 | * [Contributing](#contributing)
31 | * [License](#license)
32 |
33 |
34 | ## Features
35 |
36 | * **Laravel Mail Integration**: Seamlessly integrate with Laravel's mail system to send transactional emails via Bento.
37 | * **Event Tracking**: Easily track custom events and user behavior in your Laravel application.
38 | * **Subscriber Management**: Import and manage subscribers directly from your Laravel app.
39 | * **API Access**: Full access to Bento's REST API for advanced operations.
40 | * **Laravel-friendly**: Designed to work smoothly with Laravel's conventions and best practices.
41 |
42 |
43 | ## Requirements
44 |
45 | - PHP 8.0+
46 | - Laravel 10.0+
47 | - Bento API Keys
48 |
49 | ## Getting started
50 |
51 | ### Installation
52 |
53 | Install the package via Composer:
54 |
55 | ```bash
56 | composer require bentonow/bento-laravel-sdk
57 | ```
58 |
59 | ### Configuration
60 |
61 | 1. Publish the configuration file:
62 |
63 | ```bash
64 | php artisan vendor:publish --tag bentonow
65 | ```
66 |
67 | 2. Add a new mailer definition in `config/mail.php`:
68 |
69 | ```php
70 | 'bento' => [
71 | 'transport' => 'bento',
72 | ],
73 | ```
74 |
75 | 3. Add your Bento API keys to your `.env` file:
76 |
77 | ```dotenv
78 | BENTO_PUBLISHABLE_KEY="bento-publishable-key"
79 | BENTO_SECRET_KEY="bento-secret-key"
80 | BENTO_SITE_UUID="bento-site-uuid"
81 | MAIL_MAILER="bento"
82 | ```
83 |
84 | ## Modules
85 |
86 | ### Event Tracking
87 |
88 | Track custom events in your application:
89 |
90 | ```php
91 | use Bentonow\BentoLaravel\Facades\Bento;
92 | use Bentonow\BentoLaravel\DataTransferObjects\EventData;
93 |
94 | $data = collect([
95 | new EventData(
96 | type: '$completed_onboarding',
97 | email: "user@example.com",
98 | fields: [
99 | "first_name" => "John",
100 | "last_name" => "Doe"
101 | ]
102 | )
103 | ]);
104 |
105 | return Bento::trackEvent($data)->json();
106 | ```
107 |
108 | ### Subscriber Management
109 |
110 | Import subscribers into your Bento account:
111 |
112 | ```php
113 | use Bentonow\BentoLaravel\Facades\Bento;
114 | use Bentonow\BentoLaravel\DataTransferObjects\ImportSubscribersData;
115 |
116 | $data = collect([
117 | new ImportSubscribersData(
118 | email: "user@example.com",
119 | first_name: "John",
120 | last_name: "Doe",
121 | tags: ["lead", "mql"],
122 | removeTags: ["customers"],
123 | fields: ["role" => "ceo"]
124 | ),
125 | ]);
126 |
127 | return Bento::importSubscribers($data)->json();
128 | ```
129 |
130 | ### Find Subscriber
131 |
132 | Search your site for a subscriber:
133 |
134 | ```php
135 | use Bentonow\BentoLaravel\Facades\Bento;
136 |
137 | return Bento::findSubscriber("test@example.com")->json();
138 | ```
139 |
140 | ### Create Subscriber
141 |
142 | Creates a subscriber in your account and queues them for indexing:
143 |
144 | ```php
145 | use Bentonow\BentoLaravel\Facades\Bento;
146 | use Bentonow\BentoLaravel\DataTransferObjects\CreateSubscriberData;
147 |
148 | $data = collect([
149 | new CreateSubscriberData(email: "test@example.com")
150 | ]);
151 |
152 | return Bento::createSubscriber($data)->json();
153 | ```
154 |
155 | ### Run Command
156 |
157 | Execute a command and change a subscriber's data:
158 |
159 | ```php
160 | use Bentonow\BentoLaravel\Facades\Bento;
161 | use Bentonow\BentoLaravel\DataTransferObjects\CommandData;
162 | use Bentonow\BentoLaravel\Enums\Command;
163 |
164 | $data = collect([
165 | new CommandData(Command::REMOVE_TAG, "test@gmail.com", "test")
166 | ]);
167 |
168 | return Bento::subscriberCommand($data)->json();
169 | ```
170 |
171 | ### Get Tags
172 |
173 | Returns a list of tags in your account:
174 |
175 | ```php
176 | use Bentonow\BentoLaravel\Facades\Bento;
177 |
178 | return Bento::getTags()->json();
179 | ```
180 |
181 | ### Create Tag
182 |
183 | Creates a custom tag in your account:
184 |
185 | ```php
186 | use Bentonow\BentoLaravel\Facades\Bento;
187 | use Bentonow\BentoLaravel\DataTransferObjects\CreateTagData;
188 |
189 | $data = new CreateTagData(name: "example tag");
190 |
191 | return Bento::createTag($data)->json();
192 | ```
193 |
194 | ### Get Fields
195 |
196 | The field model is a simple named key value pair, think of it as a form field:
197 |
198 | ```php
199 | use Bentonow\BentoLaravel\Facades\Bento;
200 |
201 | return Bento::getFields()->json();
202 | ```
203 |
204 | ### Create Field
205 |
206 | Creates a custom field in your account:
207 |
208 | ```php
209 | use Bentonow\BentoLaravel\Facades\Bento;
210 | use Bentonow\BentoLaravel\DataTransferObjects\CreateFieldData;
211 |
212 | $data = new CreateFieldData(key: "last_name");
213 |
214 | return Bento::createField($data)->json();
215 | ```
216 |
217 | ### Get Broadcasts
218 |
219 | Returns a list of broadcasts in your account:
220 |
221 | ```php
222 | use Bentonow\BentoLaravel\Facades\Bento;
223 |
224 | return Bento::getBroadcasts()->json();
225 | ```
226 |
227 | ### Create Broadcasts
228 |
229 | Create new broadcasts to be sent:
230 |
231 | ```php
232 | use Bentonow\BentoLaravel\Facades\Bento;
233 | use Bentonow\BentoLaravel\DataTransferObjects\CreateBroadcastData;
234 | use Bentonow\BentoLaravel\DataTransferObjects\ContactData;
235 | use Bentonow\BentoLaravel\Enums\BroadcastType;
236 |
237 | $data = Collect([
238 | new CreateBroadcastData(
239 | name: "Campaign #1 Example",
240 | subject: "Hello world Plain Text",
241 | content: "
Hi {{ visitor.first_name }}
", 242 | type: BroadcastType::PLAIN, 243 | from: new ContactData( 244 | name: "John Doe", 245 | emailAddress: "example@example.com" 246 | ), 247 | inclusive_tags: "lead,mql", 248 | exclusive_tags: "customers", 249 | segment_id: "segment_123456789", 250 | batch_size_per_hour: 1500 251 | ), 252 | ]); 253 | 254 | return Bento::createBroadcast($data)->json(); 255 | ``` 256 | 257 | ### Get Site Stats 258 | 259 | Returns a list of site stats: 260 | 261 | ```php 262 | use Bentonow\BentoLaravel\Facades\Bento; 263 | 264 | return Bento::getSiteStats()->json(); 265 | ``` 266 | 267 | ### Get Segment Stats 268 | 269 | Returns a list of a segments stats: 270 | 271 | ```php 272 | use Bentonow\BentoLaravel\Facades\Bento; 273 | use Bentonow\BentoLaravel\DataTransferObjects\SegmentStatsData; 274 | 275 | $data = new SegmentStatsData(segment_id: "123"); 276 | 277 | return Bento::getSegmentStats($data)->json(); 278 | ``` 279 | 280 | ### Get Report Stats 281 | 282 | Returns an object containing data for a specific report: 283 | 284 | ```php 285 | use Bentonow\BentoLaravel\Facades\Bento; 286 | use Bentonow\BentoLaravel\DataTransferObjects\ReportStatsData; 287 | 288 | $data = new ReportStatsData(report_id: "456"); 289 | 290 | return Bento::getReportStats($data)->json(); 291 | ``` 292 | 293 | ### Search Blacklists 294 | 295 | Validates the IP or domain name with industry email reputation services to check for delivery issues: 296 | 297 | ```php 298 | use Bentonow\BentoLaravel\Facades\Bento; 299 | use Bentonow\BentoLaravel\DataTransferObjects\BlacklistStatusData; 300 | 301 | $data = new BlacklistStatusData(domain: null, ipAddress: "1.1.1.1"); 302 | return Bento::getBlacklistStatus($data)->json(); 303 | ``` 304 | 305 | ### Validate Email 306 | 307 | Validates the email address using the provided information to infer its validity: 308 | 309 | ```php 310 | use Bentonow\BentoLaravel\Facades\Bento; 311 | use Bentonow\BentoLaravel\DataTransferObjects\ValidateEmailData; 312 | 313 | $data = new ValidateEmailData( 314 | emailAddress: "test@example.com", 315 | fullName: "John Snow", 316 | userAgent: null, 317 | ipAddress: null 318 | ); 319 | 320 | return Bento::validateEmail($data)->json(); 321 | ``` 322 | 323 | ### Moderate Content 324 | 325 | An opinionated Content moderation: 326 | 327 | ```php 328 | use Bentonow\BentoLaravel\Facades\Bento; 329 | use Bentonow\BentoLaravel\DataTransferObjects\ContentModerationData; 330 | 331 | $data = new ContentModerationData("Its just so fluffy!"); 332 | return Bento::getContentModeration($data)->json(); 333 | ``` 334 | 335 | ### Guess Gender 336 | 337 | Guess a subscriber's gender using their first and last name. Best for US users; based on US Census Data: 338 | 339 | ```php 340 | use Bentonow\BentoLaravel\Facades\Bento; 341 | use Bentonow\BentoLaravel\DataTransferObjects\GenderData; 342 | 343 | $data = new GenderData("John Doe"); 344 | return Bento::getGender($data)->json(); 345 | ``` 346 | 347 | ### Geolocate Ip Address 348 | 349 | This endpoint attempts to geolocate the provided IP address: 350 | 351 | ```php 352 | use Bentonow\BentoLaravel\Facades\Bento; 353 | use Bentonow\BentoLaravel\DataTransferObjects\GeoLocateIpData; 354 | 355 | $data = new GeoLocateIpData("1.1.1.1"); 356 | return Bento::geoLocateIp($data)->json(); 357 | ``` 358 | 359 | ## Things to Know 360 | 361 | 1. The SDK integrates seamlessly with Laravel's mail system for sending transactional emails. 362 | 2. For event tracking and data importing, use the BentoConnector class. 363 | 3. All API requests are made using strongly-typed request classes for better type safety. 364 | 4. The SDK supports Laravel's environment-based configuration for easy setup across different environments. 365 | 5. For signed emails with return urls, please assign the `bento.signature` middleware or the `BentoSignatureExclusion::class`. This must be before the signed middleware to remove all utm and tracking url params. 366 | 6. Bento does not support `no-reply` sender addresses for transactional emails. You MUST use an author you have configured as your sender address. 367 | 7. For more advanced usage, refer to the [Bento API Documentation](https://docs.bentonow.com). 368 | 369 | ## Contributing 370 | 371 | We welcome contributions! Please see our [contributing guidelines](CODE_OF_CONDUCT.md) for details on how to submit pull requests, report issues, and suggest improvements. 372 | 373 | ## License 374 | 375 | The Bento SDK for Laravel is available as open source under the terms of the [MIT License](LICENSE.md). 376 | -------------------------------------------------------------------------------- /art/bento-laravel-sdk.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bentonow/bento-laravel-sdk/aea7769403fc1cd2c7014f763bb0b9743ae56c8c/art/bento-laravel-sdk.png -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "bentonow/bento-laravel-sdk", 3 | "description": "Laravel SDK for Bento", 4 | "keywords": [ 5 | "Bento", 6 | "Bentonow", 7 | "bento", 8 | "bentonow", 9 | "email", 10 | "transactional", 11 | "laravel-driver" 12 | ], 13 | "homepage": "https://github.com/bentonow/bento-laravel-sdk", 14 | "license": "MIT", 15 | "type": "library", 16 | "authors": [ 17 | { 18 | "name": "Aaron Francis", 19 | "email": "hello@aaronfrancis.com", 20 | "homepage": "https://aaronfrancis.com", 21 | "role": "Developer" 22 | }, 23 | { 24 | "name": "Zachary Oakes", 25 | "email": "zachary.oakes@gmail.com", 26 | "homepage": "https://www.zacharyoakes.com", 27 | "role": "Developer" 28 | } 29 | ], 30 | "require": { 31 | "php": ">=8.1", 32 | "saloonphp/saloon": "^v3.11.2" 33 | }, 34 | "require-dev": { 35 | "laravel/pint": "^v1.21.2", 36 | "orchestra/testbench": "^v9.12.0", 37 | "pestphp/pest": "^v3.7.4", 38 | "pestphp/pest-plugin-arch": "^3.0", 39 | "pestphp/pest-plugin-laravel": "^v3.1.0", 40 | "spatie/ray": "^1.41.6" 41 | }, 42 | "autoload": { 43 | "psr-4": { 44 | "Bentonow\\BentoLaravel\\": "src/", 45 | "Bentonow\\BentoLaravel\\Tests\\": "tests/" 46 | } 47 | }, 48 | "scripts": { 49 | "test": "vendor/bin/pest" 50 | }, 51 | "config": { 52 | "sort-packages": true, 53 | "allow-plugins": { 54 | "php-http/discovery": false, 55 | "pestphp/pest-plugin": true 56 | } 57 | }, 58 | "extra": { 59 | "laravel": { 60 | "providers": [ 61 | "Bentonow\\BentoLaravel\\BentoLaravelServiceProvider" 62 | ], 63 | "aliases": { 64 | "Bento": "Bentonow\\BentoLaravel\\Facades\\Bento" 65 | } 66 | } 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /config/bentonow.php: -------------------------------------------------------------------------------- 1 | env('BENTO_SECRET_KEY'), 5 | 'publishable_key' => env('BENTO_PUBLISHABLE_KEY'), 6 | 'site_uuid' => env('BENTO_SITE_UUID'), 7 | ]; 8 | -------------------------------------------------------------------------------- /phpunit.xml: -------------------------------------------------------------------------------- 1 | 2 |This is a test broadcast.
', 24 | ], 25 | 'sent_final_batch_at' => '2024-09-12T07:21:33.102Z', 26 | 'created_at' => '2024-09-12T07:21:33.102Z', 27 | 'send_at' => '2024-09-12T07:21:33.102Z', 28 | 'stats' => [ 29 | 'open_rate' => 0, 30 | ], 31 | ], 32 | ], 33 | [ 34 | 'id' => '1235', 35 | 'type' => 'visitors-fields', 36 | 'attributes' => [ 37 | 'name' => 'broadcast 2', 38 | 'share_url' => 'https://example.com/broadcast/1235', 39 | 'template' => [ 40 | 'subject' => 'Test Broadcast 2', 41 | 'to' => 'test@example.com', 42 | 'html' => 'This is a test broadcast.
', 43 | ], 44 | 'sent_final_batch_at' => '2024-09-12T07:21:33.102Z', 45 | 'created_at' => '2024-09-12T07:21:33.102Z', 46 | 'send_at' => '2024-09-12T07:21:33.102Z', 47 | 'stats' => [ 48 | 'open_rate' => 0, 49 | ], 50 | ], 51 | ], 52 | ], 53 | ], status: 200), 54 | ]); 55 | 56 | $connector = new BentoConnector; 57 | $connector->authenticate(new BasicAuthenticator('publish_key', 'secret_key')); 58 | $connector->withMockClient($mockClient); 59 | 60 | $request = new GetBroadcasts; 61 | 62 | $response = $connector->send($request); 63 | expect($response->body())->toBeJson() 64 | ->and($response->status())->toBe(200) 65 | ->and($response->json('data'))->toBeArray()->not()->toBeEmpty()->toHaveCount(2) 66 | ->and($response->json('data')[0]['attributes'])->toBeArray()->toHaveCount(7) 67 | ->and($response->json('data')[1]['attributes'])->toBeArray()->toHaveCount(7) 68 | ->and($response->json('data')[0]['attributes']['template'])->toBeArray()->toHaveCount(3) 69 | ->and($response->json('data')[1]['attributes']['template'])->toBeArray()->toHaveCount(3) 70 | ->and($response->json('data')[0]['id'])->toBeString()->toBe('1234') 71 | ->and($response->json('data')[1]['id'])->toBeString()->toBe('1235') 72 | ->and($response->json('data')[0]['attributes']['name'])->toBeString()->toBe('broadcast 1') 73 | ->and($response->json('data')[1]['attributes']['name'])->toBeString()->toBe('broadcast 2') 74 | ->and($response->json('data')[0]['attributes']['template']['subject'])->toBeString()->tobe('Test Broadcast') 75 | ->and($response->json('data')[1]['attributes']['template']['subject'])->toBeString()->tobe('Test Broadcast 2'); 76 | }); 77 | 78 | it('can get a list of broadcasts no results', function () { 79 | $mockClient = new MockClient([ 80 | GetBroadcasts::class => MockResponse::make(body: [ 81 | ], status: 200), 82 | ]); 83 | 84 | $connector = new BentoConnector; 85 | $connector->authenticate(new BasicAuthenticator('publish_key', 'secret_key')); 86 | $connector->withMockClient($mockClient); 87 | 88 | $request = new GetBroadcasts; 89 | 90 | $response = $connector->send($request); 91 | expect($response->body())->toBeJson() 92 | ->and($response->status())->toBe(200) 93 | ->and($response->json('data'))->toBeEmpty(); 94 | 95 | }); 96 | 97 | it('can get a list of broadcasts no results (500)', function () { 98 | $mockClient = new MockClient([ 99 | GetBroadcasts::class => MockResponse::make(body: [ 100 | ], status: 500), 101 | ]); 102 | 103 | $connector = new BentoConnector; 104 | $connector->authenticate(new BasicAuthenticator('publish_key', 'secret_key')); 105 | $connector->withMockClient($mockClient); 106 | 107 | $request = new GetBroadcasts; 108 | 109 | $response = $connector->send($request); 110 | expect($response->body())->toBeJson() 111 | ->and($response->status())->toBe(500) 112 | ->and($response->json('data'))->toBeArray()->toBeEmpty(); 113 | 114 | })->throws(InternalServerErrorException::class); 115 | -------------------------------------------------------------------------------- /tests/Unit/GetContentModerationTest.php: -------------------------------------------------------------------------------- 1 | MockResponse::make(body: [ 13 | 'data' => [ 14 | 'valid' => true, 15 | 'reasons' => [], 16 | 'safe_original_content' => 'Its just so fluffy!', 17 | ], 18 | ], status: 200), 19 | ]); 20 | 21 | $connector = new BentoConnector; 22 | $connector->authenticate(new BasicAuthenticator('publish_key', 'secret_key')); 23 | $connector->withMockClient($mockClient); 24 | 25 | $data = new ContentModerationData('Its just so fluffy!'); 26 | 27 | $request = new GetContentModeration($data); 28 | 29 | $response = $connector->send($request); 30 | expect($response->body())->toBeJson() 31 | ->and($response->status())->toBe(200) 32 | ->and($response->json('data')['valid'])->toBe(true) 33 | ->and($response->json('data')['reasons'])->toBeArray()->toBeEmpty() 34 | ->and($response->json('data')['safe_original_content'])->toBe('Its just so fluffy!') 35 | ->and($request->query()->get('content'))->not()->toBeEmpty()->toBe('Its just so fluffy!'); 36 | }); 37 | -------------------------------------------------------------------------------- /tests/Unit/GetFieldsTest.php: -------------------------------------------------------------------------------- 1 | MockResponse::make(body: [ 13 | 'data' => [ 14 | [ 15 | 'id' => '1234', 16 | 'type' => 'visitors-fields', 17 | 'attributes' => [ 18 | 'name' => 'Currency', 19 | 'key' => 'currency', 20 | 'whitelisted' => null, 21 | 'created_at' => '2024-09-12T07:21:33.102Z', 22 | ], 23 | ], 24 | [ 25 | 'id' => '1235', 26 | 'type' => 'visitors-fields', 27 | 'attributes' => [ 28 | 'name' => 'Lifetime Value', 29 | 'key' => 'lifetime_value', 30 | 'whitelisted' => null, 31 | 'created_at' => '2024-09-12T07:21:33.095Z', 32 | ], 33 | ], 34 | ], 35 | ], status: 200), 36 | ]); 37 | 38 | $connector = new BentoConnector; 39 | $connector->authenticate(new BasicAuthenticator('publish_key', 'secret_key')); 40 | $connector->withMockClient($mockClient); 41 | 42 | $request = new GetFields; 43 | 44 | $response = $connector->send($request); 45 | expect($response->body())->toBeJson() 46 | ->and($response->status())->toBe(200) 47 | ->and($response->json('data'))->toBeArray()->not()->toBeEmpty()->toHaveCount(2) 48 | ->and($response->json('data')[0]['attributes'])->toBeArray()->toHaveCount(4) 49 | ->and($response->json('data')[1]['attributes'])->toBeArray()->toHaveCount(4) 50 | ->and($response->json('data')[0]['id'])->toBeString()->toBe('1234') 51 | ->and($response->json('data')[1]['id'])->toBeString()->toBe('1235') 52 | ->and($response->json('data')[0]['attributes']['name'])->toBeString()->toBe('Currency') 53 | ->and($response->json('data')[1]['attributes']['name'])->toBeString()->toBe('Lifetime Value'); 54 | 55 | }); 56 | 57 | it('can get a list of fields no results', function () { 58 | $mockClient = new MockClient([ 59 | GetFields::class => MockResponse::make(body: [ 60 | ], status: 200), 61 | ]); 62 | 63 | $connector = new BentoConnector; 64 | $connector->authenticate(new BasicAuthenticator('publish_key', 'secret_key')); 65 | $connector->withMockClient($mockClient); 66 | 67 | $request = new GetFields; 68 | 69 | $response = $connector->send($request); 70 | expect($response->body())->toBeJson() 71 | ->and($response->status())->toBe(200) 72 | ->and($response->json('data'))->toBeEmpty(); 73 | 74 | }); 75 | 76 | it('can get a list of fields no results (500)', function () { 77 | $mockClient = new MockClient([ 78 | GetFields::class => MockResponse::make(body: [ 79 | ], status: 500), 80 | ]); 81 | 82 | $connector = new BentoConnector; 83 | $connector->authenticate(new BasicAuthenticator('publish_key', 'secret_key')); 84 | $connector->withMockClient($mockClient); 85 | 86 | $request = new GetFields; 87 | 88 | $response = $connector->send($request); 89 | expect($response->body())->toBeJson() 90 | ->and($response->status())->toBe(500) 91 | ->and($response->json('data'))->toBeArray()->toBeEmpty(); 92 | 93 | })->throws(InternalServerErrorException::class); 94 | -------------------------------------------------------------------------------- /tests/Unit/GetGenderTest.php: -------------------------------------------------------------------------------- 1 | MockResponse::make(body: [ 13 | 'data' => [ 14 | 'gender' => 'male', 15 | 'confidence' => 0.99497487437186, 16 | ], 17 | ], status: 200), 18 | ]); 19 | 20 | $connector = new BentoConnector; 21 | $connector->authenticate(new BasicAuthenticator('publish_key', 'secret_key')); 22 | $connector->withMockClient($mockClient); 23 | 24 | $data = new GenderData('John Doe'); 25 | 26 | $request = new GetGender($data); 27 | 28 | $response = $connector->send($request); 29 | expect($response->body())->toBeJson() 30 | ->and($response->status())->toBe(200) 31 | ->and($response->json('data')['gender'])->toBeString()->toBe('male') 32 | ->and($response->json('data')['confidence'])->toBeFloat()->toBe(0.99497487437186) 33 | ->and($request->query()->get('name'))->not()->toBeEmpty()->toBe('John Doe'); 34 | }); 35 | 36 | it('it failes to get gender for name', function () { 37 | $mockClient = new MockClient([ 38 | GetGender::class => MockResponse::make(body: [ 39 | 'data' => [ 40 | 'gender' => 'unknown', 41 | 'confidence' => null, 42 | ], 43 | ], status: 200), 44 | ]); 45 | 46 | $connector = new BentoConnector; 47 | $connector->authenticate(new BasicAuthenticator('publish_key', 'secret_key')); 48 | $connector->withMockClient($mockClient); 49 | 50 | $data = new GenderData(''); 51 | 52 | $request = new GetGender($data); 53 | 54 | $response = $connector->send($request); 55 | expect($response->body())->toBeJson() 56 | ->and($response->status())->toBe(200) 57 | ->and($response->json('data')['gender'])->toBeString()->toBe('unknown') 58 | ->and($response->json('data')['confidence'])->toBeNull() 59 | ->and($request->query()->get('name'))->toBe(''); 60 | }); 61 | -------------------------------------------------------------------------------- /tests/Unit/GetReportStatsTest.php: -------------------------------------------------------------------------------- 1 | MockResponse::make(body: [ 13 | 'report_data' => [ 14 | 'data' => [], 15 | 'chart_style' => 'count', 16 | 'report_type' => 'Reporting::Reports::VisitorCountReport', 17 | 'report_name' => 'New Subscribers', 18 | ], 19 | ], status: 200), 20 | ]); 21 | 22 | $connector = new BentoConnector; 23 | $connector->authenticate(new BasicAuthenticator('publish_key', 'secret_key')); 24 | $connector->withMockClient($mockClient); 25 | 26 | $data = new ReportStatsData('456'); 27 | 28 | $request = new GetReportStats($data); 29 | 30 | $response = $connector->send($request); 31 | expect($response->body())->toBeJson() 32 | ->and($response->status())->toBe(200) 33 | ->and($response->json('report_data')['data'])->toBeArray() 34 | ->and($response->json('report_data')['chart_style'])->toBeString()->toBe('count') 35 | ->and($response->json('report_data')['report_type'])->toBeString()->toBe('Reporting::Reports::VisitorCountReport') 36 | ->and($response->json('report_data')['report_name'])->toBeString()->toBe('New Subscribers'); 37 | }); 38 | -------------------------------------------------------------------------------- /tests/Unit/GetSegmentStatsTest.php: -------------------------------------------------------------------------------- 1 | MockResponse::make(body: [ 13 | 'data' => [ 14 | 'user_count' => 0, 15 | 'subscriber_count' => 0, 16 | 'unsubscriber_count' => 0, 17 | ], 18 | ], status: 200), 19 | ]); 20 | 21 | $connector = new BentoConnector; 22 | $connector->authenticate(new BasicAuthenticator('publish_key', 'secret_key')); 23 | $connector->withMockClient($mockClient); 24 | 25 | $data = new SegmentStatsData(segment_id: '123'); 26 | 27 | $request = new GetSegmentStats($data); 28 | 29 | $response = $connector->send($request); 30 | expect($response->body())->toBeJson() 31 | ->and($response->status())->toBe(200) 32 | ->and($response->json('data')['user_count'])->toBeInt()->toBe(0) 33 | ->and($response->json('data')['subscriber_count'])->toBeInt()->toBe(0) 34 | ->and($response->json('data')['unsubscriber_count'])->toBeInt()->toBe(0) 35 | ->and($request->query()->get('segment_id'))->not()->toBeEmpty()->toBe('123'); 36 | }); 37 | -------------------------------------------------------------------------------- /tests/Unit/GetSiteStatsTest.php: -------------------------------------------------------------------------------- 1 | MockResponse::make(body: [ 12 | 'data' => [ 13 | 'user_count' => 8, 14 | 'subscriber_count' => 6, 15 | 'unsubscriber_count' => 2, 16 | ], 17 | ], status: 200), 18 | ]); 19 | 20 | $connector = new BentoConnector; 21 | $connector->authenticate(new BasicAuthenticator('publish_key', 'secret_key')); 22 | $connector->withMockClient($mockClient); 23 | 24 | $request = new GetSiteStats; 25 | 26 | $response = $connector->send($request); 27 | expect($response->body())->toBeJson() 28 | ->and($response->status())->toBe(200) 29 | ->and($response->json('data')['user_count'])->toBeInt()->toBe(8) 30 | ->and($response->json('data')['subscriber_count'])->toBeInt()->toBe(6) 31 | ->and($response->json('data')['unsubscriber_count'])->toBeInt()->toBe(2); 32 | }); 33 | -------------------------------------------------------------------------------- /tests/Unit/GetTagsTest.php: -------------------------------------------------------------------------------- 1 | MockResponse::make(body: [ 13 | 'data' => [ 14 | [ 15 | 'id' => '1234', 16 | 'type' => 'tags', 17 | 'attributes' => [ 18 | 'name' => 'purchased', 19 | 'created_at' => '2024-08-19T00:09:08.678Z', 20 | 'discarded_at' => null, 21 | 'site_id' => 123, 22 | ], 23 | ], 24 | [ 25 | 'id' => '1235', 26 | 'type' => 'tags', 27 | 'attributes' => [ 28 | 'name' => 'onboarding', 29 | 'created_at' => '2024-08-19T00:09:08.414Z', 30 | 'discarded_at' => null, 31 | 'site_id' => 123, 32 | ], 33 | ], 34 | ], 35 | ], status: 200), 36 | ]); 37 | 38 | $connector = new BentoConnector; 39 | $connector->authenticate(new BasicAuthenticator('publish_key', 'secret_key')); 40 | $connector->withMockClient($mockClient); 41 | 42 | $request = new GetTags; 43 | 44 | $response = $connector->send($request); 45 | expect($response->body())->toBeJson() 46 | ->and($response->status())->toBe(200) 47 | ->and($response->json('data'))->toBeArray()->not()->toBeEmpty()->toHaveCount(2) 48 | ->and($response->json('data')[0]['attributes'])->toBeArray()->toHaveCount(4) 49 | ->and($response->json('data')[1]['attributes'])->toBeArray()->toHaveCount(4) 50 | ->and($response->json('data')[0]['id'])->toBeString()->toBe('1234') 51 | ->and($response->json('data')[1]['id'])->toBeString()->toBe('1235') 52 | ->and($response->json('data')[0]['attributes']['name'])->toBeString()->toBe('purchased') 53 | ->and($response->json('data')[1]['attributes']['name'])->toBeString()->toBe('onboarding'); 54 | 55 | }); 56 | 57 | it('can get a list of tags no results', function () { 58 | $mockClient = new MockClient([ 59 | GetTags::class => MockResponse::make(body: [ 60 | ], status: 200), 61 | ]); 62 | 63 | $connector = new BentoConnector; 64 | $connector->authenticate(new BasicAuthenticator('publish_key', 'secret_key')); 65 | $connector->withMockClient($mockClient); 66 | 67 | $request = new GetTags; 68 | 69 | $response = $connector->send($request); 70 | expect($response->body())->toBeJson() 71 | ->and($response->status())->toBe(200) 72 | ->and($response->json('data'))->toBeEmpty(); 73 | 74 | }); 75 | 76 | it('can get a list of tags no results (500)', function () { 77 | $mockClient = new MockClient([ 78 | GetTags::class => MockResponse::make(body: [ 79 | ], status: 500), 80 | ]); 81 | 82 | $connector = new BentoConnector; 83 | $connector->authenticate(new BasicAuthenticator('publish_key', 'secret_key')); 84 | $connector->withMockClient($mockClient); 85 | 86 | $request = new GetTags; 87 | 88 | $response = $connector->send($request); 89 | expect($response->body())->toBeJson() 90 | ->and($response->status())->toBe(500) 91 | ->and($response->json('data'))->toBeArray()->toBeEmpty(); 92 | 93 | })->throws(InternalServerErrorException::class); 94 | -------------------------------------------------------------------------------- /tests/Unit/ImportSubscribersTest.php: -------------------------------------------------------------------------------- 1 | MockResponse::make(body: [ 14 | 'data' => [ 15 | 'results' => 2, 16 | 'failed' => 0, 17 | ], 18 | ], status: 200), 19 | ]); 20 | 21 | $connector = new BentoConnector; 22 | $connector->authenticate(new BasicAuthenticator('publish_key', 'secret_key')); 23 | $connector->withMockClient($mockClient); 24 | 25 | $data = collect([ 26 | new ImportSubscribersData( 27 | email: 'test@example.com', 28 | firstName: 'John', 29 | lastName: 'Doe', 30 | tags: ['onboarding', 'website', 'purchased'], 31 | removeTags: ['temp_subscriber'], 32 | fields: ['order_count' => 2, 'lifetime_value' => 80, 'currency' => 'USD'] 33 | ), 34 | new ImportSubscribersData( 35 | email: 'test2@example.com', 36 | firstName: 'Jane', 37 | lastName: 'Doe', 38 | tags: ['onboarding', 'mobile', 'purchased'], 39 | removeTags: ['unverified'], 40 | fields: ['order_count' => 1, 'lifetime_value' => 1000, 'currency' => 'USD'] 41 | ), 42 | ]); 43 | 44 | $request = new ImportSubscribers($data); 45 | 46 | $response = $connector->send($request); 47 | 48 | expect($response->body())->toBeJson() 49 | ->and($response->status())->toBe(200) 50 | ->and($response->json('data')['results'])->toBeInt()->toBe(2) 51 | ->and($response->json('data')['failed'])->toBeInt()->toBe(0) 52 | ->and($request->body()->get('subscribers'))->not()->toBeEmpty(); 53 | }); 54 | 55 | it('can not import subscribers', function () { 56 | $mockClient = new MockClient([ 57 | ImportSubscribers::class => MockResponse::make(body: [ 58 | 'data' => [ 59 | 'results' => 0, 60 | 'failed' => 2, 61 | ], 62 | ], status: 200), 63 | ]); 64 | 65 | $connector = new BentoConnector; 66 | $connector->authenticate(new BasicAuthenticator('publish_key', 'secret_key')); 67 | $connector->withMockClient($mockClient); 68 | 69 | $data = collect([ 70 | new ImportSubscribersData( 71 | email: 'test@example.com', 72 | firstName: 'John', 73 | lastName: 'Doe', 74 | tags: ['onboarding', 'website', 'purchased'], 75 | removeTags: ['temp_subscriber'], 76 | fields: ['order_count' => 2, 'lifetime_value' => 80, 'currency' => 'USD'] 77 | ), 78 | new ImportSubscribersData( 79 | email: 'test2@example.com', 80 | firstName: 'Jane', 81 | lastName: 'Doe', 82 | tags: ['onboarding', 'mobile', 'purchased'], 83 | removeTags: ['unverified'], 84 | fields: ['order_count' => 1, 'lifetime_value' => 1000, 'currency' => 'USD'] 85 | ), 86 | ]); 87 | 88 | $request = new ImportSubscribers($data); 89 | 90 | $response = $connector->send($request); 91 | 92 | expect($response->body())->toBeJson() 93 | ->and($response->status())->toBe(200) 94 | ->and($response->json('data')['results'])->toBeInt()->toBe(0) 95 | ->and($response->json('data')['failed'])->toBeInt()->toBe(2) 96 | ->and($request->body()->get('subscribers'))->not()->toBeEmpty(); 97 | }); 98 | 99 | it('has an error when import subscribers', function () { 100 | $mockClient = new MockClient([ 101 | ImportSubscribers::class => MockResponse::make(body: [], status: 500), 102 | ]); 103 | 104 | $connector = new BentoConnector; 105 | $connector->authenticate(new BasicAuthenticator('publish_key', 'secret_key')); 106 | $connector->withMockClient($mockClient); 107 | 108 | $data = collect([ 109 | new ImportSubscribersData( 110 | email: 'test@example.com', 111 | firstName: 'John', 112 | lastName: 'Doe', 113 | tags: ['onboarding', 'website', 'purchased'], 114 | removeTags: ['temp_subscriber'], 115 | fields: ['order_count' => 2, 'lifetime_value' => 80, 'currency' => 'USD'] 116 | ), 117 | new ImportSubscribersData( 118 | email: 'test2@example.com', 119 | firstName: 'Jane', 120 | lastName: 'Doe', 121 | tags: ['onboarding', 'mobile', 'purchased'], 122 | removeTags: ['unverified'], 123 | fields: ['order_count' => 1, 'lifetime_value' => 1000, 'currency' => 'USD'] 124 | ), 125 | ]); 126 | 127 | $request = new ImportSubscribers($data); 128 | 129 | $response = $connector->send($request); 130 | 131 | expect($response->body())->toBeJson() 132 | ->and($response->status())->toBe(500) 133 | ->and($response->json('data'))->toBeEmpty() 134 | ->and($request->body()->get('subscribers'))->not()->toBeEmpty(); 135 | })->throws(InternalServerErrorException::class); 136 | -------------------------------------------------------------------------------- /tests/Unit/SubscriberCommandTest.php: -------------------------------------------------------------------------------- 1 | MockResponse::make(body: [ 14 | 'data' => [ 15 | 'results' => 1, 16 | ], 17 | ], status: 200), 18 | ]); 19 | 20 | $connector = new BentoConnector; 21 | $connector->authenticate(new BasicAuthenticator('publish_key', 'secret_key')); 22 | $connector->withMockClient($mockClient); 23 | 24 | $data = collect([ 25 | new CommandData( 26 | Command::ADD_TAG, 27 | 'test@example.com', 28 | 'test' 29 | ), 30 | ]); 31 | 32 | $request = new SubscriberCommand($data); 33 | 34 | $response = $connector->send($request); 35 | 36 | expect($response->body())->toBeJson() 37 | ->and($response->status())->toBe(200) 38 | ->and($response->json('data')['results'])->toBeInt()->not()->toBeEmpty()->toBe(1) 39 | ->and($request->body()->get('command'))->toBeArray()->not()->toBeEmpty() 40 | ->and($request->body()->get('command')[0]->command->value)->toBeString()->toBe('add_tag') 41 | ->and($request->body()->get('command')[0]->email)->toBeString()->toBe('test@example.com') 42 | ->and($request->body()->get('command')[0]->query)->toBeString()->toBe('test'); 43 | }); 44 | 45 | it('can remove a Tag from subscriber', function () { 46 | $mockClient = new MockClient([ 47 | SubscriberCommand::class => MockResponse::make(body: [ 48 | 'data' => [ 49 | 'results' => 1, 50 | ], 51 | ], status: 200), 52 | ]); 53 | 54 | $connector = new BentoConnector; 55 | $connector->authenticate(new BasicAuthenticator('publish_key', 'secret_key')); 56 | $connector->withMockClient($mockClient); 57 | 58 | $data = collect([ 59 | new CommandData( 60 | Command::REMOVE_TAG, 61 | 'test@example.com', 62 | 'test' 63 | ), 64 | ]); 65 | 66 | $request = new SubscriberCommand($data); 67 | 68 | $response = $connector->send($request); 69 | 70 | expect($response->body())->toBeJson() 71 | ->and($response->status())->toBe(200) 72 | ->and($response->json('data')['results'])->toBeInt()->not()->toBeEmpty()->toBe(1) 73 | ->and($request->body()->get('command'))->toBeArray()->not()->toBeEmpty() 74 | ->and($request->body()->get('command')[0]->command->value)->toBeString()->toBe('remove_tag') 75 | ->and($request->body()->get('command')[0]->email)->toBeString()->toBe('test@example.com') 76 | ->and($request->body()->get('command')[0]->query)->toBeString()->toBe('test'); 77 | }); 78 | 79 | it('can add a Tag by event to subscriber', function () { 80 | $mockClient = new MockClient([ 81 | SubscriberCommand::class => MockResponse::make(body: [ 82 | 'data' => [ 83 | 'results' => 1, 84 | ], 85 | ], status: 200), 86 | ]); 87 | 88 | $connector = new BentoConnector; 89 | $connector->authenticate(new BasicAuthenticator('publish_key', 'secret_key')); 90 | $connector->withMockClient($mockClient); 91 | 92 | $data = collect([ 93 | new CommandData( 94 | Command::ADD_TAG_VIA_EVENT, 95 | 'test@example.com', 96 | 'test' 97 | ), 98 | ]); 99 | 100 | $request = new SubscriberCommand($data); 101 | 102 | $response = $connector->send($request); 103 | 104 | expect($response->body())->toBeJson() 105 | ->and($response->status())->toBe(200) 106 | ->and($response->json('data')['results'])->toBeInt()->not()->toBeEmpty()->toBe(1) 107 | ->and($request->body()->get('command'))->toBeArray()->not()->toBeEmpty() 108 | ->and($request->body()->get('command')[0]->command->value)->toBeString()->toBe('add_tag_via_event') 109 | ->and($request->body()->get('command')[0]->email)->toBeString()->toBe('test@example.com') 110 | ->and($request->body()->get('command')[0]->query)->toBeString()->toBe('test'); 111 | }); 112 | 113 | it('can add a field to subscriber', function () { 114 | $mockClient = new MockClient([ 115 | SubscriberCommand::class => MockResponse::make(body: [ 116 | 'data' => [ 117 | 'results' => 1, 118 | ], 119 | ], status: 200), 120 | ]); 121 | 122 | $connector = new BentoConnector; 123 | $connector->authenticate(new BasicAuthenticator('publish_key', 'secret_key')); 124 | $connector->withMockClient($mockClient); 125 | 126 | $data = collect([ 127 | new CommandData( 128 | Command::ADD_FIELD, 129 | 'test@example.com', 130 | 'test' 131 | ), 132 | ]); 133 | 134 | $request = new SubscriberCommand($data); 135 | 136 | $response = $connector->send($request); 137 | 138 | expect($response->body())->toBeJson() 139 | ->and($response->status())->toBe(200) 140 | ->and($response->json('data')['results'])->toBeInt()->not()->toBeEmpty()->toBe(1) 141 | ->and($request->body()->get('command'))->toBeArray()->not()->toBeEmpty() 142 | ->and($request->body()->get('command')[0]->command->value)->toBeString()->toBe('add_field') 143 | ->and($request->body()->get('command')[0]->email)->toBeString()->toBe('test@example.com') 144 | ->and($request->body()->get('command')[0]->query)->toBeString()->toBe('test'); 145 | }); 146 | 147 | it('can remove a field to subscriber', function () { 148 | $mockClient = new MockClient([ 149 | SubscriberCommand::class => MockResponse::make(body: [ 150 | 'data' => [ 151 | 'results' => 1, 152 | ], 153 | ], status: 200), 154 | ]); 155 | 156 | $connector = new BentoConnector; 157 | $connector->authenticate(new BasicAuthenticator('publish_key', 'secret_key')); 158 | $connector->withMockClient($mockClient); 159 | 160 | $data = collect([ 161 | new CommandData( 162 | Command::REMOVE_FIELD, 163 | 'test@example.com', 164 | 'test' 165 | ), 166 | ]); 167 | 168 | $request = new SubscriberCommand($data); 169 | 170 | $response = $connector->send($request); 171 | 172 | expect($response->body())->toBeJson() 173 | ->and($response->status())->toBe(200) 174 | ->and($response->json('data')['results'])->toBeInt()->not()->toBeEmpty()->toBe(1) 175 | ->and($request->body()->get('command'))->toBeArray()->not()->toBeEmpty() 176 | ->and($request->body()->get('command')[0]->command->value)->toBeString()->toBe('remove_field') 177 | ->and($request->body()->get('command')[0]->email)->toBeString()->toBe('test@example.com') 178 | ->and($request->body()->get('command')[0]->query)->toBeString()->toBe('test'); 179 | }); 180 | 181 | it('can subscribe a subscriber', function () { 182 | $mockClient = new MockClient([ 183 | SubscriberCommand::class => MockResponse::make(body: [ 184 | 'data' => [ 185 | 'results' => 1, 186 | ], 187 | ], status: 200), 188 | ]); 189 | 190 | $connector = new BentoConnector; 191 | $connector->authenticate(new BasicAuthenticator('publish_key', 'secret_key')); 192 | $connector->withMockClient($mockClient); 193 | 194 | $data = collect([ 195 | new CommandData( 196 | Command::SUBSCRIBE, 197 | 'test@example.com', 198 | 'test' 199 | ), 200 | ]); 201 | 202 | $request = new SubscriberCommand($data); 203 | 204 | $response = $connector->send($request); 205 | 206 | expect($response->body())->toBeJson() 207 | ->and($response->status())->toBe(200) 208 | ->and($response->json('data')['results'])->toBeInt()->not()->toBeEmpty()->toBe(1) 209 | ->and($request->body()->get('command'))->toBeArray()->not()->toBeEmpty() 210 | ->and($request->body()->get('command')[0]->command->value)->toBeString()->toBe('subscribe') 211 | ->and($request->body()->get('command')[0]->email)->toBeString()->toBe('test@example.com') 212 | ->and($request->body()->get('command')[0]->query)->toBeString()->toBe('test'); 213 | }); 214 | 215 | it('can unsubscribe a subscriber', function () { 216 | $mockClient = new MockClient([ 217 | SubscriberCommand::class => MockResponse::make(body: [ 218 | 'data' => [ 219 | 'results' => 1, 220 | ], 221 | ], status: 200), 222 | ]); 223 | 224 | $connector = new BentoConnector; 225 | $connector->authenticate(new BasicAuthenticator('publish_key', 'secret_key')); 226 | $connector->withMockClient($mockClient); 227 | 228 | $data = collect([ 229 | new CommandData( 230 | Command::UNSUBSCRIBE, 231 | 'test@example.com', 232 | 'test' 233 | ), 234 | ]); 235 | 236 | $request = new SubscriberCommand($data); 237 | 238 | $response = $connector->send($request); 239 | 240 | expect($response->body())->toBeJson() 241 | ->and($response->status())->toBe(200) 242 | ->and($response->json('data')['results'])->toBeInt()->not()->toBeEmpty()->toBe(1) 243 | ->and($request->body()->get('command'))->toBeArray()->not()->toBeEmpty() 244 | ->and($request->body()->get('command')[0]->command->value)->toBeString()->toBe('unsubscribe') 245 | ->and($request->body()->get('command')[0]->email)->toBeString()->toBe('test@example.com') 246 | ->and($request->body()->get('command')[0]->query)->toBeString()->toBe('test'); 247 | }); 248 | 249 | it('can change a subscriber email', function () { 250 | $mockClient = new MockClient([ 251 | SubscriberCommand::class => MockResponse::make(body: [ 252 | 'data' => [ 253 | 'results' => 1, 254 | ], 255 | ], status: 200), 256 | ]); 257 | 258 | $connector = new BentoConnector; 259 | $connector->authenticate(new BasicAuthenticator('publish_key', 'secret_key')); 260 | $connector->withMockClient($mockClient); 261 | 262 | $data = collect([ 263 | new CommandData( 264 | Command::CHANGE_EMAIL, 265 | 'test@example.com', 266 | 'test2@example.com' 267 | ), 268 | ]); 269 | 270 | $request = new SubscriberCommand($data); 271 | 272 | $response = $connector->send($request); 273 | 274 | expect($response->body())->toBeJson() 275 | ->and($response->status())->toBe(200) 276 | ->and($response->json('data')['results'])->toBeInt()->not()->toBeEmpty()->toBe(1) 277 | ->and($request->body()->get('command'))->toBeArray()->not()->toBeEmpty() 278 | ->and($request->body()->get('command')[0]->command->value)->toBeString()->toBe('change_email') 279 | ->and($request->body()->get('command')[0]->email)->toBeString()->toBe('test@example.com') 280 | ->and($request->body()->get('command')[0]->query)->toBeString()->toBe('test2@example.com'); 281 | }); 282 | -------------------------------------------------------------------------------- /tests/Unit/TestMailable.php: -------------------------------------------------------------------------------- 1 | set(['app.mailer.bento.transport' => 'bento']); 10 | Mail::fake(); 11 | }); 12 | 13 | test('confirm mailer is set to bento', function () { 14 | expect(config('app.mailer.bento.transport'))->toBe('bento'); 15 | }); 16 | 17 | test('validate sender', function () { 18 | 19 | Mail::assertNothingSent(); 20 | 21 | Mail::to('test@example.com')->send(new TestMailable); 22 | 23 | Mail::assertSent(TestMailable::class, function ($mail) { 24 | return $mail->hasFrom('test@example.com'); 25 | }); 26 | }); 27 | 28 | test('validate single recipient', function () { 29 | 30 | Mail::assertNothingSent(); 31 | 32 | Mail::to('test@example.com')->send(new TestMailable); 33 | 34 | Mail::assertSent(TestMailable::class, 'test@example.com'); 35 | }); 36 | 37 | test('validate multiple recipients', function () { 38 | 39 | Mail::assertNothingSent(); 40 | 41 | Mail::to([ 42 | ['email' => 'recipient1@example.com', 'name' => 'Recipient 1'], 43 | ['email' => 'recipient2@example.com', 'name' => 'Recipient 2'], 44 | ]) 45 | ->send(new TestMailable); 46 | 47 | Mail::assertSent(TestMailable::class, 'recipient1@example.com'); 48 | Mail::assertSent(TestMailable::class, 'recipient2@example.com'); 49 | }); 50 | 51 | test('validate single cc', function () { 52 | 53 | Mail::assertNothingSent(); 54 | 55 | Mail::to('test@example.com') 56 | ->cc('recipient1@example.com') 57 | ->send(new TestMailable); 58 | 59 | Mail::assertSent(TestMailable::class, function ($mail) { 60 | return $mail->hasCc('recipient1@example.com'); 61 | }); 62 | }); 63 | 64 | test('validate multiple ccs', function () { 65 | 66 | Mail::assertNothingSent(); 67 | 68 | Mail::to('test@example.com') 69 | ->cc([ 70 | ['email' => 'carboncopy1@example.com', 'name' => 'Carbon Copy'], 71 | ['email' => 'carboncopy2@example.com', 'name' => 'Carbon Copy'], 72 | ]) 73 | ->send(new TestMailable); 74 | 75 | Mail::assertSent(TestMailable::class, function ($mail) { 76 | return $mail->hasCc(['carboncopy1@example.com', 'carboncopy2@example.com']); 77 | }); 78 | }); 79 | 80 | it('can get transport', function () { 81 | 82 | $this->transporter = new BentoTransport; 83 | 84 | $app = app(); 85 | 86 | $manager = $app->get(MailManager::class); 87 | 88 | $transport = $manager->createSymfonyTransport(['transport' => 'bento']); 89 | 90 | expect((string) $transport)->toBe('bento'); 91 | }); 92 | 93 | it('can send with bento transport', function () { 94 | 95 | $this->transporter = new BentoTransport; 96 | 97 | $app = app(); 98 | 99 | $manager = $app->get(MailManager::class); 100 | 101 | $manager->createSymfonyTransport(['transport' => 'bento']); 102 | 103 | Mail::assertNothingSent(); 104 | 105 | Mail::to('test@example.com')->send(new TestMailable); 106 | 107 | Mail::assertSent(TestMailable::class, 'test@example.com'); 108 | }); 109 | -------------------------------------------------------------------------------- /tests/Unit/UserImportCommandTest.php: -------------------------------------------------------------------------------- 1 | in('isolation'); 17 | 18 | beforeEach(function () { 19 | // Create test users 20 | $this->users = collect([ 21 | (object) ['name' => 'John Doe', 'email' => 'john@example.com'], 22 | (object) ['name' => 'Jane Smith', 'email' => 'jane@example.com'], 23 | (object) ['name' => 'Bob Wilson', 'email' => 'bob@example.com'], 24 | ]); 25 | 26 | // Mock config values 27 | config([ 28 | 'bentonow.publishable_key' => 'test-pub-key', 29 | 'bentonow.secret_key' => 'test-secret-key', 30 | 'bentonow.site_uuid' => 'test-site-uuid', 31 | ]); 32 | 33 | $this->batchCallCount = 0; 34 | 35 | MockClient::global([ 36 | ImportSubscribers::class => function (PendingRequest $request) { 37 | $subscribers = $request->body()?->get('subscribers') ?? []; 38 | $count = count($subscribers); 39 | $this->batchCallCount++; 40 | 41 | // Test: handles partial failures correctly (3 users) 42 | if ($count === 3 && $this->batchCallCount === 1) { 43 | return MockResponse::make(['results' => 1, 'failed' => 2]); 44 | } 45 | 46 | // Test: handles large datasets with multiple batches (1001 users split into 500, 500, 1) 47 | if ($count === 500 || $count === 1) { 48 | return MockResponse::make(['results' => $count, 'failed' => 0]); 49 | } 50 | 51 | // Default: full success 52 | return MockResponse::make(['results' => $count, 'failed' => 0]); 53 | }, 54 | ]); 55 | }); 56 | 57 | afterEach(function () { 58 | Mockery::close(); 59 | }); 60 | 61 | it('processes users in batches and tracks results correctly', function () { 62 | // Mock the User model 63 | $userMock = Mockery::mock('overload:'.User::class); 64 | $userMock->shouldReceive('select') 65 | ->once() 66 | ->with('name', 'email') 67 | ->andReturnSelf(); 68 | 69 | $userMock->shouldReceive('lazy') 70 | ->once() 71 | ->with(500) 72 | ->andReturn(LazyCollection::make($this->users)); 73 | 74 | // Create and run the command 75 | $command = $this->app->make(UserImportCommand::class); 76 | $output = new BufferedOutput; 77 | $command->setOutput(new OutputStyle(new ArrayInput([]), $output)); 78 | 79 | $result = $command->handle(); 80 | 81 | expect($result)->toBe(Command::SUCCESS); 82 | 83 | // Verify output 84 | $outputText = $output->fetch(); 85 | expect($outputText)->toContain('Processed batch: 1 successful, 2 failed') 86 | ->toContain('Completed! Successfully imported 1 users. Failed to import 2 users.'); 87 | }); 88 | 89 | it('handles large datasets with multiple batches', function () { 90 | // Create a large dataset (1001 users) 91 | $largeDataset = collect(range(1, 1001))->map(function ($i) { 92 | return (object) [ 93 | 'name' => "User{$i} Name{$i}", 94 | 'email' => "user{$i}@example.com", 95 | ]; 96 | }); 97 | 98 | // Mock the User model 99 | $userMock = Mockery::mock('overload:'.User::class); 100 | $userMock->shouldReceive('select') 101 | ->once() 102 | ->with('name', 'email') 103 | ->andReturnSelf(); 104 | 105 | $userMock->shouldReceive('lazy') 106 | ->once() 107 | ->with(500) 108 | ->andReturn(LazyCollection::make($largeDataset)); 109 | 110 | // Create and run the command 111 | $command = $this->app->make(UserImportCommand::class); 112 | $output = new BufferedOutput; 113 | $command->setOutput(new OutputStyle(new ArrayInput([]), $output)); 114 | 115 | $result = $command->handle(); 116 | 117 | expect($result)->toBe(Command::SUCCESS); 118 | 119 | // Verify output 120 | $outputText = $output->fetch(); 121 | expect($outputText)->toContain('Processed batch: 500 successful, 0 failed') 122 | ->toContain('Completed! Successfully imported 1001 users. Failed to import 0 users.'); 123 | }); 124 | 125 | it('handles empty dataset gracefully', function () { 126 | // Mock the User model with empty dataset 127 | $userMock = Mockery::mock('overload:'.User::class); 128 | $userMock->shouldReceive('select') 129 | ->once() 130 | ->with('name', 'email') 131 | ->andReturnSelf(); 132 | 133 | $userMock->shouldReceive('lazy') 134 | ->once() 135 | ->with(500) 136 | ->andReturn(LazyCollection::make([])); 137 | 138 | // Create and run the command 139 | $command = $this->app->make(UserImportCommand::class); 140 | $output = new BufferedOutput; 141 | $command->setOutput(new OutputStyle(new ArrayInput([]), $output)); 142 | 143 | $result = $command->handle(); 144 | 145 | expect($result)->toBe(Command::SUCCESS); 146 | 147 | // Verify output 148 | $outputText = $output->fetch(); 149 | expect($outputText)->toContain('Completed! Successfully imported 0 users. Failed to import 0 users.'); 150 | }); 151 | 152 | it('handles partial failures correctly', function () { 153 | // Mock the User model 154 | $userMock = Mockery::mock('overload:'.User::class); 155 | $userMock->shouldReceive('select') 156 | ->once() 157 | ->with('name', 'email') 158 | ->andReturnSelf(); 159 | 160 | $userMock->shouldReceive('lazy') 161 | ->once() 162 | ->with(500) 163 | ->andReturn(LazyCollection::make($this->users)); 164 | 165 | // Create and run the command 166 | $command = $this->app->make(UserImportCommand::class); 167 | $output = new BufferedOutput; 168 | $command->setOutput(new OutputStyle(new ArrayInput([]), $output)); 169 | 170 | $result = $command->handle(); 171 | 172 | expect($result)->toBe(Command::SUCCESS); 173 | 174 | // Verify output 175 | $outputText = $output->fetch(); 176 | expect($outputText)->toContain('Processed batch: 1 successful, 2 failed') 177 | ->toContain('Completed! Successfully imported 1 users. Failed to import 2 users.'); 178 | }); 179 | -------------------------------------------------------------------------------- /tests/Unit/ValidateEmailTest.php: -------------------------------------------------------------------------------- 1 | MockResponse::make(body: [ 14 | 'data' => [ 15 | 'valid' => false, 16 | ], 17 | ], status: 200), 18 | ]); 19 | 20 | $connector = new BentoConnector; 21 | $connector->authenticate(new BasicAuthenticator('publish_key', 'secret_key')); 22 | $connector->withMockClient($mockClient); 23 | 24 | $data = new ValidateEmailData( 25 | emailAddress: 'test@example.com', 26 | fullName: 'John Snow', 27 | userAgent: null, 28 | ipAddress: null 29 | ); 30 | 31 | $request = new ValidateEmail($data); 32 | 33 | $response = $connector->send($request); 34 | 35 | expect($response->body())->toBeJson() 36 | ->and($response->status())->toBe(200) 37 | ->and($response->json('data')['valid'])->toBeBool()->toBe(false) 38 | ->and($request->query()->get('name'))->toBeString()->not()->toBeEmpty()->toBe('John Snow') 39 | ->and($request->query()->get('email'))->toBeString()->not()->toBeEmpty()->toBe('test@example.com') 40 | ->and($request->query()->get('ip'))->toBeEmpty(); 41 | }); 42 | 43 | it('fails to validate email (500)', function (): void { 44 | $mockClient = new MockClient([ 45 | ValidateEmail::class => MockResponse::make(body: [], status: 500), 46 | ]); 47 | 48 | $connector = new BentoConnector; 49 | $connector->authenticate(new BasicAuthenticator('publish_key', 'secret_key')); 50 | $connector->withMockClient($mockClient); 51 | 52 | $data = new ValidateEmailData( 53 | emailAddress: 'test@example.com', 54 | fullName: 'John Snow', 55 | userAgent: null, 56 | ipAddress: null 57 | ); 58 | 59 | $request = new ValidateEmail($data); 60 | 61 | $response = $connector->send($request); 62 | 63 | expect($response->body())->toBeJson() 64 | ->and($response->status())->toBe(500) 65 | ->and($response->json('data')['id'])->toBeEmpty(); 66 | })->throws(InternalServerErrorException::class); 67 | --------------------------------------------------------------------------------