# Testing Guide

This guide documents the testing infrastructure, patterns, and conventions for the BuyerKiosk codebase.

## Quick Start

```bash
# Run all tests
./test.sh

# Run only unit tests
./test.sh --testsuite unit

# Run only integration tests
./test.sh --testsuite integration

# Run with coverage report
./test.sh --coverage

# Run specific test file
cd userfrosting && ./vendor/bin/phpunit tests/Unit/Cash/CashBalancerTest.php

# Run tests matching a pattern
cd userfrosting && ./vendor/bin/phpunit --filter "CashBalancer"
```

## Directory Structure

```
userfrosting/tests/
├── Unit/                    # Unit tests (fast, isolated)
│   ├── Backstock/           # Backstock module tests
│   ├── Cash/                # Cash management tests
│   ├── ComebackCash/        # Comeback Cash tests
│   ├── Core/                # Core module tests (Store, Buy, Customer)
│   ├── DigitalSign/         # Digital sign tests
│   ├── Employee/            # Employee sync tests
│   ├── EventManagement/     # Event management tests
│   ├── Infrastructure/      # Infrastructure tests (bootstrap, etc.)
│   ├── SMS/                 # SMS service tests
│   ├── Task/                # Task system tests
│   └── Workbook/            # Workbook/Daybook tests
├── Integration/             # Integration tests (real DB, slower)
│   ├── Api/                 # API endpoint tests
│   ├── Backstock/           # Backstock workflow tests
│   ├── Cash/                # Cash reconciliation tests
│   ├── ComebackCash/        # Coupon lifecycle tests
│   ├── Core/                # Core workflow tests
│   └── Employee/            # Employee sync integration tests
├── Fixtures/                # Test data factories
│   ├── Backstock/           # EventFixtures, BinFixtures, CategoryFixtures
│   ├── Cash/                # CashActivityFixtures
│   ├── Core/                # StoreFixtures, BuyFixtures, CustomerFixtures, LookupTableSeeder
│   ├── Employee/            # EmployeeFixtures
│   ├── SMS/                 # MessageFixtures
│   └── Workbook/            # NoteFixtures, TaskFixtures, WhiteboardFixtures
├── Mocks/                   # Reusable mock classes
│   ├── PdoMockBuilder.php   # Fluent PDO mock builder
│   ├── RedisMock.php        # In-memory Redis mock
│   ├── StoreMock.php        # Store object factory
│   ├── TwilioMock.php       # Twilio SMS mock
│   └── VonageMock.php       # Vonage SMS mock
├── Support/                 # Test support utilities
├── DatabaseTestCase.php     # Base class for database tests
└── bootstrap.php            # Test environment setup
```

## Writing Unit Tests

### Basic Test Structure

```php
<?php

namespace Tests\Unit\Cash;

use PHPUnit\Framework\TestCase;
use BuyerKiosk\Cash\CashBalancer;

/**
 * Tests for CashBalancer
 *
 * Business Rules:
 * - PaidIn and S2R transactions add cash to register
 * - PaidOut and R2S transactions remove cash from register
 * - Variance = Calculated - Reported
 *
 * @see src/BuyerKiosk/Cash/CashBalancer.php
 */
class CashBalancerTest extends TestCase
{
    /**
     * Test variance calculation with matching totals
     *
     * Scenario: When calculated and reported totals match,
     * variance should be zero.
     */
    public function testVarianceWithMatchingTotals(): void
    {
        // Arrange
        $cashActivity = [
            ['transType' => 'PaidIn', 'total' => 100.00],
        ];
        $salesReport = $this->createMock(SalesReport::class);
        $salesReport->cashPaidIn = 100.00;
        $salesReport->cashPaidOut = 0.00;

        // Act
        $balancer = new CashBalancer($cashActivity, $salesReport);
        $variance = $balancer->calculateVariance();

        // Assert
        $this->assertEquals(0.00, $variance['cashIn']);
    }
}
```

### Documenting Business Rules

Follow the ComebackCash pattern - document business rules in test file headers:

```php
/**
 * Tests for EventService
 *
 * Business Rules:
 * - Events have 5 phases: upcoming, build-up, active, wind-down, completed
 * - Build-up starts N days before startDate (default 14)
 * - Wind-down ends N days after endDate (default 7)
 * - Progress is tracked separately per phase
 * - Alerts trigger when progress falls behind schedule
 *
 * Phase Boundaries:
 * - Upcoming: today < buildUpStart
 * - Build-up: buildUpStart <= today < startDate
 * - Active: startDate <= today <= endDate
 * - Wind-down: endDate < today <= windDownEnd
 * - Completed: today > windDownEnd
 */
```

### Using Data Providers

For testing multiple scenarios with the same logic:

```php
/**
 * @dataProvider varianceCalculationProvider
 */
public function testVarianceCalculation(
    float $paidIn,
    float $reportedIn,
    float $expectedVariance
): void {
    $cashActivity = [
        ['transType' => 'PaidIn', 'total' => $paidIn],
    ];

    $salesReport = $this->createMock(SalesReport::class);
    $salesReport->cashPaidIn = $reportedIn;

    $balancer = new CashBalancer($cashActivity, $salesReport);
    $variance = $balancer->calculateVariance();

    $this->assertEquals($expectedVariance, $variance['cashIn']);
}

public static function varianceCalculationProvider(): array
{
    return [
        'matching totals' => [100.00, 100.00, 0.00],
        'overage' => [150.00, 100.00, 50.00],
        'shortage' => [75.00, 100.00, -25.00],
        'zero activity' => [0.00, 100.00, -100.00],
    ];
}
```

## Using Mock Classes

### PdoMockBuilder - Database Mocking

```php
use Tests\Mocks\PdoMockBuilder;

public function testDatabaseQuery(): void
{
    $pdo = PdoMockBuilder::create($this)
        ->expectQuery('SELECT * FROM employees WHERE id = ?')
        ->withParams([123])
        ->willReturn([
            ['id' => 123, 'name' => 'John Doe'],
        ])
        ->build();

    // Use $pdo in your test
}

// Multiple queries
$pdo = PdoMockBuilder::create($this)
    ->expectQuery('SELECT * FROM employees')
    ->willReturn([...])
    ->expectQuery('UPDATE employees SET ...')
    ->willReturn(1) // affected rows
    ->build();

// Consecutive calls
$pdo = PdoMockBuilder::create($this)
    ->expectQuery('SELECT COUNT(*) FROM orders')
    ->willReturnOnConsecutiveCalls([5], [10], [15])
    ->build();

// Exception simulation
$pdo = PdoMockBuilder::create($this)
    ->expectQuery('INSERT INTO ...')
    ->willThrow(new \PDOException('Duplicate key'))
    ->build();
```

### TwilioMock - SMS Testing

```php
use Tests\Mocks\TwilioMock;

public function testSmsSending(): void
{
    $twilio = TwilioMock::create($this)
        ->expectSend()
        ->to('+15551234567')
        ->withBody('Your code is 123456')
        ->willSucceed()
        ->build();

    // Use $twilio in your test
    $twilio->verify(); // Assert all expectations met
}

// Failure simulation
$twilio = TwilioMock::create($this)
    ->expectSend()
    ->willFail(21211, 'Invalid phone number')
    ->build();
```

### RedisMock - Queue and Cache Testing

```php
use Tests\Mocks\RedisMock;

public function testRedisOperations(): void
{
    $redis = new RedisMock();

    // String operations
    $redis->set('key', 'value');
    $this->assertEquals('value', $redis->get('key'));

    // Hash operations
    $redis->hset('user:1', 'name', 'John');
    $this->assertEquals('John', $redis->hget('user:1', 'name'));

    // Queue operations
    $redis->lpush('queue', 'job1');
    $redis->lpush('queue', 'job2');
    $this->assertEquals('job1', $redis->rpop('queue'));
}
```

### StoreMock - Store Configuration

```php
use Tests\Mocks\StoreMock;

// Quick creation with defaults
$store = StoreMock::make();

// With specific typeNum
$store = StoreMock::forStore('ou00')->build();

// With integrations enabled
$store = StoreMock::forStore('pa00')
    ->withIntegrations(['twilio', 'quickbooks'])
    ->build();

// With timezone
$store = StoreMock::forStore('ou00')
    ->withTimezone('America/Chicago')
    ->build();

// With custom overrides
$store = StoreMock::forStore('ou00')
    ->build(['billingActive' => 2]);
```

## Using Fixture Classes

### Creating Test Data

```php
use Tests\Fixtures\Core\StoreFixtures;
use Tests\Fixtures\Backstock\EventFixtures;

// Create a test object with defaults
$store = StoreFixtures::createTestStore();

// Create with overrides
$store = StoreFixtures::createTestStore([
    'typeNum' => 'pa00',
    'timezone' => 'America/Chicago',
]);

// Create a row array (for database insertion)
$storeRow = StoreFixtures::createTestStoreRow();

// Create a set of related objects
$events = EventFixtures::createTestEventSet(); // Returns upcoming, active, completed
```

### Database Insertion

```php
use Tests\Fixtures\Core\BuyFixtures;

// Insert and get the ID
$buyId = BuyFixtures::insertTestBuy($this->db, [
    'customerId' => 123,
    'total' => 150.00,
]);

// Verify insertion
$this->assertRowExists('buys', ['buyID' => $buyId]);
```

## Writing Integration Tests

Integration tests use real database connections with transaction rollback:

```php
<?php

namespace Tests\Integration\Core;

use Tests\DatabaseTestCase;
use Tests\Fixtures\Core\BuyFixtures;

class BuyWorkflowTest extends DatabaseTestCase
{
    public function testBuyLifecycle(): void
    {
        // Insert test data (will be rolled back after test)
        $buyId = BuyFixtures::insertTestBuy($this->db, [
            'status' => 'pending',
        ]);

        // Verify initial state
        $this->assertRowExists('buys', ['buyID' => $buyId, 'status' => 'pending']);

        // Update status
        $stmt = $this->db->prepare('UPDATE buys SET status = ? WHERE buyID = ?');
        $stmt->execute(['completed', $buyId]);

        // Verify final state
        $this->assertRowExists('buys', ['buyID' => $buyId, 'status' => 'completed']);
    }

    public function testMultiStoreOperations(): void
    {
        // Connect to store-specific database
        $storeDb = $this->connectToStore('ou00');

        // Perform operations on store database
        // (will also be rolled back after test)
    }
}
```

### DatabaseTestCase Features

```php
// Connect to store database
$db = $this->connectToStore('ou00');

// Switch database on existing connection
$this->switchDatabase('kiosk_pa00');

// Custom assertions
$this->assertRowExists('table', ['column' => 'value']);
$this->assertRowNotExists('table', ['column' => 'value']);

// Transaction savepoints for nested tests
$this->createSavepoint('before_insert');
// ... do stuff ...
$this->rollbackToSavepoint('before_insert');
$this->releaseSavepoint('before_insert');

// Helper methods
$id = $this->insertRow('table', ['col1' => 'val1']);
$rows = $this->executeQuery('SELECT * FROM table WHERE id = ?', [$id]);
$count = $this->executeStatement('UPDATE table SET ...');
$rowCount = $this->getRowCount('table', ['status' => 'active']);
```

## Test Naming Conventions

- Test classes: `{ClassName}Test.php`
- Test methods: `test{Description}` or `test_{snake_case_description}`
- Data providers: `{methodName}Provider`

Good examples:
```php
public function testCalculateVarianceWithMatchingTotals(): void
public function test_variance_calculation_with_overage(): void
public function testPhaseTransitionFromBuildUpToActive(): void
```

## CI/CD Integration

Tests run automatically on every PR via GitHub Actions:

- **Trigger**: Push to `master`, `main`, `daybook` or PR to `master`/`main`
- **Services**: MySQL 8.0, Redis 7
- **Coverage**: Generated on PRs, uploaded to Codecov
- **Timeout**: 10 minutes max

### Running Tests Locally Like CI

```bash
# Set up test database
mysql -u root -p -e "CREATE DATABASE IF NOT EXISTS kiosk_test;"

# Create .env.testing
cat > userfrosting/.env.testing << 'EOF'
DB_HOST=127.0.0.1
DB_USER=root
DB_PASSWORD=yourpassword
DB_NAME=kiosk_test
REDIS_HOST=127.0.0.1
REDIS_PORT=6379
EOF

# Run tests
./test.sh
```

## Coverage Reports

```bash
# Generate HTML coverage report
cd userfrosting
XDEBUG_MODE=coverage ./vendor/bin/phpunit --coverage-html coverage/

# Open in browser
open coverage/index.html
```

Coverage targets:
- **80%** minimum for changed files
- **90%** overall target

## Troubleshooting

### Common Issues

1. **"Class not found" errors**
   - Run `composer dump-autoload` in `userfrosting/`
   - Check namespace matches directory structure

2. **Database connection failures**
   - Verify MySQL is running
   - Check `.env` or `.env.testing` credentials
   - Ensure test database exists

3. **Slim environment warnings**
   - These are harmless in CLI testing context
   - Suppressed by error_reporting in bootstrap.php

4. **Test isolation failures**
   - Ensure test extends `DatabaseTestCase`
   - Check for static state between tests
   - Verify tearDown() is called

### Debug Tips

```php
// Print variable during test
var_dump($variable);

// Stop test at specific point
$this->fail('Debug stop point');

// Skip test conditionally
if (!extension_loaded('redis')) {
    $this->markTestSkipped('Redis extension not available');
}
```

## Further Reading

- [PHPUnit 12 Documentation](https://docs.phpunit.de/en/12.0/)
- `tests/Unit/ComebackCash/` - Example of well-documented business logic tests
- `tests/Integration/ComebackCash/CouponLifecycleTest.php` - Example integration test
- `docs/specs/005-test-infrastructure/` - Full test infrastructure specification
