Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d8d6b1b22f | ||
|
|
0d4eab2016 |
@@ -592,6 +592,11 @@ curl -X POST https://yourdomain.com/api/short-url/links \
|
||||
| `pixel_google_id` | string | ❌ | Google Tag / GA4 ID |
|
||||
| `pixel_linkedin_id` | string | ❌ | LinkedIn Partner ID |
|
||||
| `webhook_url` | string (URL) | ❌ | Per-link webhook endpoint |
|
||||
| `targeting_rules` | array | ❌ | JSON targeting rules (device, geo, rotation, language) |
|
||||
| `password` | string | ❌ | Access protection password |
|
||||
| `show_warning_page` | boolean | ❌ | Show safety warning page before redirect |
|
||||
| `track_visits` | boolean | ❌ | Track visitor clicks and logs |
|
||||
| `track_browser_language` | boolean | ❌ | Track visitor browser language locale |
|
||||
|
||||
**Response:** `201 Created` with the created link object.
|
||||
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
// 1. Create short_url_pixels table
|
||||
Schema::create('short_url_pixels', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->string('name', 150);
|
||||
$table->string('type', 50); // meta, google, linkedin, tiktok, pinterest
|
||||
$table->string('pixel_id', 100);
|
||||
$table->boolean('is_active')->default(true);
|
||||
$table->timestamps();
|
||||
|
||||
$table->unique(['type', 'pixel_id']);
|
||||
});
|
||||
|
||||
// 2. Create short_url_pixel pivot table
|
||||
Schema::create('short_url_pixel', function (Blueprint $table) {
|
||||
$table->foreignId('short_url_id')->constrained('short_urls')->cascadeOnDelete();
|
||||
$table->foreignId('pixel_id')->constrained('short_url_pixels')->cascadeOnDelete();
|
||||
$table->primary(['short_url_id', 'pixel_id']);
|
||||
});
|
||||
|
||||
// 3. Data migration: Migrate existing pixel IDs from short_urls to short_url_pixels and pivot
|
||||
if (Schema::hasColumn('short_urls', 'pixel_meta_id')) {
|
||||
$oldUrls = DB::table('short_urls')
|
||||
->whereNotNull('pixel_meta_id')
|
||||
->orWhereNotNull('pixel_google_id')
|
||||
->orWhereNotNull('pixel_linkedin_id')
|
||||
->get();
|
||||
|
||||
foreach ($oldUrls as $url) {
|
||||
// Migrate Meta Pixel
|
||||
if (! empty($url->pixel_meta_id)) {
|
||||
$pixelId = DB::table('short_url_pixels')
|
||||
->where('type', 'meta')
|
||||
->where('pixel_id', $url->pixel_meta_id)
|
||||
->value('id');
|
||||
|
||||
if (! $pixelId) {
|
||||
$pixelId = DB::table('short_url_pixels')->insertGetId([
|
||||
'name' => 'Meta Pixel ('.$url->pixel_meta_id.')',
|
||||
'type' => 'meta',
|
||||
'pixel_id' => $url->pixel_meta_id,
|
||||
'is_active' => true,
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
}
|
||||
|
||||
DB::table('short_url_pixel')->insertOrIgnore([
|
||||
'short_url_id' => $url->id,
|
||||
'pixel_id' => $pixelId,
|
||||
]);
|
||||
}
|
||||
|
||||
// Migrate Google Tag / GA4
|
||||
if (! empty($url->pixel_google_id)) {
|
||||
$pixelId = DB::table('short_url_pixels')
|
||||
->where('type', 'google')
|
||||
->where('pixel_id', $url->pixel_google_id)
|
||||
->value('id');
|
||||
|
||||
if (! $pixelId) {
|
||||
$pixelId = DB::table('short_url_pixels')->insertGetId([
|
||||
'name' => 'Google Tag ('.$url->pixel_google_id.')',
|
||||
'type' => 'google',
|
||||
'pixel_id' => $url->pixel_google_id,
|
||||
'is_active' => true,
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
}
|
||||
|
||||
DB::table('short_url_pixel')->insertOrIgnore([
|
||||
'short_url_id' => $url->id,
|
||||
'pixel_id' => $pixelId,
|
||||
]);
|
||||
}
|
||||
|
||||
// Migrate LinkedIn Insight
|
||||
if (! empty($url->pixel_linkedin_id)) {
|
||||
$pixelId = DB::table('short_url_pixels')
|
||||
->where('type', 'linkedin')
|
||||
->where('pixel_id', $url->pixel_linkedin_id)
|
||||
->value('id');
|
||||
|
||||
if (! $pixelId) {
|
||||
$pixelId = DB::table('short_url_pixels')->insertGetId([
|
||||
'name' => 'LinkedIn Insight ('.$url->pixel_linkedin_id.')',
|
||||
'type' => 'linkedin',
|
||||
'pixel_id' => $url->pixel_linkedin_id,
|
||||
'is_active' => true,
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
}
|
||||
|
||||
DB::table('short_url_pixel')->insertOrIgnore([
|
||||
'short_url_id' => $url->id,
|
||||
'pixel_id' => $pixelId,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Drop the old columns
|
||||
Schema::table('short_urls', function (Blueprint $table) {
|
||||
$table->dropColumn(['pixel_meta_id', 'pixel_google_id', 'pixel_linkedin_id']);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
// 1. Re-add the columns
|
||||
Schema::table('short_urls', function (Blueprint $table) {
|
||||
$table->string('pixel_meta_id', 100)->nullable();
|
||||
$table->string('pixel_google_id', 100)->nullable();
|
||||
$table->string('pixel_linkedin_id', 100)->nullable();
|
||||
});
|
||||
|
||||
// 2. Re-populate the old columns from pivot data
|
||||
$associations = DB::table('short_url_pixel')
|
||||
->join('short_url_pixels', 'short_url_pixel.pixel_id', '=', 'short_url_pixels.id')
|
||||
->select('short_url_pixel.short_url_id', 'short_url_pixels.type', 'short_url_pixels.pixel_id')
|
||||
->get();
|
||||
|
||||
foreach ($associations as $assoc) {
|
||||
if ($assoc->type === 'meta') {
|
||||
DB::table('short_urls')->where('id', $assoc->short_url_id)->update(['pixel_meta_id' => $assoc->pixel_id]);
|
||||
} elseif ($assoc->type === 'google') {
|
||||
DB::table('short_urls')->where('id', $assoc->short_url_id)->update(['pixel_google_id' => $assoc->pixel_id]);
|
||||
} elseif ($assoc->type === 'linkedin') {
|
||||
DB::table('short_urls')->where('id', $assoc->short_url_id)->update(['pixel_linkedin_id' => $assoc->pixel_id]);
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Drop tables
|
||||
Schema::dropIfExists('short_url_pixel');
|
||||
Schema::dropIfExists('short_url_pixels');
|
||||
}
|
||||
};
|
||||
@@ -460,4 +460,10 @@ return [
|
||||
'badge_expires' => 'Expires: :date',
|
||||
'badge_no_expiry' => 'No expiry',
|
||||
'badge_redirect' => ':code redirect',
|
||||
'pixels_navigation_label' => 'Retargeting Pixels',
|
||||
'pixel_resource_title' => 'Retargeting Pixel',
|
||||
'pixel_name' => 'Pixel Name',
|
||||
'pixel_type' => 'Provider',
|
||||
'pixel_id_label' => 'Pixel ID / Tag ID',
|
||||
'pixel_status_active' => 'Active',
|
||||
];
|
||||
|
||||
@@ -461,4 +461,10 @@ return [
|
||||
'badge_expires' => 'Wygasa: :date',
|
||||
'badge_no_expiry' => 'Bez wygasania',
|
||||
'badge_redirect' => 'Przekierowanie :code',
|
||||
'pixels_navigation_label' => 'Piksele retargetingowe',
|
||||
'pixel_resource_title' => 'Piksel retargetingowy',
|
||||
'pixel_name' => 'Nazwa piksela',
|
||||
'pixel_type' => 'Dostawca',
|
||||
'pixel_id_label' => 'ID Piksela / Tagu',
|
||||
'pixel_status_active' => 'Aktywny',
|
||||
];
|
||||
|
||||
@@ -33,8 +33,16 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
@php
|
||||
$pixelMetaIds = $pixels->where('type', 'meta')->pluck('pixel_id');
|
||||
$pixelGoogleIds = $pixels->where('type', 'google')->pluck('pixel_id');
|
||||
$pixelLinkedinIds = $pixels->where('type', 'linkedin')->pluck('pixel_id');
|
||||
$pixelTiktokIds = $pixels->where('type', 'tiktok')->pluck('pixel_id');
|
||||
$pixelPinterestIds = $pixels->where('type', 'pinterest')->pluck('pixel_id');
|
||||
@endphp
|
||||
|
||||
<!-- Meta / Facebook Pixel -->
|
||||
@if(!empty($pixelMetaId))
|
||||
@if($pixelMetaIds->isNotEmpty())
|
||||
<script>
|
||||
!function(f,b,e,v,n,t,s)
|
||||
{if(f.fbq)return;n=f.fbq=function(){n.callMethod?
|
||||
@@ -44,29 +52,37 @@
|
||||
t.src=v;s=b.getElementsByTagName(e)[0];
|
||||
s.parentNode.insertBefore(t,s)}(window, document,'script',
|
||||
'https://connect.facebook.net/en_US/fbevents.js');
|
||||
fbq('init', '{{ $pixelMetaId }}');
|
||||
@foreach($pixelMetaIds as $id)
|
||||
fbq('init', '{{ $id }}');
|
||||
fbq('track', 'PageView');
|
||||
@endforeach
|
||||
</script>
|
||||
<noscript><img height="1" width="1" style="display:none" src="https://www.facebook.com/tr?id={{ $pixelMetaId }}&ev=PageView&noscript=1" /></noscript>
|
||||
@foreach($pixelMetaIds as $id)
|
||||
<noscript><img height="1" width="1" style="display:none" src="https://www.facebook.com/tr?id={{ $id }}&ev=PageView&noscript=1" /></noscript>
|
||||
@endforeach
|
||||
@endif
|
||||
|
||||
<!-- Google Analytics / GTM -->
|
||||
@if(!empty($pixelGoogleId))
|
||||
<script async src="https://www.googletagmanager.com/gtag/js?id={{ $pixelGoogleId }}"></script>
|
||||
@if($pixelGoogleIds->isNotEmpty())
|
||||
@php $firstGoogleId = $pixelGoogleIds->first(); @endphp
|
||||
<script async src="https://www.googletagmanager.com/gtag/js?id={{ $firstGoogleId }}"></script>
|
||||
<script>
|
||||
window.dataLayer = window.dataLayer || [];
|
||||
function gtag(){dataLayer.push(arguments);}
|
||||
gtag('js', new Date());
|
||||
gtag('config', '{{ $pixelGoogleId }}');
|
||||
@foreach($pixelGoogleIds as $id)
|
||||
gtag('config', '{{ $id }}');
|
||||
@endforeach
|
||||
</script>
|
||||
@endif
|
||||
|
||||
<!-- LinkedIn Insight -->
|
||||
@if(!empty($pixelLinkedinId))
|
||||
@if($pixelLinkedinIds->isNotEmpty())
|
||||
<script type="text/javascript">
|
||||
_linkedin_data_partner_id = "{{ $pixelLinkedinId }}";
|
||||
window._linkedin_data_partner_ids = window._linkedin_data_partner_ids || [];
|
||||
window._linkedin_data_partner_ids.push(_linkedin_data_partner_id);
|
||||
@foreach($pixelLinkedinIds as $id)
|
||||
window._linkedin_data_partner_ids.push("{{ $id }}");
|
||||
@endforeach
|
||||
(function(l) {
|
||||
if (!l){window.lintrk = function(a,b){window.lintrk.q.push([a,b])};
|
||||
window.lintrk.q=[]}
|
||||
@@ -76,7 +92,36 @@
|
||||
b.src = "https://snap.licdn.com/li.lms-analytics/insight.min.js";
|
||||
s.parentNode.insertBefore(b, s);})(window.lintrk);
|
||||
</script>
|
||||
<noscript><img height="1" width="1" style="display:none;" alt="" src="https://px.ads.linkedin.com/collect/?pid={{ $pixelLinkedinId }}&fmt=gif" /></noscript>
|
||||
@foreach($pixelLinkedinIds as $id)
|
||||
<noscript><img height="1" width="1" style="display:none;" alt="" src="https://px.ads.linkedin.com/collect/?pid={{ $id }}&fmt=gif" /></noscript>
|
||||
@endforeach
|
||||
@endif
|
||||
|
||||
<!-- TikTok Pixel -->
|
||||
@if($pixelTiktokIds->isNotEmpty())
|
||||
<script>
|
||||
!function (w, d, t) {
|
||||
w.TiktokAnalyticsObject=t;var ttq=w[t]=w[t]||[];ttq.methods=["page","track","identify","instances","debug","on","off","once","ready","alias","group","enableCookie","disableCookie","holdConsent","revokeConsent","grantConsent"],ttq.setAndDefer=function(t,e){t[e]=function(){t.push([e].concat(Array.prototype.slice.call(arguments,0)))}};for(var i=0;i<ttq.methods.length;i++)ttq.setAndDefer(ttq,ttq.methods[i]);ttq.instance=function(t){for(var e=ttq._i[t]||[],n=0;n<ttq.methods.length;n++)ttq.setAndDefer(e,ttq.methods[n]);return e},ttq.load=function(e,n){var r="https://analytics.tiktok.com/i18n/pixel/events.js",o=n&&n.mixpanel;ttq._i=ttq._i||{},ttq._i[e]=[],ttq._i[e]._u=r,ttq._t=ttq._t||{},ttq._t[e]=+new Date,ttq._o=ttq._o||{},ttq._o[e]=o||{};var a=d.createElement("script");a.type="text/javascript",a.async=!0,a.src=r+"?sdkid="+e+"&lib="+t;var c=d.getElementsByTagName("script")[0];c.parentNode.insertBefore(a,c)};
|
||||
@foreach($pixelTiktokIds as $id)
|
||||
ttq.load('{{ $id }}');
|
||||
ttq.page();
|
||||
@endforeach
|
||||
}(window, document, 'ttq');
|
||||
</script>
|
||||
@endif
|
||||
|
||||
<!-- Pinterest Tag -->
|
||||
@if($pixelPinterestIds->isNotEmpty())
|
||||
<script>
|
||||
!function(e,n,t,r,a,s,o){e[r]||(e[r]=function(){(e[r].q=e[r].q||[]).push(arguments)},e[r].q=e[r].q||[],s=n.createElement(t),s.async=!0,s.src="https://s.pntrac.com/tag.js",o=n.getElementsByTagName(t)[0],o.parentNode.insertBefore(s,o))}(window,document,"script","pintrk");
|
||||
@foreach($pixelPinterestIds as $id)
|
||||
pintrk('load', '{{ $id }}');
|
||||
pintrk('page');
|
||||
@endforeach
|
||||
</script>
|
||||
@foreach($pixelPinterestIds as $id)
|
||||
<noscript><img height="1" width="1" style="display:none;" alt="" src="https://ct.pinterest.com/v3/?event=init&tid={{ $id }}&noscript=1" /></noscript>
|
||||
@endforeach
|
||||
@endif
|
||||
</head>
|
||||
<body class="bg-[#FCFCFC] dark:bg-[#0C0C0C] min-h-screen flex flex-col justify-between items-center py-10 px-6 font-sans antialiased">
|
||||
|
||||
152
src/Filament/Resources/ShortUrlPixelResource.php
Normal file
152
src/Filament/Resources/ShortUrlPixelResource.php
Normal file
@@ -0,0 +1,152 @@
|
||||
<?php
|
||||
|
||||
namespace Bjanczak\FilamentShortUrl\Filament\Resources;
|
||||
|
||||
use Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlPixelResource\Pages\ListShortUrlPixels;
|
||||
use Bjanczak\FilamentShortUrl\Models\ShortUrlPixel;
|
||||
use Filament\Forms\Components\Select;
|
||||
use Filament\Forms\Components\TextInput;
|
||||
use Filament\Forms\Components\Toggle;
|
||||
use Filament\Resources\Resource;
|
||||
use Filament\Schemas\Schema;
|
||||
use Filament\Tables\Actions\DeleteAction;
|
||||
use Filament\Tables\Actions\EditAction;
|
||||
use Filament\Tables\Columns\IconColumn;
|
||||
use Filament\Tables\Columns\TextColumn;
|
||||
use Filament\Tables\Columns\ToggleColumn;
|
||||
use Filament\Tables\Table;
|
||||
|
||||
class ShortUrlPixelResource extends Resource
|
||||
{
|
||||
protected static ?string $model = ShortUrlPixel::class;
|
||||
|
||||
protected static string|\BackedEnum|null $navigationIcon = 'heroicon-o-funnel';
|
||||
|
||||
protected static ?int $navigationSort = 51;
|
||||
|
||||
protected static ?string $recordTitleAttribute = 'name';
|
||||
|
||||
public static function getNavigationLabel(): string
|
||||
{
|
||||
return __('filament-short-url::default.pixels_navigation_label') ?? 'Retargeting Pixels';
|
||||
}
|
||||
|
||||
public static function getModelLabel(): string
|
||||
{
|
||||
return __('filament-short-url::default.pixel_resource_title') ?? 'Retargeting Pixel';
|
||||
}
|
||||
|
||||
public static function getPluralModelLabel(): string
|
||||
{
|
||||
return __('filament-short-url::default.pixels_navigation_label') ?? 'Retargeting Pixels';
|
||||
}
|
||||
|
||||
public static function getNavigationGroup(): string|\UnitEnum|null
|
||||
{
|
||||
try {
|
||||
return ShortUrlResource::getNavigationGroup();
|
||||
} catch (\Throwable) {
|
||||
return __('filament-short-url::default.navigation_group');
|
||||
}
|
||||
}
|
||||
|
||||
public static function getNavigationSort(): ?int
|
||||
{
|
||||
try {
|
||||
return ShortUrlResource::getNavigationSort() + 1;
|
||||
} catch (\Throwable) {
|
||||
return static::$navigationSort;
|
||||
}
|
||||
}
|
||||
|
||||
public static function form(Schema $schema): Schema
|
||||
{
|
||||
return $schema
|
||||
->components([
|
||||
TextInput::make('name')
|
||||
->label(__('filament-short-url::default.pixel_name') ?? 'Pixel Name')
|
||||
->required()
|
||||
->maxLength(150)
|
||||
->placeholder('e.g. Meta Ads - Yacht Promo'),
|
||||
|
||||
Select::make('type')
|
||||
->label(__('filament-short-url::default.pixel_type') ?? 'Provider')
|
||||
->options([
|
||||
'meta' => 'Meta / Facebook Pixel',
|
||||
'google' => 'Google Tag (GA4 / GTM)',
|
||||
'linkedin' => 'LinkedIn Insight Tag',
|
||||
'tiktok' => 'TikTok Pixel',
|
||||
'pinterest' => 'Pinterest Tag',
|
||||
])
|
||||
->required()
|
||||
->native(false),
|
||||
|
||||
TextInput::make('pixel_id')
|
||||
->label(__('filament-short-url::default.pixel_id_label') ?? 'Pixel ID / Tag ID')
|
||||
->required()
|
||||
->maxLength(100)
|
||||
->placeholder('e.g. 1234567890 or G-XXXXXXXXXX'),
|
||||
|
||||
Toggle::make('is_active')
|
||||
->label(__('filament-short-url::default.pixel_status_active') ?? 'Active')
|
||||
->default(true),
|
||||
])
|
||||
->columns(1);
|
||||
}
|
||||
|
||||
public static function table(Table $table): Table
|
||||
{
|
||||
return $table
|
||||
->columns([
|
||||
TextColumn::make('name')
|
||||
->label(__('filament-short-url::default.pixel_name') ?? 'Pixel Name')
|
||||
->searchable()
|
||||
->sortable()
|
||||
->weight('bold'),
|
||||
|
||||
TextColumn::make('type')
|
||||
->label(__('filament-short-url::default.pixel_type') ?? 'Provider')
|
||||
->badge()
|
||||
->formatStateUsing(fn (string $state): string => match ($state) {
|
||||
'meta' => 'Meta',
|
||||
'google' => 'Google',
|
||||
'linkedin' => 'LinkedIn',
|
||||
'tiktok' => 'TikTok',
|
||||
'pinterest' => 'Pinterest',
|
||||
default => ucfirst($state),
|
||||
})
|
||||
->color(fn (string $state): string => match ($state) {
|
||||
'meta' => 'info',
|
||||
'google' => 'warning',
|
||||
'linkedin' => 'primary',
|
||||
'tiktok' => 'gray',
|
||||
'pinterest' => 'danger',
|
||||
default => 'gray',
|
||||
}),
|
||||
|
||||
TextColumn::make('pixel_id')
|
||||
->label(__('filament-short-url::default.pixel_id_label') ?? 'ID')
|
||||
->searchable()
|
||||
->copyable()
|
||||
->fontFamily('mono')
|
||||
->color('gray'),
|
||||
|
||||
ToggleColumn::make('is_active')
|
||||
->label(__('filament-short-url::default.pixel_status_active') ?? 'Active')
|
||||
->sortable(),
|
||||
])
|
||||
->filters([])
|
||||
->actions([
|
||||
EditAction::make()->modalWidth('md'),
|
||||
DeleteAction::make(),
|
||||
])
|
||||
->bulkActions([]);
|
||||
}
|
||||
|
||||
public static function getPages(): array
|
||||
{
|
||||
return [
|
||||
'index' => ListShortUrlPixels::route('/'),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
<?php
|
||||
|
||||
namespace Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlPixelResource\Pages;
|
||||
|
||||
use Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlPixelResource;
|
||||
use Filament\Actions\CreateAction;
|
||||
use Filament\Resources\Pages\ManageRecords;
|
||||
|
||||
class ListShortUrlPixels extends ManageRecords
|
||||
{
|
||||
protected static string $resource = ShortUrlPixelResource::class;
|
||||
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return [
|
||||
CreateAction::make()
|
||||
->icon('heroicon-o-plus')
|
||||
->size('sm')
|
||||
->color('primary')
|
||||
->modalWidth('md'),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -547,22 +547,14 @@ class ShortUrlForm
|
||||
Section::make(__('filament-short-url::default.marketing_pixels_title'))
|
||||
->description(__('filament-short-url::default.marketing_pixels_desc'))
|
||||
->schema([
|
||||
TextInput::make('pixel_meta_id')
|
||||
->label(__('filament-short-url::default.pixel_meta'))
|
||||
->placeholder('e.g., 1234567890')
|
||||
->maxLength(100)
|
||||
->nullable(),
|
||||
TextInput::make('pixel_google_id')
|
||||
->label(__('filament-short-url::default.pixel_google'))
|
||||
->placeholder('e.g., G-XXXXXXXXXX or AW-XXXXXXXXXX')
|
||||
->maxLength(100)
|
||||
->nullable(),
|
||||
TextInput::make('pixel_linkedin_id')
|
||||
->label(__('filament-short-url::default.pixel_linkedin'))
|
||||
->placeholder('e.g., 1234567')
|
||||
->maxLength(100)
|
||||
->nullable(),
|
||||
])->columns(3),
|
||||
Select::make('pixels')
|
||||
->label(__('filament-short-url::default.pixels_navigation_label') ?? 'Retargeting Pixels')
|
||||
->multiple()
|
||||
->relationship('pixels', 'name', modifyQueryUsing: fn ($query) => $query->where('is_active', true))
|
||||
->preload()
|
||||
->searchable()
|
||||
->columnSpanFull(),
|
||||
]),
|
||||
|
||||
Section::make(__('filament-short-url::default.marketing_webhooks_title'))
|
||||
->description(__('filament-short-url::default.marketing_webhooks_desc'))
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace Bjanczak\FilamentShortUrl;
|
||||
|
||||
use Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlPixelResource;
|
||||
use Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource;
|
||||
use Filament\Contracts\Plugin;
|
||||
use Filament\Panel;
|
||||
@@ -44,6 +45,7 @@ class FilamentShortUrlPlugin implements Plugin
|
||||
$panel
|
||||
->resources([
|
||||
ShortUrlResource::class,
|
||||
ShortUrlPixelResource::class,
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
@@ -57,14 +57,65 @@ class ShortUrlApiController extends Controller
|
||||
'expiration_redirect_url' => 'nullable|url|max:2048',
|
||||
'activated_at' => 'nullable|date',
|
||||
'expires_at' => 'nullable|date',
|
||||
'webhook_url' => 'nullable|url|max:2048',
|
||||
'targeting_rules' => 'nullable|array',
|
||||
'password' => 'nullable|string|max:255',
|
||||
'show_warning_page' => 'nullable|boolean',
|
||||
'track_visits' => 'nullable|boolean',
|
||||
'track_browser_language' => 'nullable|boolean',
|
||||
'pixels' => 'nullable|array',
|
||||
'pixels.*' => 'integer|exists:short_url_pixels,id',
|
||||
// Keep for backward compatibility
|
||||
'pixel_meta_id' => 'nullable|string|max:100',
|
||||
'pixel_google_id' => 'nullable|string|max:100',
|
||||
'pixel_linkedin_id' => 'nullable|string|max:100',
|
||||
'webhook_url' => 'nullable|url|max:2048',
|
||||
]);
|
||||
|
||||
$pixelIds = $validated['pixels'] ?? [];
|
||||
|
||||
// Backward compatibility support for old pixel columns
|
||||
if (! empty($validated['pixel_meta_id'])) {
|
||||
$metaPixelId = \Bjanczak\FilamentShortUrl\Models\ShortUrlPixel::firstOrCreate([
|
||||
'type' => 'meta',
|
||||
'pixel_id' => $validated['pixel_meta_id'],
|
||||
], [
|
||||
'name' => 'Meta Pixel (' . $validated['pixel_meta_id'] . ')',
|
||||
'is_active' => true,
|
||||
])->id;
|
||||
$pixelIds[] = $metaPixelId;
|
||||
}
|
||||
|
||||
if (! empty($validated['pixel_google_id'])) {
|
||||
$googlePixelId = \Bjanczak\FilamentShortUrl\Models\ShortUrlPixel::firstOrCreate([
|
||||
'type' => 'google',
|
||||
'pixel_id' => $validated['pixel_google_id'],
|
||||
], [
|
||||
'name' => 'Google Tag (' . $validated['pixel_google_id'] . ')',
|
||||
'is_active' => true,
|
||||
])->id;
|
||||
$pixelIds[] = $googlePixelId;
|
||||
}
|
||||
|
||||
if (! empty($validated['pixel_linkedin_id'])) {
|
||||
$linkedinPixelId = \Bjanczak\FilamentShortUrl\Models\ShortUrlPixel::firstOrCreate([
|
||||
'type' => 'linkedin',
|
||||
'pixel_id' => $validated['pixel_linkedin_id'],
|
||||
], [
|
||||
'name' => 'LinkedIn Insight (' . $validated['pixel_linkedin_id'] . ')',
|
||||
'is_active' => true,
|
||||
])->id;
|
||||
$pixelIds[] = $linkedinPixelId;
|
||||
}
|
||||
|
||||
// Clean up parameters that shouldn't be mass assigned
|
||||
unset($validated['pixel_meta_id'], $validated['pixel_google_id'], $validated['pixel_linkedin_id'], $validated['pixels']);
|
||||
|
||||
$shortUrl = $this->service->create($validated);
|
||||
|
||||
if (! empty($pixelIds)) {
|
||||
$shortUrl->pixels()->sync($pixelIds);
|
||||
}
|
||||
|
||||
// Fire 'created' webhook if active
|
||||
$this->dispatchCreatedWebhook($shortUrl);
|
||||
|
||||
@@ -92,6 +143,8 @@ class ShortUrlApiController extends Controller
|
||||
*/
|
||||
private function transformLink(ShortUrl $link): array
|
||||
{
|
||||
$pixels = $link->relationLoaded('pixels') ? $link->pixels : $link->pixels()->get();
|
||||
|
||||
return [
|
||||
'id' => $link->id,
|
||||
'destination_url' => $link->destination_url,
|
||||
@@ -104,10 +157,22 @@ class ShortUrlApiController extends Controller
|
||||
'max_visits' => $link->max_visits ? (int) $link->max_visits : null,
|
||||
'activated_at' => $link->activated_at ? $link->activated_at->toIso8601String() : null,
|
||||
'expires_at' => $link->expires_at ? $link->expires_at->toIso8601String() : null,
|
||||
'pixel_meta_id' => $link->pixel_meta_id,
|
||||
'pixel_google_id' => $link->pixel_google_id,
|
||||
'pixel_linkedin_id' => $link->pixel_linkedin_id,
|
||||
'pixel_meta_id' => $pixels->where('type', 'meta')->first()?->pixel_id,
|
||||
'pixel_google_id' => $pixels->where('type', 'google')->first()?->pixel_id,
|
||||
'pixel_linkedin_id' => $pixels->where('type', 'linkedin')->first()?->pixel_id,
|
||||
'webhook_url' => $link->webhook_url,
|
||||
'targeting_rules' => $link->targeting_rules,
|
||||
'password' => $link->password,
|
||||
'show_warning_page' => (bool) $link->show_warning_page,
|
||||
'track_visits' => (bool) $link->track_visits,
|
||||
'track_browser_language' => (bool) $link->track_browser_language,
|
||||
'pixels' => $pixels->map(fn ($p) => [
|
||||
'id' => $p->id,
|
||||
'name' => $p->name,
|
||||
'type' => $p->type,
|
||||
'pixel_id' => $p->pixel_id,
|
||||
'is_active' => (bool) $p->is_active,
|
||||
])->toArray(),
|
||||
'notes' => $link->notes,
|
||||
'created_at' => $link->created_at->toIso8601String(),
|
||||
];
|
||||
|
||||
@@ -162,12 +162,12 @@ class ShortUrlRedirectController extends Controller
|
||||
cache()->forget("filament-short-url:{$shortUrl->url_key}");
|
||||
}
|
||||
|
||||
if (! empty($shortUrl->pixel_meta_id) || ! empty($shortUrl->pixel_google_id) || ! empty($shortUrl->pixel_linkedin_id)) {
|
||||
$activePixels = $shortUrl->pixels()->where('is_active', true)->get();
|
||||
|
||||
if ($activePixels->isNotEmpty()) {
|
||||
return response(view('filament-short-url::pixel-loading', [
|
||||
'destination' => $destination,
|
||||
'pixelMetaId' => $shortUrl->pixel_meta_id,
|
||||
'pixelGoogleId' => $shortUrl->pixel_google_id,
|
||||
'pixelLinkedinId' => $shortUrl->pixel_linkedin_id,
|
||||
'pixels' => $activePixels,
|
||||
]))->header('Content-Type', 'text/html');
|
||||
}
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Collection;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Carbon;
|
||||
@@ -82,9 +83,6 @@ class ShortUrl extends Model
|
||||
'unique_visits',
|
||||
'max_visits',
|
||||
'expiration_redirect_url',
|
||||
'pixel_meta_id',
|
||||
'pixel_google_id',
|
||||
'pixel_linkedin_id',
|
||||
'webhook_url',
|
||||
'qr_logo',
|
||||
'qr_scans',
|
||||
@@ -126,6 +124,11 @@ class ShortUrl extends Model
|
||||
return $this->hasMany(ShortUrlDailyStats::class, 'short_url_id');
|
||||
}
|
||||
|
||||
public function pixels(): BelongsToMany
|
||||
{
|
||||
return $this->belongsToMany(ShortUrlPixel::class, 'short_url_pixel', 'short_url_id', 'pixel_id');
|
||||
}
|
||||
|
||||
// ─── Scopes ──────────────────────────────────────────────────────────────
|
||||
|
||||
public function scopeEnabled(Builder $query): Builder
|
||||
@@ -198,15 +201,6 @@ class ShortUrl extends Model
|
||||
$m->expiration_redirect_url = null;
|
||||
}
|
||||
|
||||
if (empty($m->pixel_meta_id)) {
|
||||
$m->pixel_meta_id = null;
|
||||
}
|
||||
if (empty($m->pixel_google_id)) {
|
||||
$m->pixel_google_id = null;
|
||||
}
|
||||
if (empty($m->pixel_linkedin_id)) {
|
||||
$m->pixel_linkedin_id = null;
|
||||
}
|
||||
if (empty($m->webhook_url)) {
|
||||
$m->webhook_url = null;
|
||||
}
|
||||
|
||||
28
src/Models/ShortUrlPixel.php
Normal file
28
src/Models/ShortUrlPixel.php
Normal file
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
namespace Bjanczak\FilamentShortUrl\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
||||
|
||||
class ShortUrlPixel extends Model
|
||||
{
|
||||
protected $fillable = [
|
||||
'name',
|
||||
'type',
|
||||
'pixel_id',
|
||||
'is_active',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'is_active' => 'boolean',
|
||||
];
|
||||
|
||||
/**
|
||||
* Get the short URLs associated with this pixel.
|
||||
*/
|
||||
public function shortUrls(): BelongsToMany
|
||||
{
|
||||
return $this->belongsToMany(ShortUrl::class, 'short_url_pixel', 'pixel_id', 'short_url_id');
|
||||
}
|
||||
}
|
||||
125
tests/Feature/ShortUrlApiTest.php
Normal file
125
tests/Feature/ShortUrlApiTest.php
Normal file
@@ -0,0 +1,125 @@
|
||||
<?php
|
||||
|
||||
use Bjanczak\FilamentShortUrl\Jobs\SendWebhookJob;
|
||||
use Bjanczak\FilamentShortUrl\Models\ShortUrl;
|
||||
use Bjanczak\FilamentShortUrl\Services\ShortUrlSettingsManager;
|
||||
use Illuminate\Support\Facades\Queue;
|
||||
|
||||
beforeEach(function () {
|
||||
// Enable REST API and inject mock keys
|
||||
app(ShortUrlSettingsManager::class)->set([
|
||||
'api_enabled' => true,
|
||||
'api_keys' => [
|
||||
['name' => 'Active Test Token', 'key' => 'sh_key_active_token', 'is_active' => true],
|
||||
['name' => 'Inactive Test Token', 'key' => 'sh_key_inactive_token', 'is_active' => false],
|
||||
],
|
||||
]);
|
||||
});
|
||||
|
||||
it('rejects API requests without a key', function () {
|
||||
$response = $this->getJson('/api/short-url/links');
|
||||
|
||||
$response->assertStatus(401)
|
||||
->assertJsonFragment(['error' => 'Unauthorized. API Key is missing.']);
|
||||
});
|
||||
|
||||
it('returns 503 when the REST API is disabled in settings', function () {
|
||||
app(ShortUrlSettingsManager::class)->set(['api_enabled' => false]);
|
||||
|
||||
$response = $this->getJson('/api/short-url/links', [
|
||||
'X-Api-Key' => 'sh_key_active_token',
|
||||
]);
|
||||
|
||||
$response->assertStatus(503)
|
||||
->assertJsonPath('error', fn ($v) => str_contains($v, 'disabled'));
|
||||
});
|
||||
|
||||
it('rejects API requests with an invalid key', function () {
|
||||
$response = $this->getJson('/api/short-url/links', [
|
||||
'X-Api-Key' => 'sh_key_invalid_value',
|
||||
]);
|
||||
|
||||
$response->assertStatus(401)
|
||||
->assertJsonFragment(['error' => 'Unauthorized. Invalid or inactive API Key.']);
|
||||
});
|
||||
|
||||
it('rejects API requests with an inactive key', function () {
|
||||
$response = $this->getJson('/api/short-url/links', [
|
||||
'X-Api-Key' => 'sh_key_inactive_token',
|
||||
]);
|
||||
|
||||
$response->assertStatus(401)
|
||||
->assertJsonFragment(['error' => 'Unauthorized. Invalid or inactive API Key.']);
|
||||
});
|
||||
|
||||
it('allows API requests with a valid key', function () {
|
||||
// Seed at least one short URL
|
||||
ShortUrl::create([
|
||||
'destination_url' => 'https://example.com/api-test',
|
||||
'url_key' => 'apitest',
|
||||
]);
|
||||
|
||||
$response = $this->getJson('/api/short-url/links', [
|
||||
'X-Api-Key' => 'sh_key_active_token',
|
||||
]);
|
||||
|
||||
$response->assertStatus(200)
|
||||
->assertJsonStructure([
|
||||
'data',
|
||||
'meta' => ['current_page', 'last_page', 'per_page', 'total'],
|
||||
])
|
||||
->assertJsonFragment(['url_key' => 'apitest']);
|
||||
});
|
||||
|
||||
it('allows creating a short link programmatically via POST', function () {
|
||||
Queue::fake([SendWebhookJob::class]);
|
||||
|
||||
$response = $this->postJson('/api/short-url/links', [
|
||||
'destination_url' => 'https://google.com',
|
||||
'url_key' => 'googleapi',
|
||||
'notes' => 'API Generated link',
|
||||
'single_use' => true,
|
||||
'pixel_meta_id' => '12345',
|
||||
'webhook_url' => 'https://webhook.site/test',
|
||||
], [
|
||||
'X-Api-Key' => 'sh_key_active_token',
|
||||
]);
|
||||
|
||||
$response->assertStatus(201)
|
||||
->assertJsonFragment(['url_key' => 'googleapi'])
|
||||
->assertJsonFragment(['notes' => 'API Generated link']);
|
||||
|
||||
$this->assertDatabaseHas('short_urls', [
|
||||
'url_key' => 'googleapi',
|
||||
'single_use' => true,
|
||||
'webhook_url' => 'https://webhook.site/test',
|
||||
]);
|
||||
|
||||
$this->assertDatabaseHas('short_url_pixels', [
|
||||
'type' => 'meta',
|
||||
'pixel_id' => '12345',
|
||||
]);
|
||||
|
||||
// SendWebhookJob should be dispatched for 'created' event since custom webhook_url is set
|
||||
Queue::assertPushed(SendWebhookJob::class, function ($job) {
|
||||
return $job->url === 'https://webhook.site/test' && $job->event === 'created';
|
||||
});
|
||||
});
|
||||
|
||||
it('allows deleting a short link via DELETE', function () {
|
||||
$link = ShortUrl::create([
|
||||
'destination_url' => 'https://delete-me.com',
|
||||
'url_key' => 'delkey',
|
||||
]);
|
||||
|
||||
$response = $this->deleteJson("/api/short-url/links/{$link->id}", [], [
|
||||
'X-Api-Key' => 'sh_key_active_token',
|
||||
]);
|
||||
|
||||
$response->assertStatus(200)
|
||||
->assertJsonFragment(['message' => 'Short URL deleted successfully.']);
|
||||
|
||||
$this->assertDatabaseMissing('short_urls', [
|
||||
'id' => $link->id,
|
||||
]);
|
||||
});
|
||||
126
tests/Feature/ShortUrlPixelTest.php
Normal file
126
tests/Feature/ShortUrlPixelTest.php
Normal file
@@ -0,0 +1,126 @@
|
||||
<?php
|
||||
|
||||
use Bjanczak\FilamentShortUrl\Models\ShortUrl;
|
||||
use Bjanczak\FilamentShortUrl\Models\ShortUrlPixel;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
it('serves the pixel-loading interstitial view when pixels are set', function () {
|
||||
$link = ShortUrl::create([
|
||||
'destination_url' => 'https://example.com/target',
|
||||
'url_key' => 'pixelkey',
|
||||
]);
|
||||
|
||||
$metaPixel = ShortUrlPixel::create([
|
||||
'name' => 'My Meta Pixel',
|
||||
'type' => 'meta',
|
||||
'pixel_id' => 'META-12345',
|
||||
'is_active' => true,
|
||||
]);
|
||||
|
||||
$googlePixel = ShortUrlPixel::create([
|
||||
'name' => 'My Google Tag',
|
||||
'type' => 'google',
|
||||
'pixel_id' => 'G-GA54321',
|
||||
'is_active' => true,
|
||||
]);
|
||||
|
||||
$linkedinPixel = ShortUrlPixel::create([
|
||||
'name' => 'My LinkedIn Tag',
|
||||
'type' => 'linkedin',
|
||||
'pixel_id' => 'LNK-98765',
|
||||
'is_active' => true,
|
||||
]);
|
||||
|
||||
$tiktokPixel = ShortUrlPixel::create([
|
||||
'name' => 'My TikTok Tag',
|
||||
'type' => 'tiktok',
|
||||
'pixel_id' => 'TT-65432',
|
||||
'is_active' => true,
|
||||
]);
|
||||
|
||||
$pinterestPixel = ShortUrlPixel::create([
|
||||
'name' => 'My Pinterest Tag',
|
||||
'type' => 'pinterest',
|
||||
'pixel_id' => 'PIN-78901',
|
||||
'is_active' => true,
|
||||
]);
|
||||
|
||||
$link->pixels()->sync([
|
||||
$metaPixel->id,
|
||||
$googlePixel->id,
|
||||
$linkedinPixel->id,
|
||||
$tiktokPixel->id,
|
||||
$pinterestPixel->id,
|
||||
]);
|
||||
|
||||
$response = $this->get('/s/pixelkey');
|
||||
|
||||
$response->assertStatus(200);
|
||||
$response->assertViewIs('filament-short-url::pixel-loading');
|
||||
|
||||
// Assert Meta Pixel is rendered
|
||||
$response->assertSee("fbq('init', 'META-12345')", false);
|
||||
|
||||
// Assert Google Analytics is rendered
|
||||
$response->assertSee('https://www.googletagmanager.com/gtag/js?id=G-GA54321', false);
|
||||
$response->assertSee("gtag('config', 'G-GA54321')", false);
|
||||
|
||||
// Assert LinkedIn Insight is rendered
|
||||
$response->assertSee('window._linkedin_data_partner_ids.push("LNK-98765")', false);
|
||||
|
||||
// Assert TikTok Pixel is rendered
|
||||
$response->assertSee("ttq.load('TT-65432')", false);
|
||||
|
||||
// Assert Pinterest Tag is rendered
|
||||
$response->assertSee("pintrk('load', 'PIN-78901')", false);
|
||||
});
|
||||
|
||||
it('bypasses interstitial loading when no pixels are set', function () {
|
||||
$link = ShortUrl::create([
|
||||
'destination_url' => 'https://example.com/direct',
|
||||
'url_key' => 'directkey',
|
||||
]);
|
||||
|
||||
$response = $this->get('/s/directkey');
|
||||
|
||||
$response->assertRedirect('https://example.com/direct');
|
||||
});
|
||||
|
||||
it('does not load inactive pixels from registry', function () {
|
||||
$link = ShortUrl::create([
|
||||
'destination_url' => 'https://example.com/inactive-target',
|
||||
'url_key' => 'inactivekey',
|
||||
]);
|
||||
|
||||
$activePixel = ShortUrlPixel::create([
|
||||
'name' => 'Active Meta Pixel',
|
||||
'type' => 'meta',
|
||||
'pixel_id' => 'META-ACTIVE',
|
||||
'is_active' => true,
|
||||
]);
|
||||
|
||||
$inactivePixel = ShortUrlPixel::create([
|
||||
'name' => 'Inactive Google Tag',
|
||||
'type' => 'google',
|
||||
'pixel_id' => 'G-INACTIVE',
|
||||
'is_active' => false,
|
||||
]);
|
||||
|
||||
$link->pixels()->sync([
|
||||
$activePixel->id,
|
||||
$inactivePixel->id,
|
||||
]);
|
||||
|
||||
$response = $this->get('/s/inactivekey');
|
||||
|
||||
$response->assertStatus(200);
|
||||
$response->assertViewIs('filament-short-url::pixel-loading');
|
||||
|
||||
// Assert active is rendered
|
||||
$response->assertSee("fbq('init', 'META-ACTIVE')", false);
|
||||
|
||||
// Assert inactive is NOT rendered
|
||||
$response->assertDontSee('G-INACTIVE', false);
|
||||
});
|
||||
677
tests/Feature/ShortUrlRedirectTest.php
Normal file
677
tests/Feature/ShortUrlRedirectTest.php
Normal file
@@ -0,0 +1,677 @@
|
||||
<?php
|
||||
|
||||
use Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource\Pages\ViewShortUrlStats;
|
||||
use Bjanczak\FilamentShortUrl\Jobs\TrackShortUrlVisitJob;
|
||||
use Bjanczak\FilamentShortUrl\Models\ShortUrl;
|
||||
use Bjanczak\FilamentShortUrl\Models\ShortUrlDailyStats;
|
||||
use Bjanczak\FilamentShortUrl\Services\GeoIpService;
|
||||
use Bjanczak\FilamentShortUrl\Services\ShortUrlService;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Facades\Queue;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
if (! function_exists('createShortUrl')) {
|
||||
function createShortUrl(array $attrs = []): ShortUrl
|
||||
{
|
||||
return app(ShortUrlService::class)->create(array_merge([
|
||||
'destination_url' => 'https://example.com',
|
||||
], $attrs));
|
||||
}
|
||||
}
|
||||
|
||||
it('redirects to destination url', function () {
|
||||
$shortUrl = createShortUrl(['url_key' => 'abc123', 'track_visits' => false]);
|
||||
|
||||
$this->get('/s/abc123')
|
||||
->assertRedirect('https://example.com');
|
||||
});
|
||||
|
||||
it('returns 404 for unknown key', function () {
|
||||
$this->get('/s/doesnotexist')->assertStatus(404);
|
||||
});
|
||||
|
||||
it('returns 410 for disabled url', function () {
|
||||
createShortUrl(['url_key' => 'disabled1', 'is_enabled' => false, 'track_visits' => false]);
|
||||
|
||||
$this->get('/s/disabled1')->assertStatus(410);
|
||||
});
|
||||
|
||||
it('returns 410 for expired url', function () {
|
||||
createShortUrl([
|
||||
'url_key' => 'expired1',
|
||||
'expires_at' => now()->subDay(),
|
||||
'track_visits' => false,
|
||||
]);
|
||||
|
||||
$this->get('/s/expired1')->assertStatus(410);
|
||||
});
|
||||
|
||||
it('redirects to expiration fallback url when deactivated or expired', function () {
|
||||
createShortUrl([
|
||||
'url_key' => 'fallback-expired',
|
||||
'expires_at' => now()->subDay(),
|
||||
'expiration_redirect_url' => 'https://fallback.example.com',
|
||||
'track_visits' => false,
|
||||
]);
|
||||
|
||||
$this->get('/s/fallback-expired')
|
||||
->assertRedirect('https://fallback.example.com');
|
||||
});
|
||||
|
||||
it('redirects to expiration fallback url when deactivated_at is past', function () {
|
||||
createShortUrl([
|
||||
'url_key' => 'fallback-deactivated',
|
||||
'activated_at' => now()->subDays(2),
|
||||
'deactivated_at' => now()->subDay(),
|
||||
'expiration_redirect_url' => 'https://fallback.example.com',
|
||||
'track_visits' => false,
|
||||
]);
|
||||
|
||||
$this->get('/s/fallback-deactivated')
|
||||
->assertRedirect('https://fallback.example.com');
|
||||
});
|
||||
|
||||
it('redirects to expiration fallback url when activated_at is in future', function () {
|
||||
createShortUrl([
|
||||
'url_key' => 'fallback-not-yet-active',
|
||||
'activated_at' => now()->addDay(),
|
||||
'expiration_redirect_url' => 'https://fallback.example.com',
|
||||
'track_visits' => false,
|
||||
]);
|
||||
|
||||
$this->get('/s/fallback-not-yet-active')
|
||||
->assertRedirect('https://fallback.example.com');
|
||||
});
|
||||
|
||||
it('redirects to destination when activated_at is in past and deactivated_at is in future', function () {
|
||||
createShortUrl([
|
||||
'url_key' => 'active-range',
|
||||
'activated_at' => now()->subDay(),
|
||||
'deactivated_at' => now()->addDay(),
|
||||
'track_visits' => false,
|
||||
]);
|
||||
|
||||
$this->get('/s/active-range')
|
||||
->assertRedirect('https://example.com');
|
||||
});
|
||||
|
||||
it('redirects to expiration fallback when max visits is reached', function () {
|
||||
$shortUrl = createShortUrl([
|
||||
'url_key' => 'fallback-max-visits',
|
||||
'activated_at' => now()->subDay(),
|
||||
'max_visits' => 2,
|
||||
'expiration_redirect_url' => 'https://fallback.example.com',
|
||||
'track_visits' => false,
|
||||
]);
|
||||
|
||||
$shortUrl->total_visits = 2;
|
||||
$shortUrl->save();
|
||||
|
||||
$this->get('/s/fallback-max-visits')
|
||||
->assertRedirect('https://fallback.example.com');
|
||||
});
|
||||
|
||||
it('redirects to destination when max visits is not reached', function () {
|
||||
$shortUrl = createShortUrl([
|
||||
'url_key' => 'under-max-visits',
|
||||
'max_visits' => 2,
|
||||
'expiration_redirect_url' => 'https://fallback.example.com',
|
||||
'track_visits' => false,
|
||||
]);
|
||||
|
||||
$shortUrl->total_visits = 1;
|
||||
$shortUrl->save();
|
||||
|
||||
$this->get('/s/under-max-visits')
|
||||
->assertRedirect('https://example.com');
|
||||
});
|
||||
|
||||
it('disables single-use url after first visit', function () {
|
||||
createShortUrl(['url_key' => 'single1', 'single_use' => true, 'track_visits' => false]);
|
||||
|
||||
$this->get('/s/single1')->assertRedirect();
|
||||
|
||||
$shortUrl = ShortUrl::where('url_key', 'single1')->first();
|
||||
expect($shortUrl->is_enabled)->toBeFalse();
|
||||
});
|
||||
|
||||
it('uses 302 redirect by default', function () {
|
||||
createShortUrl(['url_key' => 'temp302', 'redirect_status_code' => 302, 'track_visits' => false]);
|
||||
|
||||
$this->get('/s/temp302')->assertStatus(302);
|
||||
});
|
||||
|
||||
it('records visit data when tracking is enabled', function () {
|
||||
Queue::fake();
|
||||
|
||||
createShortUrl(['url_key' => 'track1', 'track_visits' => true]);
|
||||
|
||||
$this->get('/s/track1', ['User-Agent' => 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) Chrome/120.0.0.0']);
|
||||
|
||||
Queue::assertPushed(
|
||||
TrackShortUrlVisitJob::class
|
||||
);
|
||||
});
|
||||
|
||||
it('does not record visit when tracking is disabled', function () {
|
||||
Queue::fake();
|
||||
|
||||
createShortUrl(['url_key' => 'notrack1', 'track_visits' => false]);
|
||||
|
||||
$this->get('/s/notrack1');
|
||||
|
||||
Queue::assertNotPushed(
|
||||
TrackShortUrlVisitJob::class
|
||||
);
|
||||
});
|
||||
|
||||
it('extracts proxy-resistant client IP address', function () {
|
||||
config(['filament-short-url.queue_connection' => 'sync']);
|
||||
config(['filament-short-url.geo_ip.enabled' => false]);
|
||||
config(['filament-short-url.trust_cdn_headers' => true]);
|
||||
|
||||
$shortUrl = createShortUrl(['url_key' => 'proxyip', 'track_visits' => true]);
|
||||
|
||||
$this->get('/s/proxyip', [
|
||||
'CF-Connecting-IP' => '1.2.3.4',
|
||||
'X-Real-IP' => '5.6.7.8',
|
||||
'X-Forwarded-For' => '9.10.11.12, 13.14.15.16',
|
||||
]);
|
||||
|
||||
$visit = $shortUrl->visits()->first();
|
||||
expect($visit)->not->toBeNull()
|
||||
->and($visit->ip_address)->toBe('1.2.3.4');
|
||||
|
||||
$shortUrl2 = createShortUrl(['url_key' => 'proxyip2', 'track_visits' => true]);
|
||||
$this->get('/s/proxyip2', [
|
||||
'X-Forwarded-For' => '9.10.11.12, 13.14.15.16',
|
||||
]);
|
||||
|
||||
$visit2 = $shortUrl2->visits()->first();
|
||||
expect($visit2->ip_address)->toBe('9.10.11.12');
|
||||
});
|
||||
|
||||
it('resolves country from edge CDN headers offline', function () {
|
||||
config(['filament-short-url.queue_connection' => 'sync']);
|
||||
config(['filament-short-url.geo_ip.enabled' => true]);
|
||||
config(['filament-short-url.geo_ip.driver' => 'headers']);
|
||||
config(['filament-short-url.trust_cdn_headers' => true]);
|
||||
|
||||
$shortUrl = createShortUrl(['url_key' => 'cdngeo', 'track_visits' => true]);
|
||||
|
||||
$this->get('/s/cdngeo', [
|
||||
'CF-IPCountry' => 'PL',
|
||||
]);
|
||||
|
||||
$visit = $shortUrl->visits()->first();
|
||||
expect($visit)->not->toBeNull()
|
||||
->and($visit->country_code)->toBe('PL')
|
||||
->and($visit->country)->toBe('Poland');
|
||||
});
|
||||
|
||||
it('caches stats page calculations', function () {
|
||||
$shortUrl = createShortUrl(['url_key' => 'cachestats']);
|
||||
|
||||
$page = new ViewShortUrlStats;
|
||||
$page->record = $shortUrl;
|
||||
|
||||
// First mount: should be 0 since no visits exist
|
||||
$page->mount($shortUrl);
|
||||
expect($page->totalVisits)->toBe(0);
|
||||
|
||||
// Create a visit in the database (cache is not cleared automatically for this manual action)
|
||||
$shortUrl->visits()->create([
|
||||
'ip_address' => '1.2.3.4',
|
||||
'visited_at' => now(),
|
||||
]);
|
||||
|
||||
// Second mount: should still be 0 because stats are cached
|
||||
$page->mount($shortUrl);
|
||||
expect($page->totalVisits)->toBe(0);
|
||||
|
||||
// Clear the specific date-filtered cache key
|
||||
$dateFrom = now()->subDays(29)->format('Y-m-d');
|
||||
$dateTo = now()->format('Y-m-d');
|
||||
Cache::forget("short_url_stats_{$shortUrl->id}_{$dateFrom}_{$dateTo}");
|
||||
|
||||
// Third mount: should now be 1 because cache is cleared and recalculated
|
||||
$page->mount($shortUrl);
|
||||
expect($page->totalVisits)->toBe(1);
|
||||
});
|
||||
|
||||
it('records visit data with UTM parameters and resolved referer host', function () {
|
||||
config(['filament-short-url.queue_connection' => 'sync']);
|
||||
config(['filament-short-url.geo_ip.enabled' => false]);
|
||||
|
||||
$shortUrl = createShortUrl(['url_key' => 'utmtrack', 'track_visits' => true, 'track_referer_url' => true]);
|
||||
|
||||
$this->get('/s/utmtrack?utm_source=google&utm_medium=cpc&utm_campaign=summer_sale&utm_term=shoes&utm_content=banner', [
|
||||
'Referer' => 'https://m.facebook.com/some/path?query=string',
|
||||
]);
|
||||
|
||||
$visit = $shortUrl->visits()->first();
|
||||
expect($visit)->not->toBeNull();
|
||||
expect($visit->utm_source)->toBe('google');
|
||||
expect($visit->utm_medium)->toBe('cpc');
|
||||
expect($visit->utm_campaign)->toBe('summer_sale');
|
||||
expect($visit->utm_term)->toBe('shoes');
|
||||
expect($visit->utm_content)->toBe('banner');
|
||||
expect($visit->referer_url)->toBe('https://m.facebook.com/some/path?query=string');
|
||||
expect($visit->referer_host)->toBe('facebook.com');
|
||||
});
|
||||
|
||||
it('resolves city from edge CDN headers offline', function () {
|
||||
config(['filament-short-url.queue_connection' => 'sync']);
|
||||
config(['filament-short-url.geo_ip.enabled' => true]);
|
||||
config(['filament-short-url.geo_ip.driver' => 'headers']);
|
||||
config(['filament-short-url.trust_cdn_headers' => true]);
|
||||
|
||||
$shortUrl = createShortUrl(['url_key' => 'cdncity', 'track_visits' => true]);
|
||||
|
||||
$this->get('/s/cdncity', [
|
||||
'CF-IPCountry' => 'US',
|
||||
'CF-IPCity' => 'New York',
|
||||
]);
|
||||
|
||||
$visit = $shortUrl->visits()->first();
|
||||
expect($visit)->not->toBeNull()
|
||||
->and($visit->country_code)->toBe('US')
|
||||
->and($visit->country)->toBe('United States')
|
||||
->and($visit->city)->toBe('New York');
|
||||
});
|
||||
|
||||
it('resolves country and city from ip-api driver', function () {
|
||||
Http::fake([
|
||||
'http://ip-api.com/*' => Http::response([
|
||||
'status' => 'success',
|
||||
'country' => 'Germany',
|
||||
'countryCode' => 'DE',
|
||||
'city' => 'Berlin',
|
||||
], 200),
|
||||
]);
|
||||
|
||||
config(['filament-short-url.geo_ip.enabled' => true]);
|
||||
config(['filament-short-url.geo_ip.driver' => 'ip-api']);
|
||||
|
||||
$geoIpService = app(GeoIpService::class);
|
||||
$result = $geoIpService->resolve('8.8.8.8');
|
||||
|
||||
expect($result)->toBe([
|
||||
'country' => 'Germany',
|
||||
'country_code' => 'DE',
|
||||
'city' => 'Berlin',
|
||||
]);
|
||||
});
|
||||
|
||||
it('applies rate limiting on redirects when enabled', function () {
|
||||
config(['filament-short-url.rate_limiting.enabled' => true]);
|
||||
config(['filament-short-url.rate_limiting.max_attempts' => 2]);
|
||||
config(['filament-short-url.rate_limiting.decay_seconds' => 10]);
|
||||
|
||||
$shortUrl = createShortUrl(['url_key' => 'ratelimit1', 'track_visits' => false]);
|
||||
|
||||
// Attempt 1: Success
|
||||
$this->get('/s/ratelimit1')->assertRedirect('https://example.com');
|
||||
|
||||
// Attempt 2: Success
|
||||
$this->get('/s/ratelimit1')->assertRedirect('https://example.com');
|
||||
|
||||
// Attempt 3: Rate limited (429)
|
||||
$this->get('/s/ratelimit1')->assertStatus(429);
|
||||
});
|
||||
|
||||
it('requires password to redirect when protected', function () {
|
||||
$shortUrl = createShortUrl([
|
||||
'url_key' => 'password123',
|
||||
'password' => 'secret-key',
|
||||
'track_visits' => false,
|
||||
]);
|
||||
|
||||
// Unauthenticated request should render password prompt view
|
||||
$response = $this->get('/s/password123');
|
||||
$response->assertStatus(200);
|
||||
$response->assertSee('Password Required');
|
||||
|
||||
// Send incorrect password
|
||||
$response = $this->post('/s/password123', ['password' => 'wrong']);
|
||||
$response->assertStatus(200);
|
||||
$response->assertSee('Incorrect password');
|
||||
|
||||
// Send correct password
|
||||
$response = $this->post('/s/password123', ['password' => 'secret-key']);
|
||||
$response->assertRedirect('/s/password123');
|
||||
|
||||
// Following request should redirect to target
|
||||
$this->get('/s/password123')->assertRedirect('https://example.com');
|
||||
});
|
||||
|
||||
it('shows warning page before redirecting when enabled', function () {
|
||||
$shortUrl = createShortUrl([
|
||||
'url_key' => 'warn1',
|
||||
'show_warning_page' => true,
|
||||
'track_visits' => false,
|
||||
]);
|
||||
|
||||
// Unconfirmed visit should render warning page
|
||||
$response = $this->get('/s/warn1');
|
||||
$response->assertStatus(200);
|
||||
$response->assertSee('Security Redirect Warning');
|
||||
$response->assertSee('https://example.com');
|
||||
|
||||
// Confirmed visit should redirect
|
||||
$this->get('/s/warn1?confirmed=1')->assertRedirect('https://example.com');
|
||||
});
|
||||
|
||||
it('requires password first, then shows warning page when both are enabled', function () {
|
||||
$shortUrl = createShortUrl([
|
||||
'url_key' => 'both-secure',
|
||||
'password' => 'secret-combination',
|
||||
'show_warning_page' => true,
|
||||
'track_visits' => false,
|
||||
]);
|
||||
|
||||
// 1. Visit without password -> password prompt page, not warning page
|
||||
$response = $this->get('/s/both-secure');
|
||||
$response->assertStatus(200);
|
||||
$response->assertSee('Password Required');
|
||||
$response->assertDontSee('Security Redirect Warning');
|
||||
|
||||
// 2. Submit wrong password -> password prompt page with error
|
||||
$response = $this->post('/s/both-secure', ['password' => 'wrong']);
|
||||
$response->assertStatus(200);
|
||||
$response->assertSee('Incorrect password');
|
||||
$response->assertDontSee('Security Redirect Warning');
|
||||
|
||||
// 3. Submit correct password -> redirects back to the short URL path
|
||||
$response = $this->post('/s/both-secure', ['password' => 'secret-combination']);
|
||||
$response->assertRedirect('/s/both-secure');
|
||||
|
||||
// 4. Follow redirect (GET request with authenticated session, but without confirmed parameter)
|
||||
// -> shows redirect warning page, not direct redirect
|
||||
$response = $this->get('/s/both-secure');
|
||||
$response->assertStatus(200);
|
||||
$response->assertSee('Security Redirect Warning');
|
||||
$response->assertSee('https://example.com');
|
||||
|
||||
// 5. Follow the confirmed link (confirmed=1) -> redirects to destination
|
||||
$this->get('/s/both-secure?confirmed=1')
|
||||
->assertRedirect('https://example.com');
|
||||
});
|
||||
|
||||
it('targets redirect by device type', function () {
|
||||
$shortUrl = createShortUrl([
|
||||
'url_key' => 'device-target',
|
||||
'track_visits' => false,
|
||||
'targeting_rules' => [
|
||||
'type' => 'device',
|
||||
'device' => [
|
||||
'ios' => 'https://ios.example.com',
|
||||
'android' => 'https://android.example.com',
|
||||
'desktop' => 'https://desktop.example.com',
|
||||
],
|
||||
],
|
||||
]);
|
||||
|
||||
// iOS
|
||||
$this->get('/s/device-target', ['User-Agent' => 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X)'])
|
||||
->assertRedirect('https://ios.example.com');
|
||||
|
||||
// Android
|
||||
$this->get('/s/device-target', ['User-Agent' => 'Mozilla/5.0 (Linux; Android 10)'])
|
||||
->assertRedirect('https://android.example.com');
|
||||
|
||||
// Desktop
|
||||
$this->get('/s/device-target', ['User-Agent' => 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)'])
|
||||
->assertRedirect('https://desktop.example.com');
|
||||
});
|
||||
|
||||
it('targets redirect by country code', function () {
|
||||
config(['filament-short-url.trust_cdn_headers' => true]);
|
||||
|
||||
$shortUrl = createShortUrl([
|
||||
'url_key' => 'geo-target',
|
||||
'track_visits' => false,
|
||||
'targeting_rules' => [
|
||||
'type' => 'geo',
|
||||
'geo' => [
|
||||
['country_code' => 'PL', 'url' => 'https://pl.example.com'],
|
||||
['country_code' => 'US', 'url' => 'https://us.example.com'],
|
||||
],
|
||||
],
|
||||
]);
|
||||
|
||||
// PL
|
||||
$this->get('/s/geo-target', ['CF-IPCountry' => 'PL'])
|
||||
->assertRedirect('https://pl.example.com');
|
||||
|
||||
// US
|
||||
$this->get('/s/geo-target', ['CF-IPCountry' => 'US'])
|
||||
->assertRedirect('https://us.example.com');
|
||||
|
||||
// Other (fallback)
|
||||
$this->get('/s/geo-target', ['CF-IPCountry' => 'DE'])
|
||||
->assertRedirect('https://example.com');
|
||||
});
|
||||
|
||||
it('targets redirect by browser language', function () {
|
||||
$shortUrl = createShortUrl([
|
||||
'url_key' => 'lang-target',
|
||||
'track_visits' => false,
|
||||
'targeting_rules' => [
|
||||
'type' => 'language',
|
||||
'language' => [
|
||||
['language_code' => 'PL', 'url' => 'https://pl.example.com'],
|
||||
['language_code' => 'en-US', 'url' => 'https://enus.example.com'],
|
||||
['language_code' => 'de', 'url' => 'https://de.example.com'],
|
||||
],
|
||||
],
|
||||
]);
|
||||
|
||||
// PL (matching base pl)
|
||||
$this->get('/s/lang-target', ['Accept-Language' => 'pl-PL,pl;q=0.9'])
|
||||
->assertRedirect('https://pl.example.com');
|
||||
|
||||
// en-US (matching exact locale en-US)
|
||||
$this->get('/s/lang-target', ['Accept-Language' => 'en-US,en;q=0.8'])
|
||||
->assertRedirect('https://enus.example.com');
|
||||
|
||||
// de (matching base de from de-DE)
|
||||
$this->get('/s/lang-target', ['Accept-Language' => 'de-DE,de;q=0.8'])
|
||||
->assertRedirect('https://de.example.com');
|
||||
|
||||
// Other (fallback)
|
||||
$this->get('/s/lang-target', ['Accept-Language' => 'fr-FR,fr;q=0.9'])
|
||||
->assertRedirect('https://example.com');
|
||||
});
|
||||
|
||||
it('targets redirect by rotation rules', function () {
|
||||
$shortUrl = createShortUrl([
|
||||
'url_key' => 'rotation-target',
|
||||
'track_visits' => false,
|
||||
'targeting_rules' => [
|
||||
'type' => 'rotation',
|
||||
'rotation' => [
|
||||
['url' => 'https://a.example.com', 'weight' => 100],
|
||||
],
|
||||
],
|
||||
]);
|
||||
|
||||
$this->get('/s/rotation-target')->assertRedirect('https://a.example.com');
|
||||
});
|
||||
|
||||
it('aggregates old visits and prunes logs', function () {
|
||||
config(['filament-short-url.pruning.enabled' => true]);
|
||||
config(['filament-short-url.pruning.retention_days' => 7]);
|
||||
|
||||
$shortUrl = createShortUrl(['url_key' => 'agg1']);
|
||||
|
||||
// Create raw visit from yesterday
|
||||
$visitYesterday = $shortUrl->visits()->create([
|
||||
'ip_address' => '1.1.1.1',
|
||||
'ip_hash' => hash('sha256', '1.1.1.1'),
|
||||
'visited_at' => now()->subDay(),
|
||||
'device_type' => 'desktop',
|
||||
'browser' => 'Chrome',
|
||||
'operating_system' => 'Windows',
|
||||
'country_code' => 'PL',
|
||||
'country' => 'Poland',
|
||||
]);
|
||||
|
||||
// Create raw visit older than retention window (8 days ago)
|
||||
$visitOld = $shortUrl->visits()->create([
|
||||
'ip_address' => '2.2.2.2',
|
||||
'ip_hash' => hash('sha256', '2.2.2.2'),
|
||||
'visited_at' => now()->subDays(8),
|
||||
'device_type' => 'mobile',
|
||||
'browser' => 'Safari',
|
||||
'operating_system' => 'iOS',
|
||||
'country_code' => 'US',
|
||||
'country' => 'United States',
|
||||
]);
|
||||
|
||||
// Run aggregation command
|
||||
$this->artisan('short-url:aggregate-and-prune')->assertSuccessful();
|
||||
|
||||
// Verify stats were aggregated for yesterday (Eloquent-based to handle date cast differences across DBs)
|
||||
$statsYesterday = ShortUrlDailyStats::where('short_url_id', $shortUrl->id)
|
||||
->whereDate('date', now()->subDay()->toDateString())
|
||||
->first();
|
||||
|
||||
expect($statsYesterday)->not->toBeNull();
|
||||
expect($statsYesterday->visits_count)->toBe(1);
|
||||
expect($statsYesterday->unique_visits_count)->toBe(1);
|
||||
|
||||
// Verify stats were aggregated for 8 days ago
|
||||
$statsOld = ShortUrlDailyStats::where('short_url_id', $shortUrl->id)
|
||||
->whereDate('date', now()->subDays(8)->toDateString())
|
||||
->first();
|
||||
|
||||
expect($statsOld)->not->toBeNull();
|
||||
expect($statsOld->visits_count)->toBe(1);
|
||||
expect($statsOld->unique_visits_count)->toBe(1);
|
||||
|
||||
// Check pruning: visit from 8 days ago should be deleted, yesterday should remain
|
||||
$this->assertDatabaseMissing('short_url_visits', ['id' => $visitOld->id]);
|
||||
$this->assertDatabaseHas('short_url_visits', ['id' => $visitYesterday->id]);
|
||||
});
|
||||
|
||||
it('tracks QR code scan visits via query parameters', function () {
|
||||
config(['filament-short-url.queue_connection' => 'sync']);
|
||||
|
||||
$shortUrl = createShortUrl(['url_key' => 'qrtest1', 'track_visits' => true]);
|
||||
|
||||
// Test ?source=qr
|
||||
$this->get('/s/qrtest1?source=qr');
|
||||
$visit1 = $shortUrl->visits()->latest('id')->first();
|
||||
expect($visit1)->not->toBeNull()
|
||||
->and($visit1->is_qr_scan)->toBeTrue();
|
||||
|
||||
// Test ?qr=1
|
||||
$this->get('/s/qrtest1?qr=1');
|
||||
$visit2 = $shortUrl->visits()->latest('id')->first();
|
||||
expect($visit2)->not->toBeNull()
|
||||
->and($visit2->is_qr_scan)->toBeTrue();
|
||||
|
||||
// Test direct visit
|
||||
$this->get('/s/qrtest1');
|
||||
$visit3 = $shortUrl->visits()->latest('id')->first();
|
||||
expect($visit3)->not->toBeNull()
|
||||
->and($visit3->is_qr_scan)->toBeFalse();
|
||||
|
||||
$shortUrl->refresh();
|
||||
expect($shortUrl->qr_scans)->toBe(2);
|
||||
});
|
||||
|
||||
it('extracts and tracks visitor browser language from headers', function () {
|
||||
config(['filament-short-url.queue_connection' => 'sync']);
|
||||
|
||||
$shortUrl = createShortUrl(['url_key' => 'langtest1', 'track_visits' => true]);
|
||||
|
||||
$this->get('/s/langtest1', [
|
||||
'Accept-Language' => 'pl-PL,pl;q=0.9,en-US;q=0.8,en;q=0.7',
|
||||
]);
|
||||
|
||||
$visit = $shortUrl->visits()->first();
|
||||
expect($visit)->not->toBeNull()
|
||||
->and($visit->browser_language)->toBe('pl');
|
||||
});
|
||||
|
||||
it('does not track browser language if track_browser_language is disabled', function () {
|
||||
config(['filament-short-url.queue_connection' => 'sync']);
|
||||
|
||||
$shortUrl = createShortUrl([
|
||||
'url_key' => 'langtest2',
|
||||
'track_visits' => true,
|
||||
'track_browser_language' => false,
|
||||
]);
|
||||
|
||||
$this->get('/s/langtest2', [
|
||||
'Accept-Language' => 'pl-PL,pl;q=0.9,en-US;q=0.8,en;q=0.7',
|
||||
]);
|
||||
|
||||
$visit = $shortUrl->visits()->first();
|
||||
expect($visit)->not->toBeNull()
|
||||
->and($visit->browser_language)->toBeNull();
|
||||
});
|
||||
|
||||
it('tracks browser language if track_browser_language is enabled', function () {
|
||||
config(['filament-short-url.queue_connection' => 'sync']);
|
||||
|
||||
$shortUrl = createShortUrl([
|
||||
'url_key' => 'langtest3',
|
||||
'track_visits' => true,
|
||||
'track_browser_language' => true,
|
||||
]);
|
||||
|
||||
$this->get('/s/langtest3', [
|
||||
'Accept-Language' => 'fr-FR,fr;q=0.9',
|
||||
]);
|
||||
|
||||
$visit = $shortUrl->visits()->first();
|
||||
expect($visit)->not->toBeNull()
|
||||
->and($visit->browser_language)->toBe('fr');
|
||||
});
|
||||
|
||||
it('enforces max_visits in real-time even when model caching is active', function () {
|
||||
config([
|
||||
'filament-short-url.cache_ttl' => 3600,
|
||||
'filament-short-url.queue_connection' => 'sync',
|
||||
]);
|
||||
|
||||
$shortUrl = createShortUrl([
|
||||
'url_key' => 'max-visits-cache',
|
||||
'max_visits' => 2,
|
||||
'track_visits' => true,
|
||||
]);
|
||||
|
||||
// First visit: gets cached, redirects
|
||||
$this->get('/s/max-visits-cache')->assertRedirect('https://example.com');
|
||||
|
||||
// Second visit: loaded from cache, redirects
|
||||
$this->get('/s/max-visits-cache')->assertRedirect('https://example.com');
|
||||
|
||||
// Third visit: should detect limit reached and return 410
|
||||
$this->get('/s/max-visits-cache')->assertStatus(410);
|
||||
});
|
||||
|
||||
it('enforces single_use in real-time even when model caching is active', function () {
|
||||
config([
|
||||
'filament-short-url.cache_ttl' => 3600,
|
||||
'filament-short-url.queue_connection' => 'sync',
|
||||
]);
|
||||
|
||||
$shortUrl = createShortUrl([
|
||||
'url_key' => 'single-use-cache',
|
||||
'single_use' => true,
|
||||
'track_visits' => true,
|
||||
]);
|
||||
|
||||
// First visit: redirects and disables the URL
|
||||
$this->get('/s/single-use-cache')->assertRedirect('https://example.com');
|
||||
|
||||
// Second visit: should return 410, even if the model exists in the cache as enabled
|
||||
$this->get('/s/single-use-cache')->assertStatus(410);
|
||||
});
|
||||
Reference in New Issue
Block a user