1 Commits

Author SHA1 Message Date
Bartłomiej Janczak
d8d6b1b22f feat: Implement Centralized Retargeting Pixels Registry (v2.1.0) 2026-06-03 14:55:46 +02:00
15 changed files with 1434 additions and 48 deletions

View File

@@ -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');
}
};

View File

@@ -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',
];

View File

@@ -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',
];

View File

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

View 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('/'),
];
}
}

View File

@@ -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'),
];
}
}

View File

@@ -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'))

View File

@@ -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,
]);
}

View File

@@ -57,19 +57,65 @@ class ShortUrlApiController extends Controller
'expiration_redirect_url' => 'nullable|url|max:2048',
'activated_at' => 'nullable|date',
'expires_at' => 'nullable|date',
'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',
'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',
]);
$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);
@@ -97,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,
@@ -109,15 +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(),
];

View File

@@ -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');
}

View File

@@ -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;
}

View 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');
}
}

View 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,
]);
});

View 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);
});

View 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);
});