Skip to content

30 — Testing

Current Testing State

PyroRadius currently has zero test coverage. PHPUnit and Playwright are installed but no tests have been written. This is technical debt that should be addressed before the system scales further or onboards additional developers.

Tool Status Location
PHPUnit Installed (via Composer) vendor/bin/phpunit
Playwright Installed (via npm) node_modules/.bin/playwright
Feature Tests Not written tests/Feature/ (empty)
Unit Tests Not written tests/Unit/ (empty)
E2E Tests Not written tests/e2e/ (to be created)

This documentation is honest: no tests exist. Every production change is currently validated manually.


How to Run Tests (When Written)

PHPUnit (Backend)

# From /var/www/pyroradius/
php artisan test

# Run a specific test file
php artisan test tests/Feature/BillingServiceTest.php

# Run with coverage report (requires Xdebug or PCOV)
php artisan test --coverage

# Verbose output
php artisan test --verbose

# Filter by test name
php artisan test --filter testInvoiceIsIdempotent

Playwright (Frontend E2E)

# From /var/www/pyroradius/
npx playwright test

# Run a specific spec file
npx playwright test tests/e2e/customer-recharge.spec.ts

# Run in headed mode (to watch the browser)
npx playwright test --headed

# Run on a specific browser
npx playwright test --browser=chromium

# Show test report
npx playwright show-report

Testing Strategy: Priority Order

These features must be tested first because failures in them cause real financial loss, RADIUS outages, or data corruption.

Priority 1 — Critical (Must test before any production change)

  1. BillingService — invoice generation, payment application, renewal logic
  2. RadiusService — RADIUS table writes (radcheck, radreply, radgroupreply, radusergroup)
  3. Permission gates — all 4 roles (super_admin, admin, dealer, sub_dealer) must have correct access
  4. CustomerController store/update — customer creation with code generation, expiry logic

Priority 2 — High

  1. SyncAllRadius command — RADIUS sync correctness and idempotency
  2. SyncOntAssignments command — lock timeout handling, IS DISTINCT FROM guard
  3. Customer recharge flow — payment + invoice + RADIUS + WhatsApp pipeline
  4. WhatsAppService — template dispatch and log-only mode

Priority 3 — Medium

  1. AuditLogger — audit trail entries on every mutation
  2. MikroTikService — disconnect and live traffic (mocked)
  3. DashboardController — stats accuracy, online count cache
  4. Dealer scoping — every query filtered by dealer_id/sub_dealer_id

Setting Up a Test Database

Never run tests against the production RADIUS tables. Create a separate test database.

# On the server, as root
sudo -u postgres psql

-- Create test database
CREATE DATABASE pyroradius_test OWNER pyroradius;

-- Grant permissions
GRANT ALL PRIVILEGES ON DATABASE pyroradius_test TO pyroradius;
\q

In phpunit.xml, configure the test environment:

<php>
    <env name="APP_ENV" value="testing"/>
    <env name="DB_CONNECTION" value="pgsql"/>
    <env name="DB_DATABASE" value="pyroradius_test"/>
    <env name="CACHE_DRIVER" value="array"/>
    <env name="SESSION_DRIVER" value="array"/>
    <env name="QUEUE_CONNECTION" value="sync"/>
    <env name="RADIUS_DB_DATABASE" value="radius_test"/>
    <env name="WHATSAPP_ENABLED" value="false"/>
    <env name="LOG_CHANNEL" value="null"/>
</php>

Run migrations on the test database before the first test run:

php artisan migrate --env=testing

Use RefreshDatabase trait in tests to reset state between tests:

use Illuminate\Foundation\Testing\RefreshDatabase;

Mocking RADIUS

RADIUS tables live in a separate PostgreSQL database (radius or configured via RADIUS_DB_CONNECTION). In tests, point to a radius_test database.

sudo -u postgres psql
CREATE DATABASE radius_test OWNER pyroradius;
GRANT ALL PRIVILEGES ON DATABASE radius_test TO pyroradius;

Add a second DB connection in config/database.php:

'radius_test' => [
    'driver'   => 'pgsql',
    'host'     => env('RADIUS_DB_HOST', '127.0.0.1'),
    'database' => env('RADIUS_TEST_DB_DATABASE', 'radius_test'),
    'username' => env('RADIUS_DB_USERNAME', 'pyroradius'),
    'password' => env('RADIUS_DB_PASSWORD', ''),
    'schema'   => 'public',
    'charset'  => 'utf8',
],

Set RADIUS_DB_CONNECTION=radius_test in the test environment. RadiusService will write to the test database.

Alternatively, use a RadiusServiceFake:

// In a ServiceProvider or test setUp:
$this->app->bind(RadiusService::class, RadiusServiceFake::class);
class RadiusServiceFake extends RadiusService
{
    public array $synced = [];

    public function syncCustomer(Customer $customer): void
    {
        $this->synced[] = $customer->id;
    }
}

Mocking WhatsApp

Set WHATSAPP_ENABLED=false in the test environment. WhatsAppService checks this flag and falls through to the log driver when disabled.

For tests that need to assert a message was "sent":

// Swap WhatsAppService with a fake
$fake = new WhatsAppServiceFake();
$this->app->instance(WhatsAppService::class, $fake);

// Assert after action
$this->assertCount(1, $fake->sent);
$this->assertEquals('recharge_confirmation', $fake->sent[0]['template']);

Mocking MikroTik

MikroTikService calls RouterOS API over the network. In tests, bind a stub:

$this->app->bind(MikroTikService::class, function () {
    return new class extends MikroTikService {
        public function disconnectPppoe(string $username): bool { return true; }
        public function getLiveTraffic(string $username): array { return ['rx' => 0, 'tx' => 0]; }
    };
});

This prevents network calls during test runs.


Example PHPUnit Test — BillingService

<?php

namespace Tests\Feature;

use App\Models\Customer;
use App\Models\Invoice;
use App\Models\Payment;
use App\Models\Package;
use App\Services\BillingService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
use Carbon\Carbon;

class BillingServiceTest extends TestCase
{
    use RefreshDatabase;

    protected BillingService $billing;

    protected function setUp(): void
    {
        parent::setUp();
        $this->billing = app(BillingService::class);
    }

    /** @test */
    public function it_generates_an_invoice_for_a_customer(): void
    {
        $package = Package::factory()->create(['price' => 2000]);
        $customer = Customer::factory()->create([
            'package_id'  => $package->id,
            'expiry_date' => Carbon::now()->addDays(30),
        ]);

        $invoice = $this->billing->generateInvoice($customer, now()->format('Y-m'));

        $this->assertInstanceOf(Invoice::class, $invoice);
        $this->assertEquals(2000, $invoice->amount);
        $this->assertEquals($customer->id, $invoice->customer_id);
    }

    /** @test */
    public function invoice_generation_is_idempotent_for_same_period(): void
    {
        $package  = Package::factory()->create(['price' => 2000]);
        $customer = Customer::factory()->create(['package_id' => $package->id]);
        $period   = now()->format('Y-m');

        $first  = $this->billing->generateInvoice($customer, $period);
        $second = $this->billing->generateInvoice($customer, $period);

        $this->assertEquals($first->id, $second->id);
        $this->assertDatabaseCount('invoices', 1);
    }

    /** @test */
    public function payment_is_applied_to_outstanding_invoice(): void
    {
        $package  = Package::factory()->create(['price' => 2000]);
        $customer = Customer::factory()->create(['package_id' => $package->id]);
        $invoice  = Invoice::factory()->create([
            'customer_id' => $customer->id,
            'amount'      => 2000,
            'paid'        => false,
        ]);

        $this->billing->applyPayment($customer, 2000, 'cash');

        $this->assertDatabaseHas('invoices', [
            'id'   => $invoice->id,
            'paid' => true,
        ]);
    }

    /** @test */
    public function excess_payment_applies_to_older_unpaid_invoices(): void
    {
        $package  = Package::factory()->create(['price' => 2000]);
        $customer = Customer::factory()->create(['package_id' => $package->id]);

        $older = Invoice::factory()->create([
            'customer_id' => $customer->id,
            'amount'      => 2000,
            'paid'        => false,
            'period'      => '2025-05',
        ]);
        $newer = Invoice::factory()->create([
            'customer_id' => $customer->id,
            'amount'      => 2000,
            'paid'        => false,
            'period'      => '2025-06',
        ]);

        // Pay 4000 — both invoices should be cleared
        $this->billing->applyPayment($customer, 4000, 'cash');

        $this->assertDatabaseHas('invoices', ['id' => $older->id, 'paid' => true]);
        $this->assertDatabaseHas('invoices', ['id' => $newer->id, 'paid' => true]);
    }

    /** @test */
    public function customer_expiry_extends_by_one_month_on_recharge(): void
    {
        $package  = Package::factory()->create(['price' => 2000]);
        $original = Carbon::parse('2025-06-01');
        $customer = Customer::factory()->create([
            'package_id'  => $package->id,
            'expiry_date' => $original,
        ]);

        $this->billing->recharge($customer, 2000, 'cash');

        $customer->refresh();
        $this->assertEquals(
            $original->copy()->addMonth()->toDateString(),
            $customer->expiry_date->toDateString()
        );
    }
}

Example Playwright Test — Customer Recharge Flow

Save at tests/e2e/customer-recharge.spec.ts:

import { test, expect } from '@playwright/test';

const BASE_URL = process.env.APP_URL || 'https://pyroradius.pyronet.com.pk';

test.describe('Customer Recharge Flow', () => {
  test.beforeEach(async ({ page }) => {
    // Log in as super_admin
    await page.goto(`${BASE_URL}/login`);
    await page.fill('input[name="email"]', 'admin@pyronet.com.pk');
    await page.fill('input[name="password"]', 'change-me-on-first-login');
    await page.click('button[type="submit"]');
    await expect(page).toHaveURL(/dashboard/);
  });

  test('admin can recharge a customer and see updated expiry', async ({ page }) => {
    // Navigate to the customer list
    await page.goto(`${BASE_URL}/customers`);
    await expect(page.locator('h1')).toContainText('Customers');

    // Open first customer
    await page.locator('table tbody tr').first().click();
    const expiryBefore = await page.locator('[data-testid="expiry-date"]').textContent();

    // Click Recharge button
    await page.click('[data-testid="recharge-btn"]');

    // Fill recharge form
    await page.fill('input[name="amount"]', '2000');
    await page.selectOption('select[name="payment_method"]', 'cash');
    await page.click('[data-testid="confirm-recharge"]');

    // Wait for success toast
    await expect(page.locator('[data-testid="toast-success"]')).toBeVisible();

    // Verify expiry date changed
    const expiryAfter = await page.locator('[data-testid="expiry-date"]').textContent();
    expect(expiryAfter).not.toEqual(expiryBefore);
  });

  test('dealer cannot access billing actions without permission', async ({ page }) => {
    // Log in as dealer (no billing permission)
    await page.goto(`${BASE_URL}/login`);
    await page.fill('input[name="email"]', 'dealer@example.com');
    await page.fill('input[name="password"]', 'password');
    await page.click('button[type="submit"]');

    await page.goto(`${BASE_URL}/customers`);
    // Recharge button should not be visible
    await expect(page.locator('[data-testid="recharge-btn"]')).not.toBeVisible();
  });
});

Playwright config (playwright.config.ts):

import { defineConfig } from '@playwright/test';

export default defineConfig({
  testDir: './tests/e2e',
  use: {
    baseURL: process.env.APP_URL || 'http://localhost',
    headless: true,
    screenshot: 'only-on-failure',
    video: 'retain-on-failure',
  },
  reporter: [['html', { outputFolder: 'playwright-report' }]],
});

Critical Paths — Must Test Before Any Production Change

These paths touch money, connectivity, or security. Any regression here directly harms customers or revenue.

Path What to verify
Customer recharge Expiry extends, invoice created, RADIUS synced, WhatsApp sent
Invoice generation Correct amount, correct period, idempotent on repeat
Payment application Marks invoice paid, excess applies to older invoices
RADIUS sync radcheck username + password written, radgroupreply speed written, radusergroup group written
Customer suspend RADIUS entry disabled or removed
Dealer permission gate Dealer without billing.recharge cannot POST to recharge endpoint
Admin bypass super_admin and admin bypass all permission checks
Customer code generation Code is PR + 5-digit sequential, no duplicates
PPPoE password storage Stored as plain text, never hashed
SyncOntAssignments Does not lock tables under concurrent load

Pre-Production Manual Verification Checklist

Until automated tests exist, run these manual checks before every production deploy:

  • [ ] Log in as super_admin — dashboard loads
  • [ ] Log in as a dealer account — cannot see admin-only menu items
  • [ ] View customer list — loads without error
  • [ ] Open one customer — profile, invoices, ledger tabs load
  • [ ] Generate an invoice — correct amount, no duplicate on re-run
  • [ ] Apply a payment — invoice marked paid, expiry updates
  • [ ] Check tail -f /var/www/pyroradius/storage/logs/laravel.log — no new errors
  • [ ] Check queue workers — supervisorctl status all shows RUNNING
  • [ ] Check RADIUS tables — psql radius -c "SELECT * FROM radcheck LIMIT 5;" returns rows
  • [ ] Check online count — dashboard shows reasonable online count (not 0 or NULL)