value = $value; } public static function fromValue($value): self { return new self($value); } public function map($callback): self { if ($this->value !== null) { $this->value = $callback($this->value); } return $this; } public function getOrCall($callback) { return $this->value !== null ? $this->value : $callback(); } public function getOrThrow($exception) { if ($this->value === null) { throw $exception; } return $this->value; } } } namespace App\Filters { use CodeIgniter\Filters\FilterInterface; use CodeIgniter\HTTP\RequestInterface; use CodeIgniter\HTTP\ResponseInterface; // Test stubs for application filters to bypass logging, ACL, and security checks during tests. class AclFilter implements FilterInterface { public function before(RequestInterface $request, $arguments = null) { return null; } public function after(RequestInterface $request, ResponseInterface $response, $arguments = null) { return; } } class HttpRequestLog implements FilterInterface { public function before(RequestInterface $request, $arguments = null) { return null; } public function after(RequestInterface $request, ResponseInterface $response, $arguments = null) { return; } } class SecurityInputFilter implements FilterInterface { public function before(RequestInterface $request, $arguments = null) { return null; } public function after(RequestInterface $request, ResponseInterface $response, $arguments = null) { return; } } class GlobalPostFileUploadGuard implements FilterInterface { public function before(RequestInterface $request, $arguments = null) { return null; } public function after(RequestInterface $request, ResponseInterface $response, $arguments = null) { return; } } class Cors implements FilterInterface { public function before(RequestInterface $request, $arguments = null) { return null; } public function after(RequestInterface $request, ResponseInterface $response, $arguments = null) { return; } } } namespace Dotenv\Repository { interface RepositoryInterface { public function get($name); public function set($name, $value); } class RepositoryBuilder { public static function createWithDefaultAdapters(): self { return new self(); } public function addAdapter($adapter): self { // No-op for testing. return $this; } public function immutable(): self { return $this; } public function make(): RepositoryInterface { return new class() implements RepositoryInterface { public function get($name) { $value = getenv($name); return $value === false ? null : $value; } public function set($name, $value) { putenv("$name=$value"); } }; } } } namespace Dotenv\Repository\Adapter { class PutenvAdapter { // Stub class used only to satisfy Illuminate\Support\Env references. } } namespace Tests\unit { use CodeIgniter\Test\CIUnitTestCase; use CodeIgniter\Test\FeatureTestTrait; class ExpenseControllerTest extends CIUnitTestCase { use FeatureTestTrait; protected function setUp(): void { parent::setUp(); // Override routes for testing to avoid filters/middleware. $this->withRoutes([ ['get', 'expense', 'ExpenseController::index'], ['post', 'expense/save', 'ExpenseController::save'], ['get', 'expense/get/(:num)', 'ExpenseController::getExpense/$1'], ['post', 'expense/delete/(:num)','ExpenseController::delete/$1'], ['get', 'expense/client-policies', 'ExpenseController::clientPolicies'], ]); // Clean up any test data from previous runs $db = db_connect('default'); $db->table('expenses')->like('description', 'CI4_TEST_', 'after')->delete(); } /** * Helper to insert an expense row directly into the database. */ private function createExpenseRecord(array $overrides = []): int { $db = db_connect('default'); $data = array_merge([ 'client_id' => 1, 'client_policy_id' => 1, 'description' => 'CI4_TEST_' . uniqid('', true), 'approved_by' => 1, 'amount' => 100.00, 'created_at' => date('Y-m-d H:i:s'), 'is_active' => 1, ], $overrides); $db->table('expenses')->insert($data); return (int) $db->insertID(); } public function testCreateExpenseSuccess(): void { $payload = [ 'client_id' => '1', 'client_policy_id' => '1', 'approved_by' => '1', 'description' => 'CI4_TEST_Valid Description 123-ABC', 'expense_date' => '12-01-2026', 'amount' => '250.75', ]; $result = $this->post('expense/save', $payload); $result->assertStatus(200); $json = json_decode($result->getJSON(), true); $this->assertIsArray($json); $this->assertTrue($json['status'] ?? false); $this->assertNotEmpty($json['id'] ?? null); $insertedId = (int) $json['id']; $db = db_connect('default'); $row = $db->table('expenses') ->where('description', $payload['description']) ->orderBy('id', 'DESC') ->get() ->getRowArray(); $this->assertNotEmpty($row); $this->assertSame(1, (int) $row['is_active']); $this->assertSame($payload['description'], $row['description']); $this->assertEquals(250.75, (float) $row['amount']); } public function testCreateExpenseValidationFailureMissingFields(): void { $payload = [ // all required fields missing / empty ]; $result = $this->post('expense/save', $payload); $result->assertStatus(400); $json = json_decode($result->getJSON(), true); $this->assertFalse($json['status'] ?? true); $this->assertSame('Input validation failed', $json['message'] ?? ''); $this->assertArrayHasKey('client_id', $json['errors'] ?? []); $this->assertArrayHasKey('client_policy_id', $json['errors'] ?? []); $this->assertArrayHasKey('approved_by', $json['errors'] ?? []); $this->assertArrayHasKey('description', $json['errors'] ?? []); $this->assertArrayHasKey('amount', $json['errors'] ?? []); $this->assertArrayHasKey('expense_date', $json['errors'] ?? []); } public function testCreateExpenseValidationFailureInvalidDescription(): void { $payload = [ 'client_id' => '1', 'client_policy_id' => '1', 'approved_by' => '1', 'description' => 'Invalid @ Description <>', // invalid characters 'expense_date' => '12-01-2026', 'amount' => '100.00', ]; $result = $this->post('expense/save', $payload); $result->assertStatus(400); $json = json_decode($result->getJSON(), true); $this->assertFalse($json['status'] ?? true); $this->assertArrayHasKey('description', $json['errors'] ?? []); $this->assertStringContainsString( 'invalid characters', strtolower($json['errors']['description'] ?? '') ); } public function testUpdateExpenseSuccess(): void { $id = $this->createExpenseRecord([ 'description' => 'CI4_TEST_Original Description', 'amount' => 50.00, ]); $payload = [ 'id' => (string) $id, 'client_id' => '1', 'client_policy_id'=> '1', 'approved_by' => '1', 'description' => 'Updated Description 456', 'expense_date' => '13-01-2026', 'amount' => '75.50', ]; $result = $this->post('expense/save', $payload); $result->assertStatus(200); $json = json_decode($result->getJSON(), true); $this->assertTrue($json['status'] ?? false); $this->assertSame($id, (int) ($json['id'] ?? 0)); $db = db_connect('default'); $row = $db->table('expenses')->where('id', $id)->get()->getRowArray(); $this->assertNotEmpty($row); $this->assertSame('Updated Description 456', $row['description']); $this->assertEquals(75.50, (float) $row['amount']); } public function testUpdateExpenseValidationFailureInvalidAmount(): void { $id = $this->createExpenseRecord(); $payload = [ 'id' => (string) $id, 'client_id' => '1', 'client_policy_id'=> '1', 'approved_by' => '1', 'description' => 'Another Valid Description', 'expense_date' => '14-01-2026', 'amount' => '-10', // invalid negative ]; $result = $this->post('expense/save', $payload); $result->assertStatus(400); $json = json_decode($result->getJSON(), true); $this->assertFalse($json['status'] ?? true); $this->assertArrayHasKey('amount', $json['errors'] ?? []); $this->assertStringContainsString( 'cannot be negative', strtolower($json['errors']['amount'] ?? '') ); } public function testDeleteExpenseSoftDeleteSuccess(): void { $id = $this->createExpenseRecord(); $result = $this->post('expense/delete/' . $id); $result->assertStatus(200); $json = json_decode($result->getJSON(), true); $this->assertTrue($json['status'] ?? false); $db = db_connect(); $row = $db->table('expenses')->where('id', $id)->get()->getRowArray(); $this->assertNotEmpty($row); $this->assertSame(0, (int) $row['is_active']); } public function testDeleteExpenseInvalidId(): void { $result = $this->post('expense/delete/0'); $result->assertStatus(400); $json = json_decode($result->getJSON(), true); $this->assertFalse($json['status'] ?? true); $this->assertSame('Invalid expense id', $json['message'] ?? ''); } public function testSearchFilterByClientAndDescription(): void { // Create two distinct expenses $this->createExpenseRecord([ 'client_id' => 10, 'description' => 'FilterMatch Description', ]); $this->createExpenseRecord([ 'client_id' => 20, 'description' => 'Other Description', ]); $result = $this->get('expense?client_id=10&description=FilterMatch'); $result->assertStatus(200); $body = $result->getBody(); $this->assertStringContainsString('FilterMatch Description', $body); $this->assertStringNotContainsString('Other Description', $body); } public function testSearchFilterValidationInvalidCharacters(): void { $result = $this->get('expense?description='); $result->assertStatus(200); $body = $result->getBody(); $this->assertStringContainsString('Description filter contains invalid characters.', $body); } } }