Skip to content

Testing

Braden Keith edited this page Sep 20, 2025 · 1 revision

Testing

Comprehensive testing strategies for availability rules and logic.

Basic Testing

Testing Rule Creation

<?php

namespace Tests\Feature;

use Tests\TestCase;
use App\Models\Resource;
use RomegaSoftware\Availability\Support\Effect;
use Illuminate\Foundation\Testing\RefreshDatabase;

class AvailabilityRuleTest extends TestCase
{
    use RefreshDatabase;
    
    public function test_can_create_availability_rule()
    {
        $resource = Resource::factory()->create();
        
        $rule = $resource->availabilityRules()->create([
            'type' => 'weekdays',
            'config' => ['days' => [1, 2, 3, 4, 5]],
            'effect' => Effect::Allow,
            'priority' => 10,
            'enabled' => true,
        ]);
        
        $this->assertDatabaseHas('availability_rules', [
            'subject_type' => Resource::class,
            'subject_id' => $resource->id,
            'type' => 'weekdays',
            'effect' => 'allow',
        ]);
        
        $this->assertEquals([1, 2, 3, 4, 5], $rule->config['days']);
    }
}

Testing Availability Checks

use Carbon\Carbon;
use RomegaSoftware\Availability\Support\AvailabilityEngine;

class AvailabilityCheckTest extends TestCase
{
    private AvailabilityEngine $engine;
    
    protected function setUp(): void
    {
        parent::setUp();
        $this->engine = app(AvailabilityEngine::class);
    }
    
    public function test_resource_available_during_business_hours()
    {
        $resource = Resource::factory()->create([
            'availability_default' => Effect::Deny,
        ]);
        
        // Setup business hours
        $resource->availabilityRules()->createMany([
            [
                'type' => 'weekdays',
                'config' => ['days' => [1, 2, 3, 4, 5]],
                'effect' => Effect::Allow,
                'priority' => 10,
            ],
            [
                'type' => 'time_of_day',
                'config' => ['from' => '09:00', 'to' => '17:00'],
                'effect' => Effect::Allow,
                'priority' => 20,
            ],
        ]);
        
        // Monday at 10 AM - should be available
        $monday10am = Carbon::parse('2025-01-13 10:00:00');
        $this->assertTrue($this->engine->isAvailable($resource, $monday10am));
        
        // Saturday at 10 AM - should not be available
        $saturday10am = Carbon::parse('2025-01-18 10:00:00');
        $this->assertFalse($this->engine->isAvailable($resource, $saturday10am));
        
        // Monday at 8 PM - should not be available
        $monday8pm = Carbon::parse('2025-01-13 20:00:00');
        $this->assertFalse($this->engine->isAvailable($resource, $monday8pm));
    }
}

Testing Priority Logic

class PriorityTest extends TestCase
{
    public function test_higher_priority_rules_override_lower()
    {
        $resource = Resource::factory()->create([
            'availability_default' => Effect::Deny,
        ]);
        
        // Base rule: Allow weekdays
        $resource->availabilityRules()->create([
            'type' => 'weekdays',
            'config' => ['days' => [1, 2, 3, 4, 5]],
            'effect' => Effect::Allow,
            'priority' => 10,
        ]);
        
        // Override: Deny on specific date
        $resource->availabilityRules()->create([
            'type' => 'blackout_date',
            'config' => ['dates' => ['2025-01-15']],
            'effect' => Effect::Deny,
            'priority' => 50,
        ]);
        
        $engine = app(AvailabilityEngine::class);
        
        // Regular Wednesday - should be available
        $wednesday = Carbon::parse('2025-01-08 10:00:00');
        $this->assertTrue($engine->isAvailable($resource, $wednesday));
        
        // Blackout Wednesday - should not be available
        $blackoutWednesday = Carbon::parse('2025-01-15 10:00:00');
        $this->assertFalse($engine->isAvailable($resource, $blackoutWednesday));
    }
}

Testing Custom Evaluators

namespace Tests\Unit\Evaluators;

use Tests\TestCase;
use App\Availability\Evaluators\MinimumNoticeEvaluator;
use App\Models\Resource;
use Carbon\Carbon;

class MinimumNoticeEvaluatorTest extends TestCase
{
    private MinimumNoticeEvaluator $evaluator;
    
    protected function setUp(): void
    {
        parent::setUp();
        $this->evaluator = new MinimumNoticeEvaluator();
    }
    
    public function test_requires_minimum_notice_hours()
    {
        $resource = Resource::factory()->create();
        $config = ['hours' => 48];
        
        // 24 hours notice - not enough
        Carbon::setTestNow('2025-01-01 10:00:00');
        $tooSoon = Carbon::parse('2025-01-02 09:00:00');
        $this->assertFalse(
            $this->evaluator->matches($config, $tooSoon, $resource)
        );
        
        // 49 hours notice - enough
        $farEnough = Carbon::parse('2025-01-03 11:00:00');
        $this->assertTrue(
            $this->evaluator->matches($config, $farEnough, $resource)
        );
    }
    
    public function test_handles_business_hours_only()
    {
        $resource = Resource::factory()->create();
        $config = [
            'hours' => 24,
            'business_hours_only' => true,
        ];
        
        // Test that weekends don't count
        Carbon::setTestNow('2025-01-03 17:00:00'); // Friday 5 PM
        
        // Monday 5 PM would be 72 hours but only 8 business hours
        $monday = Carbon::parse('2025-01-06 17:00:00');
        
        $this->assertFalse(
            $this->evaluator->matches($config, $monday, $resource)
        );
    }
}

Testing Inventory Gates

class InventoryGateTest extends TestCase
{
    public function test_inventory_gate_blocks_when_insufficient_stock()
    {
        $product = Product::factory()->create([
            'stock_quantity' => 5,
            'availability_default' => Effect::Deny,
        ]);
        
        // Configure resolver
        config(['availability.inventory_gate.resolver' => function ($subject) {
            return $subject->stock_quantity;
        }]);
        
        $product->availabilityRules()->create([
            'type' => 'inventory_gate',
            'config' => ['min' => 10],
            'effect' => Effect::Allow,
            'priority' => 10,
        ]);
        
        $engine = app(AvailabilityEngine::class);
        
        // Should not be available (5 < 10)
        $this->assertFalse($engine->isAvailable($product, now()));
        
        // Update stock
        $product->update(['stock_quantity' => 15]);
        
        // Should now be available (15 >= 10)
        $this->assertTrue($engine->isAvailable($product, now()));
    }
    
    public function test_inventory_gate_with_boolean_resolver()
    {
        $service = Service::factory()->create();
        
        $hasCapacity = true;
        config(['availability.inventory_gate.resolver' => function () use (&$hasCapacity) {
            return $hasCapacity;
        }]);
        
        $service->availabilityRules()->create([
            'type' => 'inventory_gate',
            'config' => ['min' => 1],
            'effect' => Effect::Allow,
            'priority' => 10,
        ]);
        
        $engine = app(AvailabilityEngine::class);
        
        // Should be available
        $this->assertTrue($engine->isAvailable($service, now()));
        
        // Change capacity
        $hasCapacity = false;
        
        // Should not be available
        $this->assertFalse($engine->isAvailable($service, now()));
    }
}

Integration Testing

class BookingIntegrationTest extends TestCase
{
    public function test_complete_booking_flow()
    {
        $room = Room::factory()->create([
            'availability_default' => Effect::Deny,
            'availability_timezone' => 'America/New_York',
        ]);
        
        // Setup availability
        $this->setupRoomAvailability($room);
        
        // Try to book during available time
        $bookingTime = Carbon::parse('2025-01-15 14:00:00', 'America/New_York');
        
        $response = $this->postJson('/api/bookings', [
            'room_id' => $room->id,
            'start_time' => $bookingTime->toIso8601String(),
            'duration' => 60,
        ]);
        
        $response->assertStatus(201);
        $this->assertDatabaseHas('bookings', [
            'room_id' => $room->id,
        ]);
        
        // Try to double-book (should fail)
        $response = $this->postJson('/api/bookings', [
            'room_id' => $room->id,
            'start_time' => $bookingTime->toIso8601String(),
            'duration' => 30,
        ]);
        
        $response->assertStatus(422);
        $response->assertJsonPath('errors.room', ['Room is not available at this time']);
    }
    
    private function setupRoomAvailability($room)
    {
        $room->availabilityRules()->createMany([
            [
                'type' => 'weekdays',
                'config' => ['days' => [1, 2, 3, 4, 5]],
                'effect' => Effect::Allow,
                'priority' => 10,
            ],
            [
                'type' => 'time_of_day',
                'config' => ['from' => '09:00', 'to' => '17:00'],
                'effect' => Effect::Allow,
                'priority' => 20,
            ],
        ]);
    }
}

Testing Helpers

Factory States

namespace Database\Factories;

use App\Models\Resource;
use RomegaSoftware\Availability\Support\Effect;

class ResourceFactory extends Factory
{
    public function definition()
    {
        return [
            'name' => $this->faker->words(3, true),
            'availability_default' => Effect::Deny,
            'availability_timezone' => 'UTC',
        ];
    }
    
    public function available()
    {
        return $this->state(function () {
            return [
                'availability_default' => Effect::Allow,
            ];
        })->afterCreating(function (Resource $resource) {
            $resource->availabilityRules()->create([
                'type' => 'weekdays',
                'config' => ['days' => [1, 2, 3, 4, 5]],
                'effect' => Effect::Allow,
                'priority' => 10,
            ]);
        });
    }
    
    public function withBusinessHours()
    {
        return $this->afterCreating(function (Resource $resource) {
            $resource->availabilityRules()->createMany([
                [
                    'type' => 'weekdays',
                    'config' => ['days' => [1, 2, 3, 4, 5]],
                    'effect' => Effect::Allow,
                    'priority' => 10,
                ],
                [
                    'type' => 'time_of_day',
                    'config' => ['from' => '09:00', 'to' => '17:00'],
                    'effect' => Effect::Allow,
                    'priority' => 20,
                ],
            ]);
        });
    }
}

Test Trait

namespace Tests\Traits;

use RomegaSoftware\Availability\Support\Effect;

trait TestsAvailability
{
    protected function assertAvailable($subject, $moment, $message = '')
    {
        $engine = app(AvailabilityEngine::class);
        $this->assertTrue(
            $engine->isAvailable($subject, $moment),
            $message ?: "Expected {$subject->name} to be available at {$moment}"
        );
    }
    
    protected function assertNotAvailable($subject, $moment, $message = '')
    {
        $engine = app(AvailabilityEngine::class);
        $this->assertFalse(
            $engine->isAvailable($subject, $moment),
            $message ?: "Expected {$subject->name} to not be available at {$moment}"
        );
    }
    
    protected function createAvailabilityRule($subject, $type, $config, $effect = Effect::Allow, $priority = 10)
    {
        return $subject->availabilityRules()->create([
            'type' => $type,
            'config' => $config,
            'effect' => $effect,
            'priority' => $priority,
            'enabled' => true,
        ]);
    }
}

Mocking and Stubbing

use Mockery;

class MockedAvailabilityTest extends TestCase
{
    public function test_with_mocked_engine()
    {
        $mockEngine = Mockery::mock(AvailabilityEngine::class);
        $mockEngine->shouldReceive('isAvailable')
            ->with(Mockery::type(Resource::class), Mockery::type(Carbon::class))
            ->andReturn(true);
        
        $this->app->instance(AvailabilityEngine::class, $mockEngine);
        
        $service = new BookingService();
        $result = $service->checkAvailability(Resource::first(), now());
        
        $this->assertTrue($result);
    }
    
    public function test_with_partial_mock()
    {
        $resource = Mockery::mock(Resource::class)->makePartial();
        $resource->shouldReceive('availabilityRules->get')
            ->andReturn(collect([
                new AvailabilityRule([
                    'type' => 'weekdays',
                    'config' => ['days' => [1]],
                    'effect' => Effect::Allow,
                    'priority' => 10,
                ]),
            ]));
        
        $engine = app(AvailabilityEngine::class);
        $monday = Carbon::parse('next monday');
        
        $this->assertTrue($engine->isAvailable($resource, $monday));
    }
}

Performance Testing

class PerformanceTest extends TestCase
{
    public function test_availability_check_performance()
    {
        $resource = Resource::factory()->create();
        
        // Create many rules
        for ($i = 0; $i < 100; $i++) {
            $resource->availabilityRules()->create([
                'type' => 'time_of_day',
                'config' => [
                    'from' => sprintf('%02d:00', $i % 24),
                    'to' => sprintf('%02d:59', $i % 24),
                ],
                'effect' => Effect::Allow,
                'priority' => $i,
            ]);
        }
        
        $engine = app(AvailabilityEngine::class);
        
        $start = microtime(true);
        
        for ($i = 0; $i < 1000; $i++) {
            $engine->isAvailable($resource, now()->addMinutes($i));
        }
        
        $duration = microtime(true) - $start;
        
        // Should complete 1000 checks in under 1 second
        $this->assertLessThan(1.0, $duration, 
            "Performance test failed: {$duration} seconds for 1000 checks");
    }
}

Edge Case Testing

class EdgeCaseTest extends TestCase
{
    public function test_daylight_saving_time_transition()
    {
        $resource = Resource::factory()->create([
            'availability_timezone' => 'America/New_York',
        ]);
        
        $resource->availabilityRules()->create([
            'type' => 'time_of_day',
            'config' => ['from' => '02:00', 'to' => '03:00'],
            'effect' => Effect::Allow,
            'priority' => 10,
        ]);
        
        $engine = app(AvailabilityEngine::class);
        
        // Spring forward (2 AM becomes 3 AM)
        $springForward = Carbon::parse('2025-03-09 02:30:00', 'America/New_York');
        
        // This time doesn't exist due to DST
        $this->assertFalse($engine->isAvailable($resource, $springForward));
    }
    
    public function test_leap_year_february_29()
    {
        $resource = Resource::factory()->create();
        
        $resource->availabilityRules()->create([
            'type' => 'date_range',
            'config' => [
                'from' => '02-28',
                'to' => '03-01',
                'kind' => 'yearly',
            ],
            'effect' => Effect::Allow,
            'priority' => 10,
        ]);
        
        $engine = app(AvailabilityEngine::class);
        
        // Leap year
        $leapYear = Carbon::parse('2024-02-29');
        $this->assertTrue($engine->isAvailable($resource, $leapYear));
        
        // Non-leap year
        $normalYear = Carbon::parse('2025-02-28');
        $this->assertTrue($engine->isAvailable($resource, $normalYear));
    }
    
    public function test_concurrent_rule_modifications()
    {
        $resource = Resource::factory()->create();
        
        // Simulate concurrent requests
        $promises = [];
        for ($i = 0; $i < 10; $i++) {
            $promises[] = function () use ($resource, $i) {
                $resource->availabilityRules()->create([
                    'type' => 'weekdays',
                    'config' => ['days' => [$i % 7 + 1]],
                    'effect' => Effect::Allow,
                    'priority' => $i,
                ]);
            };
        }
        
        // Execute concurrently
        foreach ($promises as $promise) {
            $promise();
        }
        
        // Verify all rules were created
        $this->assertEquals(10, $resource->availabilityRules()->count());
    }
}

Next Steps

Getting Started

Installation
Set up the package in your Laravel app

Quick Start
Get running in 5 minutes

Basic Usage
Common patterns and examples


Core Concepts

How It Works
Understanding the evaluation engine

Rule Types
Available rule types and configurations

Priority System
How rule priority affects evaluation


Advanced Topics

Inventory Gates
Dynamic availability based on stock

Custom Evaluators
Build your own rule types

Complex Scenarios
Real-world implementation patterns

Performance Tips
Optimization strategies


API Reference

Configuration
Package configuration options

Models & Traits
Available models and traits

Testing
Testing your availability rules

Clone this wiki locally