nhance/tests/unit/ExpenseControllerTest.php

400 lines
12 KiB
PHP

<?php
namespace PhpOption {
/**
* Lightweight stub of PhpOption\Option used only for testing env() handling.
* This is intentionally minimal and should not be relied on in application code.
*/
class Option
{
private $value;
public function __construct($value)
{
$this->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=<script>alert(1)</script>');
$result->assertStatus(200);
$body = $result->getBody();
$this->assertStringContainsString('Description filter contains invalid characters.', $body);
}
}
}