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)¶
- BillingService — invoice generation, payment application, renewal logic
- RadiusService — RADIUS table writes (radcheck, radreply, radgroupreply, radusergroup)
- Permission gates — all 4 roles (super_admin, admin, dealer, sub_dealer) must have correct access
- CustomerController store/update — customer creation with code generation, expiry logic
Priority 2 — High¶
- SyncAllRadius command — RADIUS sync correctness and idempotency
- SyncOntAssignments command — lock timeout handling, IS DISTINCT FROM guard
- Customer recharge flow — payment + invoice + RADIUS + WhatsApp pipeline
- WhatsAppService — template dispatch and log-only mode
Priority 3 — Medium¶
- AuditLogger — audit trail entries on every mutation
- MikroTikService — disconnect and live traffic (mocked)
- DashboardController — stats accuracy, online count cache
- 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:
Use RefreshDatabase trait in tests to reset state between tests:
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 allshows 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)