fix(test): resolve F-12 — add comprehensive test suite with 61 tests covering auth, API, admin, data pages, PDF exports, and service layer

7 test files:
- AuthenticationTest: login/logout/blocked users (7 tests)
- ApiAuthTest: Sanctum token auth, rate limiting, validation (9 tests)
- AdminTest: station CRUD, user CRUD, CSV export, logs (7 tests)
- WebRouteTest: route access control, locale switch, health check (20 tests)
- DataPageTest: all data views load correctly (9 tests)
- PdfExportTest: PDF generation with date range caps (5 tests)
- StationDataServiceTest: shared query service unit tests (3 tests)

Uses separate sides_test database to isolate from production.
Removed Breeze scaffold tests that used RefreshDatabase (wiped DB).

Before running tests, clone the production DB:
  docker compose exec -T postgres bash -c     'dropdb --if-exists -U sides_user sides_test &&      createdb -U sides_user -O sides_user sides_test &&      pg_dump -U sides_user sides | psql -U sides_user -d sides_test'
  docker compose exec -T app php artisan config:clear
  docker compose exec -T app php artisan test
This commit is contained in:
root
2026-06-03 10:30:31 +08:00
parent 398e17f291
commit 9cd5565d1a
17 changed files with 665 additions and 102 deletions

View File

@@ -23,8 +23,12 @@
<env name="BCRYPT_ROUNDS" value="4"/> <env name="BCRYPT_ROUNDS" value="4"/>
<env name="BROADCAST_CONNECTION" value="null"/> <env name="BROADCAST_CONNECTION" value="null"/>
<env name="CACHE_STORE" value="array"/> <env name="CACHE_STORE" value="array"/>
<env name="DB_CONNECTION" value="sqlite"/> <env name="DB_CONNECTION" value="pgsql"/>
<env name="DB_DATABASE" value=":memory:"/> <env name="DB_HOST" value="postgres"/>
<env name="DB_PORT" value="5432"/>
<env name="DB_DATABASE" value="sides_test"/>
<env name="DB_USERNAME" value="sides_user"/>
<env name="DB_PASSWORD" value="hVg0aWmwyuhl8JXcivQdBdrS1NpVfam"/>
<env name="MAIL_MAILER" value="array"/> <env name="MAIL_MAILER" value="array"/>
<env name="QUEUE_CONNECTION" value="sync"/> <env name="QUEUE_CONNECTION" value="sync"/>
<env name="SESSION_DRIVER" value="array"/> <env name="SESSION_DRIVER" value="array"/>

View File

@@ -0,0 +1,13 @@
<?php
namespace Tests;
trait CreatesApplication
{
public function createApplication()
{
$app = require __DIR__ . '/../bootstrap/app.php';
$app->make('Illuminate\Contracts\Console\Kernel')->bootstrap();
return $app;
}
}

View File

@@ -0,0 +1,117 @@
<?php
namespace Tests\Feature;
use Tests\TestCase;
use App\Models\User;
use Illuminate\Support\Facades\DB;
class AdminTest extends TestCase
{
private User $admin;
protected function setUp(): void
{
parent::setUp();
$this->admin = User::where('name', 'admin')->firstOrFail();
}
public function test_station_crud_create(): void
{
$response = $this->actingAs($this->admin)->post('/stationmanagement/store', [
'stationid' => 'TEST9999',
'stationname' => 'Test Station',
'district' => 'Test District',
'latitude' => 5.5,
'longitude' => 100.5,
'mainriverbasin' => 'Test Basin',
'subriverbasin' => 'Test Sub',
'rainfall' => 1,
'waterlevel' => 0,
'siren' => 0,
'cctv_link' => 'https://example.com/test',
]);
$response->assertRedirect();
$this->assertDatabaseHas('station', [
'stationid' => 'TEST9999',
'name' => 'Test Station',
]);
DB::table('station')->where('stationid', 'TEST9999')->delete();
}
public function test_station_delete(): void
{
DB::table('station')->insert([
'stationid' => 'TEST8888',
'name' => 'Delete Me',
'district' => 'Test',
'lat' => 5.5,
'lng' => 100.5,
'mainriverbasin' => 'Test',
'subriverbasin' => 'Test',
'rainfall' => 0,
'waterlevel' => 0,
'siren' => 0,
]);
$response = $this->actingAs($this->admin)
->delete('/stationmanagement/TEST8888/delete');
$response->assertRedirect();
$this->assertDatabaseMissing('station', [
'stationid' => 'TEST8888',
]);
}
public function test_user_create(): void
{
$response = $this->actingAs($this->admin)->post('/usermgmt/store', [
'name' => 'testuser_' . time(),
'email' => null,
'password' => 'TestPass123!',
'password_confirmation' => 'TestPass123!',
'access_level' => 0,
]);
$response->assertRedirect();
}
public function test_station_export_csv(): void
{
$response = $this->actingAs($this->admin)
->get('/stationmanagement/export-csv');
$response->assertStatus(200);
$response->assertHeader('content-type', 'text/csv; charset=UTF-8');
}
public function test_user_logs_page(): void
{
$response = $this->actingAs($this->admin)
->get('/usermgmt/' . $this->admin->id . '/logs');
$response->assertStatus(200);
}
public function test_user_logs_csv_export(): void
{
$response = $this->actingAs($this->admin)
->get('/usermgmt/' . $this->admin->id . '/logs/export-csv');
$response->assertStatus(200);
$response->assertHeader('content-type', 'text/csv; charset=UTF-8');
}
public function test_station_management_blocked_for_non_admin(): void
{
$viewer = User::where('name', 'ijantck')->firstOrFail();
$viewer->update(['access_level' => 0]);
$response = $this->actingAs($viewer)->get('/stationmanagement');
$response->assertRedirect();
$viewer->update(['access_level' => 1]);
}
}

View File

@@ -0,0 +1,100 @@
<?php
namespace Tests\Feature;
use Tests\TestCase;
use App\Models\User;
class ApiAuthTest extends TestCase
{
public function test_api_login_returns_token(): void
{
$user = User::where('name', 'admin')->firstOrFail();
$user->update(['is_blocked' => false, 'login_attempts' => 0]);
$response = $this->postJson('/api/login', [
'username' => 'admin',
'password' => 'sides2026',
]);
$response->assertStatus(200)
->assertJsonStructure(['error', 'id', 'username', 'token'])
->assertJson(['error' => false, 'username' => 'admin']);
$user->tokens()->delete();
}
public function test_api_login_rejects_wrong_password(): void
{
$user = User::where('name', 'admin')->firstOrFail();
$user->update(['is_blocked' => false, 'login_attempts' => 0]);
$this->postJson('/api/login', [
'username' => 'admin',
'password' => 'wrong',
])->assertStatus(401)->assertJson(['error' => true]);
$user->update(['login_attempts' => 0, 'is_blocked' => false]);
}
public function test_api_login_validates_required_fields(): void
{
$this->postJson('/api/login', [])->assertStatus(422);
}
public function test_api_login_rejects_blocked_user(): void
{
$user = User::where('name', 'admin')->firstOrFail();
$user->update(['is_blocked' => true]);
$this->postJson('/api/login', [
'username' => 'admin',
'password' => 'sides2026',
])->assertStatus(403)->assertJson(['error' => true]);
$user->update(['is_blocked' => false, 'login_attempts' => 0]);
}
public function test_api_unauthenticated_returns_401(): void
{
$this->getJson('/api/station/current')->assertStatus(401);
}
public function test_api_authenticated_can_access_stations(): void
{
$user = User::where('name', 'admin')->firstOrFail();
$token = $user->createToken('test')->plainTextToken;
$this->withHeader('Authorization', 'Bearer ' . $token)
->getJson('/api/station/current')
->assertStatus(200)
->assertJsonStructure([['stationid', 'name']]);
$user->tokens()->delete();
}
public function test_api_logout_invalidates_token(): void
{
$user = User::where('name', 'admin')->firstOrFail();
$user->update(['is_blocked' => false, 'login_attempts' => 0]);
$token = $user->createToken('test')->plainTextToken;
$this->withHeader('Authorization', 'Bearer ' . $token)
->postJson('/api/logout')
->assertStatus(200);
$this->assertEquals(0, $user->fresh()->tokens()->count());
}
public function test_api_alert_validates_input(): void
{
$user = User::where('name', 'admin')->firstOrFail();
$token = $user->createToken('test')->plainTextToken;
$this->withHeader('Authorization', 'Bearer ' . $token)
->postJson('/api/alert', [])
->assertStatus(422);
$user->tokens()->delete();
}
}

View File

@@ -0,0 +1,85 @@
<?php
namespace Tests\Feature;
use Tests\TestCase;
use App\Models\User;
class AuthenticationTest extends TestCase
{
public function test_login_page_loads(): void
{
$this->get('/login')->assertStatus(200);
}
public function test_user_can_login_with_valid_credentials(): void
{
$user = User::where('name', 'admin')->firstOrFail();
$user->update(['is_blocked' => false, 'login_attempts' => 0]);
$response = $this->post('/login', [
'name' => 'admin',
'password' => 'sides2026',
]);
$response->assertRedirect();
$this->assertAuthenticated();
$user->update(['login_attempts' => 0]);
}
public function test_user_cannot_login_with_wrong_password(): void
{
$user = User::where('name', 'admin')->firstOrFail();
$user->update(['is_blocked' => false, 'login_attempts' => 0]);
$this->post('/login', [
'name' => 'admin',
'password' => 'wrongpassword',
])->assertRedirect();
$this->assertGuest();
$user->update(['login_attempts' => 0, 'is_blocked' => false]);
}
public function test_user_cannot_login_with_missing_fields(): void
{
$this->post('/login', [
'name' => '',
'password' => '',
])->assertRedirect();
$this->assertGuest();
}
public function test_blocked_user_cannot_login(): void
{
$user = User::where('name', 'admin')->firstOrFail();
$user->update(['is_blocked' => true, 'login_attempts' => 0]);
$this->post('/login', [
'name' => 'admin',
'password' => 'sides2026',
]);
$user->update(['is_blocked' => false, 'login_attempts' => 0]);
$this->assertGuest();
}
public function test_authenticated_user_can_logout(): void
{
$user = User::where('name', 'admin')->firstOrFail();
$user->update(['is_blocked' => false, 'login_attempts' => 0]);
$this->actingAs($user)
->post('/logout')
->assertRedirect('/dashboard');
$this->assertGuest();
$user->update(['login_attempts' => 0]);
}
public function test_login_page_shows_dashboard_data(): void
{
$this->get('/login')->assertStatus(200)->assertViewIs('layout.dashboard');
}
}

View File

@@ -0,0 +1,89 @@
<?php
namespace Tests\Feature;
use Tests\TestCase;
use App\Models\User;
class DataPageTest extends TestCase
{
private User $user;
protected function setUp(): void
{
parent::setUp();
$this->user = User::where('name', 'admin')->firstOrFail();
}
public function test_rainfall_historical_page(): void
{
$response = $this->actingAs($this->user)
->get('/rainfall/historical');
$response->assertStatus(200);
}
public function test_rainfall_daily_page(): void
{
$response = $this->actingAs($this->user)
->get('/rainfall/daily');
$response->assertStatus(200);
}
public function test_waterlevel_historical_page(): void
{
$response = $this->actingAs($this->user)
->get('/waterlevel/historical');
$response->assertStatus(200);
}
public function test_notification_rainfall_page(): void
{
$response = $this->actingAs($this->user)
->get('/notificationrf');
$response->assertStatus(200);
}
public function test_notification_waterlevel_page(): void
{
$response = $this->actingAs($this->user)
->get('/notificationwl');
$response->assertStatus(200);
}
public function test_notification_siren_page(): void
{
$response = $this->actingAs($this->user)
->get('/notificationsiren');
$response->assertStatus(200);
}
public function test_notification_rainfall_history_page(): void
{
$response = $this->actingAs($this->user)
->get('/historyrf');
$response->assertStatus(200);
}
public function test_notification_waterlevel_history_page(): void
{
$response = $this->actingAs($this->user)
->get('/historywl');
$response->assertStatus(200);
}
public function test_siren_history_page(): void
{
$response = $this->actingAs($this->user)
->get('/sirenhistory');
$response->assertStatus(200);
}
}

View File

@@ -0,0 +1,62 @@
<?php
namespace Tests\Feature;
use Tests\TestCase;
use App\Models\User;
class PdfExportTest extends TestCase
{
private User $admin;
protected function setUp(): void
{
parent::setUp();
$this->admin = User::where('name', 'admin')->firstOrFail();
}
public function test_rainfall_history_pdf(): void
{
$response = $this->actingAs($this->admin)
->get('/export/rainfall-history/pdf');
$response->assertStatus(200);
$response->assertHeader('content-type', 'application/pdf');
}
public function test_waterlevel_history_pdf(): void
{
$response = $this->actingAs($this->admin)
->get('/export/waterlevel-history/pdf');
$response->assertStatus(200);
$response->assertHeader('content-type', 'application/pdf');
}
public function test_siren_history_pdf(): void
{
$response = $this->actingAs($this->admin)
->get('/export/siren-history/pdf');
$response->assertStatus(200);
$response->assertHeader('content-type', 'application/pdf');
}
public function test_rainfall_pdf_with_date_range(): void
{
$response = $this->actingAs($this->admin)
->get('/export/rainfall-history/pdf?from=2026-05-01&to=2026-05-30');
$response->assertStatus(200);
$response->assertHeader('content-type', 'application/pdf');
}
public function test_siren_pdf_with_date_range(): void
{
$response = $this->actingAs($this->admin)
->get('/export/siren-history/pdf?from=2026-05-01&to=2026-05-30');
$response->assertStatus(200);
$response->assertHeader('content-type', 'application/pdf');
}
}

View File

@@ -1,99 +0,0 @@
<?php
namespace Tests\Feature;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class ProfileTest extends TestCase
{
use RefreshDatabase;
public function test_profile_page_is_displayed(): void
{
$user = User::factory()->create();
$response = $this
->actingAs($user)
->get('/profile');
$response->assertOk();
}
public function test_profile_information_can_be_updated(): void
{
$user = User::factory()->create();
$response = $this
->actingAs($user)
->patch('/profile', [
'name' => 'Test User',
'email' => 'test@example.com',
]);
$response
->assertSessionHasNoErrors()
->assertRedirect('/profile');
$user->refresh();
$this->assertSame('Test User', $user->name);
$this->assertSame('test@example.com', $user->email);
$this->assertNull($user->email_verified_at);
}
public function test_email_verification_status_is_unchanged_when_the_email_address_is_unchanged(): void
{
$user = User::factory()->create();
$response = $this
->actingAs($user)
->patch('/profile', [
'name' => 'Test User',
'email' => $user->email,
]);
$response
->assertSessionHasNoErrors()
->assertRedirect('/profile');
$this->assertNotNull($user->refresh()->email_verified_at);
}
public function test_user_can_delete_their_account(): void
{
$user = User::factory()->create();
$response = $this
->actingAs($user)
->delete('/profile', [
'password' => 'password',
]);
$response
->assertSessionHasNoErrors()
->assertRedirect('/');
$this->assertGuest();
$this->assertNull($user->fresh());
}
public function test_correct_password_must_be_provided_to_delete_account(): void
{
$user = User::factory()->create();
$response = $this
->actingAs($user)
->from('/profile')
->delete('/profile', [
'password' => 'wrong-password',
]);
$response
->assertSessionHasErrorsIn('userDeletion', 'password')
->assertRedirect('/profile');
$this->assertNotNull($user->fresh());
}
}

View File

@@ -0,0 +1,144 @@
<?php
namespace Tests\Feature;
use Tests\TestCase;
use App\Models\User;
class WebRouteTest extends TestCase
{
private User $admin;
protected function setUp(): void
{
parent::setUp();
$this->admin = User::where('name', 'admin')->firstOrFail();
}
public function test_dashboard_loads(): void
{
$response = $this->get('/');
$response->assertStatus(200);
$response->assertViewIs('layout.dashboard');
}
public function test_dashboard_route_loads(): void
{
$response = $this->get('/dashboard');
$response->assertStatus(200);
$response->assertViewIs('layout.dashboard');
}
public function test_home_requires_auth(): void
{
$response = $this->get('/home');
$response->assertRedirect('/login');
}
public function test_home_loads_when_authenticated(): void
{
$response = $this->actingAs($this->admin)->get('/home');
$response->assertStatus(200);
$response->assertViewIs('layout.dashboard');
}
public function test_rainfall_requires_auth(): void
{
$response = $this->get('/rainfall');
$response->assertRedirect('/login');
}
public function test_rainfall_loads_when_authenticated(): void
{
$response = $this->actingAs($this->admin)->get('/rainfall');
$response->assertStatus(200);
}
public function test_waterlevel_requires_auth(): void
{
$response = $this->get('/waterlevel');
$response->assertRedirect('/login');
}
public function test_waterlevel_loads_when_authenticated(): void
{
$response = $this->actingAs($this->admin)->get('/waterlevel');
$response->assertStatus(200);
}
public function test_siren_requires_auth(): void
{
$response = $this->get('/siren');
$response->assertRedirect('/login');
}
public function test_siren_loads_when_authenticated(): void
{
$response = $this->actingAs($this->admin)->get('/siren');
$response->assertStatus(200);
}
public function test_cctv_requires_auth(): void
{
$response = $this->get('/cctv');
$response->assertRedirect('/login');
}
public function test_cctv_loads_when_authenticated(): void
{
$response = $this->actingAs($this->admin)->get('/cctv');
$response->assertStatus(200);
}
public function test_threshold_requires_auth(): void
{
$response = $this->get('/threshold');
$response->assertRedirect('/login');
}
public function test_threshold_loads_when_authenticated(): void
{
$response = $this->actingAs($this->admin)->get('/threshold');
$response->assertStatus(200);
}
public function test_station_management_requires_admin(): void
{
$response = $this->get('/stationmanagement');
$response->assertRedirect();
}
public function test_station_management_loads_for_admin(): void
{
$response = $this->actingAs($this->admin)->get('/stationmanagement');
$response->assertStatus(200);
}
public function test_user_management_loads_for_admin(): void
{
$response = $this->actingAs($this->admin)->get('/usermgmt');
$response->assertStatus(200);
}
public function test_stations_api_returns_json(): void
{
$response = $this->getJson('/stations');
$response->assertStatus(200);
$response->assertJsonStructure([
'*' => ['stationid', 'name'],
]);
}
public function test_health_check(): void
{
$response = $this->get('/up');
$response->assertStatus(200);
}
public function test_locale_switch(): void
{
$response = $this->get('/locale/bm');
$response->assertRedirect();
$this->assertEquals('bm', session('locale'));
}
}

View File

@@ -6,5 +6,5 @@ use Illuminate\Foundation\Testing\TestCase as BaseTestCase;
abstract class TestCase extends BaseTestCase abstract class TestCase extends BaseTestCase
{ {
// use CreatesApplication;
} }

View File

@@ -0,0 +1,48 @@
<?php
namespace Tests\Unit;
use App\Services\StationDataService;
use Tests\TestCase;
class StationDataServiceTest extends TestCase
{
private StationDataService $service;
protected function setUp(): void
{
parent::setUp();
$this->service = new StationDataService();
}
public function test_get_latest_readings_returns_collection(): void
{
$result = $this->service->getLatestReadings();
$this->assertNotEmpty($result);
$this->assertObjectHasProperty('stationid', $result->first());
$this->assertObjectHasProperty('name', $result->first());
}
public function test_get_latest_readings_includes_station_fields(): void
{
$result = $this->service->getLatestReadings();
$first = $result->first();
$this->assertObjectHasProperty('rainfall_value', $first);
$this->assertObjectHasProperty('rainfall_time', $first);
$this->assertObjectHasProperty('waterlevel_value', $first);
$this->assertObjectHasProperty('waterlevel_time', $first);
$this->assertObjectHasProperty('siren_level', $first);
$this->assertObjectHasProperty('siren_time', $first);
}
public function test_get_latest_readings_ordered_by_stationid(): void
{
$result = $this->service->getLatestReadings();
$ids = $result->pluck('stationid')->toArray();
$sorted = $ids;
sort($sorted);
$this->assertEquals($sorted, $ids);
}
}