├── .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 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | 90 | 91 | 92 | 93 | 94 | 95 | 96 | 97 | 98 | 99 | 100 | 101 | 102 | 103 | 104 | 105 | 106 | 107 | 108 | 109 | 110 | 111 | 112 | 113 | 114 | 115 | 116 | 117 | 118 | 119 | 120 | 121 | 122 | 123 | 124 | 125 | 126 | 127 | 128 | 129 | 130 | 131 | 132 | 133 | 134 | 135 | 136 | 137 | 138 | 139 | 140 | 141 | 142 | 143 | 144 | 145 | 146 | 147 | 148 | 149 | 150 | -------------------------------------------------------------------------------- /.idea/blade.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | 90 | 91 | 92 | 93 | 94 | 95 | 96 | 97 | 98 | 99 | 100 | 101 | 102 | 103 | 104 | 105 | 106 | 107 | 108 | 109 | 110 | 111 | 112 | 113 | 114 | 115 | 116 | 117 | 118 | 119 | 120 | 121 | 122 | -------------------------------------------------------------------------------- /.idea/codeception.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 11 | 12 | -------------------------------------------------------------------------------- /.idea/inspectionProfiles/Project_Default.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 6 | -------------------------------------------------------------------------------- /.idea/laravel-idea-personal.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 9 | 10 | -------------------------------------------------------------------------------- /.idea/laravel-idea.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 7 | -------------------------------------------------------------------------------- /.idea/modules.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /.idea/php-test-framework.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /.idea/php.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 12 | 13 | 15 | 16 | 18 | 19 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | 90 | 91 | 92 | 93 | 94 | 95 | 96 | 97 | 98 | 99 | 100 | 101 | 102 | 103 | 104 | 105 | 106 | 107 | 108 | 109 | 110 | 111 | 112 | 113 | 114 | 115 | 116 | 117 | 118 | 119 | 120 | 121 | 122 | 123 | 124 | 125 | 126 | 127 | 128 | 129 | 130 | 131 | 132 | 133 | 134 | 135 | 136 | 137 | 138 | 139 | 140 | 141 | 142 | 143 | 144 | 145 | 146 | 147 | 148 | 149 | 150 | 151 | 152 | 153 | 154 | 155 | 156 | 157 | 158 | 159 | 160 | 161 | 162 | 163 | 164 | 165 | 166 | 167 | 168 | 169 | 170 | 171 | 172 | 173 | 175 | 176 | 177 | 178 | 179 | 180 | 181 | 183 | -------------------------------------------------------------------------------- /.idea/phpspec.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 8 | 9 | 11 | 12 | 14 | 15 | 17 | 18 | 20 | 21 | 22 | -------------------------------------------------------------------------------- /.idea/phpunit.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 9 | 10 | -------------------------------------------------------------------------------- /.idea/runConfigurations/phpunit_xml.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 6 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /.idea/vcs.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | In the interest of fostering an open and welcoming environment, we as 6 | contributors and maintainers pledge to making participation in our project and 7 | our community a harassment-free experience for everyone, regardless of age, body 8 | size, disability, ethnicity, gender identity and expression, level of experience, 9 | nationality, personal appearance, race, religion, or sexual identity and 10 | orientation. 11 | 12 | ## Our Standards 13 | 14 | Examples of behavior that contributes to creating a positive environment 15 | include: 16 | 17 | * Using welcoming and inclusive language 18 | * Being respectful of differing viewpoints and experiences 19 | * Gracefully accepting constructive criticism 20 | * Focusing on what is best for the community 21 | * Showing empathy towards other community members 22 | 23 | Examples of unacceptable behavior by participants include: 24 | 25 | * The use of sexualized language or imagery and unwelcome sexual attention or 26 | advances 27 | * Trolling, insulting/derogatory comments, and personal or political attacks 28 | * Public or private harassment 29 | * Publishing others' private information, such as a physical or electronic 30 | address, without explicit permission 31 | * Other conduct which could reasonably be considered inappropriate in a 32 | professional setting 33 | 34 | ## Our Responsibilities 35 | 36 | Project maintainers are responsible for clarifying the standards of acceptable 37 | behavior and are expected to take appropriate and fair corrective action in 38 | response to any instances of unacceptable behavior. 39 | 40 | Project maintainers have the right and responsibility to remove, edit, or 41 | reject comments, commits, code, wiki edits, issues, and other contributions 42 | that are not aligned to this Code of Conduct, or to ban temporarily or 43 | permanently any contributor for other behaviors that they deem inappropriate, 44 | threatening, offensive, or harmful. 45 | 46 | ## Scope 47 | 48 | This Code of Conduct applies both within project spaces and in public spaces 49 | when an individual is representing the project or its community. Examples of 50 | representing a project or community include using an official project e-mail 51 | address, posting via an official social media account, or acting as an appointed 52 | representative at an online or offline event. Representation of a project may be 53 | further defined and clarified by project maintainers. 54 | 55 | ## Enforcement 56 | 57 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 58 | reported by contacting the project team at support@bento.com. All 59 | complaints will be reviewed and investigated and will result in a response that 60 | is deemed necessary and appropriate to the circumstances. The project team is 61 | obligated to maintain confidentiality with regard to the reporter of an incident. 62 | Further details of specific enforcement policies may be posted separately. 63 | 64 | Project maintainers who do not follow or enforce the Code of Conduct in good 65 | faith may face temporary or permanent repercussions as determined by other 66 | members of the project's leadership. 67 | 68 | ## Attribution 69 | 70 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, 71 | available at [http://contributor-covenant.org/version/1/4][version] 72 | 73 | [homepage]: http://contributor-covenant.org 74 | [version]: http://contributor-covenant.org/version/1/4/ 75 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Bento 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Bento Laravel SDK 2 | 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 | [![Tests](https://github.com/bentonow/bento-laravel-sdk/actions/workflows/tests.yml/badge.svg?branch=main)](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 | 7 | 8 | 9 | ./tests 10 | 11 | 12 | 13 | 14 | ./app 15 | ./src 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | -------------------------------------------------------------------------------- /src/Actions/UserImportAction.php: -------------------------------------------------------------------------------- 1 | chunk(500)->each(function ($usersChunk): void { 24 | $bento = new BentoConnector; 25 | $request = new ImportSubscribers($usersChunk->values()); 26 | $importResult = $bento->send($request); 27 | $this->success = +$importResult->json()['results'] ?? 0; 28 | $this->failures = +$importResult->json()['failed'] ?? 0; 29 | }); 30 | 31 | return ['results' => $this->success, 'failed' => $this->failures]; 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/BentoConnector.php: -------------------------------------------------------------------------------- 1 | 'application/json', 25 | 'Content-Type' => 'application/json', 26 | 'User-Agent' => 'bento-laravel-'.config('bentonow.site_uuid'), 27 | ]; 28 | } 29 | 30 | protected function defaultQuery(): array 31 | { 32 | return [ 33 | 'site_uuid' => config('bentonow.site_uuid'), 34 | ]; 35 | } 36 | 37 | protected function defaultAuth(): BasicAuthenticator 38 | { 39 | return new BasicAuthenticator(config('bentonow.publishable_key'), config('bentonow.secret_key')); 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /src/BentoLaravelServiceProvider.php: -------------------------------------------------------------------------------- 1 | app->runningInConsole()) { 16 | $this->publishes([ 17 | __DIR__.'/../config/bentonow.php' => config_path('bentonow.php'), 18 | ], 'bentonow'); 19 | } 20 | 21 | $this->registerCommands(); 22 | 23 | // Register the middleware 24 | $this->app['router']->aliasMiddleware('bento.signature', BentoSignatureExclusion::class); 25 | 26 | Mail::extend('bento', fn (array $config = []) => new BentoTransport); 27 | } 28 | 29 | /** 30 | * Register the package's commands. 31 | */ 32 | protected function registerCommands(): void 33 | { 34 | if ($this->app->runningInConsole()) { 35 | $this->commands([ 36 | Console\UserImportCommand::class, 37 | Console\InstallCommand::class, 38 | ]); 39 | } 40 | } 41 | 42 | public function register(): void 43 | { 44 | $this->mergeConfigFrom( 45 | __DIR__.'/../config/bentonow.php', 46 | 'bentonow' 47 | ); 48 | 49 | $this->app->singleton('bento', fn ($app) => new BentoConnector); 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /src/BentoTransport.php: -------------------------------------------------------------------------------- 1 | transform($message); 21 | 22 | Http::baseUrl(sprintf('https://%s', self::HOST)) 23 | ->withQueryParameters([ 24 | 'site_uuid' => config('bentonow.site_uuid', config('bentonow.siteUUID')), 25 | ]) 26 | ->withBasicAuth( 27 | config('bentonow.publishable_key', config('bentonow.publishableKey')), 28 | config('bentonow.secret_key', config('bentonow.secretKey')), 29 | ) 30 | ->post('/api/v1/batch/emails', $bodyParameters) 31 | ->throw() 32 | ->body(); 33 | 34 | } catch (ConnectionException|RequestException $e) { 35 | throw new TransportException('Failed to send email via BentoTransport', 0, $e); 36 | } 37 | } 38 | 39 | public function __toString(): string 40 | { 41 | return 'bento'; 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /src/Console/InstallCommand.php: -------------------------------------------------------------------------------- 1 | callSilently('vendor:publish', [ 16 | '--tag' => 'bento-config', 17 | ]); 18 | 19 | if ($this->confirm('Would you like to star the repo on GitHub?')) { 20 | $url = 'https://github.com/bentonow/bento-laravel-sdk'; 21 | 22 | $command = [ 23 | 'Darwin' => 'open', 24 | 'Linux' => 'xdg-open', 25 | 'Windows' => 'start', 26 | ][PHP_OS_FAMILY] ?? null; 27 | 28 | if ($command) { 29 | exec("{$command} {$url}"); 30 | } 31 | } 32 | 33 | $this->info('Bento has been installed!'); 34 | 35 | return self::SUCCESS; 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/Console/UserImportCommand.php: -------------------------------------------------------------------------------- 1 | lazy(500) 30 | ->chunk(500) 31 | ->each(function ($chunk) use (&$totalSuccess, &$totalFailures) { 32 | $subscribers = $chunk->collect()->map(function ($user) { 33 | return new ImportSubscribersData( 34 | email: $user->email, 35 | firstName: Str::of($user->name) 36 | ->after('.') 37 | ->before(' ') 38 | ->__toString(), 39 | lastName: Str::of($user->name) 40 | ->after(' ') 41 | ->__toString(), 42 | tags: ['onboarding_complete'], 43 | removeTags: null, 44 | fields: ['imported_at' => now()] 45 | ); 46 | }); 47 | 48 | $importResult = (new UserImportAction)->execute($subscribers); 49 | $totalSuccess += $importResult['results']; 50 | $totalFailures += $importResult['failed']; 51 | 52 | $this->info("Processed batch: {$importResult['results']} successful, {$importResult['failed']} failed"); 53 | }); 54 | $this->info("Completed! Successfully imported {$totalSuccess} users. Failed to import {$totalFailures} users."); 55 | 56 | return self::SUCCESS; 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /src/DataTransferObjects/BlacklistStatusData.php: -------------------------------------------------------------------------------- 1 | $this->name, 29 | 'subject' => $this->subject, 30 | 'content' => $this->content, 31 | 'type' => $this->type->value, 32 | 'from' => array_filter(['email' => $this->from->emailAddress, 'name' => $this->from->name]), 33 | 'inclusive_tags' => $this->inclusive_tags, 34 | 'exclusive_tags' => $this->exclusive_tags, 35 | 'segment_id' => $this->segment_id, 36 | 'batch_size_per_hour' => $this->batch_size_per_hour, 37 | 'send_at' => $this->send_at, 38 | 'approved' => $this->approved, 39 | ]; 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /src/DataTransferObjects/CreateFieldData.php: -------------------------------------------------------------------------------- 1 | ipAddress = $this->validateIpAddress($ipAddress); 16 | } 17 | 18 | protected function validateIpAddress($ipAddress): string 19 | { 20 | try { 21 | throw_unless( 22 | filter_var( 23 | $ipAddress, 24 | FILTER_VALIDATE_IP, 25 | FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE 26 | ), 27 | new Exception('Invalid IP address provided.') 28 | ); 29 | 30 | return $this->ipAddress = $ipAddress; 31 | } catch (Throwable $e) { 32 | throw new Exception('Invalid IP address provided.'); 33 | } 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/DataTransferObjects/ImportSubscribersData.php: -------------------------------------------------------------------------------- 1 | $this->email, 22 | 'first_name' => $this->firstName, 23 | 'last_name' => $this->lastName, 24 | 'tags' => ! empty($this->tags) ? implode(',', $this->tags) : null, 25 | 'remove_tags' => ! empty($this->removeTags) ? implode(',', $this->removeTags) : null, 26 | ], $this->fields ?? [])); 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/DataTransferObjects/ReportStatsData.php: -------------------------------------------------------------------------------- 1 | ipAddress = $this->validateIpAddress($ipAddress); 16 | } 17 | 18 | protected function validateIpAddress(?string $ipAddress): ?string 19 | { 20 | if (empty($ipAddress)) { 21 | return null; 22 | } 23 | 24 | return (new GeoLocateIpData($ipAddress))->ipAddress; 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/Enums/BroadcastType.php: -------------------------------------------------------------------------------- 1 | send($request); 74 | } 75 | 76 | /** 77 | * Get the request class for the given method. 78 | */ 79 | private static function getRequestClass(string $method): ?string 80 | { 81 | $mapping = [ 82 | 'trackEvent' => CreateEvents::class, 83 | 'importSubscribers' => ImportSubscribers::class, 84 | 'upsertSubscribers' => ImportSubscribers::class, 85 | 'findSubscriber' => FindSubscriber::class, 86 | 'createSubscriber' => CreateSubscriber::class, 87 | 'subscriberCommand' => SubscriberCommand::class, 88 | 'getTags' => GetTags::class, 89 | 'createTag' => CreateTag::class, 90 | 'getFields' => GetFields::class, 91 | 'createField' => CreateField::class, 92 | 'getBroadcasts' => GetBroadcasts::class, 93 | 'createBroadcast' => CreateBroadcast::class, 94 | 'getSiteStats' => GetSiteStats::class, 95 | 'getSegmentStats' => GetSegmentStats::class, 96 | 'getReportStats' => GetReportStats::class, 97 | 'getBlacklistStatus' => GetBlacklistStatus::class, 98 | 'validateEmail' => ValidateEmail::class, 99 | 'getContentModeration' => GetContentModeration::class, 100 | 'getGender' => GetGender::class, 101 | 'geoLocateIp' => GeoLocateIp::class, 102 | ]; 103 | 104 | return $mapping[$method] ?? null; 105 | } 106 | } 107 | -------------------------------------------------------------------------------- /src/Http/Middleware/BentoSignatureExclusion.php: -------------------------------------------------------------------------------- 1 | 17 | */ 18 | private const REQUIRED_PARAMETERS = [ 19 | 'expires', 20 | 'signature', 21 | ]; 22 | 23 | /** 24 | * Handle an incoming request. 25 | * 26 | * @return mixed 27 | * 28 | * @throws InvalidSignatureException 29 | */ 30 | public function handle(Request $request, Closure $next) 31 | { 32 | // Get all current query parameters 33 | $queryParams = $request->query->all(); 34 | 35 | // Filter to keep only required parameters 36 | $cleanedParams = array_filter( 37 | $queryParams, 38 | fn ($key) => in_array($key, self::REQUIRED_PARAMETERS, true), 39 | ARRAY_FILTER_USE_KEY 40 | ); 41 | 42 | // Create a new request with only the required parameters for validation 43 | $validationRequest = Request::create( 44 | $request->url(), 45 | $request->method(), 46 | $cleanedParams 47 | ); 48 | 49 | if (! $validationRequest->hasValidSignature()) { 50 | throw new InvalidSignatureException; 51 | } 52 | 53 | // Replace the query parameters in the original request with the cleaned ones 54 | $request->query->replace($cleanedParams); 55 | 56 | // Update the server's QUERY_STRING 57 | $request->server->set('QUERY_STRING', http_build_query($cleanedParams)); 58 | 59 | return $next($request); 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /src/Requests/CreateBroadcast.php: -------------------------------------------------------------------------------- 1 | $this->broadcastCollection->map(fn ($broadcast) => $broadcast->__toArray()), 32 | ]; 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/Requests/CreateEvents.php: -------------------------------------------------------------------------------- 1 | */ 20 | public function __construct(private readonly Collection $eventsCollection) {} 21 | 22 | public function resolveEndpoint(): string 23 | { 24 | return 'batch/events'; 25 | } 26 | 27 | protected function defaultBody(): array 28 | { 29 | $events = $this->eventsCollection->map(function ($event) { 30 | return [ 31 | 'type' => $event->type, 32 | 'email' => $event->email, 33 | 'fields' => empty($event->fields) ? null : $event->fields, 34 | 'details' => empty($event->details) ? null : $event->details, 35 | ]; 36 | }); 37 | 38 | return [ 39 | 'events' => $events, 40 | ]; 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /src/Requests/CreateField.php: -------------------------------------------------------------------------------- 1 | [ 30 | 'key' => $this->data->key, 31 | ], 32 | ]; 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/Requests/CreateSubscriber.php: -------------------------------------------------------------------------------- 1 | [ 32 | 'email' => $this->subscriber->email, 33 | ], 34 | ]; 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/Requests/CreateTag.php: -------------------------------------------------------------------------------- 1 | [ 32 | 'name' => $this->data->name, 33 | ], 34 | ]; 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/Requests/FindSubscriber.php: -------------------------------------------------------------------------------- 1 | $this->email, 28 | ]; 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/Requests/GeoLocateIP.php: -------------------------------------------------------------------------------- 1 | $this->data->ipAddress, 26 | ]; 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/Requests/GetBlacklistStatus.php: -------------------------------------------------------------------------------- 1 | $this->data->domain, 28 | 'ip' => $this->data->ipAddress, 29 | ]; 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/Requests/GetBroadcasts.php: -------------------------------------------------------------------------------- 1 | $this->data->content, 28 | ]; 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/Requests/GetFields.php: -------------------------------------------------------------------------------- 1 | $this->data->fullName, 28 | ]; 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/Requests/GetReportStats.php: -------------------------------------------------------------------------------- 1 | $this->data->report_id, 28 | ]; 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/Requests/GetSegmentStats.php: -------------------------------------------------------------------------------- 1 | $this->data->segment_id, 28 | ]; 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/Requests/GetSiteStats.php: -------------------------------------------------------------------------------- 1 | |LazyCollection $subscriberCollection 23 | */ 24 | public function __construct(private readonly Collection|LazyCollection $subscriberCollection) {} 25 | 26 | public function resolveEndpoint(): string 27 | { 28 | return '/batch/subscribers'; 29 | } 30 | 31 | protected function defaultBody(): array 32 | { 33 | return [ 34 | 'subscribers' => $this->subscriberCollection->map(fn ($subscriber) => $subscriber->__toArray()), 35 | ]; 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/Requests/SubscriberCommand.php: -------------------------------------------------------------------------------- 1 | */ 20 | public function __construct(private readonly Collection $commandsCollection) {} 21 | 22 | public function resolveEndpoint(): string 23 | { 24 | return '/fetch/commands'; 25 | } 26 | 27 | protected function defaultBody(): array 28 | { 29 | return [ 30 | 'command' => $this->commandsCollection->toArray(), 31 | ]; 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/Requests/ValidateEmail.php: -------------------------------------------------------------------------------- 1 | $this->data->emailAddress, 28 | 'name' => $this->data->fullName, 29 | 'user_agent' => $this->data->userAgent, 30 | 'ip' => $this->data->ipAddress, 31 | ]; 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/Responses/BentoApiResponse.php: -------------------------------------------------------------------------------- 1 | getOriginalMessage()); 14 | 15 | $payload = [ 16 | 'from' => $symfonyEmail->getFrom()[0]->getAddress(), 17 | 'subject' => $symfonyEmail->getSubject(), 18 | 'html_body' => $symfonyEmail->getHtmlBody(), 19 | 'transactional' => true, 20 | ]; 21 | 22 | if ($symfonyEmail->getTo()) { 23 | $payload['to'] = $this->formatEmailAddresses($symfonyEmail->getTo()); 24 | } 25 | 26 | if ($symfonyEmail->getCc()) { 27 | $payload['cc'] = $this->formatEmailAddresses($symfonyEmail->getCc()); 28 | } 29 | 30 | if ($symfonyEmail->getBcc()) { 31 | $payload['bcc'] = $this->formatEmailAddresses($symfonyEmail->getBcc()); 32 | } 33 | 34 | return [ 35 | 'emails' => [$payload], 36 | ]; 37 | } 38 | 39 | /** 40 | * @param Address[] $addresses 41 | */ 42 | private function formatEmailAddresses(array $addresses): string 43 | { 44 | return implode( 45 | ',', 46 | array_map(fn (Address $address) => $address->getAddress(), $addresses), 47 | ); 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /tests/Architecture/NoDebuggingStatementsTest.php: -------------------------------------------------------------------------------- 1 | expect(['dd', 'ddd', 'die', 'dump', 'ray', 'sleep']) 5 | ->toBeUsedInNothing(); 6 | -------------------------------------------------------------------------------- /tests/Pest.php: -------------------------------------------------------------------------------- 1 | beforeEach(fn () => MockClient::destroyGlobal()) 10 | ->in(__DIR__); 11 | -------------------------------------------------------------------------------- /tests/TestCase.php: -------------------------------------------------------------------------------- 1 | response()->json(['status' => 'ok'])) 10 | ->name('test.route') 11 | ->middleware('bento.signature'); 12 | }); 13 | 14 | test('valid signature passes', function (): void { 15 | $signedUrl = URL::signedRoute('test.route'); 16 | 17 | $this->get($signedUrl) 18 | ->assertOk(); 19 | }); 20 | 21 | test('invalid signature fails', function (): void { 22 | $this->get('test-route?signature=invalid') 23 | ->assertForbidden(); 24 | }); 25 | 26 | test('additional parameters dont invalidate signature', function (): void { 27 | // Get the signed URL first 28 | $signedUrl = URL::signedRoute('test.route'); 29 | 30 | // Get the signature parameters 31 | parse_str(parse_url($signedUrl, PHP_URL_QUERY), $params); 32 | 33 | // Create parameters array with additional parameters 34 | $urlParams = [ 35 | 'random_param' => 'test', 36 | 'tracking_id' => '123', 37 | 'user_id' => '456', 38 | 'signature' => $params['signature'], 39 | ]; 40 | 41 | // Add expires parameter if it exists in the original URL 42 | if (isset($params['expires'])) { 43 | $urlParams['expires'] = $params['expires']; 44 | } 45 | 46 | // Create URL with parameters 47 | $urlWithParams = '/test-route?'.http_build_query($urlParams); 48 | 49 | $this->get($urlWithParams) 50 | ->assertOk(); 51 | }); 52 | 53 | test('missing required parameters invalidate signature', function (): void { 54 | // Get the signed URL first 55 | $signedUrl = URL::temporarySignedRoute('test.route', now()->addMinutes(30)); 56 | 57 | // Get the signature parameters 58 | parse_str(parse_url($signedUrl, PHP_URL_QUERY), $params); 59 | 60 | // Create URL with only signature (missing expires) 61 | $urlWithParams = '/test-route?signature='.$params['signature']; 62 | 63 | $this->get($urlWithParams) 64 | ->assertForbidden(); 65 | }); 66 | 67 | test('multiple additional parameters are handled correctly', function (): void { 68 | // Get the signed URL first 69 | $signedUrl = URL::signedRoute('test.route'); 70 | 71 | // Get the signature parameters 72 | parse_str(parse_url($signedUrl, PHP_URL_QUERY), $params); 73 | 74 | // Create parameters array with multiple additional parameters 75 | $urlParams = [ 76 | 'utm_source' => 'facebook', 77 | 'utm_medium' => 'social', 78 | 'utm_campaign' => 'summer2024', 79 | 'utm_content' => 'ad1', 80 | 'utm_term' => 'sale', 81 | 'fbclid' => 'abc123', 82 | 'bento_uuid' => '456', 83 | 'random_param1' => 'value1', 84 | 'random_param2' => 'value2', 85 | 'signature' => $params['signature'], 86 | ]; 87 | 88 | // Add expires parameter if it exists in the original URL 89 | if (isset($params['expires'])) { 90 | $urlParams['expires'] = $params['expires']; 91 | } 92 | 93 | // Create URL with parameters 94 | $urlWithParams = '/test-route?'.http_build_query($urlParams); 95 | 96 | $this->get($urlWithParams) 97 | ->assertOk(); 98 | }); 99 | 100 | test('query parameters are cleaned after validation', function (): void { 101 | // Get the signed URL first 102 | $signedUrl = URL::signedRoute('test.route'); 103 | 104 | // Get the signature parameters 105 | parse_str(parse_url($signedUrl, PHP_URL_QUERY), $params); 106 | 107 | // Parameters that should be removed 108 | $testParams = [ 109 | 'utm_source' => 'facebook', 110 | 'fbclid' => 'abc123', 111 | ]; 112 | 113 | // Create parameters array with test parameters 114 | $urlParams = array_merge($testParams, [ 115 | 'signature' => $params['signature'], 116 | ]); 117 | 118 | // Add expires parameter if it exists in the original URL 119 | if (isset($params['expires'])) { 120 | $urlParams['expires'] = $params['expires']; 121 | } 122 | 123 | // Create URL with parameters 124 | $urlWithParams = '/test-route?'.http_build_query($urlParams); 125 | 126 | $response = $this->get($urlWithParams); 127 | $response->assertOk(); 128 | 129 | // Verify additional parameters are removed 130 | foreach ($testParams as $key => $value) { 131 | expect(request()->query($key))->toBeNull(); 132 | } 133 | 134 | // Verify required parameters are preserved 135 | expect(request()->query('signature'))->toBe($params['signature']); 136 | if (isset($params['expires'])) { 137 | expect(request()->query('expires'))->toBe($params['expires']); 138 | } 139 | }); 140 | 141 | test('works with temporary signed urls', function (): void { 142 | $signedUrl = URL::temporarySignedRoute('test.route', now()->addMinutes(30)); 143 | 144 | // Get the signature parameters 145 | parse_str(parse_url($signedUrl, PHP_URL_QUERY), $params); 146 | 147 | // Create parameters array with additional parameters 148 | $urlParams = [ 149 | 'utm_source' => 'test', 150 | 'fbclid' => '123', 151 | 'random_param' => 'value', 152 | 'signature' => $params['signature'], 153 | 'expires' => $params['expires'], 154 | ]; 155 | 156 | // Create URL with parameters 157 | $urlWithParams = '/test-route?'.http_build_query($urlParams); 158 | 159 | $this->get($urlWithParams) 160 | ->assertOk(); 161 | }); 162 | -------------------------------------------------------------------------------- /tests/Unit/CreateEventTest.php: -------------------------------------------------------------------------------- 1 | MockResponse::make(body: [ 16 | 'results' => 1, 17 | 'failed' => 0, 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 EventData( 27 | type: '$completed_onboarding', 28 | email: 'test@example.com', 29 | fields: [ 30 | 'first_name' => 'John', 31 | 'last_name' => 'Doe', 32 | ], 33 | ), 34 | ]); 35 | 36 | $request = new CreateEvents($data); 37 | 38 | $response = $connector->send($request); 39 | 40 | expect($response->body())->toBeJson() 41 | ->and($response->status())->toBe(200) 42 | ->and($response->json('results'))->toBe(1) 43 | ->and($response->json('failed'))->toBe(0) 44 | ->and($request->body()->get('events'))->not()->toBeEmpty() 45 | ->and($request->body()->get('events')[0]['type'])->toBe('$completed_onboarding') 46 | ->and($request->body()->get('events')[0]['email'])->toBe('test@example.com') 47 | ->and($request->body()->get('events')[0]['fields'])->toBe([ 48 | 'first_name' => 'John', 49 | 'last_name' => 'Doe', 50 | ]); 51 | }); 52 | 53 | it('can create an event with details', function (): void { 54 | $mockClient = new MockClient([ 55 | CreateEvents::class => MockResponse::make(body: [ 56 | 'results' => 1, 57 | 'failed' => 0, 58 | ], status: 200), 59 | ]); 60 | 61 | $connector = new BentoConnector; 62 | $connector->authenticate(new BasicAuthenticator('publish_key', 'secret_key')); 63 | $connector->withMockClient($mockClient); 64 | 65 | $data = collect([ 66 | new EventData( 67 | type: '$purchase', 68 | email: 'test@example.com', 69 | fields: [ 70 | 'first_name' => 'John', 71 | 'last_name' => 'Doe', 72 | ], 73 | details: [ 74 | 'amount' => 99.99, 75 | 'currency' => 'USD', 76 | 'product_id' => '123', 77 | ], 78 | ), 79 | ]); 80 | 81 | $request = new CreateEvents($data); 82 | 83 | $response = $connector->send($request); 84 | 85 | expect($response->body())->toBeJson() 86 | ->and($response->status())->toBe(200) 87 | ->and($response->json('results'))->toBe(1) 88 | ->and($response->json('failed'))->toBe(0) 89 | ->and($request->body()->get('events'))->not()->toBeEmpty() 90 | ->and($request->body()->get('events')[0]['type'])->toBe('$purchase') 91 | ->and($request->body()->get('events')[0]['email'])->toBe('test@example.com') 92 | ->and($request->body()->get('events')[0]['fields'])->toBe([ 93 | 'first_name' => 'John', 94 | 'last_name' => 'Doe', 95 | ]) 96 | ->and($request->body()->get('events')[0]['details'])->toBe([ 97 | 'amount' => 99.99, 98 | 'currency' => 'USD', 99 | 'product_id' => '123', 100 | ]); 101 | }); 102 | 103 | it('can create multiple events in a single request', function (): void { 104 | $mockClient = new MockClient([ 105 | CreateEvents::class => MockResponse::make(body: [ 106 | 'results' => 2, 107 | 'failed' => 0, 108 | ], status: 200), 109 | ]); 110 | 111 | $connector = new BentoConnector; 112 | $connector->authenticate(new BasicAuthenticator('publish_key', 'secret_key')); 113 | $connector->withMockClient($mockClient); 114 | 115 | $data = collect([ 116 | new EventData( 117 | type: '$page_view', 118 | email: 'user1@example.com', 119 | fields: [ 120 | 'first_name' => 'John', 121 | 'last_name' => 'Doe', 122 | ], 123 | ), 124 | new EventData( 125 | type: '$form_submission', 126 | email: 'user2@example.com', 127 | fields: [ 128 | 'first_name' => 'Jane', 129 | 'last_name' => 'Smith', 130 | ], 131 | ), 132 | ]); 133 | 134 | $request = new CreateEvents($data); 135 | 136 | $response = $connector->send($request); 137 | 138 | expect($response->body())->toBeJson() 139 | ->and($response->status())->toBe(200) 140 | ->and($response->json('results'))->toBe(2) 141 | ->and($response->json('failed'))->toBe(0) 142 | ->and($request->body()->get('events'))->toHaveCount(2) 143 | ->and($request->body()->get('events')[0]['type'])->toBe('$page_view') 144 | ->and($request->body()->get('events')[0]['email'])->toBe('user1@example.com') 145 | ->and($request->body()->get('events')[1]['type'])->toBe('$form_submission') 146 | ->and($request->body()->get('events')[1]['email'])->toBe('user2@example.com'); 147 | }); 148 | 149 | it('handles events with empty fields and details', function (): void { 150 | $mockClient = new MockClient([ 151 | CreateEvents::class => MockResponse::make(body: [ 152 | 'results' => 1, 153 | 'failed' => 0, 154 | ], status: 200), 155 | ]); 156 | 157 | $connector = new BentoConnector; 158 | $connector->authenticate(new BasicAuthenticator('publish_key', 'secret_key')); 159 | $connector->withMockClient($mockClient); 160 | 161 | $data = collect([ 162 | new EventData( 163 | type: '$page_view', 164 | email: 'test@example.com', 165 | ), 166 | ]); 167 | 168 | $request = new CreateEvents($data); 169 | 170 | $response = $connector->send($request); 171 | 172 | expect($response->body())->toBeJson() 173 | ->and($response->status())->toBe(200) 174 | ->and($response->json('results'))->toBe(1) 175 | ->and($response->json('failed'))->toBe(0) 176 | ->and($request->body()->get('events'))->not()->toBeEmpty() 177 | ->and($request->body()->get('events')[0]['type'])->toBe('$page_view') 178 | ->and($request->body()->get('events')[0]['email'])->toBe('test@example.com') 179 | ->and($request->body()->get('events')[0]['fields'])->toBeNull() 180 | ->and($request->body()->get('events')[0]['details'])->toBeNull(); 181 | }); 182 | 183 | it('fails to create an event', function (): void { 184 | $mockClient = new MockClient([ 185 | CreateEvents::class => MockResponse::make(body: [ 186 | 'results' => 0, 187 | 'failed' => 1, 188 | ], status: 200), 189 | ]); 190 | 191 | $connector = new BentoConnector; 192 | $connector->authenticate(new BasicAuthenticator('publish_key', 'secret_key')); 193 | $connector->withMockClient($mockClient); 194 | 195 | $data = collect([ 196 | new EventData( 197 | type: '$completed_onboarding', 198 | email: 'test@example.com', 199 | fields: [ 200 | 'first_name' => 'John', 201 | 'last_name' => 'Doe', 202 | ], 203 | ), 204 | ]); 205 | 206 | $request = new CreateEvents($data); 207 | 208 | $response = $connector->send($request); 209 | 210 | expect($response->body())->toBeJson() 211 | ->and($response->status())->toBe(200) 212 | ->and($response->json('results'))->toBe(0) 213 | ->and($response->json('failed'))->toBe(1) 214 | ->and($request->body()->get('events'))->not()->toBeEmpty() 215 | ->and($request->body()->get('events')[0]['type'])->toBe('$completed_onboarding') 216 | ->and($request->body()->get('events')[0]['email'])->toBe('test@example.com') 217 | ->and($request->body()->get('events')[0]['fields'])->toBe([ 218 | 'first_name' => 'John', 219 | 'last_name' => 'Doe', 220 | ]); 221 | }); 222 | 223 | it('has an error when create an event', function (): void { 224 | $mockClient = new MockClient([ 225 | CreateEvents::class => MockResponse::make(body: [], status: 500), 226 | ]); 227 | 228 | $connector = new BentoConnector; 229 | $connector->authenticate(new BasicAuthenticator('publish_key', 'secret_key')); 230 | $connector->withMockClient($mockClient); 231 | 232 | $data = collect([ 233 | new EventData( 234 | type: '$completed_onboarding', 235 | email: 'test@example.com', 236 | fields: [ 237 | 'first_name' => 'John', 238 | 'last_name' => 'Doe', 239 | ], 240 | ), 241 | ]); 242 | 243 | $request = new CreateEvents($data); 244 | 245 | $response = $connector->send($request); 246 | 247 | expect($response->body())->toBeJson() 248 | ->and($response->status())->toBe(500) 249 | ->and($response->json('results'))->toBeEmpty() 250 | ->and($request->body()->get('events'))->not()->toBeEmpty() 251 | ->and($request->body()->get('events')[0]['type'])->toBe('$completed_onboarding') 252 | ->and($request->body()->get('events')[0]['email'])->toBe('test@example.com') 253 | ->and($request->body()->get('events')[0]['fields'])->toBe([ 254 | 'first_name' => 'John', 255 | 'last_name' => 'Doe', 256 | ]); 257 | })->throws(InternalServerErrorException::class); 258 | 259 | it('converts empty arrays to null in fields and details', function (): void { 260 | $mockClient = new MockClient([ 261 | CreateEvents::class => MockResponse::make(body: [ 262 | 'results' => 1, 263 | 'failed' => 0, 264 | ], status: 200), 265 | ]); 266 | 267 | $connector = new BentoConnector; 268 | $connector->authenticate(new BasicAuthenticator('publish_key', 'secret_key')); 269 | $connector->withMockClient($mockClient); 270 | 271 | $data = collect([ 272 | new EventData( 273 | type: '$page_view', 274 | email: 'test@example.com', 275 | fields: [], 276 | details: [], 277 | ), 278 | ]); 279 | 280 | $request = new CreateEvents($data); 281 | 282 | $response = $connector->send($request); 283 | 284 | expect($response->body())->toBeJson() 285 | ->and($response->status())->toBe(200) 286 | ->and($response->json('results'))->toBe(1) 287 | ->and($response->json('failed'))->toBe(0) 288 | ->and($request->body()->get('events'))->not()->toBeEmpty() 289 | ->and($request->body()->get('events')[0]['type'])->toBe('$page_view') 290 | ->and($request->body()->get('events')[0]['email'])->toBe('test@example.com') 291 | ->and($request->body()->get('events')[0]['fields'])->toBeNull() 292 | ->and($request->body()->get('events')[0]['details'])->toBeNull(); 293 | }); 294 | -------------------------------------------------------------------------------- /tests/Unit/CreateFieldTest.php: -------------------------------------------------------------------------------- 1 | MockResponse::make(body: [ 16 | 'data' => [ 17 | 'id' => 'abc123', 18 | 'type' => 'visitors-fields', 19 | 'attributes' => [ 20 | 'name' => 'First Name', 21 | 'key' => 'first_name', 22 | 'whitelisted' => null, 23 | ], 24 | ], 25 | ], status: 200), 26 | ]); 27 | 28 | $connector = new BentoConnector; 29 | $connector->authenticate(new BasicAuthenticator('publish_key', 'secret_key')); 30 | $connector->withMockClient($mockClient); 31 | 32 | $data = new CreateFieldData( 33 | key: 'First Name' 34 | ); 35 | 36 | $request = new CreateField($data); 37 | 38 | $response = $connector->send($request); 39 | 40 | expect($response->body())->toBeJson() 41 | ->and($response->status())->toBe(200) 42 | ->and($response->json('data')['id'])->toBe('abc123') 43 | ->and($response->json('data')['attributes']['key'])->toBe('first_name') 44 | ->and($response->json('data')['attributes']['name'])->toBe('First Name') 45 | ->and($request->body()->get('field'))->not()->toBeEmpty() 46 | ->and($request->body()->get('field')['key'])->toBe('First Name'); 47 | }); 48 | 49 | it('fails to create server error (500) a field', function (): void { 50 | $mockClient = new MockClient([ 51 | CreateField::class => MockResponse::make(body: [ 52 | 53 | ], status: 500), 54 | ]); 55 | 56 | $connector = new BentoConnector; 57 | $connector->authenticate(new BasicAuthenticator('publish_key', 'secret_key')); 58 | $connector->withMockClient($mockClient); 59 | 60 | $data = new CreateFieldData( 61 | key: 'First Name' 62 | ); 63 | 64 | $request = new CreateField($data); 65 | 66 | $response = $connector->send($request); 67 | 68 | expect($response->body())->toBeJson() 69 | ->and($response->status())->toBe(500) 70 | ->and($response->json('data'))->toBeEmpty() 71 | ->and($request->body()->get('field'))->not()->toBeEmpty() 72 | ->and($request->body()->get('field')->key)->toBe('first_name'); 73 | })->throws(InternalServerErrorException::class); 74 | -------------------------------------------------------------------------------- /tests/Unit/CreateSubscriberTest.php: -------------------------------------------------------------------------------- 1 | MockResponse::make(body: [ 16 | 'data' => [ 17 | 'id' => '12345', 18 | 'type' => 'visitors', 19 | 'attributes' => [ 20 | 'uuid' => '123-123-123-123', 21 | 'email' => 'test@example.com', 22 | 'fields' => [], 23 | 'cached_tag_ids' => [], 24 | ], 25 | ], 26 | ], status: 200), 27 | ]); 28 | 29 | $connector = new BentoConnector; 30 | $connector->authenticate(new BasicAuthenticator('publish_key', 'secret_key')); 31 | $connector->withMockClient($mockClient); 32 | 33 | $data = new CreateSubscriberData( 34 | email: 'test@example.com' 35 | ); 36 | 37 | $request = new CreateSubscriber($data); 38 | 39 | $response = $connector->send($request); 40 | 41 | expect($response->body())->toBeJson() 42 | ->and($response->status())->toBe(200) 43 | ->and($response->json('data')['id'])->toBe('12345') 44 | ->and($response->json('data')['attributes']['uuid'])->toBe('123-123-123-123') 45 | ->and($response->json('data')['attributes']['email'])->toBe('test@example.com') 46 | ->and($request->body()->get('subscriber'))->not()->toBeEmpty() 47 | ->and($request->body()->get('subscriber')['email'])->toBe('test@example.com'); 48 | }); 49 | 50 | it('fails to create a subscriber (500)', function (): void { 51 | $mockClient = new MockClient([ 52 | CreateSubscriber::class => MockResponse::make(body: [ 53 | ], status: 500), 54 | ]); 55 | 56 | $connector = new BentoConnector; 57 | $connector->authenticate(new BasicAuthenticator('publish_key', 'secret_key')); 58 | $connector->withMockClient($mockClient); 59 | 60 | $data = new CreateSubscriberData( 61 | email: 'test@example.com' 62 | ); 63 | 64 | $request = new CreateSubscriber($data); 65 | 66 | $response = $connector->send($request); 67 | 68 | expect($response->body())->toBeJson() 69 | ->and($response->status())->toBe(500) 70 | ->and($response->json('data'))->toBeEmpty(); 71 | })->throws(InternalServerErrorException::class); 72 | -------------------------------------------------------------------------------- /tests/Unit/CreateTagTest.php: -------------------------------------------------------------------------------- 1 | MockResponse::make(body: [ 16 | 'data' => [ 17 | 'id' => 'abc123', 18 | 'type' => 'tags', 19 | 'attributes' => [ 20 | 'name' => 'purchased', 21 | 'created_at' => '2024-08-06T05:44:04.444Z', 22 | 'discarded_at' => null, 23 | ], 24 | ], 25 | ], status: 200), 26 | ]); 27 | 28 | $connector = new BentoConnector; 29 | $connector->authenticate(new BasicAuthenticator('publish_key', 'secret_key')); 30 | $connector->withMockClient($mockClient); 31 | 32 | $data = new CreateTagData( 33 | name: 'purchased' 34 | ); 35 | 36 | $request = new CreateTag($data); 37 | 38 | $response = $connector->send($request); 39 | 40 | expect($response->body())->toBeJson() 41 | ->and($response->status())->toBe(200) 42 | ->and($response->json('data')['id'])->toBe('abc123') 43 | ->and($response->json('data')['attributes']['created_at'])->toBe('2024-08-06T05:44:04.444Z') 44 | ->and($response->json('data')['attributes']['name'])->toBe('purchased') 45 | ->and($request->body()->get('tag'))->not()->toBeEmpty() 46 | ->and($request->body()->get('tag')['name'])->toBe('purchased'); 47 | }); 48 | 49 | it('fails to create a Tag (500)', function (): void { 50 | $mockClient = new MockClient([ 51 | CreateTag::class => MockResponse::make(body: [], status: 500), 52 | ]); 53 | 54 | $connector = new BentoConnector; 55 | $connector->authenticate(new BasicAuthenticator('publish_key', 'secret_key')); 56 | $connector->withMockClient($mockClient); 57 | 58 | $data = new CreateTagData( 59 | name: 'purchased' 60 | ); 61 | 62 | $request = new CreateTag($data); 63 | 64 | $response = $connector->send($request); 65 | 66 | expect($response->body())->toBeJson() 67 | ->and($response->status())->toBe(500) 68 | ->and($response->json('data')['id'])->toBeEmpty(); 69 | })->throws(InternalServerErrorException::class); 70 | -------------------------------------------------------------------------------- /tests/Unit/FindSubscriberTest.php: -------------------------------------------------------------------------------- 1 | MockResponse::make(body: [ 15 | 'data' => [ 16 | 'id' => '12345', 17 | 'type' => 'visitors', 18 | 'attributes' => [ 19 | 'uuid' => '123-123-123-123', 20 | 'email' => 'test@example.com', 21 | 'fields' => [], 22 | 'cached_tag_ids' => [], 23 | ], 24 | ], 25 | ], status: 200), 26 | ]); 27 | 28 | $connector = new BentoConnector; 29 | $connector->authenticate(new BasicAuthenticator('publish_key', 'secret_key')); 30 | $connector->withMockClient($mockClient); 31 | 32 | $data = 'test@example.com'; 33 | 34 | $request = new FindSubscriber($data); 35 | 36 | $response = $connector->send($request); 37 | expect($response->body())->toBeJson() 38 | ->and($response->status())->toBe(200) 39 | ->and($response->json('data')['id'])->toBe('12345') 40 | ->and($response->json('data')['attributes']['uuid'])->toBe('123-123-123-123') 41 | ->and($response->json('data')['attributes']['email'])->toBe('test@example.com') 42 | ->and($request->query()->get('email'))->not()->toBeEmpty()->toBe('test@example.com'); 43 | }); 44 | 45 | it('fails to find a subscriber by email', function (): void { 46 | $mockClient = new MockClient([ 47 | FindSubscriber::class => MockResponse::make(body: [], status: 200), 48 | ]); 49 | 50 | $connector = new BentoConnector; 51 | $connector->authenticate(new BasicAuthenticator('publish_key', 'secret_key')); 52 | $connector->withMockClient($mockClient); 53 | 54 | $data = 'test@example.com'; 55 | 56 | $request = new FindSubscriber($data); 57 | 58 | $response = $connector->send($request); 59 | expect($response->body())->toBeJson() 60 | ->and($response->status())->toBe(200) 61 | ->and($response->json('data'))->toBeEmpty(); 62 | }); 63 | 64 | it('fails to find a subscriber by email (500)', function (): void { 65 | $mockClient = new MockClient([ 66 | FindSubscriber::class => MockResponse::make(body: [ 67 | 68 | ], status: 500), 69 | ]); 70 | 71 | $connector = new BentoConnector; 72 | $connector->authenticate(new BasicAuthenticator('publish_key', 'secret_key')); 73 | $connector->withMockClient($mockClient); 74 | 75 | $data = 'test@example.com'; 76 | 77 | $request = new FindSubscriber($data); 78 | 79 | $response = $connector->send($request); 80 | expect($response->body())->toBeJson() 81 | ->and($response->status())->toBe(500) 82 | ->and($response->json('data'))->toBeEmpty(); 83 | })->throws(InternalServerErrorException::class); 84 | -------------------------------------------------------------------------------- /tests/Unit/GeolocateIpTest.php: -------------------------------------------------------------------------------- 1 | MockResponse::make(body: [ 15 | 'data' => [ 16 | [ 17 | 'request' => '1.1.1.1', 18 | 'ip' => '1.1.1.1', 19 | 'country_code2' => 'JP', 20 | 'country_code3' => 'JPN', 21 | 'country_name' => 'Japan', 22 | 'continent_code' => 'AS', 23 | 'region_name' => '42', 24 | 'city_name' => 'Tokyo', 25 | 'postal_code' => '206-0000', 26 | 'latitude' => 35.6895, 27 | 'longitude' => 139.69171, 28 | 'dma_code' => null, 29 | 'area_code' => null, 30 | 'timezone' => 'Asia/Tokyo', 31 | 'real_region_name' => 'Tokyo', 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 | $data = new GeoLocateIpData('1.1.1.1'); 43 | 44 | $request = new GeoLocateIP($data); 45 | 46 | $response = $connector->send($request); 47 | expect($response->body())->toBeJson() 48 | ->and($response->status())->toBe(200) 49 | ->and($response->json('data')[0]['ip'])->toBe('1.1.1.1') 50 | ->and($response->json('data')[0]['country_code2'])->toBe('JP') 51 | ->and($response->json('data')[0]['country_code3'])->toBe('JPN') 52 | ->and($response->json('data')[0]['country_name'])->toBe('Japan') 53 | ->and($response->json('data')[0]['continent_code'])->toBe('AS') 54 | ->and($response->json('data')[0]['region_name'])->toBe('42') 55 | ->and($response->json('data')[0]['city_name'])->toBe('Tokyo') 56 | ->and($response->json('data')[0]['postal_code'])->toBe('206-0000') 57 | ->and($response->json('data')[0]['latitude'])->toBe(35.6895) 58 | ->and($response->json('data')[0]['longitude'])->toBe(139.69171) 59 | ->and($response->json('data')[0]['dma_code'])->toBeNull() 60 | ->and($response->json('data')[0]['area_code'])->toBeNull() 61 | ->and($response->json('data')[0]['timezone'])->toBe('Asia/Tokyo') 62 | ->and($response->json('data')[0]['real_region_name'])->toBe('Tokyo') 63 | ->and($request->query()->get('ip'))->not()->toBeEmpty()->toBe('1.1.1.1'); 64 | }); 65 | -------------------------------------------------------------------------------- /tests/Unit/GetBlackListStatusTest.php: -------------------------------------------------------------------------------- 1 | MockResponse::make(body: [ 13 | 'data' => [ 14 | 'query' => '1.1.1.1', 15 | 'description' => 'If any of the following blacklist providers contains true you have a problem on your hand.', 16 | 'results' => false, 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 BlacklistStatusData(domain: null, ipAddress: '1.1.1.1'); 26 | 27 | $request = new GetBlacklistStatus($data); 28 | 29 | $response = $connector->send($request); 30 | expect($response->body())->toBeJson() 31 | ->and($response->status())->toBe(200) 32 | ->and($response->json('data')['query'])->toBe('1.1.1.1') 33 | ->and($response->json('data')['description'])->toBe('If any of the following blacklist providers contains true you have a problem on your hand.') 34 | ->and($response->json('data')['results'])->toBe(false) 35 | ->and($request->query()->get('ip'))->not()->toBeEmpty()->toBe('1.1.1.1'); 36 | }); 37 | 38 | it('can check blacklist by domain', function () { 39 | $mockClient = new MockClient([ 40 | GetBlacklistStatus::class => MockResponse::make(body: [ 41 | 'data' => [ 42 | 'query' => 'bentonow.com', 43 | 'description' => 'If any of the following blacklist providers contains true you have a problem on your hand.', 44 | 'results' => false, 45 | ], 46 | ], status: 200), 47 | ]); 48 | 49 | $connector = new BentoConnector; 50 | $connector->authenticate(new BasicAuthenticator('publish_key', 'secret_key')); 51 | $connector->withMockClient($mockClient); 52 | 53 | $data = new BlacklistStatusData(domain: 'bentonow.com', ipAddress: null); 54 | 55 | $request = new GetBlacklistStatus($data); 56 | 57 | $response = $connector->send($request); 58 | expect($response->body())->toBeJson() 59 | ->and($response->status())->toBe(200) 60 | ->and($response->json('data')['query'])->toBe('bentonow.com') 61 | ->and($response->json('data')['description'])->toBe('If any of the following blacklist providers contains true you have a problem on your hand.') 62 | ->and($response->json('data')['results'])->toBe(false) 63 | ->and($request->query()->get('domain'))->not()->toBeEmpty()->toBe('bentonow.com'); 64 | }); 65 | 66 | it('can not check blacklist', function () { 67 | $mockClient = new MockClient([ 68 | GetBlacklistStatus::class => MockResponse::make(body: [ 69 | 'data' => [ 70 | 'query' => null, 71 | 'description' => 'If any of the following blacklist providers contains true you have a problem on your hand.', 72 | 'results' => [ 73 | 'result' => 'Please provide an IP or clean domain (google.com).', 74 | ], 75 | ], 76 | ], status: 200), 77 | ]); 78 | 79 | $connector = new BentoConnector; 80 | $connector->authenticate(new BasicAuthenticator('publish_key', 'secret_key')); 81 | $connector->withMockClient($mockClient); 82 | 83 | $data = new BlacklistStatusData(domain: null, ipAddress: null); 84 | 85 | $request = new GetBlacklistStatus($data); 86 | 87 | $response = $connector->send($request); 88 | expect($response->body())->toBeJson() 89 | ->and($response->status())->toBe(200) 90 | ->and($response->json('data')['query'])->toBe(null) 91 | ->and($response->json('data')['description'])->toBe('If any of the following blacklist providers contains true you have a problem on your hand.') 92 | ->and($response->json('data')['results']['result'])->toBe('Please provide an IP or clean domain (google.com).') 93 | ->and($request->query()->get('domain'))->toBeNull() 94 | ->and($request->query()->get('ip'))->toBeNull(); 95 | 96 | }); 97 | -------------------------------------------------------------------------------- /tests/Unit/GetBroadcastsTest.php: -------------------------------------------------------------------------------- 1 | MockResponse::make(body: [ 13 | 'data' => [ 14 | [ 15 | 'id' => '1234', 16 | 'type' => 'visitors-fields', 17 | 'attributes' => [ 18 | 'name' => 'broadcast 1', 19 | 'share_url' => 'https://example.com/broadcast/1234', 20 | 'template' => [ 21 | 'subject' => 'Test Broadcast', 22 | 'to' => 'test@example.com', 23 | 'html' => '

Test Broadcast

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' => '

Test Broadcast 2

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 | --------------------------------------------------------------------------------