Initial commit

This commit is contained in:
Zhongwei Li
2025-11-30 08:49:43 +08:00
commit 720714fdad
23 changed files with 4002 additions and 0 deletions

View File

@@ -0,0 +1,771 @@
---
name: php-test-writer
description: Skill for creating and editing PHP tests following project conventions. Use when creating tests, updating test files, or refactoring tests. Applies proper structure, naming, factory usage, and Laravel/PHPUnit best practices.
---
# PHP Test Writer Skill
You are an expert at writing PHP tests for Laravel applications. Your role is to create well-structured, maintainable tests that follow the project's established conventions.
## Test Method Naming - CRITICAL Pattern
**ALWAYS use the `test_` prefix. DO NOT use the `#[Test]` attribute.**
```php
// ✅ CORRECT - Use ONLY test_ prefix
public function test_order_calculates_total_correctly()
{
// test implementation
}
// ❌ WRONG - Do not use #[Test] attribute
#[Test]
public function test_order_calculates_total_correctly()
{
// test implementation
}
// ❌ WRONG - Do not use #[Test] without prefix
#[Test]
public function order_calculates_total_correctly()
{
// test implementation
}
```
**Why:** The project uses the `test_` prefix pattern consistently. While `#[Test]` is valid in PHPUnit, it's unnecessary when using the prefix and adds visual noise to test files.
## Project Context
**Important System Details:**
- **Multitenancy**: Most models have `customer_id` - use `->recycle($customer)` to avoid N+1 customer creation
- **Database Schema**: Uses squashed schema (`database/schema/testing-schema.sql`)
- **Laravel Sail**: All commands must use `./vendor/bin/sail` prefix
- **TestCase Properties**: Feature tests have protected properties like `$customer`, `$user`, `$customerUser` - **DO NOT override these**
## Critical Guidelines
### 1. Always Read TestCase.php First
**MANDATORY**: Before writing any feature test, read `tests/TestCase.php` to understand:
- Protected properties that cannot be overridden
- Available helper methods (e.g., `getCustomer()`, `getAdminUser()`, `actingAsCustomerUser()`)
- Setup methods that run automatically (e.g., `setupGroups()`, `setupCurrencies()`)
```php
// ❌ BAD - Will cause errors
class MyTest extends TestCase
{
protected $customer; // ERROR: Property already exists in TestCase
}
// ✅ GOOD - Use TestCase helper methods
class MyTest extends TestCase
{
public function test_something()
{
$customer = $this->getCustomer(); // Use TestCase helper
}
}
```
### 2. File Structure & Naming
**Mirror the app/ directory structure:**
```
app/Services/DataObject/DataObjectService.php
→ tests/Feature/Services/DataObject/DataObjectService/DataObjectServiceTest.php
app/Enums/Filtering/RelativeDatePointEnum.php
→ tests/Unit/Enums/Filtering/RelativeDatePointEnum/RelativeDatePointEnumResolveTest.php
```
**Prefer split over flat structure:**
- When a class has many methods or complex edge cases, create a directory
- Use subdirectories to organize related tests
```
✅ Good (split structure):
tests/Feature/Services/DataObject/DataObjectService/
├── BaseDataObjectServiceTest.php # Base class
├── Create/
│ ├── BasicCreateTest.php
│ ├── UserColumnTest.php
│ └── FailedOperationTest.php
└── Update/
├── BasicUpdateTest.php
└── UserColumnTest.php
❌ Avoid (flat structure for complex classes):
tests/Feature/Services/DataObject/
└── DataObjectServiceTest.php # Too much in one file
```
### 3. Test Method Naming
## Test Method Naming - CRITICAL Pattern
**ALWAYS use the `test_` prefix. DO NOT use the `#[Test]` attribute.**
```php
// ✅ CORRECT - Use ONLY test_ prefix
public function test_order_calculates_total_correctly()
{
// test implementation
}
// ❌ WRONG - Do not use #[Test] attribute
#[Test]
public function test_order_calculates_total_correctly()
{
// test implementation
}
// ❌ WRONG - Do not use #[Test] without prefix
#[Test]
public function order_calculates_total_correctly()
{
// test implementation
}
```
**Why:** The project uses the `test_` prefix pattern consistently. While `#[Test]` is valid in PHPUnit, it's unnecessary when using the prefix and adds visual noise to test files.
**Formula**: `test_{methodUnderTest}__{conditions}__{expectedOutput}`
```php
// ✅ Excellent examples:
public function test_update_dispatches_data_object_received_event()
public function test_process_converts_non_string_values_to_strings()
public function test_last_month_with_year_transition()
public function test_attempt_to_create_dataobject_with_existing_extref__throws_error()
public function test_resolve_by_external_id_only_finds_users_for_correct_customer()
// ❌ Avoid:
public function test_update() // Too vague
public function testUpdateMethod() // Not descriptive enough
```
**When a whole file tests a single method:**
- Method name can be omitted from test name
- Example: `RelativeDatePointEnumResolveTest.php` tests only `resolve()`, so methods are named like `test_current_quarter_boundaries()`
**Always add PHPDoc:**
```php
/**
* Test that updating a DataObject dispatches DataObjectReceived event
*/
public function test_update_dispatches_data_object_received_event()
{
// Test implementation
}
```
### 4. Test Structure: Arrange-Act-Assert
Use the AAA pattern when it makes sense:
```php
public function test_update_object_fields()
{
// Arrange
$objectDefinition = $this->getObjectDefinition(
data_key: 'test_object_update'
);
$dataObject = $this->dataObjectService->create(
objectDefinition: $objectDefinition,
objectFields: [
'field1' => 'value1',
'field2' => 'value2',
]
);
// Act
$updatedDataObject = $this->dataObjectService->update(
dataObject: $dataObject,
objectFields: [
'field1' => 'updated_value1',
],
throwOnValidationErrors: true,
);
// Assert
$this->assertEquals('updated_value1', $updatedDataObject->object_fields['field1']);
$this->assertEquals('value2', $updatedDataObject->object_fields['field2']);
}
```
### 5. Factory Usage
**ALWAYS use factories - NEVER create models manually:**
```php
// ✅ GOOD - Use factories
$customer = Customer::factory()->create();
$user = User::factory()->create();
$customerUser = CustomerUser::factory()
->recycle($customer)
->recycle($user)
->create();
$objectDefinition = ObjectDefinition::factory()
->recycle($customer)
->create();
// ❌ BAD - Manual creation
$customer = Customer::create(['name' => 'Test Customer']);
$user = new User(['name' => 'Test', 'email' => 'test@test.com']);
$user->save();
```
**Use ->recycle() extensively for multitenancy:**
```php
// ✅ EXCELLENT - Recycle customer across all models
$customer = Customer::factory()->create();
$objectDefinition = ObjectDefinition::factory()
->recycle($customer) // Uses same customer
->create();
$dataObject = DataObject::factory()
->recycle($customer) // Same customer
->recycle($objectDefinition) // And its nested relations also use same customer
->createOneWithService();
// ❌ BAD - Creates multiple customers
$objectDefinition = ObjectDefinition::factory()->create(); // Creates new customer
$dataObject = DataObject::factory()
->recycle($objectDefinition)
->createOneWithService(); // objectDefinition and dataObject have different customers!
```
**Factory Tips:**
- Check if factories have custom states before manually setting attributes
- Use `->forCustomerUser()`, `->forUserGroup()`, etc. when available
- DataObject uses `->createOneWithService()` or `->createWithService()` instead of `->create()`
### 6. Named Arguments
**Always use named arguments for clarity:**
```php
// ✅ GOOD
$result = $this->processor->process(
inputValue: 'test',
processingContext: [],
objectDefinition: $objectDefinition,
columnData: $columnData
);
$dataObject = $this->dataObjectService->create(
objectDefinition: $objectDefinition,
objectFields: ['name' => 'Test'],
extRef: 'ext-123',
visibleRef: 'VIS-123'
);
// ❌ BAD
$result = $this->processor->process('test', [], $objectDefinition, $columnData);
$dataObject = $this->dataObjectService->create($objectDefinition, ['name' => 'Test'], 'ext-123');
```
### 7. Authentication & Session
**Use TestCase helpers:**
```php
// ✅ GOOD - Use TestCase helpers
$customer = $this->getCustomer();
$adminUser = $this->getAdminUser();
$adminCustomerUser = $this->getAdminCustomerUser();
// Acting as a customer user
$this->actingAsCustomerUser($adminCustomerUser);
// Or for session only
CustomerSession::store($customer);
// ❌ BAD - Manual session manipulation
session()->put('customer', CustomerSessionData::fromCustomer($customer)->toArray());
```
### 8. DataObject & ObjectDefinition Management
**CRITICAL: Use services and helpers for data management**
**DataObject Operations:**
- **ALL DataObject changes MUST go through DataObjectService**
- Never create or update DataObjects directly with Eloquent
- Resolve the service using `app()->make()` NOT `app()`
```php
// ✅ GOOD - Use DataObjectService
/** @var DataObjectService $dataObjectService */
$dataObjectService = app()->make(DataObjectService::class);
$dataObject = $dataObjectService->create(
objectDefinition: $objectDefinition,
extRef: 'test-ref',
visibleRef: 'TEST-001',
objectFields: ['field1' => 'value1']
);
$updated = $dataObjectService->update(
dataObject: $dataObject,
objectFields: ['field1' => 'updated_value']
);
// ❌ BAD - Direct model creation/update
$dataObject = DataObject::create([...]); // NEVER DO THIS
$dataObject->update([...]); // NEVER DO THIS
```
**ObjectDefinition Creation:**
- **ALWAYS use TestCase helper methods** for creating ObjectDefinitions
- Helper methods: `getObjectDefinition()` and `getManagedObjectDefinition()`
- These helpers use ObjectDefinitionService internally
```php
// ✅ GOOD - Use TestCase helper
$objectDefinition = $this->getObjectDefinition(
data_key: 'test_object',
columns: [
ObjectDefinitionColumnData::stringColumn(
column_key: 'name',
column_name: 'Name'
),
ObjectDefinitionColumnData::decimalColumn(
column_key: 'amount',
column_name: 'Amount'
),
],
);
// For managed object definitions (e.g., Integration)
$objectDefinition = $this->getManagedObjectDefinition(
data_key: 'deal',
manageable: $integration,
primaryTitleColumn: 'name',
columns: [
ObjectDefinitionColumnData::stringColumn(
column_name: 'name',
column_key: 'name'
),
],
);
// ❌ BAD - Manual creation with factories
$objectDefinition = ObjectDefinition::factory()
->recycle($this->customer)
->create(['data_key' => 'test']);
ObjectDefinitionColumn::factory()
->recycle($objectDefinition)
->create(['column_key' => 'test']);
```
**Service Resolution Pattern:**
```php
// ✅ GOOD - Use app()->make() for type-safe resolution
/** @var DataObjectService $dataObjectService */
$dataObjectService = app()->make(DataObjectService::class);
/** @var ObjectDefinitionService $objectDefinitionService */
$objectDefinitionService = app()->make(ObjectDefinitionService::class);
// ❌ BAD - Using app() directly (no type safety)
$dataObjectService = app(DataObjectService::class);
```
### 9. Base Test Classes
**Create base classes for shared setup:**
```php
// Example: BaseDataObjectServiceTest.php
abstract class BaseDataObjectServiceTest extends TestCase
{
protected ?DataObjectService $dataObjectService = null;
protected ?ObjectDefinitionService $objectDefinitionService = null;
protected function setUp(): void
{
parent::setUp();
$this->setupUserAndCustomer();
$this->dataObjectService = app()->make(DataObjectService::class);
$this->objectDefinitionService = app()->make(ObjectDefinitionService::class);
}
}
// Then extend in specific tests
class BasicCreateTest extends BaseDataObjectServiceTest
{
public function test_something()
{
// $this->dataObjectService is already available
}
}
```
**Create custom assertion helpers:**
```php
// Example: BaseProcessorTestCase.php
protected function assertProcessedSuccessfully(
ColumnProcessingResult $result,
mixed $expectedValue,
string $message = ''
): void {
$this->assertFalse($result->hasErrors(), $message ?: 'Expected processing to succeed');
$this->assertTrue($result->isSuccess(), $message ?: 'Expected success');
$this->assertEquals($expectedValue, $result->value, $message ?: 'Value mismatch');
}
// Usage in tests
$result = $this->processValue(inputValue: 'test', columnData: $columnData);
$this->assertProcessedSuccessfully(result: $result, expectedValue: 'test');
```
### 10. Common Patterns
**Testing events:**
```php
Event::fake();
// ... perform action ...
Event::assertDispatched(DataObjectReceived::class, function ($event) use ($dataObject) {
return $event->dataObject->id === $dataObject->id;
});
```
**Testing exceptions:**
```php
$this->expectException(DuplicateExtRefException::class);
$this->expectExceptionMessage('External reference already exists');
// ... code that should throw ...
```
**Using data providers:**
```php
/**
* @dataProvider nullAndEmptyValueProvider
*/
public function test_handles_null_and_empty($value)
{
// Test implementation
}
public static function nullAndEmptyValueProvider(): array
{
return [
'null' => [null],
'empty string' => [''],
];
}
```
### 11. Assertions
**Use specific assertions with meaningful messages:**
```php
// ✅ GOOD
$this->assertEquals('expected', $actual, 'Default value was not applied correctly');
$this->assertNotNull($result, 'Result should not be null');
$this->assertCount(3, $items, 'Expected 3 items in collection');
$this->assertInstanceOf(DataObject::class, $result);
$this->assertDatabaseHas('data_objects', ['ext_ref' => 'test-123']);
// ❌ AVOID
$this->assertTrue($actual == 'expected'); // Use assertEquals instead
$this->assertTrue(!is_null($result)); // Use assertNotNull instead
```
## Anti-Patterns to Avoid
### ❌ Hardcoded IDs
```php
// BAD
$dataObject = DataObject::create([
'object_definition_id' => 1,
'customer_id' => 1,
]);
// GOOD
$dataObject = DataObject::factory()
->recycle($objectDefinition)
->recycle($customer)
->createOneWithService();
```
### ❌ Manual Model Creation
```php
// BAD
$user = User::create([
'name' => 'Test',
'email' => 'test@example.com',
'password' => bcrypt('password'),
]);
// GOOD
$user = User::factory()->create([
'email' => 'test@example.com' // Only specify what matters for the test
]);
```
### ❌ Overriding TestCase Protected Properties
```php
// BAD - Will cause errors
class MyTest extends TestCase
{
protected $customer; // ERROR: Already defined in TestCase
protected $user; // ERROR: Already defined in TestCase
}
// GOOD - Use TestCase helpers
class MyTest extends TestCase
{
public function test_something()
{
$customer = $this->getCustomer();
$user = User::factory()->create();
}
}
```
### ❌ Using env() Directly
```php
// BAD
$apiKey = env('API_KEY');
// GOOD
$apiKey = config('services.api.key');
```
## Test Execution
**Running tests:**
```bash
# All tests
./vendor/bin/sail php artisan test
# Specific file
./vendor/bin/sail php artisan test tests/Feature/Services/DataObject/DataObjectService/Create/BasicCreateTest.php
# Specific test method
./vendor/bin/sail php artisan test --filter=test_update_dispatches_data_object_received_event
# With filter
./vendor/bin/sail php artisan test --filter=DataObjectService
```
**Schema regeneration (when migrations change):**
```bash
./vendor/bin/sail php artisan schema:regenerate-testing --env=testing
```
## Examples from Codebase
### Feature Test Example (Integration)
```php
<?php
namespace Tests\Feature\Services\DataObject\DataObjectService\Create;
use App\Data\ObjectDefinition\ObjectDefinitionColumnData;
use App\Exceptions\DataObject\DuplicateExtRefException;
use Tests\Feature\Services\DataObject\DataObjectService\BaseDataObjectServiceTest;
/**
* Test basic creation functionality of DataObjectService
*/
class BasicCreateTest extends BaseDataObjectServiceTest
{
public function test_attempt_to_create_dataobject_with_existing_extref__throws_error()
{
$objectDefinition = $this->getObjectDefinition(
columns: [
ObjectDefinitionColumnData::stringColumn(column_key: 'test_field'),
],
);
$this->dataObjectService->create(
objectDefinition: $objectDefinition,
objectFields: ['test_field' => 'Test Value'],
extRef: 'test-create-ref',
);
$this->expectException(DuplicateExtRefException::class);
// Should throw an exception because the extRef already exists
$this->dataObjectService->create(
objectDefinition: $objectDefinition,
objectFields: ['test_field' => 'Test Value'],
extRef: 'test-create-ref',
);
}
}
```
### Unit Test Example (Isolated)
```php
<?php
namespace Tests\Unit\Enums\Filtering\RelativeDatePointEnum;
use App\Enums\Filtering\RelativeDatePointEnum;
use Carbon\Carbon;
use Exception;
use Tests\Unit\BaseUnitTestCase;
class RelativeDatePointEnumResolveTest extends BaseUnitTestCase
{
/**
* Test that context period boundaries resolve correctly
*/
public function test_context_period_boundaries_resolve_correctly(): void
{
$periodStart = Carbon::parse('2025-01-01 00:00:00', 'UTC');
$periodEnd = Carbon::parse('2025-01-31 23:59:59', 'UTC');
$startResult = RelativeDatePointEnum::START_OF_CONTEXT_PERIOD->resolve(
contextPeriodStart: $periodStart,
contextPeriodEnd: $periodEnd
);
$endResult = RelativeDatePointEnum::END_OF_CONTEXT_PERIOD->resolve(
contextPeriodStart: $periodStart,
contextPeriodEnd: $periodEnd
);
$this->assertEquals('2025-01-01 00:00:00', $startResult->format('Y-m-d H:i:s'));
$this->assertEquals('2025-01-31 23:59:59', $endResult->format('Y-m-d H:i:s'));
}
/**
* Test that context period boundaries throw exception when context is missing
*/
public function test_context_period_boundaries_throw_exception_when_missing(): void
{
$this->expectException(Exception::class);
$this->expectExceptionMessage('Cannot resolve relative date point');
RelativeDatePointEnum::START_OF_CONTEXT_PERIOD->resolve();
}
}
```
### Base Test Class Example
```php
<?php
namespace Tests\Feature\Services\DataObject\ObjectFields\ColumnTypeProcessors;
use App\Data\ObjectDefinition\ObjectDefinitionColumnData;
use App\Enums\DataObject\Error\DataObjectErrorCode;
use App\Enums\ObjectDefinition\ObjectDefinitionColumn\ColumnTypeEnum;
use App\Models\ObjectDefinition;
use App\Services\DataObject\ObjectFields\ColumnProcessingResult;
use App\Services\DataObject\ObjectFields\ColumnTypeProcessors\AbstractColumnProcessor;
use Tests\TestCase;
/**
* Base test case for column processor tests with helpful assertion methods
*/
abstract class BaseProcessorTestCase extends TestCase
{
protected AbstractColumnProcessor $processor;
/**
* Create a simple column data object for testing
*/
protected function makeColumnData(
string $columnKey = 'test_field',
ColumnTypeEnum $columnType = ColumnTypeEnum::STRING,
bool $isRequired = false,
mixed $defaultValue = null,
?string $columnName = null
): ObjectDefinitionColumnData {
return ObjectDefinitionColumnData::from([
'column_key' => $columnKey,
'column_name' => $columnName ?? ucfirst(str_replace('_', ' ', $columnKey)),
'column_type' => $columnType,
'is_required' => $isRequired,
'default_value' => $defaultValue,
]);
}
/**
* Process a value using the processor with standard test parameters
*/
protected function processValue(
mixed $inputValue,
?ObjectDefinitionColumnData $columnData = null,
array $processingContext = []
): ColumnProcessingResult {
$columnData = $columnData ?? $this->makeColumnData();
$objectDefinition = \Mockery::mock(ObjectDefinition::class);
return $this->processor->process(
inputValue: $inputValue,
processingContext: $processingContext,
objectDefinition: $objectDefinition,
columnData: $columnData
);
}
/**
* Assert that processing was successful and returned the expected value
*/
protected function assertProcessedSuccessfully(
ColumnProcessingResult $result,
mixed $expectedValue,
string $message = ''
): void {
$this->assertFalse($result->hasErrors(), $message ?: 'Expected processing to succeed but it had errors');
$this->assertTrue($result->isSuccess(), $message ?: 'Expected processing to be marked as successful');
$this->assertEquals($expectedValue, $result->value, $message ?: 'Expected processed value did not match');
}
}
```
## Workflow
When writing tests:
1. **Read TestCase.php** to understand available helpers and protected properties
2. **Check for existing similar tests** to follow established patterns
3. **Read the PHPUnit guidelines** at `docs/development/guidelines/php/phpunit-guidelines.md`
4. **Determine test type**: Feature (integration) or Unit (isolated)
5. **Create proper directory structure** mirroring app/ directory
6. **Use factories exclusively** with `->recycle()` for multitenancy
7. **Write descriptive test names** following the convention
8. **Add PHPDoc** explaining what the test does
9. **Use named arguments** throughout
10. **Run the tests** to verify they pass
11. **Consider creating base test class** if you have multiple related test files
## Final Reminder
- **ALWAYS read TestCase.php first** for feature tests
- **NEVER override TestCase protected properties**
- **ALWAYS use factories** with `->recycle($customer)`
- **ALWAYS use named arguments** for clarity
- **ALL DataObject changes through DataObjectService** - Never create/update DataObjects directly
- **Use TestCase helpers for ObjectDefinitions** - `getObjectDefinition()` or `getManagedObjectDefinition()`
- **Resolve services with `app()->make()`** - NOT `app()` for type safety
- **Mirror app/ directory structure** in tests
- **Prefer split over flat** structure for complex classes
- **Run tests after writing** to ensure they pass
Your goal is to create maintainable, readable tests that future developers can easily understand and extend.