diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..5752e5b --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,75 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + branches: [main] + +jobs: + tests: + name: Tests + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: '8.3' + extensions: mbstring, sqlite3, pdo_sqlite + coverage: none + + - name: Install Composer dependencies + run: composer install --no-interaction --prefer-dist --optimize-autoloader + + - name: Copy environment file + run: cp .env.example .env + + - name: Generate application key + run: php artisan key:generate + + - name: Run tests + run: php artisan test --parallel + env: + DB_CONNECTION: sqlite + DB_DATABASE: ':memory:' + + lint: + name: Pint (Code Style) + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: '8.3' + coverage: none + + - name: Install Composer dependencies + run: composer install --no-interaction --prefer-dist --optimize-autoloader + + - name: Run Pint + run: vendor/bin/pint --test + + static-analysis: + name: Larastan (Static Analysis) + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: '8.3' + coverage: none + + - name: Install Composer dependencies + run: composer install --no-interaction --prefer-dist --optimize-autoloader + + - name: Run Larastan + run: vendor/bin/phpstan analyse --memory-limit=512M diff --git a/PLAN.md b/PLAN.md index 84353b7..bbaaf25 100644 --- a/PLAN.md +++ b/PLAN.md @@ -168,14 +168,18 @@ Features that differentiate from a basic analytics tool. --- -## Phase 10: Testing & Quality +## Phase 10: Testing & Quality ✅ + +- [x] Write Feature tests for `CollectController` (valid payloads, invalid tracking IDs, rate limiting) +- [x] Write Feature tests for `WebsiteController` CRUD and authorization +- [x] Write Unit tests for `AnalyticsQueryService` aggregation logic +- [x] Write Unit tests for `GoalEvaluationService` +- [ ] Write browser tests (Dusk) for dashboard interactions and date picker (deferred — requires Dusk package + browser driver) +- [x] Add CI pipeline (GitHub Actions): run tests, Pint linting, Larastan static analysis +- [x] Create model factories for all analytics models +- [x] Fix base Controller `AuthorizesRequests` trait +- [x] Fix DailyAggregate/PageDailyAggregate date cast for SQLite compatibility -- [ ] Write Feature tests for `CollectController` (valid payloads, invalid tracking IDs, rate limiting) -- [ ] Write Feature tests for `WebsiteController` CRUD and authorization -- [ ] Write Unit tests for `AnalyticsQueryService` aggregation logic -- [ ] Write Unit tests for `GoalEvaluationService` -- [ ] Write browser tests (Dusk) for dashboard interactions and date picker -- [ ] Add CI pipeline (GitHub Actions): run tests, Pint linting, Larastan static analysis --- diff --git a/app/Http/Controllers/Controller.php b/app/Http/Controllers/Controller.php index 8677cd5..e7f7c94 100644 --- a/app/Http/Controllers/Controller.php +++ b/app/Http/Controllers/Controller.php @@ -2,7 +2,9 @@ namespace App\Http\Controllers; +use Illuminate\Foundation\Auth\Access\AuthorizesRequests; + abstract class Controller { - // + use AuthorizesRequests; } diff --git a/app/Models/DailyAggregate.php b/app/Models/DailyAggregate.php index c472200..0b2f1dc 100644 --- a/app/Models/DailyAggregate.php +++ b/app/Models/DailyAggregate.php @@ -20,7 +20,6 @@ class DailyAggregate extends Model protected function casts(): array { return [ - 'date' => 'date', 'visitors' => 'integer', 'sessions' => 'integer', 'page_views' => 'integer', diff --git a/app/Models/PageDailyAggregate.php b/app/Models/PageDailyAggregate.php index e6cda4c..ca352a6 100644 --- a/app/Models/PageDailyAggregate.php +++ b/app/Models/PageDailyAggregate.php @@ -20,7 +20,6 @@ class PageDailyAggregate extends Model protected function casts(): array { return [ - 'date' => 'date', 'views' => 'integer', 'unique_views' => 'integer', 'total_time_on_page' => 'integer', diff --git a/composer.json b/composer.json index a9cabe0..16ad9b1 100644 --- a/composer.json +++ b/composer.json @@ -16,6 +16,7 @@ }, "require-dev": { "fakerphp/faker": "^1.23", + "larastan/larastan": "^3.9", "laravel/breeze": "^2.4", "laravel/pail": "^1.2.2", "laravel/pint": "^1.24", diff --git a/composer.lock b/composer.lock index b8a6191..7faf959 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "ea6b8a5091bcb1b3c932f36a03f8f62f", + "content-hash": "d00d52ebd78c3db0e6046725a4b740d7", "packages": [ { "name": "bacon/bacon-qr-code", @@ -6905,6 +6905,137 @@ }, "time": "2025-04-30T06:54:44+00:00" }, + { + "name": "iamcal/sql-parser", + "version": "v0.7", + "source": { + "type": "git", + "url": "https://github.com/iamcal/SQLParser.git", + "reference": "610392f38de49a44dab08dc1659960a29874c4b8" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/iamcal/SQLParser/zipball/610392f38de49a44dab08dc1659960a29874c4b8", + "reference": "610392f38de49a44dab08dc1659960a29874c4b8", + "shasum": "" + }, + "require-dev": { + "php-coveralls/php-coveralls": "^1.0", + "phpunit/phpunit": "^5|^6|^7|^8|^9" + }, + "type": "library", + "autoload": { + "psr-4": { + "iamcal\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Cal Henderson", + "email": "cal@iamcal.com" + } + ], + "description": "MySQL schema parser", + "support": { + "issues": "https://github.com/iamcal/SQLParser/issues", + "source": "https://github.com/iamcal/SQLParser/tree/v0.7" + }, + "time": "2026-01-28T22:20:33+00:00" + }, + { + "name": "larastan/larastan", + "version": "v3.9.3", + "source": { + "type": "git", + "url": "https://github.com/larastan/larastan.git", + "reference": "64a52bcc5347c89fdf131cb59f96ebfbc8d1ad65" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/larastan/larastan/zipball/64a52bcc5347c89fdf131cb59f96ebfbc8d1ad65", + "reference": "64a52bcc5347c89fdf131cb59f96ebfbc8d1ad65", + "shasum": "" + }, + "require": { + "ext-json": "*", + "iamcal/sql-parser": "^0.7.0", + "illuminate/console": "^11.44.2 || ^12.4.1 || ^13", + "illuminate/container": "^11.44.2 || ^12.4.1 || ^13", + "illuminate/contracts": "^11.44.2 || ^12.4.1 || ^13", + "illuminate/database": "^11.44.2 || ^12.4.1 || ^13", + "illuminate/http": "^11.44.2 || ^12.4.1 || ^13", + "illuminate/pipeline": "^11.44.2 || ^12.4.1 || ^13", + "illuminate/support": "^11.44.2 || ^12.4.1 || ^13", + "php": "^8.2", + "phpstan/phpstan": "^2.1.32" + }, + "require-dev": { + "doctrine/coding-standard": "^13", + "laravel/framework": "^11.44.2 || ^12.7.2 || ^13", + "mockery/mockery": "^1.6.12", + "nikic/php-parser": "^5.4", + "orchestra/canvas": "^v9.2.2 || ^10.0.1 || ^11", + "orchestra/testbench-core": "^9.12.0 || ^10.1 || ^11", + "phpstan/phpstan-deprecation-rules": "^2.0.1", + "phpunit/phpunit": "^10.5.35 || ^11.5.15 || ^12.5.8" + }, + "suggest": { + "orchestra/testbench": "Using Larastan for analysing a package needs Testbench", + "phpmyadmin/sql-parser": "Install to enable Larastan's optional phpMyAdmin-based SQL parser automatically" + }, + "type": "phpstan-extension", + "extra": { + "phpstan": { + "includes": [ + "extension.neon" + ] + }, + "branch-alias": { + "dev-master": "3.0-dev" + } + }, + "autoload": { + "psr-4": { + "Larastan\\Larastan\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Can Vural", + "email": "can9119@gmail.com" + } + ], + "description": "Larastan - Discover bugs in your code without running it. A phpstan/phpstan extension for Laravel", + "keywords": [ + "PHPStan", + "code analyse", + "code analysis", + "larastan", + "laravel", + "package", + "php", + "static analysis" + ], + "support": { + "issues": "https://github.com/larastan/larastan/issues", + "source": "https://github.com/larastan/larastan/tree/v3.9.3" + }, + "funding": [ + { + "url": "https://github.com/canvural", + "type": "github" + } + ], + "time": "2026-02-20T12:07:12+00:00" + }, { "name": "laravel/breeze", "version": "v2.4.1", @@ -7534,6 +7665,59 @@ }, "time": "2022-02-21T01:04:05+00:00" }, + { + "name": "phpstan/phpstan", + "version": "2.1.42", + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phpstan/phpstan/zipball/1279e1ce86ba768f0780c9d889852b4e02ff40d0", + "reference": "1279e1ce86ba768f0780c9d889852b4e02ff40d0", + "shasum": "" + }, + "require": { + "php": "^7.4|^8.0" + }, + "conflict": { + "phpstan/phpstan-shim": "*" + }, + "bin": [ + "phpstan", + "phpstan.phar" + ], + "type": "library", + "autoload": { + "files": [ + "bootstrap.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "PHPStan - PHP Static Analysis Tool", + "keywords": [ + "dev", + "static analysis" + ], + "support": { + "docs": "https://phpstan.org/user-guide/getting-started", + "forum": "https://github.com/phpstan/phpstan/discussions", + "issues": "https://github.com/phpstan/phpstan/issues", + "security": "https://github.com/phpstan/phpstan/security/policy", + "source": "https://github.com/phpstan/phpstan-src" + }, + "funding": [ + { + "url": "https://github.com/ondrejmirtes", + "type": "github" + }, + { + "url": "https://github.com/phpstan", + "type": "github" + } + ], + "time": "2026-03-17T14:58:32+00:00" + }, { "name": "phpunit/php-code-coverage", "version": "11.0.12", diff --git a/database/factories/AnalyticsSessionFactory.php b/database/factories/AnalyticsSessionFactory.php new file mode 100644 index 0000000..18acd90 --- /dev/null +++ b/database/factories/AnalyticsSessionFactory.php @@ -0,0 +1,48 @@ + */ +class AnalyticsSessionFactory extends Factory +{ + public function definition(): array + { + $started = now()->subMinutes(fake()->numberBetween(1, 60)); + + return [ + 'website_id' => Website::factory(), + 'visitor_id' => Visitor::factory(), + 'uid' => Str::random(32), + 'started_at' => $started, + 'ended_at' => $started->copy()->addSeconds(fake()->numberBetween(10, 600)), + 'duration' => fake()->numberBetween(10, 600), + 'is_bounce' => false, + 'entry_page' => '/', + 'exit_page' => '/about', + 'pages_count' => fake()->numberBetween(1, 10), + 'ip_address' => fake()->ipv4(), + 'referrer_url' => null, + 'referrer_domain' => null, + 'utm_source' => null, + 'utm_medium' => null, + 'utm_campaign' => null, + 'utm_term' => null, + 'utm_content' => null, + ]; + } + + public function bounce(): static + { + return $this->state([ + 'is_bounce' => true, + 'pages_count' => 1, + 'duration' => 0, + ]); + } +} diff --git a/database/factories/EventFactory.php b/database/factories/EventFactory.php new file mode 100644 index 0000000..f204498 --- /dev/null +++ b/database/factories/EventFactory.php @@ -0,0 +1,29 @@ + */ +class EventFactory extends Factory +{ + public function definition(): array + { + return [ + 'website_id' => Website::factory(), + 'visitor_id' => Visitor::factory(), + 'analytics_session_id' => AnalyticsSession::factory(), + 'name' => fake()->randomElement(['click', 'signup', 'purchase', 'download']), + 'category' => fake()->randomElement(['engagement', 'conversion', 'navigation']), + 'action' => fake()->word(), + 'label' => fake()->word(), + 'value' => fake()->optional(0.5)->randomFloat(2, 1, 100), + 'properties' => null, + 'occurred_at' => now(), + ]; + } +} diff --git a/database/factories/GoalConversionFactory.php b/database/factories/GoalConversionFactory.php new file mode 100644 index 0000000..7f3627c --- /dev/null +++ b/database/factories/GoalConversionFactory.php @@ -0,0 +1,26 @@ + */ +class GoalConversionFactory extends Factory +{ + public function definition(): array + { + return [ + 'goal_id' => Goal::factory(), + 'website_id' => Website::factory(), + 'visitor_id' => Visitor::factory(), + 'analytics_session_id' => AnalyticsSession::factory(), + 'value' => fake()->randomFloat(2, 1, 100), + 'converted_at' => now(), + ]; + } +} diff --git a/database/factories/GoalFactory.php b/database/factories/GoalFactory.php new file mode 100644 index 0000000..d5b10de --- /dev/null +++ b/database/factories/GoalFactory.php @@ -0,0 +1,63 @@ + */ +class GoalFactory extends Factory +{ + public function definition(): array + { + return [ + 'website_id' => Website::factory(), + 'name' => fake()->sentence(2), + 'type' => 'url_destination', + 'conditions' => ['path' => '/thank-you', 'match_type' => 'exact'], + 'default_value' => fake()->randomFloat(2, 1, 100), + 'is_active' => true, + ]; + } + + public function urlDestination(string $path = '/thank-you', string $matchType = 'exact'): static + { + return $this->state([ + 'type' => 'url_destination', + 'conditions' => ['path' => $path, 'match_type' => $matchType], + ]); + } + + public function eventMatch(string $eventName, ?string $category = null): static + { + return $this->state([ + 'type' => 'event_match', + 'conditions' => array_filter([ + 'event_name' => $eventName, + 'event_category' => $category, + ]), + ]); + } + + public function duration(int $seconds): static + { + return $this->state([ + 'type' => 'duration', + 'conditions' => ['seconds' => $seconds], + ]); + } + + public function pagesPerSession(int $count): static + { + return $this->state([ + 'type' => 'pages_per_session', + 'conditions' => ['page_count' => $count], + ]); + } + + public function inactive(): static + { + return $this->state(['is_active' => false]); + } +} diff --git a/database/factories/PageViewFactory.php b/database/factories/PageViewFactory.php new file mode 100644 index 0000000..e224310 --- /dev/null +++ b/database/factories/PageViewFactory.php @@ -0,0 +1,28 @@ + */ +class PageViewFactory extends Factory +{ + public function definition(): array + { + return [ + 'website_id' => Website::factory(), + 'visitor_id' => Visitor::factory(), + 'analytics_session_id' => AnalyticsSession::factory(), + 'url' => fake()->url(), + 'path' => '/'.fake()->slug(2), + 'title' => fake()->sentence(3), + 'referrer' => null, + 'time_on_page' => fake()->numberBetween(5, 300), + 'viewed_at' => now(), + ]; + } +} diff --git a/database/factories/VisitorFactory.php b/database/factories/VisitorFactory.php new file mode 100644 index 0000000..258ddbc --- /dev/null +++ b/database/factories/VisitorFactory.php @@ -0,0 +1,32 @@ + */ +class VisitorFactory extends Factory +{ + public function definition(): array + { + return [ + 'website_id' => Website::factory(), + 'uid' => Str::random(32), + 'first_seen_at' => now(), + 'last_seen_at' => now(), + 'device_type' => fake()->randomElement(['desktop', 'mobile', 'tablet']), + 'browser' => fake()->randomElement(['Chrome', 'Firefox', 'Safari', 'Edge']), + 'browser_version' => fake()->numerify('##.#'), + 'os' => fake()->randomElement(['Windows', 'macOS', 'Linux', 'iOS', 'Android']), + 'os_version' => fake()->numerify('##.#'), + 'screen_resolution' => fake()->randomElement(['1920x1080', '1366x768', '390x844']), + 'language' => fake()->randomElement(['en', 'de', 'fr', 'es']), + 'country' => fake()->countryCode(), + 'region' => fake()->state(), + 'city' => fake()->city(), + ]; + } +} diff --git a/database/factories/WebsiteFactory.php b/database/factories/WebsiteFactory.php new file mode 100644 index 0000000..8c750ef --- /dev/null +++ b/database/factories/WebsiteFactory.php @@ -0,0 +1,29 @@ + */ +class WebsiteFactory extends Factory +{ + public function definition(): array + { + return [ + 'user_id' => User::factory(), + 'name' => fake()->company(), + 'domain' => fake()->domainName(), + 'tracking_id' => 'TA-'.strtoupper(Str::random(10)), + 'is_active' => true, + 'settings' => [], + ]; + } + + public function inactive(): static + { + return $this->state(['is_active' => false]); + } +} diff --git a/phpstan.neon b/phpstan.neon new file mode 100644 index 0000000..f760928 --- /dev/null +++ b/phpstan.neon @@ -0,0 +1,11 @@ +includes: + - vendor/larastan/larastan/extension.neon + +parameters: + paths: + - app/ + level: 5 + ignoreErrors: + - '#Call to an undefined method Illuminate\\Database\\Eloquent\\Builder#' + excludePaths: + - app/Console/Commands/Inspire.php diff --git a/tests/Feature/CollectControllerTest.php b/tests/Feature/CollectControllerTest.php new file mode 100644 index 0000000..d167d7b --- /dev/null +++ b/tests/Feature/CollectControllerTest.php @@ -0,0 +1,182 @@ +website = Website::factory()->create(['domain' => 'example.com']); + } + + private function validPayload(array $overrides = []): array + { + return array_merge([ + 'tid' => $this->website->tracking_id, + 'vid' => 'visitor-abc-123', + 'sid' => 'session-abc-123', + 'type' => 'pageview', + 'url' => 'https://example.com/hello', + 'path' => '/hello', + 'title' => 'Hello Page', + ], $overrides); + } + + // -- Store endpoint -- + + public function test_valid_pageview_payload_dispatches_job(): void + { + Bus::fake(); + + $response = $this->postJson('/api/collect', $this->validPayload()); + + $response->assertOk()->assertJson(['ok' => true]); + Bus::assertDispatched(ProcessTrackingPayload::class); + } + + public function test_valid_event_payload_dispatches_job(): void + { + Bus::fake(); + + $response = $this->postJson('/api/collect', $this->validPayload([ + 'type' => 'event', + 'event_name' => 'signup', + 'event_category' => 'conversion', + ])); + + $response->assertOk()->assertJson(['ok' => true]); + Bus::assertDispatched(ProcessTrackingPayload::class); + } + + public function test_missing_required_fields_returns_validation_error(): void + { + $response = $this->postJson('/api/collect', []); + + $response->assertUnprocessable(); + $response->assertJsonValidationErrors(['tid', 'vid', 'sid', 'type']); + } + + public function test_invalid_type_returns_validation_error(): void + { + $response = $this->postJson('/api/collect', $this->validPayload([ + 'type' => 'invalid_type', + ])); + + $response->assertUnprocessable(); + $response->assertJsonValidationErrors(['type']); + } + + public function test_domain_mismatch_returns_error(): void + { + Bus::fake(); + + $response = $this->postJson('/api/collect', $this->validPayload([ + 'url' => 'https://evil-site.com/phishing', + ])); + + $response->assertStatus(400); + $response->assertJson(['ok' => false, 'reason' => 'domain_mismatch']); + Bus::assertNotDispatched(ProcessTrackingPayload::class); + } + + public function test_subdomain_is_allowed(): void + { + Bus::fake(); + + $response = $this->postJson('/api/collect', $this->validPayload([ + 'url' => 'https://www.example.com/page', + ])); + + $response->assertOk()->assertJson(['ok' => true]); + Bus::assertDispatched(ProcessTrackingPayload::class); + } + + public function test_unknown_tracking_id_still_succeeds(): void + { + Bus::fake(); + + $response = $this->postJson('/api/collect', $this->validPayload([ + 'tid' => 'TA-NONEXISTENT', + 'url' => null, + ])); + + $response->assertOk()->assertJson(['ok' => true]); + Bus::assertDispatched(ProcessTrackingPayload::class); + } + + public function test_bot_requests_are_rejected(): void + { + Bus::fake(); + + $response = $this->postJson('/api/collect', $this->validPayload(), [ + 'User-Agent' => 'Mozilla/5.0 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)', + ]); + + $response->assertOk()->assertJson(['ok' => false, 'reason' => 'bot']); + Bus::assertNotDispatched(ProcessTrackingPayload::class); + } + + public function test_payload_with_utm_parameters(): void + { + Bus::fake(); + + $response = $this->postJson('/api/collect', $this->validPayload([ + 'utm_source' => 'google', + 'utm_medium' => 'cpc', + 'utm_campaign' => 'spring_sale', + 'utm_term' => 'shoes', + 'utm_content' => 'ad_v1', + ])); + + $response->assertOk()->assertJson(['ok' => true]); + Bus::assertDispatched(ProcessTrackingPayload::class); + } + + // -- Pixel endpoint -- + + public function test_pixel_returns_gif(): void + { + Bus::fake(); + + $response = $this->get('/api/pixel?tid='.$this->website->tracking_id); + + $response->assertOk(); + $response->assertHeader('Content-Type', 'image/gif'); + Bus::assertDispatched(ProcessTrackingPayload::class); + } + + public function test_pixel_without_tid_returns_gif_without_dispatching(): void + { + Bus::fake(); + + $response = $this->get('/api/pixel'); + + $response->assertOk(); + $response->assertHeader('Content-Type', 'image/gif'); + Bus::assertNotDispatched(ProcessTrackingPayload::class); + } + + public function test_pixel_rejects_bots(): void + { + Bus::fake(); + + $response = $this->get('/api/pixel?tid='.$this->website->tracking_id, [ + 'User-Agent' => 'Mozilla/5.0 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)', + ]); + + $response->assertOk(); + $response->assertHeader('Content-Type', 'image/gif'); + Bus::assertNotDispatched(ProcessTrackingPayload::class); + } +} diff --git a/tests/Feature/WebsiteControllerTest.php b/tests/Feature/WebsiteControllerTest.php new file mode 100644 index 0000000..557d94e --- /dev/null +++ b/tests/Feature/WebsiteControllerTest.php @@ -0,0 +1,204 @@ +user = User::factory()->create(); + } + + // -- Index -- + + public function test_guests_cannot_access_website_list(): void + { + $this->get(route('websites.index'))->assertRedirect(route('login')); + } + + public function test_authenticated_user_can_see_their_websites(): void + { + $website = Website::factory()->for($this->user)->create(); + Website::factory()->create(); // another user's website + + $response = $this->actingAs($this->user)->get(route('websites.index')); + + $response->assertOk(); + $response->assertSeeText($website->name); + } + + // -- Create / Store -- + + public function test_user_can_view_create_form(): void + { + $this->actingAs($this->user) + ->get(route('websites.create')) + ->assertOk(); + } + + public function test_user_can_create_website(): void + { + $response = $this->actingAs($this->user)->post(route('websites.store'), [ + 'name' => 'My Site', + 'domain' => 'mysite.com', + ]); + + $response->assertRedirect(route('websites.index')); + $this->assertDatabaseHas('websites', [ + 'user_id' => $this->user->id, + 'name' => 'My Site', + 'domain' => 'mysite.com', + ]); + } + + public function test_create_website_generates_tracking_id(): void + { + $this->actingAs($this->user)->post(route('websites.store'), [ + 'name' => 'My Site', + 'domain' => 'mysite.com', + ]); + + $website = Website::where('domain', 'mysite.com')->first(); + $this->assertNotNull($website->tracking_id); + $this->assertStringStartsWith('TA-', $website->tracking_id); + } + + public function test_create_website_validates_domain_format(): void + { + $response = $this->actingAs($this->user)->post(route('websites.store'), [ + 'name' => 'My Site', + 'domain' => 'not a domain!!', + ]); + + $response->assertSessionHasErrors('domain'); + } + + public function test_create_website_requires_name(): void + { + $response = $this->actingAs($this->user)->post(route('websites.store'), [ + 'name' => '', + 'domain' => 'mysite.com', + ]); + + $response->assertSessionHasErrors('name'); + } + + // -- Edit / Update -- + + public function test_owner_can_edit_website(): void + { + $website = Website::factory()->for($this->user)->create(); + + $this->actingAs($this->user) + ->get(route('websites.edit', $website)) + ->assertOk(); + } + + public function test_non_owner_cannot_edit_website(): void + { + $otherUser = User::factory()->create(); + $website = Website::factory()->for($otherUser)->create(); + + $this->actingAs($this->user) + ->get(route('websites.edit', $website)) + ->assertForbidden(); + } + + public function test_owner_can_update_website(): void + { + $website = Website::factory()->for($this->user)->create(); + + $response = $this->actingAs($this->user)->put(route('websites.update', $website), [ + 'name' => 'Updated Name', + 'domain' => 'updated.com', + ]); + + $response->assertRedirect(route('dashboard.show', $website)); + $this->assertDatabaseHas('websites', [ + 'id' => $website->id, + 'name' => 'Updated Name', + 'domain' => 'updated.com', + ]); + } + + public function test_non_owner_cannot_update_website(): void + { + $otherUser = User::factory()->create(); + $website = Website::factory()->for($otherUser)->create(); + + $this->actingAs($this->user) + ->put(route('websites.update', $website), [ + 'name' => 'Hacked', + 'domain' => 'hacked.com', + ]) + ->assertForbidden(); + } + + // -- Delete -- + + public function test_owner_can_delete_website(): void + { + $website = Website::factory()->for($this->user)->create(); + + $response = $this->actingAs($this->user) + ->delete(route('websites.destroy', $website)); + + $response->assertRedirect(route('websites.index')); + $this->assertDatabaseMissing('websites', ['id' => $website->id]); + } + + public function test_non_owner_cannot_delete_website(): void + { + $otherUser = User::factory()->create(); + $website = Website::factory()->for($otherUser)->create(); + + $this->actingAs($this->user) + ->delete(route('websites.destroy', $website)) + ->assertForbidden(); + + $this->assertDatabaseHas('websites', ['id' => $website->id]); + } + + // -- Show redirects to dashboard -- + + public function test_show_redirects_to_dashboard(): void + { + $website = Website::factory()->for($this->user)->create(); + + $this->actingAs($this->user) + ->get(route('websites.show', $website)) + ->assertRedirect(route('dashboard.show', $website)); + } + + // -- Snippet -- + + public function test_owner_can_view_snippet(): void + { + $website = Website::factory()->for($this->user)->create(); + + $this->actingAs($this->user) + ->get(route('websites.snippet', $website)) + ->assertOk() + ->assertSeeText($website->tracking_id); + } + + public function test_non_owner_cannot_view_snippet(): void + { + $otherUser = User::factory()->create(); + $website = Website::factory()->for($otherUser)->create(); + + $this->actingAs($this->user) + ->get(route('websites.snippet', $website)) + ->assertForbidden(); + } +} diff --git a/tests/Unit/AnalyticsQueryServiceTest.php b/tests/Unit/AnalyticsQueryServiceTest.php new file mode 100644 index 0000000..e794b2f --- /dev/null +++ b/tests/Unit/AnalyticsQueryServiceTest.php @@ -0,0 +1,285 @@ +website = Website::factory()->create(); + } + + private function makeService(?Carbon $from = null, ?Carbon $to = null): AnalyticsQueryService + { + return AnalyticsQueryService::for( + $this->website, + $from ?? Carbon::today()->subDays(6), + $to ?? Carbon::today(), + ); + } + + private function createVisitorWithPageViews(int $pvCount, ?Carbon $viewedAt = null): void + { + $visitor = Visitor::factory()->for($this->website)->create(); + $session = AnalyticsSession::factory()->for($this->website)->create([ + 'visitor_id' => $visitor->id, + 'started_at' => $viewedAt ?? now(), + 'ended_at' => $viewedAt ?? now(), + 'duration' => 60, + 'pages_count' => $pvCount, + 'is_bounce' => $pvCount <= 1, + ]); + + for ($i = 0; $i < $pvCount; $i++) { + PageView::factory()->for($this->website)->create([ + 'visitor_id' => $visitor->id, + 'analytics_session_id' => $session->id, + 'viewed_at' => $viewedAt ?? now(), + 'path' => '/page-'.$i, + 'time_on_page' => 30, + ]); + } + } + + // -- Summary metrics from raw data -- + + public function test_total_page_views_counts_correctly(): void + { + $this->createVisitorWithPageViews(3); + $this->createVisitorWithPageViews(2); + + $service = $this->makeService(); + + $this->assertEquals(5, $service->totalPageViews()); + } + + public function test_unique_visitors_counts_distinct(): void + { + $this->createVisitorWithPageViews(2); + $this->createVisitorWithPageViews(1); + + $service = $this->makeService(); + + $this->assertEquals(2, $service->uniqueVisitors()); + } + + public function test_total_sessions(): void + { + $this->createVisitorWithPageViews(2); + $this->createVisitorWithPageViews(1); + + $service = $this->makeService(); + + $this->assertEquals(2, $service->totalSessions()); + } + + public function test_bounce_rate_calculation(): void + { + // Non-bounce session + $this->createVisitorWithPageViews(3); + // Bounce session + $visitor = Visitor::factory()->for($this->website)->create(); + AnalyticsSession::factory()->for($this->website)->bounce()->create([ + 'visitor_id' => $visitor->id, + 'started_at' => now(), + ]); + + $service = $this->makeService(); + + $this->assertEquals(50.0, $service->bounceRate()); + } + + public function test_bounce_rate_zero_when_no_sessions(): void + { + $service = $this->makeService(); + $this->assertEquals(0, $service->bounceRate()); + } + + public function test_avg_session_duration(): void + { + $visitor1 = Visitor::factory()->for($this->website)->create(); + AnalyticsSession::factory()->for($this->website)->create([ + 'visitor_id' => $visitor1->id, + 'duration' => 100, + 'started_at' => now(), + ]); + $visitor2 = Visitor::factory()->for($this->website)->create(); + AnalyticsSession::factory()->for($this->website)->create([ + 'visitor_id' => $visitor2->id, + 'duration' => 200, + 'started_at' => now(), + ]); + + $service = $this->makeService(); + + $this->assertEquals(150, $service->avgSessionDuration()); + } + + public function test_summary_returns_all_keys(): void + { + $service = $this->makeService(); + $summary = $service->summary(); + + $this->assertArrayHasKey('page_views', $summary); + $this->assertArrayHasKey('visitors', $summary); + $this->assertArrayHasKey('sessions', $summary); + $this->assertArrayHasKey('bounce_rate', $summary); + $this->assertArrayHasKey('avg_duration', $summary); + $this->assertArrayHasKey('avg_pages', $summary); + } + + // -- Date range filtering -- + + public function test_only_counts_data_within_date_range(): void + { + $this->createVisitorWithPageViews(2, Carbon::today()); + $this->createVisitorWithPageViews(1, Carbon::today()->subDays(30)); + + $service = $this->makeService(Carbon::today(), Carbon::today()); + + $this->assertEquals(2, $service->totalPageViews()); + $this->assertEquals(1, $service->uniqueVisitors()); + } + + // -- Aggregate data -- + + public function test_reads_from_aggregate_tables_for_past_days(): void + { + DailyAggregate::create([ + 'website_id' => $this->website->id, + 'date' => Carbon::yesterday()->toDateString(), + 'visitors' => 50, + 'sessions' => 80, + 'page_views' => 200, + 'bounce_count' => 20, + 'total_duration' => 4000, + ]); + + $service = $this->makeService( + Carbon::yesterday(), + Carbon::yesterday(), + ); + + $this->assertEquals(200, $service->totalPageViews()); + $this->assertEquals(50, $service->uniqueVisitors()); + $this->assertEquals(80, $service->totalSessions()); + } + + // -- Top pages -- + + public function test_top_pages_returns_ordered_results(): void + { + $visitor = Visitor::factory()->for($this->website)->create(); + $session = AnalyticsSession::factory()->for($this->website)->create([ + 'visitor_id' => $visitor->id, + 'started_at' => now(), + ]); + + PageView::factory()->for($this->website)->count(3)->create([ + 'visitor_id' => $visitor->id, + 'analytics_session_id' => $session->id, + 'path' => '/popular', + 'viewed_at' => now(), + ]); + PageView::factory()->for($this->website)->create([ + 'visitor_id' => $visitor->id, + 'analytics_session_id' => $session->id, + 'path' => '/less-popular', + 'viewed_at' => now(), + ]); + + $service = $this->makeService(); + $pages = $service->topPages(10); + + $this->assertGreaterThanOrEqual(2, $pages->count()); + $this->assertEquals('/popular', $pages->first()->path); + } + + // -- Visitors over time -- + + public function test_visitors_over_time_returns_collection(): void + { + $this->createVisitorWithPageViews(1); + + $service = $this->makeService(); + $timeseries = $service->visitorsOverTime(); + + $this->assertNotEmpty($timeseries); + $first = $timeseries->first(); + $this->assertArrayHasKey('period', $first); + $this->assertArrayHasKey('visitors', $first); + $this->assertArrayHasKey('page_views', $first); + $this->assertArrayHasKey('sessions', $first); + } + + // -- Traffic sources -- + + public function test_traffic_sources_groups_by_referrer(): void + { + $visitor = Visitor::factory()->for($this->website)->create(); + AnalyticsSession::factory()->for($this->website)->create([ + 'visitor_id' => $visitor->id, + 'referrer_domain' => 'google.com', + 'started_at' => now(), + ]); + AnalyticsSession::factory()->for($this->website)->create([ + 'visitor_id' => $visitor->id, + 'referrer_domain' => '', + 'started_at' => now(), + ]); + + $service = $this->makeService(); + $sources = $service->trafficSources(); + + $this->assertGreaterThanOrEqual(1, $sources->count()); + } + + // -- Segment filtering -- + + public function test_segment_filters_data_by_visitor_ids(): void + { + $visitor1 = Visitor::factory()->for($this->website)->create(['country' => 'DE']); + $visitor2 = Visitor::factory()->for($this->website)->create(['country' => 'US']); + $session1 = AnalyticsSession::factory()->for($this->website)->create([ + 'visitor_id' => $visitor1->id, + 'started_at' => now(), + ]); + $session2 = AnalyticsSession::factory()->for($this->website)->create([ + 'visitor_id' => $visitor2->id, + 'started_at' => now(), + ]); + + PageView::factory()->for($this->website)->count(3)->create([ + 'visitor_id' => $visitor1->id, + 'analytics_session_id' => $session1->id, + 'viewed_at' => now(), + ]); + PageView::factory()->for($this->website)->count(2)->create([ + 'visitor_id' => $visitor2->id, + 'analytics_session_id' => $session2->id, + 'viewed_at' => now(), + ]); + + $service = $this->makeService(); + $service->withSegment(collect([$visitor1->id])); + + $this->assertEquals(3, $service->totalPageViews()); + $this->assertEquals(1, $service->uniqueVisitors()); + $this->assertEquals(1, $service->totalSessions()); + } +} diff --git a/tests/Unit/GoalEvaluationServiceTest.php b/tests/Unit/GoalEvaluationServiceTest.php new file mode 100644 index 0000000..d2fdf3e --- /dev/null +++ b/tests/Unit/GoalEvaluationServiceTest.php @@ -0,0 +1,284 @@ +service = new GoalEvaluationService; + $this->website = Website::factory()->create(); + $this->visitor = Visitor::factory()->for($this->website)->create(); + $this->session = AnalyticsSession::factory()->for($this->website)->create([ + 'visitor_id' => $this->visitor->id, + 'duration' => 120, + 'pages_count' => 5, + ]); + } + + // -- URL Destination -- + + public function test_url_destination_exact_match(): void + { + $goal = Goal::factory()->for($this->website)->urlDestination('/thank-you')->create(); + + $this->service->evaluate($this->website, $this->visitor, $this->session, [ + 'type' => 'pageview', + 'path' => '/thank-you', + ], Carbon::now()); + + $this->assertDatabaseHas('goal_conversions', [ + 'goal_id' => $goal->id, + 'visitor_id' => $this->visitor->id, + ]); + } + + public function test_url_destination_exact_mismatch(): void + { + Goal::factory()->for($this->website)->urlDestination('/thank-you')->create(); + + $this->service->evaluate($this->website, $this->visitor, $this->session, [ + 'type' => 'pageview', + 'path' => '/other-page', + ], Carbon::now()); + + $this->assertDatabaseCount('goal_conversions', 0); + } + + public function test_url_destination_starts_with(): void + { + $goal = Goal::factory()->for($this->website)->urlDestination('/checkout', 'starts_with')->create(); + + $this->service->evaluate($this->website, $this->visitor, $this->session, [ + 'type' => 'pageview', + 'path' => '/checkout/step-2', + ], Carbon::now()); + + $this->assertDatabaseHas('goal_conversions', ['goal_id' => $goal->id]); + } + + public function test_url_destination_contains(): void + { + $goal = Goal::factory()->for($this->website)->urlDestination('success', 'contains')->create(); + + $this->service->evaluate($this->website, $this->visitor, $this->session, [ + 'type' => 'pageview', + 'path' => '/order/success/123', + ], Carbon::now()); + + $this->assertDatabaseHas('goal_conversions', ['goal_id' => $goal->id]); + } + + public function test_url_destination_regex(): void + { + $goal = Goal::factory()->for($this->website)->urlDestination('/^\\/order\\/\\d+$/', 'regex')->create(); + + $this->service->evaluate($this->website, $this->visitor, $this->session, [ + 'type' => 'pageview', + 'path' => '/order/42', + ], Carbon::now()); + + $this->assertDatabaseHas('goal_conversions', ['goal_id' => $goal->id]); + } + + // -- Event Match -- + + public function test_event_match_by_name(): void + { + $goal = Goal::factory()->for($this->website)->eventMatch('purchase')->create(); + + $this->service->evaluate($this->website, $this->visitor, $this->session, [ + 'type' => 'event', + 'event_name' => 'purchase', + ], Carbon::now()); + + $this->assertDatabaseHas('goal_conversions', ['goal_id' => $goal->id]); + } + + public function test_event_match_wrong_name_does_not_convert(): void + { + Goal::factory()->for($this->website)->eventMatch('purchase')->create(); + + $this->service->evaluate($this->website, $this->visitor, $this->session, [ + 'type' => 'event', + 'event_name' => 'signup', + ], Carbon::now()); + + $this->assertDatabaseCount('goal_conversions', 0); + } + + public function test_event_match_with_category(): void + { + $goal = Goal::factory()->for($this->website)->eventMatch('click', 'cta')->create(); + + // wrong category + $this->service->evaluate($this->website, $this->visitor, $this->session, [ + 'type' => 'event', + 'event_name' => 'click', + 'event_category' => 'navigation', + ], Carbon::now()); + $this->assertDatabaseCount('goal_conversions', 0); + + // right category + $this->service->evaluate($this->website, $this->visitor, $this->session, [ + 'type' => 'event', + 'event_name' => 'click', + 'event_category' => 'cta', + ], Carbon::now()); + $this->assertDatabaseCount('goal_conversions', 1); + } + + public function test_event_match_requires_event_type(): void + { + Goal::factory()->for($this->website)->eventMatch('purchase')->create(); + + $this->service->evaluate($this->website, $this->visitor, $this->session, [ + 'type' => 'pageview', + 'event_name' => 'purchase', + ], Carbon::now()); + + $this->assertDatabaseCount('goal_conversions', 0); + } + + // -- Duration -- + + public function test_duration_goal_converts_when_threshold_met(): void + { + $goal = Goal::factory()->for($this->website)->duration(60)->create(); + + $this->service->evaluate($this->website, $this->visitor, $this->session, [ + 'type' => 'pageview', + 'path' => '/', + ], Carbon::now()); + + $this->assertDatabaseHas('goal_conversions', ['goal_id' => $goal->id]); + } + + public function test_duration_goal_does_not_convert_below_threshold(): void + { + Goal::factory()->for($this->website)->duration(300)->create(); + + $this->service->evaluate($this->website, $this->visitor, $this->session, [ + 'type' => 'pageview', + 'path' => '/', + ], Carbon::now()); + + $this->assertDatabaseCount('goal_conversions', 0); + } + + // -- Pages per session -- + + public function test_pages_per_session_goal_converts(): void + { + $goal = Goal::factory()->for($this->website)->pagesPerSession(3)->create(); + + $this->service->evaluate($this->website, $this->visitor, $this->session, [ + 'type' => 'pageview', + 'path' => '/', + ], Carbon::now()); + + $this->assertDatabaseHas('goal_conversions', ['goal_id' => $goal->id]); + } + + public function test_pages_per_session_goal_does_not_convert_below_threshold(): void + { + Goal::factory()->for($this->website)->pagesPerSession(10)->create(); + + $this->service->evaluate($this->website, $this->visitor, $this->session, [ + 'type' => 'pageview', + 'path' => '/', + ], Carbon::now()); + + $this->assertDatabaseCount('goal_conversions', 0); + } + + // -- De-duplication -- + + public function test_does_not_duplicate_conversion_within_same_session(): void + { + $goal = Goal::factory()->for($this->website)->urlDestination('/thank-you')->create(); + + $this->service->evaluate($this->website, $this->visitor, $this->session, [ + 'type' => 'pageview', + 'path' => '/thank-you', + ], Carbon::now()); + + $this->service->evaluate($this->website, $this->visitor, $this->session, [ + 'type' => 'pageview', + 'path' => '/thank-you', + ], Carbon::now()); + + $this->assertDatabaseCount('goal_conversions', 1); + } + + // -- Inactive goals -- + + public function test_inactive_goals_are_skipped(): void + { + Goal::factory()->for($this->website)->urlDestination('/thank-you')->inactive()->create(); + + $this->service->evaluate($this->website, $this->visitor, $this->session, [ + 'type' => 'pageview', + 'path' => '/thank-you', + ], Carbon::now()); + + $this->assertDatabaseCount('goal_conversions', 0); + } + + // -- Conversion value -- + + public function test_conversion_stores_goal_default_value(): void + { + $goal = Goal::factory()->for($this->website)->urlDestination('/buy')->create([ + 'default_value' => 49.99, + ]); + + $this->service->evaluate($this->website, $this->visitor, $this->session, [ + 'type' => 'pageview', + 'path' => '/buy', + ], Carbon::now()); + + $conversion = GoalConversion::where('goal_id', $goal->id)->first(); + $this->assertNotNull($conversion); + $this->assertEquals(49.99, (float) $conversion->value); + } + + // -- Multiple goals -- + + public function test_evaluates_multiple_goals_independently(): void + { + $goal1 = Goal::factory()->for($this->website)->urlDestination('/thank-you')->create(); + $goal2 = Goal::factory()->for($this->website)->duration(60)->create(); + Goal::factory()->for($this->website)->urlDestination('/other-page')->create(); + + $this->service->evaluate($this->website, $this->visitor, $this->session, [ + 'type' => 'pageview', + 'path' => '/thank-you', + ], Carbon::now()); + + $this->assertDatabaseCount('goal_conversions', 2); + $this->assertDatabaseHas('goal_conversions', ['goal_id' => $goal1->id]); + $this->assertDatabaseHas('goal_conversions', ['goal_id' => $goal2->id]); + } +}